diff --git a/Cargo.lock b/Cargo.lock index 1042d70..ec3a232 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,6 +198,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "arr-subs" +version = "0.1.0" +dependencies = [ + "arr-core", + "thiserror", + "tokio", +] + [[package]] name = "assert-json-diff" version = "2.0.2" diff --git a/Cargo.toml b/Cargo.toml index 1e41ea8..5dff684 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ arr-indexer = { path = "crates/arr-indexer" } arr-meta = { path = "crates/arr-meta" } arr-parse = { path = "crates/arr-parse" } arr-probe = { path = "crates/arr-probe" } +arr-subs = { path = "crates/arr-subs" } # Runtime and HTTP axum = "0.8" diff --git a/crates/arr-subs/Cargo.toml b/crates/arr-subs/Cargo.toml new file mode 100644 index 0000000..bcabaff --- /dev/null +++ b/crates/arr-subs/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "arr-subs" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[features] +default = [] +# Translation backends, one feature each (DESIGN.md §15). The backends +# themselves land in #191, #192 and #193; the switches exist now so the crate +# they will hang off is already shaped for them, and so a build can be told +# which ones to compile in before any of them exists. +translate-openai = [] +translate-deepl = [] +translate-google = [] +translate-command = [] + +[dependencies] +arr-core.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/arr-subs/src/error.rs b/crates/arr-subs/src/error.rs new file mode 100644 index 0000000..6df7d43 --- /dev/null +++ b/crates/arr-subs/src/error.rs @@ -0,0 +1,105 @@ +//! Errors the subtitle crate can produce. + +use std::{path::PathBuf, time::Duration}; + +use crate::model::{CandidateId, ProviderId}; + +/// Result alias for every fallible operation in this crate. +pub type Result = std::result::Result; + +/// Everything that can go wrong reaching a subtitle provider. +/// +/// Every variant names the provider, because two are configured at once +/// (DESIGN.md §15) and "search failed" without a name is unactionable in the +/// operator message §9.5 folds these into. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The provider could not be reached at all, or the connection died. + #[error("{provider} is unreachable")] + Transport { + /// The provider that was called. + provider: ProviderId, + /// The underlying transport failure. + #[source] + source: Box, + }, + + /// The provider refused the credentials it was given. + /// + /// Separate from [`Error::Transport`] because it never resolves by + /// retrying: the configuration is wrong (DESIGN.md §10). + #[error("{provider} rejected the configured credentials")] + Unauthorized { + /// The provider that was called. + provider: ProviderId, + }, + + /// The provider's rate limit or daily download cap was hit. + /// + /// Its own variant so the reconcile loop can show it as a queue state + /// rather than a failure — being at the cap is expected (DESIGN.md §15). + #[error("{provider} is at its cap")] + RateLimited { + /// The provider that was called. + provider: ProviderId, + /// How long the provider asked to be left alone, when it says. + retry_after: Option, + }, + + /// A candidate the provider offered is no longer downloadable. + #[error("{provider} no longer has candidate {candidate}")] + NotFound { + /// The provider that was called. + provider: ProviderId, + /// The candidate that was asked for. + candidate: CandidateId, + }, + + /// The provider answered, but not with the shape its API documents. + #[error("{provider} answered with something unexpected: {detail}")] + Malformed { + /// The provider that was called. + provider: ProviderId, + /// What was wrong, short enough to log. + detail: String, + }, + + /// Reading or writing a file on the way to or from a provider failed. + #[error("could not read {path}")] + Io { + /// The file involved. + path: PathBuf, + /// The underlying IO failure. + #[source] + source: std::io::Error, + }, +} + +impl Error { + /// Whether calling the same provider again later is worth doing. + /// + /// A cap lifts and a network recovers; bad credentials and a malformed + /// response do not fix themselves, and retrying them only burns budget. + #[must_use] + pub const fn is_transient(&self) -> bool { + matches!(self, Self::Transport { .. } | Self::RateLimited { .. }) + } +} + +#[cfg(test)] +mod tests { + use super::Error; + use crate::model::ProviderId; + + #[test] + fn a_cap_is_worth_retrying_and_a_bad_key_is_not() { + let provider = ProviderId::new("opensubtitles"); + assert!(Error::RateLimited { + provider: provider.clone(), + retry_after: None, + } + .is_transient()); + assert!(!Error::Unauthorized { provider }.is_transient()); + } +} diff --git a/crates/arr-subs/src/lib.rs b/crates/arr-subs/src/lib.rs new file mode 100644 index 0000000..4585a6e --- /dev/null +++ b/crates/arr-subs/src/lib.rs @@ -0,0 +1,175 @@ +//! 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 themselves are +//! later issues. Which compiled-in backend runs is a database setting, so +//! switching engines never needs a rebuild. + +use std::{fmt, future::Future, pin::Pin}; + +pub mod error; +pub mod model; + +pub use error::{Error, Result}; +pub use model::{ + Candidate, CandidateId, Fetched, MediaFile, MediaRef, ProviderId, SearchRequest, SubtitleFormat, +}; + +/// The candidates one search turned up. +pub type SearchFuture<'a> = Pin>> + Send + 'a>>; + +/// One downloaded subtitle. +pub type DownloadFuture<'a> = Pin> + Send + 'a>>; + +/// 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); + } + + #[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); + } +} diff --git a/crates/arr-subs/src/model.rs b/crates/arr-subs/src/model.rs new file mode 100644 index 0000000..b36b089 --- /dev/null +++ b/crates/arr-subs/src/model.rs @@ -0,0 +1,219 @@ +//! The domain types a subtitle provider deals in. +//! +//! Everything here is a fact about a candidate or about the file it is wanted +//! for. None of it decides anything: which candidate wins is ranking, and +//! ranking is pure and lives in `arr-core` (#185). + +use std::{fmt, path::PathBuf}; + +use arr_core::{Language, Source}; + +/// The name a provider answers to in candidate rows, settings and the UI. +/// +/// A string rather than an enum: providers arrive one issue at a time and the +/// set is a deployment fact, not a domain rule. Ranking never looks at it. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ProviderId(String); + +impl ProviderId { + /// Name a provider. + #[must_use] + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + /// The name as written. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ProviderId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +/// A provider's own handle for one candidate, opaque to everything else. +/// +/// Each provider decides what this spells — a numeric file id, a URL, a +/// composite key. It is only ever handed back to the provider that issued it. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct CandidateId(String); + +impl CandidateId { + /// Wrap a provider's handle. + #[must_use] + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + /// The handle as written. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for CandidateId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +/// Which title a media file belongs to, in the terms providers search by. +/// +/// TMDB ids rather than titles: a provider that matches on a title string +/// matches the wrong film often enough to matter, and every title arr knows +/// carries a TMDB id already (DESIGN.md §4). +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum MediaRef { + /// A film. + Movie { + /// TMDB's id for the film. + tmdb_id: u64, + }, + /// One episode of a series. + Episode { + /// TMDB's id for the series, not for the episode. + tmdb_id: u64, + /// Season number as TMDB numbers it. + season: u16, + /// Episode number within the season. + episode: u16, + }, +} + +/// The file subtitles are wanted for. +/// +/// The path and size are here because `moviehash` — the signal that wins +/// ranking outright (DESIGN.md §15) — is computed from the bytes of the file +/// and is quoted alongside its length. Computing it is the searching +/// provider's job, not this type's: only OpenSubtitles.com accepts one. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MediaFile { + /// Where the video sits on disk. + pub path: PathBuf, + /// Its length in bytes. + pub size: u64, + /// The release name it was imported under, when one is known. Providers + /// that index by release name search on it, and ranking scores an exact + /// match against it. + pub release_name: Option, + /// The title the file belongs to. + pub media: MediaRef, +} + +/// One search put to one provider. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SearchRequest { + /// The file subtitles are wanted for. + pub file: MediaFile, + /// The languages worth returning. A provider may answer with fewer, and + /// answering with more is not an error — ranking discards the rest. + pub languages: Vec, +} + +/// One subtitle a provider is offering, with the facts ranking needs. +/// +/// Nothing here is normalised or scored. A provider reports what its API says +/// and leaves every judgement to `arr-core` (#185), so that the rule which +/// rejected a candidate can be named against the fact that triggered it. +#[derive(Clone, Debug, PartialEq)] +pub struct Candidate { + /// The provider offering it. + pub provider: ProviderId, + /// That provider's handle for it, for [`Provider::download`]. + /// + /// [`Provider::download`]: crate::Provider::download + pub id: CandidateId, + /// The language it claims to be in, pt-PT and pt-BR kept apart. + pub language: Language, + /// Whether the provider matched it by `moviehash` — the exact file, not + /// merely the same title. + pub hash_match: bool, + /// The release the subtitle was timed against, when the provider says. + pub release_name: Option, + /// The release group of that release. + pub group: Option, + /// The source of that release. + pub source: Option, + /// Uploader rating, on whatever scale the provider uses. A tiebreaker + /// only, so the scale never has to be reconciled between providers. + pub rating: Option, + /// How many times it has been downloaded. The second tiebreaker. + pub download_count: Option, + /// A forced track: foreign lines and on-screen signs only. It never + /// satisfies a want, and arr never goes looking for one (DESIGN.md §15). + pub forced: bool, + /// Subtitles for the deaf and hard of hearing. Complete, so it satisfies, + /// but ranked below a plain subtitle for the same language. + pub sdh: bool, +} + +/// The wire format of a downloaded subtitle. +/// +/// Sidecars on disk are SRT (DESIGN.md §15), but providers serve other things +/// and converting is not this crate's decision to make silently. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum SubtitleFormat { + /// `SubRip`, the format sidecars are written in. + Srt, + /// Advanced `SubStation` Alpha, and SSA with it. + Ass, + /// `WebVTT`. + Vtt, + /// Something else, spelled as the provider spelled it. + Other(String), +} + +impl fmt::Display for SubtitleFormat { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Srt => formatter.write_str("srt"), + Self::Ass => formatter.write_str("ass"), + Self::Vtt => formatter.write_str("vtt"), + Self::Other(format) => formatter.write_str(format), + } + } +} + +/// A downloaded subtitle, in memory. +/// +/// Bytes rather than a path: what happens next is not the provider's call. +/// Syncing runs `alass` over it (#194) and only then does it get a name and a +/// place in the title folder (#195). +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Fetched { + /// The candidate that was downloaded. + pub id: CandidateId, + /// The language it is in. + pub language: Language, + /// The format the provider served. + pub format: SubtitleFormat, + /// The subtitle itself, exactly as it arrived. Not decoded to UTF-8 here: + /// providers serve Latin-1 and Windows-1252 files, and guessing wrong is + /// worse than handing the bytes on. + pub content: Vec, +} + +#[cfg(test)] +mod tests { + use super::{CandidateId, ProviderId, SubtitleFormat}; + + #[test] + fn ids_render_as_the_string_they_wrap() { + assert_eq!( + ProviderId::new("opensubtitles").to_string(), + "opensubtitles" + ); + assert_eq!(CandidateId::new("123456").as_str(), "123456"); + } + + #[test] + fn an_unknown_format_keeps_its_own_spelling() { + assert_eq!(SubtitleFormat::Srt.to_string(), "srt"); + assert_eq!(SubtitleFormat::Other("sub".to_owned()).to_string(), "sub"); + } +}