Files
arr/crates/arr-subs/src/lib.rs
T
2026-08-25 01:04:43 +01:00

239 lines
8.6 KiB
Rust

//! 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 (#191, #192, #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};
pub mod convert;
pub mod decode;
// `unused_crate_dependencies` is a per-target lint; `wiremock` is only used
// from the `tests/podnapisi.rs` integration target, not from this library
// target's own `#[cfg(test)]` module.
#[cfg(test)]
use wiremock as _;
pub mod error;
pub mod model;
pub mod opensubtitles;
pub mod podnapisi;
pub mod srt;
pub mod sync;
pub mod translate;
pub use error::{Error, Result};
pub use model::{
Candidate, CandidateId, Fetched, MediaFile, MediaRef, ProviderId, SearchRequest, SubtitleFormat,
};
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, Syncer, DEFAULT_BINARY as ALASS_DEFAULT_BINARY};
pub use translate::{Backend, BackendId, Batch, BatchCue, TranslateFuture, TranslatedCue};
// The OpenSubtitles provider's HTTP paths are exercised by the integration
// tests, which link this crate; the unit-test target still has to satisfy
// the unused-crate-dependencies lint for the dev-dependency itself.
#[cfg(test)]
use wiremock as _;
/// 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>>;
/// 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<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);
}
#[test]
fn no_engine_is_compiled_in_by_default() {
// The default feature set is empty (Cargo.toml), matching #198's
// scope: the backends themselves are later issues.
assert!(super::compiled_engines().is_empty());
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);
}
}