feat(web): tell pending seasons from settled empty
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -87,6 +87,7 @@ import {
|
|||||||
fileAttributeTags,
|
fileAttributeTags,
|
||||||
formatAirDate,
|
formatAirDate,
|
||||||
isUnaired,
|
isUnaired,
|
||||||
|
refreshSeriesMetadata,
|
||||||
removeEpisodeFiles,
|
removeEpisodeFiles,
|
||||||
removeSeasonFiles,
|
removeSeasonFiles,
|
||||||
removeSeries,
|
removeSeries,
|
||||||
@@ -2971,6 +2972,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");
|
||||||
@@ -3005,6 +3014,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;
|
||||||
@@ -3456,6 +3468,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) {
|
||||||
@@ -3465,13 +3513,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) {
|
||||||
@@ -3479,6 +3594,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),
|
||||||
@@ -3508,6 +3624,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) {
|
||||||
@@ -3529,6 +3646,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();
|
||||||
@@ -3542,6 +3660,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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user