From 0c2ba6ff15a45e5017a32ec9e1181a102d3cfd41 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 19:42:04 +0100 Subject: [PATCH 1/4] docs(design): size bands are per episode A release covering several episodes is measured by size divided by the episode count, for the target penalty and the floor alike. An unknown count divides by one, which fails toward rejecting rather than grabbing. Refs #179 --- DESIGN.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/DESIGN.md b/DESIGN.md index 457278a..09e4f0e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -77,7 +77,8 @@ Policy required_audio rule set, see §5.2 dub_blacklist [pt-BR] hdr_rules see §5.3 - size_bands per resolution: floor, target, penalty curve + size_bands per resolution: floor, target, penalty curve, + all per episode (§5.5) resolution_pref [2160p, 1080p] source_weights small tiebreaker @@ -284,6 +285,28 @@ remux scores badly but stays eligible, so it is picked when it is the only option. The floor matters — unbounded "smaller is better" selects a 3 GB 4K encode that looks like mud. +**A band describes one episode.** For a movie the question never arises — one +release is one film. For TV a release may carry a single episode, several, or a +whole season, and the shipped TV numbers are episode numbers: a 1080p target of +2 GiB is what one episode should weigh, not a ten-episode pack. So a release is +measured by its size divided by the number of episodes it covers, and that +per-episode figure is what both the target penalty and the floor are compared +against. A 20 GiB pack of ten episodes is scored as 2 GiB and sits at target, +not ten times above it. + +The floor takes the same figure. It is a hard reject rather than a penalty, so +comparing a pack's total against an episode-sized floor lets every pack through +untested — wrong in the opposite direction from the target penalty. The floor +exists to keep a 3 GB 4K encode out, and a pack of ten such encodes has to fail +it just as plainly. + +**An unknown episode count is one episode.** A release matched before its +season's episodes have been revealed by a metadata refresh has nothing to divide +by, and the rule does not guess a number: the divisor is 1 and the release is +measured by its full size. That is the behaviour today, and it makes a pack look +oversized rather than undersized — it fails toward rejecting a good pack rather +than grabbing a bad one, and the next search after a refresh has the real count. + Source tier (`Remux > BluRay > WEB-DL > WEBRip > HDTV`) survives as a small tiebreaker. Seeders are log-scaled and small: enough to complete, past that it does not matter. Telesync, CAM and screener are **hard filters**, not low From dc6c25f582eeb1baa333a5f855036c6aec6f703b Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 19:51:28 +0100 Subject: [PATCH 2/4] fix(daemon): retry season packs after backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed season-pack grab held the season off the pack lane forever: pack_hard_failed was a bare EXISTS over failed grabs, so one bad torrent disabled pack search for good, against §6.2's "it never gives up entirely, it goes quiet". The guard now rides the shared backoff curve (backoff_elapsed, 1h → 6h → 1d → 3d, capped 7d), counting failed pack grabs as attempts and anchoring on the latest one's grabbed_at. Both the targeted and RSS lanes agree. A manual season search waives the window outright — the season deck is §6.2's escape hatch. Closes #181. Co-Authored-By: Claude Fable 5 --- ...e5681e0c00b1f9692a7142043067ff64a00a1.json | 21 --- ...337f18909a58d0f863c794f93f8285b9133c8.json | 16 +- ...55064d5a83eafe5bc95ddda9a9056d5724f10.json | 104 +++++++++-- ...549ae5075a56a668e0bf836d85e22d9202019.json | 28 +++ crates/arr-core/src/grabbing.rs | 16 +- crates/arr-daemon/src/rss.rs | 20 +- crates/arr-daemon/src/tv_grab.rs | 172 ++++++++++++++---- 7 files changed, 288 insertions(+), 89 deletions(-) delete mode 100644 .sqlx/query-8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1.json create mode 100644 .sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json diff --git a/.sqlx/query-8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1.json b/.sqlx/query-8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1.json deleted file mode 100644 index 92c0152..0000000 --- a/.sqlx/query-8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT EXISTS (\n SELECT 1 FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'\n ) AS \"failed!: bool\"", - "describe": { - "columns": [ - { - "name": "failed!: bool", - "ordinal": 0, - "type_info": "Integer", - "origin": "Expression" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "8cdf7339d395e21af86bd71e011e5681e0c00b1f9692a7142043067ff64a00a1" -} diff --git a/.sqlx/query-a1be0fa3691226439f768d11600337f18909a58d0f863c794f93f8285b9133c8.json b/.sqlx/query-a1be0fa3691226439f768d11600337f18909a58d0f863c794f93f8285b9133c8.json index 4720e1d..29cd7e6 100644 --- a/.sqlx/query-a1be0fa3691226439f768d11600337f18909a58d0f863c794f93f8285b9133c8.json +++ b/.sqlx/query-a1be0fa3691226439f768d11600337f18909a58d0f863c794f93f8285b9133c8.json @@ -6,12 +6,24 @@ { "name": "tmdb_id!: i64", "ordinal": 0, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "movies", + "name": "tmdb_id" + } + } }, { "name": "metadata_refreshed_at", "ordinal": 1, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "movies", + "name": "metadata_refreshed_at" + } + } } ], "parameters": { diff --git a/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json b/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json index 42f6e34..a440463 100644 --- a/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json +++ b/.sqlx/query-e57dd914010e4346ee3b5cc64fe55064d5a83eafe5bc95ddda9a9056d5724f10.json @@ -6,67 +6,145 @@ { "name": "id!: i64", "ordinal": 0, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "id" + } + } }, { "name": "tmdb_id!: i64", "ordinal": 1, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "tmdb_id" + } + } }, { "name": "tvdb_id", "ordinal": 2, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "tvdb_id" + } + } }, { "name": "title!: String", "ordinal": 3, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "title" + } + } }, { "name": "year", "ordinal": 4, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "year" + } + } }, { "name": "original_language", "ordinal": 5, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "original_language" + } + } }, { "name": "root_id!: i64", "ordinal": 6, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "root_id" + } + } }, { "name": "auto_track!: bool", "ordinal": 7, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "auto_track" + } + } }, { "name": "upstream_ended!: bool", "ordinal": 8, - "type_info": "Integer" + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "upstream_ended" + } + } }, { "name": "metadata_refreshed_at", "ordinal": 9, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "metadata_refreshed_at" + } + } }, { "name": "poster_path", "ordinal": 10, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "poster_path" + } + } }, { "name": "backdrop_path", "ordinal": 11, - "type_info": "Text" + "type_info": "Text", + "origin": { + "Table": { + "table": "series", + "name": "backdrop_path" + } + } }, { "name": "vote_average", "ordinal": 12, - "type_info": "Float" + "type_info": "Float", + "origin": { + "Table": { + "table": "series", + "name": "vote_average" + } + } } ], "parameters": { diff --git a/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json b/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json new file mode 100644 index 0000000..207bd32 --- /dev/null +++ b/.sqlx/query-ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019.json @@ -0,0 +1,28 @@ +{ + "db_name": "SQLite", + "query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'", + "describe": { + "columns": [ + { + "name": "failures!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "last_failed_at?: String", + "ordinal": 1, + "type_info": "Text", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "ea21a91634b441e4cacf693f767549ae5075a56a668e0bf836d85e22d9202019" +} diff --git a/crates/arr-core/src/grabbing.rs b/crates/arr-core/src/grabbing.rs index 7f8ec4e..0c5eb39 100644 --- a/crates/arr-core/src/grabbing.rs +++ b/crates/arr-core/src/grabbing.rs @@ -4,7 +4,8 @@ //! while a season is airing, grab per episode. A completed season with no //! episodes on disk prefers the pack — one torrent, better seeded, consistent //! encode. A pack that hard-failed must not cost the whole season, so the -//! season falls back to per-episode instead of being blacklisted outright. +//! season falls back to per-episode while the failure's §6.2 backoff window +//! is open, and retries the pack once it elapses — quiet, never off. //! //! Re-grabbing a pack once an airing season completes is deliberately not //! done (§14): a season with any episode already on disk grabs per episode. @@ -31,8 +32,9 @@ pub struct SeasonGrabFacts<'a> { /// re-grabs a pack over episodes on disk, and a pack must not re-import /// what exists). pub any_episode_on_disk: bool, - /// Whether a season-pack grab for this season already hard-failed. - pub pack_hard_failed: bool, + /// Whether a failed season-pack grab still holds the season off the + /// pack lane — true only while the §6.2 backoff window is open. + pub pack_backoff_active: bool, } /// Picks the grab mode for one season. @@ -49,7 +51,7 @@ pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode { .iter() .all(|date| date.is_some_and(|date| date <= facts.now)); - if fully_released && !facts.any_episode_on_disk && !facts.pack_hard_failed { + if fully_released && !facts.any_episode_on_disk && !facts.pack_backoff_active { SeasonGrabMode::SeasonPack } else { SeasonGrabMode::PerEpisode @@ -69,7 +71,7 @@ mod tests { air_dates, now: SystemTime::UNIX_EPOCH + 100 * DAY, any_episode_on_disk: false, - pack_hard_failed: false, + pack_backoff_active: false, } } @@ -105,10 +107,10 @@ mod tests { } #[test] - fn a_hard_failed_pack_falls_back_to_per_episode() { + fn a_failed_pack_inside_its_backoff_window_falls_back_to_per_episode() { let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)]; let mut facts = facts(&aired); - facts.pack_hard_failed = true; + facts.pack_backoff_active = true; assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode); } diff --git a/crates/arr-daemon/src/rss.rs b/crates/arr-daemon/src/rss.rs index 03a1cb3..1116b96 100644 --- a/crates/arr-daemon/src/rss.rs +++ b/crates/arr-daemon/src/rss.rs @@ -32,8 +32,8 @@ use arr_dl::TransmissionClient; use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest}; use crate::grab::{ - store_episode_release, store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber, - SeedingRules, + backoff_elapsed, store_episode_release, store_release, Eligible, GrabError, GrabScope, + GrabTarget, Grabber, SeedingRules, }; use crate::indexers::IndexerDirectory; use crate::reconcile::{Action, ActionFuture, Outcome}; @@ -625,11 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result ) .fetch_all(database.pool()) .await?; - let pack_hard_failed = sqlx::query_scalar!( - r#"SELECT EXISTS ( - SELECT 1 FROM grabs - WHERE target_kind = 'season' AND target_id = ? AND state = 'failed' - ) AS "failed!: bool""#, + let failed_packs = sqlx::query!( + r#"SELECT count(*) AS "failures!: i64", + max(grabbed_at) AS "last_failed_at?: String" + FROM grabs + WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#, season_id ) .fetch_one(database.pool()) @@ -642,7 +642,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result .collect::>(), now: std::time::SystemTime::now(), any_episode_on_disk: episodes.iter().any(|episode| episode.on_disk), - pack_hard_failed, + pack_backoff_active: failed_packs.failures > 0 + && !backoff_elapsed( + failed_packs.failures, + failed_packs.last_failed_at.as_deref(), + ), }) == SeasonGrabMode::SeasonPack) } diff --git a/crates/arr-daemon/src/tv_grab.rs b/crates/arr-daemon/src/tv_grab.rs index 92ae166..a146857 100644 --- a/crates/arr-daemon/src/tv_grab.rs +++ b/crates/arr-daemon/src/tv_grab.rs @@ -79,6 +79,7 @@ impl TvGrabAction { &season, &blacklist, EPISODE_SEARCHES_PER_TICK - episode_searches, + false, ) .await { @@ -102,12 +103,15 @@ impl TvGrabAction { /// Search and grab for one season. `None` means the season was skipped /// without spending an indexer call — nothing due, or no language yet. + /// `ignore_pack_backoff` is the manual trigger's "try again now": it + /// waives the failed-pack backoff window for this one invocation. async fn grab_season( &self, database: &Db, season: &PendingSeason, blacklist: &Blacklist, episode_budget: usize, + ignore_pack_backoff: bool, ) -> Result, GrabError> { // §5.2: without the title's original language there is nothing to // evaluate a release against, and guessing is worse than waiting. @@ -141,7 +145,8 @@ impl TvGrabAction { air_dates: &air_dates, now, any_episode_on_disk: episodes.iter().any(|episode| episode.has_file), - pack_hard_failed: pack_hard_failed(database, season.season_id).await?, + pack_backoff_active: !ignore_pack_backoff + && pack_backoff_active(database, season.season_id).await?, }); let aired_due_gaps: Vec<&SeasonEpisode> = gaps @@ -566,8 +571,10 @@ impl TvGrabAction { } /// The manual season trigger (issue #125's deck, drained per #132): - /// reset every open episode's backoff and run the same pack-or-fall-back - /// lane as the tick, scoped to this one season with no tick budgets. + /// reset every open episode's backoff, waive the failed-pack backoff + /// (§6.2's escape hatch is this deck), and run the same + /// pack-or-fall-back lane as the tick, scoped to this one season with + /// no tick budgets. /// /// A satisfied season — nothing open, or a grab already in flight — /// gets its deck refreshed and no grab; a blocked series refuses. @@ -625,7 +632,7 @@ impl TvGrabAction { ); return Ok(()); } - self.grab_season(database, &season, &blacklist, usize::MAX) + self.grab_season(database, &season, &blacklist, usize::MAX, true) .await?; Ok(()) } @@ -1106,19 +1113,21 @@ async fn load_season_release( })) } -/// Whether a season-pack grab for this season already hard-failed — the -/// fall-back-to-per-episode signal. -async fn pack_hard_failed(database: &Db, season_id: i64) -> Result { - let failed = sqlx::query_scalar!( - r#"SELECT EXISTS ( - SELECT 1 FROM grabs - WHERE target_kind = 'season' AND target_id = ? AND state = 'failed' - ) AS "failed!: bool""#, +/// Whether failed season-pack grabs still hold this season off the pack +/// lane. §6.2: a failure quiets the pack search on the shared backoff curve +/// (each failed grab is one attempt), it never disables it. Anchored on the +/// latest failed grab's `grabbed_at` — failure time itself is not recorded. +async fn pack_backoff_active(database: &Db, season_id: i64) -> Result { + let row = sqlx::query!( + r#"SELECT count(*) AS "failures!: i64", + max(grabbed_at) AS "last_failed_at?: String" + FROM grabs + WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#, season_id ) .fetch_one(database.pool()) .await?; - Ok(failed) + Ok(row.failures > 0 && !backoff_elapsed(row.failures, row.last_failed_at.as_deref())) } /// A pack for exactly this season. Multi-season packs are never selected @@ -1463,38 +1472,41 @@ mod tests { /// The third acceptance case, selection side: a hard-failed pack put the /// release on the blacklist and the season falls back to per-episode — /// the pack is not tried again and the episodes are not written off. - #[tokio::test] - async fn a_hard_failed_pack_falls_back_to_per_episode() { - let (_dir, database, season_id) = - wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; - // What the import tick left behind: a failed season grab and the - // pack release on the blacklist (§6.3). + /// Seed what the import tick leaves behind after a pack fails: one + /// `failed` season grab per (infohash, age) pair. + async fn failed_packs(database: &Db, season_id: i64, ages: &[&str]) { let release_id: i64 = sqlx::query_scalar( "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) - VALUES (7, 'pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920, - 'https://tracker/pack.torrent', '{}', 'eligible') + VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920, + 'https://tracker/oldpack.torrent', '{}', 'eligible') RETURNING id", ) .fetch_one(database.pool()) .await .unwrap(); - sqlx::query( - "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state) - VALUES (?, 'season', ?, 'dead', 'failed')", - ) - .bind(release_id) - .bind(season_id) - .execute(database.pool()) - .await - .unwrap(); - arr_db::blacklist::add( - database.pool(), - Some("dead"), - "Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos", - "dolby_vision_profile", - ) - .await - .unwrap(); + for (index, age) in ages.iter().enumerate() { + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at) + VALUES (?, 'season', ?, ?, 'failed', + strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))", + ) + .bind(release_id) + .bind(season_id) + .bind(format!("dead{index}")) + .bind(age) + .execute(database.pool()) + .await + .unwrap(); + } + } + + /// A failed pack inside its §6.2 backoff window stays per-episode: the + /// window quiets the pack lane, per-episode still makes progress. + #[tokio::test] + async fn a_failed_pack_inside_its_window_falls_back_to_per_episode() { + let (_dir, database, season_id) = + wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs(&database, season_id, &["-10 minutes"]).await; let indexer = prowlarr().await; let (downloader, fake) = transmission().await; @@ -1520,6 +1532,90 @@ mod tests { ); } + /// §6.2: one failed pack backs the pack lane off for 1h, then the tick + /// retries the pack instead of never trying again. + #[tokio::test] + async fn a_failed_pack_retries_after_its_backoff_window() { + let (_dir, database, season_id) = + wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs(&database, season_id, &["-2 hours"]).await; + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + + action(&indexer, &downloader).tick(&database).await.unwrap(); + + assert_eq!(fake.torrents().len(), 1); + assert!(fake.torrents()[0].source.ends_with("pack.torrent")); + assert_eq!( + grabs(&database).await.last().unwrap(), + &("season".to_owned(), season_id, "sent".to_owned()) + ); + } + + /// Repeated failures ride the capped curve: five failed packs mean a 7d + /// window — still closed at 6d, open at 8d. Quiet, never off. + #[tokio::test] + async fn a_repeatedly_failed_pack_retries_on_the_capped_curve() { + let inside = wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs( + &inside.1, + inside.2, + &["-30 days", "-25 days", "-20 days", "-12 days", "-6 days"], + ) + .await; + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + action(&indexer, &downloader).tick(&inside.1).await.unwrap(); + assert!( + fake.torrents() + .iter() + .all(|torrent| !torrent.source.ends_with("pack.torrent")), + "6 days into a 7d window the pack lane stays quiet" + ); + + let elapsed = wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs( + &elapsed.1, + elapsed.2, + &["-30 days", "-25 days", "-20 days", "-12 days", "-8 days"], + ) + .await; + let (downloader, fake) = transmission().await; + action(&indexer, &downloader) + .tick(&elapsed.1) + .await + .unwrap(); + assert_eq!(fake.torrents().len(), 1); + assert!(fake.torrents()[0].source.ends_with("pack.torrent")); + } + + /// The escape hatch (§6.2, issue #181): a manual season search waives + /// the failed-pack backoff and retries the pack right now. + #[tokio::test] + async fn a_manual_season_search_waives_the_pack_backoff() { + let (_dir, database, season_id) = + wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + failed_packs(&database, season_id, &["-10 minutes"]).await; + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + + action(&indexer, &downloader) + .search_season_now(&database, season_id) + .await + .unwrap(); + + assert_eq!(fake.torrents().len(), 1); + assert!(fake.torrents()[0].source.ends_with("pack.torrent")); + assert_eq!( + grabs(&database).await.last().unwrap(), + &("season".to_owned(), season_id, "sent".to_owned()) + ); + assert_eq!( + episode_states(&database).await, + vec!["downloading", "downloading", "downloading"] + ); + } + /// §14: a season with an episode already on disk never grabs the pack, /// even when fully released. The remaining episodes come individually. #[tokio::test] From 2286cba86235362a4f5609430b2a6ae9a8430d86 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 20:10:23 +0100 Subject: [PATCH 3/4] feat(core): score season packs per episode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A size band describes one episode (DESIGN.md §5.5), so both the target penalty and the floor now compare a release's size divided by the number of episodes it covers. The caller supplies the count — arr-core has no IO. Movies and unknown counts divide by one, so movie scoring is unchanged and pinned by test. Closes #180 Co-Authored-By: Claude Fable 5 --- ...0c22ee77649a368081580a1f13ff29288325d.json | 33 +++ ...1fcd279d4542e6aa1f8d8d80bf96746e1f33.json} | 26 +- ...e23d8618487133ff69ee2c88868dc87d35a51.json | 33 +++ crates/arr-api/src/movies.rs | 23 +- crates/arr-api/src/search.rs | 21 +- crates/arr-api/src/series.rs | 38 ++- crates/arr-core/src/policy.rs | 27 +- crates/arr-core/src/score.rs | 277 +++++++++++++++++- crates/arr-daemon/src/grab.rs | 54 +++- crates/arr-daemon/src/import.rs | 2 + crates/arr-db/src/policy.rs | 7 +- 11 files changed, 502 insertions(+), 39 deletions(-) create mode 100644 .sqlx/query-05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d.json rename .sqlx/{query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json => query-0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33.json} (66%) create mode 100644 .sqlx/query-a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51.json diff --git a/.sqlx/query-05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d.json b/.sqlx/query-05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d.json new file mode 100644 index 0000000..1ae4e8d --- /dev/null +++ b/.sqlx/query-05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d.json @@ -0,0 +1,33 @@ +{ + "db_name": "SQLite", + "query": "SELECT se.number AS \"number!: i64\", COUNT(e.id) AS \"episodes!: i64\"\n FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id\n WHERE se.series_id = ?\n GROUP BY se.number", + "describe": { + "columns": [ + { + "name": "number!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "seasons", + "name": "number" + } + } + }, + { + "name": "episodes!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "05b52c731b198cc338c15afff3b0c22ee77649a368081580a1f13ff29288325d" +} diff --git a/.sqlx/query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json b/.sqlx/query-0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33.json similarity index 66% rename from .sqlx/query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json rename to .sqlx/query-0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33.json index ea47275..86b77a4 100644 --- a/.sqlx/query-c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d.json +++ b/.sqlx/query-0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33.json @@ -1,11 +1,22 @@ { "db_name": "SQLite", - "query": "SELECT s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?", + "query": "SELECT s.id AS \"series_id!: i64\", s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?", "describe": { "columns": [ { - "name": "series_title!: String", + "name": "series_id!: i64", "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "series", + "name": "id" + } + } + }, + { + "name": "series_title!: String", + "ordinal": 1, "type_info": "Text", "origin": { "Table": { @@ -16,7 +27,7 @@ }, { "name": "series_tvdb_id", - "ordinal": 1, + "ordinal": 2, "type_info": "Integer", "origin": { "Table": { @@ -27,7 +38,7 @@ }, { "name": "original_language", - "ordinal": 2, + "ordinal": 3, "type_info": "Text", "origin": { "Table": { @@ -38,7 +49,7 @@ }, { "name": "season_number!: i64", - "ordinal": 3, + "ordinal": 4, "type_info": "Integer", "origin": { "Table": { @@ -49,7 +60,7 @@ }, { "name": "episode_number!: i64", - "ordinal": 4, + "ordinal": 5, "type_info": "Integer", "origin": { "Table": { @@ -63,6 +74,7 @@ "Right": 1 }, "nullable": [ + false, false, true, true, @@ -70,5 +82,5 @@ false ] }, - "hash": "c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d" + "hash": "0618d006da5cdf0d86d851cce7ba1fcd279d4542e6aa1f8d8d80bf96746e1f33" } diff --git a/.sqlx/query-a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51.json b/.sqlx/query-a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51.json new file mode 100644 index 0000000..8373dad --- /dev/null +++ b/.sqlx/query-a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51.json @@ -0,0 +1,33 @@ +{ + "db_name": "SQLite", + "query": "SELECT se.number AS \"number!: i64\", COUNT(e.id) AS \"episodes!: i64\"\n FROM seasons se\n LEFT JOIN episodes e ON e.season_id = se.id\n WHERE se.series_id = (SELECT s2.series_id FROM episodes e2\n JOIN seasons s2 ON s2.id = e2.season_id\n WHERE e2.id = ?)\n GROUP BY se.number", + "describe": { + "columns": [ + { + "name": "number!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "seasons", + "name": "number" + } + } + }, + { + "name": "episodes!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "a241c5a1ab23ffd966c92edc611e23d8618487133ff69ee2c88868dc87d35a51" +} diff --git a/crates/arr-api/src/movies.rs b/crates/arr-api/src/movies.rs index 9cbf8b8..351b1e5 100644 --- a/crates/arr-api/src/movies.rs +++ b/crates/arr-api/src/movies.rs @@ -1,5 +1,7 @@ +use std::collections::BTreeMap; + use arr_core::policy::Candidate; -use arr_core::score::score; +use arr_core::score::{claimed_episode_count, score}; use arr_core::{ParsedRelease, Policy}; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; @@ -602,7 +604,7 @@ pub async fn releases( .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::NotFound)? .policy; - rescore(&mut releases, &policy)?; + rescore(&mut releases, &policy, None)?; Ok(Json(releases)) } @@ -611,18 +613,30 @@ pub async fn releases( /// daemon's winner pick; the deck the user sees must reflect policy tuning /// made since the release was last searched, not whatever was true then. /// +/// `season_lengths` maps season number to episode count for the deck's series +/// — a size band describes one episode, so a pack's size is divided by how +/// many it covers. `None` is the movie decks: one release is one film, +/// whatever episode-shaped noise its name parses to. +/// /// Score magnitudes stay far below `f64`'s 52-bit mantissa (they are sums of /// policy weights in the thousands), so the `i64` -> `f64` cast into the /// column's storage type is exact. #[allow(clippy::cast_precision_loss)] -pub(crate) fn rescore(releases: &mut [Release], policy: &Policy) -> Result<(), ApiError> { +pub(crate) fn rescore( + releases: &mut [Release], + policy: &Policy, + season_lengths: Option<&BTreeMap>, +) -> Result<(), ApiError> { let mut totals = Vec::with_capacity(releases.len()); for release in releases.iter() { let parsed: ParsedRelease = serde_json::from_value(release.parsed.clone()) .map_err(|error| ApiError::Database(error.to_string()))?; let seeders = u32::try_from(release.seeders.unwrap_or(0)).unwrap_or(u32::MAX); let size = u64::try_from(release.size).unwrap_or(0); - totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders).total); + let episodes = season_lengths.map_or(1, |lengths| { + claimed_episode_count(parsed.episode.as_ref(), lengths) + }); + totals.push(score(policy, Candidate::PreGrab(&parsed), size, seeders, episodes).total); } let mut indices: Vec = (0..releases.len()).collect(); indices.sort_by_key(|&i| (bucket(releases[i].verdict.as_deref()), -totals[i])); @@ -1430,6 +1444,7 @@ mod tests { arr_core::policy::Candidate::PreGrab(&parsed), size_bytes, seeders, + 1, ) .total; #[allow(clippy::cast_precision_loss)] diff --git a/crates/arr-api/src/search.rs b/crates/arr-api/src/search.rs index 73dc4ce..0c178b4 100644 --- a/crates/arr-api/src/search.rs +++ b/crates/arr-api/src/search.rs @@ -1,7 +1,8 @@ +use std::collections::BTreeMap; use std::time::UNIX_EPOCH; use arr_core::policy::{evaluate, Candidate}; -use arr_core::score::score; +use arr_core::score::{claimed_episode_count, score}; use arr_core::{Language, Policy, Rule, TitleOverrides, Verdict}; use arr_db::policy::language; use arr_db::{blacklist, Blacklist}; @@ -488,6 +489,7 @@ async fn movie_releases( &overrides, &original_language, &blacklist, + None, )?); } } @@ -511,7 +513,7 @@ async fn episode_releases( ) -> Result, ApiError> { let database = state.database().ok_or(ApiError::Unavailable)?; let episode = sqlx::query!( - r#"SELECT s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language, + r#"SELECT s.id AS "series_id!: i64", s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language, se.number AS "season_number!: i64", e.number AS "episode_number!: i64" FROM episodes e JOIN seasons se ON se.id = e.season_id @@ -522,6 +524,7 @@ async fn episode_releases( .fetch_optional(database.pool()) .await? .ok_or(ApiError::EpisodeNotFound)?; + let season_lengths = crate::series::season_lengths(state, episode.series_id).await?; let loaded = database .episode_policy(episode_id) .await @@ -564,6 +567,7 @@ async fn episode_releases( &loaded.overrides, &original_language, &blacklist, + Some(&season_lengths), )?); } } @@ -704,14 +708,22 @@ fn classify( overrides: &TitleOverrides, original_language: &Language, blacklist: &Blacklist, + season_lengths: Option<&BTreeMap>, ) -> Result { let parsed = arr_parse::parse(&release.name); + // A size band describes one episode (`DESIGN.md` §5.5): a pack's size is + // divided by how many episodes it covers. `None` is the movie search — + // one release is one film. + let episodes = season_lengths.map_or(1, |lengths| { + claimed_episode_count(parsed.episode.as_ref(), lengths) + }); let evaluation = evaluate( policy, overrides, original_language, Candidate::PreGrab(&parsed), release.size, + episodes, ); let (verdict, rule) = if blacklist.blocks_candidate(&release.name, &release.download_url) { ("rejected", Some(blacklist::RULE.to_owned())) @@ -723,6 +735,7 @@ fn classify( Candidate::PreGrab(&parsed), release.size.unwrap_or_default(), release.seeders.unwrap_or_default(), + episodes, ); // A release with no size has nothing to say about its size band, so that // term is dropped rather than scored as if it were at the floor. Every @@ -1451,6 +1464,7 @@ mod tests { &TitleOverrides::default(), &Language::Other("en".into()), &Blacklist::default(), + None, ) .expect("classified release") }; @@ -1544,7 +1558,7 @@ mod tests { imdb_id: None, }; let parsed = arr_parse::parse(&release.name); - let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8); + let core_score = score(&policy, Candidate::PreGrab(&parsed), 0, 8, 1); let classified = classify( release, @@ -1552,6 +1566,7 @@ mod tests { &TitleOverrides::default(), &Language::Other("en".into()), &Blacklist::default(), + None, ) .expect("classified release"); diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index f7dd443..1fc7b6d 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -13,7 +13,7 @@ //! - **Status is derived, never stored** (§4.2). Every series the API returns //! carries a status computed from its episodes at request time. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason}; @@ -1360,7 +1360,7 @@ pub async fn episode_releases( State(state): State, Path(id): Path, ) -> Result>, ApiError> { - load_episode(&state, id).await?; + let episode = load_episode(&state, id).await?; let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id) .fetch_all(pool(&state)?) .await?; @@ -1372,7 +1372,8 @@ pub async fn episode_releases( .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::EpisodeNotFound)? .policy; - rescore(&mut releases, &policy)?; + let lengths = season_lengths(&state, episode.series_id).await?; + rescore(&mut releases, &policy, Some(&lengths))?; Ok(Json(releases)) } @@ -1407,6 +1408,34 @@ pub async fn grab_episode( /// Resolves a season by its number within one series, so the deck is /// addressed the way the UI shows seasons (`/series/{id}/seasons/{n}`). +/// Per-season episode counts for one series: the divisor data behind +/// per-episode size normalisation (`DESIGN.md` §5.5). A season with no +/// revealed episodes counts zero, which `claimed_episode_count` treats as +/// unknown. +pub(crate) async fn season_lengths( + state: &AppState, + series_id: i64, +) -> Result, ApiError> { + let rows = sqlx::query!( + r#"SELECT se.number AS "number!: i64", COUNT(e.id) AS "episodes!: i64" + FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id + WHERE se.series_id = ? + GROUP BY se.number"#, + series_id + ) + .fetch_all(pool(state)?) + .await?; + Ok(rows + .into_iter() + .map(|row| { + ( + u32::try_from(row.number).unwrap_or_default(), + u32::try_from(row.episodes).unwrap_or_default(), + ) + }) + .collect()) +} + async fn load_season_id(state: &AppState, series_id: i64, number: i64) -> Result { sqlx::query_scalar!( r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#, @@ -1484,7 +1513,8 @@ pub async fn season_releases( .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::SeasonNotFound)? .policy; - rescore(&mut releases, &policy)?; + let lengths = season_lengths(&state, series_id).await?; + rescore(&mut releases, &policy, Some(&lengths))?; Ok(Json(releases)) } diff --git a/crates/arr-core/src/policy.rs b/crates/arr-core/src/policy.rs index cd3e2e0..f9ab8bb 100644 --- a/crates/arr-core/src/policy.rs +++ b/crates/arr-core/src/policy.rs @@ -64,6 +64,10 @@ pub struct EvaluationContext<'a> { /// when the caller has no size to offer, which abstains rather than /// rejects. pub size_bytes: Option, + /// How many episodes `size_bytes` covers (`DESIGN.md` §5.5) — a size band + /// describes one episode, so the size rule divides by this. One for a + /// movie or an unknown count; zero is treated as one. + pub episode_count: u32, } /// A rule's identity when no violation exists to carry concrete evidence. @@ -107,6 +111,7 @@ pub fn evaluate( original_language: &Language, candidate: Candidate<'_>, size_bytes: Option, + episode_count: u32, ) -> Evaluation { let context = EvaluationContext { policy, @@ -114,6 +119,7 @@ pub fn evaluate( original_language, candidate, size_bytes, + episode_count, }; let rules: [&dyn PolicyRule; 6] = [ &ResolutionRule, @@ -227,7 +233,8 @@ impl PolicyRule for SizeRule { else { return RuleEvaluation::Unknown(RuleKind::Size); }; - match crate::score::is_below_floor(context.policy, resolution, size) { + match crate::score::is_below_floor(context.policy, resolution, size, context.episode_count) + { None => RuleEvaluation::Unknown(RuleKind::Size), Some(true) => RuleEvaluation::HardFail(Rule::Size), Some(false) => RuleEvaluation::Pass(RuleKind::Size), @@ -515,7 +522,7 @@ mod tests { } fn verdict(policy: &Policy, overrides: &TitleOverrides, candidate: Candidate<'_>) -> Verdict { - evaluate(policy, overrides, &en(), candidate, None).verdict + evaluate(policy, overrides, &en(), candidate, None, 1).verdict } fn verdict_for( @@ -529,6 +536,7 @@ mod tests { original_language, candidate, None, + 1, ) .verdict } @@ -543,6 +551,7 @@ mod tests { &en(), Candidate::PreGrab(&claims), None, + 1, ); assert_eq!(report.verdict, Verdict::Eligible); @@ -725,6 +734,7 @@ mod tests { &en(), Candidate::PostDownload(&media), None, + 1, ); assert_eq!(report.verdict, Verdict::Eligible); @@ -781,6 +791,7 @@ mod tests { &en(), Candidate::PostDownload(&media), None, + 1, ); let expected = if rejected { Verdict::Rejected(Rule::DolbyVisionProfile(profile)) @@ -800,6 +811,7 @@ mod tests { &en(), Candidate::PreGrab(&claims), None, + 1, ); assert_eq!(report.verdict, Verdict::Eligible); @@ -832,6 +844,7 @@ mod tests { original_language: &original, candidate: Candidate::PreGrab(&claims), size_bytes: None, + episode_count: 1, }; let soft = FixedRule { evaluation: RuleEvaluation::SoftFail(Rule::Other("soft".to_owned())), @@ -860,6 +873,7 @@ mod tests { original_language: &original, candidate: Candidate::PreGrab(&claims), size_bytes: None, + episode_count: 1, }; let first = FixedRule { evaluation: RuleEvaluation::HardFail(Rule::Other("first".to_owned())), @@ -995,7 +1009,8 @@ mod tests { &overrides, &en(), Candidate::PreGrab(&claims), - None + None, + 1 ) .verdict, Verdict::Eligible @@ -1008,7 +1023,8 @@ mod tests { &overrides, &en(), Candidate::PostDownload(&media), - None + None, + 1 ) .verdict, Verdict::Waived(Rule::RequiredAudio) @@ -1082,6 +1098,7 @@ mod tests { &en(), Candidate::PostDownload(&media), None, + 1, ); assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified)); @@ -1093,6 +1110,7 @@ mod tests { &Language::PortugueseBrazil, Candidate::PostDownload(&media), None, + 1, ); assert_eq!(report.verdict, Verdict::Waived(Rule::PortugueseUnverified)); } @@ -1106,6 +1124,7 @@ mod tests { &en(), Candidate::PreGrab(&claims), None, + 1, ); assert_eq!(report.verdict, Verdict::Eligible); assert_eq!( diff --git a/crates/arr-core/src/score.rs b/crates/arr-core/src/score.rs index bc3bd92..3cfa5b4 100644 --- a/crates/arr-core/src/score.rs +++ b/crates/arr-core/src/score.rs @@ -19,6 +19,10 @@ //! remux playback test in `DESIGN.md` §14; the model is the decision, not the //! constants. +use std::collections::BTreeMap; + +use arr_parse::EpisodeClaim; + use crate::{policy::Candidate, Policy, Release, Resolution, SizeBand, Source}; const BYTES_PER_GIB: i64 = 1 << 30; @@ -84,13 +88,26 @@ pub struct Score { /// resolution the policy carries no band for — scores zero on the dominant /// term rather than being penalised for what the release name failed to say. /// The resolution-rank term treats an unclaimed resolution the same way. +/// +/// A band describes one episode (`DESIGN.md` §5.5), so `episode_count` is the +/// divisor that turns a season pack's total into the per-episode figure the +/// band is compared against. The caller supplies it — `arr-core` has no IO, +/// and a `Season` claim names a season, not a length. A movie, and any release +/// whose count is unknown, is one episode; zero is treated as one. #[must_use] -pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders: u32) -> Score { +pub fn score( + policy: &Policy, + candidate: Candidate<'_>, + size_bytes: u64, + seeders: u32, + episode_count: u32, +) -> Score { let weights = &policy.score_weights; let claimed = candidate.resolution(); + let per_episode = per_episode_size(size_bytes, episode_count); let size = claimed .and_then(|resolution| policy.size_bands.get(&resolution)) - .map_or(0, |band| size_points(band, weights, size_bytes)); + .map_or(0, |band| size_points(band, weights, per_episode)); let source = candidate .source() .map_or(0, |source| source_points(policy, source)); @@ -113,25 +130,69 @@ pub fn score(policy: &Policy, candidate: Candidate<'_>, size_bytes: u64, seeders /// /// Pre-grab, the name is all there is (`DESIGN.md` §5.6). #[must_use] -pub fn score_release(policy: &Policy, release: &Release) -> Score { +pub fn score_release(policy: &Policy, release: &Release, episode_count: u32) -> Score { score( policy, Candidate::PreGrab(&release.parsed), release.size, release.seeders, + episode_count, ) } /// Whether a size falls below its band's floor, which is a hard filter. /// +/// The floor takes the same per-episode figure as the target penalty +/// (`DESIGN.md` §5.5): comparing a pack's total against an episode-sized floor +/// would let every pack through untested. Zero `episode_count` is one episode. +/// /// `None` when the policy carries no band for that resolution: no band is no /// opinion, not a rejection. #[must_use] -pub fn is_below_floor(policy: &Policy, resolution: Resolution, size_bytes: u64) -> Option { +pub fn is_below_floor( + policy: &Policy, + resolution: Resolution, + size_bytes: u64, + episode_count: u32, +) -> Option { policy .size_bands .get(&resolution) - .map(|band| size_bytes < band.floor_bytes) + .map(|band| per_episode_size(size_bytes, episode_count) < band.floor_bytes) +} + +/// How many episodes a release's size covers (`DESIGN.md` §5.5): the divisor +/// behind [`score`] and [`is_below_floor`]. +/// +/// `season_lengths` maps season number to that season's episode count as the +/// caller knows it — the caller supplies the fact the way `apply_auto_track` +/// takes `ever_refreshed`, because `arr-core` cannot look it up. An `Episodes` +/// claim knows its own length; a `Season` or `Seasons` claim is summed from +/// the map. Anything unknown — no claim, a daily episode, a season the map +/// does not carry — falls back to one episode, which measures the release by +/// its full size and fails toward rejecting a good pack rather than grabbing +/// a bad one. +#[must_use] +pub fn claimed_episode_count( + claim: Option<&EpisodeClaim>, + season_lengths: &BTreeMap, +) -> u32 { + let count = match claim { + Some(EpisodeClaim::Episodes { episodes, .. }) => { + u32::try_from(episodes.len()).unwrap_or(u32::MAX) + } + Some(EpisodeClaim::Season { season }) => season_lengths.get(season).copied().unwrap_or(0), + Some(EpisodeClaim::Seasons { first, last }) => (*first..=*last) + .map(|season| season_lengths.get(&season).copied().unwrap_or(0)) + .fold(0u32, u32::saturating_add), + Some(EpisodeClaim::Daily { .. }) | None => 1, + }; + count.max(1) +} + +/// The per-episode figure every size band is compared against. +fn per_episode_size(size_bytes: u64, episode_count: u32) -> u64 { + size_bytes / u64::from(episode_count.max(1)) } /// The size term: a ramp from the floor up to the target, then a penalty that @@ -262,7 +323,13 @@ mod tests { fn scored(source: ClaimedSource, size_bytes: u64, seeders: u32) -> Score { let claims = claims(source); - score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders) + score( + &policy(), + Candidate::PreGrab(&claims), + size_bytes, + seeders, + 1, + ) } /// The same, at a resolution the caller picks. @@ -277,10 +344,20 @@ mod tests { source: Some(source), ..NameClaims::default() }; - score(&policy(), Candidate::PreGrab(&claims), size_bytes, seeders) + score( + &policy(), + Candidate::PreGrab(&claims), + size_bytes, + seeders, + 1, + ) } fn size_rule(size_bytes: u64) -> RuleEvaluation { + size_rule_for(size_bytes, 1) + } + + fn size_rule_for(size_bytes: u64, episode_count: u32) -> RuleEvaluation { let policy = policy(); let overrides = TitleOverrides::default(); let language = Language::Other("en".to_owned()); @@ -291,6 +368,7 @@ mod tests { original_language: &language, candidate: Candidate::PreGrab(&claims), size_bytes: Some(size_bytes), + episode_count, }) } @@ -321,6 +399,7 @@ mod tests { &Language::Other("en".to_owned()), Candidate::PreGrab(&claims), Some(gib(60)), + 1, ); // A bad score, but a score: nothing filters it out, so a selection @@ -387,10 +466,13 @@ mod tests { assert_eq!(size_rule(gib(3)), RuleEvaluation::HardFail(Rule::Size)); assert!(matches!(size_rule(gib(9)), RuleEvaluation::Pass(_))); assert_eq!( - is_below_floor(&policy(), Resolution::R2160p, gib(3)), + is_below_floor(&policy(), Resolution::R2160p, gib(3), 1), Some(true) ); - assert_eq!(is_below_floor(&policy(), Resolution::R720p, gib(3)), None); + assert_eq!( + is_below_floor(&policy(), Resolution::R720p, gib(3), 1), + None + ); } #[test] @@ -399,7 +481,7 @@ mod tests { source: Some(ClaimedSource::WebDl), ..NameClaims::default() }; - let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10); + let score = score(&policy(), Candidate::PreGrab(&claims), gib(20), 10, 1); assert_eq!(score.size, 0); assert_eq!(score.total, score.source + score.seeders); @@ -417,8 +499,8 @@ mod tests { }, ); let claims = claims(ClaimedSource::WebDl); - let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10); - let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10); + let at = score(&policy, Candidate::PreGrab(&claims), gib(10), 10, 1); + let under = score(&policy, Candidate::PreGrab(&claims), gib(9), 10, 1); assert_eq!(at.size, i64::from(ScoreWeights::default().size_at_target)); assert_eq!(under.size, 0); @@ -466,7 +548,7 @@ mod tests { resolution: Some(resolution), ..NameClaims::default() }; - score(&policy, Candidate::PreGrab(&claims), gib(8), 0).resolution + score(&policy, Candidate::PreGrab(&claims), gib(8), 0, 1).resolution }; assert_eq!(at(ClaimedResolution::P2160), 2 * step); @@ -483,7 +565,7 @@ mod tests { source: Some(ClaimedSource::WebDl), ..NameClaims::default() }; - let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10); + let unclaimed = score(&policy(), Candidate::PreGrab(&unclaimed), gib(20), 10, 1); assert_eq!(unranked.resolution, 0); assert_eq!(unclaimed.resolution, 0); @@ -500,4 +582,171 @@ mod tests { assert!(hd.seeders > uhd.seeders); assert!(uhd.total > hd.total); } + + /// Issue #180: a movie passes one episode's worth and must score exactly + /// as it did before packs learned to divide. Every term pinned to a + /// literal so the normalisation cannot quietly move movie scoring. + #[test] + fn a_movie_scores_exactly_as_before_pack_normalisation() { + let at_target = scored(ClaimedSource::WebDl, gib(22), 40); + assert_eq!( + at_target, + Score { + total: 1398, + size: 1000, + source: 50, + seeders: 48, + resolution: 300, + } + ); + + let over_target = scored(ClaimedSource::WebDl, gib(30), 40); + assert_eq!( + over_target, + Score { + total: 918, + size: 520, + source: 50, + seeders: 48, + resolution: 300, + } + ); + } + + #[test] + fn a_pack_scores_the_same_size_term_as_one_episode_of_its_per_episode_size() { + let episode = scored(ClaimedSource::WebDl, gib(22), 10); + let claims = claims(ClaimedSource::WebDl); + let pack = score(&policy(), Candidate::PreGrab(&claims), gib(220), 10, 10); + + assert_eq!(pack.size, episode.size); + assert_eq!(pack.total, episode.total); + } + + #[test] + fn a_pack_below_the_floor_per_episode_is_rejected() { + // 30 GiB across ten episodes is 3 GiB each, under the 8 GiB 4K floor + // — a pack of mud-quality encodes fails as plainly as one of them. + assert_eq!( + is_below_floor(&policy(), Resolution::R2160p, gib(30), 10), + Some(true) + ); + assert_eq!( + size_rule_for(gib(30), 10), + RuleEvaluation::HardFail(Rule::Size) + ); + + // The same total over three episodes is 10 GiB each and passes. + assert_eq!( + is_below_floor(&policy(), Resolution::R2160p, gib(30), 3), + Some(false) + ); + assert!(matches!(size_rule_for(gib(30), 3), RuleEvaluation::Pass(_))); + } + + #[test] + fn an_unknown_episode_count_falls_back_to_one_episode() { + let single = scored(ClaimedSource::WebDl, gib(22), 10); + let claims = claims(ClaimedSource::WebDl); + let zero = score(&policy(), Candidate::PreGrab(&claims), gib(22), 10, 0); + + assert_eq!(zero, single); + assert_eq!( + is_below_floor(&policy(), Resolution::R2160p, gib(3), 0), + is_below_floor(&policy(), Resolution::R2160p, gib(3), 1) + ); + } + + #[test] + fn claimed_episode_count_comes_from_the_claim_and_the_season_lengths() { + let lengths = BTreeMap::from([(1, 10), (2, 8), (3, 12)]); + + let episodes = EpisodeClaim::Episodes { + season: 1, + episodes: vec![2, 3, 4], + }; + assert_eq!(claimed_episode_count(Some(&episodes), &lengths), 3); + assert_eq!( + claimed_episode_count(Some(&EpisodeClaim::Season { season: 2 }), &lengths), + 8 + ); + assert_eq!( + claimed_episode_count(Some(&EpisodeClaim::Seasons { first: 1, last: 3 }), &lengths), + 30 + ); + + // Unknowns are one episode: no claim, a daily episode, a season the + // map has not revealed yet. + assert_eq!(claimed_episode_count(None, &lengths), 1); + assert_eq!( + claimed_episode_count( + Some(&EpisodeClaim::Daily { + year: 2024, + month: 5, + day: 13 + }), + &lengths + ), + 1 + ); + assert_eq!( + claimed_episode_count(Some(&EpisodeClaim::Season { season: 9 }), &lengths), + 1 + ); + } + + /// The observed case from issue #180: Rick and Morty S08 against a + /// TV policy (1080p target 2 GiB, 2160p target 6 GiB, 60 points per GiB + /// over, resolution step 300). Measured by pack total, the 1080p pack's + /// smaller size penalty beat the 300-point resolution step; per episode, + /// 2160p ranks first as `resolution_pref` says it should. + #[test] + fn the_rick_and_morty_season_pack_ranks_2160p_above_1080p() { + let policy = Policy { + size_bands: BTreeMap::from([ + ( + Resolution::R1080p, + SizeBand { + floor_bytes: GIB / 4, + target_bytes: gib(2), + penalty_points_per_gib_over: 60, + }, + ), + ( + Resolution::R2160p, + SizeBand { + floor_bytes: gib(1), + target_bytes: gib(6), + penalty_points_per_gib_over: 60, + }, + ), + ]), + ..self::policy() + }; + let episodes = 10; + let hd_pack = (43 * GIB) / 10; // Rick.and.Morty.S08.1080p.x265-ELiTE, 4.3 GiB + let uhd_pack = (138 * GIB) / 10; // S08 Complete 2160p HDR, 13.8 GiB + + let scored = |resolution, size| { + let claims = NameClaims { + resolution: Some(resolution), + source: Some(ClaimedSource::WebDl), + ..NameClaims::default() + }; + score(&policy, Candidate::PreGrab(&claims), size, 20, episodes) + }; + let hd = scored(ClaimedResolution::P1080, hd_pack); + let uhd = scored(ClaimedResolution::P2160, uhd_pack); + + // Neither pack trips the floor per episode, so the ranking decides. + assert_eq!( + is_below_floor(&policy, Resolution::R1080p, hd_pack, episodes), + Some(false) + ); + assert_eq!( + is_below_floor(&policy, Resolution::R2160p, uhd_pack, episodes), + Some(false) + ); + assert!(uhd.total > hd.total); + } } diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index 66b0eb9..d462a77 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -13,13 +13,14 @@ //! - the `grabs` row is written from that response, so a crash between the add //! and the insert heals on the next tick instead of leaving an orphan. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use arr_core::policy::{evaluate, Candidate}; -use arr_core::{score::score, Language, Policy, TitleOverrides, Verdict}; +use arr_core::score::{claimed_episode_count, score}; +use arr_core::{Language, Policy, TitleOverrides, Verdict}; use arr_db::{blacklist, Blacklist, Db, MoviePolicy}; use arr_dl::{AddTorrent, TorrentSource, TransmissionClient}; use arr_indexer::{Download, ProwlarrClient, SearchRelease, SearchRequest}; @@ -1198,6 +1199,7 @@ pub(crate) async fn store_release( overrides, original_language, blacklist, + 1, ) .await?; sqlx::query!( @@ -1224,6 +1226,21 @@ pub(crate) async fn store_episode_release( original_language: &Language, blacklist: &Blacklist, ) -> Result<(i64, Option), GrabError> { + // A size band describes one episode (`DESIGN.md` §5.5): the release is + // measured per episode, and a season pack's length comes from the series + // the covered episodes belong to. An unrevealed season divides by one. + let claim = arr_parse::parse(&release.name).episode; + let season_lengths = match episode_ids.first() { + Some(&episode_id) + if claim + .as_ref() + .is_some_and(arr_parse::EpisodeClaim::is_season_pack) => + { + season_lengths_of(database, episode_id).await? + } + _ => BTreeMap::new(), + }; + let episode_count = claimed_episode_count(claim.as_ref(), &season_lengths); let (release_id, eligible) = classify_and_store( database, release, @@ -1231,6 +1248,7 @@ pub(crate) async fn store_episode_release( overrides, original_language, blacklist, + episode_count, ) .await?; for episode_id in episode_ids { @@ -1246,6 +1264,35 @@ pub(crate) async fn store_episode_release( Ok((release_id, eligible)) } +/// Per-season episode counts for the series one covered episode belongs to +/// (`DESIGN.md` §5.5): the divisor data for season-pack normalisation. +async fn season_lengths_of( + database: &Db, + episode_id: i64, +) -> Result, GrabError> { + let rows = sqlx::query!( + r#"SELECT se.number AS "number!: i64", COUNT(e.id) AS "episodes!: i64" + FROM seasons se + LEFT JOIN episodes e ON e.season_id = se.id + WHERE se.series_id = (SELECT s2.series_id FROM episodes e2 + JOIN seasons s2 ON s2.id = e2.season_id + WHERE e2.id = ?) + GROUP BY se.number"#, + episode_id + ) + .fetch_all(database.pool()) + .await?; + Ok(rows + .into_iter() + .map(|row| { + ( + u32::try_from(row.number).unwrap_or_default(), + u32::try_from(row.episodes).unwrap_or_default(), + ) + }) + .collect()) +} + async fn classify_and_store( database: &Db, release: &SearchRelease, @@ -1253,6 +1300,7 @@ async fn classify_and_store( overrides: &TitleOverrides, original_language: &Language, blacklist: &Blacklist, + episode_count: u32, ) -> Result<(i64, Option), GrabError> { let parsed = arr_parse::parse(&release.name); let evaluation = evaluate( @@ -1261,12 +1309,14 @@ async fn classify_and_store( original_language, Candidate::PreGrab(&parsed), release.size, + episode_count, ); let scored = score( policy, Candidate::PreGrab(&parsed), release.size.unwrap_or_default(), release.seeders.unwrap_or_default(), + episode_count, ); // A release that did not say its size is not a tiny one: scoring it // against the band's floor would bury it. Same treatment as the manual diff --git a/crates/arr-daemon/src/import.rs b/crates/arr-daemon/src/import.rs index c55ef8d..c26dcd0 100644 --- a/crates/arr-daemon/src/import.rs +++ b/crates/arr-daemon/src/import.rs @@ -307,6 +307,7 @@ impl ImportAction { &original_language, Candidate::PostDownload(&feature.media), Some(feature.size), + 1, ); let waiver: Option = match evaluation.verdict { Verdict::Rejected(rule) => { @@ -494,6 +495,7 @@ impl ImportAction { &original_language, Candidate::PostDownload(&assignment.file.media), Some(assignment.file.size), + 1, ); let waiver = match evaluation.verdict { Verdict::Rejected(rule) => { diff --git a/crates/arr-db/src/policy.rs b/crates/arr-db/src/policy.rs index 5130f33..354d183 100644 --- a/crates/arr-db/src/policy.rs +++ b/crates/arr-db/src/policy.rs @@ -619,7 +619,12 @@ mod tests { let one_and_a_half_gib = 1536 << 20; assert_eq!( - arr_core::score::is_below_floor(&loaded.policy, Resolution::R1080p, one_and_a_half_gib), + arr_core::score::is_below_floor( + &loaded.policy, + Resolution::R1080p, + one_and_a_half_gib, + 1 + ), Some(false) ); } From 1e0387320941cd2611b759f7f6eea695ec7f64da Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 20:14:41 +0100 Subject: [PATCH 4/4] feat(web): name the season deck's real state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty season deck was three truths wearing one message, and the one it chose to blame was wrong: a season on the per-episode lane sat on "sweeping indexers…" for the full wait and then blamed a backoff for a pack search that was never going to run. `GET /api/series/{id}/seasons/{n}/pack-state` says which lane the season takes and why, from `season_grab_reason` in arr-core, plus the failed-pack tally and when #181's window reopens. Seasons gain `last_pack_search_at`, written only by a season-scoped sweep, so a pack search that ran and found nothing is a settled answer rather than a pending one. The deck then says the true thing in each case, and a season held off the pack lane by a failure offers the retry that waives its window. Refs #182 --- ...11d6a3ec1613976c52a8a7f8225e1dc06d162.json | 28 ++ ...67e30032a438c4815816323cf62896398d2a2.json | 33 +++ ...2c7c5618b25843192ffa28b9c13eacd0b9c26.json | 26 ++ ...41c2bfbbec508109bed2e7a460ffcc2b5cdf1.json | 12 + crates/arr-api/src/lib.rs | 5 + crates/arr-api/src/series.rs | 241 +++++++++++++++++- crates/arr-core/src/grabbing.rs | 145 ++++++++++- crates/arr-core/src/lib.rs | 5 +- crates/arr-daemon/src/tv_grab.rs | 66 +++++ .../migrations/0023_season_pack_search.sql | 6 + web/src/main.ts | 172 +++++++++++-- web/src/releases.ts | 61 +++++ web/src/series.ts | 16 +- web/src/style.css | 11 + 14 files changed, 782 insertions(+), 45 deletions(-) create mode 100644 .sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json create mode 100644 .sqlx/query-20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2.json create mode 100644 .sqlx/query-2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26.json create mode 100644 .sqlx/query-f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1.json create mode 100644 crates/arr-db/migrations/0023_season_pack_search.sql diff --git a/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json b/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json new file mode 100644 index 0000000..d9045ff --- /dev/null +++ b/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json @@ -0,0 +1,28 @@ +{ + "db_name": "SQLite", + "query": "SELECT count(*) AS \"failures!: i64\",\n max(grabbed_at) AS \"last_failed_at?: String\"\n FROM grabs\n WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'", + "describe": { + "columns": [ + { + "name": "failures!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "last_failed_at?: String", + "ordinal": 1, + "type_info": "Text", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162" +} diff --git a/.sqlx/query-20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2.json b/.sqlx/query-20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2.json new file mode 100644 index 0000000..00f73bd --- /dev/null +++ b/.sqlx/query-20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2.json @@ -0,0 +1,33 @@ +{ + "db_name": "SQLite", + "query": "SELECT e.air_date,\n EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n ) AS \"has_file!: bool\"\n FROM episodes e\n WHERE e.season_id = ?", + "describe": { + "columns": [ + { + "name": "air_date", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "episodes", + "name": "air_date" + } + } + }, + { + "name": "has_file!: bool", + "ordinal": 1, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false + ] + }, + "hash": "20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2" +} diff --git a/.sqlx/query-2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26.json b/.sqlx/query-2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26.json new file mode 100644 index 0000000..aeb8ac3 --- /dev/null +++ b/.sqlx/query-2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT last_pack_search_at FROM seasons WHERE id = ?", + "describe": { + "columns": [ + { + "name": "last_pack_search_at", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "seasons", + "name": "last_pack_search_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26" +} diff --git a/.sqlx/query-f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1.json b/.sqlx/query-f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1.json new file mode 100644 index 0000000..ac65f55 --- /dev/null +++ b/.sqlx/query-f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE seasons\n SET last_pack_search_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index ccd645d..1fd1f75 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -100,6 +100,7 @@ fn api_router() -> OpenApiRouter { .routes(routes!(series::grab_episode)) .routes(routes!(series::search_season)) .routes(routes!(series::season_releases)) + .routes(routes!(series::season_pack_state)) .routes(routes!(series::grab_season_release)) .routes(routes!(series::files)) .routes(routes!(metadata::series_metadata)) @@ -334,6 +335,10 @@ mod tests { "/api/series/{series_id}/seasons/{season_number}/releases", "get", ), + ( + "/api/series/{series_id}/seasons/{season_number}/pack-state", + "get", + ), ( "/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab", "post", diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index f7dd443..28ec456 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -18,8 +18,8 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason}; use arr_core::{ - derive_series_status, EpisodeId, Language, MediaState, RootId, SeasonId, SeriesId, - SeriesStatus, TitleOverrides, + derive_series_status, search_backoff, season_grab_reason, EpisodeId, Language, MediaState, + PerEpisodeReason, RootId, SeasonGrabFacts, SeasonId, SeriesId, SeriesStatus, TitleOverrides, }; use arr_db::policy::language; use axum::extract::{Path, Query, State}; @@ -1488,6 +1488,141 @@ pub async fn season_releases( Ok(Json(releases)) } +/// Which lane a season's missing episodes take (#182, §6.2). +#[derive(Debug, Clone, Copy, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum SeasonLane { + SeasonPack, + PerEpisode, +} + +/// What holds a season off the pack lane, in the order that answers the +/// operator's question best — the most fundamental reason first (#182). +#[derive(Debug, Clone, Copy, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PackLaneReason { + NoEpisodes, + StillAiring, + EpisodesOnDisk, + PackBackoff, +} + +/// Why the season release deck holds what it holds (#182). +/// +/// An empty deck is three different truths, and until this endpoint existed +/// the deck could not tell them apart, so it blamed backoff for all three: +/// a pack sweep that has not landed yet, a pack sweep that landed and found +/// nothing, and a season on the per-episode lane, for which no pack sweep is +/// coming at all. The lane says which; `last_pack_search_at` separates the +/// first two, exactly as a movie's `last_searched_at` does (#177). +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SeasonPackState { + pub lane: SeasonLane, + /// `None` when the season is on the pack lane. + pub reason: Option, + /// Failed pack grabs so far. §6.2's backoff step is indexed by this, so + /// it is also how long the lane stays quiet (#181). + pub pack_failures: i64, + /// When the pack lane reopens on its own, RFC3339. `None` unless a + /// failure's window is currently open. + pub pack_retry_at: Option, + /// When a pack sweep for this season last completed. `None` means no + /// pack search has ever run, so an empty deck is pending, not settled. + pub last_pack_search_at: Option, +} + +#[utoipa::path( + get, path = "/api/series/{series_id}/seasons/{season_number}/pack-state", tag = "series", + params( + ("series_id" = i64, Path, description = "Series row id"), + ("season_number" = i64, Path, description = "Season number, not its row id") + ), + responses( + (status = 200, body = SeasonPackState), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +pub async fn season_pack_state( + State(state): State, + Path((series_id, number)): Path<(i64, i64)>, +) -> Result, ApiError> { + load_series_row(&state, series_id).await?; + let season_id = load_season_id(&state, series_id, number).await?; + let pool = pool(&state)?; + + let episodes = sqlx::query!( + r#"SELECT e.air_date, + EXISTS ( + SELECT 1 FROM media_files f + WHERE f.owner_kind = 'episode' AND f.owner_id = e.id + ) AS "has_file!: bool" + FROM episodes e + WHERE e.season_id = ?"#, + season_id + ) + .fetch_all(pool) + .await?; + let last_pack_search_at = sqlx::query_scalar!( + "SELECT last_pack_search_at FROM seasons WHERE id = ?", + season_id + ) + .fetch_one(pool) + .await?; + // The same failed-pack tally the grab lane backs off on (#181), read + // here so the deck can name the window instead of guessing at one. + let failed = sqlx::query!( + r#"SELECT count(*) AS "failures!: i64", + max(grabbed_at) AS "last_failed_at?: String" + FROM grabs + WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#, + season_id + ) + .fetch_one(pool) + .await?; + + let window = chrono::TimeDelta::from_std(search_backoff(failed.failures)).unwrap_or_default(); + let reopens_at = if failed.failures > 0 { + failed + .last_failed_at + .as_deref() + .and_then(|at| DateTime::parse_from_rfc3339(at).ok()) + .map(|at| at.with_timezone(&Utc) + window) + } else { + None + }; + let pack_retry_at = reopens_at.filter(|at| *at > Utc::now()); + + let air_dates: Vec> = episodes + .iter() + .map(|episode| air_date(episode.air_date.as_deref())) + .collect(); + let reason = season_grab_reason(&SeasonGrabFacts { + air_dates: &air_dates, + now: SystemTime::now(), + any_episode_on_disk: episodes.iter().any(|episode| episode.has_file), + pack_backoff_active: pack_retry_at.is_some(), + }); + + Ok(Json(SeasonPackState { + lane: if reason.is_some() { + SeasonLane::PerEpisode + } else { + SeasonLane::SeasonPack + }, + reason: reason.map(|reason| match reason { + PerEpisodeReason::NoEpisodes => PackLaneReason::NoEpisodes, + PerEpisodeReason::StillAiring => PackLaneReason::StillAiring, + PerEpisodeReason::EpisodesOnDisk => PackLaneReason::EpisodesOnDisk, + PerEpisodeReason::PackBackoff => PackLaneReason::PackBackoff, + }), + pack_failures: failed.failures, + pack_retry_at: pack_retry_at.map(|at| at.to_rfc3339()), + last_pack_search_at, + })) +} + #[utoipa::path( post, path = "/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab", tag = "series", params(("series_id" = i64, Path), ("season_number" = i64, Path), ("release_id" = i64, Path)), @@ -2366,6 +2501,108 @@ mod tests { assert_eq!(after["status"], "complete"); } + /// #182: an empty season deck has to say which empty it is. The lane + /// answers "no pack is coming"; `last_pack_search_at` answers "one ran + /// and found nothing" — and until both existed the deck blamed backoff + /// for every case, including seasons that were never on the pack lane. + #[tokio::test] + async fn the_pack_state_names_why_a_season_deck_is_empty() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, false).await; + let series_id = series["id"].as_i64().expect("id"); + let season = add_season( + &base, + series_id, + 1, + serde_json::json!([ + {"number": 1, "title": "Pilot", "air_date": "2001-01-01"}, + {"number": 2, "title": "Second", "air_date": "2001-01-08"} + ]), + ) + .await; + let season_id = season["id"].as_i64().expect("season id"); + let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id"); + let pool = state.database().expect("database").pool(); + + let state_of = |url: String| async move { + let response = reqwest::get(url).await.expect("pack state"); + assert_eq!(response.status(), StatusCode::OK); + response + .json::() + .await + .expect("pack state json") + }; + let url = format!("{base}/api/series/{series_id}/seasons/1/pack-state"); + + let fresh = state_of(url.clone()).await; + assert_eq!(fresh["lane"], "season_pack"); + assert_eq!(fresh["reason"], serde_json::Value::Null); + assert_eq!(fresh["last_pack_search_at"], serde_json::Value::Null); + assert_eq!(fresh["pack_failures"], 0); + + // A pack sweep that ran turns an empty deck from pending to settled. + sqlx::query( + "UPDATE seasons SET last_pack_search_at = '2026-01-01T00:00:00.000Z' WHERE id = ?", + ) + .bind(season_id) + .execute(pool) + .await + .expect("stamp the sweep"); + let swept = state_of(url.clone()).await; + assert_eq!(swept["lane"], "season_pack"); + assert_eq!(swept["last_pack_search_at"], "2026-01-01T00:00:00.000Z"); + + // §6.2/#181: a failed pack quiets the lane, and the deck says until + // when rather than only that it is quiet. + let release_id = sqlx::query_scalar::<_, i64>( + "INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) + VALUES (7, 'pack', 'Bluey S01 1080p WEB-DL', 1000, 'url', '{}', 'eligible') + RETURNING id", + ) + .fetch_one(pool) + .await + .expect("release"); + sqlx::query( + "INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at) + VALUES (?, 'season', ?, 'hash', 'failed', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + ) + .bind(release_id) + .bind(season_id) + .execute(pool) + .await + .expect("failed pack grab"); + let quiet = state_of(url.clone()).await; + assert_eq!(quiet["lane"], "per_episode"); + assert_eq!(quiet["reason"], "pack_backoff"); + assert_eq!(quiet["pack_failures"], 1); + assert!( + quiet["pack_retry_at"].is_string(), + "the deck offers a date, not just a closed door" + ); + + // §14 outranks it: a pack would re-import what is on disk, so + // clearing the failure would not earn a pack anyway. + sqlx::query("INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, '/library/e01.mkv', 1)") + .bind(episode_id) + .execute(pool) + .await + .expect("file on disk"); + let on_disk = state_of(url.clone()).await; + assert_eq!(on_disk["reason"], "episodes_on_disk"); + + // An unaired episode outranks both. + sqlx::query( + "UPDATE episodes SET air_date = '2999-01-01' WHERE season_id = ? AND number = 2", + ) + .bind(season_id) + .execute(pool) + .await + .expect("unair the finale"); + let airing = state_of(url).await; + assert_eq!(airing["reason"], "still_airing"); + } + #[tokio::test] async fn manual_episode_actions_are_scoped_and_respect_blocked() { let (_dir, state, base) = application().await; diff --git a/crates/arr-core/src/grabbing.rs b/crates/arr-core/src/grabbing.rs index 0c5eb39..e6d5491 100644 --- a/crates/arr-core/src/grabbing.rs +++ b/crates/arr-core/src/grabbing.rs @@ -10,7 +10,7 @@ //! Re-grabbing a pack once an airing season completes is deliberately not //! done (§14): a season with any episode already on disk grabs per episode. -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; /// How a season's missing wanted episodes should be grabbed next. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -37,24 +37,75 @@ pub struct SeasonGrabFacts<'a> { pub pack_backoff_active: bool, } -/// Picks the grab mode for one season. +/// Why a season is not on the season-pack lane. +/// +/// The season release deck (§9.3) is empty for a season on the per-episode +/// lane and stays empty however long it waits, so it has to name which of +/// these it is rather than blaming a sweep that is not coming (#182). +/// Ordered by how fundamental the answer is: an unaired episode outranks a +/// failed pack, because clearing the failure would still not earn a pack. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PerEpisodeReason { + /// No episodes are known for the season, so there is nothing to pack. + NoEpisodes, + /// An episode has not aired, or carries no air date at all. + StillAiring, + /// §14: a pack would re-import episodes that are already on disk. + EpisodesOnDisk, + /// §6.2: a failed pack grab's backoff window is still open. + PackBackoff, +} + +/// Why one season takes the per-episode lane, or `None` when it takes the +/// pack lane. /// /// A season is fully released only when every known episode has an air date /// in the past. An episode with no date could still be unaired, and grabbing /// a "complete" pack of a season that is not complete costs a whole torrent /// of the wrong thing — so an undated episode keeps the season per-episode. #[must_use] -pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode { - let fully_released = !facts.air_dates.is_empty() - && facts - .air_dates - .iter() - .all(|date| date.is_some_and(|date| date <= facts.now)); +pub fn season_grab_reason(facts: &SeasonGrabFacts<'_>) -> Option { + if facts.air_dates.is_empty() { + return Some(PerEpisodeReason::NoEpisodes); + } + if !facts + .air_dates + .iter() + .all(|date| date.is_some_and(|date| date <= facts.now)) + { + return Some(PerEpisodeReason::StillAiring); + } + if facts.any_episode_on_disk { + return Some(PerEpisodeReason::EpisodesOnDisk); + } + if facts.pack_backoff_active { + return Some(PerEpisodeReason::PackBackoff); + } + None +} - if fully_released && !facts.any_episode_on_disk && !facts.pack_backoff_active { - SeasonGrabMode::SeasonPack - } else { - SeasonGrabMode::PerEpisode +/// Picks the grab mode for one season. +#[must_use] +pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode { + match season_grab_reason(facts) { + None => SeasonGrabMode::SeasonPack, + Some(_) => SeasonGrabMode::PerEpisode, + } +} + +/// §6.2's targeted-search curve: `1h → 6h → 1d → 3d`, capped at 7d, indexed +/// by how many attempts have already been spent. +/// +/// The pack lane counts a season's failed pack grabs as its attempts, so the +/// deck can say when the lane reopens rather than only that it is shut. +#[must_use] +pub fn search_backoff(attempts: i64) -> Duration { + match attempts { + ..=1 => Duration::from_hours(1), + 2 => Duration::from_hours(6), + 3 => Duration::from_hours(24), + 4 => Duration::from_hours(72), + _ => Duration::from_hours(24 * 7), } } @@ -122,4 +173,74 @@ mod tests { facts.any_episode_on_disk = true; assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode); } + + /// #182: the deck says which of the four it is, not just "not a pack". + #[test] + fn the_reason_names_the_condition_that_holds_the_pack_lane_shut() { + assert_eq!( + season_grab_reason(&facts(&[])), + Some(PerEpisodeReason::NoEpisodes) + ); + + let airing = [ + Some(SystemTime::UNIX_EPOCH + 10 * DAY), + Some(SystemTime::UNIX_EPOCH + 110 * DAY), + ]; + assert_eq!( + season_grab_reason(&facts(&airing)), + Some(PerEpisodeReason::StillAiring) + ); + let undated = [Some(SystemTime::UNIX_EPOCH + 10 * DAY), None]; + assert_eq!( + season_grab_reason(&facts(&undated)), + Some(PerEpisodeReason::StillAiring) + ); + + let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)]; + let mut on_disk = facts(&aired); + on_disk.any_episode_on_disk = true; + assert_eq!( + season_grab_reason(&on_disk), + Some(PerEpisodeReason::EpisodesOnDisk) + ); + + let mut backoff = facts(&aired); + backoff.pack_backoff_active = true; + assert_eq!( + season_grab_reason(&backoff), + Some(PerEpisodeReason::PackBackoff) + ); + + assert_eq!(season_grab_reason(&facts(&aired)), None); + } + + /// A failed pack is not the headline when the season could not have had + /// a pack anyway — clearing it would change nothing. + #[test] + fn an_unaired_episode_outranks_a_failed_pack() { + let airing = [ + Some(SystemTime::UNIX_EPOCH + 10 * DAY), + Some(SystemTime::UNIX_EPOCH + 110 * DAY), + ]; + let mut facts = facts(&airing); + facts.pack_backoff_active = true; + facts.any_episode_on_disk = true; + assert_eq!( + season_grab_reason(&facts), + Some(PerEpisodeReason::StillAiring) + ); + } + + /// §6.2's curve, shared by the pack lane so the deck can say when it + /// reopens rather than only that it is shut. + #[test] + fn the_backoff_curve_climbs_and_caps_at_a_week() { + assert_eq!(search_backoff(0), Duration::from_hours(1)); + assert_eq!(search_backoff(1), Duration::from_hours(1)); + assert_eq!(search_backoff(2), Duration::from_hours(6)); + assert_eq!(search_backoff(3), DAY); + assert_eq!(search_backoff(4), 3 * DAY); + assert_eq!(search_backoff(5), 7 * DAY); + assert_eq!(search_backoff(50), 7 * DAY); + } } diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 4c5abde..4421f0f 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -14,7 +14,10 @@ pub mod status; pub mod tracking; pub use arr_parse::NameClaims as ParsedRelease; -pub use grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode}; +pub use grabbing::{ + search_backoff, season_grab_mode, season_grab_reason, PerEpisodeReason, SeasonGrabFacts, + SeasonGrabMode, +}; pub use matching::{ match_episode, match_movie, EpisodeMatch, MatchKind, MatchShape, MovieMatch, ReleaseIds, WantedEpisode, WantedMovie, diff --git a/crates/arr-daemon/src/tv_grab.rs b/crates/arr-daemon/src/tv_grab.rs index a146857..e3fc68b 100644 --- a/crates/arr-daemon/src/tv_grab.rs +++ b/crates/arr-daemon/src/tv_grab.rs @@ -374,6 +374,10 @@ impl TvGrabAction { original_language: &Language, blacklist: &Blacklist, ) -> Result, GrabError> { + // #182: a season-scoped sweep is the pack lane's own search, and the + // release deck needs to know it completed to tell "nothing found" + // from "nothing has run yet". + let pack_sweep = matches!(selector, TvSelector::Season { .. }); let target = TvTarget { tvdb_id: season.series_tvdb_id.and_then(|id| u64::try_from(id).ok()), title: season.series_title.clone(), @@ -451,6 +455,9 @@ impl TvGrabAction { eligible: stored, }); } + if pack_sweep && any_searchable { + record_pack_search(database, season.season_id).await?; + } Ok(candidates) } @@ -1113,6 +1120,22 @@ async fn load_season_release( })) } +/// When the season's own pack search last completed (#182). Written only by +/// a season-scoped sweep, so it answers the deck's question — did a pack +/// search run — and nothing else. +async fn record_pack_search(database: &Db, season_id: i64) -> Result<(), GrabError> { + sqlx::query!( + "UPDATE seasons + SET last_pack_search_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ?", + season_id + ) + .execute(database.pool()) + .await?; + Ok(()) +} + /// Whether failed season-pack grabs still hold this season off the pack /// lane. §6.2: a failure quiets the pack search on the shared backoff curve /// (each failed grab is one attempt), it never disables it. Anchored on the @@ -1648,6 +1671,49 @@ mod tests { assert!(sources[1].ends_with("e03.torrent")); } + /// #182: the season deck cannot tell a queued pack sweep from one that + /// ran and found nothing unless the pack search leaves a mark. Episode + /// searches must not leave it — a season on the per-episode lane never + /// asked for a pack, and pretending otherwise is the lie the deck told. + #[tokio::test] + async fn only_a_pack_sweep_stamps_the_season() { + let (_dir, database, season_id) = + wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await; + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + + let stamp = |database: Db| async move { + sqlx::query_scalar::<_, Option>( + "SELECT last_pack_search_at FROM seasons WHERE id = ?", + ) + .bind(season_id) + .fetch_one(database.pool()) + .await + .unwrap() + }; + assert!(stamp(database.clone()).await.is_none()); + + action(&indexer, &downloader).tick(&database).await.unwrap(); + let after_pack = stamp(database.clone()).await; + assert!( + after_pack.is_some(), + "a fully released season takes the pack lane and its sweep is the deck's evidence" + ); + assert_eq!(fake.torrents().len(), 1); + + // An airing season is on the per-episode lane: its episode searches + // say nothing about whether a pack was ever looked for. + let (_other_dir, airing, airing_id) = wanted_season(&["2024-04-11", "2999-01-01"]).await; + action(&indexer, &downloader).tick(&airing).await.unwrap(); + let never: Option = + sqlx::query_scalar("SELECT last_pack_search_at FROM seasons WHERE id = ?") + .bind(airing_id) + .fetch_one(airing.pool()) + .await + .unwrap(); + assert!(never.is_none()); + } + /// An empty result backs the whole season off together (§6.2) instead of /// hammering the tracker every 30 s. #[tokio::test] diff --git a/crates/arr-db/migrations/0023_season_pack_search.sql b/crates/arr-db/migrations/0023_season_pack_search.sql new file mode 100644 index 0000000..a6d14f1 --- /dev/null +++ b/crates/arr-db/migrations/0023_season_pack_search.sql @@ -0,0 +1,6 @@ +-- #182. The season release deck cannot tell a queued pack sweep from one +-- that ran and found nothing, so it blames backoff for both. Only the season +-- knows when its own pack search last completed: episodes' `last_searched_at` +-- moves for reasons that have nothing to do with the pack lane, and a season +-- on the per-episode lane never touches the pack search at all. +ALTER TABLE seasons ADD COLUMN last_pack_search_at TEXT; diff --git a/web/src/main.ts b/web/src/main.ts index cd15fbd..cb3714b 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -38,6 +38,7 @@ import { formatAudio, formatHdr, formatResolution, + formatRetryWait, formatScore, formatSeeders, formatSize, @@ -53,6 +54,7 @@ import { queueSearch, removeMovie, ruleLabel, + type SeasonPackState, sweepExpected, totalSize, type WaiveOutcome, @@ -2799,6 +2801,24 @@ function tvReleasesMain(): TvReleasesView { } else { delete statusEl.dataset.tone; } + delete statusEl.dataset.action; + } + + /** + * A status that carries its own way out. A season held off the pack lane + * cannot be helped by the head's re-search alone (#181, #182), so the + * sentence that explains the wait also offers the retry that ends it. + */ + function setStatusAction(text: string, label: string, run: () => void) { + const action = document.createElement("button"); + action.type = "button"; + action.className = "control"; + action.textContent = label; + action.addEventListener("click", run); + statusEl.hidden = false; + statusEl.replaceChildren(document.createTextNode(text), action); + delete statusEl.dataset.tone; + statusEl.dataset.action = ""; } const actions: ReleaseActions = { @@ -2841,23 +2861,93 @@ function tvReleasesMain(): TvReleasesView { return; } if (!paintBuckets(dom, outcome.releases, actions)) { - // issue 167: an empty deck sweeps on open instead of describing a sweep - if (sweepIfEmpty) { - startSweep(current); - } else { - setStatus("no releases indexed yet — re-search queues a targeted sweep"); - } + clearBuckets(dom); + await emptyVerdict(current, ticket, sweepIfEmpty); return; } setStatus(null); } /** - * A queued sweep is done when its releases appear in the table. Nothing - * found is indistinguishable from still running, so the wait says so - * honestly instead of promising either way. + * An empty season deck is three different truths (#182), and until the + * season could say which, it blamed backoff for all three: a pack sweep + * still running, a pack sweep that ran and found nothing, and a season on + * the per-episode lane, where no pack sweep is coming at all. The lane + * answers the third; `last_pack_search_at` separates the first two, the + * same way a movie's `last_searched_at` does (#177). */ - function watchSweep(current: TvDeckRequest, ticket: number) { + async function emptyVerdict(current: TvDeckRequest, ticket: number, sweepIfEmpty: boolean) { + const outcome = await current.target.packState?.(); + if (ticket !== sequence || request !== current) { + return; + } + if (outcome?.kind === "state" && outcome.state.lane === "per_episode") { + sweep.disabled = false; + describePerEpisode(current, outcome.state); + return; + } + if (outcome?.kind === "state" && outcome.state.last_pack_search_at !== null) { + sweep.disabled = false; + setStatus( + `no season pack found — indexers last swept ${formatSweepAge(outcome.state.last_pack_search_at)}; re-search runs a new one`, + ); + return; + } + // issue 167: an empty deck sweeps on open instead of describing a sweep + if (sweepIfEmpty) { + void startSweep(current, outcome?.kind === "state" ? outcome.state : null); + return; + } + setStatus("no releases indexed yet — re-search queues a targeted sweep"); + } + + /** + * Name the lane instead of the backoff. A season grabbing episode by + * episode has no pack to show and never will while the reason holds, so + * the deck says which reason it is and where the releases actually are. + */ + function describePerEpisode(current: TvDeckRequest, state: SeasonPackState) { + const elsewhere = "open an episode for its releases"; + if (state.reason === "no_episodes") { + setStatus( + "no episodes known for this season yet — a metadata refresh has to find them before anything can be searched", + ); + return; + } + if (state.reason === "still_airing") { + setStatus( + `season still airing — a pack is only searched once every episode has aired, so this one is grabbed episode by episode; ${elsewhere}`, + ); + return; + } + if (state.reason === "episodes_on_disk") { + setStatus( + `episodes already on disk — a pack would re-import them, so the rest is grabbed episode by episode; ${elsewhere}`, + ); + return; + } + const failures = + state.pack_failures === 1 ? "1 failed pack grab" : `${state.pack_failures} failed pack grabs`; + const quiet = + state.pack_retry_at === null + ? "pack search is quiet until its backoff elapses" + : `pack search is quiet for another ${formatRetryWait(state.pack_retry_at)}, then retries on its own`; + setStatusAction( + `${failures} — ${quiet}. episodes are grabbed one at a time meanwhile.`, + "retry the pack now", + () => { + void startSweep(current, state); + }, + ); + } + + /** + * A sweep is done when its releases appear, or when the season stamps the + * pack search it just finished. `baseline` is that stamp as it read before + * the sweep was queued: once it moves, an empty deck is a settled answer + * rather than a pending one, and `load` says so. + */ + function watchSweep(current: TvDeckRequest, baseline: string | null, ticket: number) { sweep.disabled = true; setStatus("sweeping indexers…", undefined, true); const deadline = Date.now() + TV_SWEEP_WAIT_MS; @@ -2875,10 +2965,19 @@ function tvReleasesMain(): TvReleasesView { setStatus(null); return; } + const state = await current.target.packState?.(); + if (ticket !== sequence || request !== current) { + return; + } + if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) { + sweep.disabled = false; + await emptyVerdict(current, ticket, false); + return; + } if (Date.now() >= deadline) { sweep.disabled = false; setStatus( - "sweep has not landed yet — it may be waiting out its backoff; results appear here once it runs", + "sweep has not landed yet — nothing has come back from the indexers; results appear here when it does", ); return; } @@ -2891,23 +2990,40 @@ function tvReleasesMain(): TvReleasesView { }, TV_SWEEP_POLL_MS); } - /** Queue a targeted sweep, then watch for its releases to land (§6.2). */ - function startSweep(current: TvDeckRequest) { + /** + * Queue a targeted sweep, then watch for it to land (§6.2). `known` is the + * lane as it read a moment ago, so the watch is only entered when a pack + * sweep is actually expected: on the per-episode lane the sweep searches + * episodes, and waiting for a pack that is not coming is the lie #182 is + * about. A failed pack is the exception — a manual search waives its + * window and does try a pack (#181). + */ + async function startSweep(current: TvDeckRequest, known?: SeasonPackState | null) { sweep.disabled = true; - void (async () => { - const outcome = await current.target.search(); - if (request !== current) { - return; - } - if (outcome.kind === "error") { - sweep.disabled = false; - setStatus(`search failed — ${outcome.detail}`, "fault"); - return; - } - sequence += 1; - window.clearTimeout(pollTimer); - watchSweep(current, sequence); - })(); + const before = + known === undefined + ? await current.target.packState?.().then((it) => (it.kind === "state" ? it.state : null)) + : known; + if (request !== current) { + return; + } + const outcome = await current.target.search(); + if (request !== current) { + return; + } + sequence += 1; + window.clearTimeout(pollTimer); + if (outcome.kind === "error") { + sweep.disabled = false; + setStatus(`search failed — ${outcome.detail}`, "fault"); + return; + } + if (before && before.lane === "per_episode" && before.reason !== "pack_backoff") { + sweep.disabled = false; + setStatus("searching the season's episodes — open an episode for its releases"); + return; + } + watchSweep(current, before?.last_pack_search_at ?? null, sequence); } sweep.addEventListener("click", () => { @@ -2915,7 +3031,7 @@ function tvReleasesMain(): TvReleasesView { if (!current) { return; } - startSweep(current); + void startSweep(current); }); function open(next: TvDeckRequest) { diff --git a/web/src/releases.ts b/web/src/releases.ts index 2e74b21..527bc48 100644 --- a/web/src/releases.ts +++ b/web/src/releases.ts @@ -95,6 +95,67 @@ export function formatSweepAge(lastSearchedAt: string, now = Date.now()): string return `${Math.round(minutes / (24 * 60))} d ago`; } +/** Which lane a season's missing episodes take (#182, §6.2). */ +export type SeasonLane = "season_pack" | "per_episode"; + +/** What holds a season off the pack lane (#182). */ +export type PackLaneReason = "no_episodes" | "still_airing" | "episodes_on_disk" | "pack_backoff"; + +/** + * Why the season deck holds what it holds, from + * `/api/series/{id}/seasons/{n}/pack-state`. An empty deck is three states, + * not one: a pack sweep still running, a pack sweep that found nothing, and + * a season on the per-episode lane, where no pack sweep is coming. + */ +export interface SeasonPackState { + lane: SeasonLane; + reason: PackLaneReason | null; + pack_failures: number; + pack_retry_at: string | null; + last_pack_search_at: string | null; +} + +export type PackStateOutcome = + | { kind: "state"; state: SeasonPackState } + | { kind: "error"; detail: string }; + +export async function seasonPackState( + seriesId: number, + seasonNumber: number, +): Promise { + try { + const response = await fetch(`/api/series/${seriesId}/seasons/${seasonNumber}/pack-state`); + if (!response.ok) { + return { kind: "error", detail: await errorDetail(response) }; + } + return { kind: "state", state: (await response.json()) as SeasonPackState }; + } catch { + return { kind: "error", detail: "daemon unreachable" }; + } +} + +/** + * `pack_retry_at` as a coarse wait. The deck says how long the lane stays + * quiet, so "it retries on its own" is a promise with a date on it. + */ +export function formatRetryWait(retryAt: string, now = Date.now()): string { + const reopens = Date.parse(retryAt); + if (Number.isNaN(reopens)) { + return "later"; + } + const minutes = Math.round((reopens - now) / 60_000); + if (minutes < 2) { + return "any moment"; + } + if (minutes < 60) { + return `${minutes} min`; + } + if (minutes < 48 * 60) { + return `${Math.round(minutes / 60)} h`; + } + return `${Math.round(minutes / (24 * 60))} d`; +} + /** One library file as `/api/movies/{id}/files` reports it (§5.6, §5.7). */ export interface MovieFile { id: number; diff --git a/web/src/series.ts b/web/src/series.ts index 6d1deeb..0c5d63c 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -3,8 +3,14 @@ // (src/api/) is uncommitted, so CI's tsc cannot see it. import type { MetadataTrailer } from "./movie"; -import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases"; -import { errorDetail, probedAttributeTags, waiverOverride } from "./releases"; +import type { + ActionOutcome, + MovieRelease, + PackStateOutcome, + ReleasesOutcome, + WaiveOutcome, +} from "./releases"; +import { errorDetail, probedAttributeTags, seasonPackState, waiverOverride } from "./releases"; /** §4.2 derived status — displayed, never editable. */ export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended"; @@ -233,6 +239,11 @@ export interface TvTarget { releases: () => Promise; grab: (releaseId: number) => Promise; search: () => Promise; + /** + * Why the deck is empty (#182). Seasons only: an episode deck has one + * lane, so it has nothing to disambiguate. + */ + packState?: () => Promise; } export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget { @@ -241,6 +252,7 @@ export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget { releases: () => fetchJson(`${base}/releases`), grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`), search: () => post(`${base}/search`), + packState: () => seasonPackState(seriesId, seasonNumber), }; } diff --git a/web/src/style.css b/web/src/style.css index f44f7ca..8a0ca95 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -537,6 +537,17 @@ body { color: var(--signal-fault); } +/* a status that carries its own way out keeps the control on the sentence's + own left edge, so the explanation reads first and the action follows it + (#182). Long enough copy wraps the control to its own line, which is the + reading order anyway. */ +.deck-status[data-action] { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); +} + /* a sweep in flight borrows the rail's probing-lamp idiom, inline */ .deck-status .lamp { display: inline-block;