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.
- {mikancli-0.1.7 → mikancli-0.2.0}/PKG-INFO +28 -5
- {mikancli-0.1.7 → mikancli-0.2.0}/README.md +26 -4
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/__init__.py +1 -1
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/cli/entrypoint.py +7 -6
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/cli/input_parsing.py +3 -2
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/cli/qbittorrent_flow.py +72 -16
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/cli/save_path_flow.py +4 -3
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/cli/search_flow.py +66 -26
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/core/models.py +1 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/core/rules.py +2 -1
- mikancli-0.2.0/mikancli/display.py +173 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/i18n.py +24 -2
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/integrations/qbittorrent.py +29 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/integrations/qbittorrent_client.py +19 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli.egg-info/PKG-INFO +28 -5
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli.egg-info/SOURCES.txt +3 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli.egg-info/requires.txt +1 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/pyproject.toml +2 -1
- mikancli-0.2.0/tests/test_cli_json.py +30 -0
- mikancli-0.2.0/tests/test_display.py +67 -0
- mikancli-0.2.0/tests/test_feed_preview.py +76 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/tests/test_qbittorrent_episode_filter.py +14 -0
- mikancli-0.1.7/mikancli/display.py +0 -103
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/__main__.py +0 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/cli/__init__.py +0 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/cli/prompts.py +0 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/config.py +0 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/core/__init__.py +0 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/core/normalize.py +0 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/integrations/__init__.py +0 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/integrations/mikan.py +0 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/integrations/mikan_parsers.py +0 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli/integrations/mikan_urls.py +0 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli.egg-info/dependency_links.txt +0 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli.egg-info/entry_points.txt +0 -0
- {mikancli-0.1.7 → mikancli-0.2.0}/mikancli.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
76
|
+
with loading(t("qb.setup.verifying")):
|
|
77
|
+
version = check_connection(settings)
|
|
74
78
|
except QBittorrentError as exc:
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
248
|
+
print_message(t("qb.submit.failed", error=str(exc)), style="error")
|
|
193
249
|
return QBITTORRENT_ERROR
|
|
194
250
|
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
183
|
+
with loading(t("search.searching")):
|
|
184
|
+
candidates = search_mikan_bangumi(keyword)
|
|
146
185
|
except MikanLookupError as exc:
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
+
with loading(t("search.loading_subgroups")):
|
|
205
|
+
subgroups = fetch_mikan_subgroups(bangumi.bangumi_id)
|
|
166
206
|
except MikanLookupError as exc:
|
|
167
|
-
|
|
207
|
+
print_message(str(exc), style="error")
|
|
168
208
|
break
|
|
169
209
|
|
|
170
210
|
if not subgroups:
|
|
171
|
-
|
|
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
|
-
|
|
228
|
+
with loading(t("search.loading_feed")):
|
|
229
|
+
feed_items = fetch_mikan_feed_items(subgroup.feed_url)
|
|
189
230
|
except MikanLookupError as exc:
|
|
190
|
-
|
|
231
|
+
print_message(str(exc), style="error")
|
|
191
232
|
continue
|
|
192
233
|
|
|
193
|
-
|
|
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:
|
|
@@ -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=
|
|
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,
|