diff --git a/crates/arr-api/src/lib.rs b/crates/arr-api/src/lib.rs index 1770cbf..ccd645d 100644 --- a/crates/arr-api/src/lib.rs +++ b/crates/arr-api/src/lib.rs @@ -89,6 +89,7 @@ fn api_router() -> OpenApiRouter { .routes(routes!(metadata::movie_metadata)) .routes(routes!(series::list, series::create)) .routes(routes!(series::get, series::update, series::delete)) + .routes(routes!(series::refresh_metadata)) .routes(routes!(series::seasons, series::create_season)) .routes(routes!(series::update_season)) .routes(routes!(series::delete_season_files)) diff --git a/crates/arr-api/src/series.rs b/crates/arr-api/src/series.rs index a483891..f7dd443 100644 --- a/crates/arr-api/src/series.rs +++ b/crates/arr-api/src/series.rs @@ -57,6 +57,10 @@ pub struct Series { pub poster_path: Option, /// TMDB's rating, out of 10; `null` when TMDB has no votes for it. pub vote_average: Option, + /// NULL until a metadata refresh has stamped it (#160). Issue #177: this + /// is what tells the SPA a series with no seasons yet is still seeding + /// its first refresh, rather than a title upstream genuinely lists none. + pub metadata_refreshed_at: Option, /// `airing`, `incomplete`, `waiting`, `complete` or `ended` (§4.2). pub status: String, /// Episodes currently marked wanted (§4.1 — the only intent). @@ -327,6 +331,7 @@ fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime) blocked: row.blocked, poster_path: row.poster_path.clone(), vote_average: row.vote_average, + metadata_refreshed_at: row.metadata_refreshed_at.clone(), status: status_name(derive_series_status(&core_series(row), episodes, now)).to_owned(), wanted_episodes: i64::try_from(wanted.count()).unwrap_or(i64::MAX), available_episodes: i64::try_from(available.count()).unwrap_or(i64::MAX), @@ -509,6 +514,31 @@ pub async fn get( Ok(Json(load_series(&state, id).await?)) } +#[utoipa::path( + post, path = "/api/series/{series_id}/refresh-metadata", tag = "series", + params(("series_id" = i64, Path, description = "Series row id")), + responses( + (status = 202, body = Accepted), + (status = 404, body = ErrorBody), + (status = 500, body = ErrorBody), + (status = 503, body = ErrorBody) + ) +)] +/// Issue #177: the same on-demand command `create` sends on add, resent by +/// hand when a page gave up polling for it. Same terms — asynchronous, best +/// effort, and a failure leaves `metadata_refreshed_at` NULL for the daily +/// sweep to pick up. +pub async fn refresh_metadata( + State(state): State, + Path(series_id): Path, +) -> Result<(StatusCode, Json), ApiError> { + load_series_row(&state, series_id).await?; + state + .send_metadata_command(MetadataCommand::Series { series_id }) + .map_err(|_| ApiError::Unavailable)?; + Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) +} + #[utoipa::path( patch, path = "/api/series/{series_id}", tag = "series", request_body = UpdateSeries, params(("series_id" = i64, Path, description = "Series row id")), @@ -1805,6 +1835,62 @@ mod tests { ); } + /// Issue #177: the series response carries `metadata_refreshed_at` so + /// the SPA can tell a never-refreshed series from a settled empty one. + #[tokio::test] + async fn series_response_exposes_metadata_refreshed_at() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, true).await; + assert!(series["metadata_refreshed_at"].is_null()); + } + + /// Issue #177: the retry control a gave-up poll offers resends the same + /// on-demand command `create` sends, so a second refresh attempt does + /// not need the scheduled sweep to come around. + #[tokio::test] + async fn refreshing_metadata_by_hand_queues_the_same_command() { + let (_dir, state, base) = application().await; + let root_id = tv_root(&state, "main").await; + let series = add_series(&base, root_id, true).await; + let series_id = series["id"].as_i64().expect("series id"); + assert_eq!( + state + .next_metadata_command() + .await + .expect("create's command"), + MetadataCommand::Series { series_id } + ); + + let response = reqwest::Client::new() + .post(format!("{base}/api/series/{series_id}/refresh-metadata")) + .send() + .await + .expect("refresh metadata"); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!( + state + .next_metadata_command() + .await + .expect("retry's command"), + MetadataCommand::Series { series_id } + ); + } + + #[tokio::test] + async fn refreshing_metadata_for_an_unknown_series_is_a_404() { + let (_dir, _state, base) = application().await; + + let response = reqwest::Client::new() + .post(format!("{base}/api/series/404/refresh-metadata")) + .send() + .await + .expect("refresh metadata"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + #[tokio::test] async fn series_must_sit_on_a_tv_root() { let (_dir, state, base) = application().await; diff --git a/web/src/main.ts b/web/src/main.ts index 509bfc6..cd15fbd 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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("#series"); const deckEl = must("#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(); } diff --git a/web/src/series.ts b/web/src/series.ts index f5dbedc..6d1deeb 100644 --- a/web/src/series.ts +++ b/web/src/series.ts @@ -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 { } } +/** Issue #177: resend the on-demand refresh a page gave up polling for. */ +export async function refreshSeriesMetadata(seriesId: number): Promise { + 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;