Skip to content
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ All via environment / `.env`:
| `DOWNLOAD_DIR` | `/downloads` | Active download dir. |
| `DISK_PATH` | `/downloads` | Path used for the disk-free gauge. |
| `BROWSE_ROOT` | `LIBRARY_ROOT` | Root the admin file browser is scoped to. |
| `REMOVE_ON_COMPLETE` | `0` | Remove finished torrents (stops seeding). |
| `REMOVE_ON_COMPLETE` | `0` | Remove finished torrents (stops seeding). Only after a clean sort; unfiled content is quarantined first. |
| `MEDIASORT_MODE` | `auto` | `auto` \| `hardlink` \| `copy` \| `move`. See [docs/HOOKS.md](docs/HOOKS.md). |
| `QUARANTINE_DIR` | `<release parent>/_failed` | Where the sorter parks content it couldn't file. |
| `HUNT_MAX_ACTIVE` | `5` | Skip hunting if this many torrents are already downloading. |
| `HUNT_MAX_PER_RUN` | `3` | Max grabs per scheduler tick. |
| `RSS_INTERVAL_SECONDS` | `1800` | How often the scheduler scans/reconciles/hunts. |
Expand Down
38 changes: 36 additions & 2 deletions docs/HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ Two things make this setup work cleanly:
NAS mount). Then the sorter relocates finished files with an instant rename —
one copy, no duplication. If they're on different filesystems, the sorter must
copy, which leaves the original behind (and fills your download disk).
Note that two *separate* bind mounts count as different filesystems even when
they point into the same share (`/mnt/nas/torrents:/downloads` +
`/mnt/nas/media:/library`): rename fails with EXDEV and every move becomes a
copy. Mounting the share's parent once and pointing both paths inside it
restores instant renames.
- **`MEDIASORT_MODE`.** Set this in the container's environment. `auto` (default)
tries hardlink → move → copy; on a CIFS/SMB share (no hardlinks) it moves.
Set it to `move` explicitly if you want predictable move-and-clean behavior and
Expand All @@ -83,8 +88,37 @@ Two things make this setup work cleanly:
3. Fires notifications per `NOTIFY_ON`.
4. If `REMOVE_ON_COMPLETE=1`, removes the torrent from the client (stops seeding).

If the sort fails, the torrent is **not** removed — you never lose a file that
didn't make it to the library.
### How the sorter protects your files

- **Nothing half-written is ever visible.** Copies are written to a hidden
`.<name>.<pid>.faucet-partial` beside the destination, fsynced, size-checked,
and only then renamed into place. A crash, a dropped share, or a container
restart mid-copy leaves the library untouched (idle partials are cleaned up
after 30 minutes).
- **Existing files are replaced only by provably better releases.** The sorter
records the resolution and cam/telesync status of every file it places. A
new release for the same path replaces it only if it ranks higher; equal,
worse, or unknown quality keeps what you have.
- **Samples are skipped, extras are kept.** `Sample/` content is ignored;
`Featurettes/`, `Behind The Scenes/`, `-trailer` files etc. go to the
matching Plex extras folder. Two videos that would get the same name never
overwrite each other (numbered `CD1`/`CD2` parts become `- pt1`/`- pt2`).
- **Subtitles keep their tags** (`Movie (2019).en.forced.srt`), including
RARBG-style `Subs/` folders.
- **Anything that can't be filed is quarantined, not deleted.** When the release
is being consumed (`MEDIASORT_MODE=move` or `REMOVE_ON_COMPLETE=1`), leftover
content — unparseable files, lower-quality duplicates, disc images, archives —
is moved intact to `_failed/` next to the release (override with
`QUARANTINE_DIR`). The sweep never touches `_failed/`; review it by hand.

The sorter's exit code tells the hook what's safe:

| rc | Meaning | Torrent removed? |
|----|---------|------------------|
| 0 | Everything of value filed (or left seeding) | yes, if `REMOVE_ON_COMPLETE=1` |
| 4 | Filed; some content quarantined to `_failed/` | yes — nothing is left inside it |
| 2 | I/O error; release left in place for retry | no |
| 1 | Library not mounted / no input | no |

## Catch-up sweep (safety net)

Expand Down
13 changes: 13 additions & 0 deletions faucet/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,19 @@ def connect() -> Iterator[sqlite3.Connection]:
UNIQUE(title, year)
);

-- Quality of files the sorter placed. The sorter renames releases to
-- 'Title (Year).ext' / 'Show - S01E01.ext', which strips the resolution tag,
-- so this is the only record of what an on-disk file actually is. The sorter
-- consults it before replacing an existing file ("replace only if better").
CREATE TABLE IF NOT EXISTS library_files (
path TEXT PRIMARY KEY,
quality TEXT, -- 2160p | 1080p | 720p | 480p | NULL
is_cam INTEGER DEFAULT 0,
release TEXT, -- the release name it was parsed from
size INTEGER DEFAULT 0,
sorted_ts TEXT
);

-- The computed wants: missing or upgrade-eligible items the hunter targets.
CREATE TABLE IF NOT EXISTS wanted (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Expand Down
30 changes: 23 additions & 7 deletions faucet/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@
db = None


# faucet/sort.py exit codes the hook acts on
SORT_OK = 0
SORT_QUARANTINED = 4


def _path_size(path: str) -> int:
"""Total bytes of the completed download (file or dir tree)."""
p = Path(path)
Expand Down Expand Up @@ -98,19 +103,30 @@ def main():
if "completed" in config.notify_on:
notify(config.notify_urls, "Download complete", name)

# 1. sort — delegate to the sorter script, pointed at the completed path
# 1. sort — delegate to the sorter script, pointed at the completed path.
# Exit codes (see faucet/sort.py): 0 filed, 4 filed with some content
# quarantined to _failed/, anything else = leave the torrent alone. The
# torrent is only removed on 0 or 4, because in those cases nothing of
# value is left inside it.
sorter = Path(__file__).resolve().parent / "sort.py"
env = dict(os.environ, FAUCET_PATH=path, CASCADE_PATH=path)
res = subprocess.run([sys.executable, str(sorter)], env=env)
if res.returncode != 0:
record("sort_failed", name, f"sort failed (rc={res.returncode})")
rc = res.returncode
if rc not in (SORT_OK, SORT_QUARANTINED):
record("sort_failed", name, f"sort failed (rc={rc}); torrent left in place")
if "failed" in config.notify_on:
notify(config.notify_urls, "Sort failed", name)
return res.returncode
return rc

record("sorted", name, "filed onto library")
if "sorted" in config.notify_on:
notify(config.notify_urls, "Sorted to library", name)
if rc == SORT_QUARANTINED:
record("quarantined", name,
"some content couldn't be filed; moved to _failed/ for review")
if "failed" in config.notify_on:
notify(config.notify_urls, "Needs review (quarantined)", name)
else:
record("sorted", name, "filed onto library")
if "sorted" in config.notify_on:
notify(config.notify_urls, "Sorted to library", name)

# 2. optional auto-remove
if os.environ.get("REMOVE_ON_COMPLETE", "0") in ("1", "true", "yes") and tid:
Expand Down
43 changes: 7 additions & 36 deletions faucet/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@
from . import db
from .config import config

# quality detection/ranking is shared with the sorter; the private aliases keep
# existing call sites (fixmatch, tests) working
from .quality import RES_RANK # noqa: F401 - re-exported for movies.py
from .quality import detect_cam as _detect_cam
from .quality import detect_quality as _detect_quality
from .quality import file_rank as _movie_file_rank

try:
from guessit import guessit
except ImportError: # pragma: no cover
Expand All @@ -28,9 +35,6 @@
VIDEO_EXTS = {".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".ts", ".m2ts"}
MIN_SIZE = 50 * 1024 * 1024 # ignore sub-50MB junk/samples

_RES_TOKENS = [("2160p", ("2160p", "4k", "uhd")), ("1080p", ("1080p",)),
("720p", ("720p",)), ("480p", ("480p",))]


def _clean_episode_filename(name: str) -> str:
"""Strip multi-segment episode suffixes that break guessit's parser.
Expand Down Expand Up @@ -96,39 +100,6 @@ def _library_root() -> Path:
return Path(os.environ.get("LIBRARY_ROOT", "/library"))


def _detect_quality(name: str) -> str | None:
n = name.lower()
for label, toks in _RES_TOKENS:
if any(t in n for t in toks):
return label
return None


_CAM_RE = None


def _detect_cam(name: str) -> bool:
"""Cam-family rip (CAM/TS/TC/telesync/screener) — a '1080p TS' is 1080p in
name only; these files are always upgrade-eligible."""
global _CAM_RE
if _CAM_RE is None:
import re
_CAM_RE = re.compile(
r"\b(hd-?cam|cam-?rip|telesync|hd-?ts|telecine|dvd-?scr|screener|"
r"ts|tc|cam|scr)\b", re.I)
return bool(_CAM_RE.search(name))


# resolution rank shared with reconcile (higher = better; cam is below any
# real source at equal resolution)
RES_RANK = {"2160p": 4, "1080p": 3, "720p": 2, "480p": 1, None: 0, "": 0}


def _movie_file_rank(quality: str | None, is_cam: bool) -> float:
r = RES_RANK.get(quality, 0)
return r - 0.5 if is_cam else r


def _record_unparsed(path: str, kind: str, reason: str) -> None:
try:
with db.connect() as c:
Expand Down
54 changes: 54 additions & 0 deletions faucet/quality.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Release quality detection and ranking, shared by the scanner and the sorter.

Dependency-free on purpose: ``faucet/sort.py`` imports this while running as a
subprocess of the completion hook, and must not drag in the DB or config just
to compare two release names.
"""
from __future__ import annotations

import re

# resolution tokens, best first
RES_TOKENS = [("2160p", ("2160p", "4k", "uhd")), ("1080p", ("1080p",)),
("720p", ("720p",)), ("480p", ("480p",))]

# higher = better; unknown resolution ranks 0
RES_RANK = {"2160p": 4, "1080p": 3, "720p": 2, "480p": 1, None: 0, "": 0}

_CAM_RE = re.compile(
r"\b(hd-?cam|cam-?rip|telesync|hd-?ts|telecine|dvd-?scr|screener|"
r"ts|tc|cam|scr)\b", re.IGNORECASE)


def detect_quality(name: str) -> str | None:
"""Resolution label ('1080p', ...) found in a release/file name, or None."""
n = (name or "").lower()
for label, toks in RES_TOKENS:
if any(t in n for t in toks):
return label
return None


def detect_cam(name: str) -> bool:
"""Cam-family rip (CAM/TS/TC/telesync/screener). A '1080p TS' is 1080p in
name only, so these always rank below a real source."""
return bool(_CAM_RE.search(name or ""))


def file_rank(quality: str | None, is_cam: bool) -> float:
"""Comparable rank: resolution, with cam-family rips half a step lower."""
r = RES_RANK.get(quality, 0)
return r - 0.5 if is_cam else r


def is_better(new_quality: str | None, new_cam: bool,
old_quality: str | None, old_cam: bool) -> bool:
"""True only when the new file is PROVABLY better than the old one.

Unknown on either side is never "better": an unrecognised incoming release
must not replace a file, and a file whose quality was never recorded can't
be shown to be worse. Equal rank is not better either.
"""
if not new_quality or not old_quality:
return False
return file_rank(new_quality, new_cam) > file_rank(old_quality, old_cam)
Loading
Loading