Files
arr/crates/arr-api/src/state.rs
T
Miguel PalhasandClaude Opus 5 449750e426 feat(arr): serve the subtitle API
Issue #199, DESIGN.md §15 and §9.1. Lists what exists per media file and
per title, runs the enabled providers for one language and returns every
candidate with §9.3's verdict vocabulary — including the rejected ones
naming the rule that killed each — then grabs, translates and deletes.

Inline rather than 202-and-poll like the release deck: a subtitle search
is one or two HTTP calls and nothing persists its candidates, so there
is nothing to come back for. The cost is that a grab repeats the
`forced` and `sdh` facts the search reported, since the server does not
remember them.

Every write ends by marking the language satisfied, whether or not it is
in the wanted set. That is §15's "manual actions bypass the wanted-set
logic": the operator asking for Spanish gets Spanish, and the loop does
not then read it as a gap. A forced track is the exception §15 names — it
covers signs only — so it is recorded and satisfies nothing.

`alass` (#194) does not run yet: a fetched sidecar is recorded unsynced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:27:03 +01:00

284 lines
9.9 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 arr_subs::{Backend, Provider};
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>>>,
subtitle_providers: Arc<Vec<Arc<dyn Provider>>>,
translation_backends: Arc<Vec<Arc<dyn Backend>>>,
}
/// 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)),
subtitle_providers: Arc::new(Vec::new()),
translation_backends: Arc::new(Vec::new()),
})
}
/// Attach the migrated application database.
#[must_use]
pub fn with_database(mut self, database: Db) -> Self {
self.database = Some(database);
self
}
/// Attach the subtitle providers this deployment has credentials for
/// (`DESIGN.md` §15).
///
/// Which of them a search actually runs is the `providers_enabled`
/// setting, read per request; this is the narrower fact of which ones
/// exist at all, because credentials are bootstrap config and never
/// reach the database (§10).
#[must_use]
pub fn with_subtitle_providers(mut self, providers: Vec<Arc<dyn Provider>>) -> Self {
self.subtitle_providers = Arc::new(providers);
self
}
/// Attach the translation backends this binary compiled in (§15).
///
/// Empty when no `translate-*` cargo feature is on, which is the default
/// — a manual translation then fails with a message saying so rather
/// than silently doing nothing.
#[must_use]
pub fn with_translation_backends(mut self, backends: Vec<Arc<dyn Backend>>) -> Self {
self.translation_backends = Arc::new(backends);
self
}
pub(crate) fn subtitle_provider(&self, id: &str) -> Option<&Arc<dyn Provider>> {
self.subtitle_providers
.iter()
.find(|provider| provider.id().as_str() == id)
}
pub(crate) fn subtitle_providers(&self) -> &[Arc<dyn Provider>] {
&self.subtitle_providers
}
pub(crate) fn translation_backend(&self, id: &str) -> Option<&Arc<dyn Backend>> {
self.translation_backends
.iter()
.find(|backend| backend.id().as_str() == id)
}
/// 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)
}
}