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:
@@ -40,7 +40,8 @@ pub use series::{
|
||||
UpdateSeason, UpdateSeries,
|
||||
};
|
||||
pub use state::{
|
||||
AppState, EpisodeCommand, MovieCommand, SeasonCommand, Upstreams, DEFAULT_TMDB_URL,
|
||||
AppState, EpisodeCommand, MetadataCommand, MovieCommand, SeasonCommand, Upstreams,
|
||||
DEFAULT_TMDB_URL,
|
||||
};
|
||||
pub use trailer::{Trailer, TrailerKind};
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::owners::Owner;
|
||||
use crate::state::{AppState, MovieCommand};
|
||||
use crate::state::{AppState, MetadataCommand, MovieCommand};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct Movie {
|
||||
@@ -330,9 +330,18 @@ pub async fn create(
|
||||
)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
let movie_id = result.last_insert_rowid();
|
||||
// Issue #176: the digital release date §6.2 gates targeted search on
|
||||
// comes from a refresh, 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 movie up: `metadata_refreshed_at` is NULL.
|
||||
if let Err(error) = state.send_metadata_command(MetadataCommand::Movie { movie_id }) {
|
||||
tracing::warn!(movie_id, %error, "metadata refresh not queued for the new movie");
|
||||
}
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(load_movie(&state, result.last_insert_rowid()).await?),
|
||||
Json(load_movie(&state, movie_id).await?),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1278,6 +1287,52 @@ mod tests {
|
||||
assert_eq!(title_target(root, root), None);
|
||||
}
|
||||
|
||||
/// Issue #176: §6.2 gates targeted search on the digital release date,
|
||||
/// and that only ever arrives from a metadata refresh. Adding a movie
|
||||
/// queues one rather than waiting for the daily lane. TMDB here is a
|
||||
/// closed port: the add still returns 201 and the command is still
|
||||
/// queued.
|
||||
#[tokio::test]
|
||||
async fn adding_a_movie_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 movie = add_movie(&base, 693_134, 1).await;
|
||||
let movie_id = movie["id"].as_i64().expect("id");
|
||||
|
||||
assert_eq!(
|
||||
state.next_metadata_command().await.expect("command"),
|
||||
MetadataCommand::Movie { movie_id }
|
||||
);
|
||||
let refreshed_at: Option<String> =
|
||||
sqlx::query_scalar("SELECT metadata_refreshed_at FROM movies WHERE id = ?")
|
||||
.bind(movie_id)
|
||||
.fetch_one(state.database().expect("database").pool())
|
||||
.await
|
||||
.expect("movie row");
|
||||
assert!(
|
||||
refreshed_at.is_none(),
|
||||
"an unrefreshed movie stays due for the scheduled sweep"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn release_actions_are_scoped_to_the_movie() {
|
||||
let (_dir, state, base) = application().await;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -74,6 +74,8 @@ pub struct AppState {
|
||||
pending_episode_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<EpisodeCommand>>>,
|
||||
season_commands: mpsc::Sender<SeasonCommand>,
|
||||
pending_season_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<SeasonCommand>>>,
|
||||
metadata_commands: mpsc::Sender<MetadataCommand>,
|
||||
pending_metadata_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MetadataCommand>>>,
|
||||
}
|
||||
|
||||
/// Work explicitly requested through the movie API.
|
||||
@@ -104,6 +106,22 @@ pub enum SeasonCommand {
|
||||
Grab { season_id: i64, release_id: i64 },
|
||||
}
|
||||
|
||||
/// A title whose TMDB metadata should be refreshed now rather than on the
|
||||
/// daily lane's next tick (issue #176).
|
||||
///
|
||||
/// Sent when a title is added: a new series has no seasons at all until a
|
||||
/// refresh reveals them, and a new movie has no digital release date, so
|
||||
/// waiting up to a day is the difference between a usable page and an empty
|
||||
/// one. Unlike the other three commands this is not an operator action, so a
|
||||
/// full channel is dropped rather than reported — the scheduled sweep still
|
||||
/// owns the title, because a title that was never refreshed keeps
|
||||
/// `metadata_refreshed_at` NULL.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MetadataCommand {
|
||||
Series { series_id: i64 },
|
||||
Movie { movie_id: i64 },
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Build the state, including the shared HTTP client.
|
||||
///
|
||||
@@ -115,6 +133,7 @@ impl AppState {
|
||||
let (movie_commands, pending_movie_commands) = mpsc::channel(64);
|
||||
let (episode_commands, pending_episode_commands) = mpsc::channel(64);
|
||||
let (season_commands, pending_season_commands) = mpsc::channel(64);
|
||||
let (metadata_commands, pending_metadata_commands) = mpsc::channel(64);
|
||||
Ok(Self {
|
||||
http,
|
||||
upstreams: Arc::new(upstreams),
|
||||
@@ -125,6 +144,8 @@ impl AppState {
|
||||
pending_episode_commands: Arc::new(tokio::sync::Mutex::new(pending_episode_commands)),
|
||||
season_commands,
|
||||
pending_season_commands: Arc::new(tokio::sync::Mutex::new(pending_season_commands)),
|
||||
metadata_commands,
|
||||
pending_metadata_commands: Arc::new(tokio::sync::Mutex::new(pending_metadata_commands)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,6 +183,16 @@ impl AppState {
|
||||
self.pending_season_commands.lock().await.recv().await
|
||||
}
|
||||
|
||||
/// Wait for the next on-demand metadata refresh in the daemon's metadata
|
||||
/// lane.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// If every sender has been dropped.
|
||||
pub async fn next_metadata_command(&self) -> Option<MetadataCommand> {
|
||||
self.pending_metadata_commands.lock().await.recv().await
|
||||
}
|
||||
|
||||
pub(crate) fn http(&self) -> &reqwest::Client {
|
||||
&self.http
|
||||
}
|
||||
@@ -194,4 +225,14 @@ impl AppState {
|
||||
) -> Result<(), mpsc::error::TrySendError<SeasonCommand>> {
|
||||
self.season_commands.try_send(command)
|
||||
}
|
||||
|
||||
/// Ask the metadata lane to refresh one title now. Best effort by
|
||||
/// design: an add must not fail because the channel is full or because
|
||||
/// nothing is draining it, so the caller logs and carries on.
|
||||
pub(crate) fn send_metadata_command(
|
||||
&self,
|
||||
command: MetadataCommand,
|
||||
) -> Result<(), mpsc::error::TrySendError<MetadataCommand>> {
|
||||
self.metadata_commands.try_send(command)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user