feat(web): name the season deck's real state

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
This commit is contained in:
Miguel Palhas
2026-08-24 20:14:41 +01:00
parent dc6c25f582
commit 1e03873209
14 changed files with 782 additions and 45 deletions
+66
View File
@@ -374,6 +374,10 @@ impl TvGrabAction {
original_language: &Language,
blacklist: &Blacklist,
) -> Result<Vec<TvCandidate>, 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<String>>(
"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<String> =
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]