There is exactly one Deezer artist called "Wand" (1213178) and it holds the LA psych band, a Brazilian funk MC, an Indonesian act, an Indian one, a 2020 house project and whoever released Hard Knox in 2009 — 46 of its 72 rows are somebody else's. Ranking search results cannot help (there is one candidate) and neither can credit role (every row is primary-credited to it). The Apple namesake filter that should have caught this switched itself off: APPLE_EXACT_ARTIST_CANDIDATE_LIMIT is 8 and Apple has TEN artists named Wand, so the candidate fetch failed closed and every catalogue came back empty. It fails closed for a good reason at page-load time — it picks the winner by overlap with the requested artist's own Deezer releases, which on a fused record are partly the co-tenants'. Circular evidence has to fail closed. So the answer is not to raise that cap but to stop asking the fused list. artist_release_ownership runs in the DEFERRED identity pass, where the linked MBID has already named the artist, and picks the Apple performer that reproduces THAT catalogue. Non-circular, so 20 candidates is safe. Evidence is MB release groups of every type, the verified Apple catalogue, and the label sets of both (MB at one sighting, Apple at two — Apple files two rafftheraff singles under the LA band). Pruning only runs where a row is provably carried by a different same-named Apple artist, so the gate opens on 4 of the 55 artists measured and stays shut on Wednesday's twenty namesakes, Jungle's sixteen and Roosevelt's fourteen. The same evidence repairs the opposite failure. The guest guard keeps an appears_on row only if MB credits it to the artist's own MBID, which assumes MB mirrors collaborations. It does for Watch the Throne, whose artist CREDIT names both. It does not when the collaboration is its own entity: VULTURES 1, VULTURES 2 and KIDS SEE GHOSTS appear nowhere in Ye's 315 release groups, and all three were being erased from his page. Apple's artistName credit, read as a list of principals, restores them without restoring guest spots — Estelle's "American Boy (feat. Kanye West)" is still hers. A fused record's PORTRAIT is wrong for the same reason its releases are: it is whichever co-tenant uploaded last, a real photograph of the wrong person, so deezerPortraitMissing cannot see it. The verdict is persisted, because the search dropdown is typed against per keystroke and can never afford the lookups that produced it. Its store is deliberately not in net-cache-v1 — prune_disk_cache deletes every json there by mtime, and a verdict is not a cached response. Measured live: Wand 72 -> 26, Hot Toddy 36 -> 16, Crazy P 68 -> 51, Turnover 30 -> 25, Kanye 50 -> 64; Weezer, Pond, Billy Cobb, JPEGMAFIA and Daft Punk untouched. The page's "wand." wordmark went with the co-tenants and should have: it was traced from the Indonesian single's cover.
1109 lines
41 KiB
Rust
1109 lines
41 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};
|
|
|
|
const AUDIO_EXTS: [&str; 8] = ["flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma"];
|
|
|
|
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,
|
|
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> {
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
|
|
let art_dir = data_dir.join("art");
|
|
std::fs::create_dir_all(&art_dir).map_err(|e| e.to_string())?;
|
|
|
|
let library = Arc::new(Library::open(&data_dir.join("library.db")).map_err(|e| e.to_string())?);
|
|
let repair_library = library.clone();
|
|
let repair_art_dir = art_dir.clone();
|
|
|
|
app.manage(LibraryState {
|
|
library,
|
|
// The disk layer only caches slow-moving providers (MusicBrainz,
|
|
// Wikipedia) so a restarted app keeps its supplement/type-hint and
|
|
// description warmth instead of re-serializing MusicBrainz at 1 req/s.
|
|
metadata: Arc::new({
|
|
let client = MetadataClient::new()
|
|
.with_disk_cache(data_dir.join("net-cache-v1"))
|
|
// Its own directory, NOT inside the cache above: that one is
|
|
// pruned by mtime, and these are verdicts rather than cached
|
|
// responses.
|
|
.with_identity_store(data_dir.join("artist-identity-v1"));
|
|
// Hero candidate/scoring diagnostics on demand, so an in-APP hero
|
|
// resolution can be compared against `hero_source_probe` — the two
|
|
// do not always see the same candidate set, and only the app's own
|
|
// trace can explain a selection the user actually saw.
|
|
if std::env::var("SOULSTREAM_HERO_TRACE").is_ok_and(|value| value == "1") {
|
|
client.with_hero_diagnostics()
|
|
} else {
|
|
client
|
|
}
|
|
}),
|
|
catalog_activity: std::sync::atomic::AtomicU64::new(0),
|
|
art_dir,
|
|
album_cache: tokio::sync::Mutex::new(HashMap::new()),
|
|
art_locks: Mutex::new(HashMap::new()),
|
|
staging_targets: Mutex::new(HashMap::new()),
|
|
});
|
|
|
|
spawn_identity_warmer(app.clone());
|
|
|
|
// Startup: drop rows for deleted files, then index anything new.
|
|
let handle = app.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
if let Ok(Ok(repaired)) = tauri::async_runtime::spawn_blocking(move || {
|
|
repair_library.repair_art_paths(&repair_art_dir)
|
|
})
|
|
.await
|
|
{
|
|
if repaired > 0 {
|
|
log::info!("library repair: restored {repaired} artwork cache references");
|
|
}
|
|
}
|
|
let state = handle.state::<LibraryState>();
|
|
let lib = state.library.clone();
|
|
let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing()).await;
|
|
let lib = state.library.clone();
|
|
if let Ok(Ok(removed)) =
|
|
tauri::async_runtime::spawn_blocking(move || lib.prune_non_audio()).await
|
|
{
|
|
if removed > 0 {
|
|
log::info!("library cleanup: removed {removed} non-audio track rows");
|
|
}
|
|
}
|
|
// Backfill sample-rate / bit-depth / size for tracks indexed before
|
|
// those columns existed, so the Now Playing panel shows full quality
|
|
// info for older library files. Size is the sentinel, so this runs
|
|
// once per file (after prune, every remaining track has a file).
|
|
let lib = state.library.clone();
|
|
let _ = tauri::async_runtime::spawn_blocking(move || {
|
|
let todo = lib.tracks_missing_props().unwrap_or_default();
|
|
if todo.is_empty() {
|
|
return;
|
|
}
|
|
let n = todo.len();
|
|
for (id, path) in todo {
|
|
let p = std::path::Path::new(&path);
|
|
let (sr, bd) = match tagger::read_summary(p) {
|
|
Ok(s) => (s.sample_rate, s.bit_depth),
|
|
Err(_) => (None, None),
|
|
};
|
|
let size = std::fs::metadata(p).ok().map(|m| m.len());
|
|
let _ = lib.set_track_props(id, sr, bd, size);
|
|
}
|
|
log::info!("library backfill: audio props for {n} tracks");
|
|
})
|
|
.await;
|
|
// Old staging leftovers: folders emptied by organizing are removed
|
|
// on the fly now, but sweep empties left behind by earlier runs.
|
|
sweep_empty_dirs(&downloads_dir(&handle), 0);
|
|
scan_downloads(&handle).await;
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove empty directory trees below `dir` (post-order; `dir` itself stays).
|
|
fn sweep_empty_dirs(dir: &Path, depth: u8) {
|
|
if depth > 6 {
|
|
return;
|
|
}
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
sweep_empty_dirs(&path, depth + 1);
|
|
// Fails (harmlessly) unless the directory is empty.
|
|
let _ = std::fs::remove_dir(&path);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn downloads_dir(app: &tauri::AppHandle) -> PathBuf {
|
|
let cfg = crate::config::load(app);
|
|
crate::config::download_dir(app, &cfg)
|
|
}
|
|
|
|
/// Index every audio file under the downloads dir that isn't known yet.
|
|
async fn scan_downloads(app: &tauri::AppHandle) {
|
|
let root = downloads_dir(app);
|
|
let mut files = Vec::new();
|
|
collect_audio_files(&root, &mut files, 0);
|
|
if files.is_empty() {
|
|
return;
|
|
}
|
|
log::info!(
|
|
"library scan: {} audio files under {}",
|
|
files.len(),
|
|
root.display()
|
|
);
|
|
|
|
let state = app.state::<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;
|
|
}
|
|
}
|
|
let _ = app.emit("soulseek:library-changed", ());
|
|
}
|
|
|
|
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() {
|
|
collect_audio_files(&path, out, depth + 1);
|
|
} else if path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
|
|
.unwrap_or(false)
|
|
{
|
|
out.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run one file through the pipeline. `remote_path` (when this came from a
|
|
/// fresh download) improves folder-based metadata parsing.
|
|
pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path: Option<&str>) {
|
|
// Download responses include album artwork and metadata sidecars. Never
|
|
// feed those files to tag parsing or the library database.
|
|
if !is_audio_path(local_path) {
|
|
log::debug!("non-audio download {}", local_path.display());
|
|
// If the rest of this staging folder was already organized, send this
|
|
// extra (cover art, log, cue…) after its album and tidy the folder.
|
|
if remote_path.is_some() {
|
|
if let Some(parent) = local_path.parent() {
|
|
let album_dir = app
|
|
.state::<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).
|
|
let rg_mbid = meta.as_ref().and_then(|m| m.release_group_mbid.clone());
|
|
let art_path = fetch_art_cached(
|
|
&state,
|
|
&final_artist,
|
|
&final_album,
|
|
mbid.as_deref(),
|
|
rg_mbid.as_deref(),
|
|
false,
|
|
)
|
|
.await;
|
|
|
|
let artist_art_path = fetch_artist_art_cached(&state, &final_artist).await;
|
|
|
|
// 5. Write tags. Fresh downloads carry the canonical cover, replacing
|
|
// whatever picture the uploader embedded (config-gated) so the file, the
|
|
// library, and the catalog all show the same artwork. Startup-scanned
|
|
// files are never stripped: they only gain art when they have none.
|
|
let cfg = crate::config::load(app);
|
|
let replace_embedded = remote_path.is_some() && crate::config::replace_embedded_art(&cfg);
|
|
let embed_max_edge = crate::config::embed_art_max_edge(&cfg);
|
|
let art_bytes = match (&art_path, replace_embedded || !summary.has_art) {
|
|
(Some(p), true) => std::fs::read(p).ok(),
|
|
_ => None,
|
|
};
|
|
let write = TagWrite {
|
|
artist: summary.artist.is_none().then_some(final_artist.clone()),
|
|
album_artist: summary
|
|
.album_artist
|
|
.is_none()
|
|
.then_some(final_artist.clone()),
|
|
album: summary.album.is_none().then_some(final_album.clone()),
|
|
title: summary.title.is_none().then_some(title.clone()),
|
|
track_no: summary.track_no.or(parsed.track_no),
|
|
year: summary.year.is_none().then(|| final_year).flatten(),
|
|
};
|
|
{
|
|
let p = local_path.to_path_buf();
|
|
let res = tauri::async_runtime::spawn_blocking(move || {
|
|
let embed = art_bytes.map(|b| prepare_embed_art(b, embed_max_edge));
|
|
tagger::write_tags(&p, &write, embed.as_deref(), replace_embedded)
|
|
})
|
|
.await;
|
|
if let Ok(Err(e)) = res {
|
|
log::warn!("tagging {} failed: {e}", local_path.display());
|
|
}
|
|
}
|
|
|
|
// 6. Fresh downloads become part of the managed library/share folder.
|
|
// Keep an existing library exactly where it is; reorganizing old files is
|
|
// a deliberate future action, not a surprise side effect of startup scan.
|
|
let library_path = if remote_path.is_some() {
|
|
organize_download(
|
|
app,
|
|
local_path,
|
|
&final_artist,
|
|
&final_album,
|
|
release_date.as_deref(),
|
|
final_year,
|
|
)
|
|
} else {
|
|
local_path.to_path_buf()
|
|
};
|
|
|
|
// 6b. The canonical cover also becomes the album folder's cover.jpg /
|
|
// folder.jpg, so Explorer and any other player show the artwork the app
|
|
// does — stale uploader art under those names is replaced.
|
|
if remote_path.is_some() && crate::config::write_folder_cover(&cfg) {
|
|
if let (Some(album_dir), Some(art)) = (library_path.parent(), &art_path) {
|
|
let (dir, art) = (album_dir.to_path_buf(), art.clone());
|
|
let _ =
|
|
tauri::async_runtime::spawn_blocking(move || write_album_folder_cover(&dir, &art))
|
|
.await;
|
|
}
|
|
}
|
|
|
|
// 7. Library rows.
|
|
{
|
|
let lib = state.library.clone();
|
|
let p = library_path.to_string_lossy().into_owned();
|
|
let art = art_path.as_ref().map(|a| a.to_string_lossy().into_owned());
|
|
let track_no = summary.track_no.or(parsed.track_no);
|
|
let (duration, bitrate) = (summary.duration_secs, summary.bitrate_kbps);
|
|
let (sample_rate, bit_depth) = (summary.sample_rate, summary.bit_depth);
|
|
let size = std::fs::metadata(&library_path).ok().map(|m| m.len());
|
|
let (artist, album, title2) = (final_artist.clone(), final_album.clone(), title.clone());
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0);
|
|
let res = tauri::async_runtime::spawn_blocking(move || -> library::Result<()> {
|
|
let album_id = lib.upsert_album(
|
|
&artist,
|
|
&album,
|
|
final_year,
|
|
art.as_deref(),
|
|
artist_art_path.as_deref(),
|
|
mbid.as_deref(),
|
|
now,
|
|
)?;
|
|
if let Some(a) = &art {
|
|
lib.set_album_art(album_id, a)?;
|
|
}
|
|
lib.upsert_track(
|
|
album_id,
|
|
track_no,
|
|
&title2,
|
|
&artist,
|
|
duration,
|
|
bitrate,
|
|
sample_rate,
|
|
bit_depth,
|
|
size,
|
|
&p,
|
|
now,
|
|
)?;
|
|
Ok(())
|
|
})
|
|
.await;
|
|
if let Ok(Err(e)) = res {
|
|
log::warn!("library insert for {} failed: {e}", local_path.display());
|
|
}
|
|
}
|
|
|
|
let _ = app.emit("soulseek:library-changed", ());
|
|
}
|
|
|
|
async fn fetch_artist_art_cached(
|
|
state: &tauri::State<'_, LibraryState>,
|
|
artist: &str,
|
|
) -> Option<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())
|
|
}
|
|
|
|
/// Preserve the original portrait, but hand the webview a clean 512px
|
|
/// thumbnail generated with Lanczos3. This is large enough for a 2x card and
|
|
/// avoids browser scaling a huge compressed source directly into the tile.
|
|
fn write_artist_thumbnail(bytes: &[u8], target: &Path) -> Option<()> {
|
|
let decoded = image::load_from_memory(bytes).ok()?;
|
|
let thumb = decoded.resize(512, 512, image::imageops::FilterType::Lanczos3);
|
|
let mut out = std::io::Cursor::new(Vec::new());
|
|
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 92);
|
|
thumb.write_with_encoder(encoder).ok()?;
|
|
std::fs::write(target, out.into_inner()).ok()
|
|
}
|
|
|
|
/// Refresh portraits for every distinct artist already represented in the
|
|
/// library. Existing cache files are replaced so Settings can recover from a
|
|
/// failed lookup or a poor match.
|
|
pub async fn refresh_all_artist_art(app: &tauri::AppHandle) -> Result<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 {
|
|
path.extension()
|
|
.and_then(|e| e.to_str())
|
|
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Move a newly completed download into one album directory under the managed
|
|
/// root. The filename remains intact, preserving track numbering supplied by
|
|
/// the source. If a matching file exists, retain both files with a suffix.
|
|
fn organize_download(
|
|
app: &tauri::AppHandle,
|
|
source: &Path,
|
|
artist: &str,
|
|
album: &str,
|
|
release_date: Option<&str>,
|
|
year: Option<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");
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
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).
|
|
fn safe_segment(value: &str) -> String {
|
|
clean_chars(value)
|
|
.trim()
|
|
.trim_end_matches([' ', '.'])
|
|
.to_string()
|
|
}
|
|
|
|
fn safe_component(value: &str) -> String {
|
|
let trimmed = safe_segment(value);
|
|
if trimmed.is_empty() {
|
|
"Unknown Album".into()
|
|
} else {
|
|
trimmed
|
|
}
|
|
}
|
|
|
|
/// `<art_dir>/<artist - album>.jpg`, fetched at most once per album.
|
|
/// `force` refetches even when a cached file exists.
|
|
pub async fn fetch_art_cached(
|
|
state: &tauri::State<'_, LibraryState>,
|
|
artist: &str,
|
|
album: &str,
|
|
mbid: Option<&str>,
|
|
rg_mbid: Option<&str>,
|
|
force: bool,
|
|
) -> Option<PathBuf> {
|
|
let key: String = format!("{artist} - {album}")
|
|
.to_lowercase()
|
|
.chars()
|
|
.map(|c| match c {
|
|
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
|
|
c if (c as u32) < 0x20 => '_',
|
|
c => c,
|
|
})
|
|
.collect();
|
|
let target = state.art_dir.join(format!("{key}.jpg"));
|
|
|
|
let lock = {
|
|
let mut locks = state.art_locks.lock().unwrap();
|
|
locks
|
|
.entry(key.clone())
|
|
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
|
.clone()
|
|
};
|
|
let _guard = lock.lock().await;
|
|
|
|
if target.exists() && !force {
|
|
return Some(target);
|
|
}
|
|
let bytes = state
|
|
.metadata
|
|
.fetch_art(mbid, rg_mbid, artist, album)
|
|
.await?;
|
|
std::fs::write(&target, &bytes).ok()?;
|
|
log::info!(
|
|
"album art saved: {} ({} KB)",
|
|
target.display(),
|
|
bytes.len() / 1024
|
|
);
|
|
Some(target)
|
|
}
|
|
|
|
/// Downscale the canonical cover for embedding into audio files. `max_edge`
|
|
/// 0 embeds the original untouched; anything already at or under the cap
|
|
/// passes through byte-for-byte. Oversized covers are Lanczos3-resized and
|
|
/// re-encoded as JPEG 92 so a 3000px original doesn't add megabytes to every
|
|
/// track — the full-resolution file stays in the art cache and album folder.
|
|
fn prepare_embed_art(bytes: Vec<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 jpeg = if bytes.starts_with(b"\x89PNG") {
|
|
let Ok(img) = image::load_from_memory(&bytes) else {
|
|
return;
|
|
};
|
|
let mut output = std::io::Cursor::new(Vec::new());
|
|
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95);
|
|
if img.into_rgb8().write_with_encoder(encoder).is_err() {
|
|
return;
|
|
}
|
|
output.into_inner()
|
|
} else {
|
|
bytes
|
|
};
|
|
// Always write the two canonical names, and additionally overwrite any
|
|
// *existing* generic art-slot file an uploader left behind (albumart.jpg,
|
|
// front.jpg, …) — players that prefer those names must not show stale art.
|
|
// Only .jpg/.jpeg names are replaced in place; scans/booklets keep their
|
|
// distinctive filenames and are never touched.
|
|
const GENERIC_ART_NAMES: [&str; 6] = [
|
|
"albumart.jpg",
|
|
"albumart.jpeg",
|
|
"album.jpg",
|
|
"front.jpg",
|
|
"front.jpeg",
|
|
"thumb.jpg",
|
|
];
|
|
let existing_generic: Vec<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());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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());
|
|
|
|
let art_path = fetch_art_cached(
|
|
&state,
|
|
&album.artist,
|
|
&album.title,
|
|
mbid.as_deref(),
|
|
rg_mbid.as_deref(),
|
|
true,
|
|
)
|
|
.await
|
|
.ok_or("no art found for this album")?;
|
|
|
|
let art_str = art_path.to_string_lossy().into_owned();
|
|
{
|
|
let lib = state.library.clone();
|
|
let a = art_str.clone();
|
|
tauri::async_runtime::spawn_blocking(move || lib.set_album_art(album_id, &a))
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
|
|
// Re-embed into every track, replacing the old picture, and refresh the
|
|
// album folder's cover.jpg/folder.jpg so every surface agrees again.
|
|
let cfg = crate::config::load(app);
|
|
let embed_max_edge = crate::config::embed_art_max_edge(&cfg);
|
|
let lib = state.library.clone();
|
|
let tracks = tauri::async_runtime::spawn_blocking(move || lib.album_tracks(album_id))
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())?;
|
|
let bytes = std::fs::read(&art_path).map_err(|e| e.to_string())?;
|
|
let embed = {
|
|
let b = bytes.clone();
|
|
tauri::async_runtime::spawn_blocking(move || prepare_embed_art(b, embed_max_edge))
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
};
|
|
let mut album_dirs = std::collections::BTreeSet::new();
|
|
for t in &tracks {
|
|
if let Some(dir) = Path::new(&t.path).parent() {
|
|
album_dirs.insert(dir.to_path_buf());
|
|
}
|
|
}
|
|
for t in tracks {
|
|
let b = embed.clone();
|
|
let p = PathBuf::from(&t.path);
|
|
let res = tauri::async_runtime::spawn_blocking(move || tagger::replace_art(&p, &b)).await;
|
|
if let Ok(Err(e)) = res {
|
|
log::warn!("re-embed art failed for {}: {e}", t.path);
|
|
}
|
|
}
|
|
if crate::config::write_folder_cover(&cfg) {
|
|
let art = art_path.clone();
|
|
let _ = tauri::async_runtime::spawn_blocking(move || {
|
|
for dir in album_dirs {
|
|
write_album_folder_cover(&dir, &art);
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
let _ = app.emit("soulseek:library-changed", ());
|
|
Ok(art_str)
|
|
}
|
|
|
|
/// Upgrade every library album to the canonical high-resolution cover:
|
|
/// refetch, re-embed, and rewrite folder covers. Returns how many albums
|
|
/// succeeded. MusicBrainz throttling makes this roughly one album per
|
|
/// second; progress lands in the log.
|
|
pub async fn refresh_all_album_art(app: &tauri::AppHandle) -> Result<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)
|
|
}
|