d8797e1996
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>
239 lines
8.2 KiB
Rust
239 lines
8.2 KiB
Rust
//! What the API needs to answer a request: one HTTP client and the addresses
|
|
//! of the three upstreams the service cannot work without (DESIGN.md §3).
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use arr_db::Db;
|
|
use tokio::sync::mpsc;
|
|
|
|
/// The TMDB API root. Not a bootstrap setting (DESIGN.md §10) — only the key
|
|
/// is configurable, so this is a constant that tests point elsewhere.
|
|
pub const DEFAULT_TMDB_URL: &str = "https://api.themoviedb.org/3";
|
|
|
|
/// How long an upstream has to answer a probe before it counts as
|
|
/// unreachable. Health is polled by a human waiting on a page.
|
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(3);
|
|
|
|
/// Where the upstreams live, and the keys for the two that need one.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Upstreams {
|
|
pub prowlarr_url: String,
|
|
pub prowlarr_api_key: Option<String>,
|
|
pub transmission_url: String,
|
|
pub tmdb_url: String,
|
|
pub tmdb_api_key: Option<String>,
|
|
}
|
|
|
|
impl Upstreams {
|
|
/// Every upstream at its documented default, no keys.
|
|
#[must_use]
|
|
pub fn new(prowlarr_url: String, transmission_url: String) -> Self {
|
|
Self {
|
|
prowlarr_url,
|
|
prowlarr_api_key: None,
|
|
transmission_url,
|
|
tmdb_url: DEFAULT_TMDB_URL.to_string(),
|
|
tmdb_api_key: None,
|
|
}
|
|
}
|
|
|
|
/// Set the Prowlarr API key.
|
|
#[must_use]
|
|
pub fn with_prowlarr_api_key(mut self, key: Option<String>) -> Self {
|
|
self.prowlarr_api_key = key;
|
|
self
|
|
}
|
|
|
|
/// Set the TMDB API key.
|
|
#[must_use]
|
|
pub fn with_tmdb_api_key(mut self, key: Option<String>) -> Self {
|
|
self.tmdb_api_key = key;
|
|
self
|
|
}
|
|
|
|
/// Point TMDB somewhere other than the real API. Tests and the
|
|
/// `ARR_TMDB_URL` e2e seam only.
|
|
#[must_use]
|
|
pub fn with_tmdb_url(mut self, url: String) -> Self {
|
|
self.tmdb_url = url;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Shared handler state. Cheap to clone: the client pools internally and the
|
|
/// upstream addresses are behind an [`Arc`].
|
|
#[derive(Debug, Clone)]
|
|
pub struct AppState {
|
|
http: reqwest::Client,
|
|
upstreams: Arc<Upstreams>,
|
|
database: Option<Db>,
|
|
movie_commands: mpsc::Sender<MovieCommand>,
|
|
pending_movie_commands: Arc<tokio::sync::Mutex<mpsc::Receiver<MovieCommand>>>,
|
|
episode_commands: mpsc::Sender<EpisodeCommand>,
|
|
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.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum MovieCommand {
|
|
Search { movie_id: i64 },
|
|
Grab { movie_id: i64, release_id: i64 },
|
|
}
|
|
|
|
/// Work explicitly requested through the series API.
|
|
///
|
|
/// Intent lives at the leaf (`DESIGN.md` §4.1), so a manual action names an
|
|
/// episode even when the release that satisfies it is a season pack.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum EpisodeCommand {
|
|
Search { episode_id: i64 },
|
|
Grab { episode_id: i64, release_id: i64 },
|
|
}
|
|
|
|
/// Work explicitly requested through the season deck API (issue #125).
|
|
///
|
|
/// A season pack is one torrent for the whole season and the grab targets
|
|
/// `target_kind = 'season'`, so the manual action names a season — unlike
|
|
/// [`EpisodeCommand`], where intent lives at the leaf.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum SeasonCommand {
|
|
Search { season_id: i64 },
|
|
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.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// If the TLS backend cannot be initialised.
|
|
pub fn new(upstreams: Upstreams) -> Result<Self, reqwest::Error> {
|
|
let http = reqwest::Client::builder().timeout(PROBE_TIMEOUT).build()?;
|
|
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),
|
|
database: None,
|
|
movie_commands,
|
|
pending_movie_commands: Arc::new(tokio::sync::Mutex::new(pending_movie_commands)),
|
|
episode_commands,
|
|
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)),
|
|
})
|
|
}
|
|
|
|
/// Attach the migrated application database.
|
|
#[must_use]
|
|
pub fn with_database(mut self, database: Db) -> Self {
|
|
self.database = Some(database);
|
|
self
|
|
}
|
|
|
|
/// Wait for the next manual movie action in the daemon's reconcile loop.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// If every sender has been dropped.
|
|
pub async fn next_movie_command(&self) -> Option<MovieCommand> {
|
|
self.pending_movie_commands.lock().await.recv().await
|
|
}
|
|
|
|
/// Wait for the next manual episode action in the daemon's reconcile loop.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// If every sender has been dropped.
|
|
pub async fn next_episode_command(&self) -> Option<EpisodeCommand> {
|
|
self.pending_episode_commands.lock().await.recv().await
|
|
}
|
|
|
|
/// Wait for the next manual season action in the daemon's reconcile loop.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// If every sender has been dropped.
|
|
pub async fn next_season_command(&self) -> Option<SeasonCommand> {
|
|
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
|
|
}
|
|
|
|
pub(crate) fn upstreams(&self) -> &Upstreams {
|
|
&self.upstreams
|
|
}
|
|
|
|
pub(crate) fn database(&self) -> Option<&Db> {
|
|
self.database.as_ref()
|
|
}
|
|
|
|
pub(crate) fn send_movie_command(
|
|
&self,
|
|
command: MovieCommand,
|
|
) -> Result<(), mpsc::error::TrySendError<MovieCommand>> {
|
|
self.movie_commands.try_send(command)
|
|
}
|
|
|
|
pub(crate) fn send_episode_command(
|
|
&self,
|
|
command: EpisodeCommand,
|
|
) -> Result<(), mpsc::error::TrySendError<EpisodeCommand>> {
|
|
self.episode_commands.try_send(command)
|
|
}
|
|
|
|
pub(crate) fn send_season_command(
|
|
&self,
|
|
command: SeasonCommand,
|
|
) -> 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)
|
|
}
|
|
}
|