forked from Keelrleek/SoulStream
523 lines
19 KiB
Rust
523 lines
19 KiB
Rust
//! 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<u16>,
|
||
/// Where downloads are saved. None → default `<Music>\SoulSeek+`.
|
||
#[serde(default)]
|
||
pub download_dir: Option<String>,
|
||
/// 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<String>,
|
||
/// 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<String>,
|
||
/// Extra folders to share (the downloads folder is always shared).
|
||
#[serde(default)]
|
||
pub shared_folders: Vec<String>,
|
||
/// Concurrent upload slots offered to peers.
|
||
#[serde(default)]
|
||
pub upload_slots: Option<u32>,
|
||
/// Named platform output endpoint. None follows Windows' default device.
|
||
#[serde(default)]
|
||
pub audio_output_device: Option<String>,
|
||
/// 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<bool>,
|
||
/// 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<u32>,
|
||
/// 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<bool>,
|
||
/// 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<bool>,
|
||
/// 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<u32>,
|
||
/// Stream cache size cap in MB, oldest evicted first. None → 2048.
|
||
/// Some(0) → unlimited.
|
||
#[serde(default)]
|
||
pub stream_cache_max_mb: Option<u64>,
|
||
/// 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<bool>,
|
||
pub word_timing: Option<bool>,
|
||
/// 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<bool>,
|
||
/// 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<String>,
|
||
/// The experimental Freenet social layer (crates/freenet-adapter).
|
||
/// None → false: strictly opt-in, and flipping it off is the kill
|
||
/// switch — the adapter drops its connection and stops retrying. A
|
||
/// missing or broken node never affects Soulseek or playback.
|
||
#[serde(default)]
|
||
pub freenet_enabled: Option<bool>,
|
||
/// WebSocket API port of the EXTERNAL local Freenet node. None → 7509.
|
||
#[serde(default)]
|
||
pub freenet_ws_port: Option<u16>,
|
||
/// Whether profile avatars fetched over Freenet may be decoded and
|
||
/// shown. None → true (the pipeline sanitizes them regardless); false
|
||
/// degrades every remote avatar to the identicon, no fetch at all.
|
||
#[serde(default)]
|
||
pub freenet_load_remote_images: Option<bool>,
|
||
/// The Community area's local view of a Soulseek contact: petname, group,
|
||
/// note and the two local marks. Private to this device by design — none
|
||
/// of it is ever published or sent to the person it describes.
|
||
#[serde(default)]
|
||
pub contacts: Vec<ContactMeta>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
pub struct ContactMeta {
|
||
pub username: String,
|
||
#[serde(default)]
|
||
pub petname: Option<String>,
|
||
#[serde(default)]
|
||
pub group: Option<String>,
|
||
#[serde(default)]
|
||
pub note: Option<String>,
|
||
#[serde(default)]
|
||
pub trusted: bool,
|
||
#[serde(default)]
|
||
pub favorite: bool,
|
||
}
|
||
|
||
/// 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<PathBuf> {
|
||
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 `<Music>\SoulStream`.
|
||
/// A pre-rename `<Music>\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<PathBuf> {
|
||
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<String> {
|
||
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> {
|
||
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<String> {
|
||
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\""
|
||
);
|
||
}
|
||
}
|