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.
- mkv2cast/__init__.py +77 -0
- mkv2cast/__main__.py +14 -0
- mkv2cast/cli.py +1886 -0
- mkv2cast/config.py +638 -0
- mkv2cast/converter.py +1454 -0
- mkv2cast/history.py +389 -0
- mkv2cast/i18n.py +179 -0
- mkv2cast/integrity.py +176 -0
- mkv2cast/json_progress.py +311 -0
- mkv2cast/locales/de/LC_MESSAGES/mkv2cast.mo +0 -0
- mkv2cast/locales/de/LC_MESSAGES/mkv2cast.po +382 -0
- mkv2cast/locales/en/LC_MESSAGES/mkv2cast.mo +0 -0
- mkv2cast/locales/en/LC_MESSAGES/mkv2cast.po +382 -0
- mkv2cast/locales/es/LC_MESSAGES/mkv2cast.mo +0 -0
- mkv2cast/locales/es/LC_MESSAGES/mkv2cast.po +382 -0
- mkv2cast/locales/fr/LC_MESSAGES/mkv2cast.mo +0 -0
- mkv2cast/locales/fr/LC_MESSAGES/mkv2cast.po +430 -0
- mkv2cast/locales/it/LC_MESSAGES/mkv2cast.mo +0 -0
- mkv2cast/locales/it/LC_MESSAGES/mkv2cast.po +382 -0
- mkv2cast/notifications.py +196 -0
- mkv2cast/pipeline.py +641 -0
- mkv2cast/ui/__init__.py +26 -0
- mkv2cast/ui/legacy_ui.py +136 -0
- mkv2cast/ui/rich_ui.py +462 -0
- mkv2cast/ui/simple_rich.py +243 -0
- mkv2cast/watcher.py +293 -0
- mkv2cast-1.2.7.post4.dist-info/METADATA +1411 -0
- mkv2cast-1.2.7.post4.dist-info/RECORD +31 -0
- mkv2cast-1.2.7.post4.dist-info/WHEEL +4 -0
- mkv2cast-1.2.7.post4.dist-info/entry_points.txt +2 -0
- mkv2cast-1.2.7.post4.dist-info/licenses/LICENSE +50 -0
mkv2cast/ui/legacy_ui.py
ADDED
|
@@ -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)
|