forked from Keelrleek/SoulStream
Two threads from one session, both of them the same shape: something was being decided from the wrong source. THE PROVIDERS WERE ASKED ABOUT A FOLDER. The picker sent the directory name to Deezer and MusicBrainz — the ripper's naming, carrying catalogue numbers, years and initials. "[WARP CD 156] Battles - Mirrored - 2007" matches nothing anywhere; "Mirrored" matches instantly. Across the reference library 501 of 515 records carry a tag album and 315 disagree with their folder, and no amount of cleaning turns "ACPP" into "Prospect Park, Brooklyn NY". The record's own claim now leads and the folder is the fallback, in one place (src/lib/import-title.js) that apply and the picker both read — apply already resolved it this way, and only the picker did not. The verdict judge had the same split: it compared candidates against the folder too, so an exact answer scored as mere containment and a clean match wore a warning. Identification also stops searching when searching cannot work. A folder named "1989 Pauls Boutique" is a hopeless query and an easy comparison, so after Deezer and MusicBrainz come back empty the artist's own catalogue is ranked by title similarity. Scoring rather than stripping is the point: 1989, 1984 and 2001 are real albums, and any cleaner aggressive enough to rescue Paul's Boutique would have destroyed them. Words split on whitespace with punctuation folded inside them, so "Paul's" stays one token; pressing vocabulary is ignored for scoring only; coverage is measured from both sides. 15 assertions pin the cases that would break it. Cached verdicts now carry the engine that produced them. A cached match is good forever, a cached "nothing found" only as good as the thing that looked — and nothing ever re-asked. Negatives from an older engine, or from a query that has since changed, are dropped and asked again. Soulseek was asked the same wrong question, with the artist said twice: "battles battles - mirrored - 2007". The server matches on ALL terms, so every leftover token narrows the search — that copy was only found because the peer happened to have 2007 in its path. The ladder now leads with the record's name and keeps the folder as the narrower rung behind it. AVAILABILITY OUTRANKS ENCODING. Candidate copies were ranked with avgBitrate above hasFreeSlot, and queue depth — captured on every candidate — was never read at all. A peer with no free slot and 277 people ahead beat a free peer with a queue of two, because its files reported a bitrate and the others reported none, and `?? 0` scores "didn't say" as the worst possible answer. Bitrate is now ignored between two fully-lossless copies, where it describes the material rather than the quality. And nothing ever gave up. Track 1 is requested alone so it is not queued behind eleven of our own files, the rest follow once the peer proves it will serve us, and a copy that makes no progress for 45s is abandoned for the next ranked one — cancelling the transfers it leaves, which used to trickle on forever. A refusal falls through to the next peer instead of ending the record. "Try another peer" is reachable while one is loading, which it never was. RE-IMPORTING BUILT A DUPLICATE FACTORY. Apply decided a track was already filed by comparing the destination's size to its source — but apply itself rewrites that file, embedding the chosen cover, so it is 582 KB larger from the moment it is written and the sizes can never match again. Every run laid down another " (n)" copy with its own library row: three runs left three of everything, 2.16 GB. A file at the destination that this run did not write is now simply an earlier import; within one run a repeated name is still a collision, which is what `kept` is for. ARTWORK FOLLOWS THE RECORD, ONLY THE AUDIO FOLLOWS THE CHOICE. Apply read art from whichever folder the audio came from, which for a kept found copy is the pen — and a downloaded album often carries no picture, so three records imported with no cover while their own files held a 1500px one. It also only ever looked at tags, never at the Folder.jpg the review had just offered as "yours". COVERS ARE RESAMPLED BY RUST, NOT BY THE COMPOSITOR. The webview was handed 2000px masters to paint 132px tiles — a 15x single-step reduction that reads as aliasing on photographic covers, and carries megabytes of base64 to draw a thumbnail. Artist portraits already had this exact fix; album covers never got it. getArt is now bounded by default and getArtMaster is the deliberate act, because ~60 places paint a cover and only two want real pixels: with the polarity the other way round, every surface anyone forgets is silently wrong. The fullscreen viewer also framed non-square art inside a square element, so its rounded corners and shadow wrapped 96px of nothing; it takes the artwork's own ratio now, bounded on both axes. Smaller, from the same passes: the preview play button is a real transport rather than a restart, and leaving a record silences its own preview without touching music started elsewhere; bucket chips navigate with per-lens memory instead of overwriting the saved resume position; Next counts from the record's real place in a queue that re-sorts under it, which was skipping one record for every one reviewed under a filter; a cover URL with nothing behind it is shown as the empty answer it is rather than a broken image in a pickable frame; write_tags returns early when there is nothing to change, instead of rewriting the whole file to write values it already had; and the import window has a Start over, which says what it costs before it costs it.
1356 lines
51 KiB
Rust
1356 lines
51 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};
|
|
|
|
// aiff/aif joined for the import tool: the reference library carries 13 AIFF
|
|
// tracks that a scan would otherwise silently drop, and "silently" is the
|
|
// problem — the album imports incomplete with no warning.
|
|
pub(crate) const AUDIO_EXTS: [&str; 10] =
|
|
["flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "aiff", "aif"];
|
|
|
|
pub struct LibraryState {
|
|
pub library: Arc<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> {
|
|
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 —
|
|
// 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.
|
|
if path.file_name().is_some_and(|n| n == "(Cache)") {
|
|
continue;
|
|
}
|
|
collect_audio_files(&path, out, depth + 1);
|
|
} else if path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
|
|
.unwrap_or(false)
|
|
{
|
|
out.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run one file through the pipeline. `remote_path` (when this came from a
|
|
/// fresh download) improves folder-based metadata parsing.
|
|
pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path: Option<&str>) {
|
|
// Download responses include album artwork and metadata sidecars. Never
|
|
// feed those files to tag parsing or the library database.
|
|
if !is_audio_path(local_path) {
|
|
log::debug!("non-audio download {}", local_path.display());
|
|
// If the rest of this staging folder was already organized, send this
|
|
// extra (cover art, log, cue…) after its album and tidy the folder.
|
|
if remote_path.is_some() {
|
|
if let Some(parent) = local_path.parent() {
|
|
let album_dir = app
|
|
.state::<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),
|
|
year: summary.year.is_none().then(|| final_year).flatten(),
|
|
label: None,
|
|
genre: None,
|
|
};
|
|
{
|
|
let p = local_path.to_path_buf();
|
|
let res = tauri::async_runtime::spawn_blocking(move || {
|
|
let embed = art_bytes.map(|b| prepare_embed_art(b, embed_max_edge));
|
|
tagger::write_tags(&p, &write, embed.as_deref(), replace_embedded)
|
|
})
|
|
.await;
|
|
if let Ok(Err(e)) = res {
|
|
log::warn!("tagging {} failed: {e}", local_path.display());
|
|
}
|
|
}
|
|
|
|
// 6. Fresh downloads become part of the managed library/share folder.
|
|
// Keep an existing library exactly where it is; reorganizing old files is
|
|
// a deliberate future action, not a surprise side effect of startup scan.
|
|
let library_path = if remote_path.is_some() {
|
|
organize_download(
|
|
app,
|
|
local_path,
|
|
&final_artist,
|
|
&final_album,
|
|
release_date.as_deref(),
|
|
final_year,
|
|
)
|
|
} else {
|
|
local_path.to_path_buf()
|
|
};
|
|
|
|
// (The folder cover needs no separate write any more — step 4 filed the
|
|
// master INTO the album folder, which is the same folder organize just
|
|
// moved the audio into.)
|
|
|
|
// 7. Library rows.
|
|
{
|
|
let lib = state.library.clone();
|
|
let p = library_path.to_string_lossy().into_owned();
|
|
let art = art_path.as_ref().map(|a| a.to_string_lossy().into_owned());
|
|
let track_no = summary.track_no.or(parsed.track_no);
|
|
let (duration, bitrate) = (summary.duration_secs, summary.bitrate_kbps);
|
|
let (sample_rate, bit_depth) = (summary.sample_rate, summary.bit_depth);
|
|
let size = std::fs::metadata(&library_path).ok().map(|m| m.len());
|
|
let (artist, album, title2) = (final_artist.clone(), final_album.clone(), title.clone());
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0);
|
|
let res = tauri::async_runtime::spawn_blocking(move || -> library::Result<()> {
|
|
let album_id = lib.upsert_album(
|
|
&artist,
|
|
&album,
|
|
final_year,
|
|
art.as_deref(),
|
|
artist_art_path.as_deref(),
|
|
mbid.as_deref(),
|
|
now,
|
|
)?;
|
|
if let Some(a) = &art {
|
|
lib.set_album_art(album_id, a)?;
|
|
}
|
|
lib.upsert_track(
|
|
album_id,
|
|
track_no,
|
|
&title2,
|
|
&artist,
|
|
duration,
|
|
bitrate,
|
|
sample_rate,
|
|
bit_depth,
|
|
size,
|
|
&p,
|
|
now,
|
|
)?;
|
|
Ok(())
|
|
})
|
|
.await;
|
|
if let Ok(Err(e)) = res {
|
|
log::warn!("library insert for {} failed: {e}", local_path.display());
|
|
}
|
|
}
|
|
|
|
let _ = app.emit("soulseek:library-changed", ());
|
|
}
|
|
|
|
async fn fetch_artist_art_cached(
|
|
state: &tauri::State<'_, LibraryState>,
|
|
artist: &str,
|
|
) -> Option<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 {
|
|
path.extension()
|
|
.and_then(|e| e.to_str())
|
|
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Move a newly completed download into one album directory under the managed
|
|
/// root. The filename remains intact, preserving track numbering supplied by
|
|
/// the source. If a matching file exists, retain both files with a suffix.
|
|
/// The album directory the configured layout renders for this record —
|
|
/// deterministic from the tags alone, so the cover can be filed there
|
|
/// before the first track even arrives. Nothing is created here.
|
|
fn album_layout_dir(
|
|
app: &tauri::AppHandle,
|
|
artist: &str,
|
|
album: &str,
|
|
release_date: Option<&str>,
|
|
year: Option<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)
|
|
}
|