Merge #187: OpenSubtitles.com provider

Closes #187

# Conflicts:
#	Cargo.lock
#	crates/arr-subs/Cargo.toml
This commit is contained in:
Miguel Palhas
2026-08-24 23:04:46 +01:00
7 changed files with 1271 additions and 0 deletions
+7
View File
@@ -19,12 +19,19 @@ translate-command = []
[dependencies]
arr-core.workspace = true
arr-parse.workspace = true
chardetng.workspace = true
encoding_rs.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
wiremock.workspace = true
[lints]
workspace = true
+8
View File
@@ -23,6 +23,7 @@ use std::{fmt, future::Future, pin::Pin};
pub mod decode;
pub mod error;
pub mod model;
pub mod opensubtitles;
pub mod srt;
pub mod translate;
@@ -30,9 +31,16 @@ pub use error::{Error, Result};
pub use model::{
Candidate, CandidateId, Fetched, MediaFile, MediaRef, ProviderId, SearchRequest, SubtitleFormat,
};
pub use opensubtitles::{moviehash, OpenSubtitles, OpenSubtitlesConfig};
pub use srt::Cue;
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>>;
+723
View File
@@ -0,0 +1,723 @@
//! The OpenSubtitles.com provider (`DESIGN.md` §15, issue #187).
//!
//! One of the two providers §15 names, and the primary one: it has an
//! official REST API, it keeps pt-PT and pt-BR apart properly, and it is the
//! only provider that answers `moviehash` searches — the signal that wins
//! ranking outright.
//!
//! Both lanes feed one search: `moviehash`, computed from the media file's
//! own bytes, and the TMDB id with season and episode for TV. Candidates come
//! back carrying everything #185 ranks on, ordered best first through
//! [`arr_core::subs::rank`] — this module reports what the API said and lets
//! the pure ranker decide what it means.
//!
//! Credentials arrive as [`OpenSubtitlesConfig`] — bootstrap config or
//! environment per §10, never a database row. An API key alone is enough to
//! search; downloading also needs a user token, which is fetched from
//! `/login` on demand and refreshed once when it expires.
//!
//! Nothing here talks to the live service in tests: every HTTP path runs
//! against fixture responses served by `wiremock`.
use std::collections::HashMap;
use std::io::{Read, Seek, SeekFrom};
use std::sync::Mutex;
use std::time::Duration;
use reqwest::{Client, Method, StatusCode, Url};
use serde::Deserialize;
use arr_core::Language;
use crate::{
Candidate, CandidateId, DownloadFuture, Error, Fetched, MediaFile, MediaRef, Provider,
ProviderId, Result, SearchFuture, SearchRequest, SubtitleFormat,
};
/// How much of either end of the file the `moviehash` reads. The algorithm
/// OpenSubtitles.com specifies sums the file size plus both 64 KiB end chunks
/// as 8-byte little-endian words, modulo 2^64.
const HASH_CHUNK: u64 = 65_536;
/// How much of an unexpected response body is worth keeping in an error.
const MAX_ERROR_BODY: usize = 512;
const PROVIDER_NAME: &str = "opensubtitles";
const USER_AGENT: &str = "arr v0.1.0";
/// Compute OpenSubtitles.com's `moviehash` for a media file.
///
/// `Ok(None)` when the file is shorter than two chunks: there are no two
/// distinct ends to read, the API rejects such files outright, so arr drops
/// the hash lane and searches by TMDB id instead. An unreadable file is a
/// real [`std::io::Error`] — searching without the winning signal silently
/// would hide exactly the kind of path problem the operator must see.
///
/// # Errors
///
/// Propagates [`std::io::Error`] from opening or reading the file.
pub fn moviehash(path: &std::path::Path, size: u64) -> std::io::Result<Option<String>> {
if size < HASH_CHUNK * 2 {
return Ok(None);
}
let mut file = std::fs::File::open(path)?;
let mut buf = vec![0u8; HASH_CHUNK_USIZE];
let mut hash = size;
hash = hash.wrapping_add(chunk_sum(&mut file, &mut buf)?);
file.seek(SeekFrom::Start(size - HASH_CHUNK))?;
hash = hash.wrapping_add(chunk_sum(&mut file, &mut buf)?);
Ok(Some(format!("{hash:016x}")))
}
const HASH_CHUNK_USIZE: usize = 65_536;
fn chunk_sum(file: &mut std::fs::File, buf: &mut [u8]) -> std::io::Result<u64> {
file.read_exact(buf)?;
Ok(buf.as_chunks::<8>().0.iter().fold(0u64, |acc, word| {
acc.wrapping_add(u64::from_le_bytes(*word))
}))
}
/// Credentials for OpenSubtitles.com (DESIGN.md §10).
///
/// The API key identifies the application; the user pair is optional but
/// required before anything can be downloaded. Never persisted to the
/// database — they reach here from bootstrap config or environment only.
#[derive(Clone, PartialEq, Eq)]
pub struct OpenSubtitlesConfig {
/// The registered API key, sent as `Api-Key` on every call.
pub api_key: String,
/// The account to download under, when configured.
pub username: Option<String>,
/// That account's password.
pub password: Option<String>,
}
// Hand-written: a derived Debug would print the credentials.
impl std::fmt::Debug for OpenSubtitlesConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenSubtitlesConfig")
.field("api_key", &"<redacted>")
.field("username", &self.username.as_ref().map(|_| "<redacted>"))
.field("has_password", &self.password.is_some())
.finish()
}
}
impl OpenSubtitlesConfig {
fn user(&self) -> Option<(&str, &str)> {
Some((self.username.as_deref()?, self.password.as_deref()?))
}
}
/// What search learned about one candidate that its opaque id does not
/// carry: the language it claims and the container format it downloads as.
#[derive(Clone, Debug)]
struct CandidateMeta {
language: Language,
format: SubtitleFormat,
}
/// The OpenSubtitles.com client behind the [`Provider`] trait.
pub struct OpenSubtitles {
http: Client,
base_url: Url,
config: OpenSubtitlesConfig,
id: ProviderId,
/// The login token, fetched lazily on first download and replaced once
/// when it expires mid-flight. `None` until a download is asked for:
/// searches run with the API key alone.
token: Mutex<Option<String>>,
/// Facts recorded when candidates were offered, so `download` — which
/// receives only an opaque id — can still fill in `Fetched`.
meta: Mutex<HashMap<CandidateId, CandidateMeta>>,
}
// Hand-written: the config and the token must never reach a log line.
impl std::fmt::Debug for OpenSubtitles {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenSubtitles")
.field("base_url", &self.base_url.as_str())
.field("id", &self.id)
.finish_non_exhaustive()
}
}
impl OpenSubtitles {
/// A client against the real OpenSubtitles.com API.
///
/// # Errors
///
/// Fails if the HTTP client cannot be constructed.
pub fn new(config: OpenSubtitlesConfig) -> Result<Self> {
Self::with_base_url(config, "https://api.opensubtitles.com/api/v1/")
}
/// Point the client somewhere else. Tests use this against `wiremock`;
/// nothing else should.
///
/// # Errors
///
/// Same as [`Self::new`].
pub fn with_base_url(config: OpenSubtitlesConfig, base_url: &str) -> Result<Self> {
let id = ProviderId::new(PROVIDER_NAME);
let http = Client::builder().build().map_err(|err| Error::Transport {
provider: id.clone(),
source: Box::new(err),
})?;
let base_url = base_url.parse().map_err(|err| Error::Malformed {
provider: id.clone(),
detail: format!("bad base URL: {err}"),
})?;
Ok(Self {
http,
base_url,
config,
id,
token: Mutex::new(None),
meta: Mutex::new(HashMap::new()),
})
}
async fn decode<T: for<'de> Deserialize<'de>>(&self, response: reqwest::Response) -> Result<T> {
let bytes = response.bytes().await.map_err(|err| Error::Transport {
provider: self.id.clone(),
source: Box::new(err),
})?;
serde_json::from_slice(&bytes).map_err(|err| Error::Malformed {
provider: self.id.clone(),
detail: err.to_string(),
})
}
/// Map every non-success status onto the error the loop can act on.
async fn check_status(&self, response: reqwest::Response) -> Result<reqwest::Response> {
match response.status() {
StatusCode::OK => Ok(response),
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(Error::Unauthorized {
provider: self.id.clone(),
}),
// The documented daily-download cap answers 406; a plain rate
// limit answers 429. Both mean "come back later", not "broken".
StatusCode::TOO_MANY_REQUESTS | StatusCode::NOT_ACCEPTABLE => Err(Error::RateLimited {
provider: self.id.clone(),
retry_after: retry_after(&response),
}),
other => {
let body = response.text().await.unwrap_or_default();
Err(Error::Malformed {
provider: self.id.clone(),
detail: format!("status {}: {}", other.as_u16(), truncate(&body)),
})
}
}
}
async fn send(
&self,
method: Method,
path: &str,
query: Option<&[(String, String)]>,
json: Option<&serde_json::Value>,
bearer: Option<&str>,
) -> Result<reqwest::Response> {
let url = self.base_url.join(path).map_err(|err| Error::Malformed {
provider: self.id.clone(),
detail: format!("bad request path {path}: {err}"),
})?;
let mut request = self
.http
.request(method, url)
.header("Api-Key", &self.config.api_key)
.header("User-Agent", USER_AGENT);
if let Some(query) = query {
request = request.query(query);
}
if let Some(json) = json {
request = request.json(json);
}
if let Some(token) = bearer {
request = request.bearer_auth(token);
}
tracing::debug!(provider = %self.id, path, "OpenSubtitles request");
let response = request.send().await.map_err(|err| Error::Transport {
provider: self.id.clone(),
source: Box::new(err),
})?;
self.check_status(response).await
}
/// Fetch (or refetch) the user token downloads travel under.
async fn login(&self) -> Result<String> {
let Some((username, password)) = self.config.user() else {
return Err(Error::Unauthorized {
provider: self.id.clone(),
});
};
let url = self
.base_url
.join("login")
.map_err(|err| Error::Malformed {
provider: self.id.clone(),
detail: format!("bad login path: {err}"),
})?;
let response = self
.http
.post(url)
.basic_auth(username, Some(password))
.header("Api-Key", &self.config.api_key)
.header("User-Agent", USER_AGENT)
.send()
.await
.map_err(|err| Error::Transport {
provider: self.id.clone(),
source: Box::new(err),
})?;
let response = self.check_status(response).await?;
let body: LoginBody = self.decode(response).await?;
*self.token.lock().expect("token lock poisoned") = Some(body.token.clone());
Ok(body.token)
}
/// The current token, logging in first if there is none yet.
async fn current_token(&self) -> Result<String> {
if let Some(token) = self.token.lock().expect("token lock poisoned").clone() {
return Ok(token);
}
self.login().await
}
async fn search_inner(&self, request: &SearchRequest) -> Result<Vec<Candidate>> {
let hash =
moviehash(&request.file.path, request.file.size).map_err(|source| Error::Io {
path: request.file.path.clone(),
source,
})?;
let mut params: Vec<(String, String)> =
vec![("languages".to_owned(), languages_param(&request.languages))];
match request.file.media {
MediaRef::Movie { tmdb_id } => {
params.push(("tmdb_movie_id".to_owned(), tmdb_id.to_string()));
}
MediaRef::Episode {
tmdb_id,
season,
episode,
} => {
params.push(("tmdb_series_id".to_owned(), tmdb_id.to_string()));
params.push(("season_number".to_owned(), season.to_string()));
params.push(("episode_number".to_owned(), episode.to_string()));
}
}
if let Some(hash) = hash.as_deref() {
params.push(("moviehash".to_owned(), hash.to_owned()));
}
let response = self
.send(Method::GET, "subtitles", Some(&params), None, None)
.await?;
let page: SubtitlesPage = self.decode(response).await?;
let offered: Vec<_> = page
.data
.into_iter()
.filter_map(SubtitleEntry::into_offered)
// A provider may answer with more than was asked for; ranking
// runs per wanted language, so anything else is dropped here.
.filter(|offered| request.languages.contains(&offered.candidate.language))
.collect();
// Remember what each offered candidate is, so download can fill a
// Fetched in from just its id.
let mut meta = self.meta.lock().expect("meta lock poisoned");
meta.clear();
meta.extend(offered.iter().map(|offered| {
(
offered.candidate.id.clone(),
CandidateMeta {
language: offered.candidate.language.clone(),
format: offered.format.clone(),
},
)
}));
Ok(rank_candidates(
offered.into_iter().map(|o| o.candidate).collect(),
&request.file,
hash.as_deref(),
))
}
async fn download_inner(&self, id: &CandidateId) -> Result<Fetched> {
let Some(meta) = self
.meta
.lock()
.expect("meta lock poisoned")
.get(id)
.cloned()
else {
return Err(Error::NotFound {
provider: self.id.clone(),
candidate: id.clone(),
});
};
let file_id: u64 = id.as_str().parse().map_err(|_| Error::NotFound {
provider: self.id.clone(),
candidate: id.clone(),
})?;
let token = self.current_token().await?;
let body = serde_json::json!({ "file_id": file_id });
let response = self
.send(Method::POST, "download", None, Some(&body), Some(&token))
.await;
// An expired token answers 401. One refresh, then give up.
let response = match response {
Err(Error::Unauthorized { .. }) if self.config.user().is_some() => {
let fresh = self.login().await?;
self.send(Method::POST, "download", None, Some(&body), Some(&fresh))
.await?
}
other => other?,
};
let link: DownloadLink = self.decode(response).await?;
let content = self.fetch_bytes(&link.link).await?;
Ok(Fetched {
id: id.clone(),
language: meta.language,
format: meta.format,
content,
})
}
async fn fetch_bytes(&self, link: &str) -> Result<Vec<u8>> {
let url: Url = link.parse().map_err(|err| Error::Malformed {
provider: self.id.clone(),
detail: format!("bad download link: {err}"),
})?;
let response = self
.http
.get(url)
.send()
.await
.map_err(|err| Error::Transport {
provider: self.id.clone(),
source: Box::new(err),
})?;
let response = self.check_status(response).await?;
let bytes = response.bytes().await.map_err(|err| Error::Transport {
provider: self.id.clone(),
source: Box::new(err),
})?;
Ok(bytes.to_vec())
}
}
impl Provider for OpenSubtitles {
fn id(&self) -> ProviderId {
self.id.clone()
}
fn search<'a>(&'a self, request: &'a SearchRequest) -> SearchFuture<'a> {
Box::pin(async move { self.search_inner(request).await })
}
fn download<'a>(&'a self, id: &'a CandidateId) -> DownloadFuture<'a> {
Box::pin(async move { self.download_inner(id).await })
}
}
fn retry_after(response: &reqwest::Response) -> Option<Duration> {
response
.headers()
.get(reqwest::header::RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse::<u64>()
.ok()
.map(Duration::from_secs)
}
fn truncate(body: &str) -> String {
body.chars().take(MAX_ERROR_BODY).collect()
}
/// One API entry turned into arr's terms, plus the facts download will need.
struct Offered {
candidate: Candidate,
format: SubtitleFormat,
}
/// The wire shape of `POST /login`: the user token everything that touches
/// the account travels under.
#[derive(Debug, Deserialize)]
struct LoginBody {
token: String,
}
/// The wire shape of `POST /download`: a short-lived direct link to the
/// subtitle bytes themselves.
#[derive(Debug, Deserialize)]
struct DownloadLink {
link: String,
}
/// The wire shape of `GET /subtitles`. Only the fields ranking or the
/// download need; anything else the API adds is ignored.
#[derive(Debug, Deserialize)]
struct SubtitlesPage {
data: Vec<SubtitleEntry>,
}
#[derive(Debug, Deserialize)]
struct SubtitleEntry {
attributes: SubtitleAttributes,
}
impl SubtitleEntry {
fn into_offered(self) -> Option<Offered> {
let attributes = self.attributes;
let file_id = attributes.files.first()?.file_id;
let language = language_of(attributes.language.as_deref()?)?;
let release_name = attributes.release.filter(|release| !release.is_empty());
let format = format_of(attributes.format.as_deref());
Some(Offered {
candidate: Candidate {
provider: ProviderId::new(PROVIDER_NAME),
id: CandidateId::new(file_id.to_string()),
language,
hash_match: attributes.moviehash_match,
group: release_name.as_deref().and_then(group_of_release),
source: release_name.as_deref().and_then(source_of_release),
rating: attributes.ratings,
download_count: Some(attributes.download_count),
forced: attributes.foreign_parts_only,
sdh: attributes.hearing_impaired,
release_name,
},
format,
})
}
}
#[derive(Debug, Deserialize)]
struct SubtitleAttributes {
language: Option<String>,
release: Option<String>,
#[serde(default)]
moviehash_match: bool,
ratings: Option<f32>,
#[serde(default)]
download_count: u64,
#[serde(default)]
hearing_impaired: bool,
#[serde(default)]
foreign_parts_only: bool,
format: Option<String>,
files: Vec<SubtitleFileRef>,
}
#[derive(Debug, Deserialize)]
struct SubtitleFileRef {
file_id: u64,
}
/// The API spells languages `pt-PT`, `pt-BR`, `en`; accept `_` too, since
/// tooling around this API uses it.
fn language_of(code: &str) -> Option<Language> {
match code.replace('_', "-").to_ascii_lowercase().as_str() {
"pt-pt" => Some(Language::PortuguesePortugal),
"pt-br" => Some(Language::PortugueseBrazil),
"pt" | "por" => Some(Language::PortugueseUnverified),
"" => None,
_ => Some(Language::Other(code.to_owned())),
}
}
/// The `languages` query parameter: comma-separated codes, pt-PT and pt-BR
/// kept apart — keeping them apart is why this is the primary provider.
fn languages_param(languages: &[Language]) -> String {
languages
.iter()
.map(|language| match language {
Language::PortuguesePortugal => "pt-PT",
Language::PortugueseBrazil => "pt-BR",
Language::PortugueseUnverified => "pt",
Language::Other(tag) => tag.as_str(),
})
.collect::<Vec<_>>()
.join(",")
}
/// The release group of the release a subtitle was timed against, read off
/// the release name with the same parser the indexer uses.
fn group_of_release(release: &str) -> Option<String> {
arr_parse::parse(release).group
}
fn source_of_release(release: &str) -> Option<arr_core::Source> {
arr_parse::parse(release).source.map(Into::into)
}
fn format_of(format: Option<&str>) -> SubtitleFormat {
match format.unwrap_or_default().to_ascii_lowercase().as_str() {
"srt" | "subrip" => SubtitleFormat::Srt,
"ass" | "ssa" => SubtitleFormat::Ass,
"vtt" | "webvtt" => SubtitleFormat::Vtt,
other => SubtitleFormat::Other(other.to_owned()),
}
}
/// Order the candidates best first through the pure ranker (#185), building
/// the target from the same facts the search ran on: the file's own hash and
/// whatever the release name claims about group and source.
fn rank_candidates(
candidates: Vec<Candidate>,
file: &MediaFile,
target_hash: Option<&str>,
) -> Vec<Candidate> {
let claims = file.release_name.as_deref().map(arr_parse::parse);
let target = arr_core::subs::SubtitleTarget {
moviehash: target_hash,
release_name: file.release_name.as_deref(),
release_group: claims.as_ref().and_then(|claims| claims.group.as_deref()),
source: claims
.as_ref()
.and_then(|claims| claims.source.map(Into::into)),
};
let cores: Vec<_> = candidates.iter().map(|c| c.to_core(target_hash)).collect();
let order: Vec<usize> = arr_core::subs::rank(&target, &cores)
.iter()
.map(|entry| entry.index)
.collect();
let mut slots: Vec<_> = candidates.into_iter().map(Some).collect();
order
.iter()
.map(|&index| slots[index].take().expect("each index ranks once"))
.collect()
}
#[cfg(test)]
mod tests {
use super::{format_of, language_of, moviehash, rank_candidates, PROVIDER_NAME};
use crate::{Candidate, CandidateId, MediaFile, MediaRef, ProviderId, SubtitleFormat};
use arr_core::Language;
#[test]
fn a_zero_filled_file_hashes_to_its_own_size() {
// Every 8-byte word of both chunks sums to zero, so the whole hash is
// the file size: 262144 = 0x40000. Hand-derived from the algorithm,
// not computed with the code under test.
let dir = tempfile::tempdir().expect("test setup");
let path = dir.path().join("zeros.mkv");
std::fs::write(&path, vec![0u8; 262_144]).expect("test setup");
let hash = moviehash(&path, 262_144).expect("test setup");
assert_eq!(hash.as_deref(), Some("0000000000040000"));
}
#[test]
fn the_last_chunk_is_read_from_the_end_not_the_front() {
// First chunk all 0xFF: 8192 words of -1 sum to -8192. Last chunk all
// zero. Size 131072 - 8192 = 122880 = 0x1e000. If both ends were read
// from the front the sum would double instead.
let dir = tempfile::tempdir().expect("test setup");
let path = dir.path().join("ends.mkv");
let first = vec![0xFFu8; 65_536];
let rest = vec![0u8; 65_536];
let mut bytes = first;
bytes.extend_from_slice(&rest);
std::fs::write(&path, &bytes).expect("test setup");
let hash = moviehash(&path, 131_072).expect("test setup");
assert_eq!(hash.as_deref(), Some("000000000001e000"));
}
#[test]
fn a_file_too_short_for_two_chunks_has_no_hash() {
let dir = tempfile::tempdir().expect("test setup");
let path = dir.path().join("small.mkv");
std::fs::write(&path, vec![0u8; 65_536]).expect("test setup");
assert_eq!(moviehash(&path, 65_536).expect("test setup"), None);
}
#[test]
fn languages_map_both_ways_with_pt_variants_apart() {
assert_eq!(language_of("pt-PT"), Some(Language::PortuguesePortugal));
assert_eq!(language_of("pt_BR"), Some(Language::PortugueseBrazil));
assert_eq!(language_of("por"), Some(Language::PortugueseUnverified));
assert_eq!(language_of("en"), Some(Language::Other("en".to_owned())));
assert_eq!(language_of(""), None);
assert_eq!(
super::languages_param(&[
Language::PortuguesePortugal,
Language::PortugueseBrazil,
Language::PortugueseUnverified,
Language::Other("en".to_owned()),
]),
"pt-PT,pt-BR,pt,en"
);
}
#[test]
fn formats_spell_the_way_providers_do() {
assert_eq!(format_of(Some("srt")), SubtitleFormat::Srt);
assert_eq!(format_of(Some("subrip")), SubtitleFormat::Srt);
assert_eq!(format_of(Some("ass")), SubtitleFormat::Ass);
assert_eq!(format_of(Some("vtt")), SubtitleFormat::Vtt);
assert_eq!(
format_of(Some("idx")),
SubtitleFormat::Other("idx".to_owned())
);
assert_eq!(format_of(None), SubtitleFormat::Other(String::new()));
}
fn candidate(id: u64, hash_match: bool, downloads: u64) -> Candidate {
Candidate {
provider: ProviderId::new(PROVIDER_NAME),
id: CandidateId::new(id.to_string()),
language: Language::PortuguesePortugal,
hash_match,
release_name: None,
group: None,
source: None,
rating: None,
download_count: Some(downloads),
forced: false,
sdh: false,
}
}
#[test]
fn search_results_come_back_ranked_best_first() {
let file = MediaFile {
path: "/m/f.mkv".into(),
size: 1 << 20,
release_name: None,
media: MediaRef::Movie { tmdb_id: 42 },
};
// The hash-matched candidate has fewer downloads; ranking puts it
// first anyway, because a moviehash match wins outright.
let candidates = vec![candidate(1, false, 9_999), candidate(2, true, 1)];
let ranked = rank_candidates(candidates, &file, Some("abc123"));
let ids: Vec<_> = ranked.iter().map(|c| c.id.as_str()).collect();
assert_eq!(ids, ["2", "1"]);
}
}
@@ -0,0 +1,23 @@
{
"total_count": "1",
"data": [
{
"id": "7111111",
"type": "subtitle",
"attributes": {
"subtitle_id": "7111111",
"language": "pt-PT",
"release": "Series.S01E02.1080p.WEB-DL-GRP",
"moviehash_match": false,
"ratings": 60.0,
"download_count": 77,
"hearing_impaired": false,
"foreign_parts_only": false,
"format": "ass",
"files": [
{ "file_id": 61111111, "file_name": "series.s01e02.pt.ass", "cd_number": 1 }
]
}
}
]
}
@@ -0,0 +1,80 @@
{
"total_count": "3",
"data": [
{
"id": "7013923",
"type": "subtitle",
"attributes": {
"subtitle_id": "7013923",
"language": "pt-PT",
"release": "Dune.Part.Two.2024.1080p.WEB-DL.DDP5.1.Atmos.H.264-GRP",
"moviehash_match": true,
"ratings": 82.0,
"download_count": 12345,
"hearing_impaired": false,
"foreign_parts_only": false,
"format": "srt",
"uploader": { "name": "someone", "rating": 90 },
"files": [
{ "file_id": 60619911, "file_name": "Dune.Part.Two.2024.1080p.WEB-DL-GRP.pt.srt", "cd_number": 1 }
],
"feature_details": { "year": 2024, "title": "Dune: Part Two", "tmdb_id": 693134 }
}
},
{
"id": "7000001",
"type": "subtitle",
"attributes": {
"subtitle_id": "7000001",
"language": "pt-BR",
"release": "Dune.Part.Two.2024.2160p.BluRay.GRP",
"moviehash_match": false,
"ratings": 95.0,
"download_count": 999999,
"hearing_impaired": false,
"foreign_parts_only": false,
"format": "srt",
"uploader": { "name": "other", "rating": 100 },
"files": [
{ "file_id": 60000001, "file_name": "dune.part.two.br.srt", "cd_number": 1 }
]
}
},
{
"id": "6999999",
"type": "subtitle",
"attributes": {
"subtitle_id": "6999999",
"language": "en",
"release": "Dune.Part.Two.2024.1080p.WEB-DL.GRP",
"moviehash_match": true,
"ratings": 40.0,
"download_count": 5,
"hearing_impaired": true,
"foreign_parts_only": false,
"format": "srt",
"files": [
{ "file_id": 59999991, "file_name": "dune.en.sdh.srt", "cd_number": 1 }
]
}
},
{
"id": "6888888",
"type": "subtitle",
"attributes": {
"subtitle_id": "6888888",
"language": "fr",
"release": "Dune.Part.Two.2024.1080p.WEB-DL.GRP",
"moviehash_match": false,
"ratings": null,
"download_count": 12,
"hearing_impaired": false,
"foreign_parts_only": true,
"format": "srt",
"files": [
{ "file_id": 58888881, "file_name": "dune.fr.forced.srt", "cd_number": 1 }
]
}
}
]
}
+423
View File
@@ -0,0 +1,423 @@
//! OpenSubtitles.com provider tests. Everything runs against `wiremock` —
//! the issue rules out pointing tests at the live service, which would
//! burn the daily download cap and leak a key into CI.
// Same per-target quirk as in `lib.rs`: an integration test links the library's
// dependencies without using them directly.
use {arr_parse as _, reqwest as _, serde as _, thiserror as _, tracing as _};
use std::time::Duration;
use arr_core::Language;
use arr_subs::{
CandidateId, Error, MediaFile, MediaRef, OpenSubtitles, OpenSubtitlesConfig, Provider,
SearchRequest, SubtitleFormat,
};
use tempfile::TempDir;
use wiremock::matchers::{body_partial_json, header, method, path, query_param};
use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate};
const SEARCH_MOVIE: &str = include_str!("fixtures/subtitles_search_movie.json");
const SEARCH_EPISODE: &str = include_str!("fixtures/subtitles_search_episode.json");
fn config() -> OpenSubtitlesConfig {
OpenSubtitlesConfig {
api_key: "test-api-key".to_owned(),
username: Some("user".to_owned()),
password: Some("pass".to_owned()),
}
}
fn client(server: &MockServer) -> OpenSubtitles {
OpenSubtitles::with_base_url(config(), &format!("{}/api/v1/", server.uri()))
.expect("client builds")
}
fn anonymous_client(server: &MockServer) -> OpenSubtitles {
OpenSubtitles::with_base_url(
OpenSubtitlesConfig {
api_key: "test-api-key".to_owned(),
username: None,
password: None,
},
&format!("{}/api/v1/", server.uri()),
)
.expect("client builds")
}
/// 256 KiB of zeros hashes to its own size — see the unit tests in the
/// module — so every search against this file sends
/// `moviehash=0000000000040000`.
struct HashedFile(TempDir);
impl HashedFile {
fn new() -> Self {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("dune.mkv");
std::fs::write(&path, vec![0u8; 262_144]).expect("write");
Self(dir)
}
fn media(&self) -> MediaFile {
MediaFile {
path: self.0.path().join("dune.mkv"),
size: 262_144,
release_name: Some("Dune.Part.Two.2024.1080p.WEB-DL.GRP".to_owned()),
media: MediaRef::Movie { tmdb_id: 693_134 },
}
}
}
fn request(file: MediaFile, languages: Vec<Language>) -> SearchRequest {
SearchRequest { file, languages }
}
async fn mount_search(server: &MockServer, body: &'static str) {
Mock::given(method("GET"))
.and(path("/api/v1/subtitles"))
.and(header("Api-Key", "test-api-key"))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(server)
.await;
}
#[tokio::test]
async fn movie_search_sends_both_lanes_and_ranks_hash_match_first() {
let server = MockServer::start().await;
let file = HashedFile::new();
Mock::given(method("GET"))
.and(path("/api/v1/subtitles"))
.and(header("Api-Key", "test-api-key"))
.and(query_param("tmdb_movie_id", "693134"))
.and(query_param("moviehash", "0000000000040000"))
.and(query_param("languages", "pt-PT,pt-BR,en"))
.respond_with(ResponseTemplate::new(200).set_body_string(SEARCH_MOVIE))
.mount(&server)
.await;
let candidates = client(&server)
.search(&request(
file.media(),
vec![
Language::PortuguesePortugal,
Language::PortugueseBrazil,
Language::Other("en".to_owned()),
],
))
.await
.expect("search succeeds");
// The French entry is not a wanted language and is gone; pt-PT, pt-BR
// and English remain. The hash-matched pt-PT candidate outranks the
// pt-BR one despite ten thousand times fewer downloads, because a
// moviehash match wins outright (§15).
assert_eq!(candidates.len(), 3);
assert_eq!(candidates[0].id.as_str(), "60619911");
assert_eq!(candidates[0].language, Language::PortuguesePortugal);
assert!(candidates[0].hash_match);
// Facts #185 ranks on survive the mapping.
assert_eq!(
candidates[0].release_name.as_deref(),
Some("Dune.Part.Two.2024.1080p.WEB-DL.DDP5.1.Atmos.H.264-GRP")
);
assert_eq!(candidates[0].group.as_deref(), Some("GRP"));
assert_eq!(candidates[1].download_count, Some(999_999));
// A plain subtitle outranks an SDH one for the same language — here even
// against another moviehash match, because plainness is the first tier.
assert!(candidates[2].hash_match);
assert!(candidates[2].sdh);
}
#[tokio::test]
async fn episode_search_addresses_the_series_not_the_movie_lane() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/subtitles"))
.and(query_param("tmdb_series_id", "94605"))
.and(query_param("season_number", "3"))
.and(query_param("episode_number", "7"))
.respond_with(ResponseTemplate::new(200).set_body_string(SEARCH_EPISODE))
.mount(&server)
.await;
let file = MediaFile {
// Below the two-chunk minimum, so the hash lane stays out and no
// real file needs to exist on disk.
path: "/m/series.mkv".into(),
size: 65_536,
release_name: None,
media: MediaRef::Episode {
tmdb_id: 94_605,
season: 3,
episode: 7,
},
};
let candidates = client(&server)
.search(&request(file, vec![Language::PortuguesePortugal]))
.await
.expect("search succeeds");
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].id.as_str(), "61111111");
}
/// Matches a request where the named query parameter is absent entirely.
struct QueryParamMissing(&'static str);
impl Match for QueryParamMissing {
fn matches(&self, request: &Request) -> bool {
!request.url.query_pairs().any(|(key, _)| key == self.0)
}
}
#[tokio::test]
async fn a_file_too_small_to_hash_searches_without_the_hash_lane() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/subtitles"))
.and(query_param("tmdb_movie_id", "42"))
.and(QueryParamMissing("moviehash"))
.respond_with(ResponseTemplate::new(200).set_body_string(SEARCH_EPISODE))
.mount(&server)
.await;
let file = MediaFile {
path: "/m/small.mkv".into(),
size: 65_536,
release_name: None,
media: MediaRef::Movie { tmdb_id: 42 },
};
let candidates = client(&server)
.search(&request(file, vec![Language::PortuguesePortugal]))
.await
.expect("search succeeds");
assert_eq!(candidates.len(), 1);
}
#[tokio::test]
async fn an_expired_key_is_unauthorized_and_a_cap_is_rate_limited() {
let server = MockServer::start().await;
let file = HashedFile::new();
Mock::given(method("GET"))
.and(path("/api/v1/subtitles"))
.and(header("Api-Key", "wrong-key"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/api/v1/subtitles"))
.and(header("Api-Key", "test-api-key"))
.respond_with(ResponseTemplate::new(429).insert_header("Retry-After", "3600"))
.mount(&server)
.await;
let wrong_key = OpenSubtitles::with_base_url(
OpenSubtitlesConfig {
api_key: "wrong-key".to_owned(),
username: None,
password: None,
},
&format!("{}/api/v1/", server.uri()),
)
.expect("client builds");
let err = wrong_key
.search(&request(file.media(), vec![Language::PortuguesePortugal]))
.await
.expect_err("bad key is refused");
assert!(matches!(err, Error::Unauthorized { .. }));
let err = client(&server)
.search(&request(file.media(), vec![Language::PortuguesePortugal]))
.await
.expect_err("cap hit");
match err {
Error::RateLimited { retry_after, .. } => {
assert_eq!(retry_after, Some(Duration::from_hours(1)));
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
async fn mount_login(server: &MockServer, token: &'static str) {
Mock::given(method("POST"))
.and(path("/api/v1/login"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "token": token })),
)
.mount(server)
.await;
}
async fn mount_download_link(server: &MockServer, token: &'static str, file_id: u64) {
Mock::given(method("POST"))
.and(path("/api/v1/download"))
.and(header("Authorization", format!("Bearer {token}")))
.and(body_partial_json(serde_json::json!({ "file_id": file_id })))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"link": format!("{}/file/{file_id}.srt", server.uri()),
"file_name": "subtitle.srt",
"requests": 99
})))
.mount(server)
.await;
}
#[tokio::test]
async fn download_logs_in_then_walks_link_and_bytes_back_into_fetched() {
const SRT: &[u8] = b"1\n00:00:01,000 --> 00:00:02,000\nol\xc3\xa1\n";
let server = MockServer::start().await;
let file = HashedFile::new();
mount_search(&server, SEARCH_MOVIE).await;
mount_login(&server, "token-1").await;
mount_download_link(&server, "token-1", 60_619_911).await;
Mock::given(method("GET"))
.and(path("/file/60619911.srt"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(SRT.to_vec()))
.mount(&server)
.await;
let provider = client(&server);
provider
.search(&request(file.media(), vec![Language::PortuguesePortugal]))
.await
.expect("search succeeds");
let fetched = provider
.download(&CandidateId::new("60619911"))
.await
.expect("download succeeds");
assert_eq!(fetched.id.as_str(), "60619911");
assert_eq!(fetched.language, Language::PortuguesePortugal);
assert_eq!(fetched.format, SubtitleFormat::Srt);
assert_eq!(fetched.content, SRT);
}
#[tokio::test]
async fn an_expired_token_refreshes_once_and_retries() {
let server = MockServer::start().await;
let file = HashedFile::new();
mount_search(&server, SEARCH_MOVIE).await;
// The first login hands out the stale token; the refresh after the 401
// gets the fresh one. `up_to_times(1)` retires the first mock so the
// second call falls through to the next.
Mock::given(method("POST"))
.and(path("/api/v1/login"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "token": "stale" })),
)
.up_to_n_times(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/v1/login"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({ "token": "fresh" })),
)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/v1/download"))
.and(header("Authorization", "Bearer stale"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
mount_download_link(&server, "fresh", 60_619_911).await;
Mock::given(method("GET"))
.and(path("/file/60619911.srt"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"sub".to_vec()))
.mount(&server)
.await;
let provider = client(&server);
provider
.search(&request(file.media(), vec![Language::PortuguesePortugal]))
.await
.expect("search succeeds");
let fetched = provider
.download(&CandidateId::new("60619911"))
.await
.expect("the refreshed token downloads");
assert_eq!(fetched.content, b"sub");
}
#[tokio::test]
async fn hitting_the_download_cap_is_rate_limited_not_broken() {
let server = MockServer::start().await;
let file = HashedFile::new();
mount_search(&server, SEARCH_MOVIE).await;
mount_login(&server, "t").await;
// 406 is what the API documents for the daily-download cap.
Mock::given(method("POST"))
.and(path("/api/v1/download"))
.respond_with(ResponseTemplate::new(406).insert_header("Retry-After", "86400"))
.mount(&server)
.await;
let provider = client(&server);
provider
.search(&request(file.media(), vec![Language::PortuguesePortugal]))
.await
.expect("search succeeds");
let err = provider
.download(&CandidateId::new("60619911"))
.await
.expect_err("cap reached");
match err {
Error::RateLimited { retry_after, .. } => {
assert_eq!(retry_after, Some(Duration::from_hours(24)));
}
other => panic!("expected RateLimited, got {other:?}"),
}
}
#[tokio::test]
async fn downloading_without_user_credentials_never_reaches_the_wire() {
let server = MockServer::start().await;
let file = HashedFile::new();
mount_search(&server, SEARCH_MOVIE).await;
let provider = anonymous_client(&server);
provider
.search(&request(file.media(), vec![Language::PortuguesePortugal]))
.await
.expect("search succeeds anonymously");
let err = provider
.download(&CandidateId::new("60619911"))
.await
.expect_err("no user configured");
assert!(matches!(err, Error::Unauthorized { .. }));
// No /login or /download request was ever mounted, so reaching for them
// would have failed the test with an unhandled-request error instead.
}
#[tokio::test]
async fn an_id_this_provider_never_offered_is_not_found() {
let server = MockServer::start().await;
let err = client(&server)
.download(&CandidateId::new("99999999"))
.await
.expect_err("unknown id");
match err {
Error::NotFound { candidate, .. } => assert_eq!(candidate.as_str(), "99999999"),
other => panic!("expected NotFound, got {other:?}"),
}
}