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
@@ -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": "0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162"
}
@@ -0,0 +1,33 @@
{
"db_name": "SQLite",
"query": "SELECT e.air_date,\n EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n ) AS \"has_file!: bool\"\n FROM episodes e\n WHERE e.season_id = ?",
"describe": {
"columns": [
{
"name": "air_date",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "episodes",
"name": "air_date"
}
}
},
{
"name": "has_file!: bool",
"ordinal": 1,
"type_info": "Integer",
"origin": "Expression"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false
]
},
"hash": "20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT last_pack_search_at FROM seasons WHERE id = ?",
"describe": {
"columns": [
{
"name": "last_pack_search_at",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "seasons",
"name": "last_pack_search_at"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE seasons\n SET last_pack_search_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1"
}
+5
View File
@@ -100,6 +100,7 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(series::grab_episode)) .routes(routes!(series::grab_episode))
.routes(routes!(series::search_season)) .routes(routes!(series::search_season))
.routes(routes!(series::season_releases)) .routes(routes!(series::season_releases))
.routes(routes!(series::season_pack_state))
.routes(routes!(series::grab_season_release)) .routes(routes!(series::grab_season_release))
.routes(routes!(series::files)) .routes(routes!(series::files))
.routes(routes!(metadata::series_metadata)) .routes(routes!(metadata::series_metadata))
@@ -334,6 +335,10 @@ mod tests {
"/api/series/{series_id}/seasons/{season_number}/releases", "/api/series/{series_id}/seasons/{season_number}/releases",
"get", "get",
), ),
(
"/api/series/{series_id}/seasons/{season_number}/pack-state",
"get",
),
( (
"/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab", "/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab",
"post", "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::tracking::{apply_auto_track, apply_tracked, RefreshedSeason};
use arr_core::{ use arr_core::{
derive_series_status, EpisodeId, Language, MediaState, RootId, SeasonId, SeriesId, derive_series_status, search_backoff, season_grab_reason, EpisodeId, Language, MediaState,
SeriesStatus, TitleOverrides, PerEpisodeReason, RootId, SeasonGrabFacts, SeasonId, SeriesId, SeriesStatus, TitleOverrides,
}; };
use arr_db::policy::language; use arr_db::policy::language;
use axum::extract::{Path, Query, State}; use axum::extract::{Path, Query, State};
@@ -1488,6 +1488,141 @@ pub async fn season_releases(
Ok(Json(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( #[utoipa::path(
post, path = "/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab", tag = "series", 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)), params(("series_id" = i64, Path), ("season_number" = i64, Path), ("release_id" = i64, Path)),
@@ -2366,6 +2501,108 @@ mod tests {
assert_eq!(after["status"], "complete"); 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] #[tokio::test]
async fn manual_episode_actions_are_scoped_and_respect_blocked() { async fn manual_episode_actions_are_scoped_and_respect_blocked() {
let (_dir, state, base) = application().await; let (_dir, state, base) = application().await;
+133 -12
View File
@@ -10,7 +10,7 @@
//! 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.
use std::time::SystemTime; use std::time::{Duration, SystemTime};
/// How a season's missing wanted episodes should be grabbed next. /// How a season's missing wanted episodes should be grabbed next.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -37,24 +37,75 @@ pub struct SeasonGrabFacts<'a> {
pub pack_backoff_active: bool, pub pack_backoff_active: bool,
} }
/// Picks the grab mode for one season. /// Why a season is not on the season-pack lane.
///
/// The season release deck (§9.3) is empty for a season on the per-episode
/// lane and stays empty however long it waits, so it has to name which of
/// these it is rather than blaming a sweep that is not coming (#182).
/// Ordered by how fundamental the answer is: an unaired episode outranks a
/// failed pack, because clearing the failure would still not earn a pack.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PerEpisodeReason {
/// No episodes are known for the season, so there is nothing to pack.
NoEpisodes,
/// An episode has not aired, or carries no air date at all.
StillAiring,
/// §14: a pack would re-import episodes that are already on disk.
EpisodesOnDisk,
/// §6.2: a failed pack grab's backoff window is still open.
PackBackoff,
}
/// Why one season takes the per-episode lane, or `None` when it takes the
/// pack lane.
/// ///
/// A season is fully released only when every known episode has an air date /// A season is fully released only when every known episode has an air date
/// in the past. An episode with no date could still be unaired, and grabbing /// in the past. An episode with no date could still be unaired, and grabbing
/// a "complete" pack of a season that is not complete costs a whole torrent /// a "complete" pack of a season that is not complete costs a whole torrent
/// of the wrong thing — so an undated episode keeps the season per-episode. /// of the wrong thing — so an undated episode keeps the season per-episode.
#[must_use] #[must_use]
pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode { pub fn season_grab_reason(facts: &SeasonGrabFacts<'_>) -> Option<PerEpisodeReason> {
let fully_released = !facts.air_dates.is_empty() if facts.air_dates.is_empty() {
&& facts return Some(PerEpisodeReason::NoEpisodes);
.air_dates }
.iter() if !facts
.all(|date| date.is_some_and(|date| date <= facts.now)); .air_dates
.iter()
.all(|date| date.is_some_and(|date| date <= facts.now))
{
return Some(PerEpisodeReason::StillAiring);
}
if facts.any_episode_on_disk {
return Some(PerEpisodeReason::EpisodesOnDisk);
}
if facts.pack_backoff_active {
return Some(PerEpisodeReason::PackBackoff);
}
None
}
if fully_released && !facts.any_episode_on_disk && !facts.pack_backoff_active { /// Picks the grab mode for one season.
SeasonGrabMode::SeasonPack #[must_use]
} else { pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode {
SeasonGrabMode::PerEpisode match season_grab_reason(facts) {
None => SeasonGrabMode::SeasonPack,
Some(_) => SeasonGrabMode::PerEpisode,
}
}
/// §6.2's targeted-search curve: `1h → 6h → 1d → 3d`, capped at 7d, indexed
/// by how many attempts have already been spent.
///
/// The pack lane counts a season's failed pack grabs as its attempts, so the
/// deck can say when the lane reopens rather than only that it is shut.
#[must_use]
pub fn search_backoff(attempts: i64) -> Duration {
match attempts {
..=1 => Duration::from_hours(1),
2 => Duration::from_hours(6),
3 => Duration::from_hours(24),
4 => Duration::from_hours(72),
_ => Duration::from_hours(24 * 7),
} }
} }
@@ -122,4 +173,74 @@ mod tests {
facts.any_episode_on_disk = true; facts.any_episode_on_disk = true;
assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode); assert_eq!(season_grab_mode(&facts), SeasonGrabMode::PerEpisode);
} }
/// #182: the deck says which of the four it is, not just "not a pack".
#[test]
fn the_reason_names_the_condition_that_holds_the_pack_lane_shut() {
assert_eq!(
season_grab_reason(&facts(&[])),
Some(PerEpisodeReason::NoEpisodes)
);
let airing = [
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
Some(SystemTime::UNIX_EPOCH + 110 * DAY),
];
assert_eq!(
season_grab_reason(&facts(&airing)),
Some(PerEpisodeReason::StillAiring)
);
let undated = [Some(SystemTime::UNIX_EPOCH + 10 * DAY), None];
assert_eq!(
season_grab_reason(&facts(&undated)),
Some(PerEpisodeReason::StillAiring)
);
let aired = [Some(SystemTime::UNIX_EPOCH + 10 * DAY)];
let mut on_disk = facts(&aired);
on_disk.any_episode_on_disk = true;
assert_eq!(
season_grab_reason(&on_disk),
Some(PerEpisodeReason::EpisodesOnDisk)
);
let mut backoff = facts(&aired);
backoff.pack_backoff_active = true;
assert_eq!(
season_grab_reason(&backoff),
Some(PerEpisodeReason::PackBackoff)
);
assert_eq!(season_grab_reason(&facts(&aired)), None);
}
/// A failed pack is not the headline when the season could not have had
/// a pack anyway — clearing it would change nothing.
#[test]
fn an_unaired_episode_outranks_a_failed_pack() {
let airing = [
Some(SystemTime::UNIX_EPOCH + 10 * DAY),
Some(SystemTime::UNIX_EPOCH + 110 * DAY),
];
let mut facts = facts(&airing);
facts.pack_backoff_active = true;
facts.any_episode_on_disk = true;
assert_eq!(
season_grab_reason(&facts),
Some(PerEpisodeReason::StillAiring)
);
}
/// §6.2's curve, shared by the pack lane so the deck can say when it
/// reopens rather than only that it is shut.
#[test]
fn the_backoff_curve_climbs_and_caps_at_a_week() {
assert_eq!(search_backoff(0), Duration::from_hours(1));
assert_eq!(search_backoff(1), Duration::from_hours(1));
assert_eq!(search_backoff(2), Duration::from_hours(6));
assert_eq!(search_backoff(3), DAY);
assert_eq!(search_backoff(4), 3 * DAY);
assert_eq!(search_backoff(5), 7 * DAY);
assert_eq!(search_backoff(50), 7 * DAY);
}
} }
+4 -1
View File
@@ -14,7 +14,10 @@ pub mod status;
pub mod tracking; pub mod tracking;
pub use arr_parse::NameClaims as ParsedRelease; pub use arr_parse::NameClaims as ParsedRelease;
pub use grabbing::{season_grab_mode, SeasonGrabFacts, SeasonGrabMode}; pub use grabbing::{
search_backoff, season_grab_mode, season_grab_reason, PerEpisodeReason, SeasonGrabFacts,
SeasonGrabMode,
};
pub use matching::{ pub use matching::{
match_episode, match_movie, EpisodeMatch, MatchKind, MatchShape, MovieMatch, ReleaseIds, match_episode, match_movie, EpisodeMatch, MatchKind, MatchShape, MovieMatch, ReleaseIds,
WantedEpisode, WantedMovie, WantedEpisode, WantedMovie,
+66
View File
@@ -374,6 +374,10 @@ impl TvGrabAction {
original_language: &Language, original_language: &Language,
blacklist: &Blacklist, blacklist: &Blacklist,
) -> Result<Vec<TvCandidate>, GrabError> { ) -> 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 { let target = TvTarget {
tvdb_id: season.series_tvdb_id.and_then(|id| u64::try_from(id).ok()), tvdb_id: season.series_tvdb_id.and_then(|id| u64::try_from(id).ok()),
title: season.series_title.clone(), title: season.series_title.clone(),
@@ -451,6 +455,9 @@ impl TvGrabAction {
eligible: stored, eligible: stored,
}); });
} }
if pack_sweep && any_searchable {
record_pack_search(database, season.season_id).await?;
}
Ok(candidates) 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 /// 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 /// 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 /// (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")); 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 /// An empty result backs the whole season off together (§6.2) instead of
/// hammering the tracker every 30 s. /// hammering the tracker every 30 s.
#[tokio::test] #[tokio::test]
@@ -0,0 +1,6 @@
-- #182. The season release deck cannot tell a queued pack sweep from one
-- that ran and found nothing, so it blames backoff for both. Only the season
-- knows when its own pack search last completed: episodes' `last_searched_at`
-- moves for reasons that have nothing to do with the pack lane, and a season
-- on the per-episode lane never touches the pack search at all.
ALTER TABLE seasons ADD COLUMN last_pack_search_at TEXT;
+144 -28
View File
@@ -38,6 +38,7 @@ import {
formatAudio, formatAudio,
formatHdr, formatHdr,
formatResolution, formatResolution,
formatRetryWait,
formatScore, formatScore,
formatSeeders, formatSeeders,
formatSize, formatSize,
@@ -53,6 +54,7 @@ import {
queueSearch, queueSearch,
removeMovie, removeMovie,
ruleLabel, ruleLabel,
type SeasonPackState,
sweepExpected, sweepExpected,
totalSize, totalSize,
type WaiveOutcome, type WaiveOutcome,
@@ -2799,6 +2801,24 @@ function tvReleasesMain(): TvReleasesView {
} else { } else {
delete statusEl.dataset.tone; delete statusEl.dataset.tone;
} }
delete statusEl.dataset.action;
}
/**
* A status that carries its own way out. A season held off the pack lane
* cannot be helped by the head's re-search alone (#181, #182), so the
* sentence that explains the wait also offers the retry that ends it.
*/
function setStatusAction(text: string, label: string, run: () => void) {
const action = document.createElement("button");
action.type = "button";
action.className = "control";
action.textContent = label;
action.addEventListener("click", run);
statusEl.hidden = false;
statusEl.replaceChildren(document.createTextNode(text), action);
delete statusEl.dataset.tone;
statusEl.dataset.action = "";
} }
const actions: ReleaseActions = { const actions: ReleaseActions = {
@@ -2841,23 +2861,93 @@ function tvReleasesMain(): TvReleasesView {
return; return;
} }
if (!paintBuckets(dom, outcome.releases, actions)) { if (!paintBuckets(dom, outcome.releases, actions)) {
// issue 167: an empty deck sweeps on open instead of describing a sweep clearBuckets(dom);
if (sweepIfEmpty) { await emptyVerdict(current, ticket, sweepIfEmpty);
startSweep(current);
} else {
setStatus("no releases indexed yet — re-search queues a targeted sweep");
}
return; return;
} }
setStatus(null); setStatus(null);
} }
/** /**
* A queued sweep is done when its releases appear in the table. Nothing * An empty season deck is three different truths (#182), and until the
* found is indistinguishable from still running, so the wait says so * season could say which, it blamed backoff for all three: a pack sweep
* honestly instead of promising either way. * still running, a pack sweep that ran and found nothing, and a season on
* the per-episode lane, where no pack sweep is coming at all. The lane
* answers the third; `last_pack_search_at` separates the first two, the
* same way a movie's `last_searched_at` does (#177).
*/ */
function watchSweep(current: TvDeckRequest, ticket: number) { async function emptyVerdict(current: TvDeckRequest, ticket: number, sweepIfEmpty: boolean) {
const outcome = await current.target.packState?.();
if (ticket !== sequence || request !== current) {
return;
}
if (outcome?.kind === "state" && outcome.state.lane === "per_episode") {
sweep.disabled = false;
describePerEpisode(current, outcome.state);
return;
}
if (outcome?.kind === "state" && outcome.state.last_pack_search_at !== null) {
sweep.disabled = false;
setStatus(
`no season pack found — indexers last swept ${formatSweepAge(outcome.state.last_pack_search_at)}; re-search runs a new one`,
);
return;
}
// issue 167: an empty deck sweeps on open instead of describing a sweep
if (sweepIfEmpty) {
void startSweep(current, outcome?.kind === "state" ? outcome.state : null);
return;
}
setStatus("no releases indexed yet — re-search queues a targeted sweep");
}
/**
* Name the lane instead of the backoff. A season grabbing episode by
* episode has no pack to show and never will while the reason holds, so
* the deck says which reason it is and where the releases actually are.
*/
function describePerEpisode(current: TvDeckRequest, state: SeasonPackState) {
const elsewhere = "open an episode for its releases";
if (state.reason === "no_episodes") {
setStatus(
"no episodes known for this season yet — a metadata refresh has to find them before anything can be searched",
);
return;
}
if (state.reason === "still_airing") {
setStatus(
`season still airing — a pack is only searched once every episode has aired, so this one is grabbed episode by episode; ${elsewhere}`,
);
return;
}
if (state.reason === "episodes_on_disk") {
setStatus(
`episodes already on disk — a pack would re-import them, so the rest is grabbed episode by episode; ${elsewhere}`,
);
return;
}
const failures =
state.pack_failures === 1 ? "1 failed pack grab" : `${state.pack_failures} failed pack grabs`;
const quiet =
state.pack_retry_at === null
? "pack search is quiet until its backoff elapses"
: `pack search is quiet for another ${formatRetryWait(state.pack_retry_at)}, then retries on its own`;
setStatusAction(
`${failures}${quiet}. episodes are grabbed one at a time meanwhile.`,
"retry the pack now",
() => {
void startSweep(current, state);
},
);
}
/**
* A sweep is done when its releases appear, or when the season stamps the
* pack search it just finished. `baseline` is that stamp as it read before
* the sweep was queued: once it moves, an empty deck is a settled answer
* rather than a pending one, and `load` says so.
*/
function watchSweep(current: TvDeckRequest, baseline: string | null, ticket: number) {
sweep.disabled = true; sweep.disabled = true;
setStatus("sweeping indexers…", undefined, true); setStatus("sweeping indexers…", undefined, true);
const deadline = Date.now() + TV_SWEEP_WAIT_MS; const deadline = Date.now() + TV_SWEEP_WAIT_MS;
@@ -2875,10 +2965,19 @@ function tvReleasesMain(): TvReleasesView {
setStatus(null); setStatus(null);
return; return;
} }
const state = await current.target.packState?.();
if (ticket !== sequence || request !== current) {
return;
}
if (state?.kind === "state" && state.state.last_pack_search_at !== baseline) {
sweep.disabled = false;
await emptyVerdict(current, ticket, false);
return;
}
if (Date.now() >= deadline) { if (Date.now() >= deadline) {
sweep.disabled = false; sweep.disabled = false;
setStatus( setStatus(
"sweep has not landed yet — it may be waiting out its backoff; results appear here once it runs", "sweep has not landed yet — nothing has come back from the indexers; results appear here when it does",
); );
return; return;
} }
@@ -2891,23 +2990,40 @@ function tvReleasesMain(): TvReleasesView {
}, TV_SWEEP_POLL_MS); }, TV_SWEEP_POLL_MS);
} }
/** Queue a targeted sweep, then watch for its releases to land (§6.2). */ /**
function startSweep(current: TvDeckRequest) { * Queue a targeted sweep, then watch for it to land (§6.2). `known` is the
* lane as it read a moment ago, so the watch is only entered when a pack
* sweep is actually expected: on the per-episode lane the sweep searches
* episodes, and waiting for a pack that is not coming is the lie #182 is
* about. A failed pack is the exception — a manual search waives its
* window and does try a pack (#181).
*/
async function startSweep(current: TvDeckRequest, known?: SeasonPackState | null) {
sweep.disabled = true; sweep.disabled = true;
void (async () => { const before =
const outcome = await current.target.search(); known === undefined
if (request !== current) { ? await current.target.packState?.().then((it) => (it.kind === "state" ? it.state : null))
return; : known;
} if (request !== current) {
if (outcome.kind === "error") { return;
sweep.disabled = false; }
setStatus(`search failed — ${outcome.detail}`, "fault"); const outcome = await current.target.search();
return; if (request !== current) {
} return;
sequence += 1; }
window.clearTimeout(pollTimer); sequence += 1;
watchSweep(current, sequence); window.clearTimeout(pollTimer);
})(); if (outcome.kind === "error") {
sweep.disabled = false;
setStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
if (before && before.lane === "per_episode" && before.reason !== "pack_backoff") {
sweep.disabled = false;
setStatus("searching the season's episodes — open an episode for its releases");
return;
}
watchSweep(current, before?.last_pack_search_at ?? null, sequence);
} }
sweep.addEventListener("click", () => { sweep.addEventListener("click", () => {
@@ -2915,7 +3031,7 @@ function tvReleasesMain(): TvReleasesView {
if (!current) { if (!current) {
return; return;
} }
startSweep(current); void startSweep(current);
}); });
function open(next: TvDeckRequest) { function open(next: TvDeckRequest) {
+61
View File
@@ -95,6 +95,67 @@ export function formatSweepAge(lastSearchedAt: string, now = Date.now()): string
return `${Math.round(minutes / (24 * 60))} d ago`; return `${Math.round(minutes / (24 * 60))} d ago`;
} }
/** Which lane a season's missing episodes take (#182, §6.2). */
export type SeasonLane = "season_pack" | "per_episode";
/** What holds a season off the pack lane (#182). */
export type PackLaneReason = "no_episodes" | "still_airing" | "episodes_on_disk" | "pack_backoff";
/**
* Why the season deck holds what it holds, from
* `/api/series/{id}/seasons/{n}/pack-state`. An empty deck is three states,
* not one: a pack sweep still running, a pack sweep that found nothing, and
* a season on the per-episode lane, where no pack sweep is coming.
*/
export interface SeasonPackState {
lane: SeasonLane;
reason: PackLaneReason | null;
pack_failures: number;
pack_retry_at: string | null;
last_pack_search_at: string | null;
}
export type PackStateOutcome =
| { kind: "state"; state: SeasonPackState }
| { kind: "error"; detail: string };
export async function seasonPackState(
seriesId: number,
seasonNumber: number,
): Promise<PackStateOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}/seasons/${seasonNumber}/pack-state`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "state", state: (await response.json()) as SeasonPackState };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* `pack_retry_at` as a coarse wait. The deck says how long the lane stays
* quiet, so "it retries on its own" is a promise with a date on it.
*/
export function formatRetryWait(retryAt: string, now = Date.now()): string {
const reopens = Date.parse(retryAt);
if (Number.isNaN(reopens)) {
return "later";
}
const minutes = Math.round((reopens - now) / 60_000);
if (minutes < 2) {
return "any moment";
}
if (minutes < 60) {
return `${minutes} min`;
}
if (minutes < 48 * 60) {
return `${Math.round(minutes / 60)} h`;
}
return `${Math.round(minutes / (24 * 60))} d`;
}
/** One library file as `/api/movies/{id}/files` reports it (§5.6, §5.7). */ /** One library file as `/api/movies/{id}/files` reports it (§5.6, §5.7). */
export interface MovieFile { export interface MovieFile {
id: number; id: number;
+14 -2
View File
@@ -3,8 +3,14 @@
// (src/api/) is uncommitted, so CI's tsc cannot see it. // (src/api/) is uncommitted, so CI's tsc cannot see it.
import type { MetadataTrailer } from "./movie"; import type { MetadataTrailer } from "./movie";
import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases"; import type {
import { errorDetail, probedAttributeTags, waiverOverride } from "./releases"; ActionOutcome,
MovieRelease,
PackStateOutcome,
ReleasesOutcome,
WaiveOutcome,
} from "./releases";
import { errorDetail, probedAttributeTags, seasonPackState, waiverOverride } from "./releases";
/** §4.2 derived status — displayed, never editable. */ /** §4.2 derived status — displayed, never editable. */
export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended"; export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended";
@@ -233,6 +239,11 @@ export interface TvTarget {
releases: () => Promise<ReleasesOutcome>; releases: () => Promise<ReleasesOutcome>;
grab: (releaseId: number) => Promise<ActionOutcome>; grab: (releaseId: number) => Promise<ActionOutcome>;
search: () => Promise<ActionOutcome>; search: () => Promise<ActionOutcome>;
/**
* Why the deck is empty (#182). Seasons only: an episode deck has one
* lane, so it has nothing to disambiguate.
*/
packState?: () => Promise<PackStateOutcome>;
} }
export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget { export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget {
@@ -241,6 +252,7 @@ export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget {
releases: () => fetchJson(`${base}/releases`), releases: () => fetchJson(`${base}/releases`),
grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`), grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`),
search: () => post(`${base}/search`), search: () => post(`${base}/search`),
packState: () => seasonPackState(seriesId, seasonNumber),
}; };
} }
+11
View File
@@ -537,6 +537,17 @@ body {
color: var(--signal-fault); color: var(--signal-fault);
} }
/* a status that carries its own way out keeps the control on the sentence's
own left edge, so the explanation reads first and the action follows it
(#182). Long enough copy wraps the control to its own line, which is the
reading order anyway. */
.deck-status[data-action] {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
/* a sweep in flight borrows the rail's probing-lamp idiom, inline */ /* a sweep in flight borrows the rail's probing-lamp idiom, inline */
.deck-status .lamp { .deck-status .lamp {
display: inline-block; display: inline-block;