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:
+96
-13
@@ -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 };
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user