//! 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, #[serde(default)] omdb_api_key: Option, #[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, /// 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, /// 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, } fn settings_path(app: &AppHandle) -> Result { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { load(app).opensubtitles_password.filter(|t| !t.trim().is_empty()) } #[tauri::command] pub fn get_opensubtitles_api_key(app: AppHandle) -> Option { 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 { 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, current_password: Option, username: String, password: String, ) -> (Option, Option) { 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())); } }