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
|
@@ -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.")
|