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:
co-authored by
Claude Fable 5
parent
5db3a8d7a4
commit
6d78cacce0
+96
-13
@@ -24,11 +24,14 @@ import {
|
|||||||
formatSeeders,
|
formatSeeders,
|
||||||
formatSize,
|
formatSize,
|
||||||
formatSource,
|
formatSource,
|
||||||
|
formatSweepAge,
|
||||||
grabRelease,
|
grabRelease,
|
||||||
type MovieRelease,
|
type MovieRelease,
|
||||||
movieReleases,
|
movieReleases,
|
||||||
|
movieSearchState,
|
||||||
queueSearch,
|
queueSearch,
|
||||||
ruleLabel,
|
ruleLabel,
|
||||||
|
sweepExpected,
|
||||||
waiveAndGrab,
|
waiveAndGrab,
|
||||||
waiverOverride,
|
waiverOverride,
|
||||||
} from "./releases";
|
} from "./releases";
|
||||||
@@ -722,11 +725,23 @@ function releasesMain(): ReleasesView {
|
|||||||
};
|
};
|
||||||
// guards a stale fetch from painting over a newer view
|
// guards a stale fetch from painting over a newer view
|
||||||
let sequence = 0;
|
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.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) {
|
if (tone) {
|
||||||
status.dataset.tone = tone;
|
status.dataset.tone = tone;
|
||||||
} else {
|
} else {
|
||||||
@@ -765,7 +780,6 @@ function releasesMain(): ReleasesView {
|
|||||||
}
|
}
|
||||||
clearBuckets();
|
clearBuckets();
|
||||||
if (releases.length === 0) {
|
if (releases.length === 0) {
|
||||||
setStatus("no releases indexed for this title — search indexers runs a sweep now");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
@@ -811,6 +825,7 @@ function releasesMain(): ReleasesView {
|
|||||||
}
|
}
|
||||||
sequence += 1;
|
sequence += 1;
|
||||||
const ticket = sequence;
|
const ticket = sequence;
|
||||||
|
window.clearTimeout(pollTimer);
|
||||||
setStatus("reading releases…");
|
setStatus("reading releases…");
|
||||||
const outcome = await movieReleases(movie.id);
|
const outcome = await movieReleases(movie.id);
|
||||||
if (ticket !== sequence || current !== movie) {
|
if (ticket !== sequence || current !== movie) {
|
||||||
@@ -821,9 +836,72 @@ function releasesMain(): ReleasesView {
|
|||||||
setStatus(`releases unavailable — ${outcome.detail}`, "fault");
|
setStatus(`releases unavailable — ${outcome.detail}`, "fault");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (outcome.releases.length === 0) {
|
||||||
|
clearBuckets();
|
||||||
|
await emptyVerdict(movie, ticket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
render(outcome.releases);
|
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) {
|
function open(movie: LibraryMovie, from: HTMLElement, container: HTMLElement) {
|
||||||
current = movie;
|
current = movie;
|
||||||
origin = from;
|
origin = from;
|
||||||
@@ -842,7 +920,7 @@ function releasesMain(): ReleasesView {
|
|||||||
view.hidden = true;
|
view.hidden = true;
|
||||||
current = null;
|
current = null;
|
||||||
sequence += 1;
|
sequence += 1;
|
||||||
window.clearTimeout(refetchTimer);
|
window.clearTimeout(pollTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
@@ -873,21 +951,26 @@ function releasesMain(): ReleasesView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sweep.disabled = true;
|
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) {
|
if (current !== movie) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sweep.disabled = false;
|
|
||||||
if (outcome.kind === "error") {
|
if (outcome.kind === "error") {
|
||||||
|
sweep.disabled = false;
|
||||||
setStatus(`search failed — ${outcome.detail}`, "fault");
|
setStatus(`search failed — ${outcome.detail}`, "fault");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus("search queued — indexers answer asynchronously, rereading shortly");
|
sequence += 1;
|
||||||
window.clearTimeout(refetchTimer);
|
window.clearTimeout(pollTimer);
|
||||||
refetchTimer = window.setTimeout(() => {
|
watchSweep(movie, baseline, sequence);
|
||||||
void load();
|
})();
|
||||||
}, 3000);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { open, hide };
|
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 };
|
export type ActionOutcome = { kind: "done" } | { kind: "error"; detail: string };
|
||||||
|
|
||||||
/** §6.2 manual trigger: one targeted sweep, user-initiated. */
|
/** §6.2 manual trigger: one targeted sweep, user-initiated. */
|
||||||
|
|||||||
@@ -550,6 +550,13 @@ body {
|
|||||||
color: var(--signal-fault);
|
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 {
|
.deck-group {
|
||||||
margin: 0 0 var(--space-8);
|
margin: 0 0 var(--space-8);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user