//! 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) -> 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:?}"), } }