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) {
|
||||
|
||||
Reference in New Issue
Block a user