yt-dlp-controller 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.
@@ -0,0 +1,4 @@
1
+ """
2
+ YT-DLP Controller
3
+ """
4
+ __version__ = "0.1.0"
@@ -0,0 +1,269 @@
1
+ """Textual 应用:全局配置、队列单例、状态栏、启动恢复与退出保护。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+ from rich.spinner import SPINNERS
9
+ from rich.text import Text
10
+ from textual import work
11
+ from textual.app import App, ComposeResult
12
+ from textual.binding import Binding
13
+ from textual.widget import Widget
14
+ from textual.widgets import Static
15
+
16
+ from . import __version__
17
+ from .config import load_config
18
+ from .queue import QueueManager
19
+ from .widgets import ConfirmModal, ProgressCover
20
+ from .ytdlp import human_speed
21
+
22
+ _DOTS = SPINNERS["dots"]
23
+
24
+
25
+ class StatusBar(Static):
26
+ """底部状态栏:展示最近一次操作结果 / 进行中提示;spinner=True 时显示点阵转圈。"""
27
+
28
+ def __init__(self, *args, **kwargs) -> None:
29
+ super().__init__(*args, **kwargs)
30
+ self._text = ""
31
+ self._busy = False
32
+ self._frame = 0
33
+ self._timer = None
34
+
35
+ def render(self) -> Text:
36
+ if self._busy:
37
+ glyph = _DOTS["frames"][self._frame % len(_DOTS["frames"])]
38
+ return Text.assemble((glyph, "bold"), " ", self._text)
39
+ return Text(self._text)
40
+
41
+ def set_status(self, text: str) -> None:
42
+ self._busy = False
43
+ self._text = text
44
+ self._stop_timer()
45
+ self.refresh()
46
+
47
+ def set_spinner(self, text: str) -> None:
48
+ self._busy = True
49
+ self._text = text
50
+ self._ensure_timer()
51
+ self.refresh()
52
+
53
+ def _ensure_timer(self) -> None:
54
+ if self._timer is None and self.is_mounted:
55
+ self._timer = self.set_interval(_DOTS["interval"] / 1000, self._tick)
56
+
57
+ def _stop_timer(self) -> None:
58
+ if self._timer is not None:
59
+ self._timer.stop()
60
+ self._timer = None
61
+
62
+ def _tick(self) -> None:
63
+ self._frame += 1
64
+ self.refresh()
65
+
66
+ def on_mount(self) -> None:
67
+ self._ensure_timer()
68
+
69
+
70
+ class YtdlpControllerApp(App):
71
+ """yt-dlp 图形化终端界面。"""
72
+
73
+ CSS_PATH = Path(__file__).parent / "app.tcss"
74
+ TITLE = "YT-DLP Controller"
75
+ SUB_TITLE = f"v{__version__}"
76
+ ENABLE_COMMAND_PALETTE = False
77
+
78
+ BINDINGS = [
79
+ Binding("ctrl+q", "quit", "退出", priority=True),
80
+ ]
81
+
82
+ def __init__(self) -> None:
83
+ super().__init__()
84
+ self.config: dict = {}
85
+ self._queue: QueueManager | None = None
86
+ self._quitting = False
87
+ self._quit_pending = False
88
+ self._booted = False
89
+
90
+ # ------------------------------------------------------------ 全局资源
91
+
92
+ def get_queue(self) -> QueueManager:
93
+ """队列管理器单例(延迟创建,首次访问时加载 queue.json)。"""
94
+ if self._queue is None:
95
+ self._queue = QueueManager(lambda: self.config)
96
+ return self._queue
97
+
98
+ def get_settings(self) -> dict:
99
+ return self.config
100
+
101
+ def reload_config(self) -> list[str]:
102
+ """重新读取配置并返回告警(坏值已回落,不阻断启动)。"""
103
+ config, warnings = load_config()
104
+ self.config = config
105
+ return warnings
106
+
107
+ # ------------------------------------------------------------ 状态栏 / 提示
108
+
109
+ def compose(self) -> ComposeResult:
110
+ yield StatusBar("", id="status")
111
+
112
+ def get_loading_widget(self) -> Widget:
113
+ """全局默认:任意控件 .loading=True 时用 indeterminate 进度条覆盖其区域。"""
114
+ return ProgressCover()
115
+
116
+ def status(self, message: str, kind: str = "") -> None:
117
+ try:
118
+ bar = self.query_one("#status", StatusBar)
119
+ except Exception:
120
+ return
121
+ bar.update_classes({"ok": kind == "ok", "warn": kind == "warn", "err": kind == "err"})
122
+ bar.set_status(message)
123
+
124
+ def spinner(self, on: bool, message: str = "加载中…") -> None:
125
+ """状态栏点阵 spinner(无遮罩、不阻断输入):适合轻量、即时的后台动作。"""
126
+ try:
127
+ bar = self.query_one("#status", StatusBar)
128
+ except Exception:
129
+ return
130
+ bar.update_classes({})
131
+ if on:
132
+ bar.set_spinner(message)
133
+ else:
134
+ bar.set_status("")
135
+
136
+ def notify_ok(self, message: str) -> None:
137
+ self.status(message, "ok")
138
+ self.notify(message, severity="success", timeout=4)
139
+
140
+ def notify_err(self, message: str) -> None:
141
+ self.status(message, "err")
142
+ self.notify(message, severity="error", timeout=8)
143
+
144
+ def notify_warn(self, message: str) -> None:
145
+ self.status(message, "warn")
146
+ self.notify(message, severity="warning", timeout=5)
147
+
148
+ # ------------------------------------------------------------ 退出
149
+
150
+ def action_quit(self) -> None:
151
+ """全局退出(Ctrl+Q / 首页退出):有未完成任务时先确认。"""
152
+ if self._quitting or self._quit_pending:
153
+ return
154
+ queue = self.get_queue()
155
+ active = queue.active_count()
156
+ if active > 0:
157
+ self._quit_pending = True
158
+ self.push_screen(
159
+ ConfirmModal(
160
+ f"仍有 {active} 个任务进行中,退出将中断下载。确定退出吗?",
161
+ title="退出确认",
162
+ yes="退出",
163
+ no="继续下载",
164
+ default_yes=False,
165
+ ),
166
+ self._on_quit_confirmed,
167
+ )
168
+ return
169
+ self._shutdown_and_exit()
170
+
171
+ def _on_quit_confirmed(self, confirmed: bool) -> None:
172
+ self._quit_pending = False
173
+ if confirmed:
174
+ self._shutdown_and_exit()
175
+
176
+ @work
177
+ async def _shutdown_and_exit(self) -> None:
178
+ self._quitting = True
179
+ try:
180
+ await self.get_queue().shutdown()
181
+ finally:
182
+ self.exit()
183
+
184
+ # ------------------------------------------------------------ 启动
185
+
186
+ def on_mount(self) -> None:
187
+ self._boot()
188
+
189
+ @work
190
+ async def _boot(self) -> None:
191
+ if self._booted:
192
+ return
193
+ self._booted = True
194
+ try:
195
+ warnings = self.reload_config()
196
+ except Exception as exc: # 配置层不应抛错,兜底避免白屏
197
+ self.notify_err(f"配置加载失败:{exc}")
198
+ warnings = []
199
+ for warning in warnings:
200
+ self.notify_warn(warning)
201
+
202
+ from .screens.home import HomeScreen
203
+
204
+ self.push_screen(HomeScreen())
205
+
206
+ queue = self.get_queue()
207
+ interrupted = queue.interrupted
208
+ if interrupted:
209
+ self.status(f"{len(interrupted)} 个任务上次被中断,等待确认续传", "warn")
210
+ resume = await self.push_screen_wait(
211
+ ConfirmModal(
212
+ f"检测到 {len(interrupted)} 个任务上次被中断,是否继续下载(保留 .part 断点续传)?",
213
+ title="恢复任务",
214
+ yes="继续下载",
215
+ no="稍后处理",
216
+ default_yes=True,
217
+ )
218
+ )
219
+ if resume:
220
+ count = queue.resume_interrupted()
221
+ self.notify_ok(f"已重新排队 {count} 个任务")
222
+ queue.start()
223
+ self._consume_queue_events()
224
+
225
+ @work(exclusive=True)
226
+ async def _consume_queue_events(self) -> None:
227
+ """统一消费队列事件:每 200ms 批量 drain 一次,再分发给栈内各屏。
228
+
229
+ 单消费者避免「队列页 / 历史页同时挂载时互相抢事件」;每屏按 task id
230
+ 增量更新单元格,不做整表重建。
231
+ """
232
+ queue = self.get_queue()
233
+ while True:
234
+ first = await queue.events.get()
235
+ batch = [first]
236
+ await asyncio.sleep(0.2)
237
+ while not queue.events.empty():
238
+ batch.append(queue.events.get_nowait())
239
+ for screen in list(self.screen_stack):
240
+ handler = getattr(screen, "apply_queue_events", None)
241
+ if not callable(handler):
242
+ continue
243
+ try:
244
+ handler(batch)
245
+ except Exception:
246
+ # 单个屏幕刷新失败不应中断全局事件消费,下一批事件或重进页面会自愈
247
+ continue
248
+
249
+ # ------------------------------------------------------------ 汇总文案
250
+
251
+ def queue_summary(self) -> str:
252
+ queue = self.get_queue()
253
+ counts = queue.counts()
254
+ parts = []
255
+ if counts["running"]:
256
+ parts.append(f"↓ {human_speed(queue.total_speed())}")
257
+ parts.append(f"{counts['running']} 进行中")
258
+ if counts["queued"]:
259
+ parts.append(f"{counts['queued']} 等待")
260
+ if counts["paused"]:
261
+ parts.append(f"{counts['paused']} 暂停")
262
+ if counts["failed"]:
263
+ parts.append(f"{counts['failed']} 失败")
264
+ if not parts:
265
+ parts.append(f"{counts['completed']} 已完成")
266
+ return " · ".join(parts)
267
+
268
+
269
+ __all__ = ["YtdlpControllerApp", "StatusBar"]
@@ -0,0 +1,238 @@
1
+ /* app.tcss — yt-dlp-controller 样式(复用 aliyun-controller 规范,按本项目裁剪) */
2
+
3
+ Screen {
4
+ background: $surface;
5
+ }
6
+
7
+ /* ---------- 按钮:全局单行(无边框;四项目一致) ---------- */
8
+ Button {
9
+ height: 1;
10
+ border: none;
11
+ min-width: 0;
12
+ padding: 0 1;
13
+ }
14
+
15
+ Button:focus {
16
+ border: none;
17
+ text-style: bold reverse;
18
+ }
19
+
20
+ Button:disabled {
21
+ text-opacity: 60%;
22
+ }
23
+
24
+ /* ---------- 模态三段式:标题栏 / 内容 / 按钮栏 ---------- */
25
+ .modal-box {
26
+ layout: vertical;
27
+ height: auto;
28
+ max-height: 90%;
29
+ border: round $primary;
30
+ background: $surface;
31
+ padding: 0;
32
+ }
33
+
34
+ .modal-title {
35
+ height: auto;
36
+ padding: 0 2;
37
+ border-bottom: hkey $secondary;
38
+ content-align: left middle;
39
+ }
40
+
41
+ .modal-main {
42
+ height: auto;
43
+ padding: 1 2;
44
+ }
45
+
46
+ .btn-row {
47
+ height: auto;
48
+ padding: 0 2;
49
+ border-top: hkey $secondary;
50
+ align-horizontal: right;
51
+ dock: bottom;
52
+ }
53
+
54
+ .btn-row Button {
55
+ margin-left: 2;
56
+ }
57
+
58
+ /* ---------- 页面公共 ---------- */
59
+ .page-hint {
60
+ height: 1;
61
+ padding: 0 1;
62
+ color: $text-muted;
63
+ }
64
+
65
+ /* ---------- 内容区小容器(筛选 / 排序 / 主操作按钮) ---------- */
66
+ .filter-row {
67
+ height: auto;
68
+ layout: horizontal;
69
+ }
70
+
71
+ .filter-row.gap-top {
72
+ margin-top: 1;
73
+ }
74
+
75
+ .filter-row .fl-label {
76
+ width: auto;
77
+ color: $text-muted;
78
+ margin-right: 1;
79
+ content-align: left middle;
80
+ }
81
+
82
+ .filter-row Input {
83
+ width: 1fr;
84
+ }
85
+
86
+ .filter-row .fill {
87
+ width: 1fr;
88
+ }
89
+
90
+ .filter-row Select {
91
+ margin-right: 2;
92
+ }
93
+
94
+ .filter-row Button {
95
+ margin-left: 2;
96
+ }
97
+
98
+ /* ---------- 表格 ---------- */
99
+ .tbl {
100
+ height: 1fr;
101
+ margin: 1 1 0 1;
102
+ border: round $secondary;
103
+ overflow-x: hidden;
104
+ }
105
+
106
+ .tbl:focus {
107
+ border: round $primary;
108
+ }
109
+
110
+ /* ---------- 信息面板 ---------- */
111
+ .panel {
112
+ height: auto;
113
+ margin: 1 1 0 1;
114
+ padding: 1 2;
115
+ border: round $secondary;
116
+ }
117
+
118
+ /* 紧凑面板:80x28 下容纳三块内容而不触发外层滚动 */
119
+ .panel.tight {
120
+ padding: 0 1;
121
+ }
122
+
123
+ /* ---------- 首页 ---------- */
124
+ #home-main {
125
+ height: 1fr;
126
+ align: center middle;
127
+ }
128
+
129
+ #home-col {
130
+ width: auto;
131
+ max-width: 90%;
132
+ height: auto;
133
+ }
134
+
135
+ #home-banner {
136
+ width: 1fr;
137
+ text-align: left;
138
+ color: $primary;
139
+ }
140
+
141
+ #home-plain {
142
+ width: 1fr;
143
+ text-align: center;
144
+ color: $primary;
145
+ }
146
+
147
+ #home-version {
148
+ width: 1fr;
149
+ text-align: right;
150
+ color: $text-muted;
151
+ margin-bottom: 1;
152
+ }
153
+
154
+ #home-list {
155
+ width: 1fr;
156
+ height: auto;
157
+ border: none;
158
+ background: transparent;
159
+ }
160
+
161
+ .logo-page #home-plain {
162
+ display: none;
163
+ }
164
+
165
+ .logo-page.-sm #home-banner {
166
+ display: none;
167
+ }
168
+
169
+ .logo-page.-sm #home-plain {
170
+ display: block;
171
+ }
172
+
173
+ .logo-page.-sm #home-col {
174
+ width: 1fr;
175
+ padding: 0 2;
176
+ }
177
+
178
+ /* ---------- 设置页 / 信息页:kv 条目 ---------- */
179
+ .kv-row {
180
+ height: 1;
181
+ layout: horizontal;
182
+ }
183
+
184
+ .kv-row.gap-top {
185
+ margin-top: 1;
186
+ }
187
+
188
+ .kv-label {
189
+ width: 18;
190
+ color: $text-muted;
191
+ content-align: left middle;
192
+ }
193
+
194
+ .kv-label.sm {
195
+ width: 10;
196
+ }
197
+
198
+ .kv-value {
199
+ width: 1fr;
200
+ content-align: left middle;
201
+ text-style: bold;
202
+ }
203
+
204
+ .kv-value.dim {
205
+ text-style: none;
206
+ color: $text-muted;
207
+ }
208
+
209
+ /* ---------- 下载选项 ---------- */
210
+ .panel .opt-row {
211
+ height: auto;
212
+ layout: horizontal;
213
+ align-vertical: middle;
214
+ }
215
+
216
+ .panel .opt-row .opt-label {
217
+ width: auto;
218
+ color: $text-muted;
219
+ margin-right: 1;
220
+ content-align: left middle;
221
+ }
222
+
223
+ .panel .opt-row Select {
224
+ margin-right: 3;
225
+ }
226
+
227
+ /* ---------- 底部状态栏 ---------- */
228
+ #status {
229
+ height: 1;
230
+ dock: bottom;
231
+ padding: 0 1;
232
+ background: $panel;
233
+ color: $text;
234
+ }
235
+
236
+ #status.ok { color: $success; }
237
+ #status.warn { color: $warning; }
238
+ #status.err { color: $error; }