fix(web): show sweep in flight, not empty deck

An empty release list has two meanings the deck conflated: a sweep
that has not landed yet and a sweep that found nothing. The movie's
last_searched_at tells them apart, so the deck now shows a probing
'sweeping indexers' state and polls the movie (5 s, 150 s budget)
until the sweep lands, then renders buckets or an honest empty
verdict with the sweep's age. A sweep held by the §6.2 backoff times
out to a message that says so.

Closes #101

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-23 08:07:33 +01:00
parent 5db3a8d7a4
commit 6d78cacce0
3 changed files with 154 additions and 13 deletions
+96 -13
View File
@@ -24,11 +24,14 @@ import {
formatSeeders,
formatSize,
formatSource,
formatSweepAge,
grabRelease,
type MovieRelease,
movieReleases,
movieSearchState,
queueSearch,
ruleLabel,
sweepExpected,
waiveAndGrab,
waiverOverride,
} from "./releases";
@@ -722,11 +725,23 @@ function releasesMain(): ReleasesView {
};
// guards a stale fetch from painting over a newer view
let sequence = 0;
let refetchTimer: number | undefined;
let pollTimer: number | undefined;
function setStatus(text: string | null, tone?: "fault") {
// §6.2: the sweep runs on the daemon's own cadence. Poll modestly, and
// stop promising once the backoff window is plausibly in charge.
const SWEEP_POLL_MS = 5000;
const SWEEP_WAIT_MS = 150_000;
function setStatus(text: string | null, tone?: "fault", busy = false) {
status.hidden = text === null;
status.textContent = text ?? "";
if (busy && text !== null) {
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.dataset.state = "probing";
status.replaceChildren(lamp, document.createTextNode(text));
} else {
status.textContent = text ?? "";
}
if (tone) {
status.dataset.tone = tone;
} else {
@@ -765,7 +780,6 @@ function releasesMain(): ReleasesView {
}
clearBuckets();
if (releases.length === 0) {
setStatus("no releases indexed for this title — search indexers runs a sweep now");
return;
}
setStatus(null);
@@ -811,6 +825,7 @@ function releasesMain(): ReleasesView {
}
sequence += 1;
const ticket = sequence;
window.clearTimeout(pollTimer);
setStatus("reading releases…");
const outcome = await movieReleases(movie.id);
if (ticket !== sequence || current !== movie) {
@@ -821,9 +836,72 @@ function releasesMain(): ReleasesView {
setStatus(`releases unavailable — ${outcome.detail}`, "fault");
return;
}
if (outcome.releases.length === 0) {
clearBuckets();
await emptyVerdict(movie, ticket);
return;
}
render(outcome.releases);
}
/**
* The empty deck is two different truths (§9.3, issue 101): a sweep that
* has not landed yet, or a sweep that landed and found nothing. Only the
* movie's `last_searched_at` can tell them apart.
*/
async function emptyVerdict(movie: LibraryMovie, ticket: number) {
const outcome = await movieSearchState(movie.id);
if (ticket !== sequence || current !== movie) {
return;
}
if (outcome.kind === "state" && outcome.movie.last_searched_at !== null) {
setStatus(
`no releases found — sweep finished ${formatSweepAge(outcome.movie.last_searched_at)}; search indexers runs a new one`,
);
return;
}
if (outcome.kind === "state" && sweepExpected(outcome.movie)) {
// first sweep still in flight — visibly different from "nothing found"
watchSweep(movie, null, ticket);
return;
}
setStatus("no releases indexed for this title — search indexers runs a sweep now");
}
/** Poll the movie until `last_searched_at` moves off `baseline`. */
function watchSweep(movie: LibraryMovie, baseline: string | null, ticket: number) {
sweep.disabled = true;
setStatus("sweeping indexers…", undefined, true);
const deadline = Date.now() + SWEEP_WAIT_MS;
const tick = async () => {
if (ticket !== sequence || current !== movie) {
return;
}
const outcome = await movieSearchState(movie.id);
if (ticket !== sequence || current !== movie) {
return;
}
if (outcome.kind === "state" && outcome.movie.last_searched_at !== baseline) {
sweep.disabled = false;
void load();
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",
);
return;
}
pollTimer = window.setTimeout(() => {
void tick();
}, SWEEP_POLL_MS);
};
pollTimer = window.setTimeout(() => {
void tick();
}, SWEEP_POLL_MS);
}
function open(movie: LibraryMovie, from: HTMLElement, container: HTMLElement) {
current = movie;
origin = from;
@@ -842,7 +920,7 @@ function releasesMain(): ReleasesView {
view.hidden = true;
current = null;
sequence += 1;
window.clearTimeout(refetchTimer);
window.clearTimeout(pollTimer);
}
function close() {
@@ -873,21 +951,26 @@ function releasesMain(): ReleasesView {
return;
}
sweep.disabled = true;
void queueSearch(movie.id).then((outcome) => {
void (async () => {
// snapshot the last sweep first: its change is the completion signal
const before = await movieSearchState(movie.id);
if (current !== movie) {
return;
}
const baseline = before.kind === "state" ? before.movie.last_searched_at : null;
const outcome = await queueSearch(movie.id);
if (current !== movie) {
return;
}
sweep.disabled = false;
if (outcome.kind === "error") {
sweep.disabled = false;
setStatus(`search failed — ${outcome.detail}`, "fault");
return;
}
setStatus("search queued — indexers answer asynchronously, rereading shortly");
window.clearTimeout(refetchTimer);
refetchTimer = window.setTimeout(() => {
void load();
}, 3000);
});
sequence += 1;
window.clearTimeout(pollTimer);
watchSweep(movie, baseline, sequence);
})();
});
return { open, hide };