feat(arr): add opensubtitles.com subtitle provider
Searches by moviehash computed from the media file and by TMDB id with season/episode for TV, ranks candidates through arr_core::subs::rank, and downloads under a lazily-fetched user token. The daily download cap (429/406) surfaces as Error::RateLimited so the loop can show a queue state. Credentials come from config or environment only; tests run against wiremock fixtures.
This commit is contained in:
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user