Files
SoulStream/src-tauri/src/config.rs
T
KeelrLeek 63a0357651 the soulseek hub: the whole Qt client as one area, wired to the wire
Kyle: "this is going to be a soulseek menu now — essentially the soulseek
app in our app." Approved against research/soulseek-search/hub2.html, a
one-to-one mock in the real frame, then built in three layers.

THE AREA. Search and Downloads leave the sidebar; one Soulseek entry
(globe) opens src/views/Soulseek.svelte, whose tab row is SoulseekQt's own
anatomy: Transfers, Search, Browse, Rooms, Chat, Users. The old view ids
stay routable — App.svelte aliases "search" and "downloads" to the hub and
the component syncs its tab off them — so the title bar's download ring,
"get on soulseek" hand-offs, history entries and restored sessions all
land on the right tab. The glass bar rides the hub with the connection
line on its right (online as X · sharing N files), the page takes Home's
below-the-bar inset, and the bar arrows join the chrome spec (16px glyphs)
alongside the sidebar toggle's disc. The replaced Search.svelte and
Downloads.svelte are deleted; Transfers carries their data forward in Qt's
own columns (User/File/Progress/Status/ETA/Speed/Size/Place, cancel and
play/reveal/add on the rows, clear-complete and open-folder in the
toolbar, live speed totals in the footer) and Search re-skins the peer
search as the Qt table: results regrouped user -> folder -> file,
slot/queue chips, format filter chips that actually filter, fold-out
files with stream-and-download on hover.

THE WIRE. soulseek-proto learns the rest of the protocol: rooms 13-17
(say/join/leave/user-joined/user-left), private messages 22/23, the
watch/status/stats family 5/6/7/36, the room list 64, and the peer-side
browse pair 4/5 with Directory::read to decode what our own responder
already encoded. Parsers are tolerant where the wire is baroque — roster
sections consume their own declared counts even when they disagree with
the name list, RoomList ignores the private-room tail, truncated stats
default instead of failing the frame. Codec tests ride along, including a
browse round-trip against our own encoder.

soulseek-client turns those into Events (RoomList/RoomJoined/RoomMessage/
PrivateMessage/UserWatched/...), auto-acks every private message the
moment it lands — unacked DMs are redelivered on every login — and grows
a spawn-shaped browse(): the returned future owns an Arc of the internals
so the caller drops the client lock before awaiting the 45s worst case.

THE APP. The event pump forwards everything as soulseek:* events; new
commands cover join/leave/say/message/room-list/browse plus a buddy list
persisted in config and re-watched on every login so status and stats
stream while connected. The frontend slsk store drains it all into the
four tabs: Rooms (joined + popular lists, live roster column, per-user
colored feed, say box — the server echoes our own lines so nothing is
appended locally), Chat (recency-sorted conversations, unread badges, DM
bubbles — our half appended locally since DMs are NOT echoed), Users
(add/remove buddies, status chips, one-click Browse/Chat cross-wiring),
and Browse (type a name, walk their tree, stream or download any file).

And the sharing side finally taps the shoulder: UploadToast announces the
first event of every new upload in the shared toast slot, folding a
same-user burst into one counting pill ("nikac is taking 12 files")
instead of twelve entrances.

Live-verified against the real network: the server handed 703 rooms after
login, and a browse of a live peer returned 3,796 directories / 34,773
files in 929ms with full paths, sizes and attributes. Join/say and DMs
are wired but deliberately unexercised — the first hello in a real room
is Kyle's to say, not a probe's.
2026-08-31 15:13:53 -05:00

489 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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>,
}
/// 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\""
);
}
}