feat(web): tell pending seasons from settled empty

This commit is contained in:
Miguel Palhas
2026-08-24 18:58:35 +01:00
parent 88a353dc68
commit b2147fe7b9
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!(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))
+86
View File
@@ -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;