//! 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}; const AUDIO_EXTS: [&str; 8] = ["flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma"]; 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, 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. sweep_empty_dirs(&downloads_dir(&handle), 0); scan_downloads(&handle).await; }); Ok(()) } /// 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) } /// 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; } } let _ = app.emit("soulseek:library-changed", ()); } 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() { 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). let rg_mbid = meta.as_ref().and_then(|m| m.release_group_mbid.clone()); let art_path = fetch_art_cached( &state, &final_artist, &final_album, mbid.as_deref(), rg_mbid.as_deref(), false, ) .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 cfg = crate::config::load(app); 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(), }; { 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() }; // 6b. The canonical cover also becomes the album folder's cover.jpg / // folder.jpg, so Explorer and any other player show the artwork the app // does — stale uploader art under those names is replaced. if remote_path.is_some() && crate::config::write_folder_cover(&cfg) { if let (Some(album_dir), Some(art)) = (library_path.parent(), &art_path) { let (dir, art) = (album_dir.to_path_buf(), art.clone()); let _ = tauri::async_runtime::spawn_blocking(move || write_album_folder_cover(&dir, &art)) .await; } } // 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()) } /// 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 decoded = image::load_from_memory(bytes).ok()?; let thumb = decoded.resize(512, 512, 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).ok()?; std::fs::write(target, out.into_inner()).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. fn organize_download( app: &tauri::AppHandle, source: &Path, 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"); } 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, } } } 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). fn safe_segment(value: &str) -> String { clean_chars(value) .trim() .trim_end_matches([' ', '.']) .to_string() } 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. pub async fn fetch_art_cached( state: &tauri::State<'_, LibraryState>, artist: &str, album: &str, mbid: Option<&str>, rg_mbid: Option<&str>, force: 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 target = state.art_dir.join(format!("{key}.jpg")); 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 target.exists() && !force { return Some(target); } let bytes = state .metadata .fetch_art(mbid, rg_mbid, artist, album) .await?; std::fs::write(&target, &bytes).ok()?; log::info!( "album art saved: {} ({} KB)", target.display(), bytes.len() / 1024 ); Some(target) } /// 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. 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 jpeg = if bytes.starts_with(b"\x89PNG") { let Ok(img) = image::load_from_memory(&bytes) else { return; }; 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; } 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()); } } } /// 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()); let art_path = fetch_art_cached( &state, &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, and refresh the // album folder's cover.jpg/folder.jpg so every surface agrees again. let cfg = crate::config::load(app); let embed_max_edge = crate::config::embed_art_max_edge(&cfg); 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 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) }