@@ -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",
|
||||
|
||||
@@ -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};
|
||||
@@ -1518,6 +1518,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)),
|
||||
@@ -2396,6 +2531,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;
|
||||
|
||||
Reference in New Issue
Block a user