Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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` | `<release parent>/_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. |
Expand Down
26 changes: 26 additions & 0 deletions docs/HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -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
Expand Down
60 changes: 55 additions & 5 deletions faucet/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion faucet/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
18 changes: 13 additions & 5 deletions faucet/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading