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) { 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( 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())?; 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)); } }