Files
arr/crates/arr-api/src/state.rs
T
2026-08-23 16:49:42 +01:00

198 lines
6.4 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>>>,
}
/// 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 },
}
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);
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)),
})
}
/// 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
}
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)
}
}