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:
Renamr Scaffold
2026-08-29 14:08:17 +00:00
parent c158befa83
commit b47d28375b
10 changed files with 1550 additions and 25 deletions
+8 -5
View File
@@ -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(&params("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(&params("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(&params("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"));
}
}