Compare commits

...

38 commits
v0.1.0 ... main

Author SHA1 Message Date
Breadway
5a98860185 Merge feature/tui-ux into main
Some checks failed
check / check (push) Failing after 4m26s
dev release / build (push) Successful in 4m30s
TUI navigation and readability improvements on top of the TPB
supplement sources.
2026-08-16 10:20:49 +08:00
Breadway
f5625528db Improve TUI navigation, search, and list readability
Tab jump keys, reverse-tab, title filter, and first/last/page movement
make a 160-item library usable. Review/Stuck/Library badge counts, a
movie detail pane, scrollable Calendar/Health, history and candidate
colors, and non-blocking add-search fix the daily friction. Status
messages expire so key hints come back; review selection no longer
walks off the end of the list.
2026-08-16 10:20:43 +08:00
Breadway
6059d77065 Add torrents-csv and YTS as TPB search fallbacks
apibay has been timing out and the configured 1337x mirrors are all
Cloudflare 521, so general-content search was failing closed. TPB stays
primary; on failure or empty results, movies try YTS then torrents-csv
then 1337x, and TV tries torrents-csv then 1337x. Both new sources are
JSON hash-to-magnet, same grab shape as TPB. Prepend a working 1337x
mirror (1337xx.to) to the default ring.
2026-08-16 10:20:33 +08:00
Breadway
7ab28d30a7 Fix review-queue dead ends and harden grab/import/API paths
Some checks failed
check / check (push) Failing after 14m5s
dev release / build (push) Successful in 3m54s
Stop upgrade-search from queuing mid-confidence matches that approve
cannot honor (owned movies/episodes 409'd on the TUI). De-dupe pending
review rows, scope 1080p gates to the target episode, refuse unsafe
pack cleanup, and require a token for non-loopback binds.
2026-08-16 00:44:58 +08:00
Breadway
4a2adbc24d Bump version to v0.1.1
All checks were successful
dev release / build (push) Successful in 3m30s
beta (rc) release / build (push) Has been skipped
release / build (push) Successful in 3m25s
2026-08-15 23:05:47 +08:00
Breadway
f0138cf59b Pin bread-onnx and bread-utils to bread-ecosystem v0.7.2
All checks were successful
dev release / build (push) Successful in 2m41s
2026-08-15 22:53:46 +08:00
Breadway
812b70d4b7 ci: link breadarrd/breadarr-tui against hestia's glibc, not the CI container's
All checks were successful
dev release / build (push) Successful in 2m39s
hestia (the actual deployment target) runs Ubuntu 24.04 (glibc 2.39);
the shared Arch CI image tracks a much newer glibc (currently 2.43).
A binary linked normally there requires GLIBC_2.43 symbols and refuses
to start on hestia at all (glibc symbol versioning is forward-only) —
this is what crashed breadarrd in production after its first
bakery-managed install.

Fix: ci/build.sh now pulls a real glibc + gcc-libs (for libstdc++)
package pair from the Arch Linux Archive, matching hestia's actual
versions, and links against them via --sysroot instead of the
container's native ones. cargo-zigbuild (targeting an older glibc via
zig's cross-linker) was tried first and doesn't work here: zig has no
version database for libstdc++ specifically, and onnxruntime --
statically linked in by the ort crate -- needs a real, complete one.
A real archived glibc+gcc-libs pair sidesteps that entirely.

reqwest's native-tls now vendors (statically builds) OpenSSL instead
of dynamically linking the build host's, since the old sysroot has no
libssl/libcrypto of its own — also means the shipped binary no longer
depends on the target system's OpenSSL version at all.

Verified end-to-end on hestia's actual self-hosted runner: real
ci/build.sh, real bread-ci:breadarr image, real docker build/run —
produced binary requires GLIBC up to 2.39 and GLIBCXX up to 3.4.30
only, and actually starts and runs on hestia.
2026-08-15 22:33:16 +08:00
Breadway
624b63da3f Track AGENTS.md
Some checks failed
check / check (push) Failing after 1m16s
dev release / build (push) Successful in 2m3s
2026-08-15 22:03:30 +08:00
Breadway
73df2a4c3c docs: bakery/homelab distribution, single-trunk CONTRIBUTING, drop leftover tarball
Some checks failed
check / check (push) Failing after 34s
State that breadarr is bakery-distributed (not in the BOS ISO, not a GTK
desktop-shell app, no bread events), add CONTRIBUTING.md for the
single-trunk + RC-tag model, ignore leftover src.tar.xz artifacts, and
delete breadarrd/src/src.tar.xz.
2026-08-15 21:35:31 +08:00
Breadway
3e566a193d pushing for dev release
All checks were successful
dev release / build (push) Successful in 39s
2026-08-12 09:17:20 +08:00
Breadway
1084be86cd Fix nyaa grabs being silently dropped as false magnet rejections
Some checks failed
dev release / build (push) Failing after 38s
add_torrent_response_is_rejected treated success_count == 0 alone as
an outright rejection. qBittorrent returns exactly that (with
pending_count: 1) for every URL-based add while it fetches the
torrent asynchronously — the shape nyaa's RSS feed always uses, since
it hands over a .torrent download URL, never a magnet. Every nyaa
grab was therefore marked MagnetRejected and silently dropped (no
release row, no log line) while the torrent downloaded successfully
in the background. With no release row, best_existing_score always
saw None, so the same episode got re-grabbed from every new feed
entry — the repeated duplicate downloads sitting in
/mnt/media/downloads/ (Mushoku Tensei, Tenki no Ko, Code Geass) traced
back to this. Root-caused by an Opus 5 investigation.

Also stop treating a genuine rejection as silent: process_item now
records a failed release row and logs a warning, matching the
existing HashCaptureFailed pattern, instead of just marking the item
seen and moving on with no trace.
2026-08-12 09:07:40 +08:00
Breadway
b49a8597e2 Delete leftover download folders once their video has been imported
move_or_copy_file only ever relocates the single video file it locates
inside a torrent's content_path — a multi-file release's own folder
(sample clips, .nfo/.srt/.jpg sidecars) was left behind with nothing
pointing at it anymore once the torrent got removed from qBittorrent.
Verified against production: 18 of ~30 entries in the downloads
directory were exactly this — husk folders with the video long since
moved into the library. run_import_cycle now removes content_path
itself (guarded against ever equalling the downloads root) alongside
the existing delete_torrent call.
2026-08-12 09:07:40 +08:00
fbf8d58587 Merge pull request 'feature/bakery-integration' (#1) from feature/bakery-integration into main
Some checks failed
dev release / build (push) Failing after 2m8s
Reviewed-on: #1
2026-08-06 10:53:15 +08:00
Breadway
5543485976 Fix all cargo clippy warnings across the workspace
Some checks failed
check / check (push) Failing after 53s
Removes genuinely dead code (unused import, no-op cast, an unused
PendingGrab accessor, and TmdbClient's TV-search methods now that TVDB
fully covers that path), tightens len()>0 checks to is_empty(), swaps
two fixed-size test vec!s for arrays, restructures a match to avoid an
unnecessary unwrap_err, fixes doc-comment list indentation, and hoists
a locked-connection call out of a match scrutinee. Struct fields/enum
variants that are still meaningful but not read by current callers
(TorrentInfo::save_path, GrabCycleStats::items_seen, X1337 fallback
route, BacklogCandidate::path) get #[allow(dead_code)] rather than
deletion, same for the two 8-argument functions (too_many_arguments).
2026-08-06 08:51:07 +08:00
Breadway
471ca884a6 gitignore: stop tracking graphify-out/ tool cache
graphify's local knowledge-graph output was never gitignored, so its
generated cache/graph files (75 tracked entries) were showing up as
dirty noise on every run. Add graphify-out/ to .gitignore and untrack
the existing files.
2026-08-06 08:46:00 +08:00
Breadway
6f1fe776ad CI: adopt the shared pinned-Arch-container build system
Some checks failed
check / check (push) Failing after 35s
Adds ci/build.sh + ci/bread-ecosystem.rev (pinned to bread-ecosystem
147cfbbf96ae4b171027defa1130d2caddb934b1), points the three release
workflows' build step through it, and adds check.yml as breadarr's first
lint/test gate on feature/fix branches. No ci/deps.txt: verified ort-sys's
build.rs has no system build-time requirements beyond rust/base-devel
(its download-binaries feature is pure-Rust ureq/lzma-rust2/hmac-sha256).
2026-08-05 19:15:47 +08:00
Breadway
3cbac5ffe9 CI: onboard breadarr onto the bakery distribution system
Adds bakery.toml (system_deps verified via ldd + Command::new grep against
breadarrd/src, not guessed) and dev-release.yml/rc-release.yml/release.yml
following bread-ecosystem's current single-trunk + RC-tag CI model. No
GitHub Release step anywhere — this repo has no GitHub mirror.
2026-08-05 19:04:22 +08:00
Breadway
7e1b7450cf Fix TUI Add flow landing new movies/shows flat in the library root
add_selected_search_result passed the raw configured default_root_folder
straight through as root_folder for both movies and series, with no
per-item subfolder computed. import_one/season_dir both expect
root_folder to already be the item's own folder, so new grabs landed
directly in the shared library root instead of their own folder,
invisible to Jellyfin's per-category libraries. Split default_root_folder
(series) from a new movies_root_folder, and have the TUI build the
"{Title} (Year)" subfolder itself before sending the add request.
2026-08-05 19:02:27 +08:00
Breadway
15b5b12a06 can't be bothered writing a commit message 2026-08-03 09:35:47 +08:00
Breadway
15cb0b6d81 Gitignore local repo-hygiene CLAUDE.md notes 2026-08-03 08:44:13 +08:00
Breadway
d110556a4d Add oversized-AV1 remediation and orphaned-file relink commands
- transcode_job gains is_anime/force_reencode so a job can be
  re-encoded even though it's already AV1 - needed for the rate-control
  bug that left ~476 files larger than their originals; the normal
  backfill query skips already-AV1 files, so this is find_oversized_
  av1_candidates plus a dedicated retranscode-oversized CLI command
- relink-orphaned-files: read-only reconciliation for episode_file rows
  that lost their association (DB-recovery incident) despite the real
  file still sitting where the importer would have put it
- qbit: delete finished torrents from qBittorrent after import instead
  of relocating them with set_location, since nothing is left seeding
  from the old save path after the move
2026-08-03 08:44:07 +08:00
Breadway
109b29ee55 Continue transcode/import feature work and fix bugs found by audit
These four files mix two things too interleaved to commit separately:
in-progress work on anime pipeline tuning (quality/preset/thread
config, per-pipeline parallelism caps), sampled decode verification,
and subtitle-codec-aware remuxing that predates this commit, plus a
set of correctness fixes from an independent Opus 5 review applied
directly on top of it:

- Attached-pic (cover art) streams could be probed as the real video
  stream when they came first, silently replacing the actual
  codec/height (ffprobe.rs)
- probe_failed rows (null codec/height) were enqueued for transcode
  and could never be claimed again, due to the unique partial index -
  should_enqueue now rejects them
- tokio::try_join! cancelled the sibling pipeline's in-flight
  spawn_blocking encode on the first Err instead of letting it finish
- Duplicate episode_file rows for the same media only had one swept on
  an upgrade swap, leaving stale rows/files behind
- File-swap and DB-write on transcode completion weren't atomic;
  wrapped in a transaction and made probe-refresh failure non-fatal
- In-progress transcode temp files were visible to library scans
  (video extension, no dotfile prefix) and could get imported mid-encode
- Remux temp files leaked on error paths; a Jellyfin refresh failure
  failed the whole import cycle instead of just logging
- insufficient_space could false-positive when the download and
  library dirs are on the same filesystem (rename is free there)
- Config validation for reference_height and min_size_reduction_pct,
  which previously could silently divide-by-zero or overflow deep
  inside an encode
2026-08-03 08:43:50 +08:00
Breadway
0f609aa4cc Fix bugs found by an Opus 5 audit: races, parsing, and CDATA gaps
- Atomic claim on review-queue approval, closing a double-approve race
  that could grab the same release twice (scheduler.rs)
- Constant-time comparison for the daemon API token, closing a timing
  side channel
- RSS items wrapped in CDATA (common for titles with '&') were
  silently dropped - only Event::Text was ever handled
- Reject malformed apibay info_hash values before building a magnet
  link that extract_btih can't parse back out
- Parse sizes with no space before the unit ("38.1GiB")
- Fix "Season N - NN" episode parsing and stop misreading a
  YYYY-MM-DD date as a bare episode range
- Query embeddings are no longer cached, fixing unbounded cache growth
  over the daemon's lifetime (only library-side candidates need caching)
2026-08-03 08:43:29 +08:00
Breadway
66d323b7f7 Fix real bugs found by an independent Opus review of the transcode feature
Requested and applied a full review of the AV1 transcode implementation.
Findings and fixes:

- Season-pack import bypassed upgrade_locked entirely: a season pack
  scored higher than a locally-transcoded file's stale release score
  would silently overwrite it. Fixed in import_season_pack_file (and
  mirrored into import_one for defense-in-depth) to check
  upgrade_locked before the score comparison, not after.
- The post-import enqueue hook only checked anime, silently skipping
  the HDR/2160p exclusions find_backlog_candidates applies to the
  backfill -- a freshly-grabbed HDR file would have gone through the
  unverified HDR-via-VAAPI path. Centralized all eligibility rules
  (anime, already-av1, HDR, height) into transcode::should_enqueue,
  used by both import_one and the newly-added season-pack enqueue
  hook (season packs previously had no transcode hook at all, despite
  being the actual headline use case -- 100GB+ season packs).
- find_backlog_candidates: a NULL-height row was included as a
  candidate but could never actually be claimed (claim_pending_jobs
  requires non-null height), permanently stuck pending. Fixed the
  WHERE clause.
- encode_and_verify now probes the real input file first and skips
  the encode entirely if it's already AV1 -- closes a narrow crash
  window where a rename-succeeded-but-DB-update-failed job would
  otherwise re-encode an already-transcoded file on retry.
- finalize_job's success path could leak a verified temp file and
  strand a job in 'running' forever if a filesystem operation failed
  partway through; now wrapped so any failure there cleans up and
  marks the job failed like every other error path.
- reset_orphaned_running_jobs now also sweeps each reset job's
  leftover temp file (job-id-scoped paths, so this lookup is
  unambiguous) rather than leaking them on a disk that's usually
  already tight on space.
- Added a unique index preventing two active jobs for the same file
  ever existing at once -- closes the remaining gap in last commit's
  concurrency fix (a daemon restart's reset could otherwise race a
  still-alive transcode-library backfill onto the same file).
- Made the VAAPI rate-control mode explicit (VBR) instead of
  driver-inferred.
- The daemon's transcode ticker awaited each cycle inline in the
  tokio::select! loop, blocking every other cycle (import, search,
  upgrade, reconcile) for the full multi-minute duration of an
  encode. Now spawns each cycle detached with a try-lock guard so
  overlapping ticks skip cleanly rather than stacking (the
  concurrency cap from the previous commit makes this safe).

Two items reviewed and deliberately left as documented, not fixed:
mp4 sources with mov_text subtitles will fail the encode cleanly (no
data loss, just no space saved) since Matroska can't hold that codec
via stream copy -- fixing this needs per-stream codec probing that
wasn't safe to add untested at this hour. Renaming an mp4 source's
extension to .mkv after transcoding is cosmetic (Jellyfin
content-sniffs fine) and left alone.
2026-07-25 00:38:51 +08:00
Breadway
576aad3bfe Fix a real production incident: unbounded concurrent transcode encodes
The daemon's steady-state transcode ticker and a manually-launched
transcode-library backfill process were both independently claiming
pending jobs from the same queue with no shared concurrency
awareness, each capping only at its own parallelism_max. Combined
with large files easily outlasting the 30s poll interval, this
stacked to 20+ simultaneous GPU encode/decode sessions and triggered
the kernel OOM killer on a shared 15GB host running a dozen+ other
containers (confirmed via dmesg; no services were lost, no library
files were touched — the original-file-safety design held under the
crash).

Fixes: claim_pending_jobs now treats its limit as a total concurrency
cap (subtracting already-running jobs, wrapped in a BEGIN IMMEDIATE
transaction so this is correct across concurrent processes touching
the same database, not just within one). Added reset_orphaned_running_jobs,
called on real daemon startup, so a crash never permanently strands
job slots in 'running'. Lowered the default parallelism_max from 4
to 2 given the observed real-world memory pressure.
2026-07-25 00:20:05 +08:00
Breadway
d533301880 Add native GPU-accelerated AV1 transcode capability
Post-grab async background swap, upgrade_locked flag to prevent the
upgrade loop from re-inflating a locally-transcoded file, and a
transcode-library CLI backfill for the existing catalog. Bitrate model
calibrated against Silicon Valley's real HEVC bitrate, scaled by
resolution and AV1's encoding efficiency. HDR/2160p and anime excluded
from this first pass. Jellyfin session polling throttles batch
encoding back to 1 stream during active playback.
2026-07-24 23:57:58 +08:00
Breadway
691fb39cbb Add grab_enabled kill switch for the passive RSS grab loop 2026-07-24 23:20:43 +08:00
Breadway
e179b9bf90 Apply quality gates before queuing for review, not just before auto-grab
The "reject sub-1080p when a better resolution exists in this batch" gate
(and every other quality gate) only ran on the confident auto-match path —
a low-confidence match skipped straight to queue_for_review before ever
reaching it. A human review decision is about whether this is genuinely
the right show, not a backdoor around quality standards; a title match
being ambiguous is no reason to let a worse-quality release through
untested. Verified live: several 720p season-pack releases were sitting in
the review queue for shows that also had 1080p+ alternatives in the same
search batch. Moved the gate check ahead of the review-queue branch so it
applies uniformly.
2026-07-21 21:52:40 +08:00
Breadway
2f0d8300ce Check eligibility before queuing a low-confidence match for review
process_item queued anything landing in the matcher's "needs review"
confidence band immediately, without ever checking whether the show/
season/episode/movie actually needed anything — that check only ran on
the auto-match path. A low-confidence match against an already-complete
show gained nothing from a human's yes/no, it was just noise that
reappeared every cycle the source kept re-listing the same old release
(verified live: fully-complete shows' season-pack re-releases piling up
in the review queue indefinitely). Reordered so the eligibility check
(movie/season-pack/episode) runs first regardless of confidence, and only
a genuinely-needed release ever reaches the queue-for-review decision.
2026-07-21 21:38:00 +08:00
Breadway
d1909f3083 Recognize season-pack titles beyond the literal "(S?N Complete)" shape
SEASON_PACK_RE only matched an explicit parenthesized "(S01 Complete)"
form, so any other real-world season-pack naming convention — bare
"S10.COMPLETE", spelled-out "Season 8 Complete", "[Season 4 Four
Complete]", or no "Complete" marker at all ("Game of Thrones - Season 8
S08 - 2019") — came back with season = None entirely, not just an
unresolved episode. That fed straight into review-queue approvals
returning "could not resolve which episode this release is" for releases
that were genuine, resolvable season packs. Broadened to a bare season
marker, safe here since this is only reached after SXXEXX_RE/SXX_DASH_EP_RE
have already failed to find a real episode number.
2026-07-21 21:12:12 +08:00
Breadway
6e7be67f0b Overhaul breadarr-tui UX: filter/sort, color, help overlay, cross-nav
Library tab gets a cycling filter (all/series/movies/missing/unmonitored)
and sort (title/missing/kind), color-coded kind tags and missing-count
severity, and monitor-toggle without opening detail. Keybinding hints move
out of cramped block titles into a context-aware status bar plus a `?`
help overlay, both driven by one shared keybinding table so they can't
drift apart. Episode status icons and review-queue confidence get the same
severity coloring. Stuck tab gains real selection/focus and can jump
straight to a show's Library detail (needed adding media_item_id to
StalledGrab's query — the one small backend touch in this pass). Adds a
manual refresh-now key.
2026-07-21 20:55:04 +08:00
Breadway
99604a7e55 Prefer season packs over individual episodes in search results
Search results were sorted purely by seeder count, so a season pack only
won out over a single-episode release by accident. Season packs already
clear every missing episode of that season in one grab and go through the
same seeder/quality gate as anything else (an unviable pack still gets
rejected there and iteration falls through to the next candidate) — so
sorting packs first, seeders as the tiebreaker, is a strict improvement
with no new risk. Shared by both the automated search cycle and the
manual-search candidate list.
2026-07-21 20:33:59 +08:00
Breadway
ba05a3cb0c Default TVDB "season 0" specials to unmonitored
Season 0 is TVDB's catch-all for specials/recaps/shorts and often a tie-in
movie already tracked as its own separate media_item (verified live:
Chainsaw Man's season 0 included a movie already present as its own entry).
Monitoring these by default means the library never actually "completes"
and inflates the missing-episode count with content nobody asked to
acquire as an episode. Regular seasons are unaffected.
2026-07-21 20:22:27 +08:00
Breadway
17d82b84c2 Fetch TVDB's English-translated episode names instead of original-language
Plain /episodes/default returns names in the show's original airing
language — for a lot of anime that's Japanese with no English name at all,
which is what was ending up embedded in generated filenames. TVDB carries
real crowd-sourced English translations at /episodes/default/eng (same
numbering/air-date fields, just a better name) — try that first and only
fall back to the original-language endpoint if a show has no English data.
2026-07-21 20:09:24 +08:00
Breadway
dcf9ee241c Don't embed CJK episode titles in generated filenames
TVDB has no English episode title at all for some shows (entire seasons of
otherwise-English-titled shows came back Japanese-only) — using it verbatim
put unreadable-to-most-tooling script into an otherwise Latin-script library.
Falls back to the no-title filename shape instead.
2026-07-21 19:45:30 +08:00
Breadway
60d01657eb Allow a trailing fansub revision tag in SxxExx parsing
"S01E01v2" (a fixed re-release of an episode) glued the version marker
directly onto the episode number with no separator, so the word-boundary
check after the digits never matched and the whole file fell through as
unparsed. Verified live: some shows had zero episode files linked because
every release happened to be a v2.
2026-07-21 19:32:13 +08:00
Breadway
54818f5f05 Fix qBittorrent WebUI API compat and season-folder placement in library scan
qBittorrent's newer WebUI API returns 204 (not 200 "Ok.") on login success,
and a JSON success/failure summary (not plain "Ok."/"Fails." text) from
torrents/add — both broke against the currently deployed version. Also had
scan_tv_root move already-tracked episode files into their Season NN
subfolder instead of just recording wherever they already sat on disk.
2026-07-21 19:17:42 +08:00
Breadway
9d8a59e3a8 Switch to tag-pinned bread-ecosystem deps; bump version to v0.1.0 2026-07-19 03:53:09 +08:00
54 changed files with 9928 additions and 1243 deletions

View file

@ -0,0 +1,25 @@
name: check
# Fast-fail lint/test on short-lived work branches, before it ever reaches
# main and triggers a dev-track release build.
on:
push:
branches: ['feature/**', 'fix/**', 'main']
pull_request:
jobs:
check:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: clippy
run: cd src && bash ci/build.sh cargo clippy --workspace --all-targets --locked -- -D warnings
- name: test
run: cd src && bash ci/build.sh cargo test --workspace --locked

View file

@ -0,0 +1,99 @@
name: dev release
# Publishes a dev-track build on every push to `main` (the trunk branch —
# there is no separate `dev` branch). See bread-ecosystem's
# docs/release-channels.md for the release-track policy this is part of.
on:
push:
branches: ['main']
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch main --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && bash ci/build.sh cargo build --release --locked
- name: compute dev version
run: |
set -euo pipefail
cd src
# Base the dev version off the latest published stable tag, not
# Cargo.toml — Cargo.toml can go stale relative to the last real
# release, which would make a dev build sort as OLDER than what's
# already installed and bakery would correctly refuse it.
LATEST_TAG="$(git ls-remote --tags --refs \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
if [ -n "${LATEST_TAG}" ]; then
CUR="${LATEST_TAG}"
else
# breadarr's own Cargo.toml is a virtual workspace manifest with
# no [workspace.package] version — breadarrd/Cargo.toml is the
# daemon crate's own version, used as the fallback instead.
CUR="$(grep -m1 '^version' breadarrd/Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
fi
IFS='.' read -r MA MI PA <<< "${CUR}"
SHA="$(git rev-parse --short HEAD)"
TS="$(date -u +%Y%m%d%H%M%S)"
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
- name: prepare artifacts
run: |
set -euo pipefail
PKG_DIR="/srv/breadway-dl/dev/breadarr/${VERSION}"
mkdir -p "${PKG_DIR}"
for bin in breadarrd breadarr-tui; do
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
strip "${PKG_DIR}/${bin}-x86_64"
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/${bin}-x86_64.sha256"
done
cp src/packaging/systemd/breadarrd.service "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadarr/latest"
- name: sign dev binaries
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
PKG_DIR="/srv/breadway-dl/dev/breadarr/${VERSION}"
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
for bin in breadarrd breadarr-tui; do
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/${bin}-x86_64" \
-x "${PKG_DIR}/${bin}-x86_64.minisig" </dev/null
echo "signed ${bin}-x86_64"
done
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping breadarrd-x86_64/breadarr-tui-x86_64 UNSIGNED"
fi
# No GitHub Release upload step here — breadarr has no GitHub mirror,
# and dev builds happen on every push and would spam a release per
# commit anyway, so dl.breadway.dev/dev/ is the only distribution
# point for this track.
- name: regenerate dev index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: dev
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
# mktemp: a fixed clone path races when multiple repos' dev/rc
# workflows run close together on the same self-hosted runner.
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -0,0 +1,79 @@
name: beta (rc) release
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag pushed
# to `main` — there is no separate `beta` branch; "freezing" is just
# pausing pushes to main while an RC gets tested. See bread-ecosystem's
# docs/release-channels.md for the release-track policy.
on:
push:
tags: ['v*']
jobs:
build:
if: ${{ contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && bash ci/build.sh cargo build --release --locked
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/beta/breadarr/${VERSION}"
mkdir -p "${PKG_DIR}"
for bin in breadarrd breadarr-tui; do
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
strip "${PKG_DIR}/${bin}-x86_64"
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/${bin}-x86_64.sha256"
done
cp src/packaging/systemd/breadarrd.service "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadarr/latest"
- name: sign beta binaries
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/beta/breadarr/${VERSION}"
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
for bin in breadarrd breadarr-tui; do
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/${bin}-x86_64" \
-x "${PKG_DIR}/${bin}-x86_64.minisig" </dev/null
echo "signed ${bin}-x86_64"
done
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping breadarrd-x86_64/breadarr-tui-x86_64 UNSIGNED"
fi
# No GitHub Release upload step here — breadarr has no GitHub mirror,
# and beta builds happen on every RC tag while the branch is frozen
# for testing, so dl.breadway.dev/beta/ is the only distribution
# point for this track.
- name: regenerate beta index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
TRACK: beta
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
# mktemp: a fixed clone path races when multiple repos' dev/rc
# workflows run close together on the same self-hosted runner.
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"

View file

@ -0,0 +1,79 @@
name: release
on:
push:
tags: ['v*']
jobs:
build:
if: ${{ !contains(github.ref_name, '-rc.') }}
runs-on: [self-hosted, hestia]
steps:
- name: checkout
run: |
set -euo pipefail
rm -rf src && mkdir src
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build
run: cd src && bash ci/build.sh cargo build --release --locked
- name: prepare artifacts
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/breadarr/${VERSION}"
mkdir -p "${PKG_DIR}"
for bin in breadarrd breadarr-tui; do
cp "src/target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
strip "${PKG_DIR}/${bin}-x86_64"
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/${bin}-x86_64.sha256"
done
cp src/packaging/systemd/breadarrd.service "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "/srv/breadway-dl/breadarr/latest"
# Signs with the shared bakery ecosystem signing key (same key that
# signs index.json). BAKERY_MINISIGN_SEC_KEY_PATH is a *path on this
# runner's disk* (hestia has persistent storage), not the key
# contents. Dormant (binaries ship unsigned) until that secret is
# provisioned for this repo.
- name: sign release binaries
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="/srv/breadway-dl/breadarr/${VERSION}"
if [ -n "${MINISIGN_SEC_KEY:-}" ]; then
for bin in breadarrd breadarr-tui; do
minisign -W -S -s "${MINISIGN_SEC_KEY}" -m "${PKG_DIR}/${bin}-x86_64" \
-x "${PKG_DIR}/${bin}-x86_64.minisig" </dev/null
echo "signed ${bin}-x86_64"
done
else
echo "::warning::BAKERY_MINISIGN_SEC_KEY_PATH not set — shipping breadarrd-x86_64/breadarr-tui-x86_64 UNSIGNED"
fi
- name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate stable index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the stable track)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
# mktemp: a fixed clone path races when multiple repos' release
# workflows run close together on the same self-hosted runner.
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
rm -rf "${ECOSYSTEM_CI_DIR}"
# No GitHub Release upload step — breadarr has no GitHub mirror,
# unlike most sibling bread-ecosystem repos. dl.breadway.dev is the
# only distribution point for this repo.

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
graphify-out/graph.json merge=graphify

11
.gitignore vendored
View file

@ -1,5 +1,16 @@
/target
/.ci-old-glibc
config.toml
breadarrd.toml
*.db
*.db-wal
*.db-shm
# Local hygiene notes (not for commit)
CLAUDE.md
# Leftover source tarballs (never commit these)
**/src.tar.xz
# graphify knowledge-graph output (local tool cache, not for commit)
graphify-out/

53
AGENTS.md Normal file
View file

@ -0,0 +1,53 @@
# AGENTS.md — Repo hygiene
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation.
This repo follows the branch/release workflow documented in `CONTRIBUTING.md`
— read and follow it for any git, branch, or release work here (the
single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work,
etc). Don't improvise a different workflow. The short version: there is one
long-lived branch, `main` — no `dev` or `beta` branch exists. `main`
auto-publishes a bakery dev-track build on every push. "Beta" and "stable"
are both just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a
beta-track build, push a plain `vX.Y.Z` tag to cut the signed stable
release. "Freezing" for stabilization means pausing pushes to `main`, not
moving a branch.
## Product identity
breadarr is a **homelab** product (single-daemon Sonarr/Radarr/Prowlarr
replacement: `breadarrd` + `breadarr-tui`). It is bakery-distributed
(`bakery install breadarr`), **not** baked into the BOS ISO, **not** a
GTK/desktop-shell app, and does **not** emit or subscribe to bread events.
bread's `KNOWN_APPS` list already reserves `"arr"`; do not start emitting
on that id unless an explicit integration pass asks for it.
Do not add a bos-settings panel, a breadway.dev website entry, or an ISO
bake. Those are out of scope for this product.
## Remotes
- `origin` — Forgejo (`git.breadway.dev`) only. Unlike most sibling
bread-ecosystem repos, this one has no GitHub mirror — don't assume a
`github` remote exists. Push `origin` only.
## CI
Workflows live under `.forgejo/workflows/` (not `.github/`):
- `check.yml` — clippy + test on push to `feature/**` and `fix/**`,
before a change reaches `main` and triggers a dev-track release.
- `dev-release.yml` — bakery dev-track build on every push to `main`.
- `rc-release.yml` — bakery beta-track build on any `vX.Y.Z-rc.N` tag.
- `release.yml` — signed bakery stable release on any other `v*` tag
(skips tags containing `-rc.`).
All four run on the self-hosted `hestia` runner. There is no GitHub
Release upload (no GitHub mirror). Do not claim this repo has no CI.
## Cleanup
- Delete feature/fix branches once merged. Check with `git branch --merged main`.
## Don't
- Don't embed credentials in remote URLs — SSH or a credential helper only.
- Don't emit bread events, add a settings panel, website entry, or ISO bake.
- Don't commit leftover source tarballs (`**/src.tar.xz`).

98
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,98 @@
# Contributing
`breadarr` — single-daemon Sonarr+Radarr+Prowlarr replacement for a
homelab host (`breadarrd` + `breadarr-tui`).
It is bakery-distributed (`bakery install breadarr`), **not** baked into
the BOS ISO, **not** a GTK/desktop-shell app, and does **not** emit bread
events. bread's `KNOWN_APPS` list reserves `"arr"`; this repo does not
emit on that id.
Part of the bread ecosystem; this repo follows the same single-trunk
branch/release workflow as every other bakery-channel product.
## Branches
There is one long-lived branch: **`main`**. All day-to-day work lands here.
Every push to `main` automatically builds and publishes a **dev-track**
build (see Tracks below) — a real install you can test before cutting
anything more formal.
New work — features and bug fixes alike — goes on a short-lived branch:
```
feature/<short-name>
fix/<issue-number-or-short-name>
```
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
branches get deleted on merge — they never accumulate the kind of drift a
second long-lived branch does.
## The release cycle
There's no separate `beta` or release branch — "stable" and "beta" are both
just **tags** on `main`, not branches that need to be kept in sync:
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
auto-publishes a dev build — install it with `bakery track set dev` and
`bakery update --all`, then fix anything broken with another push.
2. When you want to stabilize before a real release, tag a release
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1`. That
tag alone triggers a beta-track build — "freezing" is just pausing
pushes to `main` while you test it, not a branch operation. Cut
`-rc.2`, `-rc.3`, etc. for further fixes.
3. Once an RC has gone without issues, tag the real release:
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
signed stable release build.
This repo has no GitHub mirror. Push tags and branches to `origin`
(Forgejo) only.
## Tracks, from a user's perspective
```
bakery track show # what you're currently on (defaults to stable)
bakery track set dev # or beta, or stable
bakery update --all # pull the latest build on your current track
```
| Track | What it is | Published from |
|--------|-----------|-----------------|
| `stable` | The last tagged release | a `vX.Y.Z` tag |
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
| `dev` | Bleeding edge | `main`, on every push |
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
latest published stable tag, so they always sort as newer than what you
have installed — no manual version bumping needed. Beta versions are just
the RC tag itself (already valid semver, already sorts below the real
release it's a candidate for).
## Local development
```sh
cargo build --release --workspace --locked
cargo test --workspace --locked
cargo clippy --workspace --all-targets --locked -- -D warnings
```
Host tools the daemon shells out to: `mkvtoolnix-cli` (`mkvmerge`) and
`ffmpeg`/`ffprobe`. See `bakery.toml` and the README setup section.
## CI
- `check.yml` — clippy + test on push to `feature/**`, `fix/**`, and
`main`, and on pull requests.
- `dev-release.yml` — triggered on push to `main`.
- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push.
- `release.yml` — triggered on any other `v*` tag push, cuts the actual
stable release.
All CI runs on a self-hosted runner. See
[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md)
for the full policy, including how a new product gets wired onto these tracks.
## Questions
Open an issue on this repo's Forgejo tracker.

23
Cargo.lock generated
View file

@ -177,7 +177,8 @@ dependencies = [
[[package]]
name = "bread-onnx"
version = "0.3.0"
version = "0.7.2"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
dependencies = [
"anyhow",
"bread-utils",
@ -191,7 +192,8 @@ dependencies = [
[[package]]
name = "bread-utils"
version = "0.3.0"
version = "0.7.2"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
dependencies = [
"dirs",
"serde",
@ -200,7 +202,7 @@ dependencies = [
[[package]]
name = "breadarr-shared"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"bread-utils",
@ -212,7 +214,7 @@ dependencies = [
[[package]]
name = "breadarr-tui"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"breadarr-shared",
@ -226,7 +228,7 @@ dependencies = [
[[package]]
name = "breadarrd"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"async-trait",
@ -236,6 +238,7 @@ dependencies = [
"chrono",
"fastrand",
"nix",
"openssl-sys",
"ort",
"quick-xml",
"regex",
@ -1765,6 +1768,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-src"
version = "300.6.1+3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.117"
@ -1773,6 +1785,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]

View file

@ -5,6 +5,10 @@ A single-daemon Rust replacement for the Sonarr + Radarr + Prowlarr stack — on
- **`breadarrd`** — the daemon. Watches sources, matches releases to your library, scores and grabs candidates, imports completed downloads, probes them for real ground-truth quality, and refreshes Jellyfin. Runs as a `systemd --user` service.
- **`breadarr-tui`** — a terminal client (ratatui) that talks to `breadarrd`'s local HTTP API. No web UI, on purpose.
## Distribution
breadarr is a **homelab** product. It is distributed through bakery (`bakery install breadarr`) and is **not** baked into the BOS ISO. It is not a GTK/desktop-shell app, has no bos-settings panel, and does not subscribe to or emit bread events. bread's `KNOWN_APPS` list reserves the `"arr"` app id for a possible future integration; this repo does not emit on that id.
## Why this exists
Sonarr + Radarr + Prowlarr is three separate services, three databases, three web UIs, and a lot of setup surface for one workflow: watch for releases, pick the best one, download it, file it correctly, tell Jellyfin. breadarr collapses that into a single daemon, with a few specific problems solved directly rather than configured around:
@ -22,13 +26,17 @@ Sonarr + Radarr + Prowlarr is three separate services, three databases, three we
- **nyaa.si** (RSS) — anime TV, polled continuously. Full-auto, no request budget concerns (it's a plain RSS feed).
- **apibay.org** (a community JSON API mirror of The Pirate Bay's search) — the primary search-driven source for general TV and movies. Unlike 1337x this is a genuine machine-readable API, needs no HTML scraping, and its search actually ranks by relevance rather than pure seeder count, which matters a lot for titles made of common words.
- **1337x** (scraped HTML, via community mirrors) — secondary search-driven source, tried after TPB. 1337x's main domain is Cloudflare-protected and has a ban history, so requests are round-robined across mirrors with automatic cooldown/backoff on failures, jittered between searches, and rate-limit responses are honored explicitly.
- **nyaa.si search mode** — anime movies specifically route here instead of TPB/1337x, since nyaa is the safe, official-RSS-interface target and gives materially better results for anime content.
- **torrents-csv** (JSON DHT-dump search) — first general-content fallback when TPB fails or returns nothing. Same hash-to-magnet grab as TPB; covers movies and TV.
- **YTS** (JSON `list_movies` API via `yts.lt``yts.mx` no longer resolves) — movie-only fallback after TPB. Each hit expands into one candidate per quality so the scorer sees 720p/1080p/2160p separately.
- **1337x** (scraped HTML, via community mirrors) — last-resort general-content fallback after the JSON sources. 1337x's main domain is Cloudflare-protected and has a ban history, so requests are round-robined across mirrors with automatic cooldown/backoff on failures, jittered between searches, and rate-limit responses are honored explicitly.
- **nyaa.si search mode** — anime movies specifically route here instead of the general-content chain, since nyaa is the safe, official-RSS-interface target and gives materially better results for anime content.
All three search-driven sources share one per-cycle request budget (default: 5 searches per 30-minute cycle, TPB tried first, then 1337x, then nyaa search) — kept conservative since 1337x has a ban history and the goal is steady backlog clearing, not maximum throughput. A whole cycle failing outright backs off the *next cycle's* interval (1h → 2h → 4h, capped), on top of each source's own per-mirror cooldowns.
Search-driven sources share one per-cycle request budget (default: 5 searches per 30-minute cycle). General content tries TPB, then YTS (movies) / torrents-csv, then 1337x; anime movies go to nyaa search. Kept conservative since 1337x has a ban history and the goal is steady backlog clearing, not maximum throughput. A whole cycle failing outright backs off the *next cycle's* interval (1h → 2h → 4h, capped), on top of each source's own per-mirror cooldowns.
## Setup
Install via bakery (`bakery install breadarr`) on a homelab host, or build from this repo. breadarr is not on the BOS ISO.
1. Copy `config.example.toml` to `~/.config/breadarr/breadarrd.toml` and fill in:
- qBittorrent WebUI URL/credentials
- Jellyfin URL + API key
@ -46,24 +54,27 @@ All three search-driven sources share one per-cycle request budget (default: 5 s
## Using the TUI
`Tab` cycles Library / History / Review Queue / Add Show / Stuck / Calendar / Health. `j`/`k` or arrow keys navigate, `Enter` opens detail or runs a search, `Esc` backs out.
`Tab` / `Shift+Tab` cycle Library / History / Review / Add / Stuck / Calendar / Health / Profiles; `1``8` jump straight to a tab. `j`/`k` or arrows move, `g`/`G` jump to first/last, `PgUp`/`PgDn` page, `Enter` opens detail or runs a search, `Esc` backs out. `?` is the full key list.
- **Review Queue**`a` approves and `r` rejects a pending low-confidence title match. Check this periodically, especially early on.
- **Library** (with an item's detail open) — `s` triggers an immediate search-now pass for that item's backlog; `m`/`e`/`S` toggle monitored on the show/episode/season respectively; `x` (confirm with a second `x`) removes the item from tracking without touching files on disk; `d` (confirm with a second `d`) deletes a bad imported file from disk and clears its tracking, freeing the episode/movie to be re-grabbed on the next cycle — the redownload path for a file that turned out to be wrong or broken; `c` fetches the manual release picker for the selected episode/movie, `Enter` grabs the highlighted candidate, `Esc` cancels.
- **Stuck** — surfaces grabs that look stalled (no download progress advancing, or missing from qBittorrent) before the daemon's own auto-fail timers would catch them.
- **Calendar** — upcoming/recently-aired episodes in a roughly week-either-side window.
- **Health** — the library-health report (see below) rendered as a tab instead of curled by hand.
- **Review**`a` approves and `r` rejects a pending low-confidence title match. The tab title badges the pending count. Check this periodically, especially early on.
- **Library**`/` incrementally filters the list by title; `f`/`o` cycle kind-filter and sort. With an item's detail open: `s` triggers an immediate search-now pass for that item's backlog; `n` jumps to the next missing monitored episode; `m`/`e`/`S` toggle monitored on the show/episode/season respectively; `x` (confirm with a second `x`) removes the item from tracking without touching files on disk; `d` (confirm with a second `d`) deletes a bad imported file from disk and clears its tracking, freeing the episode/movie to be re-grabbed on the next cycle — the redownload path for a file that turned out to be wrong or broken; `c` fetches the manual release picker for the selected episode/movie, `Enter` grabs the highlighted candidate, `Esc` cancels. Movies (no episode list) show a summary pane with the same keys.
- **Stuck** — surfaces grabs that look stalled (no download progress advancing, or missing from qBittorrent) before the daemon's own auto-fail timers would catch them. `Enter` jumps to that show.
- **Calendar** — upcoming/recently-aired episodes in a roughly week-either-side window. `Enter` opens the matching episode.
- **Health** — daemon cycle outcomes plus the library-health report (see below), scrollable.
- **Profiles** — quality-profile weight axes; open a profile and edit a weight in place.
## Operational notes
- `curl http://127.0.0.1:7879/health` reports daemon status plus the last grab/import/search/upgrade cycle outcome — the cheapest way to confirm the automation loop is actually alive.
- `curl http://127.0.0.1:7879/health` is liveness-only (is the process up). Cycle outcomes live at `/health/detail` (requires `Authorization: Bearer <token>` when `daemon.api_token` is set).
- `curl http://127.0.0.1:7879/library/health` (or the TUI's Health tab) reports corrupt files, under-1080p files, missing-English-audio files, non-English-default-audio files, missing-subtitle files, duplicate-file groups, and library-wide summary stats (codec breakdown, resolution distribution, subtitle coverage %) — all derived from `ffprobe` data, not release-title claims.
- The API is plaintext HTTP — there is no TLS. Binding `listen_addr` off loopback requires a non-empty `daemon.api_token`.
- If `daemon.api_token` is set, every route except `/health` requires `Authorization: Bearer <token>`.
- Library normalization is a manual, explicit action (not run automatically against your files): `breadarrd debug-scan-tv <root-dir>` / `breadarrd debug-scan-movies <root-dir>`.
- `breadarrd remux-backlog` sweeps the whole library for files with a non-English default audio track and applies the same track-promotion fix used automatically on fresh imports — a one-time (or occasional) pass against files that predate the fix, or were imported before breadarr started tracking them.
- `breadarrd probe-library` backfills `ffprobe` data for the whole library in one run (the running daemon does this incrementally, a bounded batch per hour, so it doesn't stall the grab/import/search cycles — this command is for getting it all done immediately instead).
- `breadarrd verify-library` runs the expensive full-decode corruption check (`ffmpeg -xerror`, actually decoding every frame) against every file whose cheap header probe succeeded but hasn't been decode-verified yet. This is opt-in and can take minutes per file, so — unlike `probe-library` — it's never run automatically by any ticker; run it by hand (or on a cron) whenever you want a real, not-just-header-parseable confirmation the library is intact.
- Other `debug-*` subcommands are diagnostics for exercising one piece of the pipeline directly — run `breadarrd <name>` with no args to see its usage. Currently: `debug-qbit-add`, `debug-qbit-list`, `debug-jellyfin-refresh`, `debug-tvdb-add`, `debug-tvdb-search`, `debug-anime-map-refresh`, `debug-match-title`, `debug-grab-cycle`, `debug-import-cycle`, `debug-1337x-search`, `debug-scan-tv`, `debug-scan-movies`, `debug-search-show` (a manually-triggered, unthrottled search pass over one already-tracked title's whole backlog), `debug-reconcile-report` (dry-run of the disk-reconciliation pass — safe to run against a freshly-restored or otherwise suspect database before trusting the hourly ticker with it unattended).
- `breadarrd transcode-library` backfills AV1 transcode over existing library files (requires `[transcode] enabled = true`). `breadarrd retranscode-oversized` re-encodes already-AV1 files that landed larger than the current ceiling. `breadarrd relink-orphaned-files` reattaches episode files on disk that tracking lost.
- The embedding model (~90MB) downloads automatically on first run.
- The database is backed up (with its WAL/SHM sidecars) to `<db-dir>/backups/` on every daemon startup, keeping the 5 most recent copies — there's no migration framework, so this stands in for the pre-upgrade backup Sonarr/Radarr do on every schema change.
- An hourly background pass reconciles tracked `episode_file` paths against what's actually on disk: a file renamed or transcoded in place (e.g. Tdarr converting codec/container) gets its path repaired rather than being wrongly treated as deleted; a file genuinely gone gets cleared from tracking so it becomes searchable again. A circuit breaker refuses to touch anything if an anomalous fraction of the library looks missing at once (the classic false signal of an offline mount), rather than mass-clearing tracking for files that are actually still there.
@ -71,8 +82,8 @@ All three search-driven sources share one per-cycle request budget (default: 5 s
## Known limitations
- No subtitle generation yet — a Whisper-based reimplementation of an existing external tool is planned (see [Roadmap](#roadmap)), with the schema (`episode_file.subtitle_status`) already reserved for it.
- No Sonarr/Radarr API compatibility shim, so tools expecting that API (e.g. Overseerr/Seerr) can't integrate directly yet — also planned, with an empty `compat/` module reserved so it isn't a retrofit later.
- **Quality-scoring weights are hardcoded, not configurable.** Every axis — resolution tier, source tier, codec tier, bit depth, HDR, repack/proper bonus, and so on — is a fixed constant in `QualityProfile::default_tv`/`default_movie` (`scoring/profile.rs`). The `quality_profile` table and its `weights` JSON column already exist in the schema and are already parsed on load; `scoring/profile.rs` just doesn't read them yet, so every install currently gets the same scoring behavior regardless of what tradeoffs you'd actually prefer (e.g. valuing efficient codecs over raw resolution, or not caring about HDR at all). This is the single biggest gap between "works great for the specific setup it shipped with" and "works well for a range of libraries and preferences" — see [Roadmap](#roadmap).
- No Sonarr/Radarr API compatibility shim yet, so tools expecting that API (e.g. Overseerr/Seerr) can't integrate directly — still a future idea (see [Roadmap](#roadmap)).
- Quality-profile weights are loaded and editable (Profiles tab). `min_seeders` and the group denylist are still hardcoded defaults, not user-configurable.
- The title-matching embedding model (all-MiniLM-L6-v2) doesn't actually discriminate between unrelated romanized-Japanese titles — it clusters any two romaji strings as "similar foreign text" regardless of content (verified live: several unrelated anime auto-matched at >0.85 confidence against a handful of "attractor" shows with zero real relation). A token-overlap gate (`MIN_TOKEN_OVERLAP` in `matcher/mod.rs`) blocks this from both auto-matching *and* reaching the review queue, but the underlying model limitation is a workaround, not a fix.
- The search loop's per-cycle budget is intentionally conservative; raising it trades faster backlog clearing for more request volume against 1337x specifically, which has a ban history. The upgrade-search loop shares the same underlying sources and the same conservatism applies.
@ -80,10 +91,6 @@ All three search-driven sources share one per-cycle request budget (default: 5 s
Everything above is shipped and running. This section is the honest, disciplined version of "what would this become if taken all the way" — grounded in subsystems that already exist, not a wishlist. Nothing here contradicts the project's two foundational design choices (a small hardcoded set of sources, not a general indexer-plugin architecture; a TUI, not a web UI) — anything that would require reconsidering either is flagged as such.
### Near-term (extends existing, working subsystems)
- **Configurable quality-profile weights.** The most user-facing gap in the project today. Different people reasonably want different tradeoffs — some care most about resolution, some would rather have a smaller, more-efficient-codec file, some don't watch anything HDR and don't want it influencing scores at all — and right now every install gets identical hardcoded behavior. The `quality_profile.weights` column already exists in the schema and is already parsed as JSON on load; the gap is purely that `scoring/profile.rs` ignores it in favor of the `default_tv`/`default_movie` constants. Wiring the column through the scoring engine and exposing it as an editable profile in the TUI turns an already-half-built feature into a real one, without changing the scoring model's shape — same axes, same gates, just user-owned numbers instead of fixed ones.
### Medium-term (closes the loop on data already being collected)
- **Tdarr hand-off.** `media_file_probe` already has `video_codec`, `container_bitrate`, `video_bitrate`, and `hdr` per file — everything needed to generate a "these files are still H.264/high-bitrate and are good AV1-transcode candidates" list without re-scanning the filesystem. Whether that's a report the user acts on manually or a direct trigger into the existing Tdarr pipeline is a judgment call for whenever this is built — but the *data* side of this is already sitting in the database, unused.
@ -94,7 +101,7 @@ Everything above is shipped and running. This section is the honest, disciplined
### Longer-term (larger, still-plausible extensions)
- **Learning from review-queue decisions.** Every approve/reject in the review queue is already a labeled example of "was this match actually correct." Logging outcomes (not just acting on them) and periodically comparing auto-tuned per-library confidence thresholds against the fixed `AUTO_MATCH_CONFIDENCE`/`MIN_TOKEN_OVERLAP` constants in `matcher/mod.rs` is a plausible way to let the matcher get measurably better over time for *this* library's actual title vocabulary — anime is the library segment most exposed to the token-overlap workaround today, so it's also the segment most likely to benefit first. This is a genuine judgment call (a wrong auto-tune silently degrades match quality with no review-queue visibility into it happening), not a slam dunk — worth prototyping as an offline analysis of logged decisions before it's ever allowed to write back to live thresholds.
- **Seerr/Overseerr compatibility shim, then real request fulfillment.** The `compat/` extension point has been reserved from the start for a Sonarr/Radarr v3-API-compatible shim, since that's what Seerr's client code expects. Once that shim exists, the natural next step isn't just protocol compatibility — it's tracking *fulfillment* end-to-end (a request maps to a `media_item`, which maps to `release`/`event_history` rows that already record exactly when and how it was grabbed and imported), giving a requester real status instead of Seerr's own best-effort polling.
- **Seerr/Overseerr compatibility shim, then real request fulfillment.** A Sonarr/Radarr v3-API-compatible shim is the integration path Seerr's client code expects. Once that shim exists, the natural next step isn't just protocol compatibility — it's tracking *fulfillment* end-to-end (a request maps to a `media_item`, which maps to `release`/`event_history` rows that already record exactly when and how it was grabbed and imported), giving a requester real status instead of Seerr's own best-effort polling.
- **Whisper subtitle generation.** Fully speced already: reimplement the existing external Python/Whisper tool's exact behavior — "Full Subtitles" (everything transcribed) and "Foreign Parts" (segments where a translate-pass diverges from the transcribe-pass, via text-similarity diff at a 0.80 threshold) SRT tracks, muxed with `mkvmerge` — but gated and on-demand (triggered post-import only when a file lacks subtitles and has non-English/fallback audio), unlike the external tool's full-library batch sweep. `episode_file.subtitle_status` is already wired through the schema for this. The open technical questions are a Rust equivalent of Python's `difflib.SequenceMatcher` for the similarity diff, and which Whisper-in-Rust GPU backend story actually works well across the range of consumer GPU hardware this runs on in practice (CUDA/Metal/Vulkan-centric crates like `whisper-rs` have no strong story for e.g. Intel Arc or other OpenVINO-friendly hardware).
### Deliberately not on this list

45
bakery.toml Normal file
View file

@ -0,0 +1,45 @@
name = "breadarr"
description = "Single-daemon Sonarr+Radarr+Prowlarr replacement — release watching, matching, grabbing, importing, and a terminal UI, no web UI"
binaries = ["breadarrd", "breadarr-tui"]
# mkvtoolnix-cli / ffmpeg: `mkvmerge` and `ffprobe`/`ffmpeg` are shelled out
# to directly via Command::new() (breadarrd/src/importer/mkv.rs for the
# audio-track remux fix; breadarrd/src/importer/ffprobe.rs and
# breadarrd/src/transcode/mod.rs for media probing/corruption verification/
# transcode-backlog work) rather than linked, so neither shows up in
# `ldd target/release/breadarrd` -- confirmed by grepping breadarrd/src for
# Command::new("mkvmerge"|"ffprobe"|"ffmpeg") and cross-checking against the
# README's own host-requirements instructions. The ONNX embedding model (the
# `ort` crate, used for fuzzy title matching) needs no system_deps entry:
# `ldd target/release/breadarrd` lists no libonnxruntime.so, and `strings`
# on the built binary is full of onnxruntime's own C++ symbol names --
# confirming the `ort` crate's default strategy statically bundled its own
# onnxruntime build directly into breadarrd rather than dynamically linking
# the system onnxruntime-cpu package. TLS is vendored: breadarrd builds
# OpenSSL via openssl-sys's `vendored` feature, so the shipped binary does
# not depend on the host's libssl/libcrypto. libstdc++/libgcc/glibc/zlib/
# zstd/brotli also appear in `ldd` but are omitted here the same way
# breadcast's bakery.toml omits them: glibc/gcc-libs are unavoidable
# base-system dependencies, and zlib/zstd/brotli are themselves
# transitive deps of curl/pacman already guaranteed present on any real
# Arch install.
system_deps = [
"mkvtoolnix-cli",
"ffmpeg",
]
optional_system_deps = []
bread_deps = []
license_file = "LICENSE"
[[service]]
unit = "breadarrd.service"
enable = true
[config]
dir = "~/.config/breadarr"
example = "config.example.toml"
[install]
post_install = [
"loginctl enable-linger \"$USER\" || true",
"systemctl --user is-active --quiet breadarrd || systemctl --user start breadarrd",
]

View file

@ -1,6 +1,6 @@
[package]
name = "breadarr-shared"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
[dependencies]
@ -9,5 +9,4 @@ anyhow.workspace = true
toml.workspace = true
reqwest.workspace = true
chrono.workspace = true
# TODO(owner): switch to tag-pinned git dependency once bread-utils is merged and tagged, matching the bread-theme pattern
bread-utils = { path = "../../bread-ecosystem/bread-utils" }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }

View file

@ -7,6 +7,7 @@ use crate::dto::{
SearchResult, StuckReport, UpdateQualityProfileWeightsRequest, WeightsDto,
};
#[derive(Clone)]
pub struct DaemonClient {
base_url: String,
client: reqwest::Client,
@ -16,8 +17,9 @@ impl DaemonClient {
/// `api_token` mirrors `config.daemon.api_token` server-side — empty
/// means "no auth configured," so this stays a no-op default header
/// rather than sending a meaningless empty bearer token on every
/// request.
pub fn new(base_url: impl Into<String>, api_token: &str) -> Self {
/// request. A non-empty token that cannot be encoded as an HTTP header
/// is an error (not a silent unauthenticated client).
pub fn new(base_url: impl Into<String>, api_token: &str) -> Result<Self> {
let mut builder = reqwest::Client::builder()
// A default so no request can hang the TUI forever with zero
// feedback if the daemon is unreachable or a connection stalls.
@ -26,20 +28,23 @@ impl DaemonClient {
// which overrides this.
.timeout(std::time::Duration::from_secs(30));
if !api_token.is_empty() {
let token = api_token.replace(['\r', '\n'], "");
anyhow::ensure!(
!token.is_empty(),
"daemon.api_token is non-empty but contains only CR/LF"
);
let value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
.context("daemon.api_token is not a valid HTTP header value")?;
let mut headers = reqwest::header::HeaderMap::new();
if let Ok(value) =
reqwest::header::HeaderValue::from_str(&format!("Bearer {api_token}"))
{
headers.insert(reqwest::header::AUTHORIZATION, value);
}
builder = builder.default_headers(headers);
}
Self {
Ok(Self {
base_url: base_url.into(),
client: builder
.build()
.expect("reqwest client builder should not fail with only a timeout/headers set"),
}
})
}
pub async fn health(&self) -> Result<bool> {
@ -59,7 +64,7 @@ impl DaemonClient {
pub async fn health_detail(&self) -> Result<HealthDetail> {
let resp = self
.client
.get(format!("{}/health", self.base_url))
.get(format!("{}/health/detail", self.base_url))
.timeout(std::time::Duration::from_secs(2))
.send()
.await

View file

@ -23,21 +23,35 @@ pub struct Config {
pub sources: SourcesConfig,
#[serde(default)]
pub notifications: NotificationsConfig,
#[serde(default)]
pub transcode: TranscodeConfig,
}
/// Where the TUI's "add show" flow places new series by default. Sonarr/
/// Radarr let you pick a root folder per add; a single configured default
/// is a reasonable v1 simplification — per-add picking can follow later.
/// Where the TUI's "add" flow places new series/movies by default. Sonarr/
/// Radarr let you pick a root folder per add; a single configured default per
/// kind is a reasonable v1 simplification — per-add picking can follow later.
/// Series and movies need *separate* defaults (not one shared value) because
/// they live under different category roots on disk (e.g. `TV Shows/` vs
/// `Movies/`) — a real production bug had both kinds falling back to one
/// bare library root with no per-item subfolder, landing new grabs directly
/// in the library root instead of inside their own show/movie folder,
/// invisible to Jellyfin's per-category libraries.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct LibraryConfig {
#[serde(default = "default_root_folder")]
pub default_root_folder: String,
#[serde(default = "default_movies_root_folder")]
pub movies_root_folder: String,
}
fn default_root_folder() -> String {
"~/breadarr-library".to_string()
}
fn default_movies_root_folder() -> String {
"~/breadarr-library/Movies".to_string()
}
#[derive(Debug, Clone, Deserialize)]
pub struct SourcesConfig {
/// 1337x's main domain bans IPs at the Cloudflare WAF level after
@ -55,6 +69,12 @@ pub struct SourcesConfig {
pub nyaa_rss_url: String,
#[serde(default = "default_grab_poll_interval_secs")]
pub grab_poll_interval_secs: u64,
/// Human kill switch for the passive RSS-feed grab loop (nyaa, anime
/// only) — same reasoning as `search_enabled`/`upgrade_enabled`, kept
/// as its own flag since this loop watches a different source and can
/// need to be paused independently of the search-driven ones.
#[serde(default = "default_grab_enabled")]
pub grab_enabled: bool,
#[serde(default = "default_import_poll_interval_secs")]
pub import_poll_interval_secs: u64,
#[serde(default = "default_search_poll_interval_secs")]
@ -77,6 +97,16 @@ pub struct SourcesConfig {
/// titles made of common words.
#[serde(default = "default_tpb_api_url")]
pub tpb_api_url: String,
/// torrents.csv DHT-dump search — first fallback when TPB fails or
/// returns nothing. Same hash-to-magnet grab shape as TPB, covers
/// movies and TV.
#[serde(default = "default_torrents_csv_url")]
pub torrents_csv_url: String,
/// YTS v2 list_movies JSON — movie-only fallback after TPB / torrents-csv.
/// `yts.mx` does not resolve; this default is a working host as of
/// 2026-08-16.
#[serde(default = "default_yts_api_url")]
pub yts_api_url: String,
/// Human kill switch for the upgrade-search loop, same reasoning as
/// `search_enabled` — off by default would mean nothing ever improves,
/// but a user who's happy with their current files (or wants to save
@ -108,11 +138,14 @@ impl Default for SourcesConfig {
torrent_1337x_mirrors: default_1337x_mirrors(),
nyaa_rss_url: default_nyaa_rss_url(),
grab_poll_interval_secs: default_grab_poll_interval_secs(),
grab_enabled: default_grab_enabled(),
import_poll_interval_secs: default_import_poll_interval_secs(),
search_poll_interval_secs: default_search_poll_interval_secs(),
search_budget_per_cycle: default_search_budget_per_cycle(),
search_enabled: default_search_enabled(),
tpb_api_url: default_tpb_api_url(),
torrents_csv_url: default_torrents_csv_url(),
yts_api_url: default_yts_api_url(),
upgrade_enabled: default_upgrade_enabled(),
upgrade_poll_interval_secs: default_upgrade_poll_interval_secs(),
upgrade_budget_per_cycle: default_upgrade_budget_per_cycle(),
@ -125,6 +158,14 @@ fn default_tpb_api_url() -> String {
"https://apibay.org/q.php".to_string()
}
fn default_torrents_csv_url() -> String {
"https://torrents-csv.com/service/search".to_string()
}
fn default_yts_api_url() -> String {
"https://yts.lt/api/v2/list_movies.json".to_string()
}
fn default_upgrade_enabled() -> bool {
true
}
@ -161,12 +202,17 @@ fn default_grab_poll_interval_secs() -> u64 {
300
}
fn default_grab_enabled() -> bool {
true
}
fn default_import_poll_interval_secs() -> u64 {
60
}
fn default_1337x_mirrors() -> Vec<String> {
[
"https://www.1337xx.to",
"https://13377x.info",
"https://13377x.email",
"https://1337xto.info",
@ -197,12 +243,10 @@ pub struct DaemonConfig {
#[serde(default = "default_model_dir")]
pub model_dir: String,
/// Bearer token required on every API request when non-empty. Empty
/// (the default) means auth is off entirely — `listen_addr` defaults to
/// loopback-only, so a fresh install isn't suddenly locked out of its
/// own unconfigured daemon. This matters once `listen_addr` is changed
/// to bind non-loopback (e.g. so a TUI on a different host on the same
/// tailnet can reach it) — without a token, that's unauthenticated
/// add/delete/search access to anyone who can reach the port.
/// (the default) means auth is off entirely — allowed only when
/// `listen_addr` is loopback. A non-loopback bind with an empty token
/// is rejected at load. When set, the token must be at least 16
/// characters so a length-oracle of short guesses is useless.
#[serde(default)]
pub api_token: String,
}
@ -266,6 +310,283 @@ pub struct JellyfinConfig {
pub api_key: String,
}
/// GPU-accelerated AV1 transcode: re-encodes freshly-grabbed and existing
/// library files down to a space-reasonable size instead of keeping
/// whatever the source release happened to be (REMUX, huge season packs,
/// etc). `enabled` defaults false — this needs a manual calibration pass
/// against real content on the target GPU before it's safe to run
/// unattended against a whole library.
#[derive(Debug, Clone, Deserialize)]
pub struct TranscodeConfig {
#[serde(default)]
pub enabled: bool,
/// Tight on purpose — the actual pace is bottlenecked by encode time
/// (minutes per file), not this interval; a short poll just means a
/// freshly-completed job's slot gets refilled promptly instead of
/// sitting idle for the rest of a longer interval.
#[serde(default = "default_transcode_poll_interval_secs")]
pub poll_interval_secs: u64,
#[serde(default = "default_vaapi_device")]
pub vaapi_device: String,
/// Applies to both pipelines identically when Jellyfin reports an
/// active transcoding session — deliberately not split per-pipeline;
/// when someone's actually watching something, both the GPU (which
/// they're using) and CPU (contending for the same box) should back off.
#[serde(default = "default_parallelism_min")]
pub parallelism_min: usize,
/// The total concurrent **live-action** (`av1_vaapi`, GPU-bound) stream
/// budget — not just "the batch job's cap", but the real ceiling the
/// GPU can sustain at all, batch work and live Jellyfin viewers
/// combined. `transcode::live_action_parallelism_for` subtracts however
/// many Jellyfin transcode sessions are actually active from this
/// number to get the batch job's actual parallelism each cycle, so
/// real viewers get exactly the headroom they need rather than the
/// batch job dropping to a flat minimum regardless of how many people
/// are watching. Deliberately separate from `parallelism_max_anime` —
/// the two pipelines contend for genuinely different hardware (GPU
/// encode engine vs CPU threads), so raising one shouldn't raise the
/// other. Empirically calibrated on Hestia's Arc A380: per-stream
/// throughput stays above 1.5x realtime through 7 concurrent streams,
/// crosses below it at 8 (aggregate throughput itself plateaus around
/// ~12x realtime from 5-6 streams on, i.e. the GPU's actual saturation
/// point) — see [[breadarr-av1-transcode]] for the full scaling-test
/// numbers.
#[serde(default = "default_parallelism_max")]
pub parallelism_max: usize,
/// Ramp-up ceiling for concurrent **anime** (`libsvtav1`, CPU-bound)
/// encode streams — capped separately from `parallelism_max` (see its
/// doc comment) precisely because a single shared cap would let "raise
/// GPU parallelism" accidentally also raise anime concurrency, and
/// anime jobs are CPU-thread-hungry (`anime_svtav1_max_threads` each)
/// in a way live-action jobs aren't. Kept at the original
/// conservative shared-cap default (2) since concurrent-anime-job
/// memory/CPU behavior at higher counts hasn't been load-tested the
/// way the live-action GPU path has.
#[serde(default = "default_parallelism_max_anime")]
pub parallelism_max_anime: usize,
/// The "looks fine, no complaints" calibration reference: a real
/// bitrate (Mbps, in kbps here) from content already in the library at
/// `reference_height` that the user is happy with. New AV1 encodes are
/// targeted relative to this, scaled by resolution and AV1's encoding
/// efficiency, rather than picking a bitrate out of thin air.
#[serde(default = "default_reference_bitrate_kbps")]
pub reference_bitrate_kbps: u32,
#[serde(default = "default_reference_height")]
pub reference_height: u32,
/// AV1 reaches equivalent perceived quality to HEVC at a meaningfully
/// lower bitrate — this factor is applied on top of the resolution
/// scaling so the AV1 target isn't just a like-for-like copy of the
/// HEVC/H264 reference bitrate. Conservative (not maximally aggressive)
/// on purpose: erring toward "still clearly smaller" over "as small as
/// AV1 could theoretically go" leaves margin against visible artifacts.
#[serde(default = "default_av1_efficiency_factor")]
pub av1_efficiency_factor: f32,
/// HDR10/Dolby Vision metadata preservation through the GPU encoder
/// hasn't been verified yet — excluded from both the backfill and the
/// post-grab path until that's specifically checked on a few samples.
#[serde(default = "default_exclude_hdr")]
pub exclude_hdr: bool,
/// Excludes the 2160p tier from the first pass for the same reason as
/// `exclude_hdr` (most current 4K content in this library is HDR
/// anyway) — revisit once HDR handling is confirmed safe.
#[serde(default = "default_exclude_min_height")]
pub exclude_min_height: u32,
/// `global_quality` for the live-action `av1_vaapi` `QVBR` encode — the
/// actual quality driver now that rate control is quality-based rather
/// than a flat bitrate target (see `run_ffmpeg_encode_live_action`).
/// `reference_bitrate_kbps`/`av1_efficiency_factor` still compute a
/// `-b:v`/`-maxrate`/`-bufsize` ceiling alongside this, so a source that's
/// already unusually efficient doesn't get inflated up toward the
/// ceiling — QVBR only spends up to it on content that actually needs it.
#[serde(default = "default_quality_live_action")]
pub quality_live_action: u32,
/// Root folder path prefixes (exact string prefix match against
/// `episode_file.path`) routed to the anime encode pipeline
/// (`run_ffmpeg_encode_anime`) instead of the live-action one, regardless
/// of `anime_mapping`/`anime_tmdb_movie` metadata coverage — path is a
/// more reliable signal than TVDB/TMDB anime-list membership, which has
/// real gaps (e.g. Avatar: The Last Airbender and some Dragon Ball movies
/// were missing from those tables and slipped through as "not anime").
/// Empty by default (a no-op) — set per-deployment to match how the
/// library is actually organized.
#[serde(default)]
pub anime_root_folders: Vec<String>,
/// CRF for the anime pipeline's software `libsvtav1` encode (0-63, lower
/// = higher quality/larger). No hardware AV1 10-bit encode entrypoint
/// exists on Hestia's Arc A380 (`vainfo` only lists `AV1Profile0`,
/// 8-bit) — anime needs true 10-bit output to avoid banding in the flat
/// gradients the art style is full of, so this pipeline trades GPU
/// offload for CPU-based `libsvtav1` specifically to get it.
#[serde(default = "default_quality_anime")]
pub quality_anime: u32,
/// `libsvtav1` preset (0-13, lower = slower/better compression AND more
/// memory-hungry — SVT-AV1's lookahead/reference buffering scales with
/// preset, not just thread count). Raised from an initial guess of 6 to
/// 10 after a real validation run hit a genuine kernel OOM: preset 6 on
/// a single 1080p anime episode grew to 9.3GB resident memory on
/// Hestia's 6-core/12-thread box. This runs as unattended background
/// work, so trading some compression efficiency for a much smaller,
/// safer memory footprint is the right call — see
/// `anime_svtav1_max_threads` for the other half of that fix.
#[serde(default = "default_anime_svtav1_preset")]
pub anime_svtav1_preset: u32,
/// Passed to `libsvtav1` as `-svtav1-params lp=N` — caps how many
/// worker threads it uses, independent of preset. More parallel workers
/// means more concurrently-buffered frames, so this is the other lever
/// (alongside `anime_svtav1_preset`) for bounding the encoder's peak
/// memory to something predictable regardless of how many cores the
/// host actually has. Default is conservative (well under a typical
/// modern host's core count) after the same OOM incident that raised
/// the preset default.
#[serde(default = "default_anime_svtav1_max_threads")]
pub anime_svtav1_max_threads: u32,
/// Hard floor on what counts as "worth keeping": a transcode whose
/// output isn't at least this fraction smaller than the original is
/// discarded (job marked `skipped`, original left untouched) rather than
/// swapped in. Exists because quality-driven rate control can still
/// occasionally produce an output that's the same size as or larger than
/// an already-efficient source — this is the invariant that makes that
/// safe regardless of how good the rate-control tuning is, after a real
/// incident where flat-bitrate VBR targeting silently produced files
/// *larger* than the original on the majority of a backfill.
#[serde(default = "default_min_size_reduction_pct")]
pub min_size_reduction_pct: f64,
/// Skip attempting a transcode at all (no GPU/CPU time spent) when the
/// source's current bitrate is already at or below this fraction of the
/// resolution-scaled ceiling (`target_bitrate_kbps`) — a strong signal
/// there's little room left to save, so it's not worth the encode time
/// to find out (the `min_size_reduction_pct` check above would reject
/// most of these anyway, this just avoids paying for that finding).
#[serde(default = "default_skip_below_ceiling_ratio")]
pub skip_below_ceiling_ratio: f64,
/// Size (seconds) of each of the three start/middle/end windows
/// `ffprobe::verify_decodable_sampled` actually decodes, instead of the
/// whole file — a full decode verification was measured as the actual
/// CPU bottleneck of a transcode cycle (400%+ CPU per job, dwarfing the
/// GPU encode time), not the encode itself. Bounds verification cost to
/// a small constant regardless of source length.
#[serde(default = "default_verify_sample_secs")]
pub verify_sample_secs: f64,
}
impl Default for TranscodeConfig {
fn default() -> Self {
Self {
enabled: false,
poll_interval_secs: default_transcode_poll_interval_secs(),
vaapi_device: default_vaapi_device(),
parallelism_min: default_parallelism_min(),
parallelism_max: default_parallelism_max(),
parallelism_max_anime: default_parallelism_max_anime(),
reference_bitrate_kbps: default_reference_bitrate_kbps(),
reference_height: default_reference_height(),
av1_efficiency_factor: default_av1_efficiency_factor(),
exclude_hdr: default_exclude_hdr(),
exclude_min_height: default_exclude_min_height(),
quality_live_action: default_quality_live_action(),
anime_root_folders: Vec::new(),
quality_anime: default_quality_anime(),
anime_svtav1_preset: default_anime_svtav1_preset(),
anime_svtav1_max_threads: default_anime_svtav1_max_threads(),
min_size_reduction_pct: default_min_size_reduction_pct(),
skip_below_ceiling_ratio: default_skip_below_ceiling_ratio(),
verify_sample_secs: default_verify_sample_secs(),
}
}
}
fn default_transcode_poll_interval_secs() -> u64 {
60
}
fn default_vaapi_device() -> String {
"/dev/dri/renderD128".to_string()
}
fn default_parallelism_min() -> usize {
1
}
fn default_parallelism_max() -> usize {
// The measured total GPU budget (not "batch cap plus a static
// reservation") — `live_action_parallelism_for` dynamically subtracts
// real Jellyfin transcode sessions from this each cycle. Raised from
// an initial conservative guess of 2 after an actual concurrent-stream
// scaling test on Hestia's Arc A380 (see `parallelism_max`'s doc
// comment): per-stream throughput stays above 1.5x realtime through 7
// total concurrent streams, crossing below at 8.
7
}
fn default_parallelism_max_anime() -> usize {
// Kept at the original conservative shared-cap value — unlike
// `parallelism_max`, this hasn't been load-tested at higher counts.
// A real incident already showed a *single* uncapped anime job could
// hit 9.3GB resident memory; multiple concurrent anime jobs (each its
// own `anime_svtav1_max_threads`-sized thread pool) multiply both CPU
// thread contention and memory pressure in a way the GPU path doesn't
// have to worry about. Raise deliberately, per-deployment, only after
// watching `free -h` and CPU load under real concurrent-anime load.
2
}
fn default_reference_bitrate_kbps() -> u32 {
5320
}
fn default_reference_height() -> u32 {
1080
}
fn default_av1_efficiency_factor() -> f32 {
0.7
}
fn default_exclude_hdr() -> bool {
true
}
fn default_exclude_min_height() -> u32 {
2000
}
// Starting point for `av1_vaapi`'s `-global_quality` under `QVBR`, needs the
// same real-hardware calibration pass as the bitrate reference did — this is
// a reasonable guess (roughly x264/x265 "visually near-lossless" territory
// on the encoder's internal QP-like scale), not a measured value.
fn default_quality_live_action() -> u32 {
26
}
// SVT-AV1 CRF starting point for the anime pipeline — slightly lower
// (higher quality) than the live-action guess above since flat-color/
// gradient-heavy anime content shows banding more readily than live-action
// grain/texture does at the same nominal quality level. Also unvalidated
// against real hardware/content yet.
fn default_quality_anime() -> u32 {
24
}
fn default_anime_svtav1_preset() -> u32 {
10
}
fn default_anime_svtav1_max_threads() -> u32 {
4
}
fn default_min_size_reduction_pct() -> f64 {
0.10
}
fn default_skip_below_ceiling_ratio() -> f64 {
0.5
}
fn default_verify_sample_secs() -> f64 {
20.0
}
/// TVDB v4 API key, exchanged for a short-lived JWT at request time.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct TvdbConfig {
@ -289,9 +610,100 @@ impl Config {
let raw = fs::read_to_string(&path)?;
let cfg: Config = toml::from_str(&raw)?;
cfg.validate()?;
Ok(cfg)
}
/// Rejects values that are individually syntactically valid TOML but
/// make the daemon unsafe or the transcode pipeline's math nonsensical
/// — a typo here would otherwise only surface much later, or bind an
/// unauthenticated API on a reachable address.
fn validate(&self) -> Result<()> {
// `target_bitrate_kbps` divides by `reference_height` (via
// `reference_pixels`); zero makes that ratio `f64::INFINITY`, which
// saturates to `u32::MAX` on the cast back to `u32` — then
// `run_ffmpeg_encode_live_action`'s `bitrate_ceiling_kbps * 3`
// overflows that `u32::MAX` (panics in a debug build, silently
// wraps to a nonsense small value in release).
anyhow::ensure!(
self.transcode.reference_height > 0,
"transcode.reference_height must be greater than 0"
);
// `is_beneficial` computes `original_bytes * (1.0 -
// min_size_reduction_pct)` as the max allowed output size — a
// negative value here would raise that ceiling *above* the
// original, letting a transcode that actually grew the file still
// count as "beneficial." That's the exact failure mode
// `min_size_reduction_pct` exists to prevent (see its own doc
// comment: a real incident where flat-bitrate VBR silently produced
// files larger than the original).
anyhow::ensure!(
(0.0..=1.0).contains(&self.transcode.min_size_reduction_pct),
"transcode.min_size_reduction_pct must be between 0.0 and 1.0"
);
anyhow::ensure!(
(0.0..=1.0).contains(&self.transcode.skip_below_ceiling_ratio),
"transcode.skip_below_ceiling_ratio must be between 0.0 and 1.0"
);
anyhow::ensure!(
self.transcode.parallelism_min >= 1,
"transcode.parallelism_min must be at least 1"
);
anyhow::ensure!(
self.transcode.parallelism_max >= self.transcode.parallelism_min,
"transcode.parallelism_max must be >= parallelism_min"
);
anyhow::ensure!(
self.transcode.parallelism_max_anime >= 1,
"transcode.parallelism_max_anime must be at least 1"
);
anyhow::ensure!(
(0..=63).contains(&self.transcode.quality_anime),
"transcode.quality_anime must be between 0 and 63"
);
anyhow::ensure!(
(0..=13).contains(&self.transcode.anime_svtav1_preset),
"transcode.anime_svtav1_preset must be between 0 and 13"
);
anyhow::ensure!(
self.transcode.verify_sample_secs.is_finite()
&& self.transcode.verify_sample_secs > 0.0,
"transcode.verify_sample_secs must be greater than 0"
);
const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace", "off"];
anyhow::ensure!(
LOG_LEVELS
.iter()
.any(|l| self.daemon.log_level.eq_ignore_ascii_case(l)),
"daemon.log_level must be one of error, warn, info, debug, trace, off"
);
if !self.daemon.api_token.is_empty() {
anyhow::ensure!(
self.daemon.api_token.len() >= 16,
"daemon.api_token must be at least 16 characters when set"
);
}
if self.daemon.api_token.is_empty() && !is_loopback_listen_addr(&self.daemon.listen_addr) {
anyhow::bail!(
"daemon.api_token is required when listen_addr ({}) is not loopback",
self.daemon.listen_addr
);
}
Ok(())
}
/// `true` when `daemon.listen_addr` is loopback (`127.0.0.1`, `::1`,
/// `localhost`). Used at startup to decide whether an empty token is
/// merely a local-process warning or already refused by `validate`.
pub fn listen_is_loopback(&self) -> bool {
is_loopback_listen_addr(&self.daemon.listen_addr)
}
/// Expand a `~/...` path the same way configured library roots are.
pub fn expand_path(input: &str) -> PathBuf {
expand_home(input)
}
pub fn db_path(&self) -> PathBuf {
expand_home(&self.daemon.db_path)
}
@ -303,6 +715,10 @@ impl Config {
pub fn default_root_folder(&self) -> PathBuf {
expand_home(&self.library.default_root_folder)
}
pub fn movies_root_folder(&self) -> PathBuf {
expand_home(&self.library.movies_root_folder)
}
}
fn config_path() -> PathBuf {
@ -328,6 +744,29 @@ fn expand_home(input: &str) -> PathBuf {
PathBuf::from(input)
}
/// Loopback hosts we allow to bind without an API token: IPv4/IPv6
/// loopback socket addresses, plus the `localhost` hostname form.
fn is_loopback_listen_addr(listen_addr: &str) -> bool {
if let Ok(addr) = listen_addr.parse::<std::net::SocketAddr>() {
return addr.ip().is_loopback();
}
let host = if let Some(rest) = listen_addr.strip_prefix('[') {
rest.split(']').next().unwrap_or(rest)
} else if let Some((h, port)) = listen_addr.rsplit_once(':') {
if port.parse::<u16>().is_ok() && !h.contains(':') {
h
} else {
listen_addr
}
} else {
listen_addr
};
host.eq_ignore_ascii_case("localhost")
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
}
fn default_log_level() -> String {
"info".to_string()
}
@ -378,4 +817,132 @@ mod tests {
assert_eq!(cfg.daemon.log_level, "debug");
assert_eq!(cfg.daemon.listen_addr, "127.0.0.1:7879");
}
#[test]
fn default_config_passes_validation() {
Config::default().validate().unwrap();
}
// Regression test for a real gap found in review: `reference_height =
// 0` makes `target_bitrate_kbps`'s resolution-scaling ratio divide by
// zero, which eventually overflows a `u32` multiplication deep inside
// the live-action encoder's maxrate calculation — a config typo that
// used to only surface as a panic/garbage value in the middle of an
// encode, not at startup.
#[test]
fn rejects_a_zero_reference_height() {
let cfg: Config = toml::from_str("[transcode]\nreference_height = 0\n").unwrap();
assert!(cfg.validate().is_err());
}
// Regression test for a real gap found in review: a negative
// `min_size_reduction_pct` would let `is_beneficial` accept an encode
// that actually *grew* the file — the exact invariant this field exists
// to guarantee against.
#[test]
fn rejects_a_negative_min_size_reduction_pct() {
let cfg: Config = toml::from_str("[transcode]\nmin_size_reduction_pct = -0.1\n").unwrap();
assert!(cfg.validate().is_err());
}
#[test]
fn rejects_a_min_size_reduction_pct_above_one() {
let cfg: Config = toml::from_str("[transcode]\nmin_size_reduction_pct = 1.5\n").unwrap();
assert!(cfg.validate().is_err());
}
#[test]
fn default_loopback_with_empty_token_is_ok() {
Config::default().validate().unwrap();
assert!(is_loopback_listen_addr("127.0.0.1:7879"));
assert!(is_loopback_listen_addr("localhost:7879"));
assert!(is_loopback_listen_addr("[::1]:7879"));
assert!(is_loopback_listen_addr("::1"));
}
#[test]
fn non_loopback_without_token_is_rejected() {
let mut cfg = Config::default();
cfg.daemon.listen_addr = "0.0.0.0:7879".into();
assert!(cfg.validate().is_err());
}
#[test]
fn non_loopback_with_token_is_ok() {
let mut cfg = Config::default();
cfg.daemon.listen_addr = "0.0.0.0:7879".into();
cfg.daemon.api_token = "a-token-16-chars+".into();
cfg.validate().unwrap();
}
#[test]
fn short_api_token_is_rejected() {
let mut cfg = Config::default();
cfg.daemon.api_token = "tooshort".into();
assert!(cfg.validate().is_err());
}
#[test]
fn rejects_invalid_log_level() {
let mut cfg = Config::default();
cfg.daemon.log_level = "loud".into();
assert!(cfg.validate().is_err());
}
#[test]
fn accepts_off_log_level() {
let mut cfg = Config::default();
cfg.daemon.log_level = "off".into();
cfg.validate().unwrap();
}
#[test]
fn rejects_skip_below_ceiling_ratio_out_of_range() {
let mut cfg = Config::default();
cfg.transcode.skip_below_ceiling_ratio = 1.5;
assert!(cfg.validate().is_err());
}
#[test]
fn rejects_zero_parallelism_min() {
let mut cfg = Config::default();
cfg.transcode.parallelism_min = 0;
assert!(cfg.validate().is_err());
}
#[test]
fn rejects_parallelism_max_below_min() {
let mut cfg = Config::default();
cfg.transcode.parallelism_min = 3;
cfg.transcode.parallelism_max = 2;
assert!(cfg.validate().is_err());
}
#[test]
fn rejects_zero_parallelism_max_anime() {
let mut cfg = Config::default();
cfg.transcode.parallelism_max_anime = 0;
assert!(cfg.validate().is_err());
}
#[test]
fn rejects_quality_anime_out_of_range() {
let mut cfg = Config::default();
cfg.transcode.quality_anime = 64;
assert!(cfg.validate().is_err());
}
#[test]
fn rejects_anime_svtav1_preset_out_of_range() {
let mut cfg = Config::default();
cfg.transcode.anime_svtav1_preset = 14;
assert!(cfg.validate().is_err());
}
#[test]
fn rejects_zero_verify_sample_secs() {
let mut cfg = Config::default();
cfg.transcode.verify_sample_secs = 0.0;
assert!(cfg.validate().is_err());
}
}

View file

@ -56,6 +56,7 @@ pub struct ReviewQueueEntry {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StalledGrab {
pub release_id: i64,
pub media_item_id: i64,
pub media_title: String,
pub raw_title: String,
pub grabbed_at: String,
@ -135,6 +136,12 @@ pub struct CalendarEntry {
pub has_file: bool,
}
/// Cheap liveness payload for unauthenticated `GET /health`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthStatus {
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthDetail {
pub status: String,
@ -142,6 +149,7 @@ pub struct HealthDetail {
pub last_import_cycle: Option<CycleInfo>,
pub last_search_cycle: Option<CycleInfo>,
pub last_upgrade_cycle: Option<CycleInfo>,
pub last_transcode_cycle: Option<CycleInfo>,
pub search_halted: bool,
}

View file

@ -1,6 +1,6 @@
[package]
name = "breadarr-tui"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
[dependencies]

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@ use std::time::Duration;
use anyhow::Result;
use breadarr_shared::{Config, DaemonClient};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
@ -14,14 +14,17 @@ use crossterm::terminal::{
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use app::{App, Focus, Tab};
use app::{App, Focus, LibraryRoots, StuckSection, Tab};
#[tokio::main]
async fn main() -> Result<()> {
let config = Config::load()?;
let base_url = format!("http://{}", config.daemon.listen_addr);
let client = DaemonClient::new(base_url, &config.daemon.api_token);
let root_folder = config.default_root_folder().to_string_lossy().to_string();
let client = DaemonClient::new(base_url, &config.daemon.api_token)?;
let roots = LibraryRoots {
series: config.default_root_folder().to_string_lossy().to_string(),
movies: config.movies_root_folder().to_string_lossy().to_string(),
};
enable_raw_mode()?;
let mut stdout = io::stdout();
@ -30,7 +33,7 @@ async fn main() -> Result<()> {
let mut terminal = Terminal::new(backend)?;
let mut app = App::new(client);
let result = run(&mut terminal, &mut app, &root_folder).await;
let result = run(&mut terminal, &mut app, &roots).await;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
@ -42,13 +45,17 @@ async fn main() -> Result<()> {
async fn run(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
root_folder: &str,
roots: &LibraryRoots,
) -> Result<()> {
let mut last_refresh = tokio::time::Instant::now() - Duration::from_secs(10);
loop {
if last_refresh.elapsed() >= Duration::from_secs(3) {
app.poll_background().await;
app.expire_status();
if last_refresh.elapsed() >= Duration::from_secs(3) || app.force_refresh {
app.refresh_active_tab().await;
app.force_refresh = false;
last_refresh = tokio::time::Instant::now();
}
@ -59,7 +66,7 @@ async fn run(
if key.kind != KeyEventKind::Press {
continue;
}
handle_key(app, key.code, root_folder).await;
handle_key(app, key, roots).await;
if app.should_quit {
return Ok(());
}
@ -68,7 +75,9 @@ async fn run(
}
}
async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) {
async fn handle_key(app: &mut App, key: KeyEvent, roots: &LibraryRoots) {
let code = key.code;
// Typing into the add-show search box takes priority over global keys.
if matches!(app.tab, Tab::Add) && matches!(app.focus, Focus::AddSearchInput) {
match code {
@ -77,8 +86,31 @@ async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) {
KeyCode::Backspace => {
app.add_query.pop();
}
KeyCode::Esc => app.should_quit = true,
KeyCode::Tab => cycle_tab(app),
KeyCode::Esc => {
app.add_query.clear();
}
KeyCode::Tab => switch_tab(app, tab_offset(app.tab, 1)),
KeyCode::BackTab => switch_tab(app, tab_offset(app.tab, -1)),
_ => {}
}
return;
}
// Incremental Library title filter — same input-mode isolation as Add.
if matches!(app.tab, Tab::Library) && matches!(app.focus, Focus::LibraryFilterInput) {
match code {
KeyCode::Enter => app.confirm_library_filter(),
KeyCode::Char(c) => {
app.library_query.push(c);
app.recompute_library_view();
}
KeyCode::Backspace => {
app.library_query.pop();
app.recompute_library_view();
}
KeyCode::Esc => app.clear_library_filter(),
KeyCode::Tab => switch_tab(app, tab_offset(app.tab, 1)),
KeyCode::BackTab => switch_tab(app, tab_offset(app.tab, -1)),
_ => {}
}
return;
@ -101,6 +133,16 @@ async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) {
return;
}
// Help overlay intercepts everything while open, same
// priority-over-global-keys idiom as the two input modes above.
if app.help_visible {
match code {
KeyCode::Char('?') | KeyCode::Esc => app.help_visible = false,
_ => {}
}
return;
}
// Any key other than a second `x`/`d` clears a pending delete
// confirmation — the confirmation must be the very next keypress, not
// just "any keypress before the user gets distracted."
@ -113,12 +155,25 @@ async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) {
match code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Tab => cycle_tab(app),
KeyCode::Tab => switch_tab(app, tab_offset(app.tab, 1)),
KeyCode::BackTab => switch_tab(app, tab_offset(app.tab, -1)),
KeyCode::Char(c) if c.is_ascii_digit() => {
if let Some(n) = c.to_digit(10) {
if (1..=Tab::ALL.len() as u32).contains(&n) {
switch_tab(app, Tab::ALL[(n as usize) - 1]);
}
}
}
KeyCode::Char('j') | KeyCode::Down => app.move_selection(1),
KeyCode::Char('k') | KeyCode::Up => app.move_selection(-1),
KeyCode::Char('g') => app.select_edge(false),
KeyCode::Char('G') => app.select_edge(true),
KeyCode::PageDown => app.move_selection(10),
KeyCode::PageUp => app.move_selection(-10),
KeyCode::Esc => match app.tab {
Tab::Library if matches!(app.focus, Focus::Candidates) => app.close_candidates(),
Tab::Library if app.detail.is_some() => app.close_detail(),
Tab::Library if !app.library_query.is_empty() => app.clear_library_filter(),
Tab::Add => app.focus = Focus::AddSearchInput,
Tab::Profiles if app.profile_detail.is_some() => app.close_profile_detail(),
_ => {}
@ -129,21 +184,52 @@ async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) {
}
Tab::Library => app.open_detail().await,
Tab::Add => match app.focus {
Focus::AddResults => app.add_selected_search_result(root_folder).await,
Focus::AddResults => app.add_selected_search_result(roots).await,
_ => app.focus = Focus::AddSearchInput,
},
Tab::Profiles if app.profile_detail.is_some() => {
app.start_editing_selected_weight();
}
Tab::Profiles => app.open_profile_detail(),
Tab::Stuck => app.jump_to_stuck_target().await,
Tab::Calendar => app.jump_to_calendar_entry().await,
_ => {}
},
KeyCode::Left | KeyCode::Right if matches!(app.tab, Tab::Stuck) => {
app.stuck_focus = match app.stuck_focus {
StuckSection::Stalled => StuckSection::Maxed,
StuckSection::Maxed => StuckSection::Stalled,
};
}
KeyCode::Char('?') => app.help_visible = true,
KeyCode::Char('R') => app.force_refresh = true,
KeyCode::Char('f') if matches!(app.tab, Tab::Library) && app.detail.is_none() => {
app.library_filter = app.library_filter.next();
app.recompute_library_view();
}
KeyCode::Char('o') if matches!(app.tab, Tab::Library) && app.detail.is_none() => {
app.library_sort = app.library_sort.next();
app.recompute_library_view();
}
KeyCode::Char('m') if matches!(app.tab, Tab::Library) && app.detail.is_none() => {
app.toggle_monitor_list_selected().await;
}
KeyCode::Char('a') if matches!(app.tab, Tab::Review) => {
app.approve_selected_review().await;
}
KeyCode::Char('r') if matches!(app.tab, Tab::Review) => {
app.reject_selected_review().await;
}
KeyCode::Char('/') if matches!(app.tab, Tab::Library) && app.detail.is_none() => {
app.start_library_filter();
}
KeyCode::Char('n')
if matches!(app.tab, Tab::Library)
&& app.detail.is_some()
&& !matches!(app.focus, Focus::Candidates) =>
{
app.select_next_missing_episode();
}
KeyCode::Char('s') if matches!(app.tab, Tab::Library) && app.detail.is_some() => {
app.search_now_selected().await;
}
@ -173,9 +259,20 @@ async fn handle_key(app: &mut App, code: KeyCode, root_folder: &str) {
}
}
fn cycle_tab(app: &mut App) {
let idx = Tab::ALL.iter().position(|t| *t == app.tab).unwrap_or(0);
app.tab = Tab::ALL[(idx + 1) % Tab::ALL.len()];
fn tab_offset(current: Tab, delta: i32) -> Tab {
let idx = Tab::ALL.iter().position(|t| *t == current).unwrap_or(0) as i32;
let len = Tab::ALL.len() as i32;
Tab::ALL[(idx + delta).rem_euclid(len) as usize]
}
fn switch_tab(app: &mut App, tab: Tab) {
if app.tab == tab {
return;
}
if matches!(app.focus, Focus::Candidates) {
app.close_candidates();
}
app.tab = tab;
app.detail = None;
app.profile_detail = None;
app.profile_weight_state.select(None);

View file

@ -1,10 +1,21 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Tabs};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs};
use ratatui::Frame;
use crate::app::{App, Focus, Tab};
use crate::app::{App, Focus, StuckSection, Tab};
use breadarr_shared::dto::CycleInfo;
// Shared color palette — kept to these meanings so a color never has to be
// second-guessed at a glance:
// Green healthy / 0 missing / high confidence / not stuck
// Yellow warning / low missing / mid confidence / at backoff ceiling
// Red critical / high missing / low confidence / past ceiling
// DarkGray unmonitored / muted / not currently relevant
// Blue TV kind tag (categorical, not severity)
// Magenta Movie kind tag (categorical, not severity)
// Cyan focus/interactive accent (tab highlight, active input, focused section) — never severity
pub fn draw(frame: &mut Frame, app: &App) {
let chunks = Layout::default()
@ -30,10 +41,154 @@ pub fn draw(frame: &mut Frame, app: &App) {
}
draw_status(frame, chunks[2], app);
if app.help_visible {
draw_help_overlay(frame, frame.area(), app);
}
}
/// Keybindings relevant to the current tab/focus/detail state, in the order
/// they should be shown — the single source of truth shared by the status
/// bar (which shows a short prefix) and the help overlay (which shows all of
/// it plus `GLOBAL_KEYS`), so the two can't drift apart.
fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> {
match app.tab {
Tab::Library if matches!(app.focus, Focus::Candidates) => {
vec![("Enter", "grab"), ("Esc", "cancel")]
}
Tab::Library if app.detail.is_some() => vec![
("Esc", "back"),
("s", "search now"),
("n", "next missing"),
("m", "monitor show"),
("e", "monitor episode"),
("S", "monitor season"),
("x", "delete show"),
("d", "delete file"),
("c", "pick release"),
],
Tab::Library if matches!(app.focus, Focus::LibraryFilterInput) => {
vec![("Enter", "keep filter"), ("Esc", "clear filter")]
}
Tab::Library => vec![
("Enter", "open detail"),
("/", "filter title"),
("f", "cycle filter"),
("o", "cycle sort"),
("m", "monitor toggle"),
],
Tab::Review => vec![("a", "approve"), ("r", "reject")],
Tab::Add => match app.focus {
Focus::AddSearchInput => vec![("Enter", "search"), ("Esc", "clear")],
_ => vec![("Enter", "add"), ("Esc", "back to search")],
},
Tab::Profiles if app.profile_detail.is_some() => {
vec![("Enter", "edit weight"), ("Esc", "back")]
}
Tab::Profiles => vec![("Enter", "open profile")],
Tab::Stuck => vec![("Left/Right", "switch section"), ("Enter", "jump to show")],
Tab::Calendar => vec![("Enter", "jump to show")],
_ => vec![],
}
}
const GLOBAL_KEYS: &[(&str, &str)] = &[
("Tab/S-Tab", "switch tab"),
("1-8", "jump tab"),
("j/k", "move"),
("g/G", "first/last"),
("PgUp/PgDn", "page"),
("?", "help"),
("R", "refresh now"),
("q", "quit"),
];
/// Centers a `width` x `height` rect inside `area` — standard ratatui idiom
/// for a popup/overlay.
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect {
x: area.x + (area.width.saturating_sub(width)) / 2,
y: area.y + (area.height.saturating_sub(height)) / 2,
width,
height,
}
}
fn draw_help_overlay(frame: &mut Frame, area: Rect, app: &App) {
let mut lines: Vec<Line> = context_keybindings(app)
.into_iter()
.map(|(key, desc)| {
Line::from(vec![
Span::styled(format!("{key:12}"), Style::default().fg(Color::Cyan)),
Span::raw(desc),
])
})
.collect();
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Global",
Style::default().add_modifier(Modifier::BOLD),
)));
lines.extend(GLOBAL_KEYS.iter().map(|(key, desc)| {
Line::from(vec![
Span::styled(format!("{key:12}"), Style::default().fg(Color::Cyan)),
Span::raw(*desc),
])
}));
let popup = centered_rect(52, lines.len() as u16 + 2, area);
frame.render_widget(Clear, popup);
let paragraph = Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title("Help — ? or Esc to close"),
);
frame.render_widget(paragraph, popup);
}
fn tab_label(tab: Tab, app: &App) -> String {
match tab {
Tab::Library => {
let missing: i64 = app.media_items.iter().map(|m| m.missing_count).sum();
if missing > 0 {
format!("Library ({missing})")
} else {
tab.title().to_string()
}
}
Tab::Review => {
let n = if matches!(app.tab, Tab::Review) {
app.review_items.len()
} else {
app.review_count
};
if n > 0 {
format!("Review ({n})")
} else {
tab.title().to_string()
}
}
Tab::Stuck => {
let n = app.stuck.as_ref().map_or(app.stuck_count, |r| {
r.stalled_grabs.len() + r.maxed_out_search_targets.len()
});
if n > 0 {
format!("Stuck ({n})")
} else {
tab.title().to_string()
}
}
_ => tab.title().to_string(),
}
}
fn draw_tabs(frame: &mut Frame, area: Rect, app: &App) {
let titles: Vec<Line> = Tab::ALL.iter().map(|t| Line::from(t.title())).collect();
let titles: Vec<Line> = Tab::ALL
.iter()
.map(|t| Line::from(tab_label(*t, app)))
.collect();
let selected = Tab::ALL.iter().position(|t| *t == app.tab).unwrap_or(0);
let (daemon_label, daemon_color) = match (&app.daemon_up, &app.health) {
@ -84,22 +239,30 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
return;
}
if detail.kind == "movie" || detail.episodes.is_empty() {
draw_movie_detail(frame, area, app, detail);
return;
}
let items: Vec<ListItem> = detail
.episodes
.iter()
.map(|e| {
let status = if e.has_file {
""
let (status, color) = if e.has_file {
("", Color::Green)
} else if e.monitored {
""
("", Color::Yellow)
} else {
"-"
("-", Color::DarkGray)
};
let title = e.title.as_deref().unwrap_or("");
ListItem::new(format!(
"{status} S{:02}E{:02} {title}",
ListItem::new(Line::from(vec![
Span::styled(status, Style::default().fg(color)),
Span::raw(format!(
" S{:02}E{:02} {title}",
e.season_number, e.episode_number
))
)),
]))
})
.collect();
let monitor_label = if detail.monitored {
@ -116,9 +279,7 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
};
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(format!(
"{} ({}) [{monitor_label}] — Esc: back s: search now m: monitor show \
e: monitor episode S: monitor season x: delete show d: delete file \
c: pick release{confirm}",
"{} ({}) [{monitor_label}]{confirm}",
detail.title,
detail.year.map(|y| y.to_string()).unwrap_or_default()
)))
@ -129,33 +290,134 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) {
}
let items: Vec<ListItem> = app
.media_items
.library_view
.iter()
.map(|m| {
let missing = if m.missing_count > 0 {
.map(|&i| {
let m = &app.media_items[i];
let kind_tag = if m.kind == "movie" { "[Movie]" } else { "[TV]" };
let kind_color = if m.kind == "movie" {
Color::Magenta
} else {
Color::Blue
};
let ratio = if m.episode_count > 0 {
m.missing_count as f64 / m.episode_count as f64
} else if m.missing_count > 0 {
1.0
} else {
0.0
};
// Mute severity color for unmonitored items — a missing count
// on something deliberately unmonitored isn't actionable.
let missing_color = if !m.monitored || m.missing_count == 0 {
Color::DarkGray
} else if ratio <= 0.25 {
Color::Yellow
} else {
Color::Red
};
let missing_text = if m.missing_count > 0 {
format!("{} missing", m.missing_count)
} else {
String::new()
};
ListItem::new(format!(
"{} ({}){}",
let title_style = if m.monitored {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
};
ListItem::new(Line::from(vec![
Span::styled(format!("{kind_tag} "), Style::default().fg(kind_color)),
Span::styled(
format!(
"{} ({})",
m.title,
m.year.map(|y| y.to_string()).unwrap_or_default(),
missing
))
m.year.map(|y| y.to_string()).unwrap_or_default()
),
title_style,
),
Span::styled(missing_text, Style::default().fg(missing_color)),
]))
})
.collect();
let search = if app.library_query.is_empty() {
String::new()
} else {
format!(" search: {}", app.library_query)
};
let filter_caret = if matches!(app.focus, Focus::LibraryFilterInput) {
""
} else {
""
};
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("Monitored Shows — Enter for detail"),
)
.block(Block::default().borders(Borders::ALL).title(format!(
"Library ({}/{}) — filter: {} sort: {}{search}{filter_caret}",
app.library_view.len(),
app.media_items.len(),
app.library_filter.label(),
app.library_sort.label()
)))
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = app.media_state.clone();
frame.render_stateful_widget(list, area, &mut state);
}
fn draw_movie_detail(
frame: &mut Frame,
area: Rect,
app: &App,
detail: &breadarr_shared::dto::MediaItemDetail,
) {
let confirm = if app.confirm_delete {
" — x AGAIN TO DELETE"
} else if app.confirm_delete_file {
" — d AGAIN TO DELETE FILE"
} else {
""
};
let monitor_label = if detail.monitored {
"monitored"
} else {
"unmonitored"
};
let have = app
.media_items
.iter()
.find(|m| m.id == detail.id)
.map(|m| m.missing_count == 0);
let file_line = match have {
Some(true) => ("file on disk", Color::Green),
Some(false) => ("missing", Color::Yellow),
None => ("file status unknown", Color::DarkGray),
};
let kind = if detail.kind == "movie" {
"Movie"
} else {
"Series"
};
let year = detail
.year
.map(|y| y.to_string())
.unwrap_or_else(|| "".into());
let lines = vec![
Line::from(Span::styled(
format!("{} ({year})", detail.title),
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(format!("{kind} · {monitor_label}")),
Line::from(Span::styled(file_line.0, Style::default().fg(file_line.1))),
Line::from(format!("root: {}", detail.root_folder)),
Line::from(""),
Line::from("s search now c pick release m monitor d delete file x remove"),
];
let paragraph = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(
format!("{} ({year}) [{monitor_label}]{confirm}", detail.title),
));
frame.render_widget(paragraph, area);
}
/// Manual release picker — candidates for whatever episode/movie was
/// selected when `c` was pressed, scored (or gate-rejected with a reason)
/// exactly like the automatic search pipeline would see them.
@ -187,15 +449,19 @@ fn draw_candidates(frame: &mut Frame, area: Rect, app: &App, media_title: &str)
if c.is_season_pack { " [PACK]" } else { "" },
if c.is_repack { " [REPACK]" } else { "" },
);
let verdict = match (c.score, &c.rejected_reason) {
(Some(score), _) => format!("score {score:.1}"),
(None, Some(reason)) => format!("REJECTED: {reason}"),
(None, None) => "unscored".to_string(),
let (verdict, color) = match (c.score, &c.rejected_reason) {
(Some(score), _) if score >= 8.0 => (format!("score {score:.1}"), Color::Green),
(Some(score), _) => (format!("score {score:.1}"), Color::Yellow),
(None, Some(reason)) => (format!("REJECTED: {reason}"), Color::Red),
(None, None) => ("unscored".to_string(), Color::DarkGray),
};
ListItem::new(format!(
"[{}] {} — {seeders} seeders, {size}{flags} — {verdict}",
ListItem::new(Line::from(vec![
Span::raw(format!(
"[{}] {} — {seeders} seeders, {size}{flags} — ",
c.source_name, c.raw_title
))
)),
Span::styled(verdict, Style::default().fg(color)),
]))
})
.collect();
let list = List::new(items)
@ -207,18 +473,32 @@ fn draw_candidates(frame: &mut Frame, area: Rect, app: &App, media_title: &str)
frame.render_stateful_widget(list, area, &mut state);
}
fn history_status_color(status: &str) -> Color {
match status {
"imported" => Color::Green,
"grabbed" => Color::Yellow,
"failed" => Color::Red,
_ => Color::DarkGray,
}
}
fn draw_history(frame: &mut Frame, area: Rect, app: &App) {
let items: Vec<ListItem> = app
.releases
.iter()
.map(|r| {
ListItem::new(format!(
"[{}] {} — {} (score {:.1})",
r.status,
ListItem::new(Line::from(vec![
Span::styled(
format!("[{}]", r.status),
Style::default().fg(history_status_color(&r.status)),
),
Span::raw(format!(
" {} — {} (score {:.1})",
r.media_title,
r.raw_title,
r.score.unwrap_or(0.0)
))
)),
]))
})
.collect();
let list = List::new(items)
@ -233,12 +513,24 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) {
.review_items
.iter()
.map(|r| {
ListItem::new(format!(
"({:.0}%) {} -> {}",
r.confidence * 100.0,
let color = if r.confidence < 0.70 {
Color::Red
} else if r.confidence < 0.85 {
Color::Yellow
} else {
Color::Green
};
ListItem::new(Line::from(vec![
Span::styled(
format!("({:.0}%)", r.confidence * 100.0),
Style::default().fg(color),
),
Span::raw(format!(
" {} -> {}",
r.raw_release_title,
r.candidate_media_title.as_deref().unwrap_or("?")
))
)),
]))
})
.collect();
let list = List::new(items)
@ -256,6 +548,11 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) {
/// than expected, how deep the review queue has backed up, and search
/// targets that have been failing every attempt long enough for their
/// backoff to hit its ceiling. Read-only report, no selection/navigation.
// Mirrors breadarrd/src/api/routes/stuck.rs::MAXED_SEARCH_COUNT. Duplicated
// because the daemon doesn't expose it via the API; if it drifts this is
// cosmetic only (wrong shade), not a behavior bug.
const MAXED_SEARCH_COUNT: i64 = 6;
fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) {
let Some(report) = &app.stuck else {
let placeholder = Paragraph::new("loading...").block(
@ -272,42 +569,74 @@ fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) {
.constraints([Constraint::Min(3), Constraint::Min(3)])
.split(area);
let stalled_border = if matches!(app.stuck_focus, StuckSection::Stalled) {
Color::Cyan
} else {
Color::Reset
};
let stalled_items: Vec<ListItem> = report
.stalled_grabs
.iter()
.map(|g| {
ListItem::new(format!(
ListItem::new(Line::from(Span::styled(
format!(
"{} — {} (grabbed {})",
g.media_title, g.raw_title, g.grabbed_at
))
),
Style::default().fg(Color::Yellow),
)))
})
.collect();
let stalled_list =
List::new(stalled_items).block(Block::default().borders(Borders::ALL).title(format!(
let stalled_list = List::new(stalled_items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(stalled_border))
.title(format!(
"Stalled grabs ({}) — review queue: {} pending",
report.stalled_grabs.len(),
report.review_queue_depth
)));
frame.render_widget(stalled_list, chunks[0]);
)),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut stalled_state = app.stalled_state.clone();
frame.render_stateful_widget(stalled_list, chunks[0], &mut stalled_state);
let maxed_border = if matches!(app.stuck_focus, StuckSection::Maxed) {
Color::Cyan
} else {
Color::Reset
};
let maxed_items: Vec<ListItem> = report
.maxed_out_search_targets
.iter()
.map(|t| {
ListItem::new(format!(
let color = if t.search_count > MAXED_SEARCH_COUNT {
Color::Red
} else {
Color::Yellow
};
ListItem::new(Line::from(Span::styled(
format!(
"{} — {} attempts, last searched {}",
t.media_title,
t.search_count,
t.last_searched_at.as_deref().unwrap_or("never")
))
),
Style::default().fg(color),
)))
})
.collect();
let maxed_list = List::new(maxed_items).block(
let maxed_list = List::new(maxed_items)
.block(
Block::default()
.borders(Borders::ALL)
.title("Search targets at max backoff"),
);
frame.render_widget(maxed_list, chunks[1]);
.border_style(Style::default().fg(maxed_border))
.title("Search targets at max backoff — Enter: jump to show"),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut maxed_state = app.maxed_state.clone();
frame.render_stateful_widget(maxed_list, chunks[1], &mut maxed_state);
}
fn draw_add(frame: &mut Frame, area: Rect, app: &App) {
@ -333,12 +662,21 @@ fn draw_add(frame: &mut Frame, area: Rect, app: &App) {
.add_results
.iter()
.map(|r| {
ListItem::new(format!(
"[{}] {} ({})",
r.kind.label(),
let kind_color = match r.kind {
crate::app::AddKind::Movie => Color::Magenta,
crate::app::AddKind::Series => Color::Blue,
};
ListItem::new(Line::from(vec![
Span::styled(
format!("[{}] ", r.kind.label()),
Style::default().fg(kind_color),
),
Span::raw(format!(
"{} ({})",
r.result.title,
r.result.year.map(|y| y.to_string()).unwrap_or_default()
))
)),
]))
})
.collect();
let list = List::new(items)
@ -353,36 +691,43 @@ fn draw_add(frame: &mut Frame, area: Rect, app: &App) {
}
/// What's aired recently or airs soon (a week back, three weeks forward —
/// see `calendar::DAYS_PAST`/`DAYS_FUTURE` server-side). Read-only, no
/// selection — a lookahead view, not something acted on directly here.
/// see `calendar::DAYS_PAST`/`DAYS_FUTURE` server-side). Selectable —
/// Enter jumps to that episode in Library.
fn draw_calendar(frame: &mut Frame, area: Rect, app: &App) {
let today = chrono::Local::now().date_naive().to_string();
let items: Vec<ListItem> = app
.calendar
.iter()
.map(|e| {
let status = if e.has_file {
""
let (status, color) = if e.has_file {
("", Color::Green)
} else if !e.monitored {
"-"
("-", Color::DarkGray)
} else if e.air_date.as_str() > today.as_str() {
""
("", Color::Yellow)
} else {
"!" // aired, monitored, still missing
("!", Color::Red)
};
let today_mark = if e.air_date == today { " today" } else { "" };
let title = e.title.as_deref().unwrap_or("");
ListItem::new(format!(
"{status} {} {} S{:02}E{:02} {title}",
ListItem::new(Line::from(vec![
Span::styled(status, Style::default().fg(color)),
Span::raw(format!(
" {} {} S{:02}E{:02} {title}{today_mark}",
e.air_date, e.media_title, e.season_number, e.episode_number
))
)),
]))
})
.collect();
let list = List::new(items).block(
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("Calendar — ✓ have it … upcoming ! aired but missing"),
);
frame.render_widget(list, area);
.title("Calendar — Enter: jump ✓ have it … upcoming ! aired but missing"),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = app.calendar_state.clone();
frame.render_stateful_widget(list, area, &mut state);
}
/// Read-only report on `media_file_probe` state: corruption, under-quality,
@ -402,7 +747,7 @@ fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(5), Constraint::Min(3)])
.constraints([Constraint::Length(6), Constraint::Min(3)])
.split(area);
let s = &report.summary;
@ -412,8 +757,13 @@ fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) {
.map(|c| format!("{}={}", c.codec, c.count))
.collect::<Vec<_>>()
.join(", ");
let cycles = app
.health
.as_ref()
.map(cycle_summary_line)
.unwrap_or_default();
let summary_text = format!(
"{} files, {:.1} GB total, {} probed — resolution: SD={} 720p={} 1080p={} 4K={} \
"{cycles}\n{} files, {:.1} GB total, {} probed — resolution: SD={} 720p={} 1080p={} 4K={} \
subtitles: {:.0}% codecs: {codec_summary}",
s.total_files,
s.total_size_bytes as f64 / 1_073_741_824.0,
@ -482,12 +832,38 @@ fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) {
items.push(ListItem::new("Nothing flagged — library looks clean."));
}
let list = List::new(items).block(
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title("Flagged files"),
);
frame.render_widget(list, chunks[1]);
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
let mut state = app.health_state.clone();
frame.render_stateful_widget(list, chunks[1], &mut state);
}
fn cycle_bit(name: &str, info: &Option<CycleInfo>) -> String {
match info {
Some(c) if c.ok => format!("{name}: ok"),
Some(_) => format!("{name}: FAIL"),
None => format!("{name}: —"),
}
}
fn cycle_summary_line(h: &breadarr_shared::dto::HealthDetail) -> String {
format!(
"{} | {} | {} | {}{}",
cycle_bit("grab", &h.last_grab_cycle),
cycle_bit("import", &h.last_import_cycle),
cycle_bit("search", &h.last_search_cycle),
cycle_bit("upgrade", &h.last_upgrade_cycle),
if h.search_halted {
" | search HALTED"
} else {
""
}
)
}
/// Quality-profile weight editing — list of profiles, then (once one is
@ -549,11 +925,23 @@ fn draw_profiles(frame: &mut Frame, area: Rect, app: &App) {
}
fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
let text = if app.status.is_empty() {
"Tab: switch view | j/k: move | q: quit".to_string()
} else {
let text = if !app.status.is_empty() {
app.status.clone()
} else {
let hints: Vec<String> = context_keybindings(app)
.into_iter()
.take(4)
.map(|(key, desc)| format!("{key}: {desc}"))
.collect();
format!("{} | ?: help", hints.join(" | "))
};
let status = Paragraph::new(text).block(Block::default().borders(Borders::ALL));
let style = if app.busy {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let status = Paragraph::new(text)
.style(style)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(status, area);
}

View file

@ -1,6 +1,6 @@
[package]
name = "breadarrd"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
[dependencies]
@ -19,9 +19,15 @@ regex.workspace = true
serde_json.workspace = true
ort.workspace = true
tokenizers.workspace = true
# TODO(owner): switch to tag-pinned git dependency once bread-onnx is merged and tagged, matching the bread-theme pattern
bread-onnx = { path = "../../bread-ecosystem/bread-onnx" }
bread-onnx = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2" }
scraper.workspace = true
chrono.workspace = true
fastrand.workspace = true
nix.workspace = true
# Forces reqwest's native-tls backend to statically build and link its own
# OpenSSL instead of dynamically linking whatever libssl/libcrypto happens
# to be on the build host — CI (see ci/build.sh) links against an archived
# old-glibc sysroot for hestia compatibility, which has no libssl/libcrypto
# of its own to dynamically link against. Also means the shipped binary no
# longer depends on the target system's OpenSSL version at all.
openssl-sys = { version = "0.9", features = ["vendored"] }

View file

@ -71,13 +71,14 @@ pub enum BackgroundRequest {
/// Mutex` is fine here (unlike `conn`) since updates are a single field
/// write with no `.await` in between. Exists so a silently-stalled
/// background loop (e.g. every cycle erroring for hours) is visible from a
/// single `/health` call instead of only in the journal.
/// single `/health/detail` call instead of only in the journal.
#[derive(Clone, Default)]
pub struct CycleStatus {
pub last_grab: Option<CycleRecord>,
pub last_import: Option<CycleRecord>,
pub last_search: Option<CycleRecord>,
pub last_upgrade: Option<CycleRecord>,
pub last_transcode: Option<CycleRecord>,
/// Set once the search-driven loop's consecutive-failure backoff hits
/// its ceiling — still ticking at max backoff underneath (self-healing
/// if the source recovers), but worth a loud, easy-to-spot signal that
@ -95,10 +96,11 @@ pub struct CycleRecord {
/// Rejects any request lacking `Authorization: Bearer <config.daemon.api_token>`
/// once a token is actually configured — a no-op (every request passes)
/// when it's empty, so an unconfigured install behaves exactly as before.
/// `/health` is deliberately exempt even with a token configured: it's
/// commonly polled by external monitoring (e.g. an uptime dashboard) that
/// has no reason to hold the same credential as the TUI/API client, and it
/// exposes nothing more sensitive than "is the process alive."
/// Exact `/health` is deliberately exempt even with a token configured:
/// it's commonly polled by external monitoring (e.g. an uptime dashboard)
/// that has no reason to hold the same credential as the TUI/API client,
/// and it exposes nothing more sensitive than "is the process alive."
/// `/health/detail` is *not* exempt — it includes cycle status.
async fn require_api_token(State(state): State<AppState>, req: Request, next: Next) -> Response {
if state.config.daemon.api_token.is_empty() || req.uri().path() == "/health" {
return next.run(req).await;
@ -108,16 +110,36 @@ async fn require_api_token(State(state): State<AppState>, req: Request, next: Ne
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.is_some_and(|token| token == state.config.daemon.api_token);
.is_some_and(|token| constant_time_eq(token, &state.config.daemon.api_token));
if !authorized {
return (StatusCode::UNAUTHORIZED, "missing or invalid API token").into_response();
}
next.run(req).await
}
/// Byte-wise `==` short-circuits on the first mismatching byte, making
/// comparison time a (weak, but real) signal of how many leading bytes of a
/// guessed token were correct — a classic timing oracle. This always walks
/// the longer input (padding the shorter against a dummy) and folds a
/// length mismatch into the accumulator so a length difference is not a
/// first-instruction return. Pair with `api_token.len() >= 16` in
/// `Config::validate` so a length-oracle of short guesses is useless.
fn constant_time_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
let max = a.len().max(b.len());
let mut acc = u8::from(a.len() != b.len());
for i in 0..max {
let x = *a.get(i).unwrap_or(&0);
let y = *b.get(i).unwrap_or(&0);
acc |= x ^ y;
}
acc == 0
}
pub fn router(state: AppState) -> Router {
Router::new()
.route("/health", get(routes::health::health))
.route("/health/detail", get(routes::health::health_detail))
.route("/media", get(routes::media::list).post(routes::media::add))
.route(
"/media/:id",
@ -178,3 +200,28 @@ pub fn router(state: AppState) -> Router {
))
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn constant_time_eq_matches_identical_strings() {
assert!(constant_time_eq("secret-token", "secret-token"));
}
#[test]
fn constant_time_eq_rejects_different_strings_of_the_same_length() {
assert!(!constant_time_eq("secret-token", "secret-toke1"));
}
#[test]
fn constant_time_eq_rejects_different_lengths() {
assert!(!constant_time_eq("short", "a-much-longer-token"));
}
#[test]
fn constant_time_eq_treats_empty_strings_as_equal() {
assert!(constant_time_eq("", ""));
}
}

View file

@ -1,6 +1,6 @@
use axum::extract::State;
use axum::Json;
use breadarr_shared::dto::{CycleInfo, HealthDetail};
use breadarr_shared::dto::{CycleInfo, HealthDetail, HealthStatus};
use crate::api::AppState;
@ -12,7 +12,15 @@ fn to_info(r: &crate::api::CycleRecord) -> CycleInfo {
}
}
pub async fn health(State(state): State<AppState>) -> Json<HealthDetail> {
/// Unauthenticated liveness — cheap, no cycle detail.
pub async fn health() -> Json<HealthStatus> {
Json(HealthStatus {
status: "ok".to_string(),
})
}
/// Authenticated cycle-status payload (same as the old `/health`).
pub async fn health_detail(State(state): State<AppState>) -> Json<HealthDetail> {
let status = state.cycle_status.lock().expect("cycle_status poisoned");
Json(HealthDetail {
status: "ok".to_string(),
@ -20,6 +28,7 @@ pub async fn health(State(state): State<AppState>) -> Json<HealthDetail> {
last_import_cycle: status.last_import.as_ref().map(to_info),
last_search_cycle: status.last_search.as_ref().map(to_info),
last_upgrade_cycle: status.last_upgrade.as_ref().map(to_info),
last_transcode_cycle: status.last_transcode.as_ref().map(to_info),
search_halted: status.search_halted,
})
}

View file

@ -1,3 +1,5 @@
use std::path::{Component, Path as FsPath, PathBuf};
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
@ -5,6 +7,7 @@ use breadarr_shared::dto::{
AddMovieRequest, AddMovieResponse, AddSeriesRequest, AddSeriesResponse, EpisodeSummary,
MediaItemDetail, MediaItemSummary, SearchNowResult,
};
use breadarr_shared::Config;
use rusqlite::{params, OptionalExtension};
use crate::api::AppState;
@ -45,7 +48,7 @@ pub async fn detail(
Path(id): Path<i64>,
) -> Result<Json<MediaItemDetail>, (StatusCode, String)> {
let conn = state.conn.lock().await;
let (kind, title, year, monitored, root_folder) = conn
let row = conn
.query_row(
"SELECT kind, title, year, monitored, root_folder FROM media_item WHERE id = ?1",
params![id],
@ -59,7 +62,11 @@ pub async fn detail(
))
},
)
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
.optional()
.map_err(internal)?;
let Some((kind, title, year, monitored, root_folder)) = row else {
return Err((StatusCode::NOT_FOUND, format!("no media_item {id}")));
};
let mut stmt = conn
.prepare(
@ -105,6 +112,8 @@ pub async fn add(
));
};
let root_folder = constrain_root_folder(&req.root_folder, &state.config.default_root_folder())?;
// Fetch before taking the lock — a sync MutexGuard can't be held across
// an `.await` point.
let episodes = tvdb.episodes(&req.tvdb_id).await.map_err(internal)?;
@ -117,7 +126,7 @@ pub async fn add(
&req.title,
req.year.map(|y| y as u32),
&req.aliases,
&req.root_folder,
&root_folder,
1,
&episodes,
)
@ -131,13 +140,14 @@ pub async fn add_movie(
State(state): State<AppState>,
Json(req): Json<AddMovieRequest>,
) -> Result<Json<AddMovieResponse>, (StatusCode, String)> {
let root_folder = constrain_root_folder(&req.root_folder, &state.config.movies_root_folder())?;
let conn = state.conn.lock().await;
let media_item_id = metadata::insert_movie(
&conn,
&req.tmdb_id,
&req.title,
req.year.map(|y| y as u32),
&req.root_folder,
&root_folder,
2,
)
.map_err(internal)?;
@ -208,13 +218,9 @@ async fn set_episode_monitored(
Ok(StatusCode::NO_CONTENT)
}
/// Toggles every episode in one season at once — a season-level "row" isn't
/// separately tracked (the `season` table exists in the schema but was
/// never actually populated by any insert path, so resurrecting it just to
/// hold one redundant monitored flag would mean keeping two copies of the
/// same state in sync for no behavioral gain); bulk-updating the episodes
/// directly gets the identical practical effect — this season's episodes
/// stop appearing in search enumeration — with one source of truth.
/// Toggles every episode in one season and the `season.monitored` flag
/// when a season row exists (`insert_series` writes those). A missing
/// season row is not an error — episodes still update.
pub async fn monitor_season(
State(state): State<AppState>,
Path((media_item_id, season_number)): Path<(i64, i64)>,
@ -242,6 +248,11 @@ async fn set_season_monitored(
params![monitored as i64, media_item_id, season_number],
)
.map_err(internal)?;
// Season row is best-effort — older libraries (or movies) may have none.
let _ = conn.execute(
"UPDATE season SET monitored = ?1 WHERE media_item_id = ?2 AND season_number = ?3",
params![monitored as i64, media_item_id, season_number],
);
if rows == 0 {
return Err((
StatusCode::NOT_FOUND,
@ -283,21 +294,20 @@ pub async fn delete_episode_file(
Path(episode_id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
let conn = state.conn.lock().await;
let row: Option<(i64, String)> = conn
.query_row(
let files = tracked_files(
&conn,
"SELECT id, path FROM episode_file WHERE episode_id = ?1",
params![episode_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(internal)?;
let Some((file_id, path)) = row else {
episode_id,
)?;
if files.is_empty() {
return Err((
StatusCode::NOT_FOUND,
format!("no file tracked for episode {episode_id}"),
));
};
}
for (file_id, path) in files {
delete_file_and_clear(&conn, &path, file_id)?;
}
conn.execute(
"UPDATE episode SET has_file = 0 WHERE id = ?1",
params![episode_id],
@ -315,21 +325,20 @@ pub async fn delete_movie_file(
Path(media_item_id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
let conn = state.conn.lock().await;
let row: Option<(i64, String)> = conn
.query_row(
let files = tracked_files(
&conn,
"SELECT id, path FROM episode_file WHERE media_item_id = ?1 AND episode_id IS NULL",
params![media_item_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.map_err(internal)?;
let Some((file_id, path)) = row else {
media_item_id,
)?;
if files.is_empty() {
return Err((
StatusCode::NOT_FOUND,
format!("no file tracked for media_item {media_item_id}"),
));
};
}
for (file_id, path) in files {
delete_file_and_clear(&conn, &path, file_id)?;
}
Ok(StatusCode::NO_CONTENT)
}
@ -339,6 +348,20 @@ pub async fn delete_movie_file(
/// alone, which a TV show shares across every one of its episode rows and
/// would otherwise risk wiping an entire show's tracked files instead of
/// the one the caller actually looked up.
fn tracked_files(
conn: &rusqlite::Connection,
sql: &str,
id: i64,
) -> Result<Vec<(i64, String)>, (StatusCode, String)> {
let mut stmt = conn.prepare(sql).map_err(internal)?;
let files = stmt
.query_map(params![id], |row| Ok((row.get(0)?, row.get(1)?)))
.map_err(internal)?
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(internal)?;
Ok(files)
}
fn delete_file_and_clear(
conn: &rusqlite::Connection,
path: &str,
@ -502,6 +525,59 @@ async fn grab_candidate_via_background(
Ok(StatusCode::NO_CONTENT)
}
/// After `~/` expand and lexical normalize, the path must stay under
/// `allowed_root`. `..` components and absolute paths outside that root
/// are rejected with 400.
fn constrain_root_folder(
requested: &str,
allowed_root: &FsPath,
) -> Result<String, (StatusCode, String)> {
let requested = requested.trim();
if requested.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"root_folder must not be empty".into(),
));
}
let expanded = Config::expand_path(requested);
if expanded
.components()
.any(|c| matches!(c, Component::ParentDir))
{
return Err((
StatusCode::BAD_REQUEST,
"root_folder must not contain '..'".into(),
));
}
let candidate = if expanded.is_absolute() {
normalize_lexically(&expanded)
} else {
normalize_lexically(&allowed_root.join(expanded))
};
let root = normalize_lexically(allowed_root);
if !candidate.starts_with(&root) {
return Err((
StatusCode::BAD_REQUEST,
format!("root_folder must be under {}", root.display()),
));
}
Ok(candidate.to_string_lossy().into_owned())
}
fn normalize_lexically(path: &FsPath) -> PathBuf {
let mut out = PathBuf::new();
for c in path.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
out
}
fn internal<E: std::fmt::Display>(e: E) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}

View file

@ -68,6 +68,12 @@ pub async fn update_weights(
Path(id): Path<i64>,
Json(req): Json<UpdateQualityProfileWeightsRequest>,
) -> Result<StatusCode, (StatusCode, String)> {
if !weights_are_valid(&req.weights) {
return Err((
StatusCode::BAD_REQUEST,
"quality-profile weights must be finite and >= 0".into(),
));
}
let weights_json = serde_json::to_string(&req.weights).map_err(internal)?;
let conn = state.conn.lock().await;
let updated = conn
@ -82,6 +88,59 @@ pub async fn update_weights(
Ok(StatusCode::NO_CONTENT)
}
fn weights_are_valid(w: &WeightsDto) -> bool {
[
w.seeder,
w.resolution_tier,
w.source_tier,
w.codec_tier,
w.bit_depth,
w.container,
w.group_allowlist,
w.repack,
w.hdr,
]
.into_iter()
.all(|v| v.is_finite() && v >= 0.0)
}
fn internal<E: std::fmt::Display>(e: E) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn valid_weights() -> WeightsDto {
WeightsDto {
seeder: 1.0,
resolution_tier: 1.0,
source_tier: 1.0,
codec_tier: 1.0,
bit_depth: 1.0,
container: 1.0,
group_allowlist: 1.0,
repack: 1.0,
hdr: 1.0,
}
}
#[test]
fn weights_are_valid_accepts_finite_non_negative() {
assert!(weights_are_valid(&valid_weights()));
}
#[test]
fn weights_are_valid_rejects_nan_inf_and_negative() {
let mut w = valid_weights();
w.seeder = f32::NAN;
assert!(!weights_are_valid(&w));
w = valid_weights();
w.hdr = f32::INFINITY;
assert!(!weights_are_valid(&w));
w = valid_weights();
w.repack = -0.1;
assert!(!weights_are_valid(&w));
}
}

View file

@ -75,15 +75,27 @@ pub async fn approve(
}
};
let torrent_hash = scheduler::grab_prepared_approval(qbit, &state.qbit_category, &prepared)
.await
.map_err(internal)?;
let torrent_hash =
match scheduler::grab_prepared_approval(qbit, &state.qbit_category, &prepared).await {
Ok(hash) => hash,
Err(e) => {
// The grab errored outright (not just "added but no hash
// captured" — `finalize_review_approval` below handles that
// case and still runs to completion). `prepare_review_approval`
// already claimed this row into `approved` before we got here;
// without releasing it back to `pending`, a transient
// qBittorrent error would strand the review permanently
// unapprovable with nothing ever recorded for it.
let conn = state.conn.lock().await;
let _ = scheduler::release_review_claim(&conn, id);
return Err(internal(e));
}
};
{
let conn = state.conn.lock().await;
scheduler::finalize_review_approval(
&conn,
id,
&prepared,
&state.qbit_category,
torrent_hash.as_deref(),
@ -99,7 +111,12 @@ pub async fn reject(
Path(id): Path<i64>,
) -> Result<StatusCode, (StatusCode, String)> {
let conn = state.conn.lock().await;
scheduler::reject_review(&conn, id).map_err(internal)?;
if !scheduler::reject_review(&conn, id).map_err(internal)? {
return Err((
StatusCode::NOT_FOUND,
format!("no pending review item {id}"),
));
}
Ok(StatusCode::NO_CONTENT)
}

View file

@ -21,7 +21,8 @@ pub async fn search(
State(state): State<AppState>,
Query(params): Query<SearchParams>,
) -> Result<Json<Vec<SearchResult>>, (StatusCode, String)> {
if params.kind == "movie" {
match params.kind.as_str() {
"movie" => {
let Some(tmdb) = &state.tmdb else {
return Err((
StatusCode::PRECONDITION_FAILED,
@ -39,9 +40,9 @@ pub async fn search(
year: r.year.map(|y| y as i64),
})
.collect();
return Ok(Json(results));
Ok(Json(results))
}
"series" => {
let Some(tvdb) = &state.tvdb else {
return Err((
StatusCode::PRECONDITION_FAILED,
@ -61,3 +62,9 @@ pub async fn search(
.collect();
Ok(Json(results))
}
other => Err((
StatusCode::BAD_REQUEST,
format!("kind must be series or movie, got {other:?}"),
)),
}
}

View file

@ -23,7 +23,7 @@ pub async fn stuck(
let mut stmt = conn
.prepare(
"SELECT r.id, m.title, r.raw_title, r.grabbed_at
"SELECT r.id, r.media_item_id, m.title, r.raw_title, r.grabbed_at
FROM release r JOIN media_item m ON m.id = r.media_item_id
WHERE r.status = 'grabbed'
AND (julianday('now') - julianday(r.grabbed_at)) * 24.0 > ?1
@ -34,9 +34,10 @@ pub async fn stuck(
.query_map([STALLED_GRAB_HOURS], |row| {
Ok(StalledGrab {
release_id: row.get(0)?,
media_title: row.get(1)?,
raw_title: row.get(2)?,
grabbed_at: row.get(3)?,
media_item_id: row.get(1)?,
media_title: row.get(2)?,
raw_title: row.get(3)?,
grabbed_at: row.get(4)?,
})
})
.map_err(internal)?

View file

@ -5,14 +5,14 @@ use rusqlite::Connection;
/// doesn't slowly fill the disk with an ever-growing pile of copies.
const MAX_BACKUPS: usize = 5;
/// Copies the database (and its WAL/SHM sidecar files, if present — WAL
/// mode means the real state can be split across all three) to a timestamped
/// backup before the daemon opens it, then prunes old backups beyond
/// Writes a consistent snapshot of the existing database to a timestamped
/// file under `<db-dir>/backups/`, then prunes old backups beyond
/// `MAX_BACKUPS`. A no-op if there's no existing database yet (fresh
/// install — nothing to back up). Sonarr/Radarr back themselves up before
/// every upgrade; breadarr has no migration framework to trigger that same
/// moment, so this runs on every startup instead, which is a superset of
/// the same protection.
/// install — nothing to back up). Uses `VACUUM INTO` so WAL state is
/// folded into one standalone file; a raw `fs::copy` of a live WAL
/// database can be torn. Sonarr/Radarr back themselves up before every
/// upgrade; breadarr has no migration framework to trigger that same
/// moment, so this runs on every startup instead.
pub fn backup_before_open(db_path: &std::path::Path) -> anyhow::Result<()> {
if !db_path.exists() {
return Ok(());
@ -32,15 +32,13 @@ pub fn backup_before_open(db_path: &std::path::Path) -> anyhow::Result<()> {
// and dedupe correctly instead of the second one silently overwriting.
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%3fZ");
let dest = backup_dir.join(format!("{timestamp}-{stem}"));
std::fs::copy(db_path, &dest)?;
for sidecar_ext in ["-wal", "-shm"] {
let sidecar = std::path::PathBuf::from(format!("{}{sidecar_ext}", db_path.display()));
if sidecar.exists() {
let dest_sidecar = backup_dir.join(format!("{timestamp}-{stem}{sidecar_ext}"));
std::fs::copy(&sidecar, &dest_sidecar)?;
}
}
let src = Connection::open_with_flags(db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
// Path is interpolated (VACUUM INTO does not bind `?` parameters);
// single quotes in the path are doubled so the SQL string stays valid.
let dest_sql = dest.to_string_lossy().replace('\'', "''");
src.execute(&format!("VACUUM INTO '{dest_sql}'"), [])?;
drop(src);
prune_old_backups(&backup_dir, stem)?;
Ok(())
@ -280,9 +278,7 @@ pub fn init(conn: &Connection) -> anyhow::Result<()> {
CREATE INDEX IF NOT EXISTS idx_event_history_media_item
ON event_history(media_item_id, occurred_at);
-- Placeholder profiles until Phase 6 builds real quality-scoring
-- weights; media_item.quality_profile_id needs something to
-- reference in the meantime.
-- Default profiles. Scoring reads these rows' `weights` on every grab.
INSERT OR IGNORE INTO quality_profile (id, name, kind, weights)
VALUES (1, 'Default TV', 'tv', '{}');
INSERT OR IGNORE INTO quality_profile (id, name, kind, weights)
@ -353,7 +349,39 @@ pub fn init(conn: &Connection) -> anyhow::Result<()> {
fetched_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_torrent_fetch_hash ON torrent_fetch(torrent_hash);
CREATE INDEX IF NOT EXISTS idx_torrent_fetch_fetched_at ON torrent_fetch(fetched_at);",
CREATE INDEX IF NOT EXISTS idx_torrent_fetch_fetched_at ON torrent_fetch(fetched_at);
-- One row per file queued for AV1 transcoding, whether from the
-- post-import async hook or the `transcode-library` backfill sweep.
-- Persisted (not an in-memory queue) so a `pending`/`running` row
-- left over from a daemon crash mid-encode just gets picked up
-- again on the next tick instead of silently vanishing.
CREATE TABLE IF NOT EXISTS transcode_job (
id INTEGER PRIMARY KEY,
episode_file_id INTEGER NOT NULL REFERENCES episode_file(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','running','done','failed','skipped')),
original_codec TEXT,
original_bytes INTEGER,
new_bytes INTEGER,
error TEXT,
queued_at TEXT NOT NULL,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_transcode_job_status ON transcode_job(status);
-- Prevents two jobs for the same file ever being active at once
-- closes the door on the same file getting encoded twice
-- concurrently regardless of how it happened (a stray duplicate
-- enqueue, a daemon-restart reset racing a still-alive backfill).
CREATE UNIQUE INDEX IF NOT EXISTS idx_transcode_job_active_episode_file
ON transcode_job(episode_file_id) WHERE status IN ('pending','running');
CREATE INDEX IF NOT EXISTS idx_release_status ON release(status);
CREATE INDEX IF NOT EXISTS idx_release_torrent_hash ON release(torrent_hash);
CREATE INDEX IF NOT EXISTS idx_release_episode_id ON release(episode_id);
CREATE INDEX IF NOT EXISTS idx_episode_file_episode_id ON episode_file(episode_id);
CREATE INDEX IF NOT EXISTS idx_review_queue_status ON review_queue(status);
CREATE INDEX IF NOT EXISTS idx_episode_air_date ON episode(air_date);",
)?;
// Progress watermark for stalled-download detection (added after the
@ -411,6 +439,196 @@ pub fn init(conn: &Connection) -> anyhow::Result<()> {
// less-common stream metadata). See `ffprobe::MediaProbe::raw_json`.
add_column_if_missing(conn, "media_file_probe", "raw_ffprobe_json", "TEXT")?;
// Set once a file has been through a successful local AV1 transcode —
// stops the upgrade cycle from treating it as still needing a bigger
// HEVC/H264 release, since `best_existing_score` otherwise only ever
// sees the stored `release.score` from original-grab time, which a
// local re-encode never touches.
add_column_if_missing(
conn,
"episode_file",
"upgrade_locked",
"INTEGER NOT NULL DEFAULT 0",
)?;
// Decided at enqueue time (path prefix match against
// `transcode.anime_root_folders`, OR'd with the `anime_mapping`/
// `anime_tmdb_movie` metadata check) and carried on the job row so
// `claim_pending_jobs` can dispatch straight to the right encode
// pipeline (`run_ffmpeg_encode_anime` vs `_live_action`) without
// re-deriving it — the eligibility metadata lookups aren't available
// from the job row's own columns alone (no media_item_id here).
add_column_if_missing(
conn,
"transcode_job",
"is_anime",
"INTEGER NOT NULL DEFAULT 0",
)?;
// Lets a job re-encode a file that's already AV1 — normally
// `encode_and_verify` treats "already AV1" as nothing-to-do and skips
// the encode entirely, which is right for a fresh library scan but
// wrong for deliberately re-transcoding a file that got mis-encoded
// (e.g. the 476 files a real rate-control bug left *larger* than their
// original — already AV1, so the normal backfill query skips them, but
// they're exactly what a remediation pass needs to revisit). See
// `find_oversized_av1_candidates`.
add_column_if_missing(
conn,
"transcode_job",
"force_reencode",
"INTEGER NOT NULL DEFAULT 0",
)?;
ensure_indexes(conn)?;
Ok(())
}
fn index_exists(conn: &Connection, name: &str) -> anyhow::Result<bool> {
let n: i64 = conn.query_row(
"SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = ?1",
[name],
|row| row.get(0),
)?;
Ok(n > 0)
}
/// `CREATE UNIQUE INDEX IF NOT EXISTS` still errors when existing rows
/// violate uniqueness (IF NOT EXISTS only checks the index name). A dirty
/// production DB must still start, so uniqueness failures warn and skip.
fn create_unique_index_best_effort(conn: &Connection, name: &str, ddl: &str) -> bool {
match conn.execute(ddl, []) {
Ok(_) => true,
Err(e) => {
tracing::warn!(
index = name,
error = %e,
"skipping unique index; existing rows would violate it"
);
false
}
}
}
/// Delete extra `media_item` rows that share a non-NULL `tvdb_id`/`tmdb_id`,
/// keeping the lowest `id`. Extras with any `episode` or `episode_file`
/// rows are left in place — those are not safe to drop. Returns whether
/// every duplicate group was reduced to a single row.
fn dedupe_media_item_external_id(conn: &Connection, column: &str) -> anyhow::Result<bool> {
debug_assert!(column == "tvdb_id" || column == "tmdb_id");
let sql = format!(
"SELECT {column}, MIN(id) FROM media_item
WHERE {column} IS NOT NULL
GROUP BY {column}
HAVING COUNT(*) > 1"
);
let dupes: Vec<(i64, i64)> = {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
let mut safe = true;
let extra_sql = format!("SELECT id FROM media_item WHERE {column} = ?1 AND id != ?2");
for (ext_id, keep_id) in dupes {
let extras: Vec<i64> = {
let mut stmt = conn.prepare(&extra_sql)?;
let rows = stmt.query_map(rusqlite::params![ext_id, keep_id], |row| row.get(0))?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
};
for extra_id in extras {
let episode_count: i64 = conn.query_row(
"SELECT count(*) FROM episode WHERE media_item_id = ?1",
[extra_id],
|row| row.get(0),
)?;
let file_count: i64 = conn.query_row(
"SELECT count(*) FROM episode_file WHERE media_item_id = ?1",
[extra_id],
|row| row.get(0),
)?;
if episode_count == 0 && file_count == 0 {
conn.execute("DELETE FROM media_item WHERE id = ?1", [extra_id])?;
} else {
tracing::warn!(
column,
ext_id,
keep_id,
extra_id,
episode_count,
file_count,
"cannot safely dedupe media_item; extra row has episodes or files"
);
safe = false;
}
}
}
Ok(safe)
}
fn ensure_unique_external_id_index(
conn: &Connection,
column: &str,
unique_name: &str,
unique_ddl: &str,
lookup_ddl: &str,
) -> anyhow::Result<()> {
let unique_ok = if dedupe_media_item_external_id(conn, column)? {
create_unique_index_best_effort(conn, unique_name, unique_ddl)
} else {
tracing::warn!(
index = unique_name,
column,
"skipping unique index; media_item still has unsafely-duplicated rows"
);
false
};
if !unique_ok && !index_exists(conn, unique_name)? {
conn.execute(lookup_ddl, [])?;
}
Ok(())
}
fn ensure_indexes(conn: &Connection) -> anyhow::Result<()> {
ensure_unique_external_id_index(
conn,
"tvdb_id",
"idx_media_item_tvdb",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_media_item_tvdb
ON media_item(tvdb_id) WHERE tvdb_id IS NOT NULL",
"CREATE INDEX IF NOT EXISTS idx_media_item_tvdb_lookup ON media_item(tvdb_id)",
)?;
ensure_unique_external_id_index(
conn,
"tmdb_id",
"idx_media_item_tmdb",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_media_item_tmdb
ON media_item(tmdb_id) WHERE tmdb_id IS NOT NULL",
"CREATE INDEX IF NOT EXISTS idx_media_item_tmdb_lookup ON media_item(tmdb_id)",
)?;
create_unique_index_best_effort(
conn,
"idx_episode_file_path",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_episode_file_path ON episode_file(path)",
);
create_unique_index_best_effort(
conn,
"idx_alias_item_text",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_alias_item_text ON alias(media_item_id, text)",
);
create_unique_index_best_effort(
conn,
"idx_review_pending_title",
"CREATE UNIQUE INDEX IF NOT EXISTS idx_review_pending_title
ON review_queue(candidate_media_item_id, raw_release_title)
WHERE status = 'pending'",
);
// No unique on release(source_id, guid): status can cycle and a
// re-grab of the same guid is legitimate. `seen_guid` already records
// first-seen.
Ok(())
}
@ -473,7 +691,7 @@ mod tests {
|row| row.get(0),
)
.unwrap();
assert_eq!(table_count, 17);
assert_eq!(table_count, 18);
}
#[test]
@ -574,19 +792,145 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
fn write_real_sqlite(db_path: &std::path::Path) {
let conn = Connection::open(db_path).unwrap();
init(&conn).unwrap();
drop(conn);
}
fn seed_movie(conn: &Connection, title: &str, tmdb_id: i64) -> i64 {
conn.execute(
"INSERT INTO media_item (kind, title, year, tmdb_id, monitored, quality_profile_id, root_folder)
VALUES ('movie', ?1, NULL, ?2, 1, 2, '/tmp')",
rusqlite::params![title, tmdb_id],
)
.unwrap();
conn.last_insert_rowid()
}
#[test]
fn init_creates_unique_and_lookup_indexes_on_a_clean_database() {
let conn = Connection::open_in_memory().unwrap();
init(&conn).unwrap();
assert!(index_exists(&conn, "idx_media_item_tvdb").unwrap());
assert!(index_exists(&conn, "idx_media_item_tmdb").unwrap());
assert!(index_exists(&conn, "idx_episode_file_path").unwrap());
assert!(index_exists(&conn, "idx_alias_item_text").unwrap());
assert!(index_exists(&conn, "idx_release_status").unwrap());
assert!(index_exists(&conn, "idx_release_torrent_hash").unwrap());
assert!(index_exists(&conn, "idx_release_episode_id").unwrap());
assert!(index_exists(&conn, "idx_episode_file_episode_id").unwrap());
assert!(index_exists(&conn, "idx_review_queue_status").unwrap());
assert!(index_exists(&conn, "idx_review_pending_title").unwrap());
assert!(index_exists(&conn, "idx_episode_air_date").unwrap());
assert!(!index_exists(&conn, "idx_media_item_tvdb_lookup").unwrap());
assert!(!index_exists(&conn, "idx_media_item_tmdb_lookup").unwrap());
}
#[test]
fn init_dedupes_empty_duplicate_tmdb_rows_and_creates_unique_index() {
let conn = Connection::open_in_memory().unwrap();
init(&conn).unwrap();
conn.execute("DROP INDEX IF EXISTS idx_media_item_tmdb", [])
.unwrap();
let first = seed_movie(&conn, "A", 99);
seed_movie(&conn, "B", 99);
init(&conn).unwrap();
let count: i64 = conn
.query_row(
"SELECT count(*) FROM media_item WHERE tmdb_id = 99",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
let kept: i64 = conn
.query_row("SELECT id FROM media_item WHERE tmdb_id = 99", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(kept, first);
assert!(index_exists(&conn, "idx_media_item_tmdb").unwrap());
}
#[test]
fn init_skips_tmdb_unique_index_when_duplicate_has_files() {
let conn = Connection::open_in_memory().unwrap();
init(&conn).unwrap();
conn.execute("DROP INDEX IF EXISTS idx_media_item_tmdb", [])
.unwrap();
seed_movie(&conn, "A", 99);
let extra = seed_movie(&conn, "B", 99);
conn.execute(
"INSERT INTO episode_file (media_item_id, path, size_bytes, subtitle_status)
VALUES (?1, '/tmp/b.mkv', 1, 'none')",
[extra],
)
.unwrap();
init(&conn).unwrap();
let count: i64 = conn
.query_row(
"SELECT count(*) FROM media_item WHERE tmdb_id = 99",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 2);
assert!(!index_exists(&conn, "idx_media_item_tmdb").unwrap());
assert!(index_exists(&conn, "idx_media_item_tmdb_lookup").unwrap());
}
#[test]
fn init_skips_path_unique_index_when_duplicates_exist() {
let conn = Connection::open_in_memory().unwrap();
init(&conn).unwrap();
conn.execute("DROP INDEX IF EXISTS idx_episode_file_path", [])
.unwrap();
let id = seed_movie(&conn, "A", 1);
for _ in 0..2 {
conn.execute(
"INSERT INTO episode_file (media_item_id, path, size_bytes, subtitle_status)
VALUES (?1, '/tmp/x.mkv', 1, 'none')",
[id],
)
.unwrap();
}
init(&conn).unwrap();
assert!(!index_exists(&conn, "idx_episode_file_path").unwrap());
}
#[test]
fn backup_before_open_copies_an_existing_database() {
let dir = std::env::temp_dir().join(format!("breadarr-backup-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let db_path = dir.join("breadarr.db");
std::fs::write(&db_path, b"fake sqlite data").unwrap();
write_real_sqlite(&db_path);
backup_before_open(&db_path).unwrap();
let backup_dir = dir.join("backups");
let backups: Vec<_> = std::fs::read_dir(&backup_dir).unwrap().collect();
let backups: Vec<_> = std::fs::read_dir(&backup_dir)
.unwrap()
.map(|e| e.unwrap().path())
.collect();
assert_eq!(backups.len(), 1, "expected exactly one backup file");
let verify = Connection::open(&backups[0]).unwrap();
let n: i64 = verify
.query_row("SELECT count(*) FROM quality_profile", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 2);
std::fs::remove_dir_all(&dir).unwrap();
}
@ -594,9 +938,10 @@ mod tests {
fn backup_before_open_prunes_beyond_max_backups() {
let dir =
std::env::temp_dir().join(format!("breadarr-backup-prune-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let db_path = dir.join("breadarr.db");
std::fs::write(&db_path, b"fake sqlite data").unwrap();
write_real_sqlite(&db_path);
// One more than MAX_BACKUPS, sleeping a few ms between each so the
// millisecond-resolution timestamp in the filename is guaranteed to

View file

@ -14,6 +14,11 @@ pub struct AudioStream {
#[derive(Debug, Clone, PartialEq)]
pub struct SubtitleStream {
pub language: Option<String>,
/// mov_text (mp4's timed-text subtitle codec) isn't valid inside a
/// Matroska container — a transcode pipeline that always outputs `.mkv`
/// needs to know this per-stream to convert rather than blindly stream
/// copy. See `transcode::subtitle_codec_args`.
pub codec: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
@ -109,6 +114,16 @@ pub fn probe(path: &Path) -> Result<MediaProbe> {
let json: serde_json::Value =
serde_json::from_slice(&output.stdout).context("ffprobe output was not valid JSON")?;
Ok(build_media_probe(&json, raw_json))
}
/// The actual JSON-to-`MediaProbe` mapping, split out from `probe` so it can
/// be unit-tested directly against hand-built ffprobe-shaped JSON — real
/// muxers are inconsistent enough about stream ordering/disposition (see
/// `probe_finds_the_real_video_stream_even_when_attached_pic_comes_first`'s
/// doc comment) that constructing every case as an actual file via `ffmpeg`
/// isn't always practical.
fn build_media_probe(json: &serde_json::Value, raw_json: String) -> MediaProbe {
let format = &json["format"];
let duration_secs = format["duration"]
.as_str()
@ -134,11 +149,18 @@ pub fn probe(path: &Path) -> Result<MediaProbe> {
.as_str()
.or_else(|| stream["tags"]["LANGUAGE"].as_str())
.map(str::to_string);
let is_attached_pic = stream["disposition"]["attached_pic"].as_i64() == Some(1);
match codec_type {
"video" if probe.video_codec.is_none() => {
// First video stream only — a second "video" stream in a
// real-world file is almost always an embedded cover-art
// thumbnail, not a second picture track.
// First non-attached-pic video stream — a second "video" stream
// in a real-world file is almost always an embedded cover-art
// thumbnail, not a second picture track, and ffmpeg does not
// guarantee it comes *after* the real content stream (some mp4
// remuxes and mkvmerge outputs put it first). Explicitly
// checking `disposition.attached_pic` rather than relying on
// stream order means a cover-art-first file no longer has its
// actual video codec/height silently replaced by the
// thumbnail's.
"video" if probe.video_codec.is_none() && !is_attached_pic => {
probe.video_codec = stream["codec_name"].as_str().map(str::to_string);
probe.width = stream["width"].as_i64();
probe.height = stream["height"].as_i64();
@ -164,13 +186,14 @@ pub fn probe(path: &Path) -> Result<MediaProbe> {
});
}
"subtitle" => {
probe.subtitles.push(SubtitleStream { language });
let codec = stream["codec_name"].as_str().map(str::to_string);
probe.subtitles.push(SubtitleStream { language, codec });
}
_ => {}
}
}
Ok(probe)
probe
}
/// ffprobe reports frame rate as a "num/den" fraction string (e.g.
@ -216,6 +239,59 @@ pub fn verify_decodable(path: &Path) -> Result<DecodeCheck> {
}
}
/// Bounded, sampled variant of `verify_decodable` for callers where a full
/// decode's O(duration) cost is the actual bottleneck — the transcode
/// pipeline measured this in practice: two concurrent full-file decode
/// verifications pinned two CPU cores at 400%+ each for the whole
/// verification pass, dwarfing the GPU encode time itself for long files.
///
/// Decodes only fixed-size windows (`sample_secs` each) near the start,
/// middle, and end of the file, rather than every frame — a deliberate
/// trade of "catches most real corruption cheaply" for "bounded cost
/// regardless of file length", not equivalent thoroughness to a full
/// decode. Truncation specifically doesn't need this: `encode_and_verify`'s
/// separate duration-match check against the original already catches that
/// regardless of what this function samples, since a truncated output's
/// container-reported duration comes up short either way.
///
/// Falls back to a full `verify_decodable` when `duration_secs` is small
/// enough that sampling wouldn't save meaningful time anyway.
pub fn verify_decodable_sampled(
path: &Path,
duration_secs: f64,
sample_secs: f64,
) -> Result<DecodeCheck> {
if duration_secs <= sample_secs * 3.0 {
return verify_decodable(path);
}
let windows = [
0.0,
(duration_secs / 2.0 - sample_secs / 2.0).max(0.0),
(duration_secs - sample_secs).max(0.0),
];
for start in windows {
let mut cmd = Command::new("ffmpeg");
cmd.args(["-v", "error", "-xerror"]);
if start > 0.0 {
cmd.args(["-ss", &format!("{start:.2}")]);
}
cmd.arg("-i").arg(path);
cmd.args(["-t", &format!("{sample_secs:.2}"), "-f", "null", "-"]);
let output = cmd
.output()
.context("failed to run ffmpeg for sampled decode verification")?;
if !(output.status.success() && output.stderr.is_empty()) {
return Ok(DecodeCheck::Corrupt(
String::from_utf8_lossy(&output.stderr).into_owned(),
));
}
}
Ok(DecodeCheck::Ok)
}
#[cfg(test)]
mod tests {
use super::*;
@ -297,6 +373,78 @@ mod tests {
assert!(result.is_err());
}
fn generate_clip(dir: &Path, name: &str, duration_secs: u32) -> std::path::PathBuf {
let path = dir.join(name);
let status = Command::new("ffmpeg")
.args([
"-y",
"-f",
"lavfi",
"-i",
&format!("testsrc=size=320x240:duration={duration_secs}:rate=5"),
])
.args(["-c:v", "libx264", "-preset", "ultrafast"])
.arg(&path)
.output()
.expect("failed to run ffmpeg to generate a test clip");
assert!(
status.status.success(),
"ffmpeg failed to generate a test clip: {}",
String::from_utf8_lossy(&status.stderr)
);
path
}
#[test]
fn verify_decodable_sampled_passes_a_genuinely_intact_short_file() {
// Short enough to hit the "falls back to a full check" path.
let dir = std::env::temp_dir().join(format!(
"breadarr-ffprobe-sampled-short-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let clip = generate_clip(&dir, "short.mkv", 2);
let result = verify_decodable_sampled(&clip, 2.0, 20.0).unwrap();
assert!(matches!(result, DecodeCheck::Ok));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn verify_decodable_sampled_passes_a_genuinely_intact_long_file() {
// Long enough (duration > sample_secs * 3) to actually exercise the
// windowed start/middle/end sampling path, not the short-file
// fallback.
let dir = std::env::temp_dir().join(format!(
"breadarr-ffprobe-sampled-long-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let clip = generate_clip(&dir, "long.mkv", 10);
let result = verify_decodable_sampled(&clip, 10.0, 2.0).unwrap();
assert!(matches!(result, DecodeCheck::Ok));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn verify_decodable_sampled_flags_a_file_that_does_not_decode_at_all() {
let dir = std::env::temp_dir().join(format!(
"breadarr-ffprobe-sampled-corrupt-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("corrupt.mkv");
std::fs::write(&path, b"this is not a real video file").unwrap();
let result = verify_decodable_sampled(&path, 10.0, 2.0).unwrap();
assert!(matches!(result, DecodeCheck::Corrupt(_)));
std::fs::remove_dir_all(&dir).unwrap();
}
/// Generates a tiny real video via ffmpeg's `lavfi` synthetic source —
/// validates the actual JSON field extraction (width/height/codec/
/// duration/audio language+default) against genuine ffprobe output,
@ -376,4 +524,82 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
// Regression test for a real gap found in review: `probe`'s "first video
// stream wins" selection used to have no idea about
// `disposition.attached_pic` and just trusted stream order — a
// convention real muxers don't reliably follow (ffmpeg's own mp4 muxer
// was observed reordering an attached-pic stream to the *end*
// regardless of requested `-map` order, which makes constructing a
// genuine cover-art-*first* file via `ffmpeg` impractical — so this
// exercises `build_media_probe` directly against hand-built,
// real-shaped ffprobe JSON instead of a generated file, deterministically
// covering the ordering `ffmpeg`'s own tooling won't produce). Unlike
// `generate_clip_with_attached_pic` in `transcode::mod::tests` (cover
// art second, the already-handled case), this puts it *first* to prove
// the fix is order-independent, not just "skip the second video
// stream".
#[test]
fn probe_finds_the_real_video_stream_even_when_attached_pic_comes_first() {
let json = serde_json::json!({
"format": { "duration": "10.0", "bit_rate": "5000000", "format_long_name": "Matroska / WebM" },
"streams": [
{
"index": 0,
"codec_type": "video",
"codec_name": "png",
"width": 64,
"height": 64,
"disposition": { "attached_pic": 1 }
},
{
"index": 1,
"codec_type": "video",
"codec_name": "h264",
"width": 640,
"height": 360,
"disposition": { "attached_pic": 0 }
}
]
});
let probe = build_media_probe(&json, "{}".to_string());
assert_eq!(probe.width, Some(640), "must pick the real content stream's width, not the 64x64 cover art's");
assert_eq!(probe.height, Some(360), "must pick the real content stream's height, not the 64x64 cover art's");
assert_eq!(probe.video_codec.as_deref(), Some("h264"), "must pick the real content stream's codec, not the cover art's png");
}
// Companion case: attached-pic *second* (the ordering the code
// previously assumed was the only one) must still work exactly as
// before — this fix is additive, not a behavior change for the
// already-handled ordering.
#[test]
fn probe_finds_the_real_video_stream_when_attached_pic_comes_second() {
let json = serde_json::json!({
"format": { "duration": "10.0", "bit_rate": "5000000", "format_long_name": "Matroska / WebM" },
"streams": [
{
"index": 0,
"codec_type": "video",
"codec_name": "h264",
"width": 640,
"height": 360,
"disposition": { "attached_pic": 0 }
},
{
"index": 1,
"codec_type": "video",
"codec_name": "png",
"width": 64,
"height": 64,
"disposition": { "attached_pic": 1 }
}
]
});
let probe = build_media_probe(&json, "{}".to_string());
assert_eq!(probe.width, Some(640));
assert_eq!(probe.height, Some(360));
assert_eq!(probe.video_codec.as_deref(), Some("h264"));
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,6 @@
use anyhow::{bail, Context, Result};
#[derive(Clone)]
pub struct JellyfinClient {
base_url: String,
api_key: String,
@ -38,4 +39,33 @@ impl JellyfinClient {
}
Ok(())
}
/// Counts sessions Jellyfin is actively transcoding for right now (as
/// opposed to direct-play/direct-stream, which cost the GPU nothing) —
/// used to throttle the AV1 batch-transcode worker back so it doesn't
/// contend with a real viewer for the same encode/decode engines.
/// `TranscodingInfo` is only present on a session object while that
/// session is actually transcoding.
pub async fn active_transcode_sessions(&self) -> Result<usize> {
let resp = self
.client
.get(format!("{}/Sessions", self.base_url))
.header("X-Emby-Token", &self.api_key)
.send()
.await
.context("jellyfin sessions request failed")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
bail!("jellyfin sessions request failed: status={status} body={body:?}");
}
let sessions: Vec<serde_json::Value> =
resp.json().await.context("failed to parse jellyfin sessions response")?;
Ok(sessions
.iter()
.filter(|s| !s["TranscodingInfo"].is_null())
.count())
}
}

View file

@ -18,6 +18,7 @@ pub struct ScanReport {
pub unmatched: Vec<String>,
pub files_linked: usize,
pub files_renamed: usize,
pub files_reorganized: usize,
}
fn get_episode_title(conn: &Connection, episode_id: i64) -> Result<Option<String>> {
@ -434,7 +435,15 @@ pub async fn scan_tv_root(
report.unmatched.push(folder_name);
continue;
};
let tvdb_id: i64 = best.external_id.parse().unwrap_or_default();
let Some(tvdb_id) = metadata::parse_external_id(&best.external_id) else {
tracing::warn!(
folder = %folder_name,
external_id = %best.external_id,
"tvdb id was not a positive integer, skipping"
);
report.unmatched.push(folder_name);
continue;
};
let canonical_year = best.year.or(year);
// Normalize the folder itself down to "Title (Year)" — release
// tags/resolution/group cruft in the original folder name isn't
@ -525,6 +534,30 @@ pub async fn scan_tv_root(
}
};
// Files imported before the season-folder convention existed
// (or moved around by hand) can still be sitting flat in the
// show root — move them under `Season NN` now rather than just
// recording wherever they happen to already be. Same
// filesystem as `series_dir`, so this is a plain rename.
let season_folder = importer::season_dir(&series_dir.to_string_lossy(), season);
let final_path = if final_path.parent() != Some(season_folder.as_path()) {
match std::fs::create_dir_all(&season_folder).and_then(|_| {
let dest = season_folder.join(final_path.file_name().unwrap());
std::fs::rename(&final_path, &dest).map(|_| dest)
}) {
Ok(dest) => {
report.files_reorganized += 1;
dest
}
Err(e) => {
tracing::warn!(file = %final_path.display(), error = %e, "season-folder move failed, keeping in place");
final_path
}
}
} else {
final_path
};
let size = std::fs::metadata(&final_path)?.len();
conn.execute(
"INSERT INTO episode_file (episode_id, path, size_bytes, subtitle_status) VALUES (?1, ?2, ?3, 'none')",
@ -704,7 +737,15 @@ pub async fn scan_movie_root(
report.unmatched.push(folder_name);
continue;
};
let tmdb_id: i64 = best.external_id.parse().unwrap_or_default();
let Some(tmdb_id) = metadata::parse_external_id(&best.external_id) else {
tracing::warn!(
folder = %folder_name,
external_id = %best.external_id,
"tmdb id was not a positive integer, skipping"
);
report.unmatched.push(folder_name);
continue;
};
let canonical_year = best.year.or(year);
// Normalizes the folder itself down to "Title (Year)" too — release
@ -858,4 +899,12 @@ mod tests {
)
);
}
#[test]
fn parse_external_id_never_yields_zero() {
assert_eq!(metadata::parse_external_id("0"), None);
assert_eq!(metadata::parse_external_id("000"), None);
assert_eq!(metadata::parse_external_id("not-a-number"), None);
assert_eq!(metadata::parse_external_id("550"), Some(550));
}
}

View file

@ -11,6 +11,7 @@ mod qbit;
mod scheduler;
mod scoring;
mod sources;
mod transcode;
use std::env;
@ -114,9 +115,18 @@ async fn main() -> Result<()> {
Some("probe-library") => {
return probe_library_cmd(&config).await;
}
Some("transcode-library") => {
return transcode_library_cmd(&config).await;
}
Some("retranscode-oversized") => {
return retranscode_oversized_cmd(&config).await;
}
Some("verify-library") => {
return verify_library_cmd(&config).await;
}
Some("relink-orphaned-files") => {
return relink_orphaned_files_cmd(&config).await;
}
_ => {}
}
@ -138,6 +148,14 @@ async fn run_daemon(config: Config) -> Result<()> {
let conn = Connection::open(config.db_path())?;
db::init(&conn)?;
info!(path = %config.db_path().display(), "database ready");
match transcode::reset_orphaned_running_jobs(&conn) {
Ok(0) => {}
Ok(n) => info!(
n,
"reset orphaned 'running' transcode jobs left over from a previous crash"
),
Err(e) => tracing::warn!(error = %e, "failed to reset orphaned transcode jobs"),
}
// A second, independent connection for the HTTP API rather than sharing
// `background_loop`'s. Both point at the same on-disk (WAL-mode)
@ -153,6 +171,14 @@ async fn run_daemon(config: Config) -> Result<()> {
let listener = tokio::net::TcpListener::bind(&config.daemon.listen_addr).await?;
info!(addr = %config.daemon.listen_addr, "listening");
if config.daemon.api_token.is_empty() {
tracing::warn!(
"daemon.api_token is empty; any local process can add/delete/grab via {}",
config.daemon.listen_addr
);
} else if !config.listen_is_loopback() {
info!("API token auth is required (listen_addr is non-loopback)");
}
let tvdb = if config.tvdb.api_key.is_empty() {
None
@ -180,6 +206,10 @@ async fn run_daemon(config: Config) -> Result<()> {
)))
};
let (background_tx, background_rx) = tokio::sync::mpsc::channel(8);
// Lifted out of `background_loop` so shutdown can wait for an in-flight
// ffmpeg encode instead of dropping the process the instant SIGTERM
// arrives (systemd's TimeoutStopSec is longer than this wait).
let transcode_busy = std::sync::Arc::new(tokio::sync::Mutex::new(()));
let background_conn = std::sync::Arc::new(tokio::sync::Mutex::new(conn));
let state = api::AppState {
@ -209,6 +239,7 @@ async fn run_daemon(config: Config) -> Result<()> {
config.clone(),
state.cycle_status.clone(),
background_rx,
transcode_busy.clone(),
)
});
let mut state = state;
@ -253,6 +284,15 @@ async fn run_daemon(config: Config) -> Result<()> {
}
}
// Wait for an in-flight transcode (it holds this mutex for the whole
// cycle) so ffmpeg can finish, or at least so systemd's longer
// TimeoutStopSec applies instead of an instant drop.
info!("waiting up to 120s for in-flight transcode to finish");
match tokio::time::timeout(std::time::Duration::from_secs(120), transcode_busy.lock()).await {
Ok(_) => info!("no in-flight transcode (or it finished)"),
Err(_) => tracing::warn!("timed out waiting 120s for in-flight transcode"),
}
Ok(())
}
@ -275,6 +315,7 @@ async fn background_loop(
config: Config,
cycle_status: std::sync::Arc<std::sync::Mutex<api::CycleStatus>>,
mut background_rx: tokio::sync::mpsc::Receiver<api::BackgroundRequest>,
transcode_busy: std::sync::Arc<tokio::sync::Mutex<()>>,
) {
let notifier = notify::Notifier::new(&config.notifications.webhook_url);
{
@ -314,6 +355,26 @@ async fn background_loop(
) {
error!(error = %e, "failed to register tpb source row");
}
if let Err(e) = conn.execute(
"INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled)
VALUES (4, 'torrents-csv', 'scrape', ?1, ?2, 1)",
rusqlite::params![
config.sources.torrents_csv_url,
config.sources.search_poll_interval_secs
],
) {
error!(error = %e, "failed to register torrents-csv source row");
}
if let Err(e) = conn.execute(
"INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled)
VALUES (5, 'yts', 'scrape', ?1, ?2, 1)",
rusqlite::params![
config.sources.yts_api_url,
config.sources.search_poll_interval_secs
],
) {
error!(error = %e, "failed to register yts source row");
}
}
// A transient failure here (network blip during the one-time model
@ -336,6 +397,21 @@ async fn background_loop(
let scrape_source =
sources::scrape::ScrapeSource::new(config.sources.torrent_1337x_mirrors.clone());
let tpb_source = sources::tpb::TpbSource::new(config.sources.tpb_api_url.clone());
let torrents_csv_source =
sources::torrents_csv::TorrentsCsvSource::new(config.sources.torrents_csv_url.clone());
let yts_source = sources::yts::YtsSource::new(config.sources.yts_api_url.clone());
let search_sources = scheduler::SearchSources {
tpb: &tpb_source,
tpb_id: 3,
torrents_csv: &torrents_csv_source,
torrents_csv_id: 4,
yts: &yts_source,
yts_id: 5,
scrape: &scrape_source,
scrape_id: 2,
nyaa_search: &nyaa_source,
nyaa_id: 1,
};
let mut grab_ticker = tokio::time::interval(std::time::Duration::from_secs(
config.sources.grab_poll_interval_secs,
@ -349,6 +425,18 @@ async fn background_loop(
let mut upgrade_ticker = tokio::time::interval(std::time::Duration::from_secs(
config.sources.upgrade_poll_interval_secs,
));
// Guards against the transcode ticker itself blocking every other cycle
// (import, search, upgrade, reconcile) for the full multi-minute
// duration of an encode — the ticker below spawns each cycle detached
// rather than awaiting it inline, and this is what stops two spawned
// cycles from running at once if one is still going when the next tick
// fires (a real possibility: files easily take longer to encode than
// `poll_interval_secs`). `claim_pending_jobs`'s own concurrency cap
// already makes overlap *safe*; this just keeps it from happening
// pointlessly. Created in `run_daemon` so shutdown can wait on it.
let mut transcode_ticker = tokio::time::interval(std::time::Duration::from_secs(
config.transcode.poll_interval_secs,
));
// Disk state doesn't change on its own — hourly is plenty to catch a
// file deleted/moved by hand without adding meaningful load (one query
// per tracked episode file, all local). Deliberately does *not* fire at
@ -366,6 +454,7 @@ async fn background_loop(
search_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
upgrade_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
reconcile_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
transcode_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
// Cycle-level backoff on top of the search loop's own per-mirror
// cooldowns: a whole cycle failing (source exhausted, or an outright
@ -378,6 +467,9 @@ async fn background_loop(
loop {
tokio::select! {
_ = grab_ticker.tick() => {
if !config.sources.grab_enabled {
continue;
}
let result = {
let conn = conn.lock().await;
scheduler::run_grab_cycle(&conn, &nyaa_source, 1, &mut title_matcher, &qbit, &config.qbit.category).await
@ -404,7 +496,7 @@ async fn background_loop(
_ = import_ticker.tick() => {
let result = {
let conn = conn.lock().await;
importer::run_import_cycle(&conn, &qbit, jellyfin.as_ref(), &config.qbit.category, &config.qbit.container_downloads_path, &config.qbit.host_downloads_path).await
importer::run_import_cycle(&conn, &qbit, jellyfin.as_ref(), &config.qbit.category, &config.qbit.container_downloads_path, &config.qbit.host_downloads_path, config.transcode.enabled.then_some(&config.transcode)).await
};
if let (Ok(stats), Some(n)) = (&result, &notifier) {
if stats.failed > 0 {
@ -444,9 +536,7 @@ async fn background_loop(
let conn = conn.lock().await;
scheduler::run_search_cycle(
&conn,
&tpb_source, 3,
&scrape_source, 2,
&nyaa_source, 1,
&search_sources,
&mut title_matcher,
&qbit, &config.qbit.category,
config.sources.search_budget_per_cycle,
@ -505,9 +595,7 @@ async fn background_loop(
let conn = conn.lock().await;
scheduler::run_upgrade_cycle(
&conn,
&tpb_source, 3,
&scrape_source, 2,
&nyaa_source, 1,
&search_sources,
&mut title_matcher,
&qbit, &config.qbit.category,
config.sources.upgrade_budget_per_cycle,
@ -533,6 +621,38 @@ async fn background_loop(
}
}
}
_ = transcode_ticker.tick() => {
if !config.transcode.enabled {
continue;
}
let Ok(busy_permit) = transcode_busy.clone().try_lock_owned() else {
// A previous cycle is still running (this file's
// encode took longer than one poll interval) — skip
// this tick rather than spawning a second overlapping
// one; the still-running cycle will pick up any newly
// pending jobs on its own next iteration anyway.
continue;
};
let conn = conn.clone();
let cfg = config.transcode.clone();
let jellyfin = jellyfin.clone();
let cycle_status = cycle_status.clone();
tokio::spawn(async move {
let _permit = busy_permit;
let result = transcode::run_cycle(conn, cfg, jellyfin.as_ref()).await;
let record = match &result {
Ok(stats) => {
info!(?stats, "transcode cycle complete");
api::CycleRecord { at: chrono::Utc::now(), ok: true, detail: format!("{stats:?}") }
}
Err(e) => {
error!(error = %e, "transcode cycle failed");
api::CycleRecord { at: chrono::Utc::now(), ok: false, detail: e.to_string() }
}
};
cycle_status.lock().expect("cycle_status poisoned").last_transcode = Some(record);
});
}
_ = reconcile_ticker.tick() => {
let result = {
let conn = conn.lock().await;
@ -569,10 +689,11 @@ async fn background_loop(
// repaired (a rename or an in-place transcode) gets re-probed
// immediately rather than waiting for its own turn a full
// interval later.
match {
let probe_result = {
let conn = conn.lock().await;
importer::probe_library(&conn)
} {
};
match probe_result {
Ok(report) if report.probed > 0 || report.failed > 0 => {
info!(?report, "media probe sweep complete");
}
@ -589,9 +710,7 @@ async fn background_loop(
Ok(targets) => scheduler::execute_search_targets(
&conn,
&targets,
&tpb_source, 3,
&scrape_source, 2,
&nyaa_source, 1,
&search_sources,
&mut title_matcher,
&qbit, &config.qbit.category,
).await,
@ -615,9 +734,7 @@ async fn background_loop(
&conn,
media_item_id,
episode_id,
&tpb_source, 3,
&scrape_source, 2,
&nyaa_source, 1,
&search_sources,
).await
};
let _ = reply.send(result);
@ -846,6 +963,7 @@ async fn debug_import_cycle(config: &Config) -> Result<()> {
&config.qbit.category,
&config.qbit.container_downloads_path,
&config.qbit.host_downloads_path,
config.transcode.enabled.then_some(&config.transcode),
)
.await?;
println!("{stats:?}");
@ -927,11 +1045,12 @@ async fn debug_scan_tv(config: &Config, path: &str) -> Result<()> {
.await?;
println!(
"matched={} unmatched={} files_linked={} files_renamed={}",
"matched={} unmatched={} files_linked={} files_renamed={} files_reorganized={}",
report.matched.len(),
report.unmatched.len(),
report.files_linked,
report.files_renamed
report.files_renamed,
report.files_reorganized
);
if !report.unmatched.is_empty() {
println!("unmatched:");
@ -974,11 +1093,12 @@ async fn debug_scan_movies(config: &Config, path: &str) -> Result<()> {
.await?;
println!(
"matched={} unmatched={} files_linked={} files_renamed={}",
"matched={} unmatched={} files_linked={} files_renamed={} files_reorganized={}",
report.matched.len(),
report.unmatched.len(),
report.files_linked,
report.files_renamed
report.files_renamed,
report.files_reorganized
);
if !report.unmatched.is_empty() {
println!("unmatched:");
@ -1040,6 +1160,22 @@ async fn debug_search_show(config: &Config, title: &str) -> Result<()> {
config.sources.search_poll_interval_secs
],
)?;
conn.execute(
"INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled)
VALUES (4, 'torrents-csv', 'scrape', ?1, ?2, 1)",
rusqlite::params![
config.sources.torrents_csv_url,
config.sources.search_poll_interval_secs
],
)?;
conn.execute(
"INSERT OR IGNORE INTO source (id, name, kind, base_url, poll_interval_secs, enabled)
VALUES (5, 'yts', 'scrape', ?1, ?2, 1)",
rusqlite::params![
config.sources.yts_api_url,
config.sources.search_poll_interval_secs
],
)?;
let qbit = QbitClient::new(config.qbit.base_url.clone())?;
if !config.qbit.username.is_empty() {
@ -1051,6 +1187,21 @@ async fn debug_search_show(config: &Config, title: &str) -> Result<()> {
let scrape_source =
sources::scrape::ScrapeSource::new(config.sources.torrent_1337x_mirrors.clone());
let nyaa_source = sources::rss::RssSource::new(config.sources.nyaa_rss_url.clone());
let torrents_csv_source =
sources::torrents_csv::TorrentsCsvSource::new(config.sources.torrents_csv_url.clone());
let yts_source = sources::yts::YtsSource::new(config.sources.yts_api_url.clone());
let search_sources = scheduler::SearchSources {
tpb: &tpb_source,
tpb_id: 3,
torrents_csv: &torrents_csv_source,
torrents_csv_id: 4,
yts: &yts_source,
yts_id: 5,
scrape: &scrape_source,
scrape_id: 2,
nyaa_search: &nyaa_source,
nyaa_id: 1,
};
let targets = scheduler::enumerate_search_targets_for_media_item(&conn, media_item_id)?;
println!("{} missing episode(s)/movie for {title:?}", targets.len());
@ -1058,12 +1209,7 @@ async fn debug_search_show(config: &Config, title: &str) -> Result<()> {
let stats = scheduler::execute_search_targets(
&conn,
&targets,
&tpb_source,
3,
&scrape_source,
2,
&nyaa_source,
1,
&search_sources,
&mut title_matcher,
&qbit,
&config.qbit.category,
@ -1148,6 +1294,44 @@ async fn remux_backlog_cmd(config: &Config) -> Result<()> {
/// doesn't stall the grab/import/search cycles; this command is for
/// immediately backfilling that same backlog by hand instead of waiting for
/// it to trickle in over several hours.
/// One-time reconciliation for episodes whose `episode_file` association
/// went missing (almost certainly the earlier DB-recovery incident) despite
/// their real file still sitting exactly where breadarr's own importer
/// would have put it — see `importer::find_relinkable_episode_files`'s doc
/// comment for the full story. Purely additive: reports what it found
/// before touching anything, never moves/deletes/overwrites a single file,
/// and flags ambiguous matches for a human to look at rather than guessing.
async fn relink_orphaned_files_cmd(config: &Config) -> Result<()> {
if let Some(parent) = config.db_path().parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(config.db_path())?;
db::init(&conn)?;
let (candidates, ambiguous) = importer::find_relinkable_episode_files(&conn)?;
println!(
"found {} orphaned episode file(s) to relink, {} ambiguous case(s) left for manual review",
candidates.len(),
ambiguous.len()
);
for c in &candidates {
println!(
" relink: {} S{:02}E{:02} -> {}",
c.series_title,
c.season_number,
c.episode_number,
c.path.display()
);
}
for a in &ambiguous {
println!(" ambiguous, skipped: {a}");
}
let linked = importer::relink_episode_files(&conn, &candidates)?;
println!("done: {linked} episode(s) relinked");
Ok(())
}
async fn probe_library_cmd(config: &Config) -> Result<()> {
if let Some(parent) = config.db_path().parent() {
std::fs::create_dir_all(parent)?;
@ -1170,6 +1354,128 @@ async fn probe_library_cmd(config: &Config) -> Result<()> {
Ok(())
}
/// One-time backfill: enqueues every existing-library file eligible for
/// AV1 transcoding (see `transcode::find_backlog_candidates` for the exact
/// eligibility rules — not already AV1, not anime, not HDR/2160p+ per the
/// first pass's scope) as a `transcode_job` row, then drives the same
/// worker loop the daemon's steady-state ticker uses
/// (`transcode::run_cycle`) until nothing is left pending. Shares that one
/// code path deliberately — there is exactly one place that actually runs
/// an encode, whether triggered by a backlog sweep or a fresh grab.
// Deliberately does NOT call `transcode::reset_orphaned_running_jobs` on
// startup the way `run_daemon` does — from this CLI's vantage point a
// `running` row could belong to the actual daemon's own ticker legitimately
// working on it right now (see the safety writeup on `claim_pending_jobs`:
// running this backfill alongside a live daemon with transcode enabled is
// intentionally supported, the two now share one atomic, count-aware
// concurrency cap), and there's no reliable way to tell that apart from a
// genuinely orphaned row from a ago-crashed run of this same command.
// If *this* command itself is killed mid-run, its claimed jobs stay
// `running` until the daemon is next restarted (which does its own reset).
async fn transcode_library_cmd(config: &Config) -> Result<()> {
if !config.transcode.enabled {
bail!("transcode.enabled is false in config — enable it before running a backfill");
}
if let Some(parent) = config.db_path().parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(config.db_path())?;
db::init(&conn)?;
let candidates = transcode::find_backlog_candidates(&conn, &config.transcode)?;
println!(
"found {} backlog candidate(s), highest-bitrate first",
candidates.len()
);
for candidate in &candidates {
transcode::enqueue(
&conn,
candidate.episode_file_id,
candidate.video_codec.as_deref(),
candidate.size_bytes,
candidate.is_anime,
false,
)?;
}
drive_transcode_queue_to_completion(conn, config).await
}
/// Re-transcodes files a real rate-control bug left larger than their
/// original (already AV1, so `transcode-library`'s own backfill query
/// excludes them) — see `transcode::find_oversized_av1_candidates`'s doc
/// comment for the full story. Enqueues with `force_reencode: true`, the
/// only thing that lets `encode_and_verify` attempt a real re-encode of a
/// file that's already AV1 rather than treating it as nothing-to-do.
async fn retranscode_oversized_cmd(config: &Config) -> Result<()> {
if !config.transcode.enabled {
bail!("transcode.enabled is false in config — enable it before running this");
}
if let Some(parent) = config.db_path().parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open(config.db_path())?;
db::init(&conn)?;
let candidates = transcode::find_oversized_av1_candidates(&conn, &config.transcode)?;
println!(
"found {} oversized AV1 file(s) to re-transcode, worst offenders first",
candidates.len()
);
for candidate in &candidates {
transcode::enqueue(
&conn,
candidate.episode_file_id,
candidate.video_codec.as_deref(),
candidate.size_bytes,
candidate.is_anime,
true,
)?;
}
drive_transcode_queue_to_completion(conn, config).await
}
/// Shared by `transcode_library_cmd` and `retranscode_oversized_cmd`: drains
/// whatever's now `pending` in `transcode_job` via repeated `run_cycle`
/// calls until nothing is left, printing a running total. The only
/// difference between the two commands is which candidates got enqueued
/// (and with what `force_reencode` value) before this runs — there's
/// exactly one place that actually drives the worker loop to completion.
async fn drive_transcode_queue_to_completion(conn: Connection, config: &Config) -> Result<()> {
let jellyfin = if config.jellyfin.base_url.is_empty() {
None
} else {
Some(JellyfinClient::new(
config.jellyfin.base_url.clone(),
config.jellyfin.api_key.clone(),
))
};
let conn = std::sync::Arc::new(tokio::sync::Mutex::new(conn));
let mut total_succeeded = 0usize;
let mut total_skipped = 0usize;
let mut total_failed = 0usize;
let mut total_bytes_saved: i64 = 0;
loop {
let stats =
transcode::run_cycle(conn.clone(), config.transcode.clone(), jellyfin.as_ref()).await?;
if stats.attempted == 0 {
break;
}
total_succeeded += stats.succeeded;
total_skipped += stats.skipped;
total_failed += stats.failed;
total_bytes_saved += stats.bytes_saved;
println!("batch: {stats:?}");
}
println!(
"done: {total_succeeded} succeeded, {total_skipped} skipped (not beneficial), {total_failed} failed, {:.1} GB saved total",
total_bytes_saved as f64 / 1_073_741_824.0
);
Ok(())
}
/// Runs `importer::verify_library` — the expensive full-decode corruption
/// check (`ffmpeg -xerror`, actually decoding every frame) against every
/// header-probed-ok file that hasn't been decode-verified yet. Unlike

View file

@ -4,14 +4,20 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rusqlite::{params, Connection};
use rusqlite::{params, Connection, OptionalExtension};
use embed::{cosine_similarity, OrtEmbedder};
// Pinned to Xenova/all-MiniLM-L6-v2 @ 751bff37182d3f1213fa05d7196b954e230abad9
// (current `main` as of this change) — not the floating `main` branch.
const MODEL_URL: &str =
"https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx";
"https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/751bff37182d3f1213fa05d7196b954e230abad9/onnx/model.onnx";
const TOKENIZER_URL: &str =
"https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/main/tokenizer.json";
"https://huggingface.co/Xenova/all-MiniLM-L6-v2/resolve/751bff37182d3f1213fa05d7196b954e230abad9/tokenizer.json";
// Official LFS sha256 of onnx/model.onnx at that commit.
const MODEL_SHA256: &str = "759c3cd2b7fe7e93933ad23c4c9181b7396442a2ed746ec7c1d46192c469c46e";
// sha256 of tokenizer.json at the same revision (not LFS; hashed from the published file).
const TOKENIZER_SHA256: &str = "da0e79933b9ed51798a3ae27893d3c5fa4a201126cef75586296df9b4d2c62a0";
/// Downloads the embedding model into `model_dir` if it isn't already
/// there — keeps setup to "run the daemon," no separate fetch step, in
@ -32,17 +38,19 @@ pub async fn ensure_model(model_dir: &Path) -> Result<(PathBuf, PathBuf)> {
if !model_path.exists() {
tracing::info!("downloading title-matching model (~90MB, one-time)");
download(MODEL_URL, model_path.clone()).await?;
download(MODEL_URL, model_path.clone(), MODEL_SHA256).await?;
}
if !tokenizer_path.exists() {
download(TOKENIZER_URL, tokenizer_path.clone()).await?;
download(TOKENIZER_URL, tokenizer_path.clone(), TOKENIZER_SHA256).await?;
}
Ok((model_path, tokenizer_path))
}
async fn download(url: &'static str, dest: PathBuf) -> Result<()> {
tokio::task::spawn_blocking(move || bread_onnx::download::ensure_file(url, &dest, None))
async fn download(url: &'static str, dest: PathBuf, sha256: &'static str) -> Result<()> {
tokio::task::spawn_blocking(move || {
bread_onnx::download::ensure_file(url, &dest, Some(sha256))
})
.await
.context("download task panicked")??;
Ok(())
@ -96,6 +104,17 @@ impl TitleMatcher {
})
}
/// Caches by text — appropriate for candidate-side text only (library
/// `media_item` titles/aliases, or a metadata provider's small,
/// bounded result list), which the same daemon process legitimately
/// re-embeds across many calls. Deliberately never used for the query
/// side (see `embed_query`): `TitleMatcher` lives for the whole life of
/// `background_loop`, which never returns, and the query is a
/// freshly-parsed release title from every RSS item and search result
/// the daemon ever sees — almost never repeated verbatim. Caching those
/// too grew this `HashMap` without bound for the process's entire
/// (months-long) lifetime, a slow but real leak on a box also running
/// several GB of concurrent GPU/CPU transcode work.
fn embed_cached(&mut self, text: &str) -> Result<Vec<f32>> {
if let Some(v) = self.cache.get(text) {
return Ok(v.clone());
@ -105,6 +124,12 @@ impl TitleMatcher {
Ok(v)
}
/// The query-side counterpart to `embed_cached` — same embedding, never
/// stored in `self.cache`. See `embed_cached`'s doc comment for why.
fn embed_query(&mut self, text: &str) -> Result<Vec<f32>> {
self.embedder.embed(text)
}
/// Given a flat list of candidate texts (e.g. every search result's
/// name plus its aliases, flattened with an index back to which result
/// each one belongs to), returns the index of whichever candidate text
@ -121,7 +146,7 @@ impl TitleMatcher {
query: &str,
candidates: &[(usize, String)],
) -> Result<Option<(usize, f32)>> {
let query_emb = self.embed_cached(query)?;
let query_emb = self.embed_query(query)?;
let mut best: Option<(usize, f32)> = None;
for (owner_index, text) in candidates {
let emb = self.embed_cached(text)?;
@ -137,7 +162,7 @@ impl TitleMatcher {
/// aliases, returning the single best match and whether it clears the
/// auto-match bar or needs a human to confirm it in the review queue.
pub fn match_title(&mut self, conn: &Connection, query: &str) -> Result<MatchOutcome> {
let query_emb = self.embed_cached(query)?;
let query_emb = self.embed_query(query)?;
let mut stmt = conn.prepare(
"SELECT id, title FROM media_item WHERE monitored = 1
@ -233,6 +258,36 @@ pub fn queue_for_review(
link: Option<&str>,
source_id: Option<i64>,
) -> Result<i64> {
// Same release + same library row already sitting in pending: a
// no-op instead of another TUI row. Upgrade-search used to re-list
// the same movie 711 times (verified live on hestia).
if let Some(existing) = conn
.query_row(
"SELECT id FROM review_queue
WHERE status = 'pending'
AND candidate_media_item_id = ?1
AND raw_release_title = ?2",
params![candidate.media_item_id, raw_release_title],
|row| row.get(0),
)
.optional()?
{
return Ok(existing);
}
if let (Some(link), Some(source_id)) = (link, source_id) {
if let Some(existing) = conn
.query_row(
"SELECT id FROM review_queue
WHERE status = 'pending' AND source_id = ?1 AND link = ?2",
params![source_id, link],
|row| row.get(0),
)
.optional()?
{
return Ok(existing);
}
}
conn.execute(
"INSERT INTO review_queue (raw_release_title, candidate_media_item_id, confidence, link, source_id, status, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, 'pending', datetime('now'))",
@ -306,4 +361,81 @@ mod tests {
0.0
);
}
fn review_queue_conn() -> Connection {
let conn = Connection::open_in_memory().unwrap();
crate::db::init(&conn).unwrap();
conn.execute(
"INSERT INTO media_item (id, kind, title, monitored, quality_profile_id, root_folder)
VALUES (1, 'movie', 'Cars 3', 1, 2, '/tmp')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')",
[],
)
.unwrap();
conn
}
fn review_candidate() -> MatchCandidate {
MatchCandidate {
media_item_id: 1,
matched_text: "Cars 3".into(),
confidence: 0.7,
}
}
#[test]
fn queue_for_review_is_idempotent_for_the_same_pending_title() {
let conn = review_queue_conn();
let c = review_candidate();
let first = queue_for_review(&conn, "Cars.3.2017.1080p", &c, Some("magnet:a"), Some(1)).unwrap();
let second =
queue_for_review(&conn, "Cars.3.2017.1080p", &c, Some("magnet:a"), Some(1)).unwrap();
assert_eq!(first, second);
let n: i64 = conn
.query_row(
"SELECT count(*) FROM review_queue WHERE status = 'pending'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(n, 1);
}
#[test]
fn queue_for_review_is_idempotent_for_the_same_pending_link() {
let conn = review_queue_conn();
let c = review_candidate();
let first =
queue_for_review(&conn, "Cars.3.2017.1080p", &c, Some("magnet:same"), Some(1)).unwrap();
let second = queue_for_review(
&conn,
"Cars.3.2017.2160p.UHD",
&c,
Some("magnet:same"),
Some(1),
)
.unwrap();
assert_eq!(first, second);
let n: i64 = conn
.query_row(
"SELECT count(*) FROM review_queue WHERE status = 'pending'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(n, 1);
}
#[test]
fn queue_for_review_still_accepts_a_different_title_and_link() {
let conn = review_queue_conn();
let c = review_candidate();
let a = queue_for_review(&conn, "Cars.3.2017.1080p", &c, Some("magnet:a"), Some(1)).unwrap();
let b = queue_for_review(&conn, "Cars.3.2017.2160p", &c, Some("magnet:b"), Some(1)).unwrap();
assert_ne!(a, b);
}
}

View file

@ -5,7 +5,7 @@ pub mod tvdb;
use std::collections::HashSet;
use anyhow::Result;
use rusqlite::{params, Connection};
use rusqlite::{params, Connection, OptionalExtension};
#[derive(Debug, Clone, PartialEq)]
pub struct SeriesSearchResult {
@ -41,6 +41,7 @@ pub struct EpisodeInfo {
/// `MutexGuard` across an `.await` point — this convenience wrapper is only
/// safe for callers with an owned, unshared `Connection` (e.g. the debug
/// CLI commands).
#[allow(clippy::too_many_arguments)]
pub async fn add_series(
conn: &Connection,
tvdb: &tvdb::TvdbClient,
@ -64,6 +65,21 @@ pub async fn add_series(
)
}
/// TVDB/TMDB ids are positive integers. `"0"` and non-numeric strings
/// must not be stored — `0` would collide under the unique external-id
/// indexes the same way a parse-failure `unwrap_or_default()` used to.
pub(crate) fn parse_external_id(raw: &str) -> Option<i64> {
raw.parse().ok().filter(|&id| id > 0)
}
fn existing_media_item_id(conn: &Connection, column: &str, value: i64) -> Result<Option<i64>> {
debug_assert!(column == "tvdb_id" || column == "tmdb_id");
let sql = format!("SELECT id FROM media_item WHERE {column} = ?1 ORDER BY id ASC LIMIT 1");
conn.query_row(&sql, params![value], |row| row.get(0))
.optional()
.map_err(Into::into)
}
#[allow(clippy::too_many_arguments)]
pub fn insert_series(
conn: &Connection,
@ -75,21 +91,23 @@ pub fn insert_series(
quality_profile_id: i64,
episodes: &[EpisodeInfo],
) -> Result<i64> {
conn.execute(
let tvdb_id = parse_external_id(tvdb_series_id);
if let Some(tvdb_id) = tvdb_id {
if let Some(existing) = existing_media_item_id(conn, "tvdb_id", tvdb_id)? {
return Ok(existing);
}
}
let tx = conn.unchecked_transaction()?;
tx.execute(
"INSERT INTO media_item (kind, title, year, tvdb_id, monitored, quality_profile_id, root_folder)
VALUES ('series', ?1, ?2, ?3, 1, ?4, ?5)",
params![
title,
year,
tvdb_series_id.parse::<i64>().ok(),
quality_profile_id,
root_folder
],
params![title, year, tvdb_id, quality_profile_id, root_folder],
)?;
let media_item_id = conn.last_insert_rowid();
let media_item_id = tx.last_insert_rowid();
for alias in aliases {
conn.execute(
tx.execute(
"INSERT INTO alias (media_item_id, text, source) VALUES (?1, ?2, 'tvdb')",
params![media_item_id, alias],
)?;
@ -97,27 +115,39 @@ pub fn insert_series(
let mut seasons_seen = HashSet::new();
for ep in episodes {
// TVDB's "season 0" is a catch-all for specials — recaps, shorts,
// and often a tie-in movie that's frequently already tracked as
// its own separate `media_item` (verified live: Chainsaw Man's
// season 0 included "Chainsaw Man The Movie: Reze Arc", which
// already exists as its own movie entry). Monitoring these by
// default means the library never actually "completes" and the
// missing-episode count is inflated with content the user was
// never trying to acquire as episodes in the first place. Regular
// seasons keep the previous default of monitored.
let monitored = i64::from(ep.season_number != 0);
if seasons_seen.insert(ep.season_number) {
conn.execute(
"INSERT OR IGNORE INTO season (media_item_id, season_number, monitored) VALUES (?1, ?2, 1)",
params![media_item_id, ep.season_number],
tx.execute(
"INSERT OR IGNORE INTO season (media_item_id, season_number, monitored) VALUES (?1, ?2, ?3)",
params![media_item_id, ep.season_number, monitored],
)?;
}
conn.execute(
tx.execute(
"INSERT OR IGNORE INTO episode
(media_item_id, season_number, episode_number, absolute_number, title, air_date, monitored, has_file)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 0)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0)",
params![
media_item_id,
ep.season_number,
ep.episode_number,
ep.absolute_number,
ep.title,
ep.air_date
ep.air_date,
monitored,
],
)?;
}
tx.commit()?;
Ok(media_item_id)
}
@ -133,16 +163,177 @@ pub fn insert_movie(
root_folder: &str,
quality_profile_id: i64,
) -> Result<i64> {
let tmdb_id = parse_external_id(tmdb_movie_id);
if let Some(tmdb_id) = tmdb_id {
if let Some(existing) = existing_media_item_id(conn, "tmdb_id", tmdb_id)? {
return Ok(existing);
}
}
conn.execute(
"INSERT INTO media_item (kind, title, year, tmdb_id, monitored, quality_profile_id, root_folder)
VALUES ('movie', ?1, ?2, ?3, 1, ?4, ?5)",
params![
title,
year,
tmdb_movie_id.parse::<i64>().ok(),
quality_profile_id,
root_folder
],
params![title, year, tmdb_id, quality_profile_id, root_folder],
)?;
Ok(conn.last_insert_rowid())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
fn conn() -> Connection {
let conn = Connection::open_in_memory().unwrap();
db::init(&conn).unwrap();
conn
}
fn ep(season: u32, episode: u32) -> EpisodeInfo {
EpisodeInfo {
season_number: season,
episode_number: episode,
absolute_number: None,
title: Some(format!("E{episode}")),
air_date: Some("2020-01-01".into()),
}
}
#[test]
fn parse_external_id_rejects_zero_and_garbage() {
assert_eq!(parse_external_id("550"), Some(550));
assert_eq!(parse_external_id("0"), None);
assert_eq!(parse_external_id("-12"), None);
assert_eq!(parse_external_id("not-a-number"), None);
assert_eq!(parse_external_id(""), None);
}
#[test]
fn insert_series_writes_seasons_and_episodes_together() {
let conn = conn();
let id = insert_series(
&conn,
"12345",
"Show",
Some(2020),
&["Alias".into()],
"/tv/Show",
1,
&[ep(1, 1), ep(1, 2), ep(2, 1)],
)
.unwrap();
let seasons: i64 = conn
.query_row(
"SELECT count(*) FROM season WHERE media_item_id = ?1",
[id],
|r| r.get(0),
)
.unwrap();
let episodes: i64 = conn
.query_row(
"SELECT count(*) FROM episode WHERE media_item_id = ?1",
[id],
|r| r.get(0),
)
.unwrap();
let aliases: i64 = conn
.query_row(
"SELECT count(*) FROM alias WHERE media_item_id = ?1",
[id],
|r| r.get(0),
)
.unwrap();
assert_eq!(seasons, 2);
assert_eq!(episodes, 3);
assert_eq!(aliases, 1);
}
#[test]
fn insert_series_rolls_back_when_an_episode_insert_fails() {
let conn = conn();
conn.execute(
"CREATE TRIGGER fail_episode BEFORE INSERT ON episode
BEGIN SELECT RAISE(ABORT, 'boom'); END",
[],
)
.unwrap();
let err = insert_series(
&conn,
"99",
"Show",
None,
&["A".into()],
"/tv/Show",
1,
&[ep(1, 1)],
);
assert!(err.is_err());
let items: i64 = conn
.query_row("SELECT count(*) FROM media_item", [], |r| r.get(0))
.unwrap();
let seasons: i64 = conn
.query_row("SELECT count(*) FROM season", [], |r| r.get(0))
.unwrap();
let aliases: i64 = conn
.query_row("SELECT count(*) FROM alias", [], |r| r.get(0))
.unwrap();
assert_eq!(items, 0);
assert_eq!(seasons, 0);
assert_eq!(aliases, 0);
}
#[test]
fn insert_series_is_idempotent_on_tvdb_id() {
let conn = conn();
let first = insert_series(
&conn,
"12345",
"Show",
None,
&[],
"/tv/Show",
1,
&[ep(1, 1)],
)
.unwrap();
let second = insert_series(
&conn,
"12345",
"Other Title",
None,
&[],
"/tv/Other",
1,
&[],
)
.unwrap();
assert_eq!(first, second);
let count: i64 = conn
.query_row(
"SELECT count(*) FROM media_item WHERE tvdb_id = 12345",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn insert_movie_is_idempotent_on_tmdb_id() {
let conn = conn();
let first = insert_movie(&conn, "550", "Fight Club", Some(1999), "/movies", 2).unwrap();
let second = insert_movie(&conn, "550", "Fight Club 2", Some(2000), "/movies", 2).unwrap();
assert_eq!(first, second);
let count: i64 = conn
.query_row(
"SELECT count(*) FROM media_item WHERE tmdb_id = 550",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
}

View file

@ -1,7 +1,7 @@
use anyhow::{Context, Result};
use serde::Deserialize;
use super::{EpisodeInfo, MovieSearchResult, SeriesSearchResult};
use super::MovieSearchResult;
pub struct TmdbClient {
bearer_token: String,
@ -22,44 +22,6 @@ impl TmdbClient {
}
}
pub async fn search_tv(&self, query: &str) -> Result<Vec<SeriesSearchResult>> {
#[derive(Deserialize)]
struct SearchResponse {
results: Vec<TvItem>,
}
#[derive(Deserialize)]
struct TvItem {
id: u64,
name: String,
first_air_date: Option<String>,
}
let resp: SearchResponse = self
.client
.get("https://api.themoviedb.org/3/search/tv")
.bearer_auth(&self.bearer_token)
.query(&[("query", query)])
.send()
.await
.context("tmdb tv search request failed")?
.error_for_status()
.context("tmdb tv search returned an error status")?
.json()
.await
.context("tmdb tv search response was not valid JSON")?;
Ok(resp
.results
.into_iter()
.map(|item| SeriesSearchResult {
external_id: item.id.to_string(),
name: item.name,
year: year_from_date(item.first_air_date.as_deref()),
aliases: Vec::new(),
})
.collect())
}
pub async fn search_movie(&self, query: &str) -> Result<Vec<MovieSearchResult>> {
#[derive(Deserialize)]
struct SearchResponse {
@ -96,48 +58,6 @@ impl TmdbClient {
})
.collect())
}
pub async fn tv_season_episodes(&self, tv_id: u64, season: u32) -> Result<Vec<EpisodeInfo>> {
#[derive(Deserialize)]
struct SeasonResponse {
#[serde(default)]
episodes: Vec<EpisodeItem>,
}
#[derive(Deserialize)]
struct EpisodeItem {
season_number: u32,
episode_number: u32,
name: Option<String>,
air_date: Option<String>,
}
let resp: SeasonResponse = self
.client
.get(format!(
"https://api.themoviedb.org/3/tv/{tv_id}/season/{season}"
))
.bearer_auth(&self.bearer_token)
.send()
.await
.context("tmdb season request failed")?
.error_for_status()
.context("tmdb season returned an error status")?
.json()
.await
.context("tmdb season response was not valid JSON")?;
Ok(resp
.episodes
.into_iter()
.map(|e| EpisodeInfo {
season_number: e.season_number,
episode_number: e.episode_number,
absolute_number: None,
title: e.name,
air_date: e.air_date,
})
.collect())
}
}
fn year_from_date(date: Option<&str>) -> Option<u32> {

View file

@ -110,8 +110,6 @@ impl TvdbClient {
}
pub async fn episodes(&self, series_id: &str) -> Result<Vec<EpisodeInfo>> {
let token = self.token().await?;
#[derive(Deserialize)]
struct EpisodesResponse {
data: EpisodesData,
@ -131,20 +129,47 @@ impl TvdbClient {
aired: Option<String>,
}
let resp: EpisodesResponse = self
.client
.get(format!(
"https://api4.thetvdb.com/v4/series/{series_id}/episodes/default"
))
// TVDB's plain `/episodes/default` returns names in the show's
// original airing language — for a lot of anime that's Japanese,
// with no English name at all (verified live: entire shows came
// back Japanese-only). `/episodes/default/eng` is the same episode
// list (same numbering/air-date fields) but with TVDB's own
// crowd-sourced English translation substituted in for `name`
// wherever one exists — a real translation, not a guess, so it's
// tried first and only falls back to the original-language
// endpoint if TVDB has no English data for this show at all.
let token = self.token().await?;
let fetch = |url: String| {
let client = self.client.clone();
let token = token.clone();
async move {
client
.get(url)
.bearer_auth(token)
.send()
.await
.context("tvdb episodes request failed")?
.error_for_status()
.context("tvdb episodes returned an error status")?
.json()
.json::<EpisodesResponse>()
.await
.context("tvdb episodes response was not valid JSON")?;
.context("tvdb episodes response was not valid JSON")
}
};
let resp = match fetch(format!(
"https://api4.thetvdb.com/v4/series/{series_id}/episodes/default/eng"
))
.await
{
Ok(resp) => resp,
Err(_) => {
fetch(format!(
"https://api4.thetvdb.com/v4/series/{series_id}/episodes/default"
))
.await?
}
};
Ok(resp
.data

View file

@ -1,4 +1,3 @@
use anyhow::Result;
use serde::Serialize;
#[derive(Serialize)]

View file

@ -34,6 +34,7 @@ pub struct ParsedRelease {
pub bit_depth: Option<u8>,
pub container: Option<String>,
pub is_repack: bool,
pub has_hdr: bool,
}
pub fn parse(raw_title: &str) -> ParsedRelease {
@ -53,6 +54,7 @@ pub fn parse(raw_title: &str) -> ParsedRelease {
let codec = tokens::extract_codec(&work);
let bit_depth = tokens::extract_bit_depth(&work);
let is_repack = tokens::REPACK_RE.is_match(&work);
let has_hdr = tokens::extract_hdr(&work);
let year = tokens::extract_year(&work);
let (season, episode, absolute_episode, title_span_end) = tokens::extract_episode_info(&work);
@ -72,6 +74,7 @@ pub fn parse(raw_title: &str) -> ParsedRelease {
bit_depth,
container,
is_repack,
has_hdr,
}
}
@ -131,6 +134,16 @@ mod tests {
assert_eq!(p.title_normalized, "Ascendance of a Bookworm");
}
#[test]
fn parses_sxxexx_with_a_trailing_fansub_revision_tag() {
// "v2" glued directly onto the episode number ("a fixed re-release
// of this episode") previously broke the word-boundary check
// entirely, leaving season/episode both None.
let p = parse("[Judas] Chainsaw Man - S01E01v2 1080p WEB-DL");
assert_eq!(p.season, Some(1));
assert_eq!(p.episode, Some(1));
}
#[test]
fn parses_subsplease_dash_episode_with_group_and_hash() {
let p = parse("[SubsPlease] Honzuki no Gekokujou S4 - 13 (1080p) [A4FE0990].mkv");
@ -171,6 +184,78 @@ mod tests {
assert_eq!(p.resolution, Some(1080));
}
#[test]
fn parses_season_pack_shapes_without_literal_parens() {
// Real review-queue entries that all failed to resolve at all
// before this fix — season came back None, not just unmatched
// episode — because SEASON_PACK_RE required a literal
// "(S?N Complete)" shape.
assert_eq!(
parse("Modern.Family.S10.COMPLETE.720p.AMZN.WEBRip.x264-GalaxyTV").season,
Some(10)
);
assert_eq!(
parse("Modern Family 2009 Season 8 Complete 720p AMZN WEBRip x264 [i_c]").season,
Some(8)
);
assert_eq!(
parse("Red.Dwarf.S04.1080p.BluRay.x264-LATENCY [Season 4 Four Complete]").season,
Some(4)
);
assert_eq!(
parse("Red.Dwarf.S11.1080p.BluRay.x264-SHORTBREHD [Season 11 Eleven]").season,
Some(11)
);
assert_eq!(
parse("Game of Thrones - Season 8 S08 - 2019 1080p Bluray AAC5.1 x264-R").season,
Some(8)
);
}
// Regression test for a real gap found in review: "Season 2 - 25" was
// first swallowed whole by `looks_like_episode_range` (its own
// `BARE_EPISODE_RANGE_RE` skips over the un-matchable "Season" word and
// finds its first real match at "2 - 25", mistaking the season marker's
// own number for a range start), and even with that fixed,
// `extract_episode_info`'s `SEASON_PACK_RE` branch used to return
// season-only and never look for a trailing episode number at all.
// Either bug alone drops episode 25 silently.
#[test]
fn parses_a_season_marker_followed_by_a_dash_episode() {
let p = parse("[Erai-raws] Some Show Season 2 - 25 [1080p]");
assert_eq!(p.season, Some(2));
assert_eq!(p.episode, Some(25));
}
// Companion case: a genuine season-only pack (no trailing dash-episode
// anywhere) must still resolve to season-only, not spuriously pick up
// an unrelated number as an episode.
#[test]
fn a_genuine_season_only_pack_with_no_dash_episode_still_has_no_episode() {
let p = parse("Some Show Season 2 Complete [1080p]");
assert_eq!(p.season, Some(2));
assert_eq!(p.episode, None);
}
// Regression test for a real gap found in review: a date-named release
// ("2024-01-15") got misread by `BARE_EPISODE_RANGE_RE` as an episode
// range — the 4-digit year is too many digits for `\d{1,3}` to match
// whole, so its first real match starts at the month/day pair
// ("01-15") instead, and `looks_like_episode_range` treats that as a
// real range. Asserted directly against the tokenizer rather than
// `parse()`, since a false-positive range and a genuine "no episode
// marker at all" both surface identically as `None`/`None` on
// `ParsedRelease` — `looks_like_episode_range` returning `false` is the
// actual fix being tested here.
#[test]
fn does_not_mistake_a_yyyy_mm_dd_date_for_an_episode_range() {
assert!(!tokens::looks_like_episode_range("Some Daily Show 2024-01-15 1080p WEB-DL"));
let p = parse("Some Daily Show 2024-01-15 1080p WEB-DL");
assert_eq!(p.season, None);
assert_eq!(p.episode, None);
}
#[test]
fn parses_yameii_dash_sxxexx_with_english_dub_tag() {
let p = parse("[Yameii] Ascendance of a Bookworm - S04E11 [English Dub] [CR WEB-DL 1080p H264 AAC] [8ACE7B72] (Honzuki no Gekokujou)");
@ -278,6 +363,16 @@ mod tests {
assert_eq!(p.year, Some(2023));
}
#[test]
fn parses_hdr_hdr10_and_dolby_vision_tokens() {
assert!(parse("Movie.2024.2160p.HDR.mkv").has_hdr);
assert!(parse("Movie.2024.DV.mkv").has_hdr);
assert!(parse("Movie.2024.2160p.HDR10.BluRay").has_hdr);
assert!(parse("Movie.2024.2160p.DoVi.mkv").has_hdr);
assert!(parse("Movie.2024.Dolby.Vision.2160p").has_hdr);
assert!(!parse("Movie.2024.1080p.WEB-DL.H264").has_hdr);
}
#[test]
fn does_not_panic_on_unparsable_manga_release() {
// Not a video release at all — should degrade gracefully, not crash.

View file

@ -29,6 +29,9 @@ static BIT_DEPTH_RE: LazyLock<Regex> =
pub(super) static REPACK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\b(REPACK|PROPER)\b").unwrap());
static HDR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\b(?:HDR10\+?|HDR|Dolby[.\s]?Vision|DoVi|DV)\b").unwrap());
static YEAR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[(\[](19|20)\d{2}[)\]]").unwrap());
// Scene-style releases ("Dune.1984.1080p.BluRay.x264-GROUP") carry the year
// bare, with no surrounding brackets — `YEAR_RE` above never matches these
@ -38,12 +41,33 @@ static YEAR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[(\[](19|20)\d{2
static BARE_YEAR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b((?:19|20)\d{2})\b").unwrap());
// The trailing `v\d+` is a fansub revision tag ("v2" = "second release of
// this episode, fixed encode/subs") stuck directly onto the episode number
// with no separator — "S01E01v2". Without consuming it before the `\b`,
// the boundary check fails outright (digit→letter isn't a word boundary),
// so the whole pattern silently doesn't match and the file falls through
// to unparsed (verified live: every v2 release of several shows, e.g. an
// entire show that only had v2 releases, ended up with zero linked
// episode files during a library scan).
static SXXEXX_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\bS(\d{1,2})E(\d{1,3})\b").unwrap());
LazyLock::new(|| Regex::new(r"(?i)\bS(\d{1,2})E(\d{1,3})(?:v\d+)?\b").unwrap());
static SXX_DASH_EP_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\bS(\d{1,2})\s*-\s*(\d{1,3})\b").unwrap());
// A bare season marker with no episode number attached — "S01", "Season 8",
// "Season.1", optionally with a trailing "Complete"/spelled-out season word
// (e.g. "[Season 4 Four Complete]") that's irrelevant to the number itself.
// Previously required literal parens around an explicit "S?N Complete)"
// shape, matching only one specific release-group convention; real
// releases routinely drop the parens, drop "Complete" entirely (e.g.
// "Game of Thrones - Season 8 S08 - 2019"), or spell "Season" out with a
// dot instead of a space (verified live: several real review-queue entries
// failed to resolve at all — season came back `None` — because none of
// these shapes matched the old pattern). Only reached after
// `SXXEXX_RE`/`SXX_DASH_EP_RE` have already failed to find an actual
// episode number, so treating a bare season marker as a pack signal here is
// safe.
static SEASON_PACK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\(S?(\d{1,2})\s*Complete\)").unwrap());
LazyLock::new(|| Regex::new(r"(?i)\bS(?:eason)?\.?\s*(\d{1,2})\b").unwrap());
static DASH_EPISODE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"-\s*(\d{1,3})\b").unwrap());
// A batch/season-pack release covering many episodes in one torrent.
@ -69,15 +93,46 @@ static SXX_EPISODE_RANGE_RE: LazyLock<Regex> =
// elsewhere in the title with a smaller trailing number.
static BARE_EPISODE_RANGE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b(\d{1,3})\s*[-~]\s*(\d{1,3})\b").unwrap());
// A `YYYY-MM-DD` date ("2024-01-15"): the year's 4 digits are too many for
// `BARE_EPISODE_RANGE_RE`'s/`DASH_EPISODE_RE`'s `\d{1,3}` to match as a
// whole, so those regexes' *first real match* on a date-named release ends
// up starting at the month ("01-15", or "01" alone) instead — a bare
// month/day pair, not a real episode range or episode number. Matched as a
// whole date span (not just a "year-" prefix check) so both the month *and*
// the day segment are covered — checking only the text immediately before a
// candidate match would still let "15" in "2024-01-15" slip through as a
// false "episode 15" once "01" alone was correctly rejected.
static DATE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b(?:19|20)\d{2}-\d{1,2}-\d{1,2}\b").unwrap());
fn overlaps_a_date(s: &str, start: usize, end: usize) -> bool {
DATE_RE.find_iter(s).any(|d| d.start() <= start && end <= d.end())
}
pub(super) fn looks_like_episode_range(s: &str) -> bool {
if BATCH_WORD_RE.is_match(s) || SXX_EPISODE_RANGE_RE.is_match(s) {
return true;
}
BARE_EPISODE_RANGE_RE.captures(s).is_some_and(|c| {
let a: u32 = c[1].parse().unwrap_or(0);
let b: u32 = c[2].parse().unwrap_or(0);
b > a
BARE_EPISODE_RANGE_RE.captures_iter(s).any(|c| {
let first = c.get(1).unwrap();
let second = c.get(2).unwrap();
let a: u32 = first.as_str().parse().unwrap_or(0);
let b: u32 = second.as_str().parse().unwrap_or(0);
if b <= a {
return false;
}
if overlaps_a_date(s, first.start(), second.end()) {
return false;
}
// "Season 2 - 25": the range's first number is really a season
// marker's own number (checked by comparing spans, not just text,
// so this only fires when the two genuinely overlap), not a range
// start — "2 - 25" isn't a real episode range, it's "season 2,
// episode 25", resolved separately in `extract_episode_info`.
let is_season_marker_number = SEASON_PACK_RE.captures(s).is_some_and(|sc| {
sc.get(1).unwrap().range() == first.range()
});
!is_season_marker_number
})
}
@ -129,6 +184,10 @@ pub(super) fn extract_bit_depth(s: &str) -> Option<u8> {
BIT_DEPTH_RE.captures(s)?[1].parse().ok()
}
pub(super) fn extract_hdr(s: &str) -> bool {
HDR_RE.is_match(s)
}
pub(super) fn extract_year(s: &str) -> Option<u32> {
if let Some(m) = YEAR_RE.find(s) {
return s[m.start() + 1..m.end() - 1].parse().ok();
@ -144,6 +203,23 @@ pub(super) fn extract_year(s: &str) -> Option<u32> {
s[m.start()..m.end()].parse().ok()
}
/// `DASH_EPISODE_RE`'s first match that isn't actually the month of a
/// `YYYY-MM-DD` date. A bare `-\s*\d{1,3}\b` alone can't tell "Show - 25
/// [1080p]" (a real episode number) apart from "...2024-01-15..." (the "01"
/// is just a month, matched for the same reason `BARE_EPISODE_RANGE_RE`
/// does in `looks_like_episode_range` — the 4-digit year is too many digits
/// to match as a whole, so the regex's first real match starts one segment
/// later). Reused by every `extract_episode_info` branch that falls back to
/// `DASH_EPISODE_RE`, not just the range-detection path, since the date
/// misread happens independently of whether `looks_like_episode_range`
/// fires.
fn find_real_dash_episode(s: &str) -> Option<regex::Captures<'_>> {
DASH_EPISODE_RE.captures_iter(s).find(|c| {
let m = c.get(0).unwrap();
!overlaps_a_date(s, m.start(), m.end())
})
}
/// Returns (season, episode, absolute_episode, title_span_end) — the last
/// element is the byte offset in `s` where the episode/season token (or,
/// failing that, the first quality marker) begins, used to slice out the
@ -173,9 +249,19 @@ pub(super) fn extract_episode_info(s: &str) -> (Option<u32>, Option<u32>, Option
}
if let Some(c) = SEASON_PACK_RE.captures(s) {
let season = c[1].parse().ok();
// A season marker immediately followed elsewhere by a dash-number
// ("Season 2 - 25") names one episode within that season, not a
// season-only pack — checked here rather than reordering the checks
// above `SXX_DASH_EP_RE`/`SXXEXX_RE` still get first crack at more
// specific shapes, and a genuine season-only pack (no trailing
// dash-number anywhere) is unaffected.
if let Some(ep) = find_real_dash_episode(s) {
let episode: Option<u32> = ep[1].parse().ok();
return (season, episode, None, c.get(0).unwrap().start());
}
return (season, None, None, c.get(0).unwrap().start());
}
if let Some(c) = DASH_EPISODE_RE.captures(s) {
if let Some(c) = find_real_dash_episode(s) {
let episode: Option<u32> = c[1].parse().ok();
return (None, episode, episode, c.get(0).unwrap().start());
}

View file

@ -33,6 +33,49 @@ impl std::fmt::Display for MagnetRejected {
impl std::error::Error for MagnetRejected {}
/// Newer qBittorrent WebUI API versions' `torrents/add` JSON response shape.
#[derive(Debug, Deserialize, Default)]
struct AddTorrentResponse {
#[serde(default)]
success_count: u32,
#[serde(default)]
failure_count: u32,
/// Present when adding by URL rather than by a magnet/`.torrent` blob
/// qBittorrent already has in hand — the torrent itself is fetched
/// asynchronously, so the immediate response has neither succeeded nor
/// failed yet, just queued. nyaa's RSS feed always hands over a
/// `.torrent` download URL (never a magnet), so this is the normal
/// shape for every anime grab, not an edge case. Verified live against
/// the deployed qBittorrent: a URL add returns HTTP 202 with
/// `{"pending_count":1,"success_count":0,"failure_count":0}` — treating
/// `success_count == 0` alone as rejection (the previous check) silently
/// dropped every one of these while the torrent downloaded successfully
/// in the background, with no DB record and no log line.
#[serde(default)]
pending_count: u32,
}
/// Interprets a `torrents/add` response body — split out from `add_magnet`
/// so it's directly testable without a live qBittorrent server.
/// qBittorrent's add-torrent endpoint returns HTTP 200/202 even when it
/// rejects the magnet outright (a dead/malformed hash, one it already knows
/// is unreachable) — the response body is the only signal. Older
/// qBittorrent versions returned plain text ("Ok." vs "Fails."); newer ones
/// return a JSON summary with success_count/failure_count/pending_count
/// instead (verified live against the currently deployed version). Without
/// checking whichever shape is actually in play, a rejected magnet looks
/// identical to a real success: the caller records a `release` row as
/// grabbed and nothing ever downloads, silently and permanently (verified
/// live — this happened for a real release under the old text-only check,
/// and separately for every nyaa URL-add under a since-fixed
/// `success_count == 0` check that didn't account for `pending_count`).
fn add_torrent_response_is_rejected(body: &str) -> bool {
match serde_json::from_str::<AddTorrentResponse>(body) {
Ok(r) => r.failure_count > 0 || (r.success_count == 0 && r.pending_count == 0),
Err(_) => body.trim() != "Ok.",
}
}
pub struct QbitClient {
base_url: String,
client: reqwest::Client,
@ -60,6 +103,9 @@ pub struct TorrentInfo {
pub name: String,
pub state: String,
pub progress: f64,
// Not read internally today, but kept to mirror qBittorrent's actual API
// shape 1:1 — useful in `{:?}` debug logging and cheap to keep in sync.
#[allow(dead_code)]
pub save_path: String,
/// Full path to the torrent's content (file or directory root) —
/// qBittorrent resolves this for us, so importers don't need to guess
@ -101,7 +147,12 @@ impl QbitClient {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() || body.trim() != "Ok." {
// Older qBittorrent WebUI API versions return 200 with body "Ok.";
// newer ones return 204 No Content with an empty body instead. Bad
// credentials return a real error status (401), which `is_success`
// already catches — the response body's exact text isn't part of
// the actual success contract, just an artifact of the old version.
if !status.is_success() {
bail!("qbit login failed: status={status} body={body:?}");
}
Ok(())
@ -140,15 +191,7 @@ impl QbitClient {
if !status.is_success() {
bail!("qbit add-torrent failed: status={status} body={body:?}");
}
// qBittorrent's add-torrent endpoint returns HTTP 200 even when
// it rejects the magnet outright (a dead/malformed hash, one it
// already knows is unreachable) — the *only* signal is the
// response body text ("Ok." vs "Fails."). Without this check a
// rejected magnet looks identical to a real success: the caller
// records a `release` row as grabbed and nothing ever
// downloads, silently and permanently (verified live — this
// happened for a real release).
if body.trim() != "Ok." {
if add_torrent_response_is_rejected(&body) {
return Err(anyhow::Error::new(MagnetRejected { body }));
}
return Ok(());
@ -204,10 +247,6 @@ impl QbitClient {
unreachable!("loop always returns or bails on its second iteration")
}
/// Moves a torrent's save location — qBittorrent physically relocates
/// the underlying file(s) itself and continues seeding from the new
/// path, rather than breadarr keeping a second permanent copy purely to
/// satisfy its own import step.
/// Held for the duration of an add-then-correlate-hash sequence — see
/// `grab_lock`'s doc comment on why this needs to be process-wide, not
/// just per-call.
@ -215,10 +254,19 @@ impl QbitClient {
self.grab_lock.lock().await
}
pub async fn set_location(&self, hash: &str, location: &str) -> Result<()> {
/// Removes a torrent from qBittorrent's own tracking after breadarr has
/// already moved its data straight into the library — `delete_files:
/// false` because by the time this is called there's nothing left at
/// the torrent's original save path for qBittorrent to delete; leaving
/// the torrent registered would just leave it sitting in an "files
/// missing" error state indefinitely. Best-effort from the caller's
/// side: a failure here never undoes or blocks the import that already
/// succeeded, it just leaves one stale entry in the qBittorrent UI to
/// clean up by hand.
pub async fn delete_torrent(&self, hash: &str) -> Result<()> {
self.post_form(
"/api/v2/torrents/setLocation",
&[("hashes", hash), ("location", location)],
"/api/v2/torrents/delete",
&[("hashes", hash), ("deleteFiles", "false")],
)
.await?;
Ok(())
@ -262,4 +310,38 @@ mod tests {
None
);
}
#[test]
fn a_pending_url_add_is_not_treated_as_rejected() {
// Exact shape qBittorrent 5.x returns for a URL add (every nyaa
// grab) — the torrent is queued for an async fetch, not yet
// succeeded or failed. This is the shape that used to be
// misread as an outright rejection.
let body = r#"{"added_torrent_ids":[],"failure_count":0,"pending_count":1,"success_count":0}"#;
assert!(!add_torrent_response_is_rejected(body));
}
#[test]
fn a_genuine_failure_is_still_rejected() {
let body = r#"{"failure_count":1,"pending_count":0,"success_count":0}"#;
assert!(add_torrent_response_is_rejected(body));
}
#[test]
fn an_in_band_success_is_not_rejected() {
// TPB/1337x magnets: qBittorrent already has the info hash, no
// async fetch needed, so success_count is set immediately.
let body = r#"{"failure_count":0,"pending_count":0,"success_count":1}"#;
assert!(!add_torrent_response_is_rejected(body));
}
#[test]
fn the_old_plain_text_ok_response_is_not_rejected() {
assert!(!add_torrent_response_is_rejected("Ok."));
}
#[test]
fn the_old_plain_text_fails_response_is_rejected() {
assert!(add_torrent_response_is_rejected("Fails."));
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
use crate::parser::ParsedRelease;
use crate::parser::{Codec, ParsedRelease, Source};
use super::profile::{ProfileKind, QualityProfile};
@ -8,8 +8,8 @@ pub fn score(parsed: &ParsedRelease, seeders: u32, has_hdr: bool, profile: &Qual
total += w.seeder * seeder_score(seeders);
total += w.resolution_tier * resolution_tier(parsed.resolution);
total += w.source_tier * parsed.source.map(|s| s as u8 as f32).unwrap_or(0.0);
total += w.codec_tier * parsed.codec.map(|c| c as u8 as f32).unwrap_or(0.0);
total += w.source_tier * source_tier(parsed.source);
total += w.codec_tier * codec_tier(parsed.codec);
if parsed.bit_depth == Some(10) {
total += w.bit_depth;
@ -52,6 +52,29 @@ fn resolution_tier(resolution: Option<u32>) -> f32 {
}
}
/// Explicit tiers — `Source::Hdtv` is discriminant 0, so casting the enum
/// to `u8` scored HDTV identically to an unknown/missing source.
fn source_tier(source: Option<Source>) -> f32 {
match source {
Some(Source::Hdtv) => 1.0,
Some(Source::WebRip) => 2.0,
Some(Source::WebDl) => 3.0,
Some(Source::BluRay) => 4.0,
Some(Source::Remux) => 5.0,
None => 0.0,
}
}
/// Same reason as `source_tier`: `Codec::H264` is discriminant 0.
fn codec_tier(codec: Option<Codec>) -> f32 {
match codec {
Some(Codec::H264) => 1.0,
Some(Codec::Hevc) => 2.0,
Some(Codec::Av1) => 3.0,
None => 0.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -152,6 +175,36 @@ mod tests {
assert!(s720 > s480);
}
#[test]
fn hdtv_h264_scores_strictly_above_a_release_with_no_source_or_codec() {
let profile = QualityProfile::default_tv();
let known = parser::parse("Show S01E01 1080p HDTV H264");
let unknown = parser::parse("Show S01E01 1080p");
assert!(known.source.is_some());
assert!(known.codec.is_some());
assert!(unknown.source.is_none());
assert!(unknown.codec.is_none());
assert!(score(&known, 50, false, &profile) > score(&unknown, 50, false, &profile));
}
#[test]
fn remux_av1_scores_above_hdtv_h264() {
let profile = QualityProfile::default_movie();
let remux_av1 = parser::parse("Movie 2024 2160p Remux AV1");
let hdtv_h264 = parser::parse("Movie 2024 2160p HDTV H264");
assert!(score(&remux_av1, 50, false, &profile) > score(&hdtv_h264, 50, false, &profile));
}
#[test]
fn parsed_hdr_movie_scores_higher_than_the_same_release_without_hdr() {
let profile = QualityProfile::default_movie();
let hdr = parser::parse("Movie.2024.2160p.BluRay.HDR.H264");
let sdr = parser::parse("Movie.2024.2160p.BluRay.H264");
assert!(hdr.has_hdr);
assert!(!sdr.has_hdr);
assert!(score(&hdr, 50, hdr.has_hdr, &profile) > score(&sdr, 50, sdr.has_hdr, &profile));
}
#[test]
fn allowlisted_group_scores_higher_than_unlisted() {
let mut profile = QualityProfile::default_tv();

View file

@ -1,6 +1,8 @@
pub mod rss;
pub mod scrape;
pub mod torrents_csv;
pub mod tpb;
pub mod yts;
use anyhow::Result;
use async_trait::async_trait;
@ -25,6 +27,39 @@ pub trait ReleaseSource {
async fn fetch(&self, query: Option<&str>) -> Result<Vec<RawReleaseItem>>;
}
/// Trackers attached to every magnet we synthesize from an info-hash
/// (TPB, torrents-csv, YTS). Same set the TPB client has used since it
/// landed — qBittorrent needs *some* announce list or the torrent sits
/// hash-only until DHT finds peers.
const MAGNET_TRACKERS: &[&str] = &[
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://tracker.openbittorrent.com:6969/announce",
"udp://exodus.desync.com:6969/announce",
];
/// A valid BitTorrent v1 info_hash: 40 hex chars or 32 base32 chars — same
/// shape `qbit::extract_btih` accepts out of a magnet URI. Shared by every
/// hash-to-magnet source so a malformed value can't silently produce a
/// magnet the grab path then fails to parse back.
pub(crate) fn is_valid_info_hash(hash: &str) -> bool {
(hash.len() == 40 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
|| (hash.len() == 32
&& hash
.bytes()
.all(|b| matches!(b, b'2'..=b'7' | b'a'..=b'z' | b'A'..=b'Z')))
}
pub(crate) fn build_magnet(info_hash: &str, name: &str) -> String {
let mut magnet = format!("magnet:?xt=urn:btih:{info_hash}&dn={}", urlencode(name));
for t in MAGNET_TRACKERS {
magnet.push_str("&tr=");
magnet.push_str(&urlencode(t));
}
magnet
}
pub(crate) fn urlencode(s: &str) -> String {
s.chars()
.map(|c| {
@ -45,7 +80,17 @@ pub(crate) fn urlencode(s: &str) -> String {
pub(crate) fn parse_human_size(s: &str) -> Option<u64> {
let s = s.trim();
let (num_part, unit) = s.split_once(' ')?;
// Split on the first character that isn't part of the number, rather
// than requiring a literal space — some sources render this without one
// ("38.1GiB"). Requiring a space made `split_once(' ')` return `None`
// for those, silently leaving `size_bytes` unset rather than failing
// outright: the gate's size sanity check (`gate.rs`) treats a missing
// size as "nothing to check" and skips it entirely instead of rejecting
// the release, so a value this parser simply couldn't read bypassed
// size validation altogether rather than being caught by it.
let split_at = s.find(|c: char| !(c.is_ascii_digit() || c == '.'))?;
let (num_part, unit) = s.split_at(split_at);
let unit = unit.trim();
let num: f64 = num_part.parse().ok()?;
let mult = match unit {
"B" => 1.0,
@ -80,4 +125,14 @@ mod tests {
fn rejects_unknown_unit() {
assert_eq!(parse_human_size("5 XiB"), None);
}
// Regression test for a real gap found in review: some sources render
// this with no space between the number and the unit — the old
// `split_once(' ')` returned `None` for those, silently leaving
// `size_bytes` unset (which skips the gate's size sanity check entirely)
// rather than rejecting a value this parser genuinely couldn't read.
#[test]
fn parses_a_size_with_no_space_before_the_unit() {
assert_eq!(parse_human_size("38.1GiB"), Some(40909563494));
}
}

View file

@ -52,6 +52,39 @@ fn build_search_url(feed_url: &str, query: Option<&str>) -> String {
}
}
/// Every field accumulated across one `<item>`'s `Text`/`CData` events,
/// bundled into one struct (rather than six separate `&mut Option<_>`
/// parameters) purely to keep `accumulate_field` under clippy's
/// too-many-arguments threshold.
#[derive(Default)]
struct ItemFields {
title: Option<String>,
link: Option<String>,
guid: Option<String>,
seeders: Option<u32>,
leechers: Option<u32>,
size_bytes: Option<u64>,
}
/// Appends rather than overwrites title/link/guid: a real feed can split a
/// single logical value across more than one `Text`/`CData` event for the
/// same tag (mixed content, or just a parser buffer boundary) — a plain
/// assignment would silently keep only the *last* fragment, truncating the
/// value. `nyaa:seeders`/`nyaa:leechers`/`nyaa:size` stay parse-and-overwrite
/// since they're short numeric/size tokens, not free text expected to span
/// multiple events.
fn accumulate_field(tag: &str, text: &str, fields: &mut ItemFields) {
match tag {
"title" => fields.title.get_or_insert_with(String::new).push_str(text),
"link" => fields.link.get_or_insert_with(String::new).push_str(text),
"guid" => fields.guid.get_or_insert_with(String::new).push_str(text),
"nyaa:seeders" => fields.seeders = text.parse().ok(),
"nyaa:leechers" => fields.leechers = text.parse().ok(),
"nyaa:size" => fields.size_bytes = parse_human_size(text),
_ => {}
}
}
fn parse_nyaa_rss(bytes: &[u8]) -> Result<Vec<RawReleaseItem>> {
let mut reader = Reader::from_reader(bytes);
reader.config_mut().trim_text(true);
@ -61,12 +94,7 @@ fn parse_nyaa_rss(bytes: &[u8]) -> Result<Vec<RawReleaseItem>> {
let mut in_item = false;
let mut cur_tag = String::new();
let mut title = None;
let mut link = None;
let mut guid = None;
let mut seeders = None;
let mut leechers = None;
let mut size_bytes = None;
let mut fields = ItemFields::default();
loop {
match reader.read_event_into(&mut buf)? {
@ -75,41 +103,42 @@ fn parse_nyaa_rss(bytes: &[u8]) -> Result<Vec<RawReleaseItem>> {
let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
if name == "item" {
in_item = true;
title = None;
link = None;
guid = None;
seeders = None;
leechers = None;
size_bytes = None;
fields = ItemFields::default();
}
cur_tag = name;
}
Event::Text(t) if in_item => {
let raw = t.decode()?;
let text = unescape(&raw)?.into_owned();
match cur_tag.as_str() {
"title" => title = Some(text),
"link" => link = Some(text),
"guid" => guid = Some(text),
"nyaa:seeders" => seeders = text.parse().ok(),
"nyaa:leechers" => leechers = text.parse().ok(),
"nyaa:size" => size_bytes = parse_human_size(&text),
_ => {}
accumulate_field(&cur_tag, &text, &mut fields);
}
// CDATA content is raw text by definition — XML entity escaping
// doesn't apply inside a CDATA section (running `unescape()` on
// it would misinterpret a literal "&amp;" as an escaped
// ampersand), so this decodes without it. Many real-world feeds
// wrap `<title>`/`<link>` in CDATA; previously only
// `Event::Text` was handled at all, so those items silently
// came back with `title = None` and were dropped at the
// `item`-close check below with zero error — pointing
// `nyaa_rss_url` at a CDATA-heavy feed yielded zero items, not
// a visible failure.
Event::CData(t) if in_item => {
let text = t.decode()?.into_owned();
accumulate_field(&cur_tag, &text, &mut fields);
}
Event::End(e) => {
let name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
if name == "item" {
if let (Some(title), Some(link), Some(guid)) =
(title.take(), link.take(), guid.take())
(fields.title.take(), fields.link.take(), fields.guid.take())
{
items.push(RawReleaseItem {
title,
link,
guid,
size_bytes,
seeders,
leechers,
size_bytes: fields.size_bytes,
seeders: fields.seeders,
leechers: fields.leechers,
});
}
in_item = false;
@ -156,6 +185,40 @@ mod tests {
assert_eq!(item.size_bytes, Some(373817344));
}
// Regression test for a real gap found in review: many real-world feeds
// wrap `<title>`/`<link>`/`<guid>` in CDATA rather than plain text
// content (often to avoid having to XML-escape ampersands/brackets
// common in release titles). Previously only `Event::Text` was
// handled — `Event::CData` was silently ignored — so every field
// wrapped this way came back `None` and the whole item was dropped at
// the `item`-close check with no error surfaced at all.
#[test]
fn parses_cdata_wrapped_fields() {
const CDATA_SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:nyaa="https://nyaa.si/xmlns/nyaa" version="2.0">
<channel>
<item>
<title><![CDATA[[Group] Some Show & Friends - 05 [1080p]]]></title>
<link><![CDATA[https://nyaa.si/download/2130903.torrent]]></link>
<guid isPermaLink="true"><![CDATA[https://nyaa.si/view/2130903]]></guid>
<nyaa:seeders>12</nyaa:seeders>
<nyaa:leechers>3</nyaa:leechers>
<nyaa:size>356.5 MiB</nyaa:size>
</item>
</channel>
</rss>"#;
let items = parse_nyaa_rss(CDATA_SAMPLE.as_bytes()).unwrap();
assert_eq!(items.len(), 1, "a CDATA-wrapped item must not be silently dropped");
let item = &items[0];
// A literal "&" survives verbatim — CDATA content isn't
// XML-entity-escaped, so this must NOT come back as "&amp;".
assert_eq!(item.title, "[Group] Some Show & Friends - 05 [1080p]");
assert_eq!(item.link, "https://nyaa.si/download/2130903.torrent");
assert_eq!(item.guid, "https://nyaa.si/view/2130903");
assert_eq!(item.seeders, Some(12));
}
#[test]
fn build_search_url_appends_query_param() {
assert_eq!(

View file

@ -313,6 +313,9 @@ fn parse_search_results(html: &str, mirror: &str) -> Option<Vec<RawReleaseItem>>
continue;
}
let detail_url = if href.starts_with("http") {
if !same_origin(href, mirror) {
continue;
}
href.to_string()
} else {
format!("{mirror}{href}")
@ -360,6 +363,27 @@ fn parse_search_results(html: &str, mirror: &str) -> Option<Vec<RawReleaseItem>>
Some(items)
}
/// Scheme+host(+port) origin of an `http(s)://...` URL. `None` if the
/// string isn't an absolute http(s) URL with a host.
fn url_origin(url: &str) -> Option<(&str, &str)> {
let (scheme, rest) = if let Some(r) = url.strip_prefix("https://") {
("https", r)
} else {
let r = url.strip_prefix("http://")?;
("http", r)
};
let hostport = rest.split('/').next().filter(|s| !s.is_empty())?;
let hostport = hostport.rsplit('@').next().unwrap_or(hostport);
Some((scheme, hostport))
}
fn same_origin(href: &str, mirror: &str) -> bool {
match (url_origin(href), url_origin(mirror)) {
(Some((as_, ah)), Some((bs, bh))) => as_ == bs && ah.eq_ignore_ascii_case(bh),
_ => false,
}
}
fn extract_magnet(html: &str) -> Option<String> {
let doc = Html::parse_document(html);
let sel = Selector::parse(r#"a[href^="magnet:"]"#).unwrap();
@ -420,6 +444,32 @@ mod tests {
assert_eq!(a[0].guid, b[0].guid);
}
#[test]
fn drops_off_origin_absolute_hrefs_but_keeps_relative() {
let html = r#"
<table class="table-list table table-responsive table-striped">
<thead><tr><th class="coll-1 name">name</th><th class="coll-2">se</th><th class="coll-3">le</th><th class="coll-4">size</th></tr></thead>
<tbody>
<tr>
<td class="coll-1 name"><a href="http://127.0.0.1/evil">Evil</a></td>
<td class="coll-2">1</td>
<td class="coll-3">1</td>
<td class="coll-4">1 MB</td>
</tr>
<tr>
<td class="coll-1 name"><a href="/torrent/3250239/Ok/">Good</a></td>
<td class="coll-2">2</td>
<td class="coll-3">2</td>
<td class="coll-4">2 MB</td>
</tr>
</tbody>
</table>"#;
let items = parse_search_results(html, "https://13377x.info").unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].title, "Good");
assert_eq!(items[0].link, "https://13377x.info/torrent/3250239/Ok/");
}
#[test]
fn extract_torrent_id_handles_relative_and_absolute_hrefs() {
assert_eq!(

View file

@ -0,0 +1,130 @@
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::Deserialize;
use super::{build_magnet, is_valid_info_hash, urlencode, RawReleaseItem, ReleaseSource};
/// Public JSON search over the torrents.csv DHT dump. Same grab shape as
/// TPB (info-hash → magnet, no HTML): used as the first general-content
/// fallback when apibay is down or returns nothing. Seeders are scrape
/// snapshots, not live tracker data, so a high number can still stall —
/// the existing seeder gate still applies.
pub struct TorrentsCsvSource {
api_url: String,
client: reqwest::Client,
}
#[derive(Deserialize)]
struct CsvResponse {
#[serde(default)]
torrents: Vec<CsvTorrent>,
}
#[derive(Deserialize)]
struct CsvTorrent {
infohash: String,
name: String,
size_bytes: Option<u64>,
seeders: Option<i64>,
leechers: Option<i64>,
}
impl TorrentsCsvSource {
pub fn new(api_url: impl Into<String>) -> Self {
Self {
api_url: api_url.into(),
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("reqwest client build"),
}
}
}
fn as_u32(n: Option<i64>) -> Option<u32> {
n.and_then(|v| u32::try_from(v.max(0)).ok())
}
#[async_trait]
impl ReleaseSource for TorrentsCsvSource {
async fn fetch(&self, query: Option<&str>) -> Result<Vec<RawReleaseItem>> {
let Some(query) = query else {
anyhow::bail!(
"TorrentsCsvSource requires a search query (this is a search-driven source, not a feed)"
);
};
let sep = if self.api_url.contains('?') { '&' } else { '?' };
let url = format!(
"{}{sep}q={}&size=25",
self.api_url.trim_end_matches('/'),
urlencode(query)
);
let parsed: CsvResponse = self
.client
.get(&url)
.send()
.await
.with_context(|| format!("request to {url} failed"))?
.error_for_status()
.with_context(|| format!("{url} returned an error status"))?
.json()
.await
.context("failed to parse torrents-csv response as JSON")?;
Ok(parsed
.torrents
.into_iter()
.filter(|t| is_valid_info_hash(&t.infohash))
.map(|t| RawReleaseItem {
title: t.name.clone(),
link: build_magnet(&t.infohash, &t.name),
guid: t.infohash,
size_bytes: t.size_bytes,
seeders: as_u32(t.seeders),
leechers: as_u32(t.leechers),
})
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_real_captured_response() {
let body = r#"{
"torrents": [
{
"infohash": "ed0da850c273e3e15a819bdcbbf418bc85107ec8",
"name": "Dune (2021) [1080p] [WEBRip]",
"size_bytes": 2947989023,
"seeders": 746,
"leechers": 38
},
{
"infohash": "not-a-hash",
"name": "garbage",
"size_bytes": 1,
"seeders": 0,
"leechers": 0
}
]
}"#;
let parsed: CsvResponse = serde_json::from_str(body).unwrap();
let items: Vec<_> = parsed
.torrents
.into_iter()
.filter(|t| is_valid_info_hash(&t.infohash))
.collect();
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "Dune (2021) [1080p] [WEBRip]");
assert_eq!(items[0].seeders, Some(746));
}
#[test]
fn empty_payload_is_no_results_not_an_error() {
let parsed: CsvResponse = serde_json::from_str(r#"{"torrents":[]}"#).unwrap();
assert!(parsed.torrents.is_empty());
}
}

View file

@ -2,7 +2,7 @@ use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::Deserialize;
use super::{urlencode, RawReleaseItem, ReleaseSource};
use super::{build_magnet, is_valid_info_hash, urlencode, RawReleaseItem, ReleaseSource};
/// A community-run JSON API mirror of The Pirate Bay's search — unlike
/// 1337x, this is a genuine machine-readable API (not HTML scraping), and
@ -27,23 +27,6 @@ struct TpbResult {
size: String,
}
const TRACKERS: &[&str] = &[
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://tracker.openbittorrent.com:6969/announce",
"udp://exodus.desync.com:6969/announce",
];
fn build_magnet(info_hash: &str, name: &str) -> String {
let mut magnet = format!("magnet:?xt=urn:btih:{info_hash}&dn={}", urlencode(name));
for t in TRACKERS {
magnet.push_str("&tr=");
magnet.push_str(&urlencode(t));
}
magnet
}
impl TpbSource {
pub fn new(api_url: impl Into<String>) -> Self {
Self {
@ -85,8 +68,15 @@ impl ReleaseSource for TpbSource {
// A query with no matches returns a single sentinel row
// (id="0", an all-zero info_hash) rather than an empty array —
// has to be filtered out explicitly or it'd be treated as one
// real (and completely bogus) result.
.filter(|r| r.id != "0" && !r.info_hash.chars().all(|c| c == '0'))
// real (and completely bogus) result. The all-zero hash is
// itself 40 valid hex characters, so `is_valid_info_hash` alone
// wouldn't catch it — both checks are needed, not one replacing
// the other.
.filter(|r| {
r.id != "0"
&& !r.info_hash.chars().all(|c| c == '0')
&& is_valid_info_hash(&r.info_hash)
})
.map(|r| RawReleaseItem {
title: r.name.clone(),
link: build_magnet(&r.info_hash, &r.name),
@ -125,8 +115,37 @@ mod tests {
let results: Vec<TpbResult> = serde_json::from_str(body).unwrap();
let filtered: Vec<_> = results
.into_iter()
.filter(|r| r.id != "0" && !r.info_hash.chars().all(|c| c == '0'))
.filter(|r| {
r.id != "0"
&& !r.info_hash.chars().all(|c| c == '0')
&& is_valid_info_hash(&r.info_hash)
})
.collect();
assert!(filtered.is_empty());
}
#[test]
fn is_valid_info_hash_accepts_both_real_shapes() {
assert!(is_valid_info_hash(
"8F87C7C186172F17E35F4512BB1A3E93B614ADED"
)); // 40 hex
assert!(is_valid_info_hash("abcdefghijklmnopqrstuvwxyz234567")); // 32 base32
}
// Regression test for a real gap found in review: a malformed
// `info_hash` from apibay used to flow straight into `build_magnet`
// with no validation, silently producing a magnet `qbit::extract_btih`
// can't parse back out — downgrading that grab to the slow polling path
// with no error surfaced anywhere.
#[test]
fn is_valid_info_hash_rejects_malformed_values() {
assert!(!is_valid_info_hash(""));
assert!(!is_valid_info_hash("too-short"));
assert!(!is_valid_info_hash(
"not-a-hex-string-at-all-nope!!!!!!!!!!!!"
)); // 40 chars, non-hex
assert!(!is_valid_info_hash(
"8F87C7C186172F17E35F4512BB1A3E93B614ADE"
)); // 39 hex chars
}
}

View file

@ -0,0 +1,194 @@
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::Deserialize;
use super::{build_magnet, is_valid_info_hash, urlencode, RawReleaseItem, ReleaseSource};
/// YTS movie API. `yts.mx` itself no longer resolves (checked 2026-07-12
/// and again 2026-08-16); the `yts.lt` / `yts.am` hosts still serve the
/// v2 JSON API, which is why the default URL is a working mirror rather
/// than the brand domain. Movies only — each hit expands into one
/// `RawReleaseItem` per quality so the scorer sees 720p/1080p/2160p as
/// distinct candidates, same as if they were separate TPB rows.
pub struct YtsSource {
api_url: String,
client: reqwest::Client,
}
#[derive(Deserialize)]
struct YtsResponse {
status: String,
data: Option<YtsData>,
}
#[derive(Deserialize, Default)]
struct YtsData {
#[serde(default)]
movies: Vec<YtsMovie>,
}
#[derive(Deserialize)]
struct YtsMovie {
title: String,
year: Option<i64>,
#[serde(default)]
torrents: Vec<YtsTorrent>,
}
#[derive(Deserialize)]
struct YtsTorrent {
hash: String,
quality: Option<String>,
#[serde(rename = "type")]
source_type: Option<String>,
video_codec: Option<String>,
seeds: Option<i64>,
peers: Option<i64>,
size_bytes: Option<u64>,
}
impl YtsSource {
pub fn new(api_url: impl Into<String>) -> Self {
Self {
api_url: api_url.into(),
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("reqwest client build"),
}
}
}
fn as_u32(n: Option<i64>) -> Option<u32> {
n.and_then(|v| u32::try_from(v.max(0)).ok())
}
/// Builds a release title the existing parser can read quality/source/codec
/// out of — YTS stores those as structured fields, not in `title`.
fn release_title(movie: &YtsMovie, torrent: &YtsTorrent) -> String {
let mut title = movie.title.clone();
if let Some(year) = movie.year {
title.push_str(&format!(" ({year})"));
}
for part in [
torrent.quality.as_deref(),
torrent.source_type.as_deref(),
torrent.video_codec.as_deref(),
]
.into_iter()
.flatten()
{
if !part.is_empty() {
title.push_str(&format!(" [{part}]"));
}
}
title
}
#[async_trait]
impl ReleaseSource for YtsSource {
async fn fetch(&self, query: Option<&str>) -> Result<Vec<RawReleaseItem>> {
let Some(query) = query else {
anyhow::bail!(
"YtsSource requires a search query (this is a search-driven source, not a feed)"
);
};
let sep = if self.api_url.contains('?') { '&' } else { '?' };
let url = format!(
"{}{sep}query_term={}&limit=20&sort_by=seeds",
self.api_url.trim_end_matches('/'),
urlencode(query)
);
let parsed: YtsResponse = self
.client
.get(&url)
.send()
.await
.with_context(|| format!("request to {url} failed"))?
.error_for_status()
.with_context(|| format!("{url} returned an error status"))?
.json()
.await
.context("failed to parse YTS response as JSON")?;
anyhow::ensure!(
parsed.status == "ok",
"YTS returned status {:?}",
parsed.status
);
let movies = parsed.data.unwrap_or_default().movies;
let mut items = Vec::new();
for movie in movies {
for torrent in &movie.torrents {
if !is_valid_info_hash(&torrent.hash) {
continue;
}
let title = release_title(&movie, torrent);
items.push(RawReleaseItem {
title: title.clone(),
link: build_magnet(&torrent.hash, &title),
guid: torrent.hash.clone(),
size_bytes: torrent.size_bytes,
seeders: as_u32(torrent.seeds),
leechers: as_u32(torrent.peers),
});
}
}
Ok(items)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{
"status": "ok",
"data": {
"movie_count": 1,
"movies": [{
"title": "Dune: Part One",
"year": 2021,
"torrents": [
{
"hash": "DEB6929BEEB09ADCBD14DC4D6081F7E6B297B88C",
"quality": "1080p",
"type": "web",
"video_codec": "x264",
"seeds": 12,
"peers": 3,
"size_bytes": 2147483648
},
{
"hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"quality": "720p",
"type": "bluray",
"video_codec": "x265",
"seeds": 4,
"peers": 1,
"size_bytes": 1073741824
}
]
}]
}
}"#;
#[test]
fn expands_one_movie_into_per_quality_rows() {
let parsed: YtsResponse = serde_json::from_str(SAMPLE).unwrap();
assert_eq!(parsed.status, "ok");
let movie = &parsed.data.unwrap().movies[0];
assert_eq!(movie.torrents.len(), 2);
let t0 = release_title(movie, &movie.torrents[0]);
assert_eq!(t0, "Dune: Part One (2021) [1080p] [web] [x264]");
let t1 = release_title(movie, &movie.torrents[1]);
assert_eq!(t1, "Dune: Part One (2021) [720p] [bluray] [x265]");
}
#[test]
fn missing_movies_array_is_empty_not_an_error() {
let parsed: YtsResponse =
serde_json::from_str(r#"{"status":"ok","data":{"movie_count":0}}"#).unwrap();
assert!(parsed.data.unwrap_or_default().movies.is_empty());
}
}

File diff suppressed because it is too large Load diff

1
ci/bread-ecosystem.rev Normal file
View file

@ -0,0 +1 @@
147cfbbf96ae4b171027defa1130d2caddb934b1

69
ci/build.sh Executable file
View file

@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Delegates to bread-ecosystem's shared CI build image/script, pinned to
# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI
# files now affect every product's release pipeline, so bumping the pin
# is a deliberate act instead of silent drift (see the bread-theme test
# that broke here for exactly that reason, before it was pinned by rev).
#
# Usage: ci/build.sh cargo build --release --locked
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")"
CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}"
# Only create/replace this product's own pin directory. A glob rm of
# /tmp/bread-ecosystem-ci-* races other products' mktemp clones used by
# release-index regen.
if [ ! -d "$CACHE_DIR/.git" ]; then
rm -rf "$CACHE_DIR"
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR"
git -C "$CACHE_DIR" checkout --quiet "$REV"
fi
# hestia (breadarrd's actual deployment target) runs Ubuntu 24.04 (glibc
# 2.39). The shared CI image is Arch, which tracks a much newer glibc
# (currently 2.43) — a binary linked normally there refuses to start on
# hestia (glibc symbol versioning is forward-only). Rather than patch the
# shared image (would affect every other product's build), pull down a
# real, complete glibc + matching gcc-libs (for libstdc++) from the Arch
# Linux Archive and link against those via --sysroot, so the binary only
# requires symbol versions that actually exist on hestia.
#
# cargo-zigbuild (targeting an older glibc via zig's cross-linker) was
# tried first and doesn't work here: zig only maintains a version database
# for glibc's own symbols, not libstdc++'s, and onnxruntime — statically
# linked in by the `ort` crate — needs a real, complete libstdc++, not
# zig's minimal stand-in. A real archived glibc+gcc-libs package pair,
# linked via --sysroot, sidesteps that entirely.
GLIBC_VER="2.39-4"
GCC_LIBS_VER="13.2.1-6"
# Pinned to the archive.archlinux.org packages fetched above (sha256 of
# the two .pkg.tar.zst files, not the extracted trees).
GLIBC_SHA256="c6aef7065e0d53d700cc5ff65d4db775cb84e2f4e3912a609223ed72fb1799d4"
GCC_LIBS_SHA256="edbb8c4772b8852fe102853a84d197253127418f50cca475feda9bbaa842a378"
OLD_GLIBC_CACHE="/tmp/bread-ci-old-glibc-${GLIBC_VER}"
if [ ! -d "${OLD_GLIBC_CACHE}/usr/lib" ]; then
rm -rf "${OLD_GLIBC_CACHE}"
mkdir -p "${OLD_GLIBC_CACHE}"
curl -sfL -o /tmp/old-glibc.pkg.tar.zst \
"https://archive.archlinux.org/packages/g/glibc/glibc-${GLIBC_VER}-x86_64.pkg.tar.zst"
curl -sfL -o /tmp/old-gcc-libs.pkg.tar.zst \
"https://archive.archlinux.org/packages/g/gcc-libs/gcc-libs-${GCC_LIBS_VER}-x86_64.pkg.tar.zst"
echo "${GLIBC_SHA256} /tmp/old-glibc.pkg.tar.zst" | sha256sum -c -
echo "${GCC_LIBS_SHA256} /tmp/old-gcc-libs.pkg.tar.zst" | sha256sum -c -
tar --zstd -xf /tmp/old-glibc.pkg.tar.zst -C "${OLD_GLIBC_CACHE}"
tar --zstd -xf /tmp/old-gcc-libs.pkg.tar.zst -C "${OLD_GLIBC_CACHE}"
rm -f /tmp/old-glibc.pkg.tar.zst /tmp/old-gcc-libs.pkg.tar.zst
fi
# The shared build script only bind-mounts $ROOT (as /workspace) into the
# container, not arbitrary host paths — so the cached sysroot has to be
# copied under $ROOT to be visible there. The download above is cached
# persistently in /tmp across runs; this copy is just a fast local `cp`.
rm -rf "${ROOT}/.ci-old-glibc"
cp -a "${OLD_GLIBC_CACHE}" "${ROOT}/.ci-old-glibc"
bash "${CACHE_DIR}/ci/build.sh" breadarr "$ROOT" sh -c '
RUSTFLAGS="-C link-arg=--sysroot=/workspace/.ci-old-glibc -C link-arg=-L/workspace/.ci-old-glibc/usr/lib" \
exec "$@"
' sh "$@"

View file

@ -12,7 +12,9 @@ model_dir = "~/.cache/breadarr/models/all-MiniLM-L6-v2"
# Empty (default) means no auth at all. Set this if listen_addr is ever
# changed to bind non-loopback (e.g. so a TUI on another host on the same
# tailnet can reach it) — otherwise that's unauthenticated add/delete/search
# access to anyone who can reach the port. /health is always exempt.
# access to anyone who can reach the port. The API is plaintext HTTP; there
# is no TLS. /health is always exempt (liveness only). /health/detail
# carries cycle info and is authenticated when this token is set.
api_token = ""
[qbit]
@ -50,15 +52,32 @@ api_key = ""
bearer_token = ""
[library]
# Default root folder for shows added via the TUI's "Add Show" flow.
# Default root folder for shows added via the TUI's "Add" flow. Each show
# lands in its own "{root}/{Title} ({Year})" subfolder.
default_root_folder = "~/breadarr-library"
# Same, but for movies added via the TUI's "Add" flow — kept separate from
# default_root_folder since movies and shows live under different category
# roots on disk.
movies_root_folder = "~/breadarr-library/Movies"
[sources]
# nyaa's English-translated anime category — the daemon polls this
# automatically and grabs anything matching a monitored, missing episode.
nyaa_rss_url = "https://nyaa.si/?page=rss&c=1_2"
grab_poll_interval_secs = 300
# Kill switch for the passive RSS-feed grab loop (nyaa). Independent of
# search_enabled / upgrade_enabled.
grab_enabled = true
import_poll_interval_secs = 60
# Community JSON API mirror of The Pirate Bay — primary general-content
# search source (movies + non-anime TV).
tpb_api_url = "https://apibay.org/q.php"
# torrents.csv DHT-dump search — first fallback when TPB fails or returns
# nothing. Same hash-to-magnet grab; movies and TV.
torrents_csv_url = "https://torrents-csv.com/service/search"
# YTS v2 list_movies JSON — movie-only fallback. yts.mx does not resolve;
# yts.lt is a working host as of 2026-08-16.
yts_api_url = "https://yts.lt/api/v2/list_movies.json"
# Search-driven acquisition (movies + non-anime TV via 1337x, anime movies
# via nyaa's search mode) — unlike the nyaa RSS feed watch above, this
# actively queries a Cloudflare-fronted service with a ban history, so keep
@ -83,6 +102,7 @@ upgrade_min_score_gain = 5.0
# tried in a fixed fallback order), with a failing mirror demoted into a
# cooldown rather than re-probed on the very next search.
torrent_1337x_mirrors = [
"https://www.1337xx.to",
"https://13377x.info",
"https://13377x.email",
"https://1337xto.info",
@ -97,3 +117,31 @@ torrent_1337x_mirrors = [
"https://1337x.unblocktorrent.info",
"https://1337x.unblocktor.xyz",
]
# Off by default — GPU/CPU AV1 encode needs a calibration pass against
# real content on the target hardware before it's safe unattended.
# Defaults match breadarr-shared/src/config.rs.
[transcode]
enabled = false
poll_interval_secs = 60
vaapi_device = "/dev/dri/renderD128"
parallelism_min = 1
# Live-action (av1_vaapi) concurrent-stream ceiling; Jellyfin viewers
# are subtracted from this each cycle.
parallelism_max = 7
# Separate CPU-bound anime (libsvtav1) ceiling.
parallelism_max_anime = 2
reference_bitrate_kbps = 5320
reference_height = 1080
av1_efficiency_factor = 0.7
exclude_hdr = true
exclude_min_height = 2000
quality_live_action = 26
# Path prefixes routed to the anime encode pipeline. Empty is a no-op.
anime_root_folders = []
quality_anime = 24
anime_svtav1_preset = 10
anime_svtav1_max_threads = 4
min_size_reduction_pct = 0.10
skip_below_ceiling_ratio = 0.5
verify_sample_secs = 20.0

View file

@ -17,7 +17,14 @@ UMask=0022
RuntimeDirectory=breadarr
RuntimeDirectoryMode=0700
KillSignal=SIGTERM
TimeoutStopSec=5
# Transcode jobs can run for minutes; 5s was cutting them off on stop.
TimeoutStopSec=180
# Modest hardening valid in a user unit. Skip ProtectHome (library +
# config live under $HOME) and MemoryDenyWriteExecute (ort/onnxruntime
# may need JIT / RWX mappings).
NoNewPrivileges=yes
RestrictSUIDSGID=yes
LockPersonality=yes
[Install]
WantedBy=default.target