feat(daemon): refresh metadata on add

The metadata lane runs daily, so a series added a moment ago showed no
seasons for up to 24 hours and a movie had no digital release date —
the field §6.2 gates targeted search on.

AppState now carries a MetadataCommand channel alongside the movie,
episode and season ones. Both create handlers send on it after the row
is committed, and a new daemon lane drains it. Its own task rather than
an arm of manual::run: a refresh against TMDB can take a while and must
not sit in front of an operator's manual search.

The add never waits on TMDB and never fails because of it. A refresh
that fails leaves metadata_refreshed_at NULL, which is what the daily
sweep already treats as due, so the title is retried rather than lost.
A command naming a title deleted in between finds no row and does
nothing. METADATA_INTERVAL is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-24 18:12:10 +01:00
parent e9806d7a84
commit d8797e1996
13 changed files with 803 additions and 100 deletions
+67 -2
View File
@@ -32,7 +32,7 @@ use utoipa::{IntoParams, ToSchema};
use crate::movies::{pool, rescore, Accepted, ApiError, ErrorBody, Release};
use crate::owners::Owner;
use crate::search::tmdb_client;
use crate::state::{AppState, EpisodeCommand, SeasonCommand};
use crate::state::{AppState, EpisodeCommand, MetadataCommand, SeasonCommand};
/// A series with the status derived from its episodes (§4.2).
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -471,9 +471,18 @@ pub async fn create(
)
.execute(pool(&state)?)
.await?;
let series_id = result.last_insert_rowid();
// Issue #176: a new series has no seasons until a refresh reveals them,
// and the metadata lane is daily. Ask it to run now. Asynchronous by
// design — the add is already committed and must not wait on, or fail
// because of, TMDB. If nothing is draining, the daily sweep still picks
// the series up: `metadata_refreshed_at` is NULL.
if let Err(error) = state.send_metadata_command(MetadataCommand::Series { series_id }) {
tracing::warn!(series_id, %error, "metadata refresh not queued for the new series");
}
Ok((
StatusCode::CREATED,
Json(load_series(&state, result.last_insert_rowid()).await?),
Json(load_series(&state, series_id).await?),
))
}
@@ -1740,6 +1749,62 @@ mod tests {
assert_eq!(vote, Some(8.417));
}
/// Issue #176: the daily metadata lane is what reveals a new series'
/// seasons, so adding one queues a refresh instead of leaving the page
/// empty for up to a day. TMDB here is a closed port: the add still
/// returns 201 and the command is still queued, because the refresh is
/// the lane's problem and not the request's.
#[tokio::test]
async fn adding_a_series_queues_a_refresh_even_with_tmdb_down() {
let dir = tempfile::tempdir().expect("tempdir");
let database = arr_db::Db::connect(dir.path().join("arr.db"))
.await
.expect("connect database");
database.migrate().await.expect("migrate database");
let state = AppState::new(
Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into())
.with_tmdb_url("http://127.0.0.1:1".into())
.with_tmdb_api_key(Some("key".into())),
)
.expect("state")
.with_database(database);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let address = listener.local_addr().expect("address");
let served = state.clone();
tokio::spawn(async move { axum::serve(listener, router(served)).await.expect("serve") });
let base = format!("http://{address}");
let response = reqwest::Client::new()
.post(format!("{base}/api/series"))
.json(&serde_json::json!({
"tmdb_id": 82_728, "title": "Bluey", "root_id": 3
}))
.send()
.await
.expect("create series");
assert_eq!(response.status(), StatusCode::CREATED);
let series_id = response.json::<serde_json::Value>().await.expect("json")["id"]
.as_i64()
.expect("series id");
assert_eq!(
state.next_metadata_command().await.expect("command"),
MetadataCommand::Series { series_id }
);
let refreshed_at: Option<String> =
sqlx::query_scalar("SELECT metadata_refreshed_at FROM series WHERE id = ?")
.bind(series_id)
.fetch_one(state.database().expect("database").pool())
.await
.expect("series row");
assert!(
refreshed_at.is_none(),
"an unrefreshed series stays due for the scheduled sweep"
);
}
#[tokio::test]
async fn series_must_sit_on_a_tv_root() {
let (_dir, state, base) = application().await;