//! App config. Non-secret settings live in `config.json`; the Soulseek //! password lives in the Windows Credential Manager (via `keyring`). use serde::{Deserialize, Serialize}; use std::path::PathBuf; use tauri::Manager; const KEYRING_SERVICE: &str = "com.soulstream.app"; /// Pre-rename service name; stored passwords migrate on first read. const LEGACY_KEYRING_SERVICE: &str = "com.trifecta.soulseek-plus"; /// Pre-rename app-data folder; migrated by `migrate_app_data` at startup. const LEGACY_APP_DATA_DIR: &str = "com.trifecta.soulseek-plus"; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ArtistHeroUpscaler { /// Fixed 3840x2160 GPU master, mip-filtered to the visible window. #[default] #[serde(alias = "fsr")] LiveGpu, #[serde(rename = "fsr_legacy")] Fsr, Lanczos3, } /// Ceiling for peer-source formats. `LossyOnly` skips lossless files — /// the "don't stream multi-hundred-MB FLACs on this connection" cap. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SourceQualityCap { #[default] Any, LossyOnly, } /// Floor for peer-source quality. Lossless formats satisfy every floor; a /// lossy file must carry a known bitrate at or above the named floor. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SourceQualityFloor { #[default] Any, Kbps192, Kbps256, Kbps320, Lossless, } /// What the resolver optimizes among sources that satisfy the cap/floor: /// best format (current behavior) or fastest transfer (free slot, upload /// speed, short queue — format only breaks ties). #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SourcePriority { #[default] Quality, Speed, } /// Per-mode source-selection preferences; streaming and downloading each /// carry their own copy. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SourcePrefs { #[serde(default)] pub max: SourceQualityCap, #[serde(default)] pub min: SourceQualityFloor, #[serde(default)] pub priority: SourcePriority, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct AppConfig { #[serde(default)] pub username: String, #[serde(default)] pub listen_port: Option, /// Where downloads are saved. None → default `\SoulSeek+`. #[serde(default)] pub download_dir: Option, /// Album-folder pattern below the managed library/download root. /// Available placeholders: {artist}, {album}, {date}, {year}. /// `\` or `/` in the pattern create subfolders (separators coming from /// tag values are flattened to `_`). #[serde(default)] pub library_layout: Option, /// The managed library root the import tool copies into. Deliberately no /// default: the Source step requires choosing it before Scan enables, and /// it must never silently coincide with the download/staging dir, whose /// prune sweeps would eat imported files. #[serde(default)] pub library_dir: Option, /// Extra folders to share (the downloads folder is always shared). #[serde(default)] pub shared_folders: Vec, /// Concurrent upload slots offered to peers. #[serde(default)] pub upload_slots: Option, /// Named platform output endpoint. None follows Windows' default device. #[serde(default)] pub audio_output_device: Option, /// Spatial upscaler used for provider artist heroes below native 4K. #[serde(default)] pub artist_hero_upscaler: ArtistHeroUpscaler, /// Source-selection preferences for streaming. #[serde(default)] pub stream_source_prefs: SourcePrefs, /// Source-selection preferences for downloads/saves. #[serde(default)] pub download_source_prefs: SourcePrefs, /// Replace whatever picture a download arrives with by the canonical /// high-resolution cover. None → true. Startup-scanned library files are /// never rewritten by this; it applies to fresh downloads only. #[serde(default)] pub replace_embedded_art: Option, /// Longest edge for covers embedded into audio files. None → 1500. /// Some(0) → embed the original untouched. The full-resolution original /// is always kept in the art cache and the album folder regardless. #[serde(default)] pub embed_art_max_edge: Option, /// Write the canonical cover as cover.jpg + folder.jpg in the organized /// album folder so Explorer and other players show the same artwork. /// None → true. #[serde(default)] pub write_folder_cover: Option, /// Save every fully streamed track into the library. None → false: /// streams stay only in the bounded stream cache below. #[serde(default)] pub stream_keep_forever: Option, /// Days a streamed track stays in the stream cache after it was last /// played. None → 30. Ignored when `stream_keep_forever` is on. #[serde(default)] pub stream_retention_days: Option, /// Stream cache size cap in MB, oldest evicted first. None → 2048. /// Some(0) → unlimited. #[serde(default)] pub stream_cache_max_mb: Option, /// Loudness normalization (−14 LUFS target, BS.1770 measurement): /// every track plays at roughly the same perceived level. None → true. #[serde(default)] pub volume_normalization: Option, pub word_timing: Option, /// Dark-glass mode: acrylic blur behind a transparent window, with the /// base surfaces thinned in CSS so the desktop glows through. None → false. #[serde(default)] pub glass_mode: Option, /// The Soulseek hub's buddy list — usernames watched on every login so /// their status and stats stream in while connected. #[serde(default)] pub buddies: Vec, } /// Default longest edge for embedded covers when the user hasn't chosen. pub const DEFAULT_EMBED_ART_MAX_EDGE: u32 = 1500; pub fn replace_embedded_art(cfg: &AppConfig) -> bool { cfg.replace_embedded_art.unwrap_or(true) } /// 0 means "embed the original at full resolution". pub fn embed_art_max_edge(cfg: &AppConfig) -> u32 { cfg.embed_art_max_edge.unwrap_or(DEFAULT_EMBED_ART_MAX_EDGE) } pub fn write_folder_cover(cfg: &AppConfig) -> bool { cfg.write_folder_cover.unwrap_or(true) } pub const DEFAULT_STREAM_RETENTION_DAYS: u32 = 30; pub const DEFAULT_STREAM_CACHE_MAX_MB: u64 = 2048; pub fn stream_keep_forever(cfg: &AppConfig) -> bool { cfg.stream_keep_forever.unwrap_or(false) } pub fn stream_retention_days(cfg: &AppConfig) -> u32 { cfg.stream_retention_days .unwrap_or(DEFAULT_STREAM_RETENTION_DAYS) .max(1) } /// 0 = unlimited. pub fn stream_cache_max_mb(cfg: &AppConfig) -> u64 { cfg.stream_cache_max_mb .unwrap_or(DEFAULT_STREAM_CACHE_MAX_MB) } pub fn volume_normalization(cfg: &AppConfig) -> bool { cfg.volume_normalization.unwrap_or(true) } /// Word-by-word lyric timing (the karaoke fill) — EXPERIMENTAL, off by /// default: generated word spans still drift on dense or echo-heavy mixes, /// and the solid line-level sync must be what users live with until the /// feature earns its keep (Kyle, 2026-08-19). pub fn glass_mode(cfg: &AppConfig) -> bool { cfg.glass_mode.unwrap_or(false) } pub fn word_timing(cfg: &AppConfig) -> bool { cfg.word_timing.unwrap_or(false) } /// One-time migration after the identifier rename: the app-data dir moved /// from `com.trifecta.soulseek-plus` to the new identifier's folder. Rename /// the old dir into place so the library DB, config and stream cache /// survive. Never overwrites existing data at the new location. pub fn migrate_app_data(app: &tauri::AppHandle) { let Ok(new_dir) = app.path().app_data_dir() else { return; }; let Some(parent) = new_dir.parent() else { return; }; let old_dir = parent.join(LEGACY_APP_DATA_DIR); if !old_dir.is_dir() { return; } let occupied = new_dir.exists() && std::fs::read_dir(&new_dir) .map(|mut d| d.next().is_some()) .unwrap_or(true); if occupied { return; } if new_dir.exists() { let _ = std::fs::remove_dir(&new_dir); } match std::fs::rename(&old_dir, &new_dir) { Ok(()) => log::info!("migrated app data from {}", old_dir.display()), Err(e) => log::warn!( "app data migration failed ({e}); old data stays in {}", old_dir.display() ), } } fn config_path(app: &tauri::AppHandle) -> Option { let dir = app.path().app_data_dir().ok()?; std::fs::create_dir_all(&dir).ok()?; Some(dir.join("config.json")) } pub fn load(app: &tauri::AppHandle) -> AppConfig { config_path(app) .and_then(|p| std::fs::read_to_string(p).ok()) .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } pub fn save(app: &tauri::AppHandle, cfg: &AppConfig) { if let Some(p) = config_path(app) { if let Ok(json) = serde_json::to_string_pretty(cfg) { if let Err(e) = std::fs::write(&p, json) { log::warn!("failed to save config to {}: {e}", p.display()); } } } } /// The effective downloads directory: configured, or `\SoulStream`. /// A pre-rename `\SoulSeek+` folder keeps being used if it exists, /// so an existing library doesn't fork into a new empty root. pub fn download_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf { if let Some(d) = &cfg.download_dir { if !d.is_empty() { return PathBuf::from(d); } } let music = app .path() .audio_dir() .or_else(|_| app.path().home_dir()) .unwrap_or_else(|_| PathBuf::from(".")); let legacy = music.join("SoulSeek+"); if legacy.is_dir() { return legacy; } music.join("SoulStream") } /// The folder holding everything the app knows ABOUT this library — the /// database, the review session, the artwork it has chosen and cached. /// /// It lives inside the library folder because the library has to be portable: /// carry the drive to another machine and the records, the decisions and the /// covers arrive together. State kept in AppData does not travel, and an /// import saved at one machine and opened at another lost every choice it had /// made while the audio arrived intact. /// /// AppData keeps only what is about the APP on THIS machine: credentials, the /// audio device, window state, and caches of REMOTE data (`net-cache-v1`, /// `stream-cache`, the GPU render caches) which are regenerable and would /// otherwise bloat the drive. /// /// The name starts with a dot so it sorts out of the way, and the share index /// skips it by name as well as by only ever indexing audio. pub const LIBRARY_DATA_DIRNAME: &str = ".soulstream"; pub fn library_data_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf { download_dir(app, cfg).join(LIBRARY_DATA_DIRNAME) } /// `library_data_dir`, created if missing. Errors are the caller's to handle: /// with the drive unplugged this cannot succeed, and inventing a fallback in /// AppData is exactly the split-brain this whole change removes. pub fn ensure_library_data_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> std::io::Result { let dir = library_data_dir(app, cfg); std::fs::create_dir_all(&dir)?; Ok(dir) } /// Everything that used to live in AppData and describes the library, moved /// once into the library folder. Ordered by how much it hurts to lose. /// /// `(appdata_relative_name, is_dir)` — the name is kept identical on the far /// side so a migrated folder is recognisable, and so a half-finished migration /// can be resumed by simply running again. const MIGRATED_LIBRARY_STATE: &[(&str, bool)] = &[ ("library.db", false), ("import-session.json", false), ("import-art", true), ("art", true), ("artist-identity-v1", true), ("artist-heroes", true), ("loudness-cache.json", false), ]; /// Move library state out of AppData and into the library folder, once. /// /// Rules, in order of importance: /// - A destination that already exists WINS and the source is left alone. /// The library folder is the truth; a stale AppData copy must never /// overwrite it, and running this twice must be a no-op. /// - Nothing is deleted until the copy is verified in place. A rename across /// volumes fails on Windows, so this is copy-then-remove, and the remove is /// skipped if anything went wrong — a leftover AppData copy is harmless, /// a half-moved database is not. /// - The SQLite sidecars (`-wal`, `-shm`) travel WITH the database. Moving /// `library.db` alone can discard committed transactions still in the WAL. /// /// Returns the names actually moved, for the log. pub fn migrate_library_state_into_library(app: &tauri::AppHandle, cfg: &AppConfig) -> Vec { let Ok(dest_root) = ensure_library_data_dir(app, cfg) else { return Vec::new(); }; let Ok(src_root) = app.path().app_data_dir() else { return Vec::new(); }; let mut moved = Vec::new(); for (name, is_dir) in MIGRATED_LIBRARY_STATE { let src = src_root.join(name); let dest = dest_root.join(name); if dest.exists() || !src.exists() { continue; } let ok = if *is_dir { copy_dir_all(&src, &dest).is_ok() } else { // The database's write-ahead log is part of the database. let mut all = std::fs::copy(&src, &dest).is_ok(); for suffix in ["-wal", "-shm"] { let extra = src_root.join(format!("{name}{suffix}")); if extra.exists() { all &= std::fs::copy(&extra, dest_root.join(format!("{name}{suffix}"))).is_ok(); } } all }; if !ok { log::warn!("library migration: could not move {name} — leaving it in AppData"); continue; } // Verified across: retire the original rather than delete it outright, // so a mistake here is recoverable by hand. let retired = src_root.join(format!("{name}.moved-to-library")); let _ = std::fs::remove_dir_all(&retired); let _ = std::fs::remove_file(&retired); if std::fs::rename(&src, &retired).is_ok() { for suffix in ["-wal", "-shm"] { let extra = src_root.join(format!("{name}{suffix}")); let _ = std::fs::rename(&extra, src_root.join(format!("{name}{suffix}.moved-to-library"))); } } moved.push((*name).to_string()); } moved } fn copy_dir_all(src: &std::path::Path, dest: &std::path::Path) -> std::io::Result<()> { std::fs::create_dir_all(dest)?; for entry in std::fs::read_dir(src)? { let entry = entry?; let to = dest.join(entry.file_name()); if entry.file_type()?.is_dir() { copy_dir_all(&entry.path(), &to)?; } else { std::fs::copy(entry.path(), to)?; } } Ok(()) } /// Where an import writes. The chosen destination, or — when none has been /// chosen — the library folder itself. /// /// These were two separately-nullable settings that both mean "the library", /// and `library_dir` being null silently disabled importing: apply refused /// with "no library folder chosen yet" and the picker's save-and-bank returned /// without doing anything or saying so. There is no sensible third answer — /// music imported into the library goes into the library folder — so an unset /// destination now means exactly that instead of meaning "refuse". /// /// A destination OUTSIDE the library folder is still honoured if explicitly /// set, but it is a strange thing to want: `prune_outside_root` will drop /// those rows again at the next launch, because they are not in the library. pub fn import_dest_dir(app: &tauri::AppHandle, cfg: &AppConfig) -> PathBuf { match cfg.library_dir.as_deref().map(str::trim) { Some(d) if !d.is_empty() => PathBuf::from(d), _ => download_dir(app, cfg), } } pub const DEFAULT_LIBRARY_LAYOUT: &str = r"{artist}\{year} - {album}"; pub fn library_layout(cfg: &AppConfig) -> &str { cfg.library_layout .as_deref() .map(str::trim) .filter(|layout| !layout.is_empty()) .unwrap_or(DEFAULT_LIBRARY_LAYOUT) } // ---- password storage (Windows Credential Manager) ---- fn entry(username: &str) -> Option { keyring::Entry::new(KEYRING_SERVICE, username).ok() } pub fn save_password(username: &str, password: &str) { if let Some(e) = entry(username) { if let Err(err) = e.set_password(password) { log::warn!("failed to store password in credential manager: {err}"); } } } pub fn load_password(username: &str) -> Option { if let Some(pw) = entry(username).and_then(|e| e.get_password().ok()) { return Some(pw); } // Fall back to the pre-rename service entry and migrate it over. let legacy = keyring::Entry::new(LEGACY_KEYRING_SERVICE, username).ok()?; let pw = legacy.get_password().ok()?; save_password(username, &pw); let _ = legacy.delete_credential(); Some(pw) } pub fn has_password(username: &str) -> bool { !username.is_empty() && load_password(username).is_some() } pub fn delete_password(username: &str) { if let Some(e) = entry(username) { let _ = e.delete_credential(); } } #[cfg(test)] mod tests { use super::ArtistHeroUpscaler; #[test] fn legacy_fsr_setting_migrates_to_live_gpu() { let migrated: ArtistHeroUpscaler = serde_json::from_str("\"fsr\"").unwrap(); assert_eq!(migrated, ArtistHeroUpscaler::LiveGpu); assert_eq!(ArtistHeroUpscaler::default(), ArtistHeroUpscaler::LiveGpu); assert_eq!( serde_json::to_string(&ArtistHeroUpscaler::LiveGpu).unwrap(), "\"live_gpu\"" ); assert_eq!( serde_json::to_string(&ArtistHeroUpscaler::Fsr).unwrap(), "\"fsr_legacy\"" ); } }