Add OpenSubtitles subtitle search + download (new Subtitles panel)
New web client (crates/aurora-curator-core/src/web/opensubtitles.rs, 9 tests): search, login, download, and fetch-subtitle-text against OpenSubtitles' REST API. Search only needs a free app-level API key; download additionally needs the end user's own OpenSubtitles account login (username/password -> JWT), since downloads are quota-limited per account. Settings gained three new fields (opensubtitles_api_key/username/password) following the existing TMDB/OMDb credential pattern, with one deliberate exception: the password is never returned to the frontend, only whether one is saved (has_opensubtitles_password), since it's a real account password rather than an API token. New src-tauri/src/subtitles.rs wraps search/download as Tauri commands and writes a downloaded subtitle right next to its video file (Show.S01E01.mkv -> Show.S01E01.en.srt). New src/components/SubtitlesPanel.tsx is the first real screen behind a sidebar entry besides Rename (previously a stub) - load a video, auto-fill the search form from the same local guess_episode/guess_movie Match uses, search, and download with live remaining/allowed quota shown per result. TheTVDB was considered instead but deliberately skipped for now: its v4 API requires either a paid commercial license or a per-end-user paid TheTVDB subscription just to log in, unlike every other source in this app. 144 tests passing (workspace). Updated README (status paragraph, project layout, new "Subtitles" section, roadmap items 5/6/7).
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
//! Online metadata clients ("Fetch Data"). TMDB (TV + movies), TVmaze (TV only, no API
|
||||
//! key needed), and OMDb (TV + movies, needs a free API key) so far; the original
|
||||
//! FileBot supports ~40 sources (`net.filebot.web.*`) - TMDB was picked first since
|
||||
//! it's the most widely useful for both TV and movies, TVmaze second since it's a
|
||||
//! Online metadata clients ("Fetch Data") plus, as of `opensubtitles`, a first
|
||||
//! non-metadata web client. TMDB (TV + movies), TVmaze (TV only, no API key needed),
|
||||
//! and OMDb (TV + movies, needs a free API key) handle title/air-date lookups; the
|
||||
//! original FileBot supports ~40 sources (`net.filebot.web.*`) - TMDB was picked first
|
||||
//! since it's the most widely useful for both TV and movies, TVmaze second since it's a
|
||||
//! zero-setup second TV source (see `tvmaze`'s module doc for why movies aren't in
|
||||
//! scope for it), OMDb third since it's the first *movie*-capable alternative to TMDB
|
||||
//! (see `omdb`'s module doc).
|
||||
//! (see `omdb`'s module doc). `opensubtitles` is a different category entirely -
|
||||
//! fetching actual subtitle files rather than title metadata - see its own module doc.
|
||||
|
||||
pub mod omdb;
|
||||
pub mod opensubtitles;
|
||||
pub mod tmdb;
|
||||
pub mod tvmaze;
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
//! A client for [OpenSubtitles](https://www.opensubtitles.com)' REST API
|
||||
//! (`api.opensubtitles.com/api/v1`) - search for subtitles matching a TV episode or
|
||||
//! movie, then download the chosen one.
|
||||
//!
|
||||
//! Unlike every other client in this module, OpenSubtitles needs **two** different
|
||||
//! credentials for two different operations:
|
||||
//! - **Search** only needs an `Api-Key` header (a free "consumer"/app key registered
|
||||
//! at opensubtitles.com under Account -> API Consumers).
|
||||
//! - **Download** additionally needs the end user's own OpenSubtitles account to be
|
||||
//! logged in (`POST /login` with `username`/`password`, returning a short-lived JWT
|
||||
//! sent as `Authorization: Bearer <token>` on the actual download call) - downloads
|
||||
//! are quota-limited per account (their docs show this ranging from single digits
|
||||
//! to 1000/day depending on account rank/VIP status, and the exact number has
|
||||
//! changed over time), so the download response's `remaining`/`allowed` counts are
|
||||
//! surfaced to the caller rather than assumed.
|
||||
//!
|
||||
//! This is the first source in Aurora Curator that isn't a metadata lookup (title/air
|
||||
//! date) - it fetches actual subtitle *files* - so its result/error shapes are its own,
|
||||
//! not mirrored from `tmdb`/`tvmaze`/`omdb`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.opensubtitles.com/api/v1";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum OpensubtitlesError {
|
||||
#[error("network error talking to OpenSubtitles: {0}")]
|
||||
Network(String),
|
||||
#[error("OpenSubtitles API error (status {status}): {message}")]
|
||||
Api { status: u16, message: String },
|
||||
#[error("OpenSubtitles login failed: check the username and password in Fetch Data Settings")]
|
||||
InvalidCredentials,
|
||||
}
|
||||
|
||||
/// Search parameters. `query` should be a show/movie title; `season`/`episode` narrow a
|
||||
/// TV search to one episode, `year` narrows either kind to one release year. `language`
|
||||
/// is a single OpenSubtitles language code (e.g. `"en"`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchParams {
|
||||
pub query: String,
|
||||
pub season: Option<u32>,
|
||||
pub episode: Option<u32>,
|
||||
pub year: Option<u32>,
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
/// One subtitle search result, flattened to the fields Aurora Curator actually shows -
|
||||
/// OpenSubtitles' real JSON shape wraps all of this in a `{data: [{attributes: {...}}]}`
|
||||
/// envelope with many more fields than are useful here (see `SearchAttributes` below).
|
||||
/// `camelCase` on the wire (same convention `matching::EpisodeGuess`/`MovieGuess` use)
|
||||
/// since this is returned directly by the `search_subtitles` Tauri command with no
|
||||
/// separate presentation-layer wrapper struct.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubtitleResult {
|
||||
/// The id needed to actually download this subtitle - see `OpensubtitlesClient::download`.
|
||||
pub file_id: u64,
|
||||
pub release: String,
|
||||
pub language: String,
|
||||
pub download_count: i64,
|
||||
pub hearing_impaired: bool,
|
||||
pub hd: bool,
|
||||
/// True when this result was matched via an exact file-hash match rather than a
|
||||
/// text search - a much stronger correctness signal than a title match alone.
|
||||
pub moviehash_match: bool,
|
||||
pub uploader: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of a successful download call: a temporary link to the actual subtitle file,
|
||||
/// plus the account's live daily quota state so the caller can show it to the user.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SubtitleDownload {
|
||||
pub link: String,
|
||||
pub file_name: String,
|
||||
pub remaining: i64,
|
||||
pub allowed: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchResponse {
|
||||
data: Vec<SearchHit>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchHit {
|
||||
attributes: SearchAttributes,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchAttributes {
|
||||
release: String,
|
||||
#[serde(default)]
|
||||
language: Option<String>,
|
||||
#[serde(default)]
|
||||
download_count: i64,
|
||||
#[serde(default)]
|
||||
hearing_impaired: bool,
|
||||
#[serde(default)]
|
||||
hd: bool,
|
||||
#[serde(default)]
|
||||
moviehash_match: bool,
|
||||
#[serde(default)]
|
||||
uploader: Option<UploaderInfo>,
|
||||
#[serde(default)]
|
||||
files: Vec<SubtitleFileRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UploaderInfo {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SubtitleFileRef {
|
||||
file_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LoginRequest<'a> {
|
||||
username: &'a str,
|
||||
password: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LoginResponse {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DownloadRequest {
|
||||
file_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DownloadResponseBody {
|
||||
link: String,
|
||||
#[serde(default)]
|
||||
file_name: String,
|
||||
#[serde(default)]
|
||||
remaining: i64,
|
||||
#[serde(default)]
|
||||
allowed: i64,
|
||||
}
|
||||
|
||||
pub struct OpensubtitlesClient {
|
||||
http: reqwest::Client,
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
impl OpensubtitlesClient {
|
||||
pub fn new(api_key: impl Into<String>) -> Self {
|
||||
Self {
|
||||
http: reqwest::Client::new(),
|
||||
base_url: DEFAULT_BASE_URL.to_string(),
|
||||
api_key: api_key.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides the API base URL - used by tests to point at a local mock server
|
||||
/// instead of the real OpenSubtitles API.
|
||||
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
|
||||
self.base_url = base_url.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Searches for subtitles. Only needs the app-level `Api-Key` - no user login
|
||||
/// required. Results with no downloadable file attached are skipped rather than
|
||||
/// surfaced with a missing id.
|
||||
pub async fn search(&self, params: &SearchParams) -> Result<Vec<SubtitleResult>, OpensubtitlesError> {
|
||||
let season_str = params.season.map(|s| s.to_string());
|
||||
let episode_str = params.episode.map(|e| e.to_string());
|
||||
let year_str = params.year.map(|y| y.to_string());
|
||||
|
||||
let mut query: Vec<(&str, &str)> = vec![
|
||||
("query", params.query.as_str()),
|
||||
("languages", params.language.as_str()),
|
||||
];
|
||||
if let Some(season) = &season_str {
|
||||
query.push(("season_number", season));
|
||||
}
|
||||
if let Some(episode) = &episode_str {
|
||||
query.push(("episode_number", episode));
|
||||
}
|
||||
if let Some(year) = &year_str {
|
||||
query.push(("year", year));
|
||||
}
|
||||
|
||||
let url = format!("{}/subtitles", self.base_url);
|
||||
let response = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("Api-Key", &self.api_key)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let message = response.text().await.unwrap_or_default();
|
||||
return Err(OpensubtitlesError::Api { status, message });
|
||||
}
|
||||
|
||||
let body: SearchResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
|
||||
|
||||
Ok(body
|
||||
.data
|
||||
.into_iter()
|
||||
.filter_map(|hit| {
|
||||
let file_id = hit.attributes.files.first()?.file_id;
|
||||
Some(SubtitleResult {
|
||||
file_id,
|
||||
release: hit.attributes.release,
|
||||
language: hit.attributes.language.unwrap_or_default(),
|
||||
download_count: hit.attributes.download_count,
|
||||
hearing_impaired: hit.attributes.hearing_impaired,
|
||||
hd: hit.attributes.hd,
|
||||
moviehash_match: hit.attributes.moviehash_match,
|
||||
uploader: hit.attributes.uploader.and_then(|u| u.name),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Logs in with the end user's own OpenSubtitles account, returning a short-lived
|
||||
/// JWT to pass to `download`. Required only for downloading, never for searching.
|
||||
pub async fn login(&self, username: &str, password: &str) -> Result<String, OpensubtitlesError> {
|
||||
let url = format!("{}/login", self.base_url);
|
||||
let response = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Api-Key", &self.api_key)
|
||||
.json(&LoginRequest { username, password })
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
|
||||
|
||||
let status = response.status();
|
||||
if status.as_u16() == 401 {
|
||||
return Err(OpensubtitlesError::InvalidCredentials);
|
||||
}
|
||||
if !status.is_success() {
|
||||
let message = response.text().await.unwrap_or_default();
|
||||
return Err(OpensubtitlesError::Api {
|
||||
status: status.as_u16(),
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
let body: LoginResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
|
||||
Ok(body.token)
|
||||
}
|
||||
|
||||
/// Exchanges a search result's `file_id` for a temporary download link, given a
|
||||
/// login token from `login`. The returned `remaining`/`allowed` reflect the
|
||||
/// account's daily download quota *after* this download.
|
||||
pub async fn download(&self, token: &str, file_id: u64) -> Result<SubtitleDownload, OpensubtitlesError> {
|
||||
let url = format!("{}/download", self.base_url);
|
||||
let response = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Api-Key", &self.api_key)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&DownloadRequest { file_id })
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let message = response.text().await.unwrap_or_default();
|
||||
return Err(OpensubtitlesError::Api { status, message });
|
||||
}
|
||||
|
||||
let body: DownloadResponseBody = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
|
||||
|
||||
Ok(SubtitleDownload {
|
||||
link: body.link,
|
||||
file_name: body.file_name,
|
||||
remaining: body.remaining,
|
||||
allowed: body.allowed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetches the actual subtitle text from a `download()` result's temporary `link`.
|
||||
/// Subtitle files (SRT/VTT) are plain text, so this returns a `String` rather than
|
||||
/// raw bytes.
|
||||
pub async fn fetch_subtitle_text(&self, link: &str) -> Result<String, OpensubtitlesError> {
|
||||
let response = self
|
||||
.http
|
||||
.get(link)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OpensubtitlesError::Network(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status().as_u16();
|
||||
let message = response.text().await.unwrap_or_default();
|
||||
return Err(OpensubtitlesError::Api { status, message });
|
||||
}
|
||||
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| OpensubtitlesError::Network(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn params(query: &str) -> SearchParams {
|
||||
SearchParams {
|
||||
query: query.to_string(),
|
||||
season: None,
|
||||
episode: None,
|
||||
year: None,
|
||||
language: "en".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn searches_and_flattens_results() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/subtitles")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("query".into(), "Girls".into()),
|
||||
mockito::Matcher::UrlEncoded("languages".into(), "en".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"data":[{"attributes":{"release":"Girls.S02E02.WEB-DL","language":"en",
|
||||
"download_count":42,"hearing_impaired":false,"hd":true,"moviehash_match":true,
|
||||
"uploader":{"name":"someone"},"files":[{"file_id":9001}]}}]}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||
let results = client.search(¶ms("Girls")).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
results,
|
||||
vec![SubtitleResult {
|
||||
file_id: 9001,
|
||||
release: "Girls.S02E02.WEB-DL".to_string(),
|
||||
language: "en".to_string(),
|
||||
download_count: 42,
|
||||
hearing_impaired: false,
|
||||
hd: true,
|
||||
moviehash_match: true,
|
||||
uploader: Some("someone".to_string()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn includes_season_and_episode_in_the_query_only_when_given() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/subtitles")
|
||||
.match_query(mockito::Matcher::AllOf(vec![
|
||||
mockito::Matcher::UrlEncoded("season_number".into(), "2".into()),
|
||||
mockito::Matcher::UrlEncoded("episode_number".into(), "5".into()),
|
||||
]))
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"data":[]}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||
let mut p = params("Some Show");
|
||||
p.season = Some(2);
|
||||
p.episode = Some(5);
|
||||
let results = client.search(&p).await.unwrap();
|
||||
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_results_with_no_file_attached_are_skipped() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/subtitles")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"data":[
|
||||
{"attributes":{"release":"No Files","language":"en","download_count":0,
|
||||
"hearing_impaired":false,"hd":false,"files":[]}},
|
||||
{"attributes":{"release":"Has A File","language":"en","download_count":1,
|
||||
"hearing_impaired":false,"hd":false,"files":[{"file_id":5}]}}
|
||||
]}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||
let results = client.search(¶ms("anything")).await.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].release, "Has A File");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn surfaces_a_server_error_on_search_as_an_api_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/subtitles")
|
||||
.match_query(mockito::Matcher::Any)
|
||||
.with_status(503)
|
||||
.with_header("content-type", "text/plain")
|
||||
.with_body("service unavailable")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||
let result = client.search(¶ms("anything")).await;
|
||||
|
||||
match result {
|
||||
Err(OpensubtitlesError::Api { status, .. }) => assert_eq!(status, 503),
|
||||
other => panic!("expected Api error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logs_in_successfully_and_returns_the_token() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("POST", "/login")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"token":"jwt-abc-123","user":{"allowed_downloads":100}}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||
let token = client.login("someone", "hunter2").await.unwrap();
|
||||
|
||||
assert_eq!(token, "jwt-abc-123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_with_bad_credentials_reports_invalid_credentials() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("POST", "/login")
|
||||
.with_status(401)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"Unauthorized"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||
let result = client.login("someone", "wrong-password").await;
|
||||
|
||||
assert!(matches!(result, Err(OpensubtitlesError::InvalidCredentials)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn downloads_and_returns_the_link_and_quota() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("POST", "/download")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(
|
||||
r#"{"link":"https://dl.opensubtitles.com/tmp/abc.srt","file_name":"Girls.S02E02.srt",
|
||||
"remaining":99,"allowed":100}"#,
|
||||
)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||
let download = client.download("jwt-abc-123", 9001).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
download,
|
||||
SubtitleDownload {
|
||||
link: "https://dl.opensubtitles.com/tmp/abc.srt".to_string(),
|
||||
file_name: "Girls.S02E02.srt".to_string(),
|
||||
remaining: 99,
|
||||
allowed: 100,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_failure_surfaces_as_an_api_error() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("POST", "/download")
|
||||
.with_status(406)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"message":"Daily download limit reached"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||
let result = client.download("jwt-abc-123", 9001).await;
|
||||
|
||||
match result {
|
||||
Err(OpensubtitlesError::Api { status, message }) => {
|
||||
assert_eq!(status, 406);
|
||||
assert!(message.contains("limit"));
|
||||
}
|
||||
other => panic!("expected Api error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetches_the_actual_subtitle_text_from_a_download_link() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
||||
let _mock = server
|
||||
.mock("GET", "/tmp/abc.srt")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "text/plain")
|
||||
.with_body("1\n00:00:01,000 --> 00:00:02,000\nHello\n")
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let client = OpensubtitlesClient::new("test-key").with_base_url(server.url());
|
||||
let text = client
|
||||
.fetch_subtitle_text(&format!("{}/tmp/abc.srt", server.url()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(text.contains("Hello"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user