b9f4ee98d5
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.
427 lines
14 KiB
Rust
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:?}"),
|
|
}
|
|
}
|