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
+5
View File
@@ -100,6 +100,7 @@ fn api_router() -> OpenApiRouter<AppState> {
.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",
+239 -2
View File
@@ -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<PackLaneReason>,
/// 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<String>,
/// 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<String>,
}
#[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<AppState>,
Path((series_id, number)): Path<(i64, i64)>,
) -> Result<Json<SeasonPackState>, 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<Option<SystemTime>> = 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::<serde_json::Value>()
.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;