239 lines
8.6 KiB
Rust
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);
|
|
}
|
|
}
|