feat: add disk stall detection

This commit is contained in:
Jan Meyer
2026-02-10 15:40:12 +01:00
parent d3d244d2b0
commit c61724118a
4 changed files with 286 additions and 8 deletions

View File

@@ -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 .encode import build_handbrake_cmd, run_encode
from .metadata import lookup_imdb, generate_nfo from .metadata import lookup_imdb, generate_nfo
from .library import list_library from .library import list_library
from .disc import check_disc_readable, unstuck_disc
def main(): def main():
@@ -59,6 +60,11 @@ def main():
action="store_true", action="store_true",
help="List all ripped movies in the output directory.", 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( parser.add_argument(
"--init-config", "--init-config",
action="store_true", action="store_true",
@@ -76,6 +82,12 @@ def main():
list_library(args.output_base) list_library(args.output_base)
return return
# ── Disc health check ────────────────────────────────────────────────
if args.check_disc:
if not unstuck_disc(args.input):
sys.exit(1)
return
# ── Banner ─────────────────────────────────────────────────────────── # ── Banner ───────────────────────────────────────────────────────────
console.print() console.print()
console.print( console.print(
@@ -154,7 +166,7 @@ def main():
args.input, output_file, title, audio_sel, subtitle_sel, encoder, 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: if returncode != 0:
console.print(f"\n[bold red]ERROR:[/] HandBrakeCLI exited with code {returncode}") console.print(f"\n[bold red]ERROR:[/] HandBrakeCLI exited with code {returncode}")
sys.exit(returncode) sys.exit(returncode)

View File

@@ -60,6 +60,9 @@ DEFAULT_CONFIG = """\
# Constant quality (lower = better quality, bigger file) # Constant quality (lower = better quality, bigger file)
# quality = 22 # quality = 22
# Seconds without progress before the disc is considered stuck (0 to disable)
# stall_timeout = 120
[api] [api]
# TMDB API key for --imdb lookups # TMDB API key for --imdb lookups
# Get a free key at https://www.themoviedb.org/settings/api # Get a free key at https://www.themoviedb.org/settings/api
@@ -87,6 +90,7 @@ def _platform_defaults() -> dict:
"encoder_fallback": "x265_10bit", "encoder_fallback": "x265_10bit",
"encoder_preset": "speed", "encoder_preset": "speed",
"quality": 22, "quality": 22,
"stall_timeout": 120,
"tmdb_api_key": "", "tmdb_api_key": "",
} }
@@ -108,6 +112,7 @@ def _load_config() -> dict:
defaults["encoder_fallback"] = encoder.get("fallback", defaults["encoder_fallback"]) defaults["encoder_fallback"] = encoder.get("fallback", defaults["encoder_fallback"])
defaults["encoder_preset"] = encoder.get("preset", defaults["encoder_preset"]) defaults["encoder_preset"] = encoder.get("preset", defaults["encoder_preset"])
defaults["quality"] = encoder.get("quality", defaults["quality"]) 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"]) defaults["tmdb_api_key"] = api.get("tmdb_api_key", defaults["tmdb_api_key"])
# Environment variables override config file # Environment variables override config file
@@ -138,6 +143,7 @@ ENCODER_PRIMARY: str = _cfg["encoder_primary"]
ENCODER_FALLBACK: str = _cfg["encoder_fallback"] ENCODER_FALLBACK: str = _cfg["encoder_fallback"]
ENCODER_PRESET: str = _cfg["encoder_preset"] ENCODER_PRESET: str = _cfg["encoder_preset"]
QUALITY: int = _cfg["quality"] QUALITY: int = _cfg["quality"]
STALL_TIMEOUT: int = _cfg["stall_timeout"]
TMDB_API_KEY: str = _cfg["tmdb_api_key"] TMDB_API_KEY: str = _cfg["tmdb_api_key"]

209
ripper/disc.py Normal file
View File

@@ -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

View File

@@ -13,7 +13,7 @@ from rich.progress import (
TimeRemainingColumn, TimeRemainingColumn,
) )
from .config import ENCODER_PRESET, QUALITY, console from .config import ENCODER_PRESET, QUALITY, STALL_TIMEOUT, console
def build_handbrake_cmd( def build_handbrake_cmd(
@@ -65,13 +65,19 @@ def build_handbrake_cmd(
return 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. Run HandBrakeCLI with a real-time rich progress bar.
Parses the JSON progress blocks from HandBrakeCLI's output to display Parses the JSON progress blocks from HandBrakeCLI's output to display
encoding progress, ETA, FPS, and current pass info. 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( process = subprocess.Popen(
cmd, cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
@@ -105,6 +111,11 @@ def run_encode(cmd: list[str]) -> int:
depth = 0 depth = 0
in_json = False in_json = False
# Stall detection state
last_pct = -1.0
last_progress_time = time.monotonic()
stall_warned = False
with progress: with progress:
for line in process.stdout: for line in process.stdout:
line = line.rstrip() line = line.rstrip()
@@ -121,7 +132,11 @@ def run_encode(cmd: list[str]) -> int:
depth = json_part.count("{") - json_part.count("}") depth = json_part.count("{") - json_part.count("}")
if depth <= 0: if depth <= 0:
in_json = False 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 = [] buf = []
continue continue
@@ -132,9 +147,38 @@ def run_encode(cmd: list[str]) -> int:
depth += line.count("{") - line.count("}") depth += line.count("{") - line.count("}")
if depth <= 0: if depth <= 0:
in_json = False 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 = [] 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% # Ensure we reach 100%
progress.update(task_id, completed=100, phase="Complete") progress.update(task_id, completed=100, phase="Complete")
@@ -142,12 +186,15 @@ def run_encode(cmd: list[str]) -> int:
return process.returncode return process.returncode
def _process_json_block(text: str, progress: Progress, task_id) -> None: def _process_json_block(text: str, progress: Progress, task_id) -> float | None:
"""Parse a JSON progress block and update the rich progress bar.""" """Parse a JSON progress block and update the rich progress bar.
Returns the current progress percentage if available, else None.
"""
try: try:
data = json.loads(text) data = json.loads(text)
except json.JSONDecodeError: except json.JSONDecodeError:
return return None
state = data.get("State", "") 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 "" 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) progress.update(task_id, completed=pct, phase=phase, fps=fps_str)
return pct
elif state == "MUXING": elif state == "MUXING":
progress.update(task_id, completed=99, phase="[yellow]Muxing…[/]", fps="") progress.update(task_id, completed=99, phase="[yellow]Muxing…[/]", fps="")
return 99.0
elif state == "SCANNING": elif state == "SCANNING":
scanning = data.get("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) title_count = scanning.get("TitleCount", 0)
phase = f"Scanning title {title_num}/{title_count}" if title_count else "Scanning…" phase = f"Scanning title {title_num}/{title_count}" if title_count else "Scanning…"
progress.update(task_id, completed=0, phase=phase, fps="") progress.update(task_id, completed=0, phase=phase, fps="")
return None