feat(arr): add arr-subs with the provider trait

The skeleton the subtitles milestone hangs off: provider domain types, an
object-safe Provider trait, the crate's own error type, and the cargo
features the translation backends will sit behind. No provider, no
translation, no ranking — DESIGN.md §15 keeps ranking pure in arr-core.
This commit is contained in:
Miguel Palhas
2026-08-24 21:42:40 +01:00
parent 695cb69f2f
commit 8b83ac9511
6 changed files with 537 additions and 0 deletions
+105
View File
@@ -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<T> = std::result::Result<T, Error>;
/// 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<dyn std::error::Error + Send + Sync>,
},
/// 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<Duration>,
},
/// 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());
}
}
+175
View File
@@ -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<Box<dyn Future<Output = Result<Vec<Candidate>>> + Send + 'a>>;
/// One downloaded subtitle.
pub type DownloadFuture<'a> = Pin<Box<dyn Future<Output = Result<Fetched>> + 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<Arc<dyn Provider>>` — 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<Arc<dyn Provider>> = 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);
}
}
+219
View File
@@ -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<String>) -> 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<String>) -> 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<String>,
/// 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<Language>,
}
/// 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<String>,
/// The release group of that release.
pub group: Option<String>,
/// The source of that release.
pub source: Option<Source>,
/// Uploader rating, on whatever scale the provider uses. A tiebreaker
/// only, so the scale never has to be reconciled between providers.
pub rating: Option<f32>,
/// How many times it has been downloaded. The second tiebreaker.
pub download_count: Option<u64>,
/// 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<u8>,
}
#[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");
}
}