Files
SoulStream/src-tauri/src/pipeline.rs
T
KeelrLeek 052e15302b android: what the phone says when things go wrong
Fifty-nine places put a raw Rust string into a field a person reads, and
the red toast drew it verbatim — a phone in a lift showed "could not reach
server: io: failed to lookup address information" under the password box.
One translator, src/mobile/trouble.js, called where an error is DRAWN
rather than where it is caught: the stores keep what Rust said and only
the sentence on screen is ours, so every sink is fixed at once. Five
screens that drew a failed read as an empty answer stop lying — Tracks no
longer reports a broken database as an empty library, a playlist that
could not be read is not an empty playlist, the lyrics face says a lookup
failed instead of "no lyrics" and finally carries the ⋯ that reaches Paste
lyrics, Gaps says why it could not reach the catalogue, and Search signed
out says so where it used to render nothing at all. The room list, which
is fire-and-forget over the wire, gets a twelve-second end and a Try
again.

rescan_library ran prune_missing() first, which deletes every track row
whose file does not exist — so one tap on You → Rescan library with the
folder unreachable emptied the whole index and returned Ok(()). It now
makes the root if it is merely not there yet and refuses if it cannot be
read, and You draws the path with what to do about it.

The mock grew a weather system so the walk can drive all of this: fail,
answer, offline and clear, checked before dispatch so a plugin command
arms like any other. Nine mock steps and one on the phone — the first
steps in thirty rounds that ever made a command reject or saw the client
signed out — and the last of them puts the weather away and leaves the app
as it found it.
2026-09-14 01:23:26 -05:00

1435 lines
55 KiB
Rust

//! 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};
// The one table, in `audio-formats`. This used to be a local list that
// disagreed with the share index and the row pruner — the scanner indexed
// `.aiff` and the pruner deleted it on the next startup.
pub struct LibraryState {
pub library: Arc<Library>,
pub(crate) metadata: Arc<MetadataClient>,
/// 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<HashMap<(String, String), Option<AlbumMeta>>>,
/// Serializes art fetch per album key.
art_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
/// Download-staging dir (`<root>/<user>/<folder>`) → organized album dir.
/// Lets late-arriving non-audio extras follow their album, after which the
/// emptied staging dirs are removed.
staging_targets: Mutex<HashMap<PathBuf, PathBuf>>,
}
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::<LibraryState>();
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<String> = 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> {
// AppData keeps only what is about the APP on THIS machine — the cache of
// remote provider responses. Everything describing the LIBRARY lives in
// the library folder so it travels with the music.
let app_dir = crate::paths::app_data(app).map_err(|e| e.to_string())?;
std::fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?;
let cfg = crate::config::load(app);
let library_root = crate::config::download_dir(app, &cfg);
let moved = crate::config::migrate_library_state_into_library(app, &cfg);
if !moved.is_empty() {
log::info!("library state moved into the library folder: {}", moved.join(", "));
}
// With the drive unplugged there is no library to open. Fall back to a
// machine-local database rather than refusing to start — the real one
// returns with the drive, and nothing here writes into the wrong library
// because every path is compared against the configured root.
let (data_dir, portable) = match crate::config::ensure_library_data_dir(app, &cfg) {
Ok(dir) => (dir, true),
Err(e) => {
log::warn!(
"library folder {} is unavailable ({e}) — running with a local database until it returns",
library_root.display()
);
(app_dir.clone(), false)
}
};
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())?);
if portable {
// The art cache moved with the database, so rows written before the
// move still point into AppData. Re-point them before anything paints.
match library.rewrite_path_prefix(&app_dir.join("art"), &art_dir) {
Ok(n) if n > 0 => log::info!("library: {n} artwork paths follow the art cache into the library folder"),
Err(e) => log::warn!("library: could not re-point artwork paths: {e}"),
_ => {}
}
// And the library itself may have arrived at a new drive letter or on
// a different machine entirely.
match library.rebase_to_root(&library_root) {
Ok(n) if n > 0 => log::info!("library moved: re-based {n} stored paths onto {}", library_root.display()),
Err(e) => log::warn!("library: could not re-base stored paths: {e}"),
_ => {}
}
}
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()
// Provider responses are about the WORLD, not this library, and
// they are cheap to refetch — they stay on the machine.
.with_disk_cache(app_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. Verdicts about the library's artists are library
// state, so they travel with it.
.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::<LibraryState>();
// Before anything asks the disk: the library folder decides what is IN
// the library. Rows from a folder that used to hold it are not "still
// there because their files exist" — they belong to another library.
let lib = state.library.clone();
let root = downloads_dir(&handle);
if let Ok(Ok(dropped)) =
tauri::async_runtime::spawn_blocking(move || lib.prune_outside_root(&root)).await
{
if dropped > 0 {
log::info!("library: dropped {dropped} tracks outside the library folder");
}
}
let lib = state.library.clone();
let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing()).await;
// After the prune, every surviving track has a file — so "the folder
// this album lives in" is answerable, and any cover pointing somewhere
// else is another album's. Files move (a re-import into a new layout, a
// drive back at a different letter) and nothing else re-asks.
let lib = state.library.clone();
let art = state.art_dir.clone();
if let Ok(Ok(moved)) =
tauri::async_runtime::spawn_blocking(move || lib.reanchor_album_art(&art)).await
{
if moved > 0 {
log::info!("library repair: re-anchored artwork for {moved} albums");
}
}
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\<album>\` 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> {
// The prune below deletes every track row whose file does not exist, and
// then every album left with none — so a root that has gone away (an
// unmounted card, a revoked SAF grant, a drive letter that moved) used to
// empty the WHOLE index on one tap and return Ok(()). Make the folder if
// it is merely not there yet, so a fresh install still works, and refuse
// if it cannot be made or read. Same idiom as connect_and_login's.
let root = downloads_dir(&app);
if std::fs::create_dir_all(&root).is_err() || !root.is_dir() {
return Err(format!("library-folder-missing: {}", root.display()));
}
let state = app.state::<LibraryState>();
let lib = state.library.clone();
let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing())
.await
.map_err(|e| e.to_string())?;
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::<LibraryState>();
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::<LibraryState>();
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<PathBuf>, 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.
// ".soulstream" is the library's own state, not music.
if path
.file_name()
.is_some_and(|n| n == "(Cache)" || n == crate::config::LIBRARY_DATA_DIRNAME)
{
continue;
}
collect_audio_files(&path, out, depth + 1);
} else if audio_formats::is_audio_path(&path) {
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::<LibraryState>()
.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::<LibraryState>();
// 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),
// Nothing to add: a downloaded file either carries its disc or has no
// disc to carry, and there is nowhere here to learn one from.
disc_no: None,
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);
// Only what the file itself says. There is no filename convention for
// a disc worth guessing from, and a wrong disc reorders an album.
let disc_no = summary.disc_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,
disc_no,
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<String> {
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<Option<Vec<u8>>, ()> {
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<usize, String> {
let state = app.state::<LibraryState>();
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 {
audio_formats::is_audio_path(path)
}
/// 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<u32>,
) -> 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<u32>,
) -> 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::<LibraryState>()
.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::<LibraryState>()
.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<u32>) -> 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<String> {
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
}
}
/// `<art_dir>/<artist - album>.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<PathBuf> {
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<u8>, max_edge: u32) -> Vec<u8> {
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<PathBuf> {
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<String> = 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<String, String> {
let state = app.state::<LibraryState>();
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<usize, String> {
let state = app.state::<LibraryState>();
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)
}