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:
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user