Files
SoulStream/src-tauri/src/pipeline.rs
T

1426 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 = app.path().app_data_dir().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> {
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())?;
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::<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)
}