Files
arr/crates/arr-subs/tests/opensubtitles.rs
T
Miguel Palhas b9f4ee98d5 fix(arr): recover lost fixtures and align provider names
The Podnapisi zip fixtures never reached the branch: a global gitignore
excludes *.zip, so git add skipped them silently and the worker's CI passed
against untracked files. Recovered, with a fixtures .gitignore that keeps
the next binary fixture from vanishing the same way.

Two providers landing in parallel also disagreed on names and on which
crate dependencies each integration-test target uses. PodnapisiProvider is
now Podnapisi, matching OpenSubtitles, and both test targets declare the
dependencies they do not use so the per-target lint stays quiet.
2026-08-24 23:07:46 +01:00

427 lines
14 KiB
Rust

//! 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 _, chardetng as _, encoding_rs as _, reqwest as _, serde as _, thiserror as _,
tracing as _, zip 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:?}"),
}
}