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 };
+51
View File
@@ -44,6 +44,57 @@ export async function movieReleases(movieId: number): Promise<ReleasesOutcome> {
}
}
/** The slice of a movie that says whether a sweep ran and whether one is due. */
export interface MovieSearchState {
wanted: boolean;
blocked: boolean;
state: string;
last_searched_at: string | null;
}
export type MovieStateOutcome =
| { kind: "state"; movie: MovieSearchState }
| { kind: "error"; detail: string };
export async function movieSearchState(movieId: number): Promise<MovieStateOutcome> {
try {
const response = await fetch(`/api/movies/${movieId}`);
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "state", movie: (await response.json()) as MovieSearchState };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/**
* Whether the daemon's reconcile loop will sweep this title on its own
* (§6.2 targeted search: wanted, not blocked, nothing on disk or in flight).
*/
export function sweepExpected(movie: MovieSearchState): boolean {
return movie.wanted && !movie.blocked && movie.state === "missing";
}
/** `last_searched_at` as a coarse age — evidence the empty verdict is real. */
export function formatSweepAge(lastSearchedAt: string, now = Date.now()): string {
const swept = Date.parse(lastSearchedAt);
if (Number.isNaN(swept)) {
return "earlier";
}
const minutes = Math.round((now - swept) / 60_000);
if (minutes < 2) {
return "just now";
}
if (minutes < 60) {
return `${minutes} min ago`;
}
if (minutes < 48 * 60) {
return `${Math.round(minutes / 60)} h ago`;
}
return `${Math.round(minutes / (24 * 60))} d ago`;
}
export type ActionOutcome = { kind: "done" } | { kind: "error"; detail: string };
/** §6.2 manual trigger: one targeted sweep, user-initiated. */
+7
View File
@@ -550,6 +550,13 @@ body {
color: var(--signal-fault);
}
/* a sweep in flight borrows the rail's probing-lamp idiom, inline */
.deck-status .lamp {
display: inline-block;
margin-right: var(--space-2);
vertical-align: baseline;
}
.deck-group {
margin: 0 0 var(--space-8);
}