@@ -89,6 +89,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.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))
|
||||
|
||||
@@ -57,6 +57,10 @@ pub struct Series {
|
||||
pub poster_path: Option<String>,
|
||||
/// TMDB's rating, out of 10; `null` when TMDB has no votes for it.
|
||||
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).
|
||||
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<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(
|
||||
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;
|
||||
|
||||
+123
-4
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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