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:
+28
@@ -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"
|
||||
}
|
||||
+33
@@ -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"
|
||||
}
|
||||
+26
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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;
|
||||
|
||||
+133
-12
@@ -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<PerEpisodeReason> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -374,6 +374,10 @@ impl TvGrabAction {
|
||||
original_language: &Language,
|
||||
blacklist: &Blacklist,
|
||||
) -> Result<Vec<TvCandidate>, GrabError> {
|
||||
// #182: a season-scoped sweep is the pack lane's own search, and the
|
||||
// release deck needs to know it completed to tell "nothing found"
|
||||
// from "nothing has run yet".
|
||||
let pack_sweep = matches!(selector, TvSelector::Season { .. });
|
||||
let target = TvTarget {
|
||||
tvdb_id: season.series_tvdb_id.and_then(|id| u64::try_from(id).ok()),
|
||||
title: season.series_title.clone(),
|
||||
@@ -451,6 +455,9 @@ impl TvGrabAction {
|
||||
eligible: stored,
|
||||
});
|
||||
}
|
||||
if pack_sweep && any_searchable {
|
||||
record_pack_search(database, season.season_id).await?;
|
||||
}
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
@@ -1113,6 +1120,22 @@ async fn load_season_release(
|
||||
}))
|
||||
}
|
||||
|
||||
/// When the season's own pack search last completed (#182). Written only by
|
||||
/// a season-scoped sweep, so it answers the deck's question — did a pack
|
||||
/// search run — and nothing else.
|
||||
async fn record_pack_search(database: &Db, season_id: i64) -> Result<(), GrabError> {
|
||||
sqlx::query!(
|
||||
"UPDATE seasons
|
||||
SET last_pack_search_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?",
|
||||
season_id
|
||||
)
|
||||
.execute(database.pool())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether failed season-pack grabs still hold this season off the pack
|
||||
/// lane. §6.2: a failure quiets the pack search on the shared backoff curve
|
||||
/// (each failed grab is one attempt), it never disables it. Anchored on the
|
||||
@@ -1648,6 +1671,49 @@ mod tests {
|
||||
assert!(sources[1].ends_with("e03.torrent"));
|
||||
}
|
||||
|
||||
/// #182: the season deck cannot tell a queued pack sweep from one that
|
||||
/// ran and found nothing unless the pack search leaves a mark. Episode
|
||||
/// searches must not leave it — a season on the per-episode lane never
|
||||
/// asked for a pack, and pretending otherwise is the lie the deck told.
|
||||
#[tokio::test]
|
||||
async fn only_a_pack_sweep_stamps_the_season() {
|
||||
let (_dir, database, season_id) =
|
||||
wanted_season(&["2024-04-11", "2024-04-11", "2024-04-11"]).await;
|
||||
let indexer = prowlarr().await;
|
||||
let (downloader, fake) = transmission().await;
|
||||
|
||||
let stamp = |database: Db| async move {
|
||||
sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT last_pack_search_at FROM seasons WHERE id = ?",
|
||||
)
|
||||
.bind(season_id)
|
||||
.fetch_one(database.pool())
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
assert!(stamp(database.clone()).await.is_none());
|
||||
|
||||
action(&indexer, &downloader).tick(&database).await.unwrap();
|
||||
let after_pack = stamp(database.clone()).await;
|
||||
assert!(
|
||||
after_pack.is_some(),
|
||||
"a fully released season takes the pack lane and its sweep is the deck's evidence"
|
||||
);
|
||||
assert_eq!(fake.torrents().len(), 1);
|
||||
|
||||
// An airing season is on the per-episode lane: its episode searches
|
||||
// say nothing about whether a pack was ever looked for.
|
||||
let (_other_dir, airing, airing_id) = wanted_season(&["2024-04-11", "2999-01-01"]).await;
|
||||
action(&indexer, &downloader).tick(&airing).await.unwrap();
|
||||
let never: Option<String> =
|
||||
sqlx::query_scalar("SELECT last_pack_search_at FROM seasons WHERE id = ?")
|
||||
.bind(airing_id)
|
||||
.fetch_one(airing.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(never.is_none());
|
||||
}
|
||||
|
||||
/// An empty result backs the whole season off together (§6.2) instead of
|
||||
/// hammering the tracker every 30 s.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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<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). */
|
||||
export interface MovieFile {
|
||||
id: number;
|
||||
|
||||
+14
-2
@@ -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<ReleasesOutcome>;
|
||||
grab: (releaseId: number) => 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 {
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user