//! 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, pub transmission_url: String, pub tmdb_url: String, pub tmdb_api_key: Option, } 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) -> Self { self.prowlarr_api_key = key; self } /// Set the TMDB API key. #[must_use] pub fn with_tmdb_api_key(mut self, key: Option) -> 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, database: Option, movie_commands: mpsc::Sender, pending_movie_commands: Arc>>, episode_commands: mpsc::Sender, pending_episode_commands: Arc>>, season_commands: mpsc::Sender, pending_season_commands: Arc>>, metadata_commands: mpsc::Sender, pending_metadata_commands: Arc>>, } /// 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 { 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 { 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 { 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 { 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 { 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> { self.movie_commands.try_send(command) } pub(crate) fn send_episode_command( &self, command: EpisodeCommand, ) -> Result<(), mpsc::error::TrySendError> { self.episode_commands.try_send(command) } pub(crate) fn send_season_command( &self, command: SeasonCommand, ) -> Result<(), mpsc::error::TrySendError> { 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> { self.metadata_commands.try_send(command) } }