Merge #177: distinguish pending refresh from empty

Closes #177
This commit is contained in:
Miguel Palhas
2026-08-24 19:00:29 +01:00
4 changed files with 225 additions and 4 deletions
+123 -4
View File
@@ -87,6 +87,7 @@ import {
fileAttributeTags,
formatAirDate,
isUnaired,
refreshSeriesMetadata,
removeEpisodeFiles,
removeSeasonFiles,
removeSeries,
@@ -2981,6 +2982,14 @@ interface SeriesView {
const PAD_TWO = (value: number): string => String(value).padStart(2, "0");
/**
* Issue #177: the on-demand refresh a new series' add sends (#176) lands
* within seconds. Poll modestly and give up bounded so a page left open
* does not spin forever.
*/
const SERIES_REFRESH_POLL_MS = 2000;
const SERIES_REFRESH_WAIT_MS = 30_000;
function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const view = must<HTMLElement>("#series");
const deckEl = must<HTMLElement>("#deck");
@@ -3015,6 +3024,9 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
// guards a stale fetch from painting over a newer view
let sequence = 0;
let removed: ((parent: Route) => void) | null = null;
let refreshPollTimer: number | undefined;
// set once the bounded poll for a pending metadata refresh times out
let refreshGaveUp = false;
function setStatus(text: string | null, tone?: "fault") {
statusEl.hidden = text === null;
@@ -3466,6 +3478,42 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
return item;
}
/**
* A never-refreshed series (`metadata_refreshed_at` null) reads as a
* refresh under way, since #176 queues one on add and it lands within
* seconds. A refreshed series with no seasons is a settled, if rare,
* empty state upstream — the two must not share a message (#177).
*/
function seasonsEmptyRow(): HTMLLIElement {
const item = document.createElement("li");
item.className = "rel rel-none readout dim";
if (series !== null && series.metadata_refreshed_at === null) {
if (refreshGaveUp) {
item.append(document.createTextNode("metadata refresh has not landed yet — "));
const retry = document.createElement("button");
retry.type = "button";
retry.className = "control control-quiet";
retry.textContent = "retry";
retry.addEventListener("click", () => {
retry.disabled = true;
void retryRefresh();
});
item.append(retry);
} else {
const lamp = document.createElement("span");
lamp.className = "lamp";
lamp.dataset.state = "probing";
item.append(
lamp,
document.createTextNode(" refreshing metadata — seasons will appear here"),
);
}
} else {
item.textContent = "no seasons listed upstream";
}
return item;
}
function renderSeasons() {
seasonsList.replaceChildren();
if (!seasons) {
@@ -3475,13 +3523,80 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
seasonsList.append(seasonRow(season));
}
if (seasons.length === 0) {
const none = document.createElement("li");
none.className = "rel rel-none readout dim";
none.textContent = "no seasons revealed yet — a metadata refresh fills this in";
seasonsList.append(none);
seasonsList.append(seasonsEmptyRow());
}
}
/**
* Poll while a refresh is pending, painting seasons as soon as they land
* with no manual reload. Stops on arrival, on the refresh being recorded
* done with none, or once `SERIES_REFRESH_WAIT_MS` passes — a page left
* open must not poll forever (#177).
*/
function syncRefreshWatch(ticket: number) {
window.clearTimeout(refreshPollTimer);
const id = seriesId;
if (id === null || series === null || seasons === null) {
return;
}
const pending = series.metadata_refreshed_at === null && seasons.length === 0;
if (!pending || refreshGaveUp) {
return;
}
const deadline = Date.now() + SERIES_REFRESH_WAIT_MS;
const tick = async () => {
if (ticket !== sequence || seriesId !== id) {
return;
}
const [detail, seasonsOutcome] = await Promise.all([fetchSeries(id), fetchSeasons(id)]);
if (ticket !== sequence || seriesId !== id) {
return;
}
if (detail && seasonsOutcome.kind === "seasons") {
series = detail;
seasons = seasonsOutcome.seasons;
paintHeader();
if (series.metadata_refreshed_at !== null || seasons.length > 0) {
renderSeasons();
return;
}
}
if (Date.now() >= deadline) {
refreshGaveUp = true;
renderSeasons();
return;
}
refreshPollTimer = window.setTimeout(() => {
void tick();
}, SERIES_REFRESH_POLL_MS);
};
refreshPollTimer = window.setTimeout(() => {
void tick();
}, SERIES_REFRESH_POLL_MS);
}
/** The empty state's retry control: resend the on-demand command by hand. */
async function retryRefresh() {
const id = seriesId;
if (id === null) {
return;
}
const ticket = sequence;
refreshGaveUp = false;
renderSeasons();
const outcome = await refreshSeriesMetadata(id);
if (ticket !== sequence || seriesId !== id) {
return;
}
if (outcome.kind === "error") {
refreshGaveUp = true;
setStatus(`refresh failed — ${outcome.detail}`, "fault");
renderSeasons();
return;
}
syncRefreshWatch(ticket);
}
async function load() {
const id = seriesId;
if (id === null) {
@@ -3489,6 +3604,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
}
sequence += 1;
const ticket = sequence;
window.clearTimeout(refreshPollTimer);
setStatus("reading series…");
const [detail, seasonsOutcome, filesOutcome, fetchedRoots] = await Promise.all([
fetchSeries(id),
@@ -3518,6 +3634,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
paintHeader();
clearRichDetail();
renderSeasons();
syncRefreshWatch(ticket);
setStatus(null);
void loadMetadata(id, ticket);
if (focusKey !== null) {
@@ -3539,6 +3656,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
parentRoute = parent;
expanded = new Set();
focusKey = null;
refreshGaveUp = false;
deckEl.hidden = true;
view.hidden = false;
clearRemove();
@@ -3552,6 +3670,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
seriesId = null;
seasons = null;
sequence += 1;
window.clearTimeout(refreshPollTimer);
clearRemove();
clearRichDetail();
}
+15
View File
@@ -16,6 +16,8 @@ export interface ApiSeries {
year: number | null;
root_id: number;
blocked: boolean;
/** Null until a metadata refresh has stamped it (#160, #177). */
metadata_refreshed_at: string | null;
status: SeriesStatus;
wanted_episodes: number;
available_episodes: number;
@@ -79,6 +81,19 @@ export async function fetchSeasons(seriesId: number): Promise<SeasonsOutcome> {
}
}
/** Issue #177: resend the on-demand refresh a page gave up polling for. */
export async function refreshSeriesMetadata(seriesId: number): Promise<ActionOutcome> {
try {
const response = await fetch(`/api/series/${seriesId}/refresh-metadata`, { method: "POST" });
if (!response.ok) {
return { kind: "error", detail: await errorDetail(response) };
}
return { kind: "done" };
} catch {
return { kind: "error", detail: "daemon unreachable" };
}
}
/** One imported episode file as `/api/series/{id}/files` reports it (§7.4). */
export interface EpisodeFile {
episode_id: number;