From 7ab28d30a707950d9aa0e4323183348cb0be04a4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:44:58 +0800 Subject: [PATCH 1/3] Fix review-queue dead ends and harden grab/import/API paths 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. --- .forgejo/workflows/check.yml | 3 +- .gitignore | 2 + CONTRIBUTING.md | 3 +- README.md | 17 +- bakery.toml | 17 +- breadarr-shared/src/client.rs | 25 +- breadarr-shared/src/config.rs | 197 +++++- breadarr-shared/src/dto.rs | 6 + breadarr-tui/src/app.rs | 141 +++- breadarr-tui/src/main.rs | 8 +- breadarr-tui/src/ui.rs | 11 +- breadarrd/src/api/mod.rs | 31 +- breadarrd/src/api/routes/health.rs | 12 +- breadarrd/src/api/routes/media.rs | 142 ++++- breadarrd/src/api/routes/quality_profiles.rs | 59 ++ breadarrd/src/api/routes/review.rs | 38 +- breadarrd/src/api/routes/search.rs | 83 +-- breadarrd/src/db.rs | 323 +++++++++- breadarrd/src/importer/mod.rs | 635 +++++++++++++++++-- breadarrd/src/library_scan.rs | 28 +- breadarrd/src/main.rs | 31 +- breadarrd/src/matcher/mod.rs | 133 +++- breadarrd/src/metadata/mod.rs | 219 ++++++- breadarrd/src/parser/mod.rs | 13 + breadarrd/src/parser/tokens.rs | 7 + breadarrd/src/scheduler.rs | 511 +++++++++++++-- breadarrd/src/scoring/score.rs | 59 +- breadarrd/src/sources/scrape.rs | 50 ++ ci/build.sh | 13 +- config.example.toml | 38 +- packaging/systemd/breadarrd.service | 9 +- 31 files changed, 2536 insertions(+), 328 deletions(-) diff --git a/.forgejo/workflows/check.yml b/.forgejo/workflows/check.yml index b547c34..8b14fac 100644 --- a/.forgejo/workflows/check.yml +++ b/.forgejo/workflows/check.yml @@ -4,7 +4,8 @@ name: check # main and triggers a dev-track release build. on: push: - branches: ['feature/**', 'fix/**'] + branches: ['feature/**', 'fix/**', 'main'] + pull_request: jobs: check: diff --git a/.gitignore b/.gitignore index 128b003..1b8749f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,13 @@ /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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8f541b..f2c30f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,7 +82,8 @@ Host tools the daemon shells out to: `mkvtoolnix-cli` (`mkvmerge`) and ## CI -- `check.yml` — clippy + test on push to `feature/**` and `fix/**`. +- `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 diff --git a/README.md b/README.md index 3d09414..4e7f5e2 100644 --- a/README.md +++ b/README.md @@ -52,24 +52,27 @@ Install via bakery (`bakery install breadarr`) on a homelab host, or build from ## 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` cycles Library / History / Review Queue / Add Show / Stuck / Calendar / Health / Profiles. `j`/`k` or arrow keys navigate, `Enter` opens detail or runs a search, `Esc` backs out. - **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. +- **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 ` 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 `. - Library normalization is a manual, explicit action (not run automatically against your files): `breadarrd debug-scan-tv ` / `breadarrd debug-scan-movies `. - `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 ` 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 `/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. @@ -77,8 +80,8 @@ Install via bakery (`bakery install breadarr`) on a homelab host, or build from ## 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. @@ -86,10 +89,6 @@ Install via bakery (`bakery install breadarr`) on a homelab host, or build from 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. @@ -100,7 +99,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 diff --git a/bakery.toml b/bakery.toml index e976440..186ac4e 100644 --- a/bakery.toml +++ b/bakery.toml @@ -14,17 +14,17 @@ binaries = ["breadarrd", "breadarr-tui"] # 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. openssl: libssl.so.3/libcrypto.so.3 -# (reqwest's TLS backend) show up directly in `ldd` for both breadarrd and -# breadarr-tui. 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 openssl/curl/pacman -# already guaranteed present on any real Arch install. +# 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", - "openssl", ] optional_system_deps = [] bread_deps = [] @@ -40,5 +40,6 @@ example = "config.example.toml" [install] post_install = [ + "loginctl enable-linger \"$USER\" || true", "systemctl --user is-active --quiet breadarrd || systemctl --user start breadarrd", ] diff --git a/breadarr-shared/src/client.rs b/breadarr-shared/src/client.rs index 174caad..5496747 100644 --- a/breadarr-shared/src/client.rs +++ b/breadarr-shared/src/client.rs @@ -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, 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, api_token: &str) -> Result { 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); - } + 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 { @@ -59,7 +64,7 @@ impl DaemonClient { pub async fn health_detail(&self) -> Result { 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 diff --git a/breadarr-shared/src/config.rs b/breadarr-shared/src/config.rs index b8c1f51..77cb044 100644 --- a/breadarr-shared/src/config.rs +++ b/breadarr-shared/src/config.rs @@ -222,12 +222,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, } @@ -595,11 +593,10 @@ impl Config { Ok(cfg) } - /// Rejects a handful of `transcode` values that are individually - /// syntactically valid TOML but make the transcode pipeline's math - /// nonsensical — there's no other validation anywhere in this config, - /// so a typo here would otherwise only surface much later, deep inside - /// an encode. + /// 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 @@ -623,9 +620,69 @@ impl Config { (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) } @@ -666,6 +723,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::() { + 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::().is_ok() && !h.contains(':') { + h + } else { + listen_addr + } + } else { + listen_addr + }; + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()) +} + fn default_log_level() -> String { "info".to_string() } @@ -749,4 +829,99 @@ mod tests { 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()); + } } diff --git a/breadarr-shared/src/dto.rs b/breadarr-shared/src/dto.rs index 070734e..7289b6c 100644 --- a/breadarr-shared/src/dto.rs +++ b/breadarr-shared/src/dto.rs @@ -136,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, diff --git a/breadarr-tui/src/app.rs b/breadarr-tui/src/app.rs index 823535a..45b213e 100644 --- a/breadarr-tui/src/app.rs +++ b/breadarr-tui/src/app.rs @@ -1,8 +1,8 @@ use anyhow::Result; use breadarr_shared::dto::{ CalendarEntry, HealthDetail, LibraryHealthReport, MediaItemDetail, MediaItemSummary, - QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, SearchResult, - StuckReport, WeightsDto, + QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, SearchNowResult, + SearchResult, StuckReport, WeightsDto, }; use breadarr_shared::DaemonClient; use ratatui::widgets::ListState; @@ -30,6 +30,13 @@ fn item_root_folder(root: &str, title: &str, year: Option) -> String { .chars() .map(|c| if "/\\:*?\"<>|".contains(c) { '_' } else { c }) .collect(); + // `.` / `..` survive the character class above and would make + // `root.join(...)` walk out of the library root. + let sanitized = if sanitized == "." || sanitized == ".." { + "_".to_string() + } else { + sanitized + }; let folder_name = match year { Some(y) => format!("{sanitized} ({y})"), None => sanitized, @@ -230,6 +237,16 @@ pub struct AddResult { pub result: SearchResult, } +/// Result of a long `DaemonClient` call spawned off the draw loop so +/// `search_now` / candidate fetch (up to 600s) cannot freeze key handling. +enum BackgroundOutcome { + SearchNow(Result), + Candidates { + episode_id: Option, + result: Result>, + }, +} + pub struct App { pub client: DaemonClient, pub daemon_up: bool, @@ -306,6 +323,10 @@ pub struct App { pub profile_weight_state: ListState, /// In-progress digits while `Focus::WeightInput` is active. pub weight_input_buffer: String, + + /// True while a long search-now / candidate-fetch task is in flight. + pub busy: bool, + background: Option>, } impl App { @@ -350,6 +371,52 @@ impl App { profile_detail: None, profile_weight_state: ListState::default(), weight_input_buffer: String::new(), + busy: false, + background: None, + } + } + + /// Applies a finished background search/candidate task. Only `.await`s + /// a handle that `is_finished()`, so the draw loop stays responsive. + pub async fn poll_background(&mut self) { + let Some(handle) = &self.background else { + return; + }; + if !handle.is_finished() { + return; + } + let handle = self.background.take().expect("just checked is_finished"); + self.busy = false; + match handle.await { + Ok(BackgroundOutcome::SearchNow(Ok(stats))) => { + self.status = format!( + "search complete: {} target(s), {} grabbed, {} error(s)", + stats.targets, stats.grabbed, stats.errors + ); + } + Ok(BackgroundOutcome::SearchNow(Err(e))) => { + self.status = format!("search failed: {e}"); + } + Ok(BackgroundOutcome::Candidates { + episode_id, + result: Ok(candidates), + }) => { + self.status = format!("{} candidate(s) found", candidates.len()); + if matches!(self.tab, Tab::Library) && self.detail.is_some() { + self.candidates_state.select(if candidates.is_empty() { + None + } else { + Some(0) + }); + self.candidates = candidates; + self.candidates_episode_id = episode_id; + self.focus = Focus::Candidates; + } + } + Ok(BackgroundOutcome::Candidates { result: Err(e), .. }) => { + self.status = format!("candidate fetch failed: {e}"); + } + Err(e) => self.status = format!("background task failed: {e}"), } } @@ -733,22 +800,21 @@ impl App { /// Manual "search now" for the show/movie currently open in the Library /// detail view — can take a while (jittered, one request per missing - /// item), so the status line makes that explicit rather than looking - /// like the UI hung. + /// item), so this is spawned off the draw loop and the status line + /// shows that work is in flight. A second press while busy is ignored. pub async fn search_now_selected(&mut self) { let Some(id) = self.detail.as_ref().map(|d| d.id) else { return; }; - self.status = "searching now (this can take a while)...".to_string(); - match self.client.search_now(id).await { - Ok(stats) => { - self.status = format!( - "search complete: {} target(s), {} grabbed, {} error(s)", - stats.targets, stats.grabbed, stats.errors - ); - } - Err(e) => self.status = format!("search failed: {e}"), + if self.busy { + return; } + self.status = "searching now...".to_string(); + self.busy = true; + let client = self.client.clone(); + self.background = Some(tokio::spawn(async move { + BackgroundOutcome::SearchNow(client.search_now(id).await) + })); } /// Fetches candidates for whatever's selected in the open detail view — @@ -771,23 +837,20 @@ impl App { Some(episode.id) }; let media_item_id = detail.id; - - self.status = "fetching candidates (this can take a while)...".to_string(); - let result = match episode_id { - Some(id) => self.client.episode_candidates(id).await, - None => self.client.movie_candidates(media_item_id).await, - }; - match result { - Ok(candidates) => { - self.status = format!("{} candidate(s) found", candidates.len()); - self.candidates_state - .select(if candidates.is_empty() { None } else { Some(0) }); - self.candidates = candidates; - self.candidates_episode_id = episode_id; - self.focus = Focus::Candidates; - } - Err(e) => self.status = format!("candidate fetch failed: {e}"), + if self.busy { + return; } + + self.status = "fetching candidates...".to_string(); + self.busy = true; + let client = self.client.clone(); + self.background = Some(tokio::spawn(async move { + let result = match episode_id { + Some(id) => client.episode_candidates(id).await, + None => client.movie_candidates(media_item_id).await, + }; + BackgroundOutcome::Candidates { episode_id, result } + })); } /// Grabs whichever candidate is currently selected in the picker @@ -1059,3 +1122,23 @@ impl App { } } } + +#[cfg(test)] +mod tests { + use super::item_root_folder; + + #[test] + fn item_root_folder_replaces_dot_and_dotdot() { + assert_eq!(item_root_folder("/lib", "..", None), "/lib/_"); + assert_eq!(item_root_folder("/lib", ".", None), "/lib/_"); + assert_eq!(item_root_folder("/lib", "..", Some(2020)), "/lib/_ (2020)"); + } + + #[test] + fn item_root_folder_sanitizes_hostile_characters() { + assert_eq!( + item_root_folder("/lib", "Foo: Bar/Baz", Some(2020)), + "/lib/Foo_ Bar_Baz (2020)" + ); + } +} diff --git a/breadarr-tui/src/main.rs b/breadarr-tui/src/main.rs index b41c78b..f4135e3 100644 --- a/breadarr-tui/src/main.rs +++ b/breadarr-tui/src/main.rs @@ -20,7 +20,7 @@ use app::{App, Focus, LibraryRoots, StuckSection, Tab}; 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 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(), @@ -50,6 +50,8 @@ async fn run( let mut last_refresh = tokio::time::Instant::now() - Duration::from_secs(10); loop { + app.poll_background().await; + if last_refresh.elapsed() >= Duration::from_secs(3) || app.force_refresh { app.refresh_active_tab().await; app.force_refresh = false; @@ -81,7 +83,9 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { KeyCode::Backspace => { app.add_query.pop(); } - KeyCode::Esc => app.should_quit = true, + KeyCode::Esc => { + app.add_query.clear(); + } KeyCode::Tab => cycle_tab(app), _ => {} } diff --git a/breadarr-tui/src/ui.rs b/breadarr-tui/src/ui.rs index c09c49d..5f7735c 100644 --- a/breadarr-tui/src/ui.rs +++ b/breadarr-tui/src/ui.rs @@ -73,7 +73,7 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> { ], Tab::Review => vec![("a", "approve"), ("r", "reject")], Tab::Add => match app.focus { - Focus::AddSearchInput => vec![("Enter", "search")], + Focus::AddSearchInput => vec![("Enter", "search"), ("Esc", "clear")], _ => vec![("Enter", "add"), ("Esc", "back to search")], }, Tab::Profiles if app.profile_detail.is_some() => { @@ -745,6 +745,13 @@ fn draw_status(frame: &mut Frame, area: Rect, app: &App) { .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); } diff --git a/breadarrd/src/api/mod.rs b/breadarrd/src/api/mod.rs index 92f82f0..ec8932d 100644 --- a/breadarrd/src/api/mod.rs +++ b/breadarrd/src/api/mod.rs @@ -71,7 +71,7 @@ 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, @@ -96,10 +96,11 @@ pub struct CycleRecord { /// Rejects any request lacking `Authorization: Bearer ` /// 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, req: Request, next: Next) -> Response { if state.config.daemon.api_token.is_empty() || req.uri().path() == "/health" { return next.run(req).await; @@ -118,21 +119,27 @@ async fn require_api_token(State(state): State, req: Request, next: Ne /// 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 -/// touches every byte of the shorter input regardless of where they first -/// differ. Real-world exposure here is low (loopback-bound by default, a -/// personal single-user daemon), but it costs nothing to close. +/// 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()); - if a.len() != b.len() { - return false; + 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; } - a.iter().zip(b.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 + 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", diff --git a/breadarrd/src/api/routes/health.rs b/breadarrd/src/api/routes/health.rs index e69aa37..3e43f4b 100644 --- a/breadarrd/src/api/routes/health.rs +++ b/breadarrd/src/api/routes/health.rs @@ -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) -> Json { +/// Unauthenticated liveness — cheap, no cycle detail. +pub async fn health() -> Json { + Json(HealthStatus { + status: "ok".to_string(), + }) +} + +/// Authenticated cycle-status payload (same as the old `/health`). +pub async fn health_detail(State(state): State) -> Json { let status = state.cycle_status.lock().expect("cycle_status poisoned"); Json(HealthDetail { status: "ok".to_string(), diff --git a/breadarrd/src/api/routes/media.rs b/breadarrd/src/api/routes/media.rs index 0c96885..83c0de3 100644 --- a/breadarrd/src/api/routes/media.rs +++ b/breadarrd/src/api/routes/media.rs @@ -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, ) -> Result, (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, Json(req): Json, ) -> Result, (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, 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, ) -> Result { let conn = state.conn.lock().await; - let row: Option<(i64, String)> = conn - .query_row( - "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 { + let files = tracked_files( + &conn, + "SELECT id, path FROM episode_file WHERE episode_id = ?1", + episode_id, + )?; + if files.is_empty() { return Err(( StatusCode::NOT_FOUND, format!("no file tracked for episode {episode_id}"), )); - }; - delete_file_and_clear(&conn, &path, file_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, ) -> Result { let conn = state.conn.lock().await; - let row: Option<(i64, String)> = conn - .query_row( - "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 { + let files = tracked_files( + &conn, + "SELECT id, path FROM episode_file WHERE media_item_id = ?1 AND episode_id IS NULL", + media_item_id, + )?; + if files.is_empty() { return Err(( StatusCode::NOT_FOUND, format!("no file tracked for media_item {media_item_id}"), )); - }; - delete_file_and_clear(&conn, &path, file_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, (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::>>() + .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 { + 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: E) -> (StatusCode, String) { (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) } diff --git a/breadarrd/src/api/routes/quality_profiles.rs b/breadarrd/src/api/routes/quality_profiles.rs index 0387a7b..50814e9 100644 --- a/breadarrd/src/api/routes/quality_profiles.rs +++ b/breadarrd/src/api/routes/quality_profiles.rs @@ -68,6 +68,12 @@ pub async fn update_weights( Path(id): Path, Json(req): Json, ) -> Result { + 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: 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)); + } +} diff --git a/breadarrd/src/api/routes/review.rs b/breadarrd/src/api/routes/review.rs index d00f480..8a846bc 100644 --- a/breadarrd/src/api/routes/review.rs +++ b/breadarrd/src/api/routes/review.rs @@ -75,21 +75,22 @@ pub async fn approve( } }; - 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 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; @@ -110,7 +111,12 @@ pub async fn reject( Path(id): Path, ) -> Result { 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) } diff --git a/breadarrd/src/api/routes/search.rs b/breadarrd/src/api/routes/search.rs index cc032e4..6d6ea7b 100644 --- a/breadarrd/src/api/routes/search.rs +++ b/breadarrd/src/api/routes/search.rs @@ -21,43 +21,50 @@ pub async fn search( State(state): State, Query(params): Query, ) -> Result>, (StatusCode, String)> { - if params.kind == "movie" { - let Some(tmdb) = &state.tmdb else { - return Err(( - StatusCode::PRECONDITION_FAILED, - "tmdb.bearer_token is not configured".into(), - )); - }; - let results = tmdb - .search_movie(¶ms.q) - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))? - .into_iter() - .map(|r| SearchResult { - external_id: r.external_id, - title: r.title, - year: r.year.map(|y| y as i64), - }) - .collect(); - return Ok(Json(results)); + match params.kind.as_str() { + "movie" => { + let Some(tmdb) = &state.tmdb else { + return Err(( + StatusCode::PRECONDITION_FAILED, + "tmdb.bearer_token is not configured".into(), + )); + }; + let results = tmdb + .search_movie(¶ms.q) + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))? + .into_iter() + .map(|r| SearchResult { + external_id: r.external_id, + title: r.title, + year: r.year.map(|y| y as i64), + }) + .collect(); + Ok(Json(results)) + } + "series" => { + let Some(tvdb) = &state.tvdb else { + return Err(( + StatusCode::PRECONDITION_FAILED, + "tvdb.api_key is not configured".into(), + )); + }; + let results = tvdb + .search_series(¶ms.q) + .await + .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))? + .into_iter() + .map(|r| SearchResult { + external_id: r.external_id, + title: r.name, + year: r.year.map(|y| y as i64), + }) + .collect(); + Ok(Json(results)) + } + other => Err(( + StatusCode::BAD_REQUEST, + format!("kind must be series or movie, got {other:?}"), + )), } - - let Some(tvdb) = &state.tvdb else { - return Err(( - StatusCode::PRECONDITION_FAILED, - "tvdb.api_key is not configured".into(), - )); - }; - let results = tvdb - .search_series(¶ms.q) - .await - .map_err(|e| (StatusCode::BAD_GATEWAY, e.to_string()))? - .into_iter() - .map(|r| SearchResult { - external_id: r.external_id, - title: r.name, - year: r.year.map(|y| y as i64), - }) - .collect(); - Ok(Json(results)) } diff --git a/breadarrd/src/db.rs b/breadarrd/src/db.rs index 4b69c42..6ecee1d 100644 --- a/breadarrd/src/db.rs +++ b/breadarrd/src/db.rs @@ -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 `/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) @@ -378,7 +374,14 @@ pub fn init(conn: &Connection) -> anyhow::Result<()> { -- 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');", + 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 @@ -477,6 +480,155 @@ pub fn init(conn: &Connection) -> anyhow::Result<()> { "INTEGER NOT NULL DEFAULT 0", )?; + ensure_indexes(conn)?; + + Ok(()) +} + +fn index_exists(conn: &Connection, name: &str) -> anyhow::Result { + 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 { + 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::>>()? + }; + + 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 = { + let mut stmt = conn.prepare(&extra_sql)?; + let rows = stmt.query_map(rusqlite::params![ext_id, keep_id], |row| row.get(0))?; + rows.collect::>>()? + }; + 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(()) } @@ -640,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(); } @@ -660,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 diff --git a/breadarrd/src/importer/mod.rs b/breadarrd/src/importer/mod.rs index e5d8774..1bce250 100644 --- a/breadarrd/src/importer/mod.rs +++ b/breadarrd/src/importer/mod.rs @@ -201,9 +201,17 @@ pub(crate) fn walk_files(dir: &Path) -> Result> { let mut out = Vec::new(); for entry in std::fs::read_dir(dir)? { let path = entry?.path(); - if path.is_dir() { + // `path.is_dir()` follows symlinks, which would let a planted + // directory link walk the importer (and library-scan) out of the + // download/library root. Skip every symlink — file links to videos + // included — and only recurse into real directories. + let ft = std::fs::symlink_metadata(&path)?.file_type(); + if ft.is_symlink() { + continue; + } + if ft.is_dir() { out.extend(walk_files(&path)?); - } else { + } else if ft.is_file() { out.push(path); } } @@ -263,9 +271,25 @@ pub(crate) fn deterministic_movie_filename(title: &str, year: Option, ext: } pub(crate) fn sanitize(s: &str) -> String { - s.chars() - .map(|c| if "/\\:*?\"<>|".contains(c) { '_' } else { c }) - .collect() + let cleaned: String = s + .chars() + .map(|c| { + if c.is_ascii_control() || "/\\:*?\"<>|".contains(c) { + '_' + } else { + c + } + }) + .collect(); + // After the replacements above, a title of `.` or `..` is still a + // hostile path component (`root_folder/../file`). Slash-containing + // titles become `foo_.._bar`, which is fine — only a lone `.`/`..` + // can climb. + if cleaned == "." || cleaned == ".." { + "_".to_string() + } else { + cleaned + } } /// True when there isn't enough free space at `dir` to hold `needed_bytes`. @@ -335,6 +359,116 @@ fn move_or_copy_file(src: &Path, dest: &Path) -> Result<()> { }) } +fn is_video_path(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| VIDEO_EXTS.contains(&e.to_lowercase().as_str())) + .unwrap_or(false) +} + +/// Lexical `..`/`.` collapse without touching the filesystem — used when +/// `canonicalize` can't (path missing) and by `remap_path` before the +/// joined result is allowed to escape `host_prefix`. +fn normalize_lexically(path: &Path) -> PathBuf { + use std::path::Component; + let mut out = PathBuf::new(); + for comp in path.components() { + match comp { + Component::Prefix(p) => out.push(p.as_os_str()), + Component::RootDir => out.push(Component::RootDir.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + Component::Normal(c) => out.push(c), + } + } + out +} + +fn canonicalize_or_normalize(path: &Path) -> PathBuf { + if let Ok(canon) = std::fs::canonicalize(path) { + return canon; + } + if let Some(parent) = path.parent() { + if let (Ok(canon_parent), Some(name)) = (std::fs::canonicalize(parent), path.file_name()) { + return canon_parent.join(name); + } + } + if path.is_absolute() { + return normalize_lexically(path); + } + std::env::current_dir() + .map(|cwd| normalize_lexically(&cwd.join(path))) + .unwrap_or_else(|_| normalize_lexically(path)) +} + +fn is_strictly_inside(path: &Path, root: &Path) -> bool { + let path = canonicalize_or_normalize(path); + let root = canonicalize_or_normalize(root); + path.starts_with(&root) && path != root +} + +fn is_common_filesystem_root(path: &Path) -> bool { + let normalized = canonicalize_or_normalize(path); + if normalized == Path::new("/") + || normalized == Path::new("/mnt") + || normalized == Path::new("/media") + { + return true; + } + if let Some(home) = std::env::var_os("HOME") { + if normalized == Path::new(&home) { + return true; + } + } + false +} + +/// `true` only for a per-torrent folder we are willing to `remove_dir_all`. +/// A configured `host_downloads_path` must strictly contain `content_path`; +/// an empty host falls back to "has a real parent that isn't `/` and isn't +/// a well-known root" so a default-empty config cannot wipe `/` or `$HOME`. +fn is_safe_cleanup_target(content_path: &Path, host_downloads_path: &str) -> bool { + let meta = match std::fs::symlink_metadata(content_path) { + Ok(m) => m, + Err(_) => return false, + }; + if !meta.is_dir() || meta.file_type().is_symlink() { + return false; + } + if !host_downloads_path.is_empty() { + return is_strictly_inside(content_path, Path::new(host_downloads_path)); + } + if is_common_filesystem_root(content_path) { + return false; + } + let Some(parent) = content_path.parent() else { + return false; + }; + if parent == Path::new("/") || parent.as_os_str().is_empty() || !parent.exists() { + return false; + } + content_path != parent +} + +fn dir_contains_video_files(dir: &Path) -> bool { + match walk_files(dir) { + Ok(files) => files.iter().any(|p| is_video_path(p)), + // A walk failure must not authorize a wipe. + Err(_) => true, + } +} + +fn remove_non_video_files(dir: &Path) -> Result<()> { + for path in walk_files(dir)? { + if !is_video_path(&path) { + let _ = std::fs::remove_file(&path); + } + } + Ok(()) +} + /// Removes whatever's left of a torrent's download folder once every file /// breadarr cares about has already been moved out of it — split out from /// `run_import_cycle` so it's directly testable without a real qBittorrent @@ -343,14 +477,21 @@ fn move_or_copy_file(src: &Path, dest: &Path) -> Result<()> { /// `.jpg` sidecars) is otherwise left behind forever, since nothing else /// ever points at it once the torrent itself is gone from qBittorrent. A /// bare file (a release with no wrapping folder) needs no cleanup here — -/// `move_or_copy_file` already consumed it. The `!= host_downloads_path` -/// guard is defense in depth against a malformed `content_path` resolving -/// to the downloads root itself; every real torrent lands in its own -/// subdirectory or as a single file, never the root. +/// `move_or_copy_file` already consumed it. +/// +/// Never wipes a directory that still contains a video we didn't import +/// (unmatched pack files), and never `remove_dir_all`s the downloads root +/// itself — `host_downloads_path` defaults to `""`, so a raw `!= host` +/// comparison is not enough. fn cleanup_leftover_download_dir(content_path: &Path, host_downloads_path: &str) -> Result<()> { - if content_path.is_dir() && content_path != Path::new(host_downloads_path) { - std::fs::remove_dir_all(content_path)?; + if !is_safe_cleanup_target(content_path, host_downloads_path) { + return Ok(()); } + if dir_contains_video_files(content_path) { + remove_non_video_files(content_path)?; + return Ok(()); + } + std::fs::remove_dir_all(content_path)?; Ok(()) } @@ -1336,7 +1477,22 @@ fn remap_path(reported: &str, container_prefix: &str, host_prefix: &str) -> Path return PathBuf::from(reported); } match reported.strip_prefix(container_prefix) { - Some(rest) => PathBuf::from(format!("{host_prefix}{rest}")), + Some(rest) => { + let rest_path = Path::new(rest); + if rest_path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return PathBuf::from(reported); + } + let joined = PathBuf::from(format!("{host_prefix}{rest}")); + let normalized = normalize_lexically(&joined); + let host = normalize_lexically(Path::new(host_prefix)); + if !normalized.starts_with(&host) { + return PathBuf::from(reported); + } + normalized + } None => PathBuf::from(reported), } } @@ -1423,17 +1579,13 @@ fn process_pending_grabs( transcode_cfg: Option<&breadarr_shared::config::TranscodeConfig>, ) -> Result<(ImportStats, Vec<(String, PathBuf)>)> { let mut stats = ImportStats::default(); - // Torrents whose data has been fully dealt with this cycle — moved into - // the library, or deleted outright because a better file already existed - // (see `ImportOutcome::SkippedAlreadyHaveBetter`, whose only producer, - // `import_one`, deletes the losing download itself). Either way nothing - // *breadarr still needs* remains at the torrent's original location, so - // the caller (`run_import_cycle`, the async I/O boundary this function is - // deliberately kept free of) removes each from qBittorrent afterward and - // clears out whatever's left of `content_path` — a multi-file release - // only ever has its video moved out by `move_or_copy_file`, so without - // this the surrounding folder (sample clips, .nfo/.srt/.jpg sidecars) - // sits there forever with no torrent left to account for it. + // Torrents whose data has been fully dealt with this cycle — imported, + // skipped as already-better, or given up on (stall / missing past grace + // / MAX_IMPORT_ERRORS). The caller (`run_import_cycle`) removes each + // from qBittorrent afterward and runs leftover-dir cleanup, which now + // refuses to wipe leftover videos or the downloads root. `fail_grab` + // itself only writes SQLite; hashes collected here are how the torrent + // actually leaves qBit. let mut imported_hashes: Vec<(String, PathBuf)> = Vec::new(); for grab in pending { @@ -1441,6 +1593,10 @@ fn process_pending_grabs( if grab_missing_past_grace(conn, grab.release_id())? { fail_grab(conn, grab, "torrent hash absent from qBittorrent")?; stats.failed += 1; + // Torrent is already gone from qBit; still collect the hash + // so delete is attempted (best-effort) and any known path + // would be cleaned. No `content_path` is available here. + imported_hashes.push((grab.torrent_hash().to_string(), PathBuf::new())); } continue; }; @@ -1454,6 +1610,12 @@ fn process_pending_grabs( "no progress for longer than the stall threshold", )?; stats.failed += 1; + let content_path = remap_path( + &torrent.content_path, + container_downloads_path, + host_downloads_path, + ); + imported_hashes.push((grab.torrent_hash().to_string(), content_path)); } continue; } @@ -1497,7 +1659,11 @@ fn process_pending_grabs( Ok(outcome) => { stats.imported += outcome.episodes_imported; stats.quality_flagged += outcome.quality_flagged; - if outcome.episodes_imported > 0 { + // Imported files *or* a no-op upgrade (`upgraded` with + // 0 imported): nothing breadarr still needs lives in + // qBit. Unmatched leftover videos are left on disk by + // `cleanup_leftover_download_dir`. + if outcome.episodes_imported > 0 || outcome.episodes_already_had_better > 0 { imported_hashes.push((grab.torrent_hash().to_string(), content_path.clone())); } } @@ -1510,6 +1676,7 @@ fn process_pending_grabs( &format!("season pack import failed {error_count} times in a row: {e}"), )?; stats.failed += 1; + imported_hashes.push((grab.torrent_hash().to_string(), content_path.clone())); } else { tracing::warn!( error = %e, @@ -1553,6 +1720,7 @@ fn process_pending_grabs( &format!("import failed {error_count} times in a row: {e}"), )?; stats.failed += 1; + imported_hashes.push((grab.torrent_hash().to_string(), content_path.clone())); } else { tracing::warn!( error = %e, @@ -2068,6 +2236,12 @@ fn import_season_pack( std::fs::create_dir_all(root_folder)?; + let tvdb_id: Option = conn.query_row( + "SELECT tvdb_id FROM media_item WHERE id = ?1", + params![media_item_id], + |row| row.get(0), + )?; + let mut outcome = SeasonPackImportOutcome::default(); for source_path in &video_files { let filename_only = source_path @@ -2075,18 +2249,24 @@ fn import_season_pack( .and_then(|f| f.to_str()) .unwrap_or_default(); let parsed = crate::parser::parse(filename_only); - let Some(episode_number) = parsed.episode.or(parsed.absolute_episode) else { - outcome.episodes_unmatched += 1; - tracing::warn!( - file = filename_only, - "season pack: could not determine an episode number for this file, skipping" - ); - continue; + // Anime packs name files `[SubsPlease] Show - 15.mkv` (absolute + // only). Treating that as S01E15 skipped the AniDB map that + // `library_scan` already uses via `resolve_episode`. + let resolved = crate::scheduler::resolve_episode(conn, tvdb_id, &parsed)?; + let (file_season, episode_number) = match resolved { + Some(pair) => pair, + None => match (parsed.season, parsed.episode) { + (Some(season), Some(episode)) => (season, episode), + _ => { + outcome.episodes_unmatched += 1; + tracing::warn!( + file = filename_only, + "season pack: could not determine an episode number for this file, skipping" + ); + continue; + } + }, }; - // Prefer the individual file's own season marker when it has one - // (a pack can occasionally mix seasons); fall back to the pack's - // own season otherwise. - let file_season = parsed.season.unwrap_or(season_number); let episode_id = match crate::scheduler::find_episode_id( conn, @@ -3158,6 +3338,11 @@ mod tests { #[test] fn sanitizes_path_hostile_characters() { assert_eq!(sanitize("Kill: Ao / Blue?"), "Kill_ Ao _ Blue_"); + assert_eq!(sanitize(".."), "_"); + assert_eq!(sanitize("."), "_"); + // Slashes become `_` first, so this is not a lone `..` component. + assert_eq!(sanitize("foo/../bar"), "foo_.._bar"); + assert_eq!(sanitize("title\nwith\x00ctrl"), "title_with_ctrl"); } #[test] @@ -3307,6 +3492,70 @@ mod tests { std::fs::remove_dir_all(&downloads).unwrap(); } + #[test] + fn cleanup_leftover_download_dir_leaves_unmatched_videos() { + let downloads = std::env::temp_dir().join(format!( + "breadarr-cleanup-leftover-video-{}", + std::process::id() + )); + let release_dir = downloads.join("Some Show S01"); + std::fs::create_dir_all(&release_dir).unwrap(); + let unmatched = release_dir.join("Show - 15.mkv"); + std::fs::write(&unmatched, b"unmatched video").unwrap(); + std::fs::write(release_dir.join("release.nfo"), b"nfo").unwrap(); + std::fs::write(release_dir.join("poster.jpg"), b"jpeg").unwrap(); + + cleanup_leftover_download_dir(&release_dir, &downloads.to_string_lossy()).unwrap(); + + assert!( + unmatched.exists(), + "an unmatched video must survive leftover-dir cleanup" + ); + assert!( + !release_dir.join("release.nfo").exists(), + "sidecars next to leftover videos should still be dropped" + ); + assert!(!release_dir.join("poster.jpg").exists()); + assert!(release_dir.exists()); + + std::fs::remove_dir_all(&downloads).unwrap(); + } + + #[test] + fn cleanup_leftover_download_dir_empty_host_still_removes_a_per_torrent_folder() { + let downloads = std::env::temp_dir().join(format!( + "breadarr-cleanup-empty-host-{}", + std::process::id() + )); + let release_dir = downloads.join("Some Movie 2016 1080p"); + std::fs::create_dir_all(&release_dir).unwrap(); + std::fs::write(release_dir.join("poster.jpg"), b"jpeg").unwrap(); + + cleanup_leftover_download_dir(&release_dir, "").unwrap(); + + assert!(!release_dir.exists()); + assert!(downloads.exists()); + + std::fs::remove_dir_all(&downloads).unwrap(); + } + + #[test] + fn cleanup_leftover_download_dir_empty_host_refuses_common_roots() { + cleanup_leftover_download_dir(Path::new("/"), "").unwrap(); + assert!(Path::new("/").exists()); + + cleanup_leftover_download_dir(Path::new("/mnt"), "").unwrap(); + cleanup_leftover_download_dir(Path::new("/media"), "").unwrap(); + + if let Some(home) = std::env::var_os("HOME") { + let home_path = PathBuf::from(&home); + if home_path.is_dir() { + cleanup_leftover_download_dir(&home_path, "").unwrap(); + assert!(home_path.exists(), "must never wipe $HOME"); + } + } + } + #[test] fn locates_a_single_file_torrent() { let dir = std::env::temp_dir().join(format!("breadarr-test-{}", std::process::id())); @@ -3320,6 +3569,42 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + #[test] + fn walk_files_does_not_follow_symlinks() { + let dir = std::env::temp_dir().join(format!( + "breadarr-walk-symlink-{}", + std::process::id() + )); + let outside = std::env::temp_dir().join(format!( + "breadarr-walk-outside-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(dir.join("real.mkv"), b"inside").unwrap(); + std::fs::write(outside.join("secret.mkv"), b"escaped").unwrap(); + std::os::unix::fs::symlink(&outside, dir.join("escape")).unwrap(); + std::os::unix::fs::symlink(outside.join("secret.mkv"), dir.join("link.mkv")).unwrap(); + + let walked = walk_files(&dir).unwrap(); + let names: Vec = walked + .iter() + .filter_map(|p| p.file_name().and_then(|n| n.to_str()).map(str::to_string)) + .collect(); + assert!(names.contains(&"real.mkv".to_string())); + assert!( + !names.contains(&"secret.mkv".to_string()), + "a directory symlink must not pull files from outside the scan root" + ); + assert!( + !names.contains(&"link.mkv".to_string()), + "a file symlink to a video must be skipped" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + std::fs::remove_dir_all(&outside).unwrap(); + } + #[test] fn remap_path_translates_container_prefix_to_host_prefix() { assert_eq!( @@ -3340,6 +3625,31 @@ mod tests { ); } + #[test] + fn remap_path_rejects_parent_dir_escape() { + let remapped = remap_path( + "/downloads/../../etc", + "/downloads", + "/home/breadway/downloads", + ); + assert_eq!(remapped, PathBuf::from("/downloads/../../etc")); + assert!( + !normalize_lexically(&remapped).starts_with("/home/breadway/downloads") + || remapped.as_os_str() == "/downloads/../../etc", + "a `..` remainder must not be rewritten under the host prefix" + ); + + let remapped = remap_path( + "/downloads/show/../../../etc/passwd", + "/downloads", + "/home/breadway/downloads", + ); + assert_eq!( + remapped, + PathBuf::from("/downloads/show/../../../etc/passwd") + ); + } + #[test] fn process_pending_grabs_routes_each_grab_by_torrent_state() { use crate::qbit::TorrentInfo; @@ -3445,12 +3755,22 @@ mod tests { // absent — simulating torrents qBit no longer knows about. ]; - let (stats, _) = process_pending_grabs(&conn, &pending, &torrents, "", "", None).unwrap(); + let (stats, hashes) = process_pending_grabs(&conn, &pending, &torrents, "", "", None).unwrap(); assert_eq!(stats.imported, 1); assert_eq!(stats.skipped_incomplete, 1); assert_eq!(stats.failed, 1); assert_eq!(stats.errors, 0); + assert!( + hashes.iter().any(|(h, _)| h == "hash-complete"), + "a successful import must still be queued for qBit delete" + ); + assert!( + hashes.iter().any(|(h, _)| h == "hash-missing-stale"), + "a grab failed for a missing torrent must be queued for qBit delete" + ); + assert!(!hashes.iter().any(|(h, _)| h == "hash-downloading")); + assert!(!hashes.iter().any(|(h, _)| h == "hash-missing-fresh")); let statuses: Vec<(i64, String)> = { let mut stmt = conn @@ -3575,9 +3895,13 @@ mod tests { // The Nth failure crosses the threshold and gives up. let pending = fetch_pending_grabs(&conn).unwrap(); - let (stats, _) = process_pending_grabs(&conn, &pending, &torrents, "", "", None).unwrap(); + let (stats, hashes) = process_pending_grabs(&conn, &pending, &torrents, "", "", None).unwrap(); assert_eq!(stats.failed, 1); assert_eq!(stats.errors, 0); + assert!( + hashes.iter().any(|(h, _)| h == "hash-broken"), + "a grab failed after MAX_IMPORT_ERRORS must be queued for qBit delete" + ); let status: String = conn .query_row("SELECT status FROM release WHERE id = 1", [], |r| r.get(0)) .unwrap(); @@ -3590,6 +3914,51 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + #[test] + fn a_stalled_grab_is_queued_for_qbit_delete() { + use crate::qbit::TorrentInfo; + + let (conn, release_id) = seeded_release_conn(STALL_THRESHOLD_HOURS + 1.0); + // Same progress already recorded — otherwise `update_grab_progress` + // would stamp `last_progress_at = now` and reset the stall clock. + conn.execute( + "UPDATE release SET last_seen_progress = 0.4, last_progress_at = datetime('now', ?1) WHERE id = ?2", + params![format!("-{} hours", STALL_THRESHOLD_HOURS + 1.0), release_id], + ) + .unwrap(); + let dir = std::env::temp_dir().join(format!( + "breadarr-stall-delete-{}", + std::process::id() + )); + let content = dir.join("partial"); + std::fs::create_dir_all(&content).unwrap(); + std::fs::write(content.join("movie.mp4"), b"partial").unwrap(); + + let pending = fetch_pending_grabs(&conn).unwrap(); + let torrents = vec![TorrentInfo { + hash: "deadbeef".to_string(), + name: "stalled".to_string(), + state: "downloading".to_string(), + progress: 0.4, + save_path: dir.to_string_lossy().to_string(), + content_path: content.to_string_lossy().to_string(), + }]; + + let (stats, hashes) = process_pending_grabs(&conn, &pending, &torrents, "", "", None).unwrap(); + assert_eq!(stats.failed, 1); + assert!( + hashes.iter().any(|(h, p)| h == "deadbeef" && p == &content), + "a stalled grab must be queued for qBit delete with its content_path" + ); + cleanup_leftover_download_dir(&content, "").unwrap(); + assert!( + content.join("movie.mp4").exists(), + "cleanup of a failed grab must not wipe leftover videos" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn imports_a_movie_release_with_no_episode_id() { let conn = Connection::open_in_memory().unwrap(); @@ -4693,4 +5062,196 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + + #[test] + fn import_season_pack_uses_anime_absolute_mapping() { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, year, tvdb_id, monitored, quality_profile_id, root_folder) + VALUES (1, 'series', 'Show', 2020, 366263, 1, 1, '/tmp')", + [], + ) + .unwrap(); + // Bookworm-style offsets: cours starting at absolute 1/15/27/37. + for (anidb_id, offset) in [(1, 0), (2, 14), (3, 26), (4, 36)] { + conn.execute( + "INSERT INTO anime_mapping (anidb_id, tvdb_id, season_offset, episode_offset) + VALUES (?1, 366263, 1, ?2)", + params![anidb_id, offset], + ) + .unwrap(); + } + // Mapped landing spot for absolute 15 (offset 14 → S01E01). + conn.execute( + "INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file) + VALUES (1, 1, 1, 1, 1, 0)", + [], + ) + .unwrap(); + // Naive S01E15 — must not be chosen over the mapped episode. + conn.execute( + "INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file) + VALUES (15, 1, 1, 15, 1, 0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'tpb', 'scrape', 'http://x')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, season_number, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at) + VALUES (1, 1, NULL, 1, '[SubsPlease] Show (01-12)', 1, 'guid-1', 15.0, 'grabbed', 'aaaa', datetime('now'))", + [], + ) + .unwrap(); + + let dir = std::env::temp_dir().join(format!( + "breadarr-season-pack-anime-{}", + std::process::id() + )); + let pack_dir = dir.join("pack"); + std::fs::create_dir_all(&pack_dir).unwrap(); + std::fs::write(pack_dir.join("[SubsPlease] Show - 15.mkv"), b"abs 15").unwrap(); + let dest_root = dir.join("library"); + + let outcome = import_season_pack( + &conn, + 1, + 1, + "Show", + 1, + &dest_root.to_string_lossy(), + &pack_dir, + None, + ) + .unwrap(); + + assert_eq!(outcome.episodes_imported, 1); + assert_eq!(outcome.episodes_unmatched, 0); + + let (has_mapped, has_naive): (i64, i64) = conn + .query_row( + "SELECT + (SELECT has_file FROM episode WHERE id = 1), + (SELECT has_file FROM episode WHERE id = 15)", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(has_mapped, 1, "absolute 15 must land on mapped S01E01"); + assert_eq!(has_naive, 0, "must not treat absolute 15 as S01E15"); + assert!(dest_root.join("Season 01").join("Show - S01E01.mkv").exists()); + assert!(!dest_root.join("Season 01").join("Show - S01E15.mkv").exists()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn import_season_pack_cleanup_preserves_unmatched_videos() { + let conn = seeded_season_pack_conn(1); + let dir = std::env::temp_dir().join(format!( + "breadarr-season-pack-leftover-{}", + std::process::id() + )); + let pack_dir = dir.join("pack"); + std::fs::create_dir_all(&pack_dir).unwrap(); + std::fs::write(pack_dir.join("Show.S01E01.mkv"), b"matched").unwrap(); + let unmatched = pack_dir.join("Show - 15.mkv"); + std::fs::write(&unmatched, b"did not parse as SxxExx").unwrap(); + std::fs::write(pack_dir.join("release.nfo"), b"nfo").unwrap(); + let dest_root = dir.join("library"); + + let outcome = import_season_pack( + &conn, + 1, + 1, + "Some Show", + 1, + &dest_root.to_string_lossy(), + &pack_dir, + None, + ) + .unwrap(); + assert_eq!(outcome.episodes_imported, 1); + assert_eq!(outcome.episodes_unmatched, 1); + + cleanup_leftover_download_dir(&pack_dir, &dir.to_string_lossy()).unwrap(); + + assert!( + unmatched.exists(), + "unmatched pack video must survive leftover-dir cleanup" + ); + assert!( + dest_root + .join("Season 01") + .join("Some Show - S01E01.mkv") + .exists() + ); + assert!(!pack_dir.join("release.nfo").exists()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn process_pending_grabs_queues_a_noop_season_pack_upgrade_for_qbit_delete() { + use crate::qbit::TorrentInfo; + + let conn = seeded_season_pack_conn(1); + let dir = std::env::temp_dir().join(format!( + "breadarr-season-pack-noop-upgrade-{}", + std::process::id() + )); + let pack_dir = dir.join("pack"); + std::fs::create_dir_all(&pack_dir).unwrap(); + std::fs::write(pack_dir.join("Show.S01E01.mkv"), b"new e01").unwrap(); + let dest_root = dir.join("library"); + let season = dest_root.join("Season 01"); + std::fs::create_dir_all(&season).unwrap(); + let existing = season.join("Some Show - S01E01.mkv"); + std::fs::write(&existing, b"already-better").unwrap(); + conn.execute( + "INSERT INTO release (id, media_item_id, episode_id, raw_title, source_id, guid, score, status, torrent_hash, grabbed_at) + VALUES (2, 1, 1, 'Some Show S01E01 REMUX', 1, 'guid-2', 50.0, 'imported', 'bbbb', datetime('now'))", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode_file (episode_id, media_item_id, path, size_bytes, subtitle_status) + VALUES (1, NULL, ?1, 14, 'none')", + params![existing.to_string_lossy()], + ) + .unwrap(); + conn.execute( + "UPDATE media_item SET root_folder = ?1 WHERE id = 1", + params![dest_root.to_string_lossy()], + ) + .unwrap(); + + let pending = fetch_pending_grabs(&conn).unwrap(); + let torrents = vec![TorrentInfo { + hash: "aaaa".to_string(), + name: "pack".to_string(), + state: "uploading".to_string(), + progress: 1.0, + save_path: dir.to_string_lossy().to_string(), + content_path: pack_dir.to_string_lossy().to_string(), + }]; + + let (stats, hashes) = process_pending_grabs(&conn, &pending, &torrents, "", "", None).unwrap(); + assert_eq!(stats.imported, 0); + assert_eq!(stats.failed, 0); + let status: String = conn + .query_row("SELECT status FROM release WHERE id = 1", [], |r| r.get(0)) + .unwrap(); + assert_eq!(status, "upgraded"); + assert!( + hashes.iter().any(|(h, _)| h == "aaaa"), + "a no-op season-pack upgrade must still be removed from qBit" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/breadarrd/src/library_scan.rs b/breadarrd/src/library_scan.rs index b5a3c7d..d2d48de 100644 --- a/breadarrd/src/library_scan.rs +++ b/breadarrd/src/library_scan.rs @@ -435,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 @@ -729,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 @@ -883,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)); + } } diff --git a/breadarrd/src/main.rs b/breadarrd/src/main.rs index 5483aa9..a34cb89 100644 --- a/breadarrd/src/main.rs +++ b/breadarrd/src/main.rs @@ -150,7 +150,10 @@ async fn run_daemon(config: Config) -> Result<()> { 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"), + 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"), } @@ -168,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 @@ -195,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 { @@ -224,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; @@ -268,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(()) } @@ -290,6 +315,7 @@ async fn background_loop( config: Config, cycle_status: std::sync::Arc>, mut background_rx: tokio::sync::mpsc::Receiver, + transcode_busy: std::sync::Arc>, ) { let notifier = notify::Notifier::new(&config.notifications.webhook_url); { @@ -372,8 +398,7 @@ async fn background_loop( // 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. - let transcode_busy = std::sync::Arc::new(tokio::sync::Mutex::new(())); + // 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, )); diff --git a/breadarrd/src/matcher/mod.rs b/breadarrd/src/matcher/mod.rs index 88a8b15..0b24485 100644 --- a/breadarrd/src/matcher/mod.rs +++ b/breadarrd/src/matcher/mod.rs @@ -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,19 +38,21 @@ 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)) - .await - .context("download task panicked")??; +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(()) } @@ -250,6 +258,36 @@ pub fn queue_for_review( link: Option<&str>, source_id: Option, ) -> Result { + // 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 7–11 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'))", @@ -323,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); + } } diff --git a/breadarrd/src/metadata/mod.rs b/breadarrd/src/metadata/mod.rs index 0765f52..5e262f9 100644 --- a/breadarrd/src/metadata/mod.rs +++ b/breadarrd/src/metadata/mod.rs @@ -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 { @@ -65,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 { + raw.parse().ok().filter(|&id| id > 0) +} + +fn existing_media_item_id(conn: &Connection, column: &str, value: i64) -> Result> { + 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, @@ -76,21 +91,23 @@ pub fn insert_series( quality_profile_id: i64, episodes: &[EpisodeInfo], ) -> Result { - 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::().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], )?; @@ -109,12 +126,12 @@ pub fn insert_series( // seasons keep the previous default of monitored. let monitored = i64::from(ep.season_number != 0); if seasons_seen.insert(ep.season_number) { - conn.execute( + 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, ?7, 0)", @@ -130,6 +147,7 @@ pub fn insert_series( )?; } + tx.commit()?; Ok(media_item_id) } @@ -145,16 +163,177 @@ pub fn insert_movie( root_folder: &str, quality_profile_id: i64, ) -> Result { + 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::().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); + } +} diff --git a/breadarrd/src/parser/mod.rs b/breadarrd/src/parser/mod.rs index f24fcaa..0d9a263 100644 --- a/breadarrd/src/parser/mod.rs +++ b/breadarrd/src/parser/mod.rs @@ -34,6 +34,7 @@ pub struct ParsedRelease { pub bit_depth: Option, pub container: Option, 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, } } @@ -360,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. diff --git a/breadarrd/src/parser/tokens.rs b/breadarrd/src/parser/tokens.rs index c48f6a8..a79d62f 100644 --- a/breadarrd/src/parser/tokens.rs +++ b/breadarrd/src/parser/tokens.rs @@ -29,6 +29,9 @@ static BIT_DEPTH_RE: LazyLock = pub(super) static REPACK_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)\b(REPACK|PROPER)\b").unwrap()); +static HDR_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\b(?:HDR10\+?|HDR|Dolby[.\s]?Vision|DoVi|DV)\b").unwrap()); + static YEAR_RE: LazyLock = 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 @@ -181,6 +184,10 @@ pub(super) fn extract_bit_depth(s: &str) -> Option { 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 { if let Some(m) = YEAR_RE.find(s) { return s[m.start() + 1..m.end() - 1].parse().ok(); diff --git a/breadarrd/src/scheduler.rs b/breadarrd/src/scheduler.rs index 1af0718..9ec61d7 100644 --- a/breadarrd/src/scheduler.rs +++ b/breadarrd/src/scheduler.rs @@ -194,13 +194,7 @@ fn movie_needs_grab(conn: &Connection, media_item_id: i64) -> Result { if has_file > 0 { return Ok(false); } - let in_flight: i64 = conn.query_row( - "SELECT count(*) FROM release WHERE media_item_id = ?1 AND episode_id IS NULL - AND status IN ('grabbed','downloading')", - params![media_item_id], - |row| row.get(0), - )?; - Ok(in_flight == 0) + Ok(!movie_has_in_flight_release(conn, media_item_id)?) } /// Upgrade-search counterpart to `movie_needs_grab`: same monitored/ @@ -216,13 +210,7 @@ fn movie_eligible_for_upgrade(conn: &Connection, media_item_id: i64) -> Result 0 { + if movie_has_in_flight_release(conn, media_item_id)? { return Ok(false); } // Same reasoning as the `upgrade_locked` check in @@ -236,10 +224,52 @@ fn movie_eligible_for_upgrade(conn: &Connection, media_item_id: i64) -> Result Result { + let n: i64 = conn.query_row( + "SELECT count(*) FROM release WHERE media_item_id = ?1 AND episode_id IS NULL + AND status IN ('grabbed','downloading')", + params![media_item_id], + |row| row.get(0), + )?; + Ok(n > 0) +} + +fn episode_has_in_flight_release(conn: &Connection, episode_id: i64) -> Result { + let n: i64 = conn.query_row( + "SELECT count(*) FROM release WHERE episode_id = ?1 AND status IN ('grabbed','downloading')", + params![episode_id], + |row| row.get(0), + )?; + Ok(n > 0) +} + +fn season_pack_in_flight(conn: &Connection, media_item_id: i64, season: u32) -> Result { + let n: i64 = conn.query_row( + "SELECT count(*) FROM release WHERE media_item_id = ?1 AND episode_id IS NULL + AND season_number = ?2 AND status IN ('grabbed','downloading')", + params![media_item_id, season], + |row| row.get(0), + )?; + Ok(n > 0) +} + +/// True when this episode already has a grabbed/downloading release, or a +/// season pack covering this season is already in flight. +fn episode_is_in_flight( + conn: &Connection, + media_item_id: i64, + episode_id: i64, + season: u32, +) -> Result { + Ok(episode_has_in_flight_release(conn, episode_id)? + || season_pack_in_flight(conn, media_item_id, season)?) +} + fn is_anime(conn: &Connection, tvdb_id: i64) -> Result { let count: i64 = conn.query_row( "SELECT count(*) FROM anime_mapping WHERE tvdb_id = ?1", @@ -295,14 +325,21 @@ fn find_monitored_missing_episode( season: u32, episode: u32, ) -> Result> { - conn.query_row( - "SELECT id FROM episode WHERE media_item_id = ?1 AND season_number = ?2 - AND episode_number = ?3 AND monitored = 1 AND has_file = 0", - params![media_item_id, season, episode], - |row| row.get(0), - ) - .optional() - .map_err(Into::into) + let id: Option = conn + .query_row( + "SELECT id FROM episode WHERE media_item_id = ?1 AND season_number = ?2 + AND episode_number = ?3 AND monitored = 1 AND has_file = 0", + params![media_item_id, season, episode], + |row| row.get(0), + ) + .optional()?; + let Some(id) = id else { + return Ok(None); + }; + if episode_is_in_flight(conn, media_item_id, id, season)? { + return Ok(None); + } + Ok(Some(id)) } /// Upgrade-search counterpart to `find_monitored_missing_episode`: same @@ -336,8 +373,14 @@ fn count_monitored_missing_episodes_in_season( season: u32, ) -> Result { conn.query_row( - "SELECT count(*) FROM episode WHERE media_item_id = ?1 AND season_number = ?2 - AND monitored = 1 AND has_file = 0", + "SELECT count(*) FROM episode e + WHERE e.media_item_id = ?1 AND e.season_number = ?2 + AND e.monitored = 1 AND e.has_file = 0 + AND NOT EXISTS (SELECT 1 FROM release r WHERE r.episode_id = e.id + AND r.status IN ('grabbed','downloading')) + AND NOT EXISTS (SELECT 1 FROM release r WHERE r.media_item_id = e.media_item_id + AND r.episode_id IS NULL AND r.season_number = e.season_number + AND r.status IN ('grabbed','downloading'))", params![media_item_id, season], |row| row.get(0), ) @@ -400,6 +443,14 @@ fn best_existing_season_pack_score( /// an upgrade-search grab (see `SearchTarget::upgrade_min_gain`), so a file /// already on disk isn't replaced over and over for score deltas too small /// to matter. +/// Upgrade-search may auto-grab a better copy of something already owned. +/// The review-queue approve handler cannot — it only runs the first-copy +/// checks — so a NeedsReview match on an upgrade cycle must use those +/// same first-copy checks or the TUI's `a` key 409s every time. +fn use_upgrade_eligibility(upgrade_min_gain: Option, needs_review: bool) -> bool { + upgrade_min_gain.is_some() && !needs_review +} + fn should_grab(new_score: f32, is_repack: bool, existing_best: Option, min_gain: f32) -> bool { match existing_best { None => true, @@ -520,9 +571,15 @@ async fn process_item( if movie_year_mismatch(parsed.year, media_item.year) { return Ok(ProcessOutcome::YearMismatch); } - let eligible = match upgrade_min_gain { - Some(_) => movie_eligible_for_upgrade(conn, media_item.id)?, - None => movie_needs_grab(conn, media_item.id)?, + // Review-queue approval only implements the first-copy path + // (`movie_needs_grab`). An upgrade-cycle match that still needs a + // human would otherwise be queued and then 409 on approve — the + // live hestia queue was 139 already-owned movies for exactly this + // reason. High-confidence auto-matches still use upgrade eligibility. + let eligible = if use_upgrade_eligibility(upgrade_min_gain, needs_review) { + movie_eligible_for_upgrade(conn, media_item.id)? + } else { + movie_needs_grab(conn, media_item.id)? }; if !eligible { return Ok(ProcessOutcome::NotMonitoredOrAlreadyHave); @@ -539,9 +596,10 @@ async fn process_item( let Some((season, episode)) = resolve_episode(conn, media_item.tvdb_id, &parsed)? else { return Ok(ProcessOutcome::CouldNotResolveEpisode); }; - let eid_opt = match upgrade_min_gain { - Some(_) => find_monitored_episode(conn, media_item.id, season, episode)?, - None => find_monitored_missing_episode(conn, media_item.id, season, episode)?, + let eid_opt = if use_upgrade_eligibility(upgrade_min_gain, needs_review) { + find_monitored_episode(conn, media_item.id, season, episode)? + } else { + find_monitored_missing_episode(conn, media_item.id, season, episode)? }; let Some(eid) = eid_opt else { return Ok(ProcessOutcome::NotMonitoredOrAlreadyHave); @@ -594,7 +652,7 @@ async fn process_item( return Ok(ProcessOutcome::QueuedForReview); } - let release_score = scoring::score(&parsed, item.seeders.unwrap_or(0), false, &profile); + let release_score = scoring::score(&parsed, item.seeders.unwrap_or(0), parsed.has_hdr, &profile); let existing_best = match (episode_id, season_pack_number) { (Some(eid), _) => best_existing_score(conn, eid)?, (None, Some(season)) => best_existing_season_pack_score(conn, media_item.id, season)?, @@ -898,6 +956,11 @@ pub struct SearchTarget { /// satisfied this target even though it has no single `episode_id` /// of its own. season_number: Option, + /// The target episode's number — `None` for a movie (or a season-pack + /// target). Used so `better_resolution_available` only counts 1080p+ + /// results that are actually this episode (or a pack of this season), + /// not a sibling's higher-res release. + episode_number: Option, query: String, route: SearchRoute, /// Significant (len >= 4, alphanumeric) lowercased words from the @@ -942,6 +1005,29 @@ fn passes_relevance_filter(target_words: &[String], candidate_title: &str) -> bo target_words.iter().all(|w| lower.contains(w.as_str())) } +/// True when a title-relevant item in this batch is 1080p+ *for this +/// target* — matching S/E, or a season pack of this season. A sibling +/// episode's 1080p must not reject this episode's only 720p option. +/// Movies: any title-relevant 1080p+ counts. +fn better_resolution_available(target: &SearchTarget, items: &[RawReleaseItem]) -> bool { + items.iter().any(|item| { + if !passes_relevance_filter(&target.title_words, &item.title) { + return false; + } + let parsed = parser::parse(&item.title); + if !parsed.resolution.is_some_and(|r| r >= 1080) { + return false; + } + if target.season_number.is_none() { + return true; + } + if looks_like_season_pack(&parsed) { + return parsed.season == target.season_number; + } + parsed.season == target.season_number && parsed.episode == target.episode_number + }) +} + /// 1337x's search chokes on punctuation (colons, apostrophes) — replace /// anything that isn't alphanumeric/whitespace with a space and collapse. fn sanitize_query_text(s: &str) -> String { @@ -1035,6 +1121,9 @@ fn enumerate_search_targets(conn: &Connection, budget: usize) -> Result Result Result = stmt @@ -1612,6 +1709,7 @@ pub fn enumerate_search_targets_for_media_item( media_item_id, episode_id: Some(episode_id), season_number: Some(season as u32), + episode_number: Some(episode as u32), query: build_tv_query(&title, season, episode, SearchRoute::Tpb), title_words: significant_words(&title), route: SearchRoute::Tpb, @@ -1738,12 +1836,7 @@ pub async fn execute_search_targets( // matching `process_item` already does per-item below). Used to // decide whether a sub-1080p candidate is a real downgrade or the // only option actually available for this target. - let better_resolution_available = sorted.iter().any(|item| { - passes_relevance_filter(&target.title_words, &item.title) - && parser::parse(&item.title) - .resolution - .is_some_and(|r| r >= 1080) - }); + let better_resolution_available = better_resolution_available(target, &sorted); // A query built for one target's title can surface a *different* // monitored show/movie in its results (1337x's search isn't tightly @@ -1901,12 +1994,7 @@ pub async fn fetch_candidates( sorted.sort_by_key(|i| std::cmp::Reverse(i.seeders.unwrap_or(0))); sorted.truncate(MAX_RESULTS_PER_SEARCH); - let better_resolution_available = sorted.iter().any(|item| { - passes_relevance_filter(&target.title_words, &item.title) - && parser::parse(&item.title) - .resolution - .is_some_and(|r| r >= 1080) - }); + let better_resolution_available = better_resolution_available(&target, &sorted); let mut candidates = Vec::new(); for item in &sorted { @@ -1929,7 +2017,7 @@ pub async fn fetch_candidates( Some(scoring::score( &parsed, item.seeders.unwrap_or(0), - false, + parsed.has_hdr, &profile, )), None, @@ -1960,6 +2048,68 @@ pub async fn fetch_candidates( Ok(candidates) } +/// Manual-path only: auto-grab of 1337x still needs detail-page +/// resolution. A picked candidate must already be a magnet or `.torrent`. +fn reject_unresolved_manual_grab_link(link: &str) -> Result<()> { + if sources::scrape::needs_resolution(link) { + anyhow::bail!("candidate must be a magnet or .torrent URL"); + } + Ok(()) +} + +/// Binds a manual grab to the episode the title actually names, not the +/// TUI selection. A season pack has no episode id. Caller `episode_id` is +/// last-resort only (movies / unparsable titles). +fn resolve_grab_episode( + conn: &Connection, + media_item: &MediaItemRow, + parsed: &ParsedRelease, + caller_episode_id: Option, +) -> Result> { + if looks_like_season_pack(parsed) { + return Ok(None); + } + if let Some((season, episode)) = resolve_episode(conn, media_item.tvdb_id, parsed)? { + if let Some(id) = find_episode_id(conn, media_item.id, season, episode)? { + return Ok(Some(id)); + } + if let Some(id) = find_monitored_episode(conn, media_item.id, season, episode)? { + return Ok(Some(id)); + } + } + Ok(caller_episode_id) +} + +/// True when this resolved grab target already has a grabbed/downloading +/// release (episode, covering season pack, or movie). +fn grab_target_in_flight( + conn: &Connection, + media_item: &MediaItemRow, + episode_id: Option, + season_pack_number: Option, +) -> Result { + if media_item.kind == "movie" { + return movie_has_in_flight_release(conn, media_item.id); + } + if let Some(season) = season_pack_number { + return season_pack_in_flight(conn, media_item.id, season); + } + if let Some(eid) = episode_id { + let season: Option = conn + .query_row( + "SELECT season_number FROM episode WHERE id = ?1", + params![eid], + |row| row.get(0), + ) + .optional()?; + if let Some(season) = season { + return episode_is_in_flight(conn, media_item.id, eid, season); + } + return episode_has_in_flight_release(conn, eid); + } + Ok(false) +} + /// Grabs a specific candidate a human picked from `fetch_candidates`' /// output, bypassing the score-vs-existing-best `should_grab` comparison /// entirely — a manual pick is an explicit override, not a competing @@ -1980,6 +2130,7 @@ pub async fn grab_candidate( link: &str, guid: &str, ) -> Result<()> { + reject_unresolved_manual_grab_link(link)?; let parsed = parser::parse(raw_title); let media_item = get_media_item(conn, media_item_id)?; let profile_kind = if media_item.kind == "movie" { @@ -1993,17 +2144,19 @@ pub async fn grab_candidate( } else { None }; - let final_episode_id = if season_pack_number.is_some() { - None - } else { - episode_id - }; + // Rebind to the episode the title actually names. Picking E06 while + // E05 is selected still grabs E06 — the TUI selection is last-resort + // only (movies / unparsable titles). + let final_episode_id = resolve_grab_episode(conn, &media_item, &parsed, episode_id)?; + if grab_target_in_flight(conn, &media_item, final_episode_id, season_pack_number)? { + anyhow::bail!("a grab is already in flight for this episode/movie"); + } // Real-time seeder data isn't available for a candidate picked from an // earlier fetch — same `seeders=0` fallback `finalize_review_approval` // already uses for the same reason, and for the same reason it's still // real signal from resolution/source/codec/etc., not a meaningless // hardcoded score. - let score = scoring::score(&parsed, 0, false, &profile); + let score = scoring::score(&parsed, 0, parsed.has_hdr, &profile); let torrent_hash = match grab_and_capture_hash(qbit, link, qbit_category).await { Ok(hash) => hash, @@ -2144,7 +2297,7 @@ pub fn prepare_review_approval(conn: &Connection, review_id: i64) -> Result Result<()> { - conn.execute( +pub fn reject_review(conn: &Connection, review_id: i64) -> Result { + let rows = conn.execute( "UPDATE review_queue SET status = 'rejected' WHERE id = ?1 AND status = 'pending'", params![review_id], )?; - Ok(()) + Ok(rows > 0) } #[cfg(test)] mod tests { use super::*; + #[test] + fn upgrade_cycle_does_not_use_upgrade_eligibility_for_a_review_match() { + assert!(!use_upgrade_eligibility(Some(5.0), true)); + assert!(use_upgrade_eligibility(Some(5.0), false)); + assert!(!use_upgrade_eligibility(None, false)); + assert!(!use_upgrade_eligibility(None, true)); + } + #[test] fn should_grab_when_nothing_exists_yet() { assert!(should_grab(10.0, false, None, 0.0)); @@ -3317,4 +3478,246 @@ mod tests { // e.g. a title that's entirely short/common words after filtering assert!(passes_relevance_filter(&[], "anything at all")); } + + fn raw_item(title: &str) -> RawReleaseItem { + RawReleaseItem { + title: title.into(), + link: String::new(), + guid: title.into(), + size_bytes: None, + seeders: Some(10), + leechers: None, + } + } + + fn tv_search_target(season: u32, episode: u32) -> SearchTarget { + SearchTarget { + media_item_id: 1, + episode_id: Some(i64::from(episode)), + season_number: Some(season), + episode_number: Some(episode), + query: "Some Show".into(), + route: SearchRoute::Tpb, + title_words: significant_words("Some Show"), + upgrade_min_gain: None, + } + } + + #[test] + fn better_resolution_available_ignores_a_sibling_episodes_1080p() { + let target = tv_search_target(1, 1); + let items = [ + raw_item("Some Show S01E01 720p WEB-DL H264"), + raw_item("Some Show S01E02 1080p WEB-DL H264"), + ]; + let flag = better_resolution_available(&target, &items); + assert!( + !flag, + "E02's 1080p must not count as a better option for E01" + ); + let parsed = parser::parse("Some Show S01E01 720p WEB-DL H264"); + let ctx = GateContext { + seeders: Some(50), + size_bytes: Some(400_000_000), + runtime_minutes: Some(24), + has_english_audio: true, + is_anime: false, + better_resolution_available: flag, + is_season_pack: false, + }; + assert_eq!( + scoring::evaluate_gates(&parsed, &ctx, &QualityProfile::default_tv()), + scoring::GateResult::Accept + ); + } + + #[test] + fn better_resolution_available_is_true_for_this_episodes_own_1080p() { + let target = tv_search_target(1, 1); + let items = [ + raw_item("Some Show S01E01 720p WEB-DL"), + raw_item("Some Show S01E01 1080p WEB-DL"), + ]; + assert!(better_resolution_available(&target, &items)); + } + + #[test] + fn better_resolution_available_counts_a_season_pack_of_this_season() { + let target = tv_search_target(1, 1); + let items = [ + raw_item("Some Show S01E01 720p WEB-DL"), + raw_item("Some Show S01 Complete 1080p WEB-DL"), + ]; + assert!(better_resolution_available(&target, &items)); + } + + #[test] + fn better_resolution_available_for_a_movie_accepts_any_title_relevant_1080p() { + let target = SearchTarget { + media_item_id: 1, + episode_id: None, + season_number: None, + episode_number: None, + query: "Some Movie".into(), + route: SearchRoute::Tpb, + title_words: significant_words("Some Movie"), + upgrade_min_gain: None, + }; + let items = [raw_item("Some Movie 2024 1080p BluRay")]; + assert!(better_resolution_available(&target, &items)); + } + + fn seeded_e05_e06_conn() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + crate::db::init(&conn).unwrap(); + conn.execute( + "INSERT INTO media_item (id, kind, title, tvdb_id, monitored, quality_profile_id, root_folder) + VALUES (1, 'series', 'Show', 12345, 1, 1, '/tmp')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file) + VALUES (5, 1, 1, 5, 1, 0), (6, 1, 1, 6, 1, 0)", + [], + ) + .unwrap(); + conn + } + + #[test] + fn resolve_grab_episode_rebinds_to_the_parsed_episode_not_the_tui_selection() { + let conn = seeded_e05_e06_conn(); + let media_item = get_media_item(&conn, 1).unwrap(); + let parsed = parser::parse("Show.S01E06.1080p"); + let bound = resolve_grab_episode(&conn, &media_item, &parsed, Some(5)).unwrap(); + assert_eq!(bound, Some(6)); + } + + #[test] + fn resolve_grab_episode_clears_episode_id_for_a_season_pack() { + let conn = seeded_e05_e06_conn(); + let media_item = get_media_item(&conn, 1).unwrap(); + let parsed = parser::parse("Show.S01.COMPLETE.1080p"); + let bound = resolve_grab_episode(&conn, &media_item, &parsed, Some(5)).unwrap(); + assert_eq!(bound, None); + } + + #[test] + fn resolve_grab_episode_keeps_caller_id_when_the_title_does_not_resolve() { + let conn = seeded_e05_e06_conn(); + let media_item = get_media_item(&conn, 1).unwrap(); + let parsed = parser::parse("Show.1080p.WEB-DL"); + let bound = resolve_grab_episode(&conn, &media_item, &parsed, Some(5)).unwrap(); + assert_eq!(bound, Some(5)); + } + + fn insert_test_source(conn: &Connection) { + conn.execute( + "INSERT INTO source (id, name, kind, base_url) VALUES (1, 'test', 'scrape', 'http://x')", + [], + ) + .ok(); + } + + #[test] + fn find_monitored_missing_episode_excludes_an_in_flight_release() { + let conn = seeded_conn(); + insert_test_source(&conn); + let episode_id = find_monitored_missing_episode(&conn, 1, 1, 1) + .unwrap() + .expect("seeded E01 should be missing"); + conn.execute( + "INSERT INTO release (media_item_id, episode_id, raw_title, source_id, guid, status, grabbed_at) + VALUES (1, ?1, 'Show S01E01', 1, 'guid-ep', 'grabbed', datetime('now'))", + params![episode_id], + ) + .unwrap(); + assert!(find_monitored_missing_episode(&conn, 1, 1, 1) + .unwrap() + .is_none()); + } + + #[test] + fn find_monitored_missing_episode_excludes_an_in_flight_season_pack() { + let conn = seeded_conn(); + insert_test_source(&conn); + conn.execute( + "INSERT INTO release (media_item_id, episode_id, season_number, raw_title, source_id, guid, status, grabbed_at) + VALUES (1, NULL, 1, 'Show S01 Complete', 1, 'guid-pack', 'downloading', datetime('now'))", + [], + ) + .unwrap(); + assert!(find_monitored_missing_episode(&conn, 1, 1, 1) + .unwrap() + .is_none()); + } + + #[test] + fn enumerate_search_targets_excludes_episodes_covered_by_an_in_flight_pack() { + let conn = search_enumeration_conn(); + // E01 is the only aired missing episode of media_item 1 that isn't + // already in-flight. Cover the season with a pack and it must drop + // out of search enum along with any sibling. + conn.execute( + "INSERT INTO episode (id, media_item_id, season_number, episode_number, monitored, has_file, air_date) + VALUES (10, 1, 1, 10, 1, 0, '2020-01-01')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO release (media_item_id, episode_id, season_number, raw_title, source_id, guid, status, grabbed_at) + VALUES (1, NULL, 1, 'Some Show S01 Complete', 1, 'guid-pack', 'grabbed', datetime('now'))", + [], + ) + .unwrap(); + let targets = enumerate_search_targets(&conn, 100).unwrap(); + assert!( + !targets.iter().any(|t| t.media_item_id == 1), + "in-flight season pack should hide every missing episode of that season" + ); + } + + #[test] + fn grab_target_in_flight_is_true_for_an_episode_with_a_grabbed_release() { + let conn = seeded_e05_e06_conn(); + insert_test_source(&conn); + conn.execute( + "INSERT INTO release (media_item_id, episode_id, raw_title, source_id, guid, status, grabbed_at) + VALUES (1, 6, 'Show S01E06', 1, 'guid-e06', 'grabbed', datetime('now'))", + [], + ) + .unwrap(); + let media_item = get_media_item(&conn, 1).unwrap(); + assert!(grab_target_in_flight(&conn, &media_item, Some(6), None).unwrap()); + assert!(!grab_target_in_flight(&conn, &media_item, Some(5), None).unwrap()); + } + + #[test] + fn grab_target_in_flight_is_true_when_a_season_pack_is_already_downloading() { + let conn = seeded_e05_e06_conn(); + insert_test_source(&conn); + conn.execute( + "INSERT INTO release (media_item_id, episode_id, season_number, raw_title, source_id, guid, status, grabbed_at) + VALUES (1, NULL, 1, 'Show S01 Complete', 1, 'guid-pack', 'downloading', datetime('now'))", + [], + ) + .unwrap(); + let media_item = get_media_item(&conn, 1).unwrap(); + assert!(grab_target_in_flight(&conn, &media_item, Some(5), None).unwrap()); + assert!(grab_target_in_flight(&conn, &media_item, None, Some(1)).unwrap()); + } + + #[test] + fn reject_unresolved_manual_grab_link_allows_magnet_and_torrent_only() { + assert!(reject_unresolved_manual_grab_link("magnet:?xt=urn:btih:deadbeef").is_ok()); + assert!(reject_unresolved_manual_grab_link("https://example.invalid/file.torrent").is_ok()); + let err = reject_unresolved_manual_grab_link("https://1337x.to/torrent/123/") + .unwrap_err() + .to_string(); + assert!( + err.contains("magnet or .torrent URL"), + "unexpected error: {err}" + ); + } } diff --git a/breadarrd/src/scoring/score.rs b/breadarrd/src/scoring/score.rs index 867b302..a720a68 100644 --- a/breadarrd/src/scoring/score.rs +++ b/breadarrd/src/scoring/score.rs @@ -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) -> 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) -> 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) -> 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(); diff --git a/breadarrd/src/sources/scrape.rs b/breadarrd/src/sources/scrape.rs index 9171198..5fffc73 100644 --- a/breadarrd/src/sources/scrape.rs +++ b/breadarrd/src/sources/scrape.rs @@ -313,6 +313,9 @@ fn parse_search_results(html: &str, mirror: &str) -> Option> 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> 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 { 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#" + + + + + + + + + + + + + + + + +
nameselesize
Evil111 MB
Good222 MB
"#; + 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!( diff --git a/ci/build.sh b/ci/build.sh index d695d77..7ca25e8 100755 --- a/ci/build.sh +++ b/ci/build.sh @@ -12,8 +12,11 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")" CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}" -if [ ! -d "$CACHE_DIR" ]; then - rm -rf /tmp/bread-ecosystem-ci-* +# 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 @@ -35,6 +38,10 @@ fi # 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}" @@ -43,6 +50,8 @@ if [ ! -d "${OLD_GLIBC_CACHE}/usr/lib" ]; then "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 diff --git a/config.example.toml b/config.example.toml index 5370a7b..6643c46 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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] @@ -63,7 +65,13 @@ movies_root_folder = "~/breadarr-library/Movies" # 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" # 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 @@ -102,3 +110,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 diff --git a/packaging/systemd/breadarrd.service b/packaging/systemd/breadarrd.service index 46525b5..a864d04 100644 --- a/packaging/systemd/breadarrd.service +++ b/packaging/systemd/breadarrd.service @@ -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 From 6059d77065d63034487bbb7b19244a6a664f2929 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 10:20:33 +0800 Subject: [PATCH 2/3] 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. --- README.md | 8 +- breadarr-shared/src/config.rs | 21 +++ breadarrd/src/main.rs | 89 ++++++++-- breadarrd/src/scheduler.rs | 245 +++++++++++++------------- breadarrd/src/sources/mod.rs | 35 ++++ breadarrd/src/sources/torrents_csv.rs | 130 ++++++++++++++ breadarrd/src/sources/tpb.rs | 42 ++--- breadarrd/src/sources/yts.rs | 194 ++++++++++++++++++++ config.example.toml | 7 + 9 files changed, 600 insertions(+), 171 deletions(-) create mode 100644 breadarrd/src/sources/torrents_csv.rs create mode 100644 breadarrd/src/sources/yts.rs diff --git a/README.md b/README.md index 4e7f5e2..edc166f 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,12 @@ 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 diff --git a/breadarr-shared/src/config.rs b/breadarr-shared/src/config.rs index 77cb044..b60c84a 100644 --- a/breadarr-shared/src/config.rs +++ b/breadarr-shared/src/config.rs @@ -97,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 @@ -134,6 +144,8 @@ impl Default for SourcesConfig { 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(), @@ -146,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 } @@ -192,6 +212,7 @@ fn default_import_poll_interval_secs() -> u64 { fn default_1337x_mirrors() -> Vec { [ + "https://www.1337xx.to", "https://13377x.info", "https://13377x.email", "https://1337xto.info", diff --git a/breadarrd/src/main.rs b/breadarrd/src/main.rs index a34cb89..2154730 100644 --- a/breadarrd/src/main.rs +++ b/breadarrd/src/main.rs @@ -355,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 @@ -377,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, @@ -501,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, @@ -562,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, @@ -679,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, @@ -705,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); @@ -1133,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() { @@ -1144,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()); @@ -1151,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, diff --git a/breadarrd/src/scheduler.rs b/breadarrd/src/scheduler.rs index 9ec61d7..90b7d96 100644 --- a/breadarrd/src/scheduler.rs +++ b/breadarrd/src/scheduler.rs @@ -652,7 +652,8 @@ async fn process_item( return Ok(ProcessOutcome::QueuedForReview); } - let release_score = scoring::score(&parsed, item.seeders.unwrap_or(0), parsed.has_hdr, &profile); + let release_score = + scoring::score(&parsed, item.seeders.unwrap_or(0), parsed.has_hdr, &profile); let existing_best = match (episode_id, season_pack_number) { (Some(eid), _) => best_existing_score(conn, eid)?, (None, Some(season)) => best_existing_season_pack_score(conn, media_item.id, season)?, @@ -911,8 +912,8 @@ pub async fn run_grab_cycle( Ok(stats) } -// --- Search-driven acquisition (1337x for general TV/movies, nyaa search -// for anime movies) --- +// --- Search-driven acquisition (TPB + YTS/csv/1337x fallbacks for +// general TV/movies, nyaa search for anime movies) --- // // Unlike the feed-based path above, there's no natural stream of "new" // items to dedup against — the recurring cost here is the *search itself*, @@ -921,18 +922,12 @@ pub async fn run_grab_cycle( // single cycle; cadence backs off exponentially (6h, 12h, 24h, 48h, 96h, // capped at a week) the more times it's been searched without success. -/// Other general-content sources considered and rejected (live-tested -/// 2026-07-12, not just assumed) before landing on TPB as primary: -/// - **YTS** (`yts.mx`): DNS doesn't resolve at all. Every known mirror -/// (`yts.am`, `yts.ag`, `yts.lt`, `yts.pe`) either 301s in a loop or drops -/// the query and lands on a bare homepage. The whole mirror network looks -/// dead, not just one domain — re-check before assuming a fix is quick. -/// - **EZTV** (`eztv.re`): redirects to `eztvx.to`, which fails to connect -/// outright (TLS/connection error, not a slow response). Also -/// Cloudflare-fronted, so even if connectivity is restored it carries the -/// same risk profile 1337x does. -/// If revisiting either, re-verify connectivity first — this isn't a -/// permanent architectural decision, just what was true when checked. +/// General-content fallbacks live-tested 2026-08-16 (TPB/apibay was +/// timing out; every configured 1337x mirror returned Cloudflare 521): +/// - **torrents-csv** and **YTS** (`yts.lt` API — `yts.mx` still does not +/// resolve) are JSON hash-to-magnet sources, same grab shape as TPB. +/// - **EZTV**'s JSON API is up but IMDb-id only; name search is a +/// Cloudflare challenge. Not wired — breadarr has TMDB/TVDB, not IMDb. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum SearchRoute { /// Primary general-content (movies + non-anime TV) route — a JSON API, @@ -947,6 +942,22 @@ enum SearchRoute { NyaaSearch, } +/// The search-driven sources plus their `source` table ids — one bundle +/// so `execute_search_targets` / `fetch_candidates` / the cycle runners +/// don't each grow another four arguments every time a fallback is added. +pub struct SearchSources<'a> { + pub tpb: &'a sources::tpb::TpbSource, + pub tpb_id: i64, + pub torrents_csv: &'a sources::torrents_csv::TorrentsCsvSource, + pub torrents_csv_id: i64, + pub yts: &'a sources::yts::YtsSource, + pub yts_id: i64, + pub scrape: &'a sources::scrape::ScrapeSource, + pub scrape_id: i64, + pub nyaa_search: &'a sources::rss::RssSource, + pub nyaa_id: i64, +} + #[derive(Debug, Clone, PartialEq)] pub struct SearchTarget { media_item_id: i64, @@ -1565,35 +1576,16 @@ pub struct SearchCycleStats { /// single broad query can dump into the review queue. const MAX_RESULTS_PER_SEARCH: usize = 15; -#[allow(clippy::too_many_arguments)] pub async fn run_search_cycle( conn: &Connection, - tpb: &sources::tpb::TpbSource, - tpb_source_id: i64, - scrape: &sources::scrape::ScrapeSource, - scrape_source_id: i64, - nyaa_search: &sources::rss::RssSource, - nyaa_source_id: i64, + sources: &SearchSources<'_>, matcher: &mut TitleMatcher, qbit: &QbitClient, qbit_category: &str, budget: usize, ) -> Result { let targets = enumerate_search_targets(conn, budget)?; - execute_search_targets( - conn, - &targets, - tpb, - tpb_source_id, - scrape, - scrape_source_id, - nyaa_search, - nyaa_source_id, - matcher, - qbit, - qbit_category, - ) - .await + execute_search_targets(conn, &targets, sources, matcher, qbit, qbit_category).await } /// Upgrade-search counterpart to `run_search_cycle`: same execution engine @@ -1601,15 +1593,9 @@ pub async fn run_search_cycle( /// ones. `min_gain` is threaded onto every target via /// `enumerate_upgrade_targets`, which is what routes `process_item` into /// its upgrade-eligibility path instead of the normal missing-content one. -#[allow(clippy::too_many_arguments)] pub async fn run_upgrade_cycle( conn: &Connection, - tpb: &sources::tpb::TpbSource, - tpb_source_id: i64, - scrape: &sources::scrape::ScrapeSource, - scrape_source_id: i64, - nyaa_search: &sources::rss::RssSource, - nyaa_source_id: i64, + sources: &SearchSources<'_>, matcher: &mut TitleMatcher, qbit: &QbitClient, qbit_category: &str, @@ -1617,20 +1603,7 @@ pub async fn run_upgrade_cycle( min_gain: f32, ) -> Result { let targets = enumerate_upgrade_targets(conn, budget, min_gain)?; - execute_search_targets( - conn, - &targets, - tpb, - tpb_source_id, - scrape, - scrape_source_id, - nyaa_search, - nyaa_source_id, - matcher, - qbit, - qbit_category, - ) - .await + execute_search_targets(conn, &targets, sources, matcher, qbit, qbit_category).await } /// Every currently-missing episode/movie for one specific `media_item`, @@ -1728,16 +1701,10 @@ pub fn find_media_item_id_by_title(conn: &Connection, title: &str) -> Result, matcher: &mut TitleMatcher, qbit: &QbitClient, qbit_category: &str, @@ -1753,8 +1720,8 @@ pub async fn execute_search_targets( // without this, a 10-episode backlog fires 10 indistinguishable // requests at a single-domain API with no mirror fallback, which is // exactly the kind of pattern that gets a source rate-limited. Caches - // which source actually answered too (the TPB→1337x fallback can mean - // two different targets with the same query string were served by two + // which source actually answered too (the TPB → YTS/csv/1337x chain + // can mean two targets with the same query were served by two // different sources), so a cache hit still attributes dedup/grab // records to the right source id. let mut query_cache: std::collections::HashMap< @@ -1763,12 +1730,6 @@ pub async fn execute_search_targets( > = std::collections::HashMap::new(); for (i, target) in targets.iter().enumerate() { - let (primary, primary_id): (&dyn ReleaseSource, i64) = match target.route { - SearchRoute::Tpb => (tpb, tpb_source_id), - SearchRoute::X1337 => (scrape, scrape_source_id), - SearchRoute::NyaaSearch => (nyaa_search, nyaa_source_id), - }; - let cache_key = (target.route, target.query.clone()); let (items, source_id) = if let Some((cached_items, cached_source_id)) = query_cache.get(&cache_key) { @@ -1779,34 +1740,11 @@ pub async fn execute_search_targets( tokio::time::sleep(std::time::Duration::from_secs(jitter_secs)).await; } - let primary_result = primary.fetch(Some(&target.query)).await; - // TPB is the primary route for general content, but a fetch - // *failure* there (not just "no relevant results") falls back - // to 1337x for the same query before giving up — keeps the - // mirror-rotation/cooldown machinery built for 1337x as real - // resilience rather than dead code, just no longer the first - // choice given TPB's better precision. `result_source_id` - // tracks which source actually produced whatever we end up - // with, since dedup (`is_seen`/`mark_seen`) and grab records - // are keyed by source id — attributing a 1337x-sourced guid to - // TPB's source id would silently break dedup between the two. - let (fetch_result, result_source_id) = match primary_result { - Err(e) if matches!(target.route, SearchRoute::Tpb) => { - tracing::warn!( - query = %target.query, - error = %e, - "TPB search failed, falling back to 1337x" - ); - (scrape.fetch(Some(&target.query)).await, scrape_source_id) - } - other => (other, primary_id), - }; - - match fetch_result { - Ok(items) => { + match fetch_for_target(target, sources).await { + Ok(pair) => { consecutive_fetch_errors = 0; - query_cache.insert(cache_key, (items.clone(), result_source_id)); - (items, result_source_id) + query_cache.insert(cache_key, pair.clone()); + pair } Err(e) => { consecutive_fetch_errors += 1; @@ -1937,14 +1875,96 @@ pub async fn execute_search_targets( Ok(stats) } -fn source_route_name(route: SearchRoute) -> &'static str { - match route { - SearchRoute::Tpb => "tpb", - SearchRoute::X1337 => "1337x", - SearchRoute::NyaaSearch => "nyaa", +fn source_name_for_id(id: i64) -> &'static str { + match id { + 1 => "nyaa", + 2 => "1337x", + 3 => "tpb", + 4 => "torrents-csv", + 5 => "yts", + _ => "unknown", } } +/// TPB first; on failure *or* empty results, walk the supplement chain +/// (torrents-csv for everything, YTS for movies, 1337x last). Empty is +/// treated as "try the next one" so a live-but-empty apibay doesn't hide +/// a title that YTS/csv actually has. Dedup/grabs use whichever source +/// actually answered. +async fn fetch_for_target( + target: &SearchTarget, + sources: &SearchSources<'_>, +) -> Result<(Vec, i64)> { + match target.route { + SearchRoute::NyaaSearch => Ok(( + sources.nyaa_search.fetch(Some(&target.query)).await?, + sources.nyaa_id, + )), + SearchRoute::X1337 => Ok(( + sources.scrape.fetch(Some(&target.query)).await?, + sources.scrape_id, + )), + SearchRoute::Tpb => fetch_general_content(target, sources).await, + } +} + +async fn fetch_general_content( + target: &SearchTarget, + sources: &SearchSources<'_>, +) -> Result<(Vec, i64)> { + let is_movie = target.episode_id.is_none(); + let mut attempts: Vec<(&dyn ReleaseSource, i64, &'static str)> = + vec![(sources.tpb, sources.tpb_id, "tpb")]; + if is_movie { + attempts.push((sources.yts, sources.yts_id, "yts")); + } + attempts.push(( + sources.torrents_csv, + sources.torrents_csv_id, + "torrents-csv", + )); + attempts.push((sources.scrape, sources.scrape_id, "1337x")); + + let mut last_err: Option = None; + let mut any_ok = false; + for (src, id, name) in attempts { + match src.fetch(Some(&target.query)).await { + Ok(items) if !items.is_empty() => { + if name != "tpb" { + tracing::info!( + query = %target.query, + source = name, + n = items.len(), + "search fallback produced results" + ); + } + return Ok((items, id)); + } + Ok(_) => { + any_ok = true; + tracing::debug!( + query = %target.query, + source = name, + "search source returned no results" + ); + } + Err(e) => { + tracing::warn!( + query = %target.query, + source = name, + error = %e, + "search source failed" + ); + last_err = Some(e); + } + } + } + if any_ok { + return Ok((Vec::new(), sources.tpb_id)); + } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("all general-content sources failed"))) +} + /// Fetches and scores (or gate-rejects) candidates for one search target — /// the same evaluation `execute_search_targets` does automatically, minus /// the grab decision, surfaced instead for a human to choose from. Used by @@ -1954,29 +1974,18 @@ fn source_route_name(route: SearchRoute) -> &'static str { /// episode/movie right now (already owned, unmonitored, or mid-grab) — /// same "nothing to do" cases `enumerate_search_targets_for_media_item` /// already excludes. -#[allow(clippy::too_many_arguments)] pub async fn fetch_candidates( conn: &Connection, media_item_id: i64, episode_id: Option, - tpb: &sources::tpb::TpbSource, - tpb_source_id: i64, - scrape: &sources::scrape::ScrapeSource, - scrape_source_id: i64, - nyaa_search: &sources::rss::RssSource, - nyaa_source_id: i64, + sources: &SearchSources<'_>, ) -> Result> { let targets = enumerate_search_targets_for_media_item(conn, media_item_id)?; let Some(target) = targets.into_iter().find(|t| t.episode_id == episode_id) else { return Ok(Vec::new()); }; - let (source, source_id): (&dyn ReleaseSource, i64) = match target.route { - SearchRoute::Tpb => (tpb, tpb_source_id), - SearchRoute::X1337 => (scrape, scrape_source_id), - SearchRoute::NyaaSearch => (nyaa_search, nyaa_source_id), - }; - let items = source.fetch(Some(&target.query)).await?; + let (items, source_id) = fetch_for_target(&target, sources).await?; let media_item = get_media_item(conn, media_item_id)?; let anime = match media_item.tvdb_id { @@ -2029,7 +2038,7 @@ pub async fn fetch_candidates( link: item.link.clone(), guid: item.guid.clone(), source_id, - source_name: source_route_name(target.route).to_string(), + source_name: source_name_for_id(source_id).to_string(), seeders: item.seeders, leechers: item.leechers, size_bytes: item.size_bytes, diff --git a/breadarrd/src/sources/mod.rs b/breadarrd/src/sources/mod.rs index 646d82f..f4a32e1 100644 --- a/breadarrd/src/sources/mod.rs +++ b/breadarrd/src/sources/mod.rs @@ -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>; } +/// 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| { diff --git a/breadarrd/src/sources/torrents_csv.rs b/breadarrd/src/sources/torrents_csv.rs new file mode 100644 index 0000000..ea9cb06 --- /dev/null +++ b/breadarrd/src/sources/torrents_csv.rs @@ -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, +} + +#[derive(Deserialize)] +struct CsvTorrent { + infohash: String, + name: String, + size_bytes: Option, + seeders: Option, + leechers: Option, +} + +impl TorrentsCsvSource { + pub fn new(api_url: impl Into) -> 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) -> Option { + 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> { + 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()); + } +} diff --git a/breadarrd/src/sources/tpb.rs b/breadarrd/src/sources/tpb.rs index e72d01f..6db4f35 100644 --- a/breadarrd/src/sources/tpb.rs +++ b/breadarrd/src/sources/tpb.rs @@ -2,18 +2,7 @@ use anyhow::{Context, Result}; use async_trait::async_trait; use serde::Deserialize; -use super::{urlencode, RawReleaseItem, ReleaseSource}; - -/// A valid BitTorrent v1 info_hash: 40 hex chars or 32 base32 chars — same -/// shape `qbit::extract_btih` accepts out of a magnet URI. Checked before -/// building a magnet from `info_hash` at all: apibay is normally reliable, -/// but a malformed value would otherwise silently produce a magnet -/// `extract_btih` can't parse back out, downgrading that grab to the slow -/// ~30s `torrents/info` polling path with no visible error anywhere. -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'))) -} +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 @@ -38,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) -> Self { Self { @@ -154,7 +126,9 @@ mod tests { #[test] fn is_valid_info_hash_accepts_both_real_shapes() { - assert!(is_valid_info_hash("8F87C7C186172F17E35F4512BB1A3E93B614ADED")); // 40 hex + assert!(is_valid_info_hash( + "8F87C7C186172F17E35F4512BB1A3E93B614ADED" + )); // 40 hex assert!(is_valid_info_hash("abcdefghijklmnopqrstuvwxyz234567")); // 32 base32 } @@ -167,7 +141,11 @@ mod tests { 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 + 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 } } diff --git a/breadarrd/src/sources/yts.rs b/breadarrd/src/sources/yts.rs new file mode 100644 index 0000000..a29bf9c --- /dev/null +++ b/breadarrd/src/sources/yts.rs @@ -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, +} + +#[derive(Deserialize, Default)] +struct YtsData { + #[serde(default)] + movies: Vec, +} + +#[derive(Deserialize)] +struct YtsMovie { + title: String, + year: Option, + #[serde(default)] + torrents: Vec, +} + +#[derive(Deserialize)] +struct YtsTorrent { + hash: String, + quality: Option, + #[serde(rename = "type")] + source_type: Option, + video_codec: Option, + seeds: Option, + peers: Option, + size_bytes: Option, +} + +impl YtsSource { + pub fn new(api_url: impl Into) -> 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) -> Option { + 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> { + 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()); + } +} diff --git a/config.example.toml b/config.example.toml index 6643c46..0c64aaa 100644 --- a/config.example.toml +++ b/config.example.toml @@ -72,6 +72,12 @@ 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 @@ -96,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", From f5625528db9f56cf158485553dc64778bc4d76ba Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 10:20:43 +0800 Subject: [PATCH 3/3] 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. --- README.md | 12 +- breadarr-tui/src/app.rs | 822 +++++++++++++++++++++++++++------------ breadarr-tui/src/main.rs | 75 +++- breadarr-tui/src/ui.rs | 294 +++++++++++--- 4 files changed, 891 insertions(+), 312 deletions(-) diff --git a/README.md b/README.md index 4e7f5e2..1c3fc09 100644 --- a/README.md +++ b/README.md @@ -52,13 +52,13 @@ Install via bakery (`bakery install breadarr`) on a homelab host, or build from ## Using the TUI -`Tab` cycles Library / History / Review Queue / Add Show / Stuck / Calendar / Health / Profiles. `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 diff --git a/breadarr-tui/src/app.rs b/breadarr-tui/src/app.rs index 45b213e..b991885 100644 --- a/breadarr-tui/src/app.rs +++ b/breadarr-tui/src/app.rs @@ -1,12 +1,13 @@ use anyhow::Result; use breadarr_shared::dto::{ - CalendarEntry, HealthDetail, LibraryHealthReport, MediaItemDetail, MediaItemSummary, - QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, SearchNowResult, - SearchResult, StuckReport, WeightsDto, + CalendarEntry, EpisodeSummary, FlaggedFile, HealthDetail, LibraryHealthReport, MediaItemDetail, + MediaItemSummary, QualityProfileSummary, ReleaseCandidate, ReleaseSummary, ReviewQueueEntry, + SearchNowResult, SearchResult, StuckReport, WeightsDto, }; use breadarr_shared::DaemonClient; use ratatui::widgets::ListState; use std::path::Path; +use std::time::{Duration, Instant}; /// Category roots the TUI's "Add" flow can place new items under — kept as /// two separate paths (not one shared default) since a series and a movie @@ -75,8 +76,8 @@ impl Tab { match self { Tab::Library => "Library", Tab::History => "History", - Tab::Review => "Review Queue", - Tab::Add => "Add Show", + Tab::Review => "Review", + Tab::Add => "Add", Tab::Stuck => "Stuck", Tab::Calendar => "Calendar", Tab::LibraryHealth => "Health", @@ -89,6 +90,10 @@ pub enum Focus { List, AddSearchInput, AddResults, + /// Incremental title filter on the Library list — `App::library_query` + /// holds the in-progress text, same priority-over-global-keys pattern + /// as `AddSearchInput`. + LibraryFilterInput, /// The manual release picker overlay — `App::candidates` holds the /// list, `App::candidates_episode_id` remembers which episode (or, if /// `None`, the open movie) it was fetched for so a grab can be @@ -238,13 +243,70 @@ pub struct AddResult { } /// Result of a long `DaemonClient` call spawned off the draw loop so -/// `search_now` / candidate fetch (up to 600s) cannot freeze key handling. +/// `search_now` / candidate fetch / add-tab search cannot freeze key +/// handling. Add-search is typically a few seconds (TVDB + TMDB together) +/// rather than minutes, but it still shouldn't stall j/k or Tab. enum BackgroundOutcome { SearchNow(Result), Candidates { episode_id: Option, result: Result>, }, + AddSearch { + results: Vec, + errors: Vec, + }, +} + +/// Case-insensitive substring match used by the Library `/` filter. +fn title_matches_query(title: &str, query: &str) -> bool { + query.is_empty() || title.to_lowercase().contains(&query.to_lowercase()) +} + +/// Next monitored-and-missing episode, wrapping from `start` so `n` can +/// be mashed through a season without first jumping to the top. +fn next_missing_index(episodes: &[EpisodeSummary], start: usize) -> Option { + if episodes.is_empty() { + return None; + } + let start = start.min(episodes.len()); + episodes + .iter() + .enumerate() + .skip(start) + .chain(episodes.iter().enumerate().take(start)) + .find(|(_, e)| e.monitored && !e.has_file) + .map(|(i, _)| i) +} + +fn clamp_list_state(state: &mut ListState, len: usize) { + match (state.selected(), len) { + (_, 0) => state.select(None), + (None, _) => state.select(Some(0)), + (Some(i), _) if i >= len => state.select(Some(len - 1)), + _ => {} + } +} + +/// Row count of the Health tab's flagged-files list, including section +/// headers — must stay in lockstep with `draw_library_health` so j/k +/// doesn't walk off the rendered items. +pub fn health_row_count(report: &LibraryHealthReport) -> usize { + let mut n = 0; + let mut add_files = |files: &[FlaggedFile]| { + if !files.is_empty() { + n += 1 + files.len(); + } + }; + add_files(&report.corrupt_files); + add_files(&report.under_quality_files); + add_files(&report.no_english_audio_files); + add_files(&report.non_english_default_audio_files); + add_files(&report.no_subtitle_files); + if !report.duplicate_groups.is_empty() { + n += 1 + report.duplicate_groups.len(); + } + n.max(1) } pub struct App { @@ -254,6 +316,10 @@ pub struct App { pub tab: Tab, pub focus: Focus, pub status: String, + /// When `status` was last written — the draw loop clears it after a + /// few seconds so action feedback doesn't permanently hide the + /// keybinding hints in the status bar. + status_set_at: Option, pub should_quit: bool, /// Toggled by `?`; intercepted at the top of `main.rs`'s key handler /// like `Focus::AddSearchInput`/`Focus::WeightInput`, but kept as its @@ -273,6 +339,9 @@ pub struct App { pub library_view: Vec, pub library_filter: LibraryFilter, pub library_sort: LibrarySort, + /// Incremental title filter from `/` — applied on top of + /// `library_filter`/`library_sort` inside `recompute_library_view`. + pub library_query: String, pub detail: Option, /// Selection within `detail.episodes` — separate from `media_state` /// since they're two different lists sharing the same tab. @@ -283,6 +352,9 @@ pub struct App { pub review_items: Vec, pub review_state: ListState, + /// Last-seen review-queue depth, kept even when the Review tab isn't + /// the one being refreshed so the tab strip can badge it. + pub review_count: usize, pub add_query: String, pub add_results: Vec, @@ -292,8 +364,12 @@ pub struct App { pub stuck_focus: StuckSection, pub stalled_state: ListState, pub maxed_state: ListState, + /// Last-seen stalled+maxed count for the Stuck tab badge. + pub stuck_count: usize, pub calendar: Vec, + pub calendar_state: ListState, pub library_health: Option, + pub health_state: ListState, pub candidates: Vec, pub candidates_state: ListState, @@ -338,6 +414,7 @@ impl App { tab: Tab::Library, focus: Focus::List, status: String::new(), + status_set_at: None, should_quit: false, help_visible: false, force_refresh: false, @@ -346,12 +423,14 @@ impl App { library_view: Vec::new(), library_filter: LibraryFilter::All, library_sort: LibrarySort::TitleAsc, + library_query: String::new(), detail: None, episode_state: ListState::default(), releases: Vec::new(), releases_state: ListState::default(), review_items: Vec::new(), review_state: ListState::default(), + review_count: 0, add_query: String::new(), add_results: Vec::new(), add_results_state: ListState::default(), @@ -359,8 +438,11 @@ impl App { stuck_focus: StuckSection::Stalled, stalled_state: ListState::default(), maxed_state: ListState::default(), + stuck_count: 0, calendar: Vec::new(), + calendar_state: ListState::default(), library_health: None, + health_state: ListState::default(), candidates: Vec::new(), candidates_state: ListState::default(), candidates_episode_id: None, @@ -376,190 +458,32 @@ impl App { } } - /// Applies a finished background search/candidate task. Only `.await`s - /// a handle that `is_finished()`, so the draw loop stays responsive. - pub async fn poll_background(&mut self) { - let Some(handle) = &self.background else { - return; + pub fn set_status(&mut self, msg: impl Into) { + self.status = msg.into(); + self.status_set_at = if self.status.is_empty() { + None + } else { + Some(Instant::now()) }; - if !handle.is_finished() { + } + + /// Drops stale action feedback so the status bar can show keybinding + /// hints again. In-flight work (`busy`) keeps its "searching..." line. + pub fn expire_status(&mut self) { + if self.busy { return; } - let handle = self.background.take().expect("just checked is_finished"); - self.busy = false; - match handle.await { - Ok(BackgroundOutcome::SearchNow(Ok(stats))) => { - self.status = format!( - "search complete: {} target(s), {} grabbed, {} error(s)", - stats.targets, stats.grabbed, stats.errors - ); + if let Some(at) = self.status_set_at { + if at.elapsed() >= Duration::from_secs(5) { + self.status.clear(); + self.status_set_at = None; } - Ok(BackgroundOutcome::SearchNow(Err(e))) => { - self.status = format!("search failed: {e}"); - } - Ok(BackgroundOutcome::Candidates { - episode_id, - result: Ok(candidates), - }) => { - self.status = format!("{} candidate(s) found", candidates.len()); - if matches!(self.tab, Tab::Library) && self.detail.is_some() { - self.candidates_state.select(if candidates.is_empty() { - None - } else { - Some(0) - }); - self.candidates = candidates; - self.candidates_episode_id = episode_id; - self.focus = Focus::Candidates; - } - } - Ok(BackgroundOutcome::Candidates { result: Err(e), .. }) => { - self.status = format!("candidate fetch failed: {e}"); - } - Err(e) => self.status = format!("background task failed: {e}"), } } - pub async fn refresh_active_tab(&mut self) { - match self.client.health_detail().await { - Ok(detail) => { - self.daemon_up = true; - self.health = Some(detail); - } - Err(_) => { - self.daemon_up = false; - self.health = None; - } - } - if !self.daemon_up { - self.status = "daemon unreachable".to_string(); - return; - } - let result: Result<()> = async { - match self.tab { - Tab::Library => { - if self.detail.is_some() { - if let Some(id) = self.selected_media_id() { - self.detail = Some(self.client.media_detail(id).await?); - } - } else { - self.media_items = self.client.list_media().await?; - self.recompute_library_view(); - } - } - Tab::History => { - self.releases = self.client.releases().await?; - if self.releases_state.selected().is_none() && !self.releases.is_empty() { - self.releases_state.select(Some(0)); - } - } - Tab::Review => { - self.review_items = self.client.review_queue().await?; - if self.review_state.selected().is_none() && !self.review_items.is_empty() { - self.review_state.select(Some(0)); - } - } - Tab::Add => {} - Tab::Stuck => { - let report = self.client.stuck().await?; - if self.stalled_state.selected().is_none() && !report.stalled_grabs.is_empty() - { - self.stalled_state.select(Some(0)); - } - if self.maxed_state.selected().is_none() - && !report.maxed_out_search_targets.is_empty() - { - self.maxed_state.select(Some(0)); - } - self.stuck = Some(report); - } - Tab::Calendar => { - self.calendar = self.client.calendar().await?; - } - Tab::LibraryHealth => { - self.library_health = Some(self.client.library_health().await?); - } - Tab::Profiles => { - if self.profile_detail.is_none() { - self.quality_profiles = self.client.quality_profiles().await?; - if self.profiles_state.selected().is_none() - && !self.quality_profiles.is_empty() - { - self.profiles_state.select(Some(0)); - } - } - } - } - Ok(()) - } - .await; - - if let Err(e) = result { - self.status = format!("error: {e}"); - } - } - - fn selected_media_id(&self) -> Option { - self.detail.as_ref().map(|d| d.id) - } - - /// Rebuilds `library_view` from `media_items` under the current - /// filter/sort, then re-selects whichever item was selected before (by - /// id, not raw index) if it's still in view — falls back to the first - /// item, or no selection if the view is now empty. Called after - /// `media_items` changes, and after `library_filter`/`library_sort` - /// change. - pub fn recompute_library_view(&mut self) { - let previously_selected_id = self.selected_media_item().map(|m| m.id); - - let mut indices: Vec = self - .media_items - .iter() - .enumerate() - .filter(|(_, m)| self.library_filter.matches(m)) - .map(|(i, _)| i) - .collect(); - - match self.library_sort { - LibrarySort::TitleAsc => indices.sort_by(|&a, &b| { - self.media_items[a] - .title - .to_lowercase() - .cmp(&self.media_items[b].title.to_lowercase()) - }), - LibrarySort::MissingDesc => indices.sort_by(|&a, &b| { - self.media_items[b] - .missing_count - .cmp(&self.media_items[a].missing_count) - }), - LibrarySort::Kind => indices.sort_by(|&a, &b| { - self.media_items[a].kind.cmp(&self.media_items[b].kind).then_with(|| { - self.media_items[a] - .title - .to_lowercase() - .cmp(&self.media_items[b].title.to_lowercase()) - }) - }), - } - self.library_view = indices; - - match previously_selected_id - .and_then(|id| self.library_view.iter().position(|&i| self.media_items[i].id == id)) - { - Some(pos) => self.media_state.select(Some(pos)), - None if !self.library_view.is_empty() => self.media_state.select(Some(0)), - None => self.media_state.select(None), - } - } - - pub fn selected_media_item(&self) -> Option<&MediaItemSummary> { - let idx = self.media_state.selected()?; - let real_idx = *self.library_view.get(idx)?; - self.media_items.get(real_idx) - } - - pub fn move_selection(&mut self, delta: i32) { - let (state, len) = match self.tab { + /// The list currently driven by j/k / g/G / PageUp/PageDown. + fn active_list(&mut self) -> (&mut ListState, usize) { + match self.tab { Tab::Library if matches!(self.focus, Focus::Candidates) => { (&mut self.candidates_state, self.candidates.len()) } @@ -582,12 +506,247 @@ impl App { StuckSection::Maxed => (&mut self.maxed_state, report_len.1), } } + Tab::Calendar => (&mut self.calendar_state, self.calendar.len()), + Tab::LibraryHealth => { + let n = self + .library_health + .as_ref() + .map(health_row_count) + .unwrap_or(0); + (&mut self.health_state, n) + } Tab::Profiles if self.profile_detail.is_some() => { (&mut self.profile_weight_state, WEIGHT_FIELDS.len()) } Tab::Profiles => (&mut self.profiles_state, self.quality_profiles.len()), - _ => return, + } + } + + pub fn select_edge(&mut self, last: bool) { + let (state, len) = self.active_list(); + if len == 0 { + return; + } + state.select(Some(if last { len - 1 } else { 0 })); + } + + /// Applies a finished background search/candidate task. Only `.await`s + /// a handle that `is_finished()`, so the draw loop stays responsive. + pub async fn poll_background(&mut self) { + let Some(handle) = &self.background else { + return; }; + if !handle.is_finished() { + return; + } + let handle = self.background.take().expect("just checked is_finished"); + self.busy = false; + match handle.await { + Ok(BackgroundOutcome::SearchNow(Ok(stats))) => { + self.set_status(format!( + "search complete: {} target(s), {} grabbed, {} error(s)", + stats.targets, stats.grabbed, stats.errors + )); + } + Ok(BackgroundOutcome::SearchNow(Err(e))) => { + self.set_status(format!("search failed: {e}")); + } + Ok(BackgroundOutcome::Candidates { + episode_id, + result: Ok(candidates), + }) => { + self.set_status(format!("{} candidate(s) found", candidates.len())); + if matches!(self.tab, Tab::Library) && self.detail.is_some() { + self.candidates_state.select(if candidates.is_empty() { + None + } else { + Some(0) + }); + self.candidates = candidates; + self.candidates_episode_id = episode_id; + self.focus = Focus::Candidates; + } + } + Ok(BackgroundOutcome::Candidates { result: Err(e), .. }) => { + self.set_status(format!("candidate fetch failed: {e}")); + } + Ok(BackgroundOutcome::AddSearch { results, errors }) => { + self.add_results_state + .select(if results.is_empty() { None } else { Some(0) }); + self.add_results = results; + self.set_status(if errors.is_empty() { + String::new() + } else { + errors.join("; ") + }); + if matches!(self.tab, Tab::Add) { + self.focus = Focus::AddResults; + } + } + Err(e) => self.set_status(format!("background task failed: {e}")), + } + } + + pub async fn refresh_active_tab(&mut self) { + match self.client.health_detail().await { + Ok(detail) => { + self.daemon_up = true; + self.health = Some(detail); + } + Err(_) => { + self.daemon_up = false; + self.health = None; + } + } + if !self.daemon_up { + self.set_status("daemon unreachable"); + return; + } + let result: Result<()> = async { + match self.tab { + Tab::Library => { + if self.detail.is_some() { + if let Some(id) = self.selected_media_id() { + self.detail = Some(self.client.media_detail(id).await?); + } + } else { + self.media_items = self.client.list_media().await?; + self.recompute_library_view(); + } + } + Tab::History => { + self.releases = self.client.releases().await?; + clamp_list_state(&mut self.releases_state, self.releases.len()); + } + Tab::Review => { + self.review_items = self.client.review_queue().await?; + self.review_count = self.review_items.len(); + clamp_list_state(&mut self.review_state, self.review_items.len()); + } + Tab::Add => {} + Tab::Stuck => { + let report = self.client.stuck().await?; + self.stuck_count = + report.stalled_grabs.len() + report.maxed_out_search_targets.len(); + clamp_list_state(&mut self.stalled_state, report.stalled_grabs.len()); + clamp_list_state(&mut self.maxed_state, report.maxed_out_search_targets.len()); + self.stuck = Some(report); + } + Tab::Calendar => { + self.calendar = self.client.calendar().await?; + clamp_list_state(&mut self.calendar_state, self.calendar.len()); + } + Tab::LibraryHealth => { + self.library_health = Some(self.client.library_health().await?); + let n = self + .library_health + .as_ref() + .map(health_row_count) + .unwrap_or(0); + clamp_list_state(&mut self.health_state, n); + } + Tab::Profiles => { + if self.profile_detail.is_none() { + self.quality_profiles = self.client.quality_profiles().await?; + clamp_list_state(&mut self.profiles_state, self.quality_profiles.len()); + } + } + } + self.refresh_badge_counts().await; + Ok(()) + } + .await; + + if let Err(e) = result { + self.set_status(format!("error: {e}")); + } + } + + /// Cheap counts for the tab-strip badges. Skips the resource the + /// active tab already fetched so we don't double-hit the same route. + async fn refresh_badge_counts(&mut self) { + if !matches!(self.tab, Tab::Review) { + if let Ok(items) = self.client.review_queue().await { + self.review_count = items.len(); + } + } + if !matches!(self.tab, Tab::Stuck) { + if let Ok(report) = self.client.stuck().await { + self.stuck_count = + report.stalled_grabs.len() + report.maxed_out_search_targets.len(); + } + } + } + + fn selected_media_id(&self) -> Option { + self.detail.as_ref().map(|d| d.id) + } + + /// Rebuilds `library_view` from `media_items` under the current + /// filter/sort, then re-selects whichever item was selected before (by + /// id, not raw index) if it's still in view — falls back to the first + /// item, or no selection if the view is now empty. Called after + /// `media_items` changes, and after `library_filter`/`library_sort` + /// change. + pub fn recompute_library_view(&mut self) { + let previously_selected_id = self.selected_media_item().map(|m| m.id); + + let query = self.library_query.clone(); + let mut indices: Vec = self + .media_items + .iter() + .enumerate() + .filter(|(_, m)| { + self.library_filter.matches(m) && title_matches_query(&m.title, &query) + }) + .map(|(i, _)| i) + .collect(); + + match self.library_sort { + LibrarySort::TitleAsc => indices.sort_by(|&a, &b| { + self.media_items[a] + .title + .to_lowercase() + .cmp(&self.media_items[b].title.to_lowercase()) + }), + LibrarySort::MissingDesc => indices.sort_by(|&a, &b| { + self.media_items[b] + .missing_count + .cmp(&self.media_items[a].missing_count) + }), + LibrarySort::Kind => indices.sort_by(|&a, &b| { + self.media_items[a] + .kind + .cmp(&self.media_items[b].kind) + .then_with(|| { + self.media_items[a] + .title + .to_lowercase() + .cmp(&self.media_items[b].title.to_lowercase()) + }) + }), + } + self.library_view = indices; + + match previously_selected_id.and_then(|id| { + self.library_view + .iter() + .position(|&i| self.media_items[i].id == id) + }) { + Some(pos) => self.media_state.select(Some(pos)), + None if !self.library_view.is_empty() => self.media_state.select(Some(0)), + None => self.media_state.select(None), + } + } + + pub fn selected_media_item(&self) -> Option<&MediaItemSummary> { + let idx = self.media_state.selected()?; + let real_idx = *self.library_view.get(idx)?; + self.media_items.get(real_idx) + } + + pub fn move_selection(&mut self, delta: i32) { + let (state, len) = self.active_list(); if len == 0 { return; } @@ -612,13 +771,47 @@ impl App { }); self.detail = Some(detail); } - Err(e) => self.status = format!("error loading detail: {e}"), + Err(e) => self.set_status(format!("error loading detail: {e}")), } } pub fn close_detail(&mut self) { self.detail = None; self.episode_state.select(None); + if matches!(self.focus, Focus::Candidates) { + self.close_candidates(); + } + } + + pub fn start_library_filter(&mut self) { + if self.detail.is_some() { + return; + } + self.focus = Focus::LibraryFilterInput; + } + + pub fn confirm_library_filter(&mut self) { + self.focus = Focus::List; + } + + pub fn clear_library_filter(&mut self) { + self.library_query.clear(); + self.focus = Focus::List; + self.recompute_library_view(); + } + + /// Advances the episode selection to the next monitored episode that + /// still has no file, wrapping so a second `n` at the end of the list + /// starts over rather than doing nothing. + pub fn select_next_missing_episode(&mut self) { + let Some(detail) = &self.detail else { + return; + }; + let start = self.episode_state.selected().map(|i| i + 1).unwrap_or(0); + match next_missing_index(&detail.episodes, start) { + Some(i) => self.episode_state.select(Some(i)), + None => self.set_status("no missing monitored episodes"), + } } /// Toggles monitored on whichever episode is currently selected in the @@ -641,14 +834,14 @@ impl App { }; match result { Ok(()) => { - self.status = "episode monitor state updated".to_string(); + self.set_status("episode monitor state updated".to_string()); if let Some(id) = self.selected_media_id() { if let Ok(fresh) = self.client.media_detail(id).await { self.detail = Some(fresh); } } } - Err(e) => self.status = format!("episode monitor toggle failed: {e}"), + Err(e) => self.set_status(format!("episode monitor toggle failed: {e}")), } } @@ -677,12 +870,12 @@ impl App { }; match result { Ok(()) => { - self.status = format!("season {season_number} monitor state updated"); + self.set_status(format!("season {season_number} monitor state updated")); if let Ok(fresh) = self.client.media_detail(media_item_id).await { self.detail = Some(fresh); } } - Err(e) => self.status = format!("season monitor toggle failed: {e}"), + Err(e) => self.set_status(format!("season monitor toggle failed: {e}")), } } @@ -694,10 +887,12 @@ impl App { return; }; match self.client.approve_review(item.id).await { - Ok(()) => self.status = format!("approved: {}", item.raw_release_title), - Err(e) => self.status = format!("approve failed: {e}"), + Ok(()) => self.set_status(format!("approved: {}", item.raw_release_title)), + Err(e) => self.set_status(format!("approve failed: {e}")), } self.review_items = self.client.review_queue().await.unwrap_or_default(); + self.review_count = self.review_items.len(); + clamp_list_state(&mut self.review_state, self.review_items.len()); } pub async fn reject_selected_review(&mut self) { @@ -708,10 +903,12 @@ impl App { return; }; match self.client.reject_review(item.id).await { - Ok(()) => self.status = format!("rejected: {}", item.raw_release_title), - Err(e) => self.status = format!("reject failed: {e}"), + Ok(()) => self.set_status(format!("rejected: {}", item.raw_release_title)), + Err(e) => self.set_status(format!("reject failed: {e}")), } self.review_items = self.client.review_queue().await.unwrap_or_default(); + self.review_count = self.review_items.len(); + clamp_list_state(&mut self.review_state, self.review_items.len()); } /// Searches series and movies together instead of requiring the user to @@ -722,41 +919,36 @@ impl App { /// a movie search returned nothing because the mode had never actually /// switched). Querying both up front removes the failure mode entirely. pub async fn run_add_search(&mut self) { - if self.add_query.trim().is_empty() { + if self.add_query.trim().is_empty() || self.busy { return; } - let (series_result, movie_result) = tokio::join!( - self.client.search_series(&self.add_query), - self.client.search_movies(&self.add_query) - ); + self.set_status("searching movies and TV..."); + self.busy = true; + let client = self.client.clone(); + let query = self.add_query.clone(); + self.background = Some(tokio::spawn(async move { + let (series_result, movie_result) = + tokio::join!(client.search_series(&query), client.search_movies(&query)); - let mut results = Vec::new(); - let mut errors = Vec::new(); - match series_result { - Ok(hits) => results.extend(hits.into_iter().map(|result| AddResult { - kind: AddKind::Series, - result, - })), - Err(e) => errors.push(format!("series search failed: {e}")), - } - match movie_result { - Ok(hits) => results.extend(hits.into_iter().map(|result| AddResult { - kind: AddKind::Movie, - result, - })), - Err(e) => errors.push(format!("movie search failed: {e}")), - } - results.sort_by_key(|a| a.result.title.to_lowercase()); - - self.add_results_state - .select(if results.is_empty() { None } else { Some(0) }); - self.add_results = results; - self.status = if errors.is_empty() { - String::new() - } else { - errors.join("; ") - }; - self.focus = Focus::AddResults; + let mut results = Vec::new(); + let mut errors = Vec::new(); + match series_result { + Ok(hits) => results.extend(hits.into_iter().map(|result| AddResult { + kind: AddKind::Series, + result, + })), + Err(e) => errors.push(format!("series search failed: {e}")), + } + match movie_result { + Ok(hits) => results.extend(hits.into_iter().map(|result| AddResult { + kind: AddKind::Movie, + result, + })), + Err(e) => errors.push(format!("movie search failed: {e}")), + } + results.sort_by_key(|a| a.result.title.to_lowercase()); + BackgroundOutcome::AddSearch { results, errors } + })); } pub async fn add_selected_search_result(&mut self, roots: &LibraryRoots) { @@ -789,12 +981,15 @@ impl App { }; match outcome { Ok(media_item_id) => { - self.status = format!("added {:?} (media_item_id={media_item_id})", result.title); + self.set_status(format!( + "added {:?} (media_item_id={media_item_id})", + result.title + )); self.add_results.clear(); self.add_query.clear(); self.focus = Focus::AddSearchInput; } - Err(e) => self.status = format!("add failed: {e}"), + Err(e) => self.set_status(format!("add failed: {e}")), } } @@ -809,7 +1004,7 @@ impl App { if self.busy { return; } - self.status = "searching now...".to_string(); + self.set_status("searching now...".to_string()); self.busy = true; let client = self.client.clone(); self.background = Some(tokio::spawn(async move { @@ -841,7 +1036,7 @@ impl App { return; } - self.status = "fetching candidates...".to_string(); + self.set_status("fetching candidates...".to_string()); self.busy = true; let client = self.client.clone(); self.background = Some(tokio::spawn(async move { @@ -879,13 +1074,13 @@ impl App { }; match result { Ok(()) => { - self.status = format!("grabbed: {}", candidate.raw_title); + self.set_status(format!("grabbed: {}", candidate.raw_title)); self.close_candidates(); if let Ok(fresh) = self.client.media_detail(media_item_id).await { self.detail = Some(fresh); } } - Err(e) => self.status = format!("grab failed: {e}"), + Err(e) => self.set_status(format!("grab failed: {e}")), } } @@ -955,7 +1150,10 @@ impl App { let value: f32 = match self.weight_input_buffer.trim().parse() { Ok(v) => v, Err(_) => { - self.status = format!("'{}' is not a valid number", self.weight_input_buffer); + self.set_status(format!( + "'{}' is not a valid number", + self.weight_input_buffer + )); return; } }; @@ -972,11 +1170,11 @@ impl App { .await { Ok(()) => { - self.status = format!("{name} updated to {value}"); + self.set_status(format!("{name} updated to {value}")); self.weight_input_buffer.clear(); self.focus = Focus::List; } - Err(e) => self.status = format!("weight update failed: {e}"), + Err(e) => self.set_status(format!("weight update failed: {e}")), } } @@ -994,11 +1192,11 @@ impl App { }; match result { Ok(()) => { - self.status = "monitor state updated".to_string(); + self.set_status("monitor state updated".to_string()); self.media_items = self.client.list_media().await.unwrap_or_default(); self.recompute_library_view(); } - Err(e) => self.status = format!("monitor toggle failed: {e}"), + Err(e) => self.set_status(format!("monitor toggle failed: {e}")), } } @@ -1014,12 +1212,12 @@ impl App { }; match result { Ok(()) => { - self.status = "monitor state updated".to_string(); + self.set_status("monitor state updated".to_string()); if let Ok(fresh) = self.client.media_detail(id).await { self.detail = Some(fresh); } } - Err(e) => self.status = format!("monitor toggle failed: {e}"), + Err(e) => self.set_status(format!("monitor toggle failed: {e}")), } } @@ -1031,18 +1229,18 @@ impl App { }; if !self.confirm_delete { self.confirm_delete = true; - self.status = "press x again to confirm delete".to_string(); + self.set_status("press x again to confirm delete".to_string()); return; } self.confirm_delete = false; match self.client.delete_media(id).await { Ok(()) => { - self.status = "deleted".to_string(); + self.set_status("deleted".to_string()); self.detail = None; self.media_items = self.client.list_media().await.unwrap_or_default(); self.recompute_library_view(); } - Err(e) => self.status = format!("delete failed: {e}"), + Err(e) => self.set_status(format!("delete failed: {e}")), } } @@ -1057,7 +1255,7 @@ impl App { }; if !self.confirm_delete_file { self.confirm_delete_file = true; - self.status = "press d again to confirm deleting this file".to_string(); + self.set_status("press d again to confirm deleting this file".to_string()); return; } self.confirm_delete_file = false; @@ -1075,14 +1273,14 @@ impl App { }; match result { Ok(()) => { - self.status = "file deleted, will be re-searched".to_string(); + self.set_status("file deleted, will be re-searched".to_string()); if let Some(id) = self.selected_media_id() { if let Ok(fresh) = self.client.media_detail(id).await { self.detail = Some(fresh); } } } - Err(e) => self.status = format!("file delete failed: {e}"), + Err(e) => self.set_status(format!("file delete failed: {e}")), } } @@ -1118,14 +1316,51 @@ impl App { self.detail = Some(detail); self.focus = Focus::List; } - Err(e) => self.status = format!("error loading detail: {e}"), + Err(e) => self.set_status(format!("error loading detail: {e}")), + } + } + + /// Jumps from the selected Calendar row to that episode's Library + /// detail — same idea as `jump_to_stuck_target`, but also lands on the + /// matching SxxExx so `c`/`d` apply to the aired episode, not S01E01. + pub async fn jump_to_calendar_entry(&mut self) { + let Some(idx) = self.calendar_state.selected() else { + return; + }; + let Some(entry) = self.calendar.get(idx).cloned() else { + return; + }; + match self.client.media_detail(entry.media_item_id).await { + Ok(detail) => { + let ep = detail.episodes.iter().position(|e| { + e.season_number == entry.season_number + && e.episode_number == entry.episode_number + }); + self.tab = Tab::Library; + self.episode_state + .select(ep.or(if detail.episodes.is_empty() { + None + } else { + Some(0) + })); + self.detail = Some(detail); + self.focus = Focus::List; + } + Err(e) => self.set_status(format!("error loading detail: {e}")), } } } #[cfg(test)] mod tests { - use super::item_root_folder; + use super::{ + clamp_list_state, health_row_count, item_root_folder, next_missing_index, + title_matches_query, + }; + use breadarr_shared::dto::{ + DuplicateGroup, EpisodeSummary, FlaggedFile, LibraryHealthReport, LibrarySummary, + }; + use ratatui::widgets::ListState; #[test] fn item_root_folder_replaces_dot_and_dotdot() { @@ -1141,4 +1376,99 @@ mod tests { "/lib/Foo_ Bar_Baz (2020)" ); } + + #[test] + fn title_matches_query_is_case_insensitive_substring() { + assert!(title_matches_query("The Matrix", "matrix")); + assert!(title_matches_query("The Matrix", "")); + assert!(!title_matches_query("The Matrix", "inception")); + } + + fn ep(season: i64, number: i64, monitored: bool, has_file: bool) -> EpisodeSummary { + EpisodeSummary { + id: season * 100 + number, + season_number: season, + episode_number: number, + title: None, + air_date: None, + monitored, + has_file, + } + } + + #[test] + fn next_missing_index_wraps_and_skips_owned_or_unmonitored() { + let episodes = vec![ + ep(1, 1, true, true), + ep(1, 2, true, false), + ep(1, 3, false, false), + ep(1, 4, true, false), + ]; + assert_eq!(next_missing_index(&episodes, 0), Some(1)); + assert_eq!(next_missing_index(&episodes, 2), Some(3)); + assert_eq!(next_missing_index(&episodes, 4), Some(1)); + assert_eq!(next_missing_index(&[], 0), None); + assert_eq!( + next_missing_index(&[ep(1, 1, true, true), ep(1, 2, false, false)], 0), + None + ); + } + + #[test] + fn clamp_list_state_pins_past_the_end_and_clears_empty() { + let mut state = ListState::default(); + state.select(Some(4)); + clamp_list_state(&mut state, 3); + assert_eq!(state.selected(), Some(2)); + clamp_list_state(&mut state, 0); + assert_eq!(state.selected(), None); + clamp_list_state(&mut state, 2); + assert_eq!(state.selected(), Some(0)); + } + + fn empty_health() -> LibraryHealthReport { + LibraryHealthReport { + corrupt_files: vec![], + under_quality_files: vec![], + no_subtitle_files: vec![], + no_english_audio_files: vec![], + non_english_default_audio_files: vec![], + duplicate_groups: vec![], + summary: LibrarySummary { + total_files: 0, + total_size_bytes: 0, + probed_files: 0, + by_video_codec: vec![], + sd_count: 0, + hd_720p_count: 0, + full_hd_1080p_count: 0, + uhd_4k_count: 0, + pct_with_subtitles: 0.0, + }, + } + } + + fn flagged(title: &str) -> FlaggedFile { + FlaggedFile { + episode_file_id: 1, + media_title: title.to_string(), + episode_label: None, + path: "/x".to_string(), + } + } + + #[test] + fn health_row_count_includes_headers_and_empty_placeholder() { + let mut report = empty_health(); + assert_eq!(health_row_count(&report), 1); + report.corrupt_files.push(flagged("A")); + report.corrupt_files.push(flagged("B")); + report.duplicate_groups.push(DuplicateGroup { + media_title: "C".into(), + episode_label: None, + paths: vec!["/a".into(), "/b".into()], + }); + // header + 2 files + header + 1 group + assert_eq!(health_row_count(&report), 5); + } } diff --git a/breadarr-tui/src/main.rs b/breadarr-tui/src/main.rs index f4135e3..d8af4b0 100644 --- a/breadarr-tui/src/main.rs +++ b/breadarr-tui/src/main.rs @@ -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, @@ -51,6 +51,7 @@ async fn run( loop { app.poll_background().await; + app.expire_status(); if last_refresh.elapsed() >= Duration::from_secs(3) || app.force_refresh { app.refresh_active_tab().await; @@ -65,7 +66,7 @@ async fn run( if key.kind != KeyEventKind::Press { continue; } - handle_key(app, key.code, roots).await; + handle_key(app, key, roots).await; if app.should_quit { return Ok(()); } @@ -74,7 +75,9 @@ async fn run( } } -async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { +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 { @@ -86,7 +89,28 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { KeyCode::Esc => { app.add_query.clear(); } - 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)), + _ => {} + } + 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; @@ -131,12 +155,25 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { 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(), _ => {} @@ -155,6 +192,7 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { } 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) => { @@ -182,6 +220,16 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { 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; } @@ -211,9 +259,20 @@ async fn handle_key(app: &mut App, code: KeyCode, roots: &LibraryRoots) { } } -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); diff --git a/breadarr-tui/src/ui.rs b/breadarr-tui/src/ui.rs index 5f7735c..77ea537 100644 --- a/breadarr-tui/src/ui.rs +++ b/breadarr-tui/src/ui.rs @@ -5,6 +5,7 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs}; use ratatui::Frame; 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: @@ -58,6 +59,7 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> { 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"), @@ -65,8 +67,12 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> { ("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"), @@ -81,13 +87,17 @@ fn context_keybindings(app: &App) -> Vec<(&'static str, &'static str)> { } 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", "switch tab"), + ("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"), @@ -128,7 +138,7 @@ fn draw_help_overlay(frame: &mut Frame, area: Rect, app: &App) { ]) })); - let popup = centered_rect(50, lines.len() as u16 + 2, area); + let popup = centered_rect(52, lines.len() as u16 + 2, area); frame.render_widget(Clear, popup); let paragraph = Paragraph::new(lines).block( Block::default() @@ -138,8 +148,47 @@ fn draw_help_overlay(frame: &mut Frame, area: Rect, app: &App) { 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 = Tab::ALL.iter().map(|t| Line::from(t.title())).collect(); + let titles: Vec = 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) { @@ -190,6 +239,11 @@ 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 = detail .episodes .iter() @@ -286,9 +340,19 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) { ])) }) .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(format!( - "Monitored Shows ({}/{}) — filter: {} sort: {}", + "Library ({}/{}) — filter: {} sort: {}{search}{filter_caret}", app.library_view.len(), app.media_items.len(), app.library_filter.label(), @@ -299,6 +363,61 @@ fn draw_library(frame: &mut Frame, area: Rect, app: &App) { 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. @@ -330,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}", - c.source_name, c.raw_title - )) + 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) @@ -350,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 = app .releases .iter() .map(|r| { - ListItem::new(format!( - "[{}] {} — {} (score {:.1})", - r.status, - r.media_title, - r.raw_title, - r.score.unwrap_or(0.0) - )) + 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) @@ -384,7 +521,10 @@ fn draw_review(frame: &mut Frame, area: Rect, app: &App) { Color::Green }; ListItem::new(Line::from(vec![ - Span::styled(format!("({:.0}%)", r.confidence * 100.0), Style::default().fg(color)), + Span::styled( + format!("({:.0}%)", r.confidence * 100.0), + Style::default().fg(color), + ), Span::raw(format!( " {} -> {}", r.raw_release_title, @@ -439,7 +579,10 @@ fn draw_stuck(frame: &mut Frame, area: Rect, app: &App) { .iter() .map(|g| { ListItem::new(Line::from(Span::styled( - format!("{} — {} (grabbed {})", g.media_title, g.raw_title, g.grabbed_at), + format!( + "{} — {} (grabbed {})", + g.media_title, g.raw_title, g.grabbed_at + ), Style::default().fg(Color::Yellow), ))) }) @@ -519,12 +662,21 @@ fn draw_add(frame: &mut Frame, area: Rect, app: &App) { .add_results .iter() .map(|r| { - ListItem::new(format!( - "[{}] {} ({})", - r.kind.label(), - r.result.title, - r.result.year.map(|y| y.to_string()).unwrap_or_default() - )) + 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) @@ -539,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 = 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}", - e.air_date, e.media_title, e.season_number, e.episode_number - )) + 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( - Block::default() - .borders(Borders::ALL) - .title("Calendar — ✓ have it … upcoming ! aired but missing"), - ); - frame.render_widget(list, area); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .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, @@ -588,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; @@ -598,8 +757,13 @@ fn draw_library_health(frame: &mut Frame, area: Rect, app: &App) { .map(|c| format!("{}={}", c.codec, c.count)) .collect::>() .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, @@ -668,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( - Block::default() - .borders(Borders::ALL) - .title("Flagged files"), - ); - frame.render_widget(list, chunks[1]); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title("Flagged files"), + ) + .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) -> 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