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:
+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