Files
aurora-curator/src-tauri/src/settings.rs
T
Renamr Scaffold b47d28375b 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).
2026-08-29 14:08:17 +00:00

409 lines
17 KiB
Rust

//! Small persisted app settings - the TMDB API token, the OMDb API key, and which TV
//! and movie metadata sources Fetch Data should use ("Fetch Data" is the only thing in
//! the app that needs settings at all; everything else works offline). Stored
//! as plain JSON in the OS's standard per-app config directory, joined with the app's
//! bundle identifier - `~/Library/Application Support/com.auroracurator.app/settings.json`
//! on macOS, `~/.config/com.auroracurator.app/settings.json` on Linux, `%APPDATA%\
//! com.auroracurator.app\settings.json` on Windows - not the original FileBot's config
//! location, since that's Java Preferences-backed and OS/JVM-specific; this is Aurora
//! Curator's own, analogous location for its own settings.
//!
//! The GUI reaches that directory through a running Tauri `AppHandle`
//! (`app.path().app_config_dir()`); the `-rename` CLI mode (`cli.rs`) never builds a
//! Tauri app at all, so it needs the exact same directory computed a different way -
//! see [`cli_tmdb_token`]/[`cli_omdb_api_key`] below, which replicate Tauri's own
//! `dirs::config_dir().join(identifier)` computation directly (confirmed against Tauri
//! 2.11's `path/desktop.rs` source) so both code paths are guaranteed to read/write the
//! same file. [`APP_CONFIG_IDENTIFIER`] must be kept in sync with `tauri.conf.json`'s
//! `identifier` field by hand - there's no way to share a constant with the JSON config.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
/// Must match `tauri.conf.json`'s `identifier` field exactly - see the module doc.
const APP_CONFIG_IDENTIFIER: &str = "com.auroracurator.app";
/// Which online source Fetch Data resolves TV episodes against. TVmaze needs no API
/// key at all; TMDB and OMDb each need their own saved credential (see
/// [`tmdb_token`]/[`omdb_api_key`]) before a batch resolved against them will succeed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TvMetadataSource {
Tmdb,
Tvmaze,
Omdb,
}
impl Default for TvMetadataSource {
fn default() -> Self {
TvMetadataSource::Tmdb
}
}
/// Which online source Fetch Data resolves movies against. Unlike TV, there's no
/// zero-setup option here (TVmaze has no movie catalog) - both choices need their own
/// saved credential.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MovieMetadataSource {
Tmdb,
Omdb,
}
impl Default for MovieMetadataSource {
fn default() -> Self {
MovieMetadataSource::Tmdb
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct StoredSettings {
#[serde(default)]
tmdb_token: Option<String>,
#[serde(default)]
omdb_api_key: Option<String>,
#[serde(default)]
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> {
let dir = app
.path()
.app_config_dir()
.map_err(|e| format!("couldn't resolve the app config directory: {e}"))?;
fs::create_dir_all(&dir).map_err(|e| format!("couldn't create {}: {e}", dir.display()))?;
Ok(dir.join("settings.json"))
}
fn load(app: &AppHandle) -> StoredSettings {
let Ok(path) = settings_path(app) else {
return StoredSettings::default();
};
fs::read_to_string(path)
.ok()
.and_then(|contents| serde_json::from_str(&contents).ok())
.unwrap_or_default()
}
fn save(app: &AppHandle, settings: &StoredSettings) -> Result<(), String> {
let path = settings_path(app)?;
let json = serde_json::to_string_pretty(settings).map_err(|e| e.to_string())?;
fs::write(&path, json).map_err(|e| format!("couldn't write {}: {e}", path.display()))
}
/// Returns the stored TMDB token, or `None` if nothing (or only blank text) is saved.
pub fn tmdb_token(app: &AppHandle) -> Option<String> {
load(app).tmdb_token.filter(|t| !t.trim().is_empty())
}
/// Same as [`tmdb_token`], but for the `-rename` CLI path, which has no `AppHandle` to
/// ask (it never builds a Tauri app at all - see `cli.rs`'s module doc). Resolves the
/// config directory itself using the identical `dirs::config_dir().join(identifier)`
/// computation Tauri's own `AppHandle::path().app_config_dir()` uses internally, so a
/// token saved through the GUI's Fetch Data Settings is found here too. Aurora Curator
/// still never accepts an API token as a CLI argument - `--db tmdb` only works once a
/// token has been saved through the app itself at least once.
pub fn cli_tmdb_token() -> Option<String> {
let dir = dirs::config_dir()?.join(APP_CONFIG_IDENTIFIER);
let contents = fs::read_to_string(dir.join("settings.json")).ok()?;
let settings: StoredSettings = serde_json::from_str(&contents).ok()?;
settings.tmdb_token.filter(|t| !t.trim().is_empty())
}
#[tauri::command]
pub fn get_tmdb_token(app: AppHandle) -> Option<String> {
tmdb_token(&app)
}
#[tauri::command]
pub fn save_tmdb_token(app: AppHandle, token: String) -> Result<(), String> {
let mut settings = load(&app);
let trimmed = token.trim().to_string();
settings.tmdb_token = if trimmed.is_empty() { None } else { Some(trimmed) };
save(&app, &settings)
}
/// Returns the stored OMDb API key, or `None` if nothing (or only blank text) is
/// saved. Mirrors [`tmdb_token`] exactly - OMDb calls its credential a "key" rather
/// than a "token", but it's the same kind of thing (a plain saved secret, entered once
/// through Fetch Data Settings, never handled by anything but the user and this app).
pub fn omdb_api_key(app: &AppHandle) -> Option<String> {
load(app).omdb_api_key.filter(|t| !t.trim().is_empty())
}
/// Same as [`omdb_api_key`], but for the `-rename` CLI path - mirrors [`cli_tmdb_token`]
/// exactly, see its doc comment for why this can't just ask an `AppHandle`.
pub fn cli_omdb_api_key() -> Option<String> {
let dir = dirs::config_dir()?.join(APP_CONFIG_IDENTIFIER);
let contents = fs::read_to_string(dir.join("settings.json")).ok()?;
let settings: StoredSettings = serde_json::from_str(&contents).ok()?;
settings.omdb_api_key.filter(|t| !t.trim().is_empty())
}
#[tauri::command]
pub fn get_omdb_api_key(app: AppHandle) -> Option<String> {
omdb_api_key(&app)
}
#[tauri::command]
pub fn save_omdb_api_key(app: AppHandle, key: String) -> Result<(), String> {
let mut settings = load(&app);
let trimmed = key.trim().to_string();
settings.omdb_api_key = if trimmed.is_empty() { None } else { Some(trimmed) };
save(&app, &settings)
}
/// Returns the stored TV metadata source (defaults to TMDB if nothing's been chosen).
pub fn tv_metadata_source(app: &AppHandle) -> TvMetadataSource {
load(app).tv_metadata_source
}
#[tauri::command]
pub fn get_tv_metadata_source(app: AppHandle) -> TvMetadataSource {
tv_metadata_source(&app)
}
#[tauri::command]
pub fn save_tv_metadata_source(app: AppHandle, source: TvMetadataSource) -> Result<(), String> {
let mut settings = load(&app);
settings.tv_metadata_source = source;
save(&app, &settings)
}
/// Returns the stored movie metadata source (defaults to TMDB if nothing's been
/// chosen - movies were TMDB-only before OMDb added a second option).
pub fn movie_metadata_source(app: &AppHandle) -> MovieMetadataSource {
load(app).movie_metadata_source
}
#[tauri::command]
pub fn get_movie_metadata_source(app: AppHandle) -> MovieMetadataSource {
movie_metadata_source(&app)
}
#[tauri::command]
pub fn save_movie_metadata_source(app: AppHandle, source: MovieMetadataSource) -> Result<(), String> {
let mut settings = load(&app);
settings.movie_metadata_source = source;
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::*;
#[test]
fn tv_metadata_source_defaults_to_tmdb() {
assert_eq!(TvMetadataSource::default(), TvMetadataSource::Tmdb);
}
#[test]
fn tv_metadata_source_serializes_as_lowercase_camel_case() {
// The frontend sends/receives this as a plain string ("tmdb"/"tvmaze"/"omdb") -
// pin the exact serde output so a future refactor can't silently change the
// wire format out from under the TS side without a test catching it.
assert_eq!(serde_json::to_string(&TvMetadataSource::Tmdb).unwrap(), "\"tmdb\"");
assert_eq!(serde_json::to_string(&TvMetadataSource::Tvmaze).unwrap(), "\"tvmaze\"");
assert_eq!(serde_json::to_string(&TvMetadataSource::Omdb).unwrap(), "\"omdb\"");
}
#[test]
fn movie_metadata_source_defaults_to_tmdb_and_serializes_as_lowercase() {
assert_eq!(MovieMetadataSource::default(), MovieMetadataSource::Tmdb);
assert_eq!(serde_json::to_string(&MovieMetadataSource::Tmdb).unwrap(), "\"tmdb\"");
assert_eq!(serde_json::to_string(&MovieMetadataSource::Omdb).unwrap(), "\"omdb\"");
}
#[test]
fn app_config_identifier_matches_tauri_conf_json() {
// `cli_tmdb_token()` recomputes the GUI's settings-file path by hand (no
// `AppHandle` available in `-rename` mode), so this constant has to be kept in
// sync with `tauri.conf.json`'s `identifier` field manually - see the module
// doc. There's no way to share a single source of truth between a Rust const
// and the JSON config, so this test at least pins the value we're relying on:
// a change to `tauri.conf.json` without a matching change here would silently
// make the CLI look in the wrong directory for a token the GUI already saved.
let conf = include_str!("../tauri.conf.json");
let parsed: serde_json::Value = serde_json::from_str(conf).unwrap();
assert_eq!(
parsed["identifier"].as_str(),
Some(APP_CONFIG_IDENTIFIER),
"APP_CONFIG_IDENTIFIER must match tauri.conf.json's identifier field"
);
}
#[test]
fn stored_settings_without_a_tv_metadata_source_field_still_deserializes() {
// Settings written before this field existed (just `{"tmdbToken": "..."}` or
// similar) must still load cleanly, defaulting to Tmdb - same
// schema-evolution pattern used for `HistoryBatch.action` (see project memory).
let old_json = r#"{"tmdb_token":"abc123"}"#;
let parsed: StoredSettings = serde_json::from_str(old_json).unwrap();
assert_eq!(parsed.tv_metadata_source, TvMetadataSource::Tmdb);
assert_eq!(parsed.tmdb_token, Some("abc123".to_string()));
}
#[test]
fn stored_settings_without_omdb_fields_still_deserializes() {
// Same schema-evolution guarantee as the TV-metadata-source test above, for the
// two fields OMDb support added: an old settings.json with neither
// `omdb_api_key` nor `movie_metadata_source` present must still load cleanly.
let old_json = r#"{"tmdb_token":"abc123","tv_metadata_source":"tvmaze"}"#;
let parsed: StoredSettings = serde_json::from_str(old_json).unwrap();
assert_eq!(parsed.omdb_api_key, None);
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()));
}
}