From c61724118a6b65e6e7d728e9457db5dc00690434 Mon Sep 17 00:00:00 2001 From: Jan Meyer Date: Tue, 10 Feb 2026 15:40:12 +0100 Subject: [PATCH] feat: add disk stall detection --- ripper/cli.py | 14 +++- ripper/config.py | 6 ++ ripper/disc.py | 209 +++++++++++++++++++++++++++++++++++++++++++++++ ripper/encode.py | 65 +++++++++++++-- 4 files changed, 286 insertions(+), 8 deletions(-) create mode 100644 ripper/disc.py diff --git a/ripper/cli.py b/ripper/cli.py index ff14228..bc42ffc 100644 --- a/ripper/cli.py +++ b/ripper/cli.py @@ -13,6 +13,7 @@ from .naming import build_scene_name, get_source_tag, get_volume_label from .encode import build_handbrake_cmd, run_encode from .metadata import lookup_imdb, generate_nfo from .library import list_library +from .disc import check_disc_readable, unstuck_disc def main(): @@ -59,6 +60,11 @@ def main(): action="store_true", help="List all ripped movies in the output directory.", ) + parser.add_argument( + "--check-disc", + action="store_true", + help="Check disc readability and attempt recovery if stuck.", + ) parser.add_argument( "--init-config", action="store_true", @@ -76,6 +82,12 @@ def main(): list_library(args.output_base) return + # ── Disc health check ──────────────────────────────────────────────── + if args.check_disc: + if not unstuck_disc(args.input): + sys.exit(1) + return + # ── Banner ─────────────────────────────────────────────────────────── console.print() console.print( @@ -154,7 +166,7 @@ def main(): args.input, output_file, title, audio_sel, subtitle_sel, encoder, ) - returncode = run_encode(cmd) + returncode = run_encode(cmd, input_path=args.input) if returncode != 0: console.print(f"\n[bold red]ERROR:[/] HandBrakeCLI exited with code {returncode}") sys.exit(returncode) diff --git a/ripper/config.py b/ripper/config.py index f51d680..b31c7c2 100644 --- a/ripper/config.py +++ b/ripper/config.py @@ -60,6 +60,9 @@ DEFAULT_CONFIG = """\ # Constant quality (lower = better quality, bigger file) # quality = 22 +# Seconds without progress before the disc is considered stuck (0 to disable) +# stall_timeout = 120 + [api] # TMDB API key for --imdb lookups # Get a free key at https://www.themoviedb.org/settings/api @@ -87,6 +90,7 @@ def _platform_defaults() -> dict: "encoder_fallback": "x265_10bit", "encoder_preset": "speed", "quality": 22, + "stall_timeout": 120, "tmdb_api_key": "", } @@ -108,6 +112,7 @@ def _load_config() -> dict: defaults["encoder_fallback"] = encoder.get("fallback", defaults["encoder_fallback"]) defaults["encoder_preset"] = encoder.get("preset", defaults["encoder_preset"]) defaults["quality"] = encoder.get("quality", defaults["quality"]) + defaults["stall_timeout"] = encoder.get("stall_timeout", defaults["stall_timeout"]) defaults["tmdb_api_key"] = api.get("tmdb_api_key", defaults["tmdb_api_key"]) # Environment variables override config file @@ -138,6 +143,7 @@ ENCODER_PRIMARY: str = _cfg["encoder_primary"] ENCODER_FALLBACK: str = _cfg["encoder_fallback"] ENCODER_PRESET: str = _cfg["encoder_preset"] QUALITY: int = _cfg["quality"] +STALL_TIMEOUT: int = _cfg["stall_timeout"] TMDB_API_KEY: str = _cfg["tmdb_api_key"] diff --git a/ripper/disc.py b/ripper/disc.py new file mode 100644 index 0000000..cafeb6e --- /dev/null +++ b/ripper/disc.py @@ -0,0 +1,209 @@ +"""Disc health checks and stuck-disc recovery (cross-platform).""" + +import os +import subprocess +import sys +import time + +from .config import console + + +# ── Tray control ───────────────────────────────────────────────────────────── + +def eject_disc(input_path: str) -> bool: + """Eject the disc tray. Returns True on success.""" + console.print(" [yellow]⏏[/] Ejecting disc tray…") + try: + if sys.platform == "win32": + # PowerShell COM-based eject + ps = ( + "$d = New-Object -ComObject Shell.Application; " + f"$d.Namespace(17).ParseName('{input_path[:2]}').InvokeVerb('Eject')" + ) + subprocess.run( + ["powershell", "-Command", ps], + capture_output=True, timeout=15, + ) + elif sys.platform == "darwin": + subprocess.run( + ["drutil", "tray", "eject"], + capture_output=True, timeout=15, + ) + else: + # Linux — try the device directly, then the mount point + subprocess.run( + ["eject", input_path], + capture_output=True, timeout=15, + ) + return True + except (FileNotFoundError, subprocess.TimeoutExpired) as e: + console.print(f" [red]✗[/] Eject failed: {e}") + return False + + +def reload_disc(input_path: str) -> bool: + """Close the disc tray / reload. Returns True on success.""" + console.print(" [yellow]⏏[/] Closing disc tray…") + try: + if sys.platform == "win32": + # No reliable close-tray on Windows; user must push it in + console.print(" [dim]Please close the disc tray manually and press Enter.[/]") + input() + elif sys.platform == "darwin": + subprocess.run( + ["drutil", "tray", "close"], + capture_output=True, timeout=15, + ) + else: + subprocess.run( + ["eject", "-t", input_path], + capture_output=True, timeout=15, + ) + return True + except (FileNotFoundError, subprocess.TimeoutExpired) as e: + console.print(f" [red]✗[/] Tray close failed: {e}") + return False + + +def cycle_tray(input_path: str, pause: float = 3.0) -> bool: + """Eject, wait, and reload the disc to clear read errors.""" + if not eject_disc(input_path): + return False + console.print(f" [dim]Waiting {pause:.0f}s before reloading…[/]") + time.sleep(pause) + if not reload_disc(input_path): + return False + # Give the drive a moment to spin up and recognise the disc + console.print(" [dim]Waiting for drive to spin up…[/]") + time.sleep(5) + return True + + +# ── Read check ─────────────────────────────────────────────────────────────── + +def _find_largest_file(directory: str) -> str | None: + """Find the largest file under a directory (likely the main VOB/M2TS).""" + largest = None + largest_size = 0 + for root, _dirs, files in os.walk(directory): + for f in files: + fp = os.path.join(root, f) + try: + sz = os.path.getsize(fp) + if sz > largest_size: + largest_size = sz + largest = fp + except OSError: + continue + return largest + + +def check_disc_readable( + input_path: str, + sample_bytes: int = 1024 * 1024, # 1 MB + timeout: float = 30.0, +) -> bool: + """ + Quick readability check: try reading a chunk from the disc. + + Reads from the largest file on the disc (typically the main movie + VOB or M2TS). Returns True if data was read without errors. + """ + target = None + + # For block devices or mount points try to find a large file + if os.path.isdir(input_path): + target = _find_largest_file(input_path) + elif os.path.isfile(input_path): + target = input_path + else: + # Could be a block device — try reading it directly + target = input_path + + if not target: + console.print(" [red]✗[/] No readable files found on disc.") + return False + + console.print(f" [dim]Reading {sample_bytes // 1024} KB from {os.path.basename(target)}…[/]") + + start = time.monotonic() + try: + with open(target, "rb") as f: + # Seek to ~10% into the file to test a non-trivial sector + try: + size = os.fstat(f.fileno()).st_size + if size > sample_bytes * 10: + f.seek(size // 10) + except OSError: + pass + + data = b"" + while len(data) < sample_bytes: + elapsed = time.monotonic() - start + if elapsed > timeout: + console.print( + f" [red]✗[/] Read timed out after {elapsed:.0f}s " + f"(got {len(data) // 1024} KB / {sample_bytes // 1024} KB)" + ) + return False + chunk = f.read(min(65536, sample_bytes - len(data))) + if not chunk: + break + data += chunk + + elapsed = time.monotonic() - start + speed = (len(data) / 1024 / 1024) / elapsed if elapsed > 0 else 0 + console.print( + f" [green]✓[/] Read {len(data) // 1024} KB OK " + f"[dim]({speed:.1f} MB/s, {elapsed:.1f}s)[/]" + ) + return True + + except OSError as e: + console.print(f" [red]✗[/] Read error: {e}") + return False + + +# ── Unstuck / recovery ─────────────────────────────────────────────────────── + +def unstuck_disc(input_path: str, max_retries: int = 3) -> bool: + """ + Attempt to recover a stuck/unreadable disc. + + Strategy: + 1. Test if the disc is readable + 2. If not, cycle the tray (eject + reload) and retry + 3. Repeat up to max_retries times + + Returns True if the disc is readable after recovery. + """ + from rich.panel import Panel + + console.print() + console.print( + Panel( + "[bold yellow]Disc Recovery[/]\n" + "[dim]Checking disc readability and attempting to clear read errors[/]", + border_style="yellow", + padding=(0, 2), + ) + ) + console.print() + + for attempt in range(1, max_retries + 1): + console.print(f" [bold]Attempt {attempt}/{max_retries}[/]") + + if check_disc_readable(input_path): + console.print() + console.print(" [green]✓[/] Disc is readable!") + return True + + if attempt < max_retries: + console.print(f"\n [yellow]⚠[/] Disc unreadable — cycling tray…\n") + if not cycle_tray(input_path): + console.print(" [red]✗[/] Could not cycle tray.") + return False + else: + console.print(f"\n [red]✗[/] Disc still unreadable after {max_retries} attempts.") + + return False diff --git a/ripper/encode.py b/ripper/encode.py index cf66352..5b16c05 100644 --- a/ripper/encode.py +++ b/ripper/encode.py @@ -13,7 +13,7 @@ from rich.progress import ( TimeRemainingColumn, ) -from .config import ENCODER_PRESET, QUALITY, console +from .config import ENCODER_PRESET, QUALITY, STALL_TIMEOUT, console def build_handbrake_cmd( @@ -65,13 +65,19 @@ def build_handbrake_cmd( return cmd -def run_encode(cmd: list[str]) -> int: +def run_encode(cmd: list[str], input_path: str | None = None) -> int: """ Run HandBrakeCLI with a real-time rich progress bar. Parses the JSON progress blocks from HandBrakeCLI's output to display encoding progress, ETA, FPS, and current pass info. + + If input_path is provided and the encode stalls (no progress for + STALL_TIMEOUT seconds), attempts disc recovery via tray cycling. """ + import select + import time + process = subprocess.Popen( cmd, stdout=subprocess.PIPE, @@ -105,6 +111,11 @@ def run_encode(cmd: list[str]) -> int: depth = 0 in_json = False + # Stall detection state + last_pct = -1.0 + last_progress_time = time.monotonic() + stall_warned = False + with progress: for line in process.stdout: line = line.rstrip() @@ -121,7 +132,11 @@ def run_encode(cmd: list[str]) -> int: depth = json_part.count("{") - json_part.count("}") if depth <= 0: in_json = False - _process_json_block("".join(buf), progress, task_id) + pct = _process_json_block("".join(buf), progress, task_id) + if pct is not None and pct > last_pct: + last_pct = pct + last_progress_time = time.monotonic() + stall_warned = False buf = [] continue @@ -132,9 +147,38 @@ def run_encode(cmd: list[str]) -> int: depth += line.count("{") - line.count("}") if depth <= 0: in_json = False - _process_json_block("".join(buf), progress, task_id) + pct = _process_json_block("".join(buf), progress, task_id) + if pct is not None and pct > last_pct: + last_pct = pct + last_progress_time = time.monotonic() + stall_warned = False buf = [] + # ── Stall detection ────────────────────────────────────── + stall_secs = time.monotonic() - last_progress_time + if stall_secs > STALL_TIMEOUT and not stall_warned: + stall_warned = True + progress.update( + task_id, + phase=f"[bold red]STALLED[/] ({stall_secs:.0f}s)", + fps="", + ) + console.print( + f"\n [bold yellow]⚠ Encoding stalled[/] — " + f"no progress for {stall_secs:.0f}s " + f"(stuck at {last_pct:.1f}%)" + ) + + # Attempt disc recovery if we know the input path + if input_path: + from .disc import cycle_tray + console.print(" [yellow]Attempting disc recovery…[/]") + cycle_tray(input_path) + console.print(" [dim]Waiting for HandBrakeCLI to resume…[/]") + # Reset the timer to give it time after recovery + last_progress_time = time.monotonic() + stall_warned = False + # Ensure we reach 100% progress.update(task_id, completed=100, phase="Complete") @@ -142,12 +186,15 @@ def run_encode(cmd: list[str]) -> int: return process.returncode -def _process_json_block(text: str, progress: Progress, task_id) -> None: - """Parse a JSON progress block and update the rich progress bar.""" +def _process_json_block(text: str, progress: Progress, task_id) -> float | None: + """Parse a JSON progress block and update the rich progress bar. + + Returns the current progress percentage if available, else None. + """ try: data = json.loads(text) except json.JSONDecodeError: - return + return None state = data.get("State", "") @@ -167,9 +214,11 @@ def _process_json_block(text: str, progress: Progress, task_id) -> None: fps_str = f"[bold]{rate:.1f}[/] fps [dim](avg {rate_avg:.1f})[/]" if rate else "" progress.update(task_id, completed=pct, phase=phase, fps=fps_str) + return pct elif state == "MUXING": progress.update(task_id, completed=99, phase="[yellow]Muxing…[/]", fps="") + return 99.0 elif state == "SCANNING": scanning = data.get("Scanning", {}) @@ -177,3 +226,5 @@ def _process_json_block(text: str, progress: Progress, task_id) -> None: title_count = scanning.get("TitleCount", 0) phase = f"Scanning title {title_num}/{title_count}" if title_count else "Scanning…" progress.update(task_id, completed=0, phase=phase, fps="") + + return None