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

View File

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

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,
)
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