mikancli 0.2.0__tar.gz → 0.2.2__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 (38) hide show
  1. {mikancli-0.2.0 → mikancli-0.2.2}/PKG-INFO +9 -2
  2. mikancli-0.2.0/README.md → mikancli-0.2.2/README.en.md +7 -1
  3. mikancli-0.2.2/README.md +227 -0
  4. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/cli/entrypoint.py +6 -1
  5. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/cli/input_parsing.py +2 -2
  6. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/cli/prompts.py +21 -5
  7. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/cli/qbittorrent_flow.py +6 -10
  8. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/cli/search_flow.py +13 -6
  9. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/display.py +46 -12
  10. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/i18n.py +8 -0
  11. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli.egg-info/PKG-INFO +9 -2
  12. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli.egg-info/SOURCES.txt +3 -1
  13. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli.egg-info/requires.txt +1 -0
  14. {mikancli-0.2.0 → mikancli-0.2.2}/pyproject.toml +3 -2
  15. {mikancli-0.2.0 → mikancli-0.2.2}/tests/test_display.py +19 -1
  16. {mikancli-0.2.0 → mikancli-0.2.2}/tests/test_feed_preview.py +5 -2
  17. mikancli-0.2.2/tests/test_search_prompt.py +35 -0
  18. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/__init__.py +0 -0
  19. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/__main__.py +0 -0
  20. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/cli/__init__.py +0 -0
  21. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/cli/save_path_flow.py +0 -0
  22. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/config.py +0 -0
  23. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/core/__init__.py +0 -0
  24. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/core/models.py +0 -0
  25. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/core/normalize.py +0 -0
  26. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/core/rules.py +0 -0
  27. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/integrations/__init__.py +0 -0
  28. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/integrations/mikan.py +0 -0
  29. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/integrations/mikan_parsers.py +0 -0
  30. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/integrations/mikan_urls.py +0 -0
  31. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/integrations/qbittorrent.py +0 -0
  32. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli/integrations/qbittorrent_client.py +0 -0
  33. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli.egg-info/dependency_links.txt +0 -0
  34. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli.egg-info/entry_points.txt +0 -0
  35. {mikancli-0.2.0 → mikancli-0.2.2}/mikancli.egg-info/top_level.txt +0 -0
  36. {mikancli-0.2.0 → mikancli-0.2.2}/setup.cfg +0 -0
  37. {mikancli-0.2.0 → mikancli-0.2.2}/tests/test_cli_json.py +0 -0
  38. {mikancli-0.2.0 → mikancli-0.2.2}/tests/test_qbittorrent_episode_filter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mikancli
3
- Version: 0.2.0
3
+ Version: 0.2.2
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,11 +20,12 @@ 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: prompt-toolkit<4,>=3.0.52
23
24
  Requires-Dist: rich>=14.0
24
25
 
25
26
  # MikanCli
26
27
 
27
- Language: [English](README.md) | [简体中文](README.zh-CN.md)
28
+ Language: [简体中文](README.md) | [English](README.en.md)
28
29
 
29
30
  MikanCli is a Python command-line tool for finding bangumi/anime on Mikanani.me, choosing the correct Bangumi and subgroup RSS feed, and turning that selection into a qBittorrent RSS download rule.
30
31
 
@@ -43,6 +44,12 @@ It supports both a guided interactive flow and a JSON preview mode for scripting
43
44
  - Submit RSS feeds and auto-download rules to qBittorrent, then verify that qBittorrent saved them
44
45
  - Print rule drafts as JSON without submitting anything
45
46
 
47
+ ## Screenshots
48
+
49
+ ![Search input (demo data)](docs/images/search-input.png)
50
+
51
+ ![RSS feed preview (demo data)](docs/images/feed-preview.png)
52
+
46
53
  ## Requirements
47
54
 
48
55
  - Python 3.10 or newer
@@ -1,6 +1,6 @@
1
1
  # MikanCli
2
2
 
3
- Language: [English](README.md) | [简体中文](README.zh-CN.md)
3
+ Language: [简体中文](README.md) | [English](README.en.md)
4
4
 
5
5
  MikanCli is a Python command-line tool for finding bangumi/anime on Mikanani.me, choosing the correct Bangumi and subgroup RSS feed, and turning that selection into a qBittorrent RSS download rule.
6
6
 
@@ -19,6 +19,12 @@ It supports both a guided interactive flow and a JSON preview mode for scripting
19
19
  - Submit RSS feeds and auto-download rules to qBittorrent, then verify that qBittorrent saved them
20
20
  - Print rule drafts as JSON without submitting anything
21
21
 
22
+ ## Screenshots
23
+
24
+ ![Search input (demo data)](docs/images/search-input.png)
25
+
26
+ ![RSS feed preview (demo data)](docs/images/feed-preview.png)
27
+
22
28
  ## Requirements
23
29
 
24
30
  - Python 3.10 or newer
@@ -0,0 +1,227 @@
1
+ # MikanCli
2
+
3
+ 语言:[简体中文](README.md) | [English](README.en.md)
4
+
5
+ MikanCli 是一个 Python 命令行工具,可以在 Mikanani.me 上搜索番剧/动画,选定对应的 Bangumi 条目和字幕组 RSS 订阅,并据此生成 qBittorrent 的 RSS 下载规则。
6
+
7
+ 它既提供引导式交互流程,也支持用于脚本或检查的 JSON 预览模式。目前交互式流程是功能最完整的模式。
8
+
9
+ ## 功能
10
+
11
+ - 按动画标题或关键词搜索 Mikan
12
+ - 从匹配的 Bangumi 结果和字幕组 RSS 订阅中进行选择
13
+ - 在确认订阅前分页预览最近的 RSS 条目
14
+ - 使用 Rich 显示规则摘要、订阅预览和状态消息,并在网络请求期间显示加载指示器
15
+ - 使用包含和排除过滤条件生成 qBittorrent RSS 规则
16
+ - 按最低集数过滤动画发布标题
17
+ - 选择并保存默认下载文件夹
18
+ - 通过命令行配置 qBittorrent WebUI 访问
19
+ - 将 RSS 订阅和自动下载规则提交到 qBittorrent,并验证 qBittorrent 已保存这些内容
20
+ - 以 JSON 形式输出规则草稿,不提交任何内容
21
+
22
+ ## 界面预览
23
+
24
+ ![搜索输入框(演示数据)](docs/images/search-input.png)
25
+
26
+ ![RSS 订阅预览(演示数据)](docs/images/feed-preview.png)
27
+
28
+ ## 环境要求
29
+
30
+ - Python 3.10 或更新版本
31
+ - `pipx`,用于将 MikanCli 安装为独立命令行应用
32
+ - qBittorrent,如果你希望 MikanCli 自动提交 RSS 订阅和规则
33
+
34
+ ## 快速开始 | 安装
35
+
36
+ 推荐使用 `pipx` 安装 MikanCli,这样可以在任意终端中直接使用。
37
+ 如果已经安装 `pipx`,可以跳过以下两条命令:
38
+
39
+ ```bash
40
+ python -m pip install --user pipx # 为当前用户安装 pipx
41
+ python -m pipx ensurepath # 将 pipx 可执行文件目录添加到 PATH
42
+ ```
43
+
44
+ 运行 `pipx ensurepath` 后,请重新打开一个终端,然后执行:
45
+
46
+ ```bash
47
+ pipx install mikancli
48
+ ```
49
+
50
+ ## 使用方法
51
+
52
+ 安装完成后,运行以下命令,并按菜单提示操作:
53
+
54
+ ```bash
55
+ mikancli
56
+ ```
57
+
58
+ ## 通过克隆仓库安装
59
+
60
+ 从本地克隆安装:
61
+
62
+ ```bash
63
+ git clone https://github.com/JianHua-Deng/MikanCli.git
64
+ cd MikanCli
65
+ python -m pipx install -e .
66
+ ```
67
+
68
+ 开发时也可以使用可编辑模式的 `pip` 安装:
69
+
70
+ ```bash
71
+ python -m pip install -e .
72
+ python -m mikancli
73
+ ```
74
+
75
+ 依赖项(包括用于输出的 Rich 和用于交互提示的 InquirerPy)声明在 `pyproject.toml` 中,由 `pip` 或 `pipx` 安装。MikanCli 运行时不会安装依赖包。
76
+
77
+ 如果已有可编辑模式的 `pip` 安装,在拉取依赖变更后,需要在原环境中重新运行 `python -m pip install -e .`。通过 `pipx` 安装的应用应使用 `pipx` 更新,以确保依赖仍安装在应用自己的环境中。
78
+
79
+ ## 终端输出
80
+
81
+ Rich 负责显示规则摘要、订阅预览和反馈消息,InquirerPy 负责菜单和文本输入。发布标题中的方括号会原样显示;较长的标题、URL 和路径会按终端宽度自动换行。
82
+
83
+ 交互式终端会在网络请求期间显示加载指示器,并在提示输入前或请求失败后停止。输出被重定向时,会改用静态状态消息,订阅预览仍保留纯文本布局。
84
+
85
+ 在 PowerShell 中禁用 Rich 颜色:
86
+
87
+ ```powershell
88
+ $env:NO_COLOR = "1"
89
+ mikancli
90
+ ```
91
+
92
+ 使用 `Remove-Item Env:NO_COLOR` 移除此设置。粗体、暗色样式和加载动画可能仍会显示;菜单样式由 InquirerPy 单独管理。
93
+
94
+ `--json` 草稿模式只输出一个 JSON 文档,不使用 Rich 格式,也不显示进度消息。
95
+
96
+ ## 引导式流程
97
+
98
+ 不带参数运行 `mikancli` 时,首先会看到菜单,可选择:
99
+
100
+ - 搜索动画
101
+ - 修改 qBittorrent 配置
102
+ - 切换语言
103
+ - 退出 MikanCli
104
+
105
+ 搜索流程依次为:
106
+
107
+ 1. 输入动画标题或关键词
108
+ 2. 在 Mikan 上搜索匹配的 Bangumi 条目
109
+ 3. 选择正确的 Bangumi 条目
110
+ 4. 从所选 Bangumi 页面获取字幕组 RSS 订阅
111
+ 5. 选择字幕组
112
+ 6. 预览最近的 RSS 订阅条目
113
+ 7. 设置包含、排除和最低集数过滤条件
114
+ 8. 选择下载保存位置
115
+ 9. 生成规则草稿
116
+ 10. 将订阅和规则提交到 qBittorrent
117
+ 11. 通过 qBittorrent WebUI API 验证订阅和规则已保存
118
+
119
+ 在需要输入文本的交互提示中,可输入 `exit` 或 `quit` 退出;菜单中也会提供退出选项。
120
+
121
+ ## qBittorrent 设置
122
+
123
+ 在 MikanCli 提交订阅或规则之前,需要先启用 qBittorrent WebUI:
124
+
125
+ 1. 打开 qBittorrent 设置
126
+ 2. 启用 WebUI 或远程控制
127
+ 3. 确认 WebUI 地址、用户名和密码。地址为空通常表示 `http://localhost:[port]`
128
+ 4. 运行 `mikancli --setup-qbittorrent`
129
+
130
+ 设置说明:
131
+
132
+ - URL 直接按 Enter 将使用 `http://localhost:8080`
133
+ - 输入 `localhost:8080` 会自动规范化为 `http://localhost:8080`
134
+ - 如果 qBittorrent WebUI 允许 localhost 免认证访问,用户名和密码可以留空
135
+ - 如果 qBittorrent 拒绝连接,请重新检查 qBittorrent 设置中的 WebUI 端口和凭据
136
+
137
+ ## 配置
138
+
139
+ MikanCli 将配置保存在 JSON 文件中:
140
+
141
+ - Windows:`%APPDATA%\Roaming\MikanCli\config.json`
142
+
143
+ 保存的设置可包括:
144
+
145
+ - 默认下载文件夹
146
+ - 语言偏好
147
+ - qBittorrent WebUI URL
148
+ - qBittorrent 用户名和密码
149
+ - qBittorrent 分类
150
+ - qBittorrent 是否以暂停状态添加匹配的种子
151
+
152
+ qBittorrent 密码会保存在配置文件中,以便 MikanCli 后续运行时提交规则。在共享机器上请妥善保护该文件。
153
+
154
+ ## 语言支持
155
+
156
+ MikanCli 的面向用户 CLI 文本支持英文和简体中文,包括交互菜单、提示、设置说明、摘要和帮助文本。命令名、选项名、JSON 字段名、Mikan 标题、字幕组名称、URL 和 qBittorrent API 数据保持固定,不随语言变化。
157
+
158
+ 语言选择优先级如下:
159
+
160
+ 1. `--language en` 或 `--language zh-CN`
161
+ 2. `MIKANCLI_LANG=en` 或 `MIKANCLI_LANG=zh-CN`
162
+ 3. 已保存的配置值
163
+ 4. 默认回退到英文
164
+
165
+ 交互式用户可随时从启动菜单切换语言。所选语言会保存到配置文件,供后续运行使用。
166
+
167
+ ## 项目结构
168
+
169
+ ```text
170
+ mikancli/
171
+ cli/ CLI 入口、提示和交互流程
172
+ core/ 数据类、规范化和规则生成逻辑
173
+ integrations/ Mikan 和 qBittorrent 适配器
174
+ config.py 用户配置和文件夹选择辅助函数
175
+ display.py Rich 摘要、订阅预览、反馈和加载指示器
176
+ ```
177
+
178
+ 控制台命令在 `pyproject.toml` 中声明:
179
+
180
+ ```toml
181
+ [project.scripts]
182
+ mikancli = "mikancli.cli.entrypoint:main"
183
+ ```
184
+
185
+ ## 命令用法
186
+
187
+ ```text
188
+ usage: mikancli [-h] [--include INCLUDE] [--exclude EXCLUDE]
189
+ [--min-episode MIN_EPISODE] [--save-path SAVE_PATH] [--json]
190
+ [--setup-qbittorrent] [--language {en,zh-CN}] [--version]
191
+ [keyword]
192
+ ```
193
+
194
+ 选项:
195
+
196
+ - `keyword`:动画标题或搜索短语
197
+ - `--include VALUE`:要求已接受的发布标题中包含某个词或短语。可重复传入多个值
198
+ - `--exclude VALUE`:拒绝标题中包含某个词或短语的发布。可重复传入多个值
199
+ - `--min-episode NUMBER`:只接受达到此集数或更高集数的动画发布标题
200
+ - `--save-path PATH`:为生成的 qBittorrent 规则使用该基础下载文件夹
201
+ - `--json`:以 JSON 形式输出规则草稿。此模式不会提交到 qBittorrent
202
+ - `--setup-qbittorrent`:配置并验证 qBittorrent WebUI 设置
203
+ - `--language {en,zh-CN}`:为本次运行选择 CLI 语言
204
+ - `--version`:输出已安装的 CLI 版本
205
+
206
+ ## 最低集数过滤
207
+
208
+ 当 RSS 条目标题采用常见动画格式,在集数前使用短横线或用方括号标注集数时,可以使用 `--min-episode`:
209
+
210
+ ```text
211
+ [SubsPlease] One Piece - 1126 (1080p)
212
+ [Skymoon-Raws][One Piece][1126][ViuTV][WEB-RIP][CHT][SRT][1080p][MKV]
213
+ ```
214
+
215
+ 例如:
216
+
217
+ ```bash
218
+ mikancli "one piece" --include SubsPlease --min-episode 1126
219
+ ```
220
+
221
+ MikanCli 会将最低集数转换为 qBittorrent 规则 `mustContain` 字段中的正则表达式,并启用 `useRegex`。普通的 `--include` 和 `--exclude` 值仍按字面词或短语处理;只有标题中包含所有 include 值,qBittorrent 才会视为匹配。
222
+
223
+ 提交最低集数规则时,MikanCli 会检查 qBittorrent 的 RSS 条目保留上限。如果 qBittorrent 每个订阅保留的条目少于 500 条,MikanCli 会询问是否调高该设置,以便 qBittorrent 能看到足够多的 Mikan 订阅条目来匹配较新的集数。
224
+
225
+ ## 发布
226
+
227
+ 仓库包含 GitHub Actions 工作流 `.github/workflows/publish.yml`。当 `main` 分支中 `pyproject.toml` 的包版本发生变化时,该工作流会发布到 PyPI。
@@ -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_message, print_text_summary
34
+ from mikancli.display import print_message, print_section, print_text_summary
35
35
  from mikancli.i18n import (
36
36
  LANGUAGE_LABELS,
37
37
  SUPPORTED_LANGUAGES,
@@ -143,6 +143,7 @@ def resolve_startup_language(requested_language: str | None, config: AppConfig)
143
143
 
144
144
 
145
145
  def prompt_startup_action() -> str:
146
+ print_section("MikanCli")
146
147
  return select_option(
147
148
  t("startup.choose_action"),
148
149
  [
@@ -156,6 +157,7 @@ def prompt_startup_action() -> str:
156
157
 
157
158
 
158
159
  def run_language_configuration_flow(config: AppConfig, config_path: Path) -> AppConfig:
160
+ print_section(t("startup.language"))
159
161
  selected_language = select_option(
160
162
  t("language.choose"),
161
163
  [(language, LANGUAGE_LABELS[language]) for language in SUPPORTED_LANGUAGES],
@@ -194,6 +196,8 @@ def build_interactive_draft(args: argparse.Namespace, *, config: AppConfig,confi
194
196
 
195
197
  bangumi, subgroup = run_interactive_selection(initial_keyword=args.keyword)
196
198
 
199
+ if not args.include or not args.exclude or args.min_episode is None:
200
+ print_section(t("ui.filters"))
197
201
  include_words = tuple(args.include) or prompt_word_list(
198
202
  t("filters.include_prompt")
199
203
  )
@@ -206,6 +210,7 @@ def build_interactive_draft(args: argparse.Namespace, *, config: AppConfig,confi
206
210
  t("filters.min_episode_prompt"),
207
211
  field_name=t("filters.min_episode_field"),
208
212
  )
213
+ print_section(t("ui.save_path"))
209
214
  save_path = resolve_save_path(
210
215
  args.save_path,
211
216
  config,
@@ -6,9 +6,9 @@ from mikancli.display import print_message
6
6
  from mikancli.i18n import t
7
7
 
8
8
 
9
- def prompt_required_text(prompt: str) -> str:
9
+ def prompt_required_text(prompt: str, *, boxed: bool = False) -> str:
10
10
  while True:
11
- entered = collapse_spaces(prompt_text(prompt, allow_exit=True))
11
+ entered = collapse_spaces(prompt_text(prompt, allow_exit=True, boxed=boxed))
12
12
  if entered:
13
13
  return entered
14
14
  print_message(t("common.value_required"), style="warning")
@@ -4,12 +4,12 @@ from collections.abc import Iterable
4
4
  from typing import Any, TypeVar
5
5
 
6
6
  from mikancli.core.normalize import collapse_spaces
7
+ from mikancli.display import PRIMARY_COLOR, console
7
8
  from mikancli.i18n import t
8
9
 
9
10
  T = TypeVar("T")
10
11
  EXIT_OPTION = "__exit_cli__"
11
12
  EXIT_TEXT_VALUES = {"exit", "quit"}
12
- PROMPT_SEPARATOR = "----------------------------------------"
13
13
  MENU_SEPARATOR_LABEL = ""
14
14
 
15
15
 
@@ -39,8 +39,18 @@ def get_separator_class():
39
39
  return Separator
40
40
 
41
41
 
42
+ def get_prompt_style():
43
+ from InquirerPy import get_style
44
+
45
+ return get_style({
46
+ "questionmark": PRIMARY_COLOR, "question": "bold",
47
+ "answermark": PRIMARY_COLOR, "answer": PRIMARY_COLOR, "input": PRIMARY_COLOR,
48
+ "pointer": f"bold {PRIMARY_COLOR}", "fuzzy_border": PRIMARY_COLOR,
49
+ }, style_override=False)
50
+
51
+
42
52
  def prepare_prompt_message(message: str) -> str:
43
- print(PROMPT_SEPARATOR)
53
+ console.print()
44
54
  return message.strip("\n")
45
55
 
46
56
 
@@ -86,6 +96,8 @@ def select_option(
86
96
  pointer=">",
87
97
  instruction=t("common.use_arrow_keys"),
88
98
  max_height="70%",
99
+ style=get_prompt_style(),
100
+ border=len(options) > 2,
89
101
  cycle=True,
90
102
  )
91
103
  selected = prompt.execute()
@@ -94,14 +106,18 @@ def select_option(
94
106
  return selected
95
107
 
96
108
 
97
- def prompt_text(message: str, *, default: str | None = None, allow_exit: bool = False) -> str:
109
+ def prompt_text(message: str, *, default: str | None = None, allow_exit: bool = False, boxed: bool = False) -> str:
98
110
 
99
111
  inquirer = get_inquirer()
100
112
 
101
113
  prompt = inquirer.text(
102
114
  message=prepare_prompt_message(message),
103
115
  default=default or "",
116
+ style=get_prompt_style(),
104
117
  )
118
+ if boxed and console.is_terminal:
119
+ # InquirerPy wraps PromptSession; its native frame preserves input editing.
120
+ prompt._session.show_frame = True
105
121
  entered = collapse_spaces(prompt.execute())
106
122
  if allow_exit and entered.casefold() in EXIT_TEXT_VALUES:
107
123
  raise ExitRequested()
@@ -113,9 +129,9 @@ def prompt_password(message: str, *, allow_exit: bool = False) -> str:
113
129
  inquirer = get_inquirer()
114
130
  prompt_factory = getattr(inquirer, "secret", None)
115
131
  if prompt_factory is not None:
116
- prompt = prompt_factory(message=prepare_prompt_message(message))
132
+ prompt = prompt_factory(message=prepare_prompt_message(message), style=get_prompt_style())
117
133
  else: # pragma: no cover - fallback for alternate InquirerPy versions
118
- prompt = inquirer.text(message=prepare_prompt_message(message), secret=True)
134
+ prompt = inquirer.text(message=prepare_prompt_message(message), secret=True, style=get_prompt_style())
119
135
 
120
136
  entered = prompt.execute()
121
137
  if allow_exit and entered.strip().casefold() in EXIT_TEXT_VALUES:
@@ -6,7 +6,7 @@ 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
+ from mikancli.display import loading, print_message, print_section
10
10
  from mikancli.i18n import t
11
11
  from mikancli.integrations.qbittorrent import (
12
12
  MIN_RSS_ARTICLES_FOR_EPISODE_FILTER,
@@ -28,15 +28,10 @@ QBITTORRENT_ERROR = 3
28
28
 
29
29
  def setup_qbittorrent(config: AppConfig, config_path: Path) -> int:
30
30
  """Prompt for qBittorrent WebUI settings, verify the connection, and save them on success. Returns QBITTORRENT_SETUP_SUCCESS when verification succeeds and QBITTORRENT_ERROR when qBittorrent rejects or cannot be reached."""
31
- print()
32
- print(t("qb.setup.title"))
33
- print(t("qb.setup.step1"))
34
- print(t("qb.setup.step2"))
35
- print(t("qb.setup.step3"))
36
- print(t("qb.setup.step4"))
37
- print(t("qb.setup.step5"))
38
- print("------------------------------------------")
39
- print()
31
+ print_section(
32
+ t("qb.setup.title").strip("- "),
33
+ "\n".join(t(f"qb.setup.step{step}") for step in range(1, 6)),
34
+ )
40
35
 
41
36
  default_url = config.qbittorrent_url or "http://localhost:8080"
42
37
  entered_url = (
@@ -208,6 +203,7 @@ def prompt_to_submit_rule_to_qbittorrent(config: AppConfig, draft: RuleDraft,) -
208
203
  if not config.qbittorrent_url:
209
204
  return QBITTORRENT_NOT_CONFIGURED
210
205
 
206
+ print_section("qBittorrent")
211
207
  should_submit = confirm_choice(
212
208
  t("qb.submit.continue"),
213
209
  default=True,
@@ -14,6 +14,7 @@ from mikancli.display import (
14
14
  loading,
15
15
  print_feed_preview_page,
16
16
  print_message,
17
+ print_section,
17
18
  )
18
19
  from mikancli.i18n import t
19
20
  from mikancli.integrations.mikan import (
@@ -36,8 +37,13 @@ def search_prompt(*, retry: bool = False) -> str:
36
37
  return t("search.prompt_retry" if retry else "search.prompt")
37
38
 
38
39
 
40
+ def prompt_search_keyword(*, retry: bool = False) -> str:
41
+ return prompt_required_text(search_prompt(retry=retry), boxed=True)
42
+
43
+
39
44
  def select_candidate_or_search_again(candidates: tuple[MikanBangumi, ...], *, keyword: str) -> MikanBangumi | str:
40
45
 
46
+ print_section(t("ui.search_results"), keyword)
41
47
  options: list[tuple[int | str, str]] = [
42
48
  (index, f"{candidate.title} (Bangumi {candidate.bangumi_id})")
43
49
  for index, candidate in enumerate(candidates)
@@ -60,6 +66,7 @@ def select_candidate_or_search_again(candidates: tuple[MikanBangumi, ...], *, ke
60
66
  def select_subgroup_or_navigate(subgroups: tuple[MikanSubgroup, ...], *, bangumi_title: str) -> MikanSubgroup | str:
61
67
  """Ask the user to choose a subgroup RSS feed or navigate back through the search flow. Returns a MikanSubgroup, BACK_TO_CANDIDATES, or SEARCH_AGAIN based on the user's decision."""
62
68
 
69
+ print_section(t("ui.subgroups"), bangumi_title)
63
70
  options: list[tuple[int | str, str]] = [
64
71
  (index, f"{subgroup.title} (Subgroup {subgroup.subgroup_id})")
65
72
  for index, subgroup in enumerate(subgroups)
@@ -176,7 +183,7 @@ def run_interactive_selection(*, initial_keyword: str | None) -> tuple[MikanBang
176
183
 
177
184
  keyword = collapse_spaces(initial_keyword or "")
178
185
  if not keyword:
179
- keyword = prompt_required_text(search_prompt())
186
+ keyword = prompt_search_keyword()
180
187
 
181
188
  while True:
182
189
  try:
@@ -184,17 +191,17 @@ def run_interactive_selection(*, initial_keyword: str | None) -> tuple[MikanBang
184
191
  candidates = search_mikan_bangumi(keyword)
185
192
  except MikanLookupError as exc:
186
193
  print_message(str(exc), style="error")
187
- keyword = prompt_required_text(search_prompt(retry=True))
194
+ keyword = prompt_search_keyword(retry=True)
188
195
  continue
189
196
 
190
197
  if not candidates:
191
198
  print_message(t("search.no_results", keyword=keyword), style="warning")
192
- keyword = prompt_required_text(search_prompt(retry=True))
199
+ keyword = prompt_search_keyword(retry=True)
193
200
  continue
194
201
 
195
202
  selected_candidate = select_candidate_or_search_again(candidates, keyword=keyword)
196
203
  if selected_candidate == SEARCH_AGAIN:
197
- keyword = prompt_required_text(search_prompt(retry=True))
204
+ keyword = prompt_search_keyword(retry=True)
198
205
  continue
199
206
 
200
207
  bangumi = selected_candidate
@@ -219,7 +226,7 @@ def run_interactive_selection(*, initial_keyword: str | None) -> tuple[MikanBang
219
226
  if selected_subgroup == BACK_TO_CANDIDATES:
220
227
  break
221
228
  if selected_subgroup == SEARCH_AGAIN:
222
- keyword = prompt_required_text(search_prompt(retry=True))
229
+ keyword = prompt_search_keyword(retry=True)
223
230
  break
224
231
 
225
232
  subgroup = selected_subgroup
@@ -235,7 +242,7 @@ def run_interactive_selection(*, initial_keyword: str | None) -> tuple[MikanBang
235
242
  if decision == BACK_TO_SUBGROUPS:
236
243
  continue
237
244
  if decision == REJECT_SUBGROUP:
238
- keyword = prompt_required_text(search_prompt(retry=True))
245
+ keyword = prompt_search_keyword(retry=True)
239
246
  break
240
247
 
241
248
  return bangumi, subgroup
@@ -3,7 +3,9 @@ from __future__ import annotations
3
3
  from contextlib import AbstractContextManager, nullcontext
4
4
  from datetime import datetime
5
5
 
6
- from rich.console import Console
6
+ from rich.console import Console, Group
7
+ from rich.panel import Panel
8
+ from rich.rule import Rule
7
9
  from rich.table import Table
8
10
  from rich.text import Text
9
11
  from rich.theme import Theme
@@ -11,11 +13,12 @@ from rich.theme import Theme
11
13
  from mikancli.core.models import MikanFeedItem, MikanSubgroup, RuleDraft
12
14
  from mikancli.i18n import t
13
15
 
16
+ PRIMARY_COLOR = "#ffb454"
14
17
  FEED_PREVIEW_PAGE_SIZE = 12
15
18
  console = Console(
16
19
  markup=False, highlight=False, emoji=False,
17
20
  theme=Theme({
18
- "heading": "bold cyan", "label": "cyan", "note": "dim",
21
+ "heading": f"bold {PRIMARY_COLOR}", "label": PRIMARY_COLOR, "note": "dim",
19
22
  "success": "green", "warning": "yellow", "error": "bold red",
20
23
  }),
21
24
  )
@@ -26,10 +29,24 @@ def print_message(message: str, *, style: str = "note") -> None:
26
29
  console.print(Text(message), style=style, soft_wrap=not console.is_terminal)
27
30
 
28
31
 
32
+ def print_section(title: str, *paragraphs: str) -> None:
33
+ """Give each interactive stage a wrapping heading and optional context."""
34
+ content = Text()
35
+ content.append(title, style="heading")
36
+ for paragraph in paragraphs:
37
+ content.append("\n\n" + paragraph)
38
+ console.print()
39
+ if console.is_terminal:
40
+ console.print(Panel(content, border_style="heading", padding=(0, 1),
41
+ width=min(console.width, 100)))
42
+ else:
43
+ console.print(content, soft_wrap=True)
44
+
45
+
29
46
  def loading(message: str) -> AbstractContextManager[object]:
30
47
  """Animate terminal requests, or print one static message when redirected."""
31
48
  if console.is_terminal and console.is_interactive:
32
- return console.status(Text(message), spinner="line")
49
+ return console.status(Text(message), spinner="dots", spinner_style=PRIMARY_COLOR)
33
50
  print_message(message)
34
51
  return nullcontext()
35
52
 
@@ -53,10 +70,17 @@ def print_text_summary(draft: RuleDraft) -> int:
53
70
  table.add_column(style="label")
54
71
  table.add_column(overflow="fold")
55
72
  for key, value in fields.items():
73
+ if console.is_terminal and key in {"must_contain", "save_path"}:
74
+ table.add_row()
56
75
  table.add_row(Text(t(f"display.{key}", value="").rstrip()), Text(str(value)))
57
76
  console.print()
58
- console.rule(Text(t("display.rule_header").strip("- ")), style="heading")
59
- console.print(table)
77
+ heading = Text(t("display.rule_header").strip("- "))
78
+ if console.is_terminal:
79
+ console.print(Panel(table, title=heading, border_style="heading",
80
+ padding=(1, 2), width=min(console.width, 100)))
81
+ else:
82
+ console.rule(heading, style="heading")
83
+ console.print(table)
60
84
  console.print()
61
85
  for note in draft.notes:
62
86
  console.print(Text(t("display.next_step", value=note)), style="note")
@@ -117,13 +141,23 @@ def print_feed_preview_page(
117
141
  console.print(Text(content), soft_wrap=True)
118
142
  return
119
143
 
120
- preview = Text()
121
- for index, line in enumerate(content.splitlines(keepends=True)):
122
- style = "heading" if index == 0 else ""
123
- if index in (1, 2) or line.startswith(" "):
124
- style = "note"
125
- preview.append(line, style=style)
126
- console.print(preview, overflow="fold")
144
+ sections = []
145
+ for index, line in enumerate(content.splitlines()):
146
+ if not line:
147
+ sections.append(Rule(style="note"))
148
+ continue
149
+ if line.startswith(" "):
150
+ details = Text(line.lstrip())
151
+ if details.cell_len <= min(console.width, 100) - 8:
152
+ sections.append(Rule(details, align="left", style="note"))
153
+ else:
154
+ sections.append(details)
155
+ continue
156
+ style = "heading" if index == 0 else "bold" if index >= 4 else ""
157
+ sections.append(Text(line, style=style))
158
+ console.print()
159
+ console.print(Panel(Group(*sections), border_style="heading",
160
+ padding=(0, 1), width=min(console.width, 100)))
127
161
 
128
162
 
129
163
  def build_feed_preview_page_text(
@@ -36,6 +36,10 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
36
36
  "common.not_set": "(not set)",
37
37
  "common.unknown": "(unknown)",
38
38
  "common.value_required": "A value is required.",
39
+ "ui.search_results": "Search results",
40
+ "ui.subgroups": "Subgroup feeds",
41
+ "ui.filters": "Download filters",
42
+ "ui.save_path": "Download location",
39
43
  "startup.choose_action": "Choose what you want to do",
40
44
  "startup.search": "Search anime",
41
45
  "startup.qbittorrent": "Modify qBittorrent configurations",
@@ -150,6 +154,10 @@ TRANSLATIONS: dict[str, dict[str, str]] = {
150
154
  "common.not_set": "(未设置)",
151
155
  "common.unknown": "(未知)",
152
156
  "common.value_required": "必须输入一个值。",
157
+ "ui.search_results": "搜索结果",
158
+ "ui.subgroups": "字幕组订阅",
159
+ "ui.filters": "下载过滤条件",
160
+ "ui.save_path": "下载位置",
153
161
  "startup.choose_action": "选择你想执行的操作",
154
162
  "startup.search": "搜索动画",
155
163
  "startup.qbittorrent": "修改 qBittorrent 配置",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mikancli
3
- Version: 0.2.0
3
+ Version: 0.2.2
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,11 +20,12 @@ 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: prompt-toolkit<4,>=3.0.52
23
24
  Requires-Dist: rich>=14.0
24
25
 
25
26
  # MikanCli
26
27
 
27
- Language: [English](README.md) | [简体中文](README.zh-CN.md)
28
+ Language: [简体中文](README.md) | [English](README.en.md)
28
29
 
29
30
  MikanCli is a Python command-line tool for finding bangumi/anime on Mikanani.me, choosing the correct Bangumi and subgroup RSS feed, and turning that selection into a qBittorrent RSS download rule.
30
31
 
@@ -43,6 +44,12 @@ It supports both a guided interactive flow and a JSON preview mode for scripting
43
44
  - Submit RSS feeds and auto-download rules to qBittorrent, then verify that qBittorrent saved them
44
45
  - Print rule drafts as JSON without submitting anything
45
46
 
47
+ ## Screenshots
48
+
49
+ ![Search input (demo data)](docs/images/search-input.png)
50
+
51
+ ![RSS feed preview (demo data)](docs/images/feed-preview.png)
52
+
46
53
  ## Requirements
47
54
 
48
55
  - Python 3.10 or newer
@@ -1,3 +1,4 @@
1
+ README.en.md
1
2
  README.md
2
3
  pyproject.toml
3
4
  mikancli/__init__.py
@@ -31,4 +32,5 @@ mikancli/integrations/qbittorrent_client.py
31
32
  tests/test_cli_json.py
32
33
  tests/test_display.py
33
34
  tests/test_feed_preview.py
34
- tests/test_qbittorrent_episode_filter.py
35
+ tests/test_qbittorrent_episode_filter.py
36
+ tests/test_search_prompt.py
@@ -1,2 +1,3 @@
1
1
  InquirerPy>=0.3
2
+ prompt-toolkit<4,>=3.0.52
2
3
  rich>=14.0
@@ -4,9 +4,9 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mikancli"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "CLI foundation for building qBittorrent RSS rules from anime search input."
9
- readme = "README.md"
9
+ readme = "README.en.md"
10
10
  requires-python = ">=3.10"
11
11
  authors = [
12
12
  { name = "metails" },
@@ -27,6 +27,7 @@ classifiers = [
27
27
  ]
28
28
  dependencies = [
29
29
  "InquirerPy>=0.3",
30
+ "prompt-toolkit>=3.0.52,<4",
30
31
  "rich>=14.0",
31
32
  ]
32
33
 
@@ -7,11 +7,28 @@ from rich.console import Console
7
7
  from rich.theme import Theme
8
8
 
9
9
  from mikancli.core.models import RuleDraft
10
- from mikancli.display import loading, print_message, print_text_summary
10
+ from mikancli.display import loading, print_message, print_section, print_text_summary
11
11
  from mikancli.i18n import get_language, set_language
12
12
 
13
13
 
14
14
  class DisplayTests(TestCase):
15
+ def test_sections_wrap_literal_context_and_stay_plain_when_redirected(self) -> None:
16
+ title = "[bold]动画[/bold]"
17
+ context = "https://example.test/rss?title=[literal]&padding=" + "x" * 60
18
+ for interactive in (False, True):
19
+ with self.subTest(interactive=interactive):
20
+ output = StringIO()
21
+ terminal = Console(file=output, width=40, height=40, force_terminal=interactive,
22
+ color_system=None, theme=Theme({"heading": "cyan"}))
23
+ with patch("mikancli.display.console", terminal):
24
+ print_section(title, context)
25
+ rendered = output.getvalue()
26
+ compact = "".join(c for c in rendered if not c.isspace() and c not in "╭╮╰╯┌┐└┘│─")
27
+ self.assertIn(title, compact)
28
+ self.assertIn(context, compact)
29
+ if not interactive:
30
+ self.assertEqual(rendered, f"\n{title}\n\n{context}\n")
31
+
15
32
  def test_loading_gates_animation_and_cleans_up_when_requests_fail(self) -> None:
16
33
  literal = "[bold]正在加载[/bold] :smile:"
17
34
  for terminal, interactive in ((False, False), (False, True), (True, False), (True, True)):
@@ -23,6 +40,7 @@ class DisplayTests(TestCase):
23
40
  raise failure
24
41
  if terminal and interactive:
25
42
  target.status.assert_called_once()
43
+ self.assertEqual(target.status.call_args.kwargs["spinner"], "dots")
26
44
  self.assertEqual(target.status.call_args.args[0].plain, literal)
27
45
  target.status.return_value.__exit__.assert_called_once_with(ValueError, failure, ANY)
28
46
  message.assert_not_called()
@@ -26,11 +26,14 @@ class FeedPreviewTests(TestCase):
26
26
  for page in (1, 2):
27
27
  with self.subTest(language=language, width=width, page=page):
28
28
  output = StringIO()
29
- terminal = Console(file=output, width=width, force_terminal=True,
29
+ terminal = Console(file=output, width=width, height=40, force_terminal=True,
30
30
  color_system=None, theme=theme)
31
31
  with patch("mikancli.display.console", terminal):
32
32
  print_feed_preview_page(subgroup, items, page=page)
33
- rendered = "".join(output.getvalue().split())
33
+ framed = output.getvalue()
34
+ self.assertIn("│", framed)
35
+ rendered = "".join(char for char in framed
36
+ if not char.isspace() and char not in "╭╮╰╯┌┐└┘│─")
34
37
  expected = build_feed_preview_page_text(subgroup, items, page=page)
35
38
  self.assertEqual(rendered, "".join(expected.split()))
36
39
  self.assertEqual("[13]" in rendered, page == 2)
@@ -0,0 +1,35 @@
1
+ from unittest import TestCase
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ from InquirerPy import inquirer
5
+ from prompt_toolkit.application import create_app_session
6
+ from prompt_toolkit.input import DummyInput
7
+ from prompt_toolkit.output import DummyOutput
8
+
9
+ from mikancli.cli import input_parsing, prompts, search_flow
10
+
11
+
12
+ class SearchPromptTests(TestCase):
13
+ def test_search_frame_survives_empty_retry_and_exit(self) -> None:
14
+ answers = iter(["", " One Piece ", "quit", "other", "redirected"])
15
+ frames = []
16
+
17
+ def execute(widget):
18
+ frames.append(widget._session.show_frame)
19
+ return next(answers)
20
+
21
+ terminal = MagicMock(is_terminal=True)
22
+ with (
23
+ create_app_session(input=DummyInput(), output=DummyOutput()),
24
+ patch.object(inquirer.text, "execute", autospec=True, side_effect=execute),
25
+ patch.object(prompts, "console", terminal),
26
+ patch.object(input_parsing, "print_message") as warning,
27
+ ):
28
+ self.assertEqual(search_flow.prompt_search_keyword(), "One Piece")
29
+ with self.assertRaises(prompts.ExitRequested):
30
+ search_flow.prompt_search_keyword(retry=True)
31
+ self.assertEqual(prompts.prompt_text("Other"), "other")
32
+ terminal.is_terminal = False
33
+ self.assertEqual(search_flow.prompt_search_keyword(), "redirected")
34
+ self.assertEqual(frames, [True, True, True, False, False])
35
+ warning.assert_called_once()
File without changes
File without changes
File without changes
File without changes