mikancli 0.1.7__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {mikancli-0.1.7 → mikancli-0.2.0}/PKG-INFO +28 -5
  2. {mikancli-0.1.7 → mikancli-0.2.0}/README.md +26 -4
  3. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/__init__.py +1 -1
  4. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/cli/entrypoint.py +7 -6
  5. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/cli/input_parsing.py +3 -2
  6. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/cli/qbittorrent_flow.py +72 -16
  7. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/cli/save_path_flow.py +4 -3
  8. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/cli/search_flow.py +66 -26
  9. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/core/models.py +1 -0
  10. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/core/rules.py +2 -1
  11. mikancli-0.2.0/mikancli/display.py +173 -0
  12. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/i18n.py +24 -2
  13. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/integrations/qbittorrent.py +29 -0
  14. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/integrations/qbittorrent_client.py +19 -0
  15. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli.egg-info/PKG-INFO +28 -5
  16. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli.egg-info/SOURCES.txt +3 -0
  17. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli.egg-info/requires.txt +1 -0
  18. {mikancli-0.1.7 → mikancli-0.2.0}/pyproject.toml +2 -1
  19. mikancli-0.2.0/tests/test_cli_json.py +30 -0
  20. mikancli-0.2.0/tests/test_display.py +67 -0
  21. mikancli-0.2.0/tests/test_feed_preview.py +76 -0
  22. {mikancli-0.1.7 → mikancli-0.2.0}/tests/test_qbittorrent_episode_filter.py +14 -0
  23. mikancli-0.1.7/mikancli/display.py +0 -103
  24. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/__main__.py +0 -0
  25. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/cli/__init__.py +0 -0
  26. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/cli/prompts.py +0 -0
  27. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/config.py +0 -0
  28. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/core/__init__.py +0 -0
  29. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/core/normalize.py +0 -0
  30. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/integrations/__init__.py +0 -0
  31. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/integrations/mikan.py +0 -0
  32. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/integrations/mikan_parsers.py +0 -0
  33. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/integrations/mikan_urls.py +0 -0
  34. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli.egg-info/dependency_links.txt +0 -0
  35. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli.egg-info/entry_points.txt +0 -0
  36. {mikancli-0.1.7 → mikancli-0.2.0}/mikancli.egg-info/top_level.txt +0 -0
  37. {mikancli-0.1.7 → mikancli-0.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mikancli
3
- Version: 0.1.7
3
+ Version: 0.2.0
4
4
  Summary: CLI foundation for building qBittorrent RSS rules from anime search input.
5
5
  Author: metails
6
6
  Project-URL: Repository, https://github.com/JianHua-Deng/MikanCli
@@ -20,6 +20,7 @@ Classifier: Topic :: Utilities
20
20
  Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  Requires-Dist: InquirerPy>=0.3
23
+ Requires-Dist: rich>=14.0
23
24
 
24
25
  # MikanCli
25
26
 
@@ -33,7 +34,8 @@ It supports both a guided interactive flow and a JSON preview mode for scripting
33
34
 
34
35
  - Search Mikan by anime title or keyword
35
36
  - Choose from matching Bangumi results and subgroup-specific RSS feeds
36
- - Preview recent RSS feed items before confirming a feed
37
+ - Preview recent RSS feed items with pagination before confirming a feed
38
+ - Read Rich-formatted rule summaries, feed previews, and status messages, with loading indicators for network requests
37
39
  - Build qBittorrent RSS rules with include and exclude filters
38
40
  - Filter anime-style episode numbers at or above a minimum episode
39
41
  - Choose and save a default download folder
@@ -88,7 +90,26 @@ python -m pip install -e .
88
90
  python -m mikancli
89
91
  ```
90
92
 
91
- Dependencies are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
93
+ Dependencies, including Rich for output and InquirerPy for prompts, are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
94
+
95
+ After pulling dependency changes into an existing editable `pip` installation, rerun `python -m pip install -e .` in that environment. Update `pipx` installations through `pipx` so dependencies stay in the app's environment.
96
+
97
+ ## Terminal Output
98
+
99
+ Rich formats rule summaries, feed previews, and feedback. InquirerPy handles menus and text input. Release titles containing brackets are displayed literally, and long titles, URLs, and paths wrap to fit the terminal.
100
+
101
+ Interactive terminals show loading indicators during network requests. Indicators stop before prompts and after failures. Redirected output uses static status messages, and feed previews retain their plain-text layout.
102
+
103
+ To disable Rich colors in PowerShell:
104
+
105
+ ```powershell
106
+ $env:NO_COLOR = "1"
107
+ mikancli
108
+ ```
109
+
110
+ Remove that setting with `Remove-Item Env:NO_COLOR`. Bold or dim text and loading animations may remain; InquirerPy manages menu styling separately.
111
+
112
+ The `--json` draft mode prints one JSON document without Rich formatting or progress messages.
92
113
 
93
114
  ## Guided Flow
94
115
 
@@ -169,7 +190,7 @@ mikancli/
169
190
  core/ dataclasses, normalization, and rule-building logic
170
191
  integrations/ Mikan and qBittorrent adapters
171
192
  config.py user config and folder selection helpers
172
- display.py text summaries and feed previews
193
+ display.py Rich summaries, feed previews, feedback, and loading indicators
173
194
  ```
174
195
 
175
196
  The console command is declared in `pyproject.toml`:
@@ -215,7 +236,9 @@ For example:
215
236
  mikancli "one piece" --include SubsPlease --min-episode 1126
216
237
  ```
217
238
 
218
- MikanCli converts the minimum episode into a qBittorrent regular expression in the rule's `mustContain` field and enables `useRegex`. Normal `--include` and `--exclude` values still behave as literal words or phrases.
239
+ MikanCli converts the minimum episode into a qBittorrent regular expression in the rule's `mustContain` field and enables `useRegex`. Normal `--include` and `--exclude` values still behave as literal words or phrases, and every include value must also be present in a title before qBittorrent treats it as a match.
240
+
241
+ When submitting a minimum-episode rule, MikanCli checks qBittorrent's RSS article retention limit. If qBittorrent is keeping fewer than 500 articles per feed, MikanCli offers to increase that setting so qBittorrent can see enough of Mikan's feed to match newer episodes.
219
242
 
220
243
  ## Release
221
244
 
@@ -10,7 +10,8 @@ It supports both a guided interactive flow and a JSON preview mode for scripting
10
10
 
11
11
  - Search Mikan by anime title or keyword
12
12
  - Choose from matching Bangumi results and subgroup-specific RSS feeds
13
- - Preview recent RSS feed items before confirming a feed
13
+ - Preview recent RSS feed items with pagination before confirming a feed
14
+ - Read Rich-formatted rule summaries, feed previews, and status messages, with loading indicators for network requests
14
15
  - Build qBittorrent RSS rules with include and exclude filters
15
16
  - Filter anime-style episode numbers at or above a minimum episode
16
17
  - Choose and save a default download folder
@@ -65,7 +66,26 @@ python -m pip install -e .
65
66
  python -m mikancli
66
67
  ```
67
68
 
68
- Dependencies are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
69
+ Dependencies, including Rich for output and InquirerPy for prompts, are declared in `pyproject.toml` and installed by `pip` or `pipx`. MikanCli does not install packages at runtime.
70
+
71
+ After pulling dependency changes into an existing editable `pip` installation, rerun `python -m pip install -e .` in that environment. Update `pipx` installations through `pipx` so dependencies stay in the app's environment.
72
+
73
+ ## Terminal Output
74
+
75
+ Rich formats rule summaries, feed previews, and feedback. InquirerPy handles menus and text input. Release titles containing brackets are displayed literally, and long titles, URLs, and paths wrap to fit the terminal.
76
+
77
+ Interactive terminals show loading indicators during network requests. Indicators stop before prompts and after failures. Redirected output uses static status messages, and feed previews retain their plain-text layout.
78
+
79
+ To disable Rich colors in PowerShell:
80
+
81
+ ```powershell
82
+ $env:NO_COLOR = "1"
83
+ mikancli
84
+ ```
85
+
86
+ Remove that setting with `Remove-Item Env:NO_COLOR`. Bold or dim text and loading animations may remain; InquirerPy manages menu styling separately.
87
+
88
+ The `--json` draft mode prints one JSON document without Rich formatting or progress messages.
69
89
 
70
90
  ## Guided Flow
71
91
 
@@ -146,7 +166,7 @@ mikancli/
146
166
  core/ dataclasses, normalization, and rule-building logic
147
167
  integrations/ Mikan and qBittorrent adapters
148
168
  config.py user config and folder selection helpers
149
- display.py text summaries and feed previews
169
+ display.py Rich summaries, feed previews, feedback, and loading indicators
150
170
  ```
151
171
 
152
172
  The console command is declared in `pyproject.toml`:
@@ -192,7 +212,9 @@ For example:
192
212
  mikancli "one piece" --include SubsPlease --min-episode 1126
193
213
  ```
194
214
 
195
- MikanCli converts the minimum episode into a qBittorrent regular expression in the rule's `mustContain` field and enables `useRegex`. Normal `--include` and `--exclude` values still behave as literal words or phrases.
215
+ MikanCli converts the minimum episode into a qBittorrent regular expression in the rule's `mustContain` field and enables `useRegex`. Normal `--include` and `--exclude` values still behave as literal words or phrases, and every include value must also be present in a title before qBittorrent treats it as a match.
216
+
217
+ When submitting a minimum-episode rule, MikanCli checks qBittorrent's RSS article retention limit. If qBittorrent is keeping fewer than 500 articles per feed, MikanCli offers to increase that setting so qBittorrent can see enough of Mikan's feed to match newer episodes.
196
218
 
197
219
  ## Release
198
220
 
@@ -4,4 +4,4 @@ __all__ = [
4
4
  "__version__",
5
5
  ]
6
6
 
7
- __version__ = "0.1.7"
7
+ __version__ = "0.2.0"
@@ -31,7 +31,7 @@ from mikancli.config import get_config_path, load_config, save_config
31
31
  from mikancli.core.models import AppConfig, RuleDraft, SearchRequest
32
32
  from mikancli.core.normalize import collapse_spaces
33
33
  from mikancli.core.rules import build_rule_draft
34
- from mikancli.display import print_text_summary
34
+ from mikancli.display import print_message, print_text_summary
35
35
  from mikancli.i18n import (
36
36
  LANGUAGE_LABELS,
37
37
  SUPPORTED_LANGUAGES,
@@ -165,7 +165,7 @@ def run_language_configuration_flow(config: AppConfig, config_path: Path) -> App
165
165
  set_language(selected_language)
166
166
  updated_config = replace(config, language=selected_language)
167
167
  save_config(config_path, updated_config)
168
- print(t("language.saved", language=LANGUAGE_LABELS[selected_language]))
168
+ print_message(t("language.saved", language=LANGUAGE_LABELS[selected_language]), style="success")
169
169
  return updated_config
170
170
 
171
171
 
@@ -220,6 +220,7 @@ def build_interactive_draft(args: argparse.Namespace, *, config: AppConfig,confi
220
220
  include_words=include_words,
221
221
  exclude_words=exclude_words,
222
222
  min_episode=min_episode,
223
+ rule_name=content_folder_name,
223
224
  save_path=final_save_path,
224
225
  )
225
226
  return build_rule_draft(
@@ -246,7 +247,7 @@ def main(argv: list[str] | None = None) -> int:
246
247
  try:
247
248
  return setup_qbittorrent(config, config_path)
248
249
  except ExitRequested:
249
- print(t("common.exited"))
250
+ print_message(t("common.exited"))
250
251
  return 0
251
252
 
252
253
  if args.json:
@@ -306,7 +307,7 @@ def main(argv: list[str] | None = None) -> int:
306
307
  config_path=config_path,
307
308
  )
308
309
  except ExitRequested:
309
- print(t("common.exited"))
310
+ print_message(t("common.exited"))
310
311
  return 0
311
312
 
312
313
  summary_exit_code = print_text_summary(draft)
@@ -315,7 +316,7 @@ def main(argv: list[str] | None = None) -> int:
315
316
  try:
316
317
  submission_exit_code = prompt_to_submit_rule_to_qbittorrent(config, draft)
317
318
  if submission_exit_code == QBITTORRENT_NOT_CONFIGURED:
318
- print(t("qb.submit.not_configured"))
319
+ print_message(t("qb.submit.not_configured"), style="warning")
319
320
  setup_exit_code = prompt_for_qbittorrent_setup_if_needed(config, config_path)
320
321
  if setup_exit_code not in {
321
322
  QBITTORRENT_SETUP_SUCCESS,
@@ -330,7 +331,7 @@ def main(argv: list[str] | None = None) -> int:
330
331
  continue
331
332
 
332
333
  except ExitRequested:
333
- print(t("common.exited"))
334
+ print_message(t("common.exited"))
334
335
  return 0
335
336
 
336
337
  if has_startup_menu and submission_exit_code == QBITTORRENT_SUBMISSION_SKIPPED:
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from mikancli.cli.prompts import prompt_text
4
4
  from mikancli.core.normalize import collapse_spaces
5
+ from mikancli.display import print_message
5
6
  from mikancli.i18n import t
6
7
 
7
8
 
@@ -10,7 +11,7 @@ def prompt_required_text(prompt: str) -> str:
10
11
  entered = collapse_spaces(prompt_text(prompt, allow_exit=True))
11
12
  if entered:
12
13
  return entered
13
- print(t("common.value_required"))
14
+ print_message(t("common.value_required"), style="warning")
14
15
 
15
16
 
16
17
  def parse_word_list(value: str) -> tuple[str, ...]:
@@ -62,4 +63,4 @@ def prompt_optional_positive_int(prompt: str, *, field_name: str) -> int | None:
62
63
  try:
63
64
  return parse_optional_positive_int(entered, field_name=field_name)
64
65
  except ValueError as exc:
65
- print(exc)
66
+ print_message(str(exc), style="warning")
@@ -6,13 +6,17 @@ from mikancli.cli.prompts import confirm_choice, prompt_password, prompt_text
6
6
  from mikancli.config import save_config
7
7
  from mikancli.core.models import AppConfig, QBittorrentSettings, RuleDraft
8
8
  from mikancli.core.normalize import collapse_spaces, sanitize_folder_name
9
+ from mikancli.display import loading, print_message
9
10
  from mikancli.i18n import t
10
11
  from mikancli.integrations.qbittorrent import (
12
+ MIN_RSS_ARTICLES_FOR_EPISODE_FILTER,
11
13
  QBittorrentError,
12
14
  build_default_feed_path,
13
15
  check_connection,
16
+ get_rss_max_articles_per_feed,
14
17
  normalize_qbittorrent_url,
15
18
  qbittorrent_rule_exists,
19
+ set_rss_max_articles_per_feed,
16
20
  submit_rule_draft,
17
21
  )
18
22
 
@@ -46,7 +50,7 @@ def setup_qbittorrent(config: AppConfig, config_path: Path) -> int:
46
50
  or "http://localhost:8080"
47
51
  )
48
52
 
49
- print(t("qb.setup.auth_hint"))
53
+ print_message(t("qb.setup.auth_hint"))
50
54
  username = (
51
55
  collapse_spaces(
52
56
  prompt_text(
@@ -69,10 +73,10 @@ def setup_qbittorrent(config: AppConfig, config_path: Path) -> int:
69
73
  )
70
74
 
71
75
  try:
72
- print(t("qb.setup.verifying"))
73
- version = check_connection(settings)
76
+ with loading(t("qb.setup.verifying")):
77
+ version = check_connection(settings)
74
78
  except QBittorrentError as exc:
75
- print(str(exc))
79
+ print_message(str(exc), style="error")
76
80
  return QBITTORRENT_ERROR
77
81
 
78
82
  save_config(
@@ -87,7 +91,7 @@ def setup_qbittorrent(config: AppConfig, config_path: Path) -> int:
87
91
  qbittorrent_add_paused=config.qbittorrent_add_paused,
88
92
  ),
89
93
  )
90
- print(t("qb.setup.verified", version=version))
94
+ print_message(t("qb.setup.verified", version=version), style="success")
91
95
  return QBITTORRENT_SETUP_SUCCESS
92
96
 
93
97
 
@@ -149,6 +153,55 @@ def prompt_for_rss_feed_name(draft: RuleDraft) -> str:
149
153
  return sanitize_folder_name(entered or default_feed_path) or default_feed_path
150
154
 
151
155
 
156
+ def prompt_to_raise_rss_article_limit_if_needed(
157
+ settings: QBittorrentSettings,
158
+ draft: RuleDraft,
159
+ ) -> None:
160
+ """Offer to raise qBittorrent's RSS retention limit for minimum-episode rules."""
161
+ if draft.min_episode is None:
162
+ return
163
+
164
+ with loading(t("qb.submit.checking")):
165
+ current_limit = get_rss_max_articles_per_feed(settings)
166
+ if (
167
+ current_limit is None
168
+ or current_limit >= MIN_RSS_ARTICLES_FOR_EPISODE_FILTER
169
+ ):
170
+ return
171
+
172
+ should_update = confirm_choice(
173
+ t(
174
+ "qb.submit.rss_limit_offer",
175
+ current=current_limit,
176
+ minimum=MIN_RSS_ARTICLES_FOR_EPISODE_FILTER,
177
+ ),
178
+ default=True,
179
+ allow_exit=True,
180
+ )
181
+ if not should_update:
182
+ print_message(
183
+ t(
184
+ "qb.submit.rss_limit_skipped",
185
+ current=current_limit,
186
+ ),
187
+ style="warning",
188
+ )
189
+ return
190
+
191
+ with loading(t("qb.submit.updating_limit")):
192
+ set_rss_max_articles_per_feed(
193
+ settings,
194
+ MIN_RSS_ARTICLES_FOR_EPISODE_FILTER,
195
+ )
196
+ print_message(
197
+ t(
198
+ "qb.submit.rss_limit_updated",
199
+ minimum=MIN_RSS_ARTICLES_FOR_EPISODE_FILTER,
200
+ ),
201
+ style="success",
202
+ )
203
+
204
+
152
205
  def prompt_to_submit_rule_to_qbittorrent(config: AppConfig, draft: RuleDraft,) -> int:
153
206
  """Ask whether to submit a confirmed rule draft to qBittorrent and report the result. Returns QBITTORRENT_SETUP_SUCCESS on success, QBITTORRENT_NOT_CONFIGURED when no WebUI URL is saved, QBITTORRENT_SUBMISSION_SKIPPED when declined, or QBITTORRENT_ERROR on submission failure."""
154
207
 
@@ -170,7 +223,9 @@ def prompt_to_submit_rule_to_qbittorrent(config: AppConfig, draft: RuleDraft,) -
170
223
  )
171
224
 
172
225
  try:
173
- if qbittorrent_rule_exists(settings, draft.rule_name):
226
+ with loading(t("qb.submit.checking")):
227
+ rule_exists = qbittorrent_rule_exists(settings, draft.rule_name)
228
+ if rule_exists:
174
229
  should_replace = confirm_choice(
175
230
  t("qb.submit.replace_existing", rule_name=draft.rule_name),
176
231
  default=False,
@@ -179,18 +234,19 @@ def prompt_to_submit_rule_to_qbittorrent(config: AppConfig, draft: RuleDraft,) -
179
234
  if not should_replace:
180
235
  return QBITTORRENT_SUBMISSION_SKIPPED
181
236
 
237
+ prompt_to_raise_rss_article_limit_if_needed(settings, draft)
182
238
  feed_path = prompt_for_rss_feed_name(draft)
183
- print(t("qb.submit.submitting"))
184
- submit_rule_draft(
185
- settings,
186
- draft,
187
- add_paused=config.qbittorrent_add_paused,
188
- assigned_category=config.qbittorrent_category,
189
- feed_path=feed_path,
190
- )
239
+ with loading(t("qb.submit.submitting")):
240
+ submit_rule_draft(
241
+ settings,
242
+ draft,
243
+ add_paused=config.qbittorrent_add_paused,
244
+ assigned_category=config.qbittorrent_category,
245
+ feed_path=feed_path,
246
+ )
191
247
  except QBittorrentError as exc:
192
- print(t("qb.submit.failed", error=str(exc)))
248
+ print_message(t("qb.submit.failed", error=str(exc)), style="error")
193
249
  return QBITTORRENT_ERROR
194
250
 
195
- print(t("qb.submit.success"))
251
+ print_message(t("qb.submit.success"), style="success")
196
252
  return QBITTORRENT_SETUP_SUCCESS
@@ -10,6 +10,7 @@ from mikancli.config import (
10
10
  )
11
11
  from mikancli.core.models import AppConfig
12
12
  from mikancli.core.normalize import collapse_spaces, sanitize_folder_name
13
+ from mikancli.display import print_message
13
14
  from mikancli.i18n import t
14
15
 
15
16
 
@@ -61,7 +62,7 @@ def prompt_for_content_folder_name(default_name: str, base_path: str | None) ->
61
62
  ):
62
63
  return folder_name
63
64
 
64
- print(t("save.choose_different"))
65
+ print_message(t("save.choose_different"), style="warning")
65
66
 
66
67
 
67
68
  def build_content_save_path(base_path: str | None, folder_name: str) -> str | None:
@@ -104,12 +105,12 @@ def prompt_for_save_path(config: AppConfig, config_path: Path) -> str:
104
105
  initial_dir=config.default_save_path or downloads_path
105
106
  )
106
107
  if not selected_path:
107
- print(t("save.no_folder_selected"))
108
+ print_message(t("save.no_folder_selected"), style="warning")
108
109
  continue
109
110
  else:
110
111
  selected_path = prompt_for_manual_save_path()
111
112
  if not selected_path:
112
- print(t("save.no_path_entered"))
113
+ print_message(t("save.no_path_entered"), style="warning")
113
114
  continue
114
115
 
115
116
  if should_save_as_default(selected_path, config):
@@ -2,9 +2,19 @@ from __future__ import annotations
2
2
 
3
3
  from mikancli.cli.input_parsing import prompt_required_text
4
4
  from mikancli.cli.prompts import select_option
5
- from mikancli.core.models import MikanBangumi, MikanSubgroup, SearchRequest
5
+ from mikancli.core.models import (
6
+ MikanBangumi,
7
+ MikanFeedItem,
8
+ MikanSubgroup,
9
+ SearchRequest,
10
+ )
6
11
  from mikancli.core.normalize import collapse_spaces
7
- from mikancli.display import build_feed_preview_text
12
+ from mikancli.display import (
13
+ FEED_PREVIEW_PAGE_SIZE,
14
+ loading,
15
+ print_feed_preview_page,
16
+ print_message,
17
+ )
8
18
  from mikancli.i18n import t
9
19
  from mikancli.integrations.mikan import (
10
20
  MikanLookupError,
@@ -18,6 +28,8 @@ BACK_TO_CANDIDATES = "__back_to_candidates__"
18
28
  BACK_TO_SUBGROUPS = "__back_to_subgroups__"
19
29
  CONFIRM_SUBGROUP = "__confirm_subgroup__"
20
30
  REJECT_SUBGROUP = "__reject_subgroup__"
31
+ NEXT_PREVIEW_PAGE = "__next_preview_page__"
32
+ PREVIOUS_PREVIEW_PAGE = "__previous_preview_page__"
21
33
 
22
34
 
23
35
  def search_prompt(*, retry: bool = False) -> str:
@@ -74,22 +86,48 @@ def select_subgroup_or_navigate(subgroups: tuple[MikanSubgroup, ...], *, bangumi
74
86
  return subgroups[selected]
75
87
 
76
88
 
77
- def confirm_subgroup_selection(preview_text: str) -> str:
78
- """Show a feed preview and ask whether to use that subgroup feed. Returns CONFIRM_SUBGROUP, REJECT_SUBGROUP, or BACK_TO_SUBGROUPS based on the user's decision"""
79
- print(preview_text)
80
- return select_option(
81
- t("search.use_subgroup"),
82
- [
83
- (CONFIRM_SUBGROUP, t("common.yes")),
84
- (REJECT_SUBGROUP, t("search.no_search_again")),
85
- (BACK_TO_SUBGROUPS, t("search.back_to_subgroups")),
86
- ],
87
- default=CONFIRM_SUBGROUP,
88
- allow_exit=True,
89
- separator_before_values=(BACK_TO_SUBGROUPS,),
90
- separator_before_exit=False,
89
+ def confirm_subgroup_selection(
90
+ subgroup: MikanSubgroup,
91
+ feed_items: tuple[MikanFeedItem, ...],
92
+ ) -> str:
93
+ """Show a paginated feed preview and ask whether to use that subgroup feed."""
94
+ page = 1
95
+ total_pages = max(
96
+ 1,
97
+ (len(feed_items) + FEED_PREVIEW_PAGE_SIZE - 1) // FEED_PREVIEW_PAGE_SIZE,
91
98
  )
92
99
 
100
+ while True:
101
+ print_feed_preview_page(subgroup, feed_items, page=page)
102
+
103
+ options = [(CONFIRM_SUBGROUP, t("common.yes"))]
104
+ if page < total_pages:
105
+ options.append((NEXT_PREVIEW_PAGE, t("search.next_page")))
106
+ if page > 1:
107
+ options.append((PREVIOUS_PREVIEW_PAGE, t("search.previous_page")))
108
+ options.extend(
109
+ [
110
+ (REJECT_SUBGROUP, t("search.no_search_again")),
111
+ (BACK_TO_SUBGROUPS, t("search.back_to_subgroups")),
112
+ ]
113
+ )
114
+
115
+ selected = select_option(
116
+ t("search.use_subgroup"),
117
+ options,
118
+ default=CONFIRM_SUBGROUP,
119
+ allow_exit=True,
120
+ separator_before_values=(REJECT_SUBGROUP,),
121
+ separator_before_exit=False,
122
+ )
123
+ if selected == NEXT_PREVIEW_PAGE:
124
+ page = min(page + 1, total_pages)
125
+ continue
126
+ if selected == PREVIOUS_PREVIEW_PAGE:
127
+ page = max(page - 1, 1)
128
+ continue
129
+ return selected
130
+
93
131
 
94
132
  def resolve_mikan_selection(request: SearchRequest,) -> tuple[MikanBangumi | None, MikanSubgroup | None, tuple[str, ...]]:
95
133
  """Resolve the first matching Bangumi and subgroup for non-interactive JSON mode. Returns optional selected objects plus notes describing missing data or JSON-mode limitations"""
@@ -142,14 +180,15 @@ def run_interactive_selection(*, initial_keyword: str | None) -> tuple[MikanBang
142
180
 
143
181
  while True:
144
182
  try:
145
- candidates = search_mikan_bangumi(keyword)
183
+ with loading(t("search.searching")):
184
+ candidates = search_mikan_bangumi(keyword)
146
185
  except MikanLookupError as exc:
147
- print(str(exc))
186
+ print_message(str(exc), style="error")
148
187
  keyword = prompt_required_text(search_prompt(retry=True))
149
188
  continue
150
189
 
151
190
  if not candidates:
152
- print(t("search.no_results", keyword=keyword))
191
+ print_message(t("search.no_results", keyword=keyword), style="warning")
153
192
  keyword = prompt_required_text(search_prompt(retry=True))
154
193
  continue
155
194
 
@@ -162,13 +201,14 @@ def run_interactive_selection(*, initial_keyword: str | None) -> tuple[MikanBang
162
201
 
163
202
  while True:
164
203
  try:
165
- subgroups = fetch_mikan_subgroups(bangumi.bangumi_id)
204
+ with loading(t("search.loading_subgroups")):
205
+ subgroups = fetch_mikan_subgroups(bangumi.bangumi_id)
166
206
  except MikanLookupError as exc:
167
- print(str(exc))
207
+ print_message(str(exc), style="error")
168
208
  break
169
209
 
170
210
  if not subgroups:
171
- print(t("search.no_subgroup"))
211
+ print_message(t("search.no_subgroup"), style="warning")
172
212
  break
173
213
 
174
214
  selected_subgroup = select_subgroup_or_navigate(
@@ -185,13 +225,13 @@ def run_interactive_selection(*, initial_keyword: str | None) -> tuple[MikanBang
185
225
  subgroup = selected_subgroup
186
226
 
187
227
  try:
188
- feed_items = fetch_mikan_feed_items(subgroup.feed_url)
228
+ with loading(t("search.loading_feed")):
229
+ feed_items = fetch_mikan_feed_items(subgroup.feed_url)
189
230
  except MikanLookupError as exc:
190
- print(str(exc))
231
+ print_message(str(exc), style="error")
191
232
  continue
192
233
 
193
- preview_text = build_feed_preview_text(subgroup, feed_items)
194
- decision = confirm_subgroup_selection(preview_text)
234
+ decision = confirm_subgroup_selection(subgroup, feed_items)
195
235
  if decision == BACK_TO_SUBGROUPS:
196
236
  continue
197
237
  if decision == REJECT_SUBGROUP:
@@ -27,6 +27,7 @@ class SearchRequest:
27
27
  include_words: tuple[str, ...] = ()
28
28
  exclude_words: tuple[str, ...] = ()
29
29
  min_episode: int | None = None
30
+ rule_name: str | None = None
30
31
  save_path: str | None = None
31
32
 
32
33
 
@@ -30,6 +30,7 @@ def build_rule_draft(request: SearchRequest, *, bangumi: MikanBangumi | None = N
30
30
  Example: SearchRequest(keyword=" Solo ", include_words=("HEVC", "")) returns a draft with rule_name="Solo" and must_contain=("HEVC",).
31
31
  """
32
32
  collapsed_keyword = collapse_spaces(request.keyword)
33
+ rule_name = collapse_spaces(request.rule_name or collapsed_keyword)
33
34
 
34
35
  must_contain = dedupe_nonempty(list(request.include_words))
35
36
  must_not_contain = dedupe_nonempty(list(request.exclude_words))
@@ -37,7 +38,7 @@ def build_rule_draft(request: SearchRequest, *, bangumi: MikanBangumi | None = N
37
38
  return RuleDraft(
38
39
  keyword=collapsed_keyword,
39
40
  normalized_keyword=normalize_keyword(request.keyword),
40
- rule_name=collapsed_keyword,
41
+ rule_name=rule_name,
41
42
  must_contain=must_contain,
42
43
  must_not_contain=must_not_contain,
43
44
  min_episode=request.min_episode,