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
+14 -2
View File
@@ -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),
};
}