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:
@@ -4,6 +4,7 @@ mod history;
|
||||
mod metadata;
|
||||
mod presets;
|
||||
mod settings;
|
||||
mod subtitles;
|
||||
|
||||
use aurora_curator_core::format::ExpressionFormat;
|
||||
use aurora_curator_core::matching::{self, EpisodeGuess, MovieGuess, ReleaseInfo};
|
||||
@@ -107,6 +108,13 @@ pub fn run() {
|
||||
settings::save_tv_metadata_source,
|
||||
settings::get_movie_metadata_source,
|
||||
settings::save_movie_metadata_source,
|
||||
settings::get_opensubtitles_api_key,
|
||||
settings::save_opensubtitles_api_key,
|
||||
settings::get_opensubtitles_username,
|
||||
settings::has_opensubtitles_password,
|
||||
settings::save_opensubtitles_login,
|
||||
subtitles::search_subtitles,
|
||||
subtitles::download_subtitle,
|
||||
history::list_rename_history,
|
||||
history::undo_rename_batch,
|
||||
presets::list_presets,
|
||||
|
||||
@@ -68,6 +68,21 @@ struct StoredSettings {
|
||||
tv_metadata_source: TvMetadataSource,
|
||||
#[serde(default)]
|
||||
movie_metadata_source: MovieMetadataSource,
|
||||
/// OpenSubtitles "consumer" app key - needed to *search* subtitles at all.
|
||||
#[serde(default)]
|
||||
opensubtitles_api_key: Option<String>,
|
||||
/// The end user's own OpenSubtitles account username - needed, alongside
|
||||
/// `opensubtitles_password`, only to *download* a subtitle (search works without
|
||||
/// either). Not treated as sensitive as the password (an OpenSubtitles username is
|
||||
/// often just a public display name), so it's returned to the frontend as-is like
|
||||
/// every other credential's get command.
|
||||
#[serde(default)]
|
||||
opensubtitles_username: Option<String>,
|
||||
/// The end user's own OpenSubtitles account password. Unlike every other credential
|
||||
/// in this file, this is never sent back to the frontend once saved - see
|
||||
/// [`has_opensubtitles_password`], which only reports whether one is set.
|
||||
#[serde(default)]
|
||||
opensubtitles_password: Option<String>,
|
||||
}
|
||||
|
||||
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
@@ -192,6 +207,93 @@ pub fn save_movie_metadata_source(app: AppHandle, source: MovieMetadataSource) -
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
/// Returns the stored OpenSubtitles app key, or `None` if nothing (or only blank text)
|
||||
/// is saved. Needed for search; download additionally needs
|
||||
/// [`opensubtitles_username`]/[`opensubtitles_password_is_set`].
|
||||
pub fn opensubtitles_api_key(app: &AppHandle) -> Option<String> {
|
||||
load(app).opensubtitles_api_key.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Returns the stored OpenSubtitles account username, or `None` if unset.
|
||||
pub fn opensubtitles_username(app: &AppHandle) -> Option<String> {
|
||||
load(app).opensubtitles_username.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Returns the stored OpenSubtitles account password, or `None` if unset. Kept
|
||||
/// `pub(crate)` rather than `pub` - unlike every other credential, this value must
|
||||
/// never cross the Tauri command boundary back to the frontend (see
|
||||
/// [`has_opensubtitles_password`]), so only this crate's own subtitle-download code
|
||||
/// should ever read it.
|
||||
pub(crate) fn opensubtitles_password(app: &AppHandle) -> Option<String> {
|
||||
load(app).opensubtitles_password.filter(|t| !t.trim().is_empty())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_opensubtitles_api_key(app: AppHandle) -> Option<String> {
|
||||
opensubtitles_api_key(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_opensubtitles_api_key(app: AppHandle, key: String) -> Result<(), String> {
|
||||
let mut settings = load(&app);
|
||||
let trimmed = key.trim().to_string();
|
||||
settings.opensubtitles_api_key = if trimmed.is_empty() { None } else { Some(trimmed) };
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_opensubtitles_username(app: AppHandle) -> Option<String> {
|
||||
opensubtitles_username(&app)
|
||||
}
|
||||
|
||||
/// Reports only *whether* a password is saved, never the password itself - the one
|
||||
/// deliberate exception to this module's usual "get returns the real saved value"
|
||||
/// pattern, since a real account password is more sensitive than an API token/key.
|
||||
#[tauri::command]
|
||||
pub fn has_opensubtitles_password(app: AppHandle) -> bool {
|
||||
opensubtitles_password(&app).is_some()
|
||||
}
|
||||
|
||||
/// Pure merge logic for [`save_opensubtitles_login`], split out so it's directly unit
|
||||
/// testable without an `AppHandle`. A blank field keeps whatever was saved for it
|
||||
/// before (so updating just the password doesn't force retyping the username, and vice
|
||||
/// versa) - only when BOTH are blank does this clear the login entirely.
|
||||
fn merge_login_update(
|
||||
current_username: Option<String>,
|
||||
current_password: Option<String>,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
if username.is_empty() && password.is_empty() {
|
||||
return (None, None);
|
||||
}
|
||||
let new_username = {
|
||||
let trimmed = username.trim().to_string();
|
||||
if trimmed.is_empty() { current_username } else { Some(trimmed) }
|
||||
};
|
||||
let new_password = {
|
||||
let trimmed = password.trim().to_string();
|
||||
if trimmed.is_empty() { current_password } else { Some(trimmed) }
|
||||
};
|
||||
(new_username, new_password)
|
||||
}
|
||||
|
||||
/// Saves the OpenSubtitles login (username + password) as one unit, since downloading
|
||||
/// needs both together. See [`merge_login_update`] for the exact keep-vs-clear rules.
|
||||
#[tauri::command]
|
||||
pub fn save_opensubtitles_login(app: AppHandle, username: String, password: String) -> Result<(), String> {
|
||||
let mut settings = load(&app);
|
||||
let (new_username, new_password) = merge_login_update(
|
||||
settings.opensubtitles_username.take(),
|
||||
settings.opensubtitles_password.take(),
|
||||
username,
|
||||
password,
|
||||
);
|
||||
settings.opensubtitles_username = new_username;
|
||||
settings.opensubtitles_password = new_password;
|
||||
save(&app, &settings)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -258,4 +360,49 @@ mod tests {
|
||||
assert_eq!(parsed.movie_metadata_source, MovieMetadataSource::Tmdb);
|
||||
assert_eq!(parsed.tv_metadata_source, TvMetadataSource::Tvmaze);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_settings_without_opensubtitles_fields_still_deserializes() {
|
||||
// Same schema-evolution guarantee as the OMDb test above, for the three fields
|
||||
// the OpenSubtitles subtitle feature added.
|
||||
let old_json = r#"{"tmdb_token":"abc123","omdb_api_key":"def456"}"#;
|
||||
let parsed: StoredSettings = serde_json::from_str(old_json).unwrap();
|
||||
assert_eq!(parsed.opensubtitles_api_key, None);
|
||||
assert_eq!(parsed.opensubtitles_username, None);
|
||||
assert_eq!(parsed.opensubtitles_password, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_login_update_keeps_the_other_field_when_one_is_blank() {
|
||||
// Updating just the password shouldn't silently clear a previously-saved
|
||||
// username, and vice versa.
|
||||
let (username, password) = merge_login_update(
|
||||
Some("old-name".to_string()),
|
||||
Some("old-pass".to_string()),
|
||||
String::new(),
|
||||
"new-pass".to_string(),
|
||||
);
|
||||
assert_eq!(username, Some("old-name".to_string()));
|
||||
assert_eq!(password, Some("new-pass".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_login_update_clears_both_only_when_both_are_blank() {
|
||||
let (username, password) = merge_login_update(
|
||||
Some("old-name".to_string()),
|
||||
Some("old-pass".to_string()),
|
||||
String::new(),
|
||||
String::new(),
|
||||
);
|
||||
assert_eq!(username, None);
|
||||
assert_eq!(password, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_login_update_trims_whitespace() {
|
||||
let (username, password) =
|
||||
merge_login_update(None, None, " someone ".to_string(), " hunter2 ".to_string());
|
||||
assert_eq!(username, Some("someone".to_string()));
|
||||
assert_eq!(password, Some("hunter2".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! The Tauri-facing side of subtitle search/download, via OpenSubtitles
|
||||
//! (`aurora_curator_core::web::opensubtitles`) - the app's first web feature that isn't
|
||||
//! a title/air-date metadata lookup. Search only needs the OpenSubtitles app key saved
|
||||
//! in Fetch Data Settings; download additionally needs the user's own OpenSubtitles
|
||||
//! account login (also saved there, kept separately from the app key - see
|
||||
//! `settings.rs`'s `opensubtitles_*` functions) since downloads are quota-limited per
|
||||
//! account, not per app.
|
||||
|
||||
use aurora_curator_core::web::opensubtitles::{OpensubtitlesClient, OpensubtitlesError, SearchParams, SubtitleResult};
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::settings;
|
||||
|
||||
fn describe_error(err: OpensubtitlesError) -> String {
|
||||
match err {
|
||||
OpensubtitlesError::InvalidCredentials => {
|
||||
"OpenSubtitles login failed - check the username and password in Fetch Data Settings.".to_string()
|
||||
}
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Searches OpenSubtitles for subtitles matching a TV episode or movie. Only needs the
|
||||
/// app-level API key - no account login required (unlike `download_subtitle`).
|
||||
#[tauri::command]
|
||||
pub async fn search_subtitles(
|
||||
app: AppHandle,
|
||||
query: String,
|
||||
season: Option<u32>,
|
||||
episode: Option<u32>,
|
||||
year: Option<u32>,
|
||||
language: String,
|
||||
) -> Result<Vec<SubtitleResult>, String> {
|
||||
let api_key = settings::opensubtitles_api_key(&app)
|
||||
.ok_or_else(|| "Add a free OpenSubtitles API key in Fetch Data Settings first.".to_string())?;
|
||||
|
||||
let client = OpensubtitlesClient::new(api_key);
|
||||
let language = if language.trim().is_empty() {
|
||||
"en".to_string()
|
||||
} else {
|
||||
language
|
||||
};
|
||||
let params = SearchParams {
|
||||
query,
|
||||
season,
|
||||
episode,
|
||||
year,
|
||||
language,
|
||||
};
|
||||
client.search(¶ms).await.map_err(describe_error)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubtitleDownloadOutcome {
|
||||
pub saved_path: String,
|
||||
pub remaining_downloads: i64,
|
||||
pub allowed_downloads: i64,
|
||||
}
|
||||
|
||||
/// Logs in with the user's saved OpenSubtitles account, downloads one subtitle by its
|
||||
/// search-result `file_id`, and writes it to disk right next to `video_path`. Requires
|
||||
/// all three OpenSubtitles credentials (API key + username + password) to already be
|
||||
/// saved through Fetch Data Settings - Aurora Curator never accepts them as arguments
|
||||
/// from anywhere but that settings form, same standing rule as every other credential.
|
||||
#[tauri::command]
|
||||
pub async fn download_subtitle(
|
||||
app: AppHandle,
|
||||
file_id: u64,
|
||||
video_path: String,
|
||||
language: String,
|
||||
) -> Result<SubtitleDownloadOutcome, String> {
|
||||
let api_key = settings::opensubtitles_api_key(&app)
|
||||
.ok_or_else(|| "Add a free OpenSubtitles API key in Fetch Data Settings first.".to_string())?;
|
||||
let username = settings::opensubtitles_username(&app).ok_or_else(|| {
|
||||
"Add your OpenSubtitles account login in Fetch Data Settings first - downloading needs it, even though searching doesn't.".to_string()
|
||||
})?;
|
||||
let password = settings::opensubtitles_password(&app).ok_or_else(|| {
|
||||
"Add your OpenSubtitles account login in Fetch Data Settings first - downloading needs it, even though searching doesn't.".to_string()
|
||||
})?;
|
||||
|
||||
let client = OpensubtitlesClient::new(api_key);
|
||||
let token = client.login(&username, &password).await.map_err(describe_error)?;
|
||||
let download = client.download(&token, file_id).await.map_err(describe_error)?;
|
||||
let text = client.fetch_subtitle_text(&download.link).await.map_err(describe_error)?;
|
||||
|
||||
let saved_path = subtitle_path_for(&video_path, &language)?;
|
||||
std::fs::write(&saved_path, text).map_err(|e| format!("couldn't write {saved_path}: {e}"))?;
|
||||
|
||||
Ok(SubtitleDownloadOutcome {
|
||||
saved_path,
|
||||
remaining_downloads: download.remaining,
|
||||
allowed_downloads: download.allowed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Computes where a downloaded subtitle should be saved: right next to the video file,
|
||||
/// same base name, with the language code inserted before a `.srt` extension (e.g.
|
||||
/// `Show.S01E01.mkv` -> `Show.S01E01.en.srt`) - the convention most media players (and
|
||||
/// FileBot itself) look for automatically, so no separate "subtitles" folder or naming
|
||||
/// scheme is needed for this to just work.
|
||||
fn subtitle_path_for(video_path: &str, language: &str) -> Result<String, String> {
|
||||
let path = Path::new(video_path);
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("{video_path} has no parent directory"))?;
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| format!("{video_path} has no filename"))?;
|
||||
let lang = if language.trim().is_empty() { "en" } else { language.trim() };
|
||||
Ok(parent.join(format!("{stem}.{lang}.srt")).to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn subtitle_path_sits_next_to_the_video_with_the_language_code_inserted() {
|
||||
let path = subtitle_path_for("/movies/Inception (2010)/Inception.2010.1080p.mkv", "en").unwrap();
|
||||
assert_eq!(path, "/movies/Inception (2010)/Inception.2010.1080p.en.srt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtitle_path_defaults_to_english_when_no_language_is_given() {
|
||||
let path = subtitle_path_for("/tv/Show/Show.S01E01.mkv", "").unwrap();
|
||||
assert_eq!(path, "/tv/Show/Show.S01E01.en.srt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtitle_path_trims_whitespace_from_the_language_code() {
|
||||
let path = subtitle_path_for("/tv/Show/Show.S01E01.mkv", " fr ").unwrap();
|
||||
assert_eq!(path, "/tv/Show/Show.S01E01.fr.srt");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user