//! Post-download pipeline: tags → MusicBrainz canonicalization → album art //! → embed → library DB. Also scans the downloads folder on startup so //! pre-existing files join the library. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use tauri::{Emitter, Manager}; use library::Library; use metadata::{AlbumMeta, MetadataClient}; use tagger::{TagSummary, TagWrite}; // aiff/aif joined for the import tool: the reference library carries 13 AIFF // tracks that a scan would otherwise silently drop, and "silently" is the // problem — the album imports incomplete with no warning. pub(crate) const AUDIO_EXTS: [&str; 10] = ["flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "aiff", "aif"]; pub struct LibraryState { pub library: Arc, pub(crate) metadata: Arc, /// Unix seconds of the last user-facing catalog command. The library /// identity warmer only works while this has been quiet, so background /// warmth never queues ahead of a page the user is actually looking at. pub(crate) catalog_activity: std::sync::atomic::AtomicU64, pub(crate) art_dir: PathBuf, /// (artist, album) lowercase → resolved metadata, deduping MB lookups. album_cache: tokio::sync::Mutex>>, /// Serializes art fetch per album key. art_locks: Mutex>>>, /// Download-staging dir (`//`) → organized album dir. /// Lets late-arriving non-audio extras follow their album, after which the /// emptied staging dirs are removed. staging_targets: Mutex>, } impl LibraryState { /// Stamp a user-facing catalog command; the identity warmer defers to this. pub(crate) fn touch_catalog_activity(&self) { self.catalog_activity .store(unix_secs(), std::sync::atomic::Ordering::Relaxed); } fn catalog_idle_secs(&self) -> u64 { let last = self .catalog_activity .load(std::sync::atomic::Ordering::Relaxed); if last == 0 { u64::MAX } else { unix_secs().saturating_sub(last) } } } fn unix_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } fn warm_name_key(name: &str) -> String { name.to_lowercase() .chars() .filter(|c| c.is_alphanumeric()) .collect() } /// Background identity warmer: once per app start, walk the library's /// artists (most recently added first) and touch each one's MusicBrainz /// identity chain, so the 24h disk cache keeps the artists the user actually /// visits permanently warm — a cold page's slowest stage (3.7-8.5 s of /// 1 req/s-gated MusicBrainz) becomes a disk read for library artists. /// /// Politeness rules, in order of importance: /// - Never compete with the user: work only after 25 s of catalog quiet. /// - Never rush MusicBrainz: an 8 s gap after every artist that needed real /// network work (already-warm artists advance after 1 s). /// - Bounded: at most 150 artists per app start. fn spawn_identity_warmer(app: tauri::AppHandle) { tauri::async_runtime::spawn(async move { // Let startup scans and the user's first page own the providers. tokio::time::sleep(std::time::Duration::from_secs(90)).await; let state = app.state::(); let lib = state.library.clone(); let Ok(Ok(albums)) = tauri::async_runtime::spawn_blocking(move || lib.list_albums()).await else { return; }; let mut seen = std::collections::HashSet::new(); let artists: Vec = albums .into_iter() .map(|album| album.artist) .filter(|artist| !artist.trim().is_empty()) .filter(|artist| seen.insert(warm_name_key(artist))) .take(150) .collect(); if artists.is_empty() { return; } log::info!("identity warmer: {} library artists queued", artists.len()); let mut warmed = 0usize; for artist in artists { // Yield to the user for as long as they are browsing. loop { let idle = state.catalog_idle_secs(); if idle >= 25 { break; } tokio::time::sleep(std::time::Duration::from_secs(26 - idle)).await; } let started = std::time::Instant::now(); let search = state.metadata.catalog_search(&artist).await; let deezer_id = search .artists .iter() .find(|candidate| warm_name_key(&candidate.name) == warm_name_key(&artist)) .map(|candidate| candidate.id); if let Some(deezer_id) = deezer_id { let _ = state .metadata .catalog_artist_identity(deezer_id, &artist) .await; let _ = state.metadata.catalog_artist_socials(deezer_id).await; warmed += 1; } // Real network work earns MusicBrainz a long gap; a disk-warm // artist (sub-second) only needs a token pause. let gap = if started.elapsed() < std::time::Duration::from_millis(600) { 1 } else { 8 }; tokio::time::sleep(std::time::Duration::from_secs(gap)).await; } log::info!("identity warmer: warmed {warmed} artists"); }); } pub fn init(app: &tauri::AppHandle) -> Result<(), String> { let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?; let art_dir = data_dir.join("art"); std::fs::create_dir_all(&art_dir).map_err(|e| e.to_string())?; let library = Arc::new(Library::open(&data_dir.join("library.db")).map_err(|e| e.to_string())?); let repair_library = library.clone(); let repair_art_dir = art_dir.clone(); app.manage(LibraryState { library, // The disk layer only caches slow-moving providers (MusicBrainz, // Wikipedia) so a restarted app keeps its supplement/type-hint and // description warmth instead of re-serializing MusicBrainz at 1 req/s. metadata: Arc::new({ let client = MetadataClient::new() .with_disk_cache(data_dir.join("net-cache-v1")) // Its own directory, NOT inside the cache above: that one is // pruned by mtime, and these are verdicts rather than cached // responses. .with_identity_store(data_dir.join("artist-identity-v1")); // Hero candidate/scoring diagnostics on demand, so an in-APP hero // resolution can be compared against `hero_source_probe` — the two // do not always see the same candidate set, and only the app's own // trace can explain a selection the user actually saw. if std::env::var("SOULSTREAM_HERO_TRACE").is_ok_and(|value| value == "1") { client.with_hero_diagnostics() } else { client } }), catalog_activity: std::sync::atomic::AtomicU64::new(0), art_dir, album_cache: tokio::sync::Mutex::new(HashMap::new()), art_locks: Mutex::new(HashMap::new()), staging_targets: Mutex::new(HashMap::new()), }); spawn_identity_warmer(app.clone()); // Startup: drop rows for deleted files, then index anything new. let handle = app.clone(); tauri::async_runtime::spawn(async move { if let Ok(Ok(repaired)) = tauri::async_runtime::spawn_blocking(move || { repair_library.repair_art_paths(&repair_art_dir) }) .await { if repaired > 0 { log::info!("library repair: restored {repaired} artwork cache references"); } } let state = handle.state::(); let lib = state.library.clone(); let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing()).await; let lib = state.library.clone(); if let Ok(Ok(removed)) = tauri::async_runtime::spawn_blocking(move || lib.prune_non_audio()).await { if removed > 0 { log::info!("library cleanup: removed {removed} non-audio track rows"); } } // Backfill sample-rate / bit-depth / size for tracks indexed before // those columns existed, so the Now Playing panel shows full quality // info for older library files. Size is the sentinel, so this runs // once per file (after prune, every remaining track has a file). let lib = state.library.clone(); let _ = tauri::async_runtime::spawn_blocking(move || { let todo = lib.tracks_missing_props().unwrap_or_default(); if todo.is_empty() { return; } let n = todo.len(); for (id, path) in todo { let p = std::path::Path::new(&path); let (sr, bd) = match tagger::read_summary(p) { Ok(s) => (s.sample_rate, s.bit_depth), Err(_) => (None, None), }; let size = std::fs::metadata(p).ok().map(|m| m.len()); let _ = lib.set_track_props(id, sr, bd, size); } log::info!("library backfill: audio props for {n} tracks"); }) .await; // Old staging leftovers: folders emptied by organizing are removed // on the fly now, but sweep empties left behind by earlier runs — // dead transfers first, so the folders they emptied qualify. sweep_stale_partials(&downloads_dir(&handle)); sweep_stale_auditions(&downloads_dir(&handle)); sweep_empty_dirs(&downloads_dir(&handle), 0); scan_downloads(&handle).await; }); Ok(()) } /// A `.partial` in the "(Cache)" staging pen whose bytes last moved more /// than this long ago is a transfer that died — an active download's mtime /// is always fresh, and a retry recreates its file from wherever the peer /// resumes. const STALE_PARTIAL_SECS: u64 = 3 * 24 * 60 * 60; fn sweep_stale_partials(root: &Path) { fn walk(dir: &Path, removed: &mut u32) { let Ok(entries) = std::fs::read_dir(dir) else { return }; for e in entries.flatten() { let p = e.path(); if p.is_dir() { walk(&p, removed); } else if p.extension().is_some_and(|x| x == "partial") { let stale = e .metadata() .and_then(|m| m.modified()) .ok() .and_then(|t| t.elapsed().ok()) .is_some_and(|age| age.as_secs() > STALE_PARTIAL_SECS); if stale && std::fs::remove_file(&p).is_ok() { *removed += 1; } } } } let mut removed = 0u32; walk(&root.join("(Cache)"), &mut removed); if removed > 0 { log::info!("staging sweep: removed {removed} stale .partial file(s)"); } } /// An audition album nobody claimed within this window is a version the /// review looked at and walked away from. Longer than the .partial window on /// purpose: these are COMPLETE albums, and the whole point of caching them is /// that a mind changed a few days later costs nothing. const STALE_AUDITION_SECS: u64 = 7 * 24 * 60 * 60; /// Reclaim `(Cache)\_audition\\` folders that have sat untouched past /// the retention window. Claiming one moves it into the library, so anything /// still here was passed over; the newest file in the folder is what ages, /// so a transfer still filling in is never swept out from under itself. pub(crate) fn sweep_stale_auditions(root: &Path) { let pen = root.join("(Cache)").join("_audition"); let Ok(entries) = std::fs::read_dir(&pen) else { return }; let (mut albums, mut bytes) = (0u32, 0u64); for e in entries.flatten() { let dir = e.path(); if !dir.is_dir() { continue; } // The FOLDER's own mtime is the floor. import_audition creates the // directory before the first byte lands, so an empty one is usually a // transfer that just started — ageing it from the epoch would have a // Rescan in that window delete the pen out from under it. An empty // folder that has genuinely sat a week still ages out on its own date. let mut newest = e .metadata() .and_then(|m| m.modified()) .unwrap_or_else(|_| std::time::SystemTime::now()); let mut size = 0u64; // Every file in the folder ages it, strays included — a peer's .m3u // lands at the same moment as the audio, so nothing here can hold a // spent folder open. for f in std::fs::read_dir(&dir).into_iter().flatten().flatten() { if let Ok(m) = f.metadata() { size += m.len(); if let Ok(t) = m.modified() { newest = newest.max(t); } } } let stale = newest .elapsed() .ok() .is_some_and(|age| age.as_secs() > STALE_AUDITION_SECS); if stale && std::fs::remove_dir_all(&dir).is_ok() { albums += 1; bytes += size; } } if albums > 0 { log::info!( "audition sweep: reclaimed {albums} unclaimed album(s), {:.1} MB", bytes as f64 / 1_048_576.0 ); } } /// Remove empty directory trees below `dir` (post-order; `dir` itself stays). fn sweep_empty_dirs(dir: &Path, depth: u8) { if depth > 6 { return; } let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { sweep_empty_dirs(&path, depth + 1); // Fails (harmlessly) unless the directory is empty. let _ = std::fs::remove_dir(&path); } } } fn downloads_dir(app: &tauri::AppHandle) -> PathBuf { let cfg = crate::config::load(app); crate::config::download_dir(app, &cfg) } /// The Library page's Rescan: what startup does — drop rows whose files /// are gone (a manual delete in Explorer is a legitimate edit), then index /// anything new under the root. Without the prune, deletions were ghosts /// until the next app launch. #[tauri::command] pub async fn rescan_library(app: tauri::AppHandle) -> Result<(), String> { let state = app.state::(); let lib = state.library.clone(); let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing()) .await .map_err(|e| e.to_string())?; let root = downloads_dir(&app); sweep_stale_partials(&root); sweep_stale_auditions(&root); sweep_empty_dirs(&root, 0); scan_downloads(&app).await; Ok(()) } /// Index every audio file under the downloads dir that isn't known yet. async fn scan_downloads(app: &tauri::AppHandle) { let root = downloads_dir(app); let mut files = Vec::new(); collect_audio_files(&root, &mut files, 0); if files.is_empty() { return; } log::info!( "library scan: {} audio files under {}", files.len(), root.display() ); let state = app.state::(); for path in files { let known = { let lib = state.library.clone(); let p = path.to_string_lossy().into_owned(); tauri::async_runtime::spawn_blocking(move || lib.has_track_path(&p).unwrap_or(false)) .await .unwrap_or(false) }; if !known { process_file(app, &path, None).await; } } repair_artist_portraits(app).await; let _ = app.emit("soulseek:library-changed", ()); } /// Artist portraits are written ONCE, by `process_file` at import. This scan /// skips every file it already knows, so a portrait that later goes missing /// from disk — a cleared art directory, a half-copied profile — leaves the /// database pointing at a file that will never come back, and the shelf head /// falls back to its icon forever. /// /// Repair exactly that: an artist whose stored path is NULL or no longer on /// disk. Untouched portraits cost nothing, and an affected artist costs one /// paced Deezer read (shared budget, same as every other read here). /// /// Deliberately NOT a re-resolve of portraits that DO exist. That would repair /// the other half of this problem — entries stored before `fetch_artist_art` /// learned to rank candidates and reject Deezer's empty-md5 placeholder — but /// it is a request per artist on every scan, and it would overwrite a portrait /// the user is already looking at. Dropping the `needs` condition below is all /// it would take, if that is ever wanted. async fn repair_artist_portraits(app: &tauri::AppHandle) { let state = app.state::(); let stored = { let lib = state.library.clone(); match tauri::async_runtime::spawn_blocking(move || lib.artist_portraits()).await { Ok(Ok(rows)) => rows, _ => return, } }; for (artist, path) in stored { let needs = match path.as_deref() { None => true, Some(p) => !Path::new(p).exists(), }; if !needs { continue; } if let Some(fresh) = fetch_artist_art_cached(&state, &artist).await { let lib = state.library.clone(); let name = artist.clone(); let updated = tauri::async_runtime::spawn_blocking(move || lib.set_artist_art(&name, &fresh)) .await; if let Ok(Ok(n)) = updated { log::info!("artist portrait repaired for {artist} ({n} albums)"); } } } } pub(crate) fn collect_audio_files(dir: &Path, out: &mut Vec, depth: u8) { if depth > 6 { return; } let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { // "(Cache)" is the download staging pen — a completed file // sitting there is seconds from being organized out, and // indexing it would pin a library row to a staging path. if path.file_name().is_some_and(|n| n == "(Cache)") { continue; } collect_audio_files(&path, out, depth + 1); } else if path .extension() .and_then(|e| e.to_str()) .map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str())) .unwrap_or(false) { out.push(path); } } } /// Run one file through the pipeline. `remote_path` (when this came from a /// fresh download) improves folder-based metadata parsing. pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path: Option<&str>) { // Download responses include album artwork and metadata sidecars. Never // feed those files to tag parsing or the library database. if !is_audio_path(local_path) { log::debug!("non-audio download {}", local_path.display()); // If the rest of this staging folder was already organized, send this // extra (cover art, log, cue…) after its album and tidy the folder. if remote_path.is_some() { if let Some(parent) = local_path.parent() { let album_dir = app .state::() .staging_targets .lock() .unwrap() .get(parent) .cloned(); if let Some(album_dir) = album_dir { cleanup_staging(app, parent, &album_dir); } } } return; } let state = app.state::(); // 1. Existing tags (retry once: freshly-written files can be briefly // locked by AV/indexing on Windows). let mut summary = TagSummary::default(); for attempt in 0..2 { let p = local_path.to_path_buf(); match tauri::async_runtime::spawn_blocking(move || tagger::read_summary(&p)).await { Ok(Ok(s)) => { summary = s; break; } Ok(Err(e)) if attempt == 0 => { log::warn!( "tag read failed for {} (retrying): {e}", local_path.display() ); tokio::time::sleep(std::time::Duration::from_millis(750)).await; } Ok(Err(e)) => log::warn!("tag read failed for {}: {e}", local_path.display()), Err(e) => log::warn!("tag read task failed for {}: {e}", local_path.display()), } } let summary: TagSummary = summary; // 2. Fill gaps from the path (remote path preferred: original layout). let parsed = tagger::parse_from_path(remote_path.unwrap_or(&local_path.to_string_lossy())); let artist_guess = summary.artist.clone().or(parsed.artist.clone()); let album_guess = summary.album.clone().or(parsed.album.clone()); let title = summary .title .clone() .or(parsed.title.clone()) .unwrap_or_else(|| { local_path .file_stem() .unwrap_or_default() .to_string_lossy() .into_owned() }); // 3. Canonicalize via MusicBrainz (cached per album). let meta = match (&artist_guess, &album_guess) { (Some(artist), Some(album)) => { let key = (artist.to_lowercase(), album.to_lowercase()); let mut cache = state.album_cache.lock().await; if let Some(cached) = cache.get(&key) { cached.clone() } else { let looked = state.metadata.lookup_album(artist, album).await; cache.insert(key, looked.clone()); looked } } _ => None, }; let final_artist = meta .as_ref() .map(|m| m.artist.clone()) .or(artist_guess) .unwrap_or_else(|| "Unknown Artist".into()); let final_album = meta .as_ref() .map(|m| m.album.clone()) .or(album_guess) .unwrap_or_else(|| "Unknown Album".into()); let final_year = summary .year .or(parsed.year) .or(meta.as_ref().and_then(|m| m.year)); let release_date = meta .as_ref() .and_then(|m| m.release_date.clone()) .or_else(|| final_year.map(|year| year.to_string())); let mbid = meta.as_ref().and_then(|m| m.release_mbid.clone()); // A repeated save downloads (or copies from the stream cache) into a // staging folder first. Do this check before organization: otherwise the // collision-safe filename suffix (" (2)") makes it a new path and creates // a duplicate track row for the same song. if remote_path.is_some() { let lib = state.library.clone(); let artist = final_artist.clone(); let album = final_album.clone(); let track = title.clone(); match tauri::async_runtime::spawn_blocking(move || lib.has_track(&artist, &album, &track)) .await { Ok(Ok(true)) => { log::info!( "skipping duplicate library track: {} — {} — {}", final_artist, final_album, title ); if let Err(e) = std::fs::remove_file(local_path) { log::warn!( "could not discard duplicate staging file {}: {e}", local_path.display() ); } return; } Ok(Err(e)) => log::warn!("could not check for duplicate library track: {e}"), Err(e) => log::warn!("duplicate library check task failed: {e}"), Ok(Ok(false)) => {} } } // 4. Album art (once per album; serialized against concurrent tracks). // The master is the album FOLDER's cover.jpg. For a download the layout // makes that folder knowable before the file is organized into it, so // the cover is filed first and the audio moves in beside it; a scanned // file's album folder is simply where it already sits. let cfg = crate::config::load(app); let rg_mbid = meta.as_ref().and_then(|m| m.release_group_mbid.clone()); let album_dir = if remote_path.is_some() { album_layout_dir( app, &final_artist, &final_album, release_date.as_deref(), final_year, ) } else { local_path.parent().map(Path::to_path_buf).unwrap_or_default() }; let art_path = fetch_art_cached( &state, &album_dir, &final_artist, &final_album, mbid.as_deref(), rg_mbid.as_deref(), remote_path.is_some() && crate::config::write_folder_cover(&cfg), ) .await; let artist_art_path = fetch_artist_art_cached(&state, &final_artist).await; // 5. Write tags. Fresh downloads carry the canonical cover, replacing // whatever picture the uploader embedded (config-gated) so the file, the // library, and the catalog all show the same artwork. Startup-scanned // files are never stripped: they only gain art when they have none. let replace_embedded = remote_path.is_some() && crate::config::replace_embedded_art(&cfg); let embed_max_edge = crate::config::embed_art_max_edge(&cfg); let art_bytes = match (&art_path, replace_embedded || !summary.has_art) { (Some(p), true) => std::fs::read(p).ok(), _ => None, }; let write = TagWrite { artist: summary.artist.is_none().then_some(final_artist.clone()), album_artist: summary .album_artist .is_none() .then_some(final_artist.clone()), album: summary.album.is_none().then_some(final_album.clone()), title: summary.title.is_none().then_some(title.clone()), track_no: summary.track_no.or(parsed.track_no), year: summary.year.is_none().then(|| final_year).flatten(), label: None, genre: None, }; { let p = local_path.to_path_buf(); let res = tauri::async_runtime::spawn_blocking(move || { let embed = art_bytes.map(|b| prepare_embed_art(b, embed_max_edge)); tagger::write_tags(&p, &write, embed.as_deref(), replace_embedded) }) .await; if let Ok(Err(e)) = res { log::warn!("tagging {} failed: {e}", local_path.display()); } } // 6. Fresh downloads become part of the managed library/share folder. // Keep an existing library exactly where it is; reorganizing old files is // a deliberate future action, not a surprise side effect of startup scan. let library_path = if remote_path.is_some() { organize_download( app, local_path, &final_artist, &final_album, release_date.as_deref(), final_year, ) } else { local_path.to_path_buf() }; // (The folder cover needs no separate write any more — step 4 filed the // master INTO the album folder, which is the same folder organize just // moved the audio into.) // 7. Library rows. { let lib = state.library.clone(); let p = library_path.to_string_lossy().into_owned(); let art = art_path.as_ref().map(|a| a.to_string_lossy().into_owned()); let track_no = summary.track_no.or(parsed.track_no); let (duration, bitrate) = (summary.duration_secs, summary.bitrate_kbps); let (sample_rate, bit_depth) = (summary.sample_rate, summary.bit_depth); let size = std::fs::metadata(&library_path).ok().map(|m| m.len()); let (artist, album, title2) = (final_artist.clone(), final_album.clone(), title.clone()); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); let res = tauri::async_runtime::spawn_blocking(move || -> library::Result<()> { let album_id = lib.upsert_album( &artist, &album, final_year, art.as_deref(), artist_art_path.as_deref(), mbid.as_deref(), now, )?; if let Some(a) = &art { lib.set_album_art(album_id, a)?; } lib.upsert_track( album_id, track_no, &title2, &artist, duration, bitrate, sample_rate, bit_depth, size, &p, now, )?; Ok(()) }) .await; if let Ok(Err(e)) = res { log::warn!("library insert for {} failed: {e}", local_path.display()); } } let _ = app.emit("soulseek:library-changed", ()); } async fn fetch_artist_art_cached( state: &tauri::State<'_, LibraryState>, artist: &str, ) -> Option { let key: String = artist .to_lowercase() .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .collect(); let target = state.art_dir.join(format!("artist-{key}.jpg")); let original = state.art_dir.join(format!("artist-{key}-original")); // Older portraits were stored without a source/thumbnail split. Re-fetch // those once so the new high-quality thumbnail path takes effect. if target.exists() && original.exists() { return Some(target.to_string_lossy().into_owned()); } let bytes = state.metadata.fetch_artist_art(artist).await?; std::fs::write(&original, &bytes).ok()?; write_artist_thumbnail(&bytes, &target)?; Some(target.to_string_lossy().into_owned()) } /// One definition of "a thumbnail" for every surface that shows art in a tile: /// Lanczos3 down to `max_edge`, re-encoded JPEG q92. /// /// The webview is a poor resampler for big reductions. It scales a decoded /// source into the tile in ONE bilinear step, so a 2000px cover in a 132px /// card samples roughly every fifteenth pixel — which reads as aliasing, and /// is worst on photographic covers with fine detail or overlaid text. Lanczos3 /// on the Rust side averages the pixels that are actually being dropped. /// /// `Ok(None)` means the source is already within `max_edge` and should be /// handed through untouched; `Err(())` means it could not be decoded at all. /// Aspect ratio is preserved (`resize` fits inside the box), so a non-square /// cover stays non-square and the tile's `object-fit: cover` still does the /// cropping — the thumbnail never decides the composition. pub fn thumbnail_jpeg(bytes: &[u8], max_edge: u32) -> Result>, ()> { let decoded = image::load_from_memory(bytes).map_err(|_| ())?; if decoded.width().max(decoded.height()) <= max_edge { return Ok(None); } let thumb = decoded.resize(max_edge, max_edge, image::imageops::FilterType::Lanczos3); let mut out = std::io::Cursor::new(Vec::new()); let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 92); thumb.write_with_encoder(encoder).map_err(|_| ())?; Ok(Some(out.into_inner())) } /// Preserve the original portrait, but hand the webview a clean 512px /// thumbnail generated with Lanczos3. This is large enough for a 2x card and /// avoids browser scaling a huge compressed source directly into the tile. fn write_artist_thumbnail(bytes: &[u8], target: &Path) -> Option<()> { let out = match thumbnail_jpeg(bytes, 512) { Ok(Some(thumb)) => thumb, Ok(None) => bytes.to_vec(), Err(()) => return None, }; std::fs::write(target, out).ok() } /// Refresh portraits for every distinct artist already represented in the /// library. Existing cache files are replaced so Settings can recover from a /// failed lookup or a poor match. pub async fn refresh_all_artist_art(app: &tauri::AppHandle) -> Result { let state = app.state::(); let albums = { let lib = state.library.clone(); tauri::async_runtime::spawn_blocking(move || lib.list_albums()) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())? }; let mut artists = std::collections::BTreeSet::new(); for album in albums { artists.insert(album.artist); } let mut refreshed = 0; for artist in artists { let key: String = artist .to_lowercase() .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .collect(); let target = state.art_dir.join(format!("artist-{key}.jpg")); let original = state.art_dir.join(format!("artist-{key}-original")); let _ = std::fs::remove_file(&target); let _ = std::fs::remove_file(&original); if let Some(bytes) = state.metadata.fetch_artist_art(&artist).await { if std::fs::write(&original, &bytes).is_ok() && write_artist_thumbnail(&bytes, &target).is_some() { let path = target.to_string_lossy().into_owned(); let lib = state.library.clone(); let artist2 = artist.clone(); tauri::async_runtime::spawn_blocking(move || lib.set_artist_art(&artist2, &path)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; refreshed += 1; } } } let _ = app.emit("soulseek:library-changed", ()); Ok(refreshed) } pub fn is_audio_path(path: &Path) -> bool { path.extension() .and_then(|e| e.to_str()) .map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str())) .unwrap_or(false) } /// Move a newly completed download into one album directory under the managed /// root. The filename remains intact, preserving track numbering supplied by /// the source. If a matching file exists, retain both files with a suffix. /// The album directory the configured layout renders for this record — /// deterministic from the tags alone, so the cover can be filed there /// before the first track even arrives. Nothing is created here. fn album_layout_dir( app: &tauri::AppHandle, artist: &str, album: &str, release_date: Option<&str>, year: Option, ) -> PathBuf { let cfg = crate::config::load(app); let date = display_date(release_date).unwrap_or_else(|| { year.map(|y| y.to_string()) .unwrap_or_else(|| "Unknown date".into()) }); let rendered = render_layout( crate::config::library_layout(&cfg), artist, album, &date, year, ); // Separators written in the layout create subfolders. Separators inside // tag values were flattened by render_layout, so metadata can neither // split folders nor escape the library root ("..", trailing dots and // empty segments are dropped by safe_segment). let mut target_dir = crate::config::download_dir(app, &cfg); let mut depth = 0; for seg in rendered.split(['/', '\\']) { let seg = safe_segment(seg); if seg.is_empty() { continue; } target_dir = target_dir.join(seg); depth += 1; } if depth == 0 { target_dir = target_dir.join("Unknown Album"); } target_dir } fn organize_download( app: &tauri::AppHandle, source: &Path, artist: &str, album: &str, release_date: Option<&str>, year: Option, ) -> PathBuf { let target_dir = album_layout_dir(app, artist, album, release_date, year); if std::fs::create_dir_all(&target_dir).is_err() { return source.to_path_buf(); } let name = source.file_name().unwrap_or_default().to_string_lossy(); let mut target = target_dir.join(safe_component(&name)); if source == target { return source.to_path_buf(); } if target.exists() { let stem = target .file_stem() .unwrap_or_default() .to_string_lossy() .into_owned(); let ext = target .extension() .map(|e| format!(".{}", e.to_string_lossy())) .unwrap_or_default(); for n in 2..10_000 { let candidate = target_dir.join(format!("{stem} ({n}){ext}")); if !candidate.exists() { target = candidate; break; } } } match std::fs::rename(source, &target) { Ok(()) => { // Remember where this staging folder's album lives, then tidy it: // once the last track leaves, remaining extras follow and the // empty staging dirs are removed. if let Some(parent) = source.parent() { app.state::() .staging_targets .lock() .unwrap() .insert(parent.to_path_buf(), target_dir.clone()); cleanup_staging(app, parent, &target_dir); } target } Err(e) => { log::warn!("could not organize {}: {e}", source.display()); source.to_path_buf() } } } /// Non-audio files worth keeping next to the organized album. const EXTRA_EXTS: &[&str] = &[ "jpg", "jpeg", "png", "gif", "webp", "bmp", "nfo", "txt", "log", "cue", "m3u", "m3u8", "pdf", "sfv", "md5", "accurip", ]; /// If `staging` holds no more audio or in-flight downloads, move its album /// extras into `album_dir` and delete the emptied staging directories up to /// (but excluding) the download root. Backs off untouched at the first sign /// of anything unexpected: pending `.partial` files, subfolders, or file /// types outside the extras allowlist. fn cleanup_staging(app: &tauri::AppHandle, staging: &Path, album_dir: &Path) { let root = downloads_dir(app); if !staging.starts_with(&root) || staging == root || album_dir.starts_with(staging) { return; } let Ok(entries) = std::fs::read_dir(staging) else { return; }; let mut extras = Vec::new(); for entry in entries.flatten() { let path = entry.path(); if path.is_dir() || is_audio_path(&path) { return; } let ext = path .extension() .and_then(|e| e.to_str()) .map(|e| e.to_ascii_lowercase()) .unwrap_or_default(); if ext == "partial" || !EXTRA_EXTS.contains(&ext.as_str()) { return; } extras.push(path); } for src in extras { let name = src .file_name() .unwrap_or_default() .to_string_lossy() .into_owned(); let dst = album_dir.join(safe_component(&name)); if dst.exists() { // The album already has this extra; the copy adds nothing. let _ = std::fs::remove_file(&src); } else if std::fs::rename(&src, &dst).is_err() { return; // locked or cross-volume oddity: keep the folder } } let mut dir = staging.to_path_buf(); while dir != root && dir.starts_with(&root) && std::fs::remove_dir(&dir).is_ok() { app.state::() .staging_targets .lock() .unwrap() .remove(&dir); match dir.parent() { Some(p) => dir = p.to_path_buf(), None => break, } } } pub(crate) fn render_layout(layout: &str, artist: &str, album: &str, date: &str, year: Option) -> String { layout .replace("{artist}", &clean_chars(artist)) .replace("{album}", &clean_chars(album)) .replace("{date}", &clean_chars(date)) .replace( "{year}", &year .map(|v| v.to_string()) .unwrap_or_else(|| "Unknown year".into()), ) } /// MusicBrainz uses ISO dates. Display the fully-known dates in the requested /// day-month-year ordering while preserving partial dates such as "1997". fn display_date(raw: Option<&str>) -> Option { let raw = raw?.trim(); let parts: Vec<_> = raw.split('-').collect(); match parts.as_slice() { [year, month, day] if year.len() == 4 && month.len() == 2 && day.len() == 2 => { Some(format!("{day}-{month}-{year}")) } _ if !raw.is_empty() => Some(raw.to_string()), _ => None, } } /// Replace characters Windows forbids in file names. fn clean_chars(value: &str) -> String { value .chars() .map(|c| match c { '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_', c if (c as u32) < 0x20 => '_', c => c, }) .collect() } /// One path segment: cleaned and trimmed; Windows rejects trailing dots and /// spaces. May return an empty string (caller decides the fallback). pub(crate) fn safe_segment(value: &str) -> String { clean_chars(value) .trim() .trim_end_matches([' ', '.']) .to_string() } pub(crate) fn safe_component(value: &str) -> String { let trimmed = safe_segment(value); if trimmed.is_empty() { "Unknown Album".into() } else { trimmed } } /// `/.jpg`, fetched at most once per album. /// `force` refetches even when a cached file exists. /// The album's master cover lives IN the album folder as `cover.jpg` — art /// travels with the music, one copy exists, and deleting the album deletes /// its art (his call, 2026-08-07; AppData keeps only artist portraits and /// the import picker's transient extractions). `replace` stomps whatever /// cover is there (a fresh download's canonical art, a forced refresh); /// without it an existing folder cover IS the master and no fetch happens. pub async fn fetch_art_cached( state: &tauri::State<'_, LibraryState>, album_dir: &Path, artist: &str, album: &str, mbid: Option<&str>, rg_mbid: Option<&str>, replace: bool, ) -> Option { let key: String = format!("{artist} - {album}") .to_lowercase() .chars() .map(|c| match c { '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_', c if (c as u32) < 0x20 => '_', c => c, }) .collect(); let lock = { let mut locks = state.art_locks.lock().unwrap(); locks .entry(key.clone()) .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) .clone() }; let _guard = lock.lock().await; if !replace { for name in ["cover.jpg", "folder.jpg"] { let existing = album_dir.join(name); if existing.exists() { return Some(existing); } } } let bytes = state .metadata .fetch_art(mbid, rg_mbid, artist, album) .await?; std::fs::create_dir_all(album_dir).ok()?; let cover = write_album_cover_files(album_dir, &bytes)?; log::info!( "album art saved: {} ({} KB)", cover.display(), bytes.len() / 1024 ); Some(cover) } /// Downscale the canonical cover for embedding into audio files. `max_edge` /// 0 embeds the original untouched; anything already at or under the cap /// passes through byte-for-byte. Oversized covers are Lanczos3-resized and /// re-encoded as JPEG 92 so a 3000px original doesn't add megabytes to every /// track — the full-resolution file stays in the art cache and album folder. pub(crate) fn prepare_embed_art(bytes: Vec, max_edge: u32) -> Vec { if max_edge == 0 { return bytes; } let Ok(img) = image::load_from_memory(&bytes) else { return bytes; }; if img.width().max(img.height()) <= max_edge { return bytes; } let resized = img.resize(max_edge, max_edge, image::imageops::FilterType::Lanczos3); let mut output = std::io::Cursor::new(Vec::new()); let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 92); match resized.into_rgb8().write_with_encoder(encoder) { Ok(()) => output.into_inner(), Err(_) => bytes, } } /// Write the canonical cover into the album folder as `cover.jpg` and /// `folder.jpg` — the two names Explorer and virtually every player read. /// Rare PNG originals are re-encoded as JPEG 95 so the extension never lies. /// A same-length file is assumed already ours and skipped. fn write_album_folder_cover(album_dir: &Path, art_file: &Path) { let Ok(bytes) = std::fs::read(art_file) else { return; }; let _ = write_album_cover_files(album_dir, &bytes); } /// Write cover bytes into the album folder under the canonical names, and /// return the `cover.jpg` path — the album's master. See the doc above. pub(crate) fn write_album_cover_files(album_dir: &Path, bytes: &[u8]) -> Option { let bytes = bytes.to_vec(); let jpeg = if bytes.starts_with(b"\x89PNG") { let Ok(img) = image::load_from_memory(&bytes) else { return None; }; let mut output = std::io::Cursor::new(Vec::new()); let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95); if img.into_rgb8().write_with_encoder(encoder).is_err() { return None; } output.into_inner() } else { bytes }; // Always write the two canonical names, and additionally overwrite any // *existing* generic art-slot file an uploader left behind (albumart.jpg, // front.jpg, …) — players that prefer those names must not show stale art. // Only .jpg/.jpeg names are replaced in place; scans/booklets keep their // distinctive filenames and are never touched. const GENERIC_ART_NAMES: [&str; 6] = [ "albumart.jpg", "albumart.jpeg", "album.jpg", "front.jpg", "front.jpeg", "thumb.jpg", ]; let existing_generic: Vec = std::fs::read_dir(album_dir) .map(|entries| { entries .flatten() .filter_map(|e| e.file_name().into_string().ok()) .filter(|n| GENERIC_ART_NAMES.contains(&n.to_ascii_lowercase().as_str())) .collect() }) .unwrap_or_default(); for name in ["cover.jpg", "folder.jpg"] .into_iter() .chain(existing_generic.iter().map(String::as_str)) { let target = album_dir.join(name); let already_ours = std::fs::metadata(&target) .map(|m| m.len() == jpeg.len() as u64) .unwrap_or(false); if already_ours { continue; } if let Err(e) = std::fs::write(&target, &jpeg) { log::warn!("could not write album cover {}: {e}", target.display()); } } let cover = album_dir.join("cover.jpg"); cover.exists().then_some(cover) } /// Force-refetch an album's art with fresh MusicBrainz data and re-embed /// it into every track (replacing whatever is there). pub async fn refresh_album_art(app: &tauri::AppHandle, album_id: i64) -> Result { let state = app.state::(); let lib = state.library.clone(); let row = tauri::async_runtime::spawn_blocking(move || lib.get_album(album_id)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())? .ok_or("album not found")?; let (album, _stored_mbid) = row; // Fresh lookup for both MBIDs (the DB only stores the release MBID). let meta = state .metadata .lookup_album(&album.artist, &album.title) .await; let mbid = meta.as_ref().and_then(|m| m.release_mbid.clone()); let rg_mbid = meta.as_ref().and_then(|m| m.release_group_mbid.clone()); // The tracks come first now: the album FOLDER is where the master cover // lives, and the folder is wherever the tracks actually are. let lib = state.library.clone(); let tracks = tauri::async_runtime::spawn_blocking(move || lib.album_tracks(album_id)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; let album_dir = tracks .first() .and_then(|t| Path::new(&t.path).parent().map(Path::to_path_buf)) .ok_or("album has no tracks on disk")?; let art_path = fetch_art_cached( &state, &album_dir, &album.artist, &album.title, mbid.as_deref(), rg_mbid.as_deref(), true, ) .await .ok_or("no art found for this album")?; let art_str = art_path.to_string_lossy().into_owned(); { let lib = state.library.clone(); let a = art_str.clone(); tauri::async_runtime::spawn_blocking(move || lib.set_album_art(album_id, &a)) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; } // Re-embed into every track, replacing the old picture; tracks living in // OTHER folders (multi-disc spreads) get their own cover copies below. let cfg = crate::config::load(app); let embed_max_edge = crate::config::embed_art_max_edge(&cfg); let bytes = std::fs::read(&art_path).map_err(|e| e.to_string())?; let embed = { let b = bytes.clone(); tauri::async_runtime::spawn_blocking(move || prepare_embed_art(b, embed_max_edge)) .await .map_err(|e| e.to_string())? }; let mut album_dirs = std::collections::BTreeSet::new(); for t in &tracks { if let Some(dir) = Path::new(&t.path).parent() { album_dirs.insert(dir.to_path_buf()); } } for t in tracks { let b = embed.clone(); let p = PathBuf::from(&t.path); let res = tauri::async_runtime::spawn_blocking(move || tagger::replace_art(&p, &b)).await; if let Ok(Err(e)) = res { log::warn!("re-embed art failed for {}: {e}", t.path); } } if crate::config::write_folder_cover(&cfg) { let art = art_path.clone(); let _ = tauri::async_runtime::spawn_blocking(move || { for dir in album_dirs { write_album_folder_cover(&dir, &art); } }) .await; } let _ = app.emit("soulseek:library-changed", ()); Ok(art_str) } /// Upgrade every library album to the canonical high-resolution cover: /// refetch, re-embed, and rewrite folder covers. Returns how many albums /// succeeded. MusicBrainz throttling makes this roughly one album per /// second; progress lands in the log. pub async fn refresh_all_album_art(app: &tauri::AppHandle) -> Result { let state = app.state::(); let albums = { let lib = state.library.clone(); tauri::async_runtime::spawn_blocking(move || lib.list_albums()) .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())? }; let total = albums.len(); let mut refreshed = 0; for (i, album) in albums.into_iter().enumerate() { match refresh_album_art(app, album.id).await { Ok(_) => refreshed += 1, Err(e) => log::warn!( "art refresh failed for {} - {}: {e}", album.artist, album.title ), } log::info!("album art refresh: {}/{total}", i + 1); } Ok(refreshed) }