From fb6c64b4d64f7c46595bd03ea462c6210a7e6e3d Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:21:18 -0400 Subject: [PATCH 1/9] quality: shared release quality detection and ranking --- faucet/quality.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 faucet/quality.py diff --git a/faucet/quality.py b/faucet/quality.py new file mode 100644 index 0000000..0934399 --- /dev/null +++ b/faucet/quality.py @@ -0,0 +1,54 @@ +"""Release quality detection and ranking, shared by the scanner and the sorter. + +Dependency-free on purpose: ``faucet/sort.py`` imports this while running as a +subprocess of the completion hook, and must not drag in the DB or config just +to compare two release names. +""" +from __future__ import annotations + +import re + +# resolution tokens, best first +RES_TOKENS = [("2160p", ("2160p", "4k", "uhd")), ("1080p", ("1080p",)), + ("720p", ("720p",)), ("480p", ("480p",))] + +# higher = better; unknown resolution ranks 0 +RES_RANK = {"2160p": 4, "1080p": 3, "720p": 2, "480p": 1, None: 0, "": 0} + +_CAM_RE = re.compile( + r"\b(hd-?cam|cam-?rip|telesync|hd-?ts|telecine|dvd-?scr|screener|" + r"ts|tc|cam|scr)\b", re.IGNORECASE) + + +def detect_quality(name: str) -> str | None: + """Resolution label ('1080p', ...) found in a release/file name, or None.""" + n = (name or "").lower() + for label, toks in RES_TOKENS: + if any(t in n for t in toks): + return label + return None + + +def detect_cam(name: str) -> bool: + """Cam-family rip (CAM/TS/TC/telesync/screener). A '1080p TS' is 1080p in + name only, so these always rank below a real source.""" + return bool(_CAM_RE.search(name or "")) + + +def file_rank(quality: str | None, is_cam: bool) -> float: + """Comparable rank: resolution, with cam-family rips half a step lower.""" + r = RES_RANK.get(quality, 0) + return r - 0.5 if is_cam else r + + +def is_better(new_quality: str | None, new_cam: bool, + old_quality: str | None, old_cam: bool) -> bool: + """True only when the new file is PROVABLY better than the old one. + + Unknown on either side is never "better": an unrecognised incoming release + must not replace a file, and a file whose quality was never recorded can't + be shown to be worse. Equal rank is not better either. + """ + if not new_quality or not old_quality: + return False + return file_rank(new_quality, new_cam) > file_rank(old_quality, old_cam) From 681ef412979429aefbe1f53b2e27830696def8f3 Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:22:12 -0400 Subject: [PATCH 2/9] library: use shared faucet.quality for detection and ranking --- faucet/library.py | 43 +++++++------------------------------------ 1 file changed, 7 insertions(+), 36 deletions(-) diff --git a/faucet/library.py b/faucet/library.py index 225af75..af5b10e 100644 --- a/faucet/library.py +++ b/faucet/library.py @@ -18,6 +18,13 @@ from . import db from .config import config +# quality detection/ranking is shared with the sorter; the private aliases keep +# existing call sites (fixmatch, tests) working +from .quality import RES_RANK # noqa: F401 - re-exported for movies.py +from .quality import detect_cam as _detect_cam +from .quality import detect_quality as _detect_quality +from .quality import file_rank as _movie_file_rank + try: from guessit import guessit except ImportError: # pragma: no cover @@ -28,9 +35,6 @@ VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".ts", ".m2ts"} MIN_SIZE = 50 * 1024 * 1024 # ignore sub-50MB junk/samples -_RES_TOKENS = [("2160p", ("2160p", "4k", "uhd")), ("1080p", ("1080p",)), - ("720p", ("720p",)), ("480p", ("480p",))] - def _clean_episode_filename(name: str) -> str: """Strip multi-segment episode suffixes that break guessit's parser. @@ -96,39 +100,6 @@ def _library_root() -> Path: return Path(os.environ.get("LIBRARY_ROOT", "/library")) -def _detect_quality(name: str) -> str | None: - n = name.lower() - for label, toks in _RES_TOKENS: - if any(t in n for t in toks): - return label - return None - - -_CAM_RE = None - - -def _detect_cam(name: str) -> bool: - """Cam-family rip (CAM/TS/TC/telesync/screener) — a '1080p TS' is 1080p in - name only; these files are always upgrade-eligible.""" - global _CAM_RE - if _CAM_RE is None: - import re - _CAM_RE = re.compile( - r"\b(hd-?cam|cam-?rip|telesync|hd-?ts|telecine|dvd-?scr|screener|" - r"ts|tc|cam|scr)\b", re.I) - return bool(_CAM_RE.search(name)) - - -# resolution rank shared with reconcile (higher = better; cam is below any -# real source at equal resolution) -RES_RANK = {"2160p": 4, "1080p": 3, "720p": 2, "480p": 1, None: 0, "": 0} - - -def _movie_file_rank(quality: str | None, is_cam: bool) -> float: - r = RES_RANK.get(quality, 0) - return r - 0.5 if is_cam else r - - def _record_unparsed(path: str, kind: str, reason: str) -> None: try: with db.connect() as c: From c3c5ac81c410595df1ae8983b331226ec192afd0 Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:23:14 -0400 Subject: [PATCH 3/9] db: library_files table recording the quality of sorter-placed files --- faucet/db.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/faucet/db.py b/faucet/db.py index bfa1b70..2b4864f 100644 --- a/faucet/db.py +++ b/faucet/db.py @@ -220,6 +220,19 @@ def connect() -> Iterator[sqlite3.Connection]: UNIQUE(title, year) ); +-- Quality of files the sorter placed. The sorter renames releases to +-- 'Title (Year).ext' / 'Show - S01E01.ext', which strips the resolution tag, +-- so this is the only record of what an on-disk file actually is. The sorter +-- consults it before replacing an existing file ("replace only if better"). +CREATE TABLE IF NOT EXISTS library_files ( + path TEXT PRIMARY KEY, + quality TEXT, -- 2160p | 1080p | 720p | 480p | NULL + is_cam INTEGER DEFAULT 0, + release TEXT, -- the release name it was parsed from + size INTEGER DEFAULT 0, + sorted_ts TEXT +); + -- The computed wants: missing or upgrade-eligible items the hunter targets. CREATE TABLE IF NOT EXISTS wanted ( id INTEGER PRIMARY KEY AUTOINCREMENT, From 67e020f44683a9175e8699879bb9d811c1ed949d Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:25:18 -0400 Subject: [PATCH 4/9] sort: never lose or overwrite data when filing releases --- faucet/sort.py | 1042 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 778 insertions(+), 264 deletions(-) diff --git a/faucet/sort.py b/faucet/sort.py index 427c638..673a6d5 100644 --- a/faucet/sort.py +++ b/faucet/sort.py @@ -1,44 +1,94 @@ #!/usr/bin/env python3 """ -mediasort.py — parse, rename, and file movie/TV releases into a Plex/Jellyfin tree. +sort.py — parse, rename, and file movie/TV releases into a Plex/Jellyfin tree. Modes: - Manual: mediasort.py /path/to/file_or_dir [more paths...] - Hook: invoked by transmission-daemon with TR_TORRENT_DIR / TR_TORRENT_NAME - in the environment (no args needed). - -Behavior: - Movies -> /movies/ (<Year>)/<Title> (<Year>).<ext> - TV -> <MEDIA_ROOT>/tvshows/<Show>/Season NN/<Show> - SNNENN.<ext> - - - Operates on video files above MIN_SIZE_MB (skips samples/junk). - - Pulls along same-basename subtitle sidecars (.srt/.ass/.sub). - - HARDLINKs by default (instant, keeps seeding intact, no double disk use). - Falls back to copy across filesystems. Set MODE="move" to move instead. - - Dry-run with --dry-run or DRY_RUN=1. - - Idempotent: skips if destination already exists with same size. - -Requires: pip install guessit + Manual: sort.py /path/to/file_or_dir [more paths...] + Hook: run by faucet.hook / faucet.sweep with FAUCET_PATH (or Transmission's + TR_TORRENT_DIR / TR_TORRENT_NAME) in the environment. + +Layout: + Movies -> <LIBRARY_ROOT>/movies/<Title> (<Year>)/<Title> (<Year>).<ext> + multi-part: <Title> (<Year>) - pt1.<ext>, - pt2 ... + extras: <Title> (<Year>)/Featurettes/<name>.<ext> (Plex folders) + TV -> <LIBRARY_ROOT>/tvshows/<Show>/Season NN/<Show> - SNNENN.<ext> + Subs -> next to their video, keeping language/flag tags: + <Title> (<Year>).en.forced.srt + +Safety model (every rule here exists because its absence lost data): + * Nothing is ever written at its final path until it is complete: copies go + to a hidden '.<name>.<pid>.faucet-partial' beside the destination, are fsynced and + size-checked, then renamed into place. + * An existing library file is replaced only when the incoming release is + PROVABLY better (faucet.quality.is_better) against the quality recorded + when the existing file was sorted (library_files table). Unknown on either + side keeps the existing file. + * Samples are skipped; extras go to Plex extras folders; two videos that + would land on the same path never overwrite each other. + * When the release is being consumed (MEDIASORT_MODE=move, or the hook will + delete it via REMOVE_ON_COMPLETE), anything that could not be filed is + moved to a quarantine dir (default: <release parent>/_failed, which the + sweep skips) instead of being deleted with the torrent. + +Exit codes (the hook only removes the torrent on 0 or 4): + 0 done; everything of value was filed (or the release is left seeding) + 1 fatal before any work (no inputs, library not mounted) + 2 transient failure; the release was left in place so it can be retried + 4 done; some content was quarantined for review + +Env: + LIBRARY_ROOT / MEDIA_ROOT library root (default /library) + MEDIASORT_MODE auto | hardlink | copy | move (default auto) + MEDIASORT_MIN_MB smallest video treated as content (default 50) + QUARANTINE_DIR override the quarantine location + REMOVE_ON_COMPLETE same flag the hook uses + DRY_RUN=1 / --dry-run log actions only """ +from __future__ import annotations +import argparse +import glob +import logging import os -import sys +import re import shutil -import logging -import argparse +import sys +import time +from collections import Counter +from dataclasses import dataclass +from datetime import datetime from pathlib import Path +# Run as a script (the hook does `python /app/faucet/sort.py`), sys.path[0] is +# the package dir itself, so `import faucet...` silently failed in production. +_PKG_PARENT = str(Path(__file__).resolve().parent.parent) +if _PKG_PARENT not in sys.path: + sys.path.insert(0, _PKG_PARENT) + try: from guessit import guessit except ImportError: sys.stderr.write("guessit not installed. Run: pip3 install guessit\n") - sys.exit(2) + sys.exit(1) try: - from faucet.classify import classify, dest_folder + from faucet.classify import classify except Exception: # noqa: BLE001 - sorter can run standalone classify = None - dest_folder = None + +try: + from faucet.quality import RES_RANK, detect_cam, detect_quality, is_better +except Exception: # noqa: BLE001 - standalone: never replace + RES_RANK = {} + + def detect_quality(name): + return None + + def detect_cam(name): + return False + + def is_better(*_a): + return False # ---------------------------------------------------------------------------- # Config @@ -49,22 +99,64 @@ GAMES_DIR = MEDIA_ROOT / "games" OTHER_DIR = MEDIA_ROOT / "other" -# Non-video content (games, software) is filed as a whole release rather than -# per-file. These extensions/markers identify game/disc releases. -GAME_EXTS = {".iso", ".bin", ".cue", ".nsp", ".xci", ".rom", ".pkg", ".rvz", - ".wbfs", ".chd", ".rpx", ".cia", ".3ds", ".nds", ".gba"} -ARCHIVE_EXTS = {".zip", ".rar", ".7z", ".tar", ".gz"} - MODE = os.environ.get("MEDIASORT_MODE", "auto") # auto | hardlink | copy | move MIN_SIZE_MB = int(os.environ.get("MEDIASORT_MIN_MB", "50")) LOG_FILE = os.environ.get("MEDIASORT_LOG", "/var/log/mediasort.log") +QUARANTINE_NAME = "_failed" # faucet.sweep skips this name + +EXIT_OK = 0 +EXIT_FATAL = 1 +EXIT_RETRY = 2 +EXIT_QUARANTINED = 4 VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".ts", ".m2ts"} SUB_EXTS = {".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt"} +ARCHIVE_EXTS = {".zip", ".rar", ".7z", ".tar", ".gz"} +# Unambiguous console/ROM formats. .iso/.bin/.cue are also used for movie and +# software disc images, so on their own they don't make a release a game. +CONSOLE_EXTS = {".nsp", ".xci", ".rom", ".pkg", ".rvz", ".wbfs", ".chd", ".rpx", + ".cia", ".3ds", ".nds", ".gba"} +DISC_EXTS = {".iso", ".bin", ".cue"} +GAME_EXTS = CONSOLE_EXTS | DISC_EXTS +# Tracker/scene clutter that's safe to delete with a release. Anything NOT +# listed (archives, disc images, audio, unknown formats) is kept, whatever its +# size: a size heuristic once classed small ISOs and RAR volumes as junk. +JUNK_EXTS = {".txt", ".nfo", ".url", ".sfv", ".jpg", ".jpeg", ".png", ".gif", + ".webp", ".md5", ".sha1", ".srr", ".srs", ".diz", ".exe", ".lnk", + ".html", ".htm", ".db", ".ini", ".torrent", ".log", ".ds_store"} # Characters illegal on most filesystems (incl. CIFS/Windows-backed shares). ILLEGAL = '<>:"/\\|?*' +PARTIAL_SUFFIX = ".faucet-partial" +STALE_PARTIAL_SECS = 30 * 60 +COPY_CHUNK = 16 * 1024 * 1024 + +# place() outcomes +PLACED, EXISTS, KEPT, DRY = "placed", "exists", "kept", "dry" + +# Plex local-extras folders, keyed by normalized directory name +EXTRA_DIRS = { + "behind the scenes": "Behind The Scenes", "behindthescenes": "Behind The Scenes", + "deleted scenes": "Deleted Scenes", "deleted": "Deleted Scenes", + "featurettes": "Featurettes", "featurette": "Featurettes", + "interviews": "Interviews", "interview": "Interviews", + "scenes": "Scenes", "shorts": "Shorts", + "trailers": "Trailers", "trailer": "Trailers", + "extras": "Other", "extra": "Other", "bonus": "Other", + "bonus features": "Other", "special features": "Other", "other": "Other", +} +# Plex inline-extra suffixes ("Movie-trailer.mkv"). Deliberately a suffix match +# only: a bare "trailer" token would swallow every Trailer Park Boys episode. +_INLINE_EXTRA = re.compile(r"-(behindthescenes|deleted|featurette|interview|trailer)$", re.IGNORECASE) +_INLINE_KIND = {"behindthescenes": "Behind The Scenes", "deleted": "Deleted Scenes", + "featurette": "Featurettes", "interview": "Interviews", + "trailer": "Trailers"} +_SAMPLE_STEM = re.compile(r"(^|[ ._\-\[(])sample($|[ ._\-\])])", re.IGNORECASE) +_SUB_DIRS = {"subs", "subtitles", "sub"} +_CAM_SOURCES = {"camera", "hd camera", "telesync", "hd telesync", "telecine", + "hd telecine", "screener"} + # ---------------------------------------------------------------------------- # Helpers @@ -89,34 +181,76 @@ def sanitize(name: str) -> str: return cleaned.rstrip(". ") +def _norm(name: str) -> str: + return " ".join(re.split(r"[\s._\-]+", name.lower())).strip() + + +def _truthy(key: str) -> bool: + return os.environ.get(key, "0").strip().lower() in ("1", "true", "yes", "on") + + +def _files(root: Path) -> list[Path]: + if root.is_file(): + return [root] + out = [] + for p in root.rglob("*"): + try: + if p.is_file() and not p.name.endswith(PARTIAL_SUFFIX): + out.append(p) + except OSError: + continue # vanished mid-walk (NAS hiccup) + return sorted(out) + + +def _rel_dirs(root: Path, p: Path) -> tuple[str, ...]: + if root.is_file() or root == p: + return () + try: + return p.relative_to(root).parts[:-1] + except ValueError: + return () + + def iter_video_files(root: Path): """Yield video files under root above the size threshold.""" - if root.is_file(): - candidates = [root] - else: - candidates = [p for p in root.rglob("*") if p.is_file()] - for p in candidates: + for p in _files(root): if p.suffix.lower() not in VIDEO_EXTS: continue try: size = p.stat().st_size except OSError: - continue # vanished mid-scan (NAS hiccup) - if size < MIN_SIZE_MB * 1024 * 1024: - logging.info("SKIP (under %dMB): %s", MIN_SIZE_MB, p.name) continue - yield p + if size >= MIN_SIZE_MB * 1024 * 1024: + yield p + + +def is_sample(root: Path, video: Path) -> bool: + if any(_norm(d) in ("sample", "samples") for d in _rel_dirs(root, video)): + return True + return bool(_SAMPLE_STEM.search(video.stem)) + + +def extra_kind(root: Path, video: Path) -> str | None: + """Plex extras folder for this video, or None if it isn't an extra.""" + for d in _rel_dirs(root, video): + kind = EXTRA_DIRS.get(_norm(d)) + if kind: + return kind + m = _INLINE_EXTRA.search(video.stem) + return _INLINE_KIND[m.group(1).lower()] if m else None def find_sidecars(video: Path): - """Find subtitle files sharing the video's basename stem in the same dir.""" + """Subtitle files next to the video whose name is the video's stem, or the + stem followed by '.<tags>' (Movie.en.srt, Movie.en.forced.srt).""" stem = video.stem - for p in video.parent.iterdir(): - if ( - p.is_file() - and p.suffix.lower() in SUB_EXTS - and p.stem.startswith(stem) - ): + try: + entries = list(video.parent.iterdir()) + except OSError: + return + for p in entries: + if (p.is_file() and p.suffix.lower() in SUB_EXTS + and (p.stem == stem or p.stem.startswith(stem + "."))): yield p @@ -129,24 +263,20 @@ def best_parse_source(video: Path): dirs, and prepend the first ancestor that actually parses as media so the inner filename can't drag the result to a bogus title. """ - # Dirs that never carry useful release info. - JUNK = {"downloads", "incomplete", "complete", "info", "sample", "samples", - "subs", "subtitles", "extras", "featurettes", "proof", "screens"} + junk = {"downloads", "incomplete", "complete", "info", "sample", "samples", + "subs", "subtitles", "extras", "featurettes", "proof", "screens", + QUARANTINE_NAME} candidates = [] for anc in video.parents: name = anc.name - if not name or name.lower() in JUNK: + if not name or name.lower() in junk: continue - # Looks like a release name if it has separators and some length. if any(c in name for c in ".-_ ") and len(name) >= 6: candidates.append(name) - # Don't climb past the download root once we have something. if len(candidates) >= 2: break - # Prefer the richest candidate: the one guessit can pull a season/episode - # or year from. Fall back to the longest, then to the bare filename. best = None for c in candidates: g = guessit(c) @@ -162,111 +292,624 @@ def best_parse_source(video: Path): return f"{best}/{video.name}" if best else video.name -def plan_destination(video: Path): - """Return (dest_dir, dest_basename) or None if unparseable.""" - info = guessit(best_parse_source(video)) +# ---------------------------------------------------------------------------- +# Planning +# ---------------------------------------------------------------------------- +@dataclass +class Plan: + src: Path + dest: Path + kind: str # movie | episode | extra + anchor: Path # folder extras attach under + size: int = 0 + part: int | None = None + quality: str | None = None + is_cam: bool = False + release: str = "" + role: str = "feature" # feature | part | extra + note: str = "" + + +def _as_int(v): + if isinstance(v, list): + v = v[0] if v else None + try: + return int(v) if v is not None else None + except (TypeError, ValueError): + return None + + +def _plan(video: Path) -> Plan | None: + source = best_parse_source(video) + info = guessit(source) vtype = info.get("type") ext = video.suffix.lower() + res = info.get("screen_size") + quality = res if res in RES_RANK and res else detect_quality(source) + src_tag = str(info.get("source") or "").lower() + is_cam = detect_cam(source) or src_tag in _CAM_SOURCES + part = _as_int(info.get("cd")) or _as_int(info.get("part")) if vtype == "movie": title = info.get("title") if not title: return None year = info.get("year") - folder = sanitize(f"{title} ({year})" if year else title) - base = folder - return MOVIES_DIR / folder, base + ext + if not year: + # 'Title (Year)' is the whole naming contract, and a yearless + # "movie" is usually a misparse (a bare 'ep.mkv' inside an + # episode folder parses as a movie titled "ep") + return None + folder = sanitize(f"{title} ({year})") + if not folder: + return None + dest_dir = MOVIES_DIR / folder + return Plan(video, dest_dir / (folder + ext), "movie", dest_dir, + part=part, quality=quality, is_cam=is_cam, release=source) if vtype == "episode": - show = info.get("title") - season = info.get("season") - episode = info.get("episode") + show, season, episode = info.get("title"), info.get("season"), info.get("episode") if show is None or season is None or episode is None: return None - # guessit can return a list for multi-SEASON names too (e.g. 'S01-S03'); - # a range spans folders, so treat it as unparseable rather than crash + # a season range spans folders — can't be filed as one episode if isinstance(season, list): return None - # guessit can return a list for multi-episode files if isinstance(episode, list): ep_tag = "".join(f"E{int(e):02d}" for e in episode) else: ep_tag = f"E{int(episode):02d}" - show_s = sanitize(show) + show_s = sanitize(str(show)) + if not show_s: + return None dest_dir = TV_DIR / show_s / f"Season {int(season):02d}" base = sanitize(f"{show_s} - S{int(season):02d}{ep_tag}") - return dest_dir, base + ext - + return Plan(video, dest_dir / (base + ext), "episode", TV_DIR / show_s, + quality=quality, is_cam=is_cam, release=source) return None -def place(src: Path, dest: Path, dry: bool): - """Place src at dest using the best available method. Idempotent on equal size. +def plan_destination(video: Path): + """Return (dest_dir, dest_basename) or None if unparseable.""" + p = _plan(video) + return (p.dest.parent, p.dest.name) if p else None + + +def _resolve_group(plans: list[Plan], unfiled: list) -> list[Plan]: + """Several videos in one release planned onto the same path. Numbered parts + (CD1/CD2) are stacked Plex-style; otherwise the largest wins and the rest + are left unfiled — never overwritten onto each other.""" + if len(plans) == 1: + return plans + parts = [p.part for p in plans] + if all(parts) and len(set(parts)) == len(parts): + for p in plans: + p.role = "part" + p.dest = p.dest.with_name(f"{p.dest.stem} - pt{p.part}{p.dest.suffix}") + return plans + plans = sorted(plans, key=lambda p: p.size, reverse=True) + keep = plans[0] + for other in plans[1:]: + unfiled.append((other.src, f"same destination as larger {keep.src.name}")) + return [keep] + + +def _sub_suffixes(video_stem: str, subs: list[Path]) -> list[tuple[Path, str]]: + """Name tags for each subtitle. 'Movie.en.forced.srt' -> '.en.forced'; + RARBG-style 'Subs/2_English.srt' -> '.English' (the ordinal is kept only + when dropping it would make two subtitle names collide).""" + raw = {} + for s in subs: + if s.stem == video_stem: + toks = [] + elif s.stem.startswith(video_stem + "."): + toks = s.stem[len(video_stem) + 1:].split(".") + else: + toks = s.stem.split(".") + raw[s] = [t for t in (sanitize(t) for t in toks) if t] + stripped = {s: [re.sub(r"^\d+_", "", t) or t for t in toks] for s, toks in raw.items()} + counts = Counter((".".join(t).lower(), s.suffix.lower()) for s, t in stripped.items()) + out = [] + for s in subs: + toks = stripped[s] + if counts[(".".join(toks).lower(), s.suffix.lower())] > 1: + toks = raw[s] + out.append((s, "".join("." + t for t in toks))) + return out + + +def _collect_subs(root: Path, placements: list[Plan]) -> dict[Path, list[tuple[Path, str]]]: + all_subs = [p for p in _files(root) if p.suffix.lower() in SUB_EXTS] + claimed: set[Path] = set() + per_video: dict[Path, list[Path]] = {} + for plan in placements: + stem = plan.src.stem + mine = [s for s in all_subs if s not in claimed and ( + (s.parent == plan.src.parent + and (s.stem == stem or s.stem.startswith(stem + "."))) + or (s.parent != plan.src.parent and s.parent.name == stem))] # Subs/<stem>/.. + claimed.update(mine) + per_video[plan.src] = mine + primaries = [p for p in placements if p.role != "extra"] + if len(primaries) == 1 and primaries[0].kind == "movie": + loose = [s for s in all_subs if s not in claimed + and any(_norm(d) in _SUB_DIRS for d in _rel_dirs(root, s))] + per_video[primaries[0].src].extend(loose) + return {v: _sub_suffixes(v.stem, subs) for v, subs in per_video.items() if subs} - Strategy (MODE='auto', the default): - 1. hardlink — one copy of bytes, source kept so torrents keep seeding. - Works only on the same *local* filesystem (not CIFS/SMB). - 2. move (rename) — instant, no duplication, when 1 isn't supported but - src and dest share a filesystem (e.g. both on the same NAS share). - Source disappears, so seeding of that torrent stops. - 3. copy — last resort, only when src and dest are genuinely on different - filesystems. This is the one that duplicates bytes; we avoid it unless - nothing else works. - Explicit MODE='move'/'copy'/'hardlink' forces a single method. - """ - if dest.exists() and dest.stat().st_size == src.stat().st_size: - logging.info("EXISTS (same size), skipping: %s", dest) - return - if dry: - logging.info("DRY-RUN %s -> %s", src, dest) - return - dest.parent.mkdir(parents=True, exist_ok=True) +# ---------------------------------------------------------------------------- +# Library quality history +# ---------------------------------------------------------------------------- +_DB = None - # explicit single-method modes - if MODE == "move": - shutil.move(str(src), str(dest)) - logging.info("MOVED %s -> %s", src.name, dest) - return - if MODE == "copy": - if dest.exists(): - dest.unlink() - shutil.copy2(src, dest) - logging.info("COPIED %s -> %s", src.name, dest) + +def _db(): + global _DB + if _DB is None: + try: + from faucet import db as dbmod + dbmod.init() + _DB = dbmod + except Exception as e: # noqa: BLE001 + logging.warning("library DB unavailable (%s); existing files will " + "never be replaced", e) + _DB = False + return _DB or None + + +def _recorded_quality(path: Path) -> tuple[str | None, bool]: + dbm = _db() + if dbm: + try: + with dbm.connect() as c: + row = c.execute("SELECT quality, is_cam FROM library_files WHERE path=?", + (str(path),)).fetchone() + if row: + return row["quality"], bool(row["is_cam"]) + except Exception as e: # noqa: BLE001 + logging.warning("quality lookup failed for %s: %s", path, e) + # a hand-placed file may still carry its release tags in the name + return detect_quality(path.name), detect_cam(path.name) + + +def _record(plan: Plan) -> None: + dbm = _db() + if not dbm: return + try: + size = plan.dest.stat().st_size + with dbm.connect() as c: + c.execute( + "INSERT INTO library_files (path, quality, is_cam, release, size, sorted_ts) " + "VALUES (?,?,?,?,?,?) ON CONFLICT(path) DO UPDATE SET " + "quality=excluded.quality, is_cam=excluded.is_cam, " + "release=excluded.release, size=excluded.size, sorted_ts=excluded.sorted_ts", + (str(plan.dest), plan.quality, int(plan.is_cam), plan.release, size, + datetime.now().isoformat(timespec="seconds"))) + except Exception as e: # noqa: BLE001 - never fail a sort on this + logging.warning("could not record quality for %s: %s", plan.dest, e) + + +def _better_than_existing(plan: Plan) -> bool: + old_q, old_cam = _recorded_quality(plan.dest) + ok = is_better(plan.quality, plan.is_cam, old_q, old_cam) + + def fmt(q, cam): + return f"{q or 'unknown'}{' CAM' if cam else ''}" + + plan.note = (f"library has {plan.dest.name} ({fmt(old_q, old_cam)}); " + f"incoming is {fmt(plan.quality, plan.is_cam)}") + return ok + + +# ---------------------------------------------------------------------------- +# Placement +# ---------------------------------------------------------------------------- +def _partial(dest: Path) -> Path: + # per-process name: two sorters racing on one release (hook + sweep) each + # build their own copy and the atomic rename lets exactly one win intact + return dest.with_name(f".{dest.name}.{os.getpid()}{PARTIAL_SUFFIX}") + + +def _reap_stale_partials(dest: Path) -> None: + """Remove partials left for this destination by killed sorters. A live + copy rewrites its partial continuously, so only idle ones are touched.""" + cutoff = time.time() - STALE_PARTIAL_SECS + try: + for p in dest.parent.glob(f".{glob.escape(dest.name)}.*{PARTIAL_SUFFIX}"): + try: + if p.stat().st_mtime < cutoff: + p.unlink() + logging.info("removed stale partial %s", p.name) + except OSError: + continue + except OSError: + pass - # auto / hardlink: try hardlink, then move (same-fs rename), then copy. - # 1. hardlink (keeps seeding, zero extra space — same local fs only) - if MODE in ("auto", "hardlink"): + +def _unlink_quiet(p: Path) -> None: + try: + p.unlink() + except FileNotFoundError: + pass + except OSError as e: + logging.warning("could not remove %s: %s", p, e) + + +def _try_link(src: Path, dest: Path) -> bool: + tmp = _partial(dest) + _unlink_quiet(tmp) + try: + os.link(src, tmp) + except OSError: + return False + try: + os.replace(tmp, dest) + except OSError: + _unlink_quiet(tmp) + raise + return True + + +def _try_rename(src: Path, dest: Path) -> bool: + """Same-filesystem move. rename(2) is atomic and replaces dest in one step.""" + try: + os.rename(src, dest) + return True + except OSError: + return False + + +def _copy_atomic(src: Path, dest: Path) -> None: + """Copy to a hidden partial beside dest, fsync, verify, then swap it in. + An interrupted copy never touches dest.""" + tmp = _partial(dest) + _unlink_quiet(tmp) + try: + with open(src, "rb") as fsrc, open(tmp, "wb") as fdst: + shutil.copyfileobj(fsrc, fdst, COPY_CHUNK) + fdst.flush() + os.fsync(fdst.fileno()) try: - if dest.exists(): - dest.unlink() - os.link(src, dest) - logging.info("LINKED %s -> %s", src.name, dest) - return + shutil.copystat(src, tmp) except OSError: - logging.info("hardlink not supported here (likely CIFS/SMB)") + pass # CIFS may refuse metadata; content is what matters + want, got = src.stat().st_size, tmp.stat().st_size + if want != got: + raise OSError(f"short copy of {src.name}: {got} of {want} bytes") + os.replace(tmp, dest) + except BaseException: + _unlink_quiet(tmp) + raise + + +def _transfer(src: Path, dest: Path) -> str: + if MODE in ("auto", "hardlink") and _try_link(src, dest): + return "LINKED" + if MODE in ("auto", "move") and _try_rename(src, dest): + return "MOVED" + # hardlink mode falls back to copy, never to a move: the point of hardlink + # mode is that the torrent keeps its data + _copy_atomic(src, dest) + if MODE == "move": + src.unlink() + return "MOVED (copy+delete)" + return "COPIED" + + +def place(src: Path, dest: Path, dry: bool, replace_if=None) -> str: + """Put src at dest. Returns PLACED, EXISTS (same size already there), KEPT + (a different file is there and replace_if() didn't approve replacing it) + or DRY. Raises OSError on I/O failure, leaving dest untouched.""" + replacing = False + if dest.exists(): + if dest.stat().st_size == src.stat().st_size: + logging.info("EXISTS (same size), skipping: %s", dest) + return EXISTS + if replace_if is None or not replace_if(): + return KEPT + replacing = True + if dry: + logging.info("DRY-RUN %s%s -> %s", "(replace) " if replacing else "", src, dest) + return DRY + dest.parent.mkdir(parents=True, exist_ok=True) + _reap_stale_partials(dest) + how = _transfer(src, dest) + logging.info("%s%s %s -> %s", "REPLACED/" if replacing else "", how, src.name, dest) + return PLACED + - # 2. move via rename — instant and dup-free when src/dest share a filesystem - # (your case: download dir and library both on the same NAS share). +# ---------------------------------------------------------------------------- +# Leftovers: cleanup and quarantine +# ---------------------------------------------------------------------------- +def _is_valuable(root: Path, p: Path, handled: set) -> bool: + if p in handled: + return False + ext = p.suffix.lower() + if ext in VIDEO_EXTS: + return not is_sample(root, p) + if ext in SUB_EXTS: + return True + if ext in JUNK_EXTS or p.name.lower() in (".ds_store", "thumbs.db", "desktop.ini"): + return False + if "sample" in p.name.lower() and ext not in DISC_EXTS | ARCHIVE_EXTS: + return False try: - if dest.exists(): - dest.unlink() - os.rename(src, dest) - logging.info("MOVED (same-fs rename) %s -> %s", src.name, dest) - return + return p.stat().st_size > 0 except OSError: - logging.info("same-fs rename not possible (cross-filesystem) — copying") + return True # can't tell — keep it + + +def _safe_to_remove(root: Path) -> bool: + """Refuse to delete or relocate anything that isn't clearly one release.""" + try: + r = root.resolve() + lib = MEDIA_ROOT.resolve() + except OSError: + return False + if len(r.parts) < 3 or r == lib or r in lib.parents or lib in r.parents: + return False + return r.parent.name != QUARANTINE_NAME - # 3. copy — genuinely different filesystems; the only case that duplicates. + +def _cleanup_release_dir(root: Path, handled: set | frozenset = frozenset()) -> bool: + """Remove a leftover release folder when nothing of value remains (only + tracker clutter, samples, empty files, and files already filed). + Returns True if removed.""" + if not _safe_to_remove(root): + logging.warning("refusing to clean up %s", root) + return False + try: + for p in _files(root): + if _is_valuable(root, p, set(handled)): + logging.info("leaving release dir (has %s)", p.name) + return False + shutil.rmtree(root) + logging.info("cleaned up leftover release dir: %s", root.name) + return True + except OSError as e: + logging.warning("could not clean release dir %s: %s", root, e) + return False + + +def quarantine_dir(root: Path) -> Path: + explicit = os.environ.get("QUARANTINE_DIR") + return Path(explicit) if explicit else root.parent / QUARANTINE_NAME + + +def quarantine(root: Path) -> bool: + """Move a whole release (dir or file) into the quarantine dir, intact. + Returns False if it couldn't be moved (the caller must then not let the + hook delete it).""" + if not _safe_to_remove(root): + logging.error("refusing to quarantine %s", root) + return False + base = quarantine_dir(root) + target = base / root.name + n = 2 + while target.exists(): + target = base / f"{root.stem if root.is_file() else root.name} ({n}){root.suffix if root.is_file() else ''}" + n += 1 + try: + base.mkdir(parents=True, exist_ok=True) + if not _try_rename(root, target): + tmp = _partial(target) + if root.is_dir(): + shutil.rmtree(tmp, ignore_errors=True) + shutil.copytree(root, tmp) + os.rename(tmp, target) + shutil.rmtree(root) + else: + _copy_atomic(root, target) + root.unlink() + except OSError as e: + logging.error("QUARANTINE FAILED for %s: %s", root, e) + return False + logging.warning("QUARANTINED %s -> %s", root.name, target) + return True + + +def _consumed(handled: set) -> bool: + """Is this release being used up? True when its files are moved away + (MODE=move), when the hook will delete it (REMOVE_ON_COMPLETE), or in auto + mode when every filed video actually moved (nothing left seeding).""" + if MODE == "move" or _truthy("REMOVE_ON_COMPLETE"): + return True + if MODE == "auto": + vids = [p for p in handled if p.suffix.lower() in VIDEO_EXTS] + return bool(vids) and not any(p.exists() for p in vids) + return False + + +def _finish(root: Path, handled: set, unfiled: list, dry: bool) -> int: + for p, why in unfiled: + logging.warning("NOT FILED %s: %s", p.name, why) + if root.is_file(): + leftover = root.exists() and root not in handled + valuable = [root] if leftover else [] + else: + valuable = [p for p in _files(root) if _is_valuable(root, p, handled)] + if dry: + if valuable: + logging.info("DRY-RUN would quarantine %s (%d item(s) not filed)", + root.name, len(valuable)) + return EXIT_QUARANTINED + return EXIT_OK + if not _consumed(handled): + return EXIT_OK # still seeding; leave it be + if not valuable: + if root.is_dir(): + _cleanup_release_dir(root, handled) + return EXIT_OK + for p in valuable: + if not any(p == u for u, _ in unfiled): + logging.warning("NOT FILED %s: no rule for this file", p.name) + return EXIT_QUARANTINED if quarantine(root) else EXIT_RETRY + + +# ---------------------------------------------------------------------------- +# Games +# ---------------------------------------------------------------------------- +def release_is_game(root: Path, ctype: str | None = None) -> bool: + """Console/ROM formats always mean a game. Disc images and archive-only + releases are a game only when the classifier says so — otherwise they're + movie/software discs or packed video and must not vanish into games/.""" + files = _files(root) + exts = {p.suffix.lower() for p in files} + if exts & CONSOLE_EXTS: + return True + if ctype in ("movie", "tv"): + return False + if exts & VIDEO_EXTS: + return False + return ctype == "game" and bool(exts & (DISC_EXTS | ARCHIVE_EXTS)) + + +def handle_game(root: Path, platform: str | None, dry: bool) -> str: + """File a whole game/software release intact into games/<Platform>/<name>. + Returns PLACED, EXISTS or DRY; raises OSError on failure (dest untouched).""" + sub = sanitize(platform) if platform else "PC" + name = sanitize(root.stem if root.is_file() else root.name) + dest = GAMES_DIR / sub / name if dest.exists(): - dest.unlink() - shutil.copy2(src, dest) - logging.info("COPIED (cross-fs) %s -> %s", src.name, dest) + logging.warning("GAME EXISTS: %s", dest) + return EXISTS + if dry: + logging.info("DRY-RUN game %s -> %s", root.name, dest) + return DRY + dest.parent.mkdir(parents=True, exist_ok=True) + if MODE in ("auto", "move") and _try_rename(root, dest): + logging.info("GAME moved %s -> %s", root.name, dest) + return PLACED + tmp = _partial(dest) + try: + if root.is_dir(): + shutil.rmtree(tmp, ignore_errors=True) + shutil.copytree(root, tmp) + os.rename(tmp, dest) + else: + _copy_atomic(root, dest) + except BaseException: + shutil.rmtree(tmp, ignore_errors=True) + raise + if MODE == "move": + if root.is_dir(): + shutil.rmtree(root) + else: + root.unlink() + logging.info("GAME filed %s -> %s", root.name, dest) + return PLACED + + +# ---------------------------------------------------------------------------- +# One release +# ---------------------------------------------------------------------------- +def _classify(root: Path) -> tuple[str | None, str | None]: + if classify is None: + return None, None + k = classify(root.stem if root.is_file() else root.name) + return k["type"], k["platform"] + + +def sort_video_release(root: Path, dry: bool) -> int: + handled: set[Path] = set() + unfiled: list[tuple[Path, str]] = [] + retry = False + groups: dict[str, list[Plan]] = {} + extras: list[tuple[Path, str]] = [] + + for v in (p for p in _files(root) if p.suffix.lower() in VIDEO_EXTS): + if is_sample(root, v): + logging.info("SKIP sample: %s", v.name) + continue + kind = extra_kind(root, v) + if kind: + extras.append((v, kind)) + continue + try: + size = v.stat().st_size + plan = _plan(v) + except OSError as e: + logging.error("cannot read %s: %s", v, e) + retry = True + continue + if size < MIN_SIZE_MB * 1024 * 1024: + unfiled.append((v, f"under {MIN_SIZE_MB} MB and not named as a sample")) + continue + if plan is None: + unfiled.append((v, "unparseable (no title+year, or no season/episode)")) + continue + plan.size = size + groups.setdefault(str(plan.dest).lower(), []).append(plan) + + placements: list[Plan] = [] + for plans in groups.values(): + placements.extend(_resolve_group(plans, unfiled)) + + anchors = {p.anchor for p in placements} + anchor = anchors.pop() if len(anchors) == 1 else None + for v, kind in extras: + if anchor is None: + unfiled.append((v, f"{kind} extra with no single title to attach to")) + continue + name = sanitize(_INLINE_EXTRA.sub("", v.stem)) or "extra" + placements.append(Plan(v, anchor / kind / (name + v.suffix.lower()), "extra", + anchor, role="extra")) + + subs = _collect_subs(root, placements) + + for plan in placements: + policy = None if plan.role == "extra" else (lambda p=plan: _better_than_existing(p)) + try: + outcome = place(plan.src, plan.dest, dry, replace_if=policy) + except OSError as e: + logging.error("failed to file %s: %s", plan.src.name, e) + retry = True + continue + if outcome == KEPT: + unfiled.append((plan.src, plan.note or f"{plan.dest.name} already in library")) + continue + handled.add(plan.src) + if outcome == PLACED and plan.role != "extra": + _record(plan) + for sub, suffix in subs.get(plan.src, []): + sdest = plan.dest.with_name(plan.dest.stem + suffix + sub.suffix.lower()) + try: + so = place(sub, sdest, dry) + except OSError as e: + logging.error("failed to file subtitle %s: %s", sub.name, e) + retry = True + continue + if so == KEPT: + unfiled.append((sub, f"subtitle {sdest.name} already exists")) + else: + handled.add(sub) + + if retry: + logging.error("I/O errors while filing %s — left in place for retry", root.name) + return EXIT_RETRY + return _finish(root, handled, unfiled, dry) + + +def sort_release(root: Path, dry: bool) -> int: + if not root.exists(): + logging.warning("Input not found: %s", root) + return EXIT_RETRY + ctype, platform = _classify(root) + if release_is_game(root, ctype): + try: + outcome = handle_game(root, platform, dry) + except OSError as e: + logging.error("game file failed for %s: %s", root.name, e) + return EXIT_RETRY + if outcome != EXISTS: + return EXIT_OK + return _finish(root, set(), [(root, "game already in library")], dry) + return sort_video_release(root, dry) # ---------------------------------------------------------------------------- -# Main +# Inputs # ---------------------------------------------------------------------------- def _recover_inputs(td: str, tn: str) -> list: """When TR_TORRENT_DIR/TR_TORRENT_NAME join to a path that doesn't exist @@ -280,7 +923,6 @@ def _recover_inputs(td: str, tn: str) -> list: return [] def norm(s: str) -> str: - # collapse all separators/spaces so mangled vs real names compare equal return "".join(ch for ch in s.lower() if ch.isalnum()) want = norm(tn) @@ -288,8 +930,6 @@ def norm(s: str) -> str: try: for entry in parent.iterdir(): en = norm(entry.name) - # exact normalized match, or the real entry is a superstring of the - # mangled name's tail (the post-slash remainder) if en == want or (want and (en.endswith(want) or want.endswith(en))): matches.append(entry) except OSError: @@ -298,12 +938,7 @@ def norm(s: str) -> str: def resolve_inputs(args): - """CLI paths if given, else Transmission hook env vars. - - Falls back to scanning TR_TORRENT_DIR when the exact joined path doesn't - exist, so torrents whose names contain path separators (tracker URL - watermarks) still get sorted. - """ + """CLI paths if given, else hook env vars (see _recover_inputs).""" if args.paths: return [Path(p) for p in args.paths] cp = os.environ.get("FAUCET_PATH") or os.environ.get("CASCADE_PATH") @@ -313,10 +948,6 @@ def resolve_inputs(args): p = Path(cp) if p.exists(): return [p] - # Mangled path: the name contains '/' (tracker URL watermark), so even - # p.parent is wrong. Walk up to the first ancestor that actually exists - # on disk (the real download/complete dir), then scan it for a match. - # That ancestor is container-translated already (cp came from the hook). scan = p while scan != scan.parent and not scan.exists(): scan = scan.parent @@ -325,7 +956,7 @@ def resolve_inputs(args): if rec: logging.info("recovered %d input(s) by scanning %s", len(rec), scan) return rec - return [p] # let the caller log 'not found' + return [p] if td and tn: p = Path(td) / tn if p.exists(): @@ -338,87 +969,10 @@ def resolve_inputs(args): return [] -def handle_game(root: Path, platform: str | None, dry: bool) -> bool: - """File a whole game/software release into /games/<Platform>/<Name>. - - Games aren't single video files — they're ISOs, archives, or folders of - files that must stay together. So we move/link the entire release directory - (or file) intact rather than picking through it. No renaming: game release - names carry version/scene info worth preserving. - """ - sub = sanitize(platform) if platform else "PC" - name = sanitize(root.stem if root.is_file() else root.name) - dest = GAMES_DIR / sub / name - if dest.exists(): - logging.info("GAME EXISTS, skipping: %s", dest) - return False - if dry: - logging.info("DRY-RUN game %s -> %s", root.name, dest) - return True - dest.parent.mkdir(parents=True, exist_ok=True) - try: - if MODE == "move": - shutil.move(str(root), str(dest)) - else: - # try same-fs rename first (instant, no dup), then copy the tree. - try: - os.rename(str(root), str(dest)) - except OSError: - if root.is_dir(): - shutil.copytree(str(root), str(dest)) - else: - shutil.copy2(str(root), str(dest)) - logging.info("GAME filed %s -> %s", root.name, dest) - return True - except (OSError, shutil.Error) as e: - logging.error("game file failed for %s: %s", root.name, e) - return False +_SEVERITY = {EXIT_OK: 0, EXIT_QUARANTINED: 1, EXIT_RETRY: 2} -def release_is_game(root: Path) -> bool: - """Heuristic: does this release look like a game/disc rather than video? - Checks for game/disc file extensions anywhere in the tree.""" - files = [root] if root.is_file() else [p for p in root.rglob("*") if p.is_file()] - for p in files: - if p.suffix.lower() in GAME_EXTS: - return True - # archive-only release with no video inside also leans game/software - has_video = any(p.suffix.lower() in VIDEO_EXTS for p in files) - has_archive = any(p.suffix.lower() in ARCHIVE_EXTS for p in files) - return has_archive and not has_video - - -def _cleanup_release_dir(root: Path) -> None: - """Remove a leftover torrent folder after its video(s) have been moved out. - Safe by design: only deletes when nothing of value remains — i.e. the dir - holds only known junk (tracker .txt/.nfo/.url notices, sample files, images, - empty subdirs). If any sizeable or unrecognized file is present, leaves the - folder alone rather than risk deleting wanted data.""" - JUNK_EXTS = {".txt", ".nfo", ".url", ".sfv", ".jpg", ".jpeg", ".png", - ".gif", ".md5", ".srr", ".diz"} - SMALL = 5 * 1024 * 1024 # 5 MB — anything bigger we won't auto-delete - try: - leftovers = [p for p in root.rglob("*") if p.is_file()] - for p in leftovers: - name = p.name.lower() - # subtitles are small but NOT junk — an unmoved .srt (e.g. in a - # subs/ folder that didn't match a sidecar stem) blocks cleanup - if p.suffix.lower() in SUB_EXTS: - logging.info("leaving release dir (has subtitle file): %s", p.name) - return - is_junk = (p.suffix.lower() in JUNK_EXTS - or "sample" in name - or p.stat().st_size < SMALL) - if not is_junk: - logging.info("leaving release dir (has non-junk file): %s", p.name) - return - shutil.rmtree(root) - logging.info("cleaned up leftover release dir: %s", root.name) - except OSError as e: - logging.warning("could not clean release dir %s: %s", root, e) - - -def main(): +def main() -> int: ap = argparse.ArgumentParser(description="Sort media into Plex/Jellyfin tree.") ap.add_argument("paths", nargs="*", help="File(s) or dir(s) to process.") ap.add_argument("--dry-run", action="store_true", help="Show actions only.") @@ -429,60 +983,20 @@ def main(): inputs = resolve_inputs(args) if not inputs: - logging.error("No input paths (no args and no TR_TORRENT_* env). Exiting.") - sys.exit(1) - + logging.error("No input paths (no args and no FAUCET_PATH / TR_TORRENT_* env).") + return EXIT_FATAL if not MEDIA_ROOT.exists(): - logging.error("MEDIA_ROOT %s not present — is the NAS mounted?", MEDIA_ROOT) - sys.exit(1) + logging.error("LIBRARY_ROOT %s not present — is the NAS mounted?", MEDIA_ROOT) + return EXIT_FATAL - processed = 0 + rc = EXIT_OK for root in inputs: - if not root.exists(): - logging.warning("Input not found: %s", root) - continue - - # Classify the release first. Games/software file as a whole release; - # video falls through to the existing per-file movie/TV logic. - ctype = None - platform = None - if classify is not None: - k = classify(root.stem if root.is_file() else root.name) - ctype, platform = k["type"], k["platform"] - - if ctype == "game" or release_is_game(root): - if handle_game(root, platform, dry): - processed += 1 - continue - - for video in iter_video_files(root): - # one bad file must never abort the whole release (a raised - # OSError/ValueError here used to kill every remaining file) - try: - plan = plan_destination(video) - if not plan: - logging.warning("UNPARSEABLE, skipping: %s", video.name) - continue - dest_dir, dest_name = plan - dest = dest_dir / dest_name - place(video, dest, dry) - for sub in find_sidecars(video): - place(sub, dest_dir / (dest.stem + sub.suffix), dry) - processed += 1 - except Exception as e: # noqa: BLE001 - logging.error("failed to file %s: %s", video.name, e) - - # Clean up the leftover torrent folder once its video(s) are filed. - # Only when MODE actually relocated the video (move/auto) — for - # copy/hardlink we leave the source so seeding keeps working. And only - # if no video files remain (don't nuke a dir with unsorted content). - if (not dry and root.is_dir() and MODE in ("move", "auto") - and not any(True for _ in iter_video_files(root))): - _cleanup_release_dir(root) - - logging.info("Done. %d video file(s) handled (mode=%s, dry=%s).", - processed, MODE, dry) + r = sort_release(root, dry) + if _SEVERITY.get(r, 2) > _SEVERITY.get(rc, 2): + rc = r + logging.info("Done: rc=%d (mode=%s, dry=%s).", rc, MODE, dry) + return rc if __name__ == "__main__": - main() + raise SystemExit(main()) From c22ce280ea1595c188500ad6c3df93597acb0bf2 Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:25:41 -0400 Subject: [PATCH 5/9] hook: only remove the torrent when the sorter left nothing of value --- faucet/hook.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/faucet/hook.py b/faucet/hook.py index 5dc12ca..6b81136 100644 --- a/faucet/hook.py +++ b/faucet/hook.py @@ -37,6 +37,11 @@ db = None +# faucet/sort.py exit codes the hook acts on +SORT_OK = 0 +SORT_QUARANTINED = 4 + + def _path_size(path: str) -> int: """Total bytes of the completed download (file or dir tree).""" p = Path(path) @@ -98,19 +103,30 @@ def main(): if "completed" in config.notify_on: notify(config.notify_urls, "Download complete", name) - # 1. sort — delegate to the sorter script, pointed at the completed path + # 1. sort — delegate to the sorter script, pointed at the completed path. + # Exit codes (see faucet/sort.py): 0 filed, 4 filed with some content + # quarantined to _failed/, anything else = leave the torrent alone. The + # torrent is only removed on 0 or 4, because in those cases nothing of + # value is left inside it. sorter = Path(__file__).resolve().parent / "sort.py" env = dict(os.environ, FAUCET_PATH=path, CASCADE_PATH=path) res = subprocess.run([sys.executable, str(sorter)], env=env) - if res.returncode != 0: - record("sort_failed", name, f"sort failed (rc={res.returncode})") + rc = res.returncode + if rc not in (SORT_OK, SORT_QUARANTINED): + record("sort_failed", name, f"sort failed (rc={rc}); torrent left in place") if "failed" in config.notify_on: notify(config.notify_urls, "Sort failed", name) - return res.returncode + return rc - record("sorted", name, "filed onto library") - if "sorted" in config.notify_on: - notify(config.notify_urls, "Sorted to library", name) + if rc == SORT_QUARANTINED: + record("quarantined", name, + "some content couldn't be filed; moved to _failed/ for review") + if "failed" in config.notify_on: + notify(config.notify_urls, "Needs review (quarantined)", name) + else: + record("sorted", name, "filed onto library") + if "sorted" in config.notify_on: + notify(config.notify_urls, "Sorted to library", name) # 2. optional auto-remove if os.environ.get("REMOVE_ON_COMPLETE", "0") in ("1", "true", "yes") and tid: From d2e00c9b8b3eb2957b2c8af675a4ccc53bc8b7c8 Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:26:11 -0400 Subject: [PATCH 6/9] sweep: treat sorter rc 4 (quarantined) as swept --- faucet/sweep.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/faucet/sweep.py b/faucet/sweep.py index 6c16e7f..61bc11c 100644 --- a/faucet/sweep.py +++ b/faucet/sweep.py @@ -152,7 +152,9 @@ def run(dry: bool = False, settle_min: int | None = None) -> dict: continue log.info("SORTING: %s", item.name) rc = _sort_one(item, dry) - if rc == 0: + # 0 = filed; 4 = filed, with leftovers quarantined to _failed/ (which + # this sweep never revisits) + if rc in (0, 4): swept += 1 else: failed += 1 From 8f5809367be3d2d37ca743f37390df1a637637de Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:27:23 -0400 Subject: [PATCH 7/9] tests: sorter data-safety regressions --- tests/test_sort_safety.py | 417 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 tests/test_sort_safety.py diff --git a/tests/test_sort_safety.py b/tests/test_sort_safety.py new file mode 100644 index 0000000..4cb5186 --- /dev/null +++ b/tests/test_sort_safety.py @@ -0,0 +1,417 @@ +"""Sorter data-safety regressions. + +Every test here encodes a way faucet/sort.py used to destroy or misfile data +in production (MEDIASORT_MODE=move, REMOVE_ON_COMPLETE=1, /downloads and +/library as separate mounts so rename() fails with EXDEV). +""" +from __future__ import annotations + +import errno +import importlib +import os +import shutil +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +REPO = Path(__file__).resolve().parent.parent +MB = 1024 * 1024 + + +def mk(path: Path, mb: float, fill: bytes = b"x") -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(fill * int(mb * MB)) + return path + + +@pytest.fixture +def env(tmp_path, monkeypatch): + lib = tmp_path / "lib" + complete = tmp_path / "downloads" / "complete" + lib.mkdir() + complete.mkdir(parents=True) + monkeypatch.setenv("LIBRARY_ROOT", str(lib)) + monkeypatch.setenv("EVENTS_FILE", str(tmp_path / "config" / "events.jsonl")) + monkeypatch.setenv("MEDIASORT_MIN_MB", "1") + monkeypatch.setenv("MEDIASORT_LOG", str(tmp_path / "sort.log")) + monkeypatch.setenv("MEDIASORT_MODE", "move") + for k in ("REMOVE_ON_COMPLETE", "QUARANTINE_DIR", "FAUCET_PATH", "CASCADE_PATH", + "TR_TORRENT_DIR", "TR_TORRENT_NAME", "DRY_RUN"): + monkeypatch.delenv(k, raising=False) + from faucet import config as cfg + importlib.reload(cfg) + from faucet import db + importlib.reload(db) + db.init() + from faucet import sort as S + importlib.reload(S) + return SimpleNamespace(S=S, db=db, lib=lib, dl=complete, tmp=tmp_path) + + +def _no_same_fs_moves(monkeypatch, S): + """Production shape: hardlink and rename both fail across the mounts.""" + monkeypatch.setattr(S, "_try_link", lambda *a: False) + monkeypatch.setattr(S, "_try_rename", lambda *a: False) + + +def _record(db, path: Path, quality, cam=False): + with db.connect() as c: + c.execute("INSERT INTO library_files (path, quality, is_cam) VALUES (?,?,?)", + (str(path), quality, int(cam))) + + +MOVIE_DIR = "Some Movie (2019)" +MOVIE_FILE = "Some Movie (2019).mkv" + + +# ── F1: nothing unfiled is ever deleted with the torrent ───────────────────── + +def test_unparseable_release_is_quarantined_not_deleted(env): + rel = env.dl / "[Grp] Show - 13 [1080p]" + mk(rel / "[Grp] Show - 13 [1080p].mkv", 2) + rc = env.S.sort_release(rel, dry=False) + assert rc == env.S.EXIT_QUARANTINED + assert not rel.exists() + assert (env.dl / "_failed" / rel.name / "[Grp] Show - 13 [1080p].mkv").exists() + assert not list(env.lib.rglob("*.mkv")) + + +def test_quarantine_name_collisions_are_numbered(env): + for _ in range(2): + rel = env.dl / "Unparseable Thing" + mk(rel / "clip.mkv", 2) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_QUARANTINED + names = sorted(p.name for p in (env.dl / "_failed").iterdir()) + assert names == ["Unparseable Thing", "Unparseable Thing (2)"] + + +def test_seeding_release_is_left_alone(env): + """copy mode without REMOVE_ON_COMPLETE: the torrent keeps seeding, so + unfiled content stays exactly where it is.""" + env.S.MODE = "copy" + rel = env.dl / "[Grp] Show - 13 [1080p]" + f = mk(rel / "[Grp] Show - 13 [1080p].mkv", 2) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_OK + assert f.exists() and not (env.dl / "_failed").exists() + + +def test_missing_input_is_retryable_not_success(env): + assert env.S.sort_release(env.dl / "gone", dry=False) == env.S.EXIT_RETRY + + +def test_sorter_subprocess_exit_code_and_package_imports(env): + """Runs sort.py exactly like the hook does (script path, cwd = package + parent, no PYTHONPATH). The game release only lands in games/ if + faucet.classify imported — it silently didn't in production.""" + game = env.dl / "Some.Game-CODEX" + mk(game / "setup.iso", 2) + run_env = {k: v for k, v in os.environ.items() if k != "PYTHONPATH"} + run_env["FAUCET_PATH"] = str(game) + r = subprocess.run([sys.executable, str(REPO / "faucet" / "sort.py")], + env=run_env, cwd=str(env.tmp), capture_output=True, text=True, + check=False) + assert r.returncode == 0, r.stdout + r.stderr + assert (env.lib / "games" / "Windows" / "Some.Game-CODEX" / "setup.iso").exists() + + bad = env.dl / "[Grp] Show - 13 [1080p]" + mk(bad / "ep.mkv", 2) + run_env["FAUCET_PATH"] = str(bad) + r = subprocess.run([sys.executable, str(REPO / "faucet" / "sort.py")], + env=run_env, cwd=str(env.tmp), capture_output=True, text=True, + check=False) + assert r.returncode == 4, r.stdout + r.stderr + + +@pytest.mark.parametrize("rc,removed,event", [(0, True, "sorted"), + (4, True, "quarantined"), + (2, False, "sort_failed"), + (1, False, "sort_failed")]) +def test_hook_removes_torrent_only_when_nothing_is_left(env, monkeypatch, rc, removed, event): + monkeypatch.setenv("REMOVE_ON_COMPLETE", "1") + monkeypatch.setenv("FAUCET_PATH", str(env.dl / "x")) + monkeypatch.setenv("FAUCET_ID", "7") + from faucet import hook + importlib.reload(hook) + calls = [] + + class FakeClient: + def remove(self, tid, delete_data=False): + calls.append((tid, delete_data)) + + monkeypatch.setattr(hook, "make_client", lambda *a, **k: FakeClient()) + monkeypatch.setattr(hook.subprocess, "run", + lambda *a, **k: SimpleNamespace(returncode=rc)) + hook.main() + assert bool(calls) is removed + events = [r["event"] for r in env.db.recent_history()] + assert event in events + + +# ── F2: samples / extras / duplicates never overwrite the feature ──────────── + +@pytest.mark.parametrize("sample_first", [True, False]) +def test_sample_and_featurette_never_overwrite_feature(env, monkeypatch, sample_first): + _no_same_fs_moves(monkeypatch, env.S) + rel = env.dl / "Some.Movie.2019.2160p.UHD.BluRay.x265-GRP" + feature = mk(rel / "Some.Movie.2019.2160p.UHD.BluRay.x265-GRP.mkv", 3, b"F") + # the sample is BIGGER than the feature: size must not decide what's a sample + mk(rel / "Sample" / "some.movie.2019.2160p.sample.mkv", 5, b"S") + mk(rel / "Featurettes" / "Behind.The.Scenes.mkv", 4, b"B") + if not sample_first: + monkeypatch.setattr(env.S, "_files", lambda root, _f=env.S._files: list(reversed(_f(root)))) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_OK + movie = env.lib / "movies" / MOVIE_DIR + assert (movie / MOVIE_FILE).read_bytes() == b"F" * 3 * MB + assert (movie / "Featurettes" / "Behind.The.Scenes.mkv").read_bytes()[:1] == b"B" + assert not feature.exists() and not rel.exists() # sample was junk + assert not list(movie.rglob("*sample*")) + + +def test_inline_trailer_suffix_is_an_extra(env): + rel = env.dl / "Some.Movie.2019.1080p.WEB-DL-GRP" + mk(rel / "Some.Movie.2019.1080p.WEB-DL-GRP.mkv", 3) + mk(rel / "Some.Movie-trailer.mkv", 2) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_OK + assert (env.lib / "movies" / MOVIE_DIR / "Trailers" / "Some.Movie.mkv").exists() + + +def test_trailer_park_boys_is_not_a_trailer(env): + rel = env.dl / "Trailer.Park.Boys.S01E01.1080p.WEB-DL-GRP" + mk(rel / "Trailer.Park.Boys.S01E01.1080p.WEB-DL-GRP.mkv", 2) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_OK + assert (env.lib / "tvshows" / "Trailer Park Boys" / "Season 01" + / "Trailer Park Boys - S01E01.mkv").exists() + + +def test_unnumbered_duplicates_keep_largest_and_quarantine_rest(env): + rel = env.dl / "Some.Movie.2019.1080p.BluRay-GRP" + mk(rel / "Some.Movie.2019.1080p.BluRay-GRP.mkv", 4, b"A") + mk(rel / "Some.Movie.2019.1080p.BluRay-GRP.Alt.Cut.mkv", 2, b"B") + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_QUARANTINED + assert (env.lib / "movies" / MOVIE_DIR / MOVIE_FILE).read_bytes()[:1] == b"A" + left = list((env.dl / "_failed").rglob("*.mkv")) + assert [p.read_bytes()[:1] for p in left] == [b"B"] + + +def test_cd_parts_are_stacked(env): + rel = env.dl / "Some.Movie.2019.1080p.BluRay-GRP" + mk(rel / "Some.Movie.2019.1080p.BluRay-GRP.CD1.mkv", 2, b"1") + mk(rel / "Some.Movie.2019.1080p.BluRay-GRP.CD2.mkv", 2, b"2") + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_OK + movie = env.lib / "movies" / MOVIE_DIR + assert (movie / "Some Movie (2019) - pt1.mkv").read_bytes()[:1] == b"1" + assert (movie / "Some Movie (2019) - pt2.mkv").read_bytes()[:1] == b"2" + + +# ── F3 / F9: an interrupted copy never damages the library ─────────────────── + +def _dying_copy(fsrc, fdst, length=0): + fdst.write(fsrc.read(100)) + raise OSError(errno.EIO, "CIFS connection dropped") + + +@pytest.mark.parametrize("mode", ["move", "auto", "copy", "hardlink"]) +def test_failed_copy_leaves_existing_library_file_intact(env, monkeypatch, mode): + env.S.MODE = mode + _no_same_fs_moves(monkeypatch, env.S) + movie = env.lib / "movies" / MOVIE_DIR + existing = mk(movie / MOVIE_FILE, 2, b"O") + _record(env.db, existing, "720p") # upgrade is approved... + rel = env.dl / "Some.Movie.2019.1080p.BluRay-GRP" + src = mk(rel / "Some.Movie.2019.1080p.BluRay-GRP.mkv", 3, b"N") + monkeypatch.setattr(shutil, "copyfileobj", _dying_copy) # ...and the copy dies + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_RETRY + assert existing.read_bytes() == b"O" * 2 * MB + assert src.exists() + assert not list(movie.glob(".*faucet-partial")) + + +def test_final_path_never_holds_a_partial_file(env, monkeypatch): + _no_same_fs_moves(monkeypatch, env.S) + dest = env.lib / "movies" / MOVIE_DIR / MOVIE_FILE + real = shutil.copyfileobj + seen = [] + + def watching(fsrc, fdst, length=0): + seen.append(dest.exists()) + return real(fsrc, fdst, length) + + monkeypatch.setattr(shutil, "copyfileobj", watching) + rel = env.dl / "Some.Movie.2019.1080p.BluRay-GRP" + mk(rel / "Some.Movie.2019.1080p.BluRay-GRP.mkv", 2) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_OK + assert seen == [False] and dest.exists() + + +def test_stale_partials_from_killed_sorters_are_reaped(env): + movie = env.lib / "movies" / MOVIE_DIR + stale = mk(movie / f".{MOVIE_FILE}.99999.faucet-partial", 1) + live = mk(movie / f".{MOVIE_FILE}.88888.faucet-partial", 1) + old = stale.stat().st_mtime - env.S.STALE_PARTIAL_SECS - 60 + os.utime(stale, (old, old)) + rel = env.dl / "Some.Movie.2019.1080p.BluRay-GRP" + mk(rel / "Some.Movie.2019.1080p.BluRay-GRP.mkv", 2) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_OK + assert not stale.exists() and live.exists() + + +# ── F3: replace only when provably better ──────────────────────────────────── + +@pytest.mark.parametrize("recorded,cam,incoming,replaced", [ + ("720p", False, "1080p", True), # upgrade + ("1080p", True, "1080p", True), # cam -> real source at same resolution + ("1080p", False, "720p", False), # downgrade + ("1080p", False, "1080p", False), # sideways + (None, False, "2160p", False), # existing quality never recorded +]) +def test_replace_only_if_better(env, monkeypatch, recorded, cam, incoming, replaced): + _no_same_fs_moves(monkeypatch, env.S) + movie = env.lib / "movies" / MOVIE_DIR + existing = mk(movie / MOVIE_FILE, 2, b"O") + if recorded: + _record(env.db, existing, recorded, cam) + rel = env.dl / f"Some.Movie.2019.{incoming}.BluRay-GRP" + mk(rel / f"Some.Movie.2019.{incoming}.BluRay-GRP.mkv", 3, b"N") + rc = env.S.sort_release(rel, dry=False) + if replaced: + assert rc == env.S.EXIT_OK + assert existing.read_bytes()[:1] == b"N" + with env.db.connect() as c: + row = c.execute("SELECT quality, is_cam FROM library_files WHERE path=?", + (str(existing),)).fetchone() + assert row["quality"] == incoming and row["is_cam"] == 0 + else: + assert rc == env.S.EXIT_QUARANTINED + assert existing.read_bytes() == b"O" * 2 * MB + assert list((env.dl / "_failed").rglob("*.mkv")) + + +def test_same_size_duplicate_is_treated_as_filed(env): + movie = env.lib / "movies" / MOVIE_DIR + mk(movie / MOVIE_FILE, 2) + rel = env.dl / "Some.Movie.2019.1080p.BluRay-GRP" + mk(rel / "Some.Movie.2019.1080p.BluRay-GRP.mkv", 2) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_OK + assert not rel.exists() + + +def test_new_file_quality_is_recorded(env): + rel = env.dl / "Some.Movie.2019.1080p.HDTS.x264-GRP" + mk(rel / "Some.Movie.2019.1080p.HDTS.x264-GRP.mkv", 2) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_OK + with env.db.connect() as c: + row = c.execute("SELECT quality, is_cam, release FROM library_files").fetchone() + assert row["quality"] == "1080p" and row["is_cam"] == 1 + assert "HDTS" in row["release"] + + +# ── F4: subtitles keep their language tags ─────────────────────────────────── + +def test_sidecar_subtitles_keep_language_and_flags(env): + rel = env.dl / "Some.Movie.2019.1080p.BluRay-GRP" + stem = "Some.Movie.2019.1080p.BluRay-GRP" + mk(rel / f"{stem}.mkv", 2) + for tag in ("en", "es", "en.forced"): + (rel / f"{stem}.{tag}.srt").write_text(tag) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_OK + movie = env.lib / "movies" / MOVIE_DIR + for tag in ("en", "es", "en.forced"): + assert (movie / f"Some Movie (2019).{tag}.srt").read_text() == tag + + +def test_subs_folder_is_attached_to_movie(env): + rel = env.dl / "Some.Movie.2019.1080p.BluRay-GRP" + mk(rel / "Some.Movie.2019.1080p.BluRay-GRP.mkv", 2) + (rel / "Subs").mkdir() + (rel / "Subs" / "2_English.srt").write_text("full") + (rel / "Subs" / "3_English.srt").write_text("sdh") + (rel / "Subs" / "4_French.srt").write_text("fr") + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_OK + movie = env.lib / "movies" / MOVIE_DIR + # the English pair would collide once the ordinal is dropped, so it's kept + assert (movie / "Some Movie (2019).2_English.srt").read_text() == "full" + assert (movie / "Some Movie (2019).3_English.srt").read_text() == "sdh" + assert (movie / "Some Movie (2019).French.srt").read_text() == "fr" + assert not rel.exists() + + +def test_per_episode_subs_folders_follow_their_episode(env): + rel = env.dl / "Show.S01.1080p.WEB-DL-GRP" + for ep in ("01", "02"): + stem = f"Show.S01E{ep}.1080p.WEB-DL-GRP" + mk(rel / f"{stem}.mkv", 2) + d = rel / "Subs" / stem + d.mkdir(parents=True) + (d / "2_English.srt").write_text(ep) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_OK + season = env.lib / "tvshows" / "Show" / "Season 01" + assert (season / "Show - S01E01.English.srt").read_text() == "01" + assert (season / "Show - S01E02.English.srt").read_text() == "02" + + +def test_colliding_subtitle_is_never_overwritten(env): + movie = env.lib / "movies" / MOVIE_DIR + mk(movie / MOVIE_FILE, 2) + (movie / "Some Movie (2019).en.srt").write_text("original") + rel = env.dl / "Some.Movie.2019.1080p.BluRay-GRP" + stem = "Some.Movie.2019.1080p.BluRay-GRP" + mk(rel / f"{stem}.mkv", 2) + (rel / f"{stem}.en.srt").write_text("different subtitle") + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_QUARANTINED + assert (movie / "Some Movie (2019).en.srt").read_text() == "original" + + +# ── F6: disc images and packed video aren't games ──────────────────────────── + +def test_movie_disc_image_is_not_filed_as_game(env): + rel = env.dl / "Some.Movie.2019.1080p.BluRay-GRP" + mk(rel / "some.movie.iso", 2) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_QUARANTINED + assert not (env.lib / "games").exists() + + +def test_rar_packed_tv_is_not_filed_as_game(env): + rel = env.dl / "Show.S01E01.1080p.WEB-DL-GRP" + mk(rel / "show.s01e01.rar", 2) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_QUARANTINED + assert not (env.lib / "games").exists() + + +def test_console_rom_is_a_game_whatever_the_name(env): + rel = env.dl / "Something Unrecognisable" + mk(rel / "title.nsp", 2) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_OK + assert list((env.lib / "games").rglob("title.nsp")) + + +def test_existing_game_is_quarantined_not_deleted(env): + rel = env.dl / "Some.Game-CODEX" + mk(rel / "setup.iso", 2) + (env.lib / "games" / "Windows" / "Some.Game-CODEX").mkdir(parents=True) + assert env.S.sort_release(rel, dry=False) == env.S.EXIT_QUARANTINED + assert (env.dl / "_failed" / "Some.Game-CODEX" / "setup.iso").exists() + + +# ── guard rails ────────────────────────────────────────────────────────────── + +def test_quarantine_and_cleanup_refuse_dangerous_roots(env): + S = env.S + assert not S._safe_to_remove(env.lib) + assert not S._safe_to_remove(env.lib / "movies") + assert not S._safe_to_remove(Path("/downloads")) + assert not S._safe_to_remove(env.dl / "_failed" / "already-quarantined") + assert S._safe_to_remove(env.dl / "Some.Release") + + +def test_dry_run_touches_nothing(env): + rel = env.dl / "[Grp] Show - 13 [1080p]" + f = mk(rel / "ep.mkv", 2) + good = env.dl / "Some.Movie.2019.1080p.BluRay-GRP" + g = mk(good / "Some.Movie.2019.1080p.BluRay-GRP.mkv", 2) + assert env.S.sort_release(rel, dry=True) == env.S.EXIT_QUARANTINED + assert env.S.sort_release(good, dry=True) == env.S.EXIT_OK + assert f.exists() and g.exists() + assert not list(env.lib.rglob("*.mkv")) From de9f5d7e8f83cb3539a160f120864eaf193a5f3e Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:29:10 -0400 Subject: [PATCH 8/9] docs: describe the sorter's safety model and exit codes --- docs/HOOKS.md | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/HOOKS.md b/docs/HOOKS.md index f5e148f..b7e157c 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -70,6 +70,11 @@ Two things make this setup work cleanly: NAS mount). Then the sorter relocates finished files with an instant rename — one copy, no duplication. If they're on different filesystems, the sorter must copy, which leaves the original behind (and fills your download disk). + Note that two *separate* bind mounts count as different filesystems even when + they point into the same share (`/mnt/nas/torrents:/downloads` + + `/mnt/nas/media:/library`): rename fails with EXDEV and every move becomes a + copy. Mounting the share's parent once and pointing both paths inside it + restores instant renames. - **`MEDIASORT_MODE`.** Set this in the container's environment. `auto` (default) tries hardlink → move → copy; on a CIFS/SMB share (no hardlinks) it moves. Set it to `move` explicitly if you want predictable move-and-clean behavior and @@ -83,8 +88,37 @@ Two things make this setup work cleanly: 3. Fires notifications per `NOTIFY_ON`. 4. If `REMOVE_ON_COMPLETE=1`, removes the torrent from the client (stops seeding). -If the sort fails, the torrent is **not** removed — you never lose a file that -didn't make it to the library. +### How the sorter protects your files + +- **Nothing half-written is ever visible.** Copies are written to a hidden + `.<name>.<pid>.faucet-partial` beside the destination, fsynced, size-checked, + and only then renamed into place. A crash, a dropped share, or a container + restart mid-copy leaves the library untouched (idle partials are cleaned up + after 30 minutes). +- **Existing files are replaced only by provably better releases.** The sorter + records the resolution and cam/telesync status of every file it places. A + new release for the same path replaces it only if it ranks higher; equal, + worse, or unknown quality keeps what you have. +- **Samples are skipped, extras are kept.** `Sample/` content is ignored; + `Featurettes/`, `Behind The Scenes/`, `-trailer` files etc. go to the + matching Plex extras folder. Two videos that would get the same name never + overwrite each other (numbered `CD1`/`CD2` parts become `- pt1`/`- pt2`). +- **Subtitles keep their tags** (`Movie (2019).en.forced.srt`), including + RARBG-style `Subs/` folders. +- **Anything that can't be filed is quarantined, not deleted.** When the release + is being consumed (`MEDIASORT_MODE=move` or `REMOVE_ON_COMPLETE=1`), leftover + content — unparseable files, lower-quality duplicates, disc images, archives — + is moved intact to `_failed/` next to the release (override with + `QUARANTINE_DIR`). The sweep never touches `_failed/`; review it by hand. + +The sorter's exit code tells the hook what's safe: + +| rc | Meaning | Torrent removed? | +|----|---------|------------------| +| 0 | Everything of value filed (or left seeding) | yes, if `REMOVE_ON_COMPLETE=1` | +| 4 | Filed; some content quarantined to `_failed/` | yes — nothing is left inside it | +| 2 | I/O error; release left in place for retry | no | +| 1 | Library not mounted / no input | no | ## Catch-up sweep (safety net) From 194b129fef769468c6654d1faed00acee549b0d9 Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:30:03 -0400 Subject: [PATCH 9/9] readme: document MEDIASORT_MODE and QUARANTINE_DIR --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4cef8d4..7096f13 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,9 @@ All via environment / `.env`: | `DOWNLOAD_DIR` | `/downloads` | Active download dir. | | `DISK_PATH` | `/downloads` | Path used for the disk-free gauge. | | `BROWSE_ROOT` | `LIBRARY_ROOT` | Root the admin file browser is scoped to. | -| `REMOVE_ON_COMPLETE` | `0` | Remove finished torrents (stops seeding). | +| `REMOVE_ON_COMPLETE` | `0` | Remove finished torrents (stops seeding). Only after a clean sort; unfiled content is quarantined first. | +| `MEDIASORT_MODE` | `auto` | `auto` \| `hardlink` \| `copy` \| `move`. See [docs/HOOKS.md](docs/HOOKS.md). | +| `QUARANTINE_DIR` | `<release parent>/_failed` | Where the sorter parks content it couldn't file. | | `HUNT_MAX_ACTIVE` | `5` | Skip hunting if this many torrents are already downloading. | | `HUNT_MAX_PER_RUN` | `3` | Max grabs per scheduler tick. | | `RSS_INTERVAL_SECONDS` | `1800` | How often the scheduler scans/reconciles/hunts. |