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
+1
View File
@@ -89,6 +89,7 @@ fn api_router() -> OpenApiRouter<AppState> {
.routes(routes!(metadata::movie_metadata)) .routes(routes!(metadata::movie_metadata))
.routes(routes!(series::list, series::create)) .routes(routes!(series::list, series::create))
.routes(routes!(series::get, series::update, series::delete)) .routes(routes!(series::get, series::update, series::delete))
.routes(routes!(series::refresh_metadata))
.routes(routes!(series::seasons, series::create_season)) .routes(routes!(series::seasons, series::create_season))
.routes(routes!(series::update_season)) .routes(routes!(series::update_season))
.routes(routes!(series::delete_season_files)) .routes(routes!(series::delete_season_files))
+86
View File
@@ -57,6 +57,10 @@ pub struct Series {
pub poster_path: Option<String>, pub poster_path: Option<String>,
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it. /// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
pub vote_average: Option<f64>, pub vote_average: Option<f64>,
/// 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<String>,
/// `airing`, `incomplete`, `waiting`, `complete` or `ended` (§4.2). /// `airing`, `incomplete`, `waiting`, `complete` or `ended` (§4.2).
pub status: String, pub status: String,
/// Episodes currently marked wanted (§4.1 — the only intent). /// 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, blocked: row.blocked,
poster_path: row.poster_path.clone(), poster_path: row.poster_path.clone(),
vote_average: row.vote_average, 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(), status: status_name(derive_series_status(&core_series(row), episodes, now)).to_owned(),
wanted_episodes: i64::try_from(wanted.count()).unwrap_or(i64::MAX), wanted_episodes: i64::try_from(wanted.count()).unwrap_or(i64::MAX),
available_episodes: i64::try_from(available.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?)) 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<AppState>,
Path(series_id): Path<i64>,
) -> Result<(StatusCode, Json<Accepted>), 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( #[utoipa::path(
patch, path = "/api/series/{series_id}", tag = "series", request_body = UpdateSeries, patch, path = "/api/series/{series_id}", tag = "series", request_body = UpdateSeries,
params(("series_id" = i64, Path, description = "Series row id")), 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] #[tokio::test]
async fn series_must_sit_on_a_tv_root() { async fn series_must_sit_on_a_tv_root() {
let (_dir, state, base) = application().await; let (_dir, state, base) = application().await;
+123 -4
View File
@@ -87,6 +87,7 @@ import {
fileAttributeTags, fileAttributeTags,
formatAirDate, formatAirDate,
isUnaired, isUnaired,
refreshSeriesMetadata,
removeEpisodeFiles, removeEpisodeFiles,
removeSeasonFiles, removeSeasonFiles,
removeSeries, removeSeries,
@@ -2981,6 +2982,14 @@ interface SeriesView {
const PAD_TWO = (value: number): string => String(value).padStart(2, "0"); 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 { function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
const view = must<HTMLElement>("#series"); const view = must<HTMLElement>("#series");
const deckEl = must<HTMLElement>("#deck"); 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 // guards a stale fetch from painting over a newer view
let sequence = 0; let sequence = 0;
let removed: ((parent: Route) => void) | null = null; 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") { function setStatus(text: string | null, tone?: "fault") {
statusEl.hidden = text === null; statusEl.hidden = text === null;
@@ -3466,6 +3478,42 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
return item; 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() { function renderSeasons() {
seasonsList.replaceChildren(); seasonsList.replaceChildren();
if (!seasons) { if (!seasons) {
@@ -3475,13 +3523,80 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
seasonsList.append(seasonRow(season)); seasonsList.append(seasonRow(season));
} }
if (seasons.length === 0) { if (seasons.length === 0) {
const none = document.createElement("li"); seasonsList.append(seasonsEmptyRow());
none.className = "rel rel-none readout dim";
none.textContent = "no seasons revealed yet — a metadata refresh fills this in";
seasonsList.append(none);
} }
} }
/**
* 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() { async function load() {
const id = seriesId; const id = seriesId;
if (id === null) { if (id === null) {
@@ -3489,6 +3604,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
} }
sequence += 1; sequence += 1;
const ticket = sequence; const ticket = sequence;
window.clearTimeout(refreshPollTimer);
setStatus("reading series…"); setStatus("reading series…");
const [detail, seasonsOutcome, filesOutcome, fetchedRoots] = await Promise.all([ const [detail, seasonsOutcome, filesOutcome, fetchedRoots] = await Promise.all([
fetchSeries(id), fetchSeries(id),
@@ -3518,6 +3634,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
paintHeader(); paintHeader();
clearRichDetail(); clearRichDetail();
renderSeasons(); renderSeasons();
syncRefreshWatch(ticket);
setStatus(null); setStatus(null);
void loadMetadata(id, ticket); void loadMetadata(id, ticket);
if (focusKey !== null) { if (focusKey !== null) {
@@ -3539,6 +3656,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
parentRoute = parent; parentRoute = parent;
expanded = new Set(); expanded = new Set();
focusKey = null; focusKey = null;
refreshGaveUp = false;
deckEl.hidden = true; deckEl.hidden = true;
view.hidden = false; view.hidden = false;
clearRemove(); clearRemove();
@@ -3552,6 +3670,7 @@ function seriesMain(tvDeck: TvReleasesView, views: HideableView[]): SeriesView {
seriesId = null; seriesId = null;
seasons = null; seasons = null;
sequence += 1; sequence += 1;
window.clearTimeout(refreshPollTimer);
clearRemove(); clearRemove();
clearRichDetail(); clearRichDetail();
} }
+15
View File
@@ -16,6 +16,8 @@ export interface ApiSeries {
year: number | null; year: number | null;
root_id: number; root_id: number;
blocked: boolean; blocked: boolean;
/** Null until a metadata refresh has stamped it (#160, #177). */
metadata_refreshed_at: string | null;
status: SeriesStatus; status: SeriesStatus;
wanted_episodes: number; wanted_episodes: number;
available_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). */ /** One imported episode file as `/api/series/{id}/files` reports it (§7.4). */
export interface EpisodeFile { export interface EpisodeFile {
episode_id: number; episode_id: number;