//! Subtitles — everything subtitle-shaped that touches the outside world. //! //! DESIGN.md §15. This crate owns the providers, the extraction of embedded //! text tracks, the translation backends and the `alass` wrapper. It is the //! subtitle counterpart of `arr-probe`: it shells out and makes network calls, //! and the rules that consume what it returns stay pure in `arr-core`. //! //! What it deliberately does not own is any judgement. Which candidate wins is //! ranking (#185), whether a language is still wanted is the reconcile loop's //! (#196), and where a file lands on disk is §7.4's layout (#195). A provider //! here reports facts and downloads what it is told to. //! //! Translation backends each sit behind their own cargo feature — //! `translate-openai`, `translate-deepl`, `translate-google`, //! `translate-command`. The features are declared; the backends live here //! behind their flags (`openai` #191, `deepl`/`google` #192, `command` //! #193). What they share — the [`translate::Backend`] //! trait, cue chunking, reply validation — lives in [`translate`], and the SRT //! cues it works in live in [`srt`]. Which compiled-in backend runs is a //! database setting, so switching engines never needs a rebuild. use std::{fmt, future::Future, pin::Pin}; #[cfg(feature = "translate-command")] pub mod command; pub mod convert; pub mod decode; #[cfg(feature = "translate-deepl")] pub mod deepl; pub mod error; #[cfg(feature = "translate-google")] pub mod google; pub mod model; #[cfg(feature = "translate-openai")] pub mod openai; pub mod opensubtitles; pub mod podnapisi; pub mod srt; pub mod sync; pub mod translate; #[cfg(feature = "translate-command")] pub use command::{Command, CommandConfig, DEFAULT_TIMEOUT as COMMAND_DEFAULT_TIMEOUT}; #[cfg(feature = "translate-deepl")] pub use deepl::{DeepL, DeepLConfig}; pub use error::{Error, Result}; #[cfg(feature = "translate-google")] pub use google::{Google, GoogleConfig}; pub use model::{ Candidate, CandidateId, Fetched, MediaFile, MediaRef, ProviderId, SearchRequest, SubtitleFormat, }; #[cfg(feature = "translate-openai")] pub use openai::{OpenAi, OpenAiConfig}; pub use opensubtitles::{moviehash, OpenSubtitles, OpenSubtitlesConfig}; pub use podnapisi::{Podnapisi, PodnapisiBuilder, DEFAULT_BASE_URL as PODNAPISI_DEFAULT_BASE_URL}; pub use srt::Cue; pub use sync::{ Outcome, Rejection, Settled, SyncState, Syncer, DEFAULT_BINARY as ALASS_DEFAULT_BINARY, }; pub use translate::{Backend, BackendId, Batch, BatchCue, TranslateFuture, TranslatedCue}; // `unused_crate_dependencies` is a per-target lint. Every provider and // backend exercises its HTTP paths from an integration target under `tests/`, // so this library target never uses `wiremock` itself and has to say so. #[cfg(test)] use wiremock as _; /// The candidates one search turned up. pub type SearchFuture<'a> = Pin>> + Send + 'a>>; /// One downloaded subtitle. pub type DownloadFuture<'a> = Pin> + Send + 'a>>; /// Translation engine names DESIGN.md §15 recognises, whether or not this /// binary has the matching cargo feature compiled in. pub const ENGINES: &[&str] = &["openai", "deepl", "google", "command"]; /// Which of [`ENGINES`] this binary actually compiled in. /// /// A backend whose feature is not compiled in must not be selectable /// (DESIGN.md §15, issue #198): the settings API checks a requested engine /// against this list before it ever reaches the database. #[must_use] pub fn compiled_engines() -> Vec<&'static str> { let mut engines = Vec::new(); if cfg!(feature = "translate-openai") { engines.push("openai"); } if cfg!(feature = "translate-deepl") { engines.push("deepl"); } if cfg!(feature = "translate-google") { engines.push("google"); } if cfg!(feature = "translate-command") { engines.push("command"); } engines } /// One subtitle source: OpenSubtitles.com, Podnapisi (DESIGN.md §15). /// /// Boxed futures rather than `async fn`, so that the trait stays object safe /// and the reconcile loop can hold the enabled providers as /// `Vec>` — which set is enabled is a database setting, not /// a compile-time fact. This is the same shape `arr-daemon`'s reconcile /// actions use. pub trait Provider: fmt::Debug + Send + Sync { /// The name this provider answers to in candidates, settings and the UI. fn id(&self) -> ProviderId; /// Find candidates for one media file. /// /// A provider that has nothing returns an empty list. An error means the /// provider could not answer — an empty answer is not a failure, and the /// difference decides whether the loop moves on to translating (§15). /// /// # Errors /// /// Anything in [`Error`]: unreachable, refused credentials, at its cap, or /// an answer that did not parse. fn search<'a>(&'a self, request: &'a SearchRequest) -> SearchFuture<'a>; /// Download one candidate this provider offered. /// /// The id must be one this provider issued; ids are opaque and are never /// portable between providers. /// /// # Errors /// /// Anything in [`Error`], and [`Error::NotFound`] when the candidate has /// gone away between the search and the download. fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a>; } #[cfg(test)] mod tests { use std::sync::Arc; use arr_core::Language; use super::{ Candidate, CandidateId, DownloadFuture, Fetched, MediaFile, MediaRef, Provider, ProviderId, SearchFuture, SearchRequest, SubtitleFormat, }; #[derive(Debug)] struct StubProvider; impl StubProvider { fn id() -> ProviderId { ProviderId::new("stub") } } impl Provider for StubProvider { fn id(&self) -> ProviderId { Self::id() } fn search<'a>(&'a self, request: &'a SearchRequest) -> SearchFuture<'a> { Box::pin(async move { Ok(request .languages .iter() .map(|language| Candidate { provider: Self::id(), id: CandidateId::new(language.to_string()), language: language.clone(), hash_match: true, release_name: request.file.release_name.clone(), group: None, source: None, rating: None, download_count: None, forced: false, sdh: false, }) .collect()) }) } fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a> { Box::pin(async move { Ok(Fetched { id: id.clone(), language: Language::PortuguesePortugal, format: SubtitleFormat::Srt, content: b"1\n00:00:01,000 --> 00:00:02,000\nol\xc3\xa1\n".to_vec(), }) }) } } fn request() -> SearchRequest { SearchRequest { file: MediaFile { path: "/mnt/media/film.mkv".into(), size: 1_234, release_name: Some("Film.2024.2160p.WEB-DL".to_owned()), media: MediaRef::Movie { tmdb_id: 42 }, }, languages: vec![ Language::PortuguesePortugal, Language::Other("en".to_owned()), ], } } #[tokio::test] async fn a_provider_is_usable_behind_a_trait_object() { // The reconcile loop holds whichever providers the settings enabled, // so the trait has to survive erasure — not only monomorphisation. let providers: Vec> = vec![Arc::new(StubProvider)]; let request = request(); let candidates = providers[0] .search(&request) .await .expect("the stub always answers"); assert_eq!(candidates.len(), 2); assert_eq!(candidates[0].provider, ProviderId::new("stub")); assert_eq!(candidates[0].language, Language::PortuguesePortugal); } #[test] fn every_compiled_engine_is_a_known_engine_name() { // Which engines are compiled in depends on the feature flags; what // must hold under every flag combination is that nothing appears in // `compiled_engines` that the settings API would not recognise. for engine in super::compiled_engines() { assert!(super::ENGINES.contains(&engine)); } } #[tokio::test] async fn a_candidate_id_round_trips_to_its_download() { let provider = StubProvider; let candidates = provider .search(&request()) .await .expect("the stub always answers"); let wanted = &candidates[0].id; let fetched = provider.download(wanted).await.expect("the stub serves it"); assert_eq!(&fetched.id, wanted); assert_eq!(fetched.format, SubtitleFormat::Srt); } }