Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,311 @@
|
||||
//! 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>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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")
|
||||
}
|
||||
|
||||
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\""
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user