feat: add disk stall detection
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
209
ripper/disc.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user