diff --git a/crates/soulseek-client/src/lib.rs b/crates/soulseek-client/src/lib.rs index 597ca9f..416a145 100644 --- a/crates/soulseek-client/src/lib.rs +++ b/crates/soulseek-client/src/lib.rs @@ -625,6 +625,19 @@ impl Client { } } + /// Declare, or take back, one interest on the server. Fire and forget: + /// the server sends nothing back, and reading our own interests is how you + /// confirm it landed. + pub fn declare_interest(&self, thing: &str, like: bool, on: bool) -> Result<()> { + let msg = match (like, on) { + (true, true) => server::add_thing_i_like(thing), + (true, false) => server::remove_thing_i_like(thing), + (false, true) => server::add_thing_i_hate(thing), + (false, false) => server::remove_thing_i_hate(thing), + }; + self.inner.send_server(msg) + } + /// Users the server ranks as similar to us by shared interests. Spawn-shaped. pub fn similar_users( &self, diff --git a/crates/soulseek-proto/src/server.rs b/crates/soulseek-proto/src/server.rs index 51e2244..588ddd8 100644 --- a/crates/soulseek-proto/src/server.rs +++ b/crates/soulseek-proto/src/server.rs @@ -31,7 +31,11 @@ pub mod code { pub const PING: u32 = 32; pub const SHARED_FOLDERS_AND_FILES: u32 = 35; pub const GET_USER_STATS: u32 = 36; + pub const ADD_THING_I_LIKE: u32 = 51; + pub const REMOVE_THING_I_LIKE: u32 = 52; pub const USER_INTERESTS: u32 = 57; + pub const ADD_THING_I_HATE: u32 = 117; + pub const REMOVE_THING_I_HATE: u32 = 118; pub const SIMILAR_USERS: u32 = 110; pub const ROOM_LIST: u32 = 64; pub const HAVE_NO_PARENTS: u32 = 71; @@ -138,6 +142,28 @@ pub fn get_user_stats(username: &str) -> Vec { .finish_u32(code::GET_USER_STATS) } +/// Declare a like (code 51). These are what the server matches on when it +/// ranks similar users, so a client that never sends one is invisible to that +/// ranking however much it reads. +pub fn add_thing_i_like(thing: &str) -> Vec { + Writer::new().string(thing).finish_u32(code::ADD_THING_I_LIKE) +} + +/// Take a like back (code 52). +pub fn remove_thing_i_like(thing: &str) -> Vec { + Writer::new().string(thing).finish_u32(code::REMOVE_THING_I_LIKE) +} + +/// Declare a dislike (code 117). +pub fn add_thing_i_hate(thing: &str) -> Vec { + Writer::new().string(thing).finish_u32(code::ADD_THING_I_HATE) +} + +/// Take a dislike back (code 118). +pub fn remove_thing_i_hate(thing: &str) -> Vec { + Writer::new().string(thing).finish_u32(code::REMOVE_THING_I_HATE) +} + /// A user's declared likes and hates (code 57). pub fn user_interests(username: &str) -> Vec { Writer::new() diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0cb3296..f90ee0d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,5517 +1,5571 @@ -use serde::{Deserialize, Serialize}; -use tauri::{Emitter, Manager, State}; - -use soulseek_client::{Client, Config, DownloadState, DownloadUpdate, Event}; -use soulseek_proto::types::SearchResponse; - -use crate::config; -use crate::hero_crop::{maximal_hero_rect, plan_hero_crop, HeroCropPlan}; -use crate::hero_detector::detect_subjects; -use crate::pipeline::{self, LibraryState}; -use crate::playback::{self, PlayerState}; -use crate::state::AppState; - -#[derive(Serialize)] -pub struct AppStatus { - pub version: &'static str, - pub connected_as: Option, -} - -#[tauri::command] -pub async fn app_status(state: State<'_, AppState>) -> Result { - Ok(AppStatus { - version: env!("CARGO_PKG_VERSION"), - connected_as: state.connected_as.lock().await.clone(), - }) -} - -/// The server's excluded-phrase list for query sanitizing; empty until the -/// server sends it after login. Frontend also receives it via the -/// `soulseek:excluded-phrases` event — this command covers a WebView reload -/// that happens after login already delivered the event. -#[tauri::command] -pub async fn excluded_search_phrases(state: State<'_, AppState>) -> Result, String> { - Ok(state.excluded_search_phrases.lock().await.clone()) -} - -#[derive(Serialize)] -pub struct StoredCredentials { - pub username: String, - pub has_password: bool, -} - -#[tauri::command] -pub fn get_credentials(app: tauri::AppHandle) -> StoredCredentials { - let cfg = config::load(&app); - let has_password = config::has_password(&cfg.username); - StoredCredentials { - username: cfg.username, - has_password, - } -} - -/// Settings surfaced to the Settings view. -#[derive(Serialize)] -pub struct Settings { - pub username: String, - pub has_password: bool, - pub download_dir: String, - pub library_layout: String, - pub shared_folders: Vec, - pub listen_port: Option, - pub upload_slots: u32, - pub connected: bool, - pub share_files: u32, - pub audio_output_device: Option, - pub audio_output_devices: Vec, - pub artist_hero_upscaler: config::ArtistHeroUpscaler, - pub replace_embedded_art: bool, - /// 0 = embed the original at full resolution. - pub embed_art_max_edge: u32, - pub write_folder_cover: bool, - pub stream_keep_forever: bool, - pub stream_retention_days: u32, - /// 0 = unlimited. - pub stream_cache_max_mb: u64, - pub stream_source_prefs: config::SourcePrefs, - pub download_source_prefs: config::SourcePrefs, - pub volume_normalization: bool, - /// Word-by-word lyric fill — experimental, default off. - pub word_timing: bool, - /// Dark-glass acrylic backdrop, default off. - pub glass_mode: bool, -} - -#[tauri::command] -pub async fn get_settings( - app: tauri::AppHandle, - state: State<'_, AppState>, -) -> Result { - let cfg = config::load(&app); - let share_files = state - .client - .lock() - .await - .as_ref() - .map(|c| c.share_counts().1) - .unwrap_or(0); - Ok(Settings { - has_password: config::has_password(&cfg.username), - download_dir: config::download_dir(&app, &cfg) - .to_string_lossy() - .into_owned(), - library_layout: config::library_layout(&cfg).to_string(), - shared_folders: cfg.shared_folders.clone(), - listen_port: cfg.listen_port, - upload_slots: cfg.upload_slots.unwrap_or(2), - connected: state.connected_as.lock().await.is_some(), - share_files, - audio_output_device: cfg.audio_output_device.clone(), - audio_output_devices: player::output_device_names().unwrap_or_default(), - artist_hero_upscaler: cfg.artist_hero_upscaler, - replace_embedded_art: config::replace_embedded_art(&cfg), - embed_art_max_edge: config::embed_art_max_edge(&cfg), - write_folder_cover: config::write_folder_cover(&cfg), - stream_keep_forever: config::stream_keep_forever(&cfg), - stream_retention_days: config::stream_retention_days(&cfg), - stream_cache_max_mb: config::stream_cache_max_mb(&cfg), - stream_source_prefs: cfg.stream_source_prefs, - download_source_prefs: cfg.download_source_prefs, - volume_normalization: config::volume_normalization(&cfg), - word_timing: config::word_timing(&cfg), - glass_mode: config::glass_mode(&cfg), - username: cfg.username, - }) -} - -/// Persist non-secret settings. Some (download dir, port) apply on next -/// connect; shared folders re-index live if connected. -#[tauri::command] -pub async fn set_settings( - app: tauri::AppHandle, - state: State<'_, AppState>, - players: State<'_, PlayerState>, - download_dir: Option, - library_layout: Option, - shared_folders: Option>, - listen_port: Option, - upload_slots: Option, - audio_output_device: Option, - artist_hero_upscaler: Option, - replace_embedded_art: Option, - embed_art_max_edge: Option, - write_folder_cover: Option, - stream_keep_forever: Option, - stream_retention_days: Option, - stream_cache_max_mb: Option, - stream_source_prefs: Option, - download_source_prefs: Option, - volume_normalization: Option, - word_timing: Option, - glass_mode: Option, -) -> Result<(), String> { - let mut cfg = config::load(&app); - let library_folder_moved = download_dir - .as_deref() - .is_some_and(|d| Some(d) != cfg.download_dir.as_deref()); - if let Some(d) = download_dir { - cfg.download_dir = Some(d); - } - if let Some(layout) = library_layout { - // Separators are allowed: they create subfolders below the library - // root. organize_download sanitizes every rendered segment, so a - // layout (or tag value) cannot escape the root. - cfg.library_layout = Some(layout); - } - if let Some(f) = shared_folders { - cfg.shared_folders = f; - } - if listen_port.is_some() { - cfg.listen_port = listen_port; - } - if let Some(s) = upload_slots { - cfg.upload_slots = Some(s); - } - if let Some(device) = audio_output_device { - let device = match device.trim() { - "" => None, - name => Some(name.to_string()), - }; - // Open first; only persist a choice that can actually be used. - players.player.set_output_device(device.clone())?; - cfg.audio_output_device = device; - } - if let Some(upscaler) = artist_hero_upscaler { - cfg.artist_hero_upscaler = upscaler; - } - if let Some(prefs) = stream_source_prefs { - cfg.stream_source_prefs = prefs; - } - if let Some(prefs) = download_source_prefs { - cfg.download_source_prefs = prefs; - } - if replace_embedded_art.is_some() { - cfg.replace_embedded_art = replace_embedded_art; - } - if embed_art_max_edge.is_some() { - cfg.embed_art_max_edge = embed_art_max_edge; - } - if write_folder_cover.is_some() { - cfg.write_folder_cover = write_folder_cover; - } - let retention_changed = stream_keep_forever.is_some() - || stream_retention_days.is_some() - || stream_cache_max_mb.is_some(); - if stream_keep_forever.is_some() { - cfg.stream_keep_forever = stream_keep_forever; - } - if stream_retention_days.is_some() { - cfg.stream_retention_days = stream_retention_days; - } - if stream_cache_max_mb.is_some() { - cfg.stream_cache_max_mb = stream_cache_max_mb; - } - if let Some(enabled) = volume_normalization { - cfg.volume_normalization = Some(enabled); - // Applies live: off glides the current track back to unity; on - // takes effect from the next track. - crate::loudness::set_enabled(&app, enabled); - } - if word_timing.is_some() { - cfg.word_timing = word_timing; - } - if glass_mode.is_some() { - cfg.glass_mode = glass_mode; - } - config::save(&app, &cfg); - - // A new library folder is a new library. Re-base immediately rather than - // at the next launch, so what the shelves show is what the folder holds — - // the old behaviour left the previous folder's albums standing forever, - // and emptying the new folder could not remove them. - if library_folder_moved { - let app2 = app.clone(); - let root = config::download_dir(&app, &cfg); - tauri::async_runtime::spawn(async move { - let lib = app2.state::().library.clone(); - if let Ok(Ok(dropped)) = - tauri::async_runtime::spawn_blocking(move || lib.prune_outside_root(&root)).await - { - log::info!("library folder changed: dropped {dropped} tracks from the old folder"); - } - let _ = crate::pipeline::rescan_library(app2.clone()).await; - let _ = app2.emit("soulseek:library-changed", ()); - }); - } - - // Tighter limits take effect right away, not at the next stream/startup. - if retention_changed { - let app2 = app.clone(); - tauri::async_runtime::spawn_blocking(move || crate::playback::prune_cache(&app2)); - } - - // Re-index shares live so the change takes effect without reconnecting. - if let Some(client) = state.client.lock().await.as_ref() { - let dl = config::download_dir(&app, &cfg); - let mut folders = vec![dl]; - folders.extend(cfg.shared_folders.iter().map(std::path::PathBuf::from)); - let (fdirs, ffiles) = client.update_shares(folders).await; - let _ = app.emit( - "soulseek:shares", - serde_json::json!({ "folders": fdirs, "files": ffiles }), - ); - } - Ok(()) -} - -/// Forget stored credentials (config + credential manager). -#[tauri::command] -pub fn forget_credentials(app: tauri::AppHandle) -> Result<(), String> { - let mut cfg = config::load(&app); - config::delete_password(&cfg.username); - cfg.username = String::new(); - config::save(&app, &cfg); - Ok(()) -} - -/// Open a native folder picker; returns the chosen path or null. -#[tauri::command] -pub async fn pick_folder(app: tauri::AppHandle) -> Result, String> { - use tauri_plugin_dialog::DialogExt; - let (tx, rx) = tokio::sync::oneshot::channel(); - app.dialog().file().pick_folder(move |path| { - let _ = tx.send(path.map(|p| p.to_string())); - }); - Ok(rx.await.unwrap_or(None)) -} - -/// What the frontend sees for one search response, flattened per file -/// grouping done client-side (UI groups by user + folder). -#[derive(Serialize, Clone)] -pub struct SearchResultPayload { - pub token: u32, - pub username: String, - pub has_free_upload_slot: bool, - pub upload_speed: u32, - pub queue_length: u32, - pub files: Vec, -} - -#[derive(Serialize, Clone)] -pub struct FilePayload { - pub path: String, - pub name: String, - pub folder: String, - pub size: u64, - pub extension: String, - pub bitrate: Option, - pub duration: Option, - pub sample_rate: Option, - pub bit_depth: Option, - pub vbr: bool, -} - -/// Broad queries match entire shared folders; a single response can list -/// thousands of files. The UI shows folder groups (500 files per group max), -/// so forwarding more than this per response only burns serialization, -/// IPC and webview memory. -const FILES_PER_RESPONSE_MAX: usize = 100; - -fn to_payload(token: u32, r: &SearchResponse) -> SearchResultPayload { - SearchResultPayload { - token, - username: r.username.clone(), - has_free_upload_slot: r.has_free_upload_slot, - upload_speed: r.upload_speed, - queue_length: r.queue_length, - files: r - .files - .iter() - .take(FILES_PER_RESPONSE_MAX) - .map(|f| { - let (folder, name) = match f.filename.rsplit_once('\\') { - Some((d, n)) => (d.to_string(), n.to_string()), - None => (String::new(), f.filename.clone()), - }; - // Many clients leave the extension attribute empty; - // fall back to the filename suffix. - let extension = if f.extension.is_empty() { - name.rsplit_once('.') - .map(|(_, e)| e.to_string()) - .unwrap_or_default() - } else { - f.extension.clone() - }; - FilePayload { - path: f.filename.clone(), - name, - folder, - size: f.size, - extension: extension.to_lowercase(), - bitrate: f.bitrate(), - duration: f.duration_secs(), - sample_rate: f.sample_rate(), - bit_depth: f.bit_depth(), - vbr: f.is_vbr(), - } - }) - .collect(), - } -} - -#[derive(Serialize, Clone)] -#[serde(tag = "kind", rename_all = "kebab-case")] -enum UiEvent { - Connected { username: String }, - Disconnected { reason: String }, -} - -/// One download's status, as the frontend sees it. -#[derive(Serialize, Clone)] -pub struct DownloadPayload { - pub id: u64, - pub username: String, - pub path: String, - pub name: String, - pub state: String, - pub queue_place: Option, - pub local_path: Option, - pub error: Option, - pub received: u64, - pub total: u64, - pub speed_bps: u64, -} - -fn download_payload(u: &DownloadUpdate) -> DownloadPayload { - let name = u - .filename - .rsplit('\\') - .next() - .unwrap_or(&u.filename) - .to_string(); - let (state, queue_place, local_path, error) = match &u.state { - DownloadState::Connecting => ("connecting", None, None, None), - DownloadState::Requested => ("requested", None, None, None), - DownloadState::Queued { place } => ("queued", *place, None, None), - DownloadState::Transferring => ("transferring", None, None, None), - DownloadState::Complete { local_path } => { - ("complete", None, Some(local_path.clone()), None) - } - DownloadState::Failed { reason } => ("failed", None, None, Some(reason.clone())), - DownloadState::Cancelled => ("cancelled", None, None, None), - }; - DownloadPayload { - id: u.id, - username: u.username.clone(), - path: u.filename.clone(), - name, - state: state.into(), - queue_place, - local_path, - error, - received: u.received, - total: u.total, - speed_bps: u.speed_bps, - } -} - -/// Connect using credentials saved in the config file, without exposing -/// the password to the webview. Errors if none are stored. -#[tauri::command] -pub async fn connect_stored( - app: tauri::AppHandle, - state: State<'_, AppState>, -) -> Result { - let cfg = config::load(&app); - let password = config::load_password(&cfg.username); - match (cfg.username.is_empty(), password) { - (false, Some(pw)) => connect_and_login(app, state, cfg.username, pw, false).await, - _ => Err("no stored credentials".into()), - } -} - -#[tauri::command] -pub async fn connect_and_login( - app: tauri::AppHandle, - state: State<'_, AppState>, - username: String, - password: String, - remember: bool, -) -> Result { - // Drop any previous session first. - if let Some(old) = state.client.lock().await.take() { - old.shutdown(); - } - *state.connected_as.lock().await = None; - - let stored = config::load(&app); - let mut cfg = Config::default(); - if let Some(port) = stored.listen_port { - cfg.listen_port = port; - } - if let Some(slots) = stored.upload_slots { - cfg.upload_slots = slots; - } - // Configured download target, or the default. - let downloads = config::download_dir(&app, &stored); - cfg.downloads_dir = downloads.clone(); - // Be a good citizen: share everything we've downloaded, plus any extra - // configured folders. - std::fs::create_dir_all(&downloads).ok(); - let mut shares = vec![downloads]; - shares.extend(stored.shared_folders.iter().map(std::path::PathBuf::from)); - cfg.shared_folders = shares; - cfg.description = "Shared with SoulStream".into(); - - // The previous session's listener releases its port a moment after - // shutdown; retry briefly instead of failing with WSAEADDRINUSE. - let (client, mut events) = { - let mut attempt = 0; - loop { - match Client::connect(cfg.clone()).await { - Ok(pair) => break pair, - Err(e) if attempt < 8 && e.to_string().contains("10048") => { - attempt += 1; - tokio::time::sleep(std::time::Duration::from_millis(250)).await; - } - Err(e) => return Err(format!("could not reach server: {e}")), - } - } - }; - - let greeting = client - .login(&username, &password) - .await - .map_err(|e| e.to_string())?; - - if remember { - let mut save = stored.clone(); - save.username = username.clone(); - config::save(&app, &save); - config::save_password(&username, &password); - } - - // Report share counts to the UI once indexed. - let (folders, files) = client.share_counts(); - let _ = app.emit( - "soulseek:shares", - serde_json::json!({ "folders": folders, "files": files }), - ); - - // Watch the buddy list so status/stats stream in for the Users tab. - for buddy in &config::load(&app).buddies { - let _ = client.watch_user(buddy); - } - - *state.client.lock().await = Some(client); - *state.connected_as.lock().await = Some(username.clone()); - - // Forward client events to the webview for the life of this session. - let event_app = app.clone(); - let event_user = username.clone(); - tauri::async_runtime::spawn(async move { - let _ = event_app.emit( - "soulseek:state", - UiEvent::Connected { - username: event_user, - }, - ); - - // Search responses arrive in the thousands within seconds on broad - // queries. Emitting each one individually floods the IPC bridge and - // the webview's event loop (UI lockups, eventually OOM), so buffer - // them and flush as one batch per tick. A per-token cap bounds the - // total work a single search can ever create. - const SEARCH_FLUSH_MS: u64 = 150; - const SEARCH_BATCH_MAX: usize = 100; - const SEARCH_RESPONSES_MAX: u32 = 1500; - const SEARCH_FILES_MAX: u32 = 20_000; - let mut search_buf: Vec = Vec::new(); - // token → (responses forwarded, files forwarded) - let mut sent_counts: std::collections::HashMap = - std::collections::HashMap::new(); - let mut flush = tokio::time::interval(std::time::Duration::from_millis(SEARCH_FLUSH_MS)); - flush.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - - loop { - let ev = tokio::select! { - maybe_ev = events.recv() => match maybe_ev { - Some(ev) => ev, - None => break, - }, - _ = flush.tick() => { - if !search_buf.is_empty() { - let _ = event_app - .emit("soulseek:search-results", std::mem::take(&mut search_buf)); - } - continue; - } - }; - match ev { - Event::SearchResult { token, response } => { - let (responses, files) = sent_counts.entry(token).or_insert((0, 0)); - if *responses >= SEARCH_RESPONSES_MAX || *files >= SEARCH_FILES_MAX { - continue; - } - *responses += 1; - let payload = to_payload(token, &response); - *files += payload.files.len() as u32; - search_buf.push(payload); - if search_buf.len() >= SEARCH_BATCH_MAX { - let _ = event_app - .emit("soulseek:search-results", std::mem::take(&mut search_buf)); - } - } - Event::Download(update) => { - // Next-track preloads are invisible: no player, no - // Downloads view, no library pipeline. On completion, - // tell the frontend so it can append the cached track - // to the sink for a gapless transition. - if let Some(ps) = event_app.try_state::() { - let is_preload = ps.preloads.lock().unwrap().contains_key(&update.id); - if is_preload { - use soulseek_client::DownloadState as DS; - let terminal = matches!( - &update.state, - DS::Complete { .. } | DS::Failed { .. } | DS::Cancelled - ); - // Mirror every update for the debug overlay's - // QUEUE section — these transfers are otherwise - // invisible by design. - let state_label = match &update.state { - DS::Connecting => "connecting".to_string(), - DS::Requested => "requested".to_string(), - DS::Queued { .. } => "queued".to_string(), - DS::Transferring => "transferring".to_string(), - DS::Complete { .. } => "done".to_string(), - DS::Failed { reason } => format!("failed: {reason}"), - DS::Cancelled => "cancelled".to_string(), - }; - let place = match &update.state { - DS::Queued { place } => *place, - _ => None, - }; - crate::diagnostics::note_preload( - &event_app, - update.id, - crate::diagnostics::PreloadDl { - username: update.username.clone(), - path: update.filename.clone(), - state: state_label, - place, - received: update.received, - total: update.total, - speed_bps: update.speed_bps, - }, - terminal, - ); - if terminal { - let cache = ps.preloads.lock().unwrap().remove(&update.id); - let save_cache = - ps.deferred_saves.lock().unwrap().remove(&update.id); - let _ = event_app.emit( - "soulseek:preload", - serde_json::json!({ - "username": update.username, - "path": update.filename, - "done": matches!(&update.state, DS::Complete { .. }), - }), - ); - if matches!(&update.state, DS::Complete { .. }) { - // Gapless normalization: measure the next - // track now so its gain is ready at the - // boundary. - if let Some(c) = &cache { - crate::loudness::on_preload_ready(&event_app, c); - } - // Only explicit saves reach the library — - // unless streamed tracks are configured to - // be kept forever. - let keep = - config::stream_keep_forever(&config::load(&event_app)); - let save = if keep { - save_cache.or(cache) - } else { - save_cache - }; - let app2 = event_app.clone(); - let username = update.username.clone(); - let remote = update.filename.clone(); - tauri::async_runtime::spawn(async move { - if let Some(cache) = save { - let downloads_dir = - config::download_dir(&app2, &config::load(&app2)); - if let Err(err) = organize_cached_save( - &app2, - &downloads_dir, - &cache, - &remote, - ) - .await - { - log::warn!("could not save completed preload {username}\\{remote}: {err}"); - } - let _ = app2.emit("soulseek:library-changed", ()); - } - // The cache just grew; enforce retention - // after any save copy is done with it. - let app3 = app2.clone(); - let _ = tauri::async_runtime::spawn_blocking(move || { - crate::playback::prune_cache(&app3) - }) - .await; - }); - } - } - continue; - } - } - // Stream downloads feed the player instead of the - // Downloads view. - let stream = event_app - .try_state::() - .and_then(|ps| ps.streams.lock().unwrap().get(&update.id).cloned()); - if let Some(progress) = stream { - use soulseek_client::DownloadState as DS; - let (done, failed) = match &update.state { - DS::Complete { .. } => (true, None), - DS::Failed { reason } => (false, Some(reason.clone())), - DS::Cancelled => (false, Some("stream cancelled".to_string())), - _ => (false, None), - }; - progress.update(update.received, done, failed.clone()); - let _ = event_app.emit( - "soulseek:stream", - serde_json::json!({ - "received": update.received, - "total": update.total, - "done": done, - "failed": failed, - }), - ); - if done || failed.is_some() { - if let Some(ps) = event_app.try_state::() { - ps.streams.lock().unwrap().remove(&update.id); - let cache = ps.stream_caches.lock().unwrap().remove(&update.id); - let save_cache = - ps.deferred_saves.lock().unwrap().remove(&update.id); - if done { - // Streams stay cache-only unless the user - // saved this track or retention is set to - // keep streamed tracks forever. - let keep = - config::stream_keep_forever(&config::load(&event_app)); - let save = if keep { - save_cache.or(cache) - } else { - save_cache - }; - let app2 = event_app.clone(); - let username = update.username.clone(); - let remote = update.filename.clone(); - tauri::async_runtime::spawn(async move { - if let Some(cache) = save { - let downloads_dir = - config::download_dir(&app2, &config::load(&app2)); - if let Err(err) = organize_cached_save( - &app2, - &downloads_dir, - &cache, - &remote, - ) - .await - { - log::warn!("could not save completed stream {username}\\{remote}: {err}"); - } - let _ = app2.emit("soulseek:library-changed", ()); - } - // The cache just grew; enforce retention - // after any save copy is done with it. - let app3 = app2.clone(); - let _ = tauri::async_runtime::spawn_blocking(move || { - crate::playback::prune_cache(&app3) - }) - .await; - }); - } - } - } - } else if crate::import::is_audition(update.id) { - // Audition downloads are candidate versions under - // review in the (Cache) pen — they stay OUT of the - // library pipeline; the import window watches them. - use soulseek_client::DownloadState as DS; - // WHICH record and WHICH track, straight from the - // registry — looked up before audition_done retires - // the id. The UI matches on this, never on the id: - // the first events of a transfer are emitted before - // import_audition has returned its ids at all. - let (au_rel, au_idx) = crate::import::audition_of(update.id) - .map(|(r, i)| (r, i as i64)) - .unwrap_or_else(|| (String::new(), -1)); - match &update.state { - DS::Complete { local_path } => { - let _ = event_app.emit( - "import:audition", - serde_json::json!({ - "id": update.id, - "rel": au_rel, - "idx": au_idx, - "localPath": local_path, - "remote": update.filename, - "done": true, - }), - ); - crate::import::audition_done(update.id); - } - DS::Failed { reason } => { - let _ = event_app.emit( - "import:audition", - serde_json::json!({ - "id": update.id, - "rel": au_rel, - "idx": au_idx, - "remote": update.filename, - "failed": reason, - }), - ); - crate::import::audition_done(update.id); - } - DS::Cancelled => { - crate::import::audition_done(update.id); - } - // In flight. The picker's row can only say what it - // is told, and "arriving…" was all it had: the - // phase, the QUEUE PLACE and the speed were all - // known here and thrown away. A place that ticks - // down is the difference between waiting and - // wondering whether anything is happening. - rest => { - let (phase, place) = match rest { - DS::Connecting => ("connecting", None), - DS::Requested => ("requested", None), - DS::Queued { place } => ("queued", *place), - DS::Transferring => ("transferring", None), - _ => ("working", None), - }; - let _ = event_app.emit( - "import:audition", - serde_json::json!({ - "id": update.id, - "rel": au_rel, - "idx": au_idx, - "remote": update.filename, - "received": update.received, - "total": update.total, - "speedBps": update.speed_bps, - "phase": phase, - "place": place, - }), - ); - } - } - } else { - let _ = event_app.emit("soulseek:download", download_payload(&update)); - // Completed real downloads go through the tag/art - // pipeline and into the library. - if let soulseek_client::DownloadState::Complete { local_path } = - &update.state - { - let app2 = event_app.clone(); - let local = std::path::PathBuf::from(local_path.clone()); - let remote = update.filename.clone(); - tauri::async_runtime::spawn(async move { - pipeline::process_file(&app2, &local, Some(&remote)).await; - }); - } - } - } - Event::Disconnected { reason } => { - let _ = event_app.emit("soulseek:state", UiEvent::Disconnected { reason }); - if let Some(st) = event_app.try_state::() { - *st.connected_as.lock().await = None; - } - break; - } - Event::ExcludedSearchPhrases(p) => { - log::info!("server sent {} excluded search phrases: {:?}", p.len(), p); - let lowered: Vec = p.iter().map(|s| s.to_lowercase()).collect(); - if let Some(st) = event_app.try_state::() { - *st.excluded_search_phrases.lock().await = lowered.clone(); - } - // The resolver builds queries frontend-side; hand it the - // list so poisoned terms never reach the wire. - let _ = event_app.emit("soulseek:excluded-phrases", lowered); - } - Event::Upload(u) => { - use soulseek_client::UploadState as US; - let state = match &u.state { - US::Queued => "queued", - US::Transferring => "transferring", - US::Complete => "complete", - US::Failed { .. } => "failed", - }; - let _ = event_app.emit( - "soulseek:upload", - serde_json::json!({ - "id": u.id, - "username": u.username, - "name": u.filename.rsplit('\\').next().unwrap_or(&u.filename), - // Full shared path too — Transfers shows the - // parent folder ahead of the name, Qt-style. - "path": u.filename, - "state": state, - "sent": u.sent, - "total": u.total, - }), - ); - } - Event::RespondedToSearch { username, files } => { - let _ = event_app.emit( - "soulseek:responded", - serde_json::json!({ "username": username, "files": files }), - ); - } - // ---- the Soulseek hub's seats: rooms, chat, users ---- - Event::RoomList { rooms } => { - let rooms: Vec<_> = rooms - .into_iter() - .map(|(name, users)| serde_json::json!({ "name": name, "users": users })) - .collect(); - let _ = event_app.emit("soulseek:room-list", rooms); - } - Event::RoomJoined { room, users } => { - let _ = event_app.emit( - "soulseek:room-joined", - serde_json::json!({ "room": room, "users": room_users_json(&users) }), - ); - } - Event::RoomLeft { room } => { - let _ = event_app.emit("soulseek:room-left", serde_json::json!({ "room": room })); - } - Event::RoomUserJoined { room, user } => { - let _ = event_app.emit( - "soulseek:room-user", - serde_json::json!({ - "room": room, - "joined": true, - "user": room_user_json(&user), - }), - ); - } - Event::RoomUserLeft { room, username } => { - let _ = event_app.emit( - "soulseek:room-user", - serde_json::json!({ "room": room, "joined": false, "username": username }), - ); - } - Event::RoomMessage { - room, - username, - message, - } => { - let _ = event_app.emit( - "soulseek:room-message", - serde_json::json!({ - "room": room, - "username": username, - "message": message, - "ts": now_ms(), - }), - ); - } - Event::PrivateMessage { - id, - timestamp, - username, - message, - } => { - // Identity-link handshakes ride private messages and - // never reach the Inbox (slsk_link.rs). - if crate::slsk_link::intercept_pm(&event_app, &username, &message).await - || crate::friends::intercept_pm(&event_app, &username, &message) - || crate::shared_playlists::intercept_pm(&event_app, &username, &message) - { - continue; - } - let _ = event_app.emit( - "soulseek:private-message", - serde_json::json!({ - "id": id, - "timestamp": timestamp, - "username": username, - "message": message, - }), - ); - } - Event::UserWatched { - username, - exists, - status, - avg_speed, - file_count, - dir_count, - } => { - let _ = event_app.emit( - "soulseek:user-state", - serde_json::json!({ - "kind": "watched", - "username": username, - "exists": exists, - "status": status, - "avg_speed": avg_speed, - "file_count": file_count, - "dir_count": dir_count, - }), - ); - } - Event::UserStatus { - username, status, .. - } => { - let _ = event_app.emit( - "soulseek:user-state", - serde_json::json!({ "kind": "status", "username": username, "status": status }), - ); - } - Event::UserStats { - username, - avg_speed, - file_count, - dir_count, - } => { - let _ = event_app.emit( - "soulseek:user-state", - serde_json::json!({ - "kind": "stats", - "username": username, - "avg_speed": avg_speed, - "file_count": file_count, - "dir_count": dir_count, - }), - ); - } - _ => {} - } - } - }); - - Ok(greeting) -} - -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0) -} - -fn room_user_json(u: &soulseek_client::RoomUser) -> serde_json::Value { - serde_json::json!({ - "username": u.username, - "status": u.status, - "avg_speed": u.avg_speed, - "file_count": u.file_count, - "dir_count": u.dir_count, - }) -} - -fn room_users_json(users: &[soulseek_client::RoomUser]) -> Vec { - users.iter().map(room_user_json).collect() -} - -// ---- the Soulseek hub: rooms / chat / users / browse commands ---- - -/// Briefly lock the client, run `f`, release. Every hub command is a small -/// synchronous send; only browse runs long, and it detaches first. -async fn with_client( - state: &State<'_, AppState>, - f: impl FnOnce(&soulseek_client::Client) -> soulseek_client::Result, -) -> Result { - let guard = state.client.lock().await; - let client = guard.as_ref().ok_or("not connected to Soulseek")?; - f(client).map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn slsk_join_room(state: State<'_, AppState>, room: String) -> Result<(), String> { - with_client(&state, |c| c.join_room(&room)).await -} - -#[tauri::command] -pub async fn slsk_leave_room(state: State<'_, AppState>, room: String) -> Result<(), String> { - with_client(&state, |c| c.leave_room(&room)).await -} - -#[tauri::command] -pub async fn slsk_say_room( - state: State<'_, AppState>, - room: String, - message: String, -) -> Result<(), String> { - let message = message.trim().to_string(); - if message.is_empty() { - return Ok(()); - } - with_client(&state, |c| c.say_room(&room, &message)).await -} - -#[tauri::command] -pub async fn slsk_message_user( - state: State<'_, AppState>, - username: String, - message: String, -) -> Result<(), String> { - let message = message.trim().to_string(); - if message.is_empty() { - return Ok(()); - } - with_client(&state, |c| c.message_user(&username, &message)).await -} - -#[tauri::command] -pub async fn slsk_room_list(state: State<'_, AppState>) -> Result<(), String> { - with_client(&state, |c| c.request_room_list()).await -} - -#[derive(serde::Serialize)] -pub struct BrowseFileDto { - pub name: String, - /// Full remote path (folder + name), ready for a download request. - pub path: String, - pub size: u64, - pub ext: String, - pub bitrate: Option, - pub duration: Option, -} - -#[derive(serde::Serialize)] -pub struct BrowseDirDto { - pub name: String, - pub files: Vec, -} - -/// Walk a user's shares. Long-running: the client lock is held only long -/// enough to start the request. -#[derive(Serialize)] -pub struct UserInfoDto { - pub username: String, - pub description: String, - /// The picture ONLY after the avatar pipeline's bounded decode + resize - /// (profile.rs): a peer's bytes are hostile input, never rendered raw. - pub picture_png_base64: Option, - pub picture_note: Option, - pub total_uploads: u32, - pub queue_size: u32, - pub has_free_slot: bool, -} - -/// A peer's native user-info card: description, picture, upload state. -#[tauri::command] -pub async fn slsk_user_info( - state: State<'_, AppState>, - username: String, -) -> Result { - let fut = { - let guard = state.client.lock().await; - let client = guard.as_ref().ok_or("not connected to Soulseek")?; - client.user_info(&username) - }; - let info = fut.await.map_err(|e| e.to_string())?; - let (picture_png_base64, picture_note) = match info.picture.as_deref() { - None => (None, None), - Some(bytes) => match crate::profile::normalize_avatar(bytes) { - Ok(png) => { - use base64::Engine; - (Some(base64::engine::general_purpose::STANDARD.encode(png)), None) - } - Err(e) => (None, Some(format!("picture refused: {e}"))), - }, - }; - Ok(UserInfoDto { - username, - description: info.description, - picture_png_base64, - picture_note, - total_uploads: info.total_uploads, - queue_size: info.queue_size, - has_free_slot: info.has_free_slot, - }) -} - -#[derive(Serialize)] -pub struct InterestsDto { - pub likes: Vec, - pub hates: Vec, -} - -/// A user's declared Soulseek interests — raw likes and hates, not follows. -#[tauri::command] -pub async fn slsk_user_interests( - state: State<'_, AppState>, - username: String, -) -> Result { - let fut = { - let guard = state.client.lock().await; - let client = guard.as_ref().ok_or("not connected to Soulseek")?; - client.user_interests(&username) - }; - let (likes, hates) = fut.await.map_err(|e| e.to_string())?; - Ok(InterestsDto { likes, hates }) -} - -#[derive(Serialize)] -pub struct SimilarUserDto { - pub username: String, - pub rating: u32, -} - -/// Users the server ranks as similar to us by shared interests. -#[tauri::command] -pub async fn slsk_similar_users(state: State<'_, AppState>) -> Result, String> { - let fut = { - let guard = state.client.lock().await; - let client = guard.as_ref().ok_or("not connected to Soulseek")?; - client.similar_users() - }; - let users = fut.await.map_err(|e| e.to_string())?; - Ok(users - .into_iter() - .map(|(username, rating)| SimilarUserDto { username, rating }) - .collect()) -} - -#[tauri::command] -pub async fn slsk_browse_user( - app: tauri::AppHandle, - state: State<'_, AppState>, - username: String, -) -> Result, String> { - let fut = { - let guard = state.client.lock().await; - let client = guard.as_ref().ok_or("not connected to Soulseek")?; - client.browse(&username) - }; - let dirs = fut.await.map_err(|e| e.to_string())?; - // The library overlap rides along: their folders against your albums, - // on this device, and the Discover shelf hears about it by event. - crate::overlap::note_browse(&app, &username, &dirs); - Ok(dirs - .into_iter() - .map(|d| { - let dir_name = d.name.clone(); - BrowseDirDto { - files: d - .files - .into_iter() - .map(|f| { - let bitrate = f.bitrate(); - let duration = f.duration_secs(); - let ext = if f.extension.is_empty() { - f.filename.rsplit('.').next().unwrap_or_default().to_string() - } else { - f.extension.clone() - }; - BrowseFileDto { - path: format!("{dir_name}\\{}", f.filename), - name: f.filename, - size: f.size, - ext, - bitrate, - duration, - } - }) - .collect(), - name: dir_name, - } - }) - .collect()) -} - -#[tauri::command] -pub fn buddy_list(app: tauri::AppHandle) -> Vec { - config::load(&app).buddies -} - -#[tauri::command] -pub async fn buddy_add( - app: tauri::AppHandle, - state: State<'_, AppState>, - username: String, -) -> Result, String> { - let username = username.trim().to_string(); - if username.is_empty() { - return Err("no username".into()); - } - let mut cfg = config::load(&app); - if !cfg.buddies.iter().any(|b| b == &username) { - cfg.buddies.push(username.clone()); - config::save(&app, &cfg); - } - // Watch immediately when connected; on later logins the connect path - // re-watches the whole list. - let _ = with_client(&state, |c| c.watch_user(&username)).await; - let _ = with_client(&state, |c| c.get_user_stats(&username)).await; - Ok(cfg.buddies) -} - -#[tauri::command] -pub async fn buddy_remove( - app: tauri::AppHandle, - state: State<'_, AppState>, - username: String, -) -> Result, String> { - let mut cfg = config::load(&app); - cfg.buddies.retain(|b| b != &username); - config::save(&app, &cfg); - let _ = with_client(&state, |c| c.unwatch_user(&username)).await; - Ok(cfg.buddies) -} - -// ---- catalog browser (Deezer-backed) ---- - -const ARTIST_HERO_MIN_WIDTH: u32 = 1920; -const ARTIST_HERO_MIN_HEIGHT: u32 = 800; -const ARTIST_HERO_MAX_BYTES: u64 = 30 * 1024 * 1024; -const ARTIST_HERO_PROVIDER_TARGET_WIDTH: u32 = 3840; -const ARTIST_HERO_PROVIDER_TARGET_HEIGHT: u32 = 2160; -const ARTIST_HERO_PREVIEW_MAX_WIDTH: u32 = 1920; -const ARTIST_HERO_GPU_PREPARE_CONCURRENCY: usize = 2; -const ARTIST_HERO_FSR_SHARPNESS_STOPS: &str = "0.25"; -const ARTIST_HERO_LUT_STRENGTH: f32 = 0.30; -const ARTIST_HERO_FADE_STRENGTH: f32 = 1.0; -const ARTIST_HERO_GRAIN_STRENGTH: f32 = 0.70; -/// Bump whenever any pixel-producing hero setting or processing order changes. -const ARTIST_HERO_CROP_RECIPE: &str = "vertical-yoloxnano-c789161e-windows-face-v2-faces-first"; -const ARTIST_HERO_CACHE_RECIPE: &str = - "w3840h2160-jpeg93-fsr025-ruby030-fade100-grain070-contentcrop-v5"; -const ARTIST_HERO_CACHE_TTL: std::time::Duration = - std::time::Duration::from_secs(30 * 24 * 60 * 60); -const ARTIST_HERO_CACHE_MAX_BYTES: u64 = 256 * 1024 * 1024; -const ARTIST_HERO_CACHE_MAX_ENTRY_BYTES: u64 = 41 * 1024 * 1024; -// v2 invalidated manifests written before provider artwork was required to -// close the loop back to the requested Deezer identity. v3 invalidates the -// window where an exact-name collision forced every Bing card to cite a -// catalogue release, which discarded the press photography that makes the best -// heroes; those degraded selections are pinned for a day otherwise. Bump this -// whenever hero *selection* changes, not merely its rendering. Keeping the same -// file path is safe: a recipe mismatch forces a fresh lookup and the successful -// result atomically replaces the stale manifest. -// v5: TheAudioDB records referencing a DUPLICATE MusicBrainz entry for the same -// artist are no longer rejected as namesakes, so artists cached under v4 are -// holding a logo-less manifest this build would now fill. Safe to bump: a -// stale-recipe manifest is kept as a floor and only replaced by a better -// lookup, so nobody goes blank while the new one resolves. -// v6: manifests written while Fanart.tv's CDN was refusing connections -// (2026-07-27) hold Bing-tier heroes and would have kept them for a month under -// the flat TTL below. Bumping re-resolves every artist once against the -// providers that are up TODAY; the tier-aware TTL keeps it from happening again. -const ARTIST_ART_MANIFEST_RECIPE: &str = "artist-art-tier-v6-fallback-ttl"; -// A resolved hero is expensive to find and does not go stale the way metadata -// does -- a press photo is still the artist's press photo a month later. The -// old 24h expiry meant every artist was re-discovered daily through a tier that -// fails per-name, so a single bad day blanked artists that had perfectly good -// heroes: 26 manifests on disk (Crazy P, MSPAINT, Sword II, Hermeth, Billy Cobb, -// Pond, Weezer, Daft Punk...) went to 3. Match the cover-resolution horizon. -const ARTIST_ART_MANIFEST_TTL: std::time::Duration = - std::time::Duration::from_secs(30 * 24 * 60 * 60); -// ...but only for art that came from the TOP of the chain. Everything below it -// was a fallback taken because the better provider was unreachable at that -// moment -- `image_host_alive` deliberately reports a dead CDN as "returned -// nothing", which is what lets the chain degrade gracefully -- and a month is -// far too long to live with the consequences of one bad afternoon. Re-checked -// daily instead, so an artist stops wearing a Bing hero the day after Fanart.tv -// comes back. -const ARTIST_ART_FALLBACK_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); - -/// Did the top provider tier supply the banner this manifest will actually -/// show? The manifest never recorded which provider won, so the tier is read -/// back off the URL's host. -/// -/// Banners only, deliberately: logos routinely and legitimately come from -/// TheAudioDB even when Fanart.tv covered the artist, and treating that as a -/// fallback would put every artist on a daily re-check. An artist Fanart.tv -/// simply does not cover is re-checked daily forever, which is the price of -/// noticing the day they add one. -fn manifest_from_top_tier(art: &metadata::CatalogArtistArt) -> bool { - art.banners.iter().any(|url| { - // Every stored URL is validated as `https://...` before it gets here, - // so the authority is simply what sits between the scheme and the - // first slash (minus any port). - url.strip_prefix("https://") - .and_then(|rest| rest.split('/').next()) - .map(|authority| authority.split(':').next().unwrap_or(authority)) - .is_some_and(|host| host == "fanart.tv" || host.ends_with(".fanart.tv")) - }) -} -const ARTIST_ART_MANIFEST_MAX_BYTES: u64 = 64 * 1024; -// Identity is part of the key because two Deezer artists can share the same -// display name (and, occasionally, the same release title/reference artwork). -// The resolver's Apple candidate selection is artist-ID scoped as of v2. -const COVER_RESOLUTION_RECIPE: &str = "same-art-dhash10-v2-identity"; -const COVER_RESOLUTION_TTL: std::time::Duration = std::time::Duration::from_secs(30 * 24 * 60 * 60); -const COVER_RESOLUTION_CACHE_MAX_FILES: usize = 4096; -const ARTIST_ART_MANIFEST_CACHE_MAX_FILES: usize = 512; -const PERSISTENT_CACHE_SWEEP_INTERVAL_SECS: u64 = 60 * 60; - -fn artist_hero_gpu_prepare_slots() -> &'static tokio::sync::Semaphore { - static SLOTS: std::sync::OnceLock = std::sync::OnceLock::new(); - SLOTS.get_or_init(|| tokio::sync::Semaphore::new(ARTIST_HERO_GPU_PREPARE_CONCURRENCY)) -} - -#[derive(Serialize, Deserialize)] -struct CachedArtistArtManifest { - recipe: String, - artist_id: u64, - art: metadata::CatalogArtistArt, -} - -#[derive(Serialize, Deserialize)] -struct CachedCoverResolution { - recipe: String, - best_url: String, -} - -fn maybe_sweep_cache_directory( - directory: &std::path::Path, - ttl: std::time::Duration, - max_files: usize, - last_sweep: &std::sync::atomic::AtomicU64, - file_count: &std::sync::atomic::AtomicUsize, - sweep_in_progress: &std::sync::atomic::AtomicBool, -) { - use std::sync::atomic::Ordering; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0); - let previous = last_sweep.load(Ordering::Relaxed); - let sweep_due = now.saturating_sub(previous) >= PERSISTENT_CACHE_SWEEP_INTERVAL_SECS; - let at_capacity = file_count.load(Ordering::Relaxed) >= max_files; - if (!sweep_due && !at_capacity) - || sweep_in_progress - .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) - .is_err() - { - return; - } - last_sweep.store(now, Ordering::Relaxed); - - let Ok(entries) = std::fs::read_dir(directory) else { - sweep_in_progress.store(false, Ordering::Release); - return; - }; - let mut retained = Vec::new(); - for entry in entries.flatten() { - let path = entry.path(); - let Ok(metadata) = entry.metadata() else { - continue; - }; - if !metadata.is_file() { - continue; - } - let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH); - let expired = modified.elapsed().is_ok_and(|age| age >= ttl); - if expired { - let _ = std::fs::remove_file(path); - } else { - retained.push((modified, path)); - } - } - // This runs immediately before a write. Prune to a 90% low-water mark: - // leaving only one slot would force a 4K-entry directory scan + sort on - // every subsequent cover write once the cache first reached capacity. - let retained_limit = max_files - .saturating_mul(9) - .checked_div(10) - .unwrap_or(0) - .min(max_files.saturating_sub(1)); - let mut removed = 0usize; - if retained.len() > retained_limit { - retained.sort_unstable_by_key(|(modified, _)| *modified); - removed = retained.len() - retained_limit; - for (_, path) in retained.iter().take(removed) { - let _ = std::fs::remove_file(path); - } - } - file_count.store(retained.len() - removed, Ordering::Relaxed); - sweep_in_progress.store(false, Ordering::Release); -} - -fn cover_resolution_cache_path( - app: &tauri::AppHandle, - artist_id: Option, - artist: &str, - title: &str, - cover: &str, -) -> Result { - use std::hash::{Hash as _, Hasher as _}; - let dir = app - .path() - .app_cache_dir() - .map_err(|error| error.to_string())? - .join("cover-resolutions-v1"); - std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; - let mut key = std::collections::hash_map::DefaultHasher::new(); - COVER_RESOLUTION_RECIPE.hash(&mut key); - artist_id.hash(&mut key); - artist.hash(&mut key); - title.hash(&mut key); - cover.hash(&mut key); - Ok(dir.join(format!("{:016x}.json", key.finish()))) -} - -async fn load_cover_resolution( - app: &tauri::AppHandle, - artist_id: Option, - artist: &str, - title: &str, - cover: &str, -) -> Option { - let path = cover_resolution_cache_path(app, artist_id, artist, title, cover).ok()?; - let metadata = tokio::fs::metadata(&path).await.ok()?; - let fresh = metadata - .modified() - .ok() - .and_then(|modified| modified.elapsed().ok()) - .is_some_and(|age| age < COVER_RESOLUTION_TTL); - if !fresh || metadata.len() > 4096 { - let _ = tokio::fs::remove_file(path).await; - return None; - } - let cached = - serde_json::from_slice::(&tokio::fs::read(path).await.ok()?).ok()?; - (cached.recipe == COVER_RESOLUTION_RECIPE && cached.best_url.starts_with("https://")) - .then_some(cached.best_url) -} - -fn store_cover_resolution(path: &std::path::Path, best_url: &str) -> Result<(), String> { - static LAST_SWEEP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - static FILE_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); - static SWEEP_IN_PROGRESS: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - if let Some(directory) = path.parent() { - maybe_sweep_cache_directory( - directory, - COVER_RESOLUTION_TTL, - COVER_RESOLUTION_CACHE_MAX_FILES, - &LAST_SWEEP, - &FILE_COUNT, - &SWEEP_IN_PROGRESS, - ); - } - let replaces_existing = path.is_file(); - let cached = CachedCoverResolution { - recipe: COVER_RESOLUTION_RECIPE.to_string(), - best_url: best_url.to_string(), - }; - let bytes = serde_json::to_vec(&cached).map_err(|error| error.to_string())?; - let temporary = path.with_extension(format!("{}.tmp", std::process::id())); - std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?; - if let Err(first_error) = std::fs::rename(&temporary, path) { - let _ = std::fs::remove_file(path); - if let Err(retry_error) = std::fs::rename(&temporary, path) { - let _ = std::fs::remove_file(&temporary); - return Err(format!( - "cover resolution cache rename failed ({first_error}); replacement failed ({retry_error})" - )); - } - } - if !replaces_existing { - FILE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - } - Ok(()) -} - -fn artist_art_manifest_path( - app: &tauri::AppHandle, - artist_id: u64, -) -> Result { - let dir = app - .path() - .app_cache_dir() - .map_err(|error| error.to_string())? - .join("artist-art-manifests-v1"); - std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; - Ok(dir.join(format!("{artist_id}.json"))) -} - -/// A persisted manifest that passed structural validation, plus whether it can -/// be served without asking the network again. -struct StoredArtistArt { - art: metadata::CatalogArtistArt, - /// Fresh enough AND written by the current selection recipe. - current: bool, -} - -/// Read a persisted manifest without destroying it. -/// -/// This deliberately does NOT delete stale or older-recipe manifests. It used -/// to, and that is what blanked artists: the file was removed on the way in, -/// then discovery failed, and an artist that had a perfectly good hero five -/// minutes earlier had nothing at all. A known hero is only ever replaced by a -/// better one now -- see `catalog_artist_art`. Only genuinely unusable files -/// (oversized or unparseable) are removed. -async fn read_artist_art_manifest( - app: &tauri::AppHandle, - artist_id: u64, -) -> Option { - let path = artist_art_manifest_path(app, artist_id).ok()?; - let metadata = tokio::fs::metadata(&path).await.ok()?; - if metadata.len() > ARTIST_ART_MANIFEST_MAX_BYTES { - let _ = tokio::fs::remove_file(path).await; - return None; - } - // Age is taken here but judged below: how long this manifest counts as - // fresh depends on which provider tier produced it, which is only knowable - // once it has been parsed. - let age = metadata - .modified() - .ok() - .and_then(|modified| modified.elapsed().ok()); - let bytes = tokio::fs::read(&path).await.ok()?; - let Ok(manifest) = serde_json::from_slice::(&bytes) else { - let _ = tokio::fs::remove_file(path).await; - return None; - }; - let art = manifest.art; - let valid_urls = art - .banners - .iter() - .chain(art.logos.iter()) - .all(|url| url.starts_with("https://")); - // Identity and shape must hold whatever the recipe was; a manifest for the - // wrong artist is never usable, however recent. - if manifest.artist_id != artist_id - || art.artist.id != artist_id - || art.banner_custom - || art.banners.len() > 8 - || art.logos.len() > 8 - || !valid_urls - { - return None; - } - let ttl = if manifest_from_top_tier(&art) { - ARTIST_ART_MANIFEST_TTL - } else { - ARTIST_ART_FALLBACK_TTL - }; - let fresh = age.is_some_and(|age| age < ttl); - let current = fresh && manifest.recipe == ARTIST_ART_MANIFEST_RECIPE; - Some(StoredArtistArt { art, current }) -} - -/// The strict read: only a manifest that is fresh and matches the current -/// recipe. Callers that must not show an out-of-recipe hero use this. -async fn load_artist_art_manifest( - app: &tauri::AppHandle, - artist_id: u64, -) -> Option { - read_artist_art_manifest(app, artist_id) - .await - .and_then(|stored| stored.current.then_some(stored.art)) -} - -/// Persist a resolved manifest off the request path. Re-stamps whatever is -/// written with the current recipe, which is also how an older-recipe manifest -/// that survived as a floor gets migrated forward. -fn store_art_manifest_in_background( - app: &tauri::AppHandle, - artist_id: u64, - art: &metadata::CatalogArtistArt, -) { - let store_app = app.clone(); - let store_art = art.clone(); - tauri::async_runtime::spawn(async move { - let result = tokio::task::spawn_blocking(move || { - store_artist_art_manifest(&store_app, artist_id, &store_art) - }) - .await; - if let Ok(Err(error)) = result { - log::warn!("could not cache artist artwork manifest: {error}"); - } - }); -} - -fn store_artist_art_manifest( - app: &tauri::AppHandle, - artist_id: u64, - art: &metadata::CatalogArtistArt, -) -> Result<(), String> { - if art.banners.is_empty() && art.logos.is_empty() { - return Ok(()); - } - let path = artist_art_manifest_path(app, artist_id)?; - static LAST_SWEEP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - static FILE_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); - static SWEEP_IN_PROGRESS: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - if let Some(directory) = path.parent() { - maybe_sweep_cache_directory( - directory, - ARTIST_ART_MANIFEST_TTL, - ARTIST_ART_MANIFEST_CACHE_MAX_FILES, - &LAST_SWEEP, - &FILE_COUNT, - &SWEEP_IN_PROGRESS, - ); - } - let replaces_existing = path.is_file(); - let manifest = CachedArtistArtManifest { - recipe: ARTIST_ART_MANIFEST_RECIPE.to_string(), - artist_id, - art: art.clone(), - }; - let bytes = serde_json::to_vec(&manifest).map_err(|error| error.to_string())?; - if bytes.len() as u64 > ARTIST_ART_MANIFEST_MAX_BYTES { - return Err("artist artwork manifest is unexpectedly large".into()); - } - let temporary = path.with_extension(format!("{}.tmp", std::process::id())); - std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?; - if let Err(first_error) = std::fs::rename(&temporary, &path) { - let _ = std::fs::remove_file(&path); - if let Err(retry_error) = std::fs::rename(&temporary, &path) { - let _ = std::fs::remove_file(&temporary); - return Err(format!( - "artist art manifest rename failed ({first_error}); replacement failed ({retry_error})" - )); - } - } - if !replaces_existing { - FILE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - } - Ok(()) -} - -#[derive(Serialize)] -pub struct ArtistHeroOverride { - pub data_url: String, - pub width: u32, - pub height: u32, -} - -#[derive(Debug, Clone, Serialize)] -pub struct CatalogArtistHeroSeed { - pub banner: String, - pub banner_custom: bool, -} - -#[derive(Serialize)] -pub struct CatalogHeroArtResult { - pub data_url: String, - pub cacheable: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CatalogHeroCropPlan { - pub recipe: String, - pub source_checksum: String, - #[serde(flatten)] - pub geometry: HeroCropPlan, -} - -#[derive(Serialize)] -pub struct CatalogHeroPreviewResult { - pub data_url: String, - pub plan: CatalogHeroCropPlan, -} - -/// Header preceding the raw RGBA payload returned to the live GPU hero path. -/// The WebView receives this as an ArrayBuffer through Tauri's binary IPC, -/// avoiding both base64 expansion and a lossy JPEG round-trip: -/// -/// magic[4] | width(u32 LE) | height(u32 LE) | grain_seed(u32 LE) | RGBA8 -const CATALOG_HERO_GPU_MAGIC: &[u8; 4] = b"SHG1"; -const CATALOG_HERO_GPU_HEADER_BYTES: usize = 16; - -/// The CHOSEN hero per artist — a decision about this library, not a render -/// cache — so it travels with the library. (The upscaled results keyed by URL -/// and recipe stay in the machine's cache dir: they regenerate cheaply and -/// would otherwise put gigabytes of derived pixels on the drive.) -fn artist_hero_dir(app: &tauri::AppHandle) -> Result { - let cfg = config::load(app); - let dir = config::ensure_library_data_dir(app, &cfg) - .map_err(|e| e.to_string())? - .join("artist-heroes"); - std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - Ok(dir) -} - -fn finished_hero_cache_path( - app: &tauri::AppHandle, - url: &str, - upscaler: config::ArtistHeroUpscaler, -) -> Result { - use std::hash::{Hash as _, Hasher as _}; - let dir = app - .path() - .app_cache_dir() - .map_err(|error| error.to_string())? - .join("finished-artist-heroes"); - std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; - let mut key = std::collections::hash_map::DefaultHasher::new(); - ARTIST_HERO_CACHE_RECIPE.hash(&mut key); - match upscaler { - config::ArtistHeroUpscaler::LiveGpu => "live_gpu", - config::ArtistHeroUpscaler::Fsr => "fsr", - config::ArtistHeroUpscaler::Lanczos3 => "lanczos3", - } - .hash(&mut key); - url.hash(&mut key); - Ok(dir.join(format!("{:016x}.data", key.finish()))) -} - -fn finished_hero_checksum(data_url: &str) -> u64 { - use std::hash::{Hash as _, Hasher as _}; - let mut checksum = std::collections::hash_map::DefaultHasher::new(); - data_url.as_bytes().hash(&mut checksum); - checksum.finish() -} - -fn validate_finished_hero(mut cached: String) -> Option { - let footer = cached.rfind('\n')?; - let expected = u64::from_str_radix(cached.get(footer + 1..)?, 16).ok()?; - cached.truncate(footer); - (cached.len() > 1_000 - && cached.starts_with("data:image/") - && cached.contains(";base64,") - && finished_hero_checksum(&cached) == expected) - .then_some(cached) -} - -fn store_finished_hero(path: &std::path::Path, data_url: &str) -> Result<(), String> { - use std::io::Write as _; - use std::sync::atomic::{AtomicU64, Ordering}; - static NEXT_WRITE: AtomicU64 = AtomicU64::new(1); - - let id = NEXT_WRITE.fetch_add(1, Ordering::Relaxed); - let temporary = path.with_extension(format!("{}.{}.tmp", std::process::id(), id)); - let mut file = std::fs::File::create(&temporary).map_err(|error| error.to_string())?; - file.write_all(data_url.as_bytes()) - .map_err(|error| error.to_string())?; - write!(file, "\n{:016x}", finished_hero_checksum(data_url)) - .map_err(|error| error.to_string())?; - file.sync_all().map_err(|error| error.to_string())?; - drop(file); - if let Err(first_error) = std::fs::rename(&temporary, path) { - let existing_is_valid = std::fs::read_to_string(path) - .ok() - .and_then(validate_finished_hero) - .is_some(); - if existing_is_valid { - let _ = std::fs::remove_file(&temporary); - } else { - let _ = std::fs::remove_file(path); - if let Err(retry_error) = std::fs::rename(&temporary, path) { - let _ = std::fs::remove_file(&temporary); - return Err(format!( - "cache rename failed ({first_error}); replacement failed ({retry_error})" - )); - } - } - } - - // Bound the persistent cache by evicting oldest outputs. This runs only - // after an expensive new render, never on the latency-sensitive read path. - let Some(dir) = path.parent() else { - return Ok(()); - }; - let mut files = std::fs::read_dir(dir) - .map_err(|error| error.to_string())? - .filter_map(Result::ok) - .filter_map(|entry| { - let path = entry.path(); - if path.extension().and_then(|extension| extension.to_str()) != Some("data") { - return None; - } - let metadata = entry.metadata().ok()?; - metadata.is_file().then_some(( - path, - metadata.len(), - metadata.modified().unwrap_or(std::time::UNIX_EPOCH), - )) - }) - .collect::>(); - let mut total = files.iter().map(|(_, bytes, _)| *bytes).sum::(); - files.sort_by_key(|(_, _, modified)| *modified); - for (oldest, bytes, _) in files { - if total <= ARTIST_HERO_CACHE_MAX_BYTES { - break; - } - if std::fs::remove_file(oldest).is_ok() { - total = total.saturating_sub(bytes); - } - } - Ok(()) -} - -async fn load_finished_hero(path: &std::path::Path) -> Option { - let metadata = tokio::fs::metadata(path).await.ok()?; - let fresh = metadata - .modified() - .ok() - .and_then(|modified| modified.elapsed().ok()) - .is_some_and(|age| age < ARTIST_HERO_CACHE_TTL); - if fresh && metadata.len() <= ARTIST_HERO_CACHE_MAX_ENTRY_BYTES { - if let Ok(cached) = tokio::fs::read_to_string(path).await { - if let Some(cached) = validate_finished_hero(cached) { - return Some(cached); - } - } - } - let _ = tokio::fs::remove_file(path).await; - None -} - -fn artist_image_format(path: &std::path::Path) -> Result<(&'static str, &'static str), String> { - let format = image::ImageReader::open(path) - .map_err(|e| e.to_string())? - .with_guessed_format() - .map_err(|e| e.to_string())? - .format() - .ok_or_else(|| "Could not identify that image format".to_string())?; - match format { - image::ImageFormat::Jpeg => Ok(("jpg", "image/jpeg")), - image::ImageFormat::Png => Ok(("png", "image/png")), - image::ImageFormat::WebP => Ok(("webp", "image/webp")), - _ => Err("Choose a JPEG, PNG, or WebP image".into()), - } -} - -fn validate_artist_hero(path: &std::path::Path) -> Result<(u32, u32), String> { - let size = std::fs::metadata(path).map_err(|e| e.to_string())?.len(); - if size > ARTIST_HERO_MAX_BYTES { - return Err("Hero images must be 30 MB or smaller".into()); - } - let (width, height) = image::image_dimensions(path).map_err(|e| e.to_string())?; - if width < ARTIST_HERO_MIN_WIDTH || height < ARTIST_HERO_MIN_HEIGHT { - return Err(format!( - "That image is {width}×{height}. Choose at least {ARTIST_HERO_MIN_WIDTH}×{ARTIST_HERO_MIN_HEIGHT}." - )); - } - if u64::from(width) * 10 < u64::from(height) * 13 { - return Err(format!( - "That image is {width}×{height}. Choose a landscape photo for the wide hero." - )); - } - Ok((width, height)) -} - -fn load_artist_hero_override( - app: &tauri::AppHandle, - artist_id: u64, -) -> Result, String> { - use base64::Engine as _; - let dir = artist_hero_dir(app)?; - for (ext, mime) in [ - ("jpg", "image/jpeg"), - ("png", "image/png"), - ("webp", "image/webp"), - ] { - let path = dir.join(format!("{artist_id}.{ext}")); - if !path.is_file() { - continue; - } - let (width, height) = validate_artist_hero(&path)?; - let bytes = std::fs::read(path).map_err(|e| e.to_string())?; - return Ok(Some(ArtistHeroOverride { - data_url: format!( - "data:{mime};base64,{}", - base64::engine::general_purpose::STANDARD.encode(bytes) - ), - width, - height, - })); - } - Ok(None) -} - -/// Import a user-owned high-resolution artist hero into app data. The numeric -/// catalog id makes the stored filename collision-safe without trusting an -/// artist name as a filesystem path. -#[tauri::command] -pub async fn pick_artist_hero( - app: tauri::AppHandle, - artist_id: u64, -) -> Result, String> { - use tauri_plugin_dialog::DialogExt; - let (tx, rx) = tokio::sync::oneshot::channel(); - app.dialog() - .file() - .add_filter("High-resolution image", &["jpg", "jpeg", "png", "webp"]) - .pick_file(move |path| { - let _ = tx.send(path.map(|p| p.to_string())); - }); - let Some(source) = rx.await.unwrap_or(None) else { - return Ok(None); - }; - let source = std::path::PathBuf::from(source); - let (width, height) = validate_artist_hero(&source)?; - let (ext, _) = artist_image_format(&source)?; - let dir = artist_hero_dir(&app)?; - let temporary = dir.join(format!("{artist_id}.importing")); - std::fs::copy(&source, &temporary).map_err(|e| e.to_string())?; - for old_ext in ["jpg", "png", "webp"] { - let _ = std::fs::remove_file(dir.join(format!("{artist_id}.{old_ext}"))); - } - let destination = dir.join(format!("{artist_id}.{ext}")); - if let Err(error) = std::fs::rename(&temporary, &destination) { - let _ = std::fs::remove_file(&temporary); - return Err(error.to_string()); - } - let mut hero = load_artist_hero_override(&app, artist_id)? - .ok_or_else(|| "The imported hero could not be reopened".to_string())?; - hero.width = width; - hero.height = height; - Ok(Some(hero)) -} - -#[tauri::command] -pub fn clear_artist_hero(app: tauri::AppHandle, artist_id: u64) -> Result { - let dir = artist_hero_dir(&app)?; - let mut removed = false; - for ext in ["jpg", "png", "webp"] { - let path = dir.join(format!("{artist_id}.{ext}")); - if path.is_file() { - std::fs::remove_file(path).map_err(|e| e.to_string())?; - removed = true; - } - } - Ok(removed) -} - -#[tauri::command] -pub async fn catalog_search( - state: State<'_, LibraryState>, - query: String, -) -> Result { - state.touch_catalog_activity(); - Ok(state.metadata.catalog_search(&query).await) -} - -#[tauri::command] -pub async fn catalog_search_tracks( - state: State<'_, LibraryState>, - query: String, - limit: Option, -) -> Result, String> { - state.touch_catalog_activity(); - Ok(state - .metadata - .catalog_search_tracks(&query, limit.unwrap_or(10)) - .await) -} - -#[tauri::command] -pub async fn catalog_artist_track_candidates( - state: State<'_, LibraryState>, - artist: String, - title: String, -) -> Result, String> { - state.touch_catalog_activity(); - Ok(state - .metadata - .catalog_artist_track_candidates(&artist, &title) - .await) -} - -#[tauri::command] -pub async fn catalog_track_by_isrc( - state: State<'_, LibraryState>, - isrc: String, -) -> Result, String> { - state.touch_catalog_activity(); - Ok(state.metadata.catalog_track_by_isrc(&isrc).await) -} - -#[tauri::command] -pub async fn catalog_artist( - app: tauri::AppHandle, - state: State<'_, LibraryState>, - artist_id: u64, -) -> Result { - state.touch_catalog_activity(); - let mut page = state - .metadata - .catalog_artist(artist_id) - .await - .ok_or_else(|| "artist lookup failed".to_string())?; - if let Some(custom) = load_artist_hero_override(&app, artist_id)? { - page.banner = Some(custom.data_url.clone()); - // A deliberate user override remains fixed instead of being diluted by - // automatic provider art. Clearing it restores the provider carousel. - page.banners = vec![custom.data_url]; - page.banner_custom = true; - } - Ok(page) -} - -#[tauri::command] -pub async fn catalog_artist_art( - app: tauri::AppHandle, - state: State<'_, LibraryState>, - artist_id: u64, -) -> Result { - // The selected provider/Bing URL tier is stable for the same period as the - // metadata cache. Persisting that tiny manifest means an app restart can - // address the already-known hero immediately instead of rediscovering Bing. - let stored = read_artist_art_manifest(&app, artist_id).await; - let mut art = match stored { - // Fresh, current recipe, and the host is still serving the images: - // serve it, no network. The liveness check matters because a manifest - // outlives the CDN behind it -- measured 2026-07-28, Fanart.tv's API - // stayed up while `assets.fanart.tv` refused every connection, so every - // stored manifest naming it replayed a hero that could never load and - // short-circuited the discovery that would have found a working one. - // One probe per host per 5 minutes, so the hero returns on its own once - // the CDN does. - Some(stored) - if stored.current && state.metadata.art_is_servable(&stored.art).await => - { - stored.art - } - // Either nothing on disk, or something stale/older-recipe. Try to do - // better, but keep what we have as a floor: the discovery tier fails - // per-artist-name (Bing collapses rare names onto popular entities and - // intermittently serves other queries' results), so a failed lookup is - // a normal event, not a reason to show an artist with no hero. - other => { - let resolved = state.metadata.catalog_artist_art(artist_id).await; - // Only banners we already had AND can still show count as a floor. - // Restoring unreachable ones would undo the whole point of falling - // through: discovery correctly dropped a dead CDN, and the floor - // would put it straight back. Note that `catalog_artist_art` - // returns Some as soon as the Deezer *profile* resolves, so "found - // nothing" arrives as Some with an empty banner list -- an - // emptiness check, not a None check, is what detects a failed hero - // discovery. - let previous = match other.map(|stored| stored.art) { - Some(art) - if !art.banners.is_empty() - && state.metadata.art_is_servable(&art).await => - { - Some(art) - } - _ => None, - }; - match (resolved, previous) { - (Some(mut resolved), Some(previous)) if resolved.banners.is_empty() => { - log::info!( - "artist {artist_id}: hero discovery found nothing, keeping {} \ - previously resolved banner(s)", - previous.banners.len() - ); - resolved.banner = previous.banner; - resolved.banners = previous.banners; - if resolved.logos.is_empty() { - resolved.logos = previous.logos; - } - store_art_manifest_in_background(&app, artist_id, &resolved); - resolved - } - (Some(resolved), _) => { - store_art_manifest_in_background(&app, artist_id, &resolved); - resolved - } - (None, Some(previous)) => previous, - (None, None) => return Err("artist artwork lookup failed".to_string()), - } - } - }; - if let Some(custom) = load_artist_hero_override(&app, artist_id)? { - art.banner = Some(custom.data_url.clone()); - art.banners = vec![custom.data_url]; - art.banner_custom = true; - } - Ok(art) -} - -/// Definitive provider-logo candidates without waiting for the independent -/// Bing hero fallback. The frontend uses this to decide whether cover OCR is -/// allowed as soon as the exact release list is ready. -#[tauri::command] -pub async fn catalog_artist_logos( - state: State<'_, LibraryState>, - artist_id: u64, -) -> Result, String> { - state - .metadata - .catalog_artist_logos(artist_id) - .await - .ok_or_else(|| "artist logo lookup failed".to_string()) -} - -/// Deferred MusicBrainz pass — MB-only releases + type hints. Kept out of -/// `catalog_artist` so its rate-limited (1 req/s) calls never delay the grid. -/// Socials ride `catalog_artist_socials` below, off this critical path. -#[tauri::command] -pub async fn catalog_artist_identity( - state: State<'_, LibraryState>, - artist_id: u64, - artist: String, -) -> Result { - Ok(state - .metadata - .catalog_artist_identity(artist_id, &artist) - .await) -} - -/// Identity-anchored artist biography for the Now Playing panel. Replaces a -/// frontend "first Wikipedia hit for ` musician band`" lookup that -/// regularly described a different artist entirely, and which also bypassed -/// this client's Wikimedia UA/pacing/caching. -#[tauri::command] -pub async fn catalog_artist_bio( - state: State<'_, LibraryState>, - artist_id: Option, - artist: String, -) -> Result, String> { - state.touch_catalog_activity(); - Ok(state.metadata.artist_bio(artist_id, &artist).await) -} - -/// Persist "this Deezer artist record holds more than one act, show this -/// portrait instead". -/// -/// The verdict costs rate-limited MusicBrainz and Apple lookups, so the search -/// bar — typed against per keystroke — can only ever consult a remembered one. -/// The frontend calls this once the identity merge has proven the record fused -/// AND the identity-gated hero has resolved, so every later surface (suggestion -/// dropdown, search results, related artists, Now Playing) agrees for free. -#[tauri::command] -pub async fn remember_fused_artist_portrait( - state: State<'_, LibraryState>, - artist_id: u64, - portrait: Option, -) -> Result<(), String> { - state - .metadata - .remember_fused_portrait(artist_id, portrait); - Ok(()) -} - -/// Footer links, fetched only after the sorting-critical identity merge has -/// landed — one MusicBrainz request that used to cost every cold artist ~1 s -/// of time-to-correctly-sorted inside the identity join. -#[tauri::command] -pub async fn catalog_artist_socials( - state: State<'_, LibraryState>, - artist_id: u64, -) -> Result, String> { - Ok(state.metadata.catalog_artist_socials(artist_id).await) -} - -// v4: the alpha ramp now scales to the measured ink distance, so every cached -// mask predates the anti-aliasing this pipeline relies on and must be redrawn. -// v5: three changes, each of which can turn a previously-empty result into a -// mark (or trim one that over-reached), so every v4 entry is stale by -// construction — the alpha ramp's ceiling can no longer exceed the ink's own -// distance, the dominance guard admits flat multi-tone crops, and a row walk -// that never met a gap falls back to the OCR box instead of running to the crop -// edge. Wand had four covers printing its name and v4 extracted none of them. -// v10: the winner's supersample scale is fractional instead of a floored -// integer. Every mask whose crop was wider than half the target was previously -// supersampled by exactly 1x — i.e. not at all — so the covers this rewrites are -// the LARGE printed wordmarks, which is where the staircase was worst. -const COVER_LOGO_RECIPE: &str = "cover-logo-v10-supersample-2400-fractional"; -// A prolific artist's wordmark is often nowhere near the top of this list. The -// rank puts albums first and penalises `remix|mix|edit|live` titles — but for -// Poolside (74 releases) every one of the SEVEN covers that OCR reads as -// "POOLSIDE" is a remix single, so all seven sat outside a 14-cover window and -// the artist silently lost its logo. Raising the cap costs nothing for artists -// that hit early: the locate loop stops as soon as COVER_LOGO_MAX_HITS is -// reached, so the extra covers are only ever fetched when the early ones miss. -// The price is paid by artists with no wordmark anywhere, who now OCR more -// covers before giving up — once, then it is cached. -const COVER_LOGO_MAX_COVERS: usize = 40; -const COVER_LOGO_MAX_HITS: usize = 5; -const COVER_LOGO_LOCATE_WINDOW: usize = 4; -const COVER_LOGO_UPGRADE_WINDOW: usize = 4; - -/// Completed cover-derived logos, including session-scoped negative results. -/// The full input fingerprint prevents namesakes or a changed ranked cover list -/// from sharing a result. -fn cover_logo_cache() -> &'static std::sync::Mutex>> -{ - static CACHE: std::sync::OnceLock< - std::sync::Mutex>>, - > = std::sync::OnceLock::new(); - CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) -} - -type CoverLogoResult = Result, String>; - -/// Rapid navigation can ask for the same artist again before the first OCR pass -/// finishes. Share that work instead of launching a second full pipeline. -fn cover_logo_inflight() -> &'static tokio::sync::Mutex< - std::collections::HashMap>>, -> { - static INFLIGHT: std::sync::OnceLock< - tokio::sync::Mutex< - std::collections::HashMap< - String, - std::sync::Arc>, - >, - >, - > = std::sync::OnceLock::new(); - INFLIGHT.get_or_init(|| tokio::sync::Mutex::new(std::collections::HashMap::new())) -} - -/// One album cover the frontend offers as a wordmark source: its title (so the -/// backend can resolve the same art at a higher resolution) and its cover URL. -#[derive(Debug, Clone, Deserialize)] -pub struct CoverRef { - pub title: String, - pub url: String, -} - -fn cover_logo_key(artist_id: u64, artist: &str, covers: &[CoverRef]) -> String { - use std::hash::{Hash as _, Hasher as _}; - let mut hash = std::collections::hash_map::DefaultHasher::new(); - COVER_LOGO_RECIPE.hash(&mut hash); - artist_id.hash(&mut hash); - artist.to_lowercase().hash(&mut hash); - for cover in covers.iter().take(COVER_LOGO_MAX_COVERS) { - cover.title.hash(&mut hash); - cover.url.hash(&mut hash); - } - format!("{artist_id}-{:016x}", hash.finish()) -} - -fn cover_logo_disk_dir(app: &tauri::AppHandle) -> Option { - // `catalog_cached_cover_logo` restores the newest positive file by artist - // id before the current cover fingerprint is available. Isolate this - // identity-hardened generation so a pre-hardening logo derived from a - // fused namesake release can never flash during that early restore. - let dir = app.path().app_cache_dir().ok()?.join("cover-logos-v3"); - std::fs::create_dir_all(&dir).ok()?; - Some(dir) -} - -fn cover_logo_disk_path(app: &tauri::AppHandle, key: &str) -> Option { - Some(cover_logo_disk_dir(app)?.join(format!("{key}.data"))) -} - -fn valid_cached_cover_logo(value: &str) -> bool { - value.starts_with("data:image/png;base64,") && value.len() > 64 -} - -async fn read_cover_logo_disk(path: &std::path::Path) -> Option { - let value = tokio::fs::read_to_string(path).await.ok()?; - valid_cached_cover_logo(&value).then_some(value) -} - -async fn write_cover_logo_disk(path: &std::path::Path, value: &str) { - let temp = path.with_extension(format!("tmp-{}", std::process::id())); - if tokio::fs::write(&temp, value).await.is_err() { - return; - } - // Windows rename does not replace an existing destination. Invalid or - // interrupted cache entries are disposable and regenerated here. - let _ = tokio::fs::remove_file(path).await; - if tokio::fs::rename(&temp, path).await.is_err() { - let _ = tokio::fs::remove_file(&temp).await; - } -} - -struct LocatedCoverLogo { - rank: usize, - title: String, - url: String, - bytes_1000: std::sync::Arc>, - loc: crate::cover_logo::BandLocation, - dims_1000: (u32, u32), -} - -struct DownloadedCoverLogo { - rank: usize, - title: String, - url: String, - bytes_1000: std::sync::Arc>, -} - -struct CoverLogoDownloadJob { - download_elapsed: std::time::Duration, - downloaded: Option, -} - -async fn download_cover_logo_candidate( - metadata: std::sync::Arc, - rank: usize, - cover: CoverRef, -) -> CoverLogoDownloadJob { - let download_started = std::time::Instant::now(); - let Some(bytes_1000) = metadata.cover_bytes(&cover.url).await else { - return CoverLogoDownloadJob { - download_elapsed: download_started.elapsed(), - downloaded: None, - }; - }; - CoverLogoDownloadJob { - download_elapsed: download_started.elapsed(), - downloaded: Some(DownloadedCoverLogo { - rank, - title: cover.title, - url: cover.url, - bytes_1000, - }), - } -} - -async fn locate_downloaded_cover_logo( - downloaded: DownloadedCoverLogo, - artist: std::sync::Arc, -) -> (std::time::Duration, Option) { - let locate_started = std::time::Instant::now(); - let bytes_for_ocr = std::sync::Arc::clone(&downloaded.bytes_1000); - let found = tokio::task::spawn_blocking(move || { - use image::GenericImageView as _; - let image = image::load_from_memory(bytes_for_ocr.as_slice()).ok()?; - let dims = image.dimensions(); - let loc = crate::cover_logo::locate_wordmark(&image, &artist) - .ok() - .flatten()?; - Some((loc, dims)) - }) - .await - .ok() - .flatten(); - let locate_elapsed = locate_started.elapsed(); - ( - locate_elapsed, - found.map(|(loc, dims_1000)| LocatedCoverLogo { - rank: downloaded.rank, - title: downloaded.title, - url: downloaded.url, - bytes_1000: downloaded.bytes_1000, - loc, - dims_1000, - }), - ) -} - -struct CoverLogoCandidate { - rank: usize, - title: String, - bytes_1000: std::sync::Arc>, - loc: crate::cover_logo::BandLocation, - dims_1000: (u32, u32), - bytes_hi: Option>>, -} - -struct CoverLogoUpgradeJob { - elapsed: std::time::Duration, - candidate: CoverLogoCandidate, -} - -async fn upgrade_cover_logo_candidate( - metadata: std::sync::Arc, - artist_id: u64, - artist: std::sync::Arc, - located: LocatedCoverLogo, -) -> CoverLogoUpgradeJob { - let started = std::time::Instant::now(); - let hi_url = metadata - .resolve_cover_for_artist_id(Some(artist_id), &artist, &located.title, &located.url) - .await - .unwrap_or_else(|| located.url.clone()); - let bytes_hi = if hi_url != located.url { - metadata.cover_bytes(&hi_url).await - } else { - None - }; - CoverLogoUpgradeJob { - elapsed: started.elapsed(), - candidate: CoverLogoCandidate { - rank: located.rank, - title: located.title, - bytes_1000: located.bytes_1000, - loc: located.loc, - dims_1000: located.dims_1000, - bytes_hi, - }, - } -} - -async fn generate_cover_logo( - metadata: std::sync::Arc, - artist_id: u64, - artist: String, - covers: Vec, -) -> CoverLogoResult { - let total_started = std::time::Instant::now(); - let offered: Vec<_> = covers.into_iter().take(COVER_LOGO_MAX_COVERS).collect(); - let artist_shared: std::sync::Arc = std::sync::Arc::from(artist.as_str()); - - // Download and OCR a rank-ordered sliding window. Tasks run concurrently, - // but awaiting the handles from the front commits results in original rank - // order. Stopping at hit five therefore selects exactly the old first-five - // hit set; at most WINDOW-1 later covers have speculative work in flight. - let locate_pipeline_started = std::time::Instant::now(); - let mut locate_pending = std::collections::VecDeque::new(); - let mut next_rank = 0usize; - while next_rank < offered.len() && locate_pending.len() < COVER_LOGO_LOCATE_WINDOW { - let metadata = std::sync::Arc::clone(&metadata); - let cover = offered[next_rank].clone(); - let rank = next_rank; - locate_pending.push_back(( - rank, - tokio::spawn(download_cover_logo_candidate(metadata, rank, cover)), - )); - next_rank += 1; - } - - let upgrade_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(COVER_LOGO_UPGRADE_WINDOW)); - let mut upgrade_pending = Vec::new(); - let mut upgrade_started: Option = None; - let mut hit_summary = Vec::new(); - let mut located_count = 0usize; - let mut downloaded_count = 0usize; - let mut download_work = std::time::Duration::ZERO; - let mut locate_work = std::time::Duration::ZERO; - while let Some((rank, handle)) = locate_pending.pop_front() { - match handle.await { - Ok(job) => { - download_work += job.download_elapsed; - if let Some(downloaded) = job.downloaded { - downloaded_count += 1; - // OCR itself stays rank-ordered and sequential. Windows OCR - // produced occasional different boxes under parallel engine - // pressure; overlapping downloads retains most of the wall - // win while preserving the original deterministic pass order. - let (elapsed, hit) = locate_downloaded_cover_logo( - downloaded, - std::sync::Arc::clone(&artist_shared), - ) - .await; - locate_work += elapsed; - if let Some(hit) = hit { - located_count += 1; - hit_summary.push((hit.rank, hit.title.clone())); - upgrade_started.get_or_insert_with(std::time::Instant::now); - let rank = hit.rank; - let metadata = std::sync::Arc::clone(&metadata); - let artist = std::sync::Arc::clone(&artist_shared); - let slots = std::sync::Arc::clone(&upgrade_slots); - upgrade_pending.push(( - rank, - tokio::spawn(async move { - let _permit = slots.acquire_owned().await.ok(); - upgrade_cover_logo_candidate(metadata, artist_id, artist, hit).await - }), - )); - } - } - } - Err(error) => log::warn!("cover-logo locate task rank {rank} failed: {error}"), - } - if located_count >= COVER_LOGO_MAX_HITS { - for (_, handle) in locate_pending.drain(..) { - handle.abort(); - } - break; - } - if next_rank < offered.len() { - let metadata = std::sync::Arc::clone(&metadata); - let cover = offered[next_rank].clone(); - let rank = next_rank; - locate_pending.push_back(( - rank, - tokio::spawn(download_cover_logo_candidate(metadata, rank, cover)), - )); - next_rank += 1; - } - } - let locate_pipeline_elapsed = locate_pipeline_started.elapsed(); - - // Hi-res resolution begins as soon as each ordered OCR hit commits, so its - // network time overlaps the remaining OCR passes. Handles are consumed in - // hit/rank order to retain exact equal-score winner semantics. - let mut candidates = Vec::with_capacity(located_count); - let mut upgrade_work = std::time::Duration::ZERO; - for (rank, handle) in upgrade_pending { - match handle.await { - Ok(job) => { - upgrade_work += job.elapsed; - candidates.push(job.candidate); - } - Err(error) => log::warn!("cover-logo upgrade task rank {rank} failed: {error}"), - } - } - let upgrade_elapsed = upgrade_started - .map(|started| started.elapsed()) - .unwrap_or(std::time::Duration::ZERO); - - // Selection recipe is intentionally unchanged. Keep the best image in - // memory and PNG-encode once, rather than encoding every provisional best. - const PROMINENCE_W: f32 = 3.0; - const MIN_PROMINENCE: f32 = 0.05; - const MIN_MARK_H: u32 = 74; - // Mask long edge the WINNER is re-cut at. Matching the tracer's own raster - // is the whole rationale: one good Lanczos enlargement in Rust instead of - // Lanczos followed by the browser's bilinear, with the trace then running at - // roughly the mask's native resolution. - // - // Swept against 1200/3600/4800 over eight artists (see - // `research/cover-logo-superres/prototypes/supersample-batch.mjs`), and 2400 - // is where the curve flattens. 1200 is below several crops and leaves them - // untouched. Past 2400 the mask overshoots the trace raster, so the browser - // DOWNSCALES what Rust just built — every shape measure is flat from there - // (Poolside, Wand, Camping In Alaska, Turnover all identical at 2400 and - // 3600) while the data URL doubles: Mildlife 137KB -> 267KB, Camping 76KB -> - // 152KB, for nothing. The one artist that still gains above 2400 is Hot - // Toddy, and it gains from the downscale itself rather than from the extra - // detail — the same smoothing the tracer's blur term would give it. - const MASK_SUPERSAMPLE_EDGE: u32 = 2400; - let extract_started = std::time::Instant::now(); - let (best, winner) = tokio::task::spawn_blocking(move || { - use base64::Engine as _; - use image::{GenericImageView as _, ImageEncoder as _}; - // The winner's INPUTS, not its mask: the mask is re-cut once below at - // full supersample, so scoring never pays for it and never sees it. - let mut best: Option<(f32, CoverLogoCandidate)> = None; - for candidate in candidates { - let prom_w = (candidate.loc.rect.w / candidate.dims_1000.0 as f32).min(1.0); - if prom_w < MIN_PROMINENCE { - continue; - } - let mark = candidate - .bytes_hi - .as_ref() - .and_then(|bytes| image::load_from_memory(bytes.as_slice()).ok()) - .and_then(|hi| { - let (hw, hh) = hi.dimensions(); - let scaled = candidate.loc.scaled( - hw as f32 / candidate.dims_1000.0 as f32, - hh as f32 / candidate.dims_1000.1 as f32, - ); - crate::cover_logo::extract_wordmark(&hi, &scaled, 0) - }) - .or_else(|| { - image::load_from_memory(candidate.bytes_1000.as_slice()) - .ok() - .and_then(|image| { - crate::cover_logo::extract_wordmark(&image, &candidate.loc, 0) - }) - }); - let Some(mark) = mark else { continue }; - if mark.image.height() < MIN_MARK_H { - continue; - } - let final_score = mark.score + PROMINENCE_W * prom_w; - if best.as_ref().is_none_or(|(score, _)| final_score > *score) { - best = Some((final_score, candidate)); - } - } - let Some((_, winner)) = best else { - return (None, None); - }; - let (rank, title) = (winner.rank, winner.title.clone()); - // Re-cut the winning mark, this time resampling the crop up before the - // alpha ramp so the ramp reads interpolated colour and places the edge - // between source pixels rather than on them. Selection above ran at - // native scale on purpose — see `try_extract_wordmark`'s `target_edge`. - // Falls back to the selection-scale mask, so the richer pass failing can - // never cost an artist its logo. - let recut = winner - .bytes_hi - .as_ref() - .and_then(|bytes| image::load_from_memory(bytes.as_slice()).ok()) - .and_then(|hi| { - let (hw, hh) = hi.dimensions(); - let scaled = winner.loc.scaled( - hw as f32 / winner.dims_1000.0 as f32, - hh as f32 / winner.dims_1000.1 as f32, - ); - crate::cover_logo::extract_wordmark(&hi, &scaled, MASK_SUPERSAMPLE_EDGE) - .or_else(|| crate::cover_logo::extract_wordmark(&hi, &scaled, 0)) - }) - .or_else(|| { - image::load_from_memory(winner.bytes_1000.as_slice()) - .ok() - .and_then(|img| { - crate::cover_logo::extract_wordmark( - &img, - &winner.loc, - MASK_SUPERSAMPLE_EDGE, - ) - .or_else(|| crate::cover_logo::extract_wordmark(&img, &winner.loc, 0)) - }) - }); - let Some(image) = recut.map(|mark| mark.image) else { - return (None, None); - }; - let (width, height) = image.dimensions(); - let mut png = Vec::new(); - if image::codecs::png::PngEncoder::new(&mut png) - .write_image( - image.as_raw(), - width, - height, - image::ExtendedColorType::Rgba8, - ) - .is_err() - { - return (None, None); - } - ( - Some(format!( - "data:image/png;base64,{}", - base64::engine::general_purpose::STANDARD.encode(&png) - )), - Some((rank, title, width, height)), - ) - }) - .await - .map_err(|error| error.to_string())?; - let extract_elapsed = extract_started.elapsed(); - - log::info!( - "cover-logo perf artist={artist:?} offered={} downloaded={downloaded_count} hits={located_count} locate_pipeline_ms={} download_work_ms={} ocr_work_ms={} upgrade_wall_ms={} upgrade_work_ms={} extract_ms={} total_ms={} hit_ranks={hit_summary:?} winner={winner:?}", - offered.len(), - locate_pipeline_elapsed.as_millis(), - download_work.as_millis(), - locate_work.as_millis(), - upgrade_elapsed.as_millis(), - upgrade_work.as_millis(), - extract_elapsed.as_millis(), - total_started.elapsed().as_millis(), - ); - Ok(best) -} - -/// Restore the most recently finalized cover-derived logo for an artist before -/// the exhaustive release list is ready. Only positive, recipe-versioned final -/// results are persisted, so this never exposes a speculative OCR candidate. -#[tauri::command] -pub async fn catalog_cached_cover_logo( - app: tauri::AppHandle, - artist_id: u64, -) -> Result, String> { - let Some(dir) = cover_logo_disk_dir(&app) else { - return Ok(None); - }; - let prefix = format!("{artist_id}-"); - let mut paths: Vec<_> = std::fs::read_dir(dir) - .into_iter() - .flatten() - .filter_map(Result::ok) - .filter(|entry| { - entry.file_name().to_string_lossy().starts_with(&prefix) - && entry.path().extension().and_then(|ext| ext.to_str()) == Some("data") - }) - .collect(); - paths.sort_by_key(|entry| { - std::cmp::Reverse( - entry - .metadata() - .and_then(|metadata| metadata.modified()) - .ok(), - ) - }); - for entry in paths { - if let Some(value) = read_cover_logo_disk(&entry.path()).await { - return Ok(Some(value)); - } - } - Ok(None) -} - -/// Deferred: derive an artist wordmark from album covers for artists with no -/// provider (Fanart/TheAudioDB) logo. Downloads the covers, OCRs each for the -/// band-name line, segments the cleanest off its background onto transparency, -/// and returns it as a PNG data URL — `None` when no cover yields a clean -/// wordmark (those stay on the plain text-name fallback). Cached per artist. -/// -/// Covers arrive already ranked studio-albums-first (their art prints the bare -/// band name; singles/remixes print tracklists and credits), so OCR walks the -/// clean covers first and early-outs once it has enough hits. -#[tauri::command] -pub async fn catalog_cover_logo( - app: tauri::AppHandle, - state: State<'_, LibraryState>, - artist_id: u64, - artist: String, - covers: Vec, -) -> Result, String> { - let key = cover_logo_key(artist_id, &artist, &covers); - if let Some(hit) = cover_logo_cache().lock().unwrap().get(&key).cloned() { - log::info!("cover-logo cache memory artist={artist:?} artist_id={artist_id}"); - return Ok(hit); - } - - let disk_path = cover_logo_disk_path(&app, &key); - if let Some(path) = disk_path.as_deref() { - if let Some(hit) = read_cover_logo_disk(path).await { - cover_logo_cache() - .lock() - .unwrap() - .insert(key, Some(hit.clone())); - log::info!("cover-logo cache disk artist={artist:?} artist_id={artist_id}"); - return Ok(Some(hit)); - } - } - - let inflight = { - let mut inflight = cover_logo_inflight().lock().await; - inflight - .entry(key.clone()) - .or_insert_with(|| std::sync::Arc::new(tokio::sync::OnceCell::new())) - .clone() - }; - let metadata = std::sync::Arc::clone(&state.metadata); - let generated = inflight - .get_or_init(|| generate_cover_logo(metadata, artist_id, artist.clone(), covers.clone())) - .await - .clone(); - - let should_persist = if let Ok(value) = &generated { - let mut cache = cover_logo_cache().lock().unwrap(); - if cache.len() > 500 { - cache.clear(); - } - let was_missing = !cache.contains_key(&key); - cache.insert(key.clone(), value.clone()); - was_missing && value.is_some() - } else { - false - }; - if should_persist { - if let (Some(path), Ok(Some(value))) = (disk_path.as_deref(), generated.as_ref()) { - write_cover_logo_disk(path, value).await; - } - } - - let mut active = cover_logo_inflight().lock().await; - if active - .get(&key) - .is_some_and(|current| std::sync::Arc::ptr_eq(current, &inflight)) - { - active.remove(&key); - } - generated -} - -/// Per-cover diagnostic for the cover-logo pipeline: for each candidate cover, -/// what OCR line matched the artist name, its box (in the 1000px OCR space), the -/// hi-res source it resolved to, the extracted-mark size, and the extraction -/// score — plus which cover WON. Answers "which album art is the logo lifted -/// from, where on it, and how large?". Not cached; not on the hot path. -#[derive(Debug, Clone, Serialize)] -pub struct CoverLogoDiag { - pub title: String, - pub url: String, - pub cover_w: u32, - pub cover_h: u32, - pub matched: bool, - pub matched_text: String, - pub match_score: f32, - pub box_x: f32, - pub box_y: f32, - pub box_w: f32, - pub box_h: f32, - pub box_pct_w: f32, - pub box_pct_h: f32, - pub hi_url: String, - pub hi_w: u32, - pub hi_h: u32, - pub mark_w: u32, - pub mark_h: u32, - pub extract_score: f32, - pub reject_reason: String, - /// The extracted mask itself, as a `data:image/png` URL, or empty when the - /// cover produced none. The numbers above say a mark was found and how big - /// it is; only the pixels say whether its EDGES are worth tracing, which is - /// the whole question when sweeping `target_edge`. Empty on the OCR-matrix - /// paths that never extract. - pub mark_png: String, - pub winner: bool, - pub ocr: Vec, -} - -#[tauri::command] -pub async fn catalog_cover_logo_debug( - state: State<'_, LibraryState>, - artist_id: Option, - artist: String, - covers: Vec, - target_edge: Option, -) -> Result, String> { - use image::GenericImageView as _; - // Mask supersample target to segment at. 0/absent means native, which is - // what candidate SELECTION uses in production; a value here is for sweeping - // "does more supersampling help" without a rebuild per level. - let target_edge = target_edge.unwrap_or(0); - - // Download 1000px covers for OCR. - let mut downloaded = Vec::new(); - // Must match COVER_LOGO_MAX_COVERS, not undercut it. At 14 this diagnostic - // silently hid exactly the covers the real pipeline relies on — Poolside's - // seven POOLSIDE-readable covers are all remix singles ranked past 14, so - // the tool reported "one match" for an artist production finds several on. - for cover in covers.into_iter().take(COVER_LOGO_MAX_COVERS) { - if let Some(bytes) = state.metadata.cover_bytes(&cover.url).await { - downloaded.push((cover.title, cover.url, bytes)); - } - } - - // Locate the band-name line on each (blocking OCR). - let artist_locate = artist.clone(); - let located = tokio::task::spawn_blocking(move || { - let mut out = Vec::new(); - for (title, url, bytes) in downloaded { - let Ok(img) = image::load_from_memory(&bytes) else { - continue; - }; - let (cw, ch) = img.dimensions(); - let found = crate::cover_logo::locate_wordmark_named(&img, &artist_locate) - .ok() - .flatten(); - let ocr = crate::cover_logo::ocr_lines_multi(&img) - .unwrap_or_default() - .into_iter() - .map(|l| { - format!( - "\"{}\" @{},{} {}x{}", - l.text, - l.rect.x.round(), - l.rect.y.round(), - l.rect.w.round(), - l.rect.h.round() - ) - }) - .collect(); - out.push((title, url, bytes, cw, ch, found, ocr)); - } - out - }) - .await - .map_err(|e| e.to_string())?; - - // For each hit: resolve to hi-res, extract, score. - let mut rows: Vec = Vec::new(); - for (title, url, bytes_1000, cw, ch, found, ocr) in located { - let mut row = CoverLogoDiag { - title: title.clone(), - url: url.clone(), - cover_w: cw, - cover_h: ch, - ocr, - matched: false, - matched_text: String::new(), - match_score: 0.0, - box_x: 0.0, - box_y: 0.0, - box_w: 0.0, - box_h: 0.0, - box_pct_w: 0.0, - box_pct_h: 0.0, - hi_url: url.clone(), - hi_w: cw, - hi_h: ch, - mark_w: 0, - mark_h: 0, - extract_score: 0.0, - reject_reason: String::new(), - mark_png: String::new(), - winner: false, - }; - if let Some((loc, text, mscore)) = found { - row.matched = true; - row.matched_text = text; - row.match_score = mscore; - row.box_x = loc.rect.x; - row.box_y = loc.rect.y; - row.box_w = loc.rect.w; - row.box_h = loc.rect.h; - row.box_pct_w = loc.rect.w / cw as f32 * 100.0; - row.box_pct_h = loc.rect.h / ch as f32 * 100.0; - - let hi_url = state - .metadata - .resolve_cover_for_artist_id(artist_id, &artist, &title, &url) - .await - .unwrap_or_else(|| url.clone()); - let bytes_hi = if hi_url != url { - state.metadata.cover_bytes(&hi_url).await - } else { - None - }; - let (bytes_ex, loc_ex, hw, hh) = match bytes_hi - .as_ref() - .and_then(|b| image::load_from_memory(b).ok()) - { - Some(hi) => { - let (hw, hh) = hi.dimensions(); - let scaled = loc.scaled(hw as f32 / cw as f32, hh as f32 / ch as f32); - (bytes_hi.clone().unwrap(), scaled, hw, hh) - } - None => (bytes_1000.clone(), loc, cw, ch), - }; - row.hi_url = hi_url; - row.hi_w = hw; - row.hi_h = hh; - if let Ok(img_ex) = image::load_from_memory(&bytes_ex) { - match crate::cover_logo::try_extract_wordmark(&img_ex, &loc_ex, target_edge) { - Ok(mark) => { - use base64::Engine as _; - use image::ImageEncoder as _; - row.mark_w = mark.image.width(); - row.mark_h = mark.image.height(); - row.extract_score = mark.score; - let mut png = Vec::new(); - if image::codecs::png::PngEncoder::new(&mut png) - .write_image( - mark.image.as_raw(), - row.mark_w, - row.mark_h, - image::ExtendedColorType::Rgba8, - ) - .is_ok() - { - row.mark_png = format!( - "data:image/png;base64,{}", - base64::engine::general_purpose::STANDARD.encode(&png) - ); - } - } - Err(reason) => row.reject_reason = reason.to_string(), - } - } - } - rows.push(row); - } - // Mark the winner using the SAME prominence-weighted score the real pipeline - // uses (extract score + 3.0 * box-width-fraction), so the diagnostic winner - // matches what the logo command would pick. - let final_score = |r: &CoverLogoDiag| r.extract_score + 3.0 * (r.box_pct_w / 100.0); - if let Some((idx, _)) = rows - .iter() - .enumerate() - .filter(|(_, r)| r.mark_h >= 74 && r.box_pct_w / 100.0 >= 0.05) - .max_by(|a, b| final_score(a.1).total_cmp(&final_score(b.1))) - { - rows[idx].winner = true; - } - Ok(rows) -} - -/// EXPERIMENT: for each cover, run Windows OCR across a matrix of input scales -/// and preprocessing (raw vs Otsu-binarize) and report every line read. Answers -/// "does downscaling / flattening help the built-in OCR read stylized wordmarks -/// (Paper Years, Age of the Ego)?" before committing to a heavier local model. -#[tauri::command] -pub async fn catalog_ocr_experiment( - state: State<'_, LibraryState>, - urls: Vec, -) -> Result, String> { - let mut imgs = Vec::new(); - for url in urls.into_iter().take(8) { - if let Some(bytes) = state.metadata.cover_bytes(&url).await { - imgs.push((url, bytes)); - } - } - let out = tokio::task::spawn_blocking(move || { - use image::GenericImageView as _; - let scales = [1000u32, 768, 640, 512]; - let mut results = Vec::new(); - for (url, bytes) in imgs { - let Ok(base) = image::load_from_memory(&bytes) else { - continue; - }; - let mut variants = serde_json::Map::new(); - for &s in &scales { - let scaled = base.resize(s, s, image::imageops::FilterType::Lanczos3); - for (tag, img) in [ - (format!("{s}px/raw"), scaled.clone()), - (format!("{s}px/bin"), crate::cover_logo::binarize(&scaled)), - ] { - let lines = crate::cover_logo::ocr_lines(&img) - .unwrap_or_default() - .into_iter() - .map(|l| l.text) - .collect::>(); - variants.insert(tag, serde_json::json!(lines)); - } - } - let (w, h) = base.dimensions(); - results.push( - serde_json::json!({ "url": url, "base": format!("{w}x{h}"), "ocr": variants }), - ); - } - results - }) - .await - .map_err(|e| e.to_string())?; - Ok(out) -} - -#[tauri::command] -pub async fn catalog_artist_hero_seed( - app: tauri::AppHandle, - state: State<'_, LibraryState>, - artist_id: u64, -) -> Result, String> { - // Local user art is the only truly instant high-confidence source. Check it - // before any remote identity/provider work; the complete art request still - // runs independently so automatic logos remain available. - if let Some(custom) = load_artist_hero_override(&app, artist_id)? { - return Ok(Some(CatalogArtistHeroSeed { - banner: custom.data_url, - banner_custom: true, - })); - } - if let Some(banner) = load_artist_art_manifest(&app, artist_id) - .await - .and_then(|art| art.banners.into_iter().next()) - { - return Ok(Some(CatalogArtistHeroSeed { - banner, - banner_custom: false, - })); - } - Ok(state - .metadata - .catalog_artist_hero_seed(artist_id) - .await - .map(|banner| CatalogArtistHeroSeed { - banner, - banner_custom: false, - })) -} - -#[tauri::command] -pub async fn catalog_artist_overview( - state: State<'_, LibraryState>, - artist_id: u64, -) -> Result { - state.touch_catalog_activity(); - state - .metadata - .catalog_artist_overview(artist_id) - .await - .ok_or_else(|| "artist overview lookup failed".to_string()) -} - -#[tauri::command] -pub async fn catalog_artist_popular( - state: State<'_, LibraryState>, - artist_id: u64, -) -> Result, String> { - state.touch_catalog_activity(); - state - .metadata - .catalog_artist_popular(artist_id) - .await - .ok_or_else(|| "artist popular lookup failed".to_string()) -} - -/// Genres and Last.fm totals for the artist a search is about — the search -/// hero's stat line and tag chips. Deferred like every other supplement: it -/// rides MusicBrainz's gate and must never hold up the results. -#[tauri::command] -pub async fn catalog_artist_profile( - state: State<'_, LibraryState>, - artist_id: u64, -) -> Result { - state.touch_catalog_activity(); - Ok(state.metadata.artist_profile(artist_id).await) -} - -/// One search-result row, reduced to what a listener lookup needs. -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TrackListenerQuery { - /// The PERFORMING artist's Deezer id. `None` for a row the provider gave no - /// artist id for, which simply gets no count — never a guess from the name. - pub artist_id: Option, - pub title: String, -} - -/// Last.fm listener counts for a set of search-result rows, answered in order. -/// -/// Deferred on purpose: `catalog_search` is typed against per keystroke and must -/// stay three Deezer calls, so the counts are asked for separately once the -/// results are on screen. See `MetadataClient::track_listeners`. -#[tauri::command] -pub async fn catalog_track_listeners( - state: State<'_, LibraryState>, - tracks: Vec, -) -> Result>, String> { - state.touch_catalog_activity(); - let rows: Vec<(Option, String)> = tracks - .into_iter() - .map(|t| (t.artist_id, t.title)) - .collect(); - Ok(state.metadata.track_listeners(&rows).await) -} - -#[tauri::command] -pub async fn catalog_related( - state: State<'_, LibraryState>, - artist_id: u64, -) -> Result, String> { - Ok(state.metadata.catalog_related(artist_id).await) -} - -/// Proxy a catalog cover image into a data URL (CDNs lack CORS headers). -#[tauri::command] -pub async fn catalog_art(state: State<'_, LibraryState>, url: String) -> Result { - if !url.starts_with("https://") { - return Err("not an https url".into()); - } - state - .metadata - .fetch_image_data_url(&url) - .await - .ok_or_else(|| "image fetch failed".to_string()) -} - -/// Upgrade a catalog cover to the highest-resolution image that is verifiably -/// the SAME artwork as `cover` (Apple / Deezer-max / — for MB cards — Cover Art -/// Archive). Returns a replacement URL, or `None` to keep `cover`. Does no -/// MusicBrainz lookup, so a whole page can resolve its covers in parallel. -#[tauri::command] -pub async fn catalog_cover( - app: tauri::AppHandle, - state: State<'_, LibraryState>, - artist_id: Option, - artist: String, - title: String, - cover: String, -) -> Result, String> { - if cover.is_empty() { - return Ok(None); - } - if let Some(best) = load_cover_resolution(&app, artist_id, &artist, &title, &cover).await { - return Ok(Some(best)); - } - let resolved = state - .metadata - .resolve_cover_for_artist_id(artist_id, &artist, &title, &cover) - .await; - if let Some(best_url) = resolved.as_ref() { - if let Ok(path) = cover_resolution_cache_path(&app, artist_id, &artist, &title, &cover) { - let best_url = best_url.clone(); - // Finish the tiny atomic JSON write before exposing the URL. If it - // were detached, a very fast browser failure could invalidate a - // not-yet-written file and the late writer would resurrect that - // proven-bad URL for the next 30 days. - let result = - tokio::task::spawn_blocking(move || store_cover_resolution(&path, &best_url)).await; - if let Ok(Err(error)) = result { - log::warn!("could not cache cover resolution: {error}"); - } - } - } - Ok(resolved) -} - -/// Forget one persisted positive resolution after the browser proves its URL -/// unusable. The exact artist/title/reference key prevents a flaky edition -/// from evicting any other album's warm result. -#[tauri::command] -pub async fn invalidate_catalog_cover( - app: tauri::AppHandle, - artist_id: Option, - artist: String, - title: String, - cover: String, -) -> Result<(), String> { - let path = cover_resolution_cache_path(&app, artist_id, &artist, &title, &cover)?; - match tokio::fs::remove_file(path).await { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.to_string()), - } -} - -struct HeroTempFiles(Vec); - -impl Drop for HeroTempFiles { - fn drop(&mut self) { - for path in &self.0 { - let _ = std::fs::remove_file(path); - } - } -} - -fn scaled_height(source_width: u32, source_height: u32, target_width: u32) -> u32 { - (u64::from(source_height) * u64::from(target_width) / u64::from(source_width)).max(1) as u32 -} - -fn fidelityfx_cli_path(app: &tauri::AppHandle) -> Result { - let bundled = app - .path() - .resolve( - "resources/FidelityFX_CLI.exe", - tauri::path::BaseDirectory::Resource, - ) - .map_err(|error| error.to_string())?; - if bundled.is_file() { - return Ok(bundled); - } - - // Tauri resolves bundled resources at runtime. This fallback keeps direct - // `cargo run` and Rust-side tests useful before the bundle is assembled. - let development = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("resources") - .join("FidelityFX_CLI.exe"); - if development.is_file() { - return Ok(development); - } - Err("bundled FidelityFX upscaler is unavailable".into()) -} - -fn run_fidelityfx(cli: &std::path::Path, args: &[String]) -> Result<(), String> { - let mut command = std::process::Command::new(cli); - command.args(args); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt as _; - command.creation_flags(0x08000000); - } - let output = command.output().map_err(|error| error.to_string())?; - if output.status.success() { - return Ok(()); - } - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - Err(format!( - "FidelityFX failed ({}): {}{}", - output.status, - stdout.trim(), - stderr.trim() - )) -} - -fn fsr_upscale_hero( - app: &tauri::AppHandle, - decoded: &image::DynamicImage, - source_width: u32, - source_height: u32, -) -> Result { - use std::sync::atomic::{AtomicU64, Ordering}; - static NEXT_JOB: AtomicU64 = AtomicU64::new(1); - - let cli = fidelityfx_cli_path(app)?; - let work_dir = app - .path() - .app_cache_dir() - .map_err(|error| error.to_string())? - .join("hero-processing"); - std::fs::create_dir_all(&work_dir).map_err(|error| error.to_string())?; - let id = NEXT_JOB.fetch_add(1, Ordering::Relaxed); - let prefix = format!("{}-{id}", std::process::id()); - let input = work_dir.join(format!("{prefix}-input.png")); - let intermediate = work_dir.join(format!("{prefix}-intermediate.png")); - let easu = work_dir.join(format!("{prefix}-easu.png")); - let rcas = work_dir.join(format!("{prefix}-rcas.png")); - let _cleanup = HeroTempFiles(vec![ - input.clone(), - intermediate.clone(), - easu.clone(), - rcas.clone(), - ]); - - decoded - .save_with_format(&input, image::ImageFormat::Png) - .map_err(|error| error.to_string())?; - - // FSR1's documented performance ratio is 2x per dimension. A 720p source - // therefore reaches 4K in two EASU passes (720p -> 1440p -> 2160p), then - // receives one final RCAS pass at presentation resolution. - let first_width = source_width - .saturating_mul(2) - .min(ARTIST_HERO_PROVIDER_TARGET_WIDTH); - let mut current = input.clone(); - if first_width < ARTIST_HERO_PROVIDER_TARGET_WIDTH { - let first_height = scaled_height(source_width, source_height, first_width); - run_fidelityfx( - &cli, - &[ - "-Mode".into(), - "EASU".into(), - "-Scale".into(), - first_width.to_string(), - first_height.to_string(), - current.to_string_lossy().into_owned(), - intermediate.to_string_lossy().into_owned(), - ], - )?; - current = intermediate.clone(); - } - - run_fidelityfx( - &cli, - &[ - "-Mode".into(), - "EASU".into(), - "-Scale".into(), - ARTIST_HERO_PROVIDER_TARGET_WIDTH.to_string(), - ARTIST_HERO_PROVIDER_TARGET_HEIGHT.to_string(), - current.to_string_lossy().into_owned(), - easu.to_string_lossy().into_owned(), - ], - )?; - run_fidelityfx( - &cli, - &[ - "-Mode".into(), - "RCAS".into(), - "-Sharpness".into(), - ARTIST_HERO_FSR_SHARPNESS_STOPS.into(), - easu.to_string_lossy().into_owned(), - rcas.to_string_lossy().into_owned(), - ], - )?; - image::open(rcas).map_err(|error| error.to_string()) -} - -fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 { - let t = ((value - edge0) / (edge1 - edge0)).clamp(0.0, 1.0); - t * t * (3.0 - 2.0 * t) -} - -fn mix(left: f32, right: f32, amount: f32) -> f32 { - left + (right - left) * amount -} - -fn ruby_chrome(color: [f32; 3]) -> [f32; 3] { - let luma = color[0] * 0.2126 + color[1] * 0.7152 + color[2] * 0.0722; - let shadow = 1.0 - smoothstep(0.16, 0.60, luma); - let highlight = smoothstep(0.38, 0.90, luma); - let mut graded = color.map(|channel| mix(luma, channel, 1.24)); - graded = graded.map(|channel| (channel - 0.5) * 1.12 + 0.5); - graded = graded.map(|channel| { - let curved = channel * channel * (3.0 - 2.0 * channel); - mix(channel, curved, 0.18) - }); - graded[0] += highlight * 0.070 + shadow * 0.018; - graded[1] -= highlight * 0.028; - graded[2] += shadow * 0.045 + highlight * 0.018; - graded.map(|channel| channel.clamp(0.0, 1.0)) -} - -/// Bake the approved look into the final 4K raster. The order is deliberate: -/// Ruby Chrome LUT -> vintage fade -> native-resolution monochrome grain. -fn apply_baked_hero_look(image: &mut image::RgbImage, source: &[u8]) { - for pixel in image.pixels_mut() { - let input = [ - f32::from(pixel[0]) / 255.0, - f32::from(pixel[1]) / 255.0, - f32::from(pixel[2]) / 255.0, - ]; - let graded = ruby_chrome(input); - let color = [ - mix(input[0], graded[0], ARTIST_HERO_LUT_STRENGTH), - mix(input[1], graded[1], ARTIST_HERO_LUT_STRENGTH), - mix(input[2], graded[2], ARTIST_HERO_LUT_STRENGTH), - ]; - let luma = color[0] * 0.2126 + color[1] * 0.7152 + color[2] * 0.0722; - let faded_color = [ - color[0] * 0.78 + 0.085, - color[1] * 0.78 + 0.078, - color[2] * 0.78 + 0.068, - ]; - let faded_luma = luma * 0.78 + 0.078; - for channel in 0..3 { - let faded = mix(faded_luma, faded_color[channel], 0.88).clamp(0.0, 1.0); - let output = mix(color[channel], faded, ARTIST_HERO_FADE_STRENGTH); - pixel[channel] = (output.clamp(0.0, 1.0) * 255.0).round() as u8; - } - } - add_baked_hero_grain(image, source); -} - -/// Deterministic triangular monochrome grain, generated one sample per final -/// 4K pixel and encoded with the image. It does not move, resize, or regenerate -/// with the window because it is part of the finished hero raster. -fn add_baked_hero_grain(image: &mut image::RgbImage, source: &[u8]) { - let stride = (source.len() / 4096).max(1); - let mut seed = 0xcbf29ce484222325_u64; - for byte in source.iter().step_by(stride) { - seed ^= u64::from(*byte); - seed = seed.wrapping_mul(0x100000001b3); - } - if seed == 0 { - seed = 0x9e3779b97f4a7c15; - } - - let next = |state: &mut u64| { - *state ^= *state << 13; - *state ^= *state >> 7; - *state ^= *state << 17; - *state - }; - let amplitude = (28.0 * ARTIST_HERO_GRAIN_STRENGTH).round() as i16; - for pixel in image.pixels_mut() { - let first = (next(&mut seed) & 0xff) as i16 - 128; - let second = (next(&mut seed) & 0xff) as i16 - 128; - let delta = ((first + second) * amplitude / 256).clamp(-amplitude, amplitude); - for channel in pixel.0.iter_mut() { - *channel = (i16::from(*channel) + delta).clamp(0, 255) as u8; - } - } -} - -/// Crop letterbox bars baked into an image's edges — YouTube channel "TV art" -/// pads its safe area this way. A bar row/column is either near-black or -/// "murk": dark AND featureless (low brightness range), which catches the -/// blurry dark gradients YouTube composites between the bar and the artwork — -/// those read as broken letterboxing on the hero, and the approved behaviour -/// is a confident crop, never letterboxing. Conservative guards remain: a bar -/// must be a contiguous ≥2% band, each side is capped at a third, and the -/// crop is rejected outright if it would remove half the image — so a -/// genuinely dark photo cannot be eaten. Returns `None` when nothing trims. -fn trim_letterbox_bars(image: &image::DynamicImage) -> Option { - let rgb = image.to_rgb8(); - let (w, h) = rgb.dimensions(); - if w < 64 || h < 64 { - return None; - } - let barlike = - |luma_max: u8, luma_min: u8| luma_max < 16 || (luma_max < 60 && luma_max - luma_min < 48); - let dark_row = |y: u32| { - let mut max = 0u8; - let mut min = 255u8; - for x in (0..w).step_by(4) { - let p = rgb.get_pixel(x, y).0; - let l = p[0].max(p[1]).max(p[2]); - max = max.max(l); - min = min.min(l); - } - barlike(max, min) - }; - let dark_col = |x: u32| { - let mut max = 0u8; - let mut min = 255u8; - for y in (0..h).step_by(4) { - let p = rgb.get_pixel(x, y).0; - let l = p[0].max(p[1]).max(p[2]); - max = max.max(l); - min = min.min(l); - } - barlike(max, min) - }; - let mut top = 0; - while top < h / 3 && dark_row(top) { - top += 1; - } - let mut bottom = 0; - while bottom < h / 3 && dark_row(h - 1 - bottom) { - bottom += 1; - } - let mut left = 0; - while left < w / 3 && dark_col(left) { - left += 1; - } - let mut right = 0; - while right < w / 3 && dark_col(w - 1 - right) { - right += 1; - } - let min_band_h = (h / 50).max(4); - let min_band_w = (w / 50).max(4); - if top < min_band_h { - top = 0; - } - if bottom < min_band_h { - bottom = 0; - } - if left < min_band_w { - left = 0; - } - if right < min_band_w { - right = 0; - } - if top + bottom == 0 && left + right == 0 { - return None; - } - let new_w = w - left - right; - let new_h = h - top - bottom; - if new_w < w / 2 || new_h < h / 2 { - return None; - } - Some(image.crop_imm(left, top, new_w, new_h)) -} - -fn hero_source_checksum(bytes: &[u8]) -> String { - // This is an integrity/version key, not a security boundary. Keeping it as - // hex avoids losing 64-bit precision while the plan round-trips through JS. - use std::hash::{Hash as _, Hasher as _}; - let mut checksum = std::collections::hash_map::DefaultHasher::new(); - bytes.hash(&mut checksum); - format!("{:016x}", checksum.finish()) -} - -fn valid_hero_crop_plan( - plan: &CatalogHeroCropPlan, - source_checksum: &str, - width: u32, - height: u32, -) -> bool { - if plan.recipe != ARTIST_HERO_CROP_RECIPE - || plan.source_checksum != source_checksum - || plan.geometry.source_width != width - || plan.geometry.source_height != height - { - return false; - } - let Some(maximal) = maximal_hero_rect(width, height) else { - return false; - }; - let crop = plan.geometry.crop; - crop.x == maximal.x - && crop.width == maximal.width - && crop.height == maximal.height - && crop.width > 0 - && crop.height > 0 - && u64::from(crop.x) + u64::from(crop.width) <= u64::from(width) - && u64::from(crop.y) + u64::from(crop.height) <= u64::from(height) -} - -/// Decode, conservatively remove provider letterboxing, choose one immutable -/// source-pixel crop, and apply it before any preview resize or 4K work. -fn prepare_provider_hero( - app: &tauri::AppHandle, - bytes: &[u8], - trim_letterbox: bool, - supplied_plan: Option, -) -> Result<(image::DynamicImage, CatalogHeroCropPlan), String> { - use image::GenericImageView as _; - - if bytes.len() as u64 > ARTIST_HERO_MAX_BYTES { - return Err("provider hero exceeds the 30 MB limit".into()); - } - let mut decoded = image::load_from_memory(bytes).map_err(|error| error.to_string())?; - if trim_letterbox { - if let Some(trimmed) = trim_letterbox_bars(&decoded) { - decoded = trimmed; - } - } - let (width, height) = decoded.dimensions(); - let source_checksum = hero_source_checksum(bytes); - let plan = match supplied_plan - .filter(|plan| valid_hero_crop_plan(plan, &source_checksum, width, height)) - { - Some(plan) => plan, - None => { - let maximal = maximal_hero_rect(width, height) - .ok_or_else(|| "provider hero is too small to crop".to_string())?; - let (subjects, faces) = if maximal.height < height { - match detect_subjects(app, &decoded) { - Ok(detections) => detections, - Err(error) => { - log::warn!("content-aware artist hero crop unavailable: {error}"); - (Vec::new(), Vec::new()) - } - } - } else { - // Exact/wide sources have no vertical freedom, so inference - // could not affect the result and is deliberately skipped. - (Vec::new(), Vec::new()) - }; - let geometry = plan_hero_crop(width, height, &subjects, &faces) - .ok_or_else(|| "provider hero crop could not be planned".to_string())?; - log::info!( - "artist hero crop {}x{} -> x{} y{} {}x{} ({} people, {} faces, aware={})", - width, - height, - geometry.crop.x, - geometry.crop.y, - geometry.crop.width, - geometry.crop.height, - geometry.subject_count, - geometry.associated_face_count, - geometry.content_aware, - ); - CatalogHeroCropPlan { - recipe: ARTIST_HERO_CROP_RECIPE.to_string(), - source_checksum, - geometry, - } - } - }; - let crop = plan.geometry.crop; - Ok(( - decoded.crop_imm(crop.x, crop.y, crop.width, crop.height), - plan, - )) -} - -fn provider_hero_preview_data_url( - app: &tauri::AppHandle, - bytes: &[u8], - trim_letterbox: bool, -) -> Result { - use base64::Engine as _; - use image::GenericImageView as _; - - let (cropped, plan) = prepare_provider_hero(app, bytes, trim_letterbox, None)?; - let (width, _) = cropped.dimensions(); - // Preview never upscales. Rounding down to a 16-pixel width makes its - // encoded dimensions exactly 16:9 while losing at most 15 source pixels. - let preview_width = width.min(ARTIST_HERO_PREVIEW_MAX_WIDTH).max(16) / 16 * 16; - let preview_height = preview_width / 16 * 9; - let preview = cropped.resize_exact( - preview_width, - preview_height, - image::imageops::FilterType::CatmullRom, - ); - let rgb = preview.to_rgb8(); - let mut output = Vec::new(); - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 90) - .encode( - &rgb, - preview_width, - preview_height, - image::ExtendedColorType::Rgb8, - ) - .map_err(|error| error.to_string())?; - Ok(CatalogHeroPreviewResult { - data_url: format!( - "data:image/jpeg;base64,{}", - base64::engine::general_purpose::STANDARD.encode(output) - ), - plan, - }) -} - -/// Lossless, pre-cropped source pixels for the live GPU path. Cropping is -/// completed here, before the WebView sees the raster, so EASU cannot sample -/// discarded pixels around a crop edge. The source-bound grain seed keeps the -/// 4K grain field stable across visits and window sizes. -fn provider_hero_gpu_payload( - app: &tauri::AppHandle, - bytes: &[u8], - trim_letterbox: bool, - supplied_plan: Option, -) -> Result, String> { - use image::GenericImageView as _; - - let (cropped, plan) = prepare_provider_hero(app, bytes, trim_letterbox, supplied_plan)?; - let (width, height) = cropped.dimensions(); - let rgba = cropped.to_rgba8().into_raw(); - let expected = u64::from(width) - .checked_mul(u64::from(height)) - .and_then(|pixels| pixels.checked_mul(4)) - .ok_or_else(|| "provider hero dimensions overflow".to_string())?; - if rgba.len() as u64 != expected { - return Err("provider hero RGBA payload has an invalid length".into()); - } - - // WebGL floats represent every integer exactly only through 24 bits. - let grain_seed = u64::from_str_radix(&plan.source_checksum, 16) - .unwrap_or(1) - .wrapping_mul(0x9e37_79b9) - .rotate_left(17) as u32 - & 0x00ff_ffff; - let mut payload = Vec::with_capacity(CATALOG_HERO_GPU_HEADER_BYTES + rgba.len()); - payload.extend_from_slice(CATALOG_HERO_GPU_MAGIC); - payload.extend_from_slice(&width.to_le_bytes()); - payload.extend_from_slice(&height.to_le_bytes()); - payload.extend_from_slice(&grain_seed.max(1).to_le_bytes()); - payload.extend_from_slice(&rgba); - Ok(payload) -} - -fn provider_hero_data_url( - app: &tauri::AppHandle, - bytes: &[u8], - upscaler: config::ArtistHeroUpscaler, - trim_letterbox: bool, - supplied_plan: Option, -) -> Result<(String, bool), String> { - use base64::Engine as _; - use image::GenericImageView as _; - - let (decoded, _) = prepare_provider_hero(app, bytes, trim_letterbox, supplied_plan)?; - let (width, height) = decoded.dimensions(); - let (finished, cacheable) = if width == ARTIST_HERO_PROVIDER_TARGET_WIDTH - && height == ARTIST_HERO_PROVIDER_TARGET_HEIGHT - { - (decoded, true) - } else if width > ARTIST_HERO_PROVIDER_TARGET_WIDTH { - // More-than-4K sources need a high-quality downsample, not an upscale. - ( - decoded.resize_exact( - ARTIST_HERO_PROVIDER_TARGET_WIDTH, - ARTIST_HERO_PROVIDER_TARGET_HEIGHT, - image::imageops::FilterType::Lanczos3, - ), - true, - ) - } else { - match upscaler { - config::ArtistHeroUpscaler::Fsr => { - match fsr_upscale_hero(app, &decoded, width, height) { - Ok(image) => ( - if image.dimensions() - == ( - ARTIST_HERO_PROVIDER_TARGET_WIDTH, - ARTIST_HERO_PROVIDER_TARGET_HEIGHT, - ) - { - image - } else { - image.resize_exact( - ARTIST_HERO_PROVIDER_TARGET_WIDTH, - ARTIST_HERO_PROVIDER_TARGET_HEIGHT, - image::imageops::FilterType::Lanczos3, - ) - }, - true, - ), - Err(error) => { - log::warn!("FSR hero upscale failed, using Lanczos3: {error}"); - ( - decoded - .resize_exact( - ARTIST_HERO_PROVIDER_TARGET_WIDTH, - ARTIST_HERO_PROVIDER_TARGET_HEIGHT, - image::imageops::FilterType::Lanczos3, - ) - .unsharpen(0.7, 2), - // Display the resilient fallback, but do not preserve it - // under the FSR key. A later visit should retry the - // quality mode the user actually selected. - false, - ) - } - } - } - config::ArtistHeroUpscaler::LiveGpu | config::ArtistHeroUpscaler::Lanczos3 => ( - decoded - .resize_exact( - ARTIST_HERO_PROVIDER_TARGET_WIDTH, - ARTIST_HERO_PROVIDER_TARGET_HEIGHT, - image::imageops::FilterType::Lanczos3, - ) - .unsharpen(0.7, 2), - true, - ), - } - }; - let mut rgb = finished.to_rgb8(); - apply_baked_hero_look(&mut rgb, bytes); - let mut output = Vec::new(); - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 93) - .encode( - &rgb, - ARTIST_HERO_PROVIDER_TARGET_WIDTH, - ARTIST_HERO_PROVIDER_TARGET_HEIGHT, - image::ExtendedColorType::Rgb8, - ) - .map_err(|error| error.to_string())?; - Ok(( - format!( - "data:image/jpeg;base64,{}", - base64::engine::general_purpose::STANDARD.encode(output) - ), - cacheable, - )) -} - -/// Hero-only proxy. Unlike `catalog_art`, this may upscale a 720p provider -/// image. Keeping the commands separate protects album covers and logo PNGs -/// from lossy conversion. -#[tauri::command] -pub async fn catalog_hero_preview( - app: tauri::AppHandle, - state: State<'_, LibraryState>, - url: String, -) -> Result { - if !url.starts_with("https://") { - return Err("not an https url".into()); - } - let bytes = state - .metadata - .hero_bytes(&url) - .await - .ok_or_else(|| "hero image fetch failed".to_string())?; - let trim_letterbox = url.starts_with("https://yt3.googleusercontent.com/") - || url.starts_with("https://yt3.ggpht.com/"); - let worker_app = app.clone(); - tokio::task::spawn_blocking(move || { - provider_hero_preview_data_url(&worker_app, &bytes, trim_letterbox) - }) - .await - .map_err(|error| error.to_string())? -} - -/// Crop-first, lossless input for the fixed-4K GPU renderer. Raw binary IPC -/// returns an ArrayBuffer rather than a JSON number array or base64 string. -#[tauri::command] -pub async fn catalog_hero_gpu_source( - app: tauri::AppHandle, - state: State<'_, LibraryState>, - url: String, - plan: Option, -) -> Result { - if !url.starts_with("https://") { - return Err("not an https url".into()); - } - if config::load(&app).artist_hero_upscaler != config::ArtistHeroUpscaler::LiveGpu { - return Err("live GPU hero rendering is disabled".into()); - } - let bytes = state - .metadata - .hero_bytes(&url) - .await - .ok_or_else(|| "hero image fetch failed".to_string())?; - let trim_letterbox = url.starts_with("https://yt3.googleusercontent.com/") - || url.starts_with("https://yt3.ggpht.com/"); - // Autocomplete warming and rapid artist navigation can leave work for - // different sources in flight. Bound the decode/crop/detector phase so - // those stale jobs cannot multiply peak CPU and decoded-raster memory. - let prepare_slot = artist_hero_gpu_prepare_slots() - .acquire() - .await - .map_err(|_| "live GPU hero preparation is unavailable".to_string())?; - let worker_app = app.clone(); - let payload = tokio::task::spawn_blocking(move || { - // Keep the permit inside the blocking job. If rapid navigation drops - // the command future, Tokio detaches an already-running blocking task; - // a permit owned only by the async frame would be released early and - // stale decode/YOLO jobs could again exceed the global bound. - let _prepare_slot = prepare_slot; - provider_hero_gpu_payload(&worker_app, &bytes, trim_letterbox, plan) - }) - .await - .map_err(|error| error.to_string())??; - Ok(tauri::ipc::Response::new(payload)) -} - -#[tauri::command] -pub async fn catalog_cached_hero_art( - app: tauri::AppHandle, - url: String, -) -> Result, String> { - if !url.starts_with("https://") { - return Err("not an https url".into()); - } - let upscaler = config::load(&app).artist_hero_upscaler; - if upscaler == config::ArtistHeroUpscaler::LiveGpu { - return Ok(None); - } - let cache_path = finished_hero_cache_path(&app, &url, upscaler)?; - Ok(load_finished_hero(&cache_path).await) -} - -#[tauri::command] -pub async fn catalog_hero_art( - app: tauri::AppHandle, - state: State<'_, LibraryState>, - url: String, - plan: Option, -) -> Result { - if !url.starts_with("https://") { - return Err("not an https url".into()); - } - let upscaler = config::load(&app).artist_hero_upscaler; - if upscaler == config::ArtistHeroUpscaler::LiveGpu { - return Err("live GPU heroes are not baked".into()); - } - let cache_path = finished_hero_cache_path(&app, &url, upscaler)?; - if let Some(cached) = load_finished_hero(&cache_path).await { - return Ok(CatalogHeroArtResult { - data_url: cached, - cacheable: true, - }); - } - let bytes = state - .metadata - .hero_bytes(&url) - .await - .ok_or_else(|| "hero image fetch failed".to_string())?; - // Only YouTube channel banners are known to bake letterbox bars into the - // raster; the trim never runs on curated art-database sources. - let trim_letterbox = url.starts_with("https://yt3.googleusercontent.com/") - || url.starts_with("https://yt3.ggpht.com/"); - let worker_app = app.clone(); - tokio::task::spawn_blocking(move || { - let (data_url, cacheable) = - provider_hero_data_url(&worker_app, &bytes, upscaler, trim_letterbox, plan)?; - if cacheable { - if let Err(error) = store_finished_hero(&cache_path, &data_url) { - log::warn!("could not cache finished artist hero: {error}"); - } - } - Ok(CatalogHeroArtResult { - data_url, - cacheable, - }) - }) - .await - .map_err(|error| error.to_string())? -} - -#[tauri::command] -pub async fn catalog_album( - state: State<'_, LibraryState>, - album_id: u64, -) -> Result { - state.touch_catalog_activity(); - state - .metadata - .catalog_album(album_id) - .await - .ok_or_else(|| "album lookup failed".to_string()) -} - -/// Editorial context (description/genre/label) for one release — TheAudioDB -/// first, Wikipedia summary fallback. Always succeeds; empty fields mean -/// neither source knew the record. -#[tauri::command] -pub async fn catalog_album_info( - state: State<'_, LibraryState>, - artist_id: Option, - artist: String, - title: String, -) -> Result { - state.touch_catalog_activity(); - Ok(state - .metadata - .album_info_for_artist_id(artist_id, &artist, &title) - .await) -} - -#[tauri::command] -pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> { - if let Some(client) = state.client.lock().await.take() { - client.shutdown(); - } - *state.connected_as.lock().await = None; - Ok(()) -} - -#[tauri::command] -pub async fn start_search(state: State<'_, AppState>, query: String) -> Result { - let guard = state.client.lock().await; - let client = guard.as_ref().ok_or("not connected")?; - client.search(&query).map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn stop_search(state: State<'_, AppState>, token: u32) -> Result<(), String> { - if let Some(client) = state.client.lock().await.as_ref() { - client.stop_search(token); - } - Ok(()) -} - -#[tauri::command] -pub async fn download_file( - state: State<'_, AppState>, - username: String, - path: String, - size: u64, -) -> Result { - let guard = state.client.lock().await; - let client = guard.as_ref().ok_or("not connected")?; - Ok(client.download(&username, &path, size)) -} - -/// A streamed file's peer identity, used to locate its cached copy. -#[derive(serde::Deserialize)] -pub struct StreamRef { - pub username: String, - pub path: String, -} - -/// A streamed file plus its size, for saving into the library. -#[derive(serde::Deserialize)] -pub struct SaveRef { - pub username: String, - pub path: String, - pub size: u64, -} - -fn sanitize_seg(part: &str) -> String { - part.chars() - .map(|c| if "<>:\"/\\|?*".contains(c) { '_' } else { c }) - .collect::() - .trim() - .to_string() -} - -/// Save the file(s) you're streaming into the library. Cached tracks are -/// organized straight from the stream cache — no re-download, and crucially no -/// second transfer of a file the peer is already streaming to us (which -/// deadlocks both). Only tracks that aren't cached yet are downloaded. -/// Returns (organized_from_cache, queued_for_download). -#[tauri::command] -pub async fn save_to_library( - app: tauri::AppHandle, - state: State<'_, AppState>, - files: Vec, -) -> Result<(u32, u32), String> { - let downloads_dir = config::download_dir(&app, &config::load(&app)); - let mut organized = 0u32; - let mut queued = 0u32; - for f in files { - // Only a BYTE-COMPLETE cache saves directly: a mid-stream (or - // abandoned) cache file is a prefix of the track, and the organizer - // would file the truncation as the real thing. Anything shorter - // falls through — to the in-flight transfer if one is running, else - // to a fresh download of the known file. - let cached = crate::playback::cache_path(&app, &f.username, &f.path) - .filter(|c| c.exists()) - .filter(|c| { - f.size == 0 || c.metadata().map(|m| m.len() == f.size).unwrap_or(false) - }); - if let Some(cache) = cached { - if organize_cached_save(&app, &downloads_dir, &cache, &f.path).await? { - organized += 1; - continue; - } - } - // The file may already be arriving as the playing stream or an - // album-continuation preload. Subscribe to that transfer instead of - // opening a duplicate peer request that can remain queued forever. - if let Some(players) = app.try_state::() { - let cache = crate::playback::cache_path(&app, &f.username, &f.path); - let transfer = cache.as_ref().and_then(|cache| { - players - .preloads - .lock() - .unwrap() - .iter() - .find(|(_, target)| *target == cache) - .map(|(id, _)| *id) - .or_else(|| { - players - .stream_caches - .lock() - .unwrap() - .iter() - .find(|(_, target)| *target == cache) - .map(|(id, _)| *id) - }) - }); - if let (Some(id), Some(cache)) = (transfer, cache) { - players.deferred_saves.lock().unwrap().insert(id, cache); - queued += 1; - continue; - } - } - // Not cached — fetch the known file (still no re-search). - let guard = state.client.lock().await; - if let Some(client) = guard.as_ref() { - client.download(&f.username, &f.path, f.size); - queued += 1; - } - } - let _ = app.emit("soulseek:library-changed", ()); - Ok((organized, queued)) -} - -/// Byte length of the stream-cache copy of one peer file, if it exists. -/// The caller compares against the peer-stated size to tell a finished -/// cache from a mid-stream prefix. Read-only — never contacts the peer. -#[tauri::command] -pub fn stream_cache_len(app: tauri::AppHandle, username: String, path: String) -> Option { - crate::playback::cache_path(&app, &username, &path) - .filter(|c| c.exists()) - .and_then(|c| c.metadata().ok()) - .map(|m| m.len()) -} - -/// Copy a completed stream cache into staging, then run the normal tagging and -/// library organizer. Returns false only when the copy itself did not happen. -async fn organize_cached_save( - app: &tauri::AppHandle, - downloads_dir: &std::path::Path, - cache: &std::path::Path, - remote_path: &str, -) -> Result { - let (folder, name) = match remote_path.rsplit_once('\\') { - Some((dir, n)) => (dir.rsplit('\\').next().unwrap_or(""), n), - None => ("", remote_path), - }; - let mut dir = downloads_dir.join("stream-save"); - if !folder.is_empty() { - dir.push(sanitize_seg(folder)); - } - std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - let dest = dir.join(sanitize_seg(name)); - if std::fs::copy(cache, &dest).is_err() { - return Ok(false); - } - crate::pipeline::process_file(app, &dest, Some(remote_path)).await; - Ok(true) -} - -/// Copy the files being streamed (already in the stream cache) into a chosen -/// folder — no re-download, no re-search. Skips any track not yet cached. -/// Returns how many were saved and how many weren't cached. -#[tauri::command] -pub async fn copy_stream_to_folder( - app: tauri::AppHandle, - files: Vec, - folder: String, -) -> Result<(u32, u32), String> { - let dest = std::path::PathBuf::from(&folder); - std::fs::create_dir_all(&dest).map_err(|e| e.to_string())?; - let mut saved = 0u32; - let mut missing = 0u32; - for f in files { - let Some(cache) = crate::playback::cache_path(&app, &f.username, &f.path) else { - missing += 1; - continue; - }; - if !cache.exists() { - missing += 1; - continue; - } - let name = f.path.rsplit(['\\', '/']).next().unwrap_or("track"); - match std::fs::copy(&cache, dest.join(name)) { - Ok(_) => saved += 1, - Err(_) => missing += 1, - } - } - Ok((saved, missing)) -} - -#[tauri::command] -pub async fn cancel_download(state: State<'_, AppState>, id: u64) -> Result<(), String> { - if let Some(client) = state.client.lock().await.as_ref() { - client.cancel_download(id); - } - Ok(()) -} - -/// Reveal a completed download in Explorer. -#[tauri::command] -pub fn show_in_folder(path: String) { - let _ = std::process::Command::new("explorer") - .args(["/select,", &path]) - .spawn(); -} - -#[tauri::command] -pub async fn refresh_artist_art(app: tauri::AppHandle) -> Result { - crate::pipeline::refresh_all_artist_art(&app).await -} - -/// Stream a remote file: download into the cache and start playback the -/// moment bytes arrive. -#[tauri::command] -pub async fn play_stream( - app: tauri::AppHandle, - state: State<'_, AppState>, - players: State<'_, PlayerState>, - username: String, - path: String, - size: u64, - duration: Option, -) -> Result<(), String> { - let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?; - - // Fully cached from an earlier stream → play directly. Touch it so - // retention counts from this play, not the original stream. - if let Ok(md) = std::fs::metadata(&cache) { - if size > 0 && md.len() == size { - playback::touch(&cache); - crate::diagnostics::clear_stream(&app); - // This track never becomes a transfer: there is no StreamProgress - // and no download task, so the update loop below that emits - // `soulseek:stream` never runs for it. Say it once, or the UI keeps - // the provisional counts it set when it asked to play and shows a - // download pinned at 0% — or, coming out of a gapless advance, an - // indeterminate "Streaming from " — on a file that is already - // complete. `done` also lets the frontend take its completed-track - // path (final waveform, pre-buffer the next). - let _ = app.emit( - "soulseek:stream", - serde_json::json!({ - "received": size, - "total": size, - "done": true, - "failed": Option::::None, - }), - ); - let app2 = app.clone(); - let cache2 = cache.clone(); - crate::loudness::on_track_started( - &app, - &cache, - size, - None, - Box::new(move || { - app2.state::() - .player - .play_local(&cache2, duration); - }), - ); - return Ok(()); - } - let _ = std::fs::remove_file(&cache); - } - let partial = playback::partial_path(&cache); - - // A preload may already be fetching this exact file: attach to it and - // keep the bytes it has instead of restarting the transfer. - let preload_id = players - .preloads - .lock() - .unwrap() - .iter() - .find(|(_, c)| **c == cache) - .map(|(id, _)| *id); - if let Some(id) = preload_id { - players.preloads.lock().unwrap().remove(&id); - // From here the transfer is the CURRENT track's stream; the overlay's - // DL line (note_stream below) reports it, not the on-deck list. - crate::diagnostics::retire_preload(&app, id); - let progress = player::StreamProgress::new(); - if let Ok(md) = std::fs::metadata(&partial) { - progress.update(md.len(), false, None); - } - players.streams.lock().unwrap().insert(id, progress.clone()); - players - .stream_caches - .lock() - .unwrap() - .insert(id, cache.clone()); - crate::diagnostics::note_stream(&app, size, progress.clone()); - let app2 = app.clone(); - let partial2 = partial.clone(); - let progress2 = progress.clone(); - crate::loudness::on_track_started( - &app, - &cache, - size, - Some((partial, progress)), - Box::new(move || { - app2.state::() - .player - .play_stream(&partial2, progress2, size, duration); - }), - ); - return Ok(()); - } - - let _ = std::fs::remove_file(&partial); - - let progress = player::StreamProgress::new(); - let id = { - let guard = state.client.lock().await; - let client = guard.as_ref().ok_or("not connected")?; - client.download_to(&username, &path, size, cache.clone()) - }; - players.streams.lock().unwrap().insert(id, progress.clone()); - players - .stream_caches - .lock() - .unwrap() - .insert(id, cache.clone()); - - crate::diagnostics::note_stream(&app, size, progress.clone()); - let app2 = app.clone(); - let partial2 = partial.clone(); - let progress2 = progress.clone(); - crate::loudness::on_track_started( - &app, - &cache, - size, - Some((partial, progress)), - Box::new(move || { - app2.state::() - .player - .play_stream(&partial2, progress2, size, duration); - }), - ); - Ok(()) -} - -/// Quietly download the next queue track into the stream cache so playback -/// can move to it without a buffering gap. No player, UI, or library -/// side-effects; play_stream attaches to it when the track comes up. -#[tauri::command] -pub async fn preload_stream( - app: tauri::AppHandle, - state: State<'_, AppState>, - players: State<'_, PlayerState>, - username: String, - path: String, - size: u64, -) -> Result<(), String> { - let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?; - if let Ok(md) = std::fs::metadata(&cache) { - if size > 0 && md.len() == size { - // Gapless normalization: boundary gain ready before the switch. - crate::loudness::on_preload_ready(&app, &cache); - return Ok(()); // already fully cached - } - let _ = std::fs::remove_file(&cache); - } - if players - .preloads - .lock() - .unwrap() - .values() - .any(|c| *c == cache) - { - return Ok(()); // already preloading - } - let _ = std::fs::remove_file(&playback::partial_path(&cache)); - - let id = { - let guard = state.client.lock().await; - let client = guard.as_ref().ok_or("not connected")?; - client.download_to(&username, &path, size, cache.clone()) - }; - players.preloads.lock().unwrap().insert(id, cache); - Ok(()) -} - -/// Append a fully cached stream track behind the active source so the -/// transition happens inside the same audio sink (true gapless). Returns -/// false — and does nothing — while the cache file is still incomplete. -#[tauri::command] -pub fn preload_cached_stream( - app: tauri::AppHandle, - players: State<'_, PlayerState>, - username: String, - path: String, - size: u64, - duration: Option, -) -> Result { - let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?; - match std::fs::metadata(&cache) { - Ok(md) if size == 0 || md.len() == size => { - playback::touch(&cache); - players.player.preload_local(&cache, duration); - // Gapless normalization: boundary gain ready before the switch. - crate::loudness::on_preload_ready(&app, &cache); - Ok(true) - } - _ => Ok(false), - } -} - -/// Waveform peaks for a stream-cached track. While the download is still in -/// flight, the partial file is decoded and its peaks scaled into the leading -/// `received/size` share of the buckets (rest zero), so the waveform appears -/// immediately and grows with the transfer. Complete files are memoized. -#[tauri::command] -pub async fn stream_waveform( - app: tauri::AppHandle, - players: State<'_, PlayerState>, - username: String, - path: String, - size: u64, - buckets: usize, -) -> Result { - const MAX_WAVEFORM_BUCKETS: usize = 4096; - if buckets > MAX_WAVEFORM_BUCKETS { - return Err(format!( - "waveform bucket count exceeds {MAX_WAVEFORM_BUCKETS}" - )); - } - if buckets == 0 { - return Ok(WaveformSlice::empty()); - } - let Some(cache) = playback::cache_path(&app, &username, &path) else { - return Ok(WaveformSlice::empty()); - }; - let key = format!("{}#{buckets}", cache.display()); - let job = players.waveform_job(&key); - let result = async { - // Re-check the filesystem only after acquiring the per-key lock: a - // partial decoder ahead of us may have run while the transfer renamed - // the completed file into place. - let job_guard = job.clone().lock_owned().await; - if matches!(std::fs::metadata(&cache), Ok(md) if size == 0 || md.len() == size) { - players.remove_progressive_waveform(&key); - if let Some(cached) = players.cached_waveform(&key) { - return Ok(WaveformSlice::complete(cached)); - } - - let p = cache.clone(); - let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || { - let peaks = player::compute_waveform(&p, buckets); - (job_guard, peaks) - }) - .await - .map_err(|e| e.to_string())?; - if !peaks.is_empty() { - players.cache_waveform(key.clone(), peaks.clone()); - } - drop(job_guard); - return Ok(WaveformSlice::complete(peaks)); - } - - // Seeing a partial file invalidates any completed waveform retained - // under the same stable cache path from an earlier transfer. - players.remove_cached_waveform(&key); - let partial = playback::partial_path(&cache); - let have = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0); - if have == 0 || size == 0 { - return Ok(WaveformSlice::empty()); - } - if let Some(previous) = players.progressive_waveform(&key) { - if !previous.should_recompute(have) { - return Ok(WaveformSlice { - coverage: (previous.decoded_bytes() as f64 / size as f64).min(1.0) as f32, - peaks: previous.peaks(), - }); - } - } - - // Byte share ≈ time share (close enough for a scrubber even on VBR). - // The cached threshold doubles after every useful decode, bounding all - // partial-file work geometrically instead of decoding from zero every - // three seconds. - let frac = (have as f64 / size as f64).min(1.0); - let n = ((buckets as f64 * frac).round() as usize).clamp(1, buckets); - let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || { - let peaks = player::compute_waveform(&partial, n); - (job_guard, peaks) - }) - .await - .map_err(|e| e.to_string())?; - if !peaks.is_empty() { - players.cache_progressive_waveform( - key.clone(), - playback::ProgressiveWaveform::new(have, size, peaks.clone()), - ); - } - drop(job_guard); - // The peaks are ONLY the decoded audio — no zero padding to the full - // bucket count any more. The old padding made the array's length lie - // about its contents: the UI folded real-peaks-plus-zero-tail across a - // frontier computed from a FRESHER byte count, so the drawn shape - // stretched between refreshes and the loudness scale ate the zeros. - // Coverage says exactly how much of the track these peaks describe; - // where the standing bars end IS how much of the track has loaded. - Ok(WaveformSlice { - coverage: frac as f32, - peaks, - }) - } - .await; - players.release_waveform_job(&key, &job); - result -} - -/// Is this exact stream source already fully cached on disk? -/// -/// The catalog's play paths ask BEFORE running a Soulseek resolve: a complete -/// cache file can play immediately — even offline — and the search would only -/// rediscover the peer that filled it. Anything less than a byte-complete -/// file answers false, so eviction and half-finished transfers fall back to -/// the normal resolver on their own. -#[tauri::command] -pub fn stream_cache_ready( - app: tauri::AppHandle, - username: String, - path: String, - size: u64, -) -> bool { - let Some(cache) = playback::cache_path(&app, &username, &path) else { - return false; - }; - size > 0 && matches!(std::fs::metadata(&cache), Ok(md) if md.len() == size) -} - -/// A waveform plus how much of the track it actually describes. `coverage` -/// is the byte share of the transfer the peaks were decoded from (1.0 for a -/// complete file); the peaks span exactly that prefix of the track and -/// nothing more. -#[derive(Clone, serde::Serialize)] -pub struct WaveformSlice { - coverage: f32, - peaks: Vec, -} - -impl WaveformSlice { - fn empty() -> Self { - WaveformSlice { - coverage: 0.0, - peaks: Vec::new(), - } - } - - fn complete(peaks: Vec) -> Self { - WaveformSlice { - coverage: 1.0, - peaks, - } - } -} - -/// Play an already-downloaded local file. -#[tauri::command] -pub fn play_local( - app: tauri::AppHandle, - path: String, - duration: Option, - start_secs: Option, -) -> Result<(), String> { - if std::fs::metadata(&path).is_err() { - return Err("file not found".into()); - } - crate::diagnostics::clear_stream(&app); - let app2 = app.clone(); - let path2 = path.clone(); - crate::loudness::on_track_started( - &app, - std::path::Path::new(&path), - 0, - None, - Box::new(move || { - // `start_secs` = play a SLICE of the file from there (the - // setlist segment rows); plain tracks pass None. - app2.state::() - .player - .play_local_from(&path2, duration, start_secs); - }), - ); - Ok(()) -} - -/// Decode a local track now and append it behind the active source. This is -/// used for gapless album playback; it has no effect on streaming downloads. -#[tauri::command] -pub fn preload_local( - app: tauri::AppHandle, - players: State<'_, PlayerState>, - path: String, - duration: Option, -) -> Result<(), String> { - if std::fs::metadata(&path).is_err() { - return Err("file not found".into()); - } - players.player.preload_local(&path, duration); - // Gapless normalization: have the boundary gain ready before the switch. - crate::loudness::on_preload_ready(&app, std::path::Path::new(&path)); - Ok(()) -} - -#[tauri::command] -pub async fn library_albums(lib: State<'_, LibraryState>) -> Result, String> { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || l.list_albums().map_err(|e| e.to_string())) - .await - .map_err(|e| e.to_string())? -} - -/// Per-album aggregates (length, bytes, bitrate, formats, last played) for the -/// Library page — one call for the whole library. `min_secs` is the play floor: -/// the frontend passes the same RECENTS_FLOOR_SECS the recents list uses. -#[tauri::command] -pub async fn library_album_stats( - lib: State<'_, LibraryState>, - min_secs: u32, -) -> Result, String> { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || { - l.album_stats(min_secs).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn album_tracks( - lib: State<'_, LibraryState>, - album_id: i64, -) -> Result, String> { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || { - l.album_tracks(album_id).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -/// Force-refetch an album's cover art and re-embed it into its tracks. -#[tauri::command] -pub async fn refresh_album_art(app: tauri::AppHandle, album_id: i64) -> Result { - pipeline::refresh_album_art(&app, album_id).await -} - -/// Upgrade every library album to the canonical high-resolution cover -/// (refetch + re-embed + folder covers). Returns how many succeeded. -#[tauri::command] -pub async fn refresh_all_album_art(app: tauri::AppHandle) -> Result { - pipeline::refresh_all_album_art(&app).await -} - -/// Album art as a data URL (small files; avoids asset-protocol scopes). -/// -/// With `max_edge` the art comes back as a Lanczos3 thumbnail bounded to that -/// many pixels on its long side — what every tile wants. Masters here run to -/// 2000px+, and handing one of those to a 132px card makes the webview do a -/// 15x single-step reduction; `thumbnail_jpeg` explains why that aliases. It -/// also cuts the base64 a tile carries from megabytes to tens of kilobytes. -/// -/// Callers that must show TRUE pixels — the fullscreen viewer, the import -/// picker's A/B and its loupe — pass no `max_edge` and get the file untouched. -/// A source already within bounds is passed through rather than re-encoded, so -/// this never costs quality it was meant to protect. -#[tauri::command] -pub async fn album_art( - app: tauri::AppHandle, - path: String, - max_edge: Option, -) -> Result { - // Thumbnails used to cost a FULL master decode + Lanczos resize per - // request, on the unbounded blocking pool, with nothing remembered — - // so a Library page scrolling past a few hundred tiles pinned every - // core in image decodes and the AUDIO thread started to starve (the - // player decodes on its own std::thread, which only chops when the - // whole machine is saturated). Two changes, and either alone would - // have hidden the other's need: - // 1. a decode SEMAPHORE — at most 2 covers decode at once, ever; - // 2. a thumbnail disk cache keyed by (path, mtime, len, edge) — each - // cover is decoded once per size PER LIFETIME, then it's a small - // file read. Covers change by being replaced (cover_pick writes a - // new file), so the mtime+len key self-invalidates; orphaned - // entries are tens of KB and left to lie. - // Master requests (no edge: the player-bar hero) stay a plain read — - // no decode, no permit, no cache. - let thumb_dir = app - .path() - .app_cache_dir() - .ok() - .map(|d| d.join("art-thumbs-v1")); - - // Phase 1 — cheap and permit-free: stat, cache probe, raw read. - let probe_path = path.clone(); - let probe_dir = thumb_dir.clone(); - let (bytes, write_to) = tauri::async_runtime::spawn_blocking( - move || -> Result<(Vec, Option), String> { - let Some(edge) = max_edge else { - return Ok((std::fs::read(&probe_path).map_err(|e| e.to_string())?, None)); - }; - let target = probe_dir.and_then(|dir| art_thumb_target(&dir, &probe_path, edge)); - if let Some(t) = &target { - if let Ok(cached) = std::fs::read(t) { - return Ok((cached, None)); - } - } - Ok((std::fs::read(&probe_path).map_err(|e| e.to_string())?, target)) - }, - ) - .await - .map_err(|e| e.to_string())??; - - // Phase 2 — the decode, gated. Runs only on a cache miss. - let bytes = match write_to { - None => bytes, - Some(target) => { - let _permit = ART_DECODE_SLOTS - .acquire() - .await - .map_err(|e| e.to_string())?; - let edge = max_edge.unwrap_or(0); - tauri::async_runtime::spawn_blocking(move || { - // An undecodable or already-small source falls through to the - // original bytes: a thumbnail is an optimisation, never a - // reason to show nothing. Either result is CACHED, so the - // "already small" discovery (itself a full decode) is also - // paid once. - let out = match pipeline::thumbnail_jpeg(&bytes, edge) { - Ok(Some(thumb)) => thumb, - _ => bytes, - }; - if let Some(dir) = target.parent() { - let _ = std::fs::create_dir_all(dir); - } - let _ = std::fs::write(&target, &out); // failure-soft - out - }) - .await - .map_err(|e| e.to_string())? - } - }; - use base64::Engine as _; - Ok(format!( - "data:image/jpeg;base64,{}", - base64::engine::general_purpose::STANDARD.encode(bytes) - )) -} - -/// At most this many album-art decodes run at once, machine-wide. Art is -/// cosmetic; audio and the UI are not — two permits keep a burst of tile -/// requests from ever owning more than two cores. -static ART_DECODE_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2); - -/// The disk-cache file for one (source, edge) thumbnail, keyed on -/// (path, mtime, len, edge) so a replaced cover self-invalidates. ONE -/// implementation on purpose: `album_art` reads through this key and -/// `library_art_prewarm` writes through it — if they computed it separately -/// and drifted, the warm work would be invisible to the tiles it was done -/// for. None when the source file cannot be statted. -fn art_thumb_target(dir: &std::path::Path, src: &str, edge: u32) -> Option { - let meta = std::fs::metadata(src).ok()?; - let mtime = meta - .modified() - .ok()? - .duration_since(std::time::UNIX_EPOCH) - .ok()? - .as_nanos(); - let mut hash: u64 = 0xcbf29ce484222325; - for b in src - .as_bytes() - .iter() - .chain(mtime.to_le_bytes().iter()) - .chain(meta.len().to_le_bytes().iter()) - { - hash ^= u64::from(*b); - hash = hash.wrapping_mul(0x100000001b3); - } - Some(dir.join(format!("{hash:016x}-{edge}.jpg"))) -} - -/// Make the thumbnail cache hold every library cover (and artist portrait) at -/// each requested edge, so the Library's first-ever scroll is a disk read -/// instead of a master decode — [{warm, decoded}] per edge, in the order -/// given, so the caller puts the size it paints first. -/// -/// Already-warm entries cost one stat each. Cold ones decode ONE at a time, -/// through the same 2-permit gate the tiles use — a background warm can never -/// own more than one core, and an on-screen tile waits behind at most one -/// decode. Edges walk outermost so the first edge finishes across the whole -/// library before the next begins. -#[tauri::command] -pub async fn library_art_prewarm( - app: tauri::AppHandle, - lib: State<'_, LibraryState>, - edges: Vec, -) -> Result, String> { - let Some(thumb_dir) = app - .path() - .app_cache_dir() - .ok() - .map(|d| d.join("art-thumbs-v1")) - else { - return Ok(vec![]); - }; - let l = lib.library.clone(); - let albums = - tauri::async_runtime::spawn_blocking(move || l.list_albums().map_err(|e| e.to_string())) - .await - .map_err(|e| e.to_string())??; - let mut paths: Vec = Vec::new(); - let mut seen = std::collections::HashSet::new(); - for a in &albums { - // Portraits too: the shelf heads load through the same tile path. - for p in [a.art_path.as_ref(), a.artist_art_path.as_ref()] - .into_iter() - .flatten() - { - if seen.insert(p.clone()) { - paths.push(p.clone()); - } - } - } - let mut out = Vec::new(); - for edge in edges { - if edge == 0 { - out.push((0, 0)); - continue; - } - // One pass of cheap stats finds the cold set; the warm majority never - // touches a permit (the same shape as album_art's phase 1). - let dir = thumb_dir.clone(); - let batch = paths.clone(); - let (warm, cold) = tauri::async_runtime::spawn_blocking(move || { - let mut warm = 0usize; - let mut cold: Vec<(String, std::path::PathBuf)> = Vec::new(); - for path in &batch { - match art_thumb_target(&dir, path, edge) { - Some(t) if t.exists() => warm += 1, - Some(t) => cold.push((path.clone(), t)), - None => {} - } - } - (warm, cold) - }) - .await - .map_err(|e| e.to_string())?; - let mut decoded = 0usize; - for (path, target) in cold { - let permit = ART_DECODE_SLOTS - .acquire() - .await - .map_err(|e| e.to_string())?; - let ok = tauri::async_runtime::spawn_blocking(move || { - let Ok(bytes) = std::fs::read(&path) else { - return false; - }; - // Same fallback as album_art: an undecodable or already-small - // source caches its original bytes, so the discovery is paid - // once there too. - let out = match pipeline::thumbnail_jpeg(&bytes, edge) { - Ok(Some(thumb)) => thumb, - _ => bytes, - }; - if let Some(dir) = target.parent() { - let _ = std::fs::create_dir_all(dir); - } - std::fs::write(&target, &out).is_ok() - }) - .await - .map_err(|e| e.to_string())?; - drop(permit); - if ok { - decoded += 1; - } - } - out.push((warm, decoded)); - } - Ok(out) -} - -/// The cover a streamed track carries INSIDE its own bytes, extracted to a -/// file the ordinary art path can read. -/// -/// A Soulseek transfer is a tagged audio file, and in the reference library -/// 449 of 515 records keep their best cover embedded — so a stream that -/// arrived without any catalog artwork usually still has the real thing a -/// tag read away, and that cover needs no name matching to be certain it -/// belongs to this recording. Extracted once, keyed by the bytes' own hash -/// so two tracks off one record share the file. -#[tauri::command] -pub async fn stream_embedded_art( - app: tauri::AppHandle, - username: String, - path: String, -) -> Result, String> { - let Some(cache) = playback::cache_path(&app, &username, &path) else { - return Ok(None); - }; - // Beside the library, not in AppData. The path is persisted onto the - // playlist row, and `playlist_items.art_path` is one of the columns the - // portable-library rebase rewrites — a cover kept in AppData would opt - // out of that and dangle the first time the drive is opened elsewhere. - let dir = config::ensure_library_data_dir(&app, &config::load(&app)) - .map_err(|e| e.to_string())? - .join("stream-art"); - tauri::async_runtime::spawn_blocking(move || -> Result, String> { - // A transfer still in flight has no cache file yet (the bytes land in - // `.partial`), and a tag read over a half-written one simply - // fails — either way the answer is "nothing yet", and the caller asks - // again when the transfer completes. - if !cache.exists() { - return Ok(None); - } - let Ok(Some(bytes)) = tagger::extract_art(&cache) else { - return Ok(None); - }; - if bytes.is_empty() { - return Ok(None); - } - let mut hash: u64 = 0xcbf29ce484222325; - for b in &bytes { - hash ^= *b as u64; - hash = hash.wrapping_mul(0x100000001b3); - } - std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - // No extension the loader trusts: JPEG and PNG both appear in tags and - // the art pipeline sniffs the bytes anyway. - let out = dir.join(format!("{hash:016x}.img")); - if !matches!(std::fs::metadata(&out), Ok(md) if md.len() == bytes.len() as u64) { - std::fs::write(&out, &bytes).map_err(|e| e.to_string())?; - } - Ok(Some(out.to_string_lossy().to_string())) - }) - .await - .map_err(|e| e.to_string())? -} - -/// Open an http(s) URL in the system's default browser. The WebView blocks -/// `target="_blank"` navigation (no new-window handler is installed), so -/// external links such as artist socials route through -/// this command instead. -#[tauri::command] -pub async fn open_external(url: String) -> Result<(), String> { - if !url.starts_with("https://") && !url.starts_with("http://") { - return Err("only http(s) URLs can be opened".into()); - } - let spawned = { - #[cfg(target_os = "windows")] - { - std::process::Command::new("rundll32") - .args(["url.dll,FileProtocolHandler", &url]) - .spawn() - } - #[cfg(target_os = "macos")] - { - std::process::Command::new("open").arg(&url).spawn() - } - #[cfg(all(unix, not(target_os = "macos")))] - { - std::process::Command::new("xdg-open").arg(&url).spawn() - } - }; - spawned.map(|_| ()).map_err(|e| e.to_string()) -} - -// ---- play history ---- - -#[tauri::command] -#[allow(clippy::too_many_arguments)] -pub async fn record_play( - lib: State<'_, LibraryState>, - artist: String, - title: String, - path: Option, - catalog_album_id: Option, - album_title: Option, - album_artist: Option, - cover_url: Option, -) -> Result { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || { - let cat = library::CatalogPlay { - album_id: catalog_album_id.as_deref(), - title: album_title.as_deref(), - artist: album_artist.as_deref(), - cover: cover_url.as_deref(), - }; - l.record_play(&artist, &title, path.as_deref(), cat, now_secs()) - .map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -/// Wipe listening history. Destructive and not undoable — `plays` only; -/// albums, tracks and playlists are untouched. Returns rows removed. -#[tauri::command] -pub async fn clear_play_history(lib: State<'_, LibraryState>) -> Result { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || { - l.clear_play_history().map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn play_row_count(lib: State<'_, LibraryState>) -> Result { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || l.play_row_count().map_err(|e| e.to_string())) - .await - .map_err(|e| e.to_string())? -} - -/// The sidebar's recently-played list. `min_secs` keeps skips out of it. -#[tauri::command] -pub async fn recent_albums( - lib: State<'_, LibraryState>, - limit: Option, - min_secs: Option, -) -> Result, String> { - let l = lib.library.clone(); - let limit = limit.unwrap_or(20); - let min_secs = min_secs.unwrap_or(45); - tauri::async_runtime::spawn_blocking(move || { - l.recent_albums(limit, min_secs).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn add_play_seconds( - lib: State<'_, LibraryState>, - play_id: i64, - secs: u32, -) -> Result<(), String> { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || { - l.add_play_seconds(play_id, secs).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn artist_play_stats( - lib: State<'_, LibraryState>, - artist: String, -) -> Result { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || { - l.artist_play_stats(&artist).map_err(|e| e.to_string()) - }) - .await - .map_err(|e| e.to_string())? -} - -// ---- followed artists ---- - -#[tauri::command] -pub async fn followed_artists( - lib: State<'_, LibraryState>, -) -> Result, String> { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || l.followed_artists().map_err(|e| e.to_string())) - .await - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn follow_artist( - app: tauri::AppHandle, - lib: State<'_, LibraryState>, - artist_id: u64, - name: String, - portrait: Option, -) -> Result<(), String> { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || { - l.follow_artist(artist_id, &name, portrait.as_deref(), now_secs()) - }) - .await - .map_err(|e| e.to_string())? - .map_err(|e| e.to_string())?; - let _ = app.emit("soulseek:follows-changed", ()); - Ok(()) -} - -#[tauri::command] -pub async fn unfollow_artist( - app: tauri::AppHandle, - lib: State<'_, LibraryState>, - artist_id: u64, -) -> Result<(), String> { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || l.unfollow_artist(artist_id)) - .await - .map_err(|e| e.to_string())? - .map_err(|e| e.to_string())?; - let _ = app.emit("soulseek:follows-changed", ()); - Ok(()) -} - -// ---- home ---- - -/// Headlines for the home page's news panel, one paced Google News query per -/// followed artist. Serial on purpose: the pacer inside `artist_news` is the -/// budget, and nothing user-blocking waits on this call — the frontend -/// requests it after the page is already up. -#[tauri::command] -pub async fn home_artist_news( - state: State<'_, LibraryState>, - artists: Vec, -) -> Result>, String> { - let mut out = std::collections::HashMap::new(); - for name in artists { - let items = state.metadata.artist_news(&name).await; - out.insert(name, items); - } - Ok(out) -} - -/// Warm every cache the home page reads, in the background. -/// -/// Measured 2026-08-04: assembling the release feed cold costs one -/// catalog_artist call per follow — 48 seconds for nine artists — which is why -/// the page itself must only ever read caches. This walks the follows through -/// the same metadata entry points the pages use (so the 15-minute catalog and -/// 24-hour identity caches fill), then tells the frontend fresh data is there. -#[tauri::command] -pub async fn home_warm(app: tauri::AppHandle, lib: State<'_, LibraryState>) -> Result<(), String> { - let follows = { - let l = lib.library.clone(); - tauri::async_runtime::spawn_blocking(move || l.followed_artists().map_err(|e| e.to_string())) - .await - .map_err(|e| e.to_string())?? - }; - let metadata = lib.metadata.clone(); - tauri::async_runtime::spawn(async move { - for f in follows { - // Page payload first (releases), then the MB supplement the feed - // merges (A ? of WHEN exists only there), then the related pool. - let _ = metadata.catalog_artist(f.artist_id).await; - let _ = metadata - .catalog_artist_identity(f.artist_id, &f.name) - .await; - let _ = metadata.catalog_related(f.artist_id).await; - // One artist at a time and an emit per artist: the page fills in - // progressively instead of all-at-once after the last follow. - let _ = app.emit("soulseek:home-warmed", f.artist_id); - } - let _ = app.emit("soulseek:home-ready", ()); - }); - Ok(()) -} - -// ---- playlists live in src-tauri/src/playlists.rs ---- - -fn now_secs() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -/// Compute (or fetch cached) waveform peaks for a local audio file. -/// Returns `buckets` normalized 0..1 values, or an empty array on failure. -#[tauri::command] -pub async fn waveform( - players: State<'_, PlayerState>, - path: String, - buckets: usize, - start_secs: Option, - end_secs: Option, -) -> Result, String> { - const MAX_WAVEFORM_BUCKETS: usize = 4096; - if buckets > MAX_WAVEFORM_BUCKETS { - return Err(format!( - "waveform bucket count exceeds {MAX_WAVEFORM_BUCKETS}" - )); - } - if buckets == 0 { - return Ok(Vec::new()); - } - // A range = a setlist SLICE's lane; each slice of one file caches - // separately under its own key. - let ranged = start_secs.is_some() || end_secs.is_some(); - let key = if ranged { - format!( - "{path}#{buckets}@{:.3}-{}", - start_secs.unwrap_or(0.0), - end_secs - .map(|e| format!("{e:.3}")) - .unwrap_or_else(|| "end".into()), - ) - } else { - format!("{path}#{buckets}") - }; - let job = players.waveform_job(&key); - let result = async { - let job_guard = job.clone().lock_owned().await; - if let Some(cached) = players.cached_waveform(&key) { - return Ok(cached); - } - let p = std::path::PathBuf::from(&path); - let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || { - let peaks = if ranged { - player::compute_waveform_range(&p, buckets, start_secs.unwrap_or(0.0), end_secs) - } else { - player::compute_waveform(&p, buckets) - }; - (job_guard, peaks) - }) - .await - .map_err(|e| e.to_string())?; - if !peaks.is_empty() { - players.remove_progressive_waveform(&key); - players.cache_waveform(key.clone(), peaks.clone()); - } - drop(job_guard); - Ok(peaks) - } - .await; - players.release_waveform_job(&key, &job); - result -} - -/// What the audio thread is doing right now. -/// -/// The webview learns about playback only from events, so a RELOAD leaves it -/// believing nothing is playing while audio carries on — the scrubber moved -/// under the words "Nothing playing". This is the boot-time answer, the same -/// shape of recovery `app_status` already provides for the Soulseek connection. -#[tauri::command] -pub fn player_state(players: State<'_, PlayerState>) -> crate::playback::PlaybackSnapshot { - players - .last_transport - .lock() - .map(|snapshot| snapshot.clone()) - .unwrap_or_default() -} - -/// Player transport: action ∈ pause | resume | stop, or seek/volume with value. -#[tauri::command] -pub fn player_control( - app: tauri::AppHandle, - players: State<'_, PlayerState>, - action: String, - value: Option, - rate: Option, -) -> Result<(), String> { - match action.as_str() { - "pause" => players.player.pause(), - "resume" => players.player.resume(), - "stop" => { - // Before the player, so no analyzer is still holding a start - // that could fire behind the stop. - crate::loudness::on_playback_stopped(&app); - players.player.stop() - } - "seek" => players.player.seek(value.unwrap_or(0.0)), - "scratch" => players - .player - .scratch(value.unwrap_or(0.0), rate.unwrap_or(1.0) as f32), - "end-scratch" => players.player.end_scratch(value.unwrap_or(0.0)), - "volume" => players.player.set_volume(value.unwrap_or(0.7) as f32), - other => return Err(format!("unknown action {other}")), - } - Ok(()) -} - -/// Enter mini mode: show the pre-built mini window (declared in -/// tauri.conf.json, created hidden at startup — runtime window creation -/// from a command half-initialises on Windows) and hide the main one. The -/// main window is HIDDEN, never closed: its frontend stays alive as the one -/// brain the mini remote-controls (see src/lib/miniBridge.svelte.js). -#[tauri::command] -pub async fn mini_open(app: tauri::AppHandle, width: f64, height: f64) -> Result<(), String> { - let mini = app - .get_webview_window("mini") - .ok_or_else(|| "mini window missing from config".to_string())?; - let _ = mini.set_size(tauri::LogicalSize::new(width, height)); - mini.show().map_err(|e| e.to_string())?; - let _ = mini.set_focus(); - if let Some(main) = app.get_webview_window("main") { - let _ = main.hide(); - } - Ok(()) -} - -/// Leave mini mode: bring the full app back and tuck the mini away warm -/// (hidden, not closed — reopening is instant and its mirror stays hot). -#[tauri::command] -pub async fn mini_close(app: tauri::AppHandle) -> Result<(), String> { - if let Some(main) = app.get_webview_window("main") { - let _ = main.show(); - let _ = main.set_focus(); - } - if let Some(mini) = app.get_webview_window("mini") { - let _ = mini.hide(); - } - Ok(()) -} - -/// The OS half of dark-glass mode: acrylic blur behind the calling window -/// (the window itself is transparent; the CSS half — :root.glass — thins the -/// window canvas so the blur shows through). -/// -/// NOT tauri's Effect::Acrylic: on Win11 22H2+ that picks the DWMSBT system -/// backdrop, which Windows SUSPENDS whenever the maximized webview gets -/// promoted to a hardware overlay — every hover animation or compositor -/// scroll blinked the glass dark for a beat (Kyle, 2026-08-26). The legacy -/// accent-policy acrylic below is composed with the window itself and holds -/// through interaction. -/// Whether dark-glass is currently ON for the MAIN window. Written by -/// window_glass, read by the window subclass in thumbbar.rs so it can -/// re-assert the accent after the gestures where Windows drops it: the -/// modal move/size loop, and a maximize/restore (Kyle photographed the -/// desktop punching through a maximized window on 2026-08-28 — the accent -/// did not survive the transition on its own). -pub(crate) static GLASS_ON: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); - -/// The accent states this window uses. `Blur` is the pre-acrylic -/// ACCENT_ENABLE_BLURBEHIND: visually close, but composited by DWM through -/// the modal move/size loop where the acrylic is SUSPENDED — the subclass -/// swaps down to it for the length of a gesture and back up on release -/// (see thumbbar.rs). Same struct, one field different. -#[derive(Clone, Copy, PartialEq)] -pub(crate) enum Accent { - Off, - Blur, - Acrylic, -} - -pub(crate) fn apply_accent(hwnd: *mut std::ffi::c_void, accent: Accent) -> Result<(), String> { - #[repr(C)] - struct AccentPolicy { - state: u32, - flags: u32, - gradient: u32, // 0xAABBGGRR - animation: u32, - } - #[repr(C)] - struct AttribData { - attrib: u32, - data: *mut std::ffi::c_void, - size: usize, - } - #[allow(non_snake_case)] - #[link(name = "kernel32")] - extern "system" { - fn GetModuleHandleA(name: *const u8) -> *mut std::ffi::c_void; - fn GetProcAddress( - module: *mut std::ffi::c_void, - name: *const u8, - ) -> *mut std::ffi::c_void; - } - type Swca = unsafe extern "system" fn(*mut std::ffi::c_void, *mut AttribData) -> i32; - - unsafe { - let user32 = GetModuleHandleA(b"user32.dll\0".as_ptr()); - let f = if user32.is_null() { - std::ptr::null_mut() - } else { - GetProcAddress(user32, b"SetWindowCompositionAttribute\0".as_ptr()) - }; - if f.is_null() { - return Err("SetWindowCompositionAttribute unavailable".into()); - } - let f: Swca = std::mem::transmute(f); - let mut policy = AccentPolicy { - state: match accent { - Accent::Off => 0, // ACCENT_DISABLED - Accent::Blur => 3, // ACCENT_ENABLE_BLURBEHIND - Accent::Acrylic => 4, // ACCENT_ENABLE_ACRYLICBLURBEHIND - }, - flags: 0, - // A whisper of black in the OS layer (acrylic treats alpha 0 as - // "don't draw"); the real darkening is the CSS canvas tint. - gradient: if matches!(accent, Accent::Off) { 0 } else { 0x2E00_0000 }, - animation: 0, - }; - let mut data = AttribData { - attrib: 0x13, // WCA_ACCENT_POLICY - data: &mut policy as *mut _ as _, - size: std::mem::size_of::(), - }; - f(hwnd, &mut data); - } - Ok(()) -} - -/// The DWM maximize/restore zoom animation. The accent visual detaches for -/// its length and takes until ~650ms after the call to recompose (measured -/// 2026-08-28 at 43ms/frame: desktop icons legible straight through the -/// maximized window). Disabling it removed the punch-through outright, but -/// also the animation — the window snapped to size — and Kyle chose the -/// animation back with the canvas grounded across the recomposition instead -/// (2026-08-29). So transitions are now explicitly forced ON (clearing any -/// disable a previous build left on this window), and the discrete bracket -/// in thumbbar.rs + GROUND_DISCRETE in glass.js covers the detach window. -fn set_transitions_disabled(hwnd: *mut std::ffi::c_void, disabled: bool) { - #[allow(non_snake_case)] - #[link(name = "kernel32")] - extern "system" { - fn LoadLibraryA(name: *const u8) -> *mut std::ffi::c_void; - fn GetProcAddress( - module: *mut std::ffi::c_void, - name: *const u8, - ) -> *mut std::ffi::c_void; - } - type DwmSet = - unsafe extern "system" fn(*mut std::ffi::c_void, u32, *const std::ffi::c_void, u32) -> i32; - unsafe { - let dwm = LoadLibraryA(b"dwmapi.dll".as_ptr()); - if dwm.is_null() { - return; - } - let f = GetProcAddress(dwm, b"DwmSetWindowAttribute".as_ptr()); - if f.is_null() { - return; - } - let f: DwmSet = std::mem::transmute(f); - let val: i32 = if disabled { 1 } else { 0 }; - // 3 = DWMWA_TRANSITIONS_FORCEDISABLED - let _ = f(hwnd, 3, &val as *const _ as _, 4); - } -} - -#[tauri::command] -pub fn window_glass(window: tauri::WebviewWindow, on: bool) -> Result<(), String> { - // Clear any system backdrop a previous toggle/build may have applied. - let _ = window.set_effects(None::); - let hwnd = window.hwnd().map_err(|e| e.to_string())?.0 as *mut std::ffi::c_void; - // The mini calls this too (window_glass acts on the CALLING window), and - // the subclass re-asserting from thumbbar.rs is on the MAIN window only — - // so only the main window's state is tracked. - if window.label() == "main" { - GLASS_ON.store(on, std::sync::atomic::Ordering::Relaxed); - } - set_transitions_disabled(hwnd, false); - apply_accent(hwnd, if on { Accent::Acrylic } else { Accent::Off }) -} - -#[cfg(test)] -mod hero_processing_tests { - use super::*; - - #[test] - fn baked_look_is_deterministic_and_changes_pixels() { - let original = image::RgbImage::from_pixel(32, 18, image::Rgb([92, 126, 161])); - let mut first = original.clone(); - let mut second = original.clone(); - let source = b"stable provider image bytes"; - - apply_baked_hero_look(&mut first, source); - apply_baked_hero_look(&mut second, source); - - assert_eq!(first, second); - assert_ne!(first, original); - } - - #[test] - fn sixteen_by_nine_source_finishes_at_uhd() { - assert_eq!(scaled_height(1280, 720, 3840), 2160); - } - - #[test] - fn letterbox_bars_are_trimmed() { - use image::GenericImageView as _; - // 256x144 mid-gray photo with 20px black bars baked top and bottom. - let mut img = image::RgbImage::from_pixel(256, 144, image::Rgb([120, 110, 100])); - for y in 0..20 { - for x in 0..256 { - img.put_pixel(x, y, image::Rgb([2, 2, 2])); - img.put_pixel(x, 143 - y, image::Rgb([2, 2, 2])); - } - } - let trimmed = - trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img)).expect("bars should trim"); - assert_eq!(trimmed.dimensions(), (256, 104)); - } - - #[test] - fn dark_photo_is_not_eaten() { - // Uniformly dark-but-not-black image: everything is "barlike", so the - // half-image guard rejects the crop and nothing is trimmed. - let img = image::RgbImage::from_pixel(256, 144, image::Rgb([24, 20, 18])); - assert!(trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img)).is_none()); - } - - #[test] - fn every_hero_gets_a_maximal_stable_crop() { - // Square sources retain every horizontal pixel; only Y can move. - let square = maximal_hero_rect(1000, 1000).expect("square must crop"); - assert_eq!((square.x, square.width, square.height), (0, 1000, 562)); - // Wide strips retain full height and remain horizontally centered. - let strip = maximal_hero_rect(1500, 500).expect("strip must crop"); - assert_eq!( - (strip.x, strip.y, strip.width, strip.height), - (306, 0, 888, 500) - ); - // Already 16:9 is the full source rectangle. - let hd = maximal_hero_rect(1920, 1080).expect("HD must plan"); - assert_eq!((hd.x, hd.y, hd.width, hd.height), (0, 0, 1920, 1080)); - } - - #[test] - fn featureless_murk_band_is_trimmed() { - // Bright photo with a dark featureless gradient band at the top — - // YouTube composites these between the black bar and the artwork. - let mut img = image::RgbImage::from_pixel(256, 144, image::Rgb([150, 140, 130])); - for y in 0..24 { - let shade = 20 + (y as u8); // 20..43 — dark, low range per row - for x in 0..256 { - img.put_pixel(x, y, image::Rgb([shade, shade, shade])); - } - } - let trimmed = trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img)) - .expect("murk band should trim"); - use image::GenericImageView as _; - assert_eq!(trimmed.dimensions(), (256, 120)); - } -} - - +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, Manager, State}; + +use soulseek_client::{Client, Config, DownloadState, DownloadUpdate, Event}; +use soulseek_proto::types::SearchResponse; + +use crate::config; +use crate::hero_crop::{maximal_hero_rect, plan_hero_crop, HeroCropPlan}; +use crate::hero_detector::detect_subjects; +use crate::pipeline::{self, LibraryState}; +use crate::playback::{self, PlayerState}; +use crate::state::AppState; + +#[derive(Serialize)] +pub struct AppStatus { + pub version: &'static str, + pub connected_as: Option, +} + +#[tauri::command] +pub async fn app_status(state: State<'_, AppState>) -> Result { + Ok(AppStatus { + version: env!("CARGO_PKG_VERSION"), + connected_as: state.connected_as.lock().await.clone(), + }) +} + +/// The server's excluded-phrase list for query sanitizing; empty until the +/// server sends it after login. Frontend also receives it via the +/// `soulseek:excluded-phrases` event — this command covers a WebView reload +/// that happens after login already delivered the event. +#[tauri::command] +pub async fn excluded_search_phrases(state: State<'_, AppState>) -> Result, String> { + Ok(state.excluded_search_phrases.lock().await.clone()) +} + +#[derive(Serialize)] +pub struct StoredCredentials { + pub username: String, + pub has_password: bool, +} + +#[tauri::command] +pub fn get_credentials(app: tauri::AppHandle) -> StoredCredentials { + let cfg = config::load(&app); + let has_password = config::has_password(&cfg.username); + StoredCredentials { + username: cfg.username, + has_password, + } +} + +/// Settings surfaced to the Settings view. +#[derive(Serialize)] +pub struct Settings { + pub username: String, + pub has_password: bool, + pub download_dir: String, + pub library_layout: String, + pub shared_folders: Vec, + pub listen_port: Option, + pub upload_slots: u32, + pub connected: bool, + pub share_files: u32, + pub audio_output_device: Option, + pub audio_output_devices: Vec, + pub artist_hero_upscaler: config::ArtistHeroUpscaler, + pub replace_embedded_art: bool, + /// 0 = embed the original at full resolution. + pub embed_art_max_edge: u32, + pub write_folder_cover: bool, + pub stream_keep_forever: bool, + pub stream_retention_days: u32, + /// 0 = unlimited. + pub stream_cache_max_mb: u64, + pub stream_source_prefs: config::SourcePrefs, + pub download_source_prefs: config::SourcePrefs, + pub volume_normalization: bool, + /// Word-by-word lyric fill — experimental, default off. + pub word_timing: bool, + /// Dark-glass acrylic backdrop, default off. + pub glass_mode: bool, +} + +#[tauri::command] +pub async fn get_settings( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + let cfg = config::load(&app); + let share_files = state + .client + .lock() + .await + .as_ref() + .map(|c| c.share_counts().1) + .unwrap_or(0); + Ok(Settings { + has_password: config::has_password(&cfg.username), + download_dir: config::download_dir(&app, &cfg) + .to_string_lossy() + .into_owned(), + library_layout: config::library_layout(&cfg).to_string(), + shared_folders: cfg.shared_folders.clone(), + listen_port: cfg.listen_port, + upload_slots: cfg.upload_slots.unwrap_or(2), + connected: state.connected_as.lock().await.is_some(), + share_files, + audio_output_device: cfg.audio_output_device.clone(), + audio_output_devices: player::output_device_names().unwrap_or_default(), + artist_hero_upscaler: cfg.artist_hero_upscaler, + replace_embedded_art: config::replace_embedded_art(&cfg), + embed_art_max_edge: config::embed_art_max_edge(&cfg), + write_folder_cover: config::write_folder_cover(&cfg), + stream_keep_forever: config::stream_keep_forever(&cfg), + stream_retention_days: config::stream_retention_days(&cfg), + stream_cache_max_mb: config::stream_cache_max_mb(&cfg), + stream_source_prefs: cfg.stream_source_prefs, + download_source_prefs: cfg.download_source_prefs, + volume_normalization: config::volume_normalization(&cfg), + word_timing: config::word_timing(&cfg), + glass_mode: config::glass_mode(&cfg), + username: cfg.username, + }) +} + +/// Persist non-secret settings. Some (download dir, port) apply on next +/// connect; shared folders re-index live if connected. +#[tauri::command] +pub async fn set_settings( + app: tauri::AppHandle, + state: State<'_, AppState>, + players: State<'_, PlayerState>, + download_dir: Option, + library_layout: Option, + shared_folders: Option>, + listen_port: Option, + upload_slots: Option, + audio_output_device: Option, + artist_hero_upscaler: Option, + replace_embedded_art: Option, + embed_art_max_edge: Option, + write_folder_cover: Option, + stream_keep_forever: Option, + stream_retention_days: Option, + stream_cache_max_mb: Option, + stream_source_prefs: Option, + download_source_prefs: Option, + volume_normalization: Option, + word_timing: Option, + glass_mode: Option, +) -> Result<(), String> { + let mut cfg = config::load(&app); + let library_folder_moved = download_dir + .as_deref() + .is_some_and(|d| Some(d) != cfg.download_dir.as_deref()); + if let Some(d) = download_dir { + cfg.download_dir = Some(d); + } + if let Some(layout) = library_layout { + // Separators are allowed: they create subfolders below the library + // root. organize_download sanitizes every rendered segment, so a + // layout (or tag value) cannot escape the root. + cfg.library_layout = Some(layout); + } + if let Some(f) = shared_folders { + cfg.shared_folders = f; + } + if listen_port.is_some() { + cfg.listen_port = listen_port; + } + if let Some(s) = upload_slots { + cfg.upload_slots = Some(s); + } + if let Some(device) = audio_output_device { + let device = match device.trim() { + "" => None, + name => Some(name.to_string()), + }; + // Open first; only persist a choice that can actually be used. + players.player.set_output_device(device.clone())?; + cfg.audio_output_device = device; + } + if let Some(upscaler) = artist_hero_upscaler { + cfg.artist_hero_upscaler = upscaler; + } + if let Some(prefs) = stream_source_prefs { + cfg.stream_source_prefs = prefs; + } + if let Some(prefs) = download_source_prefs { + cfg.download_source_prefs = prefs; + } + if replace_embedded_art.is_some() { + cfg.replace_embedded_art = replace_embedded_art; + } + if embed_art_max_edge.is_some() { + cfg.embed_art_max_edge = embed_art_max_edge; + } + if write_folder_cover.is_some() { + cfg.write_folder_cover = write_folder_cover; + } + let retention_changed = stream_keep_forever.is_some() + || stream_retention_days.is_some() + || stream_cache_max_mb.is_some(); + if stream_keep_forever.is_some() { + cfg.stream_keep_forever = stream_keep_forever; + } + if stream_retention_days.is_some() { + cfg.stream_retention_days = stream_retention_days; + } + if stream_cache_max_mb.is_some() { + cfg.stream_cache_max_mb = stream_cache_max_mb; + } + if let Some(enabled) = volume_normalization { + cfg.volume_normalization = Some(enabled); + // Applies live: off glides the current track back to unity; on + // takes effect from the next track. + crate::loudness::set_enabled(&app, enabled); + } + if word_timing.is_some() { + cfg.word_timing = word_timing; + } + if glass_mode.is_some() { + cfg.glass_mode = glass_mode; + } + config::save(&app, &cfg); + + // A new library folder is a new library. Re-base immediately rather than + // at the next launch, so what the shelves show is what the folder holds — + // the old behaviour left the previous folder's albums standing forever, + // and emptying the new folder could not remove them. + if library_folder_moved { + let app2 = app.clone(); + let root = config::download_dir(&app, &cfg); + tauri::async_runtime::spawn(async move { + let lib = app2.state::().library.clone(); + if let Ok(Ok(dropped)) = + tauri::async_runtime::spawn_blocking(move || lib.prune_outside_root(&root)).await + { + log::info!("library folder changed: dropped {dropped} tracks from the old folder"); + } + let _ = crate::pipeline::rescan_library(app2.clone()).await; + let _ = app2.emit("soulseek:library-changed", ()); + }); + } + + // Tighter limits take effect right away, not at the next stream/startup. + if retention_changed { + let app2 = app.clone(); + tauri::async_runtime::spawn_blocking(move || crate::playback::prune_cache(&app2)); + } + + // Re-index shares live so the change takes effect without reconnecting. + if let Some(client) = state.client.lock().await.as_ref() { + let dl = config::download_dir(&app, &cfg); + let mut folders = vec![dl]; + folders.extend(cfg.shared_folders.iter().map(std::path::PathBuf::from)); + let (fdirs, ffiles) = client.update_shares(folders).await; + let _ = app.emit( + "soulseek:shares", + serde_json::json!({ "folders": fdirs, "files": ffiles }), + ); + } + Ok(()) +} + +/// Forget stored credentials (config + credential manager). +#[tauri::command] +pub fn forget_credentials(app: tauri::AppHandle) -> Result<(), String> { + let mut cfg = config::load(&app); + config::delete_password(&cfg.username); + cfg.username = String::new(); + config::save(&app, &cfg); + Ok(()) +} + +/// Open a native folder picker; returns the chosen path or null. +#[tauri::command] +pub async fn pick_folder(app: tauri::AppHandle) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog().file().pick_folder(move |path| { + let _ = tx.send(path.map(|p| p.to_string())); + }); + Ok(rx.await.unwrap_or(None)) +} + +/// What the frontend sees for one search response, flattened per file +/// grouping done client-side (UI groups by user + folder). +#[derive(Serialize, Clone)] +pub struct SearchResultPayload { + pub token: u32, + pub username: String, + pub has_free_upload_slot: bool, + pub upload_speed: u32, + pub queue_length: u32, + pub files: Vec, +} + +#[derive(Serialize, Clone)] +pub struct FilePayload { + pub path: String, + pub name: String, + pub folder: String, + pub size: u64, + pub extension: String, + pub bitrate: Option, + pub duration: Option, + pub sample_rate: Option, + pub bit_depth: Option, + pub vbr: bool, +} + +/// Broad queries match entire shared folders; a single response can list +/// thousands of files. The UI shows folder groups (500 files per group max), +/// so forwarding more than this per response only burns serialization, +/// IPC and webview memory. +const FILES_PER_RESPONSE_MAX: usize = 100; + +fn to_payload(token: u32, r: &SearchResponse) -> SearchResultPayload { + SearchResultPayload { + token, + username: r.username.clone(), + has_free_upload_slot: r.has_free_upload_slot, + upload_speed: r.upload_speed, + queue_length: r.queue_length, + files: r + .files + .iter() + .take(FILES_PER_RESPONSE_MAX) + .map(|f| { + let (folder, name) = match f.filename.rsplit_once('\\') { + Some((d, n)) => (d.to_string(), n.to_string()), + None => (String::new(), f.filename.clone()), + }; + // Many clients leave the extension attribute empty; + // fall back to the filename suffix. + let extension = if f.extension.is_empty() { + name.rsplit_once('.') + .map(|(_, e)| e.to_string()) + .unwrap_or_default() + } else { + f.extension.clone() + }; + FilePayload { + path: f.filename.clone(), + name, + folder, + size: f.size, + extension: extension.to_lowercase(), + bitrate: f.bitrate(), + duration: f.duration_secs(), + sample_rate: f.sample_rate(), + bit_depth: f.bit_depth(), + vbr: f.is_vbr(), + } + }) + .collect(), + } +} + +#[derive(Serialize, Clone)] +#[serde(tag = "kind", rename_all = "kebab-case")] +enum UiEvent { + Connected { username: String }, + Disconnected { reason: String }, +} + +/// One download's status, as the frontend sees it. +#[derive(Serialize, Clone)] +pub struct DownloadPayload { + pub id: u64, + pub username: String, + pub path: String, + pub name: String, + pub state: String, + pub queue_place: Option, + pub local_path: Option, + pub error: Option, + pub received: u64, + pub total: u64, + pub speed_bps: u64, +} + +fn download_payload(u: &DownloadUpdate) -> DownloadPayload { + let name = u + .filename + .rsplit('\\') + .next() + .unwrap_or(&u.filename) + .to_string(); + let (state, queue_place, local_path, error) = match &u.state { + DownloadState::Connecting => ("connecting", None, None, None), + DownloadState::Requested => ("requested", None, None, None), + DownloadState::Queued { place } => ("queued", *place, None, None), + DownloadState::Transferring => ("transferring", None, None, None), + DownloadState::Complete { local_path } => { + ("complete", None, Some(local_path.clone()), None) + } + DownloadState::Failed { reason } => ("failed", None, None, Some(reason.clone())), + DownloadState::Cancelled => ("cancelled", None, None, None), + }; + DownloadPayload { + id: u.id, + username: u.username.clone(), + path: u.filename.clone(), + name, + state: state.into(), + queue_place, + local_path, + error, + received: u.received, + total: u.total, + speed_bps: u.speed_bps, + } +} + +/// Connect using credentials saved in the config file, without exposing +/// the password to the webview. Errors if none are stored. +#[tauri::command] +pub async fn connect_stored( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + let cfg = config::load(&app); + let password = config::load_password(&cfg.username); + match (cfg.username.is_empty(), password) { + (false, Some(pw)) => connect_and_login(app, state, cfg.username, pw, false).await, + _ => Err("no stored credentials".into()), + } +} + +#[tauri::command] +pub async fn connect_and_login( + app: tauri::AppHandle, + state: State<'_, AppState>, + username: String, + password: String, + remember: bool, +) -> Result { + // Drop any previous session first. + if let Some(old) = state.client.lock().await.take() { + old.shutdown(); + } + *state.connected_as.lock().await = None; + + let stored = config::load(&app); + let mut cfg = Config::default(); + if let Some(port) = stored.listen_port { + cfg.listen_port = port; + } + if let Some(slots) = stored.upload_slots { + cfg.upload_slots = slots; + } + // Configured download target, or the default. + let downloads = config::download_dir(&app, &stored); + cfg.downloads_dir = downloads.clone(); + // Be a good citizen: share everything we've downloaded, plus any extra + // configured folders. + std::fs::create_dir_all(&downloads).ok(); + let mut shares = vec![downloads]; + shares.extend(stored.shared_folders.iter().map(std::path::PathBuf::from)); + cfg.shared_folders = shares; + cfg.description = "Shared with SoulStream".into(); + + // The previous session's listener releases its port a moment after + // shutdown; retry briefly instead of failing with WSAEADDRINUSE. + let (client, mut events) = { + let mut attempt = 0; + loop { + match Client::connect(cfg.clone()).await { + Ok(pair) => break pair, + Err(e) if attempt < 8 && e.to_string().contains("10048") => { + attempt += 1; + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + Err(e) => return Err(format!("could not reach server: {e}")), + } + } + }; + + let greeting = client + .login(&username, &password) + .await + .map_err(|e| e.to_string())?; + + if remember { + let mut save = stored.clone(); + save.username = username.clone(); + config::save(&app, &save); + config::save_password(&username, &password); + } + + // Report share counts to the UI once indexed. + let (folders, files) = client.share_counts(); + let _ = app.emit( + "soulseek:shares", + serde_json::json!({ "folders": folders, "files": files }), + ); + + // Watch the buddy list so status/stats stream in for the Users tab. + let stored_now = config::load(&app); + for buddy in &stored_now.buddies { + let _ = client.watch_user(buddy); + } + // Say again what we like. The server ranks similar users on these, and a + // client that declares nothing is invisible to that ranking however much + // it reads — which is why the Discover shelf had no input. + for thing in &stored_now.interests_like { + let _ = client.declare_interest(thing, true, true); + } + for thing in &stored_now.interests_hate { + let _ = client.declare_interest(thing, false, true); + } + + *state.client.lock().await = Some(client); + *state.connected_as.lock().await = Some(username.clone()); + + // Forward client events to the webview for the life of this session. + let event_app = app.clone(); + let event_user = username.clone(); + tauri::async_runtime::spawn(async move { + let _ = event_app.emit( + "soulseek:state", + UiEvent::Connected { + username: event_user, + }, + ); + + // Search responses arrive in the thousands within seconds on broad + // queries. Emitting each one individually floods the IPC bridge and + // the webview's event loop (UI lockups, eventually OOM), so buffer + // them and flush as one batch per tick. A per-token cap bounds the + // total work a single search can ever create. + const SEARCH_FLUSH_MS: u64 = 150; + const SEARCH_BATCH_MAX: usize = 100; + const SEARCH_RESPONSES_MAX: u32 = 1500; + const SEARCH_FILES_MAX: u32 = 20_000; + let mut search_buf: Vec = Vec::new(); + // token → (responses forwarded, files forwarded) + let mut sent_counts: std::collections::HashMap = + std::collections::HashMap::new(); + let mut flush = tokio::time::interval(std::time::Duration::from_millis(SEARCH_FLUSH_MS)); + flush.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + + loop { + let ev = tokio::select! { + maybe_ev = events.recv() => match maybe_ev { + Some(ev) => ev, + None => break, + }, + _ = flush.tick() => { + if !search_buf.is_empty() { + let _ = event_app + .emit("soulseek:search-results", std::mem::take(&mut search_buf)); + } + continue; + } + }; + match ev { + Event::SearchResult { token, response } => { + let (responses, files) = sent_counts.entry(token).or_insert((0, 0)); + if *responses >= SEARCH_RESPONSES_MAX || *files >= SEARCH_FILES_MAX { + continue; + } + *responses += 1; + let payload = to_payload(token, &response); + *files += payload.files.len() as u32; + search_buf.push(payload); + if search_buf.len() >= SEARCH_BATCH_MAX { + let _ = event_app + .emit("soulseek:search-results", std::mem::take(&mut search_buf)); + } + } + Event::Download(update) => { + // Next-track preloads are invisible: no player, no + // Downloads view, no library pipeline. On completion, + // tell the frontend so it can append the cached track + // to the sink for a gapless transition. + if let Some(ps) = event_app.try_state::() { + let is_preload = ps.preloads.lock().unwrap().contains_key(&update.id); + if is_preload { + use soulseek_client::DownloadState as DS; + let terminal = matches!( + &update.state, + DS::Complete { .. } | DS::Failed { .. } | DS::Cancelled + ); + // Mirror every update for the debug overlay's + // QUEUE section — these transfers are otherwise + // invisible by design. + let state_label = match &update.state { + DS::Connecting => "connecting".to_string(), + DS::Requested => "requested".to_string(), + DS::Queued { .. } => "queued".to_string(), + DS::Transferring => "transferring".to_string(), + DS::Complete { .. } => "done".to_string(), + DS::Failed { reason } => format!("failed: {reason}"), + DS::Cancelled => "cancelled".to_string(), + }; + let place = match &update.state { + DS::Queued { place } => *place, + _ => None, + }; + crate::diagnostics::note_preload( + &event_app, + update.id, + crate::diagnostics::PreloadDl { + username: update.username.clone(), + path: update.filename.clone(), + state: state_label, + place, + received: update.received, + total: update.total, + speed_bps: update.speed_bps, + }, + terminal, + ); + if terminal { + let cache = ps.preloads.lock().unwrap().remove(&update.id); + let save_cache = + ps.deferred_saves.lock().unwrap().remove(&update.id); + let _ = event_app.emit( + "soulseek:preload", + serde_json::json!({ + "username": update.username, + "path": update.filename, + "done": matches!(&update.state, DS::Complete { .. }), + }), + ); + if matches!(&update.state, DS::Complete { .. }) { + // Gapless normalization: measure the next + // track now so its gain is ready at the + // boundary. + if let Some(c) = &cache { + crate::loudness::on_preload_ready(&event_app, c); + } + // Only explicit saves reach the library — + // unless streamed tracks are configured to + // be kept forever. + let keep = + config::stream_keep_forever(&config::load(&event_app)); + let save = if keep { + save_cache.or(cache) + } else { + save_cache + }; + let app2 = event_app.clone(); + let username = update.username.clone(); + let remote = update.filename.clone(); + tauri::async_runtime::spawn(async move { + if let Some(cache) = save { + let downloads_dir = + config::download_dir(&app2, &config::load(&app2)); + if let Err(err) = organize_cached_save( + &app2, + &downloads_dir, + &cache, + &remote, + ) + .await + { + log::warn!("could not save completed preload {username}\\{remote}: {err}"); + } + let _ = app2.emit("soulseek:library-changed", ()); + } + // The cache just grew; enforce retention + // after any save copy is done with it. + let app3 = app2.clone(); + let _ = tauri::async_runtime::spawn_blocking(move || { + crate::playback::prune_cache(&app3) + }) + .await; + }); + } + } + continue; + } + } + // Stream downloads feed the player instead of the + // Downloads view. + let stream = event_app + .try_state::() + .and_then(|ps| ps.streams.lock().unwrap().get(&update.id).cloned()); + if let Some(progress) = stream { + use soulseek_client::DownloadState as DS; + let (done, failed) = match &update.state { + DS::Complete { .. } => (true, None), + DS::Failed { reason } => (false, Some(reason.clone())), + DS::Cancelled => (false, Some("stream cancelled".to_string())), + _ => (false, None), + }; + progress.update(update.received, done, failed.clone()); + let _ = event_app.emit( + "soulseek:stream", + serde_json::json!({ + "received": update.received, + "total": update.total, + "done": done, + "failed": failed, + }), + ); + if done || failed.is_some() { + if let Some(ps) = event_app.try_state::() { + ps.streams.lock().unwrap().remove(&update.id); + let cache = ps.stream_caches.lock().unwrap().remove(&update.id); + let save_cache = + ps.deferred_saves.lock().unwrap().remove(&update.id); + if done { + // Streams stay cache-only unless the user + // saved this track or retention is set to + // keep streamed tracks forever. + let keep = + config::stream_keep_forever(&config::load(&event_app)); + let save = if keep { + save_cache.or(cache) + } else { + save_cache + }; + let app2 = event_app.clone(); + let username = update.username.clone(); + let remote = update.filename.clone(); + tauri::async_runtime::spawn(async move { + if let Some(cache) = save { + let downloads_dir = + config::download_dir(&app2, &config::load(&app2)); + if let Err(err) = organize_cached_save( + &app2, + &downloads_dir, + &cache, + &remote, + ) + .await + { + log::warn!("could not save completed stream {username}\\{remote}: {err}"); + } + let _ = app2.emit("soulseek:library-changed", ()); + } + // The cache just grew; enforce retention + // after any save copy is done with it. + let app3 = app2.clone(); + let _ = tauri::async_runtime::spawn_blocking(move || { + crate::playback::prune_cache(&app3) + }) + .await; + }); + } + } + } + } else if crate::import::is_audition(update.id) { + // Audition downloads are candidate versions under + // review in the (Cache) pen — they stay OUT of the + // library pipeline; the import window watches them. + use soulseek_client::DownloadState as DS; + // WHICH record and WHICH track, straight from the + // registry — looked up before audition_done retires + // the id. The UI matches on this, never on the id: + // the first events of a transfer are emitted before + // import_audition has returned its ids at all. + let (au_rel, au_idx) = crate::import::audition_of(update.id) + .map(|(r, i)| (r, i as i64)) + .unwrap_or_else(|| (String::new(), -1)); + match &update.state { + DS::Complete { local_path } => { + let _ = event_app.emit( + "import:audition", + serde_json::json!({ + "id": update.id, + "rel": au_rel, + "idx": au_idx, + "localPath": local_path, + "remote": update.filename, + "done": true, + }), + ); + crate::import::audition_done(update.id); + } + DS::Failed { reason } => { + let _ = event_app.emit( + "import:audition", + serde_json::json!({ + "id": update.id, + "rel": au_rel, + "idx": au_idx, + "remote": update.filename, + "failed": reason, + }), + ); + crate::import::audition_done(update.id); + } + DS::Cancelled => { + crate::import::audition_done(update.id); + } + // In flight. The picker's row can only say what it + // is told, and "arriving…" was all it had: the + // phase, the QUEUE PLACE and the speed were all + // known here and thrown away. A place that ticks + // down is the difference between waiting and + // wondering whether anything is happening. + rest => { + let (phase, place) = match rest { + DS::Connecting => ("connecting", None), + DS::Requested => ("requested", None), + DS::Queued { place } => ("queued", *place), + DS::Transferring => ("transferring", None), + _ => ("working", None), + }; + let _ = event_app.emit( + "import:audition", + serde_json::json!({ + "id": update.id, + "rel": au_rel, + "idx": au_idx, + "remote": update.filename, + "received": update.received, + "total": update.total, + "speedBps": update.speed_bps, + "phase": phase, + "place": place, + }), + ); + } + } + } else { + let _ = event_app.emit("soulseek:download", download_payload(&update)); + // Completed real downloads go through the tag/art + // pipeline and into the library. + if let soulseek_client::DownloadState::Complete { local_path } = + &update.state + { + let app2 = event_app.clone(); + let local = std::path::PathBuf::from(local_path.clone()); + let remote = update.filename.clone(); + tauri::async_runtime::spawn(async move { + pipeline::process_file(&app2, &local, Some(&remote)).await; + }); + } + } + } + Event::Disconnected { reason } => { + let _ = event_app.emit("soulseek:state", UiEvent::Disconnected { reason }); + if let Some(st) = event_app.try_state::() { + *st.connected_as.lock().await = None; + } + break; + } + Event::ExcludedSearchPhrases(p) => { + log::info!("server sent {} excluded search phrases: {:?}", p.len(), p); + let lowered: Vec = p.iter().map(|s| s.to_lowercase()).collect(); + if let Some(st) = event_app.try_state::() { + *st.excluded_search_phrases.lock().await = lowered.clone(); + } + // The resolver builds queries frontend-side; hand it the + // list so poisoned terms never reach the wire. + let _ = event_app.emit("soulseek:excluded-phrases", lowered); + } + Event::Upload(u) => { + use soulseek_client::UploadState as US; + let state = match &u.state { + US::Queued => "queued", + US::Transferring => "transferring", + US::Complete => "complete", + US::Failed { .. } => "failed", + }; + let _ = event_app.emit( + "soulseek:upload", + serde_json::json!({ + "id": u.id, + "username": u.username, + "name": u.filename.rsplit('\\').next().unwrap_or(&u.filename), + // Full shared path too — Transfers shows the + // parent folder ahead of the name, Qt-style. + "path": u.filename, + "state": state, + "sent": u.sent, + "total": u.total, + }), + ); + } + Event::RespondedToSearch { username, files } => { + let _ = event_app.emit( + "soulseek:responded", + serde_json::json!({ "username": username, "files": files }), + ); + } + // ---- the Soulseek hub's seats: rooms, chat, users ---- + Event::RoomList { rooms } => { + let rooms: Vec<_> = rooms + .into_iter() + .map(|(name, users)| serde_json::json!({ "name": name, "users": users })) + .collect(); + let _ = event_app.emit("soulseek:room-list", rooms); + } + Event::RoomJoined { room, users } => { + let _ = event_app.emit( + "soulseek:room-joined", + serde_json::json!({ "room": room, "users": room_users_json(&users) }), + ); + } + Event::RoomLeft { room } => { + let _ = event_app.emit("soulseek:room-left", serde_json::json!({ "room": room })); + } + Event::RoomUserJoined { room, user } => { + let _ = event_app.emit( + "soulseek:room-user", + serde_json::json!({ + "room": room, + "joined": true, + "user": room_user_json(&user), + }), + ); + } + Event::RoomUserLeft { room, username } => { + let _ = event_app.emit( + "soulseek:room-user", + serde_json::json!({ "room": room, "joined": false, "username": username }), + ); + } + Event::RoomMessage { + room, + username, + message, + } => { + let _ = event_app.emit( + "soulseek:room-message", + serde_json::json!({ + "room": room, + "username": username, + "message": message, + "ts": now_ms(), + }), + ); + } + Event::PrivateMessage { + id, + timestamp, + username, + message, + } => { + // Identity-link handshakes ride private messages and + // never reach the Inbox (slsk_link.rs). + if crate::slsk_link::intercept_pm(&event_app, &username, &message).await + || crate::friends::intercept_pm(&event_app, &username, &message) + || crate::shared_playlists::intercept_pm(&event_app, &username, &message) + { + continue; + } + let _ = event_app.emit( + "soulseek:private-message", + serde_json::json!({ + "id": id, + "timestamp": timestamp, + "username": username, + "message": message, + }), + ); + } + Event::UserWatched { + username, + exists, + status, + avg_speed, + file_count, + dir_count, + } => { + let _ = event_app.emit( + "soulseek:user-state", + serde_json::json!({ + "kind": "watched", + "username": username, + "exists": exists, + "status": status, + "avg_speed": avg_speed, + "file_count": file_count, + "dir_count": dir_count, + }), + ); + } + Event::UserStatus { + username, status, .. + } => { + let _ = event_app.emit( + "soulseek:user-state", + serde_json::json!({ "kind": "status", "username": username, "status": status }), + ); + } + Event::UserStats { + username, + avg_speed, + file_count, + dir_count, + } => { + let _ = event_app.emit( + "soulseek:user-state", + serde_json::json!({ + "kind": "stats", + "username": username, + "avg_speed": avg_speed, + "file_count": file_count, + "dir_count": dir_count, + }), + ); + } + _ => {} + } + } + }); + + Ok(greeting) +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +fn room_user_json(u: &soulseek_client::RoomUser) -> serde_json::Value { + serde_json::json!({ + "username": u.username, + "status": u.status, + "avg_speed": u.avg_speed, + "file_count": u.file_count, + "dir_count": u.dir_count, + }) +} + +fn room_users_json(users: &[soulseek_client::RoomUser]) -> Vec { + users.iter().map(room_user_json).collect() +} + +// ---- the Soulseek hub: rooms / chat / users / browse commands ---- + +/// Briefly lock the client, run `f`, release. Every hub command is a small +/// synchronous send; only browse runs long, and it detaches first. +async fn with_client( + state: &State<'_, AppState>, + f: impl FnOnce(&soulseek_client::Client) -> soulseek_client::Result, +) -> Result { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected to Soulseek")?; + f(client).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn slsk_join_room(state: State<'_, AppState>, room: String) -> Result<(), String> { + with_client(&state, |c| c.join_room(&room)).await +} + +#[tauri::command] +pub async fn slsk_leave_room(state: State<'_, AppState>, room: String) -> Result<(), String> { + with_client(&state, |c| c.leave_room(&room)).await +} + +#[tauri::command] +pub async fn slsk_say_room( + state: State<'_, AppState>, + room: String, + message: String, +) -> Result<(), String> { + let message = message.trim().to_string(); + if message.is_empty() { + return Ok(()); + } + with_client(&state, |c| c.say_room(&room, &message)).await +} + +#[tauri::command] +pub async fn slsk_message_user( + state: State<'_, AppState>, + username: String, + message: String, +) -> Result<(), String> { + let message = message.trim().to_string(); + if message.is_empty() { + return Ok(()); + } + with_client(&state, |c| c.message_user(&username, &message)).await +} + +#[tauri::command] +pub async fn slsk_room_list(state: State<'_, AppState>) -> Result<(), String> { + with_client(&state, |c| c.request_room_list()).await +} + +#[derive(serde::Serialize)] +pub struct BrowseFileDto { + pub name: String, + /// Full remote path (folder + name), ready for a download request. + pub path: String, + pub size: u64, + pub ext: String, + pub bitrate: Option, + pub duration: Option, +} + +#[derive(serde::Serialize)] +pub struct BrowseDirDto { + pub name: String, + pub files: Vec, +} + +/// Walk a user's shares. Long-running: the client lock is held only long +/// enough to start the request. +#[derive(Serialize)] +pub struct UserInfoDto { + pub username: String, + pub description: String, + /// The picture ONLY after the avatar pipeline's bounded decode + resize + /// (profile.rs): a peer's bytes are hostile input, never rendered raw. + pub picture_png_base64: Option, + pub picture_note: Option, + pub total_uploads: u32, + pub queue_size: u32, + pub has_free_slot: bool, +} + +/// A peer's native user-info card: description, picture, upload state. +#[tauri::command] +pub async fn slsk_user_info( + state: State<'_, AppState>, + username: String, +) -> Result { + let fut = { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected to Soulseek")?; + client.user_info(&username) + }; + let info = fut.await.map_err(|e| e.to_string())?; + let (picture_png_base64, picture_note) = match info.picture.as_deref() { + None => (None, None), + Some(bytes) => match crate::profile::normalize_avatar(bytes) { + Ok(png) => { + use base64::Engine; + (Some(base64::engine::general_purpose::STANDARD.encode(png)), None) + } + Err(e) => (None, Some(format!("picture refused: {e}"))), + }, + }; + Ok(UserInfoDto { + username, + description: info.description, + picture_png_base64, + picture_note, + total_uploads: info.total_uploads, + queue_size: info.queue_size, + has_free_slot: info.has_free_slot, + }) +} + +#[derive(Serialize)] +pub struct InterestsDto { + pub likes: Vec, + pub hates: Vec, +} + +/// A user's declared Soulseek interests — raw likes and hates, not follows. +#[tauri::command] +pub async fn slsk_user_interests( + state: State<'_, AppState>, + username: String, +) -> Result { + let fut = { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected to Soulseek")?; + client.user_interests(&username) + }; + let (likes, hates) = fut.await.map_err(|e| e.to_string())?; + Ok(InterestsDto { likes, hates }) +} + +/// What this device has declared. The server's copy is per ACCOUNT, so this +/// is what we re-assert on login rather than the authority on it. +#[tauri::command] +pub fn slsk_interests_mine(app: tauri::AppHandle) -> InterestsDto { + let cfg = config::load(&app); + InterestsDto { likes: cfg.interests_like, hates: cfg.interests_hate } +} + +/// Declare an interest, or take it back. Kept here as well as sent, because +/// the account's list can be changed by any other client signed in elsewhere. +#[tauri::command] +pub async fn slsk_interest_set( + app: tauri::AppHandle, + state: State<'_, AppState>, + thing: String, + like: bool, + on: bool, +) -> Result { + let thing = thing.trim().to_string(); + if thing.is_empty() { + return Err("nothing to declare".into()); + } + if thing.chars().count() > 64 { + return Err("an interest is at most 64 characters".into()); + } + { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected to Soulseek")?; + client.declare_interest(&thing, like, on).map_err(|e| e.to_string())?; + } + let mut cfg = config::load(&app); + let list = if like { &mut cfg.interests_like } else { &mut cfg.interests_hate }; + let at = list.iter().position(|t| t.eq_ignore_ascii_case(&thing)); + match (on, at) { + (true, None) => list.push(thing), + (false, Some(i)) => { + list.remove(i); + } + _ => {} + } + config::save(&app, &cfg); + Ok(InterestsDto { likes: cfg.interests_like, hates: cfg.interests_hate }) +} + +#[derive(Serialize)] +pub struct SimilarUserDto { + pub username: String, + pub rating: u32, +} + +/// Users the server ranks as similar to us by shared interests. +#[tauri::command] +pub async fn slsk_similar_users(state: State<'_, AppState>) -> Result, String> { + let fut = { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected to Soulseek")?; + client.similar_users() + }; + let users = fut.await.map_err(|e| e.to_string())?; + Ok(users + .into_iter() + .map(|(username, rating)| SimilarUserDto { username, rating }) + .collect()) +} + +#[tauri::command] +pub async fn slsk_browse_user( + app: tauri::AppHandle, + state: State<'_, AppState>, + username: String, +) -> Result, String> { + let fut = { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected to Soulseek")?; + client.browse(&username) + }; + let dirs = fut.await.map_err(|e| e.to_string())?; + // The library overlap rides along: their folders against your albums, + // on this device, and the Discover shelf hears about it by event. + crate::overlap::note_browse(&app, &username, &dirs); + Ok(dirs + .into_iter() + .map(|d| { + let dir_name = d.name.clone(); + BrowseDirDto { + files: d + .files + .into_iter() + .map(|f| { + let bitrate = f.bitrate(); + let duration = f.duration_secs(); + let ext = if f.extension.is_empty() { + f.filename.rsplit('.').next().unwrap_or_default().to_string() + } else { + f.extension.clone() + }; + BrowseFileDto { + path: format!("{dir_name}\\{}", f.filename), + name: f.filename, + size: f.size, + ext, + bitrate, + duration, + } + }) + .collect(), + name: dir_name, + } + }) + .collect()) +} + +#[tauri::command] +pub fn buddy_list(app: tauri::AppHandle) -> Vec { + config::load(&app).buddies +} + +#[tauri::command] +pub async fn buddy_add( + app: tauri::AppHandle, + state: State<'_, AppState>, + username: String, +) -> Result, String> { + let username = username.trim().to_string(); + if username.is_empty() { + return Err("no username".into()); + } + let mut cfg = config::load(&app); + if !cfg.buddies.iter().any(|b| b == &username) { + cfg.buddies.push(username.clone()); + config::save(&app, &cfg); + } + // Watch immediately when connected; on later logins the connect path + // re-watches the whole list. + let _ = with_client(&state, |c| c.watch_user(&username)).await; + let _ = with_client(&state, |c| c.get_user_stats(&username)).await; + Ok(cfg.buddies) +} + +#[tauri::command] +pub async fn buddy_remove( + app: tauri::AppHandle, + state: State<'_, AppState>, + username: String, +) -> Result, String> { + let mut cfg = config::load(&app); + cfg.buddies.retain(|b| b != &username); + config::save(&app, &cfg); + let _ = with_client(&state, |c| c.unwatch_user(&username)).await; + Ok(cfg.buddies) +} + +// ---- catalog browser (Deezer-backed) ---- + +const ARTIST_HERO_MIN_WIDTH: u32 = 1920; +const ARTIST_HERO_MIN_HEIGHT: u32 = 800; +const ARTIST_HERO_MAX_BYTES: u64 = 30 * 1024 * 1024; +const ARTIST_HERO_PROVIDER_TARGET_WIDTH: u32 = 3840; +const ARTIST_HERO_PROVIDER_TARGET_HEIGHT: u32 = 2160; +const ARTIST_HERO_PREVIEW_MAX_WIDTH: u32 = 1920; +const ARTIST_HERO_GPU_PREPARE_CONCURRENCY: usize = 2; +const ARTIST_HERO_FSR_SHARPNESS_STOPS: &str = "0.25"; +const ARTIST_HERO_LUT_STRENGTH: f32 = 0.30; +const ARTIST_HERO_FADE_STRENGTH: f32 = 1.0; +const ARTIST_HERO_GRAIN_STRENGTH: f32 = 0.70; +/// Bump whenever any pixel-producing hero setting or processing order changes. +const ARTIST_HERO_CROP_RECIPE: &str = "vertical-yoloxnano-c789161e-windows-face-v2-faces-first"; +const ARTIST_HERO_CACHE_RECIPE: &str = + "w3840h2160-jpeg93-fsr025-ruby030-fade100-grain070-contentcrop-v5"; +const ARTIST_HERO_CACHE_TTL: std::time::Duration = + std::time::Duration::from_secs(30 * 24 * 60 * 60); +const ARTIST_HERO_CACHE_MAX_BYTES: u64 = 256 * 1024 * 1024; +const ARTIST_HERO_CACHE_MAX_ENTRY_BYTES: u64 = 41 * 1024 * 1024; +// v2 invalidated manifests written before provider artwork was required to +// close the loop back to the requested Deezer identity. v3 invalidates the +// window where an exact-name collision forced every Bing card to cite a +// catalogue release, which discarded the press photography that makes the best +// heroes; those degraded selections are pinned for a day otherwise. Bump this +// whenever hero *selection* changes, not merely its rendering. Keeping the same +// file path is safe: a recipe mismatch forces a fresh lookup and the successful +// result atomically replaces the stale manifest. +// v5: TheAudioDB records referencing a DUPLICATE MusicBrainz entry for the same +// artist are no longer rejected as namesakes, so artists cached under v4 are +// holding a logo-less manifest this build would now fill. Safe to bump: a +// stale-recipe manifest is kept as a floor and only replaced by a better +// lookup, so nobody goes blank while the new one resolves. +// v6: manifests written while Fanart.tv's CDN was refusing connections +// (2026-07-27) hold Bing-tier heroes and would have kept them for a month under +// the flat TTL below. Bumping re-resolves every artist once against the +// providers that are up TODAY; the tier-aware TTL keeps it from happening again. +const ARTIST_ART_MANIFEST_RECIPE: &str = "artist-art-tier-v6-fallback-ttl"; +// A resolved hero is expensive to find and does not go stale the way metadata +// does -- a press photo is still the artist's press photo a month later. The +// old 24h expiry meant every artist was re-discovered daily through a tier that +// fails per-name, so a single bad day blanked artists that had perfectly good +// heroes: 26 manifests on disk (Crazy P, MSPAINT, Sword II, Hermeth, Billy Cobb, +// Pond, Weezer, Daft Punk...) went to 3. Match the cover-resolution horizon. +const ARTIST_ART_MANIFEST_TTL: std::time::Duration = + std::time::Duration::from_secs(30 * 24 * 60 * 60); +// ...but only for art that came from the TOP of the chain. Everything below it +// was a fallback taken because the better provider was unreachable at that +// moment -- `image_host_alive` deliberately reports a dead CDN as "returned +// nothing", which is what lets the chain degrade gracefully -- and a month is +// far too long to live with the consequences of one bad afternoon. Re-checked +// daily instead, so an artist stops wearing a Bing hero the day after Fanart.tv +// comes back. +const ARTIST_ART_FALLBACK_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60); + +/// Did the top provider tier supply the banner this manifest will actually +/// show? The manifest never recorded which provider won, so the tier is read +/// back off the URL's host. +/// +/// Banners only, deliberately: logos routinely and legitimately come from +/// TheAudioDB even when Fanart.tv covered the artist, and treating that as a +/// fallback would put every artist on a daily re-check. An artist Fanart.tv +/// simply does not cover is re-checked daily forever, which is the price of +/// noticing the day they add one. +fn manifest_from_top_tier(art: &metadata::CatalogArtistArt) -> bool { + art.banners.iter().any(|url| { + // Every stored URL is validated as `https://...` before it gets here, + // so the authority is simply what sits between the scheme and the + // first slash (minus any port). + url.strip_prefix("https://") + .and_then(|rest| rest.split('/').next()) + .map(|authority| authority.split(':').next().unwrap_or(authority)) + .is_some_and(|host| host == "fanart.tv" || host.ends_with(".fanart.tv")) + }) +} +const ARTIST_ART_MANIFEST_MAX_BYTES: u64 = 64 * 1024; +// Identity is part of the key because two Deezer artists can share the same +// display name (and, occasionally, the same release title/reference artwork). +// The resolver's Apple candidate selection is artist-ID scoped as of v2. +const COVER_RESOLUTION_RECIPE: &str = "same-art-dhash10-v2-identity"; +const COVER_RESOLUTION_TTL: std::time::Duration = std::time::Duration::from_secs(30 * 24 * 60 * 60); +const COVER_RESOLUTION_CACHE_MAX_FILES: usize = 4096; +const ARTIST_ART_MANIFEST_CACHE_MAX_FILES: usize = 512; +const PERSISTENT_CACHE_SWEEP_INTERVAL_SECS: u64 = 60 * 60; + +fn artist_hero_gpu_prepare_slots() -> &'static tokio::sync::Semaphore { + static SLOTS: std::sync::OnceLock = std::sync::OnceLock::new(); + SLOTS.get_or_init(|| tokio::sync::Semaphore::new(ARTIST_HERO_GPU_PREPARE_CONCURRENCY)) +} + +#[derive(Serialize, Deserialize)] +struct CachedArtistArtManifest { + recipe: String, + artist_id: u64, + art: metadata::CatalogArtistArt, +} + +#[derive(Serialize, Deserialize)] +struct CachedCoverResolution { + recipe: String, + best_url: String, +} + +fn maybe_sweep_cache_directory( + directory: &std::path::Path, + ttl: std::time::Duration, + max_files: usize, + last_sweep: &std::sync::atomic::AtomicU64, + file_count: &std::sync::atomic::AtomicUsize, + sweep_in_progress: &std::sync::atomic::AtomicBool, +) { + use std::sync::atomic::Ordering; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let previous = last_sweep.load(Ordering::Relaxed); + let sweep_due = now.saturating_sub(previous) >= PERSISTENT_CACHE_SWEEP_INTERVAL_SECS; + let at_capacity = file_count.load(Ordering::Relaxed) >= max_files; + if (!sweep_due && !at_capacity) + || sweep_in_progress + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + return; + } + last_sweep.store(now, Ordering::Relaxed); + + let Ok(entries) = std::fs::read_dir(directory) else { + sweep_in_progress.store(false, Ordering::Release); + return; + }; + let mut retained = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let Ok(metadata) = entry.metadata() else { + continue; + }; + if !metadata.is_file() { + continue; + } + let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH); + let expired = modified.elapsed().is_ok_and(|age| age >= ttl); + if expired { + let _ = std::fs::remove_file(path); + } else { + retained.push((modified, path)); + } + } + // This runs immediately before a write. Prune to a 90% low-water mark: + // leaving only one slot would force a 4K-entry directory scan + sort on + // every subsequent cover write once the cache first reached capacity. + let retained_limit = max_files + .saturating_mul(9) + .checked_div(10) + .unwrap_or(0) + .min(max_files.saturating_sub(1)); + let mut removed = 0usize; + if retained.len() > retained_limit { + retained.sort_unstable_by_key(|(modified, _)| *modified); + removed = retained.len() - retained_limit; + for (_, path) in retained.iter().take(removed) { + let _ = std::fs::remove_file(path); + } + } + file_count.store(retained.len() - removed, Ordering::Relaxed); + sweep_in_progress.store(false, Ordering::Release); +} + +fn cover_resolution_cache_path( + app: &tauri::AppHandle, + artist_id: Option, + artist: &str, + title: &str, + cover: &str, +) -> Result { + use std::hash::{Hash as _, Hasher as _}; + let dir = app + .path() + .app_cache_dir() + .map_err(|error| error.to_string())? + .join("cover-resolutions-v1"); + std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + let mut key = std::collections::hash_map::DefaultHasher::new(); + COVER_RESOLUTION_RECIPE.hash(&mut key); + artist_id.hash(&mut key); + artist.hash(&mut key); + title.hash(&mut key); + cover.hash(&mut key); + Ok(dir.join(format!("{:016x}.json", key.finish()))) +} + +async fn load_cover_resolution( + app: &tauri::AppHandle, + artist_id: Option, + artist: &str, + title: &str, + cover: &str, +) -> Option { + let path = cover_resolution_cache_path(app, artist_id, artist, title, cover).ok()?; + let metadata = tokio::fs::metadata(&path).await.ok()?; + let fresh = metadata + .modified() + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age < COVER_RESOLUTION_TTL); + if !fresh || metadata.len() > 4096 { + let _ = tokio::fs::remove_file(path).await; + return None; + } + let cached = + serde_json::from_slice::(&tokio::fs::read(path).await.ok()?).ok()?; + (cached.recipe == COVER_RESOLUTION_RECIPE && cached.best_url.starts_with("https://")) + .then_some(cached.best_url) +} + +fn store_cover_resolution(path: &std::path::Path, best_url: &str) -> Result<(), String> { + static LAST_SWEEP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + static FILE_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + static SWEEP_IN_PROGRESS: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + if let Some(directory) = path.parent() { + maybe_sweep_cache_directory( + directory, + COVER_RESOLUTION_TTL, + COVER_RESOLUTION_CACHE_MAX_FILES, + &LAST_SWEEP, + &FILE_COUNT, + &SWEEP_IN_PROGRESS, + ); + } + let replaces_existing = path.is_file(); + let cached = CachedCoverResolution { + recipe: COVER_RESOLUTION_RECIPE.to_string(), + best_url: best_url.to_string(), + }; + let bytes = serde_json::to_vec(&cached).map_err(|error| error.to_string())?; + let temporary = path.with_extension(format!("{}.tmp", std::process::id())); + std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?; + if let Err(first_error) = std::fs::rename(&temporary, path) { + let _ = std::fs::remove_file(path); + if let Err(retry_error) = std::fs::rename(&temporary, path) { + let _ = std::fs::remove_file(&temporary); + return Err(format!( + "cover resolution cache rename failed ({first_error}); replacement failed ({retry_error})" + )); + } + } + if !replaces_existing { + FILE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + Ok(()) +} + +fn artist_art_manifest_path( + app: &tauri::AppHandle, + artist_id: u64, +) -> Result { + let dir = app + .path() + .app_cache_dir() + .map_err(|error| error.to_string())? + .join("artist-art-manifests-v1"); + std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + Ok(dir.join(format!("{artist_id}.json"))) +} + +/// A persisted manifest that passed structural validation, plus whether it can +/// be served without asking the network again. +struct StoredArtistArt { + art: metadata::CatalogArtistArt, + /// Fresh enough AND written by the current selection recipe. + current: bool, +} + +/// Read a persisted manifest without destroying it. +/// +/// This deliberately does NOT delete stale or older-recipe manifests. It used +/// to, and that is what blanked artists: the file was removed on the way in, +/// then discovery failed, and an artist that had a perfectly good hero five +/// minutes earlier had nothing at all. A known hero is only ever replaced by a +/// better one now -- see `catalog_artist_art`. Only genuinely unusable files +/// (oversized or unparseable) are removed. +async fn read_artist_art_manifest( + app: &tauri::AppHandle, + artist_id: u64, +) -> Option { + let path = artist_art_manifest_path(app, artist_id).ok()?; + let metadata = tokio::fs::metadata(&path).await.ok()?; + if metadata.len() > ARTIST_ART_MANIFEST_MAX_BYTES { + let _ = tokio::fs::remove_file(path).await; + return None; + } + // Age is taken here but judged below: how long this manifest counts as + // fresh depends on which provider tier produced it, which is only knowable + // once it has been parsed. + let age = metadata + .modified() + .ok() + .and_then(|modified| modified.elapsed().ok()); + let bytes = tokio::fs::read(&path).await.ok()?; + let Ok(manifest) = serde_json::from_slice::(&bytes) else { + let _ = tokio::fs::remove_file(path).await; + return None; + }; + let art = manifest.art; + let valid_urls = art + .banners + .iter() + .chain(art.logos.iter()) + .all(|url| url.starts_with("https://")); + // Identity and shape must hold whatever the recipe was; a manifest for the + // wrong artist is never usable, however recent. + if manifest.artist_id != artist_id + || art.artist.id != artist_id + || art.banner_custom + || art.banners.len() > 8 + || art.logos.len() > 8 + || !valid_urls + { + return None; + } + let ttl = if manifest_from_top_tier(&art) { + ARTIST_ART_MANIFEST_TTL + } else { + ARTIST_ART_FALLBACK_TTL + }; + let fresh = age.is_some_and(|age| age < ttl); + let current = fresh && manifest.recipe == ARTIST_ART_MANIFEST_RECIPE; + Some(StoredArtistArt { art, current }) +} + +/// The strict read: only a manifest that is fresh and matches the current +/// recipe. Callers that must not show an out-of-recipe hero use this. +async fn load_artist_art_manifest( + app: &tauri::AppHandle, + artist_id: u64, +) -> Option { + read_artist_art_manifest(app, artist_id) + .await + .and_then(|stored| stored.current.then_some(stored.art)) +} + +/// Persist a resolved manifest off the request path. Re-stamps whatever is +/// written with the current recipe, which is also how an older-recipe manifest +/// that survived as a floor gets migrated forward. +fn store_art_manifest_in_background( + app: &tauri::AppHandle, + artist_id: u64, + art: &metadata::CatalogArtistArt, +) { + let store_app = app.clone(); + let store_art = art.clone(); + tauri::async_runtime::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + store_artist_art_manifest(&store_app, artist_id, &store_art) + }) + .await; + if let Ok(Err(error)) = result { + log::warn!("could not cache artist artwork manifest: {error}"); + } + }); +} + +fn store_artist_art_manifest( + app: &tauri::AppHandle, + artist_id: u64, + art: &metadata::CatalogArtistArt, +) -> Result<(), String> { + if art.banners.is_empty() && art.logos.is_empty() { + return Ok(()); + } + let path = artist_art_manifest_path(app, artist_id)?; + static LAST_SWEEP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + static FILE_COUNT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + static SWEEP_IN_PROGRESS: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + if let Some(directory) = path.parent() { + maybe_sweep_cache_directory( + directory, + ARTIST_ART_MANIFEST_TTL, + ARTIST_ART_MANIFEST_CACHE_MAX_FILES, + &LAST_SWEEP, + &FILE_COUNT, + &SWEEP_IN_PROGRESS, + ); + } + let replaces_existing = path.is_file(); + let manifest = CachedArtistArtManifest { + recipe: ARTIST_ART_MANIFEST_RECIPE.to_string(), + artist_id, + art: art.clone(), + }; + let bytes = serde_json::to_vec(&manifest).map_err(|error| error.to_string())?; + if bytes.len() as u64 > ARTIST_ART_MANIFEST_MAX_BYTES { + return Err("artist artwork manifest is unexpectedly large".into()); + } + let temporary = path.with_extension(format!("{}.tmp", std::process::id())); + std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?; + if let Err(first_error) = std::fs::rename(&temporary, &path) { + let _ = std::fs::remove_file(&path); + if let Err(retry_error) = std::fs::rename(&temporary, &path) { + let _ = std::fs::remove_file(&temporary); + return Err(format!( + "artist art manifest rename failed ({first_error}); replacement failed ({retry_error})" + )); + } + } + if !replaces_existing { + FILE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + Ok(()) +} + +#[derive(Serialize)] +pub struct ArtistHeroOverride { + pub data_url: String, + pub width: u32, + pub height: u32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CatalogArtistHeroSeed { + pub banner: String, + pub banner_custom: bool, +} + +#[derive(Serialize)] +pub struct CatalogHeroArtResult { + pub data_url: String, + pub cacheable: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CatalogHeroCropPlan { + pub recipe: String, + pub source_checksum: String, + #[serde(flatten)] + pub geometry: HeroCropPlan, +} + +#[derive(Serialize)] +pub struct CatalogHeroPreviewResult { + pub data_url: String, + pub plan: CatalogHeroCropPlan, +} + +/// Header preceding the raw RGBA payload returned to the live GPU hero path. +/// The WebView receives this as an ArrayBuffer through Tauri's binary IPC, +/// avoiding both base64 expansion and a lossy JPEG round-trip: +/// +/// magic[4] | width(u32 LE) | height(u32 LE) | grain_seed(u32 LE) | RGBA8 +const CATALOG_HERO_GPU_MAGIC: &[u8; 4] = b"SHG1"; +const CATALOG_HERO_GPU_HEADER_BYTES: usize = 16; + +/// The CHOSEN hero per artist — a decision about this library, not a render +/// cache — so it travels with the library. (The upscaled results keyed by URL +/// and recipe stay in the machine's cache dir: they regenerate cheaply and +/// would otherwise put gigabytes of derived pixels on the drive.) +fn artist_hero_dir(app: &tauri::AppHandle) -> Result { + let cfg = config::load(app); + let dir = config::ensure_library_data_dir(app, &cfg) + .map_err(|e| e.to_string())? + .join("artist-heroes"); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +fn finished_hero_cache_path( + app: &tauri::AppHandle, + url: &str, + upscaler: config::ArtistHeroUpscaler, +) -> Result { + use std::hash::{Hash as _, Hasher as _}; + let dir = app + .path() + .app_cache_dir() + .map_err(|error| error.to_string())? + .join("finished-artist-heroes"); + std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + let mut key = std::collections::hash_map::DefaultHasher::new(); + ARTIST_HERO_CACHE_RECIPE.hash(&mut key); + match upscaler { + config::ArtistHeroUpscaler::LiveGpu => "live_gpu", + config::ArtistHeroUpscaler::Fsr => "fsr", + config::ArtistHeroUpscaler::Lanczos3 => "lanczos3", + } + .hash(&mut key); + url.hash(&mut key); + Ok(dir.join(format!("{:016x}.data", key.finish()))) +} + +fn finished_hero_checksum(data_url: &str) -> u64 { + use std::hash::{Hash as _, Hasher as _}; + let mut checksum = std::collections::hash_map::DefaultHasher::new(); + data_url.as_bytes().hash(&mut checksum); + checksum.finish() +} + +fn validate_finished_hero(mut cached: String) -> Option { + let footer = cached.rfind('\n')?; + let expected = u64::from_str_radix(cached.get(footer + 1..)?, 16).ok()?; + cached.truncate(footer); + (cached.len() > 1_000 + && cached.starts_with("data:image/") + && cached.contains(";base64,") + && finished_hero_checksum(&cached) == expected) + .then_some(cached) +} + +fn store_finished_hero(path: &std::path::Path, data_url: &str) -> Result<(), String> { + use std::io::Write as _; + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT_WRITE: AtomicU64 = AtomicU64::new(1); + + let id = NEXT_WRITE.fetch_add(1, Ordering::Relaxed); + let temporary = path.with_extension(format!("{}.{}.tmp", std::process::id(), id)); + let mut file = std::fs::File::create(&temporary).map_err(|error| error.to_string())?; + file.write_all(data_url.as_bytes()) + .map_err(|error| error.to_string())?; + write!(file, "\n{:016x}", finished_hero_checksum(data_url)) + .map_err(|error| error.to_string())?; + file.sync_all().map_err(|error| error.to_string())?; + drop(file); + if let Err(first_error) = std::fs::rename(&temporary, path) { + let existing_is_valid = std::fs::read_to_string(path) + .ok() + .and_then(validate_finished_hero) + .is_some(); + if existing_is_valid { + let _ = std::fs::remove_file(&temporary); + } else { + let _ = std::fs::remove_file(path); + if let Err(retry_error) = std::fs::rename(&temporary, path) { + let _ = std::fs::remove_file(&temporary); + return Err(format!( + "cache rename failed ({first_error}); replacement failed ({retry_error})" + )); + } + } + } + + // Bound the persistent cache by evicting oldest outputs. This runs only + // after an expensive new render, never on the latency-sensitive read path. + let Some(dir) = path.parent() else { + return Ok(()); + }; + let mut files = std::fs::read_dir(dir) + .map_err(|error| error.to_string())? + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("data") { + return None; + } + let metadata = entry.metadata().ok()?; + metadata.is_file().then_some(( + path, + metadata.len(), + metadata.modified().unwrap_or(std::time::UNIX_EPOCH), + )) + }) + .collect::>(); + let mut total = files.iter().map(|(_, bytes, _)| *bytes).sum::(); + files.sort_by_key(|(_, _, modified)| *modified); + for (oldest, bytes, _) in files { + if total <= ARTIST_HERO_CACHE_MAX_BYTES { + break; + } + if std::fs::remove_file(oldest).is_ok() { + total = total.saturating_sub(bytes); + } + } + Ok(()) +} + +async fn load_finished_hero(path: &std::path::Path) -> Option { + let metadata = tokio::fs::metadata(path).await.ok()?; + let fresh = metadata + .modified() + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age < ARTIST_HERO_CACHE_TTL); + if fresh && metadata.len() <= ARTIST_HERO_CACHE_MAX_ENTRY_BYTES { + if let Ok(cached) = tokio::fs::read_to_string(path).await { + if let Some(cached) = validate_finished_hero(cached) { + return Some(cached); + } + } + } + let _ = tokio::fs::remove_file(path).await; + None +} + +fn artist_image_format(path: &std::path::Path) -> Result<(&'static str, &'static str), String> { + let format = image::ImageReader::open(path) + .map_err(|e| e.to_string())? + .with_guessed_format() + .map_err(|e| e.to_string())? + .format() + .ok_or_else(|| "Could not identify that image format".to_string())?; + match format { + image::ImageFormat::Jpeg => Ok(("jpg", "image/jpeg")), + image::ImageFormat::Png => Ok(("png", "image/png")), + image::ImageFormat::WebP => Ok(("webp", "image/webp")), + _ => Err("Choose a JPEG, PNG, or WebP image".into()), + } +} + +fn validate_artist_hero(path: &std::path::Path) -> Result<(u32, u32), String> { + let size = std::fs::metadata(path).map_err(|e| e.to_string())?.len(); + if size > ARTIST_HERO_MAX_BYTES { + return Err("Hero images must be 30 MB or smaller".into()); + } + let (width, height) = image::image_dimensions(path).map_err(|e| e.to_string())?; + if width < ARTIST_HERO_MIN_WIDTH || height < ARTIST_HERO_MIN_HEIGHT { + return Err(format!( + "That image is {width}×{height}. Choose at least {ARTIST_HERO_MIN_WIDTH}×{ARTIST_HERO_MIN_HEIGHT}." + )); + } + if u64::from(width) * 10 < u64::from(height) * 13 { + return Err(format!( + "That image is {width}×{height}. Choose a landscape photo for the wide hero." + )); + } + Ok((width, height)) +} + +fn load_artist_hero_override( + app: &tauri::AppHandle, + artist_id: u64, +) -> Result, String> { + use base64::Engine as _; + let dir = artist_hero_dir(app)?; + for (ext, mime) in [ + ("jpg", "image/jpeg"), + ("png", "image/png"), + ("webp", "image/webp"), + ] { + let path = dir.join(format!("{artist_id}.{ext}")); + if !path.is_file() { + continue; + } + let (width, height) = validate_artist_hero(&path)?; + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + return Ok(Some(ArtistHeroOverride { + data_url: format!( + "data:{mime};base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ), + width, + height, + })); + } + Ok(None) +} + +/// Import a user-owned high-resolution artist hero into app data. The numeric +/// catalog id makes the stored filename collision-safe without trusting an +/// artist name as a filesystem path. +#[tauri::command] +pub async fn pick_artist_hero( + app: tauri::AppHandle, + artist_id: u64, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .add_filter("High-resolution image", &["jpg", "jpeg", "png", "webp"]) + .pick_file(move |path| { + let _ = tx.send(path.map(|p| p.to_string())); + }); + let Some(source) = rx.await.unwrap_or(None) else { + return Ok(None); + }; + let source = std::path::PathBuf::from(source); + let (width, height) = validate_artist_hero(&source)?; + let (ext, _) = artist_image_format(&source)?; + let dir = artist_hero_dir(&app)?; + let temporary = dir.join(format!("{artist_id}.importing")); + std::fs::copy(&source, &temporary).map_err(|e| e.to_string())?; + for old_ext in ["jpg", "png", "webp"] { + let _ = std::fs::remove_file(dir.join(format!("{artist_id}.{old_ext}"))); + } + let destination = dir.join(format!("{artist_id}.{ext}")); + if let Err(error) = std::fs::rename(&temporary, &destination) { + let _ = std::fs::remove_file(&temporary); + return Err(error.to_string()); + } + let mut hero = load_artist_hero_override(&app, artist_id)? + .ok_or_else(|| "The imported hero could not be reopened".to_string())?; + hero.width = width; + hero.height = height; + Ok(Some(hero)) +} + +#[tauri::command] +pub fn clear_artist_hero(app: tauri::AppHandle, artist_id: u64) -> Result { + let dir = artist_hero_dir(&app)?; + let mut removed = false; + for ext in ["jpg", "png", "webp"] { + let path = dir.join(format!("{artist_id}.{ext}")); + if path.is_file() { + std::fs::remove_file(path).map_err(|e| e.to_string())?; + removed = true; + } + } + Ok(removed) +} + +#[tauri::command] +pub async fn catalog_search( + state: State<'_, LibraryState>, + query: String, +) -> Result { + state.touch_catalog_activity(); + Ok(state.metadata.catalog_search(&query).await) +} + +#[tauri::command] +pub async fn catalog_search_tracks( + state: State<'_, LibraryState>, + query: String, + limit: Option, +) -> Result, String> { + state.touch_catalog_activity(); + Ok(state + .metadata + .catalog_search_tracks(&query, limit.unwrap_or(10)) + .await) +} + +#[tauri::command] +pub async fn catalog_artist_track_candidates( + state: State<'_, LibraryState>, + artist: String, + title: String, +) -> Result, String> { + state.touch_catalog_activity(); + Ok(state + .metadata + .catalog_artist_track_candidates(&artist, &title) + .await) +} + +#[tauri::command] +pub async fn catalog_track_by_isrc( + state: State<'_, LibraryState>, + isrc: String, +) -> Result, String> { + state.touch_catalog_activity(); + Ok(state.metadata.catalog_track_by_isrc(&isrc).await) +} + +#[tauri::command] +pub async fn catalog_artist( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result { + state.touch_catalog_activity(); + let mut page = state + .metadata + .catalog_artist(artist_id) + .await + .ok_or_else(|| "artist lookup failed".to_string())?; + if let Some(custom) = load_artist_hero_override(&app, artist_id)? { + page.banner = Some(custom.data_url.clone()); + // A deliberate user override remains fixed instead of being diluted by + // automatic provider art. Clearing it restores the provider carousel. + page.banners = vec![custom.data_url]; + page.banner_custom = true; + } + Ok(page) +} + +#[tauri::command] +pub async fn catalog_artist_art( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result { + // The selected provider/Bing URL tier is stable for the same period as the + // metadata cache. Persisting that tiny manifest means an app restart can + // address the already-known hero immediately instead of rediscovering Bing. + let stored = read_artist_art_manifest(&app, artist_id).await; + let mut art = match stored { + // Fresh, current recipe, and the host is still serving the images: + // serve it, no network. The liveness check matters because a manifest + // outlives the CDN behind it -- measured 2026-07-28, Fanart.tv's API + // stayed up while `assets.fanart.tv` refused every connection, so every + // stored manifest naming it replayed a hero that could never load and + // short-circuited the discovery that would have found a working one. + // One probe per host per 5 minutes, so the hero returns on its own once + // the CDN does. + Some(stored) + if stored.current && state.metadata.art_is_servable(&stored.art).await => + { + stored.art + } + // Either nothing on disk, or something stale/older-recipe. Try to do + // better, but keep what we have as a floor: the discovery tier fails + // per-artist-name (Bing collapses rare names onto popular entities and + // intermittently serves other queries' results), so a failed lookup is + // a normal event, not a reason to show an artist with no hero. + other => { + let resolved = state.metadata.catalog_artist_art(artist_id).await; + // Only banners we already had AND can still show count as a floor. + // Restoring unreachable ones would undo the whole point of falling + // through: discovery correctly dropped a dead CDN, and the floor + // would put it straight back. Note that `catalog_artist_art` + // returns Some as soon as the Deezer *profile* resolves, so "found + // nothing" arrives as Some with an empty banner list -- an + // emptiness check, not a None check, is what detects a failed hero + // discovery. + let previous = match other.map(|stored| stored.art) { + Some(art) + if !art.banners.is_empty() + && state.metadata.art_is_servable(&art).await => + { + Some(art) + } + _ => None, + }; + match (resolved, previous) { + (Some(mut resolved), Some(previous)) if resolved.banners.is_empty() => { + log::info!( + "artist {artist_id}: hero discovery found nothing, keeping {} \ + previously resolved banner(s)", + previous.banners.len() + ); + resolved.banner = previous.banner; + resolved.banners = previous.banners; + if resolved.logos.is_empty() { + resolved.logos = previous.logos; + } + store_art_manifest_in_background(&app, artist_id, &resolved); + resolved + } + (Some(resolved), _) => { + store_art_manifest_in_background(&app, artist_id, &resolved); + resolved + } + (None, Some(previous)) => previous, + (None, None) => return Err("artist artwork lookup failed".to_string()), + } + } + }; + if let Some(custom) = load_artist_hero_override(&app, artist_id)? { + art.banner = Some(custom.data_url.clone()); + art.banners = vec![custom.data_url]; + art.banner_custom = true; + } + Ok(art) +} + +/// Definitive provider-logo candidates without waiting for the independent +/// Bing hero fallback. The frontend uses this to decide whether cover OCR is +/// allowed as soon as the exact release list is ready. +#[tauri::command] +pub async fn catalog_artist_logos( + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result, String> { + state + .metadata + .catalog_artist_logos(artist_id) + .await + .ok_or_else(|| "artist logo lookup failed".to_string()) +} + +/// Deferred MusicBrainz pass — MB-only releases + type hints. Kept out of +/// `catalog_artist` so its rate-limited (1 req/s) calls never delay the grid. +/// Socials ride `catalog_artist_socials` below, off this critical path. +#[tauri::command] +pub async fn catalog_artist_identity( + state: State<'_, LibraryState>, + artist_id: u64, + artist: String, +) -> Result { + Ok(state + .metadata + .catalog_artist_identity(artist_id, &artist) + .await) +} + +/// Identity-anchored artist biography for the Now Playing panel. Replaces a +/// frontend "first Wikipedia hit for ` musician band`" lookup that +/// regularly described a different artist entirely, and which also bypassed +/// this client's Wikimedia UA/pacing/caching. +#[tauri::command] +pub async fn catalog_artist_bio( + state: State<'_, LibraryState>, + artist_id: Option, + artist: String, +) -> Result, String> { + state.touch_catalog_activity(); + Ok(state.metadata.artist_bio(artist_id, &artist).await) +} + +/// Persist "this Deezer artist record holds more than one act, show this +/// portrait instead". +/// +/// The verdict costs rate-limited MusicBrainz and Apple lookups, so the search +/// bar — typed against per keystroke — can only ever consult a remembered one. +/// The frontend calls this once the identity merge has proven the record fused +/// AND the identity-gated hero has resolved, so every later surface (suggestion +/// dropdown, search results, related artists, Now Playing) agrees for free. +#[tauri::command] +pub async fn remember_fused_artist_portrait( + state: State<'_, LibraryState>, + artist_id: u64, + portrait: Option, +) -> Result<(), String> { + state + .metadata + .remember_fused_portrait(artist_id, portrait); + Ok(()) +} + +/// Footer links, fetched only after the sorting-critical identity merge has +/// landed — one MusicBrainz request that used to cost every cold artist ~1 s +/// of time-to-correctly-sorted inside the identity join. +#[tauri::command] +pub async fn catalog_artist_socials( + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result, String> { + Ok(state.metadata.catalog_artist_socials(artist_id).await) +} + +// v4: the alpha ramp now scales to the measured ink distance, so every cached +// mask predates the anti-aliasing this pipeline relies on and must be redrawn. +// v5: three changes, each of which can turn a previously-empty result into a +// mark (or trim one that over-reached), so every v4 entry is stale by +// construction — the alpha ramp's ceiling can no longer exceed the ink's own +// distance, the dominance guard admits flat multi-tone crops, and a row walk +// that never met a gap falls back to the OCR box instead of running to the crop +// edge. Wand had four covers printing its name and v4 extracted none of them. +// v10: the winner's supersample scale is fractional instead of a floored +// integer. Every mask whose crop was wider than half the target was previously +// supersampled by exactly 1x — i.e. not at all — so the covers this rewrites are +// the LARGE printed wordmarks, which is where the staircase was worst. +const COVER_LOGO_RECIPE: &str = "cover-logo-v10-supersample-2400-fractional"; +// A prolific artist's wordmark is often nowhere near the top of this list. The +// rank puts albums first and penalises `remix|mix|edit|live` titles — but for +// Poolside (74 releases) every one of the SEVEN covers that OCR reads as +// "POOLSIDE" is a remix single, so all seven sat outside a 14-cover window and +// the artist silently lost its logo. Raising the cap costs nothing for artists +// that hit early: the locate loop stops as soon as COVER_LOGO_MAX_HITS is +// reached, so the extra covers are only ever fetched when the early ones miss. +// The price is paid by artists with no wordmark anywhere, who now OCR more +// covers before giving up — once, then it is cached. +const COVER_LOGO_MAX_COVERS: usize = 40; +const COVER_LOGO_MAX_HITS: usize = 5; +const COVER_LOGO_LOCATE_WINDOW: usize = 4; +const COVER_LOGO_UPGRADE_WINDOW: usize = 4; + +/// Completed cover-derived logos, including session-scoped negative results. +/// The full input fingerprint prevents namesakes or a changed ranked cover list +/// from sharing a result. +fn cover_logo_cache() -> &'static std::sync::Mutex>> +{ + static CACHE: std::sync::OnceLock< + std::sync::Mutex>>, + > = std::sync::OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +type CoverLogoResult = Result, String>; + +/// Rapid navigation can ask for the same artist again before the first OCR pass +/// finishes. Share that work instead of launching a second full pipeline. +fn cover_logo_inflight() -> &'static tokio::sync::Mutex< + std::collections::HashMap>>, +> { + static INFLIGHT: std::sync::OnceLock< + tokio::sync::Mutex< + std::collections::HashMap< + String, + std::sync::Arc>, + >, + >, + > = std::sync::OnceLock::new(); + INFLIGHT.get_or_init(|| tokio::sync::Mutex::new(std::collections::HashMap::new())) +} + +/// One album cover the frontend offers as a wordmark source: its title (so the +/// backend can resolve the same art at a higher resolution) and its cover URL. +#[derive(Debug, Clone, Deserialize)] +pub struct CoverRef { + pub title: String, + pub url: String, +} + +fn cover_logo_key(artist_id: u64, artist: &str, covers: &[CoverRef]) -> String { + use std::hash::{Hash as _, Hasher as _}; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + COVER_LOGO_RECIPE.hash(&mut hash); + artist_id.hash(&mut hash); + artist.to_lowercase().hash(&mut hash); + for cover in covers.iter().take(COVER_LOGO_MAX_COVERS) { + cover.title.hash(&mut hash); + cover.url.hash(&mut hash); + } + format!("{artist_id}-{:016x}", hash.finish()) +} + +fn cover_logo_disk_dir(app: &tauri::AppHandle) -> Option { + // `catalog_cached_cover_logo` restores the newest positive file by artist + // id before the current cover fingerprint is available. Isolate this + // identity-hardened generation so a pre-hardening logo derived from a + // fused namesake release can never flash during that early restore. + let dir = app.path().app_cache_dir().ok()?.join("cover-logos-v3"); + std::fs::create_dir_all(&dir).ok()?; + Some(dir) +} + +fn cover_logo_disk_path(app: &tauri::AppHandle, key: &str) -> Option { + Some(cover_logo_disk_dir(app)?.join(format!("{key}.data"))) +} + +fn valid_cached_cover_logo(value: &str) -> bool { + value.starts_with("data:image/png;base64,") && value.len() > 64 +} + +async fn read_cover_logo_disk(path: &std::path::Path) -> Option { + let value = tokio::fs::read_to_string(path).await.ok()?; + valid_cached_cover_logo(&value).then_some(value) +} + +async fn write_cover_logo_disk(path: &std::path::Path, value: &str) { + let temp = path.with_extension(format!("tmp-{}", std::process::id())); + if tokio::fs::write(&temp, value).await.is_err() { + return; + } + // Windows rename does not replace an existing destination. Invalid or + // interrupted cache entries are disposable and regenerated here. + let _ = tokio::fs::remove_file(path).await; + if tokio::fs::rename(&temp, path).await.is_err() { + let _ = tokio::fs::remove_file(&temp).await; + } +} + +struct LocatedCoverLogo { + rank: usize, + title: String, + url: String, + bytes_1000: std::sync::Arc>, + loc: crate::cover_logo::BandLocation, + dims_1000: (u32, u32), +} + +struct DownloadedCoverLogo { + rank: usize, + title: String, + url: String, + bytes_1000: std::sync::Arc>, +} + +struct CoverLogoDownloadJob { + download_elapsed: std::time::Duration, + downloaded: Option, +} + +async fn download_cover_logo_candidate( + metadata: std::sync::Arc, + rank: usize, + cover: CoverRef, +) -> CoverLogoDownloadJob { + let download_started = std::time::Instant::now(); + let Some(bytes_1000) = metadata.cover_bytes(&cover.url).await else { + return CoverLogoDownloadJob { + download_elapsed: download_started.elapsed(), + downloaded: None, + }; + }; + CoverLogoDownloadJob { + download_elapsed: download_started.elapsed(), + downloaded: Some(DownloadedCoverLogo { + rank, + title: cover.title, + url: cover.url, + bytes_1000, + }), + } +} + +async fn locate_downloaded_cover_logo( + downloaded: DownloadedCoverLogo, + artist: std::sync::Arc, +) -> (std::time::Duration, Option) { + let locate_started = std::time::Instant::now(); + let bytes_for_ocr = std::sync::Arc::clone(&downloaded.bytes_1000); + let found = tokio::task::spawn_blocking(move || { + use image::GenericImageView as _; + let image = image::load_from_memory(bytes_for_ocr.as_slice()).ok()?; + let dims = image.dimensions(); + let loc = crate::cover_logo::locate_wordmark(&image, &artist) + .ok() + .flatten()?; + Some((loc, dims)) + }) + .await + .ok() + .flatten(); + let locate_elapsed = locate_started.elapsed(); + ( + locate_elapsed, + found.map(|(loc, dims_1000)| LocatedCoverLogo { + rank: downloaded.rank, + title: downloaded.title, + url: downloaded.url, + bytes_1000: downloaded.bytes_1000, + loc, + dims_1000, + }), + ) +} + +struct CoverLogoCandidate { + rank: usize, + title: String, + bytes_1000: std::sync::Arc>, + loc: crate::cover_logo::BandLocation, + dims_1000: (u32, u32), + bytes_hi: Option>>, +} + +struct CoverLogoUpgradeJob { + elapsed: std::time::Duration, + candidate: CoverLogoCandidate, +} + +async fn upgrade_cover_logo_candidate( + metadata: std::sync::Arc, + artist_id: u64, + artist: std::sync::Arc, + located: LocatedCoverLogo, +) -> CoverLogoUpgradeJob { + let started = std::time::Instant::now(); + let hi_url = metadata + .resolve_cover_for_artist_id(Some(artist_id), &artist, &located.title, &located.url) + .await + .unwrap_or_else(|| located.url.clone()); + let bytes_hi = if hi_url != located.url { + metadata.cover_bytes(&hi_url).await + } else { + None + }; + CoverLogoUpgradeJob { + elapsed: started.elapsed(), + candidate: CoverLogoCandidate { + rank: located.rank, + title: located.title, + bytes_1000: located.bytes_1000, + loc: located.loc, + dims_1000: located.dims_1000, + bytes_hi, + }, + } +} + +async fn generate_cover_logo( + metadata: std::sync::Arc, + artist_id: u64, + artist: String, + covers: Vec, +) -> CoverLogoResult { + let total_started = std::time::Instant::now(); + let offered: Vec<_> = covers.into_iter().take(COVER_LOGO_MAX_COVERS).collect(); + let artist_shared: std::sync::Arc = std::sync::Arc::from(artist.as_str()); + + // Download and OCR a rank-ordered sliding window. Tasks run concurrently, + // but awaiting the handles from the front commits results in original rank + // order. Stopping at hit five therefore selects exactly the old first-five + // hit set; at most WINDOW-1 later covers have speculative work in flight. + let locate_pipeline_started = std::time::Instant::now(); + let mut locate_pending = std::collections::VecDeque::new(); + let mut next_rank = 0usize; + while next_rank < offered.len() && locate_pending.len() < COVER_LOGO_LOCATE_WINDOW { + let metadata = std::sync::Arc::clone(&metadata); + let cover = offered[next_rank].clone(); + let rank = next_rank; + locate_pending.push_back(( + rank, + tokio::spawn(download_cover_logo_candidate(metadata, rank, cover)), + )); + next_rank += 1; + } + + let upgrade_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(COVER_LOGO_UPGRADE_WINDOW)); + let mut upgrade_pending = Vec::new(); + let mut upgrade_started: Option = None; + let mut hit_summary = Vec::new(); + let mut located_count = 0usize; + let mut downloaded_count = 0usize; + let mut download_work = std::time::Duration::ZERO; + let mut locate_work = std::time::Duration::ZERO; + while let Some((rank, handle)) = locate_pending.pop_front() { + match handle.await { + Ok(job) => { + download_work += job.download_elapsed; + if let Some(downloaded) = job.downloaded { + downloaded_count += 1; + // OCR itself stays rank-ordered and sequential. Windows OCR + // produced occasional different boxes under parallel engine + // pressure; overlapping downloads retains most of the wall + // win while preserving the original deterministic pass order. + let (elapsed, hit) = locate_downloaded_cover_logo( + downloaded, + std::sync::Arc::clone(&artist_shared), + ) + .await; + locate_work += elapsed; + if let Some(hit) = hit { + located_count += 1; + hit_summary.push((hit.rank, hit.title.clone())); + upgrade_started.get_or_insert_with(std::time::Instant::now); + let rank = hit.rank; + let metadata = std::sync::Arc::clone(&metadata); + let artist = std::sync::Arc::clone(&artist_shared); + let slots = std::sync::Arc::clone(&upgrade_slots); + upgrade_pending.push(( + rank, + tokio::spawn(async move { + let _permit = slots.acquire_owned().await.ok(); + upgrade_cover_logo_candidate(metadata, artist_id, artist, hit).await + }), + )); + } + } + } + Err(error) => log::warn!("cover-logo locate task rank {rank} failed: {error}"), + } + if located_count >= COVER_LOGO_MAX_HITS { + for (_, handle) in locate_pending.drain(..) { + handle.abort(); + } + break; + } + if next_rank < offered.len() { + let metadata = std::sync::Arc::clone(&metadata); + let cover = offered[next_rank].clone(); + let rank = next_rank; + locate_pending.push_back(( + rank, + tokio::spawn(download_cover_logo_candidate(metadata, rank, cover)), + )); + next_rank += 1; + } + } + let locate_pipeline_elapsed = locate_pipeline_started.elapsed(); + + // Hi-res resolution begins as soon as each ordered OCR hit commits, so its + // network time overlaps the remaining OCR passes. Handles are consumed in + // hit/rank order to retain exact equal-score winner semantics. + let mut candidates = Vec::with_capacity(located_count); + let mut upgrade_work = std::time::Duration::ZERO; + for (rank, handle) in upgrade_pending { + match handle.await { + Ok(job) => { + upgrade_work += job.elapsed; + candidates.push(job.candidate); + } + Err(error) => log::warn!("cover-logo upgrade task rank {rank} failed: {error}"), + } + } + let upgrade_elapsed = upgrade_started + .map(|started| started.elapsed()) + .unwrap_or(std::time::Duration::ZERO); + + // Selection recipe is intentionally unchanged. Keep the best image in + // memory and PNG-encode once, rather than encoding every provisional best. + const PROMINENCE_W: f32 = 3.0; + const MIN_PROMINENCE: f32 = 0.05; + const MIN_MARK_H: u32 = 74; + // Mask long edge the WINNER is re-cut at. Matching the tracer's own raster + // is the whole rationale: one good Lanczos enlargement in Rust instead of + // Lanczos followed by the browser's bilinear, with the trace then running at + // roughly the mask's native resolution. + // + // Swept against 1200/3600/4800 over eight artists (see + // `research/cover-logo-superres/prototypes/supersample-batch.mjs`), and 2400 + // is where the curve flattens. 1200 is below several crops and leaves them + // untouched. Past 2400 the mask overshoots the trace raster, so the browser + // DOWNSCALES what Rust just built — every shape measure is flat from there + // (Poolside, Wand, Camping In Alaska, Turnover all identical at 2400 and + // 3600) while the data URL doubles: Mildlife 137KB -> 267KB, Camping 76KB -> + // 152KB, for nothing. The one artist that still gains above 2400 is Hot + // Toddy, and it gains from the downscale itself rather than from the extra + // detail — the same smoothing the tracer's blur term would give it. + const MASK_SUPERSAMPLE_EDGE: u32 = 2400; + let extract_started = std::time::Instant::now(); + let (best, winner) = tokio::task::spawn_blocking(move || { + use base64::Engine as _; + use image::{GenericImageView as _, ImageEncoder as _}; + // The winner's INPUTS, not its mask: the mask is re-cut once below at + // full supersample, so scoring never pays for it and never sees it. + let mut best: Option<(f32, CoverLogoCandidate)> = None; + for candidate in candidates { + let prom_w = (candidate.loc.rect.w / candidate.dims_1000.0 as f32).min(1.0); + if prom_w < MIN_PROMINENCE { + continue; + } + let mark = candidate + .bytes_hi + .as_ref() + .and_then(|bytes| image::load_from_memory(bytes.as_slice()).ok()) + .and_then(|hi| { + let (hw, hh) = hi.dimensions(); + let scaled = candidate.loc.scaled( + hw as f32 / candidate.dims_1000.0 as f32, + hh as f32 / candidate.dims_1000.1 as f32, + ); + crate::cover_logo::extract_wordmark(&hi, &scaled, 0) + }) + .or_else(|| { + image::load_from_memory(candidate.bytes_1000.as_slice()) + .ok() + .and_then(|image| { + crate::cover_logo::extract_wordmark(&image, &candidate.loc, 0) + }) + }); + let Some(mark) = mark else { continue }; + if mark.image.height() < MIN_MARK_H { + continue; + } + let final_score = mark.score + PROMINENCE_W * prom_w; + if best.as_ref().is_none_or(|(score, _)| final_score > *score) { + best = Some((final_score, candidate)); + } + } + let Some((_, winner)) = best else { + return (None, None); + }; + let (rank, title) = (winner.rank, winner.title.clone()); + // Re-cut the winning mark, this time resampling the crop up before the + // alpha ramp so the ramp reads interpolated colour and places the edge + // between source pixels rather than on them. Selection above ran at + // native scale on purpose — see `try_extract_wordmark`'s `target_edge`. + // Falls back to the selection-scale mask, so the richer pass failing can + // never cost an artist its logo. + let recut = winner + .bytes_hi + .as_ref() + .and_then(|bytes| image::load_from_memory(bytes.as_slice()).ok()) + .and_then(|hi| { + let (hw, hh) = hi.dimensions(); + let scaled = winner.loc.scaled( + hw as f32 / winner.dims_1000.0 as f32, + hh as f32 / winner.dims_1000.1 as f32, + ); + crate::cover_logo::extract_wordmark(&hi, &scaled, MASK_SUPERSAMPLE_EDGE) + .or_else(|| crate::cover_logo::extract_wordmark(&hi, &scaled, 0)) + }) + .or_else(|| { + image::load_from_memory(winner.bytes_1000.as_slice()) + .ok() + .and_then(|img| { + crate::cover_logo::extract_wordmark( + &img, + &winner.loc, + MASK_SUPERSAMPLE_EDGE, + ) + .or_else(|| crate::cover_logo::extract_wordmark(&img, &winner.loc, 0)) + }) + }); + let Some(image) = recut.map(|mark| mark.image) else { + return (None, None); + }; + let (width, height) = image.dimensions(); + let mut png = Vec::new(); + if image::codecs::png::PngEncoder::new(&mut png) + .write_image( + image.as_raw(), + width, + height, + image::ExtendedColorType::Rgba8, + ) + .is_err() + { + return (None, None); + } + ( + Some(format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(&png) + )), + Some((rank, title, width, height)), + ) + }) + .await + .map_err(|error| error.to_string())?; + let extract_elapsed = extract_started.elapsed(); + + log::info!( + "cover-logo perf artist={artist:?} offered={} downloaded={downloaded_count} hits={located_count} locate_pipeline_ms={} download_work_ms={} ocr_work_ms={} upgrade_wall_ms={} upgrade_work_ms={} extract_ms={} total_ms={} hit_ranks={hit_summary:?} winner={winner:?}", + offered.len(), + locate_pipeline_elapsed.as_millis(), + download_work.as_millis(), + locate_work.as_millis(), + upgrade_elapsed.as_millis(), + upgrade_work.as_millis(), + extract_elapsed.as_millis(), + total_started.elapsed().as_millis(), + ); + Ok(best) +} + +/// Restore the most recently finalized cover-derived logo for an artist before +/// the exhaustive release list is ready. Only positive, recipe-versioned final +/// results are persisted, so this never exposes a speculative OCR candidate. +#[tauri::command] +pub async fn catalog_cached_cover_logo( + app: tauri::AppHandle, + artist_id: u64, +) -> Result, String> { + let Some(dir) = cover_logo_disk_dir(&app) else { + return Ok(None); + }; + let prefix = format!("{artist_id}-"); + let mut paths: Vec<_> = std::fs::read_dir(dir) + .into_iter() + .flatten() + .filter_map(Result::ok) + .filter(|entry| { + entry.file_name().to_string_lossy().starts_with(&prefix) + && entry.path().extension().and_then(|ext| ext.to_str()) == Some("data") + }) + .collect(); + paths.sort_by_key(|entry| { + std::cmp::Reverse( + entry + .metadata() + .and_then(|metadata| metadata.modified()) + .ok(), + ) + }); + for entry in paths { + if let Some(value) = read_cover_logo_disk(&entry.path()).await { + return Ok(Some(value)); + } + } + Ok(None) +} + +/// Deferred: derive an artist wordmark from album covers for artists with no +/// provider (Fanart/TheAudioDB) logo. Downloads the covers, OCRs each for the +/// band-name line, segments the cleanest off its background onto transparency, +/// and returns it as a PNG data URL — `None` when no cover yields a clean +/// wordmark (those stay on the plain text-name fallback). Cached per artist. +/// +/// Covers arrive already ranked studio-albums-first (their art prints the bare +/// band name; singles/remixes print tracklists and credits), so OCR walks the +/// clean covers first and early-outs once it has enough hits. +#[tauri::command] +pub async fn catalog_cover_logo( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + artist_id: u64, + artist: String, + covers: Vec, +) -> Result, String> { + let key = cover_logo_key(artist_id, &artist, &covers); + if let Some(hit) = cover_logo_cache().lock().unwrap().get(&key).cloned() { + log::info!("cover-logo cache memory artist={artist:?} artist_id={artist_id}"); + return Ok(hit); + } + + let disk_path = cover_logo_disk_path(&app, &key); + if let Some(path) = disk_path.as_deref() { + if let Some(hit) = read_cover_logo_disk(path).await { + cover_logo_cache() + .lock() + .unwrap() + .insert(key, Some(hit.clone())); + log::info!("cover-logo cache disk artist={artist:?} artist_id={artist_id}"); + return Ok(Some(hit)); + } + } + + let inflight = { + let mut inflight = cover_logo_inflight().lock().await; + inflight + .entry(key.clone()) + .or_insert_with(|| std::sync::Arc::new(tokio::sync::OnceCell::new())) + .clone() + }; + let metadata = std::sync::Arc::clone(&state.metadata); + let generated = inflight + .get_or_init(|| generate_cover_logo(metadata, artist_id, artist.clone(), covers.clone())) + .await + .clone(); + + let should_persist = if let Ok(value) = &generated { + let mut cache = cover_logo_cache().lock().unwrap(); + if cache.len() > 500 { + cache.clear(); + } + let was_missing = !cache.contains_key(&key); + cache.insert(key.clone(), value.clone()); + was_missing && value.is_some() + } else { + false + }; + if should_persist { + if let (Some(path), Ok(Some(value))) = (disk_path.as_deref(), generated.as_ref()) { + write_cover_logo_disk(path, value).await; + } + } + + let mut active = cover_logo_inflight().lock().await; + if active + .get(&key) + .is_some_and(|current| std::sync::Arc::ptr_eq(current, &inflight)) + { + active.remove(&key); + } + generated +} + +/// Per-cover diagnostic for the cover-logo pipeline: for each candidate cover, +/// what OCR line matched the artist name, its box (in the 1000px OCR space), the +/// hi-res source it resolved to, the extracted-mark size, and the extraction +/// score — plus which cover WON. Answers "which album art is the logo lifted +/// from, where on it, and how large?". Not cached; not on the hot path. +#[derive(Debug, Clone, Serialize)] +pub struct CoverLogoDiag { + pub title: String, + pub url: String, + pub cover_w: u32, + pub cover_h: u32, + pub matched: bool, + pub matched_text: String, + pub match_score: f32, + pub box_x: f32, + pub box_y: f32, + pub box_w: f32, + pub box_h: f32, + pub box_pct_w: f32, + pub box_pct_h: f32, + pub hi_url: String, + pub hi_w: u32, + pub hi_h: u32, + pub mark_w: u32, + pub mark_h: u32, + pub extract_score: f32, + pub reject_reason: String, + /// The extracted mask itself, as a `data:image/png` URL, or empty when the + /// cover produced none. The numbers above say a mark was found and how big + /// it is; only the pixels say whether its EDGES are worth tracing, which is + /// the whole question when sweeping `target_edge`. Empty on the OCR-matrix + /// paths that never extract. + pub mark_png: String, + pub winner: bool, + pub ocr: Vec, +} + +#[tauri::command] +pub async fn catalog_cover_logo_debug( + state: State<'_, LibraryState>, + artist_id: Option, + artist: String, + covers: Vec, + target_edge: Option, +) -> Result, String> { + use image::GenericImageView as _; + // Mask supersample target to segment at. 0/absent means native, which is + // what candidate SELECTION uses in production; a value here is for sweeping + // "does more supersampling help" without a rebuild per level. + let target_edge = target_edge.unwrap_or(0); + + // Download 1000px covers for OCR. + let mut downloaded = Vec::new(); + // Must match COVER_LOGO_MAX_COVERS, not undercut it. At 14 this diagnostic + // silently hid exactly the covers the real pipeline relies on — Poolside's + // seven POOLSIDE-readable covers are all remix singles ranked past 14, so + // the tool reported "one match" for an artist production finds several on. + for cover in covers.into_iter().take(COVER_LOGO_MAX_COVERS) { + if let Some(bytes) = state.metadata.cover_bytes(&cover.url).await { + downloaded.push((cover.title, cover.url, bytes)); + } + } + + // Locate the band-name line on each (blocking OCR). + let artist_locate = artist.clone(); + let located = tokio::task::spawn_blocking(move || { + let mut out = Vec::new(); + for (title, url, bytes) in downloaded { + let Ok(img) = image::load_from_memory(&bytes) else { + continue; + }; + let (cw, ch) = img.dimensions(); + let found = crate::cover_logo::locate_wordmark_named(&img, &artist_locate) + .ok() + .flatten(); + let ocr = crate::cover_logo::ocr_lines_multi(&img) + .unwrap_or_default() + .into_iter() + .map(|l| { + format!( + "\"{}\" @{},{} {}x{}", + l.text, + l.rect.x.round(), + l.rect.y.round(), + l.rect.w.round(), + l.rect.h.round() + ) + }) + .collect(); + out.push((title, url, bytes, cw, ch, found, ocr)); + } + out + }) + .await + .map_err(|e| e.to_string())?; + + // For each hit: resolve to hi-res, extract, score. + let mut rows: Vec = Vec::new(); + for (title, url, bytes_1000, cw, ch, found, ocr) in located { + let mut row = CoverLogoDiag { + title: title.clone(), + url: url.clone(), + cover_w: cw, + cover_h: ch, + ocr, + matched: false, + matched_text: String::new(), + match_score: 0.0, + box_x: 0.0, + box_y: 0.0, + box_w: 0.0, + box_h: 0.0, + box_pct_w: 0.0, + box_pct_h: 0.0, + hi_url: url.clone(), + hi_w: cw, + hi_h: ch, + mark_w: 0, + mark_h: 0, + extract_score: 0.0, + reject_reason: String::new(), + mark_png: String::new(), + winner: false, + }; + if let Some((loc, text, mscore)) = found { + row.matched = true; + row.matched_text = text; + row.match_score = mscore; + row.box_x = loc.rect.x; + row.box_y = loc.rect.y; + row.box_w = loc.rect.w; + row.box_h = loc.rect.h; + row.box_pct_w = loc.rect.w / cw as f32 * 100.0; + row.box_pct_h = loc.rect.h / ch as f32 * 100.0; + + let hi_url = state + .metadata + .resolve_cover_for_artist_id(artist_id, &artist, &title, &url) + .await + .unwrap_or_else(|| url.clone()); + let bytes_hi = if hi_url != url { + state.metadata.cover_bytes(&hi_url).await + } else { + None + }; + let (bytes_ex, loc_ex, hw, hh) = match bytes_hi + .as_ref() + .and_then(|b| image::load_from_memory(b).ok()) + { + Some(hi) => { + let (hw, hh) = hi.dimensions(); + let scaled = loc.scaled(hw as f32 / cw as f32, hh as f32 / ch as f32); + (bytes_hi.clone().unwrap(), scaled, hw, hh) + } + None => (bytes_1000.clone(), loc, cw, ch), + }; + row.hi_url = hi_url; + row.hi_w = hw; + row.hi_h = hh; + if let Ok(img_ex) = image::load_from_memory(&bytes_ex) { + match crate::cover_logo::try_extract_wordmark(&img_ex, &loc_ex, target_edge) { + Ok(mark) => { + use base64::Engine as _; + use image::ImageEncoder as _; + row.mark_w = mark.image.width(); + row.mark_h = mark.image.height(); + row.extract_score = mark.score; + let mut png = Vec::new(); + if image::codecs::png::PngEncoder::new(&mut png) + .write_image( + mark.image.as_raw(), + row.mark_w, + row.mark_h, + image::ExtendedColorType::Rgba8, + ) + .is_ok() + { + row.mark_png = format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(&png) + ); + } + } + Err(reason) => row.reject_reason = reason.to_string(), + } + } + } + rows.push(row); + } + // Mark the winner using the SAME prominence-weighted score the real pipeline + // uses (extract score + 3.0 * box-width-fraction), so the diagnostic winner + // matches what the logo command would pick. + let final_score = |r: &CoverLogoDiag| r.extract_score + 3.0 * (r.box_pct_w / 100.0); + if let Some((idx, _)) = rows + .iter() + .enumerate() + .filter(|(_, r)| r.mark_h >= 74 && r.box_pct_w / 100.0 >= 0.05) + .max_by(|a, b| final_score(a.1).total_cmp(&final_score(b.1))) + { + rows[idx].winner = true; + } + Ok(rows) +} + +/// EXPERIMENT: for each cover, run Windows OCR across a matrix of input scales +/// and preprocessing (raw vs Otsu-binarize) and report every line read. Answers +/// "does downscaling / flattening help the built-in OCR read stylized wordmarks +/// (Paper Years, Age of the Ego)?" before committing to a heavier local model. +#[tauri::command] +pub async fn catalog_ocr_experiment( + state: State<'_, LibraryState>, + urls: Vec, +) -> Result, String> { + let mut imgs = Vec::new(); + for url in urls.into_iter().take(8) { + if let Some(bytes) = state.metadata.cover_bytes(&url).await { + imgs.push((url, bytes)); + } + } + let out = tokio::task::spawn_blocking(move || { + use image::GenericImageView as _; + let scales = [1000u32, 768, 640, 512]; + let mut results = Vec::new(); + for (url, bytes) in imgs { + let Ok(base) = image::load_from_memory(&bytes) else { + continue; + }; + let mut variants = serde_json::Map::new(); + for &s in &scales { + let scaled = base.resize(s, s, image::imageops::FilterType::Lanczos3); + for (tag, img) in [ + (format!("{s}px/raw"), scaled.clone()), + (format!("{s}px/bin"), crate::cover_logo::binarize(&scaled)), + ] { + let lines = crate::cover_logo::ocr_lines(&img) + .unwrap_or_default() + .into_iter() + .map(|l| l.text) + .collect::>(); + variants.insert(tag, serde_json::json!(lines)); + } + } + let (w, h) = base.dimensions(); + results.push( + serde_json::json!({ "url": url, "base": format!("{w}x{h}"), "ocr": variants }), + ); + } + results + }) + .await + .map_err(|e| e.to_string())?; + Ok(out) +} + +#[tauri::command] +pub async fn catalog_artist_hero_seed( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result, String> { + // Local user art is the only truly instant high-confidence source. Check it + // before any remote identity/provider work; the complete art request still + // runs independently so automatic logos remain available. + if let Some(custom) = load_artist_hero_override(&app, artist_id)? { + return Ok(Some(CatalogArtistHeroSeed { + banner: custom.data_url, + banner_custom: true, + })); + } + if let Some(banner) = load_artist_art_manifest(&app, artist_id) + .await + .and_then(|art| art.banners.into_iter().next()) + { + return Ok(Some(CatalogArtistHeroSeed { + banner, + banner_custom: false, + })); + } + Ok(state + .metadata + .catalog_artist_hero_seed(artist_id) + .await + .map(|banner| CatalogArtistHeroSeed { + banner, + banner_custom: false, + })) +} + +#[tauri::command] +pub async fn catalog_artist_overview( + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result { + state.touch_catalog_activity(); + state + .metadata + .catalog_artist_overview(artist_id) + .await + .ok_or_else(|| "artist overview lookup failed".to_string()) +} + +#[tauri::command] +pub async fn catalog_artist_popular( + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result, String> { + state.touch_catalog_activity(); + state + .metadata + .catalog_artist_popular(artist_id) + .await + .ok_or_else(|| "artist popular lookup failed".to_string()) +} + +/// Genres and Last.fm totals for the artist a search is about — the search +/// hero's stat line and tag chips. Deferred like every other supplement: it +/// rides MusicBrainz's gate and must never hold up the results. +#[tauri::command] +pub async fn catalog_artist_profile( + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result { + state.touch_catalog_activity(); + Ok(state.metadata.artist_profile(artist_id).await) +} + +/// One search-result row, reduced to what a listener lookup needs. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TrackListenerQuery { + /// The PERFORMING artist's Deezer id. `None` for a row the provider gave no + /// artist id for, which simply gets no count — never a guess from the name. + pub artist_id: Option, + pub title: String, +} + +/// Last.fm listener counts for a set of search-result rows, answered in order. +/// +/// Deferred on purpose: `catalog_search` is typed against per keystroke and must +/// stay three Deezer calls, so the counts are asked for separately once the +/// results are on screen. See `MetadataClient::track_listeners`. +#[tauri::command] +pub async fn catalog_track_listeners( + state: State<'_, LibraryState>, + tracks: Vec, +) -> Result>, String> { + state.touch_catalog_activity(); + let rows: Vec<(Option, String)> = tracks + .into_iter() + .map(|t| (t.artist_id, t.title)) + .collect(); + Ok(state.metadata.track_listeners(&rows).await) +} + +#[tauri::command] +pub async fn catalog_related( + state: State<'_, LibraryState>, + artist_id: u64, +) -> Result, String> { + Ok(state.metadata.catalog_related(artist_id).await) +} + +/// Proxy a catalog cover image into a data URL (CDNs lack CORS headers). +#[tauri::command] +pub async fn catalog_art(state: State<'_, LibraryState>, url: String) -> Result { + if !url.starts_with("https://") { + return Err("not an https url".into()); + } + state + .metadata + .fetch_image_data_url(&url) + .await + .ok_or_else(|| "image fetch failed".to_string()) +} + +/// Upgrade a catalog cover to the highest-resolution image that is verifiably +/// the SAME artwork as `cover` (Apple / Deezer-max / — for MB cards — Cover Art +/// Archive). Returns a replacement URL, or `None` to keep `cover`. Does no +/// MusicBrainz lookup, so a whole page can resolve its covers in parallel. +#[tauri::command] +pub async fn catalog_cover( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + artist_id: Option, + artist: String, + title: String, + cover: String, +) -> Result, String> { + if cover.is_empty() { + return Ok(None); + } + if let Some(best) = load_cover_resolution(&app, artist_id, &artist, &title, &cover).await { + return Ok(Some(best)); + } + let resolved = state + .metadata + .resolve_cover_for_artist_id(artist_id, &artist, &title, &cover) + .await; + if let Some(best_url) = resolved.as_ref() { + if let Ok(path) = cover_resolution_cache_path(&app, artist_id, &artist, &title, &cover) { + let best_url = best_url.clone(); + // Finish the tiny atomic JSON write before exposing the URL. If it + // were detached, a very fast browser failure could invalidate a + // not-yet-written file and the late writer would resurrect that + // proven-bad URL for the next 30 days. + let result = + tokio::task::spawn_blocking(move || store_cover_resolution(&path, &best_url)).await; + if let Ok(Err(error)) = result { + log::warn!("could not cache cover resolution: {error}"); + } + } + } + Ok(resolved) +} + +/// Forget one persisted positive resolution after the browser proves its URL +/// unusable. The exact artist/title/reference key prevents a flaky edition +/// from evicting any other album's warm result. +#[tauri::command] +pub async fn invalidate_catalog_cover( + app: tauri::AppHandle, + artist_id: Option, + artist: String, + title: String, + cover: String, +) -> Result<(), String> { + let path = cover_resolution_cache_path(&app, artist_id, &artist, &title, &cover)?; + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + } +} + +struct HeroTempFiles(Vec); + +impl Drop for HeroTempFiles { + fn drop(&mut self) { + for path in &self.0 { + let _ = std::fs::remove_file(path); + } + } +} + +fn scaled_height(source_width: u32, source_height: u32, target_width: u32) -> u32 { + (u64::from(source_height) * u64::from(target_width) / u64::from(source_width)).max(1) as u32 +} + +fn fidelityfx_cli_path(app: &tauri::AppHandle) -> Result { + let bundled = app + .path() + .resolve( + "resources/FidelityFX_CLI.exe", + tauri::path::BaseDirectory::Resource, + ) + .map_err(|error| error.to_string())?; + if bundled.is_file() { + return Ok(bundled); + } + + // Tauri resolves bundled resources at runtime. This fallback keeps direct + // `cargo run` and Rust-side tests useful before the bundle is assembled. + let development = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("FidelityFX_CLI.exe"); + if development.is_file() { + return Ok(development); + } + Err("bundled FidelityFX upscaler is unavailable".into()) +} + +fn run_fidelityfx(cli: &std::path::Path, args: &[String]) -> Result<(), String> { + let mut command = std::process::Command::new(cli); + command.args(args); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + command.creation_flags(0x08000000); + } + let output = command.output().map_err(|error| error.to_string())?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(format!( + "FidelityFX failed ({}): {}{}", + output.status, + stdout.trim(), + stderr.trim() + )) +} + +fn fsr_upscale_hero( + app: &tauri::AppHandle, + decoded: &image::DynamicImage, + source_width: u32, + source_height: u32, +) -> Result { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT_JOB: AtomicU64 = AtomicU64::new(1); + + let cli = fidelityfx_cli_path(app)?; + let work_dir = app + .path() + .app_cache_dir() + .map_err(|error| error.to_string())? + .join("hero-processing"); + std::fs::create_dir_all(&work_dir).map_err(|error| error.to_string())?; + let id = NEXT_JOB.fetch_add(1, Ordering::Relaxed); + let prefix = format!("{}-{id}", std::process::id()); + let input = work_dir.join(format!("{prefix}-input.png")); + let intermediate = work_dir.join(format!("{prefix}-intermediate.png")); + let easu = work_dir.join(format!("{prefix}-easu.png")); + let rcas = work_dir.join(format!("{prefix}-rcas.png")); + let _cleanup = HeroTempFiles(vec![ + input.clone(), + intermediate.clone(), + easu.clone(), + rcas.clone(), + ]); + + decoded + .save_with_format(&input, image::ImageFormat::Png) + .map_err(|error| error.to_string())?; + + // FSR1's documented performance ratio is 2x per dimension. A 720p source + // therefore reaches 4K in two EASU passes (720p -> 1440p -> 2160p), then + // receives one final RCAS pass at presentation resolution. + let first_width = source_width + .saturating_mul(2) + .min(ARTIST_HERO_PROVIDER_TARGET_WIDTH); + let mut current = input.clone(); + if first_width < ARTIST_HERO_PROVIDER_TARGET_WIDTH { + let first_height = scaled_height(source_width, source_height, first_width); + run_fidelityfx( + &cli, + &[ + "-Mode".into(), + "EASU".into(), + "-Scale".into(), + first_width.to_string(), + first_height.to_string(), + current.to_string_lossy().into_owned(), + intermediate.to_string_lossy().into_owned(), + ], + )?; + current = intermediate.clone(); + } + + run_fidelityfx( + &cli, + &[ + "-Mode".into(), + "EASU".into(), + "-Scale".into(), + ARTIST_HERO_PROVIDER_TARGET_WIDTH.to_string(), + ARTIST_HERO_PROVIDER_TARGET_HEIGHT.to_string(), + current.to_string_lossy().into_owned(), + easu.to_string_lossy().into_owned(), + ], + )?; + run_fidelityfx( + &cli, + &[ + "-Mode".into(), + "RCAS".into(), + "-Sharpness".into(), + ARTIST_HERO_FSR_SHARPNESS_STOPS.into(), + easu.to_string_lossy().into_owned(), + rcas.to_string_lossy().into_owned(), + ], + )?; + image::open(rcas).map_err(|error| error.to_string()) +} + +fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 { + let t = ((value - edge0) / (edge1 - edge0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +fn mix(left: f32, right: f32, amount: f32) -> f32 { + left + (right - left) * amount +} + +fn ruby_chrome(color: [f32; 3]) -> [f32; 3] { + let luma = color[0] * 0.2126 + color[1] * 0.7152 + color[2] * 0.0722; + let shadow = 1.0 - smoothstep(0.16, 0.60, luma); + let highlight = smoothstep(0.38, 0.90, luma); + let mut graded = color.map(|channel| mix(luma, channel, 1.24)); + graded = graded.map(|channel| (channel - 0.5) * 1.12 + 0.5); + graded = graded.map(|channel| { + let curved = channel * channel * (3.0 - 2.0 * channel); + mix(channel, curved, 0.18) + }); + graded[0] += highlight * 0.070 + shadow * 0.018; + graded[1] -= highlight * 0.028; + graded[2] += shadow * 0.045 + highlight * 0.018; + graded.map(|channel| channel.clamp(0.0, 1.0)) +} + +/// Bake the approved look into the final 4K raster. The order is deliberate: +/// Ruby Chrome LUT -> vintage fade -> native-resolution monochrome grain. +fn apply_baked_hero_look(image: &mut image::RgbImage, source: &[u8]) { + for pixel in image.pixels_mut() { + let input = [ + f32::from(pixel[0]) / 255.0, + f32::from(pixel[1]) / 255.0, + f32::from(pixel[2]) / 255.0, + ]; + let graded = ruby_chrome(input); + let color = [ + mix(input[0], graded[0], ARTIST_HERO_LUT_STRENGTH), + mix(input[1], graded[1], ARTIST_HERO_LUT_STRENGTH), + mix(input[2], graded[2], ARTIST_HERO_LUT_STRENGTH), + ]; + let luma = color[0] * 0.2126 + color[1] * 0.7152 + color[2] * 0.0722; + let faded_color = [ + color[0] * 0.78 + 0.085, + color[1] * 0.78 + 0.078, + color[2] * 0.78 + 0.068, + ]; + let faded_luma = luma * 0.78 + 0.078; + for channel in 0..3 { + let faded = mix(faded_luma, faded_color[channel], 0.88).clamp(0.0, 1.0); + let output = mix(color[channel], faded, ARTIST_HERO_FADE_STRENGTH); + pixel[channel] = (output.clamp(0.0, 1.0) * 255.0).round() as u8; + } + } + add_baked_hero_grain(image, source); +} + +/// Deterministic triangular monochrome grain, generated one sample per final +/// 4K pixel and encoded with the image. It does not move, resize, or regenerate +/// with the window because it is part of the finished hero raster. +fn add_baked_hero_grain(image: &mut image::RgbImage, source: &[u8]) { + let stride = (source.len() / 4096).max(1); + let mut seed = 0xcbf29ce484222325_u64; + for byte in source.iter().step_by(stride) { + seed ^= u64::from(*byte); + seed = seed.wrapping_mul(0x100000001b3); + } + if seed == 0 { + seed = 0x9e3779b97f4a7c15; + } + + let next = |state: &mut u64| { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state + }; + let amplitude = (28.0 * ARTIST_HERO_GRAIN_STRENGTH).round() as i16; + for pixel in image.pixels_mut() { + let first = (next(&mut seed) & 0xff) as i16 - 128; + let second = (next(&mut seed) & 0xff) as i16 - 128; + let delta = ((first + second) * amplitude / 256).clamp(-amplitude, amplitude); + for channel in pixel.0.iter_mut() { + *channel = (i16::from(*channel) + delta).clamp(0, 255) as u8; + } + } +} + +/// Crop letterbox bars baked into an image's edges — YouTube channel "TV art" +/// pads its safe area this way. A bar row/column is either near-black or +/// "murk": dark AND featureless (low brightness range), which catches the +/// blurry dark gradients YouTube composites between the bar and the artwork — +/// those read as broken letterboxing on the hero, and the approved behaviour +/// is a confident crop, never letterboxing. Conservative guards remain: a bar +/// must be a contiguous ≥2% band, each side is capped at a third, and the +/// crop is rejected outright if it would remove half the image — so a +/// genuinely dark photo cannot be eaten. Returns `None` when nothing trims. +fn trim_letterbox_bars(image: &image::DynamicImage) -> Option { + let rgb = image.to_rgb8(); + let (w, h) = rgb.dimensions(); + if w < 64 || h < 64 { + return None; + } + let barlike = + |luma_max: u8, luma_min: u8| luma_max < 16 || (luma_max < 60 && luma_max - luma_min < 48); + let dark_row = |y: u32| { + let mut max = 0u8; + let mut min = 255u8; + for x in (0..w).step_by(4) { + let p = rgb.get_pixel(x, y).0; + let l = p[0].max(p[1]).max(p[2]); + max = max.max(l); + min = min.min(l); + } + barlike(max, min) + }; + let dark_col = |x: u32| { + let mut max = 0u8; + let mut min = 255u8; + for y in (0..h).step_by(4) { + let p = rgb.get_pixel(x, y).0; + let l = p[0].max(p[1]).max(p[2]); + max = max.max(l); + min = min.min(l); + } + barlike(max, min) + }; + let mut top = 0; + while top < h / 3 && dark_row(top) { + top += 1; + } + let mut bottom = 0; + while bottom < h / 3 && dark_row(h - 1 - bottom) { + bottom += 1; + } + let mut left = 0; + while left < w / 3 && dark_col(left) { + left += 1; + } + let mut right = 0; + while right < w / 3 && dark_col(w - 1 - right) { + right += 1; + } + let min_band_h = (h / 50).max(4); + let min_band_w = (w / 50).max(4); + if top < min_band_h { + top = 0; + } + if bottom < min_band_h { + bottom = 0; + } + if left < min_band_w { + left = 0; + } + if right < min_band_w { + right = 0; + } + if top + bottom == 0 && left + right == 0 { + return None; + } + let new_w = w - left - right; + let new_h = h - top - bottom; + if new_w < w / 2 || new_h < h / 2 { + return None; + } + Some(image.crop_imm(left, top, new_w, new_h)) +} + +fn hero_source_checksum(bytes: &[u8]) -> String { + // This is an integrity/version key, not a security boundary. Keeping it as + // hex avoids losing 64-bit precision while the plan round-trips through JS. + use std::hash::{Hash as _, Hasher as _}; + let mut checksum = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut checksum); + format!("{:016x}", checksum.finish()) +} + +fn valid_hero_crop_plan( + plan: &CatalogHeroCropPlan, + source_checksum: &str, + width: u32, + height: u32, +) -> bool { + if plan.recipe != ARTIST_HERO_CROP_RECIPE + || plan.source_checksum != source_checksum + || plan.geometry.source_width != width + || plan.geometry.source_height != height + { + return false; + } + let Some(maximal) = maximal_hero_rect(width, height) else { + return false; + }; + let crop = plan.geometry.crop; + crop.x == maximal.x + && crop.width == maximal.width + && crop.height == maximal.height + && crop.width > 0 + && crop.height > 0 + && u64::from(crop.x) + u64::from(crop.width) <= u64::from(width) + && u64::from(crop.y) + u64::from(crop.height) <= u64::from(height) +} + +/// Decode, conservatively remove provider letterboxing, choose one immutable +/// source-pixel crop, and apply it before any preview resize or 4K work. +fn prepare_provider_hero( + app: &tauri::AppHandle, + bytes: &[u8], + trim_letterbox: bool, + supplied_plan: Option, +) -> Result<(image::DynamicImage, CatalogHeroCropPlan), String> { + use image::GenericImageView as _; + + if bytes.len() as u64 > ARTIST_HERO_MAX_BYTES { + return Err("provider hero exceeds the 30 MB limit".into()); + } + let mut decoded = image::load_from_memory(bytes).map_err(|error| error.to_string())?; + if trim_letterbox { + if let Some(trimmed) = trim_letterbox_bars(&decoded) { + decoded = trimmed; + } + } + let (width, height) = decoded.dimensions(); + let source_checksum = hero_source_checksum(bytes); + let plan = match supplied_plan + .filter(|plan| valid_hero_crop_plan(plan, &source_checksum, width, height)) + { + Some(plan) => plan, + None => { + let maximal = maximal_hero_rect(width, height) + .ok_or_else(|| "provider hero is too small to crop".to_string())?; + let (subjects, faces) = if maximal.height < height { + match detect_subjects(app, &decoded) { + Ok(detections) => detections, + Err(error) => { + log::warn!("content-aware artist hero crop unavailable: {error}"); + (Vec::new(), Vec::new()) + } + } + } else { + // Exact/wide sources have no vertical freedom, so inference + // could not affect the result and is deliberately skipped. + (Vec::new(), Vec::new()) + }; + let geometry = plan_hero_crop(width, height, &subjects, &faces) + .ok_or_else(|| "provider hero crop could not be planned".to_string())?; + log::info!( + "artist hero crop {}x{} -> x{} y{} {}x{} ({} people, {} faces, aware={})", + width, + height, + geometry.crop.x, + geometry.crop.y, + geometry.crop.width, + geometry.crop.height, + geometry.subject_count, + geometry.associated_face_count, + geometry.content_aware, + ); + CatalogHeroCropPlan { + recipe: ARTIST_HERO_CROP_RECIPE.to_string(), + source_checksum, + geometry, + } + } + }; + let crop = plan.geometry.crop; + Ok(( + decoded.crop_imm(crop.x, crop.y, crop.width, crop.height), + plan, + )) +} + +fn provider_hero_preview_data_url( + app: &tauri::AppHandle, + bytes: &[u8], + trim_letterbox: bool, +) -> Result { + use base64::Engine as _; + use image::GenericImageView as _; + + let (cropped, plan) = prepare_provider_hero(app, bytes, trim_letterbox, None)?; + let (width, _) = cropped.dimensions(); + // Preview never upscales. Rounding down to a 16-pixel width makes its + // encoded dimensions exactly 16:9 while losing at most 15 source pixels. + let preview_width = width.min(ARTIST_HERO_PREVIEW_MAX_WIDTH).max(16) / 16 * 16; + let preview_height = preview_width / 16 * 9; + let preview = cropped.resize_exact( + preview_width, + preview_height, + image::imageops::FilterType::CatmullRom, + ); + let rgb = preview.to_rgb8(); + let mut output = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 90) + .encode( + &rgb, + preview_width, + preview_height, + image::ExtendedColorType::Rgb8, + ) + .map_err(|error| error.to_string())?; + Ok(CatalogHeroPreviewResult { + data_url: format!( + "data:image/jpeg;base64,{}", + base64::engine::general_purpose::STANDARD.encode(output) + ), + plan, + }) +} + +/// Lossless, pre-cropped source pixels for the live GPU path. Cropping is +/// completed here, before the WebView sees the raster, so EASU cannot sample +/// discarded pixels around a crop edge. The source-bound grain seed keeps the +/// 4K grain field stable across visits and window sizes. +fn provider_hero_gpu_payload( + app: &tauri::AppHandle, + bytes: &[u8], + trim_letterbox: bool, + supplied_plan: Option, +) -> Result, String> { + use image::GenericImageView as _; + + let (cropped, plan) = prepare_provider_hero(app, bytes, trim_letterbox, supplied_plan)?; + let (width, height) = cropped.dimensions(); + let rgba = cropped.to_rgba8().into_raw(); + let expected = u64::from(width) + .checked_mul(u64::from(height)) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| "provider hero dimensions overflow".to_string())?; + if rgba.len() as u64 != expected { + return Err("provider hero RGBA payload has an invalid length".into()); + } + + // WebGL floats represent every integer exactly only through 24 bits. + let grain_seed = u64::from_str_radix(&plan.source_checksum, 16) + .unwrap_or(1) + .wrapping_mul(0x9e37_79b9) + .rotate_left(17) as u32 + & 0x00ff_ffff; + let mut payload = Vec::with_capacity(CATALOG_HERO_GPU_HEADER_BYTES + rgba.len()); + payload.extend_from_slice(CATALOG_HERO_GPU_MAGIC); + payload.extend_from_slice(&width.to_le_bytes()); + payload.extend_from_slice(&height.to_le_bytes()); + payload.extend_from_slice(&grain_seed.max(1).to_le_bytes()); + payload.extend_from_slice(&rgba); + Ok(payload) +} + +fn provider_hero_data_url( + app: &tauri::AppHandle, + bytes: &[u8], + upscaler: config::ArtistHeroUpscaler, + trim_letterbox: bool, + supplied_plan: Option, +) -> Result<(String, bool), String> { + use base64::Engine as _; + use image::GenericImageView as _; + + let (decoded, _) = prepare_provider_hero(app, bytes, trim_letterbox, supplied_plan)?; + let (width, height) = decoded.dimensions(); + let (finished, cacheable) = if width == ARTIST_HERO_PROVIDER_TARGET_WIDTH + && height == ARTIST_HERO_PROVIDER_TARGET_HEIGHT + { + (decoded, true) + } else if width > ARTIST_HERO_PROVIDER_TARGET_WIDTH { + // More-than-4K sources need a high-quality downsample, not an upscale. + ( + decoded.resize_exact( + ARTIST_HERO_PROVIDER_TARGET_WIDTH, + ARTIST_HERO_PROVIDER_TARGET_HEIGHT, + image::imageops::FilterType::Lanczos3, + ), + true, + ) + } else { + match upscaler { + config::ArtistHeroUpscaler::Fsr => { + match fsr_upscale_hero(app, &decoded, width, height) { + Ok(image) => ( + if image.dimensions() + == ( + ARTIST_HERO_PROVIDER_TARGET_WIDTH, + ARTIST_HERO_PROVIDER_TARGET_HEIGHT, + ) + { + image + } else { + image.resize_exact( + ARTIST_HERO_PROVIDER_TARGET_WIDTH, + ARTIST_HERO_PROVIDER_TARGET_HEIGHT, + image::imageops::FilterType::Lanczos3, + ) + }, + true, + ), + Err(error) => { + log::warn!("FSR hero upscale failed, using Lanczos3: {error}"); + ( + decoded + .resize_exact( + ARTIST_HERO_PROVIDER_TARGET_WIDTH, + ARTIST_HERO_PROVIDER_TARGET_HEIGHT, + image::imageops::FilterType::Lanczos3, + ) + .unsharpen(0.7, 2), + // Display the resilient fallback, but do not preserve it + // under the FSR key. A later visit should retry the + // quality mode the user actually selected. + false, + ) + } + } + } + config::ArtistHeroUpscaler::LiveGpu | config::ArtistHeroUpscaler::Lanczos3 => ( + decoded + .resize_exact( + ARTIST_HERO_PROVIDER_TARGET_WIDTH, + ARTIST_HERO_PROVIDER_TARGET_HEIGHT, + image::imageops::FilterType::Lanczos3, + ) + .unsharpen(0.7, 2), + true, + ), + } + }; + let mut rgb = finished.to_rgb8(); + apply_baked_hero_look(&mut rgb, bytes); + let mut output = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 93) + .encode( + &rgb, + ARTIST_HERO_PROVIDER_TARGET_WIDTH, + ARTIST_HERO_PROVIDER_TARGET_HEIGHT, + image::ExtendedColorType::Rgb8, + ) + .map_err(|error| error.to_string())?; + Ok(( + format!( + "data:image/jpeg;base64,{}", + base64::engine::general_purpose::STANDARD.encode(output) + ), + cacheable, + )) +} + +/// Hero-only proxy. Unlike `catalog_art`, this may upscale a 720p provider +/// image. Keeping the commands separate protects album covers and logo PNGs +/// from lossy conversion. +#[tauri::command] +pub async fn catalog_hero_preview( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + url: String, +) -> Result { + if !url.starts_with("https://") { + return Err("not an https url".into()); + } + let bytes = state + .metadata + .hero_bytes(&url) + .await + .ok_or_else(|| "hero image fetch failed".to_string())?; + let trim_letterbox = url.starts_with("https://yt3.googleusercontent.com/") + || url.starts_with("https://yt3.ggpht.com/"); + let worker_app = app.clone(); + tokio::task::spawn_blocking(move || { + provider_hero_preview_data_url(&worker_app, &bytes, trim_letterbox) + }) + .await + .map_err(|error| error.to_string())? +} + +/// Crop-first, lossless input for the fixed-4K GPU renderer. Raw binary IPC +/// returns an ArrayBuffer rather than a JSON number array or base64 string. +#[tauri::command] +pub async fn catalog_hero_gpu_source( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + url: String, + plan: Option, +) -> Result { + if !url.starts_with("https://") { + return Err("not an https url".into()); + } + if config::load(&app).artist_hero_upscaler != config::ArtistHeroUpscaler::LiveGpu { + return Err("live GPU hero rendering is disabled".into()); + } + let bytes = state + .metadata + .hero_bytes(&url) + .await + .ok_or_else(|| "hero image fetch failed".to_string())?; + let trim_letterbox = url.starts_with("https://yt3.googleusercontent.com/") + || url.starts_with("https://yt3.ggpht.com/"); + // Autocomplete warming and rapid artist navigation can leave work for + // different sources in flight. Bound the decode/crop/detector phase so + // those stale jobs cannot multiply peak CPU and decoded-raster memory. + let prepare_slot = artist_hero_gpu_prepare_slots() + .acquire() + .await + .map_err(|_| "live GPU hero preparation is unavailable".to_string())?; + let worker_app = app.clone(); + let payload = tokio::task::spawn_blocking(move || { + // Keep the permit inside the blocking job. If rapid navigation drops + // the command future, Tokio detaches an already-running blocking task; + // a permit owned only by the async frame would be released early and + // stale decode/YOLO jobs could again exceed the global bound. + let _prepare_slot = prepare_slot; + provider_hero_gpu_payload(&worker_app, &bytes, trim_letterbox, plan) + }) + .await + .map_err(|error| error.to_string())??; + Ok(tauri::ipc::Response::new(payload)) +} + +#[tauri::command] +pub async fn catalog_cached_hero_art( + app: tauri::AppHandle, + url: String, +) -> Result, String> { + if !url.starts_with("https://") { + return Err("not an https url".into()); + } + let upscaler = config::load(&app).artist_hero_upscaler; + if upscaler == config::ArtistHeroUpscaler::LiveGpu { + return Ok(None); + } + let cache_path = finished_hero_cache_path(&app, &url, upscaler)?; + Ok(load_finished_hero(&cache_path).await) +} + +#[tauri::command] +pub async fn catalog_hero_art( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + url: String, + plan: Option, +) -> Result { + if !url.starts_with("https://") { + return Err("not an https url".into()); + } + let upscaler = config::load(&app).artist_hero_upscaler; + if upscaler == config::ArtistHeroUpscaler::LiveGpu { + return Err("live GPU heroes are not baked".into()); + } + let cache_path = finished_hero_cache_path(&app, &url, upscaler)?; + if let Some(cached) = load_finished_hero(&cache_path).await { + return Ok(CatalogHeroArtResult { + data_url: cached, + cacheable: true, + }); + } + let bytes = state + .metadata + .hero_bytes(&url) + .await + .ok_or_else(|| "hero image fetch failed".to_string())?; + // Only YouTube channel banners are known to bake letterbox bars into the + // raster; the trim never runs on curated art-database sources. + let trim_letterbox = url.starts_with("https://yt3.googleusercontent.com/") + || url.starts_with("https://yt3.ggpht.com/"); + let worker_app = app.clone(); + tokio::task::spawn_blocking(move || { + let (data_url, cacheable) = + provider_hero_data_url(&worker_app, &bytes, upscaler, trim_letterbox, plan)?; + if cacheable { + if let Err(error) = store_finished_hero(&cache_path, &data_url) { + log::warn!("could not cache finished artist hero: {error}"); + } + } + Ok(CatalogHeroArtResult { + data_url, + cacheable, + }) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn catalog_album( + state: State<'_, LibraryState>, + album_id: u64, +) -> Result { + state.touch_catalog_activity(); + state + .metadata + .catalog_album(album_id) + .await + .ok_or_else(|| "album lookup failed".to_string()) +} + +/// Editorial context (description/genre/label) for one release — TheAudioDB +/// first, Wikipedia summary fallback. Always succeeds; empty fields mean +/// neither source knew the record. +#[tauri::command] +pub async fn catalog_album_info( + state: State<'_, LibraryState>, + artist_id: Option, + artist: String, + title: String, +) -> Result { + state.touch_catalog_activity(); + Ok(state + .metadata + .album_info_for_artist_id(artist_id, &artist, &title) + .await) +} + +#[tauri::command] +pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> { + if let Some(client) = state.client.lock().await.take() { + client.shutdown(); + } + *state.connected_as.lock().await = None; + Ok(()) +} + +#[tauri::command] +pub async fn start_search(state: State<'_, AppState>, query: String) -> Result { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected")?; + client.search(&query).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn stop_search(state: State<'_, AppState>, token: u32) -> Result<(), String> { + if let Some(client) = state.client.lock().await.as_ref() { + client.stop_search(token); + } + Ok(()) +} + +#[tauri::command] +pub async fn download_file( + state: State<'_, AppState>, + username: String, + path: String, + size: u64, +) -> Result { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected")?; + Ok(client.download(&username, &path, size)) +} + +/// A streamed file's peer identity, used to locate its cached copy. +#[derive(serde::Deserialize)] +pub struct StreamRef { + pub username: String, + pub path: String, +} + +/// A streamed file plus its size, for saving into the library. +#[derive(serde::Deserialize)] +pub struct SaveRef { + pub username: String, + pub path: String, + pub size: u64, +} + +fn sanitize_seg(part: &str) -> String { + part.chars() + .map(|c| if "<>:\"/\\|?*".contains(c) { '_' } else { c }) + .collect::() + .trim() + .to_string() +} + +/// Save the file(s) you're streaming into the library. Cached tracks are +/// organized straight from the stream cache — no re-download, and crucially no +/// second transfer of a file the peer is already streaming to us (which +/// deadlocks both). Only tracks that aren't cached yet are downloaded. +/// Returns (organized_from_cache, queued_for_download). +#[tauri::command] +pub async fn save_to_library( + app: tauri::AppHandle, + state: State<'_, AppState>, + files: Vec, +) -> Result<(u32, u32), String> { + let downloads_dir = config::download_dir(&app, &config::load(&app)); + let mut organized = 0u32; + let mut queued = 0u32; + for f in files { + // Only a BYTE-COMPLETE cache saves directly: a mid-stream (or + // abandoned) cache file is a prefix of the track, and the organizer + // would file the truncation as the real thing. Anything shorter + // falls through — to the in-flight transfer if one is running, else + // to a fresh download of the known file. + let cached = crate::playback::cache_path(&app, &f.username, &f.path) + .filter(|c| c.exists()) + .filter(|c| { + f.size == 0 || c.metadata().map(|m| m.len() == f.size).unwrap_or(false) + }); + if let Some(cache) = cached { + if organize_cached_save(&app, &downloads_dir, &cache, &f.path).await? { + organized += 1; + continue; + } + } + // The file may already be arriving as the playing stream or an + // album-continuation preload. Subscribe to that transfer instead of + // opening a duplicate peer request that can remain queued forever. + if let Some(players) = app.try_state::() { + let cache = crate::playback::cache_path(&app, &f.username, &f.path); + let transfer = cache.as_ref().and_then(|cache| { + players + .preloads + .lock() + .unwrap() + .iter() + .find(|(_, target)| *target == cache) + .map(|(id, _)| *id) + .or_else(|| { + players + .stream_caches + .lock() + .unwrap() + .iter() + .find(|(_, target)| *target == cache) + .map(|(id, _)| *id) + }) + }); + if let (Some(id), Some(cache)) = (transfer, cache) { + players.deferred_saves.lock().unwrap().insert(id, cache); + queued += 1; + continue; + } + } + // Not cached — fetch the known file (still no re-search). + let guard = state.client.lock().await; + if let Some(client) = guard.as_ref() { + client.download(&f.username, &f.path, f.size); + queued += 1; + } + } + let _ = app.emit("soulseek:library-changed", ()); + Ok((organized, queued)) +} + +/// Byte length of the stream-cache copy of one peer file, if it exists. +/// The caller compares against the peer-stated size to tell a finished +/// cache from a mid-stream prefix. Read-only — never contacts the peer. +#[tauri::command] +pub fn stream_cache_len(app: tauri::AppHandle, username: String, path: String) -> Option { + crate::playback::cache_path(&app, &username, &path) + .filter(|c| c.exists()) + .and_then(|c| c.metadata().ok()) + .map(|m| m.len()) +} + +/// Copy a completed stream cache into staging, then run the normal tagging and +/// library organizer. Returns false only when the copy itself did not happen. +async fn organize_cached_save( + app: &tauri::AppHandle, + downloads_dir: &std::path::Path, + cache: &std::path::Path, + remote_path: &str, +) -> Result { + let (folder, name) = match remote_path.rsplit_once('\\') { + Some((dir, n)) => (dir.rsplit('\\').next().unwrap_or(""), n), + None => ("", remote_path), + }; + let mut dir = downloads_dir.join("stream-save"); + if !folder.is_empty() { + dir.push(sanitize_seg(folder)); + } + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let dest = dir.join(sanitize_seg(name)); + if std::fs::copy(cache, &dest).is_err() { + return Ok(false); + } + crate::pipeline::process_file(app, &dest, Some(remote_path)).await; + Ok(true) +} + +/// Copy the files being streamed (already in the stream cache) into a chosen +/// folder — no re-download, no re-search. Skips any track not yet cached. +/// Returns how many were saved and how many weren't cached. +#[tauri::command] +pub async fn copy_stream_to_folder( + app: tauri::AppHandle, + files: Vec, + folder: String, +) -> Result<(u32, u32), String> { + let dest = std::path::PathBuf::from(&folder); + std::fs::create_dir_all(&dest).map_err(|e| e.to_string())?; + let mut saved = 0u32; + let mut missing = 0u32; + for f in files { + let Some(cache) = crate::playback::cache_path(&app, &f.username, &f.path) else { + missing += 1; + continue; + }; + if !cache.exists() { + missing += 1; + continue; + } + let name = f.path.rsplit(['\\', '/']).next().unwrap_or("track"); + match std::fs::copy(&cache, dest.join(name)) { + Ok(_) => saved += 1, + Err(_) => missing += 1, + } + } + Ok((saved, missing)) +} + +#[tauri::command] +pub async fn cancel_download(state: State<'_, AppState>, id: u64) -> Result<(), String> { + if let Some(client) = state.client.lock().await.as_ref() { + client.cancel_download(id); + } + Ok(()) +} + +/// Reveal a completed download in Explorer. +#[tauri::command] +pub fn show_in_folder(path: String) { + let _ = std::process::Command::new("explorer") + .args(["/select,", &path]) + .spawn(); +} + +#[tauri::command] +pub async fn refresh_artist_art(app: tauri::AppHandle) -> Result { + crate::pipeline::refresh_all_artist_art(&app).await +} + +/// Stream a remote file: download into the cache and start playback the +/// moment bytes arrive. +#[tauri::command] +pub async fn play_stream( + app: tauri::AppHandle, + state: State<'_, AppState>, + players: State<'_, PlayerState>, + username: String, + path: String, + size: u64, + duration: Option, +) -> Result<(), String> { + let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?; + + // Fully cached from an earlier stream → play directly. Touch it so + // retention counts from this play, not the original stream. + if let Ok(md) = std::fs::metadata(&cache) { + if size > 0 && md.len() == size { + playback::touch(&cache); + crate::diagnostics::clear_stream(&app); + // This track never becomes a transfer: there is no StreamProgress + // and no download task, so the update loop below that emits + // `soulseek:stream` never runs for it. Say it once, or the UI keeps + // the provisional counts it set when it asked to play and shows a + // download pinned at 0% — or, coming out of a gapless advance, an + // indeterminate "Streaming from " — on a file that is already + // complete. `done` also lets the frontend take its completed-track + // path (final waveform, pre-buffer the next). + let _ = app.emit( + "soulseek:stream", + serde_json::json!({ + "received": size, + "total": size, + "done": true, + "failed": Option::::None, + }), + ); + let app2 = app.clone(); + let cache2 = cache.clone(); + crate::loudness::on_track_started( + &app, + &cache, + size, + None, + Box::new(move || { + app2.state::() + .player + .play_local(&cache2, duration); + }), + ); + return Ok(()); + } + let _ = std::fs::remove_file(&cache); + } + let partial = playback::partial_path(&cache); + + // A preload may already be fetching this exact file: attach to it and + // keep the bytes it has instead of restarting the transfer. + let preload_id = players + .preloads + .lock() + .unwrap() + .iter() + .find(|(_, c)| **c == cache) + .map(|(id, _)| *id); + if let Some(id) = preload_id { + players.preloads.lock().unwrap().remove(&id); + // From here the transfer is the CURRENT track's stream; the overlay's + // DL line (note_stream below) reports it, not the on-deck list. + crate::diagnostics::retire_preload(&app, id); + let progress = player::StreamProgress::new(); + if let Ok(md) = std::fs::metadata(&partial) { + progress.update(md.len(), false, None); + } + players.streams.lock().unwrap().insert(id, progress.clone()); + players + .stream_caches + .lock() + .unwrap() + .insert(id, cache.clone()); + crate::diagnostics::note_stream(&app, size, progress.clone()); + let app2 = app.clone(); + let partial2 = partial.clone(); + let progress2 = progress.clone(); + crate::loudness::on_track_started( + &app, + &cache, + size, + Some((partial, progress)), + Box::new(move || { + app2.state::() + .player + .play_stream(&partial2, progress2, size, duration); + }), + ); + return Ok(()); + } + + let _ = std::fs::remove_file(&partial); + + let progress = player::StreamProgress::new(); + let id = { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected")?; + client.download_to(&username, &path, size, cache.clone()) + }; + players.streams.lock().unwrap().insert(id, progress.clone()); + players + .stream_caches + .lock() + .unwrap() + .insert(id, cache.clone()); + + crate::diagnostics::note_stream(&app, size, progress.clone()); + let app2 = app.clone(); + let partial2 = partial.clone(); + let progress2 = progress.clone(); + crate::loudness::on_track_started( + &app, + &cache, + size, + Some((partial, progress)), + Box::new(move || { + app2.state::() + .player + .play_stream(&partial2, progress2, size, duration); + }), + ); + Ok(()) +} + +/// Quietly download the next queue track into the stream cache so playback +/// can move to it without a buffering gap. No player, UI, or library +/// side-effects; play_stream attaches to it when the track comes up. +#[tauri::command] +pub async fn preload_stream( + app: tauri::AppHandle, + state: State<'_, AppState>, + players: State<'_, PlayerState>, + username: String, + path: String, + size: u64, +) -> Result<(), String> { + let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?; + if let Ok(md) = std::fs::metadata(&cache) { + if size > 0 && md.len() == size { + // Gapless normalization: boundary gain ready before the switch. + crate::loudness::on_preload_ready(&app, &cache); + return Ok(()); // already fully cached + } + let _ = std::fs::remove_file(&cache); + } + if players + .preloads + .lock() + .unwrap() + .values() + .any(|c| *c == cache) + { + return Ok(()); // already preloading + } + let _ = std::fs::remove_file(&playback::partial_path(&cache)); + + let id = { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected")?; + client.download_to(&username, &path, size, cache.clone()) + }; + players.preloads.lock().unwrap().insert(id, cache); + Ok(()) +} + +/// Append a fully cached stream track behind the active source so the +/// transition happens inside the same audio sink (true gapless). Returns +/// false — and does nothing — while the cache file is still incomplete. +#[tauri::command] +pub fn preload_cached_stream( + app: tauri::AppHandle, + players: State<'_, PlayerState>, + username: String, + path: String, + size: u64, + duration: Option, +) -> Result { + let cache = playback::cache_path(&app, &username, &path).ok_or("no cache dir")?; + match std::fs::metadata(&cache) { + Ok(md) if size == 0 || md.len() == size => { + playback::touch(&cache); + players.player.preload_local(&cache, duration); + // Gapless normalization: boundary gain ready before the switch. + crate::loudness::on_preload_ready(&app, &cache); + Ok(true) + } + _ => Ok(false), + } +} + +/// Waveform peaks for a stream-cached track. While the download is still in +/// flight, the partial file is decoded and its peaks scaled into the leading +/// `received/size` share of the buckets (rest zero), so the waveform appears +/// immediately and grows with the transfer. Complete files are memoized. +#[tauri::command] +pub async fn stream_waveform( + app: tauri::AppHandle, + players: State<'_, PlayerState>, + username: String, + path: String, + size: u64, + buckets: usize, +) -> Result { + const MAX_WAVEFORM_BUCKETS: usize = 4096; + if buckets > MAX_WAVEFORM_BUCKETS { + return Err(format!( + "waveform bucket count exceeds {MAX_WAVEFORM_BUCKETS}" + )); + } + if buckets == 0 { + return Ok(WaveformSlice::empty()); + } + let Some(cache) = playback::cache_path(&app, &username, &path) else { + return Ok(WaveformSlice::empty()); + }; + let key = format!("{}#{buckets}", cache.display()); + let job = players.waveform_job(&key); + let result = async { + // Re-check the filesystem only after acquiring the per-key lock: a + // partial decoder ahead of us may have run while the transfer renamed + // the completed file into place. + let job_guard = job.clone().lock_owned().await; + if matches!(std::fs::metadata(&cache), Ok(md) if size == 0 || md.len() == size) { + players.remove_progressive_waveform(&key); + if let Some(cached) = players.cached_waveform(&key) { + return Ok(WaveformSlice::complete(cached)); + } + + let p = cache.clone(); + let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || { + let peaks = player::compute_waveform(&p, buckets); + (job_guard, peaks) + }) + .await + .map_err(|e| e.to_string())?; + if !peaks.is_empty() { + players.cache_waveform(key.clone(), peaks.clone()); + } + drop(job_guard); + return Ok(WaveformSlice::complete(peaks)); + } + + // Seeing a partial file invalidates any completed waveform retained + // under the same stable cache path from an earlier transfer. + players.remove_cached_waveform(&key); + let partial = playback::partial_path(&cache); + let have = std::fs::metadata(&partial).map(|m| m.len()).unwrap_or(0); + if have == 0 || size == 0 { + return Ok(WaveformSlice::empty()); + } + if let Some(previous) = players.progressive_waveform(&key) { + if !previous.should_recompute(have) { + return Ok(WaveformSlice { + coverage: (previous.decoded_bytes() as f64 / size as f64).min(1.0) as f32, + peaks: previous.peaks(), + }); + } + } + + // Byte share ≈ time share (close enough for a scrubber even on VBR). + // The cached threshold doubles after every useful decode, bounding all + // partial-file work geometrically instead of decoding from zero every + // three seconds. + let frac = (have as f64 / size as f64).min(1.0); + let n = ((buckets as f64 * frac).round() as usize).clamp(1, buckets); + let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || { + let peaks = player::compute_waveform(&partial, n); + (job_guard, peaks) + }) + .await + .map_err(|e| e.to_string())?; + if !peaks.is_empty() { + players.cache_progressive_waveform( + key.clone(), + playback::ProgressiveWaveform::new(have, size, peaks.clone()), + ); + } + drop(job_guard); + // The peaks are ONLY the decoded audio — no zero padding to the full + // bucket count any more. The old padding made the array's length lie + // about its contents: the UI folded real-peaks-plus-zero-tail across a + // frontier computed from a FRESHER byte count, so the drawn shape + // stretched between refreshes and the loudness scale ate the zeros. + // Coverage says exactly how much of the track these peaks describe; + // where the standing bars end IS how much of the track has loaded. + Ok(WaveformSlice { + coverage: frac as f32, + peaks, + }) + } + .await; + players.release_waveform_job(&key, &job); + result +} + +/// Is this exact stream source already fully cached on disk? +/// +/// The catalog's play paths ask BEFORE running a Soulseek resolve: a complete +/// cache file can play immediately — even offline — and the search would only +/// rediscover the peer that filled it. Anything less than a byte-complete +/// file answers false, so eviction and half-finished transfers fall back to +/// the normal resolver on their own. +#[tauri::command] +pub fn stream_cache_ready( + app: tauri::AppHandle, + username: String, + path: String, + size: u64, +) -> bool { + let Some(cache) = playback::cache_path(&app, &username, &path) else { + return false; + }; + size > 0 && matches!(std::fs::metadata(&cache), Ok(md) if md.len() == size) +} + +/// A waveform plus how much of the track it actually describes. `coverage` +/// is the byte share of the transfer the peaks were decoded from (1.0 for a +/// complete file); the peaks span exactly that prefix of the track and +/// nothing more. +#[derive(Clone, serde::Serialize)] +pub struct WaveformSlice { + coverage: f32, + peaks: Vec, +} + +impl WaveformSlice { + fn empty() -> Self { + WaveformSlice { + coverage: 0.0, + peaks: Vec::new(), + } + } + + fn complete(peaks: Vec) -> Self { + WaveformSlice { + coverage: 1.0, + peaks, + } + } +} + +/// Play an already-downloaded local file. +#[tauri::command] +pub fn play_local( + app: tauri::AppHandle, + path: String, + duration: Option, + start_secs: Option, +) -> Result<(), String> { + if std::fs::metadata(&path).is_err() { + return Err("file not found".into()); + } + crate::diagnostics::clear_stream(&app); + let app2 = app.clone(); + let path2 = path.clone(); + crate::loudness::on_track_started( + &app, + std::path::Path::new(&path), + 0, + None, + Box::new(move || { + // `start_secs` = play a SLICE of the file from there (the + // setlist segment rows); plain tracks pass None. + app2.state::() + .player + .play_local_from(&path2, duration, start_secs); + }), + ); + Ok(()) +} + +/// Decode a local track now and append it behind the active source. This is +/// used for gapless album playback; it has no effect on streaming downloads. +#[tauri::command] +pub fn preload_local( + app: tauri::AppHandle, + players: State<'_, PlayerState>, + path: String, + duration: Option, +) -> Result<(), String> { + if std::fs::metadata(&path).is_err() { + return Err("file not found".into()); + } + players.player.preload_local(&path, duration); + // Gapless normalization: have the boundary gain ready before the switch. + crate::loudness::on_preload_ready(&app, std::path::Path::new(&path)); + Ok(()) +} + +#[tauri::command] +pub async fn library_albums(lib: State<'_, LibraryState>) -> Result, String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.list_albums().map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())? +} + +/// Per-album aggregates (length, bytes, bitrate, formats, last played) for the +/// Library page — one call for the whole library. `min_secs` is the play floor: +/// the frontend passes the same RECENTS_FLOOR_SECS the recents list uses. +#[tauri::command] +pub async fn library_album_stats( + lib: State<'_, LibraryState>, + min_secs: u32, +) -> Result, String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || { + l.album_stats(min_secs).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn album_tracks( + lib: State<'_, LibraryState>, + album_id: i64, +) -> Result, String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || { + l.album_tracks(album_id).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +/// Force-refetch an album's cover art and re-embed it into its tracks. +#[tauri::command] +pub async fn refresh_album_art(app: tauri::AppHandle, album_id: i64) -> Result { + pipeline::refresh_album_art(&app, album_id).await +} + +/// Upgrade every library album to the canonical high-resolution cover +/// (refetch + re-embed + folder covers). Returns how many succeeded. +#[tauri::command] +pub async fn refresh_all_album_art(app: tauri::AppHandle) -> Result { + pipeline::refresh_all_album_art(&app).await +} + +/// Album art as a data URL (small files; avoids asset-protocol scopes). +/// +/// With `max_edge` the art comes back as a Lanczos3 thumbnail bounded to that +/// many pixels on its long side — what every tile wants. Masters here run to +/// 2000px+, and handing one of those to a 132px card makes the webview do a +/// 15x single-step reduction; `thumbnail_jpeg` explains why that aliases. It +/// also cuts the base64 a tile carries from megabytes to tens of kilobytes. +/// +/// Callers that must show TRUE pixels — the fullscreen viewer, the import +/// picker's A/B and its loupe — pass no `max_edge` and get the file untouched. +/// A source already within bounds is passed through rather than re-encoded, so +/// this never costs quality it was meant to protect. +#[tauri::command] +pub async fn album_art( + app: tauri::AppHandle, + path: String, + max_edge: Option, +) -> Result { + // Thumbnails used to cost a FULL master decode + Lanczos resize per + // request, on the unbounded blocking pool, with nothing remembered — + // so a Library page scrolling past a few hundred tiles pinned every + // core in image decodes and the AUDIO thread started to starve (the + // player decodes on its own std::thread, which only chops when the + // whole machine is saturated). Two changes, and either alone would + // have hidden the other's need: + // 1. a decode SEMAPHORE — at most 2 covers decode at once, ever; + // 2. a thumbnail disk cache keyed by (path, mtime, len, edge) — each + // cover is decoded once per size PER LIFETIME, then it's a small + // file read. Covers change by being replaced (cover_pick writes a + // new file), so the mtime+len key self-invalidates; orphaned + // entries are tens of KB and left to lie. + // Master requests (no edge: the player-bar hero) stay a plain read — + // no decode, no permit, no cache. + let thumb_dir = app + .path() + .app_cache_dir() + .ok() + .map(|d| d.join("art-thumbs-v1")); + + // Phase 1 — cheap and permit-free: stat, cache probe, raw read. + let probe_path = path.clone(); + let probe_dir = thumb_dir.clone(); + let (bytes, write_to) = tauri::async_runtime::spawn_blocking( + move || -> Result<(Vec, Option), String> { + let Some(edge) = max_edge else { + return Ok((std::fs::read(&probe_path).map_err(|e| e.to_string())?, None)); + }; + let target = probe_dir.and_then(|dir| art_thumb_target(&dir, &probe_path, edge)); + if let Some(t) = &target { + if let Ok(cached) = std::fs::read(t) { + return Ok((cached, None)); + } + } + Ok((std::fs::read(&probe_path).map_err(|e| e.to_string())?, target)) + }, + ) + .await + .map_err(|e| e.to_string())??; + + // Phase 2 — the decode, gated. Runs only on a cache miss. + let bytes = match write_to { + None => bytes, + Some(target) => { + let _permit = ART_DECODE_SLOTS + .acquire() + .await + .map_err(|e| e.to_string())?; + let edge = max_edge.unwrap_or(0); + tauri::async_runtime::spawn_blocking(move || { + // An undecodable or already-small source falls through to the + // original bytes: a thumbnail is an optimisation, never a + // reason to show nothing. Either result is CACHED, so the + // "already small" discovery (itself a full decode) is also + // paid once. + let out = match pipeline::thumbnail_jpeg(&bytes, edge) { + Ok(Some(thumb)) => thumb, + _ => bytes, + }; + if let Some(dir) = target.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(&target, &out); // failure-soft + out + }) + .await + .map_err(|e| e.to_string())? + } + }; + use base64::Engine as _; + Ok(format!( + "data:image/jpeg;base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + )) +} + +/// At most this many album-art decodes run at once, machine-wide. Art is +/// cosmetic; audio and the UI are not — two permits keep a burst of tile +/// requests from ever owning more than two cores. +static ART_DECODE_SLOTS: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2); + +/// The disk-cache file for one (source, edge) thumbnail, keyed on +/// (path, mtime, len, edge) so a replaced cover self-invalidates. ONE +/// implementation on purpose: `album_art` reads through this key and +/// `library_art_prewarm` writes through it — if they computed it separately +/// and drifted, the warm work would be invisible to the tiles it was done +/// for. None when the source file cannot be statted. +fn art_thumb_target(dir: &std::path::Path, src: &str, edge: u32) -> Option { + let meta = std::fs::metadata(src).ok()?; + let mtime = meta + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_nanos(); + let mut hash: u64 = 0xcbf29ce484222325; + for b in src + .as_bytes() + .iter() + .chain(mtime.to_le_bytes().iter()) + .chain(meta.len().to_le_bytes().iter()) + { + hash ^= u64::from(*b); + hash = hash.wrapping_mul(0x100000001b3); + } + Some(dir.join(format!("{hash:016x}-{edge}.jpg"))) +} + +/// Make the thumbnail cache hold every library cover (and artist portrait) at +/// each requested edge, so the Library's first-ever scroll is a disk read +/// instead of a master decode — [{warm, decoded}] per edge, in the order +/// given, so the caller puts the size it paints first. +/// +/// Already-warm entries cost one stat each. Cold ones decode ONE at a time, +/// through the same 2-permit gate the tiles use — a background warm can never +/// own more than one core, and an on-screen tile waits behind at most one +/// decode. Edges walk outermost so the first edge finishes across the whole +/// library before the next begins. +#[tauri::command] +pub async fn library_art_prewarm( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + edges: Vec, +) -> Result, String> { + let Some(thumb_dir) = app + .path() + .app_cache_dir() + .ok() + .map(|d| d.join("art-thumbs-v1")) + else { + return Ok(vec![]); + }; + let l = lib.library.clone(); + let albums = + tauri::async_runtime::spawn_blocking(move || l.list_albums().map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())??; + let mut paths: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for a in &albums { + // Portraits too: the shelf heads load through the same tile path. + for p in [a.art_path.as_ref(), a.artist_art_path.as_ref()] + .into_iter() + .flatten() + { + if seen.insert(p.clone()) { + paths.push(p.clone()); + } + } + } + let mut out = Vec::new(); + for edge in edges { + if edge == 0 { + out.push((0, 0)); + continue; + } + // One pass of cheap stats finds the cold set; the warm majority never + // touches a permit (the same shape as album_art's phase 1). + let dir = thumb_dir.clone(); + let batch = paths.clone(); + let (warm, cold) = tauri::async_runtime::spawn_blocking(move || { + let mut warm = 0usize; + let mut cold: Vec<(String, std::path::PathBuf)> = Vec::new(); + for path in &batch { + match art_thumb_target(&dir, path, edge) { + Some(t) if t.exists() => warm += 1, + Some(t) => cold.push((path.clone(), t)), + None => {} + } + } + (warm, cold) + }) + .await + .map_err(|e| e.to_string())?; + let mut decoded = 0usize; + for (path, target) in cold { + let permit = ART_DECODE_SLOTS + .acquire() + .await + .map_err(|e| e.to_string())?; + let ok = tauri::async_runtime::spawn_blocking(move || { + let Ok(bytes) = std::fs::read(&path) else { + return false; + }; + // Same fallback as album_art: an undecodable or already-small + // source caches its original bytes, so the discovery is paid + // once there too. + let out = match pipeline::thumbnail_jpeg(&bytes, edge) { + Ok(Some(thumb)) => thumb, + _ => bytes, + }; + if let Some(dir) = target.parent() { + let _ = std::fs::create_dir_all(dir); + } + std::fs::write(&target, &out).is_ok() + }) + .await + .map_err(|e| e.to_string())?; + drop(permit); + if ok { + decoded += 1; + } + } + out.push((warm, decoded)); + } + Ok(out) +} + +/// The cover a streamed track carries INSIDE its own bytes, extracted to a +/// file the ordinary art path can read. +/// +/// A Soulseek transfer is a tagged audio file, and in the reference library +/// 449 of 515 records keep their best cover embedded — so a stream that +/// arrived without any catalog artwork usually still has the real thing a +/// tag read away, and that cover needs no name matching to be certain it +/// belongs to this recording. Extracted once, keyed by the bytes' own hash +/// so two tracks off one record share the file. +#[tauri::command] +pub async fn stream_embedded_art( + app: tauri::AppHandle, + username: String, + path: String, +) -> Result, String> { + let Some(cache) = playback::cache_path(&app, &username, &path) else { + return Ok(None); + }; + // Beside the library, not in AppData. The path is persisted onto the + // playlist row, and `playlist_items.art_path` is one of the columns the + // portable-library rebase rewrites — a cover kept in AppData would opt + // out of that and dangle the first time the drive is opened elsewhere. + let dir = config::ensure_library_data_dir(&app, &config::load(&app)) + .map_err(|e| e.to_string())? + .join("stream-art"); + tauri::async_runtime::spawn_blocking(move || -> Result, String> { + // A transfer still in flight has no cache file yet (the bytes land in + // `.partial`), and a tag read over a half-written one simply + // fails — either way the answer is "nothing yet", and the caller asks + // again when the transfer completes. + if !cache.exists() { + return Ok(None); + } + let Ok(Some(bytes)) = tagger::extract_art(&cache) else { + return Ok(None); + }; + if bytes.is_empty() { + return Ok(None); + } + let mut hash: u64 = 0xcbf29ce484222325; + for b in &bytes { + hash ^= *b as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + // No extension the loader trusts: JPEG and PNG both appear in tags and + // the art pipeline sniffs the bytes anyway. + let out = dir.join(format!("{hash:016x}.img")); + if !matches!(std::fs::metadata(&out), Ok(md) if md.len() == bytes.len() as u64) { + std::fs::write(&out, &bytes).map_err(|e| e.to_string())?; + } + Ok(Some(out.to_string_lossy().to_string())) + }) + .await + .map_err(|e| e.to_string())? +} + +/// Open an http(s) URL in the system's default browser. The WebView blocks +/// `target="_blank"` navigation (no new-window handler is installed), so +/// external links such as artist socials route through +/// this command instead. +#[tauri::command] +pub async fn open_external(url: String) -> Result<(), String> { + if !url.starts_with("https://") && !url.starts_with("http://") { + return Err("only http(s) URLs can be opened".into()); + } + let spawned = { + #[cfg(target_os = "windows")] + { + std::process::Command::new("rundll32") + .args(["url.dll,FileProtocolHandler", &url]) + .spawn() + } + #[cfg(target_os = "macos")] + { + std::process::Command::new("open").arg(&url).spawn() + } + #[cfg(all(unix, not(target_os = "macos")))] + { + std::process::Command::new("xdg-open").arg(&url).spawn() + } + }; + spawned.map(|_| ()).map_err(|e| e.to_string()) +} + +// ---- play history ---- + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn record_play( + lib: State<'_, LibraryState>, + artist: String, + title: String, + path: Option, + catalog_album_id: Option, + album_title: Option, + album_artist: Option, + cover_url: Option, +) -> Result { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || { + let cat = library::CatalogPlay { + album_id: catalog_album_id.as_deref(), + title: album_title.as_deref(), + artist: album_artist.as_deref(), + cover: cover_url.as_deref(), + }; + l.record_play(&artist, &title, path.as_deref(), cat, now_secs()) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +/// Wipe listening history. Destructive and not undoable — `plays` only; +/// albums, tracks and playlists are untouched. Returns rows removed. +#[tauri::command] +pub async fn clear_play_history(lib: State<'_, LibraryState>) -> Result { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || { + l.clear_play_history().map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn play_row_count(lib: State<'_, LibraryState>) -> Result { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.play_row_count().map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())? +} + +/// The sidebar's recently-played list. `min_secs` keeps skips out of it. +#[tauri::command] +pub async fn recent_albums( + lib: State<'_, LibraryState>, + limit: Option, + min_secs: Option, +) -> Result, String> { + let l = lib.library.clone(); + let limit = limit.unwrap_or(20); + let min_secs = min_secs.unwrap_or(45); + tauri::async_runtime::spawn_blocking(move || { + l.recent_albums(limit, min_secs).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn add_play_seconds( + lib: State<'_, LibraryState>, + play_id: i64, + secs: u32, +) -> Result<(), String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || { + l.add_play_seconds(play_id, secs).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn artist_play_stats( + lib: State<'_, LibraryState>, + artist: String, +) -> Result { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || { + l.artist_play_stats(&artist).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +// ---- followed artists ---- + +#[tauri::command] +pub async fn followed_artists( + lib: State<'_, LibraryState>, +) -> Result, String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.followed_artists().map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn follow_artist( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + artist_id: u64, + name: String, + portrait: Option, +) -> Result<(), String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || { + l.follow_artist(artist_id, &name, portrait.as_deref(), now_secs()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let _ = app.emit("soulseek:follows-changed", ()); + Ok(()) +} + +#[tauri::command] +pub async fn unfollow_artist( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + artist_id: u64, +) -> Result<(), String> { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.unfollow_artist(artist_id)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + let _ = app.emit("soulseek:follows-changed", ()); + Ok(()) +} + +// ---- home ---- + +/// Headlines for the home page's news panel, one paced Google News query per +/// followed artist. Serial on purpose: the pacer inside `artist_news` is the +/// budget, and nothing user-blocking waits on this call — the frontend +/// requests it after the page is already up. +#[tauri::command] +pub async fn home_artist_news( + state: State<'_, LibraryState>, + artists: Vec, +) -> Result>, String> { + let mut out = std::collections::HashMap::new(); + for name in artists { + let items = state.metadata.artist_news(&name).await; + out.insert(name, items); + } + Ok(out) +} + +/// Warm every cache the home page reads, in the background. +/// +/// Measured 2026-08-04: assembling the release feed cold costs one +/// catalog_artist call per follow — 48 seconds for nine artists — which is why +/// the page itself must only ever read caches. This walks the follows through +/// the same metadata entry points the pages use (so the 15-minute catalog and +/// 24-hour identity caches fill), then tells the frontend fresh data is there. +#[tauri::command] +pub async fn home_warm(app: tauri::AppHandle, lib: State<'_, LibraryState>) -> Result<(), String> { + let follows = { + let l = lib.library.clone(); + tauri::async_runtime::spawn_blocking(move || l.followed_artists().map_err(|e| e.to_string())) + .await + .map_err(|e| e.to_string())?? + }; + let metadata = lib.metadata.clone(); + tauri::async_runtime::spawn(async move { + for f in follows { + // Page payload first (releases), then the MB supplement the feed + // merges (A ? of WHEN exists only there), then the related pool. + let _ = metadata.catalog_artist(f.artist_id).await; + let _ = metadata + .catalog_artist_identity(f.artist_id, &f.name) + .await; + let _ = metadata.catalog_related(f.artist_id).await; + // One artist at a time and an emit per artist: the page fills in + // progressively instead of all-at-once after the last follow. + let _ = app.emit("soulseek:home-warmed", f.artist_id); + } + let _ = app.emit("soulseek:home-ready", ()); + }); + Ok(()) +} + +// ---- playlists live in src-tauri/src/playlists.rs ---- + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Compute (or fetch cached) waveform peaks for a local audio file. +/// Returns `buckets` normalized 0..1 values, or an empty array on failure. +#[tauri::command] +pub async fn waveform( + players: State<'_, PlayerState>, + path: String, + buckets: usize, + start_secs: Option, + end_secs: Option, +) -> Result, String> { + const MAX_WAVEFORM_BUCKETS: usize = 4096; + if buckets > MAX_WAVEFORM_BUCKETS { + return Err(format!( + "waveform bucket count exceeds {MAX_WAVEFORM_BUCKETS}" + )); + } + if buckets == 0 { + return Ok(Vec::new()); + } + // A range = a setlist SLICE's lane; each slice of one file caches + // separately under its own key. + let ranged = start_secs.is_some() || end_secs.is_some(); + let key = if ranged { + format!( + "{path}#{buckets}@{:.3}-{}", + start_secs.unwrap_or(0.0), + end_secs + .map(|e| format!("{e:.3}")) + .unwrap_or_else(|| "end".into()), + ) + } else { + format!("{path}#{buckets}") + }; + let job = players.waveform_job(&key); + let result = async { + let job_guard = job.clone().lock_owned().await; + if let Some(cached) = players.cached_waveform(&key) { + return Ok(cached); + } + let p = std::path::PathBuf::from(&path); + let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || { + let peaks = if ranged { + player::compute_waveform_range(&p, buckets, start_secs.unwrap_or(0.0), end_secs) + } else { + player::compute_waveform(&p, buckets) + }; + (job_guard, peaks) + }) + .await + .map_err(|e| e.to_string())?; + if !peaks.is_empty() { + players.remove_progressive_waveform(&key); + players.cache_waveform(key.clone(), peaks.clone()); + } + drop(job_guard); + Ok(peaks) + } + .await; + players.release_waveform_job(&key, &job); + result +} + +/// What the audio thread is doing right now. +/// +/// The webview learns about playback only from events, so a RELOAD leaves it +/// believing nothing is playing while audio carries on — the scrubber moved +/// under the words "Nothing playing". This is the boot-time answer, the same +/// shape of recovery `app_status` already provides for the Soulseek connection. +#[tauri::command] +pub fn player_state(players: State<'_, PlayerState>) -> crate::playback::PlaybackSnapshot { + players + .last_transport + .lock() + .map(|snapshot| snapshot.clone()) + .unwrap_or_default() +} + +/// Player transport: action ∈ pause | resume | stop, or seek/volume with value. +#[tauri::command] +pub fn player_control( + app: tauri::AppHandle, + players: State<'_, PlayerState>, + action: String, + value: Option, + rate: Option, +) -> Result<(), String> { + match action.as_str() { + "pause" => players.player.pause(), + "resume" => players.player.resume(), + "stop" => { + // Before the player, so no analyzer is still holding a start + // that could fire behind the stop. + crate::loudness::on_playback_stopped(&app); + players.player.stop() + } + "seek" => players.player.seek(value.unwrap_or(0.0)), + "scratch" => players + .player + .scratch(value.unwrap_or(0.0), rate.unwrap_or(1.0) as f32), + "end-scratch" => players.player.end_scratch(value.unwrap_or(0.0)), + "volume" => players.player.set_volume(value.unwrap_or(0.7) as f32), + other => return Err(format!("unknown action {other}")), + } + Ok(()) +} + +/// Enter mini mode: show the pre-built mini window (declared in +/// tauri.conf.json, created hidden at startup — runtime window creation +/// from a command half-initialises on Windows) and hide the main one. The +/// main window is HIDDEN, never closed: its frontend stays alive as the one +/// brain the mini remote-controls (see src/lib/miniBridge.svelte.js). +#[tauri::command] +pub async fn mini_open(app: tauri::AppHandle, width: f64, height: f64) -> Result<(), String> { + let mini = app + .get_webview_window("mini") + .ok_or_else(|| "mini window missing from config".to_string())?; + let _ = mini.set_size(tauri::LogicalSize::new(width, height)); + mini.show().map_err(|e| e.to_string())?; + let _ = mini.set_focus(); + if let Some(main) = app.get_webview_window("main") { + let _ = main.hide(); + } + Ok(()) +} + +/// Leave mini mode: bring the full app back and tuck the mini away warm +/// (hidden, not closed — reopening is instant and its mirror stays hot). +#[tauri::command] +pub async fn mini_close(app: tauri::AppHandle) -> Result<(), String> { + if let Some(main) = app.get_webview_window("main") { + let _ = main.show(); + let _ = main.set_focus(); + } + if let Some(mini) = app.get_webview_window("mini") { + let _ = mini.hide(); + } + Ok(()) +} + +/// The OS half of dark-glass mode: acrylic blur behind the calling window +/// (the window itself is transparent; the CSS half — :root.glass — thins the +/// window canvas so the blur shows through). +/// +/// NOT tauri's Effect::Acrylic: on Win11 22H2+ that picks the DWMSBT system +/// backdrop, which Windows SUSPENDS whenever the maximized webview gets +/// promoted to a hardware overlay — every hover animation or compositor +/// scroll blinked the glass dark for a beat (Kyle, 2026-08-26). The legacy +/// accent-policy acrylic below is composed with the window itself and holds +/// through interaction. +/// Whether dark-glass is currently ON for the MAIN window. Written by +/// window_glass, read by the window subclass in thumbbar.rs so it can +/// re-assert the accent after the gestures where Windows drops it: the +/// modal move/size loop, and a maximize/restore (Kyle photographed the +/// desktop punching through a maximized window on 2026-08-28 — the accent +/// did not survive the transition on its own). +pub(crate) static GLASS_ON: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// The accent states this window uses. `Blur` is the pre-acrylic +/// ACCENT_ENABLE_BLURBEHIND: visually close, but composited by DWM through +/// the modal move/size loop where the acrylic is SUSPENDED — the subclass +/// swaps down to it for the length of a gesture and back up on release +/// (see thumbbar.rs). Same struct, one field different. +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum Accent { + Off, + Blur, + Acrylic, +} + +pub(crate) fn apply_accent(hwnd: *mut std::ffi::c_void, accent: Accent) -> Result<(), String> { + #[repr(C)] + struct AccentPolicy { + state: u32, + flags: u32, + gradient: u32, // 0xAABBGGRR + animation: u32, + } + #[repr(C)] + struct AttribData { + attrib: u32, + data: *mut std::ffi::c_void, + size: usize, + } + #[allow(non_snake_case)] + #[link(name = "kernel32")] + extern "system" { + fn GetModuleHandleA(name: *const u8) -> *mut std::ffi::c_void; + fn GetProcAddress( + module: *mut std::ffi::c_void, + name: *const u8, + ) -> *mut std::ffi::c_void; + } + type Swca = unsafe extern "system" fn(*mut std::ffi::c_void, *mut AttribData) -> i32; + + unsafe { + let user32 = GetModuleHandleA(b"user32.dll\0".as_ptr()); + let f = if user32.is_null() { + std::ptr::null_mut() + } else { + GetProcAddress(user32, b"SetWindowCompositionAttribute\0".as_ptr()) + }; + if f.is_null() { + return Err("SetWindowCompositionAttribute unavailable".into()); + } + let f: Swca = std::mem::transmute(f); + let mut policy = AccentPolicy { + state: match accent { + Accent::Off => 0, // ACCENT_DISABLED + Accent::Blur => 3, // ACCENT_ENABLE_BLURBEHIND + Accent::Acrylic => 4, // ACCENT_ENABLE_ACRYLICBLURBEHIND + }, + flags: 0, + // A whisper of black in the OS layer (acrylic treats alpha 0 as + // "don't draw"); the real darkening is the CSS canvas tint. + gradient: if matches!(accent, Accent::Off) { 0 } else { 0x2E00_0000 }, + animation: 0, + }; + let mut data = AttribData { + attrib: 0x13, // WCA_ACCENT_POLICY + data: &mut policy as *mut _ as _, + size: std::mem::size_of::(), + }; + f(hwnd, &mut data); + } + Ok(()) +} + +/// The DWM maximize/restore zoom animation. The accent visual detaches for +/// its length and takes until ~650ms after the call to recompose (measured +/// 2026-08-28 at 43ms/frame: desktop icons legible straight through the +/// maximized window). Disabling it removed the punch-through outright, but +/// also the animation — the window snapped to size — and Kyle chose the +/// animation back with the canvas grounded across the recomposition instead +/// (2026-08-29). So transitions are now explicitly forced ON (clearing any +/// disable a previous build left on this window), and the discrete bracket +/// in thumbbar.rs + GROUND_DISCRETE in glass.js covers the detach window. +fn set_transitions_disabled(hwnd: *mut std::ffi::c_void, disabled: bool) { + #[allow(non_snake_case)] + #[link(name = "kernel32")] + extern "system" { + fn LoadLibraryA(name: *const u8) -> *mut std::ffi::c_void; + fn GetProcAddress( + module: *mut std::ffi::c_void, + name: *const u8, + ) -> *mut std::ffi::c_void; + } + type DwmSet = + unsafe extern "system" fn(*mut std::ffi::c_void, u32, *const std::ffi::c_void, u32) -> i32; + unsafe { + let dwm = LoadLibraryA(b"dwmapi.dll".as_ptr()); + if dwm.is_null() { + return; + } + let f = GetProcAddress(dwm, b"DwmSetWindowAttribute".as_ptr()); + if f.is_null() { + return; + } + let f: DwmSet = std::mem::transmute(f); + let val: i32 = if disabled { 1 } else { 0 }; + // 3 = DWMWA_TRANSITIONS_FORCEDISABLED + let _ = f(hwnd, 3, &val as *const _ as _, 4); + } +} + +#[tauri::command] +pub fn window_glass(window: tauri::WebviewWindow, on: bool) -> Result<(), String> { + // Clear any system backdrop a previous toggle/build may have applied. + let _ = window.set_effects(None::); + let hwnd = window.hwnd().map_err(|e| e.to_string())?.0 as *mut std::ffi::c_void; + // The mini calls this too (window_glass acts on the CALLING window), and + // the subclass re-asserting from thumbbar.rs is on the MAIN window only — + // so only the main window's state is tracked. + if window.label() == "main" { + GLASS_ON.store(on, std::sync::atomic::Ordering::Relaxed); + } + set_transitions_disabled(hwnd, false); + apply_accent(hwnd, if on { Accent::Acrylic } else { Accent::Off }) +} + +#[cfg(test)] +mod hero_processing_tests { + use super::*; + + #[test] + fn baked_look_is_deterministic_and_changes_pixels() { + let original = image::RgbImage::from_pixel(32, 18, image::Rgb([92, 126, 161])); + let mut first = original.clone(); + let mut second = original.clone(); + let source = b"stable provider image bytes"; + + apply_baked_hero_look(&mut first, source); + apply_baked_hero_look(&mut second, source); + + assert_eq!(first, second); + assert_ne!(first, original); + } + + #[test] + fn sixteen_by_nine_source_finishes_at_uhd() { + assert_eq!(scaled_height(1280, 720, 3840), 2160); + } + + #[test] + fn letterbox_bars_are_trimmed() { + use image::GenericImageView as _; + // 256x144 mid-gray photo with 20px black bars baked top and bottom. + let mut img = image::RgbImage::from_pixel(256, 144, image::Rgb([120, 110, 100])); + for y in 0..20 { + for x in 0..256 { + img.put_pixel(x, y, image::Rgb([2, 2, 2])); + img.put_pixel(x, 143 - y, image::Rgb([2, 2, 2])); + } + } + let trimmed = + trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img)).expect("bars should trim"); + assert_eq!(trimmed.dimensions(), (256, 104)); + } + + #[test] + fn dark_photo_is_not_eaten() { + // Uniformly dark-but-not-black image: everything is "barlike", so the + // half-image guard rejects the crop and nothing is trimmed. + let img = image::RgbImage::from_pixel(256, 144, image::Rgb([24, 20, 18])); + assert!(trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img)).is_none()); + } + + #[test] + fn every_hero_gets_a_maximal_stable_crop() { + // Square sources retain every horizontal pixel; only Y can move. + let square = maximal_hero_rect(1000, 1000).expect("square must crop"); + assert_eq!((square.x, square.width, square.height), (0, 1000, 562)); + // Wide strips retain full height and remain horizontally centered. + let strip = maximal_hero_rect(1500, 500).expect("strip must crop"); + assert_eq!( + (strip.x, strip.y, strip.width, strip.height), + (306, 0, 888, 500) + ); + // Already 16:9 is the full source rectangle. + let hd = maximal_hero_rect(1920, 1080).expect("HD must plan"); + assert_eq!((hd.x, hd.y, hd.width, hd.height), (0, 0, 1920, 1080)); + } + + #[test] + fn featureless_murk_band_is_trimmed() { + // Bright photo with a dark featureless gradient band at the top — + // YouTube composites these between the black bar and the artwork. + let mut img = image::RgbImage::from_pixel(256, 144, image::Rgb([150, 140, 130])); + for y in 0..24 { + let shade = 20 + (y as u8); // 20..43 — dark, low range per row + for x in 0..256 { + img.put_pixel(x, y, image::Rgb([shade, shade, shade])); + } + } + let trimmed = trim_letterbox_bars(&image::DynamicImage::ImageRgb8(img)) + .expect("murk band should trim"); + use image::GenericImageView as _; + assert_eq!(trimmed.dimensions(), (256, 120)); + } +} + + diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 94f0a8e..349da6b 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -148,6 +148,14 @@ pub struct AppConfig { /// their status and stats stream in while connected. #[serde(default)] pub buddies: Vec, + /// What we have told the Soulseek server we like, and what we do not. + /// Kept here because the server's copy is per account, not per client: + /// signing in from anywhere else can change it under us, so this device's + /// list is re-declared on every login. + #[serde(default)] + pub interests_like: Vec, + #[serde(default)] + pub interests_hate: Vec, /// 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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 870e245..6013688 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -132,6 +132,8 @@ pub fn run() { commands::slsk_user_info, commands::slsk_user_interests, commands::slsk_similar_users, + commands::slsk_interests_mine, + commands::slsk_interest_set, slsk_link::slsk_link_status, slsk_link::slsk_link_self, slsk_link::slsk_link_unlink, diff --git a/src/lib/api.js b/src/lib/api.js index b3279d6..4c62fe2 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -151,6 +151,8 @@ export const slskBrowseUser = (username) => invoke("slsk_browse_user", { usernam export const slskUserInfo = (username) => invoke("slsk_user_info", { username }); export const slskUserInterests = (username) => invoke("slsk_user_interests", { username }); export const slskSimilarUsers = () => invoke("slsk_similar_users"); +export const slskInterestsMine = () => invoke("slsk_interests_mine"); +export const slskInterestSet = (thing, like, on) => invoke("slsk_interest_set", { thing, like, on }); export const slskLinkStatus = () => invoke("slsk_link_status"); export const slskLinkSelf = () => invoke("slsk_link_self"); export const slskLinkUnlink = () => invoke("slsk_link_unlink"); diff --git a/src/lib/community.svelte.js b/src/lib/community.svelte.js index e64cc60..f15d1e1 100644 --- a/src/lib/community.svelte.js +++ b/src/lib/community.svelte.js @@ -60,6 +60,8 @@ export const community = $state({ interests: {}, /** The server's similar-users answer, or null before it was asked. */ similar: null, + /** What this device has declared on Soulseek — the shelf's only input. */ + interests: null, /** slsk_link_status: our own verified link and everyone we verified. */ links: null, /** username → the last failed ask's reason. */ @@ -419,6 +421,24 @@ export async function loadUserInfo(username) { .catch((e) => (community.interests[username] = { error: String(e) })); } } +/** What we have told the server we like. The shelf ranks on exactly this. */ +export async function refreshInterests() { + try { + community.interests = await api.slskInterestsMine(); + } catch { + // not connected; the panel says so + } +} + +/** Declare an interest, or take it back, then ask the server again — the + * ranking only moves when its input does. */ +export const setInterest = (thing, like, on) => + run("interest", async () => { + community.interests = await api.slskInterestSet(thing, like, on); + community.similar = null; + await loadSimilar(true); + }); + export async function loadSimilar(force = false) { if (community.similar && !force) return; community.busy.similar = true; @@ -706,4 +726,4 @@ export const decideProposal = (owner, space, proposal, accept, reason) => // Dev handle beside `__soulstream` (state.svelte.js): probes reach the store // through this, never through a bare import (which would be a second copy). if (import.meta.env.DEV) globalThis.__community = community; -if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap }; +if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest }; diff --git a/src/views/Community.svelte b/src/views/Community.svelte index 299888d..c01d869 100644 --- a/src/views/Community.svelte +++ b/src/views/Community.svelte @@ -58,6 +58,8 @@ personaKey, loadUserInfo, loadSimilar, + refreshInterests, + setInterest, refreshLinks, linkSelf, unlinkSelf, @@ -603,9 +605,13 @@ $effect(() => { for (const f of pendingIn()) if (!f.username) untrack(() => loadRemoteProfile(f.persona_key)); }); + let likeDraft = $state(""); // Discover's similar-users shelf asks the server once per session. $effect(() => { - if (community.section === "discover" && online) untrack(() => loadSimilar()); + if (community.section === "discover" && online) { + untrack(() => loadSimilar()); + untrack(() => refreshInterests()); + } }); function saveMeta(patch = {}) { if (!drawerUser) return; @@ -1121,6 +1127,17 @@

Similar Soulseek interests

the server's match on declared likes · not follows + {#if online} + + {/if} {#if community.similar?.users?.length}
{#each community.similar.users.slice(0, 24) as su (su.username)} @@ -2177,6 +2194,16 @@ .field label span.over { color: var(--err); } .field input, .field textarea { background: var(--surf-hi); border: 1px solid transparent; border-radius: 8px; color: #fff; padding: 8px 12px; font-size: 13px; outline: none; resize: none; font-family: inherit; } .field input:focus, .field textarea:focus { border-color: rgba(255, 255, 255, 0.28); } + .likes { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 2px 0 12px; } + .likes .inl { flex: 1; min-width: 240px; } + .likes .chip .x { + all: unset; + cursor: pointer; + margin-left: 6px; + opacity: 0.65; + line-height: 1; + } + .likes .chip .x:hover { opacity: 1; } .inl { display: flex; gap: 8px; align-items: center; } .inl input { flex: 1; min-width: 0; } .chk { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--text-muted); }