From 607e780ca209be856735e0d95172e856049efdfa Mon Sep 17 00:00:00 2001 From: Harrison Korodi Date: Thu, 17 Sep 2026 17:27:55 +0000 Subject: [PATCH 1/2] safety: guard against fake releases; fix config/indexer blind spots Fake releases (the S29E01 incident) - search + hunter drop releases whose name is an executable (BLOCK_EXECUTABLE_RELEASES); hidden counts surface in the UI - live guard (GUARD_INTERVAL_SECONDS, default 60): media torrents whose file list is executables with no video are paused, flagged, recorded, notified; wants re-queued; never deleted; resumed torrents left alone - sorter backstop: such releases are quarantined with executables renamed *.faucet-blocked; new exit code 5, handled by hook and sweep - AIR_DELAY_DAYS (default 1): new episodes are not hunted until the day after they air; settable in Settings -> Behavior Config / indexer blind spots - Settings -> Connections gains a write-only Jackett API key field - scheduler and stalls read the live config (F15): keys saved in Settings reach the hunter without a restart - hunt short-circuits with one warning when the indexer isn't configured; search failures summarized; add failures logged and recorded as grab_failed (F33) - torznab documents (bad API key) raise instead of reading as "no results" - /api/search returns 503 with an explanation when unconfigured; the UI shows server error detail instead of a bare status code - dashboard indexer pill reflects the indexer (it mirrored the client); dashboard and admin /health list configuration warnings Tests - tests/test_fake_releases.py: 41 regressions - tests/test_wants_upgrades.py: use monkeypatch (direct assignment leaked a fake search into later test files) --- README.md | 4 + docs/HOOKS.md | 26 ++ faucet/app.py | 60 ++++- faucet/config.py | 2 +- faucet/hook.py | 18 +- faucet/safety.py | 226 +++++++++++++++++ faucet/scheduler.py | 130 ++++++++-- faucet/search.py | 21 +- faucet/series.py | 30 ++- faucet/sort.py | 57 ++++- faucet/stalls.py | 19 +- faucet/static/index.html | 32 ++- faucet/sweep.py | 6 +- tests/test_fake_releases.py | 465 +++++++++++++++++++++++++++++++++++ tests/test_wants_upgrades.py | 36 +-- 15 files changed, 1037 insertions(+), 95 deletions(-) create mode 100644 faucet/safety.py create mode 100644 tests/test_fake_releases.py diff --git a/README.md b/README.md index 53d8556..f12a49c 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,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. +- **Fake-release protection** — executable "releases" are hidden from search and never auto-grabbed; a download whose files turn out to be executables with no video is paused and flagged for review; and new episodes aren't hunted until the day after they air (configurable). - **Stalled-download handling** — a download with zero progress for `STALL_HOURS` is removed, blocklisted, and re-hunted with a different release automatically. - **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`. @@ -148,6 +149,9 @@ 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. | +| `AIR_DELAY_DAYS` | `1` | Days after an episode's air date before it is hunted. `0` hunts on the air date (early "releases" are usually fakes). Also in Settings → Behavior. | +| `BLOCK_EXECUTABLE_RELEASES` | `1` | Hide releases whose name is an executable (`…1080p.exe`) from search and the hunter. | +| `GUARD_INTERVAL_SECONDS` | `60` | How often live downloads are checked for executable-only payloads (paused and flagged for review). `0` disables. | | `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. | diff --git a/docs/HOOKS.md b/docs/HOOKS.md index b7e157c..b1cd8e8 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -105,6 +105,11 @@ Two things make this setup work cleanly: overwrite each other (numbered `CD1`/`CD2` parts become `- pt1`/`- pt2`). - **Subtitles keep their tags** (`Movie (2019).en.forced.srt`), including RARBG-style `Subs/` folders. +- **Executable "releases" are quarantined and defused.** A media download + whose payload is executables with no video (a common bait for brand-new + episodes) is moved to `_failed/` with every executable renamed + `*.faucet-blocked`, so it can't be launched from the share. The live guard + normally catches these earlier and pauses them — see "Fake releases" below. - **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 — @@ -117,9 +122,30 @@ The sorter's exit code tells the hook what's safe: |----|---------|------------------| | 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 | +| 5 | Suspicious (executables, no video); quarantined and defused | yes — nothing is left inside it | | 2 | I/O error; release left in place for retry | no | | 1 | Library not mounted / no input | no | +## Fake releases + +Faucet checks every download three times: + +1. **Before grabbing** — search results and hunter candidates whose name is an + executable (`Show.S01E01.1080p.exe`) are dropped (`BLOCK_EXECUTABLE_RELEASES`). + Hidden counts show up next to search results. +2. **While downloading** — every `GUARD_INTERVAL_SECONDS` (60) Faucet reads each + torrent's file list once its metadata arrives. A media torrent whose files are + executables with no video is **paused**, flagged in Activity → Transfers and + on the dashboard, recorded as a `suspicious` event, and notified when + `failed` or `suspicious` is in `NOTIFY_ON`. The release is never grabbed + again and the episode goes back to wanted. Faucet never deletes it: remove it + yourself, or resume it if you're sure (the guard won't pause it twice). + Games and software are exempt. +3. **After downloading** — the sorter backstop above (exit code 5). + +New episodes also aren't hunted until `AIR_DELAY_DAYS` (default 1) after their +air date: bait uploads appear hours before a broadcast, real ones after it. + ## Catch-up sweep (safety net) The hook handles the normal case, but it can miss: a client fires it before diff --git a/faucet/app.py b/faucet/app.py index c24af17..46e4775 100644 --- a/faucet/app.py +++ b/faucet/app.py @@ -196,13 +196,17 @@ class TorrentAction(BaseModel): def api_search(q: str = Query(..., min_length=1), cat: str = Query("all"), limit: int = Query(None)): lim = limit or cfg().search_limit + if not (cfg().jackett_url and cfg().jackett_api_key): + raise HTTPException(503, "Indexer not configured — set the Jackett URL and " + "API key in Settings → Connections.") try: results = searchmod.search(cfg().jackett_url, cfg().jackett_api_key, cfg().jackett_indexer, q, cat, lim, cfg().request_timeout) except searchmod.SearchError as e: raise HTTPException(502, str(e)) - return {"query": q, "category": cat, "total": len(results), "results": results} + return {"query": q, "category": cat, "total": len(results), "results": results, + "hidden": getattr(results, "hidden", 0)} @app.post("/api/add") @@ -243,14 +247,21 @@ def api_transfers(): xs = client().list_transfers() except DownloadClientError as e: raise HTTPException(502, str(e)) + try: + from . import safety + flagged = safety.flags() + except Exception: # noqa: BLE001 - never break the list + flagged = {} out = [] for t in xs: + f = flagged.get(str(t.id)) out.append({ "id": t.id, "name": t.name, "percent": t.percent, "down_h": searchmod.human_size(t.down_rate) + "/s", "status": t.status, "eta_h": _fmt_eta(t.eta), "ratio": t.ratio, "size": t.size, "size_h": searchmod.human_size(t.size), "error": t.error, "done": t.done, + "flag": f["reason"] if f and f.get("name") == t.name else None, }) out.sort(key=lambda x: (x["done"], -x["percent"])) return {"transfers": out} @@ -318,6 +329,28 @@ def api_stats(): return out +def config_warnings(client_ok: bool | None = None) -> list[str]: + """Problems that silently stop Faucet from working. Cheap: no network.""" + c = cfg() + w = [] + if not c.jackett_url or not c.jackett_api_key: + w.append("Jackett API key is not set — search and hunting are disabled. " + "Settings → Connections.") + if not c.client_url: + w.append("No download client URL configured. Settings → Connections.") + elif client_ok is False: + w.append(f"Download client ({c.client_kind}) is unreachable.") + try: + from . import safety + n = len(safety.flags()) + if n: + w.append(f"{n} suspicious download{'s' if n != 1 else ''} paused for review " + "(Activity → Transfers).") + except Exception: # noqa: BLE001 + pass + return w + + @app.get("/api/dashboard") def api_dashboard(): """Consolidated admin overview: storage, live activity, library health, @@ -360,6 +393,8 @@ def api_dashboard(): except DownloadClientError: client_ok = False active.sort(key=lambda a: a["down_rate"], reverse=True) + out["indexer"] = {"configured": bool(cfg().jackett_url and cfg().jackett_api_key)} + out["warnings"] = config_warnings(client_ok=client_ok) out["transfers"] = { "active": active[:8], "active_count": len(active), "downloading": downloading, "seeding": seeding, @@ -617,8 +652,10 @@ def api_settings_get(): # metadata / ui "UI_THEME": c.ui_theme, "APP_TITLE": c.app_title, } - # CLIENT_PASS is editable but never returned; show only whether one is set. + env_view["AIR_DELAY_DAYS"] = os.environ.get("AIR_DELAY_DAYS", "1") + # secrets are editable but never returned; show only whether one is set. env_view["CLIENT_PASS_SET"] = bool(c.client_pass) + env_view["JACKETT_API_KEY_SET"] = bool(c.jackett_api_key) # live status of each path (exists / writable inside the container) path_status = {k: _path_status(env_view.get(k, "")) for k in PATH_KEYS} return {"env": env_view, "db": db.all_settings(), @@ -658,6 +695,16 @@ def api_settings_patch(p: SettingsPatch): f"(is it mounted?). Saved anyway.") elif not st["writable"]: warnings.append(f"{k}: '{v}' exists but isn't writable. Saved anyway.") + if k == "AIR_DELAY_DAYS": + try: + days = int(str(v).strip()) + if not 0 <= days <= 30: + raise ValueError + except ValueError: + warnings.append(f"AIR_DELAY_DAYS: '{v}' must be a whole number " + "from 0 to 30. Not saved.") + continue + v = str(days) env_updates[k] = v else: db_updates[k] = v @@ -953,7 +1000,8 @@ def api_episode_releases(sid: int, season: int, episode: int): out.append(rr) out.sort(key=lambda x: (not x["_passes"], -x["_score"], -x.get("seeders", 0))) return {"query": query, "profile": profile["name"] if profile else None, - "considered": len(results), "releases": out} + "considered": len(results), "releases": out, + "hidden": getattr(results, "hidden", 0)} @app.get("/api/series/{sid}/seasons/{season}/releases") @@ -992,7 +1040,8 @@ def api_season_releases(sid: int, season: int): out.append(rr) out.sort(key=lambda x: (not x["_passes"], -x["_score"], -x.get("seeders", 0))) return {"query": query, "profile": profile["name"] if profile else None, - "considered": len(results), "releases": out} + "considered": len(results), "releases": out, + "hidden": getattr(results, "hidden", 0)} @app.post("/api/series/{sid}/seasons/{season}/grab") @@ -1136,7 +1185,8 @@ def health(request: _Request): user = _auth.session_user(request.cookies.get(_auth.SESSION_COOKIE)) if not user or user.get("role") != "admin": return {"status": "ok"} - status = {"status": "ok", "indexer": "unknown", "client": "unknown"} + status = {"status": "ok", "indexer": "unknown", "client": "unknown", + "warnings": config_warnings()} try: import requests requests.get(f"{cfg().jackett_url}/", timeout=5) diff --git a/faucet/config.py b/faucet/config.py index 36c45d7..a65832a 100644 --- a/faucet/config.py +++ b/faucet/config.py @@ -118,7 +118,7 @@ def reload() -> "Config": "LIBRARY_ROOT", "DOWNLOAD_DIR", "DISK_PATH", "BROWSE_ROOT", # behavior "REMOVE_ON_COMPLETE", "REQUEST_TIMEOUT", "SEARCH_LIMIT", "BIG_DOWNLOAD_GB", - "NOTIFY_URLS", "NOTIFY_ON", + "NOTIFY_URLS", "NOTIFY_ON", "AIR_DELAY_DAYS", # metadata / ui "UI_THEME", "APP_TITLE", } diff --git a/faucet/hook.py b/faucet/hook.py index 6b81136..572f339 100644 --- a/faucet/hook.py +++ b/faucet/hook.py @@ -40,6 +40,7 @@ # faucet/sort.py exit codes the hook acts on SORT_OK = 0 SORT_QUARANTINED = 4 +SORT_SUSPICIOUS = 5 def _path_size(path: str) -> int: @@ -105,20 +106,27 @@ def main(): # 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. + # quarantined to _failed/, 5 suspicious payload quarantined with its + # executables neutralized; anything else = leave the torrent alone. The + # torrent is only removed on 0, 4 or 5, because then 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) rc = res.returncode - if rc not in (SORT_OK, SORT_QUARANTINED): + if rc not in (SORT_OK, SORT_QUARANTINED, SORT_SUSPICIOUS): 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 rc - if rc == SORT_QUARANTINED: + if rc == SORT_SUSPICIOUS: + record("suspicious", name, + "executable payload with no video; quarantined to _failed/ " + "with executables renamed *.faucet-blocked") + if config.notify_urls and ({"failed", "suspicious"} & set(config.notify_on)): + notify(config.notify_urls, "Suspicious download quarantined", name) + elif rc == SORT_QUARANTINED: record("quarantined", name, "some content couldn't be filed; moved to _failed/ for review") if "failed" in config.notify_on: diff --git a/faucet/safety.py b/faucet/safety.py new file mode 100644 index 0000000..edfa88f --- /dev/null +++ b/faucet/safety.py @@ -0,0 +1,226 @@ +"""Fake-release defenses. + +Brand-new episodes attract bait torrents: a single `Show.S01E01.1080p.exe`, +or a magnet whose display name looks like an episode but whose payload is an +installer. Three layers keep those out of the library and off the share: + +1. **Name filter** (search + hunter): releases whose title ends in an + executable/script extension are dropped before anyone can grab them. +2. **Payload check** (`check_transfers`, run every GUARD_INTERVAL_SECONDS): + once a torrent's metadata arrives, a media torrent whose files are + executables with no video is PAUSED and flagged for review — never deleted. + Its release stays in the `grabbed` table (so it is never picked again) and + the wants it was grabbed for go back to 'wanted', so the hunter tries a + different release. +3. **Sorter backstop** (faucet/sort.py): anything that finishes before the + check runs is quarantined with its executables renamed so they can't be + launched from the share. + +Env: + BLOCK_EXECUTABLE_RELEASES 1 (default) / 0 — the name filter + GUARD_INTERVAL_SECONDS payload-check cadence (default 60) +""" +from __future__ import annotations + +import logging +import os +import re +from datetime import datetime +from pathlib import PurePosixPath + +log = logging.getLogger("faucet.safety") + +# Windows/macOS/Android executables and script hosts. '.com' is deliberately +# absent: tracker watermarks ("www.site.com") end in it far more often than +# real COM binaries appear. +EXEC_EXTS = { + ".exe", ".scr", ".bat", ".cmd", ".msi", ".msix", ".appx", ".lnk", ".pif", + ".js", ".jse", ".vbs", ".vbe", ".wsf", ".hta", ".ps1", ".jar", ".cpl", + ".reg", ".apk", +} +VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".ts", ".m2ts", + ".webm", ".flv"} +BLOCKED_SUFFIX = ".faucet-blocked" + +_EXEC_NAME = re.compile( + r"\.(" + "|".join(e[1:] for e in sorted(EXEC_EXTS)) + r")[\s\])]*$", re.IGNORECASE) +_SAMPLE = re.compile(r"(^|[ ._\-\[(/])sample($|[ ._\-\])/])", re.IGNORECASE) + + +def _truthy(key: str, default: str) -> bool: + return os.environ.get(key, default).strip().lower() in ("1", "true", "yes", "on") + + +def blocking_enabled() -> bool: + return _truthy("BLOCK_EXECUTABLE_RELEASES", "1") + + +def is_executable_name(name: str) -> bool: + """A release title that is itself an executable ('Show.S01E01.1080p.exe').""" + return bool(_EXEC_NAME.search((name or "").strip())) + + +def filter_release_names(results: list) -> tuple[list, int]: + """Drop results whose title is an executable. Returns (kept, hidden).""" + if not blocking_enabled(): + return list(results), 0 + kept = [r for r in results if not is_executable_name(r.get("title", ""))] + return kept, len(results) - len(kept) + + +def payload_problem(files) -> str | None: + """Why a media payload looks fake, or None. + + `files` is an iterable of objects with a `.name`/`.path` (client + TransferFile) or plain path strings. Suspicious = at least one executable + and no real (non-sample) video. A proper release that happens to bundle a + codec installer next to its .mkv is left alone.""" + execs, videos = [], 0 + for f in files: + path = f if isinstance(f, str) else (getattr(f, "path", "") or getattr(f, "name", "")) + p = PurePosixPath(path.replace("\\", "/")) + ext = p.suffix.lower() + if ext in EXEC_EXTS: + execs.append(p.name) + elif ext in VIDEO_EXTS and not _SAMPLE.search(path): + videos += 1 + if not execs or videos: + return None + shown = ", ".join(execs[:3]) + (f" (+{len(execs) - 3} more)" if len(execs) > 3 else "") + return f"executable payload with no video: {shown}" + + +# ── transfer guard ─────────────────────────────────────────────────────────── + +def _ensure_table(c) -> None: + c.execute( + "CREATE TABLE IF NOT EXISTS transfer_checks (" + " id TEXT PRIMARY KEY," # client transfer id/hash + " name TEXT," # checked under this name + " verdict TEXT," # ok | suspicious + " reason TEXT," + " checked_ts TEXT" + ")") + + +def flags() -> dict[str, dict]: + """{transfer id: {name, reason, checked_ts}} for flagged transfers.""" + from . import db + with db.connect() as c: + _ensure_table(c) + rows = c.execute("SELECT id, name, reason, checked_ts FROM transfer_checks " + "WHERE verdict='suspicious'").fetchall() + return {r["id"]: dict(r) for r in rows} + + +def _media_expected(name: str) -> bool: + """Games and software legitimately ship executables — only media is checked.""" + try: + from .classify import classify + return classify(name or "")["type"] != "game" + except Exception: # noqa: BLE001 + return True + + +def _notify(title: str, body: str) -> None: + from . import config as cfgmod + c = cfgmod.config + if not c.notify_urls or not ({"failed", "suspicious"} & set(c.notify_on)): + return + try: + from .notify import notify + notify(c.notify_urls, title, body) + except Exception as e: # noqa: BLE001 + log.warning("notification failed: %s", e) + + +def check_transfers(client=None) -> dict: + """Inspect every transfer whose metadata has arrived, once. + + A transfer is checked a single time per (id, name): once an admin resumes + a flagged torrent, the guard leaves it alone. Transfers without metadata + yet (bare magnets) are retried on the next pass.""" + from . import config as cfgmod + from . import db + from .clients import make_client + result = {"checked": 0, "flagged": [], "errors": []} + try: + if client is None: + c = cfgmod.config + client = make_client(c.client_kind, c.client_url, c.client_user, + c.client_pass, c.request_timeout) + transfers = client.list_transfers() + except Exception as e: # noqa: BLE001 + result["errors"].append(f"client unreachable: {e}") + return result + + with db.connect() as c: + _ensure_table(c) + seen = {r["id"]: r["name"] for r in + c.execute("SELECT id, name FROM transfer_checks").fetchall()} + live = {str(t.id) for t in transfers} + for tid in set(seen) - live: # client ids get reused after restarts + c.execute("DELETE FROM transfer_checks WHERE id=?", (tid,)) + + for t in transfers: + tid = str(t.id) + if seen.get(tid) == t.name: + continue + try: + files = client.files(t.id) + except Exception as e: # noqa: BLE001 + result["errors"].append(f"files() failed for {t.name}: {e}") + continue + if not files: + continue # metadata not fetched yet + result["checked"] += 1 + reason = payload_problem(files) if _media_expected(t.name) else None + now = datetime.now().isoformat(timespec="seconds") + verdict = "suspicious" if reason else "ok" + if reason: + try: + client.pause(t.id) + except Exception as e: # noqa: BLE001 + # don't record a verdict: retry the pause next pass + result["errors"].append(f"pause failed for {t.name}: {e}") + log.error("SUSPICIOUS but could not pause '%s': %s", t.name, e) + continue + with db.connect() as c: + c.execute( + "INSERT INTO transfer_checks (id, name, verdict, reason, checked_ts) " + "VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name, " + "verdict=excluded.verdict, reason=excluded.reason, " + "checked_ts=excluded.checked_ts", + (tid, t.name, verdict, reason, now)) + if not reason: + continue + from .stalls import _flip_wants_for_release + flipped = _flip_wants_for_release(t.name or "") + db.add_history("suspicious", t.name, f"paused for review — {reason}; " + f"{flipped} want(s) re-queued") + log.warning("SUSPICIOUS: paused '%s' (%s); re-queued %d want(s)", + t.name, reason, flipped) + _notify("Suspicious download paused", f"{t.name} — {reason}") + result["flagged"].append({"id": tid, "name": t.name, "reason": reason, + "flipped": flipped}) + return result + + +def neutralize(root) -> list: + """Rename executables under `root` (a dir or single file) to + '.faucet-blocked' so they can't be double-clicked from an SMB share. + Returns the renamed paths. Best effort: failures are logged, not raised.""" + from pathlib import Path + root = Path(root) + targets = [root] if root.is_file() else [p for p in root.rglob("*") if p.is_file()] + renamed = [] + for p in targets: + if p.suffix.lower() not in EXEC_EXTS: + continue + dest = p.with_name(p.name + BLOCKED_SUFFIX) + try: + p.rename(dest) + renamed.append(dest) + except OSError as e: + log.warning("could not neutralize %s: %s", p, e) + return renamed diff --git a/faucet/scheduler.py b/faucet/scheduler.py index 59f5a61..14643ab 100644 --- a/faucet/scheduler.py +++ b/faucet/scheduler.py @@ -21,9 +21,16 @@ from . import db from . import search as searchmod from . import profiles as prof -from .config import config +from . import config as _cfgmod from .clients import make_client, DownloadClientError +def _cfg(): + """The live config. Settings saves swap `faucet.config.config` for a new + object (config.reload); binding it at import time left the scheduler + hunting with whatever was configured when the process started.""" + return _cfgmod.config + + log = logging.getLogger("faucet.scheduler") # how often the loop wakes, in seconds (default 30 min) @@ -81,8 +88,8 @@ def check_subscription(sub: dict) -> dict: try: results = searchmod.search( - config.jackett_url, config.jackett_api_key, config.jackett_indexer, - query, "all", config.search_limit, config.request_timeout) + _cfg().jackett_url, _cfg().jackett_api_key, _cfg().jackett_indexer, + query, "all", _cfg().search_limit, _cfg().request_timeout) except searchmod.SearchError as e: result["error"] = f"search failed: {e}" return result @@ -110,9 +117,9 @@ def check_subscription(sub: dict) -> dict: return result # someone/another tick grabbed it between search and now try: - client = make_client(config.client_kind, config.client_url, - config.client_user, config.client_pass, config.request_timeout) - add = client.add(pick["href"], config.download_dir or None) + client = make_client(_cfg().client_kind, _cfg().client_url, + _cfg().client_user, _cfg().client_pass, _cfg().request_timeout) + add = client.add(pick["href"], _cfg().download_dir or None) result["grabbed"] = pick["title"] db.add_history("added", pick["title"], f"auto-grab: {title}") db.update_subscription( @@ -140,8 +147,8 @@ def _try_season_pack(title, season, profile): query = f"{title} S{int(season):02d}" try: results = searchmod.search( - config.jackett_url, config.jackett_api_key, config.jackett_indexer, - query, "all", config.search_limit, config.request_timeout) + _cfg().jackett_url, _cfg().jackett_api_key, _cfg().jackett_indexer, + query, "all", _cfg().search_limit, _cfg().request_timeout) except searchmod.SearchError: return None candidates = [] @@ -177,14 +184,22 @@ def hunt_wanted(series_filter=None, max_override=None) -> dict: from . import series as series_mod db.init() + if not (_cfg().jackett_url and _cfg().jackett_api_key): + # every search would fail the same way; say so once instead of + # silently failing hundreds of wants + log.warning("Hunt skipped: indexer not configured (Jackett URL/API key " + "missing — Settings → Connections).") + return {"wanted": 0, "grabbed": 0, "details": [], + "skipped_reason": "indexer not configured"} + max_active = int(os.environ.get("HUNT_MAX_ACTIVE", "5")) max_per_run = max_override if max_override is not None else int(os.environ.get("HUNT_MAX_PER_RUN", "3")) # how many torrents are already downloading right now? active = 0 try: - client0 = make_client(config.client_kind, config.client_url, - config.client_user, config.client_pass, config.request_timeout) + client0 = make_client(_cfg().client_kind, _cfg().client_url, + _cfg().client_user, _cfg().client_pass, _cfg().request_timeout) active = sum(1 for t in client0.list_transfers() if getattr(t, "status", "") == "downloading") except Exception: # noqa: BLE001 @@ -223,6 +238,8 @@ def hunt_wanted(series_filter=None, max_override=None) -> dict: continue if (w.get("reason") or "missing") != "missing": continue # upgrades hunt per-episode only + if not _episode_eligible(w): + continue # aired too recently (AIR_DELAY_DAYS) # drop stale wants for episodes already on disk (see per-episode # ownership check below) so they can't inflate a season into # pack-worthiness @@ -234,7 +251,7 @@ def hunt_wanted(series_filter=None, max_override=None) -> dict: c.execute("DELETE FROM wanted WHERE id=?", (w["id"],)) continue by_season[(w["series_id"], w["season"])].append(w) - today = datetime.now().date().isoformat() + cutoff = series_mod.hunt_cutoff() for (sid, season), eps in by_season.items(): if grabbed >= budget: break @@ -248,7 +265,7 @@ def hunt_wanted(series_filter=None, max_override=None) -> dict: row = c.execute( "SELECT COUNT(*) AS n FROM series_episodes WHERE series_id=? " "AND season=? AND air_date != '' AND air_date <= ?", - (sid, season, today)).fetchone() + (sid, season, cutoff)).fetchone() aired = row["n"] if row else 0 owned = max(0, aired - len(eps)) if aired <= 0 or owned > max(1, aired // 10): @@ -260,9 +277,9 @@ def hunt_wanted(series_filter=None, max_override=None) -> dict: if not db.mark_grabbed(pack["title"], None): continue try: - client = make_client(config.client_kind, config.client_url, - config.client_user, config.client_pass, config.request_timeout) - client.add(pack["href"], config.download_dir or None) + client = make_client(_cfg().client_kind, _cfg().client_url, + _cfg().client_user, _cfg().client_pass, _cfg().request_timeout) + client.add(pack["href"], _cfg().download_dir or None) grabbed += 1 db.add_history("added", pack["title"], f"season pack: {title} S{int(season):02d} ({len(eps)} eps)") # mark every wanted episode in this season as grabbed-by-pack @@ -281,7 +298,9 @@ def hunt_wanted(series_filter=None, max_override=None) -> dict: c.execute("DELETE FROM grabbed WHERE title=?", (pack["title"],)) details.append({"want": f"{title} S{int(season):02d} (pack)", "reason": "pack", "grabbed": None, "error": f"add failed: {e}"}) + _grab_failed(pack["title"], f"{title} S{int(season):02d} pack", e) + search_errors: list[str] = [] for w in wanted: if grabbed >= budget: break @@ -311,6 +330,8 @@ def hunt_wanted(series_filter=None, max_override=None) -> dict: season, episode = w.get("season"), w.get("episode") if not title or season is None or episode is None: continue + if not _episode_eligible(w): + continue # aired too recently (AIR_DELAY_DAYS) # last-second ownership check against the live library inventory — # the wanted table can be stale (a want flipped back by the stall # handler, or rows created during a NAS-mount hiccup). Grabbing is @@ -328,11 +349,12 @@ def hunt_wanted(series_filter=None, max_override=None) -> dict: res = {"want": query, "reason": w.get("reason"), "grabbed": None, "error": None} try: results = searchmod.search( - config.jackett_url, config.jackett_api_key, config.jackett_indexer, - query, "all", config.search_limit, config.request_timeout) + _cfg().jackett_url, _cfg().jackett_api_key, _cfg().jackett_indexer, + query, "all", _cfg().search_limit, _cfg().request_timeout) except searchmod.SearchError as e: res["error"] = f"search failed: {e}" details.append(res) + search_errors.append(str(e)) continue fresh = [r for r in results if not db.already_grabbed(r["title"])] @@ -389,9 +411,9 @@ def hunt_wanted(series_filter=None, max_override=None) -> dict: details.append(res) continue try: - client = make_client(config.client_kind, config.client_url, - config.client_user, config.client_pass, config.request_timeout) - client.add(pick["href"], config.download_dir or None) + client = make_client(_cfg().client_kind, _cfg().client_url, + _cfg().client_user, _cfg().client_pass, _cfg().request_timeout) + client.add(pick["href"], _cfg().download_dir or None) res["grabbed"] = pick["title"] grabbed += 1 db.add_history("added", pick["title"], f"hunt: {query} ({w.get('reason')})") @@ -406,14 +428,41 @@ def hunt_wanted(series_filter=None, max_override=None) -> dict: res["error"] = f"add failed: {e}" with db.connect() as c: c.execute("DELETE FROM grabbed WHERE title=?", (pick["title"],)) + _grab_failed(pick["title"], query, e) details.append(res) + if search_errors: + log.warning("Hunt: %d search(es) failed this pass (first: %s)", + len(search_errors), search_errors[0]) return {"wanted": len(wanted), "grabbed": grabbed, "details": details} +def _grab_failed(release: str, context: str, err: Exception) -> None: + """A grab the client refused. It used to vanish without a trace (F33).""" + log.warning("Hunt add failed for '%s' (%s): %s", release, context, err) + try: + db.add_history("grab_failed", release, f"{context}: {err}") + except Exception: # noqa: BLE001 + pass + + +def _episode_eligible(w: dict) -> bool: + """Has this episode want's air date passed AIR_DELAY_DAYS? A want created + before the delay was configured (or before the episode's date moved) + must not be hunted early either.""" + from . import series as series_mod + with db.connect() as c: + row = c.execute("SELECT air_date FROM series_episodes WHERE series_id=? " + "AND season=? AND episode=?", + (w.get("series_id"), w.get("season"), w.get("episode"))).fetchone() + if row is None: + return True # not in the canonical list: nothing to gate on + return series_mod.hunt_eligible(row["air_date"]) + + def _notify_grab(release_title: str, context: str, series_id=None, movie_id=None) -> None: """Poster-embedded grab notification (when 'added' is in NOTIFY_ON).""" - if "added" not in config.notify_on or not config.notify_urls: + if "added" not in _cfg().notify_on or not _cfg().notify_urls: return poster = None try: @@ -431,7 +480,7 @@ def _notify_grab(release_title: str, context: str, poster = None try: from .notify import notify - notify(config.notify_urls, f"Grabbed · {context}", release_title, poster) + notify(_cfg().notify_urls, f"Grabbed · {context}", release_title, poster) except Exception: # noqa: BLE001 pass @@ -478,6 +527,13 @@ def run_once() -> dict: if r["grabbed"]: grabbed += 1 + # 1b. fake-release guard (also runs on its own fast loop) + try: + from . import safety + safety.check_transfers() + except Exception as e: # noqa: BLE001 - never kill the tick + log.warning("Safety check error: %s", e) + # 2. stalled-download handling: remove dead torrents FIRST so their client # slots are free for this same tick's hunt, and their wants re-queue now stall_summary = {} @@ -555,15 +611,35 @@ async def _loop(): await asyncio.sleep(INTERVAL) +GUARD_INTERVAL = int(os.environ.get("GUARD_INTERVAL_SECONDS", "60")) +_guard_task: asyncio.Task | None = None + + +async def _guard_loop(): + """Payload check on a short cadence: a 1 GB fake finishes in a couple of + minutes, far inside the main tick interval.""" + from . import safety + await asyncio.sleep(15) + while True: + try: + await asyncio.to_thread(safety.check_transfers) + except Exception as e: # noqa: BLE001 - never kill the loop + log.warning("Safety guard error: %s", e) + await asyncio.sleep(GUARD_INTERVAL) + + def start(): - global _task + global _task, _guard_task if _task is None or _task.done(): _task = asyncio.create_task(_loop()) + if GUARD_INTERVAL > 0 and (_guard_task is None or _guard_task.done()): + _guard_task = asyncio.create_task(_guard_loop()) return _task def stop(): - global _task - if _task and not _task.done(): - _task.cancel() - _task = None + global _task, _guard_task + for t in (_task, _guard_task): + if t and not t.done(): + t.cancel() + _task = _guard_task = None diff --git a/faucet/search.py b/faucet/search.py index 49fbcee..64712dc 100644 --- a/faucet/search.py +++ b/faucet/search.py @@ -26,6 +26,12 @@ class SearchError(Exception): pass +class Results(list): + """Search results; `hidden` counts releases dropped as unsafe + (faucet.safety.filter_release_names).""" + hidden: int = 0 + + def human_size(n: int) -> str: f = float(n) for unit in ("B", "KB", "MB", "GB", "TB"): @@ -102,7 +108,7 @@ def indexers(jackett_url: str, api_key: str, timeout: int = 15) -> list[dict]: def search(jackett_url: str, api_key: str, indexer: str, query: str, - category: str, limit: int, timeout: int = 30) -> list[dict]: + category: str, limit: int, timeout: int = 30) -> Results: if not api_key: raise SearchError("Indexer API key not configured.") cat = CATS.get(category, "") @@ -119,6 +125,11 @@ def search(jackett_url: str, api_key: str, indexer: str, query: str, root = ET.fromstring(r.content) except ET.ParseError as e: raise SearchError(f"Bad XML from indexer: {e}") + if root.tag == "error": + # Torznab reports failures (bad API key, disabled indexer) as HTTP 200 + # with an document — previously read as "no results" + raise SearchError(f"Indexer error {root.get('code', '?')}: " + f"{root.get('description', 'unknown')}") results = [] for item in root.iter("item"): @@ -154,5 +165,9 @@ def search(jackett_url: str, api_key: str, indexer: str, query: str, "ctype": klass["type"], "platform": klass["platform"], "category": cat_num, }) - results.sort(key=lambda x: x["seeders"], reverse=True) - return results[:limit] + from .safety import filter_release_names + kept, hidden = filter_release_names(results) + kept.sort(key=lambda x: x["seeders"], reverse=True) + out = Results(kept[:limit]) + out.hidden = hidden + return out diff --git a/faucet/series.py b/faucet/series.py index aeec724..7c6b951 100644 --- a/faucet/series.py +++ b/faucet/series.py @@ -30,6 +30,26 @@ GRAB_RETRY_HOURS = int(os.environ.get("GRAB_RETRY_HOURS", "48")) +def air_delay_days() -> int: + """AIR_DELAY_DAYS: whole days after an episode's air date before it is + hunted (default 1). TMDb only gives dates, so 0 means 'from the air date' + — which races the broadcast and invites fake early releases.""" + try: + return max(0, int(os.environ.get("AIR_DELAY_DAYS", "1"))) + except ValueError: + return 1 + + +def hunt_cutoff() -> str: + """Latest air date (YYYY-MM-DD) that is eligible for hunting today.""" + from datetime import timedelta + return (datetime.now().date() - timedelta(days=air_delay_days())).isoformat() + + +def hunt_eligible(air_date: str | None) -> bool: + return bool(air_date) and air_date <= hunt_cutoff() + + def add_series(tmdb_id: int, title: str, year: int | None, poster: str | None, profile_id: int | None = None) -> int: """Start monitoring a series. Pulls its episode list immediately.""" @@ -141,13 +161,13 @@ def reconcile(series_id: int) -> dict: (series_id,)).fetchall() missing = upgrades = have = 0 - today = datetime.now().date().isoformat() for ep in canonical: season, episode = ep["season"], ep["episode"] - # skip episodes that haven't aired yet — including ones with NO air - # date (TBA / unannounced): hunting those searches forever for - # releases that can't exist - if not ep["air_date"] or ep["air_date"] > today: + # skip episodes that haven't aired (or aired too recently: real + # releases lag the broadcast and early "releases" are fakes) — + # including ones with NO air date (TBA / unannounced), which would + # search forever for releases that can't exist + if not hunt_eligible(ep["air_date"]): continue # 'future' mode: skip episodes that aired before the show was added if cutoff and ep["air_date"] and ep["air_date"] < cutoff: diff --git a/faucet/sort.py b/faucet/sort.py index 673a6d5..351e0c8 100644 --- a/faucet/sort.py +++ b/faucet/sort.py @@ -30,11 +30,13 @@ moved to a quarantine dir (default: /_failed, which the sweep skips) instead of being deleted with the torrent. -Exit codes (the hook only removes the torrent on 0 or 4): +Exit codes (the hook only removes the torrent on 0, 4 or 5): 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 + 5 suspicious: a media release that is only executables — quarantined with + its executables renamed '*.faucet-blocked' (see faucet/safety.py) Env: LIBRARY_ROOT / MEDIA_ROOT library root (default /library) @@ -108,6 +110,7 @@ def is_better(*_a): EXIT_FATAL = 1 EXIT_RETRY = 2 EXIT_QUARANTINED = 4 +EXIT_SUSPICIOUS = 5 VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".ts", ".m2ts"} SUB_EXTS = {".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt"} @@ -677,13 +680,13 @@ def quarantine_dir(root: Path) -> Path: return Path(explicit) if explicit else root.parent / QUARANTINE_NAME -def quarantine(root: Path) -> bool: +def quarantine(root: Path) -> Path | None: """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).""" + Returns where it went, or None 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 + return None base = quarantine_dir(root) target = base / root.name n = 2 @@ -704,9 +707,9 @@ def quarantine(root: Path) -> bool: root.unlink() except OSError as e: logging.error("QUARANTINE FAILED for %s: %s", root, e) - return False + return None logging.warning("QUARANTINED %s -> %s", root.name, target) - return True + return target def _consumed(handled: set) -> bool: @@ -744,7 +747,7 @@ def _finish(root: Path, handled: set, unfiled: list, dry: bool) -> int: 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 + return EXIT_QUARANTINED if quarantine(root) is not None else EXIT_RETRY # ---------------------------------------------------------------------------- @@ -891,11 +894,45 @@ def sort_video_release(root: Path, dry: bool) -> int: return _finish(root, handled, unfiled, dry) +def suspicious_reason(root: Path, ctype: str | None) -> str | None: + """A media release whose payload is executables with no video.""" + if ctype == "game": + return None + try: + from faucet.safety import payload_problem + except Exception: # noqa: BLE001 - standalone + return None + rels = [str(p.relative_to(root)) if root.is_dir() else p.name for p in _files(root)] + return payload_problem(rels) + + +def quarantine_suspicious(root: Path, reason: str, dry: bool) -> int: + logging.warning("SUSPICIOUS release %s: %s", root.name, reason) + if dry: + return EXIT_SUSPICIOUS + if not (MODE == "move" or _truthy("REMOVE_ON_COMPLETE")): + return EXIT_SUSPICIOUS # still seeding; the guard paused it + try: + from faucet.safety import neutralize + except Exception: # noqa: BLE001 + neutralize = None + moved = quarantine(root) + if moved is None: + return EXIT_RETRY + if neutralize: + for p in neutralize(moved): + logging.warning("neutralized %s", p) + return EXIT_SUSPICIOUS + + 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) + reason = suspicious_reason(root, ctype) + if reason: + return quarantine_suspicious(root, reason, dry) if release_is_game(root, ctype): try: outcome = handle_game(root, platform, dry) @@ -969,7 +1006,7 @@ def resolve_inputs(args): return [] -_SEVERITY = {EXIT_OK: 0, EXIT_QUARANTINED: 1, EXIT_RETRY: 2} +_SEVERITY = {EXIT_OK: 0, EXIT_QUARANTINED: 1, EXIT_SUSPICIOUS: 2, EXIT_RETRY: 3} def main() -> int: @@ -992,7 +1029,7 @@ def main() -> int: rc = EXIT_OK for root in inputs: r = sort_release(root, dry) - if _SEVERITY.get(r, 2) > _SEVERITY.get(rc, 2): + if _SEVERITY.get(r, 3) > _SEVERITY.get(rc, 3): rc = r logging.info("Done: rc=%d (mode=%s, dry=%s).", rc, MODE, dry) return rc diff --git a/faucet/stalls.py b/faucet/stalls.py index 7a4f633..7af70a0 100644 --- a/faucet/stalls.py +++ b/faucet/stalls.py @@ -30,9 +30,16 @@ from datetime import datetime from . import db -from .config import config +from . import config as _cfgmod from .clients import make_client, DownloadClientError +def _cfg(): + """The live config. Settings saves swap `faucet.config.config` for a new + object (config.reload); binding it at import time left the scheduler + hunting with whatever was configured when the process started.""" + return _cfgmod.config + + log = logging.getLogger("faucet.stalls") STALL_HOURS = float(os.environ.get("STALL_HOURS", "4")) @@ -105,9 +112,9 @@ def check() -> dict: removed corpse frees its client slot for the same tick's re-hunt.""" result = {"checked": 0, "stalled": [], "flipped": 0, "errors": []} try: - client = make_client(config.client_kind, config.client_url, - config.client_user, config.client_pass, - config.request_timeout) + client = make_client(_cfg().client_kind, _cfg().client_url, + _cfg().client_user, _cfg().client_pass, + _cfg().request_timeout) transfers = client.list_transfers() except Exception as e: # noqa: BLE001 result["errors"].append(f"client unreachable: {e}") @@ -172,10 +179,10 @@ def check() -> dict: f"{t.percent:.1f}%; {flipped} want(s) re-queued") log.warning("STALLED: removed '%s' (%.1f%%), re-queued %d want(s)", t.name, t.percent, flipped) - if "failed" in config.notify_on: + if "failed" in _cfg().notify_on: try: from .notify import notify - notify(config.notify_urls, "Stalled download removed", + notify(_cfg().notify_urls, "Stalled download removed", f"{t.name} ({t.percent:.1f}%) — will retry a different release") except Exception: # noqa: BLE001 pass diff --git a/faucet/static/index.html b/faucet/static/index.html index 1ab5d98..93f2f46 100644 --- a/faucet/static/index.html +++ b/faucet/static/index.html @@ -424,6 +424,7 @@ .xfer .xmeta{color:var(--muted);display:flex;gap:10px;flex-wrap:wrap;} .xfer .xsize{color:var(--muted);font-family:var(--mono);font-size:10.5px;margin-top:4px;} .xfer .xact{margin-top:5px;display:flex;gap:8px;} + .xfer .xflag{color:var(--bad);font-size:11.5px;margin:2px 0 4px;line-height:1.35;} .hist-row{display:flex;gap:10px;padding:7px 0;border-bottom:1px solid var(--line);font-size:12px;align-items:baseline;} .hist-row .ht{color:var(--muted);font-size:10px;white-space:nowrap;} .hist-row .he{font-size:10px;padding:1px 6px;border-radius:3px;border:1px solid var(--line-hi);white-space:nowrap;} @@ -755,7 +756,8 @@

Activity

function esc(s){return String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]));} function human(n){if(!n)return"0 B";const u=["B","KB","MB","GB","TB"];let i=0,v=n;while(v>=1024&&i0?1:0)+" "+u[i];} function csrfToken(){const m=document.cookie.match(/(?:^|;\s*)faucet_csrf=([^;]+)/);return m?decodeURIComponent(m[1]):"";} -async function jget(u){const r=await fetch(u);if(!r.ok)throw new Error(r.status);return r.json();} +async function jget(u){const r=await fetch(u);if(!r.ok){const d=(await r.json().catch(()=>({}))).detail;throw new Error(d?`${d} (${r.status})`:String(r.status));}return r.json();} +function hiddenNote(n){return n?` · ${n} unsafe result${n===1?'':'s'} hidden (executable files)`:'';} async function jsend(u,m,b){const r=await fetch(u,{method:m,headers:{"Content-Type":"application/json","X-CSRF-Token":csrfToken()},body:b?JSON.stringify(b):undefined});if(!r.ok)throw new Error((await r.json().catch(()=>({}))).detail||r.status);return r.json();} // deterministic hue for gradient placeholder art (no poster available) function hueOf(s){let h=0;for(const c of String(s||""))h=(h*31+c.charCodeAt(0))>>>0;return h%360;} @@ -824,9 +826,9 @@

Activity

try{ const d=await jget(`/api/search?q=${encodeURIComponent(q)}&cat=${$("cat").value}&limit=150`); allResults=d.results||[]; - if(!allResults.length){$("status").textContent="";$("resultsTable").hidden=true;$("searchEmpty").hidden=false;$("searchEmpty").textContent="No results. Try fewer words or a different category.";return;} + if(!allResults.length){$("status").textContent="";$("resultsTable").hidden=true;$("searchEmpty").hidden=false;$("searchEmpty").textContent="No results. Try fewer words or a different category."+hiddenNote(d.hidden);return;} $("filters").hidden=false;applyFilters(); - $("status").textContent=`${allResults.length} results`; + $("status").textContent=`${allResults.length} results`+hiddenNote(d.hidden); }catch(err){$("status").textContent="";$("searchEmpty").hidden=false;$("searchEmpty").textContent="Search failed: "+err.message;} finally{$("goBtn").disabled=false;} } @@ -894,14 +896,14 @@

Activity

const diskCls=dpct>90?"bad":dpct>78?"warn":""; const epPct=lib.episodes_total?Math.round(lib.episodes_have/lib.episodes_total*100):0; - let html=`
+ let html=(d.warnings||[]).map(w=>`
⚠ ${esc(w)}
`).join("")+`
Storage
${disk?esc(disk.used_h):'—'} ${disk?'of '+esc(disk.total_h)+' · '+dpct+'%':'unavailable'}
${disk?esc(disk.free_h)+' free':''}
Services
- Indexer + ${(d.indexer&&d.indexer.configured)?'Indexer':'Indexer not configured'} ${tr.client_ok?'Client':'Client offline'}
${tile("Throughput",`${esc(tr.down_h||'0 B/s')} ↓`,`${esc(tr.up_h||'0 B/s')} ↑ · ${tr.downloading||0} active · ${tr.seeding||0} seeding`)} @@ -1174,12 +1176,12 @@

${esc(s.title)}

catch(e){ $("epsr").innerHTML=`
Search failed: ${esc(e.message)}
`; return; } const rel=d.releases||[]; if(!rel.length){ - $("epsr").innerHTML=`
No matching releases found (${d.considered||0} results checked — non-matching titles and season packs are filtered out). Try again later; new releases appear over time.
`; + $("epsr").innerHTML=`
No matching releases found (${d.considered||0} results checked — non-matching titles and season packs are filtered out${hiddenNote(d.hidden)}). Try again later; new releases appear over time.
`; return; } $("epsr").innerHTML=`
${rel.length} verified release${rel.length===1?'':'s'} for this exact episode - ${d.profile?` · ranked by profile ${esc(d.profile)}`:' · ranked by seeders'}
+ ${d.profile?` · ranked by profile ${esc(d.profile)}`:' · ranked by seeders'}${hiddenNote(d.hidden)}
${rel.map((r,i)=>`
releasesizehealth
${esc(r.title)}${badgeHtml(r.badges)} @@ -1206,11 +1208,11 @@

${esc(s.title)}

catch(e){ $("spsr").innerHTML=`
Search failed: ${esc(e.message)}
`; return; } const rel=d.releases||[]; if(!rel.length){ - $("spsr").innerHTML=`
No verified season packs found (${d.considered||0} results checked).
`;return; + $("spsr").innerHTML=`
No verified season packs found (${d.considered||0} results checked${hiddenNote(d.hidden)}).
`;return; } $("spsr").innerHTML=`
${rel.length} verified pack${rel.length===1?'':'s'} for S${String(season).padStart(2,'0')} - ${d.profile?` · ranked by profile ${esc(d.profile)}`:''}
+ ${d.profile?` · ranked by profile ${esc(d.profile)}`:''}${hiddenNote(d.hidden)} ${rel.map((r,i)=>`
releasesizehealth
${esc(r.title)}${badgeHtml(r.badges)} @@ -1700,7 +1702,7 @@

${esc(m.title)}

if(drawerTab==="transfers"){ try{const d=await jget("/api/transfers");const xs=d.transfers||[]; $("drawerBody").innerHTML=xs.length?xs.map(x=>{const pct=Math.round(x.percent||0); - return `
${esc(x.name)}
+ return `
${esc(x.name)}
${x.flag?`
⚠ paused — suspicious: ${esc(x.flag)}. Don't open these files; remove it unless you're sure.
`:''}
${pct}%${esc(x.status||'')}↓${esc(x.down_h||'0/s')}
${x.size?`
${esc(human(x.size*(x.percent||0)/100))} of ${esc(x.size_h)}
`:''}
@@ -1748,6 +1750,8 @@

${esc(m.title)}

+ +
@@ -1777,10 +1781,12 @@

${esc(m.title)}

+ + - +
@@ -1810,7 +1816,7 @@

${esc(m.title)}

// send currently-typed values; null fields fall back to saved config server-side const pw=$("st_cpass").value; const payload={ - jackett_url:$("st_jurl").value.trim(), jackett_api_key:null, jackett_indexer:$("st_jidx").value.trim(), + jackett_url:$("st_jurl").value.trim(), jackett_api_key:$("st_jkey").value.trim()||null, jackett_indexer:$("st_jidx").value.trim(), client_kind:$("st_client").value, client_url:$("st_curl").value.trim(), client_user:$("st_cuser").value.trim(), client_pass:pw||null, }; @@ -1836,11 +1842,13 @@

${esc(m.title)}

REMOVE_ON_COMPLETE:$("st_rem").value, SEARCH_LIMIT:$("st_slimit").value, REQUEST_TIMEOUT:$("st_timeout").value, BIG_DOWNLOAD_GB:$("st_bigdl").value, NOTIFY_URLS:$("st_notify").value.trim(), NOTIFY_ON:$("st_notifyon").value.trim(), + AIR_DELAY_DAYS:$("st_airdelay").value, LIBRARY_ROOT:$("st_lib").value.trim(), DOWNLOAD_DIR:$("st_dldir").value.trim(), DISK_PATH:$("st_disk").value.trim(), BROWSE_ROOT:$("st_browse").value.trim(), }; // only send password if the admin actually typed a new one const pw=$("st_cpass").value; if(pw)vals.CLIENT_PASS=pw; + const jk=$("st_jkey").value.trim(); if(jk)vals.JACKETT_API_KEY=jk; try{ const r=await jsend("/api/settings","PATCH",{values:vals}); if(r.warnings&&r.warnings.length){ diff --git a/faucet/sweep.py b/faucet/sweep.py index 61bc11c..96d2107 100644 --- a/faucet/sweep.py +++ b/faucet/sweep.py @@ -152,9 +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) - # 0 = filed; 4 = filed, with leftovers quarantined to _failed/ (which - # this sweep never revisits) - if rc in (0, 4): + # 0 = filed; 4 = filed, with leftovers quarantined to _failed/; 5 = + # suspicious payload quarantined (the sweep never revisits _failed/) + if rc in (0, 4, 5): swept += 1 else: failed += 1 diff --git a/tests/test_fake_releases.py b/tests/test_fake_releases.py new file mode 100644 index 0000000..7647079 --- /dev/null +++ b/tests/test_fake_releases.py @@ -0,0 +1,465 @@ +"""Fake-release defenses, the air-date delay, and the config/hunter fixes +that the S29E01 incident exposed. + +The bait release that started this: 'South Park S29E01 South America 1080p +WEB-DL x265 NTb.exe', grabbed minutes before the episode aired; a second copy +arrived as a bare magnet whose display name had no extension at all. +""" +from __future__ import annotations + +import importlib +import json +from datetime import date, timedelta +from types import SimpleNamespace + +import pytest + +from faucet.clients.base import AddResult, DownloadClientError, Transfer, TransferFile + +MB = 1024 * 1024 +BAIT = "South Park S29E01 South America 1080p WEB-DL x265 NTb.exe" +MAGNET_NAME = "South+Park+S29E01+South+America+1080p+WEB-DL+x265+NTb" + + +def mk(path, mb, fill=b"x"): + 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("FAUCET_CONFIG_FILE", str(tmp_path / "config" / "faucet.env")) + monkeypatch.setenv("LIBRARY_ROOT", str(lib)) + monkeypatch.setenv("JACKETT_URL", "http://jackett:9117") + 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")) + monkeypatch.setenv("NOTIFY_URLS", "") + for k in ("REMOVE_ON_COMPLETE", "QUARANTINE_DIR", "AIR_DELAY_DAYS", + "BLOCK_EXECUTABLE_RELEASES", "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 ("safety", "search", "library", "wants", "series", "movies", + "stalls", "scheduler", "sort"): + mods[name] = importlib.reload(importlib.import_module(f"faucet.{name}")) + return SimpleNamespace(tmp=tmp_path, lib=lib, dl=dl, **mods) + + +class FakeClient: + name = "transmission" + + def __init__(self, transfers=(), files=None, pause_error=None): + self.transfers = list(transfers) + self.file_map = dict(files or {}) + self.paused, self.added = [], [] + self.pause_error = pause_error + + def test(self): + return True + + def list_transfers(self): + return list(self.transfers) + + def files(self, tid): + return [TransferFile(p.rsplit("/", 1)[-1], p, s, 0.0) for p, s in + self.file_map.get(str(tid), [])] + + def pause(self, tid): + if self.pause_error: + raise DownloadClientError(self.pause_error) + self.paused.append(str(tid)) + + def resume(self, tid): + pass + + def remove(self, tid, delete_data=False): + pass + + def add(self, href, *a, **k): + self.added.append(href) + return AddResult(id="9", name="x") + + +def xfer(tid, name, status="downloading", pct=8.0): + return Transfer(str(tid), name, pct, 1_000_000, 0, status, 60, 0.0, 1_000 * MB) + + +def _series(db, title, eps, profile_id=None): + 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, air in eps: + c.execute("INSERT INTO series_episodes (series_id,season,episode,title,air_date) " + "VALUES (?,?,?,?,?)", (sid, s, e, f"E{e}", air)) + return sid + + +def _history(db, event): + return [r for r in db.recent_history(200) if r["event"] == event] + + +def _release(title, href="magnet:x"): + from faucet.search import parse_badges + return {"title": title, "href": href, "seeders": 90, "size": MB * 900, + "badges": parse_badges(title)} + + +TODAY = date.today().isoformat() +YESTERDAY = (date.today() - timedelta(days=1)).isoformat() + + +# ── layer 1: release-name filter ───────────────────────────────────────────── + +@pytest.mark.parametrize("name,bad", [ + (BAIT, True), + ("Some.Movie.2019.1080p.WEB-DL.x264.scr", True), + ("Show S01E01 [1080p].exe ]", True), + ("Tool.lnk", True), + ("South.Park.S29E01.1080p.WEB.h264-ETHEL", False), + ("[www.torrenting.com] - Show S01E01 720p", False), # .com watermark + ("Movie.2019.1080p.mkv", False), + ("Game.Title.PS4.pkg", False), # console, not Windows + (MAGNET_NAME, False), # can't tell from the name +]) +def test_executable_release_names(env, name, bad): + assert env.safety.is_executable_name(name) is bad + + +TORZNAB = """ +{a}magnet:?xt=a1000 + +{b}magnet:?xt=b1000 + +""" + + +class _Resp: + def __init__(self, text): + self.content = text.encode() + + def raise_for_status(self): + pass + + +def test_search_hides_executable_releases(env, monkeypatch): + xml = TORZNAB.format(a=BAIT, b="South.Park.S29E01.1080p.WEB.h264-ETHEL") + monkeypatch.setattr(env.search.requests, "get", lambda *a, **k: _Resp(xml)) + res = env.search.search("http://j", "k", "all", "south park", "all", 50) + assert [r["title"] for r in res] == ["South.Park.S29E01.1080p.WEB.h264-ETHEL"] + assert res.hidden == 1 + + monkeypatch.setenv("BLOCK_EXECUTABLE_RELEASES", "0") + res = env.search.search("http://j", "k", "all", "south park", "all", 50) + assert len(res) == 2 and res.hidden == 0 + + +def test_torznab_error_document_is_an_error(env, monkeypatch): + """A wrong API key comes back as HTTP 200 + ; it used to look like + 'no results'.""" + xml = '' + monkeypatch.setattr(env.search.requests, "get", lambda *a, **k: _Resp(xml)) + with pytest.raises(env.search.SearchError, match="Invalid API Key"): + env.search.search("http://j", "bad", "all", "x", "all", 5) + + +# ── layer 2: payload check on live transfers ───────────────────────────────── + +@pytest.mark.parametrize("paths,bad", [ + (["South Park S29E01.exe"], True), + (["Release/setup.exe", "Release/readme.txt"], True), + (["Release/Sample/sample.mkv", "Release/player.exe"], True), # a sample isn't content + (["Release/ep.mkv", "Release/codec.exe"], False), # real video present + (["Release/ep.mkv"], False), + ([], False), +]) +def test_payload_problem(env, paths, bad): + assert bool(env.safety.payload_problem(paths)) is bad + + +def test_guard_pauses_and_flags_bait_then_respects_resume(env): + sid = _series(env.db, "South Park", [(29, 1, YESTERDAY)]) + with env.db.connect() as c: + c.execute("INSERT INTO wanted (kind,series_id,season,episode,title,reason,status) " + "VALUES ('episode',?,29,1,'x','missing','grabbed')", (sid,)) + client = FakeClient([xfer(1, BAIT)], {"1": [(BAIT, 1000 * MB)]}) + + r = env.safety.check_transfers(client) + assert client.paused == ["1"] + assert r["flagged"][0]["flipped"] == 1 + with env.db.connect() as c: + assert c.execute("SELECT status FROM wanted").fetchone()[0] == "wanted" + assert "executable payload" in env.safety.flags()["1"]["reason"] + assert _history(env.db, "suspicious") + + # admin resumes it on purpose: the guard must not fight them + client.paused.clear() + env.safety.check_transfers(client) + assert client.paused == [] + + +def test_guard_waits_for_magnet_metadata(env): + client = FakeClient([xfer(1, MAGNET_NAME, pct=0.0)], {}) + env.safety.check_transfers(client) + assert client.paused == [] and env.safety.flags() == {} + # metadata arrives: the torrent is renamed and its files are known + client.transfers = [xfer(1, BAIT)] + client.file_map = {"1": [(BAIT, 1000 * MB)]} + env.safety.check_transfers(client) + assert client.paused == ["1"] + + +def test_guard_leaves_games_and_real_releases_alone(env): + client = FakeClient( + [xfer(1, "Some.Game-CODEX"), xfer(2, "South.Park.S29E01.1080p.WEB.h264-ETHEL")], + {"1": [("Some.Game-CODEX/setup.exe", 5000 * MB)], + "2": [("South.Park.S29E01.1080p.WEB.h264-ETHEL.mkv", 900 * MB)]}) + r = env.safety.check_transfers(client) + assert r["checked"] == 2 and client.paused == [] and env.safety.flags() == {} + + +def test_guard_retries_when_pause_fails(env): + client = FakeClient([xfer(1, BAIT)], {"1": [(BAIT, 1000 * MB)]}, pause_error="rpc down") + r = env.safety.check_transfers(client) + assert r["errors"] and env.safety.flags() == {} + client.pause_error = None + env.safety.check_transfers(client) + assert client.paused == ["1"] + + +def test_guard_rechecks_reused_ids_and_prunes_gone_ones(env): + client = FakeClient([xfer(1, "Real.Show.S01E01.1080p")], + {"1": [("Real.Show.S01E01.1080p.mkv", 900 * MB)]}) + env.safety.check_transfers(client) + # Transmission restarted and handed id 1 to a different torrent + client.transfers = [xfer(1, BAIT)] + client.file_map = {"1": [(BAIT, 1000 * MB)]} + env.safety.check_transfers(client) + assert client.paused == ["1"] + client.transfers = [] + env.safety.check_transfers(client) + assert env.safety.flags() == {} + + +# ── layer 3: sorter backstop ───────────────────────────────────────────────── + +def test_sorter_quarantines_and_neutralizes_bait(env): + rel = env.dl / "South Park S29E01 South America 1080p WEB-DL x265 NTb" + mk(rel / BAIT, 2) + assert env.sort.sort_release(rel, dry=False) == env.sort.EXIT_SUSPICIOUS + parked = env.dl / "_failed" / rel.name + assert (parked / (BAIT + ".faucet-blocked")).exists() + assert not list(parked.rglob("*.exe")) + assert not list(env.lib.rglob("*")) + + +def test_sorter_single_file_bait(env): + f = mk(env.dl / BAIT, 2) + assert env.sort.sort_release(f, dry=False) == env.sort.EXIT_SUSPICIOUS + assert (env.dl / "_failed" / (BAIT + ".faucet-blocked")).exists() + + +def test_sorter_leaves_seeding_bait_in_place(env): + env.sort.MODE = "copy" + rel = env.dl / "Bait" + f = mk(rel / BAIT, 2) + assert env.sort.sort_release(rel, dry=False) == env.sort.EXIT_SUSPICIOUS + assert f.exists() and not (env.dl / "_failed").exists() + + +def test_sorter_files_real_release_with_bundled_exe(env): + rel = env.dl / "South.Park.S29E01.1080p.WEB.h264-ETHEL" + mk(rel / "South.Park.S29E01.1080p.WEB.h264-ETHEL.mkv", 2) + mk(rel / "codec-pack.exe", 1) + assert env.sort.sort_release(rel, dry=False) == env.sort.EXIT_OK + assert (env.lib / "tvshows" / "South Park" / "Season 29" + / "South Park - S29E01.mkv").exists() + + +def test_sorter_games_keep_their_executables(env): + rel = env.dl / "Some.Game-CODEX" + mk(rel / "setup.exe", 2) + mk(rel / "data.bin", 2) + assert env.sort.sort_release(rel, dry=False) != env.sort.EXIT_SUSPICIOUS + + +def test_hook_removes_quarantined_bait_and_records_it(env, monkeypatch): + 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) + removed = [] + monkeypatch.setattr(hook, "make_client", lambda *a, **k: SimpleNamespace( + remove=lambda tid, d=False: removed.append((tid, d)))) + monkeypatch.setattr(hook.subprocess, "run", lambda *a, **k: SimpleNamespace(returncode=5)) + assert hook.main() == 0 + assert removed == [("7", True)] + assert _history(env.db, "suspicious") + + +# ── air-date delay ─────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("delay,air,wanted", [ + (None, TODAY, 0), # default: day after + (None, YESTERDAY, 1), + ("0", TODAY, 1), + ("2", YESTERDAY, 0), +]) +def test_reconcile_respects_air_delay(env, monkeypatch, delay, air, wanted): + if delay is not None: + monkeypatch.setenv("AIR_DELAY_DAYS", delay) + sid = _series(env.db, "South Park", [(29, 1, air)]) + assert env.series.reconcile(sid)["missing"] == wanted + + +def test_hunter_skips_want_that_aired_too_recently(env, monkeypatch): + """A want created before the delay existed must not be hunted early.""" + sid = _series(env.db, "South Park", [(29, 1, TODAY), (28, 1, "2025-10-15")]) + with env.db.connect() as c: + for s in (29, 28): + c.execute("INSERT INTO wanted (kind,series_id,season,episode,title,reason) " + "VALUES ('episode',?,?,1,'x','missing')", (sid, s)) + queries = [] + client = FakeClient() + monkeypatch.setattr(env.scheduler, "make_client", lambda *a, **k: client) + monkeypatch.setattr(env.scheduler.searchmod, "search", + lambda *a, **k: queries.append(a[3]) or []) + env.scheduler.hunt_wanted() + assert queries == ["South Park S28E01"] + + +# ── hunter / config fixes ──────────────────────────────────────────────────── + +def test_hunt_short_circuits_without_indexer_key(env, monkeypatch): + monkeypatch.setenv("JACKETT_API_KEY", "") + env.config.reload() + sid = _series(env.db, "Show", [(1, 1, "2020-01-01")]) + env.series.reconcile(sid) + called = [] + monkeypatch.setattr(env.scheduler.searchmod, "search", lambda *a, **k: called.append(a)) + r = env.scheduler.hunt_wanted() + assert r["skipped_reason"] == "indexer not configured" and called == [] + + +def test_scheduler_sees_key_saved_after_startup(env, monkeypatch): + """F15: the key saved in Settings reached search but never the hunter.""" + monkeypatch.setenv("JACKETT_API_KEY", "") + env.config.reload() + importlib.reload(env.scheduler) # "process start" with no key + sid = _series(env.db, "Show", [(1, 1, "2020-01-01")]) + env.series.reconcile(sid) + keys = [] + monkeypatch.setattr(env.scheduler, "make_client", lambda *a, **k: FakeClient()) + monkeypatch.setattr(env.scheduler.searchmod, "search", + lambda *a, **k: keys.append(a[1]) or []) + env.config.save({"JACKETT_API_KEY": "saved-in-settings"}) # the Settings panel path + env.scheduler.hunt_wanted() + assert keys == ["saved-in-settings"] + + +def test_hunt_add_failure_is_recorded(env, monkeypatch, caplog): + sid = _series(env.db, "Show", [(1, 1, "2020-01-01")]) + env.series.reconcile(sid) + + class Refuses(FakeClient): + def add(self, href, *a, **k): + raise DownloadClientError("disk full") + + monkeypatch.setattr(env.scheduler, "make_client", lambda *a, **k: Refuses()) + monkeypatch.setattr(env.scheduler.searchmod, "search", + lambda *a, **k: [_release("Show.S01E01.1080p.WEB-DL.x264-GRP")]) + with caplog.at_level("WARNING", logger="faucet.scheduler"): + env.scheduler.hunt_wanted() + assert "disk full" in caplog.text + assert "disk full" in _history(env.db, "grab_failed")[0]["detail"] + + +def test_hunt_search_failures_are_summarized(env, monkeypatch, caplog): + sid = _series(env.db, "Show", [(1, e, "2020-01-01") for e in (1, 2, 3)]) + env.series.reconcile(sid) + monkeypatch.setattr(env.scheduler, "make_client", lambda *a, **k: FakeClient()) + + def boom(*a, **k): + raise env.search.SearchError("Indexer query failed: timeout") + + monkeypatch.setattr(env.scheduler.searchmod, "search", boom) + with caplog.at_level("WARNING", logger="faucet.scheduler"): + env.scheduler.hunt_wanted() + lines = [r for r in caplog.records if "search(es) failed" in r.getMessage()] + assert len(lines) == 1 and "3 search(es)" in lines[0].getMessage() + + +# ── API / UI contract ──────────────────────────────────────────────────────── + +@pytest.fixture +def api(env, monkeypatch): + monkeypatch.setenv("SESSION_SECRET", "test-secret") + from faucet import auth as authmod + importlib.reload(authmod) + from faucet import app as appmod + importlib.reload(appmod) + client = FakeClient([xfer(1, BAIT), xfer(2, "Real.Show.S01E01.1080p")], + {"1": [(BAIT, 1000 * MB)], + "2": [("Real.Show.S01E01.1080p.mkv", 900 * MB)]}) + monkeypatch.setattr(appmod, "client", lambda: client) + from fastapi.testclient import TestClient + tc = TestClient(appmod.app) + tc.post("/api/auth/register", json={"username": "admin", "password": "supersecret123"}) + tc.post("/api/auth/login", json={"username": "admin", "password": "supersecret123"}) + tc.headers.update({"X-CSRF-Token": tc.cookies.get("faucet_csrf") or ""}) + return SimpleNamespace(http=tc, app=appmod, client=client) + + +def test_transfers_carry_the_flag(env, api): + env.safety.check_transfers(api.client) + xs = {x["id"]: x for x in api.http.get("/api/transfers").json()["transfers"]} + assert "executable payload" in xs["1"]["flag"] and xs["2"]["flag"] is None + warnings = api.http.get("/api/dashboard").json()["warnings"] + assert any("suspicious download" in w for w in warnings) + + +def test_missing_key_is_explained_everywhere(env, api, monkeypatch): + monkeypatch.setenv("JACKETT_API_KEY", "") + env.config.reload() + r = api.http.get("/api/search?q=south+park") + assert r.status_code == 503 and "API key" in r.json()["detail"] + d = api.http.get("/api/dashboard").json() + assert d["indexer"]["configured"] is False + assert any("Jackett API key is not set" in w for w in d["warnings"]) + assert any("Jackett API key" in w for w in api.http.get("/health").json()["warnings"]) + assert api.http.get("/api/settings").json()["env"]["JACKETT_API_KEY_SET"] is False + + +def test_jackett_key_and_air_delay_save_from_settings(env, api): + r = api.http.patch("/api/settings", json={"values": { + "JACKETT_API_KEY": "new-key", "AIR_DELAY_DAYS": "2"}}).json() + assert r["status"] == "ok" and not r["warnings"] + env_view = api.http.get("/api/settings").json()["env"] + assert env_view["JACKETT_API_KEY_SET"] is True and env_view["AIR_DELAY_DAYS"] == "2" + assert "new-key" not in json.dumps(env_view) # never echoed back + assert env.config.config.jackett_api_key == "new-key" + + r = api.http.patch("/api/settings", json={"values": {"AIR_DELAY_DAYS": "soon"}}).json() + assert "AIR_DELAY_DAYS" in r["warnings"][0] + assert api.http.get("/api/settings").json()["env"]["AIR_DELAY_DAYS"] == "2" + + +def test_search_reports_hidden_count(env, api, monkeypatch): + res = env.search.Results([_release("South.Park.S29E01.1080p.WEB.h264-ETHEL")]) + res.hidden = 3 + monkeypatch.setattr(api.app.searchmod, "search", lambda *a, **k: res) + body = api.http.get("/api/search?q=south+park").json() + assert body["total"] == 1 and body["hidden"] == 3 diff --git a/tests/test_wants_upgrades.py b/tests/test_wants_upgrades.py index cde0526..0442b25 100644 --- a/tests/test_wants_upgrades.py +++ b/tests/test_wants_upgrades.py @@ -230,45 +230,45 @@ def _movie_upgrade_setup(env): return mid -def test_hunter_skips_same_quality_upgrade(env): +def test_hunter_skips_same_quality_upgrade(env, monkeypatch): 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")] + monkeypatch.setattr(env.SCH, "make_client", lambda *a, **k: client) + monkeypatch.setattr(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): +def test_hunter_grabs_a_real_upgrade(env, monkeypatch): _movie_upgrade_setup(env) client = _Client() - env.SCH.make_client = lambda *a, **k: client - env.SCH.searchmod.search = lambda *a, **k: [ + monkeypatch.setattr(env.SCH, "make_client", lambda *a, **k: client) + monkeypatch.setattr(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")] + _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): +def test_hunter_cam_upgrade_needs_a_real_source(env, monkeypatch): 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: [ + monkeypatch.setattr(env.SCH, "make_client", lambda *a, **k: client) + monkeypatch.setattr(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")] + _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): +def test_hunter_episode_upgrade_filter(env, monkeypatch): 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"])) @@ -279,14 +279,14 @@ def test_hunter_episode_upgrade_filter(env): # 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")] + monkeypatch.setattr(env.SCH, "make_client", lambda *a, **k: client) + monkeypatch.setattr(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: [ + monkeypatch.setattr(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")] + _release("Show.S01E01.1080p.WEB-DL.x264-GRP", "magnet:better")]) env.SCH.hunt_wanted() assert client.added == ["magnet:better"] From 3a2e5d85d5be871eada9506c306b7f0027eefd45 Mon Sep 17 00:00:00 2001 From: harrsn <64714607+Harrsn@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:40:22 -0400 Subject: [PATCH 2/2] tests: conftest puts the repo root on sys.path for plain pytest --- tests/conftest.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7f69789 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +"""Make the in-repo `faucet` package importable for every test module. + +CI runs plain `pytest`, which (unlike `python -m pytest`) doesn't put the repo +root on sys.path. test_faucet.py used to do this itself, so any test file that +sorted before it and imported `faucet` at module level failed to collect. +conftest.py is loaded before any test module, whatever the order. +""" +import sys +from pathlib import Path + +ROOT = str(Path(__file__).resolve().parent.parent) +if ROOT not in sys.path: + sys.path.insert(0, ROOT)