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