From f9a4af1c949e42728e36984054009ea6152cdd7b Mon Sep 17 00:00:00 2001 From: Jan Meyer Date: Tue, 10 Feb 2026 19:16:06 +0100 Subject: [PATCH] feat: handle keyboard interruption more gracefully --- ripper.py | 10 +++- ripper/__main__.py | 7 ++- ripper/cli.py | 13 ++++- ripper/encode.py | 135 +++++++++++++++++++++++++-------------------- 4 files changed, 100 insertions(+), 65 deletions(-) diff --git a/ripper.py b/ripper.py index 34bc8af..3db4bcf 100755 --- a/ripper.py +++ b/ripper.py @@ -3,17 +3,21 @@ DVD/Blu-ray ripper using HandBrakeCLI with scene-style naming. Scans a disc, selects the best audio & subtitle track per language -(passthrough), encodes with H.265 10-bit AMD VCE (falling back to -x265_10bit on CPU), and generates an NFO file via pymediainfo. +(passthrough), encodes with H.265/H.264/AV1 (auto-selecting HW +acceleration), and generates an NFO file via pymediainfo. Usage: python ripper.py --imdb tt6977338 python ripper.py --name 'Game Night' --year 2018 + python ripper.py --codec av1 --quality 30 python ripper.py --scan-only python ripper.py --list """ +import sys from ripper.cli import main -if __name__ == "__main__": +try: main() +except KeyboardInterrupt: + sys.exit(130) diff --git a/ripper/__main__.py b/ripper/__main__.py index f273452..c22e7aa 100644 --- a/ripper/__main__.py +++ b/ripper/__main__.py @@ -1,5 +1,10 @@ """Allow running as `python -m ripper`.""" +import sys + from .cli import main -main() +try: + main() +except KeyboardInterrupt: + sys.exit(130) diff --git a/ripper/cli.py b/ripper/cli.py index 6e489b2..3aa54b6 100644 --- a/ripper/cli.py +++ b/ripper/cli.py @@ -136,6 +136,15 @@ def main(): ) 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 ──────────────────────────────────────────────── available = discover_encoders() print_encoder_table(available) @@ -183,7 +192,6 @@ def main(): # Determine codec tag and bit depth from the selected encoder 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 scene = build_scene_name( @@ -220,6 +228,9 @@ def main(): ) 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: console.print(f"\n[bold red]ERROR:[/] HandBrakeCLI exited with code {returncode}") sys.exit(returncode) diff --git a/ripper/encode.py b/ripper/encode.py index 1fd175b..e5a0e92 100644 --- a/ripper/encode.py +++ b/ripper/encode.py @@ -116,71 +116,86 @@ def run_encode(cmd: list[str], input_path: str | None = None) -> int: last_progress_time = time.monotonic() stall_warned = False - with progress: - for line in process.stdout: - line = line.rstrip() + try: + with progress: + for line in process.stdout: + line = line.rstrip() - # Detect start of a JSON block - if not in_json: - if line.lstrip().startswith("{") or "Progress:" in line or "Version:" in line: - # Extract JSON portion - idx = line.find("{") - if idx >= 0: - in_json = True - json_part = line[idx:] - buf = [json_part] - depth = json_part.count("{") - json_part.count("}") - if depth <= 0: - in_json = False - 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 + # Detect start of a JSON block + if not in_json: + if line.lstrip().startswith("{") or "Progress:" in line or "Version:" in line: + # Extract JSON portion + idx = line.find("{") + if idx >= 0: + in_json = True + json_part = line[idx:] + buf = [json_part] + depth = json_part.count("{") - json_part.count("}") + if depth <= 0: + in_json = False + 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 - # Accumulate JSON lines - if "HandBrake has exited" in line: - continue - buf.append(line) - depth += line.count("{") - line.count("}") - if depth <= 0: - in_json = False - 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 = [] + # Accumulate JSON lines + if "HandBrake has exited" in line: + continue + buf.append(line) + depth += line.count("{") - line.count("}") + if depth <= 0: + in_json = False + 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}%)" - ) + # ── 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 + # 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") + # Ensure we reach 100% + 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() return process.returncode