//! 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, /// 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, } /// 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) } /// 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") } 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\"" ); } }