mkv2cast 1.2.7.post4__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,136 @@
1
+ """
2
+ Legacy text-based progress UI for mkv2cast.
3
+
4
+ Used when rich is not available or in non-interactive terminals.
5
+ """
6
+
7
+ import shutil
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from typing import Optional
11
+
12
+
13
+ def term_width() -> int:
14
+ """Get terminal width."""
15
+ try:
16
+ return shutil.get_terminal_size((120, 20)).columns
17
+ except Exception:
18
+ return 120
19
+
20
+
21
+ def mkbar(pct: int, width: int = 26) -> str:
22
+ """Create a simple progress bar string."""
23
+ pct = max(0, min(100, pct))
24
+ filled = int(pct * width / 100)
25
+ empty = width - filled
26
+ return "#" * filled + "-" * empty
27
+
28
+
29
+ def shorten(s: str, maxlen: int) -> str:
30
+ """Shorten a string with ellipsis if too long."""
31
+ if maxlen <= 0:
32
+ return ""
33
+ if len(s) <= maxlen:
34
+ return s
35
+ if maxlen <= 3:
36
+ return s[:maxlen]
37
+ return s[: maxlen - 3] + "..."
38
+
39
+
40
+ def fmt_hms(seconds: float) -> str:
41
+ """Format seconds as HH:MM:SS."""
42
+ if seconds < 0:
43
+ seconds = 0
44
+ s = int(round(seconds))
45
+ h = s // 3600
46
+ m = (s % 3600) // 60
47
+ r = s % 60
48
+ return f"{h:02d}:{m:02d}:{r:02d}"
49
+
50
+
51
+ @dataclass
52
+ class UIState:
53
+ """State for legacy UI rendering."""
54
+
55
+ stage: str
56
+ pct: int
57
+ cur: int
58
+ total: int
59
+ base: str
60
+ eta: str
61
+ speed: str
62
+ elapsed: str = ""
63
+
64
+
65
+ class LegacyProgressUI:
66
+ """Fallback progress UI when rich is not available."""
67
+
68
+ def __init__(self, progress: bool = True, bar_width: int = 26):
69
+ self.enabled = progress and sys.stdout.isatty()
70
+ self.bar_width = bar_width
71
+ self._last_render: Optional[str] = None
72
+
73
+ # Stats tracking
74
+ self.ok = 0
75
+ self.skipped = 0
76
+ self.failed = 0
77
+ self.processed = 0
78
+
79
+ def render(self, st: UIState) -> None:
80
+ """Render progress line to terminal."""
81
+ if not self.enabled:
82
+ return
83
+
84
+ w = term_width()
85
+
86
+ bar = mkbar(st.pct, self.bar_width)
87
+ elapsed_str = f" {st.elapsed}" if st.elapsed else ""
88
+ left = f"[{bar}] {st.pct:3d}% | {st.stage} | ({st.cur}/{st.total}){elapsed_str} "
89
+ right = f"| {st.eta} {st.speed}".rstrip()
90
+
91
+ avail = max(10, w - len(left) - len(right) - 1)
92
+ name = shorten(st.base, avail)
93
+
94
+ line = f"{left}{name} {right}"
95
+ pad = ""
96
+ if self._last_render is not None and len(self._last_render) > len(line):
97
+ pad = " " * (len(self._last_render) - len(line))
98
+ if line != self._last_render:
99
+ sys.stdout.write("\r" + line + pad)
100
+ sys.stdout.flush()
101
+ self._last_render = line
102
+
103
+ def endline(self) -> None:
104
+ """Clear the current progress line."""
105
+ if not self.enabled:
106
+ return
107
+ sys.stdout.write("\r" + " " * (len(self._last_render) if self._last_render else 80) + "\r")
108
+ sys.stdout.flush()
109
+ self._last_render = None
110
+
111
+ def log(self, msg: str) -> None:
112
+ """Print a log message, clearing progress line first."""
113
+ if self.enabled and self._last_render:
114
+ sys.stdout.write("\r" + " " * len(self._last_render) + "\r")
115
+ sys.stdout.flush()
116
+ print(msg, flush=True)
117
+ self._last_render = None
118
+
119
+ def inc_ok(self) -> None:
120
+ """Increment success counter."""
121
+ self.ok += 1
122
+ self.processed += 1
123
+
124
+ def inc_skipped(self) -> None:
125
+ """Increment skipped counter."""
126
+ self.skipped += 1
127
+ self.processed += 1
128
+
129
+ def inc_failed(self) -> None:
130
+ """Increment failed counter."""
131
+ self.failed += 1
132
+ self.processed += 1
133
+
134
+ def get_stats(self):
135
+ """Get current stats."""
136
+ return (self.ok, self.skipped, self.failed, self.processed)
mkv2cast/ui/rich_ui.py ADDED
@@ -0,0 +1,462 @@
1
+ """
2
+ Rich-based progress UI for mkv2cast.
3
+
4
+ Provides beautiful multi-worker progress display with colors and animations.
5
+ Requires the 'rich' package to be installed.
6
+
7
+ Respects:
8
+ - NO_COLOR environment variable
9
+ - MKV2CAST_SCRIPT_MODE environment variable
10
+ - sys.stdout.isatty() for automatic detection
11
+ """
12
+
13
+ import os
14
+ import re
15
+ import sys
16
+ import threading
17
+ import time
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Dict, List, Optional, Tuple
21
+
22
+ from rich.console import Console, Group
23
+ from rich.live import Live
24
+ from rich.text import Text
25
+
26
+ from mkv2cast.i18n import _
27
+ from mkv2cast.ui.legacy_ui import fmt_hms, shorten
28
+
29
+
30
+ def _should_use_color() -> bool:
31
+ """Check if color output should be used."""
32
+ # Check NO_COLOR environment variable (https://no-color.org/)
33
+ if os.getenv("NO_COLOR"):
34
+ return False
35
+ # Check script mode
36
+ if os.getenv("MKV2CAST_SCRIPT_MODE"):
37
+ return False
38
+ # Check if stdout is a TTY
39
+ try:
40
+ if not sys.stdout.isatty():
41
+ return False
42
+ except Exception:
43
+ return False
44
+ return True
45
+
46
+
47
+ @dataclass
48
+ class JobStatus:
49
+ """Tracks the status of a single file."""
50
+
51
+ inp: Path
52
+ stage: str = "WAITING" # WAITING, INTEGRITY, WAITING_ENCODE, ENCODE, DONE, FAILED, SKIPPED
53
+ pct: int = 0
54
+ speed: str = ""
55
+ dur_ms: int = 0 # Total duration for ETA calc
56
+ out_ms: int = 0 # Current position in ms
57
+
58
+ # Timing
59
+ start_time: float = 0
60
+ integrity_start: float = 0
61
+ integrity_elapsed: float = 0
62
+ encode_start: float = 0
63
+ encode_elapsed: float = 0
64
+ total_elapsed: float = 0
65
+
66
+ # Result
67
+ result_msg: str = ""
68
+ output_file: str = ""
69
+ worker_id: int = -1
70
+
71
+ # History tracking
72
+ history_id: int = 0
73
+
74
+
75
+ class RichProgressUI:
76
+ """Rich-based progress UI showing all files with their status."""
77
+
78
+ def __init__(self, total_files: int, encode_workers: int, integrity_workers: int):
79
+ # Respect NO_COLOR and TTY detection
80
+ use_color = _should_use_color()
81
+ self.console = Console(
82
+ force_terminal=use_color if use_color else None,
83
+ no_color=not use_color,
84
+ )
85
+ self.total_files = total_files
86
+ self.encode_workers = encode_workers
87
+ self.integrity_workers = integrity_workers
88
+ self.lock = threading.Lock()
89
+
90
+ # Stats
91
+ self.ok = 0
92
+ self.skipped = 0
93
+ self.failed = 0
94
+ self.processed = 0
95
+
96
+ # All jobs status
97
+ self.jobs: Dict[str, JobStatus] = {} # keyed by file path string
98
+
99
+ # Completed jobs log (for display)
100
+ self.completed_log: List[str] = []
101
+ self.max_completed_lines = 10
102
+
103
+ # Live display
104
+ self.live: Optional[Live] = None
105
+ self._stop_event = threading.Event()
106
+ self._refresh_thread: Optional[threading.Thread] = None
107
+
108
+ def _make_progress_bar(self, pct: int, width: int = 25) -> Text:
109
+ """Create a colored progress bar."""
110
+ pct = max(0, min(100, pct))
111
+ filled = int(pct * width / 100)
112
+ empty = width - filled
113
+
114
+ bar = Text()
115
+ bar.append("│", style="dim")
116
+ bar.append("█" * filled, style="green")
117
+ bar.append("░" * empty, style="dim")
118
+ bar.append("│", style="dim")
119
+ return bar
120
+
121
+ def _parse_speed(self, speed_str: str) -> Optional[float]:
122
+ """Parse speed string like '32.5x' to float."""
123
+ if not speed_str:
124
+ return None
125
+ m = re.match(r"^\s*([0-9]+(?:\.[0-9]+)?)x\s*$", speed_str)
126
+ if m:
127
+ try:
128
+ return float(m.group(1))
129
+ except Exception:
130
+ return None
131
+ return None
132
+
133
+ def _format_eta(self, job: JobStatus, elapsed: float) -> str:
134
+ """Calculate ETA for a job based on speed or elapsed time."""
135
+ if job.pct <= 0 or elapsed <= 0:
136
+ return "--:--:--"
137
+
138
+ if job.pct >= 100:
139
+ return "finish..."
140
+
141
+ # Try speed-based ETA first
142
+ speed_x = self._parse_speed(job.speed)
143
+ if speed_x and speed_x > 0 and job.dur_ms > 0 and job.out_ms > 0:
144
+ remaining_ms = job.dur_ms - job.out_ms
145
+ if remaining_ms > 0:
146
+ eta_s = (remaining_ms / 1000.0) / speed_x
147
+ return fmt_hms(eta_s)
148
+
149
+ # Time-based ETA
150
+ if job.pct >= 99:
151
+ avg_time_per_pct = elapsed / job.pct
152
+ return fmt_hms(avg_time_per_pct)
153
+
154
+ rate = job.pct / elapsed
155
+ remaining_pct = 100 - job.pct
156
+ eta_s = remaining_pct / rate if rate > 0 else 0
157
+ return fmt_hms(eta_s) if eta_s > 0 else "--:--:--"
158
+
159
+ def _render(self) -> Group:
160
+ """Render the current state as a rich Group."""
161
+ with self.lock:
162
+ parts = []
163
+
164
+ # Categorize all jobs
165
+ done_jobs = [j for j in self.jobs.values() if j.stage == "DONE"]
166
+ skip_jobs = [j for j in self.jobs.values() if j.stage == "SKIPPED"]
167
+ fail_jobs = [j for j in self.jobs.values() if j.stage == "FAILED"]
168
+ active_jobs = [j for j in self.jobs.values() if j.stage in ("INTEGRITY", "ENCODE")]
169
+ waiting_encode = [j for j in self.jobs.values() if j.stage == "WAITING_ENCODE"]
170
+ waiting_check = [j for j in self.jobs.values() if j.stage == "WAITING"]
171
+
172
+ # 1. SKIP files (grey)
173
+ for job in skip_jobs[-8:]:
174
+ filename = shorten(job.inp.name, 50)
175
+ reason = job.result_msg or ""
176
+ line = Text()
177
+ line.append(f"⊘ {_('SKIP')} ", style="dim")
178
+ line.append(filename, style="dim")
179
+ if reason:
180
+ line.append(f" ({reason})", style="dim italic")
181
+ parts.append(line)
182
+
183
+ # 2. DONE files (green)
184
+ for job in done_jobs[-5:]:
185
+ filename = shorten(job.inp.name, 40)
186
+ line = Text()
187
+ line.append(f"✓ {_('DONE')} ", style="bold green")
188
+ line.append(filename, style="green")
189
+ timing = []
190
+ if job.integrity_elapsed > 0:
191
+ timing.append(f"{_('int')}:{fmt_hms(job.integrity_elapsed)}")
192
+ if job.encode_elapsed > 0:
193
+ timing.append(f"{_('enc')}:{fmt_hms(job.encode_elapsed)}")
194
+ if job.total_elapsed > 0:
195
+ timing.append(f"{_('tot')}:{fmt_hms(job.total_elapsed)}")
196
+ if timing:
197
+ line.append(f" ({' '.join(timing)})", style="dim")
198
+ parts.append(line)
199
+
200
+ # 3. FAIL files (red)
201
+ for job in fail_jobs[-3:]:
202
+ filename = shorten(job.inp.name, 50)
203
+ reason = job.result_msg or _("error")
204
+ line = Text()
205
+ line.append(f"✗ {_('FAIL')} ", style="bold red")
206
+ line.append(filename, style="red")
207
+ line.append(f" ({reason})", style="red dim")
208
+ parts.append(line)
209
+
210
+ # Separator
211
+ if parts and (active_jobs or waiting_encode):
212
+ parts.append(Text("─" * 60, style="dim"))
213
+
214
+ # 4. WAITING_ENCODE files
215
+ for job in waiting_encode[:3]:
216
+ filename = shorten(job.inp.name, 50)
217
+ line = Text()
218
+ line.append(f"⏳ {_('QUEUE')} ", style="cyan")
219
+ line.append(filename, style="cyan dim")
220
+ if job.integrity_elapsed > 0:
221
+ line.append(f" ({_('check')}:{fmt_hms(job.integrity_elapsed)})", style="dim")
222
+ parts.append(line)
223
+ if len(waiting_encode) > 3:
224
+ parts.append(Text(f" ... +{len(waiting_encode) - 3} {_('in queue')}", style="cyan dim"))
225
+
226
+ # 5. ACTIVE jobs with progress bars
227
+ for job in sorted(active_jobs, key=lambda x: (0 if x.stage == "ENCODE" else 1, x.worker_id)):
228
+ filename = shorten(job.inp.name, 40)
229
+
230
+ if job.stage == "INTEGRITY":
231
+ elapsed = time.time() - job.integrity_start if job.integrity_start > 0 else 0
232
+ stage_icon = "🔍"
233
+ stage_text = _("CHECK")
234
+ else:
235
+ elapsed = time.time() - job.encode_start if job.encode_start > 0 else 0
236
+ stage_icon = "⚡"
237
+ stage_text = _("ENCODE")
238
+
239
+ eta = self._format_eta(job, elapsed)
240
+ speed_str = f" {job.speed}" if job.speed else ""
241
+
242
+ line = Text()
243
+ line.append(f"{stage_icon} ", style="yellow")
244
+ line.append(f"{stage_text:7}", style="bold yellow")
245
+ line.append(" ")
246
+ line.append_text(self._make_progress_bar(job.pct))
247
+ line.append(f" {job.pct:3d}%", style="bold yellow")
248
+ line.append(f" {fmt_hms(elapsed)}", style="blue")
249
+ line.append(f" ETA:{eta}", style="dim")
250
+ line.append(speed_str, style="magenta")
251
+ line.append(f" {filename}", style="bold yellow")
252
+
253
+ parts.append(line)
254
+
255
+ # 6. Waiting count
256
+ if waiting_check:
257
+ parts.append(Text(f"⏳ {_('Waiting for check')}: {len(waiting_check)} {_('file(s)')}", style="dim"))
258
+
259
+ if not parts:
260
+ parts.append(Text(_("Initializing..."), style="dim"))
261
+
262
+ return Group(*parts)
263
+
264
+ def _refresh_loop(self):
265
+ """Background thread that refreshes the display."""
266
+ while not self._stop_event.is_set():
267
+ try:
268
+ if self.live:
269
+ self.live.update(self._render())
270
+ except Exception:
271
+ pass
272
+ time.sleep(0.1)
273
+
274
+ def start(self):
275
+ """Start the live display."""
276
+ self.live = Live(self._render(), console=self.console, refresh_per_second=10, transient=False)
277
+ self.live.start()
278
+
279
+ self._refresh_thread = threading.Thread(target=self._refresh_loop, daemon=True)
280
+ self._refresh_thread.start()
281
+
282
+ def stop(self):
283
+ """Stop the live display."""
284
+ self._stop_event.set()
285
+ if self._refresh_thread:
286
+ self._refresh_thread.join(timeout=1.0)
287
+ if self.live:
288
+ try:
289
+ self.live.update(self._render())
290
+ self.live.stop()
291
+ except Exception:
292
+ pass
293
+ finally:
294
+ self.live = None
295
+
296
+ def register_job(self, inp: Path, backend: str = ""):
297
+ """Register a new job in waiting state."""
298
+ with self.lock:
299
+ key = str(inp)
300
+ if key not in self.jobs:
301
+ job = JobStatus(inp=inp, stage="WAITING", start_time=time.time())
302
+ self.jobs[key] = job
303
+
304
+ def start_integrity(self, worker_id: int, _filename: str, inp: Path):
305
+ """Start integrity check for a file."""
306
+ with self.lock:
307
+ key = str(inp)
308
+ if key in self.jobs:
309
+ self.jobs[key].stage = "INTEGRITY"
310
+ self.jobs[key].integrity_start = time.time()
311
+ self.jobs[key].worker_id = worker_id
312
+ self.jobs[key].pct = 0
313
+ self.jobs[key].speed = ""
314
+
315
+ def update_integrity(
316
+ self, worker_id: int, _stage: str, pct: int, _filename: str, speed: str = "", inp: Optional[Path] = None
317
+ ):
318
+ """Update integrity progress."""
319
+ with self.lock:
320
+ if inp:
321
+ key = str(inp)
322
+ else:
323
+ key = None
324
+ for k, j in self.jobs.items():
325
+ if j.worker_id == worker_id and j.stage == "INTEGRITY":
326
+ key = k
327
+ break
328
+
329
+ if key and key in self.jobs:
330
+ self.jobs[key].pct = pct
331
+ self.jobs[key].speed = speed
332
+
333
+ def stop_integrity(self, worker_id: int, inp: Optional[Path] = None):
334
+ """Mark integrity check as complete."""
335
+ with self.lock:
336
+ if inp:
337
+ key = str(inp)
338
+ if key in self.jobs:
339
+ job = self.jobs[key]
340
+ job.integrity_elapsed = time.time() - job.integrity_start
341
+ job.stage = "WAITING_ENCODE"
342
+ job.pct = 0
343
+ else:
344
+ for j in self.jobs.values():
345
+ if j.worker_id == worker_id and j.stage == "INTEGRITY":
346
+ j.integrity_elapsed = time.time() - j.integrity_start
347
+ j.stage = "WAITING_ENCODE"
348
+ j.pct = 0
349
+ break
350
+
351
+ def start_encode(self, worker_id: int, _filename: str, inp: Path, output_file: str = ""):
352
+ """Start encoding for a file."""
353
+ with self.lock:
354
+ key = str(inp)
355
+ if key in self.jobs:
356
+ self.jobs[key].stage = "ENCODE"
357
+ self.jobs[key].encode_start = time.time()
358
+ self.jobs[key].worker_id = worker_id
359
+ self.jobs[key].pct = 0
360
+ self.jobs[key].speed = ""
361
+ self.jobs[key].output_file = output_file
362
+
363
+ def update_encode(
364
+ self,
365
+ worker_id: int,
366
+ _stage: str,
367
+ pct: int,
368
+ _filename: str,
369
+ speed: str = "",
370
+ inp: Optional[Path] = None,
371
+ out_ms: int = 0,
372
+ dur_ms: int = 0,
373
+ ):
374
+ """Update encode progress."""
375
+ with self.lock:
376
+ if inp:
377
+ key = str(inp)
378
+ else:
379
+ key = None
380
+ for k, j in self.jobs.items():
381
+ if j.worker_id == worker_id and j.stage == "ENCODE":
382
+ key = k
383
+ break
384
+
385
+ if key and key in self.jobs:
386
+ self.jobs[key].pct = pct
387
+ self.jobs[key].speed = speed
388
+ if out_ms > 0:
389
+ self.jobs[key].out_ms = out_ms
390
+ if dur_ms > 0:
391
+ self.jobs[key].dur_ms = dur_ms
392
+
393
+ def stop_encode(self, worker_id: int, inp: Optional[Path] = None):
394
+ """Mark encode as complete."""
395
+ with self.lock:
396
+ if inp:
397
+ key = str(inp)
398
+ if key in self.jobs:
399
+ self.jobs[key].encode_elapsed = time.time() - self.jobs[key].encode_start
400
+ else:
401
+ for j in self.jobs.values():
402
+ if j.worker_id == worker_id and j.stage == "ENCODE":
403
+ j.encode_elapsed = time.time() - j.encode_start
404
+ break
405
+
406
+ def mark_done(self, inp: Path, _msg: str = "", final_path: Optional[Path] = None, output_size: int = 0):
407
+ """Mark a job as successfully completed."""
408
+ with self.lock:
409
+ key = str(inp)
410
+ if key in self.jobs:
411
+ job = self.jobs[key]
412
+ job.stage = "DONE"
413
+ job.total_elapsed = time.time() - job.start_time
414
+ if job.encode_start > 0 and job.encode_elapsed == 0:
415
+ job.encode_elapsed = time.time() - job.encode_start
416
+ self.ok += 1
417
+ self.processed += 1
418
+
419
+ def mark_failed(self, inp: Path, reason: str = ""):
420
+ """Mark a job as failed."""
421
+ with self.lock:
422
+ key = str(inp)
423
+ if key in self.jobs:
424
+ job = self.jobs[key]
425
+ job.stage = "FAILED"
426
+ job.result_msg = reason
427
+ job.total_elapsed = time.time() - job.start_time
428
+ if job.encode_start > 0 and job.encode_elapsed == 0:
429
+ job.encode_elapsed = time.time() - job.encode_start
430
+ if job.integrity_start > 0 and job.integrity_elapsed == 0:
431
+ job.integrity_elapsed = time.time() - job.integrity_start
432
+
433
+ self.failed += 1
434
+ self.processed += 1
435
+
436
+ def mark_skipped(self, inp: Path, reason: str = ""):
437
+ """Mark a job as skipped."""
438
+ with self.lock:
439
+ key = str(inp)
440
+ if key in self.jobs:
441
+ job = self.jobs[key]
442
+ job.stage = "SKIPPED"
443
+ job.result_msg = reason
444
+ job.total_elapsed = time.time() - job.start_time
445
+ if job.integrity_start > 0 and job.integrity_elapsed == 0:
446
+ job.integrity_elapsed = time.time() - job.integrity_start
447
+ else:
448
+ job = JobStatus(inp=inp, stage="SKIPPED", result_msg=reason)
449
+ self.jobs[key] = job
450
+
451
+ self.skipped += 1
452
+ self.processed += 1
453
+
454
+ def log(self, msg: str):
455
+ """Add a message to the log."""
456
+ with self.lock:
457
+ self.completed_log.append(msg)
458
+
459
+ def get_stats(self) -> Tuple[int, int, int, int]:
460
+ """Get current statistics."""
461
+ with self.lock:
462
+ return (self.ok, self.skipped, self.failed, self.processed)