From 1e0387320941cd2611b759f7f6eea695ec7f64da Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 20:14:41 +0100 Subject: [PATCH] feat(web): name the season deck's real state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...11d6a3ec1613976c52a8a7f8225e1dc06d162.json | 28 ++ ...67e30032a438c4815816323cf62896398d2a2.json | 33 +++ ...2c7c5618b25843192ffa28b9c13eacd0b9c26.json | 26 ++ ...41c2bfbbec508109bed2e7a460ffcc2b5cdf1.json | 12 + crates/arr-api/src/lib.rs | 5 + crates/arr-api/src/series.rs | 241 +++++++++++++++++- crates/arr-core/src/grabbing.rs | 145 ++++++++++- crates/arr-core/src/lib.rs | 5 +- crates/arr-daemon/src/tv_grab.rs | 66 +++++ .../migrations/0023_season_pack_search.sql | 6 + web/src/main.ts | 172 +++++++++++-- web/src/releases.ts | 61 +++++ web/src/series.ts | 16 +- web/src/style.css | 11 + 14 files changed, 782 insertions(+), 45 deletions(-) create mode 100644 .sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json create mode 100644 .sqlx/query-20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2.json create mode 100644 .sqlx/query-2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26.json create mode 100644 .sqlx/query-f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1.json create mode 100644 crates/arr-db/migrations/0023_season_pack_search.sql diff --git a/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json b/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json new file mode 100644 index 0000000..d9045ff --- /dev/null +++ b/.sqlx/query-0ca521a8dcb979cc90f5823eb3311d6a3ec1613976c52a8a7f8225e1dc06d162.json @@ -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" +} diff --git a/.sqlx/query-20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2.json b/.sqlx/query-20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2.json new file mode 100644 index 0000000..00f73bd --- /dev/null +++ b/.sqlx/query-20c1cbcd9cc317b148357ff64e967e30032a438c4815816323cf62896398d2a2.json @@ -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" +} diff --git a/.sqlx/query-2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26.json b/.sqlx/query-2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26.json new file mode 100644 index 0000000..aeb8ac3 --- /dev/null +++ b/.sqlx/query-2759a3a5fb0ca9d37560b8d89922c7c5618b25843192ffa28b9c13eacd0b9c26.json @@ -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" +} diff --git a/.sqlx/query-f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1.json b/.sqlx/query-f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1.json new file mode 100644 index 0000000..ac65f55 --- /dev/null +++ b/.sqlx/query-f51e2addb844a3d386ba582c71f41c2bfbbec508109bed2e7a460ffcc2b5cdf1.json @@ -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" +} diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index ccd645d..1fd1f75 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -100,6 +100,7 @@ fn api_router() -> OpenApiRouter { .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", diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index f7dd443..28ec456 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -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, + /// 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, + /// 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, +} + +#[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, + Path((series_id, number)): Path<(i64, i64)>, +) -> Result, 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> = 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::() + .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; diff --git a/crates/arr-core/src/grabbing.rs b/crates/arr-core/src/grabbing.rs index 0c5eb39..e6d5491 100644 --- a/crates/arr-core/src/grabbing.rs +++ b/crates/arr-core/src/grabbing.rs @@ -10,7 +10,7 @@ //! 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. -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; /// How a season's missing wanted episodes should be grabbed next. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -37,24 +37,75 @@ pub struct SeasonGrabFacts<'a> { 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 /// 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 /// of the wrong thing — so an undated episode keeps the season per-episode. #[must_use] -pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode { - let fully_released = !facts.air_dates.is_empty() - && facts - .air_dates - .iter() - .all(|date| date.is_some_and(|date| date <= facts.now)); +pub fn season_grab_reason(facts: &SeasonGrabFacts<'_>) -> Option { + if facts.air_dates.is_empty() { + return Some(PerEpisodeReason::NoEpisodes); + } + if !facts + .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 { - SeasonGrabMode::SeasonPack - } else { - SeasonGrabMode::PerEpisode +/// Picks the grab mode for one season. +#[must_use] +pub fn season_grab_mode(facts: &SeasonGrabFacts<'_>) -> SeasonGrabMode { + 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; 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); + } } diff --git a/crates/arr-core/src/lib.rs b/crates/arr-core/src/lib.rs index 4c5abde..4421f0f 100644 --- a/crates/arr-core/src/lib.rs +++ b/crates/arr-core/src/lib.rs @@ -14,7 +14,10 @@ pub mod status; pub mod tracking; 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::{ match_episode, match_movie, EpisodeMatch, MatchKind, MatchShape, MovieMatch, ReleaseIds, WantedEpisode, WantedMovie, diff --git a/crates/arr-daemon/src/tv_grab.rs b/crates/arr-daemon/src/tv_grab.rs index a146857..e3fc68b 100644 --- a/crates/arr-daemon/src/tv_grab.rs +++ b/crates/arr-daemon/src/tv_grab.rs @@ -374,6 +374,10 @@ impl TvGrabAction { original_language: &Language, blacklist: &Blacklist, ) -> Result, 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>( + "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 = + 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] diff --git a/crates/arr-db/migrations/0023_season_pack_search.sql b/crates/arr-db/migrations/0023_season_pack_search.sql new file mode 100644 index 0000000..a6d14f1 --- /dev/null +++ b/crates/arr-db/migrations/0023_season_pack_search.sql @@ -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; diff --git a/web/src/main.ts b/web/src/main.ts index cd15fbd..cb3714b 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -38,6 +38,7 @@ import { formatAudio, formatHdr, formatResolution, + formatRetryWait, formatScore, formatSeeders, formatSize, @@ -53,6 +54,7 @@ import { queueSearch, removeMovie, ruleLabel, + type SeasonPackState, sweepExpected, totalSize, type WaiveOutcome, @@ -2799,6 +2801,24 @@ function tvReleasesMain(): TvReleasesView { } else { 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 = { @@ -2841,23 +2861,93 @@ function tvReleasesMain(): TvReleasesView { return; } if (!paintBuckets(dom, outcome.releases, actions)) { - // issue 167: an empty deck sweeps on open instead of describing a sweep - if (sweepIfEmpty) { - startSweep(current); - } else { - setStatus("no releases indexed yet — re-search queues a targeted sweep"); - } + clearBuckets(dom); + await emptyVerdict(current, ticket, sweepIfEmpty); return; } setStatus(null); } /** - * A queued sweep is done when its releases appear in the table. Nothing - * found is indistinguishable from still running, so the wait says so - * honestly instead of promising either way. + * An empty season deck is three different truths (#182), and until the + * season could say which, it blamed backoff for all three: a pack sweep + * 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; setStatus("sweeping indexers…", undefined, true); const deadline = Date.now() + TV_SWEEP_WAIT_MS; @@ -2875,10 +2965,19 @@ function tvReleasesMain(): TvReleasesView { setStatus(null); 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) { sweep.disabled = false; 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; } @@ -2891,23 +2990,40 @@ function tvReleasesMain(): TvReleasesView { }, 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; - void (async () => { - const outcome = await current.target.search(); - if (request !== current) { - return; - } - if (outcome.kind === "error") { - sweep.disabled = false; - setStatus(`search failed — ${outcome.detail}`, "fault"); - return; - } - sequence += 1; - window.clearTimeout(pollTimer); - watchSweep(current, sequence); - })(); + const before = + known === undefined + ? await current.target.packState?.().then((it) => (it.kind === "state" ? it.state : null)) + : known; + if (request !== current) { + return; + } + const outcome = await current.target.search(); + if (request !== current) { + return; + } + sequence += 1; + 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", () => { @@ -2915,7 +3031,7 @@ function tvReleasesMain(): TvReleasesView { if (!current) { return; } - startSweep(current); + void startSweep(current); }); function open(next: TvDeckRequest) { diff --git a/web/src/releases.ts b/web/src/releases.ts index 2e74b21..527bc48 100644 --- a/web/src/releases.ts +++ b/web/src/releases.ts @@ -95,6 +95,67 @@ export function formatSweepAge(lastSearchedAt: string, now = Date.now()): string 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 { + 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). */ export interface MovieFile { id: number; diff --git a/web/src/series.ts b/web/src/series.ts index 6d1deeb..0c5d63c 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -3,8 +3,14 @@ // (src/api/) is uncommitted, so CI's tsc cannot see it. import type { MetadataTrailer } from "./movie"; -import type { ActionOutcome, MovieRelease, ReleasesOutcome, WaiveOutcome } from "./releases"; -import { errorDetail, probedAttributeTags, waiverOverride } from "./releases"; +import type { + ActionOutcome, + MovieRelease, + PackStateOutcome, + ReleasesOutcome, + WaiveOutcome, +} from "./releases"; +import { errorDetail, probedAttributeTags, seasonPackState, waiverOverride } from "./releases"; /** §4.2 derived status — displayed, never editable. */ export type SeriesStatus = "airing" | "incomplete" | "waiting" | "complete" | "ended"; @@ -233,6 +239,11 @@ export interface TvTarget { releases: () => Promise; grab: (releaseId: number) => Promise; search: () => Promise; + /** + * Why the deck is empty (#182). Seasons only: an episode deck has one + * lane, so it has nothing to disambiguate. + */ + packState?: () => Promise; } export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget { @@ -241,6 +252,7 @@ export function seasonTarget(seriesId: number, seasonNumber: number): TvTarget { releases: () => fetchJson(`${base}/releases`), grab: (releaseId) => post(`${base}/releases/${releaseId}/grab`), search: () => post(`${base}/search`), + packState: () => seasonPackState(seriesId, seasonNumber), }; } diff --git a/web/src/style.css b/web/src/style.css index f44f7ca..8a0ca95 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -537,6 +537,17 @@ body { 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 */ .deck-status .lamp { display: inline-block;