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
+147
View File
@@ -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()));
}
}