fix(daemon): retry season packs after backoff
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 <noreply@anthropic.com>
This commit is contained in:
-21
@@ -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"
|
|
||||||
}
|
|
||||||
+14
-2
@@ -6,12 +6,24 @@
|
|||||||
{
|
{
|
||||||
"name": "tmdb_id!: i64",
|
"name": "tmdb_id!: i64",
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "movies",
|
||||||
|
"name": "tmdb_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "metadata_refreshed_at",
|
"name": "metadata_refreshed_at",
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"type_info": "Text"
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "movies",
|
||||||
|
"name": "metadata_refreshed_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+91
-13
@@ -6,67 +6,145 @@
|
|||||||
{
|
{
|
||||||
"name": "id!: i64",
|
"name": "id!: i64",
|
||||||
"ordinal": 0,
|
"ordinal": 0,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "id"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tmdb_id!: i64",
|
"name": "tmdb_id!: i64",
|
||||||
"ordinal": 1,
|
"ordinal": 1,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "tmdb_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tvdb_id",
|
"name": "tvdb_id",
|
||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "tvdb_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "title!: String",
|
"name": "title!: String",
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"type_info": "Text"
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "title"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "year",
|
"name": "year",
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "year"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "original_language",
|
"name": "original_language",
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"type_info": "Text"
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "original_language"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "root_id!: i64",
|
"name": "root_id!: i64",
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "root_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "auto_track!: bool",
|
"name": "auto_track!: bool",
|
||||||
"ordinal": 7,
|
"ordinal": 7,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "auto_track"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "upstream_ended!: bool",
|
"name": "upstream_ended!: bool",
|
||||||
"ordinal": 8,
|
"ordinal": 8,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "upstream_ended"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "metadata_refreshed_at",
|
"name": "metadata_refreshed_at",
|
||||||
"ordinal": 9,
|
"ordinal": 9,
|
||||||
"type_info": "Text"
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "metadata_refreshed_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "poster_path",
|
"name": "poster_path",
|
||||||
"ordinal": 10,
|
"ordinal": 10,
|
||||||
"type_info": "Text"
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "poster_path"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "backdrop_path",
|
"name": "backdrop_path",
|
||||||
"ordinal": 11,
|
"ordinal": 11,
|
||||||
"type_info": "Text"
|
"type_info": "Text",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "backdrop_path"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "vote_average",
|
"name": "vote_average",
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"type_info": "Float"
|
"type_info": "Float",
|
||||||
|
"origin": {
|
||||||
|
"Table": {
|
||||||
|
"table": "series",
|
||||||
|
"name": "vote_average"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
|
|||||||
+28
@@ -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"
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
//! while a season is airing, grab per episode. A completed season with no
|
//! while a season is airing, grab per episode. A completed season with no
|
||||||
//! episodes on disk prefers the pack — one torrent, better seeded, consistent
|
//! 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
|
//! 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
|
//! 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.
|
//! 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
|
/// re-grabs a pack over episodes on disk, and a pack must not re-import
|
||||||
/// what exists).
|
/// what exists).
|
||||||
pub any_episode_on_disk: bool,
|
pub any_episode_on_disk: bool,
|
||||||
/// Whether a season-pack grab for this season already hard-failed.
|
/// Whether a failed season-pack grab still holds the season off the
|
||||||
pub pack_hard_failed: bool,
|
/// pack lane — true only while the §6.2 backoff window is open.
|
||||||
|
pub pack_backoff_active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Picks the grab mode for one season.
|
/// Picks the grab mode for one season.
|
||||||
@@ -49,7 +51,7 @@ pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode {
|
|||||||
.iter()
|
.iter()
|
||||||
.all(|date| date.is_some_and(|date| date <= facts.now));
|
.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
|
SeasonGrabMode::SeasonPack
|
||||||
} else {
|
} else {
|
||||||
SeasonGrabMode::PerEpisode
|
SeasonGrabMode::PerEpisode
|
||||||
@@ -69,7 +71,7 @@ mod tests {
|
|||||||
air_dates,
|
air_dates,
|
||||||
now: SystemTime::UNIX_EPOCH + 100 * DAY,
|
now: SystemTime::UNIX_EPOCH + 100 * DAY,
|
||||||
any_episode_on_disk: false,
|
any_episode_on_disk: false,
|
||||||
pack_hard_failed: false,
|
pack_backoff_active: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +107,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
|
||||||
let mut facts = facts(&aired);
|
let mut facts = facts(&aired);
|
||||||
facts.pack_hard_failed = true;
|
facts.pack_backoff_active = true;
|
||||||
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
|
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ use arr_dl::TransmissionClient;
|
|||||||
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
|
use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest};
|
||||||
|
|
||||||
use crate::grab::{
|
use crate::grab::{
|
||||||
store_episode_release, store_release, Eligible, GrabError, GrabScope, GrabTarget, Grabber,
|
backoff_elapsed, store_episode_release, store_release, Eligible, GrabError, GrabScope,
|
||||||
SeedingRules,
|
GrabTarget, Grabber, SeedingRules,
|
||||||
};
|
};
|
||||||
use crate::indexers::IndexerDirectory;
|
use crate::indexers::IndexerDirectory;
|
||||||
use crate::reconcile::{Action, ActionFuture, Outcome};
|
use crate::reconcile::{Action, ActionFuture, Outcome};
|
||||||
@@ -625,11 +625,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
|
|||||||
)
|
)
|
||||||
.fetch_all(database.pool())
|
.fetch_all(database.pool())
|
||||||
.await?;
|
.await?;
|
||||||
let pack_hard_failed = sqlx::query_scalar!(
|
let failed_packs = sqlx::query!(
|
||||||
r#"SELECT EXISTS (
|
r#"SELECT count(*) AS "failures!: i64",
|
||||||
SELECT 1 FROM grabs
|
max(grabbed_at) AS "last_failed_at?: String"
|
||||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'
|
FROM grabs
|
||||||
) AS "failed!: bool""#,
|
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||||
season_id
|
season_id
|
||||||
)
|
)
|
||||||
.fetch_one(database.pool())
|
.fetch_one(database.pool())
|
||||||
@@ -642,7 +642,11 @@ async fn pack_allowed(database: &Db, season_id: i64) -> Result<bool, GrabError>
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
now: std::time::SystemTime::now(),
|
now: std::time::SystemTime::now(),
|
||||||
any_episode_on_disk: episodes.iter().any(|episode| episode.on_disk),
|
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)
|
}) == SeasonGrabMode::SeasonPack)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ impl TvGrabAction {
|
|||||||
&season,
|
&season,
|
||||||
&blacklist,
|
&blacklist,
|
||||||
EPISODE_SEARCHES_PER_TICK - episode_searches,
|
EPISODE_SEARCHES_PER_TICK - episode_searches,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -102,12 +103,15 @@ impl TvGrabAction {
|
|||||||
|
|
||||||
/// Search and grab for one season. `None` means the season was skipped
|
/// Search and grab for one season. `None` means the season was skipped
|
||||||
/// without spending an indexer call — nothing due, or no language yet.
|
/// 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(
|
async fn grab_season(
|
||||||
&self,
|
&self,
|
||||||
database: &Db,
|
database: &Db,
|
||||||
season: &PendingSeason,
|
season: &PendingSeason,
|
||||||
blacklist: &Blacklist,
|
blacklist: &Blacklist,
|
||||||
episode_budget: usize,
|
episode_budget: usize,
|
||||||
|
ignore_pack_backoff: bool,
|
||||||
) -> Result<Option<SeasonWork>, GrabError> {
|
) -> Result<Option<SeasonWork>, GrabError> {
|
||||||
// §5.2: without the title's original language there is nothing to
|
// §5.2: without the title's original language there is nothing to
|
||||||
// evaluate a release against, and guessing is worse than waiting.
|
// evaluate a release against, and guessing is worse than waiting.
|
||||||
@@ -141,7 +145,8 @@ impl TvGrabAction {
|
|||||||
air_dates: &air_dates,
|
air_dates: &air_dates,
|
||||||
now,
|
now,
|
||||||
any_episode_on_disk: episodes.iter().any(|episode| episode.has_file),
|
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
|
let aired_due_gaps: Vec<&SeasonEpisode> = gaps
|
||||||
@@ -566,8 +571,10 @@ impl TvGrabAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The manual season trigger (issue #125's deck, drained per #132):
|
/// 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
|
/// reset every open episode's backoff, waive the failed-pack backoff
|
||||||
/// lane as the tick, scoped to this one season with no tick budgets.
|
/// (§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 —
|
/// A satisfied season — nothing open, or a grab already in flight —
|
||||||
/// gets its deck refreshed and no grab; a blocked series refuses.
|
/// gets its deck refreshed and no grab; a blocked series refuses.
|
||||||
@@ -625,7 +632,7 @@ impl TvGrabAction {
|
|||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
self.grab_season(database, &season, &blacklist, usize::MAX)
|
self.grab_season(database, &season, &blacklist, usize::MAX, true)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1106,19 +1113,21 @@ async fn load_season_release(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a season-pack grab for this season already hard-failed — the
|
/// Whether failed season-pack grabs still hold this season off the pack
|
||||||
/// fall-back-to-per-episode signal.
|
/// lane. §6.2: a failure quiets the pack search on the shared backoff curve
|
||||||
async fn pack_hard_failed(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
/// (each failed grab is one attempt), it never disables it. Anchored on the
|
||||||
let failed = sqlx::query_scalar!(
|
/// latest failed grab's `grabbed_at` — failure time itself is not recorded.
|
||||||
r#"SELECT EXISTS (
|
async fn pack_backoff_active(database: &Db, season_id: i64) -> Result<bool, GrabError> {
|
||||||
SELECT 1 FROM grabs
|
let row = sqlx::query!(
|
||||||
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'
|
r#"SELECT count(*) AS "failures!: i64",
|
||||||
) AS "failed!: bool""#,
|
max(grabbed_at) AS "last_failed_at?: String"
|
||||||
|
FROM grabs
|
||||||
|
WHERE target_kind = 'season' AND target_id = ? AND state = 'failed'"#,
|
||||||
season_id
|
season_id
|
||||||
)
|
)
|
||||||
.fetch_one(database.pool())
|
.fetch_one(database.pool())
|
||||||
.await?;
|
.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
|
/// 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
|
/// The third acceptance case, selection side: a hard-failed pack put the
|
||||||
/// release on the blacklist and the season falls back to per-episode —
|
/// 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.
|
/// the pack is not tried again and the episodes are not written off.
|
||||||
#[tokio::test]
|
/// Seed what the import tick leaves behind after a pack fails: one
|
||||||
async fn a_hard_failed_pack_falls_back_to_per_episode() {
|
/// `failed` season grab per (infohash, age) pair.
|
||||||
let (_dir, database, season_id) =
|
async fn failed_packs(database: &Db, season_id: i64, ages: &[&str]) {
|
||||||
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).
|
|
||||||
let release_id: i64 = sqlx::query_scalar(
|
let release_id: i64 = sqlx::query_scalar(
|
||||||
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
"INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict)
|
||||||
VALUES (7, 'pack', 'Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos', 85899345920,
|
VALUES (7, 'oldpack', 'Fallout.S01.2160p.WEB-DL.OLD', 85899345920,
|
||||||
'https://tracker/pack.torrent', '{}', 'eligible')
|
'https://tracker/oldpack.torrent', '{}', 'eligible')
|
||||||
RETURNING id",
|
RETURNING id",
|
||||||
)
|
)
|
||||||
.fetch_one(database.pool())
|
.fetch_one(database.pool())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
sqlx::query(
|
for (index, age) in ages.iter().enumerate() {
|
||||||
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state)
|
sqlx::query(
|
||||||
VALUES (?, 'season', ?, 'dead', 'failed')",
|
"INSERT INTO grabs (release_id, target_kind, target_id, infohash, state, grabbed_at)
|
||||||
)
|
VALUES (?, 'season', ?, ?, 'failed',
|
||||||
.bind(release_id)
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?))",
|
||||||
.bind(season_id)
|
)
|
||||||
.execute(database.pool())
|
.bind(release_id)
|
||||||
.await
|
.bind(season_id)
|
||||||
.unwrap();
|
.bind(format!("dead{index}"))
|
||||||
arr_db::blacklist::add(
|
.bind(age)
|
||||||
database.pool(),
|
.execute(database.pool())
|
||||||
Some("dead"),
|
.await
|
||||||
"Fallout.S01.2160p.WEB-DL.DDP5.1.Atmos",
|
.unwrap();
|
||||||
"dolby_vision_profile",
|
}
|
||||||
)
|
}
|
||||||
.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 indexer = prowlarr().await;
|
||||||
let (downloader, fake) = transmission().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,
|
/// §14: a season with an episode already on disk never grabs the pack,
|
||||||
/// even when fully released. The remaining episodes come individually.
|
/// even when fully released. The remaining episodes come individually.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
Reference in New Issue
Block a user