feat(web): tell pending seasons from settled empty
This commit is contained in:
+123
-4
@@ -87,6 +87,7 @@ import {
|
||||
fileAttributeTags,
|
||||
formatAirDate,
|
||||
isUnaired,
|
||||
refreshSeriesMetadata,
|
||||
removeEpisodeFiles,
|
||||
removeSeasonFiles,
|
||||
removeSeries,
|
||||
@@ -2971,6 +2972,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");
|
||||
@@ -3005,6 +3014,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;
|
||||
@@ -3456,6 +3468,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) {
|
||||
@@ -3465,13 +3513,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) {
|
||||
@@ -3479,6 +3594,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),
|
||||
@@ -3508,6 +3624,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
paintHeader();
|
||||
clearRichDetail();
|
||||
renderSeasons();
|
||||
syncRefreshWatch(ticket);
|
||||
setStatus(null);
|
||||
void loadMetadata(id, ticket);
|
||||
if (focusKey !== null) {
|
||||
@@ -3529,6 +3646,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
parentRoute = parent;
|
||||
expanded = new Set();
|
||||
focusKey = null;
|
||||
refreshGaveUp = false;
|
||||
deckEl.hidden = true;
|
||||
view.hidden = false;
|
||||
clearRemove();
|
||||
@@ -3542,6 +3660,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
|
||||
seriesId = null;
|
||||
seasons = null;
|
||||
sequence += 1;
|
||||
window.clearTimeout(refreshPollTimer);
|
||||
clearRemove();
|
||||
clearRichDetail();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user