返回 douyin-downloader
progress_display.py
根目录 / cli / progress_display.py
1 from __future__ import annotations
2
3 from typing import Optional
4
5 from rich.console import Console
6 from rich.progress import (
7 BarColumn,
8 Progress,
9 SpinnerColumn,
10 TaskProgressColumn,
11 TextColumn,
12 TimeRemainingColumn,
13 )
14 from rich.table import Table
15
16 console = Console()
17
18
19 class ProgressDisplay:
20 _URL_STEP_TOTAL = 6
21
22 def __init__(self):
23 self.console = console
24 self._progress_ctx: Optional[Progress] = None
25 self._progress: Optional[Progress] = None
26 self._overall_task_id: Optional[int] = None
27 self._url_task_id: Optional[int] = None
28 self._item_task_id: Optional[int] = None
29 self._url_index = 0
30 self._url_total = 0
31 self._url_step_completed = 0
32 self._item_total = 0
33 self._item_completed = 0
34 self._single_url_item_mode = False
35 self._item_stats = {"success": 0, "failed": 0, "skipped": 0}
36
37 def show_banner(self):
38 banner = """
39 ╔══════════════════════════════════════════╗
40 ║ Douyin Downloader v2.0.0 ║
41 ║ 抖音批量下载工具 ║
42 ╚══════════════════════════════════════════╝
43 """
44 self._active_console().print(banner, style="bold cyan")
45
46 def create_progress(self) -> Progress:
47 return Progress(
48 SpinnerColumn(),
49 TextColumn("[progress.description]{task.description}"),
50 BarColumn(),
51 TaskProgressColumn(),
52 TimeRemainingColumn(),
53 TextColumn("[dim]{task.fields[detail]}"),
54 console=self.console,
55 transient=True,
56 refresh_per_second=6,
57 )
58
59 def start_download_session(self, total_urls: int):
60 if self._progress is not None:
61 return
62
63 self._progress_ctx = self.create_progress()
64 self._progress = self._progress_ctx.__enter__()
65 self._single_url_item_mode = False
66 self._overall_task_id = self._progress.add_task(
67 "总体进度",
68 total=max(total_urls, 1),
69 completed=0,
70 detail=f"共 {total_urls} 个 URL",
71 )
72
73 def stop_download_session(self):
74 self._cleanup_url_tasks()
75
76 if self._progress_ctx is not None:
77 self._progress_ctx.__exit__(None, None, None)
78
79 self._progress_ctx = None
80 self._progress = None
81 self._overall_task_id = None
82 self._single_url_item_mode = False
83
84 def start_url(self, index: int, total: int, url: str):
85 self._url_index = index
86 self._url_total = total
87 self._url_step_completed = 0
88 self._item_total = 0
89 self._item_completed = 0
90 self._item_stats = {"success": 0, "failed": 0, "skipped": 0}
91
92 self._cleanup_url_tasks()
93 if not self._progress:
94 return
95
96 self._url_task_id = self._progress.add_task(
97 self._format_url_description("待开始"),
98 total=self._URL_STEP_TOTAL,
99 completed=0,
100 detail=self._shorten(url, max_len=72),
101 )
102
103 def complete_url(self, result=None):
104 if self._progress and self._url_task_id is not None:
105 detail = ""
106 if result:
107 detail = f"成功 {result.success} / 失败 {result.failed} / 跳过 {result.skipped}"
108 self._progress.update(
109 self._url_task_id,
110 completed=self._URL_STEP_TOTAL,
111 description=self._format_url_description("完成"),
112 detail=detail,
113 )
114
115 if self._progress and self._overall_task_id is not None:
116 if self._single_url_item_mode:
117 self._progress.update(self._overall_task_id, completed=self._item_total or 1)
118 else:
119 self._progress.advance(self._overall_task_id, 1)
120
121 def fail_url(self, reason: str):
122 if self._progress and self._url_task_id is not None:
123 self._progress.update(
124 self._url_task_id,
125 completed=self._URL_STEP_TOTAL,
126 description=self._format_url_description("失败"),
127 detail=reason,
128 )
129
130 if self._progress and self._overall_task_id is not None:
131 if self._single_url_item_mode:
132 self._progress.update(self._overall_task_id, completed=self._item_total or 1)
133 else:
134 self._progress.advance(self._overall_task_id, 1)
135
136 def advance_step(self, step: str, detail: str = ""):
137 if not self._progress or self._url_task_id is None:
138 return
139
140 self._url_step_completed = min(self._url_step_completed + 1, self._URL_STEP_TOTAL)
141 self._progress.update(
142 self._url_task_id,
143 completed=self._url_step_completed,
144 description=self._format_url_description(step),
145 detail=detail,
146 )
147
148 def update_step(self, step: str, detail: str = ""):
149 if not self._progress or self._url_task_id is None:
150 return
151
152 self._progress.update(
153 self._url_task_id,
154 description=self._format_url_description(step),
155 detail=detail,
156 )
157
158 def set_item_total(self, total: int, detail: str = ""):
159 if not self._progress:
160 return
161
162 self._item_total = max(total, 1)
163 self._item_completed = 1 if total == 0 else 0
164 self._item_stats = {"success": 0, "failed": 0, "skipped": 0}
165
166 if self._url_total == 1 and self._overall_task_id is not None:
167 self._single_url_item_mode = True
168 self._progress.update(
169 self._overall_task_id,
170 total=self._item_total,
171 completed=self._item_completed,
172 detail=f"共 {total} 个作品",
173 )
174
175 description = self._format_item_description()
176 item_detail = detail or ("无待下载条目" if total == 0 else "")
177
178 if self._item_task_id is None:
179 self._item_task_id = self._progress.add_task(
180 description,
181 total=self._item_total,
182 completed=self._item_completed,
183 detail=item_detail,
184 )
185 return
186
187 self._progress.update(
188 self._item_task_id,
189 total=self._item_total,
190 completed=self._item_completed,
191 description=description,
192 detail=item_detail,
193 )
194
195 def advance_item(self, status: str, detail: str = ""):
196 if not self._progress:
197 return
198 if self._item_task_id is None:
199 self.set_item_total(1, "初始化条目进度")
200 assert self._item_task_id is not None
201
202 if status in self._item_stats:
203 self._item_stats[status] += 1
204 if self._item_completed < self._item_total:
205 self._item_completed += 1
206
207 status_map = {"success": "成功", "failed": "失败", "skipped": "跳过"}
208 status_text = status_map.get(status, status)
209 item_detail = f"最近: {status_text} {self._shorten(detail, max_len=36)}"
210
211 self._progress.update(
212 self._item_task_id,
213 completed=self._item_completed,
214 description=self._format_item_description(),
215 detail=item_detail,
216 )
217 if self._single_url_item_mode and self._overall_task_id is not None:
218 self._progress.update(
219 self._overall_task_id,
220 completed=self._item_completed,
221 detail=f"共 {self._item_total} 个作品",
222 )
223
224 def show_result(self, result):
225 table = Table(title="Download Summary", show_header=True, header_style="bold magenta")
226 table.add_column("Metric", style="cyan")
227 table.add_column("Count", justify="right", style="green")
228
229 table.add_row("Total", str(result.total))
230 table.add_row("Success", str(result.success))
231 table.add_row("Failed", str(result.failed))
232 table.add_row("Skipped", str(result.skipped))
233
234 if result.total > 0:
235 success_rate = (result.success / result.total) * 100
236 table.add_row("Success Rate", f"{success_rate:.1f}%")
237
238 self._active_console().print(table)
239
240 def print_info(self, message: str):
241 self._active_console().print(f"[blue]ℹ[/blue] {message}")
242
243 def print_success(self, message: str):
244 self._active_console().print(f"[green]✓[/green] {message}")
245
246 def print_warning(self, message: str):
247 self._active_console().print(f"[yellow]⚠[/yellow] {message}")
248
249 def print_error(self, message: str):
250 self._active_console().print(f"[red]✗[/red] {message}")
251
252 def _cleanup_url_tasks(self):
253 if not self._progress:
254 self._url_task_id = None
255 self._item_task_id = None
256 return
257
258 if self._item_task_id is not None:
259 self._progress.remove_task(self._item_task_id)
260 self._item_task_id = None
261 if self._url_task_id is not None:
262 self._progress.remove_task(self._url_task_id)
263 self._url_task_id = None
264
265 def _format_url_description(self, step: str) -> str:
266 return f"URL {self._url_index}/{self._url_total} · {step}"
267
268 def _format_item_description(self) -> str:
269 return (
270 "作品下载 "
271 f"S:{self._item_stats['success']} "
272 f"F:{self._item_stats['failed']} "
273 f"K:{self._item_stats['skipped']}"
274 )
275
276 def _active_console(self) -> Console:
277 if self._progress:
278 return self._progress.console
279 return self.console
280
281 @staticmethod
282 def _shorten(text: str, max_len: int = 60) -> str:
283 normalized = (text or "").strip()
284 if len(normalized) <= max_len:
285 return normalized
286 return f"{normalized[: max_len - 3]}..."
287
287 lines PYTHON