mikancli 0.1.0__py3-none-any.whl
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/__init__.py +7 -0
- mikancli/__main__.py +5 -0
- mikancli/cli/__init__.py +3 -0
- mikancli/cli/entrypoint.py +215 -0
- mikancli/cli/input_parsing.py +40 -0
- mikancli/cli/prompts.py +145 -0
- mikancli/cli/qbittorrent_flow.py +169 -0
- mikancli/cli/save_path_flow.py +122 -0
- mikancli/cli/search_flow.py +203 -0
- mikancli/config.py +160 -0
- mikancli/core/__init__.py +1 -0
- mikancli/core/models.py +74 -0
- mikancli/core/normalize.py +32 -0
- mikancli/core/rules.py +52 -0
- mikancli/display.py +89 -0
- mikancli/integrations/__init__.py +1 -0
- mikancli/integrations/mikan.py +72 -0
- mikancli/integrations/mikan_parsers.py +282 -0
- mikancli/integrations/mikan_urls.py +35 -0
- mikancli/integrations/qbittorrent.py +156 -0
- mikancli/integrations/qbittorrent_client.py +243 -0
- mikancli-0.1.0.dist-info/METADATA +198 -0
- mikancli-0.1.0.dist-info/RECORD +26 -0
- mikancli-0.1.0.dist-info/WHEEL +5 -0
- mikancli-0.1.0.dist-info/entry_points.txt +2 -0
- mikancli-0.1.0.dist-info/top_level.txt +1 -0
mikancli/__init__.py
ADDED
mikancli/__main__.py
ADDED
mikancli/cli/__init__.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mikancli.cli.input_parsing import prompt_word_list
|
|
8
|
+
from mikancli.cli.prompts import ExitRequested, select_option
|
|
9
|
+
from mikancli.cli.qbittorrent_flow import (
|
|
10
|
+
QBITTORRENT_SUBMISSION_SKIPPED,
|
|
11
|
+
prompt_for_qbittorrent_setup_if_needed,
|
|
12
|
+
prompt_to_submit_rule_to_qbittorrent,
|
|
13
|
+
run_qbittorrent_configuration_flow,
|
|
14
|
+
setup_qbittorrent,
|
|
15
|
+
)
|
|
16
|
+
from mikancli.cli.save_path_flow import (
|
|
17
|
+
build_content_save_path,
|
|
18
|
+
prompt_for_content_folder_name,
|
|
19
|
+
resolve_save_path,
|
|
20
|
+
)
|
|
21
|
+
from mikancli.cli.search_flow import resolve_mikan_selection, run_interactive_selection
|
|
22
|
+
from mikancli.config import get_config_path, load_config
|
|
23
|
+
from mikancli.core.models import AppConfig, RuleDraft, SearchRequest
|
|
24
|
+
from mikancli.core.normalize import collapse_spaces
|
|
25
|
+
from mikancli.core.rules import build_rule_draft
|
|
26
|
+
from mikancli.display import print_text_summary
|
|
27
|
+
|
|
28
|
+
STARTUP_ACTION_SEARCH = "search"
|
|
29
|
+
STARTUP_ACTION_QBITTORRENT = "qbittorrent"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
33
|
+
"""Create the command-line parser for MikanCli flags and positional arguments. Returns a configured ArgumentParser; it does not parse arguments by itself."""
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="mikancli",
|
|
36
|
+
description=(
|
|
37
|
+
"Search Mikan for an anime, inspect subgroup RSS contents, and preview "
|
|
38
|
+
"or submit qBittorrent RSS rule inputs."
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument("keyword", nargs="?", help="Anime title or search phrase.")
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--include",
|
|
44
|
+
action="append",
|
|
45
|
+
default=[],
|
|
46
|
+
help="Word that must appear in accepted releases. Repeat for multiple values.",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--exclude",
|
|
50
|
+
action="append",
|
|
51
|
+
default=[],
|
|
52
|
+
help="Word that must not appear in accepted releases. Repeat for multiple values.",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--save-path",
|
|
56
|
+
help="Optional save path to attach to the qBittorrent rule.",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--json",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help="Print the draft as JSON.",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--setup-qbittorrent",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="Configure and verify qBittorrent WebUI access.",
|
|
67
|
+
)
|
|
68
|
+
return parser
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _prompt_startup_action() -> str:
|
|
72
|
+
return select_option(
|
|
73
|
+
"Choose what you want to do",
|
|
74
|
+
[
|
|
75
|
+
(STARTUP_ACTION_SEARCH, "Search anime"),
|
|
76
|
+
(STARTUP_ACTION_QBITTORRENT, "Modify qBittorrent configurations"),
|
|
77
|
+
],
|
|
78
|
+
default=STARTUP_ACTION_SEARCH,
|
|
79
|
+
allow_exit=True,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_request_from_args(args: argparse.Namespace, *, config: AppConfig, config_path: Path) -> SearchRequest:
|
|
84
|
+
"""Convert parsed JSON-mode CLI arguments into a SearchRequest. Returns the cleaned request or raises ValueError when the required keyword is missing."""
|
|
85
|
+
|
|
86
|
+
if not args.keyword:
|
|
87
|
+
raise ValueError("keyword is required when using --json")
|
|
88
|
+
|
|
89
|
+
save_path = resolve_save_path(
|
|
90
|
+
args.save_path,
|
|
91
|
+
config,
|
|
92
|
+
prompt_for_default=False,
|
|
93
|
+
config_path=config_path,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return SearchRequest(
|
|
97
|
+
keyword=collapse_spaces(args.keyword),
|
|
98
|
+
include_words=tuple(args.include),
|
|
99
|
+
exclude_words=tuple(args.exclude),
|
|
100
|
+
save_path=save_path,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _build_interactive_draft(args: argparse.Namespace, *, config: AppConfig,config_path: Path) -> RuleDraft:
|
|
105
|
+
|
|
106
|
+
bangumi, subgroup = run_interactive_selection(initial_keyword=args.keyword)
|
|
107
|
+
|
|
108
|
+
include_words = tuple(args.include) or prompt_word_list(
|
|
109
|
+
"Enter include words separated by commas, or press Enter to skip: "
|
|
110
|
+
)
|
|
111
|
+
exclude_words = tuple(args.exclude) or prompt_word_list(
|
|
112
|
+
"Enter exclude words separated by commas, or press Enter to skip: "
|
|
113
|
+
)
|
|
114
|
+
save_path = resolve_save_path(
|
|
115
|
+
args.save_path,
|
|
116
|
+
config,
|
|
117
|
+
prompt_for_default=True,
|
|
118
|
+
config_path=config_path,
|
|
119
|
+
)
|
|
120
|
+
content_folder_name = prompt_for_content_folder_name(bangumi.title)
|
|
121
|
+
final_save_path = build_content_save_path(save_path, content_folder_name)
|
|
122
|
+
|
|
123
|
+
request = SearchRequest(
|
|
124
|
+
keyword=bangumi.title,
|
|
125
|
+
include_words=include_words,
|
|
126
|
+
exclude_words=exclude_words,
|
|
127
|
+
save_path=final_save_path,
|
|
128
|
+
)
|
|
129
|
+
return build_rule_draft(
|
|
130
|
+
request,
|
|
131
|
+
bangumi=bangumi,
|
|
132
|
+
subgroup=subgroup,
|
|
133
|
+
notes=("Review the draft before submitting it to qBittorrent.",),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main(argv: list[str] | None = None) -> int:
|
|
138
|
+
|
|
139
|
+
parser = build_parser()
|
|
140
|
+
args = parser.parse_args(argv)
|
|
141
|
+
config_path = get_config_path()
|
|
142
|
+
config = load_config(config_path)
|
|
143
|
+
|
|
144
|
+
if args.setup_qbittorrent:
|
|
145
|
+
try:
|
|
146
|
+
return setup_qbittorrent(config, config_path)
|
|
147
|
+
except ExitRequested:
|
|
148
|
+
print("Exited MikanCli.")
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
if args.json:
|
|
152
|
+
try:
|
|
153
|
+
request = build_request_from_args(
|
|
154
|
+
args,
|
|
155
|
+
config=config,
|
|
156
|
+
config_path=config_path,
|
|
157
|
+
)
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
parser.error(str(exc))
|
|
160
|
+
|
|
161
|
+
bangumi, subgroup, lookup_notes = resolve_mikan_selection(request)
|
|
162
|
+
draft = build_rule_draft(
|
|
163
|
+
request,
|
|
164
|
+
bangumi=bangumi,
|
|
165
|
+
subgroup=subgroup,
|
|
166
|
+
notes=lookup_notes,
|
|
167
|
+
)
|
|
168
|
+
print(json.dumps(draft.to_dict(), ensure_ascii=False, indent=2))
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
has_startup_menu = args.keyword is None
|
|
172
|
+
|
|
173
|
+
while True:
|
|
174
|
+
try:
|
|
175
|
+
if has_startup_menu:
|
|
176
|
+
while True:
|
|
177
|
+
startup_action = _prompt_startup_action()
|
|
178
|
+
if startup_action == STARTUP_ACTION_QBITTORRENT:
|
|
179
|
+
setup_exit_code = run_qbittorrent_configuration_flow(config, config_path)
|
|
180
|
+
if setup_exit_code != 0:
|
|
181
|
+
return setup_exit_code
|
|
182
|
+
config = load_config(config_path)
|
|
183
|
+
continue
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
setup_exit_code = prompt_for_qbittorrent_setup_if_needed(
|
|
187
|
+
config,
|
|
188
|
+
config_path,
|
|
189
|
+
)
|
|
190
|
+
if setup_exit_code != 0:
|
|
191
|
+
return setup_exit_code
|
|
192
|
+
config = load_config(config_path)
|
|
193
|
+
draft = _build_interactive_draft(
|
|
194
|
+
args,
|
|
195
|
+
config=config,
|
|
196
|
+
config_path=config_path,
|
|
197
|
+
)
|
|
198
|
+
except ExitRequested:
|
|
199
|
+
print("Exited MikanCli.")
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
summary_exit_code = print_text_summary(draft)
|
|
203
|
+
if summary_exit_code != 0:
|
|
204
|
+
return summary_exit_code
|
|
205
|
+
try:
|
|
206
|
+
submission_exit_code = prompt_to_submit_rule_to_qbittorrent(config, draft)
|
|
207
|
+
except ExitRequested:
|
|
208
|
+
print("Exited MikanCli.")
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
if has_startup_menu and submission_exit_code == QBITTORRENT_SUBMISSION_SKIPPED:
|
|
212
|
+
config = load_config(config_path)
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
return 0 if submission_exit_code == QBITTORRENT_SUBMISSION_SKIPPED else submission_exit_code
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from mikancli.cli.prompts import prompt_text
|
|
4
|
+
from mikancli.core.normalize import collapse_spaces
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def prompt_required_text(prompt: str) -> str:
|
|
8
|
+
while True:
|
|
9
|
+
entered = collapse_spaces(prompt_text(prompt, allow_exit=True))
|
|
10
|
+
if entered:
|
|
11
|
+
return entered
|
|
12
|
+
print("A value is required.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_word_list(value: str) -> tuple[str, ...]:
|
|
16
|
+
"""Parse a comma-separated word list while dropping blanks and duplicates. Example: parse_word_list("HEVC, 1080p, HEVC") returns ("HEVC", "1080p")."""
|
|
17
|
+
words: list[str] = []
|
|
18
|
+
seen: set[str] = set()
|
|
19
|
+
|
|
20
|
+
for raw_part in value.split(","):
|
|
21
|
+
cleaned = collapse_spaces(raw_part)
|
|
22
|
+
if not cleaned:
|
|
23
|
+
continue
|
|
24
|
+
|
|
25
|
+
marker = cleaned.casefold()
|
|
26
|
+
if marker in seen:
|
|
27
|
+
continue
|
|
28
|
+
|
|
29
|
+
seen.add(marker)
|
|
30
|
+
words.append(cleaned)
|
|
31
|
+
|
|
32
|
+
return tuple(words)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def prompt_word_list(prompt: str) -> tuple[str, ...]:
|
|
36
|
+
entered = collapse_spaces(prompt_text(prompt, allow_exit=True))
|
|
37
|
+
if not entered:
|
|
38
|
+
return ()
|
|
39
|
+
|
|
40
|
+
return parse_word_list(entered)
|
mikancli/cli/prompts.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Any, TypeVar
|
|
5
|
+
|
|
6
|
+
from mikancli.core.normalize import collapse_spaces
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
EXIT_OPTION = "__exit_cli__"
|
|
10
|
+
EXIT_TEXT_VALUES = {"exit", "quit"}
|
|
11
|
+
PROMPT_SEPARATOR = "----------------------------------------"
|
|
12
|
+
MENU_SEPARATOR_LABEL = ""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExitRequested(Exception):
|
|
16
|
+
"""Raised when the user explicitly chooses to quit the CLI."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_inquirer():
|
|
20
|
+
try:
|
|
21
|
+
from InquirerPy import inquirer
|
|
22
|
+
except ImportError as exc:
|
|
23
|
+
raise RuntimeError(
|
|
24
|
+
"Interactive mode requires InquirerPy. Install project dependencies first."
|
|
25
|
+
) from exc
|
|
26
|
+
|
|
27
|
+
return inquirer
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _prepare_prompt_message(message: str) -> str:
|
|
31
|
+
print(f"\n{PROMPT_SEPARATOR}")
|
|
32
|
+
return message.strip("\n")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get_menu_separator() -> Any | None:
|
|
36
|
+
try:
|
|
37
|
+
from InquirerPy.separator import Separator
|
|
38
|
+
except ImportError:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
return Separator(MENU_SEPARATOR_LABEL)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_select_choices(
|
|
45
|
+
options: list[tuple[T, str]],
|
|
46
|
+
*,
|
|
47
|
+
allow_exit: bool,
|
|
48
|
+
separator_before_values: Iterable[T] = (),
|
|
49
|
+
separator_before_exit: bool = True,
|
|
50
|
+
) -> list[object]:
|
|
51
|
+
separator_values = set(separator_before_values)
|
|
52
|
+
separator = _get_menu_separator()
|
|
53
|
+
choices: list[object] = []
|
|
54
|
+
|
|
55
|
+
for value, label in options:
|
|
56
|
+
if value in separator_values and separator is not None:
|
|
57
|
+
choices.append(separator)
|
|
58
|
+
choices.append({"value": value, "name": label})
|
|
59
|
+
|
|
60
|
+
if allow_exit:
|
|
61
|
+
if choices and separator_before_exit and separator is not None:
|
|
62
|
+
choices.append(separator)
|
|
63
|
+
choices.append({"value": EXIT_OPTION, "name": "Exit MikanCli"})
|
|
64
|
+
|
|
65
|
+
return choices
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def select_option(
|
|
69
|
+
message: str,
|
|
70
|
+
options: list[tuple[T, str]],
|
|
71
|
+
*,
|
|
72
|
+
default: T | None = None,
|
|
73
|
+
allow_exit: bool = False,
|
|
74
|
+
separator_before_values: Iterable[T] = (),
|
|
75
|
+
separator_before_exit: bool = True,
|
|
76
|
+
) -> T:
|
|
77
|
+
"""Show an InquirerPy selection menu and return the selected option value. Raises ExitRequested when exit is allowed and the user chooses the exit option."""
|
|
78
|
+
inquirer = _get_inquirer()
|
|
79
|
+
|
|
80
|
+
choices = _build_select_choices(
|
|
81
|
+
options,
|
|
82
|
+
allow_exit=allow_exit,
|
|
83
|
+
separator_before_values=separator_before_values,
|
|
84
|
+
separator_before_exit=separator_before_exit,
|
|
85
|
+
)
|
|
86
|
+
prompt = inquirer.select(
|
|
87
|
+
message=_prepare_prompt_message(message),
|
|
88
|
+
choices=choices,
|
|
89
|
+
default=default,
|
|
90
|
+
pointer=">",
|
|
91
|
+
instruction="Use arrow keys",
|
|
92
|
+
max_height="70%",
|
|
93
|
+
cycle=True,
|
|
94
|
+
)
|
|
95
|
+
selected = prompt.execute()
|
|
96
|
+
if allow_exit and selected == EXIT_OPTION:
|
|
97
|
+
raise ExitRequested()
|
|
98
|
+
return selected
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def prompt_text(message: str, *, default: str | None = None, allow_exit: bool = False) -> str:
|
|
102
|
+
"""Prompt for text, collapse surrounding whitespace, and return the cleaned value. Raises ExitRequested when exit words are allowed and entered."""
|
|
103
|
+
|
|
104
|
+
inquirer = _get_inquirer()
|
|
105
|
+
|
|
106
|
+
prompt = inquirer.text(
|
|
107
|
+
message=_prepare_prompt_message(message),
|
|
108
|
+
default=default or "",
|
|
109
|
+
)
|
|
110
|
+
entered = collapse_spaces(prompt.execute())
|
|
111
|
+
if allow_exit and entered.casefold() in EXIT_TEXT_VALUES:
|
|
112
|
+
raise ExitRequested()
|
|
113
|
+
return entered
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def prompt_password(message: str, *, allow_exit: bool = False) -> str:
|
|
117
|
+
"""Prompt for hidden password input and return the raw entered value. Raises ExitRequested when exit words are allowed and entered."""
|
|
118
|
+
|
|
119
|
+
inquirer = _get_inquirer()
|
|
120
|
+
prompt_factory = getattr(inquirer, "secret", None)
|
|
121
|
+
if prompt_factory is not None:
|
|
122
|
+
prompt = prompt_factory(message=_prepare_prompt_message(message))
|
|
123
|
+
else: # pragma: no cover - fallback for alternate InquirerPy versions
|
|
124
|
+
prompt = inquirer.text(message=_prepare_prompt_message(message), secret=True)
|
|
125
|
+
|
|
126
|
+
entered = prompt.execute()
|
|
127
|
+
if allow_exit and entered.strip().casefold() in EXIT_TEXT_VALUES:
|
|
128
|
+
raise ExitRequested()
|
|
129
|
+
return entered
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def confirm_choice(message: str, *, default: bool = True, allow_exit: bool = False) -> bool:
|
|
133
|
+
"""Ask a yes/no question using the same menu style as other prompts. Returns True for Yes and False for No."""
|
|
134
|
+
|
|
135
|
+
default_value = "yes" if default else "no"
|
|
136
|
+
selected = select_option(
|
|
137
|
+
message,
|
|
138
|
+
[
|
|
139
|
+
("yes", "Yes"),
|
|
140
|
+
("no", "No"),
|
|
141
|
+
],
|
|
142
|
+
default=default_value,
|
|
143
|
+
allow_exit=allow_exit,
|
|
144
|
+
)
|
|
145
|
+
return selected == "yes"
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from mikancli.cli.prompts import confirm_choice, prompt_password, prompt_text
|
|
6
|
+
from mikancli.config import save_config
|
|
7
|
+
from mikancli.core.models import AppConfig, QBittorrentSettings, RuleDraft
|
|
8
|
+
from mikancli.core.normalize import collapse_spaces
|
|
9
|
+
from mikancli.integrations.qbittorrent import (
|
|
10
|
+
QBittorrentError,
|
|
11
|
+
check_connection,
|
|
12
|
+
normalize_qbittorrent_url,
|
|
13
|
+
submit_rule_draft,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
QBITTORRENT_SUBMISSION_SKIPPED = 2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def setup_qbittorrent(config: AppConfig, config_path: Path) -> int:
|
|
20
|
+
"""Prompt for qBittorrent WebUI settings, verify the connection, and save them on success. Returns 0 when verification succeeds and 1 when qBittorrent rejects or cannot be reached."""
|
|
21
|
+
print()
|
|
22
|
+
print("----- qBittorrent setup instructions -----")
|
|
23
|
+
print("1. Install qBittorrent and open its settings.")
|
|
24
|
+
print("2. Enable WebUI / remote control if it is not enabled yet.")
|
|
25
|
+
print("3. Copy the WebUI address, username, and password from qBittorrent.")
|
|
26
|
+
print("4. Enter those values below.")
|
|
27
|
+
print("5. After successful verification, the values will be saved to the config file for future runs.")
|
|
28
|
+
print("------------------------------------------")
|
|
29
|
+
print()
|
|
30
|
+
|
|
31
|
+
default_url = config.qbittorrent_url or "http://localhost:8080"
|
|
32
|
+
entered_url = (
|
|
33
|
+
collapse_spaces(
|
|
34
|
+
prompt_text(
|
|
35
|
+
"Enter qBittorrent WebUI URL (http://localhost:8080 is the usual default)",
|
|
36
|
+
default=default_url,
|
|
37
|
+
allow_exit=True,
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
or "http://localhost:8080"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
print()
|
|
44
|
+
print('If you have "Bypass authentication for clients on localhost" enabled in qBittorrent settings, you can just press Enter for the next two prompts.')
|
|
45
|
+
username = (
|
|
46
|
+
collapse_spaces(
|
|
47
|
+
prompt_text(
|
|
48
|
+
"Enter qBittorrent WebUI username (press Enter to leave blank)",
|
|
49
|
+
default=config.qbittorrent_username or "",
|
|
50
|
+
allow_exit=True,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
or None
|
|
54
|
+
)
|
|
55
|
+
password = prompt_password(
|
|
56
|
+
"Enter qBittorrent WebUI password (press Enter to leave blank)",
|
|
57
|
+
allow_exit=True,
|
|
58
|
+
) or None
|
|
59
|
+
|
|
60
|
+
settings = QBittorrentSettings(
|
|
61
|
+
url=entered_url,
|
|
62
|
+
username=username,
|
|
63
|
+
password=password,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
print("Verifying qBittorrent connection...")
|
|
68
|
+
version = check_connection(settings)
|
|
69
|
+
except QBittorrentError as exc:
|
|
70
|
+
print(str(exc))
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
save_config(
|
|
74
|
+
config_path,
|
|
75
|
+
AppConfig(
|
|
76
|
+
default_save_path=config.default_save_path,
|
|
77
|
+
qbittorrent_url=normalize_qbittorrent_url(entered_url),
|
|
78
|
+
qbittorrent_username=username,
|
|
79
|
+
qbittorrent_password=password,
|
|
80
|
+
qbittorrent_category=config.qbittorrent_category,
|
|
81
|
+
qbittorrent_add_paused=config.qbittorrent_add_paused,
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
print(
|
|
85
|
+
"qBittorrent connection verified successfully "
|
|
86
|
+
f"(version: {version})."
|
|
87
|
+
)
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def prompt_for_qbittorrent_setup_if_needed(config: AppConfig, config_path: Path) -> int:
|
|
92
|
+
"""Offer first-run qBittorrent setup when no WebUI URL is configured. Returns 0 when setup is skipped, already configured, or completed successfully."""
|
|
93
|
+
|
|
94
|
+
if config.qbittorrent_url:
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
should_setup = confirm_choice(
|
|
98
|
+
"qBittorrent is not set up yet. Set up qBittorrent WebUI now?",
|
|
99
|
+
default=True,
|
|
100
|
+
allow_exit=True,
|
|
101
|
+
)
|
|
102
|
+
if not should_setup:
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
while True:
|
|
106
|
+
exit_code = setup_qbittorrent(config, config_path)
|
|
107
|
+
if exit_code == 0:
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
continue_without_setup = confirm_choice(
|
|
111
|
+
"Continue without qBittorrent setup for now?",
|
|
112
|
+
default=False,
|
|
113
|
+
allow_exit=True,
|
|
114
|
+
)
|
|
115
|
+
if continue_without_setup:
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def run_qbittorrent_configuration_flow(config: AppConfig, config_path: Path) -> int:
|
|
120
|
+
"""Run the qBittorrent setup route and allow retrying after failed verification. Returns 0 after a successful setup or when the user stops retrying."""
|
|
121
|
+
|
|
122
|
+
while True:
|
|
123
|
+
exit_code = setup_qbittorrent(config, config_path)
|
|
124
|
+
if exit_code == 0:
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
retry_setup = confirm_choice(
|
|
128
|
+
"Retry qBittorrent setup?",
|
|
129
|
+
default=True,
|
|
130
|
+
allow_exit=True,
|
|
131
|
+
)
|
|
132
|
+
if not retry_setup:
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def prompt_to_submit_rule_to_qbittorrent(config: AppConfig, draft: RuleDraft,) -> int:
|
|
137
|
+
"""Ask whether to submit a confirmed rule draft to qBittorrent and report the result. Returns 0 on success, 1 on submission failure, or QBITTORRENT_SUBMISSION_SKIPPED when declined."""
|
|
138
|
+
|
|
139
|
+
if not config.qbittorrent_url:
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
should_submit = confirm_choice(
|
|
143
|
+
"Submit this RSS feed and download rule to qBittorrent now?",
|
|
144
|
+
default=True,
|
|
145
|
+
allow_exit=True,
|
|
146
|
+
)
|
|
147
|
+
if not should_submit:
|
|
148
|
+
return QBITTORRENT_SUBMISSION_SKIPPED
|
|
149
|
+
|
|
150
|
+
settings = QBittorrentSettings(
|
|
151
|
+
url=config.qbittorrent_url,
|
|
152
|
+
username=config.qbittorrent_username,
|
|
153
|
+
password=config.qbittorrent_password,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
print("Submitting RSS feed and download rule to qBittorrent...")
|
|
158
|
+
submit_rule_draft(
|
|
159
|
+
settings,
|
|
160
|
+
draft,
|
|
161
|
+
add_paused=config.qbittorrent_add_paused,
|
|
162
|
+
assigned_category=config.qbittorrent_category,
|
|
163
|
+
)
|
|
164
|
+
except QBittorrentError as exc:
|
|
165
|
+
print(str(exc))
|
|
166
|
+
return 1
|
|
167
|
+
|
|
168
|
+
print("qBittorrent feed and download rule submitted and verified successfully.")
|
|
169
|
+
return 0
|