simplefe

Log | Files | Refs

commit 7105732a28f5708f9a5860b68ff95984ea40522f
parent 1907d13e63d964f4d1de25f6e51938092863134f
Author: Chris Roberts <chris.roberts@learningunix.net>
Date:   Sun, 19 Jul 2026 15:48:33 -0500

Rebuild simplefe as a dedicated rip/encode app

Replace the generic jobs.toml + pexpect script runner with a
purpose-built pipeline: query disc info via MakeMKV robot mode
(structured parsing instead of scraping human-readable text),
show it on a page with an editable movie name and a duplicate-rip
warning, run rip -> encode -> notify -> eject as a background job
with a live status page. This fixes real gaps found testing on
actual hardware this week -- the old system only knew about one
of rip's two interactive prompts and would hang indefinitely on
the second one (duplicate-rip confirmation), and none of the
orchestration logic was testable without a real disc in a real
drive.

- settings.py: env-driven ffmpeg/hardware config, replacing hardcoded
  values in .scripts/encode and .scripts/rip
- makemkv.py: disc query/parsing (ported from the exact CINFO/TINFO
  fields already proven in .scripts/rip), duplicate-check via real
  parameterized SQL instead of hand-escaped strings, rip execution
- encode.py / notify.py: native reimplementations of
  .scripts/encode and .scripts/notify's logic
- pipeline.py: orchestrates the full run as a background thread with
  a shared status object, no task queue needed for a single-job tool
- app.py: GET /rip, POST /rip/start, GET /rip/status, plus a
  GET/POST /setup page for Jellyfin/Pushover credentials that
  applies immediately without a restart
- install.py: fixed target of /opt/simplefe regardless of where the
  script is run from, synced via stdlib shutil (rsync isn't
  guaranteed present on a fresh machine -- learned the hard way on
  ser6), and no longer destructively regenerates .env on every
  redeploy
- Mobile-friendly styling via a shared base template (missing
  viewport meta tag was causing the tiny-text rendering)
- auth.py and the Bearer/Basic enforce_auth wrapper are untouched

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Diffstat:
M.gitignore | 2+-
ATODO.md | 43+++++++++++++++++++++++++++++++++++++++++++
Ainstall.py | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Djobs.toml.example | 3---
Mpyproject.toml | 2+-
Msrc/simplefe/app.py | 154++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Dsrc/simplefe/config.py | 17-----------------
Asrc/simplefe/encode.py | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/simplefe/makemkv.py | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/simplefe/notify.py | 28++++++++++++++++++++++++++++
Asrc/simplefe/pipeline.py | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/simplefe/runner.py | 9---------
Asrc/simplefe/settings.py | 46++++++++++++++++++++++++++++++++++++++++++++++
Asrc/simplefe/templates/base.html | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/simplefe/templates/index.html | 40++++++++++++++++++++++++++++++++++++++++
Asrc/simplefe/templates/setup.html | 21+++++++++++++++++++++
Asrc/simplefe/templates/status.html | 22++++++++++++++++++++++
Mtests/conftest.py | 4++--
Dtests/fixtures/fake_job.py | 2--
Mtests/test_app.py | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Dtests/test_config.py | 41-----------------------------------------
Atests/test_encode.py | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/test_makemkv.py | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/test_notify.py | 46++++++++++++++++++++++++++++++++++++++++++++++
Atests/test_pipeline.py | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dtests/test_runner.py | 11-----------
Atests/test_settings.py | 34++++++++++++++++++++++++++++++++++
27 files changed, 1447 insertions(+), 148 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -4,5 +4,5 @@ __pycache__/ .pytest_cache/ .claude/settings.local.json .scripts -jobs.toml .env +.ruff_cache/ diff --git a/TODO.md b/TODO.md @@ -0,0 +1,43 @@ +# TODO + +## Mobile login is painful + +The `SIMPLEFE_TOKEN` used for Basic Auth is a long random string (`secrets.token_urlsafe(32)`), +which is awkward to type on a mobile keyboard. + +Deferred for later. Leaning towards a **short PIN + lockout**: swap the long token for a short +PIN (e.g. 6-8 digits, numeric keypad entry), paired with a basic lockout after N failed attempts +to compensate for the lower entropy -- reasonable trade-off given the threat model is "random +device on the home LAN," not internet-facing. Alternative considered and set aside for now: a +"remember this device" cookie, which keeps the full-strength token but only requires typing it +once per device -- doesn't weaken the credential, but reintroduces session/cookie machinery that +was deliberately skipped earlier in favor of stateless Basic Auth. + +### ~~Token was churning every redeploy, wiping other secrets too~~ (fixed) + +Turned out to be compounding the above: `install.py`'s `write_env_file()` asked "overwrite +.env?" on every run where it already existed, and answering yes didn't just rotate +`SIMPLEFE_TOKEN` -- it wrote a **brand new `.env` with only that one line**, silently deleting +`JELLYFIN_TOKEN`/`PUSHOVER_TOKEN`/`PUSHOVER_USER` too. Routine redeploys during testing were +losing the login and all notification config every time. + +Fixed: `write_env_file()` no longer prompts at all. It checks whether `SIMPLEFE_TOKEN=` is +already present in `.env` -- if so, the file is left completely untouched; if not (first-time +setup, or the line was manually removed), it appends a freshly generated token without +disturbing anything else already in the file. + +## ~~`/rip` can hang a second device while a rip is running~~ (fixed) + +Reported: with the page open on a desktop, it wouldn't load on mobile at the same time. + +Root cause: `rip_index()` (`GET /rip`) unconditionally called `makemkv.query_disc()`, which +shells out to `makemkvcon -r info` and talks to the physical optical drive directly. If a rip +was actively in progress and a second request hit `/rip` (not `/rip/status`), it tried to query +the *same busy drive* -- MakeMKV likely serializes/blocks that, hanging the second request. Not +a generic "can't handle concurrent connections" issue -- waitress serves multiple clients fine +(default 4-thread pool). + +Fixed: `rip_index()` now checks `app.config["JOB"]["running"]` first and skips +`query_disc()`/`find_existing_rip()` entirely when a job is already active, just rendering the +"a rip is already in progress" branch without ever touching the drive. Covered by +`test_rip_index_skips_disc_query_while_job_running` in `tests/test_app.py`. diff --git a/install.py b/install.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +import getpass +import os +import secrets +import shutil +import subprocess +import sys +import tempfile +import venv +from pathlib import Path + +SOURCE_ROOT = Path(__file__).resolve().parent +DEPLOY_ROOT = Path("/opt/simplefe") +VENV_DIR = DEPLOY_ROOT / ".venv" +VENV_PYTHON = VENV_DIR / "bin" / "python" +SIMPLEFE_TOKEN_KEY = "SIMPLEFE_TOKEN" + +SYNC_EXCLUDES = [ + ".venv", + ".git", + ".env", + "__pycache__", + ".pytest_cache", + "*.egg-info", +] + + +def sync_source(): + if SOURCE_ROOT == DEPLOY_ROOT: + return + print(f"Syncing source to {DEPLOY_ROOT} (requires sudo)...") + subprocess.run(["sudo", "mkdir", "-p", str(DEPLOY_ROOT)], check=True) + user = getpass.getuser() + subprocess.run( + ["sudo", "chown", "-R", f"{user}:{user}", str(DEPLOY_ROOT)], check=True + ) + shutil.copytree( + SOURCE_ROOT, + DEPLOY_ROOT, + dirs_exist_ok=True, + ignore=shutil.ignore_patterns(*SYNC_EXCLUDES), + ) + + +def create_venv(): + if VENV_DIR.exists(): + print(f"Virtual environment already exists at {VENV_DIR}, skipping.") + return + print(f"Creating virtual environment at {VENV_DIR}...") + venv.create(VENV_DIR, with_pip=True) + + +def install_package(): + print("Installing simplefe and its dependencies...") + subprocess.run( + [str(VENV_PYTHON), "-m", "pip", "install", "-e", str(DEPLOY_ROOT)], + check=True, + ) + + +def reexec_into_venv(): + if Path(sys.prefix) == VENV_DIR: + return + os.execv(str(VENV_PYTHON), [str(VENV_PYTHON), __file__, *sys.argv[1:]]) + + +def write_env_file(): + env_path = DEPLOY_ROOT / ".env" + if env_path.exists(): + if f"{SIMPLEFE_TOKEN_KEY}=" in env_path.read_text(): + print(f"{env_path} already has a {SIMPLEFE_TOKEN_KEY}, keeping it as-is.") + return + token = secrets.token_urlsafe(32) + with open(env_path, "a") as f: + f.write(f"{SIMPLEFE_TOKEN_KEY}={token}\n") + print(f"Added a freshly generated {SIMPLEFE_TOKEN_KEY} to {env_path}.") + return + + token = secrets.token_urlsafe(32) + with open(env_path, "w") as f: + f.write(f"{SIMPLEFE_TOKEN_KEY}={token}\n") + + print(f"Wrote {env_path} with a freshly generated {SIMPLEFE_TOKEN_KEY}.") + print( + "Once the service is running, visit /setup in your browser to add your " + "Jellyfin and Pushover credentials. Any ENCODE_*/DRIVE_PATH/OUTPUT_DIR/" + "OUTPUT_GROUP overrides still need to be added to .env by hand (see " + "settings.py for the full list and defaults)." + ) + + +SYSTEMD_UNIT_PATH = Path("/etc/systemd/system/simplefe.service") + +UNIT_TEMPLATE = """\ +[Unit] +Description=simplefe +After=network.target + +[Service] +Type=simple +User={user} +WorkingDirectory={project_root} +ExecStart={waitress} --listen=0.0.0.0:8000 --call simplefe.app:create_app +Restart=on-failure + +[Install] +WantedBy=multi-user.target +""" + + +def install_systemd_unit(): + waitress = VENV_DIR / "bin" / "waitress-serve" + unit_content = UNIT_TEMPLATE.format( + user=getpass.getuser(), + project_root=DEPLOY_ROOT, + waitress=waitress, + ) + + with tempfile.NamedTemporaryFile("w", suffix=".service", delete=False) as f: + f.write(unit_content) + tmp_path = f.name + + print("Installing systemd service (requires sudo)...") + subprocess.run(["sudo", "cp", tmp_path, str(SYSTEMD_UNIT_PATH)], check=True) + Path(tmp_path).unlink() + subprocess.run(["sudo", "systemctl", "daemon-reload"], check=True) + subprocess.run(["sudo", "systemctl", "enable", "simplefe"], check=True) + subprocess.run(["sudo", "systemctl", "restart", "simplefe"], check=True) + print("simplefe service installed and started.") + + +def main(): + sync_source() + create_venv() + install_package() + reexec_into_venv() + + write_env_file() + install_systemd_unit() + + +if __name__ == "__main__": + main() diff --git a/jobs.toml.example b/jobs.toml.example @@ -1,3 +0,0 @@ -[jobs.example] -path = "/path/to/your/script" -prompt = "Enter something:" diff --git a/pyproject.toml b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" name = "simplefe" version = "0.1.0" requires-python = ">=3.11" -dependencies = ["flask", "pexpect", "python-dotenv"] +dependencies = ["flask", "python-dotenv", "waitress"] [project.optional-dependencies] test = ["pytest"] diff --git a/src/simplefe/app.py b/src/simplefe/app.py @@ -1,60 +1,142 @@ -from dotenv import load_dotenv +import base64 +import os +import threading -from flask import Flask, abort, request +from dotenv import find_dotenv, load_dotenv, set_key -from simplefe.auth import check_token, load_token - -from simplefe.config import load_jobs +from flask import Flask, Response, abort, redirect, render_template, request, url_for -from simplefe.runner import run_job +from simplefe import makemkv, pipeline +from simplefe.auth import check_token, load_token +from simplefe.settings import load_settings EXEMPT_PATHS = {"/"} +SETUP_KEYS = ["JELLYFIN_TOKEN", "PUSHOVER_TOKEN", "PUSHOVER_USER"] -def create_app(jobs=None, token=None): - load_dotenv() +def create_app(token=None, dotenv_path=None): + dotenv_path = dotenv_path or find_dotenv() or ".env" + load_dotenv(dotenv_path) app = Flask(__name__) - if jobs is None: - jobs = load_jobs("jobs.toml") - - app.config["JOBS"] = jobs app.config["TOKEN"] = load_token(token) + app.config["SETTINGS"] = load_settings() + app.config["DOTENV_PATH"] = dotenv_path + app.config["JOB"] = { + "running": False, + "stage": None, + "detail": None, + "error": None, + } @app.before_request def enforce_auth(): if request.path in EXEMPT_PATHS: return auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): - abort(401) - candidate = auth_header.removeprefix("Bearer ") + if auth_header.startswith("Bearer "): + candidate = auth_header.removeprefix("Bearer ") + elif auth_header.startswith("Basic "): + decoded = base64.b64decode(auth_header.removeprefix("Basic ")).decode() + candidate = decoded.partition(":")[2] + else: + candidate = "" + if not check_token(candidate, app.config["TOKEN"]): - abort(401) + response = Response(status=401) + response.headers["WWW-Authenticate"] = 'Basic realm="simplefe"' + return response @app.route("/") def index(): return "simplefe is running" - @app.route("/jobs") - def list_jobs(): - return {"jobs": list(app.config["JOBS"].keys())} - - @app.route("/jobs/<name>") - def get_job(name): - jobs = app.config["JOBS"] - if name not in jobs: - abort(404) - return jobs[name] - - @app.route("/jobs/<name>/run", methods=["POST"]) - def trigger_job(name): - jobs = app.config["JOBS"] - if name not in jobs: - abort(404) - job = jobs[name] - response = request.json["response"] - output = run_job(job["path"], job["prompt"], response) - return {"output": output.decode()} + @app.route("/setup", methods=["GET", "POST"]) + def setup(): + if request.method == "POST": + for key in SETUP_KEYS: + value = request.form.get(key, "").strip() + if value: + set_key(app.config["DOTENV_PATH"], key, value) + os.environ[key] = value + return redirect(url_for("rip_index")) + values = {key: os.environ.get(key, "") for key in SETUP_KEYS} + return render_template("setup.html", values=values) + + @app.route("/rip") + def rip_index(): + job = app.config["JOB"] + if job["running"]: + return render_template("index.html", job=job) + + settings = app.config["SETTINGS"] + info = makemkv.query_disc() + existing = makemkv.find_existing_rip(settings.rip_db_path, info.raw_id) + needs_setup = not all(os.environ.get(key) for key in SETUP_KEYS) + return render_template( + "index.html", + info=info, + existing=existing, + job=job, + needs_setup=needs_setup, + ) + + @app.route("/rip/start", methods=["POST"]) + def rip_start(): + job = app.config["JOB"] + if job["running"]: + abort(409) + + settings = app.config["SETTINGS"] + movie_name = request.form.get("movie_name", "").strip() + raw_id = request.form.get("raw_id", "") + title = int(request.form.get("title", 0)) + override_duplicate = request.form.get("override_duplicate") == "on" + + existing = makemkv.find_existing_rip(settings.rip_db_path, raw_id) + error = None + if not movie_name: + error = "Movie name is required." + elif existing and not override_duplicate: + error = ( + "This disc has already been ripped. " + "Check the box to rip again anyway." + ) + + if error: + info = makemkv.DiscInfo( + raw_id=raw_id, guessed_name=movie_name, main_title=title + ) + needs_setup = not all(os.environ.get(key) for key in SETUP_KEYS) + return render_template( + "index.html", + info=info, + existing=existing, + job=job, + error=error, + needs_setup=needs_setup, + ) + + thread = threading.Thread( + target=pipeline.run, + kwargs=dict( + disc=0, + title=title, + movie_name=movie_name, + raw_id=raw_id, + settings=settings, + status=job, + jellyfin_token=os.environ.get("JELLYFIN_TOKEN"), + pushover_token=os.environ.get("PUSHOVER_TOKEN"), + pushover_user=os.environ.get("PUSHOVER_USER"), + ), + daemon=True, + ) + thread.start() + return redirect(url_for("rip_status")) + + @app.route("/rip/status") + def rip_status(): + return render_template("status.html", job=app.config["JOB"]) return app diff --git a/src/simplefe/config.py b/src/simplefe/config.py @@ -1,17 +0,0 @@ -import tomllib - - -class ConfigError(Exception): - pass - - -def load_jobs(path): - with open(path, "rb") as f: - data = tomllib.load(f) - jobs = data["jobs"] - - for name, job in jobs.items(): - if "path" not in job: - raise ConfigError(f"Job '{name} is missing required key: path") - - return jobs diff --git a/src/simplefe/encode.py b/src/simplefe/encode.py @@ -0,0 +1,87 @@ +import subprocess +from pathlib import Path + + +def _has_stream(input_path, select, lang): + result = subprocess.run( + [ + "ffprobe", "-v", "quiet", + "-select_streams", f"{select}:m:language:{lang}", + "-show_streams", str(input_path), + ], + capture_output=True, + text=True, + ) + return "index=" in result.stdout + + +def _video_height(input_path): + result = subprocess.run( + [ + "ffprobe", "-v", "quiet", + "-select_streams", "v:0", + "-show_entries", "stream=height", + "-of", "default=noprint_wrappers=1:nokey=1", + str(input_path), + ], + capture_output=True, + text=True, + ) + lines = [line for line in result.stdout.splitlines() if line.strip()] + return int(lines[0]) if lines else None + + +def build_stream_maps(input_path, settings): + maps = ["-map", "0:v"] + if _has_stream(input_path, "a", settings.audio_lang): + maps += ["-map", f"0:a:m:language:{settings.audio_lang}"] + else: + maps += ["-map", "0:a"] + if _has_stream(input_path, "s", settings.subtitle_lang): + maps += ["-map", f"0:s:m:language:{settings.subtitle_lang}"] + return maps + + +def should_use_software(input_path, settings): + if settings.force_software: + return True + height = _video_height(input_path) + return height is not None and height <= settings.sd_height_threshold + + +def build_ffmpeg_command(input_path, output_path, settings, use_software): + maps = build_stream_maps(input_path, settings) + base = ["ffmpeg", "-probesize", "100M", "-analyzeduration", "100M"] + if use_software: + return [ + *base, + "-i", str(input_path), + *maps, + "-c:v", "libx265", + "-crf", str(settings.crf), + "-preset", settings.preset, + "-c:a", "copy", + "-c:s", "copy", + str(output_path), + ] + return [ + *base, + "-vaapi_device", settings.vaapi_device, + "-i", str(input_path), + *maps, + "-vf", "format=nv12,hwupload,scale_vaapi=format=nv12", + "-c:v", "hevc_vaapi", + "-qp", str(settings.crf), + "-c:a", "copy", + "-c:s", "copy", + str(output_path), + ] + + +def encode(input_path, output_path, settings): + input_path = Path(input_path) + output_path = Path(output_path) + use_software = should_use_software(input_path, settings) + command = build_ffmpeg_command(input_path, output_path, settings, use_software) + subprocess.run(command, check=True) + return use_software diff --git a/src/simplefe/makemkv.py b/src/simplefe/makemkv.py @@ -0,0 +1,140 @@ +import re +import sqlite3 +import subprocess +from dataclasses import dataclass +from pathlib import Path + +MAKEMKV_COMMAND = ["flatpak", "run", "--command=makemkvcon", "com.makemkv.MakeMKV"] + +_JUNK_WORDS = { + "ws", "fs", "bd", "uhd", "hd", "3d", + "disc1", "disc2", "disc3", "disc4", +} +_RESOLUTION_RE = re.compile(r"^\d+x\d+$") +_DURATION_RE = re.compile(r"^(\d+):(\d+):(\d+)$") + + +@dataclass +class DiscInfo: + raw_id: str + guessed_name: str + main_title: int + + +def _cinfo_value(lines, attribute_id): + prefix = f"CINFO:{attribute_id}," + for line in lines: + if line.startswith(prefix): + parts = line.split('"') + if len(parts) >= 2: + return parts[1] + return "" + + +def _title_durations(lines): + durations = {} + for line in lines: + if not line.startswith("TINFO:"): + continue + parts = line.split(",", 3) + if len(parts) < 4 or parts[1] != "9" or parts[2] != "0": + continue + title_id = int(parts[0].removeprefix("TINFO:")) + value = parts[3].split('"')[1] if '"' in parts[3] else parts[3] + match = _DURATION_RE.match(value) + if not match: + continue + hours, minutes, seconds = (int(x) for x in match.groups()) + durations[title_id] = hours * 3600 + minutes * 60 + seconds + return durations + + +def guess_movie_name(raw_id): + if not raw_id: + return "" + parts = raw_id.replace("_", " ").split() + clean = [ + p + for p in parts + if p and p.lower() not in _JUNK_WORDS and not _RESOLUTION_RE.match(p.lower()) + ] + return " ".join(p.capitalize() for p in clean) + + +def parse_disc_info(output_text): + lines = output_text.splitlines() + raw_id = _cinfo_value(lines, 2) + durations = _title_durations(lines) + main_title = max(durations, key=durations.get) if durations else 0 + return DiscInfo( + raw_id=raw_id, guessed_name=guess_movie_name(raw_id), main_title=main_title + ) + + +def query_disc(disc=0): + result = subprocess.run( + [*MAKEMKV_COMMAND, "-r", "info", f"disc:{disc}"], + capture_output=True, + text=True, + ) + return parse_disc_info(result.stdout) + + +def init_db(db_path): + conn = sqlite3.connect(db_path) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS rips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + disc_id TEXT NOT NULL, + title TEXT, + output_file TEXT, + output_bytes INTEGER, + ripped_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """ + ) + conn.commit() + return conn + + +def find_existing_rip(db_path, raw_id): + if not raw_id: + return None + conn = init_db(db_path) + try: + row = conn.execute( + "SELECT title, output_file, ripped_at FROM rips WHERE disc_id = ? LIMIT 1", + (raw_id,), + ).fetchone() + finally: + conn.close() + if row is None: + return None + return {"title": row[0], "output_file": row[1], "ripped_at": row[2]} + + +def record_rip(db_path, raw_id, title, output_file, output_bytes): + conn = init_db(db_path) + try: + conn.execute( + "INSERT INTO rips (disc_id, title, output_file, output_bytes) " + "VALUES (?, ?, ?, ?)", + (raw_id, title, output_file, output_bytes), + ) + conn.commit() + finally: + conn.close() + + +def rip_title(disc, title, output_dir): + output_path = Path(output_dir) + before = set(output_path.glob("*.mkv")) + subprocess.run( + [*MAKEMKV_COMMAND, "mkv", f"disc:{disc}", str(title), str(output_path)], + check=True, + ) + new_files = set(output_path.glob("*.mkv")) - before + if not new_files: + raise RuntimeError(f"could not find ripped MKV in {output_dir}") + return max(new_files, key=lambda p: p.stat().st_mtime) diff --git a/src/simplefe/notify.py b/src/simplefe/notify.py @@ -0,0 +1,28 @@ +import urllib.error +import urllib.parse +import urllib.request + +PUSHOVER_URL = "https://api.pushover.net/1/messages.json" + + +def send_pushover(token, user, title, message): + data = urllib.parse.urlencode( + {"token": token, "user": user, "title": title, "message": message} + ).encode() + request = urllib.request.Request(PUSHOVER_URL, data=data) + try: + urllib.request.urlopen(request, timeout=10) + return True + except OSError: + return False + + +def trigger_jellyfin_scan(base_url, task_id, jellyfin_token): + url = f"{base_url}/ScheduledTasks/Running/{task_id}" + request = urllib.request.Request(url, method="POST") + request.add_header("X-Emby-Token", jellyfin_token) + try: + urllib.request.urlopen(request, timeout=10) + return True + except OSError: + return False diff --git a/src/simplefe/pipeline.py b/src/simplefe/pipeline.py @@ -0,0 +1,86 @@ +import grp +import os +import subprocess +from pathlib import Path + +from simplefe import encode as encode_module +from simplefe import makemkv +from simplefe import notify as notify_module + + +def _set(status, stage, detail=None): + status["stage"] = stage + status["detail"] = detail + + +def _set_group_permissions(path, group_name): + if not group_name: + return + try: + gid = grp.getgrnam(group_name).gr_gid + os.chown(path, -1, gid) + os.chmod(path, 0o2775) + except (KeyError, OSError): + pass + + +def run( + disc, + title, + movie_name, + raw_id, + settings, + status, + jellyfin_token=None, + pushover_token=None, + pushover_user=None, +): + status["running"] = True + status["error"] = None + try: + _set(status, "ripping", f"Ripping title {title} from disc {disc}...") + ripped = makemkv.rip_title(disc, title, settings.output_dir) + + _set(status, "moving", f"Moving to {movie_name}...") + dest_dir = Path(settings.output_dir) / movie_name + dest_dir.mkdir(parents=True, exist_ok=True) + _set_group_permissions(dest_dir, settings.output_group) + dest = dest_dir / f"{movie_name}.mkv" + ripped = ripped.rename(dest) + + encoded = ripped.with_name(f"{ripped.stem}_x265.mkv") + _set(status, "encoding", f"Encoding {ripped.name}...") + encode_module.encode(ripped, encoded, settings) + + insize = ripped.stat().st_size + outsize = encoded.stat().st_size + ripped.unlink() + + _set(status, "recording", "Recording rip in database...") + makemkv.record_rip( + settings.rip_db_path, raw_id, movie_name, str(encoded), outsize + ) + + if pushover_token and pushover_user: + _set(status, "notifying", "Sending notification...") + notify_module.send_pushover( + pushover_token, + pushover_user, + "Rip complete", + f"{movie_name} — {insize} → {outsize} bytes", + ) + + if jellyfin_token: + _set(status, "scanning", "Triggering Jellyfin scan...") + notify_module.trigger_jellyfin_scan( + settings.jellyfin_url, settings.jellyfin_scan_task_id, jellyfin_token + ) + + _set(status, "ejecting", "Ejecting disc...") + subprocess.run(["eject", settings.drive_path], capture_output=True) + + _set(status, "done", f"Done. Kept {encoded}.") + except Exception as exc: + status["error"] = str(exc) + finally: + status["running"] = False diff --git a/src/simplefe/runner.py b/src/simplefe/runner.py @@ -1,9 +0,0 @@ -import pexpect - - -def run_job(command, prompt, response): - child = pexpect.spawn(command) - child.expect(prompt) - child.sendline(response) - child.expect(pexpect.EOF) - return child.before diff --git a/src/simplefe/settings.py b/src/simplefe/settings.py @@ -0,0 +1,46 @@ +import os +from dataclasses import dataclass + + +def _bool(value): + if value is None: + return False + return value.strip().lower() in {"1", "true", "yes", "on"} + + +@dataclass +class Settings: + crf: str = "22" + preset: str = "medium" + force_software: bool = False + sd_height_threshold: int = 576 + vaapi_device: str = "/dev/dri/renderD128" + audio_lang: str = "eng" + subtitle_lang: str = "eng" + drive_path: str = "/dev/sr0" + output_dir: str = "/mnt/Movies" + rip_db_path: str = "/mnt/Movies/.rip.db" + jellyfin_url: str = "http://192.168.0.241:8096" + jellyfin_scan_task_id: str = "7738148ffcd07979c7ceb148e06b3aed" + output_group: str = "" + + +def load_settings(env=None): + env = os.environ if env is None else env + return Settings( + crf=env.get("ENCODE_CRF", "22"), + preset=env.get("ENCODE_PRESET", "medium"), + force_software=_bool(env.get("ENCODE_FORCE_SOFTWARE")), + sd_height_threshold=int(env.get("ENCODE_SD_HEIGHT_THRESHOLD", "576")), + vaapi_device=env.get("ENCODE_VAAPI_DEVICE", "/dev/dri/renderD128"), + audio_lang=env.get("ENCODE_AUDIO_LANG", "eng"), + subtitle_lang=env.get("ENCODE_SUBTITLE_LANG", "eng"), + drive_path=env.get("DRIVE_PATH", "/dev/sr0"), + output_dir=env.get("OUTPUT_DIR", "/mnt/Movies"), + rip_db_path=env.get("RIP_DB_PATH", "/mnt/Movies/.rip.db"), + jellyfin_url=env.get("JELLYFIN_URL", "http://192.168.0.241:8096"), + jellyfin_scan_task_id=env.get( + "JELLYFIN_SCAN_TASK_ID", "7738148ffcd07979c7ceb148e06b3aed" + ), + output_group=env.get("OUTPUT_GROUP", ""), + ) diff --git a/src/simplefe/templates/base.html b/src/simplefe/templates/base.html @@ -0,0 +1,56 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>simplefe</title> + {% block head_extra %}{% endblock %} + <style> + :root { color-scheme: light dark; } + body { + font-family: system-ui, sans-serif; + font-size: 1.15rem; + line-height: 1.5; + max-width: 40rem; + margin: 0 auto; + padding: 1.5rem; + } + h1 { font-size: 1.7rem; margin-bottom: 1rem; } + label { display: block; margin: 1.2rem 0 0.4rem; font-weight: bold; } + input[type="text"] { + width: 100%; + font-size: 1.15rem; + padding: 0.7rem; + box-sizing: border-box; + border: 1px solid #888; + border-radius: 0.4rem; + } + input[type="checkbox"] { + width: 1.4rem; + height: 1.4rem; + vertical-align: middle; + margin-right: 0.4rem; + } + button { + font-size: 1.15rem; + padding: 0.8rem 1.6rem; + margin-top: 1.3rem; + border: none; + border-radius: 0.4rem; + background: #2563eb; + color: white; + cursor: pointer; + } + button:active { background: #1e40af; } + a { color: #2563eb; } + ul { padding-left: 1.3rem; } + li { margin: 0.3rem 0; } + .error { color: #dc2626; font-weight: bold; } + .warning { background: rgba(217, 119, 6, 0.15); padding: 0.9rem; border-radius: 0.4rem; } + .notice { background: rgba(37, 99, 235, 0.12); padding: 0.9rem; border-radius: 0.4rem; } + </style> +</head> +<body> +{% block content %}{% endblock %} +</body> +</html> diff --git a/src/simplefe/templates/index.html b/src/simplefe/templates/index.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block content %} +<h1>Rip</h1> + +{% if needs_setup %} + <p class="notice">Notifications aren't configured yet. <a href="{{ url_for('setup') }}">Set up now</a>.</p> +{% endif %} + +{% if error %} + <p class="error">{{ error }}</p> +{% endif %} + +{% if job.running %} + <p>A rip is already in progress. <a href="{{ url_for('rip_status') }}">View status</a>.</p> +{% else %} + <p>Disc ID: {{ info.raw_id or "(none detected)" }}</p> + + {% if existing %} + <div class="warning"> + <p>Warning: this disc has already been ripped:</p> + <ul> + <li>Title: {{ existing.title }}</li> + <li>File: {{ existing.output_file }}</li> + <li>Ripped at: {{ existing.ripped_at }}</li> + </ul> + </div> + {% endif %} + + <form method="post" action="{{ url_for('rip_start') }}"> + <input type="hidden" name="raw_id" value="{{ info.raw_id }}"> + <input type="hidden" name="title" value="{{ info.main_title }}"> + <label for="movie_name">Movie name</label> + <input type="text" id="movie_name" name="movie_name" value="{{ info.guessed_name }}"> + {% if existing %} + <label><input type="checkbox" name="override_duplicate"> Rip again anyway</label> + {% endif %} + <button type="submit">Start Rip</button> + </form> +{% endif %} +{% endblock %} diff --git a/src/simplefe/templates/setup.html b/src/simplefe/templates/setup.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block content %} +<h1>Setup</h1> + +<p>These are optional -- leave any blank to skip that notification/scan.</p> + +<form method="post" action="{{ url_for('setup') }}"> + <label for="jellyfin_token">Jellyfin token</label> + <input type="text" id="jellyfin_token" name="JELLYFIN_TOKEN" value="{{ values.JELLYFIN_TOKEN }}"> + + <label for="pushover_token">Pushover token</label> + <input type="text" id="pushover_token" name="PUSHOVER_TOKEN" value="{{ values.PUSHOVER_TOKEN }}"> + + <label for="pushover_user">Pushover user key</label> + <input type="text" id="pushover_user" name="PUSHOVER_USER" value="{{ values.PUSHOVER_USER }}"> + + <button type="submit">Save</button> +</form> + +<p><a href="{{ url_for('rip_index') }}">Back to disc info</a></p> +{% endblock %} diff --git a/src/simplefe/templates/status.html b/src/simplefe/templates/status.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block head_extra %} +{% if job.running %} +<meta http-equiv="refresh" content="5"> +{% endif %} +{% endblock %} +{% block content %} +<h1>Rip Status</h1> + +{% if job.error %} + <p class="error">Error during {{ job.stage }}: {{ job.error }}</p> +{% elif job.stage == "done" %} + <p>{{ job.detail }}</p> +{% elif job.running %} + <p>Stage: {{ job.stage }}</p> + <p>{{ job.detail }}</p> +{% else %} + <p>No rip has been started yet. <a href="{{ url_for('rip_index') }}">Go to disc info</a>.</p> +{% endif %} + +<p><a href="{{ url_for('rip_index') }}">Back</a></p> +{% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py @@ -4,8 +4,8 @@ from simplefe.app import create_app @pytest.fixture() -def app(): - app = create_app(jobs={"rip": {"path": "/fake/path"}}, token="test-token") +def app(tmp_path): + app = create_app(token="test-token", dotenv_path=str(tmp_path / ".env")) app.config.update({"TESTING": True}) yield app diff --git a/tests/fixtures/fake_job.py b/tests/fixtures/fake_job.py @@ -1,2 +0,0 @@ -name = input("Enter movie name: ") -print(f"Got: {name}") diff --git a/tests/test_app.py b/tests/test_app.py @@ -1,42 +1,238 @@ -from pathlib import Path +import os -from simplefe.app import create_app +import simplefe.app as app_module -FIXTURES = Path(__file__).parent / "fixtures" +class ImmediateThread: + def __init__(self, target=None, kwargs=None, daemon=None): + self._target = target + self._kwargs = kwargs or {} -def test_list_jobs(client, auth_headers): - response = client.get("/jobs", headers=auth_headers) - assert response.json == {"jobs": ["rip"]} + def start(self): + self._target(**self._kwargs) -def test_get_job(client, auth_headers): - response = client.get("/jobs/rip", headers=auth_headers) - assert response.json == {"path": "/fake/path"} +def test_rip_index_shows_disc_info(client, auth_headers, monkeypatch): + monkeypatch.setattr( + app_module.makemkv, + "query_disc", + lambda: app_module.makemkv.DiscInfo( + raw_id="ARRIVAL_2016", guessed_name="Arrival 2016", main_title=1 + ), + ) + monkeypatch.setattr( + app_module.makemkv, "find_existing_rip", lambda db, raw_id: None + ) + response = client.get("/rip", headers=auth_headers) + assert response.status_code == 200 + assert b"Arrival 2016" in response.data + assert b"already been ripped" not in response.data -def test_get_job_not_found(client, auth_headers): - response = client.get("/jobs/nonexistent", headers=auth_headers) - assert response.status_code == 404 +def test_rip_index_skips_disc_query_while_job_running(client, auth_headers, monkeypatch): + def fail_if_called(): + raise AssertionError("query_disc should not be called while a job is running") -def test_trigger_job(auth_headers): - fake_job = FIXTURES / "fake_job.py" - app = create_app( - jobs={ - "rip": { - "path": f"python3 {fake_job}", - "prompt": "Enter movie name:", - } + monkeypatch.setattr(app_module.makemkv, "query_disc", fail_if_called) + client.application.config["JOB"]["running"] = True + + response = client.get("/rip", headers=auth_headers) + assert response.status_code == 200 + assert b"already in progress" in response.data + + +def test_rip_index_shows_duplicate_warning(client, auth_headers, monkeypatch): + monkeypatch.setattr( + app_module.makemkv, + "query_disc", + lambda: app_module.makemkv.DiscInfo( + raw_id="ARRIVAL_2016", guessed_name="Arrival 2016", main_title=1 + ), + ) + monkeypatch.setattr( + app_module.makemkv, + "find_existing_rip", + lambda db, raw_id: { + "title": "Arrival 2016", + "output_file": "/x.mkv", + "ripped_at": "now", }, - token="test-token", ) - app.config.update({"TESTING": True}) - client = app.test_client() + response = client.get("/rip", headers=auth_headers) + assert response.status_code == 200 + assert b"already been ripped" in response.data + + +def test_rip_start_requires_movie_name(client, auth_headers, monkeypatch): + monkeypatch.setattr( + app_module.makemkv, "find_existing_rip", lambda db, raw_id: None + ) response = client.post( - "/jobs/rip/run", json={"response": "The Matrix"}, headers=auth_headers + "/rip/start", + data={"movie_name": "", "raw_id": "X", "title": "1"}, + headers=auth_headers, ) + assert response.status_code == 200 + assert b"Movie name is required" in response.data + +def test_rip_start_blocks_duplicate_without_override(client, auth_headers, monkeypatch): + monkeypatch.setattr( + app_module.makemkv, + "find_existing_rip", + lambda db, raw_id: {"title": "X", "output_file": "/x.mkv", "ripped_at": "now"}, + ) + response = client.post( + "/rip/start", + data={"movie_name": "Arrival 2016", "raw_id": "X", "title": "1"}, + headers=auth_headers, + ) assert response.status_code == 200 - assert "Got: The Matrix" in response.json["output"] + assert b"already been ripped" in response.data + + +def test_rip_start_allows_duplicate_with_override(client, auth_headers, monkeypatch): + calls = [] + monkeypatch.setattr( + app_module.makemkv, + "find_existing_rip", + lambda db, raw_id: {"title": "X", "output_file": "/x.mkv", "ripped_at": "now"}, + ) + monkeypatch.setattr(app_module.pipeline, "run", lambda **kwargs: calls.append(kwargs)) + monkeypatch.setattr(app_module.threading, "Thread", ImmediateThread) + + response = client.post( + "/rip/start", + data={ + "movie_name": "Arrival 2016", + "raw_id": "X", + "title": "1", + "override_duplicate": "on", + }, + headers=auth_headers, + ) + assert response.status_code == 302 + assert len(calls) == 1 + assert calls[0]["movie_name"] == "Arrival 2016" + + +def test_rip_start_rejects_when_already_running(client, auth_headers): + client.application.config["JOB"]["running"] = True + response = client.post( + "/rip/start", + data={"movie_name": "X", "raw_id": "Y", "title": "0"}, + headers=auth_headers, + ) + assert response.status_code == 409 + + +def test_rip_status_shows_idle_message(client, auth_headers): + response = client.get("/rip/status", headers=auth_headers) + assert response.status_code == 200 + assert b"No rip has been started" in response.data + + +def test_rip_status_shows_progress(client, auth_headers): + client.application.config["JOB"].update( + { + "running": True, + "stage": "encoding", + "detail": "Encoding movie.mkv...", + "error": None, + } + ) + response = client.get("/rip/status", headers=auth_headers) + assert b"encoding" in response.data + assert b"Encoding movie.mkv" in response.data + + +def test_routes_require_auth(client): + response = client.get("/rip") + assert response.status_code == 401 + + +def _clear_setup_keys(monkeypatch): + for key in app_module.SETUP_KEYS: + monkeypatch.delenv(key, raising=False) + + +def test_rip_index_shows_setup_banner_when_unconfigured(client, auth_headers, monkeypatch): + _clear_setup_keys(monkeypatch) + monkeypatch.setattr( + app_module.makemkv, + "query_disc", + lambda: app_module.makemkv.DiscInfo(raw_id="", guessed_name="", main_title=0), + ) + monkeypatch.setattr( + app_module.makemkv, "find_existing_rip", lambda db, raw_id: None + ) + + response = client.get("/rip", headers=auth_headers) + assert b"Set up now" in response.data + + +def test_rip_index_hides_setup_banner_when_configured(client, auth_headers, monkeypatch): + _clear_setup_keys(monkeypatch) + monkeypatch.setenv("JELLYFIN_TOKEN", "jtok") + monkeypatch.setenv("PUSHOVER_TOKEN", "ptok") + monkeypatch.setenv("PUSHOVER_USER", "puser") + monkeypatch.setattr( + app_module.makemkv, + "query_disc", + lambda: app_module.makemkv.DiscInfo(raw_id="", guessed_name="", main_title=0), + ) + monkeypatch.setattr( + app_module.makemkv, "find_existing_rip", lambda db, raw_id: None + ) + + response = client.get("/rip", headers=auth_headers) + assert b"Set up now" not in response.data + + +def test_setup_get_shows_current_values(client, auth_headers, monkeypatch): + _clear_setup_keys(monkeypatch) + monkeypatch.setenv("JELLYFIN_TOKEN", "existing-jtok") + + response = client.get("/setup", headers=auth_headers) + assert response.status_code == 200 + assert b"existing-jtok" in response.data + + +def test_setup_post_saves_values_and_applies_immediately(client, auth_headers, monkeypatch): + _clear_setup_keys(monkeypatch) + + response = client.post( + "/setup", + data={ + "JELLYFIN_TOKEN": "new-jtok", + "PUSHOVER_TOKEN": "new-ptok", + "PUSHOVER_USER": "new-puser", + }, + headers=auth_headers, + ) + assert response.status_code == 302 + + assert os.environ["JELLYFIN_TOKEN"] == "new-jtok" + assert os.environ["PUSHOVER_TOKEN"] == "new-ptok" + assert os.environ["PUSHOVER_USER"] == "new-puser" + + dotenv_path = client.application.config["DOTENV_PATH"] + content = open(dotenv_path).read() + assert "new-jtok" in content + + +def test_setup_post_blank_field_does_not_overwrite_existing_value( + client, auth_headers, monkeypatch +): + _clear_setup_keys(monkeypatch) + monkeypatch.setenv("JELLYFIN_TOKEN", "keep-me") + + client.post( + "/setup", + data={"JELLYFIN_TOKEN": "", "PUSHOVER_TOKEN": "ptok", "PUSHOVER_USER": "puser"}, + headers=auth_headers, + ) + + assert os.environ["JELLYFIN_TOKEN"] == "keep-me" diff --git a/tests/test_config.py b/tests/test_config.py @@ -1,41 +0,0 @@ -import pytest - -from simplefe.config import ConfigError, load_jobs - - -def test_load_jobs_raises_on_missing_path(tmp_path): - config_file = tmp_path / "jobs.toml" - config_file.write_text(""" - [jobs.rip] - """) - with pytest.raises(ConfigError, match="rip"): - load_jobs(config_file) - - -def test_load_jobs(tmp_path): - config_file = tmp_path / "jobs.toml" - config_file.write_text(""" - [jobs.rip] - path = ".scripts/rip" - """) - - result = load_jobs(config_file) - assert result == {"rip": {"path": ".scripts/rip"}} - - -def test_load_jobs_with_multiple_jobs(tmp_path): - config_file = tmp_path / "jobs.toml" - config_file.write_text(""" -[jobs.rip] -path = ".scripts/rip" - -[jobs.encode] -path = ".scripts/encode" -""") - - result = load_jobs(config_file) - - assert result == { - "rip": {"path": ".scripts/rip"}, - "encode": {"path": ".scripts/encode"}, - } diff --git a/tests/test_encode.py b/tests/test_encode.py @@ -0,0 +1,76 @@ +from simplefe import encode +from simplefe.settings import load_settings + + +def test_build_stream_maps_uses_language_filter_when_available(monkeypatch): + monkeypatch.setattr(encode, "_has_stream", lambda path, select, lang: True) + settings = load_settings(env={}) + maps = encode.build_stream_maps("movie.mkv", settings) + assert maps == ["-map", "0:v", "-map", "0:a:m:language:eng", "-map", "0:s:m:language:eng"] + + +def test_build_stream_maps_falls_back_to_all_audio_when_no_english(monkeypatch): + monkeypatch.setattr(encode, "_has_stream", lambda path, select, lang: False) + settings = load_settings(env={}) + maps = encode.build_stream_maps("movie.mkv", settings) + assert maps == ["-map", "0:v", "-map", "0:a"] + + +def test_should_use_software_true_when_forced(monkeypatch): + settings = load_settings(env={"ENCODE_FORCE_SOFTWARE": "true"}) + + def fail_if_called(path): + raise AssertionError("should not check height when force_software is set") + + monkeypatch.setattr(encode, "_video_height", fail_if_called) + assert encode.should_use_software("movie.mkv", settings) is True + + +def test_should_use_software_true_for_sd_content(monkeypatch): + monkeypatch.setattr(encode, "_video_height", lambda path: 480) + settings = load_settings(env={}) + assert encode.should_use_software("movie.mkv", settings) is True + + +def test_should_use_software_false_for_hd_content(monkeypatch): + monkeypatch.setattr(encode, "_video_height", lambda path: 1080) + settings = load_settings(env={}) + assert encode.should_use_software("movie.mkv", settings) is False + + +def test_build_ffmpeg_command_software(): + settings = load_settings(env={"ENCODE_CRF": "18"}) + command = encode.build_ffmpeg_command( + "in.mkv", "out.mkv", settings, use_software=True + ) + assert "libx265" in command + assert "-crf" in command and "18" in command + assert "-preset" in command and "medium" in command + assert command[-1] == "out.mkv" + + +def test_build_ffmpeg_command_hardware(): + settings = load_settings(env={"ENCODE_CRF": "20"}) + command = encode.build_ffmpeg_command( + "in.mkv", "out.mkv", settings, use_software=False + ) + assert "hevc_vaapi" in command + assert "-qp" in command and "20" in command + assert "-vaapi_device" in command and "/dev/dri/renderD128" in command + + +def test_encode_runs_ffmpeg_and_returns_encoder_choice(tmp_path, monkeypatch): + calls = [] + monkeypatch.setattr(encode, "should_use_software", lambda path, settings: True) + monkeypatch.setattr( + encode, + "build_ffmpeg_command", + lambda i, o, s, use_software: ["ffmpeg", "fake", "command"], + ) + monkeypatch.setattr( + encode.subprocess, "run", lambda cmd, check=True: calls.append(cmd) + ) + settings = load_settings(env={}) + used_software = encode.encode(tmp_path / "in.mkv", tmp_path / "out.mkv", settings) + assert used_software is True + assert calls == [["ffmpeg", "fake", "command"]] diff --git a/tests/test_makemkv.py b/tests/test_makemkv.py @@ -0,0 +1,91 @@ +import pytest + +from simplefe import makemkv + +SAMPLE_OUTPUT = """\ +MSG:1005,0,1,"MakeMKV v1.18.4 linux(x64-release) started","%1","1.18.4" +DRV:0,2,999,12,"BD-RE PIONEER BD-RW BDR-XD08 1.02","ARRIVAL_2016_UHD","/dev/sr0" +CINFO:1,6209,"Blu-ray disc" +CINFO:2,0,"ARRIVAL_2016_UHD" +CINFO:28,0,"1" +TCOUNT:2 +TINFO:0,2,0,"Title 0" +TINFO:0,9,0,"0:03:12" +TINFO:0,27,0,"title00.mkv" +TINFO:1,2,0,"Title 1" +TINFO:1,9,0,"1:56:09" +TINFO:1,27,0,"title01.mkv" +""" + + +def test_guess_movie_name_strips_junk_words_and_underscores(): + assert makemkv.guess_movie_name("ARRIVAL_2016_UHD") == "Arrival 2016" + + +def test_guess_movie_name_strips_resolution_tokens(): + assert makemkv.guess_movie_name("MOVIE_1920x1080_BD") == "Movie" + + +def test_guess_movie_name_empty_input(): + assert makemkv.guess_movie_name("") == "" + + +def test_parse_disc_info_picks_longest_title_as_main(): + info = makemkv.parse_disc_info(SAMPLE_OUTPUT) + assert info.raw_id == "ARRIVAL_2016_UHD" + assert info.guessed_name == "Arrival 2016" + assert info.main_title == 1 + + +def test_parse_disc_info_handles_empty_output(): + info = makemkv.parse_disc_info("") + assert info.raw_id == "" + assert info.guessed_name == "" + assert info.main_title == 0 + + +def test_find_existing_rip_returns_none_when_not_present(tmp_path): + db_path = tmp_path / "rip.db" + assert makemkv.find_existing_rip(str(db_path), "SOME_DISC_ID") is None + + +def test_find_existing_rip_with_empty_raw_id_returns_none(tmp_path): + db_path = tmp_path / "rip.db" + assert makemkv.find_existing_rip(str(db_path), "") is None + + +def test_record_and_find_existing_rip(tmp_path): + db_path = tmp_path / "rip.db" + makemkv.record_rip( + str(db_path), "SOME_DISC_ID", "Arrival 2016", "/mnt/Movies/Arrival 2016.mkv", 123456 + ) + existing = makemkv.find_existing_rip(str(db_path), "SOME_DISC_ID") + assert existing["title"] == "Arrival 2016" + assert existing["output_file"] == "/mnt/Movies/Arrival 2016.mkv" + assert existing["ripped_at"] + + +def test_rip_title_finds_newly_created_file(tmp_path, monkeypatch): + def fake_run(cmd, check=True): + (tmp_path / "title01.mkv").write_bytes(b"fake mkv data") + + monkeypatch.setattr(makemkv.subprocess, "run", fake_run) + result = makemkv.rip_title(0, 1, tmp_path) + assert result == tmp_path / "title01.mkv" + + +def test_rip_title_ignores_pre_existing_files(tmp_path, monkeypatch): + (tmp_path / "old.mkv").write_bytes(b"old") + + def fake_run(cmd, check=True): + (tmp_path / "new.mkv").write_bytes(b"new") + + monkeypatch.setattr(makemkv.subprocess, "run", fake_run) + result = makemkv.rip_title(0, 1, tmp_path) + assert result == tmp_path / "new.mkv" + + +def test_rip_title_raises_if_no_new_file(tmp_path, monkeypatch): + monkeypatch.setattr(makemkv.subprocess, "run", lambda cmd, check=True: None) + with pytest.raises(RuntimeError): + makemkv.rip_title(0, 1, tmp_path) diff --git a/tests/test_notify.py b/tests/test_notify.py @@ -0,0 +1,46 @@ +from simplefe import notify + + +def test_send_pushover_success(monkeypatch): + captured = {} + + def fake_urlopen(request, timeout=10): + captured["url"] = request.full_url + captured["data"] = request.data + + monkeypatch.setattr(notify.urllib.request, "urlopen", fake_urlopen) + result = notify.send_pushover("tok", "usr", "Title", "Message") + assert result is True + assert captured["url"] == notify.PUSHOVER_URL + assert b"token=tok" in captured["data"] + assert b"user=usr" in captured["data"] + + +def test_send_pushover_failure_returns_false(monkeypatch): + def fake_urlopen(request, timeout=10): + raise OSError("network down") + + monkeypatch.setattr(notify.urllib.request, "urlopen", fake_urlopen) + assert notify.send_pushover("tok", "usr", "Title", "Message") is False + + +def test_trigger_jellyfin_scan_success(monkeypatch): + captured = {} + + def fake_urlopen(request, timeout=10): + captured["url"] = request.full_url + captured["headers"] = dict(request.header_items()) + + monkeypatch.setattr(notify.urllib.request, "urlopen", fake_urlopen) + result = notify.trigger_jellyfin_scan("http://host:8096", "task123", "jtoken") + assert result is True + assert captured["url"] == "http://host:8096/ScheduledTasks/Running/task123" + assert captured["headers"] == {"X-emby-token": "jtoken"} + + +def test_trigger_jellyfin_scan_failure_returns_false(monkeypatch): + def fake_urlopen(request, timeout=10): + raise OSError("unreachable") + + monkeypatch.setattr(notify.urllib.request, "urlopen", fake_urlopen) + assert notify.trigger_jellyfin_scan("http://host:8096", "task123", "jtoken") is False diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py @@ -0,0 +1,145 @@ +from pathlib import Path + +from simplefe import pipeline +from simplefe.settings import load_settings + + +def _settings(tmp_path): + return load_settings( + env={ + "OUTPUT_DIR": str(tmp_path), + "RIP_DB_PATH": str(tmp_path / "rip.db"), + } + ) + + +def _fresh_status(): + return {"running": False, "stage": None, "detail": None, "error": None} + + +def test_run_success_updates_status_through_stages(tmp_path, monkeypatch): + ripped_file = tmp_path / "title00.mkv" + + def fake_rip_title(disc, title, out_dir): + ripped_file.write_bytes(b"ripped data") + return ripped_file + + def fake_encode(input_path, output_path, settings): + Path(output_path).write_bytes(b"encoded data") + return True + + record_calls = [] + pushover_calls = [] + jellyfin_calls = [] + eject_calls = [] + + monkeypatch.setattr(pipeline.makemkv, "rip_title", fake_rip_title) + monkeypatch.setattr(pipeline.encode_module, "encode", fake_encode) + monkeypatch.setattr( + pipeline.makemkv, + "record_rip", + lambda *args, **kwargs: record_calls.append((args, kwargs)), + ) + monkeypatch.setattr( + pipeline.notify_module, + "send_pushover", + lambda *args, **kwargs: pushover_calls.append((args, kwargs)) or True, + ) + monkeypatch.setattr( + pipeline.notify_module, + "trigger_jellyfin_scan", + lambda *args, **kwargs: jellyfin_calls.append((args, kwargs)) or True, + ) + monkeypatch.setattr( + pipeline.subprocess, "run", lambda *args, **kwargs: eject_calls.append(args) + ) + + status = _fresh_status() + pipeline.run( + disc=0, + title=1, + movie_name="Test Movie", + raw_id="TEST_RAW_ID", + settings=_settings(tmp_path), + status=status, + jellyfin_token="jtok", + pushover_token="ptok", + pushover_user="puser", + ) + + assert status["error"] is None + assert status["running"] is False + assert status["stage"] == "done" + assert len(record_calls) == 1 + assert len(pushover_calls) == 1 + assert len(jellyfin_calls) == 1 + assert len(eject_calls) == 1 + assert not ripped_file.exists() + encoded_file = tmp_path / "Test Movie" / "Test Movie_x265.mkv" + assert encoded_file.exists() + + +def test_run_failure_sets_error_and_stops_at_that_stage(tmp_path, monkeypatch): + def failing_rip_title(disc, title, out_dir): + raise RuntimeError("drive not found") + + monkeypatch.setattr(pipeline.makemkv, "rip_title", failing_rip_title) + + status = _fresh_status() + pipeline.run( + disc=0, + title=1, + movie_name="Test Movie", + raw_id="ID", + settings=_settings(tmp_path), + status=status, + ) + + assert status["running"] is False + assert status["error"] == "drive not found" + assert status["stage"] == "ripping" + + +def test_run_skips_notifications_when_tokens_missing(tmp_path, monkeypatch): + ripped_file = tmp_path / "title00.mkv" + + def fake_rip_title(disc, title, out_dir): + ripped_file.write_bytes(b"ripped data") + return ripped_file + + def fake_encode(input_path, output_path, settings): + Path(output_path).write_bytes(b"encoded data") + return True + + pushover_calls = [] + jellyfin_calls = [] + + monkeypatch.setattr(pipeline.makemkv, "rip_title", fake_rip_title) + monkeypatch.setattr(pipeline.encode_module, "encode", fake_encode) + monkeypatch.setattr(pipeline.makemkv, "record_rip", lambda *a, **k: None) + monkeypatch.setattr( + pipeline.notify_module, + "send_pushover", + lambda *a, **k: pushover_calls.append(1) or True, + ) + monkeypatch.setattr( + pipeline.notify_module, + "trigger_jellyfin_scan", + lambda *a, **k: jellyfin_calls.append(1) or True, + ) + monkeypatch.setattr(pipeline.subprocess, "run", lambda *a, **k: None) + + status = _fresh_status() + pipeline.run( + disc=0, + title=1, + movie_name="Test Movie", + raw_id="ID", + settings=_settings(tmp_path), + status=status, + ) + + assert status["error"] is None + assert status["stage"] == "done" + assert pushover_calls == [] + assert jellyfin_calls == [] diff --git a/tests/test_runner.py b/tests/test_runner.py @@ -1,11 +0,0 @@ -from pathlib import Path - -from simplefe.runner import run_job - -FIXTURES = Path(__file__).parent / "fixtures" - - -def test_run_job(): - fake_job = FIXTURES / "fake_job.py" - result = run_job(f"python3 {fake_job}", "Enter movie name:", "The Matrix") - assert b"Got: The Matrix" in result diff --git a/tests/test_settings.py b/tests/test_settings.py @@ -0,0 +1,34 @@ +from simplefe.settings import load_settings + + +def test_defaults_match_scripts_encode_defaults(): + settings = load_settings(env={}) + assert settings.crf == "22" + assert settings.preset == "medium" + assert settings.force_software is False + assert settings.sd_height_threshold == 576 + assert settings.vaapi_device == "/dev/dri/renderD128" + assert settings.audio_lang == "eng" + assert settings.subtitle_lang == "eng" + assert settings.drive_path == "/dev/sr0" + assert settings.output_dir == "/mnt/Movies" + assert settings.rip_db_path == "/mnt/Movies/.rip.db" + + +def test_env_overrides(): + settings = load_settings( + env={ + "ENCODE_CRF": "18", + "ENCODE_FORCE_SOFTWARE": "true", + "ENCODE_SD_HEIGHT_THRESHOLD": "480", + } + ) + assert settings.crf == "18" + assert settings.force_software is True + assert settings.sd_height_threshold == 480 + + +def test_bool_parsing_is_case_insensitive(): + assert load_settings(env={"ENCODE_FORCE_SOFTWARE": "YES"}).force_software is True + assert load_settings(env={"ENCODE_FORCE_SOFTWARE": "0"}).force_software is False + assert load_settings(env={}).force_software is False