From aed11df442c5fd178beb2f686c78b574957de3c0 Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:06:11 -0400 Subject: [PATCH 1/9] =?UTF-8?q?quality:=20is=5Fupgrade=20=E2=80=94=20does?= =?UTF-8?q?=20a=20release=20beat=20the=20owned=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- faucet/quality.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/faucet/quality.py b/faucet/quality.py index 0934399..acb3e8c 100644 --- a/faucet/quality.py +++ b/faucet/quality.py @@ -52,3 +52,17 @@ def is_better(new_quality: str | None, new_cam: bool, if not new_quality or not old_quality: return False return file_rank(new_quality, new_cam) > file_rank(old_quality, old_cam) + + +def is_upgrade(release_name: str, owned_quality: str | None, owned_cam: bool) -> bool: + """Would grabbing `release_name` improve on the owned file? + + A cam of unknown resolution is beaten by any real source whose resolution + is known; otherwise the release must be provably better (is_better). An + upgrade grab that isn't better only gets quarantined by the sorter, so + without this check the hunter re-downloads same-quality copies every + GRAB_RETRY_HOURS forever.""" + new_q, new_cam = detect_quality(release_name), detect_cam(release_name) + if owned_cam and not owned_quality: + return bool(new_q) and not new_cam + return is_better(new_q, new_cam, owned_quality, owned_cam) From ac95f5c829248bf4c9d2224482ac586310e20801 Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:06:23 -0400 Subject: [PATCH 2/9] wants: atomic shared upsert for episode and movie wants --- faucet/wants.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 faucet/wants.py diff --git a/faucet/wants.py b/faucet/wants.py new file mode 100644 index 0000000..9167670 --- /dev/null +++ b/faucet/wants.py @@ -0,0 +1,53 @@ +"""The `wanted` table's write path, shared by series and movie reconcile. + +One row per wanted item, keyed on (kind, series_id, season, episode). For +movies `series_id` holds the movie id and season/episode are NULL, so the key +is enforced by the `ux_wanted_key` expression index (db.py) rather than the +table's UNIQUE constraint: SQLite treats NULLs in a UNIQUE constraint as +distinct, which let two concurrent reconciles insert the same movie twice. + +`upsert` is atomic across threads and processes: BEGIN IMMEDIATE takes the +write lock before the existence check, so select-then-insert can't race. +""" +from __future__ import annotations + +from datetime import datetime, timedelta + +from . import db + + +def upsert(kind: str, series_id: int, season: int | None, episode: int | None, + title: str, reason: str, retry_hours: float) -> str: + """Create or refresh one want. Returns what happened: + 'inserted' | 'updated' | 'requeued' (a stale grab flipped back to wanted) + | 'kept' (an in-flight grab, or a status this doesn't manage). + + A 'grabbed' row whose last_search is older than `retry_hours` means the + download never landed; it goes back to 'wanted' so it retries.""" + title = title or "" + with db.connect() as c: + c.execute("BEGIN IMMEDIATE") + c.execute( + "INSERT OR IGNORE INTO wanted (kind, series_id, season, episode, title, reason, status) " + "VALUES (?,?,?,?,?,?,'wanted')", + (kind, series_id, season, episode, title, reason)) + if c.execute("SELECT changes() AS n").fetchone()["n"]: + return "inserted" + row = c.execute( + "SELECT id, status, last_search FROM wanted WHERE kind=? AND series_id=? " + "AND season IS ? AND episode IS ? ORDER BY id LIMIT 1", + (kind, series_id, season, episode)).fetchone() + if row is None: # pragma: no cover - index guarantees a row + return "kept" + if row["status"] == "wanted": + c.execute("UPDATE wanted SET reason=?, title=? WHERE id=?", + (reason, title, row["id"])) + return "updated" + if row["status"] == "grabbed": + retry_before = (datetime.now() - timedelta(hours=retry_hours) + ).isoformat(timespec="seconds") + if not row["last_search"] or row["last_search"] < retry_before: + c.execute("UPDATE wanted SET status='wanted', reason=?, title=? WHERE id=?", + (reason, title, row["id"])) + return "requeued" + return "kept" From 41c915de318569844bbf2e703a851cc1b3507e2b Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:07:30 -0400 Subject: [PATCH 3/9] db: dedupe wants and enforce one per item, movies included --- faucet/db.py | 43 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/faucet/db.py b/faucet/db.py index 2b4864f..30747fc 100644 --- a/faucet/db.py +++ b/faucet/db.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import logging import sqlite3 import threading from contextlib import contextmanager @@ -19,6 +20,8 @@ from .config import config +log = logging.getLogger("faucet.db") + _lock = threading.Lock() _initialized = False @@ -302,15 +305,41 @@ def cols(table): c.execute(f"ALTER TABLE {table} ADD COLUMN {col} {decl}") except Exception: # noqa: BLE001 - table may not exist yet pass - # Older DBs keyed `wanted` on (…, title): a TMDb episode retitle then - # duplicated the want and it was hunted twice. Collapse such duplicates, - # keeping the earliest row per (kind, series_id, season, episode). + _migrate_wanted_key(c) + + +def _migrate_wanted_key(c) -> None: + """Enforce one want per (kind, series_id, season, episode). + + Older DBs keyed `wanted` on (..., title), so a TMDb episode retitle + duplicated the want and it was hunted twice. Even the current UNIQUE + constraint never covered movies: their season/episode are NULL, and SQLite + treats NULLs in a UNIQUE constraint as distinct. An expression index with + IFNULL() closes both holes without rebuilding the table — the old + title-keyed constraint is a superset key, so it never rejects anything the + new index allows. + + Duplicates are collapsed first (the index can't be built over them), + keeping the row most likely to reflect reality: an in-flight grab over a + plain want, then the most recently searched, then the oldest. + """ try: c.execute( - "DELETE FROM wanted WHERE id NOT IN (" - " SELECT MIN(id) FROM wanted GROUP BY kind, series_id, season, episode)") - except Exception: # noqa: BLE001 - pass + "DELETE FROM wanted WHERE id IN (" + " SELECT id FROM (" + " SELECT id, ROW_NUMBER() OVER (" + " PARTITION BY kind, IFNULL(series_id, -1), IFNULL(season, -1), IFNULL(episode, -1)" + " ORDER BY (status = 'grabbed') DESC, IFNULL(last_search, '') DESC, id" + " ) AS rn FROM wanted" + " ) WHERE rn > 1)") + c.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS ux_wanted_key ON wanted (" + "kind, IFNULL(series_id, -1), IFNULL(season, -1), IFNULL(episode, -1))") + except sqlite3.Error as e: + # Loud on purpose: without this index concurrent reconciles duplicate + # wants and the same item is grabbed twice. + log.error("wanted-table key migration failed (%s); duplicate wants " + "are possible until it succeeds", e) # ---- generic settings KV (the in-app settings editor uses this) ---- From 98e8b0ee7b2e7c6ffef0f07baf982b4923adda8e Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:08:49 -0400 Subject: [PATCH 4/9] library: record-aware quality, best file per episode, retire superseded copies --- faucet/library.py | 175 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 161 insertions(+), 14 deletions(-) diff --git a/faucet/library.py b/faucet/library.py index af5b10e..990f39a 100644 --- a/faucet/library.py +++ b/faucet/library.py @@ -111,6 +111,102 @@ def _record_unparsed(path: str, kind: str, reason: str) -> None: pass +def _recorded_quality(c, path: str, size: int): + """(quality, is_cam) the sorter recorded for this file, or None. + + The sorter renames releases to 'Title (Year).mkv' / 'Show - S01E01.mkv', + so for anything it placed the filename carries no quality at all; the + record is the only source. A record whose size no longer matches belongs + to a file that has since been replaced by hand, so it's ignored.""" + row = c.execute("SELECT quality, is_cam, size FROM library_files WHERE path=?", + (path,)).fetchone() + if row is None or (row["size"] and row["size"] != size): + return None + if not row["quality"] and not row["is_cam"]: + return None + return row["quality"], bool(row["is_cam"]) + + +def _unchanged(c, table: str, path: str, mtime: float) -> bool: + """Incremental-scan skip test. A row saved before the sorter's quality + record existed (quality NULL) is re-read once the record appears.""" + row = c.execute(f"SELECT mtime, quality FROM {table} WHERE path=?", + (path,)).fetchone() + if not row or abs((row["mtime"] or 0) - mtime) >= 1: + return False + if row["quality"] is not None: + return True + return c.execute( + "SELECT 1 FROM library_files WHERE path=? AND (quality IS NOT NULL OR is_cam=1)", + (path,)).fetchone() is None + + +SUPERSEDED_DIR = "_superseded" # under LIBRARY_ROOT, outside Plex sections + + +def _superseded(winner: Path, loser: Path, stats: dict) -> None: + """A strictly better copy of the same title/episode exists, so `loser` is + redundant. Candidates are only collected here; _retire_superseded moves + them after the walk, and only if the scan looked healthy.""" + pending = stats.setdefault("_superseded", {}) + pending[loser] = winner # a loser can be met twice in one walk + stats["superseded"] = len(pending) + + +def _retire_superseded(root: Path, pairs: dict) -> int: + """Move superseded files (and their sidecar subtitles) to + /_superseded/. Same share as the library, so + it's a rename; reversible; outside the movies/tvshows sections. + + Guard rails — a file is only moved when: + * the winner is a file the sorter placed (library_files record whose + size still matches), so a half-written or hand-renamed file can't + push out a good one + * winner and loser sit in the same folder (collection folders and + Fix Location links are never touched) + SUPERSEDED_ACTION=keep disables moving entirely.""" + if os.environ.get("SUPERSEDED_ACTION", "move").strip().lower() != "move": + return 0 + moved = 0 + base = root / SUPERSEDED_DIR + try: + base.mkdir(parents=True, exist_ok=True) + # keep media servers out even if a library points at the media root: + # .plexignore for Plex, .ignore for Jellyfin/Emby + for marker, body in ((".plexignore", "*\n"), (".ignore", "")): + if not (base / marker).exists(): + (base / marker).write_text(body) + except OSError as e: + log.warning("cannot prepare %s: %s", base, e) + return 0 + for loser, winner in pairs.items(): + try: + if winner.parent != loser.parent or not loser.exists(): + continue + with db.connect() as c: + if _recorded_quality(c, str(winner), winner.stat().st_size) is None: + continue + rel = loser.relative_to(root) + companions = [p for p in loser.parent.iterdir() + if p != loser and p.is_file() + and p.name.startswith(loser.stem + ".") + and p.suffix.lower() not in VIDEO_EXTS] + for src in [loser, *companions]: + dest = base / rel.parent / src.name + n = 2 + while dest.exists(): + dest = base / rel.parent / f"{src.stem} ({n}){src.suffix}" + n += 1 + dest.parent.mkdir(parents=True, exist_ok=True) + os.rename(src, dest) + log.info("retired superseded %s -> %s (better copy: %s)", + src.name, dest, winner.name) + moved += 1 + except OSError as e: + log.warning("could not retire superseded %s: %s", loser, e) + return moved + + def _scan_tv(root: Path, stats: dict, force: bool = False, seen: set | None = None) -> None: tv = root / "tvshows" @@ -129,9 +225,8 @@ def _scan_tv(root: Path, stats: dict, force: bool = False, seen.add(str(f)) # incremental: skip unchanged files already recorded with db.connect() as c: - row = c.execute("SELECT mtime FROM library_episodes WHERE path=?", - (str(f),)).fetchone() - if not force and row and abs((row["mtime"] or 0) - st.st_mtime) < 1: + unchanged = _unchanged(c, "library_episodes", str(f), st.st_mtime) + if not force and unchanged: stats["skipped"] += 1 continue @@ -168,8 +263,25 @@ def _scan_tv(root: Path, stats: dict, force: bool = False, continue if isinstance(episode, list): episode = episode[0] - quality = _detect_quality(f.name) or _detect_quality(str(f)) with db.connect() as c: + rec = _recorded_quality(c, str(f), st.st_size) + quality = rec[0] if rec else (_detect_quality(f.name) or _detect_quality(str(f))) + # Keep the BEST file per episode. Last-walked used to win, so an + # old 720p copy and its 1080p upgrade flip-flopped between scans + # and the upgrade want kept coming back. + row = c.execute("SELECT path, quality FROM library_episodes " + "WHERE season=? AND episode=? AND show_name=?", + (int(season), int(episode), show)).fetchone() + if (row and row["path"] != str(f) + and os.path.exists(row["path"] or "")): + old_rank = _movie_file_rank(row["quality"], False) + new_rank = _movie_file_rank(quality, False) + if old_rank >= new_rank: + if old_rank > new_rank: + _superseded(Path(row["path"]), f, stats) + stats["episodes"] += 1 + continue # existing file is at least as good + _superseded(f, Path(row["path"]), stats) c.execute( "INSERT INTO library_episodes (show_name, season, episode, quality, path, size, mtime) " "VALUES (?,?,?,?,?,?,?) " @@ -197,9 +309,8 @@ def _scan_movies(root: Path, stats: dict, force: bool = False, if seen is not None: seen.add(str(f)) with db.connect() as c: - row = c.execute("SELECT mtime FROM library_movies WHERE path=?", - (str(f),)).fetchone() - if not force and row and abs((row["mtime"] or 0) - st.st_mtime) < 1: + unchanged = _unchanged(c, "library_movies", str(f), st.st_mtime) + if not force and unchanged: stats["skipped"] += 1 continue info = guessit(f.name) if guessit else {} @@ -209,8 +320,13 @@ def _scan_movies(root: Path, stats: dict, force: bool = False, _record_unparsed(str(f), "movie", "no title parsed") continue year = info.get("year") - quality = _detect_quality(f.name) - is_cam = _detect_cam(f.name) or _detect_cam(str(f.parent.name)) + with db.connect() as c: + rec = _recorded_quality(c, str(f), st.st_size) + if rec: + quality, is_cam = rec + else: + quality = _detect_quality(f.name) + is_cam = _detect_cam(f.name) or _detect_cam(str(f.parent.name)) with db.connect() as c: # Keep the BEST file per (title, year): after an upgrade lands, the # old copy and the new one can briefly coexist — blindly last-write @@ -218,11 +334,15 @@ def _scan_movies(root: Path, stats: dict, force: bool = False, row = c.execute("SELECT path, quality, source FROM library_movies " "WHERE title=? AND year IS ?", (title, year)).fetchone() if (row and row["path"] != str(f) - and os.path.exists(row["path"] or "") - and _movie_file_rank(row["quality"], row["source"] == "CAM") - > _movie_file_rank(quality, is_cam)): - stats["movies"] += 1 - continue # existing file is better — keep it + and os.path.exists(row["path"] or "")): + old_rank = _movie_file_rank(row["quality"], row["source"] == "CAM") + new_rank = _movie_file_rank(quality, is_cam) + if old_rank >= new_rank: + if old_rank > new_rank: + _superseded(Path(row["path"]), f, stats) + stats["movies"] += 1 + continue # existing file is at least as good + _superseded(f, Path(row["path"]), stats) c.execute( "INSERT INTO library_movies (title, year, quality, source, path, size, mtime) " "VALUES (?,?,?,?,?,?,?) " @@ -290,13 +410,25 @@ def scan(force: bool = False) -> dict: "treating this as a mount/NAS hiccup, not deletions.", gone, total_rows) stats["prune_skipped"] = gone + healthy = False else: + healthy = True for rid, _ in gone_eps: c.execute("DELETE FROM library_episodes WHERE id=?", (rid,)) stats["pruned"] += 1 for rid, _ in gone_mv: c.execute("DELETE FROM library_movies WHERE id=?", (rid,)) stats["pruned"] += 1 + # quality records for files that no longer exist + for row in c.execute("SELECT path FROM library_files").fetchall(): + p = row["path"] or "" + if (p.startswith((tv_prefix, mv_prefix)) + and p not in seen_tv and p not in seen_mv + and not os.path.exists(p)): + c.execute("DELETE FROM library_files WHERE path=?", (p,)) + pairs = stats.pop("_superseded", {}) + if pairs and healthy: + stats["superseded_moved"] = _retire_superseded(root, pairs) log.info("Library scan: %d episodes, %d movies (%d skipped, %d unparsed, %d pruned)", stats["episodes"], stats["movies"], stats["skipped"], stats["unparsed"], stats["pruned"]) @@ -317,6 +449,21 @@ def have_episode(show_name: str, season: int, episode: int) -> dict | None: return None +def owned_file_quality(path: str | None, quality: str | None) -> tuple[str | None, bool]: + """(quality, is_cam) of an owned library file, preferring the sorter's record.""" + if not path: + return quality, False + try: + size = os.path.getsize(path) + except OSError: + size = 0 + with db.connect() as c: + rec = _recorded_quality(c, path, size) + if rec: + return rec + return quality, _detect_cam(os.path.basename(path)) + + def have_movie(title: str, year: int | None = None) -> dict | None: """Is this movie on disk? Matches on the normalized title (exact SQL title equality misses every punctuation/casing difference between the on-disk From 2a8b52e1604c4f3927e1c90944e0cf3aec29f5e0 Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:09:21 -0400 Subject: [PATCH 5/9] movies: shared want upsert; expose owned quality for the hunter --- faucet/movies.py | 76 +++++++++++++++++++----------------------------- 1 file changed, 30 insertions(+), 46 deletions(-) diff --git a/faucet/movies.py b/faucet/movies.py index ea518bd..bfb71e9 100644 --- a/faucet/movies.py +++ b/faucet/movies.py @@ -88,6 +88,27 @@ def _movie_matches(lib_title: str, lib_year, mon_title: str, mon_year) -> bool: return False +def _owned_row(m: dict): + """The library_movies row that satisfies monitored movie `m`, or None.""" + with db.connect() as c: + lib = c.execute("SELECT title, year, quality, source, path " + "FROM library_movies").fetchall() + for r in lib: + if _movie_matches(r["title"], r["year"], m["title"], m.get("year")): + return r + return None + + +def owned_quality(movie_id: int) -> tuple[bool, str | None, bool]: + """(owned, quality, is_cam) for a monitored movie — what an upgrade grab + has to beat.""" + m = get_movie(movie_id) + r = _owned_row(m) if m else None + if r is None: + return False, None, False + return True, r["quality"], r["source"] == "CAM" + + def reconcile(movie_id: int) -> dict: """Mark a monitored movie have/wanted by matching the library. Uses year-anchored subset matching so truncated disk folders still match TMDb's @@ -95,17 +116,10 @@ def reconcile(movie_id: int) -> dict: m = get_movie(movie_id) if not m: return {"have": False} - with db.connect() as c: - lib = c.execute("SELECT title, year, quality, source FROM library_movies").fetchall() - owned = None - for r in lib: - if _movie_matches(r["title"], r["year"], m["title"], m.get("year")): - owned = r - break + owned = _owned_row(m) have = owned is not None from .series import GRAB_RETRY_HOURS, _profile_min_res from .library import RES_RANK - from datetime import timedelta import os as _os # ── quality-upgrade check (the Radarr side of upgrades) ── @@ -123,8 +137,8 @@ def reconcile(movie_id: int) -> dict: except Exception: # noqa: BLE001 profile_id = None target_rank = RES_RANK.get(_profile_min_res(profile_id), 0) - owned_q = owned["quality"] if "quality" in owned.keys() else None - owned_cam = (owned["source"] if "source" in owned.keys() else None) == "CAM" + owned_q = owned["quality"] + owned_cam = owned["source"] == "CAM" if target_rank and owned_cam: upgrade_needed = True # a cam is never good enough elif target_rank and owned_q and RES_RANK.get(owned_q, 0) < target_rank: @@ -133,45 +147,15 @@ def reconcile(movie_id: int) -> dict: with db.connect() as c: c.execute("UPDATE movies SET status=? WHERE id=?", ("have" if have else "wanted", movie_id)) - if have and upgrade_needed: - title = f"{m['title']} {m['year']}" if m.get("year") else m["title"] - row = c.execute( - "SELECT id, status, last_search FROM wanted WHERE kind='movie' AND series_id=?", - (movie_id,)).fetchone() - if not row: - c.execute( - "INSERT INTO wanted (kind, series_id, title, reason, status) " - "VALUES ('movie',?,?, 'upgrade','wanted')", - (movie_id, title)) - elif row["status"] == "grabbed": - retry_before = (datetime.now() - timedelta(hours=GRAB_RETRY_HOURS) - ).isoformat(timespec="seconds") - if not row["last_search"] or row["last_search"] < retry_before: - c.execute("UPDATE wanted SET status='wanted', reason='upgrade' " - "WHERE id=?", (row["id"],)) - else: - c.execute("UPDATE wanted SET reason='upgrade' WHERE id=?", (row["id"],)) - elif have: + if have and not upgrade_needed: # on disk at (or above) target — retire any want, incl. stale 'grabbed' c.execute("DELETE FROM wanted WHERE kind='movie' AND series_id=?", (movie_id,)) - else: - title = f"{m['title']} {m['year']}" if m.get("year") else m["title"] - row = c.execute( - "SELECT id, status, last_search FROM wanted WHERE kind='movie' AND series_id=?", - (movie_id,)).fetchone() - if not row: - c.execute( - "INSERT INTO wanted (kind, series_id, title, reason, status) " - "VALUES ('movie',?,?, 'missing','wanted')", - (movie_id, title)) - elif row["status"] == "grabbed": - # a grab that never landed on disk retries after the window - retry_before = (datetime.now() - timedelta(hours=GRAB_RETRY_HOURS) - ).isoformat(timespec="seconds") - if not row["last_search"] or row["last_search"] < retry_before: - c.execute("UPDATE wanted SET status='wanted' WHERE id=?", - (row["id"],)) + if not have or upgrade_needed: + from . import wants + title = f"{m['title']} {m['year']}" if m.get("year") else m["title"] + wants.upsert("movie", movie_id, None, None, title, + "upgrade" if have else "missing", GRAB_RETRY_HOURS) return {"have": have, "upgrade": upgrade_needed} From 3d5ed434970c32195b475984b66ff0579e4aafa1 Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:10:05 -0400 Subject: [PATCH 6/9] series: episode wants go through the shared atomic upsert --- faucet/series.py | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/faucet/series.py b/faucet/series.py index ff57b8e..aeec724 100644 --- a/faucet/series.py +++ b/faucet/series.py @@ -181,31 +181,11 @@ def reconcile(series_id: int) -> dict: def _add_wanted(series_id: int, season: int, episode: int, title: str, reason: str) -> None: """Upsert one episode want keyed on (series, season, episode) — NOT on the - episode title, which TMDb can rename between refreshes (the old title-keyed - UNIQUE constraint duplicated wants when that happened). A row stuck in + episode title, which TMDb can rename between refreshes. A row stuck in 'grabbed' with nothing on disk is flipped back to 'wanted' after GRAB_RETRY_HOURS so a failed download eventually retries.""" - from datetime import timedelta - with db.connect() as c: - row = c.execute( - "SELECT id, status, last_search FROM wanted WHERE kind='episode' " - "AND series_id=? AND season=? AND episode=?", - (series_id, season, episode)).fetchone() - if row is None: - c.execute( - "INSERT INTO wanted (kind, series_id, season, episode, title, reason, status) " - "VALUES ('episode',?,?,?,?,?,'wanted')", - (series_id, season, episode, title or "", reason)) - return - if row["status"] == "wanted": - c.execute("UPDATE wanted SET reason=?, title=? WHERE id=?", - (reason, title or "", row["id"])) - elif row["status"] == "grabbed": - retry_before = (datetime.now() - - timedelta(hours=GRAB_RETRY_HOURS)).isoformat(timespec="seconds") - if not row["last_search"] or row["last_search"] < retry_before: - c.execute("UPDATE wanted SET status='wanted', reason=?, title=? WHERE id=?", - (reason, title or "", row["id"])) + from . import wants + wants.upsert("episode", series_id, season, episode, title, reason, GRAB_RETRY_HOURS) def _clear_wanted(series_id: int, season: int, episode: int, From b60f21805aa0c006c30e0c4d8e0dea3739639ae5 Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:11:31 -0400 Subject: [PATCH 7/9] scheduler: upgrade grabs must beat the owned file --- faucet/scheduler.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/faucet/scheduler.py b/faucet/scheduler.py index 7266273..59f5a61 100644 --- a/faucet/scheduler.py +++ b/faucet/scheduler.py @@ -356,6 +356,26 @@ def hunt_wanted(series_filter=None, max_override=None) -> dict: if _packs.classify_pack(r["title"]).get("kind") == "single" and _rm.matches_episode(r["title"], title, int(season), int(episode))] + if w.get("reason") == "upgrade": + # An upgrade has to beat the file on disk. The profile alone isn't + # enough: a ["1080p", "720p"] profile happily ranks a 720p release + # for a 720p-owned movie, the sorter refuses it (not better), and + # the want re-grabs another copy every GRAB_RETRY_HOURS. + from . import quality as _q + if kind == "movie": + from . import movies as _mov + is_owned, owned_q, owned_cam = _mov.owned_quality(series_id) + else: + is_owned = bool(owned) + owned_q, owned_cam = _lib.owned_file_quality( + owned.get("path"), owned.get("quality")) if owned else (None, False) + if is_owned: + before = len(fresh) + fresh = [r for r in fresh if _q.is_upgrade(r["title"], owned_q, owned_cam)] + if not fresh: + res["error"] = (f"no release better than owned " + f"{owned_q or 'unknown'}{' CAM' if owned_cam else ''} " + f"({before} candidates)") if profile: ranked = prof.rank(fresh, profile) else: From c4fd820fb8304d2d51afdc78bff1e26b2a938ffd Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:12:51 -0400 Subject: [PATCH 8/9] tests: upgrade, wanted-key and superseded-file regressions --- tests/test_wants_upgrades.py | 479 +++++++++++++++++++++++++++++++++++ 1 file changed, 479 insertions(+) create mode 100644 tests/test_wants_upgrades.py diff --git a/tests/test_wants_upgrades.py b/tests/test_wants_upgrades.py new file mode 100644 index 0000000..cde0526 --- /dev/null +++ b/tests/test_wants_upgrades.py @@ -0,0 +1,479 @@ +"""Upgrade and wanted-table correctness. + +F14: an upgrade that the sorter files as 'Title (Year).mkv' carries no quality + tag, so the scanner kept the old tagged file as "best" and the upgrade + want re-grabbed every GRAB_RETRY_HOURS forever. +F19: prod's `wanted` table still has the old title-keyed UNIQUE constraint, and + wants were created select-then-insert. +F20: movie wants (NULL season/episode) were never unique. +""" +from __future__ import annotations + +import importlib +import json +import sqlite3 +import threading +from datetime import datetime, timedelta +from pathlib import Path +from types import SimpleNamespace + +import pytest + +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" + dl = tmp_path / "downloads" / "complete" + lib.mkdir() + dl.mkdir(parents=True) + monkeypatch.setenv("EVENTS_FILE", str(tmp_path / "config" / "events.jsonl")) + monkeypatch.setenv("LIBRARY_ROOT", str(lib)) + monkeypatch.setenv("JACKETT_API_KEY", "k") + monkeypatch.setenv("HUNT_MAX_PER_RUN", "5") + monkeypatch.setenv("HUNT_MAX_ACTIVE", "10") + monkeypatch.setenv("MEDIASORT_MODE", "move") + monkeypatch.setenv("MEDIASORT_MIN_MB", "1") + monkeypatch.setenv("MEDIASORT_LOG", str(tmp_path / "sort.log")) + for k in ("REMOVE_ON_COMPLETE", "QUARANTINE_DIR", "HUNT_UPGRADES"): + monkeypatch.delenv(k, raising=False) + mods = {} + for name in ("config", "db"): + mods[name] = importlib.reload(importlib.import_module(f"faucet.{name}")) + mods["db"].init() + for name in ("quality", "library", "wants", "series", "movies", "scheduler", "sort"): + mods[name] = importlib.reload(importlib.import_module(f"faucet.{name}")) + monkeypatch.setattr(mods["library"], "MIN_SIZE", 1 * MB) + return SimpleNamespace(tmp=tmp_path, lib=lib, dl=dl, db=mods["db"], L=mods["library"], + W=mods["wants"], S=mods["series"], M=mods["movies"], + SCH=mods["scheduler"], SORT=mods["sort"]) + + +def _profile(db, resolutions): + with db.connect() as c: + return c.execute("INSERT INTO profiles (name,min_seeders,resolutions,sources) " + "VALUES ('P',0,?,?)", + (json.dumps(resolutions), json.dumps(["WEB-DL", "BluRay"]))).lastrowid + + +def _sort_in(env, release: str, mb: float = 3): + """File a release through the real sorter, exactly as the hook would.""" + rel = env.dl / release + mk(rel / f"{release}.mkv", mb, b"N") + assert env.SORT.sort_release(rel, dry=False) == env.SORT.EXIT_OK + + +def _count_wants(db, **where): + q = " AND ".join(f"{k}=?" for k in where) or "1=1" + with db.connect() as c: + return c.execute(f"SELECT COUNT(*) AS n FROM wanted WHERE {q}", + tuple(where.values())).fetchone()["n"] + + +def _series(db, title, profile_id, eps=((1, 1),)): + with db.connect() as c: + sid = c.execute("INSERT INTO series (tmdb_id,title,monitored,profile_id) " + "VALUES (?,?,1,?)", (abs(hash(title)) % 10**6, title, + profile_id)).lastrowid + for s, e in eps: + c.execute("INSERT INTO series_episodes (series_id,season,episode,title,air_date) " + "VALUES (?,?,?,?,'2020-01-01')", (sid, s, e, f"E{e}")) + return sid + + +class _Added: + id = "a" + name = "x" + duplicate = False + + +class _Client: + def __init__(self): + self.added = [] + + def list_transfers(self): + return [] + + def add(self, href, *a, **k): + self.added.append(href) + return _Added() + + +def _release(title, href): + from faucet.search import parse_badges + return {"title": title, "href": href, "seeders": 50, "size": 2 * 1024 ** 3, + "badges": parse_badges(title)} + + +# ── F14: upgrades clear once the sorter files the better copy ──────────────── + +def test_movie_upgrade_clears_when_sorter_files_better_copy(env): + mk(env.lib / "movies" / "Rags (2012)" / "Rags.2012.720p.WEB-DL.x264.mkv", 2) + env.L.scan() + mid = env.M.add_movie(12, "Rags", 2012, None, _profile(env.db, ["1080p"])) + assert env.M.reconcile(mid)["upgrade"] is True + + _sort_in(env, "Rags.2012.1080p.WEB-DL.x264-GRP") + canonical = env.lib / "movies" / "Rags (2012)" / "Rags (2012).mkv" + assert canonical.exists() + + stats = env.L.scan() + assert stats.get("superseded") == 1 + r = env.M.reconcile(mid) + assert r["have"] is True and r["upgrade"] is False + assert _count_wants(env.db, kind="movie", series_id=mid) == 0 + for force in (False, True, True): # stable across rescans + env.L.scan(force=force) + with env.db.connect() as c: + row = c.execute("SELECT path, quality FROM library_movies").fetchone() + assert row["path"] == str(canonical) and row["quality"] == "1080p" + + +def test_cam_upgrade_clears_when_real_source_lands(env): + mk(env.lib / "movies" / "Zootopia 2 (2025)" / "Zootopia 2 2025 1080p TS EN-RGB.mp4", 2) + env.L.scan() + mid = env.M.add_movie(11, "Zootopia 2", 2025, None, _profile(env.db, ["1080p"])) + assert env.M.reconcile(mid)["upgrade"] is True + + _sort_in(env, "Zootopia.2.2025.1080p.WEB-DL.x264-GRP") + env.L.scan() + assert env.M.reconcile(mid)["upgrade"] is False + with env.db.connect() as c: + row = c.execute("SELECT path, source FROM library_movies").fetchone() + assert row["path"].endswith("Zootopia 2 (2025).mkv") and row["source"] is None + + +def test_unrecorded_canonical_file_never_triggers_an_upgrade(env): + """A pre-existing Faucet-sorted file has no quality anywhere. Unknown is + left alone rather than re-downloaded.""" + mk(env.lib / "movies" / "Rags (2012)" / "Rags (2012).mkv", 2) + env.L.scan() + mid = env.M.add_movie(12, "Rags", 2012, None, _profile(env.db, ["1080p"])) + assert env.M.reconcile(mid) == {"have": True, "upgrade": False} + + +@pytest.mark.parametrize("tagged_first", [True, False]) +def test_episode_best_file_wins_regardless_of_order(env, tagged_first): + season = env.lib / "tvshows" / "Show" / "Season 01" + if tagged_first: + mk(season / "Show.S01E01.720p.WEB-DL.mkv", 2) + env.L.scan() + _sort_in(env, "Show.S01E01.1080p.WEB-DL-GRP") + else: + _sort_in(env, "Show.S01E01.1080p.WEB-DL-GRP") + env.L.scan() + mk(season / "Show.S01E01.720p.WEB-DL.mkv", 2) + for force in (False, True, False, True): + env.L.scan(force=force) + owned = env.L.have_episode("Show", 1, 1) + assert owned["quality"] == "1080p" + assert owned["path"].endswith("Show - S01E01.mkv") + sid = _series(env.db, "Show", _profile(env.db, ["1080p"])) + r = env.S.reconcile(sid) + assert r["upgrades"] == 0 and r["have"] == 1 + assert _count_wants(env.db, series_id=sid) == 0 + + +def test_row_saved_before_record_is_rescanned(env): + f = mk(env.lib / "movies" / "Rags (2012)" / "Rags (2012).mkv", 2) + env.L.scan() + with env.db.connect() as c: + assert c.execute("SELECT quality FROM library_movies").fetchone()["quality"] is None + c.execute("INSERT INTO library_files (path, quality, size) VALUES (?,?,?)", + (str(f), "2160p", f.stat().st_size)) + stats = env.L.scan() # not forced + assert stats["skipped"] == 0 + with env.db.connect() as c: + assert c.execute("SELECT quality FROM library_movies").fetchone()["quality"] == "2160p" + assert env.L.scan()["skipped"] == 1 # and then it settles + + +def test_record_for_a_replaced_file_is_ignored(env): + f = mk(env.lib / "movies" / "Rags (2012)" / "Rags (2012).mkv", 2) + with env.db.connect() as c: + c.execute("INSERT INTO library_files (path, quality, size) VALUES (?,?,?)", + (str(f), "2160p", 12345)) + env.L.scan() + with env.db.connect() as c: + assert c.execute("SELECT quality FROM library_movies").fetchone()["quality"] is None + + +def test_records_for_deleted_files_are_pruned(env): + _sort_in(env, "Rags.2012.1080p.WEB-DL.x264-GRP") + env.L.scan() + canonical = env.lib / "movies" / "Rags (2012)" / "Rags (2012).mkv" + with env.db.connect() as c: + assert c.execute("SELECT COUNT(*) AS n FROM library_files").fetchone()["n"] == 1 + canonical.unlink() + env.L.scan() + with env.db.connect() as c: + assert c.execute("SELECT COUNT(*) AS n FROM library_files").fetchone()["n"] == 0 + + +# ── F14: the hunter only grabs releases that beat the owned file ───────────── + +def _movie_upgrade_setup(env): + mk(env.lib / "movies" / "Rags (2012)" / "Rags.2012.720p.WEB-DL.x264.mkv", 2) + env.L.scan() + mid = env.M.add_movie(12, "Rags", 2012, None, _profile(env.db, ["1080p", "720p"])) + with env.db.connect() as c: # target 1080p is what flags the upgrade + c.execute("UPDATE profiles SET resolutions=? WHERE id=(SELECT profile_id " + "FROM movies WHERE id=?)", (json.dumps(["1080p", "720p"]), mid)) + assert env.M.reconcile(mid)["upgrade"] is True + return mid + + +def test_hunter_skips_same_quality_upgrade(env): + mid = _movie_upgrade_setup(env) + client = _Client() + env.SCH.make_client = lambda *a, **k: client + env.SCH.searchmod.search = lambda *a, **k: [ + _release("Rags.2012.720p.BluRay.x264-OTHER", "magnet:same")] + r = env.SCH.hunt_wanted() + assert client.added == [] and r["grabbed"] == 0 + assert "no release better than owned 720p" in r["details"][0]["error"] + assert _count_wants(env.db, kind="movie", series_id=mid, status="wanted") == 1 + + +def test_hunter_grabs_a_real_upgrade(env): + _movie_upgrade_setup(env) + client = _Client() + env.SCH.make_client = lambda *a, **k: client + env.SCH.searchmod.search = lambda *a, **k: [ + _release("Rags.2012.1080p.WEB-DL.x264-GRP", "magnet:better"), + _release("Rags.2012.720p.BluRay.x264-OTHER", "magnet:same")] + env.SCH.hunt_wanted() + assert client.added == ["magnet:better"] + + +def test_hunter_cam_upgrade_needs_a_real_source(env): + mk(env.lib / "movies" / "Zootopia 2 (2025)" / "Zootopia 2 2025 1080p TS EN-RGB.mp4", 2) + env.L.scan() + mid = env.M.add_movie(11, "Zootopia 2", 2025, None, _profile(env.db, ["1080p", "720p"])) + assert env.M.reconcile(mid)["upgrade"] is True + client = _Client() + env.SCH.make_client = lambda *a, **k: client + env.SCH.searchmod.search = lambda *a, **k: [ + _release("Zootopia.2.2025.1080p.HDTS.x264-CAMGRP", "magnet:cam"), + _release("Zootopia.2.2025.720p.WEB-DL.x264-GRP", "magnet:720"), + _release("Zootopia.2.2025.1080p.WEB-DL.x264-GRP", "magnet:1080")] + env.SCH.hunt_wanted() + assert client.added == ["magnet:1080"] + + +def test_hunter_episode_upgrade_filter(env): + mk(env.lib / "tvshows" / "Show" / "Season 01" / "Show.S01E01.720p.WEB-DL.mkv", 2) + env.L.scan() + sid = _series(env.db, "Show", _profile(env.db, ["1080p", "720p"])) + with env.db.connect() as c: + c.execute("UPDATE profiles SET resolutions=? WHERE id=?", + (json.dumps(["1080p", "720p"]), c.execute( + "SELECT profile_id FROM series WHERE id=?", (sid,)).fetchone()[0])) + # the series side upgrades against the profile's first resolution + assert env.S.reconcile(sid)["upgrades"] == 1 + client = _Client() + env.SCH.make_client = lambda *a, **k: client + env.SCH.searchmod.search = lambda *a, **k: [ + _release("Show.S01E01.720p.HDTV.x264-OTHER", "magnet:same")] + env.SCH.hunt_wanted() + assert client.added == [] + env.SCH.searchmod.search = lambda *a, **k: [ + _release("Show.S01E01.720p.HDTV.x264-OTHER", "magnet:same"), + _release("Show.S01E01.1080p.WEB-DL.x264-GRP", "magnet:better")] + env.SCH.hunt_wanted() + assert client.added == ["magnet:better"] + + +# ── F19 / F20: one want per item, atomically ───────────────────────────────── + +OLD_PROD_WANTED = """ +CREATE TABLE wanted ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + series_id INTEGER, + season INTEGER, + episode INTEGER, + title TEXT, + reason TEXT, + status TEXT DEFAULT 'wanted', + last_search TEXT, + UNIQUE(kind, series_id, season, episode, title) +); +""" + + +def test_old_prod_schema_is_deduped_and_keyed(tmp_path, monkeypatch): + monkeypatch.setenv("EVENTS_FILE", str(tmp_path / "config" / "events.jsonl")) + importlib.reload(importlib.import_module("faucet.config")) + db = importlib.reload(importlib.import_module("faucet.db")) + path = db._db_path() + path.parent.mkdir(parents=True) + raw = sqlite3.connect(path) + raw.executescript(OLD_PROD_WANTED) + rows = [ + ("episode", 1, 1, 1, "Old Title", "wanted", None), + ("episode", 1, 1, 1, "New Title", "grabbed", "2026-09-01T00:00:00"), # in flight + ("episode", 1, 1, 1, "Newer Title", "wanted", None), + ("movie", 7, None, None, "Rags 2012", "wanted", None), + ("movie", 7, None, None, "Rags 2012", "wanted", None), # NULLs: allowed + ("episode", 1, 1, 2, "E2", "wanted", None), + ] + raw.executemany("INSERT INTO wanted (kind,series_id,season,episode,title,status,last_search) " + "VALUES (?,?,?,?,?,?,?)", rows) + raw.commit() + raw.close() + + db.init() + with db.connect() as c: + got = c.execute("SELECT kind, series_id, season, episode, status FROM wanted " + "ORDER BY kind, episode").fetchall() + assert [tuple(r) for r in got] == [ + ("episode", 1, 1, 1, "grabbed"), ("episode", 1, 1, 2, "wanted"), + ("movie", 7, None, None, "wanted")] + assert c.execute("SELECT 1 FROM sqlite_master WHERE name='ux_wanted_key'").fetchone() + # the retitle that used to slip past the title-keyed constraint + c.execute("INSERT OR IGNORE INTO wanted (kind,series_id,season,episode,title) " + "VALUES ('episode',1,1,1,'Retitled')") + c.execute("INSERT OR IGNORE INTO wanted (kind,series_id,title) VALUES ('movie',7,'x')") + assert c.execute("SELECT COUNT(*) FROM wanted").fetchone()[0] == 3 + + +def test_movie_wants_are_unique(env): + for _ in range(3): + env.W.upsert("movie", 7, None, None, "Rags 2012", "missing", 48) + assert _count_wants(env.db, kind="movie", series_id=7) == 1 + with pytest.raises(sqlite3.IntegrityError), env.db.connect() as c: + c.execute("INSERT INTO wanted (kind, series_id, title) VALUES ('movie', 7, 'dup')") + + +@pytest.mark.parametrize("kind,season,episode", [("episode", 1, 1), ("movie", None, None)]) +def test_concurrent_upserts_create_one_row(env, kind, season, episode): + n = 16 + barrier = threading.Barrier(n) + results, errors = [], [] + + def worker(i): + try: + barrier.wait() + results.append(env.W.upsert(kind, 3, season, episode, f"title {i}", "missing", 48)) + except Exception as e: # noqa: BLE001 + errors.append(e) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(n)] + for t in threads: + t.start() + for t in threads: + t.join() + assert not errors + assert results.count("inserted") == 1 + assert _count_wants(env.db, kind=kind, series_id=3) == 1 + + +def test_upsert_grab_retry_semantics(env): + W, db = env.W, env.db + assert W.upsert("episode", 1, 1, 1, "Pilot", "missing", 48) == "inserted" + assert W.upsert("episode", 1, 1, 1, "Pilot (retitled)", "missing", 48) == "updated" + recent = datetime.now().isoformat(timespec="seconds") + stale = (datetime.now() - timedelta(hours=49)).isoformat(timespec="seconds") + with db.connect() as c: + c.execute("UPDATE wanted SET status='grabbed', last_search=?", (recent,)) + assert W.upsert("episode", 1, 1, 1, "Pilot", "upgrade", 48) == "kept" + with db.connect() as c: + c.execute("UPDATE wanted SET last_search=?", (stale,)) + assert W.upsert("episode", 1, 1, 1, "Pilot", "upgrade", 48) == "requeued" + with db.connect() as c: + row = c.execute("SELECT status, reason, title FROM wanted").fetchone() + assert tuple(row) == ("wanted", "upgrade", "Pilot") + assert _count_wants(db) == 1 + + +# ── superseded files are retired to /_superseded/ ────────────── + +def _rags_upgrade(env): + old = mk(env.lib / "movies" / "Rags (2012)" / "Rags.2012.720p.WEB-DL.x264.mkv", 2, b"O") + (old.parent / "Rags.2012.720p.WEB-DL.x264.en.srt").write_text("old subs") + env.L.scan() + _sort_in(env, "Rags.2012.1080p.WEB-DL.x264-GRP") + return old + + +def test_upgrade_retires_old_file_and_its_subs(env): + old = _rags_upgrade(env) + stats = env.L.scan() + assert stats["superseded_moved"] == 1 + parked = env.lib / "_superseded" / "movies" / "Rags (2012)" + assert (parked / old.name).read_bytes() == b"O" * 2 * MB + assert (parked / "Rags.2012.720p.WEB-DL.x264.en.srt").read_text() == "old subs" + assert not old.exists() + assert (env.lib / "movies" / "Rags (2012)" / "Rags (2012).mkv").exists() + assert (env.lib / "_superseded" / ".plexignore").read_text() == "*\n" + assert (env.lib / "_superseded" / ".ignore").exists() + stats = env.L.scan() # settles: nothing left to retire + assert "superseded" not in stats + with env.db.connect() as c: + assert c.execute("SELECT COUNT(*) FROM library_movies").fetchone()[0] == 1 + + +def test_episode_upgrade_retires_old_file(env): + old = mk(env.lib / "tvshows" / "Show" / "Season 01" / "Show.S01E01.720p.WEB-DL.mkv", 2) + env.L.scan() + _sort_in(env, "Show.S01E01.1080p.WEB-DL-GRP") + assert env.L.scan()["superseded_moved"] == 1 + assert (env.lib / "_superseded" / "tvshows" / "Show" / "Season 01" / old.name).exists() + + +def test_unrecorded_winner_never_retires_anything(env): + """A better file dropped in by hand (no sorter record) may be partial or + mislabeled — the old copy stays put.""" + old = mk(env.lib / "movies" / "Rags (2012)" / "Rags.2012.720p.WEB-DL.x264.mkv", 2) + env.L.scan() + mk(env.lib / "movies" / "Rags (2012)" / "Rags.2012.1080p.WEB-DL.x264.mkv", 3) + stats = env.L.scan() + assert stats["superseded"] == 1 and stats["superseded_moved"] == 0 + assert old.exists() + + +def test_other_folder_is_never_touched(env): + """Collection folders / Fix Location: a better copy elsewhere doesn't + retire a file in a different folder.""" + old = mk(env.lib / "movies" / "Rags Collection" / "Rags.2012.720p.WEB-DL.x264.mkv", 2) + env.L.scan() + _sort_in(env, "Rags.2012.1080p.WEB-DL.x264-GRP") + stats = env.L.scan() + assert stats["superseded"] == 1 and stats["superseded_moved"] == 0 + assert old.exists() + + +def test_superseded_action_keep(env, monkeypatch): + monkeypatch.setenv("SUPERSEDED_ACTION", "keep") + old = _rags_upgrade(env) + assert env.L.scan()["superseded_moved"] == 0 + assert old.exists() + + +def test_nothing_retired_when_the_mount_looks_sick(env): + old = _rags_upgrade(env) + with env.db.connect() as c: # 20 rows whose files "vanished" + for i in range(20): + c.execute("INSERT INTO library_episodes (show_name,season,episode,path,size,mtime) " + "VALUES ('Gone',1,?,?,1,1)", + (i + 1, str(env.lib / "tvshows" / "Gone" / f"e{i}.mkv"))) + stats = env.L.scan() + assert stats.get("prune_skipped") and "superseded_moved" not in stats + assert old.exists() + + +def test_retired_name_collision_is_numbered(env): + parked = env.lib / "_superseded" / "movies" / "Rags (2012)" + mk(parked / "Rags.2012.720p.WEB-DL.x264.mkv", 1, b"P") + old = _rags_upgrade(env) + env.L.scan() + assert (parked / "Rags.2012.720p.WEB-DL.x264.mkv").read_bytes()[:1] == b"P" + assert (parked / f"{old.stem} (2){old.suffix}").read_bytes()[:1] == b"O" From 340c858b827188eba8c0783e55b28047d0848082 Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:13:46 -0400 Subject: [PATCH 9/9] readme: document SUPERSEDED_ACTION and upgrade behavior --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7096f13..53d8556 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ It runs as one container over Jackett (or Prowlarr) and your torrent client. No - **Automatic background hunting** — a built-in scheduler scans the library, reconciles every monitored show and movie, and grabs what's missing on a timer (default every 30 minutes). No extra container or cron job. - **Season-pack preference** — when two or more episodes of a season are wanted, Faucet grabs a single season pack instead of many individual episodes: one client slot, many episodes, better seeded. - **Stalled-download handling** — a download with zero progress for `STALL_HOURS` is removed, blocklisted, and re-hunted with a different release automatically. -- **Movie quality upgrades** — cams/telesyncs and below-profile files are hunted for better copies; the best file per movie wins. +- **Quality upgrades** — cams/telesyncs and below-profile files are hunted for copies that actually beat what's on disk; the best file per movie/episode wins and the replaced copy is parked in `_superseded/` for you to purge. - **Concurrency caps** — never floods your client. Won't start hunting if too many torrents are already downloading, and grabs only a few per cycle; the rest stay queued for the next tick. Tunable via `HUNT_MAX_ACTIVE` / `HUNT_MAX_PER_RUN`. - **Per-show monitor modes** — `all` (hunt every missing episode), `future` (only new episodes from the add date forward — ignore a huge back catalog), or `paused`. Keeps big shows from trying to backfill hundreds of episodes. @@ -148,6 +148,7 @@ All via environment / `.env`: | `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` | `/_failed` | Where the sorter parks content it couldn't file. | +| `SUPERSEDED_ACTION` | `move` | After an upgrade lands, `move` parks the old copy in `LIBRARY_ROOT/_superseded/` (same relative path, reversible); `keep` leaves it in place. | | `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. |