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]