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
+24 -3
View File
@@ -8,6 +8,7 @@ mod import;
mod indexers;
mod jellyfin;
mod manual;
mod metadata;
mod notify;
mod reaper;
pub mod reconcile;
@@ -125,6 +126,10 @@ async fn run() -> Result<(), Error> {
let notifier = Notifier::new(config.ntfy_url.clone())?;
let (reconcile, manual_grab, manual_tv) =
reconcile_loop(&database, &config, &transmission, tmdb.as_ref(), &notifier)?;
// Issue #176: the on-demand half of the metadata lane needs its own
// handle — the sweep's `SeriesRefreshAction` is owned by `ReconcileLoop`,
// and the compat shim takes the other clone below.
let metadata_tmdb = tmdb.clone();
// Jellyseerr's Radarr shim (DESIGN.md §9.4) reads the same database and
// needs its own TMDB client for `movie/lookup`.
@@ -162,12 +167,18 @@ async fn run() -> Result<(), Error> {
// in the 64-slot buffer forever. Issue #132: the episode and season
// channels are drained by the same lane.
let mut manual_task = tokio::spawn(manual::run(
state,
database,
state.clone(),
database.clone(),
manual_grab,
manual_tv,
shutdown_rx,
shutdown_rx.clone(),
));
// Issue #176: adding a title queues a refresh here rather than waiting
// for the daily sweep. Its own task, not an arm of `manual::run`: a
// refresh can take a while against TMDB and must not sit in front of an
// operator's manual search.
let mut metadata_task =
tokio::spawn(metadata::run(state, database, metadata_tmdb, shutdown_rx));
let signal_tx = shutdown_tx.clone();
let server = async move {
axum::serve(listener, app)
@@ -184,18 +195,28 @@ async fn run() -> Result<(), Error> {
let _ = shutdown_tx.send(true);
reconcile_task.await?;
manual_task.await?;
metadata_task.await?;
result.map_err(Error::Serve)
}
result = &mut reconcile_task => {
result?;
let _ = shutdown_tx.send(true);
manual_task.await?;
metadata_task.await?;
server.await.map_err(Error::Serve)
}
result = &mut manual_task => {
result?;
let _ = shutdown_tx.send(true);
reconcile_task.await?;
metadata_task.await?;
server.await.map_err(Error::Serve)
}
result = &mut metadata_task => {
result?;
let _ = shutdown_tx.send(true);
reconcile_task.await?;
manual_task.await?;
server.await.map_err(Error::Serve)
}
}