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 .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)
|
||||||
|
|||||||
@@ -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
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,
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user