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, } #[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), 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, ) -> Result<(), String> { let mut cfg = config::load(&app); 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); } config::save(&app, &cfg); // 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 }), ); *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 ); 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 { 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), "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 }), ); } _ => {} } } }); Ok(greeting) } // ---- 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; fn artist_hero_dir(app: &tauri::AppHandle) -> Result { let dir = app .path() .app_data_dir() .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_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 { let cached = crate::playback::cache_path(&app, &f.username, &f.path).filter(|c| c.exists()); 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)) } /// 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); 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, ) -> 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 || { app2.state::().player.play_local(&path2, duration); }), ); 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())? } #[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). #[tauri::command] pub async fn album_art(path: String) -> Result { let bytes = tauri::async_runtime::spawn_blocking(move || std::fs::read(path)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; use base64::Engine as _; Ok(format!( "data:image/jpeg;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes) )) } /// 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(()) } // ---- playlists ---- fn now_secs() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } #[tauri::command] pub async fn list_playlists( lib: State<'_, LibraryState>, ) -> Result, String> { let l = lib.library.clone(); tauri::async_runtime::spawn_blocking(move || l.list_playlists().map_err(|e| e.to_string())) .await .map_err(|e| e.to_string())? } #[tauri::command] pub async fn create_playlist( app: tauri::AppHandle, lib: State<'_, LibraryState>, name: String, ) -> Result { let l = lib.library.clone(); let id = tauri::async_runtime::spawn_blocking(move || l.create_playlist(&name, now_secs())) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; let _ = app.emit("soulseek:playlists-changed", ()); Ok(id) } #[tauri::command] pub async fn delete_playlist( app: tauri::AppHandle, lib: State<'_, LibraryState>, id: i64, ) -> Result<(), String> { let l = lib.library.clone(); tauri::async_runtime::spawn_blocking(move || l.delete_playlist(id)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; let _ = app.emit("soulseek:playlists-changed", ()); Ok(()) } #[tauri::command] pub async fn playlist_tracks( lib: State<'_, LibraryState>, id: i64, ) -> Result, String> { let l = lib.library.clone(); tauri::async_runtime::spawn_blocking(move || l.playlist_tracks(id).map_err(|e| e.to_string())) .await .map_err(|e| e.to_string())? } #[derive(serde::Deserialize)] pub struct TrackInput { pub title: String, pub artist: String, pub duration_secs: Option, pub path: String, pub art_path: Option, } #[tauri::command] pub async fn add_to_playlist( app: tauri::AppHandle, lib: State<'_, LibraryState>, playlist_id: i64, tracks: Vec, ) -> Result<(), String> { let l = lib.library.clone(); tauri::async_runtime::spawn_blocking(move || -> library::Result<()> { for t in tracks { l.add_to_playlist( playlist_id, &library::PlaylistTrack { position: 0, // assigned by insert title: t.title, artist: t.artist, duration_secs: t.duration_secs, path: t.path, art_path: t.art_path, }, )?; } Ok(()) }) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; let _ = app.emit("soulseek:playlists-changed", ()); Ok(()) } #[tauri::command] pub async fn remove_from_playlist( lib: State<'_, LibraryState>, playlist_id: i64, position: i64, ) -> Result<(), String> { let l = lib.library.clone(); tauri::async_runtime::spawn_blocking(move || l.remove_from_playlist(playlist_id, position)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; Ok(()) } /// 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, ) -> 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()); } let key = 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 = 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(()) } #[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)); } }