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:
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