feat: handle keyboard interruption more gracefully
This commit is contained in:
10
ripper.py
10
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)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Allow running as `python -m ripper`."""
|
||||
|
||||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
main()
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
|
||||
@@ -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)
|
||||
|
||||
135
ripper/encode.py
135
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
|
||||
|
||||
Reference in New Issue
Block a user