feat: handle keyboard interruption more gracefully

This commit is contained in:
Jan Meyer
2026-02-10 19:16:06 +01:00
parent d08b139af6
commit f9a4af1c94
4 changed files with 100 additions and 65 deletions

View File

@@ -3,17 +3,21 @@
DVD/Blu-ray ripper using HandBrakeCLI with scene-style naming. DVD/Blu-ray ripper using HandBrakeCLI with scene-style naming.
Scans a disc, selects the best audio & subtitle track per language Scans a disc, selects the best audio & subtitle track per language
(passthrough), encodes with H.265 10-bit AMD VCE (falling back to (passthrough), encodes with H.265/H.264/AV1 (auto-selecting HW
x265_10bit on CPU), and generates an NFO file via pymediainfo. acceleration), and generates an NFO file via pymediainfo.
Usage: Usage:
python ripper.py --imdb tt6977338 python ripper.py --imdb tt6977338
python ripper.py --name 'Game Night' --year 2018 python ripper.py --name 'Game Night' --year 2018
python ripper.py --codec av1 --quality 30
python ripper.py --scan-only python ripper.py --scan-only
python ripper.py --list python ripper.py --list
""" """
import sys
from ripper.cli import main from ripper.cli import main
if __name__ == "__main__": try:
main() main()
except KeyboardInterrupt:
sys.exit(130)

View File

@@ -1,5 +1,10 @@
"""Allow running as `python -m ripper`.""" """Allow running as `python -m ripper`."""
import sys
from .cli import main from .cli import main
main() try:
main()
except KeyboardInterrupt:
sys.exit(130)

View File

@@ -136,6 +136,15 @@ def main():
) )
console.print() console.print()
try:
_run_pipeline(args, quality)
except KeyboardInterrupt:
console.print("\n [bold yellow]⚠ Interrupted[/] — exiting.\n")
sys.exit(130)
def _run_pipeline(args, quality: int) -> None:
"""Core ripping pipeline, separated for clean interrupt handling."""
# ── 1. Detect encoder ──────────────────────────────────────────────── # ── 1. Detect encoder ────────────────────────────────────────────────
available = discover_encoders() available = discover_encoders()
print_encoder_table(available) print_encoder_table(available)
@@ -183,7 +192,6 @@ def main():
# Determine codec tag and bit depth from the selected encoder # Determine codec tag and bit depth from the selected encoder
codec_tag = CODEC_SCENE_TAG.get(args.codec, args.codec.upper()) codec_tag = CODEC_SCENE_TAG.get(args.codec, args.codec.upper())
# Check if we're using a 10-bit encoder
is_10bit = "10bit" in encoder or "10" in encoder is_10bit = "10bit" in encoder or "10" in encoder
scene = build_scene_name( scene = build_scene_name(
@@ -220,6 +228,9 @@ def main():
) )
returncode = run_encode(cmd, input_path=args.input) returncode = run_encode(cmd, input_path=args.input)
if returncode == 130:
# User cancelled — re-raise so outer handler prints message
raise KeyboardInterrupt
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

@@ -116,71 +116,86 @@ def run_encode(cmd: list[str], input_path: str | None = None) -> int:
last_progress_time = time.monotonic() last_progress_time = time.monotonic()
stall_warned = False stall_warned = False
with progress: try:
for line in process.stdout: with progress:
line = line.rstrip() for line in process.stdout:
line = line.rstrip()
# Detect start of a JSON block # Detect start of a JSON block
if not in_json: if not in_json:
if line.lstrip().startswith("{") or "Progress:" in line or "Version:" in line: if line.lstrip().startswith("{") or "Progress:" in line or "Version:" in line:
# Extract JSON portion # Extract JSON portion
idx = line.find("{") idx = line.find("{")
if idx >= 0: if idx >= 0:
in_json = True in_json = True
json_part = line[idx:] json_part = line[idx:]
buf = [json_part] buf = [json_part]
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
pct = _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: if pct is not None and pct > last_pct:
last_pct = pct last_pct = pct
last_progress_time = time.monotonic() last_progress_time = time.monotonic()
stall_warned = False stall_warned = False
buf = [] buf = []
continue continue
# Accumulate JSON lines # Accumulate JSON lines
if "HandBrake has exited" in line: if "HandBrake has exited" in line:
continue continue
buf.append(line) buf.append(line)
depth += line.count("{") - line.count("}") depth += line.count("{") - line.count("}")
if depth <= 0: if depth <= 0:
in_json = False in_json = False
pct = _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: if pct is not None and pct > last_pct:
last_pct = pct last_pct = pct
last_progress_time = time.monotonic() last_progress_time = time.monotonic()
stall_warned = False stall_warned = False
buf = [] buf = []
# ── Stall detection ────────────────────────────────────── # ── Stall detection ──────────────────────────────────────
stall_secs = time.monotonic() - last_progress_time stall_secs = time.monotonic() - last_progress_time
if stall_secs > STALL_TIMEOUT and not stall_warned: if stall_secs > STALL_TIMEOUT and not stall_warned:
stall_warned = True stall_warned = True
progress.update( progress.update(
task_id, task_id,
phase=f"[bold red]STALLED[/] ({stall_secs:.0f}s)", phase=f"[bold red]STALLED[/] ({stall_secs:.0f}s)",
fps="", fps="",
) )
console.print( console.print(
f"\n [bold yellow]⚠ Encoding stalled[/] — " f"\n [bold yellow]⚠ Encoding stalled[/] — "
f"no progress for {stall_secs:.0f}s " f"no progress for {stall_secs:.0f}s "
f"(stuck at {last_pct:.1f}%)" f"(stuck at {last_pct:.1f}%)"
) )
# Attempt disc recovery if we know the input path # Attempt disc recovery if we know the input path
if input_path: if input_path:
from .disc import cycle_tray from .disc import cycle_tray
console.print(" [yellow]Attempting disc recovery…[/]") console.print(" [yellow]Attempting disc recovery…[/]")
cycle_tray(input_path) cycle_tray(input_path)
console.print(" [dim]Waiting for HandBrakeCLI to resume…[/]") console.print(" [dim]Waiting for HandBrakeCLI to resume…[/]")
# Reset the timer to give it time after recovery # Reset the timer to give it time after recovery
last_progress_time = time.monotonic() last_progress_time = time.monotonic()
stall_warned = False 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")
except KeyboardInterrupt:
# Gracefully stop HandBrakeCLI on Ctrl+C
progress.update(task_id, phase="[bold red]Cancelled[/]", fps="")
progress.stop()
console.print("\n [bold yellow]⚠ Interrupted[/] — stopping HandBrakeCLI…")
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
console.print(" [dim]HandBrakeCLI stopped.[/]")
return 130 # Standard exit code for SIGINT
process.wait() process.wait()
return process.returncode return process.returncode