1426 lines
55 KiB
Rust
1426 lines
55 KiB
Rust
//! Post-download pipeline: tags → MusicBrainz canonicalization → album art
|
|
//! → embed → library DB. Also scans the downloads folder on startup so
|
|
//! pre-existing files join the library.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
use tauri::{Emitter, Manager};
|
|
|
|
use library::Library;
|
|
use metadata::{AlbumMeta, MetadataClient};
|
|
use tagger::{TagSummary, TagWrite};
|
|
|
|
// The one table, in `audio-formats`. This used to be a local list that
|
|
// disagreed with the share index and the row pruner — the scanner indexed
|
|
// `.aiff` and the pruner deleted it on the next startup.
|
|
|
|
pub struct LibraryState {
|
|
pub library: Arc<Library>,
|
|
pub(crate) metadata: Arc<MetadataClient>,
|
|
/// Unix seconds of the last user-facing catalog command. The library
|
|
/// identity warmer only works while this has been quiet, so background
|
|
/// warmth never queues ahead of a page the user is actually looking at.
|
|
pub(crate) catalog_activity: std::sync::atomic::AtomicU64,
|
|
pub(crate) art_dir: PathBuf,
|
|
/// (artist, album) lowercase → resolved metadata, deduping MB lookups.
|
|
album_cache: tokio::sync::Mutex<HashMap<(String, String), Option<AlbumMeta>>>,
|
|
/// Serializes art fetch per album key.
|
|
art_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
|
/// Download-staging dir (`<root>/<user>/<folder>`) → organized album dir.
|
|
/// Lets late-arriving non-audio extras follow their album, after which the
|
|
/// emptied staging dirs are removed.
|
|
staging_targets: Mutex<HashMap<PathBuf, PathBuf>>,
|
|
}
|
|
|
|
impl LibraryState {
|
|
/// Stamp a user-facing catalog command; the identity warmer defers to this.
|
|
pub(crate) fn touch_catalog_activity(&self) {
|
|
self.catalog_activity
|
|
.store(unix_secs(), std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
|
|
fn catalog_idle_secs(&self) -> u64 {
|
|
let last = self
|
|
.catalog_activity
|
|
.load(std::sync::atomic::Ordering::Relaxed);
|
|
if last == 0 {
|
|
u64::MAX
|
|
} else {
|
|
unix_secs().saturating_sub(last)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn unix_secs() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn warm_name_key(name: &str) -> String {
|
|
name.to_lowercase()
|
|
.chars()
|
|
.filter(|c| c.is_alphanumeric())
|
|
.collect()
|
|
}
|
|
|
|
/// Background identity warmer: once per app start, walk the library's
|
|
/// artists (most recently added first) and touch each one's MusicBrainz
|
|
/// identity chain, so the 24h disk cache keeps the artists the user actually
|
|
/// visits permanently warm — a cold page's slowest stage (3.7-8.5 s of
|
|
/// 1 req/s-gated MusicBrainz) becomes a disk read for library artists.
|
|
///
|
|
/// Politeness rules, in order of importance:
|
|
/// - Never compete with the user: work only after 25 s of catalog quiet.
|
|
/// - Never rush MusicBrainz: an 8 s gap after every artist that needed real
|
|
/// network work (already-warm artists advance after 1 s).
|
|
/// - Bounded: at most 150 artists per app start.
|
|
fn spawn_identity_warmer(app: tauri::AppHandle) {
|
|
tauri::async_runtime::spawn(async move {
|
|
// Let startup scans and the user's first page own the providers.
|
|
tokio::time::sleep(std::time::Duration::from_secs(90)).await;
|
|
let state = app.state::<LibraryState>();
|
|
let lib = state.library.clone();
|
|
let Ok(Ok(albums)) = tauri::async_runtime::spawn_blocking(move || lib.list_albums()).await
|
|
else {
|
|
return;
|
|
};
|
|
let mut seen = std::collections::HashSet::new();
|
|
let artists: Vec<String> = albums
|
|
.into_iter()
|
|
.map(|album| album.artist)
|
|
.filter(|artist| !artist.trim().is_empty())
|
|
.filter(|artist| seen.insert(warm_name_key(artist)))
|
|
.take(150)
|
|
.collect();
|
|
if artists.is_empty() {
|
|
return;
|
|
}
|
|
log::info!("identity warmer: {} library artists queued", artists.len());
|
|
let mut warmed = 0usize;
|
|
for artist in artists {
|
|
// Yield to the user for as long as they are browsing.
|
|
loop {
|
|
let idle = state.catalog_idle_secs();
|
|
if idle >= 25 {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_secs(26 - idle)).await;
|
|
}
|
|
let started = std::time::Instant::now();
|
|
let search = state.metadata.catalog_search(&artist).await;
|
|
let deezer_id = search
|
|
.artists
|
|
.iter()
|
|
.find(|candidate| warm_name_key(&candidate.name) == warm_name_key(&artist))
|
|
.map(|candidate| candidate.id);
|
|
if let Some(deezer_id) = deezer_id {
|
|
let _ = state
|
|
.metadata
|
|
.catalog_artist_identity(deezer_id, &artist)
|
|
.await;
|
|
let _ = state.metadata.catalog_artist_socials(deezer_id).await;
|
|
warmed += 1;
|
|
}
|
|
// Real network work earns MusicBrainz a long gap; a disk-warm
|
|
// artist (sub-second) only needs a token pause.
|
|
let gap = if started.elapsed() < std::time::Duration::from_millis(600) {
|
|
1
|
|
} else {
|
|
8
|
|
};
|
|
tokio::time::sleep(std::time::Duration::from_secs(gap)).await;
|
|
}
|
|
log::info!("identity warmer: warmed {warmed} artists");
|
|
});
|
|
}
|
|
|
|
pub fn init(app: &tauri::AppHandle) -> Result<(), String> {
|
|
// AppData keeps only what is about the APP on THIS machine — the cache of
|
|
// remote provider responses. Everything describing the LIBRARY lives in
|
|
// the library folder so it travels with the music.
|
|
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
std::fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?;
|
|
|
|
let cfg = crate::config::load(app);
|
|
let library_root = crate::config::download_dir(app, &cfg);
|
|
let moved = crate::config::migrate_library_state_into_library(app, &cfg);
|
|
if !moved.is_empty() {
|
|
log::info!("library state moved into the library folder: {}", moved.join(", "));
|
|
}
|
|
|
|
// With the drive unplugged there is no library to open. Fall back to a
|
|
// machine-local database rather than refusing to start — the real one
|
|
// returns with the drive, and nothing here writes into the wrong library
|
|
// because every path is compared against the configured root.
|
|
let (data_dir, portable) = match crate::config::ensure_library_data_dir(app, &cfg) {
|
|
Ok(dir) => (dir, true),
|
|
Err(e) => {
|
|
log::warn!(
|
|
"library folder {} is unavailable ({e}) — running with a local database until it returns",
|
|
library_root.display()
|
|
);
|
|
(app_dir.clone(), false)
|
|
}
|
|
};
|
|
let art_dir = data_dir.join("art");
|
|
std::fs::create_dir_all(&art_dir).map_err(|e| e.to_string())?;
|
|
|
|
let library = Arc::new(Library::open(&data_dir.join("library.db")).map_err(|e| e.to_string())?);
|
|
if portable {
|
|
// The art cache moved with the database, so rows written before the
|
|
// move still point into AppData. Re-point them before anything paints.
|
|
match library.rewrite_path_prefix(&app_dir.join("art"), &art_dir) {
|
|
Ok(n) if n > 0 => log::info!("library: {n} artwork paths follow the art cache into the library folder"),
|
|
Err(e) => log::warn!("library: could not re-point artwork paths: {e}"),
|
|
_ => {}
|
|
}
|
|
// And the library itself may have arrived at a new drive letter or on
|
|
// a different machine entirely.
|
|
match library.rebase_to_root(&library_root) {
|
|
Ok(n) if n > 0 => log::info!("library moved: re-based {n} stored paths onto {}", library_root.display()),
|
|
Err(e) => log::warn!("library: could not re-base stored paths: {e}"),
|
|
_ => {}
|
|
}
|
|
}
|
|
let repair_library = library.clone();
|
|
let repair_art_dir = art_dir.clone();
|
|
|
|
app.manage(LibraryState {
|
|
library,
|
|
// The disk layer only caches slow-moving providers (MusicBrainz,
|
|
// Wikipedia) so a restarted app keeps its supplement/type-hint and
|
|
// description warmth instead of re-serializing MusicBrainz at 1 req/s.
|
|
metadata: Arc::new({
|
|
let client = MetadataClient::new()
|
|
// Provider responses are about the WORLD, not this library, and
|
|
// they are cheap to refetch — they stay on the machine.
|
|
.with_disk_cache(app_dir.join("net-cache-v1"))
|
|
// Its own directory, NOT inside the cache above: that one is
|
|
// pruned by mtime, and these are verdicts rather than cached
|
|
// responses. Verdicts about the library's artists are library
|
|
// state, so they travel with it.
|
|
.with_identity_store(data_dir.join("artist-identity-v1"));
|
|
// Hero candidate/scoring diagnostics on demand, so an in-APP hero
|
|
// resolution can be compared against `hero_source_probe` — the two
|
|
// do not always see the same candidate set, and only the app's own
|
|
// trace can explain a selection the user actually saw.
|
|
if std::env::var("SOULSTREAM_HERO_TRACE").is_ok_and(|value| value == "1") {
|
|
client.with_hero_diagnostics()
|
|
} else {
|
|
client
|
|
}
|
|
}),
|
|
catalog_activity: std::sync::atomic::AtomicU64::new(0),
|
|
art_dir,
|
|
album_cache: tokio::sync::Mutex::new(HashMap::new()),
|
|
art_locks: Mutex::new(HashMap::new()),
|
|
staging_targets: Mutex::new(HashMap::new()),
|
|
});
|
|
|
|
spawn_identity_warmer(app.clone());
|
|
|
|
// Startup: drop rows for deleted files, then index anything new.
|
|
let handle = app.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
if let Ok(Ok(repaired)) = tauri::async_runtime::spawn_blocking(move || {
|
|
repair_library.repair_art_paths(&repair_art_dir)
|
|
})
|
|
.await
|
|
{
|
|
if repaired > 0 {
|
|
log::info!("library repair: restored {repaired} artwork cache references");
|
|
}
|
|
}
|
|
let state = handle.state::<LibraryState>();
|
|
// Before anything asks the disk: the library folder decides what is IN
|
|
// the library. Rows from a folder that used to hold it are not "still
|
|
// there because their files exist" — they belong to another library.
|
|
let lib = state.library.clone();
|
|
let root = downloads_dir(&handle);
|
|
if let Ok(Ok(dropped)) =
|
|
tauri::async_runtime::spawn_blocking(move || lib.prune_outside_root(&root)).await
|
|
{
|
|
if dropped > 0 {
|
|
log::info!("library: dropped {dropped} tracks outside the library folder");
|
|
}
|
|
}
|
|
let lib = state.library.clone();
|
|
let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing()).await;
|
|
// After the prune, every surviving track has a file — so "the folder
|
|
// this album lives in" is answerable, and any cover pointing somewhere
|
|
// else is another album's. Files move (a re-import into a new layout, a
|
|
// drive back at a different letter) and nothing else re-asks.
|
|
let lib = state.library.clone();
|
|
let art = state.art_dir.clone();
|
|
if let Ok(Ok(moved)) =
|
|
tauri::async_runtime::spawn_blocking(move || lib.reanchor_album_art(&art)).await
|
|
{
|
|
if moved > 0 {
|
|
log::info!("library repair: re-anchored artwork for {moved} albums");
|
|
}
|
|
}
|
|
let lib = state.library.clone();
|
|
if let Ok(Ok(removed)) =
|
|
tauri::async_runtime::spawn_blocking(move || lib.prune_non_audio()).await
|
|
{
|
|
if removed > 0 {
|
|
log::info!("library cleanup: removed {removed} non-audio track rows");
|
|
}
|
|
}
|
|
// Backfill sample-rate / bit-depth / size for tracks indexed before
|
|
// those columns existed, so the Now Playing panel shows full quality
|
|
// info for older library files. Size is the sentinel, so this runs
|
|
// once per file (after prune, every remaining track has a file).
|
|
let lib = state.library.clone();
|
|
let _ = tauri::async_runtime::spawn_blocking(move || {
|
|
let todo = lib.tracks_missing_props().unwrap_or_default();
|
|
if todo.is_empty() {
|
|
return;
|
|
}
|
|
let n = todo.len();
|
|
for (id, path) in todo {
|
|
let p = std::path::Path::new(&path);
|
|
let (sr, bd) = match tagger::read_summary(p) {
|
|
Ok(s) => (s.sample_rate, s.bit_depth),
|
|
Err(_) => (None, None),
|
|
};
|
|
let size = std::fs::metadata(p).ok().map(|m| m.len());
|
|
let _ = lib.set_track_props(id, sr, bd, size);
|
|
}
|
|
log::info!("library backfill: audio props for {n} tracks");
|
|
})
|
|
.await;
|
|
// Old staging leftovers: folders emptied by organizing are removed
|
|
// on the fly now, but sweep empties left behind by earlier runs —
|
|
// dead transfers first, so the folders they emptied qualify.
|
|
sweep_stale_partials(&downloads_dir(&handle));
|
|
sweep_stale_auditions(&downloads_dir(&handle));
|
|
sweep_empty_dirs(&downloads_dir(&handle), 0);
|
|
scan_downloads(&handle).await;
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
/// A `.partial` in the "(Cache)" staging pen whose bytes last moved more
|
|
/// than this long ago is a transfer that died — an active download's mtime
|
|
/// is always fresh, and a retry recreates its file from wherever the peer
|
|
/// resumes.
|
|
const STALE_PARTIAL_SECS: u64 = 3 * 24 * 60 * 60;
|
|
|
|
fn sweep_stale_partials(root: &Path) {
|
|
fn walk(dir: &Path, removed: &mut u32) {
|
|
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
|
for e in entries.flatten() {
|
|
let p = e.path();
|
|
if p.is_dir() {
|
|
walk(&p, removed);
|
|
} else if p.extension().is_some_and(|x| x == "partial") {
|
|
let stale = e
|
|
.metadata()
|
|
.and_then(|m| m.modified())
|
|
.ok()
|
|
.and_then(|t| t.elapsed().ok())
|
|
.is_some_and(|age| age.as_secs() > STALE_PARTIAL_SECS);
|
|
if stale && std::fs::remove_file(&p).is_ok() {
|
|
*removed += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut removed = 0u32;
|
|
walk(&root.join("(Cache)"), &mut removed);
|
|
if removed > 0 {
|
|
log::info!("staging sweep: removed {removed} stale .partial file(s)");
|
|
}
|
|
}
|
|
|
|
/// An audition album nobody claimed within this window is a version the
|
|
/// review looked at and walked away from. Longer than the .partial window on
|
|
/// purpose: these are COMPLETE albums, and the whole point of caching them is
|
|
/// that a mind changed a few days later costs nothing.
|
|
const STALE_AUDITION_SECS: u64 = 7 * 24 * 60 * 60;
|
|
|
|
/// Reclaim `(Cache)\_audition\<album>\` folders that have sat untouched past
|
|
/// the retention window. Claiming one moves it into the library, so anything
|
|
/// still here was passed over; the newest file in the folder is what ages,
|
|
/// so a transfer still filling in is never swept out from under itself.
|
|
pub(crate) fn sweep_stale_auditions(root: &Path) {
|
|
let pen = root.join("(Cache)").join("_audition");
|
|
let Ok(entries) = std::fs::read_dir(&pen) else { return };
|
|
let (mut albums, mut bytes) = (0u32, 0u64);
|
|
for e in entries.flatten() {
|
|
let dir = e.path();
|
|
if !dir.is_dir() {
|
|
continue;
|
|
}
|
|
// The FOLDER's own mtime is the floor. import_audition creates the
|
|
// directory before the first byte lands, so an empty one is usually a
|
|
// transfer that just started — ageing it from the epoch would have a
|
|
// Rescan in that window delete the pen out from under it. An empty
|
|
// folder that has genuinely sat a week still ages out on its own date.
|
|
let mut newest = e
|
|
.metadata()
|
|
.and_then(|m| m.modified())
|
|
.unwrap_or_else(|_| std::time::SystemTime::now());
|
|
let mut size = 0u64;
|
|
// Every file in the folder ages it, strays included — a peer's .m3u
|
|
// lands at the same moment as the audio, so nothing here can hold a
|
|
// spent folder open.
|
|
for f in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
|
if let Ok(m) = f.metadata() {
|
|
size += m.len();
|
|
if let Ok(t) = m.modified() {
|
|
newest = newest.max(t);
|
|
}
|
|
}
|
|
}
|
|
let stale = newest
|
|
.elapsed()
|
|
.ok()
|
|
.is_some_and(|age| age.as_secs() > STALE_AUDITION_SECS);
|
|
if stale && std::fs::remove_dir_all(&dir).is_ok() {
|
|
albums += 1;
|
|
bytes += size;
|
|
}
|
|
}
|
|
if albums > 0 {
|
|
log::info!(
|
|
"audition sweep: reclaimed {albums} unclaimed album(s), {:.1} MB",
|
|
bytes as f64 / 1_048_576.0
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Remove empty directory trees below `dir` (post-order; `dir` itself stays).
|
|
fn sweep_empty_dirs(dir: &Path, depth: u8) {
|
|
if depth > 6 {
|
|
return;
|
|
}
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
sweep_empty_dirs(&path, depth + 1);
|
|
// Fails (harmlessly) unless the directory is empty.
|
|
let _ = std::fs::remove_dir(&path);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn downloads_dir(app: &tauri::AppHandle) -> PathBuf {
|
|
let cfg = crate::config::load(app);
|
|
crate::config::download_dir(app, &cfg)
|
|
}
|
|
|
|
/// The Library page's Rescan: what startup does — drop rows whose files
|
|
/// are gone (a manual delete in Explorer is a legitimate edit), then index
|
|
/// anything new under the root. Without the prune, deletions were ghosts
|
|
/// until the next app launch.
|
|
#[tauri::command]
|
|
pub async fn rescan_library(app: tauri::AppHandle) -> Result<(), String> {
|
|
let state = app.state::<LibraryState>();
|
|
let lib = state.library.clone();
|
|
let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing())
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let root = downloads_dir(&app);
|
|
sweep_stale_partials(&root);
|
|
sweep_stale_auditions(&root);
|
|
sweep_empty_dirs(&root, 0);
|
|
scan_downloads(&app).await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Index every audio file under the downloads dir that isn't known yet.
|
|
async fn scan_downloads(app: &tauri::AppHandle) {
|
|
let root = downloads_dir(app);
|
|
let mut files = Vec::new();
|
|
collect_audio_files(&root, &mut files, 0);
|
|
if files.is_empty() {
|
|
return;
|
|
}
|
|
log::info!(
|
|
"library scan: {} audio files under {}",
|
|
files.len(),
|
|
root.display()
|
|
);
|
|
|
|
let state = app.state::<LibraryState>();
|
|
for path in files {
|
|
let known = {
|
|
let lib = state.library.clone();
|
|
let p = path.to_string_lossy().into_owned();
|
|
tauri::async_runtime::spawn_blocking(move || lib.has_track_path(&p).unwrap_or(false))
|
|
.await
|
|
.unwrap_or(false)
|
|
};
|
|
if !known {
|
|
process_file(app, &path, None).await;
|
|
}
|
|
}
|
|
repair_artist_portraits(app).await;
|
|
let _ = app.emit("soulseek:library-changed", ());
|
|
}
|
|
|
|
/// Artist portraits are written ONCE, by `process_file` at import. This scan
|
|
/// skips every file it already knows, so a portrait that later goes missing
|
|
/// from disk — a cleared art directory, a half-copied profile — leaves the
|
|
/// database pointing at a file that will never come back, and the shelf head
|
|
/// falls back to its icon forever.
|
|
///
|
|
/// Repair exactly that: an artist whose stored path is NULL or no longer on
|
|
/// disk. Untouched portraits cost nothing, and an affected artist costs one
|
|
/// paced Deezer read (shared budget, same as every other read here).
|
|
///
|
|
/// Deliberately NOT a re-resolve of portraits that DO exist. That would repair
|
|
/// the other half of this problem — entries stored before `fetch_artist_art`
|
|
/// learned to rank candidates and reject Deezer's empty-md5 placeholder — but
|
|
/// it is a request per artist on every scan, and it would overwrite a portrait
|
|
/// the user is already looking at. Dropping the `needs` condition below is all
|
|
/// it would take, if that is ever wanted.
|
|
async fn repair_artist_portraits(app: &tauri::AppHandle) {
|
|
let state = app.state::<LibraryState>();
|
|
let stored = {
|
|
let lib = state.library.clone();
|
|
match tauri::async_runtime::spawn_blocking(move || lib.artist_portraits()).await {
|
|
Ok(Ok(rows)) => rows,
|
|
_ => return,
|
|
}
|
|
};
|
|
for (artist, path) in stored {
|
|
let needs = match path.as_deref() {
|
|
None => true,
|
|
Some(p) => !Path::new(p).exists(),
|
|
};
|
|
if !needs {
|
|
continue;
|
|
}
|
|
if let Some(fresh) = fetch_artist_art_cached(&state, &artist).await {
|
|
let lib = state.library.clone();
|
|
let name = artist.clone();
|
|
let updated =
|
|
tauri::async_runtime::spawn_blocking(move || lib.set_artist_art(&name, &fresh))
|
|
.await;
|
|
if let Ok(Ok(n)) = updated {
|
|
log::info!("artist portrait repaired for {artist} ({n} albums)");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn collect_audio_files(dir: &Path, out: &mut Vec<PathBuf>, depth: u8) {
|
|
if depth > 6 {
|
|
return;
|
|
}
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
// "(Cache)" is the download staging pen — a completed file
|
|
// sitting there is seconds from being organized out, and
|
|
// indexing it would pin a library row to a staging path.
|
|
// ".soulstream" is the library's own state, not music.
|
|
if path
|
|
.file_name()
|
|
.is_some_and(|n| n == "(Cache)" || n == crate::config::LIBRARY_DATA_DIRNAME)
|
|
{
|
|
continue;
|
|
}
|
|
collect_audio_files(&path, out, depth + 1);
|
|
} else if audio_formats::is_audio_path(&path) {
|
|
out.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run one file through the pipeline. `remote_path` (when this came from a
|
|
/// fresh download) improves folder-based metadata parsing.
|
|
pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path: Option<&str>) {
|
|
// Download responses include album artwork and metadata sidecars. Never
|
|
// feed those files to tag parsing or the library database.
|
|
if !is_audio_path(local_path) {
|
|
log::debug!("non-audio download {}", local_path.display());
|
|
// If the rest of this staging folder was already organized, send this
|
|
// extra (cover art, log, cue…) after its album and tidy the folder.
|
|
if remote_path.is_some() {
|
|
if let Some(parent) = local_path.parent() {
|
|
let album_dir = app
|
|
.state::<LibraryState>()
|
|
.staging_targets
|
|
.lock()
|
|
.unwrap()
|
|
.get(parent)
|
|
.cloned();
|
|
if let Some(album_dir) = album_dir {
|
|
cleanup_staging(app, parent, &album_dir);
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
let state = app.state::<LibraryState>();
|
|
|
|
// 1. Existing tags (retry once: freshly-written files can be briefly
|
|
// locked by AV/indexing on Windows).
|
|
let mut summary = TagSummary::default();
|
|
for attempt in 0..2 {
|
|
let p = local_path.to_path_buf();
|
|
match tauri::async_runtime::spawn_blocking(move || tagger::read_summary(&p)).await {
|
|
Ok(Ok(s)) => {
|
|
summary = s;
|
|
break;
|
|
}
|
|
Ok(Err(e)) if attempt == 0 => {
|
|
log::warn!(
|
|
"tag read failed for {} (retrying): {e}",
|
|
local_path.display()
|
|
);
|
|
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
|
|
}
|
|
Ok(Err(e)) => log::warn!("tag read failed for {}: {e}", local_path.display()),
|
|
Err(e) => log::warn!("tag read task failed for {}: {e}", local_path.display()),
|
|
}
|
|
}
|
|
let summary: TagSummary = summary;
|
|
|
|
// 2. Fill gaps from the path (remote path preferred: original layout).
|
|
let parsed = tagger::parse_from_path(remote_path.unwrap_or(&local_path.to_string_lossy()));
|
|
let artist_guess = summary.artist.clone().or(parsed.artist.clone());
|
|
let album_guess = summary.album.clone().or(parsed.album.clone());
|
|
let title = summary
|
|
.title
|
|
.clone()
|
|
.or(parsed.title.clone())
|
|
.unwrap_or_else(|| {
|
|
local_path
|
|
.file_stem()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.into_owned()
|
|
});
|
|
|
|
// 3. Canonicalize via MusicBrainz (cached per album).
|
|
let meta = match (&artist_guess, &album_guess) {
|
|
(Some(artist), Some(album)) => {
|
|
let key = (artist.to_lowercase(), album.to_lowercase());
|
|
let mut cache = state.album_cache.lock().await;
|
|
if let Some(cached) = cache.get(&key) {
|
|
cached.clone()
|
|
} else {
|
|
let looked = state.metadata.lookup_album(artist, album).await;
|
|
cache.insert(key, looked.clone());
|
|
looked
|
|
}
|
|
}
|
|
_ => None,
|
|
};
|
|
|
|
let final_artist = meta
|
|
.as_ref()
|
|
.map(|m| m.artist.clone())
|
|
.or(artist_guess)
|
|
.unwrap_or_else(|| "Unknown Artist".into());
|
|
let final_album = meta
|
|
.as_ref()
|
|
.map(|m| m.album.clone())
|
|
.or(album_guess)
|
|
.unwrap_or_else(|| "Unknown Album".into());
|
|
let final_year = summary
|
|
.year
|
|
.or(parsed.year)
|
|
.or(meta.as_ref().and_then(|m| m.year));
|
|
let release_date = meta
|
|
.as_ref()
|
|
.and_then(|m| m.release_date.clone())
|
|
.or_else(|| final_year.map(|year| year.to_string()));
|
|
let mbid = meta.as_ref().and_then(|m| m.release_mbid.clone());
|
|
|
|
// A repeated save downloads (or copies from the stream cache) into a
|
|
// staging folder first. Do this check before organization: otherwise the
|
|
// collision-safe filename suffix (" (2)") makes it a new path and creates
|
|
// a duplicate track row for the same song.
|
|
if remote_path.is_some() {
|
|
let lib = state.library.clone();
|
|
let artist = final_artist.clone();
|
|
let album = final_album.clone();
|
|
let track = title.clone();
|
|
match tauri::async_runtime::spawn_blocking(move || lib.has_track(&artist, &album, &track))
|
|
.await
|
|
{
|
|
Ok(Ok(true)) => {
|
|
log::info!(
|
|
"skipping duplicate library track: {} — {} — {}",
|
|
final_artist,
|
|
final_album,
|
|
title
|
|
);
|
|
if let Err(e) = std::fs::remove_file(local_path) {
|
|
log::warn!(
|
|
"could not discard duplicate staging file {}: {e}",
|
|
local_path.display()
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
Ok(Err(e)) => log::warn!("could not check for duplicate library track: {e}"),
|
|
Err(e) => log::warn!("duplicate library check task failed: {e}"),
|
|
Ok(Ok(false)) => {}
|
|
}
|
|
}
|
|
|
|
// 4. Album art (once per album; serialized against concurrent tracks).
|
|
// The master is the album FOLDER's cover.jpg. For a download the layout
|
|
// makes that folder knowable before the file is organized into it, so
|
|
// the cover is filed first and the audio moves in beside it; a scanned
|
|
// file's album folder is simply where it already sits.
|
|
let cfg = crate::config::load(app);
|
|
let rg_mbid = meta.as_ref().and_then(|m| m.release_group_mbid.clone());
|
|
let album_dir = if remote_path.is_some() {
|
|
album_layout_dir(
|
|
app,
|
|
&final_artist,
|
|
&final_album,
|
|
release_date.as_deref(),
|
|
final_year,
|
|
)
|
|
} else {
|
|
local_path.parent().map(Path::to_path_buf).unwrap_or_default()
|
|
};
|
|
let art_path = fetch_art_cached(
|
|
&state,
|
|
&album_dir,
|
|
&final_artist,
|
|
&final_album,
|
|
mbid.as_deref(),
|
|
rg_mbid.as_deref(),
|
|
remote_path.is_some() && crate::config::write_folder_cover(&cfg),
|
|
)
|
|
.await;
|
|
|
|
let artist_art_path = fetch_artist_art_cached(&state, &final_artist).await;
|
|
|
|
// 5. Write tags. Fresh downloads carry the canonical cover, replacing
|
|
// whatever picture the uploader embedded (config-gated) so the file, the
|
|
// library, and the catalog all show the same artwork. Startup-scanned
|
|
// files are never stripped: they only gain art when they have none.
|
|
let replace_embedded = remote_path.is_some() && crate::config::replace_embedded_art(&cfg);
|
|
let embed_max_edge = crate::config::embed_art_max_edge(&cfg);
|
|
let art_bytes = match (&art_path, replace_embedded || !summary.has_art) {
|
|
(Some(p), true) => std::fs::read(p).ok(),
|
|
_ => None,
|
|
};
|
|
let write = TagWrite {
|
|
artist: summary.artist.is_none().then_some(final_artist.clone()),
|
|
album_artist: summary
|
|
.album_artist
|
|
.is_none()
|
|
.then_some(final_artist.clone()),
|
|
album: summary.album.is_none().then_some(final_album.clone()),
|
|
title: summary.title.is_none().then_some(title.clone()),
|
|
track_no: summary.track_no.or(parsed.track_no),
|
|
// Nothing to add: a downloaded file either carries its disc or has no
|
|
// disc to carry, and there is nowhere here to learn one from.
|
|
disc_no: None,
|
|
year: summary.year.is_none().then(|| final_year).flatten(),
|
|
label: None,
|
|
genre: None,
|
|
};
|
|
{
|
|
let p = local_path.to_path_buf();
|
|
let res = tauri::async_runtime::spawn_blocking(move || {
|
|
let embed = art_bytes.map(|b| prepare_embed_art(b, embed_max_edge));
|
|
tagger::write_tags(&p, &write, embed.as_deref(), replace_embedded)
|
|
})
|
|
.await;
|
|
if let Ok(Err(e)) = res {
|
|
log::warn!("tagging {} failed: {e}", local_path.display());
|
|
}
|
|
}
|
|
|
|
// 6. Fresh downloads become part of the managed library/share folder.
|
|
// Keep an existing library exactly where it is; reorganizing old files is
|
|
// a deliberate future action, not a surprise side effect of startup scan.
|
|
let library_path = if remote_path.is_some() {
|
|
organize_download(
|
|
app,
|
|
local_path,
|
|
&final_artist,
|
|
&final_album,
|
|
release_date.as_deref(),
|
|
final_year,
|
|
)
|
|
} else {
|
|
local_path.to_path_buf()
|
|
};
|
|
|
|
// (The folder cover needs no separate write any more — step 4 filed the
|
|
// master INTO the album folder, which is the same folder organize just
|
|
// moved the audio into.)
|
|
|
|
// 7. Library rows.
|
|
{
|
|
let lib = state.library.clone();
|
|
let p = library_path.to_string_lossy().into_owned();
|
|
let art = art_path.as_ref().map(|a| a.to_string_lossy().into_owned());
|
|
let track_no = summary.track_no.or(parsed.track_no);
|
|
// Only what the file itself says. There is no filename convention for
|
|
// a disc worth guessing from, and a wrong disc reorders an album.
|
|
let disc_no = summary.disc_no;
|
|
let (duration, bitrate) = (summary.duration_secs, summary.bitrate_kbps);
|
|
let (sample_rate, bit_depth) = (summary.sample_rate, summary.bit_depth);
|
|
let size = std::fs::metadata(&library_path).ok().map(|m| m.len());
|
|
let (artist, album, title2) = (final_artist.clone(), final_album.clone(), title.clone());
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0);
|
|
let res = tauri::async_runtime::spawn_blocking(move || -> library::Result<()> {
|
|
let album_id = lib.upsert_album(
|
|
&artist,
|
|
&album,
|
|
final_year,
|
|
art.as_deref(),
|
|
artist_art_path.as_deref(),
|
|
mbid.as_deref(),
|
|
now,
|
|
)?;
|
|
if let Some(a) = &art {
|
|
lib.set_album_art(album_id, a)?;
|
|
}
|
|
lib.upsert_track(
|
|
album_id,
|
|
disc_no,
|
|
track_no,
|
|
&title2,
|
|
&artist,
|
|
duration,
|
|
bitrate,
|
|
sample_rate,
|
|
bit_depth,
|
|
size,
|
|
&p,
|
|
now,
|
|
)?;
|
|
Ok(())
|
|
})
|
|
.await;
|
|
if let Ok(Err(e)) = res {
|
|
log::warn!("library insert for {} failed: {e}", local_path.display());
|
|
}
|
|
}
|
|
|
|
let _ = app.emit("soulseek:library-changed", ());
|
|
}
|
|
|
|
async fn fetch_artist_art_cached(
|
|
state: &tauri::State<'_, LibraryState>,
|
|
artist: &str,
|
|
) -> Option<String> {
|
|
let key: String = artist
|
|
.to_lowercase()
|
|
.chars()
|
|
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
|
.collect();
|
|
let target = state.art_dir.join(format!("artist-{key}.jpg"));
|
|
let original = state.art_dir.join(format!("artist-{key}-original"));
|
|
// Older portraits were stored without a source/thumbnail split. Re-fetch
|
|
// those once so the new high-quality thumbnail path takes effect.
|
|
if target.exists() && original.exists() {
|
|
return Some(target.to_string_lossy().into_owned());
|
|
}
|
|
let bytes = state.metadata.fetch_artist_art(artist).await?;
|
|
std::fs::write(&original, &bytes).ok()?;
|
|
write_artist_thumbnail(&bytes, &target)?;
|
|
Some(target.to_string_lossy().into_owned())
|
|
}
|
|
|
|
/// One definition of "a thumbnail" for every surface that shows art in a tile:
|
|
/// Lanczos3 down to `max_edge`, re-encoded JPEG q92.
|
|
///
|
|
/// The webview is a poor resampler for big reductions. It scales a decoded
|
|
/// source into the tile in ONE bilinear step, so a 2000px cover in a 132px
|
|
/// card samples roughly every fifteenth pixel — which reads as aliasing, and
|
|
/// is worst on photographic covers with fine detail or overlaid text. Lanczos3
|
|
/// on the Rust side averages the pixels that are actually being dropped.
|
|
///
|
|
/// `Ok(None)` means the source is already within `max_edge` and should be
|
|
/// handed through untouched; `Err(())` means it could not be decoded at all.
|
|
/// Aspect ratio is preserved (`resize` fits inside the box), so a non-square
|
|
/// cover stays non-square and the tile's `object-fit: cover` still does the
|
|
/// cropping — the thumbnail never decides the composition.
|
|
pub fn thumbnail_jpeg(bytes: &[u8], max_edge: u32) -> Result<Option<Vec<u8>>, ()> {
|
|
let decoded = image::load_from_memory(bytes).map_err(|_| ())?;
|
|
if decoded.width().max(decoded.height()) <= max_edge {
|
|
return Ok(None);
|
|
}
|
|
let thumb = decoded.resize(max_edge, max_edge, image::imageops::FilterType::Lanczos3);
|
|
let mut out = std::io::Cursor::new(Vec::new());
|
|
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 92);
|
|
thumb.write_with_encoder(encoder).map_err(|_| ())?;
|
|
Ok(Some(out.into_inner()))
|
|
}
|
|
|
|
/// Preserve the original portrait, but hand the webview a clean 512px
|
|
/// thumbnail generated with Lanczos3. This is large enough for a 2x card and
|
|
/// avoids browser scaling a huge compressed source directly into the tile.
|
|
fn write_artist_thumbnail(bytes: &[u8], target: &Path) -> Option<()> {
|
|
let out = match thumbnail_jpeg(bytes, 512) {
|
|
Ok(Some(thumb)) => thumb,
|
|
Ok(None) => bytes.to_vec(),
|
|
Err(()) => return None,
|
|
};
|
|
std::fs::write(target, out).ok()
|
|
}
|
|
|
|
/// Refresh portraits for every distinct artist already represented in the
|
|
/// library. Existing cache files are replaced so Settings can recover from a
|
|
/// failed lookup or a poor match.
|
|
pub async fn refresh_all_artist_art(app: &tauri::AppHandle) -> Result<usize, String> {
|
|
let state = app.state::<LibraryState>();
|
|
let albums = {
|
|
let lib = state.library.clone();
|
|
tauri::async_runtime::spawn_blocking(move || lib.list_albums())
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())?
|
|
};
|
|
let mut artists = std::collections::BTreeSet::new();
|
|
for album in albums {
|
|
artists.insert(album.artist);
|
|
}
|
|
let mut refreshed = 0;
|
|
for artist in artists {
|
|
let key: String = artist
|
|
.to_lowercase()
|
|
.chars()
|
|
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
|
.collect();
|
|
let target = state.art_dir.join(format!("artist-{key}.jpg"));
|
|
let original = state.art_dir.join(format!("artist-{key}-original"));
|
|
let _ = std::fs::remove_file(&target);
|
|
let _ = std::fs::remove_file(&original);
|
|
if let Some(bytes) = state.metadata.fetch_artist_art(&artist).await {
|
|
if std::fs::write(&original, &bytes).is_ok()
|
|
&& write_artist_thumbnail(&bytes, &target).is_some()
|
|
{
|
|
let path = target.to_string_lossy().into_owned();
|
|
let lib = state.library.clone();
|
|
let artist2 = artist.clone();
|
|
tauri::async_runtime::spawn_blocking(move || lib.set_artist_art(&artist2, &path))
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())?;
|
|
refreshed += 1;
|
|
}
|
|
}
|
|
}
|
|
let _ = app.emit("soulseek:library-changed", ());
|
|
Ok(refreshed)
|
|
}
|
|
|
|
pub fn is_audio_path(path: &Path) -> bool {
|
|
audio_formats::is_audio_path(path)
|
|
}
|
|
|
|
/// Move a newly completed download into one album directory under the managed
|
|
/// root. The filename remains intact, preserving track numbering supplied by
|
|
/// the source. If a matching file exists, retain both files with a suffix.
|
|
/// The album directory the configured layout renders for this record —
|
|
/// deterministic from the tags alone, so the cover can be filed there
|
|
/// before the first track even arrives. Nothing is created here.
|
|
fn album_layout_dir(
|
|
app: &tauri::AppHandle,
|
|
artist: &str,
|
|
album: &str,
|
|
release_date: Option<&str>,
|
|
year: Option<u32>,
|
|
) -> PathBuf {
|
|
let cfg = crate::config::load(app);
|
|
let date = display_date(release_date).unwrap_or_else(|| {
|
|
year.map(|y| y.to_string())
|
|
.unwrap_or_else(|| "Unknown date".into())
|
|
});
|
|
let rendered = render_layout(
|
|
crate::config::library_layout(&cfg),
|
|
artist,
|
|
album,
|
|
&date,
|
|
year,
|
|
);
|
|
// Separators written in the layout create subfolders. Separators inside
|
|
// tag values were flattened by render_layout, so metadata can neither
|
|
// split folders nor escape the library root ("..", trailing dots and
|
|
// empty segments are dropped by safe_segment).
|
|
let mut target_dir = crate::config::download_dir(app, &cfg);
|
|
let mut depth = 0;
|
|
for seg in rendered.split(['/', '\\']) {
|
|
let seg = safe_segment(seg);
|
|
if seg.is_empty() {
|
|
continue;
|
|
}
|
|
target_dir = target_dir.join(seg);
|
|
depth += 1;
|
|
}
|
|
if depth == 0 {
|
|
target_dir = target_dir.join("Unknown Album");
|
|
}
|
|
target_dir
|
|
}
|
|
|
|
fn organize_download(
|
|
app: &tauri::AppHandle,
|
|
source: &Path,
|
|
artist: &str,
|
|
album: &str,
|
|
release_date: Option<&str>,
|
|
year: Option<u32>,
|
|
) -> PathBuf {
|
|
let target_dir = album_layout_dir(app, artist, album, release_date, year);
|
|
if std::fs::create_dir_all(&target_dir).is_err() {
|
|
return source.to_path_buf();
|
|
}
|
|
let name = source.file_name().unwrap_or_default().to_string_lossy();
|
|
let mut target = target_dir.join(safe_component(&name));
|
|
if source == target {
|
|
return source.to_path_buf();
|
|
}
|
|
if target.exists() {
|
|
let stem = target
|
|
.file_stem()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
let ext = target
|
|
.extension()
|
|
.map(|e| format!(".{}", e.to_string_lossy()))
|
|
.unwrap_or_default();
|
|
for n in 2..10_000 {
|
|
let candidate = target_dir.join(format!("{stem} ({n}){ext}"));
|
|
if !candidate.exists() {
|
|
target = candidate;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
match std::fs::rename(source, &target) {
|
|
Ok(()) => {
|
|
// Remember where this staging folder's album lives, then tidy it:
|
|
// once the last track leaves, remaining extras follow and the
|
|
// empty staging dirs are removed.
|
|
if let Some(parent) = source.parent() {
|
|
app.state::<LibraryState>()
|
|
.staging_targets
|
|
.lock()
|
|
.unwrap()
|
|
.insert(parent.to_path_buf(), target_dir.clone());
|
|
cleanup_staging(app, parent, &target_dir);
|
|
}
|
|
target
|
|
}
|
|
Err(e) => {
|
|
log::warn!("could not organize {}: {e}", source.display());
|
|
source.to_path_buf()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Non-audio files worth keeping next to the organized album.
|
|
const EXTRA_EXTS: &[&str] = &[
|
|
"jpg", "jpeg", "png", "gif", "webp", "bmp", "nfo", "txt", "log", "cue", "m3u", "m3u8", "pdf",
|
|
"sfv", "md5", "accurip",
|
|
];
|
|
|
|
/// If `staging` holds no more audio or in-flight downloads, move its album
|
|
/// extras into `album_dir` and delete the emptied staging directories up to
|
|
/// (but excluding) the download root. Backs off untouched at the first sign
|
|
/// of anything unexpected: pending `.partial` files, subfolders, or file
|
|
/// types outside the extras allowlist.
|
|
fn cleanup_staging(app: &tauri::AppHandle, staging: &Path, album_dir: &Path) {
|
|
let root = downloads_dir(app);
|
|
if !staging.starts_with(&root) || staging == root || album_dir.starts_with(staging) {
|
|
return;
|
|
}
|
|
let Ok(entries) = std::fs::read_dir(staging) else {
|
|
return;
|
|
};
|
|
let mut extras = Vec::new();
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() || is_audio_path(&path) {
|
|
return;
|
|
}
|
|
let ext = path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.map(|e| e.to_ascii_lowercase())
|
|
.unwrap_or_default();
|
|
if ext == "partial" || !EXTRA_EXTS.contains(&ext.as_str()) {
|
|
return;
|
|
}
|
|
extras.push(path);
|
|
}
|
|
for src in extras {
|
|
let name = src
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
let dst = album_dir.join(safe_component(&name));
|
|
if dst.exists() {
|
|
// The album already has this extra; the copy adds nothing.
|
|
let _ = std::fs::remove_file(&src);
|
|
} else if std::fs::rename(&src, &dst).is_err() {
|
|
return; // locked or cross-volume oddity: keep the folder
|
|
}
|
|
}
|
|
let mut dir = staging.to_path_buf();
|
|
while dir != root && dir.starts_with(&root) && std::fs::remove_dir(&dir).is_ok() {
|
|
app.state::<LibraryState>()
|
|
.staging_targets
|
|
.lock()
|
|
.unwrap()
|
|
.remove(&dir);
|
|
match dir.parent() {
|
|
Some(p) => dir = p.to_path_buf(),
|
|
None => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn render_layout(layout: &str, artist: &str, album: &str, date: &str, year: Option<u32>) -> String {
|
|
layout
|
|
.replace("{artist}", &clean_chars(artist))
|
|
.replace("{album}", &clean_chars(album))
|
|
.replace("{date}", &clean_chars(date))
|
|
.replace(
|
|
"{year}",
|
|
&year
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_else(|| "Unknown year".into()),
|
|
)
|
|
}
|
|
|
|
/// MusicBrainz uses ISO dates. Display the fully-known dates in the requested
|
|
/// day-month-year ordering while preserving partial dates such as "1997".
|
|
fn display_date(raw: Option<&str>) -> Option<String> {
|
|
let raw = raw?.trim();
|
|
let parts: Vec<_> = raw.split('-').collect();
|
|
match parts.as_slice() {
|
|
[year, month, day] if year.len() == 4 && month.len() == 2 && day.len() == 2 => {
|
|
Some(format!("{day}-{month}-{year}"))
|
|
}
|
|
_ if !raw.is_empty() => Some(raw.to_string()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Replace characters Windows forbids in file names.
|
|
fn clean_chars(value: &str) -> String {
|
|
value
|
|
.chars()
|
|
.map(|c| match c {
|
|
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
|
|
c if (c as u32) < 0x20 => '_',
|
|
c => c,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// One path segment: cleaned and trimmed; Windows rejects trailing dots and
|
|
/// spaces. May return an empty string (caller decides the fallback).
|
|
pub(crate) fn safe_segment(value: &str) -> String {
|
|
clean_chars(value)
|
|
.trim()
|
|
.trim_end_matches([' ', '.'])
|
|
.to_string()
|
|
}
|
|
|
|
pub(crate) fn safe_component(value: &str) -> String {
|
|
let trimmed = safe_segment(value);
|
|
if trimmed.is_empty() {
|
|
"Unknown Album".into()
|
|
} else {
|
|
trimmed
|
|
}
|
|
}
|
|
|
|
/// `<art_dir>/<artist - album>.jpg`, fetched at most once per album.
|
|
/// `force` refetches even when a cached file exists.
|
|
/// The album's master cover lives IN the album folder as `cover.jpg` — art
|
|
/// travels with the music, one copy exists, and deleting the album deletes
|
|
/// its art (his call, 2026-08-07; AppData keeps only artist portraits and
|
|
/// the import picker's transient extractions). `replace` stomps whatever
|
|
/// cover is there (a fresh download's canonical art, a forced refresh);
|
|
/// without it an existing folder cover IS the master and no fetch happens.
|
|
pub async fn fetch_art_cached(
|
|
state: &tauri::State<'_, LibraryState>,
|
|
album_dir: &Path,
|
|
artist: &str,
|
|
album: &str,
|
|
mbid: Option<&str>,
|
|
rg_mbid: Option<&str>,
|
|
replace: bool,
|
|
) -> Option<PathBuf> {
|
|
let key: String = format!("{artist} - {album}")
|
|
.to_lowercase()
|
|
.chars()
|
|
.map(|c| match c {
|
|
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
|
|
c if (c as u32) < 0x20 => '_',
|
|
c => c,
|
|
})
|
|
.collect();
|
|
let lock = {
|
|
let mut locks = state.art_locks.lock().unwrap();
|
|
locks
|
|
.entry(key.clone())
|
|
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
|
.clone()
|
|
};
|
|
let _guard = lock.lock().await;
|
|
|
|
if !replace {
|
|
for name in ["cover.jpg", "folder.jpg"] {
|
|
let existing = album_dir.join(name);
|
|
if existing.exists() {
|
|
return Some(existing);
|
|
}
|
|
}
|
|
}
|
|
let bytes = state
|
|
.metadata
|
|
.fetch_art(mbid, rg_mbid, artist, album)
|
|
.await?;
|
|
std::fs::create_dir_all(album_dir).ok()?;
|
|
let cover = write_album_cover_files(album_dir, &bytes)?;
|
|
log::info!(
|
|
"album art saved: {} ({} KB)",
|
|
cover.display(),
|
|
bytes.len() / 1024
|
|
);
|
|
Some(cover)
|
|
}
|
|
|
|
/// Downscale the canonical cover for embedding into audio files. `max_edge`
|
|
/// 0 embeds the original untouched; anything already at or under the cap
|
|
/// passes through byte-for-byte. Oversized covers are Lanczos3-resized and
|
|
/// re-encoded as JPEG 92 so a 3000px original doesn't add megabytes to every
|
|
/// track — the full-resolution file stays in the art cache and album folder.
|
|
pub(crate) fn prepare_embed_art(bytes: Vec<u8>, max_edge: u32) -> Vec<u8> {
|
|
if max_edge == 0 {
|
|
return bytes;
|
|
}
|
|
let Ok(img) = image::load_from_memory(&bytes) else {
|
|
return bytes;
|
|
};
|
|
if img.width().max(img.height()) <= max_edge {
|
|
return bytes;
|
|
}
|
|
let resized = img.resize(max_edge, max_edge, image::imageops::FilterType::Lanczos3);
|
|
let mut output = std::io::Cursor::new(Vec::new());
|
|
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 92);
|
|
match resized.into_rgb8().write_with_encoder(encoder) {
|
|
Ok(()) => output.into_inner(),
|
|
Err(_) => bytes,
|
|
}
|
|
}
|
|
|
|
/// Write the canonical cover into the album folder as `cover.jpg` and
|
|
/// `folder.jpg` — the two names Explorer and virtually every player read.
|
|
/// Rare PNG originals are re-encoded as JPEG 95 so the extension never lies.
|
|
/// A same-length file is assumed already ours and skipped.
|
|
fn write_album_folder_cover(album_dir: &Path, art_file: &Path) {
|
|
let Ok(bytes) = std::fs::read(art_file) else {
|
|
return;
|
|
};
|
|
let _ = write_album_cover_files(album_dir, &bytes);
|
|
}
|
|
|
|
/// Write cover bytes into the album folder under the canonical names, and
|
|
/// return the `cover.jpg` path — the album's master. See the doc above.
|
|
pub(crate) fn write_album_cover_files(album_dir: &Path, bytes: &[u8]) -> Option<PathBuf> {
|
|
let bytes = bytes.to_vec();
|
|
let jpeg = if bytes.starts_with(b"\x89PNG") {
|
|
let Ok(img) = image::load_from_memory(&bytes) else {
|
|
return None;
|
|
};
|
|
let mut output = std::io::Cursor::new(Vec::new());
|
|
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95);
|
|
if img.into_rgb8().write_with_encoder(encoder).is_err() {
|
|
return None;
|
|
}
|
|
output.into_inner()
|
|
} else {
|
|
bytes
|
|
};
|
|
// Always write the two canonical names, and additionally overwrite any
|
|
// *existing* generic art-slot file an uploader left behind (albumart.jpg,
|
|
// front.jpg, …) — players that prefer those names must not show stale art.
|
|
// Only .jpg/.jpeg names are replaced in place; scans/booklets keep their
|
|
// distinctive filenames and are never touched.
|
|
const GENERIC_ART_NAMES: [&str; 6] = [
|
|
"albumart.jpg",
|
|
"albumart.jpeg",
|
|
"album.jpg",
|
|
"front.jpg",
|
|
"front.jpeg",
|
|
"thumb.jpg",
|
|
];
|
|
let existing_generic: Vec<String> = std::fs::read_dir(album_dir)
|
|
.map(|entries| {
|
|
entries
|
|
.flatten()
|
|
.filter_map(|e| e.file_name().into_string().ok())
|
|
.filter(|n| GENERIC_ART_NAMES.contains(&n.to_ascii_lowercase().as_str()))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
for name in ["cover.jpg", "folder.jpg"]
|
|
.into_iter()
|
|
.chain(existing_generic.iter().map(String::as_str))
|
|
{
|
|
let target = album_dir.join(name);
|
|
let already_ours = std::fs::metadata(&target)
|
|
.map(|m| m.len() == jpeg.len() as u64)
|
|
.unwrap_or(false);
|
|
if already_ours {
|
|
continue;
|
|
}
|
|
if let Err(e) = std::fs::write(&target, &jpeg) {
|
|
log::warn!("could not write album cover {}: {e}", target.display());
|
|
}
|
|
}
|
|
let cover = album_dir.join("cover.jpg");
|
|
cover.exists().then_some(cover)
|
|
}
|
|
|
|
/// Force-refetch an album's art with fresh MusicBrainz data and re-embed
|
|
/// it into every track (replacing whatever is there).
|
|
pub async fn refresh_album_art(app: &tauri::AppHandle, album_id: i64) -> Result<String, String> {
|
|
let state = app.state::<LibraryState>();
|
|
|
|
let lib = state.library.clone();
|
|
let row = tauri::async_runtime::spawn_blocking(move || lib.get_album(album_id))
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or("album not found")?;
|
|
let (album, _stored_mbid) = row;
|
|
|
|
// Fresh lookup for both MBIDs (the DB only stores the release MBID).
|
|
let meta = state
|
|
.metadata
|
|
.lookup_album(&album.artist, &album.title)
|
|
.await;
|
|
let mbid = meta.as_ref().and_then(|m| m.release_mbid.clone());
|
|
let rg_mbid = meta.as_ref().and_then(|m| m.release_group_mbid.clone());
|
|
|
|
// The tracks come first now: the album FOLDER is where the master cover
|
|
// lives, and the folder is wherever the tracks actually are.
|
|
let lib = state.library.clone();
|
|
let tracks = tauri::async_runtime::spawn_blocking(move || lib.album_tracks(album_id))
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())?;
|
|
let album_dir = tracks
|
|
.first()
|
|
.and_then(|t| Path::new(&t.path).parent().map(Path::to_path_buf))
|
|
.ok_or("album has no tracks on disk")?;
|
|
|
|
let art_path = fetch_art_cached(
|
|
&state,
|
|
&album_dir,
|
|
&album.artist,
|
|
&album.title,
|
|
mbid.as_deref(),
|
|
rg_mbid.as_deref(),
|
|
true,
|
|
)
|
|
.await
|
|
.ok_or("no art found for this album")?;
|
|
|
|
let art_str = art_path.to_string_lossy().into_owned();
|
|
{
|
|
let lib = state.library.clone();
|
|
let a = art_str.clone();
|
|
tauri::async_runtime::spawn_blocking(move || lib.set_album_art(album_id, &a))
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
|
|
// Re-embed into every track, replacing the old picture; tracks living in
|
|
// OTHER folders (multi-disc spreads) get their own cover copies below.
|
|
let cfg = crate::config::load(app);
|
|
let embed_max_edge = crate::config::embed_art_max_edge(&cfg);
|
|
let bytes = std::fs::read(&art_path).map_err(|e| e.to_string())?;
|
|
let embed = {
|
|
let b = bytes.clone();
|
|
tauri::async_runtime::spawn_blocking(move || prepare_embed_art(b, embed_max_edge))
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
};
|
|
let mut album_dirs = std::collections::BTreeSet::new();
|
|
for t in &tracks {
|
|
if let Some(dir) = Path::new(&t.path).parent() {
|
|
album_dirs.insert(dir.to_path_buf());
|
|
}
|
|
}
|
|
for t in tracks {
|
|
let b = embed.clone();
|
|
let p = PathBuf::from(&t.path);
|
|
let res = tauri::async_runtime::spawn_blocking(move || tagger::replace_art(&p, &b)).await;
|
|
if let Ok(Err(e)) = res {
|
|
log::warn!("re-embed art failed for {}: {e}", t.path);
|
|
}
|
|
}
|
|
if crate::config::write_folder_cover(&cfg) {
|
|
let art = art_path.clone();
|
|
let _ = tauri::async_runtime::spawn_blocking(move || {
|
|
for dir in album_dirs {
|
|
write_album_folder_cover(&dir, &art);
|
|
}
|
|
})
|
|
.await;
|
|
}
|
|
|
|
let _ = app.emit("soulseek:library-changed", ());
|
|
Ok(art_str)
|
|
}
|
|
|
|
/// Upgrade every library album to the canonical high-resolution cover:
|
|
/// refetch, re-embed, and rewrite folder covers. Returns how many albums
|
|
/// succeeded. MusicBrainz throttling makes this roughly one album per
|
|
/// second; progress lands in the log.
|
|
pub async fn refresh_all_album_art(app: &tauri::AppHandle) -> Result<usize, String> {
|
|
let state = app.state::<LibraryState>();
|
|
let albums = {
|
|
let lib = state.library.clone();
|
|
tauri::async_runtime::spawn_blocking(move || lib.list_albums())
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.map_err(|e| e.to_string())?
|
|
};
|
|
let total = albums.len();
|
|
let mut refreshed = 0;
|
|
for (i, album) in albums.into_iter().enumerate() {
|
|
match refresh_album_art(app, album.id).await {
|
|
Ok(_) => refreshed += 1,
|
|
Err(e) => log::warn!(
|
|
"art refresh failed for {} - {}: {e}",
|
|
album.artist,
|
|
album.title
|
|
),
|
|
}
|
|
log::info!("album art refresh: {}/{total}", i + 1);
|
|
}
|
|
Ok(refreshed)
|
|
}
|