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,243 @@
1
+ """
2
+ Simple Rich-based progress UI for mkv2cast (sequential mode).
3
+
4
+ Provides a beautiful progress bar for single-file encoding.
5
+
6
+ Respects:
7
+ - NO_COLOR environment variable
8
+ - MKV2CAST_SCRIPT_MODE environment variable
9
+ - sys.stdout.isatty() for automatic detection
10
+ """
11
+
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import List, Optional, Tuple
18
+
19
+ from rich.console import Console
20
+ from rich.live import Live
21
+ from rich.progress import (
22
+ BarColumn,
23
+ Progress,
24
+ SpinnerColumn,
25
+ TaskID,
26
+ TextColumn,
27
+ TimeElapsedColumn,
28
+ TimeRemainingColumn,
29
+ )
30
+ from rich.table import Table
31
+
32
+ from mkv2cast.i18n import _
33
+ from mkv2cast.ui.legacy_ui import fmt_hms
34
+
35
+
36
+ def _should_use_color() -> bool:
37
+ """Check if color output should be used."""
38
+ # Check NO_COLOR environment variable (https://no-color.org/)
39
+ if os.getenv("NO_COLOR"):
40
+ return False
41
+ # Check script mode
42
+ if os.getenv("MKV2CAST_SCRIPT_MODE"):
43
+ return False
44
+ # Check if stdout is a TTY
45
+ try:
46
+ if not sys.stdout.isatty():
47
+ return False
48
+ except Exception:
49
+ return False
50
+ return True
51
+
52
+
53
+ class SimpleRichUI:
54
+ """Simple Rich-based UI for sequential file processing."""
55
+
56
+ def __init__(self, progress_enabled: bool = True):
57
+ # Respect NO_COLOR and TTY detection
58
+ use_color = _should_use_color()
59
+ self.console = Console(
60
+ force_terminal=use_color if use_color else None,
61
+ no_color=not use_color,
62
+ )
63
+ self.enabled = progress_enabled and use_color
64
+
65
+ # Stats
66
+ self.ok = 0
67
+ self.skipped = 0
68
+ self.failed = 0
69
+ self.processed = 0
70
+
71
+ # Current progress
72
+ self.progress: Optional[Progress] = None
73
+ self.current_task: Optional[TaskID] = None
74
+ self.live: Optional[Live] = None
75
+
76
+ def log(self, msg: str, style: str = "") -> None:
77
+ """Print a log message."""
78
+ if style:
79
+ self.console.print(msg, style=style)
80
+ else:
81
+ self.console.print(msg)
82
+
83
+ def log_file_start(self, inp: Path, output: Path) -> None:
84
+ """Log start of file processing."""
85
+ self.console.print()
86
+ self.console.print(f"[bold blue]▶[/bold blue] [cyan]{inp.name}[/cyan]")
87
+ self.console.print(f" [dim]→ {output.name}[/dim]")
88
+
89
+ def log_skip(self, reason: str) -> None:
90
+ """Log a skip."""
91
+ self.console.print(f" [yellow]⊘ {_('SKIP')}[/yellow]: {reason}")
92
+ self.skipped += 1
93
+ self.processed += 1
94
+
95
+ def log_error(self, error: str) -> None:
96
+ """Log an error."""
97
+ self.console.print(f" [red]✗ {_('FAILED')}[/red]: {error}")
98
+ self.failed += 1
99
+ self.processed += 1
100
+
101
+ def log_success(self, elapsed: float, output_size: int = 0) -> None:
102
+ """Log success."""
103
+ size_str = ""
104
+ if output_size > 0:
105
+ size_mb = output_size / (1024 * 1024)
106
+ size_str = f" ({size_mb:.1f} MB)"
107
+ self.console.print(f" [green]✓ {_('OK')}[/green] {_('in')} {fmt_hms(elapsed)}{size_str}")
108
+ self.ok += 1
109
+ self.processed += 1
110
+
111
+ def log_compatible(self) -> None:
112
+ """Log file is already compatible."""
113
+ self.console.print(f" [green]✓[/green] [dim]{_('Already compatible')}[/dim]")
114
+ self.skipped += 1
115
+ self.processed += 1
116
+
117
+ def run_ffmpeg_with_progress(
118
+ self, cmd: List[str], stage: str, dur_ms: int = 0, file_idx: int = 1, total_files: int = 1
119
+ ) -> Tuple[int, str]:
120
+ """
121
+ Run ffmpeg command while showing progress.
122
+
123
+ Returns (return_code, error_message).
124
+ """
125
+ if not self.enabled:
126
+ # Fallback to simple execution
127
+ result = subprocess.run(cmd, capture_output=True, timeout=86400)
128
+ stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
129
+ return result.returncode, stderr
130
+
131
+ # Create progress bar
132
+ progress = Progress(
133
+ SpinnerColumn(),
134
+ TextColumn("[bold blue]{task.description}[/bold blue]"),
135
+ BarColumn(bar_width=40),
136
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
137
+ TextColumn("•"),
138
+ TextColumn("[cyan]{task.fields[speed]}[/cyan]"),
139
+ TextColumn("•"),
140
+ TimeElapsedColumn(),
141
+ TextColumn("→"),
142
+ TimeRemainingColumn(),
143
+ console=self.console,
144
+ transient=True,
145
+ )
146
+
147
+ task_desc = f"[{file_idx}/{total_files}] {stage}"
148
+ task_id = progress.add_task(task_desc, total=100, speed="0.0x")
149
+
150
+ # Start ffmpeg process
151
+ process = subprocess.Popen(
152
+ cmd,
153
+ stdout=subprocess.PIPE,
154
+ stderr=subprocess.PIPE,
155
+ text=False,
156
+ )
157
+
158
+ # Read stderr for progress updates
159
+ stderr_buffer = []
160
+ last_pct = 0
161
+
162
+ with progress:
163
+ while True:
164
+ # Read one line from stderr
165
+ if process.stderr is None:
166
+ break
167
+ line = process.stderr.readline()
168
+ if not line:
169
+ break
170
+
171
+ line_str = line.decode("utf-8", errors="replace")
172
+ stderr_buffer.append(line_str)
173
+
174
+ # Parse ffmpeg progress
175
+ # Example: frame= 123 fps=45 q=28.0 size= 1234kB time=00:00:05.12 bitrate=1234.5kbits/s speed=1.23x
176
+ pct, speed = self._parse_ffmpeg_progress(line_str, dur_ms)
177
+
178
+ if pct > last_pct:
179
+ last_pct = pct
180
+ progress.update(task_id, completed=pct)
181
+
182
+ if speed:
183
+ progress.update(task_id, speed=speed)
184
+
185
+ # Wait for process to complete
186
+ process.wait()
187
+
188
+ stderr_text = "".join(stderr_buffer)
189
+ return process.returncode, stderr_text
190
+
191
+ def _parse_ffmpeg_progress(self, line: str, dur_ms: int) -> Tuple[int, str]:
192
+ """Parse ffmpeg progress line. Returns (percentage, speed)."""
193
+ pct = 0
194
+ speed = ""
195
+
196
+ # Parse time
197
+ m = re.search(r"time=\s*(\d+):(\d+):(\d+)\.(\d+)", line)
198
+ if m and dur_ms > 0:
199
+ h, mi, s, cs = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
200
+ current_ms = (h * 3600 + mi * 60 + s) * 1000 + cs * 10
201
+ pct = min(100, int(current_ms * 100 / dur_ms))
202
+
203
+ # Parse speed
204
+ m = re.search(r"speed=\s*([0-9.]+)x", line)
205
+ if m:
206
+ speed = f"{float(m.group(1)):.1f}x"
207
+
208
+ return pct, speed
209
+
210
+ def print_summary(self, total_time: float) -> None:
211
+ """Print final summary."""
212
+ self.console.print()
213
+
214
+ # Create summary table
215
+ table = Table(title=_("Summary"), box=None, show_header=False)
216
+ table.add_column("Metric", style="bold")
217
+ table.add_column("Value", justify="right")
218
+
219
+ table.add_row(f"✓ {_('Converted')}", f"[green]{self.ok}[/green]")
220
+ table.add_row(f"⊘ {_('Skipped')}", f"[yellow]{self.skipped}[/yellow]")
221
+ table.add_row(f"✗ {_('Failed')}", f"[red]{self.failed}[/red]")
222
+ table.add_row(f"⏱ {_('Total time')}", fmt_hms(total_time))
223
+
224
+ self.console.print(table)
225
+
226
+ def inc_ok(self) -> None:
227
+ """Increment success counter."""
228
+ self.ok += 1
229
+ self.processed += 1
230
+
231
+ def inc_skipped(self) -> None:
232
+ """Increment skipped counter."""
233
+ self.skipped += 1
234
+ self.processed += 1
235
+
236
+ def inc_failed(self) -> None:
237
+ """Increment failed counter."""
238
+ self.failed += 1
239
+ self.processed += 1
240
+
241
+ def get_stats(self):
242
+ """Get current stats."""
243
+ return (self.ok, self.skipped, self.failed, self.processed)
mkv2cast/watcher.py ADDED
@@ -0,0 +1,293 @@
1
+ """
2
+ Watch mode for mkv2cast.
3
+
4
+ Monitors directories for new MKV files and automatically converts them.
5
+ Uses watchdog library if available, falls back to polling otherwise.
6
+ """
7
+
8
+ import os
9
+ import time
10
+ from pathlib import Path
11
+ from threading import Event, Thread
12
+ from typing import TYPE_CHECKING, Any, Callable, Optional, Set
13
+
14
+ from mkv2cast.config import Config
15
+ from mkv2cast.integrity import check_file_stable
16
+
17
+ # Try to import watchdog for efficient file system monitoring
18
+ if TYPE_CHECKING:
19
+ from watchdog.events import FileCreatedEvent, FileMovedEvent, FileSystemEventHandler
20
+ from watchdog.observers import Observer
21
+
22
+ try:
23
+ from watchdog.events import FileCreatedEvent, FileMovedEvent, FileSystemEventHandler
24
+ from watchdog.observers import Observer
25
+
26
+ WATCHDOG_AVAILABLE = True
27
+ except ImportError:
28
+ WATCHDOG_AVAILABLE = False
29
+ # Type stubs for mypy
30
+ Observer = None # type: ignore
31
+ FileSystemEventHandler = None # type: ignore
32
+ FileCreatedEvent = None # type: ignore
33
+ FileMovedEvent = None # type: ignore
34
+
35
+
36
+ class MKVFileHandler:
37
+ """Handler for new MKV files."""
38
+
39
+ def __init__(
40
+ self,
41
+ convert_callback: Callable[[Path], None],
42
+ cfg: Config,
43
+ stable_wait: int = 5,
44
+ ):
45
+ """
46
+ Initialize the handler.
47
+
48
+ Args:
49
+ convert_callback: Function to call when a new MKV file is detected.
50
+ cfg: Configuration instance.
51
+ stable_wait: Seconds to wait for file to stabilize.
52
+ """
53
+ self.convert_callback = convert_callback
54
+ self.cfg = cfg
55
+ self.stable_wait = stable_wait
56
+ self.processing: Set[Path] = set()
57
+ self._lock = __import__("threading").Lock()
58
+
59
+ def handle_file(self, filepath: Path) -> None:
60
+ """Handle a new or moved file."""
61
+ # Only process MKV files
62
+ if not filepath.suffix.lower() == ".mkv":
63
+ return
64
+
65
+ # Skip our output files
66
+ name = filepath.name
67
+ if ".tmp." in name or self.cfg.suffix in name:
68
+ return
69
+ if ".h264." in name or ".aac." in name or ".remux." in name:
70
+ return
71
+
72
+ # Check if already processing
73
+ with self._lock:
74
+ if filepath in self.processing:
75
+ return
76
+ self.processing.add(filepath)
77
+
78
+ try:
79
+ # Wait for file to be stable (not being written)
80
+ if not check_file_stable(filepath, wait_seconds=self.stable_wait):
81
+ return
82
+
83
+ # Convert the file
84
+ self.convert_callback(filepath)
85
+
86
+ finally:
87
+ with self._lock:
88
+ self.processing.discard(filepath)
89
+
90
+
91
+ if WATCHDOG_AVAILABLE:
92
+
93
+ class WatchdogHandler(FileSystemEventHandler):
94
+ """Watchdog event handler for MKV files."""
95
+
96
+ def __init__(self, mkv_handler: MKVFileHandler):
97
+ super().__init__()
98
+ self.mkv_handler = mkv_handler
99
+
100
+ def on_created(self, event: "FileCreatedEvent") -> None: # type: ignore[override]
101
+ if not event.is_directory:
102
+ # Run in thread to not block the observer
103
+ src_path = str(event.src_path) if isinstance(event.src_path, bytes) else event.src_path
104
+ Thread(
105
+ target=self.mkv_handler.handle_file,
106
+ args=(Path(src_path),),
107
+ daemon=True,
108
+ ).start()
109
+
110
+ def on_moved(self, event: "FileMovedEvent") -> None: # type: ignore[override]
111
+ if not event.is_directory:
112
+ # Handle files moved into watched directory
113
+ dest_path = str(event.dest_path) if isinstance(event.dest_path, bytes) else event.dest_path
114
+ Thread(
115
+ target=self.mkv_handler.handle_file,
116
+ args=(Path(dest_path),),
117
+ daemon=True,
118
+ ).start()
119
+
120
+
121
+ class DirectoryWatcher:
122
+ """
123
+ Watch a directory for new MKV files.
124
+
125
+ Uses watchdog if available, otherwise falls back to polling.
126
+ """
127
+
128
+ def __init__(
129
+ self,
130
+ watch_path: Path,
131
+ convert_callback: Callable[[Path], None],
132
+ cfg: Config,
133
+ interval: float = 5.0,
134
+ recursive: bool = True,
135
+ ):
136
+ """
137
+ Initialize the watcher.
138
+
139
+ Args:
140
+ watch_path: Directory to watch.
141
+ convert_callback: Function to call for each new MKV file.
142
+ cfg: Configuration instance.
143
+ interval: Polling interval in seconds (for fallback mode).
144
+ recursive: Watch subdirectories.
145
+ """
146
+ self.watch_path = watch_path
147
+ self.convert_callback = convert_callback
148
+ self.cfg = cfg
149
+ self.interval = interval
150
+ self.recursive = recursive
151
+ self.stop_event = Event()
152
+ self._observer: Optional[Any] = None
153
+ self._poll_thread: Optional[Thread] = None
154
+
155
+ self.mkv_handler = MKVFileHandler(
156
+ convert_callback=convert_callback,
157
+ cfg=cfg,
158
+ stable_wait=cfg.stable_wait,
159
+ )
160
+
161
+ def start(self) -> None:
162
+ """Start watching the directory."""
163
+ if WATCHDOG_AVAILABLE:
164
+ self._start_watchdog()
165
+ else:
166
+ self._start_polling()
167
+
168
+ def _start_watchdog(self) -> None:
169
+ """Start watching using watchdog."""
170
+ if not WATCHDOG_AVAILABLE:
171
+ return
172
+ handler = WatchdogHandler(self.mkv_handler)
173
+ observer = Observer()
174
+ observer.schedule(handler, str(self.watch_path), recursive=self.recursive)
175
+ observer.start()
176
+ self._observer = observer
177
+
178
+ def _start_polling(self) -> None:
179
+ """Start watching using polling (fallback)."""
180
+ self._known_files: Set[Path] = set()
181
+
182
+ # Initial scan
183
+ self._scan_directory()
184
+
185
+ # Start polling thread
186
+ poll_thread = Thread(target=self._polling_loop, daemon=True)
187
+ poll_thread.start()
188
+ self._poll_thread = poll_thread
189
+
190
+ def _scan_directory(self) -> Set[Path]:
191
+ """Scan directory for MKV files."""
192
+ found = set()
193
+ try:
194
+ if self.recursive:
195
+ for root, _dirs, files in os.walk(self.watch_path):
196
+ for f in files:
197
+ if f.lower().endswith(".mkv"):
198
+ found.add(Path(root) / f)
199
+ else:
200
+ for item in self.watch_path.iterdir():
201
+ if item.is_file() and item.suffix.lower() == ".mkv":
202
+ found.add(item)
203
+ except OSError:
204
+ pass
205
+ return found
206
+
207
+ def _polling_loop(self) -> None:
208
+ """Polling loop for fallback mode."""
209
+ while not self.stop_event.is_set():
210
+ time.sleep(self.interval)
211
+
212
+ current_files = self._scan_directory()
213
+ new_files = current_files - self._known_files
214
+
215
+ for filepath in new_files:
216
+ Thread(
217
+ target=self.mkv_handler.handle_file,
218
+ args=(filepath,),
219
+ daemon=True,
220
+ ).start()
221
+
222
+ self._known_files = current_files
223
+
224
+ def stop(self) -> None:
225
+ """Stop watching."""
226
+ self.stop_event.set()
227
+
228
+ if self._observer is not None:
229
+ # Observer has stop() and join() methods
230
+ self._observer.stop() # type: ignore[attr-defined]
231
+ self._observer.join(timeout=5) # type: ignore[attr-defined]
232
+
233
+ if self._poll_thread is not None:
234
+ self._poll_thread.join(timeout=5)
235
+
236
+ def wait(self) -> None:
237
+ """Wait until stopped (blocks)."""
238
+ try:
239
+ while not self.stop_event.is_set():
240
+ time.sleep(0.5)
241
+ except KeyboardInterrupt:
242
+ pass
243
+
244
+
245
+ def watch_directory(
246
+ path: Path,
247
+ convert_callback: Callable[[Path], None],
248
+ cfg: Config,
249
+ interval: float = 5.0,
250
+ print_fn: Optional[Callable[[str], None]] = None,
251
+ ) -> None:
252
+ """
253
+ Watch a directory for new MKV files and convert them.
254
+
255
+ This function blocks until interrupted (Ctrl+C).
256
+
257
+ Args:
258
+ path: Directory to watch.
259
+ convert_callback: Function to call for each new MKV file.
260
+ cfg: Configuration instance.
261
+ interval: Polling interval in seconds.
262
+ print_fn: Optional function to print status messages.
263
+ """
264
+ if print_fn is None:
265
+ print_fn = print
266
+
267
+ if not path.is_dir():
268
+ print_fn(f"Error: {path} is not a directory")
269
+ return
270
+
271
+ mode = "watchdog" if WATCHDOG_AVAILABLE else "polling"
272
+ recursive = "recursive" if cfg.recursive else "non-recursive"
273
+
274
+ print_fn(f"Watching {path} ({mode}, {recursive})")
275
+ print_fn("Press Ctrl+C to stop")
276
+ print_fn("")
277
+
278
+ watcher = DirectoryWatcher(
279
+ watch_path=path,
280
+ convert_callback=convert_callback,
281
+ cfg=cfg,
282
+ interval=interval,
283
+ recursive=cfg.recursive,
284
+ )
285
+
286
+ try:
287
+ watcher.start()
288
+ watcher.wait()
289
+ except KeyboardInterrupt:
290
+ print_fn("\nStopping watcher...")
291
+ finally:
292
+ watcher.stop()
293
+ print_fn("Watcher stopped.")