diff --git a/Cargo.lock b/Cargo.lock index b0f1328..954dcb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,6 +180,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-log", + "tempfile", "tokio", "windows 0.61.3", ] diff --git a/crates/library/src/lib.rs b/crates/library/src/lib.rs index 92c4ac4..c0a1775 100644 --- a/crates/library/src/lib.rs +++ b/crates/library/src/lib.rs @@ -748,6 +748,19 @@ impl Library { Ok(rows.filter_map(|r| r.ok()).collect()) } + /// Drop specific track rows by id. Used when the import tool replaces an + /// album's audio with another version: the rows for the copy that was + /// replaced go with its files. The album row is deliberately left alone — + /// the caller is mid-apply and has already registered the replacement. + pub fn remove_tracks(&self, ids: &[i64]) -> Result { + let conn = self.conn.lock().unwrap(); + let mut removed = 0; + for id in ids { + removed += conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?; + } + Ok(removed) + } + /// Paths of tracks missing the audio-props backfill (size is the sentinel: /// always available from the filesystem, so a non-null size means scanned). pub fn tracks_missing_props(&self) -> Result> { diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index b454ff8..6693d54 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -8154,6 +8154,242 @@ impl MetadataClient { } } + /// Identify one album record against the providers — the import review's + /// on-demand "which record is this?" (research/import/enrich.mjs + /// providerLookup, ported). Deezer is asked down the cleaned try-list + /// until something answers; when its answer is missing — or is a + /// DIFFERENT record (Bastard → Cherry Bomb: free release, Deezer search + /// happily answers with another album) — MusicBrainz answers for the + /// album itself, the same supplement the artist page already leans on. + /// A title that is merely an edition of ours does NOT read as wrong + /// here: containment either way counts as a match, and the edition rule + /// lives with the judge, not the fetcher. + pub async fn identify_album(&self, artist: &str, raw_title: &str) -> IdentifyResult { + let tries = search_titles(artist, raw_title); + let mut candidate = None; + let mut used = None; + for t in &tries { + if let Some(c) = self.deezer_identify(artist, t).await { + candidate = Some(c); + used = Some(t.clone()); + break; + } + } + let deezer_wrong = candidate + .as_ref() + .is_some_and(|c| !tries.iter().any(|t| normx_overlap(&c.title, t))); + if candidate.is_none() || deezer_wrong { + if let Some((mb, t)) = self.mb_identify(artist, &tries).await { + candidate = Some(mb); + used = Some(t); + } + // MB missing too leaves the wrong Deezer answer in place ON + // PURPOSE: the judge shows it as "Wrong record" honestly, which + // beats an empty card (Jenny Death stays wrong, deliberately). + } + IdentifyResult { + tries, + used, + candidate, + deezer_wrong, + } + } + + /// Deezer's answer for one cleaned query: first search hit, hydrated + /// with the album detail (label/date/genre) and the full paginated + /// disc-aware tracklist — `/album/{id}` embeds only the first ~25. + async fn deezer_identify(&self, artist: &str, t: &str) -> Option { + let q = urlencode(&format!("{artist} {t}")); + let found = self + .deezer(&format!("https://api.deezer.com/search/album?q={q}&limit=3")) + .await?; + let hit = found.get("data")?.as_array()?.first()?.clone(); + let id = hit.get("id")?.as_u64()?; + + let full = self.deezer(&format!("https://api.deezer.com/album/{id}")).await; + let mut tracklist: Vec = Vec::new(); + let mut next = Some(format!("https://api.deezer.com/album/{id}/tracks?limit=200")); + while let Some(url) = next { + let Some(page) = self.deezer(&url).await else { break }; + for tr in page.get("data").and_then(|d| d.as_array()).into_iter().flatten() { + tracklist.push(IdentifyTrack { + n: tr.get("track_position").and_then(|v| v.as_u64()).map(|v| v as u32), + title: tr + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + secs: tr.get("duration").and_then(|v| v.as_u64()).map(|v| v as u32), + }); + } + next = page + .get("next") + .and_then(|v| v.as_str()) + .map(String::from); + } + + let s = |v: &serde_json::Value, k: &str| { + v.get(k).and_then(|x| x.as_str()).map(String::from) + }; + Some(IdentifyCandidate { + id: id.to_string(), + source: "Deezer".into(), + title: s(&hit, "title").unwrap_or_default(), + artist: hit + .pointer("/artist/name") + .and_then(|v| v.as_str()) + .map(String::from), + cover: s(&hit, "cover_xl").or_else(|| s(&hit, "cover_big")), + cover_big: s(&hit, "cover_big"), + cover_medium: s(&hit, "cover_medium"), + label: full.as_ref().and_then(|f| s(f, "label")), + release_date: full.as_ref().and_then(|f| s(f, "release_date")), + genre: full + .as_ref() + .and_then(|f| f.pointer("/genres/data/0/name")) + .and_then(|v| v.as_str()) + .map(String::from), + track_count: tracklist.len(), + tracklist, + }) + } + + /// MusicBrainz release-group as the album RECORD, not just as cover art + /// (the mock's mbCandidate). Exact-normalized title first, containment + /// second; one official release with recordings gives the tracklist. + /// Paced by the crate's own MusicBrainz gate. + async fn mb_identify( + &self, + artist: &str, + tries: &[String], + ) -> Option<(IdentifyCandidate, String)> { + for t in tries { + let query = format!("releasegroup:\"{}\" AND artist:\"{}\"", escape(t), escape(artist)); + let url = format!( + "https://musicbrainz.org/ws/2/release-group?query={}&fmt=json&limit=3", + urlencode(&query) + ); + let Some(body) = self.mb_get(&url).await else { continue }; + let empty = Vec::new(); + let groups = body + .get("release-groups") + .and_then(|v| v.as_array()) + .unwrap_or(&empty); + let rg = groups + .iter() + .find(|g| { + g.get("title") + .and_then(|v| v.as_str()) + .is_some_and(|title| normx(title) == normx(t)) + }) + .or_else(|| { + groups.iter().find(|g| { + g.get("title") + .and_then(|v| v.as_str()) + .is_some_and(|title| normx_overlap(title, t)) + }) + }); + let Some(rg) = rg else { continue }; + let Some(rgid) = rg.get("id").and_then(|v| v.as_str()) else { continue }; + + let rel_url = format!( + "https://musicbrainz.org/ws/2/release?release-group={rgid}\ + &inc=recordings&status=official&fmt=json&limit=1" + ); + let rel = self.mb_get(&rel_url).await; + let tracklist: Vec = rel + .as_ref() + .and_then(|r| r.pointer("/releases/0/media")) + .and_then(|m| m.as_array()) + .map(|media| { + media + .iter() + .flat_map(|m| { + m.get("tracks").and_then(|t| t.as_array()).cloned().unwrap_or_default() + }) + .map(|tr| IdentifyTrack { + n: tr.get("position").and_then(|v| v.as_u64()).map(|v| v as u32), + title: tr + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + secs: tr + .get("length") + .and_then(|v| v.as_u64()) + .map(|ms| ((ms as f64) / 1000.0).round() as u32), + }) + .collect() + }) + .unwrap_or_default(); + + let candidate = IdentifyCandidate { + id: format!("mb:{rgid}"), + source: "MusicBrainz".into(), + title: rg + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + artist: rg + .pointer("/artist-credit/0/name") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| Some(artist.to_string())), + cover: Some(format!( + "https://coverartarchive.org/release-group/{rgid}/front" + )), + cover_big: None, + cover_medium: None, + label: None, + release_date: rg + .get("first-release-date") + .and_then(|v| v.as_str()) + .map(String::from), + genre: None, + track_count: tracklist.len(), + tracklist, + }; + return Some((candidate, t.clone())); + } + None + } + + /// Fetch a chosen cover's bytes for the import apply — a plain GET, the + /// caller decides what the pixels are for. + pub async fn fetch_cover_bytes(&self, url: &str) -> Option> { + let resp = self.http.get(url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + resp.bytes().await.ok().map(|b| b.to_vec()) + } + + /// Throttled MusicBrainz GET for identify, with one retry. Status-aware + /// on purpose: a 503 from MB's rate limiter — routine right after app + /// start, when the identity warmer shares the 1 req/s budget — must + /// read as a retryable failure, never as "record absent" (the paced- + /// probe-contamination lesson, now inside the app). The retry passes + /// back through the throttle, so it is properly spaced, and a second + /// failure surfaces as an honest miss the user can re-open. + async fn mb_get(&self, url: &str) -> Option { + for attempt in 0..2 { + self.mb_throttle().await; + match self.http.get(url).send().await { + Ok(resp) if resp.status().is_success() => { + if let Ok(v) = resp.json().await { + return Some(v); + } + } + Ok(resp) if attempt == 0 => { + log::debug!("mb identify: {} from MusicBrainz, retrying", resp.status()); + } + Ok(_) | Err(_) => {} + } + } + None + } + /// Production hot-path cover upgrade for a catalog release. Returns the URL /// of the highest-resolution image that is *verifiably the same artwork* as /// the cover the UI already shows (`reference_url`), or `None` to keep it. @@ -11114,6 +11350,239 @@ pub struct CoverReport { pub chosen_url: Option, } +/* ---------------------------------------------------------------- identify -- + The import review's record identification, ported from the approved mock + (research/import/enrich.mjs). The folder name is not the album name: this + kind of library writes rip descriptors into it — "Heartleap - WEB V0", + "(1997.07.24) (album) Uchu Nippon Setagaya [128kbps]" — and sometimes the + artist again. Sent verbatim, those queries miss records that are plainly + in every catalogue (58 of 100 in the mock's first pass; the cleaned + try-list + MB fallback resolved 50 of 65). The try-list mirrors the app's + own simplifyTitle (state.svelte.js:3739); the judge still decides whatever + comes back, so better recall cannot smuggle in a wrong record. */ + +/// One track of an identify candidate's tracklist. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IdentifyTrack { + pub n: Option, + pub title: String, + pub secs: Option, +} + +/// A provider's answer for "which record is this?", in the shape the mock's +/// candidates.json established (the picker UI is built against it). `id` is +/// a string on purpose: Deezer ids are numeric ("302127"), MusicBrainz ones +/// are "mb:". +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IdentifyCandidate { + pub id: String, + pub source: String, // "Deezer" | "MusicBrainz" + pub title: String, + pub artist: Option, + /// Biggest artwork URL (Deezer xl / Cover Art Archive front). + pub cover: Option, + pub cover_big: Option, + pub cover_medium: Option, + pub label: Option, + pub release_date: Option, + pub genre: Option, + pub track_count: usize, + pub tracklist: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IdentifyResult { + /// The cleaned queries actually sent, least- to most-aggressive. + pub tries: Vec, + /// Which try produced the accepted candidate. + pub used: Option, + pub candidate: Option, + /// Deezer answered with a record whose title matches no form of ours, + /// so MusicBrainz was consulted. When MB also missed, `candidate` still + /// carries Deezer's wrong answer — shown honestly as "Wrong record". + pub deezer_wrong: bool, +} + +/// ASCII-normalize for title matching, exactly as the mock's normx: only +/// lowercase ASCII alphanumerics survive. A fully non-Latin title +/// normalizes to "" and therefore matches anything — the Kuchu Camp gap, +/// kept faithfully (script-aware retry is a someday). +fn normx(s: &str) -> String { + s.to_lowercase() + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect() +} + +/// The mock's match rule: normalized equality or containment either way. +/// Containment is what keeps an edition ("... 10th Anniversary Edition") +/// from reading as a different record. +fn normx_overlap(a: &str, b: &str) -> bool { + let (na, nb) = (normx(a), normx(b)); + na == nb || na.contains(nb.as_str()) || nb.contains(na.as_str()) +} + +/// Case-insensitive prefix strip, char-boundary safe. +fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let mut end = 0; + let mut sc = s.chars(); + for p in prefix.chars() { + let c = sc.next()?; + if !c.to_lowercase().eq(p.to_lowercase()) { + return None; + } + end += c.len_utf8(); + } + Some(&s[end..]) +} + +/// `^[\[(]\d{4}[^\])]*[\])]\s*-?\s*` — a leading "[2015] - " or +/// "(1997.07.24) " group, dates included. +fn strip_leading_year_group(s: &str) -> String { + if !(s.starts_with('[') || s.starts_with('(')) { + return s.to_string(); + } + let bytes = s.as_bytes(); + if bytes.len() < 6 || !bytes[1..5].iter().all(u8::is_ascii_digit) { + return s.to_string(); + } + // The group runs to the FIRST close bracket of either kind, as in the + // mock's character class. + let Some(close) = s.find([']', ')']) else { + return s.to_string(); + }; + if close < 5 { + return s.to_string(); + } + let rest = s[close + 1..].trim_start(); + let rest = rest.strip_prefix('-').unwrap_or(rest).trim_start(); + rest.to_string() +} + +/// Bracket groups that carry rip descriptors — "[V0]", "(320kbps)", +/// "[WEB FLAC]" — removed whole (substring match inside the group, as the +/// mock's alternation behaves). +fn strip_rip_groups(s: &str) -> String { + const TOKENS: [&str; 10] = [ + "kbps", "v0", "v2", "web", "flac", "mp3", "320", "256", "192", "128", + ]; + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(open) = rest.find(['[', '(']) { + let after = &rest[open + 1..]; + let Some(close) = after.find([']', ')']) else { break }; + let low = after[..close].to_lowercase(); + if TOKENS.iter().any(|t| low.contains(t)) { + out.push_str(&rest[..open]); + out.push(' '); + } else { + out.push_str(&rest[..open + 1 + close + 1]); + } + rest = &after[close + 1..]; + } + out.push_str(rest); + out +} + +/// Free-standing rip tokens (`\b`-bounded in the mock): whole words only, +/// so "320kbps" the word survives while "320" alone goes. +fn strip_rip_words(s: &str) -> String { + const WORDS: [&str; 14] = [ + "web", "v0", "v2", "flac", "mp3", "320", "320k", "256", "192", "128", "128kbps", + "kbps", "vinyl", "rip", + ]; + fn flush(word: &mut String, out: &mut String) { + if word.is_empty() { + return; + } + let low = word.to_lowercase(); + let user_n = low + .strip_prefix("user") + .is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit())); + if WORDS.contains(&low.as_str()) || user_n { + out.push(' '); + } else { + out.push_str(word); + } + word.clear(); + } + let mut out = String::with_capacity(s.len()); + let mut word = String::new(); + for c in s.chars() { + if c.is_ascii_alphanumeric() || c == '_' { + word.push(c); + } else { + flush(&mut word, &mut out); + out.push(c); + } + } + flush(&mut word, &mut out); + out +} + +/// The cleaned query try-list for one folder title, least- to most- +/// aggressive, deduped; may be empty when nothing survives cleaning. +pub fn search_titles(artist: &str, raw_title: &str) -> Vec { + // Invisible marks break matching. + let t: String = raw_title + .chars() + .filter(|c| !matches!(c, '\u{200e}' | '\u{200f}' | '\u{feff}')) + .collect(); + let mut t = t.trim().to_string(); + + t = strip_leading_year_group(&t); + for tag in ["(album)", "(single)", "(ep)"] { + if let Some(rest) = strip_prefix_ci(&t, tag) { + t = rest.trim_start().to_string(); + break; + } + } + // The artist repeated at the front of the album folder (one separator + // dash at most, as the mock's regex). + if let Some(rest) = strip_prefix_ci(&t, artist) { + let rest = rest.trim_start(); + let rest = rest.strip_prefix(['-', '–', '—']).unwrap_or(rest); + let rest = rest.trim_start(); + if !rest.is_empty() { + t = rest.to_string(); + } + } + + // Least aggressive: rip tokens and their bracket groups stripped. + let cleaned = { + let g = strip_rip_groups(&t); + let w = strip_rip_words(&g); + let d = w.trim_end(); + let d = d.strip_suffix(['-', '–', '—']).unwrap_or(d); + d.split_whitespace().collect::>().join(" ") + }; + // The app's simplifyTitle: cut at the first ; : ( [ and cap at six words. + let base = if cleaned.is_empty() { t.clone() } else { cleaned.clone() }; + let first = base + .split([';', ':', '(', '[']) + .next() + .unwrap_or("") + .trim() + .to_string(); + let simp_src = if first.is_empty() { base } else { first }; + let simplified = simp_src + .split_whitespace() + .take(6) + .collect::>() + .join(" "); + + let mut out = Vec::new(); + for v in [cleaned, simplified] { + if !v.is_empty() && !out.contains(&v) { + out.push(v); + } + } + out +} + struct CoverCandidate { bytes: Arc>, width: u32, @@ -13875,6 +14344,44 @@ fn urlencode(s: &str) -> String { #[cfg(test)] mod tests { + /// The try-list port (`search_titles`) against the exact folder shapes + /// the mock proved on: rip descriptors, date/type prefixes, doubled + /// artists (research/import/enrich.mjs searchTitles). + #[test] + fn search_titles_cleans_rip_descriptors() { + use super::search_titles; + assert_eq!( + search_titles("Vashti Bunyan", "Heartleap - WEB V0"), + vec!["Heartleap".to_string()] + ); + assert_eq!( + search_titles("Vashti Bunyan", "Just Another Diamond Day [V0]"), + vec!["Just Another Diamond Day".to_string()] + ); + assert_eq!( + search_titles("Fishmans", "(1997.07.24) (album) Uchu Nippon Setagaya [128kbps]"), + vec!["Uchu Nippon Setagaya".to_string()] + ); + assert_eq!( + search_titles("Smog", "Smog - The Doctor Came At Dawn (1996)"), + vec![ + "The Doctor Came At Dawn (1996)".to_string(), + "The Doctor Came At Dawn".to_string(), + ] + ); + } + + /// The edition rule's foundation: containment matches, disjoint titles + /// don't (Bastard → Cherry Bomb is the canonical wrong record). + #[test] + fn normx_overlap_editions_match_wrong_records_do_not() { + assert!(super::normx_overlap( + "Random Access Memories (10th Anniversary Edition)", + "Random Access Memories" + )); + assert!(!super::normx_overlap("Cherry Bomb", "Bastard")); + } + /// Is a provider's image host actually failing, or is this machine? /// /// Run this the next time a `failed a liveness probe` warning appears. It diff --git a/crates/soulseek-client/src/lib.rs b/crates/soulseek-client/src/lib.rs index 0ae51f6..c02768f 100644 --- a/crates/soulseek-client/src/lib.rs +++ b/crates/soulseek-client/src/lib.rs @@ -379,13 +379,29 @@ impl Client { /// Start downloading `filename` (full remote path) from `username`. /// Progress streams out as [`Event::Download`]. Returns the download id. pub fn download(&self, username: &str, filename: &str, size: u64) -> u64 { - transfer::start_download(self.inner.clone(), username, filename, size, None) + transfer::start_download(self.inner.clone(), username, filename, size, None, |_| {}) } /// Like [`download`](Self::download) but writing to an explicit local /// path (used for stream-to-cache). pub fn download_to(&self, username: &str, filename: &str, size: u64, target: PathBuf) -> u64 { - transfer::start_download(self.inner.clone(), username, filename, size, Some(target)) + transfer::start_download(self.inner.clone(), username, filename, size, Some(target), |_| {}) + } + + /// `download_to`, with `claim` run on the new id BEFORE the transfer task + /// is spawned. A download emits `Connecting` the instant it starts, so a + /// caller that must recognise its own transfers — the import tool's + /// audition pen — has to be able to register the id first. Doing it on the + /// returned value is a race the emitter usually wins. + pub fn download_to_claimed( + &self, + username: &str, + filename: &str, + size: u64, + target: PathBuf, + claim: impl FnOnce(u64), + ) -> u64 { + transfer::start_download(self.inner.clone(), username, filename, size, Some(target), claim) } /// Cancel an active download by id. diff --git a/crates/soulseek-client/src/share.rs b/crates/soulseek-client/src/share.rs index 721bead..e41f41f 100644 --- a/crates/soulseek-client/src/share.rs +++ b/crates/soulseek-client/src/share.rs @@ -160,6 +160,11 @@ fn walk(dir: &Path, base: &str, root: &Path, index: &mut Index) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { + // The download staging pen is not part of anyone's library — + // half-finished transfers are not shareable records. + if path.file_name().is_some_and(|n| n == "(Cache)") { + continue; + } walk(&path, base, root, index); } else if is_audio(&path) { let Ok(meta) = entry.metadata() else { continue }; diff --git a/crates/soulseek-client/src/transfer.rs b/crates/soulseek-client/src/transfer.rs index 8f60d75..4496175 100644 --- a/crates/soulseek-client/src/transfer.rs +++ b/crates/soulseek-client/src/transfer.rs @@ -57,10 +57,15 @@ pub(crate) fn start_download( filename: &str, size: u64, target: Option, + claim: impl FnOnce(u64), ) -> u64 { let id = inner .download_counter .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + // Before the spawn below, never after: the task's first act is to emit + // Connecting, and a listener that has not yet claimed this id cannot tell + // the event is theirs. + claim(id); let cancel = CancellationToken::new(); inner.downloads_by_file.lock().unwrap().insert( (username.to_string(), filename.to_string()), @@ -362,6 +367,11 @@ impl Download { None => ("", self.filename.as_str()), }; let mut dir = self.inner.downloads_dir.clone(); + // Staging lives in ONE visible pen — "(Cache)" — never loose in the + // library root: a half-finished transfer is not a record, and a + // peer's username is not an artist. organize_download lifts the + // finished file out; cleanup_staging tidies what it leaves behind. + dir.push("(Cache)"); dir.push(sanitize(&self.username)); if !folder.is_empty() { dir.push(sanitize(folder)); diff --git a/crates/tagger/src/lib.rs b/crates/tagger/src/lib.rs index 924caa4..7c95a63 100644 --- a/crates/tagger/src/lib.rs +++ b/crates/tagger/src/lib.rs @@ -70,6 +70,8 @@ pub struct TagWrite { pub title: Option, pub track_no: Option, pub year: Option, + pub label: Option, + pub genre: Option, } /// Cover bytes come from several providers; PNG originals exist (Cover Art @@ -117,6 +119,12 @@ pub fn write_tags( if let Some(v) = &write.album_artist { tag.insert_text(lofty::tag::ItemKey::AlbumArtist, v.clone()); } + if let Some(v) = &write.genre { + tag.set_genre(v.clone()); + } + if let Some(v) = &write.label { + tag.insert_text(lofty::tag::ItemKey::Label, v.clone()); + } if let Some(bytes) = art_jpeg { if replace_art { @@ -139,6 +147,26 @@ pub fn write_tags( Ok(()) } +/// The embedded artwork's bytes, front cover first, any picture otherwise. +/// +/// This is the import tool's "yours" side: in the reference library 449 of +/// 515 records carry their best cover INSIDE the files, and a scan that only +/// looks at loose images sees Windows Media Player's 200px cache instead. +/// lofty parses the whole tag, so the 20MB tags that defeated a capped read +/// (a 586MB single-file live set) are handled like any other. +pub fn extract_art(path: &Path) -> Result>> { + let tagged = Probe::open(path)?.read()?; + let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) else { + return Ok(None); + }; + let pics = tag.pictures(); + let best = pics + .iter() + .find(|p| p.pic_type() == PictureType::CoverFront) + .or_else(|| pics.first()); + Ok(best.map(|p| p.data().to_vec())) +} + /// Replace any existing embedded pictures with the given front cover. pub fn replace_art(path: &Path, art_jpeg: &[u8]) -> Result<()> { let mut tagged = Probe::open(path)?.read()?; diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e530bb8..c987c51 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,6 +13,11 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +[dev-dependencies] +# Import-apply tests build a whole source/pen/library bed on disk; already in +# the lock file via the dependency tree, so this adds no download. +tempfile = "3" + [dependencies] tauri = { version = "2", features = [] } tauri-plugin-log = "2" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ec8a11f..e8ac0db 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -665,6 +665,80 @@ pub async fn connect_and_login( } } } + } else if crate::import::is_audition(update.id) { + // Audition downloads are candidate versions under + // review in the (Cache) pen — they stay OUT of the + // library pipeline; the import window watches them. + use soulseek_client::DownloadState as DS; + // WHICH record and WHICH track, straight from the + // registry — looked up before audition_done retires + // the id. The UI matches on this, never on the id: + // the first events of a transfer are emitted before + // import_audition has returned its ids at all. + let (au_rel, au_idx) = crate::import::audition_of(update.id) + .map(|(r, i)| (r, i as i64)) + .unwrap_or_else(|| (String::new(), -1)); + match &update.state { + DS::Complete { local_path } => { + let _ = event_app.emit( + "import:audition", + serde_json::json!({ + "id": update.id, + "rel": au_rel, + "idx": au_idx, + "localPath": local_path, + "remote": update.filename, + "done": true, + }), + ); + crate::import::audition_done(update.id); + } + DS::Failed { reason } => { + let _ = event_app.emit( + "import:audition", + serde_json::json!({ + "id": update.id, + "rel": au_rel, + "idx": au_idx, + "remote": update.filename, + "failed": reason, + }), + ); + crate::import::audition_done(update.id); + } + DS::Cancelled => { + crate::import::audition_done(update.id); + } + // In flight. The picker's row can only say what it + // is told, and "arriving…" was all it had: the + // phase, the QUEUE PLACE and the speed were all + // known here and thrown away. A place that ticks + // down is the difference between waiting and + // wondering whether anything is happening. + rest => { + let (phase, place) = match rest { + DS::Connecting => ("connecting", None), + DS::Requested => ("requested", None), + DS::Queued { place } => ("queued", *place), + DS::Transferring => ("transferring", None), + _ => ("working", None), + }; + let _ = event_app.emit( + "import:audition", + serde_json::json!({ + "id": update.id, + "rel": au_rel, + "idx": au_idx, + "remote": update.filename, + "received": update.received, + "total": update.total, + "speedBps": update.speed_bps, + "phase": phase, + "place": place, + }), + ); + } + } } else { let _ = event_app.emit("soulseek:download", download_payload(&update)); // Completed real downloads go through the tag/art diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index b72be36..e1936eb 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -84,6 +84,12 @@ pub struct AppConfig { /// tag values are flattened to `_`). #[serde(default)] pub library_layout: Option, + /// The managed library root the import tool copies into. Deliberately no + /// default: the Source step requires choosing it before Scan enables, and + /// it must never silently coincide with the download/staging dir, whose + /// prune sweeps would eat imported files. + #[serde(default)] + pub library_dir: Option, /// Extra folders to share (the downloads folder is always shared). #[serde(default)] pub shared_folders: Vec, diff --git a/src-tauri/src/import.rs b/src-tauri/src/import.rs new file mode 100644 index 0000000..9f236df --- /dev/null +++ b/src-tauri/src/import.rs @@ -0,0 +1,1541 @@ +//! The import tool's backend: scan an arbitrary folder, keep the review +//! session across restarts, and apply the result by COPYING into the managed +//! library root. +//! +//! Three rules carried over from the prototype (research/import/, gitignored) +//! and the recon that preceded it: +//! +//! 1. The user's folder is only ever read from. Apply copies; it never +//! moves, renames or rewrites the source. "If an upgrade turns out to be +//! the wrong record, the original is still sitting there." (A record +//! whose review kept the FOUND copy instead is moved out of the audition +//! pen — the app's own cache, not his folder, which is still untouched.) +//! 2. Registration goes straight to the library crate, NOT through +//! `process_file` — whose duplicate check deletes the incoming file when +//! the record already exists (pipeline.rs, the `remote_path.is_some()` +//! branch). That is correct for Soulseek staging and fatal for imports. +//! 3. Every progress event carries a payload. A UI cannot narrate a scan it +//! cannot see. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, Manager, State}; + +use crate::config; +use crate::pipeline::{self, LibraryState}; + +/* ------------------------------------------------------------------ scan -- */ + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanTrack { + pub name: String, + pub ext: String, + pub bytes: u64, + pub seconds: Option, + pub bitrate: Option, + pub sample_rate: Option, + pub bit_depth: Option, + pub embedded_art: bool, + // What the file SAYS, for the review's "yours" column. + pub artist: Option, + pub album: Option, + pub title: Option, + pub track_no: Option, + pub year: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanAlbum { + /// Folder path relative to the scan root — the stable identity every + /// review choice is keyed by, exactly as in the prototype. + pub rel: String, + pub artist_folder: String, + pub album_folder: String, + /// Path components below the root (2 = Artist\Album). + pub depth: u32, + /// Year parsed from the folder name, when it carries one. + pub year: Option, + /// Image files sitting loose in the folder. + pub folder_art: Vec, + pub tracks: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ScanResult { + pub root: String, + pub albums: Vec, +} + +/// Per-file tick, throttled by TIME at the emit site (not by count — the old +/// every-10th-folder emit is why the count jumped 10 at a time). Carries the +/// running totals because throttling DROPS ticks: a UI accumulating per-event +/// would drift, one reading totals cannot. +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScanFileProgress<'a> { + done: usize, + total: usize, + files_done: usize, + files_total: usize, + rel: &'a str, + file: &'a str, + file_secs: Option, + file_kbps: Option, + file_lossless: bool, + tracks_secs: u64, + bytes: u64, + lossless: usize, + artists: usize, +} + +/// Per-folder summary, emitted for EVERY folder — the ledger's fold-to-one- +/// line moment must never be throttled away. +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScanFolderProgress<'a> { + done: usize, + total: usize, + rel: &'a str, + artist: &'a str, + album: &'a str, + tracks: usize, + secs: u64, + bytes: u64, + lossless: usize, + avg_kbps: Option, +} + +const IMAGE_EXTS: [&str; 5] = ["jpg", "jpeg", "png", "bmp", "webp"]; + +fn lossless_ext(ext: &str) -> bool { + matches!(ext, ".flac" | ".wav" | ".aiff" | ".aif" | ".ape" | ".wv") +} + +fn folder_year(name: &str) -> Option { + // (2017) - Album, 2017 - Album, Album (2017), Album [2017], Album - 2017 + let re_hits = name + .as_bytes() + .windows(4) + .enumerate() + .filter_map(|(i, w)| { + if w.iter().all(u8::is_ascii_digit) { + name[i..i + 4].parse::().ok().filter(|y| (1900..=2100).contains(y)) + } else { + None + } + }) + .collect::>(); + re_hits.first().copied() +} + +/// Generation counter: starting a scan retires every older one. An orphaned +/// walk (page reload, superseded Scan click) would otherwise keep reading +/// disk for minutes and braid its events into the new scan's ledger. +static SCAN_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Walk `root`, grouping audio files by their containing folder. Blocking — +/// call from a blocking task. `on_file` fires per file read, `on_folder` per +/// finished folder; `abort` is polled per file and ends the walk early. +/// Returns the albums and whether the walk was aborted. +fn scan_tree( + root: &Path, + abort: impl Fn() -> bool, + mut on_file: impl FnMut(ScanFileProgress), + mut on_folder: impl FnMut(ScanFolderProgress), +) -> (Vec, bool) { + let mut files: Vec = Vec::new(); + pipeline::collect_audio_files(root, &mut files, 0); + + // Group by parent dir, preserving discovery order. + let mut order: Vec = Vec::new(); + let mut by_dir: std::collections::HashMap> = + std::collections::HashMap::new(); + for f in files { + let dir = f.parent().unwrap_or(root).to_path_buf(); + by_dir.entry(dir.clone()).or_insert_with(|| { + order.push(dir.clone()); + Vec::new() + }); + by_dir.get_mut(&f.parent().unwrap_or(root).to_path_buf()).unwrap().push(f); + } + + let total = order.len(); + let files_total: usize = by_dir.values().map(Vec::len).sum(); + let mut files_done = 0usize; + let mut tracks_secs = 0u64; + let mut bytes_running = 0u64; + let mut lossless_running = 0usize; + let mut artist_set: std::collections::HashSet = std::collections::HashSet::new(); + + let mut albums = Vec::with_capacity(total); + for (i, dir) in order.iter().enumerate() { + let rel_path = dir.strip_prefix(root).unwrap_or(dir); + let rel = rel_path.to_string_lossy().into_owned(); + let comps: Vec = rel_path + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect(); + let depth = comps.len() as u32; + let artist_folder = comps.first().cloned().unwrap_or_default(); + let album_folder = comps.last().cloned().unwrap_or_default(); + artist_set.insert(artist_folder.clone()); + + let mut folder_art = Vec::new(); + if let Ok(entries) = std::fs::read_dir(dir) { + for e in entries.flatten() { + let p = e.path(); + if p.is_file() + && p.extension() + .and_then(|x| x.to_str()) + .map(|x| IMAGE_EXTS.contains(&x.to_ascii_lowercase().as_str())) + .unwrap_or(false) + { + folder_art.push(e.file_name().to_string_lossy().into_owned()); + } + } + } + + let mut tracks = Vec::new(); + let mut paths = by_dir.remove(dir).unwrap_or_default(); + paths.sort(); + for p in paths { + if abort() { + return (albums, true); + } + let bytes = std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0); + let ext = p + .extension() + .and_then(|e| e.to_str()) + .map(|e| format!(".{}", e.to_ascii_lowercase())) + .unwrap_or_default(); + let name = p.file_name().unwrap_or_default().to_string_lossy().into_owned(); + // Header-level read; a failed parse still lists the file. + let s = tagger::read_summary(&p).unwrap_or_default(); + + let file_lossless = lossless_ext(&ext); + files_done += 1; + tracks_secs += u64::from(s.duration_secs.unwrap_or(0)); + bytes_running += bytes; + if file_lossless { + lossless_running += 1; + } + on_file(ScanFileProgress { + done: i, + total, + files_done, + files_total, + rel: &rel, + file: &name, + file_secs: s.duration_secs, + file_kbps: s.bitrate_kbps, + file_lossless, + tracks_secs, + bytes: bytes_running, + lossless: lossless_running, + artists: artist_set.len(), + }); + + tracks.push(ScanTrack { + name, + ext, + bytes, + seconds: s.duration_secs, + bitrate: s.bitrate_kbps, + sample_rate: s.sample_rate, + bit_depth: s.bit_depth, + embedded_art: s.has_art, + artist: s.artist, + album: s.album, + title: s.title, + track_no: s.track_no, + year: s.year, + }); + } + + let f_secs: u64 = tracks.iter().map(|t| u64::from(t.seconds.unwrap_or(0))).sum(); + let f_bytes: u64 = tracks.iter().map(|t| t.bytes).sum(); + let f_lossless = tracks.iter().filter(|t| lossless_ext(&t.ext)).count(); + let rates: Vec = tracks.iter().filter_map(|t| t.bitrate).collect(); + let avg_kbps = + (!rates.is_empty()).then(|| rates.iter().sum::() / rates.len() as u32); + on_folder(ScanFolderProgress { + done: i + 1, + total, + rel: &rel, + artist: &artist_folder, + album: &album_folder, + tracks: tracks.len(), + secs: f_secs, + bytes: f_bytes, + lossless: f_lossless, + avg_kbps, + }); + + let year = folder_year(&album_folder); + albums.push(ScanAlbum { + rel, + artist_folder, + album_folder, + depth, + year, + folder_art, + tracks, + }); + } + (albums, false) +} + +/// Scan an arbitrary folder. Emits `import:scan` per file (throttled by time) +/// and `import:scan-folder` per folder, and returns the full result. +/// Read-only: nothing is written anywhere. +#[tauri::command] +pub async fn import_scan(app: tauri::AppHandle, root: String) -> Result { + let root_path = PathBuf::from(&root); + if !root_path.is_dir() { + return Err(format!("not a folder: {root}")); + } + let emitter = app.clone(); + let gen = SCAN_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + let (albums, aborted) = tauri::async_runtime::spawn_blocking(move || { + let mut last: Option = None; + scan_tree( + &root_path, + || SCAN_GEN.load(std::sync::atomic::Ordering::SeqCst) != gen, + |p| { + // ~20 ticks/s regardless of file rate; totals ride along so + // dropped ticks cost nothing. + if last.is_none_or(|t| t.elapsed() >= std::time::Duration::from_millis(50)) { + last = Some(std::time::Instant::now()); + let _ = emitter.emit("import:scan", p); + } + }, + |p| { + let _ = emitter.emit("import:scan-folder", p); + }, + ) + }) + .await + .map_err(|e| e.to_string())?; + if aborted { + return Err("scan superseded by a newer scan".into()); + } + Ok(ScanResult { root, albums }) +} + +/* --------------------------------------------------------------- session -- */ + +/// The saved review session, opaque to the backend: the frontend owns the +/// shape (choices keyed by rel, position, step — the prototype's S), the +/// backend owns durability. A file beats localStorage because it survives +/// webview cache clears and travels with app data. +#[tauri::command] +pub fn import_session_get(app: tauri::AppHandle) -> Result, String> { + let path = session_path(&app)?; + match std::fs::read_to_string(path) { + Ok(s) => Ok(Some(s)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.to_string()), + } +} + +#[tauri::command] +pub fn import_session_set(app: tauri::AppHandle, state: String) -> Result<(), String> { + let path = session_path(&app)?; + std::fs::write(path, state).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn import_session_clear(app: tauri::AppHandle) -> Result<(), String> { + // The run is over, so every version still penned is one the review saw + // and did not keep — the free-flip window closes with the session that + // opened it. (Claimed ones moved into the library during apply.) + let cfg = config::load(&app); + let pen = config::download_dir(&app, &cfg).join("(Cache)").join("_audition"); + if pen.is_dir() { + if let Err(e) = std::fs::remove_dir_all(&pen) { + log::warn!("import: could not clear the audition pen: {e}"); + } + } + let path = session_path(&app)?; + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.to_string()), + } +} + +fn session_path(app: &tauri::AppHandle) -> Result { + let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir.join("import-session.json")) +} + +/* ----------------------------------------------------------- destination -- */ + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportDest { + pub dir: Option, + /// Proposed default while no dir is chosen: the downloads root, which is + /// already the app's managed library — downloads are organized into it + /// with the library layout and the startup scan indexes it. Nothing is + /// created until it is accepted. + pub suggested: Option, + pub free_bytes: Option, + pub layout: String, +} + +/// The managed root plus its real free space — shown beside "108 GB to +/// copy", because the number is only useful if it is true. +#[tauri::command] +pub fn import_get_dest(app: tauri::AppHandle) -> Result { + let cfg = config::load(&app); + let suggested = if cfg.library_dir.is_none() { + Some(config::download_dir(&app, &cfg).to_string_lossy().into_owned()) + } else { + None + }; + let free_bytes = cfg + .library_dir + .as_deref() + .or(suggested.as_deref()) + .and_then(|d| free_space_of(Path::new(d))); + Ok(ImportDest { + dir: cfg.library_dir.clone(), + suggested, + free_bytes, + layout: config::library_layout(&cfg).to_string(), + }) +} + +#[tauri::command] +pub fn import_set_dest( + app: tauri::AppHandle, + dir: String, + layout: Option, +) -> Result { + let path = PathBuf::from(&dir); + std::fs::create_dir_all(&path).map_err(|e| format!("cannot create {dir}: {e}"))?; + let mut cfg = config::load(&app); + // The downloads root is a legal — even natural — choice: it already IS + // the managed library (organize_download files into it with the library + // layout, the startup scan indexes it). Its maintenance never harms + // imports: prune_* only drop DB rows, sweep_empty_dirs only removes + // empty folders, and process_file's duplicate-delete branch is download- + // only (remote_path is Some). Apply registers every copy the moment it + // lands, so an imported file is never "unknown" to the scan. + cfg.library_dir = Some(dir); + if let Some(l) = layout { + let trimmed = l.trim().to_string(); + cfg.library_layout = (!trimmed.is_empty()).then_some(trimmed); + } + config::save(&app, &cfg); + import_get_dest(app) +} + +/// Free space on the disk holding `path`, via the sysinfo dep the crate +/// already carries — shown beside "108 GB to copy", because the number is +/// only useful if it is true. +fn free_space_of(path: &Path) -> Option { + let disks = sysinfo::Disks::new_with_refreshed_list(); + // canonicalize returns a \\?\-verbatim path on Windows, which never + // starts_with sysinfo's plain "R:\" mount point — strip it, or every + // lookup silently reports "unknown". + let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let canon_str = canon.to_string_lossy(); + let plain = PathBuf::from( + canon_str.strip_prefix(r"\\?\").unwrap_or(&canon_str).to_string(), + ); + disks + .iter() + .filter(|d| plain.starts_with(d.mount_point())) + .max_by_key(|d| d.mount_point().as_os_str().len()) + .map(|d| d.available_space()) +} + +/* ------------------------------------------------------------- local art -- */ + +/// The picker's "yours" tile: the record's own artwork, measured — decoded +/// dimensions, never a filename's claim. Embedded front cover first (the +/// review established that is where the real covers live), else the largest +/// loose image in the folder, with Windows Media Player's derived +/// thumbnails (AlbumArt*, Folder.jpg's tiny siblings) skipped unless they +/// are all there is. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalArt { + pub path: String, + pub w: u32, + pub h: u32, + /// "embedded" | "folder" | "derived" (a WMP thumbnail was all we had) + pub kind: String, + /// The file the pixels came from. + pub from: String, + /// WMP thumbnails skipped over. + pub derived_ignored: u32, +} + +#[tauri::command] +pub async fn import_local_art( + app: tauri::AppHandle, + source_root: String, + rel: String, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || local_art_of(&app, &source_root, &rel)) + .await + .map_err(|e| e.to_string()) +} + +fn local_art_of(app: &tauri::AppHandle, source_root: &str, rel: &str) -> Option { + let dir = Path::new(source_root).join(rel); + if !dir.is_dir() { + return None; + } + + // Embedded front cover of the first files that carry one — materialized + // into app data so the webview can read it (the audio file itself can't + // be handed to an ). + let mut files: Vec = Vec::new(); + pipeline::collect_audio_files(&dir, &mut files, 0); + files.sort(); + for f in files.iter().take(5) { + let Ok(Some(bytes)) = tagger::extract_art(f) else { continue }; + let Ok(img) = image::load_from_memory(&bytes) else { continue }; + let (w, h) = (img.width(), img.height()); + let cache_dir = app.path().app_data_dir().ok()?.join("import-art"); + std::fs::create_dir_all(&cache_dir).ok()?; + // Stable name per rel so reopening a record reuses the extraction. + let mut hash: u64 = 0xcbf29ce484222325; + for b in rel.as_bytes() { + hash ^= u64::from(*b); + hash = hash.wrapping_mul(0x100000001b3); + } + let target = cache_dir.join(format!("{hash:016x}.jpg")); + if !target.exists() { + std::fs::write(&target, &bytes).ok()?; + } + return Some(LocalArt { + path: target.to_string_lossy().into_owned(), + w, + h, + kind: "embedded".into(), + from: f.file_name().unwrap_or_default().to_string_lossy().into_owned(), + derived_ignored: 0, + }); + } + + // Loose images in the folder. WMP's derived thumbnails are recognizable + // by name; they only qualify when nothing else exists. + let mut real: Vec = Vec::new(); + let mut derived: Vec = Vec::new(); + for e in std::fs::read_dir(&dir).ok()?.flatten() { + let p = e.path(); + let is_img = p.is_file() + && p.extension() + .and_then(|x| x.to_str()) + .map(|x| IMAGE_EXTS.contains(&x.to_ascii_lowercase().as_str())) + .unwrap_or(false); + if !is_img { + continue; + } + let name = p.file_name().unwrap_or_default().to_string_lossy().to_lowercase(); + if name.starts_with("albumart") { + derived.push(p); + } else { + real.push(p); + } + } + let derived_ignored = derived.len() as u32; + let (pool, kind) = if real.is_empty() { + (derived, "derived") + } else { + (real, "folder") + }; + pool.into_iter() + .filter_map(|p| { + let (w, h) = image::image_dimensions(&p).ok()?; + Some((p, w, h)) + }) + .max_by_key(|(_, w, h)| u64::from(*w) * u64::from(*h)) + .map(|(p, w, h)| LocalArt { + from: p.file_name().unwrap_or_default().to_string_lossy().into_owned(), + path: p.to_string_lossy().into_owned(), + w, + h, + kind: kind.into(), + derived_ignored: if kind == "folder" { derived_ignored } else { 0 }, + }) +} + +/* -------------------------------------------------------------- audition -- */ + +/// Transfer ids belonging to audition downloads, each mapped to the record it +/// is for and its position in that record's file list. The completion handler +/// must NOT run these through process_file — they are candidate versions +/// under review in the `(Cache)\_audition` pen, not library arrivals. +/// +/// The mapping carries `(rel, index)` rather than being a bare set because the +/// UI has to match events to a record, and it CANNOT do that by id: the +/// transfers begin — and emit Connecting, Requested and Queued — inside +/// `import_audition`, before the command returns the ids the front end would +/// look them up in. Every one of those early events was dropped, and since the +/// client emits nothing further while a transfer sits in a peer's queue, the +/// row had no news to show for as long as the wait lasted. +pub(crate) static AUDITION_IDS: std::sync::LazyLock< + std::sync::Mutex>, +> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new())); + +pub(crate) fn is_audition(id: u64) -> bool { + AUDITION_IDS.lock().unwrap().contains_key(&id) +} + +/// Which record and which position, for an event that is about to be emitted. +/// Look this up BEFORE `audition_done` retires the id. +pub(crate) fn audition_of(id: u64) -> Option<(String, usize)> { + AUDITION_IDS.lock().unwrap().get(&id).cloned() +} + +pub(crate) fn audition_done(id: u64) { + AUDITION_IDS.lock().unwrap().remove(&id); +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuditionFile { + pub path: String, + pub size: u64, +} + +/// The pen one record's found copy downloads into. DERIVED from `rel` on +/// both sides — the download and the apply-time move — so a session resumed +/// days later cannot hand the move a path the transfer never wrote to. +pub(crate) fn audition_dir(download_root: &Path, rel: &str) -> PathBuf { + let flat = rel.replace(['/', '\\'], " - "); + download_root + .join("(Cache)") + .join("_audition") + .join(pipeline::safe_component(&flat)) +} + +/// Download a found album copy into `(Cache)\_audition\\` for the +/// version comparison — the WHOLE album (his call: keeping it later is a +/// move, not a re-download, and one search's worth of requests). The caller +/// orders the list with track 1 first so the A/B can start while the rest +/// fills in. Returns transfer ids aligned with the input files. +#[tauri::command] +pub async fn import_audition( + app: tauri::AppHandle, + state: State<'_, crate::state::AppState>, + username: String, + rel: String, + files: Vec, +) -> Result, String> { + let guard = state.client.lock().await; + let client = guard.as_ref().ok_or("not connected")?; + let cfg = config::load(&app); + let base = audition_dir(&config::download_dir(&app, &cfg), &rel); + std::fs::create_dir_all(&base).map_err(|e| e.to_string())?; + let mut ids = Vec::with_capacity(files.len()); + for (i, f) in files.iter().enumerate() { + let leaf = f.path.rsplit('\\').next().unwrap_or(&f.path); + let target = base.join(pipeline::safe_component(leaf)); + // Claimed BEFORE the transfer task is spawned: it emits Connecting + // immediately, and an event whose id is not yet registered is one the + // UI throws away — and worse, one commands.rs routes down the ordinary + // download path instead of the pen's. + let id = client.download_to_claimed(&username, &f.path, f.size, target, |id| { + AUDITION_IDS.lock().unwrap().insert(id, (rel.clone(), i)); + }); + ids.push(id); + } + Ok(ids) +} + +/// Measured facts about one audio file — the version card's quality chips +/// read the arrived audition file itself, never the search response's +/// claims. Read-only. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProbeResult { + pub seconds: Option, + pub bitrate: Option, + pub sample_rate: Option, + pub bit_depth: Option, + pub bytes: u64, + pub title: Option, +} + +#[tauri::command] +pub async fn import_probe_file(path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let p = PathBuf::from(&path); + let bytes = std::fs::metadata(&p).map(|m| m.len()).unwrap_or(0); + let s = tagger::read_summary(&p).map_err(|e| e.to_string())?; + Ok(ProbeResult { + seconds: s.duration_secs, + bitrate: s.bitrate_kbps, + sample_rate: s.sample_rate, + bit_depth: s.bit_depth, + bytes, + title: s.title, + }) + }) + .await + .map_err(|e| e.to_string())? +} + +/* -------------------------------------------------------------- identify -- */ + +/// Identify one record against the providers, on demand when its card is +/// opened — never a batch (decision 12). Deezer down the cleaned try-list, +/// MusicBrainz when Deezer's answer is absent or wrong; the crate holds the +/// whole flow (metadata::identify_album). Read-only. +#[tauri::command] +pub async fn import_identify( + state: State<'_, LibraryState>, + artist: String, + title: String, +) -> Result { + Ok(state.metadata.identify_album(&artist, &title).await) +} + +/// The artwork panel's data: reference + candidates with decoded sizes and +/// same-artwork dHash flags. `reference_url` is the identified candidate's +/// cover — the picker compares everything against the record it matched. +#[tauri::command] +pub async fn import_cover_report( + state: State<'_, LibraryState>, + artist: String, + title: String, + reference_url: String, +) -> Result { + Ok(state + .metadata + .cover_report(&artist, &title, &reference_url) + .await) +} + +/* ----------------------------------------------------------------- apply -- */ + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyAlbum { + pub rel: String, + /// The values the review settled on — whatever side won each field. + pub artist: String, + pub album: String, + pub year: Option, + /// 4c.3 — the picker's resolved departures for this record. Absent on a + /// record never opened (phase-1 behaviour applies unchanged). + #[serde(default)] + pub choices: Option, + /// A release the scan met as its parts (`Analord 01`…`Analord 11`, + /// `Disc 1`/`Disc 2`): the sub-folders under `rel` holding the audio, in + /// the order the review showed them. Empty for an ordinary record, whose + /// audio is in its own folder. + /// + /// An EXPLICIT list, never a recursive walk. Recursion is what let one + /// stray file at the top of an artist folder swallow 288 descendants and + /// duplicate 9 GB; the fold is decided in the review, where it is shown + /// and can be overruled, and apply only ever reads the folders named here. + /// An empty string entry means the record's own folder — a container that + /// holds loose audio beside its parts. + #[serde(default)] + pub parts: Vec, +} + +/// What the review resolved, as VALUES — the backend never re-derives a +/// side. File-tag writes ride TagWrite's Option semantics: None = leave the +/// copied file's field alone. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyChoices { + /// Provider cover to fetch and file as the album's art (and embed into + /// copies that carry none). None keeps the local extraction. + pub art_url: Option, + pub artist: Option, + pub album: Option, + pub year: Option, + pub label: Option, + pub genre: Option, + /// Chosen provider titles by track position (aligned to the sorted file + /// list, same order the scan showed); None entries keep yours. + pub track_titles: Option>>, + /// Which VERSION of the album the library keeps: `"theirs"` means the + /// copy cached in the audition pen replaces the user's files as the + /// thing that gets imported. Absent or `"yours"` = the source folder, + /// which is phase-1 behaviour unchanged. + pub audio: Option, + /// How many tracks the found copy was supposed to bring. The pen holding + /// fewer than this means the transfer never finished, and importing it + /// would file a truncated album — apply refuses instead. + pub audio_tracks: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyReport { + pub albums: usize, + pub tracks: usize, + pub bytes: u64, + /// Records nothing was written for, WITH their identity — the caller has + /// to know which ones so it does not mark them done. A flat + /// "{rel}: {reason}" string forced the UI to parse prose to find that out, + /// and it did not, so a refused record was banked as imported. + pub skipped: Vec, + /// Files of a previous version deleted because the review chose the found + /// copy instead. Reported so a replacement is never silent. + pub replaced: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkippedAlbum { + pub rel: String, + pub album: String, + pub reason: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ApplyProgress<'a> { + done: usize, + total: usize, + album: &'a str, +} + +/// Copy the chosen records into the managed root and register them. The +/// source tree is never written to; a failed album is reported and skipped, +/// never half-registered. +#[tauri::command] +pub async fn import_apply( + app: tauri::AppHandle, + state: State<'_, LibraryState>, + source_root: String, + albums: Vec, +) -> Result { + let cfg = config::load(&app); + let Some(dest_root) = cfg.library_dir.clone().map(PathBuf::from) else { + return Err("no library folder chosen yet".into()); + }; + let layout = config::library_layout(&cfg).to_string(); + let embed_max_edge = config::embed_art_max_edge(&cfg); + let replace_embedded = config::replace_embedded_art(&cfg); + let src_root = PathBuf::from(&source_root); + // Where a kept found-copy is moved FROM. The download root, not the + // library root: the pen lives beside the transfers that filled it. + let download_root = config::download_dir(&app, &cfg); + let library = state.library.clone(); + + // Chosen provider covers are fetched up front (network is async, the + // copy loop is blocking); a failed fetch falls back to local extraction + // rather than failing the album. + let mut chosen_art: std::collections::HashMap> = + std::collections::HashMap::new(); + for a in &albums { + if let Some(url) = a.choices.as_ref().and_then(|c| c.art_url.as_deref()) { + if let Some(bytes) = state.metadata.fetch_cover_bytes(url).await { + chosen_art.insert(a.rel.clone(), bytes); + } else { + log::warn!("import: chosen cover fetch failed for {}: {url}", a.rel); + } + } + } + + let emitter = app.clone(); + let total = albums.len(); + let report = tauri::async_runtime::spawn_blocking(move || { + let mut done_albums = 0usize; + let mut done_tracks = 0usize; + let mut done_bytes = 0u64; + let mut done_replaced = 0usize; + let mut skipped = Vec::new(); + + for (i, a) in albums.iter().enumerate() { + match apply_one( + &library, + &src_root, + &dest_root, + &download_root, + &layout, + a, + chosen_art.get(&a.rel).map(Vec::as_slice), + embed_max_edge, + replace_embedded, + ) { + Ok((t, b, r)) => { + done_albums += 1; + done_tracks += t; + done_bytes += b; + done_replaced += r; + } + Err(e) => skipped.push(SkippedAlbum { + rel: a.rel.clone(), + album: a.album.clone(), + reason: e, + }), + } + let _ = emitter.emit( + "import:apply", + ApplyProgress { done: i + 1, total, album: &a.album }, + ); + } + ApplyReport { + albums: done_albums, + tracks: done_tracks, + bytes: done_bytes, + skipped, + replaced: done_replaced, + } + }) + .await + .map_err(|e| e.to_string())?; + + let _ = app.emit("soulseek:library-changed", ()); + Ok(report) +} + +/// The audio files a record OWNS — the ones directly in its folder, and only +/// those. +/// +/// This must not recurse, and the difference is not cosmetic. The scan groups +/// files by their IMMEDIATE parent, so a folder holding both loose tracks and +/// album subfolders becomes one small record plus a record per subfolder. A +/// recursive collect here made that small record swallow the entire tree: +/// `Animal Collective` (one loose file, 42 album subfolders) copied all 288 +/// descendants, flattened, into `Animal Collective/2021 - Prester John/`, and +/// every one of those albums was ALSO imported separately — the same files +/// written twice, into two different places, under one 4.3 GB album row. +fn audio_files_in(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_file() && pipeline::is_audio_path(p)) + .collect() +} + +#[allow(clippy::too_many_arguments)] +fn apply_one( + library: &library::Library, + src_root: &Path, + dest_root: &Path, + download_root: &Path, + layout: &str, + a: &ApplyAlbum, + chosen_art: Option<&[u8]>, + embed_max_edge: u32, + replace_embedded: bool, +) -> Result<(usize, u64, usize), String> { + // The version the review settled on decides WHICH folder is the source. + // Everything downstream — layout, art, tags, registration — is identical + // either way; only the bytes come from somewhere else. + let take_theirs = a.choices.as_ref().and_then(|c| c.audio.as_deref()) == Some("theirs"); + let pen_dir = audition_dir(download_root, &a.rel); + let src_dir = if take_theirs { pen_dir.clone() } else { src_root.join(&a.rel) }; + + // The destination folder, from the SAME renderer downloads use — one + // layout vocabulary, not two. Its PATH is computed before the source is + // judged, because for a kept found-copy the destination is where the + // pen's tracks have ALREADY gone if this record was banked earlier — but + // it is not CREATED until this album is certain to be written. A record + // refused for a half-arrived pen used to leave an empty album folder in + // the library on every attempt. + let rendered = pipeline::render_layout(layout, &a.artist, &a.album, "", a.year); + let mut dest_dir = dest_root.to_path_buf(); + for seg in rendered.split(['/', '\\']) { + let seg = pipeline::safe_segment(seg); + if !seg.is_empty() { + dest_dir = dest_dir.join(seg); + } + } + + // A folded release gathers from its named parts, in part order, sorted + // within each part — NOT sorted across the whole set, which would + // interleave volume 2 track 1 between volume 1's tracks. The order has to + // match the one the review displayed, because `track_titles` is + // positional: disagree here and every title past the first part lands on + // the wrong file. A found copy overrides this — the pen is flat. + let mut files: Vec = if take_theirs || a.parts.is_empty() { + let mut f = audio_files_in(&src_dir); + f.sort(); + f + } else { + let mut out = Vec::new(); + for part in &a.parts { + let dir = if part.is_empty() { src_dir.clone() } else { src_dir.join(part) }; + let mut f = audio_files_in(&dir); + f.sort(); + out.append(&mut f); + } + out + }; + + // Re-applying a kept found-copy: "Save & finish later" banks records as + // it goes, and the final apply passes every record again. The first pass + // EMPTIED the pen into the library, so an absent pen is the success case, + // not a failure — read the destination instead and let the loop's + // same-name-same-size reuse turn the whole album into a no-op. + let mut src_dir = src_dir; + if take_theirs && files.is_empty() { + files = audio_files_in(&dest_dir); + files.sort(); + if files.is_empty() { + return Err("the cached copy is no longer in the pen — find it again".into()); + } + src_dir = dest_dir.clone(); + } + + if !src_dir.is_dir() { + return Err(if take_theirs { + "the cached copy is no longer in the pen — find it again".into() + } else { + String::from("source folder is gone") + }); + } + if files.is_empty() { + return Err("no audio files".into()); + } + // A half-arrived pen would file a truncated album under the record's own + // name — worse than not importing it, because it looks complete. Refuse + // and say the count; the transfer can finish and apply again. + if take_theirs && src_dir == pen_dir { + if let Some(want) = a.choices.as_ref().and_then(|c| c.audio_tracks) { + if files.len() < want { + return Err(format!( + "the found copy is still arriving — {} of {want} tracks cached. \ + It imports as soon as the rest land; \"Keep mine\" takes your copy instead", + files.len() + )); + } + } + } + + // Past every refusal: this album IS being written, so the folder earns + // its existence now. + std::fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?; + + // The album's art_path: the review's chosen provider cover when one was + // picked, else embedded art (front cover of the first file that has one) + // — the review already established that this is where real covers live. + let mut art_path: Option = None; + let mut art_sources: Vec> = Vec::new(); + if let Some(bytes) = chosen_art { + art_sources.push(bytes.to_vec()); + } else { + for f in files.iter().take(5) { + if let Ok(Some(bytes)) = tagger::extract_art(f) { + art_sources.push(bytes); + break; + } + } + } + // The master cover is the album folder's cover.jpg — filed into the + // DESTINATION before the audio is copied in beside it. + for bytes in art_sources.iter().take(1) { + if let Some(cover) = pipeline::write_album_cover_files(&dest_dir, bytes) { + art_path = Some(cover.to_string_lossy().into_owned()); + } + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let album_id = library + .upsert_album(&a.artist, &a.album, a.year, art_path.as_deref(), None, None, now) + .map_err(|e| e.to_string())?; + + // The chosen cover's embed copy, downscaled ONCE per album to the same + // bound the download pipeline applies (a 3000px scan must not add + // megabytes to every track). + let embed_art: Option> = + chosen_art.map(|b| pipeline::prepare_embed_art(b.to_vec(), embed_max_edge)); + + // Chosen titles are indexed against the sorted list the picker showed — + // YOUR files. Keeping the found copy replaces that list wholesale, so the + // titles only still line up if the two albums have the same length; at a + // different count position i means a different song and the write would + // mislabel every track after the first divergence. + let track_titles = match a.choices.as_ref().and_then(|c| c.track_titles.as_ref()) { + Some(t) if take_theirs && t.len() != files.len() => { + log::warn!( + "import: {} keeps the found copy ({} tracks) but its chosen titles are {} long — titles left alone", + a.rel, + files.len(), + t.len() + ); + None + } + other => other, + }; + + let mut tracks = 0usize; + let mut bytes_total = 0u64; + // Every destination path this run wrote or reused, lower-cased — the set + // the replacement pass below must never touch. + let mut kept: std::collections::HashSet = std::collections::HashSet::new(); + for (i, src) in files.iter().enumerate() { + let name = src.file_name().unwrap_or_default().to_string_lossy().into_owned(); + // File names are copied verbatim — his call, and what the pipeline + // itself does. Collisions get " (n)" like organize_download — but a + // destination file with the SAME name and size is this very track + // from an earlier apply: reuse it, so re-applying is idempotent + // rather than a " (2)" duplicate factory. + let mut dest = dest_dir.join(pipeline::safe_component(&name)); + let src_len = std::fs::metadata(src).map(|m| m.len()).unwrap_or(0); + // "Same name, same size" means an EARLIER RUN already filed this very + // track — but only if this run has not just written that path itself. + // A folded release flattens its parts into one folder, and parts number + // from 01 each: `Disc 1/01.mp3` and `Disc 2/01.mp3` collide, and often + // at the same length, so the idempotency rule read the second one as + // already-imported and dropped it while still counting it. Two + // different files in one run are a collision, never a reuse. + let already = dest.exists() + && !kept.contains(&dest.to_string_lossy().to_lowercase()) + && std::fs::metadata(&dest).map(|m| m.len()).unwrap_or(u64::MAX) == src_len; + if dest.exists() && !already { + let stem = dest.file_stem().unwrap_or_default().to_string_lossy().into_owned(); + let ext = dest + .extension() + .map(|e| format!(".{}", e.to_string_lossy())) + .unwrap_or_default(); + for n in 2..100 { + let candidate = dest_dir.join(format!("{stem} ({n}){ext}")); + if !candidate.exists() { + dest = candidate; + break; + } + } + } + if !already { + if take_theirs { + // Keeping the found copy is a MOVE — the whole album already + // came down the wire once, and the pen is a cache, not a + // second library. Rename across volumes fails (the pen sits + // under the download root, the library may not), so fall back + // to copy-then-drop rather than leaving the album in limbo. + if std::fs::rename(src, &dest).is_err() { + std::fs::copy(src, &dest).map_err(|e| format!("move {name}: {e}"))?; + let _ = std::fs::remove_file(src); + } + } else { + std::fs::copy(src, &dest).map_err(|e| format!("copy {name}: {e}"))?; + } + } + + // The review's chosen values land on the COPY, never the source — + // TagWrite's Option semantics: None leaves the field as it is. A + // chosen cover embeds at the same bounded size downloads use, and + // replaces an existing picture when config says so — an explicit + // pick in the review is the strongest consent that config models. + if let Some(ch) = &a.choices { + let write = tagger::TagWrite { + artist: ch.artist.clone(), + album_artist: None, + album: ch.album.clone(), + title: track_titles.and_then(|t| t.get(i).cloned().flatten()), + track_no: None, + year: ch.year, + label: ch.label.clone(), + genre: ch.genre.clone(), + }; + let embed = embed_art.as_deref(); + if let Err(e) = tagger::write_tags( + &dest, + &write, + embed, + replace_embedded && embed.is_some(), + ) { + log::warn!("import: tagging {} failed: {e}", dest.display()); + } + } + + let s = tagger::read_summary(&dest).unwrap_or_default(); + let size = std::fs::metadata(&dest).map(|m| m.len()).ok(); + let title = s + .title + .clone() + .unwrap_or_else(|| dest.file_stem().unwrap_or_default().to_string_lossy().into_owned()); + let artist = s.artist.clone().unwrap_or_else(|| a.artist.clone()); + library + .upsert_track( + album_id, + s.track_no, + &title, + &artist, + s.duration_secs, + s.bitrate_kbps, + s.sample_rate, + s.bit_depth, + size, + &dest.to_string_lossy(), + now, + ) + .map_err(|e| e.to_string())?; + kept.insert(dest.to_string_lossy().to_lowercase()); + tracks += 1; + bytes_total += size.unwrap_or(0); + } + + // Replacing the version means the folder holds ONE version afterwards. + // A record banked as "yours" and then flipped to "theirs" inside the + // retention window (rule 5 promises that flip is free) would otherwise + // leave both copies side by side, both registered. The tile says + // "Replace the album with this copy", so the copy it replaced goes. + // + // Strictly bounded, and each bound matters: only rows THIS album owns, + // only files sitting in this album's own destination folder, and never a + // file this run just wrote or reused. The count is reported, never silent. + let mut replaced = 0usize; + if take_theirs { + let dest_key = dest_dir.to_string_lossy().to_lowercase(); + let mut stale = Vec::new(); + for t in library.album_tracks(album_id).unwrap_or_default() { + let p = PathBuf::from(&t.path); + let same_folder = p + .parent() + .map(|d| d.to_string_lossy().to_lowercase() == dest_key) + .unwrap_or(false); + if !same_folder || kept.contains(&t.path.to_lowercase()) { + continue; + } + match std::fs::remove_file(&p) { + Ok(()) => replaced += 1, + // Already gone is the same outcome; a locked file is not, and + // dropping the row for a file still on disk would leave an + // orphan the next scan re-adds. Keep the row, say so. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => replaced += 1, + Err(e) => { + log::warn!("import: could not remove the replaced {}: {e}", p.display()); + continue; + } + } + stale.push(t.id); + } + if !stale.is_empty() { + if let Err(e) = library.remove_tracks(&stale) { + log::warn!("import: {} replaced but its old rows survived: {e}", a.rel); + } + log::info!( + "import: {} now holds the found copy — {} file(s) of the previous version removed", + a.rel, + replaced + ); + } + } + + // The pen held a cache, and the cache has been spent: what is left is the + // peer's stray .m3u/.nfo and an empty folder. Only ever the record's OWN + // audition directory, never the pen root. + if take_theirs && src_dir == pen_dir { + if let Err(e) = std::fs::remove_dir_all(&pen_dir) { + log::warn!("import: could not clear the audition pen {}: {e}", pen_dir.display()); + } + } + Ok((tracks, bytes_total, replaced)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A record laid out the way apply sees one: the user's folder, the + /// audition pen beside the download root, and a library root to file + /// into. Files carry distinct bytes so "which version landed" is a + /// content question, not a guess. + struct Bed { + _tmp: tempfile::TempDir, + src_root: PathBuf, + dest_root: PathBuf, + download_root: PathBuf, + library: library::Library, + rel: String, + } + + fn bed(pen_tracks: usize) -> Bed { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let rel = "Weezer/Pinkerton".to_string(); + let src = root.join("source").join(&rel); + std::fs::create_dir_all(&src).unwrap(); + for n in 1..=2 { + std::fs::write(src.join(format!("{n:02} track.mp3")), format!("mine {n}")).unwrap(); + } + let download_root = root.join("downloads"); + let pen = audition_dir(&download_root, &rel); + std::fs::create_dir_all(&pen).unwrap(); + for n in 1..=pen_tracks { + std::fs::write(pen.join(format!("{n:02} track.flac")), format!("theirs {n}")).unwrap(); + } + let library = library::Library::open(&root.join("library.db")).unwrap(); + Bed { + src_root: root.join("source"), + dest_root: root.join("library"), + download_root, + library, + rel, + _tmp: tmp, + } + } + + fn choices(audio: &str, want: Option) -> ApplyChoices { + ApplyChoices { + art_url: None, + artist: None, + album: None, + year: None, + label: None, + genre: None, + track_titles: None, + audio: Some(audio.into()), + audio_tracks: want, + } + } + + fn album(b: &Bed, ch: Option) -> ApplyAlbum { + ApplyAlbum { + rel: b.rel.clone(), + artist: "Weezer".into(), + album: "Pinkerton".into(), + year: Some(1996), + choices: ch, + parts: Vec::new(), + } + } + + fn run(b: &Bed, a: &ApplyAlbum) -> Result<(usize, u64, usize), String> { + apply_one( + &b.library, + &b.src_root, + &b.dest_root, + &b.download_root, + "{artist}/{album}", + a, + None, + 1500, + false, + ) + } + + fn landed(b: &Bed) -> Vec<(String, String)> { + let dir = b.dest_root.join("Weezer").join("Pinkerton"); + let mut out: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| { + ( + e.file_name().to_string_lossy().into_owned(), + std::fs::read_to_string(e.path()).unwrap_or_default(), + ) + }) + .collect(); + out.sort(); + out + } + + #[test] + fn keeping_theirs_moves_the_pen_and_leaves_the_source_alone() { + let b = bed(2); + let a = album(&b, Some(choices("theirs", Some(2)))); + let (tracks, _, _) = run(&b, &a).unwrap(); + + assert_eq!(tracks, 2); + let files = landed(&b); + assert_eq!(files.len(), 2, "the found copy's tracks are what landed"); + assert!(files.iter().all(|(n, c)| n.ends_with(".flac") && c.starts_with("theirs"))); + // A move, not a copy: the pen is spent. + assert!(!audition_dir(&b.download_root, &b.rel).exists()); + // His folder is exactly as it was. + assert_eq!( + std::fs::read_dir(b.src_root.join(&b.rel)).unwrap().count(), + 2 + ); + } + + #[test] + fn re_applying_a_kept_copy_is_a_no_op_not_a_failure() { + let b = bed(2); + let a = album(&b, Some(choices("theirs", Some(2)))); + run(&b, &a).unwrap(); + // "Save & finish later" banks records as it goes; the final apply + // passes every record again, by which time the pen is empty. + let (tracks, _, _) = run(&b, &a).expect("the second pass must not fail"); + assert_eq!(tracks, 2); + assert_eq!(landed(&b).len(), 2, "no ' (2)' duplicates"); + } + + #[test] + fn a_half_arrived_pen_is_refused_rather_than_filed_short() { + let b = bed(1); + let a = album(&b, Some(choices("theirs", Some(12)))); + let err = run(&b, &a).unwrap_err(); + assert!(err.contains("1 of 12"), "{err}"); + assert!(err.contains("Keep mine"), "the way out is in the reason: {err}"); + // Refused means nothing was written — not even the folder. Creating it + // up front left an empty album in the library on every attempt. + assert!( + !b.dest_root.join("Weezer").join("Pinkerton").exists(), + "a refusal must not leave an empty album folder behind" + ); + } + + #[test] + fn flipping_to_theirs_replaces_the_copy_it_supersedes() { + let b = bed(2); + // First pass banks the record as HIS copy — the mp3s land and register. + run(&b, &album(&b, Some(choices("yours", None)))).unwrap(); + assert_eq!(landed(&b).len(), 2); + + // Then the mind changes inside the retention window: rule 5 says that + // flip is free, and "replace" has to mean the folder holds ONE version. + let (tracks, _, replaced) = run(&b, &album(&b, Some(choices("theirs", Some(2))))).unwrap(); + + assert_eq!(tracks, 2); + assert_eq!(replaced, 2, "both superseded files are reported, not silent"); + let files = landed(&b); + assert_eq!(files.len(), 2, "one version in the folder, not both"); + assert!(files.iter().all(|(n, c)| n.ends_with(".flac") && c.starts_with("theirs"))); + // And the rows follow the files. + let album_id = b.library.upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0).unwrap(); + let rows = b.library.album_tracks(album_id).unwrap(); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|t| t.path.ends_with(".flac"))); + // His source folder is STILL untouched — that rule never bends. + assert_eq!(std::fs::read_dir(b.src_root.join(&b.rel)).unwrap().count(), 2); + } + + #[test] + fn a_replacement_never_reaches_outside_the_album_folder() { + let b = bed(2); + // A file registered to the same album but living somewhere else (a + // download filed before the library root moved, say) must survive. + let elsewhere = b.dest_root.join("Elsewhere"); + std::fs::create_dir_all(&elsewhere).unwrap(); + let stray = elsewhere.join("stray.mp3"); + std::fs::write(&stray, "stray").unwrap(); + let album_id = b + .library + .upsert_album("Weezer", "Pinkerton", Some(1996), None, None, None, 0) + .unwrap(); + b.library + .upsert_track(album_id, None, "Stray", "Weezer", None, None, None, None, Some(5), + &stray.to_string_lossy(), 0) + .unwrap(); + + let (_, _, replaced) = run(&b, &album(&b, Some(choices("theirs", Some(2))))).unwrap(); + + assert_eq!(replaced, 0, "nothing outside the album's own folder is touched"); + assert!(stray.exists()); + assert!(b.library.album_tracks(album_id).unwrap().iter().any(|t| t.path.ends_with("stray.mp3"))); + } + + #[test] + fn a_record_takes_its_own_folder_and_not_the_tree_below_it() { + // The shape that broke it: an artist folder holding ONE stray track + // plus the albums themselves. The scan files that as a 1-track record; + // apply used to recurse and copy the whole artist in on top of it. + let b = bed(2); + let artist_dir = b.src_root.join("Animal Collective"); + std::fs::create_dir_all(artist_dir.join("2003 - Campfire Songs")).unwrap(); + std::fs::create_dir_all(artist_dir.join("2005 - Feels")).unwrap(); + std::fs::write(artist_dir.join("01 Prester John.mp3"), "stray").unwrap(); + for (d, n) in [("2003 - Campfire Songs", 4), ("2005 - Feels", 9)] { + for i in 1..=n { + std::fs::write(artist_dir.join(d).join(format!("{i:02} track.flac")), "x").unwrap(); + } + } + + let a = ApplyAlbum { + rel: "Animal Collective".into(), + artist: "Animal Collective".into(), + album: "Prester John".into(), + year: Some(2021), + choices: None, + parts: Vec::new(), + }; + let (tracks, _, _) = run(&b, &a).unwrap(); + + assert_eq!(tracks, 1, "the record owns ONE file; the 13 below it belong to other records"); + let dest = b.dest_root.join("Animal Collective").join("Prester John"); + let landed: Vec<_> = std::fs::read_dir(&dest) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(landed, vec!["01 Prester John.mp3"], "got: {landed:?}"); + } + + /// The other half of that rule. A release the scan met as its parts — + /// Analord's eleven volumes, a two-disc set — is folded back into one + /// record by the review, which hands apply an explicit part list. Apply + /// must take those folders, IN THAT ORDER, and still take nothing it was + /// not given: a sibling folder that is somebody else's record stays put. + #[test] + fn a_folded_release_takes_its_parts_in_order_and_no_sibling() { + let b = bed(0); + let artist_dir = b.src_root.join("AFX"); + for (vol, n) in [("Analord 01", 3), ("Analord 02", 2)] { + std::fs::create_dir_all(artist_dir.join("Analord Volume 1-11").join(vol)).unwrap(); + for i in 1..=n { + std::fs::write( + artist_dir.join("Analord Volume 1-11").join(vol).join(format!("{i:02}.mp3")), + format!("{vol} {i}"), + ) + .unwrap(); + } + } + // Not part of the fold, and not named in `parts`: it must be untouched. + std::fs::create_dir_all(artist_dir.join("Hangable Auto Bulb")).unwrap(); + std::fs::write(artist_dir.join("Hangable Auto Bulb").join("01.mp3"), "other").unwrap(); + + let a = ApplyAlbum { + rel: "AFX/Analord Volume 1-11".into(), + artist: "AFX".into(), + album: "Analord Volume 1-11".into(), + year: Some(2005), + choices: None, + parts: vec!["Analord 01".into(), "Analord 02".into()], + }; + let (tracks, _, _) = run(&b, &a).unwrap(); + assert_eq!(tracks, 5, "three from volume 1 and two from volume 2"); + + let dest = b.dest_root.join("AFX").join("Analord Volume 1-11"); + let mut landed: Vec<_> = std::fs::read_dir(&dest) + .unwrap() + .flatten() + .map(|e| std::fs::read_to_string(e.path()).unwrap()) + .collect(); + landed.sort(); + assert_eq!( + landed, + vec!["Analord 01 1", "Analord 01 2", "Analord 01 3", "Analord 02 1", "Analord 02 2"], + "every part's audio, and only the parts named", + ); + assert!( + !b.dest_root.join("AFX").join("Hangable Auto Bulb").exists(), + "a sibling folder is another record — the fold must not reach it", + ); + } + + #[test] + fn keeping_yours_still_copies_the_source() { + let b = bed(2); + let a = album(&b, Some(choices("yours", None))); + let (tracks, _, _) = run(&b, &a).unwrap(); + + assert_eq!(tracks, 2); + let files = landed(&b); + assert!(files.iter().all(|(n, c)| n.ends_with(".mp3") && c.starts_with("mine"))); + // Unclaimed: the pen keeps the version he passed over, so a + // mind-change inside the retention window is a free flip. + assert!(audition_dir(&b.download_root, &b.rel).is_dir()); + assert_eq!(std::fs::read_dir(b.src_root.join(&b.rel)).unwrap().count(), 2); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 11a69e1..e150265 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod config; mod cover_logo; mod diagnostics; mod hero_crop; +mod import; mod hero_detector; mod loudness; mod pipeline; @@ -99,6 +100,19 @@ pub fn run() { commands::add_to_playlist, commands::remove_from_playlist, commands::catalog_search, + import::import_scan, + import::import_session_get, + import::import_session_set, + import::import_session_clear, + import::import_get_dest, + import::import_set_dest, + import::import_apply, + import::import_identify, + import::import_cover_report, + import::import_local_art, + import::import_audition, + import::import_probe_file, + pipeline::rescan_library, commands::catalog_artist, commands::catalog_artist_art, commands::catalog_artist_logos, diff --git a/src-tauri/src/pipeline.rs b/src-tauri/src/pipeline.rs index e86aa5a..b943bb7 100644 --- a/src-tauri/src/pipeline.rs +++ b/src-tauri/src/pipeline.rs @@ -11,7 +11,11 @@ use library::Library; use metadata::{AlbumMeta, MetadataClient}; use tagger::{TagSummary, TagWrite}; -const AUDIO_EXTS: [&str; 8] = ["flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma"]; +// 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, @@ -20,7 +24,7 @@ pub struct LibraryState { /// identity warmer only works while this has been quiet, so background /// warmth never queues ahead of a page the user is actually looking at. pub(crate) catalog_activity: std::sync::atomic::AtomicU64, - art_dir: PathBuf, + pub(crate) art_dir: PathBuf, /// (artist, album) lowercase → resolved metadata, deduping MB lookups. album_cache: tokio::sync::Mutex>>, /// Serializes art fetch per album key. @@ -223,13 +227,106 @@ pub fn init(app: &tauri::AppHandle) -> Result<(), String> { }) .await; // Old staging leftovers: folders emptied by organizing are removed - // on the fly now, but sweep empties left behind by earlier runs. + // 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\\` 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 { @@ -253,6 +350,25 @@ fn downloads_dir(app: &tauri::AppHandle) -> PathBuf { 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::(); + 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); @@ -330,7 +446,7 @@ async fn repair_artist_portraits(app: &tauri::AppHandle) { } } -fn collect_audio_files(dir: &Path, out: &mut Vec, depth: u8) { +pub(crate) fn collect_audio_files(dir: &Path, out: &mut Vec, depth: u8) { if depth > 6 { return; } @@ -340,6 +456,12 @@ fn collect_audio_files(dir: &Path, out: &mut Vec, depth: u8) { 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() @@ -488,14 +610,31 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path } // 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(), - false, + remote_path.is_some() && crate::config::write_folder_cover(&cfg), ) .await; @@ -505,7 +644,6 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path // whatever picture the uploader embedded (config-gated) so the file, the // library, and the catalog all show the same artwork. Startup-scanned // files are never stripped: they only gain art when they have none. - let cfg = crate::config::load(app); let replace_embedded = remote_path.is_some() && crate::config::replace_embedded_art(&cfg); let embed_max_edge = crate::config::embed_art_max_edge(&cfg); let art_bytes = match (&art_path, replace_embedded || !summary.has_art) { @@ -522,6 +660,8 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path 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(); @@ -551,17 +691,9 @@ pub async fn process_file(app: &tauri::AppHandle, local_path: &Path, remote_path local_path.to_path_buf() }; - // 6b. The canonical cover also becomes the album folder's cover.jpg / - // folder.jpg, so Explorer and any other player show the artwork the app - // does — stale uploader art under those names is replaced. - if remote_path.is_some() && crate::config::write_folder_cover(&cfg) { - if let (Some(album_dir), Some(art)) = (library_path.parent(), &art_path) { - let (dir, art) = (album_dir.to_path_buf(), art.clone()); - let _ = - tauri::async_runtime::spawn_blocking(move || write_album_folder_cover(&dir, &art)) - .await; - } - } + // (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. { @@ -704,9 +836,11 @@ pub fn is_audio_path(path: &Path) -> bool { /// Move a newly completed download into one album directory under the managed /// root. The filename remains intact, preserving track numbering supplied by /// the source. If a matching file exists, retain both files with a suffix. -fn organize_download( +/// 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, - source: &Path, artist: &str, album: &str, release_date: Option<&str>, @@ -741,6 +875,18 @@ fn organize_download( 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, +) -> 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(); } @@ -852,7 +998,7 @@ fn cleanup_staging(app: &tauri::AppHandle, staging: &Path, album_dir: &Path) { } } -fn render_layout(layout: &str, artist: &str, album: &str, date: &str, year: Option) -> String { +pub(crate) fn render_layout(layout: &str, artist: &str, album: &str, date: &str, year: Option) -> String { layout .replace("{artist}", &clean_chars(artist)) .replace("{album}", &clean_chars(album)) @@ -893,14 +1039,14 @@ fn clean_chars(value: &str) -> String { /// One path segment: cleaned and trimmed; Windows rejects trailing dots and /// spaces. May return an empty string (caller decides the fallback). -fn safe_segment(value: &str) -> String { +pub(crate) fn safe_segment(value: &str) -> String { clean_chars(value) .trim() .trim_end_matches([' ', '.']) .to_string() } -fn safe_component(value: &str) -> String { +pub(crate) fn safe_component(value: &str) -> String { let trimmed = safe_segment(value); if trimmed.is_empty() { "Unknown Album".into() @@ -911,13 +1057,20 @@ fn safe_component(value: &str) -> String { /// `/.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>, - force: bool, + replace: bool, ) -> Option { let key: String = format!("{artist} - {album}") .to_lowercase() @@ -928,8 +1081,6 @@ pub async fn fetch_art_cached( c => c, }) .collect(); - let target = state.art_dir.join(format!("{key}.jpg")); - let lock = { let mut locks = state.art_locks.lock().unwrap(); locks @@ -939,20 +1090,26 @@ pub async fn fetch_art_cached( }; let _guard = lock.lock().await; - if target.exists() && !force { - return Some(target); + 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::write(&target, &bytes).ok()?; + std::fs::create_dir_all(album_dir).ok()?; + let cover = write_album_cover_files(album_dir, &bytes)?; log::info!( "album art saved: {} ({} KB)", - target.display(), + cover.display(), bytes.len() / 1024 ); - Some(target) + Some(cover) } /// Downscale the canonical cover for embedding into audio files. `max_edge` @@ -960,7 +1117,7 @@ pub async fn fetch_art_cached( /// passes through byte-for-byte. Oversized covers are Lanczos3-resized and /// re-encoded as JPEG 92 so a 3000px original doesn't add megabytes to every /// track — the full-resolution file stays in the art cache and album folder. -fn prepare_embed_art(bytes: Vec, max_edge: u32) -> Vec { +pub(crate) fn prepare_embed_art(bytes: Vec, max_edge: u32) -> Vec { if max_edge == 0 { return bytes; } @@ -987,14 +1144,21 @@ 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 { + let bytes = bytes.to_vec(); let jpeg = if bytes.starts_with(b"\x89PNG") { let Ok(img) = image::load_from_memory(&bytes) else { - return; + 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; + return None; } output.into_inner() } else { @@ -1037,6 +1201,8 @@ fn write_album_folder_cover(album_dir: &Path, art_file: &Path) { 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 @@ -1060,8 +1226,21 @@ pub async fn refresh_album_art(app: &tauri::AppHandle, album_id: i64) -> Result< 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(), @@ -1081,15 +1260,10 @@ pub async fn refresh_album_art(app: &tauri::AppHandle, album_id: i64) -> Result< .map_err(|e| e.to_string())?; } - // Re-embed into every track, replacing the old picture, and refresh the - // album folder's cover.jpg/folder.jpg so every surface agrees again. + // 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 lib = state.library.clone(); - let tracks = tauri::async_runtime::spawn_blocking(move || lib.album_tracks(album_id)) - .await - .map_err(|e| e.to_string())? - .map_err(|e| e.to_string())?; let bytes = std::fs::read(&art_path).map_err(|e| e.to_string())?; let embed = { let b = bytes.clone(); diff --git a/src/App.svelte b/src/App.svelte index 8d82c79..83cd6c2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -4,6 +4,7 @@ import PlayerBar from "./components/PlayerBar.svelte"; import NowPlaying from "./components/NowPlaying.svelte"; import ArtworkViewer from "./components/ArtworkViewer.svelte"; + import ImportWindow from "./components/ImportWindow.svelte"; import LoginModal from "./components/LoginModal.svelte"; import PreviewOverlay from "./components/PreviewOverlay.svelte"; import DebugOverlay from "./components/DebugOverlay.svelte"; @@ -21,6 +22,7 @@ import CatalogAlbum from "./views/CatalogAlbum.svelte"; import CatalogDiscography from "./views/CatalogDiscography.svelte"; import FollowToast from "./components/FollowToast.svelte"; + import ImportToast from "./components/ImportToast.svelte"; import { ui, initEvents, @@ -122,8 +124,10 @@ + + diff --git a/src/components/ImportPicker.svelte b/src/components/ImportPicker.svelte new file mode 100644 index 0000000..bf29a81 --- /dev/null +++ b/src/components/ImportPicker.svelte @@ -0,0 +1,1425 @@ + + +
+
+ {#if yoursSrc}{/if} +
+
+
{index + 1} of {total} + {bucket} + + {#if choice?.deferred && !choice?.seen} + skipped for now + {/if}
+

{a.title}

+ {artist} · {a.tracks.length} tracks · {fmtMB(a.bytes)} · {ext} {a.avgBitrate ?? "—"}k +
+
+
{gainLabel}
+
+
+
+ +
+
Artwork + + {#if !idn || idn.state === "loading"}looking the record up… + {:else if idn.state === "error"}the lookup failed + {:else if measuring}found it — measuring what each service has… + {:else if measureFailed}found it, but the sizes could not be measured + {:else if matchHasNoArt}the matching release carries no artwork + {:else if !covers.length}no service has this record + {:else}{@const best = Math.max(...covers.map((x) => x.px || 0))} + {covers.length} service{covers.length === 1 ? "" : "s"} answered{#if !yours} — you have none{:else if (yours?.w ?? 0) >= best}, and none beats your {yours.w}px — nothing to gain here{:else} — yours is {yours.w}px, the best of these is {best}px{/if} + {/if}
+
+
onChoose({ art: "yours" })} + onkeydown={(e) => e.key === "Enter" && onChoose({ art: "yours" })}> +
{ e.stopPropagation(); openFs(fsPick); }} + role="presentation"> + {#if yoursSrc} + {:else}
no artwork in the folder,
and nothing embedded
{/if} +
+
{yours ? `${yours.w} × ${yours.h}` : "none"} + yours{yoursWins ? " · already the best" : ""}
+ {#if yours}
{yours.kind === "embedded" ? `embedded in ${yours.from}` : yours.from}
{/if} + {#if yours?.kind === "derived"} +
the only picture in the folder — nothing is + embedded, so this thumbnail is all you have
+ {:else if yours?.derivedIgnored} +
{yours.derivedIgnored} Media Player thumbnail{yours.derivedIgnored === 1 ? "" : "s"} in the folder, ignored
+ {/if} +
{resolvedArt === "yours" ? "✓ using this" : "use this"}
+
+ {#each covers as c, n (c.url)} + {@const smaller = yours && c.px && c.px < yours.w} + {@const wrongTitle = c.title && norm2(c.title) !== norm2(a.title)} +
onChoose({ art: c.provider })} + onkeydown={(e) => e.key === "Enter" && onChoose({ art: c.provider })}> +
{ e.stopPropagation(); openFs(n + 1); }} + role="presentation"> +
+
{c.px + ? `${c.px} × ${c.realH ?? c.px}` + : measuring ? "measuring…" : "size unknown"} + {c.provider}{smaller ? " · smaller than yours" : ""}
+ {#if wrongTitle}
“{c.title}”
{/if} +
{resolvedArt === c.provider ? "✓ using this" : "use this"}
+
+ {/each} + + {#if idn?.state === "done" && !covers.length} +
+
{matchHasNoArt + ? "the matching release has no artwork on file" + : "no service has this record,"}{#if !matchHasNoArt}
so there is nothing to compare{/if}
+
Deezer · Apple · Cover Art Archive
+
+ {:else if !idn || idn.state === "loading"} +
+
looking this record up…
+
Deezer · Apple · Cover Art Archive
+
+ {/if} +
+
+ +
+
Is this the same record?
+ {#if !idn || idn.state === "loading"} +
Asking Deezer, then MusicBrainz when it has no answer…
+ {:else if idn.state === "error"} +
The lookup failed — {idn.error}
+ {:else} +
+ {#each ev.items as it} + {it.cls === "ok" ? "✓" : it.cls === "no" ? "✕" : "!"} {it.text} + {/each} + {#if candidate}{candidate.source} {String(candidate.id).replace(/^mb:/, "#")}{/if} +
+
{@html verdictLine[1]}
+ {/if} +
+ +
+
Audio — the version you keep + {#if !audition}whole album · one choice, like the artwork + {:else if audition.status === "searching"}searching the network… + {:else if audition.status === "none"}nothing on the network answered + {:else if audition.status === "failed"}the transfer failed{audition.error ? ` — ${audition.error}` : ""} + {:else if audition.status === "downloading"}{live?.stalled + ? "the transfer went quiet" + : "found a copy — fetching track 1 first, so the comparison can start"} + {:else if audition.status === "complete"}the whole copy is cached — keeping it is instant + {:else}{audition.arrived ?? 1} of {audition.ids?.length ?? "…"} tracks cached · the rest keep arriving + {/if} + the lit version is what gets imported
+ + {#if !audition || audition.status === "none" || audition.status === "failed"} +
onChoose({ audio: "yours" })} + onkeydown={(e) => e.key === "Enter" && onChoose({ audio: "yours" })}> + +
Yours{ext} {a.avgBitrate ?? "—"}k · {a.tracks.length} tracks · {fmtMB(a.bytes)}
+
{#each (peaksYours ?? waveFloor().map((b) => 0)) as v, i (i)}{/each}
+
{a.tracks[0]?.seconds ? mmss(a.tracks[0].seconds) : "—"}
+
+
+ + {audition?.status === "none" + ? "No peer offered this album just now — worth asking again another day." + : audition?.status === "failed" + ? "The peer stopped answering — try the search again." + : "Searches Soulseek for this whole album; the best copy found downloads into the cache so you hear and compare the real thing before choosing."} +
+ {:else if audition.status === "searching"} +
+ +
Searching…asking the network for this album
+
{#each waveFloor() as b (b.i)}{/each}
+
—
+
+ {:else} + {@const au = audition} + {@const theirsReady = !!au.track1Path} +
+
onChoose({ audio: "yours" })} + onkeydown={(e) => e.key === "Enter" && onChoose({ audio: "yours" })}> + +
Yours{ext} {a.avgBitrate ?? "—"}k · {a.tracks.length} tracks · {fmtMB(a.bytes)}
+
{#each (peaksYours ?? []) as v, i (i)}{/each}
+
{a.tracks[0]?.seconds ? mmss(a.tracks[0].seconds) : "—"}
+
{resolvedAudio === "yours" ? "✓ keeping this" : "keep this"}
+
+ +
theirsReady && onChoose({ audio: "theirs" })} + onkeydown={(e) => e.key === "Enter" && theirsReady && onChoose({ audio: "theirs" })}> + +
+ {au.candidate.folderLeaf} + {au.probe + ? `${(au.candidate.files[0]?.name ?? "").split(".").pop().toUpperCase()} ${au.probe.bitrate ?? "—"}k${au.probe.bitDepth ? ` · ${au.probe.bitDepth}-bit` : ""} · measured` + : theirsReady + ? `${au.candidate.avgBitrate ? au.candidate.avgBitrate + "k · " : ""}${au.candidate.trackCount} tracks · ${fmtMB(au.candidate.bytes)} · ${au.candidate.username}` + : au.candidate.username} +
+ {#if theirsReady && peaksTheirs} +
{#each peaksTheirs as v, i (i)}{/each}
+ {:else if theirsReady} +
{#each waveFloor() as b (b.i)}{/each}
+ {:else} +
+ {arrivalLine(live, au)} + + + +
+ {/if} +
{au.probe?.seconds ? mmss(au.probe.seconds) : "—"}
+
{resolvedAudio === "theirs" + ? "✓ keeping this" + : theirsReady ? "keep this" : live?.stalled ? "stalled" : "arriving…"}
+
+
+
+ + {#if !theirsReady && live?.stalled} + Nothing has arrived for {Math.round(live.quietMs / 1000)}s. + The peer may have gone offline or dropped the queue. You can wait, search + again, or keep your own copy — nothing is lost either way. + {:else if !theirsReady} + Track 1 comes first on purpose: the comparison can start before the rest of + the album is down. + {:else if au.probe?.seconds && a.tracks[0]?.seconds} + {@const d = Math.abs(a.tracks[0].seconds - au.probe.seconds)} + {#if d <= 2} + Ready to compare. + Track 1 is the same length in both ({mmss(au.probe.seconds)}) — the strongest signal it is the same recording. The waveforms share one scale, so shape and loudness compare honestly.{#if au.status !== "complete"} The other {Math.max(0, (au.ids?.length ?? 0) - (au.arrived ?? 0))} tracks are still coming; keeping this version waits for them.{/if} + {:else} + Track 1 differs by {d}s — different recording, different edit, or a different record. Listen before keeping it. + {/if} + {:else if au.candidate.countMatch === false} + {au.candidate.trackCount} tracks against your {a.tracks.length} — this copy may be a different edition or incomplete. + {:else} + The whole album downloads into the cache — if you keep it, nothing is fetched twice; if you keep yours, the sweep reclaims it in a week. + {/if} +
+ {/if} +
+ +{#if candidate} +
+
Track titles {a.tracks.length} yours · {candidate.tracklist.length} theirs + pick a side per track
+ {#if a.tracks.length !== candidate.tracklist.length || slipped} +
+ {#if a.tracks.length !== candidate.tracklist.length}Different track counts — {a.tracks.length} against {candidate.tracklist.length}.{/if} + {#if slipped}{slipped} track{slipped === 1 ? "" : "s"} {slipped === 1 ? "does" : "do"} not match on length.{/if} + These are paired by position because the provider sends no track + numbers, so the rows below may be lined up against the wrong songs. +
+ {:else} +
Every track pairs with one of the same length, so the rows below line up.
+ {/if} +
+
+
yours
+
{candidate.source}
+
+ {#each trackPairs as p (p.n)} +
{p.n + 1}
+ + +
{p.their ? (p.aligned ? mmss(p.dT) : `${p.drift ?? "?"}s off`) : ""}
+ {/each} +
+
+ + + {#if trackChanges}{trackChanges} title{trackChanges === 1 ? "" : "s"} will be replaced. + {:else}Keeping all of your titles.{/if} +
+
+ +
+
Tags pick a side per field + the lit one is what gets written
+
+
yours
+
{candidate.source}
+ {#each tagPairs as [k, mine, theirs] (k)} + {@const same = mine && theirs && norm2(mine) === norm2(theirs)} +
{k}
+ + + {/each} +
+
+{:else if idn?.state === "done"} +
+
Tags
+
This record has not been matched, so there is nothing to choose + between. What your files say is in the summary below.
+
+{/if} + +
+
What gets imported + {changeCount ? `${changeCount} change${changeCount === 1 ? "" : "s"} from what you have` : "everything of yours, unchanged"} + {#if choice?.seen}{/if}
+
+
Artwork
+
+ {resolvedArt === "yours" + ? (yours ? `yours · ${yours.w} × ${yours.h}` : "none — a cover will be generated") + : `${resolvedArt} · ${covers.find((c) => c.provider === resolvedArt)?.px ?? "?"}px`}
+ {#each tagPairs as [k, mine, theirs] (k)} + {@const side = resolvedTag(k)} + {@const v = side === "theirs" ? theirs : mine} + {#if v} +
{k}
+
{v}
+ {/if} + {/each} +
Track titles
+
0}> + {trackChanges ? `${trackChanges} of ${a.tracks.length} replaced` : `all ${a.tracks.length} kept as yours`}
+
Audio
+
+ {resolvedAudio === "theirs" && audition?.candidate + ? `the found copy — ${audition.candidate.folderLeaf} (${audition.candidate.avgBitrate ? audition.candidate.avgBitrate + "k, " : ""}${audition.candidate.trackCount} tracks)` + : `${ext} ${a.avgBitrate ?? "—"}k · unchanged`}
+
+
+ +{#if fs} + {@const right = fsRight} + +
+ {#if !right || !yoursSrc} + +
+ +
+
+
{a.title}{artist}
+
+
(fs = false)} + onkeydown={(e) => e.key === "Enter" && (fs = false)}>⤡
+
+ +
+
+ {yoursSrc + ? `yours · ${yours?.w} × ${yours?.h}${yours?.kind === "embedded" ? " · embedded" : ""}` + : `${right?.provider} · ${right?.px ? `${right.px} × ${right.realH ?? right.px}` : "size not measured yet"}`} + {#if yoursSrc && !covers.length} · no service has this record — nothing to compare against + {:else if !yoursSrc} · you have no artwork for this record{/if} +
+
+ {:else} +
+ +
+
+
{a.title}{artist}
+
+ + + + +
+
+
(fs = false)} + onkeydown={(e) => e.key === "Enter" && (fs = false)}>⤡
+ + {#if loupe && loupePos} + {@const bs = `${loupePos.w * LOUPE_Z}px ${loupePos.h * LOUPE_Z}px`} + {@const bx = loupeBg(loupePos.fx, loupePos.w, LOUPE / 2)} + {@const by = loupeBg(loupePos.fy, loupePos.h, LOUPE)} +
+
+
+
+ yours + {right.provider} +
+
+ {/if} + {#if fsMode === "side"} +
yours · {yours?.w}px
+
{right.provider} · {right.px}px
+ {/if} +
+ yours {yours?.w} × {yours?.h}{yours?.kind === "embedded" ? " · embedded" : ""} +  vs  {right.provider} {right.px} × {right.realH ?? right.px} +
+
+ {#each covers as c, n (c.url)} +
(fsPick = n + 1)} + onkeydown={(e) => e.key === "Enter" && (fsPick = n + 1)}> + +
{c.provider}{c.px} × {c.realH ?? c.px}
+
+ {/each} +
+
+ {/if} +
+{/if} + + diff --git a/src/components/ImportToast.svelte b/src/components/ImportToast.svelte new file mode 100644 index 0000000..1390b34 --- /dev/null +++ b/src/components/ImportToast.svelte @@ -0,0 +1,146 @@ + + +{#if shown} +
+ +
+{/if} + + diff --git a/src/components/ImportWindow.svelte b/src/components/ImportWindow.svelte new file mode 100644 index 0000000..90f0ce7 --- /dev/null +++ b/src/components/ImportWindow.svelte @@ -0,0 +1,1880 @@ + + + + +{#if open} + + +{/if} + + diff --git a/src/lib/api.js b/src/lib/api.js index c05c60e..2243f80 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -223,6 +223,36 @@ export const playerFadePauseAcked = (fadeMs) => export const playerFadeResume = (fadeMs) => invoke("player_fade_resume", { fadeMs }); export const debugStats = () => invoke("debug_stats"); + +// ---- the import tool (see src-tauri/src/import.rs) ------------------------ +// Scan is read-only; apply COPIES into the managed root and registers — the +// source folder is never written to. +export const importScan = (root) => invoke("import_scan", { root }); +export const importSessionGet = () => invoke("import_session_get"); +export const importSessionSet = (state) => invoke("import_session_set", { state }); +export const importSessionClear = () => invoke("import_session_clear"); +export const importGetDest = () => invoke("import_get_dest"); +export const importSetDest = (dir, layout = null) => invoke("import_set_dest", { dir, layout }); +export const importApply = (sourceRoot, albums) => invoke("import_apply", { sourceRoot, albums }); +// On-demand per-record identification (Deezer try-list → MB fallback) and +// the artwork panel's cover report. Both read-only. +export const importIdentify = (artist, title) => invoke("import_identify", { artist, title }); +export const importCoverReport = (artist, title, referenceUrl) => + invoke("import_cover_report", { artist, title, referenceUrl }); +// The record's own artwork, measured (embedded first, else folder images). +export const importLocalArt = (sourceRoot, rel) => invoke("import_local_art", { sourceRoot, rel }); +// Prune rows whose files are gone, then index anything new — the real rescan. +export const rescanLibrary = () => invoke("rescan_library"); +// Download a found album copy into (Cache)\_audition\\ for the version +// comparison. files ordered track-1-first; returns transfer ids. +export const importAudition = (username, rel, files) => + invoke("import_audition", { username, rel, files }); +export const onImportAudition = (cb) => listen("import:audition", (e) => cb(e.payload)); +// Measured facts about one audio file (the version card's quality chips). +export const importProbeFile = (path) => invoke("import_probe_file", { path }); +export const onImportScan = (cb) => listen("import:scan", (e) => cb(e.payload)); +export const onImportScanFolder = (cb) => listen("import:scan-folder", (e) => cb(e.payload)); +export const onImportApply = (cb) => listen("import:apply", (e) => cb(e.payload)); export const previewControl = (action, value = null) => invoke("preview_control", { action, value }); export const previewDispose = () => invoke("preview_dispose"); diff --git a/src/lib/import-audio.js b/src/lib/import-audio.js new file mode 100644 index 0000000..46d7d4d Binary files /dev/null and b/src/lib/import-audio.js differ diff --git a/src/lib/import-evidence.js b/src/lib/import-evidence.js new file mode 100644 index 0000000..5665c7b --- /dev/null +++ b/src/lib/import-evidence.js @@ -0,0 +1,63 @@ +// The import review's match evidence — ONE definition, read by both the +// picker's verdict panel and the window's outcome buckets, so the filter +// and the panel can never disagree about the same record (the mock's rule, +// research/import/render.js evidence()). + +export const normx = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); + +const EDITION_WORD = + /\b(deluxe|expanded|remaster(?:ed)?|anniversary|edition|version|reissue|bonus|explicit|clean|mono|stereo|original|special|limited)\b/gi; + +/** What a release is CALLED beyond the record's own name: bracketed years a + * ripper added, edition words a service adds. Strip both and two namings of + * one record meet. */ +export const coreTitle = (s) => + normx( + String(s ?? "") + .replace(/[\[(<][^\])>]*[\])>]/g, " ") + .replace(EDITION_WORD, " ") + .replace(/\b(19|20)\d{2}\b/g, " "), + ) || normx(s); + +/** What is actually known about whether candidate `c` is the same record as + * the scanned album (title + artist + your track count). Each row is a + * fact the user can check, not a score they have to trust. */ +export function evidence(c, artist, title, trackCount) { + if (!c) return { items: [], verdict: "none", edition: false }; + const artistOk = normx(c.artist) === normx(artist); + const titleOk = normx(c.title) === normx(title); + const titleClose = + !titleOk && + (normx(c.title).includes(normx(title)) || normx(title).includes(normx(c.title))); + // Same record, named for a different edition — the ONE case where a + // different track count is expected rather than evidence of the wrong + // album. + const edition = !titleOk && coreTitle(title) === coreTitle(c.title); + const countOk = c.trackCount === trackCount; + const items = [ + { text: artistOk ? "Artist matches" : `Artist: ${c.artist}`, cls: artistOk ? "ok" : "no" }, + { + text: titleOk + ? "Title matches" + : edition + ? `Same record, different edition: ${c.title}` + : titleClose + ? `Title: ${c.title}` + : `Different title: ${c.title}`, + cls: titleOk ? "ok" : edition || titleClose ? "warn" : "no", + }, + { + text: countOk + ? `${c.trackCount} tracks, same` + : edition + ? `${trackCount} tracks vs ${c.trackCount} — expected on another edition` + : `${trackCount} tracks vs ${c.trackCount}`, + cls: countOk ? "ok" : edition ? "warn" : "no", + }, + ]; + const bad = items.filter((i) => i.cls === "no").length; + let verdict = bad >= 2 ? "bad" : bad === 1 ? "warn" : "ok"; + // An edition difference is never "everything agrees". + if (edition && verdict === "ok") verdict = "warn"; + return { items, verdict, edition }; +} diff --git a/src/lib/import-fold.js b/src/lib/import-fold.js new file mode 100644 index 0000000..c4997ea --- /dev/null +++ b/src/lib/import-fold.js @@ -0,0 +1,203 @@ +// Folding a release that was scanned as its parts. +// +// The scan groups audio by its IMMEDIATE parent folder, so a release filed as +// a container of parts — +// +// Aphex Twin/2005 - AFX - Analord, Volume 1-11/AFX - Analord 01/ 7 tracks +// /AFX - Analord 02/ 4 tracks +// … 11 volumes +// +// — arrives as eleven albums, and the container itself as none, because it +// holds no audio of its own. Six more of Kyle's records split the same way +// (Tago Mago, Drukqs, SAW II, 26 Mixes, Fishmans, Hella): 27 records that are +// really 7 releases. +// +// The rule and every guard in it were settled against the real 515-record scan +// BEFORE any of this shipped — `research/import/fold-audit.mjs` imports this +// module, so the audited code is the running code. Re-run it after any change +// here; it prints every fold and counts swallows under four library shapes. +// +// What the audit killed, and why the guards look like this: +// +// · Matching a part's name against its CONTAINER folded 40 of 148 artist +// folders — Mac Demarco's 8 albums into one — because album folders +// routinely carry the artist's name. Parts are compared to EACH OTHER. +// · A bare "depth 3 is a part" rule is only true of libraries shaped like +// his. The release level is read off the collection instead, so a +// genre-nested library moves the answer instead of breaking it. +// · Reading that level from an inconsistently nested collection merged 5 +// records under the wrong parent. Below a dominant majority the pass +// refuses entirely: no folding beats wrong folding (see §0.1, 9 GB). + +const SEP = "\\"; +export const segs = (rel) => rel.split(SEP); +export const leaf = (rel) => segs(rel).at(-1); + +/** Dropped before anything else looks at the list — a zip's `__MACOSX` + * resource fork otherwise reads as disc two. Mirrors ImportWindow's JUNK. */ +const JUNK = /^(__MACOSX|\.DS_Store|@eaDir|System Volume Information)$/i; + +/** `CD1`, `Disc 2`, `Side B`, `Part 3` — a part that says so in its name. */ +const PART_NAME = /^(?:cd|disc|disk|side|part|vol(?:ume)?)[\s_.-]*\d+[a-z]?$/i; + +const STOP = new Set(["the", "a", "an", "of", "and", "vol", "volume", "cd", "disc", + "disk", "part", "ep", "lp", "remastered", "deluxe", "edition", "records", "web", + "mp3", "flac", "kbps", "vinyl", "reissue"]); + +const strip = (s) => s + .replace(/^[([]\d{4}[)\]]\s*-?\s*/, "").replace(/^\d{4}\s*-\s*/, "") + .replace(/[[(].*?[\])]/g, " "); + +const nameTokens = (s) => new Set( + strip(s).toLowerCase().match(/[\p{L}\p{N}]+/gu) + ?.filter((t) => t.length >= 3 && !STOP.has(t) && !/^\d+$/.test(t)) ?? []); + +/** The folder name a folded record wears, and the year in it. The container's + * own name is the release; the parts' TAGS name the parts ("Analord 01"), so + * a tag majority over a folded record would title the whole set after its + * biggest volume. */ +export const titleOf = (name) => strip(name).replace(/\s{2,}/g, " ").trim() || name; +export function yearOf(name) { + const m = name.match(/^(19|20)\d{2}(?=\s*[-–—]\s)/) + ?? name.match(/[([]((?:19|20)\d{2})[)\]]/) + ?? name.match(/\b((?:19|20)\d{2})\b/); + if (!m) return null; + const y = Number(m[0].replace(/\D/g, "")); + return y >= 1900 && y <= 2100 ? y : null; +} + +const yearsIn = (a) => { + const out = []; + if (a.year) out.push(Number(a.year)); + for (const t of a.tracks ?? []) if (t.year) out.push(Number(t.year)); + return out.filter((y) => y >= 1900 && y <= 2100); +}; + +/** Reasons NOT to fold, each independent of the depth rule so a misread + * release level is caught rather than obeyed. Measured against the disaster + * case (every artist folder treated as a container): 69 of 73 rejected. */ +const VETOES = [ + function tooManyParts({ parts }) { + return parts.length > 30 ? `${parts.length} parts — reads as a discography` : null; + }, + function yearSpread({ parts }) { + const ys = parts.flatMap(yearsIn); + if (ys.length < 2) return null; + const lo = Math.min(...ys), hi = Math.max(...ys); + return hi - lo > 1 ? `parts span ${hi - lo} years (${lo}–${hi})` : null; + }, + function noCommonThread({ parts }) { + if (parts.length < 2) return null; + const names = parts.map((p) => leaf(p.rel)); + if (names.every((n) => PART_NAME.test(strip(n).trim()))) return null; + const sets = names.map(nameTokens); + const shared = [...sets[0]].filter((t) => sets.every((s) => s.has(t))); + return shared.length ? null : "parts share no name between them"; + }, +]; + +/** Where releases live, read off the collection rather than assumed. */ +export function releaseLevel(albums) { + const tally = new Map(); + for (const a of albums) { + const d = segs(a.rel).length; + tally.set(d, (tally.get(d) ?? 0) + 1); + } + let level = 1, n = -1; + for (const [d, c] of [...tally].sort((x, y) => x[0] - y[0])) { + if (c > n) { n = c; level = d; } + } + return { level, share: albums.length ? n / albums.length : 0 }; +} + +/** Below this the collection has no single shape and nothing folds. */ +export const CONFIDENCE = 0.7; + +/** + * Fold nested parts into their release. + * + * @param {Array} input raw scan albums + * @param {Set} [keepSeparate] container rels the user has un-folded + * @returns {{records, folds, vetoed, level, share, refused}} + * `records` is the list to use everywhere downstream; a folded one carries + * `parts` (sub-paths under its own rel, in order) so apply knows where the + * audio actually is without ever walking the tree. + */ +export function foldReleases(input, keepSeparate = new Set()) { + const albums = [], dropped = []; + for (const a of input) (JUNK.test(leaf(a.rel)) ? dropped : albums).push(a); + const { level, share } = releaseLevel(albums); + if (share < CONFIDENCE) { + return { records: albums, dropped, folds: [], vetoed: [], level, share, refused: true }; + } + + const byRel = new Map(albums.map((a) => [a.rel, a])); + const groups = new Map(); + for (const a of albums) { + if (segs(a.rel).length <= level) continue; + const container = segs(a.rel).slice(0, level).join(SEP); + if (!groups.has(container)) groups.set(container, []); + groups.get(container).push(a); + } + + const folds = [], vetoed = [], absorbed = new Set(); + for (const [container, deeper] of groups) { + // A container holding loose audio beside its parts is a record too; + // its own files join the fold, first, as the unnamed part "". + const own = byRel.get(container) ?? null; + const parts = [...deeper].sort((x, y) => leaf(x.rel).localeCompare( + leaf(y.rel), undefined, { numeric: true, sensitivity: "base" })); + const all = own ? [own, ...parts] : parts; + + const blocks = VETOES.map((v) => v({ container, parts: all })).filter(Boolean); + if (blocks.length) { + vetoed.push({ container, parts: all.length, reasons: blocks }); + continue; + } + const entry = { + container, + name: titleOf(leaf(container)), + parts: all.length, + tracks: all.reduce((n, p) => n + (p.tracks?.length ?? 0), 0), + members: all.map((p) => leaf(p.rel)), + }; + if (keepSeparate.has(container)) { + entry.off = true; + folds.push(entry); + continue; + } + folds.push(entry); + for (const p of all) absorbed.add(p.rel); + + // Order is part order, then the scan's order within each part — the + // same order apply walks, because `trackTitles` is positional and a + // disagreement here would relabel every track past the first part. + const merged = { + rel: container, + artistFolder: segs(container)[0], + albumFolder: leaf(container), + depth: segs(container).length, + year: yearOf(leaf(container)) ?? all.map((p) => p.year).find(Boolean) ?? null, + folderArt: [...new Set(all.flatMap((p) => p.folderArt ?? []))], + tracks: all.flatMap((p) => p.tracks ?? []), + parts: all.map((p) => (p === own ? "" : leaf(p.rel))), + foldedFrom: all.length, + }; + byRel.set(container, merged); + } + + const records = []; + const emitted = new Set(); + for (const a of albums) { + if (absorbed.has(a.rel)) { + const container = segs(a.rel).slice(0, level).join(SEP); + if (!emitted.has(container)) { + emitted.add(container); + records.push(byRel.get(container)); + } + continue; + } + records.push(a); + } + return { records, dropped, folds, vetoed, level, share, refused: false }; +} diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 793cf3a..cf120ab 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -213,6 +213,27 @@ export function announceFollow(on, artist) { followToast.portrait = artist?.picture ?? null; } +/** Background-import status, read by ImportToast. The import window keeps + * scanning/copying after it is closed (the component stays mounted, only + * its template goes), and closed must never mean invisible: while work + * runs, the toast narrates it and clicks back into the window. Terminal + * phases (scanned/applied/error) are only entered when the window is + * CLOSED at that moment — an open window narrates its own completions — + * and they stick until the window is reopened, which acknowledges them. */ +export const importStatus = $state({ + phase: "idle", // idle | scanning | scanned | applying | applied | error + done: 0, // scanning/applying: progress; scanned/applied: final count + total: 0, + label: "", // applied: the "· 12 GB" tail; otherwise unused + skipped: 0, // applied: records nothing was written for — the toast must + // say so, because "Imported 0 records" on its own reads as a dead end +}); + +/** A paused import's presence on the Library page: the Import button wears + * a percentage badge while a saved review session exists. Fed by + * ImportWindow (boot restore, every persist, cleared on completion). */ +export const importResume = $state({ active: false, pct: 0 }); + export const ui = $state({ view: "home", albumId: null, // selected album in Library view @@ -237,6 +258,11 @@ export const ui = $state({ // of whatever cover to show — { src, local, title, subtitle } — so any // surface can open it, not only the playing track. artwork: null, + // The import tool's window. `false` when closed; `true` opens it (resuming + // any saved session); `{ root }` opens it with a source folder pre-picked. + // Lives on ui rather than the Library page because importing is not a + // first-run event — another drive can arrive any day. + importOpen: false, // WebView2 keeps document.visibilityState="visible" while the native // window is minimized. This is the authoritative shared activity gate for // hidden animation, canvas, carousel, and high-frequency position work. @@ -3591,7 +3617,7 @@ export const resolver = $state({ error: "", }); -const RESOLVER_AUDIO_QUALITY = { +export const RESOLVER_AUDIO_QUALITY = { flac: 4, ape: 4, aiff: 3.5, wav: 3.5, m4a: 2.5, ogg: 2.5, opus: 2.5, aac: 2.2, mp3: 2, wma: 1.5, }; @@ -3794,7 +3820,7 @@ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); /** Query with server-excluded phrases removed (whole-word, any position) and * whitespace collapsed. Returns "" when nothing usable remains. */ -function sanitizeSearchQuery(query) { +export function sanitizeSearchQuery(query) { let out = ` ${query ?? ""} `; for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) { out = out.replace(new RegExp(`(?<=^|[^\\p{L}\\p{N}])${escapeRegExp(phrase)}(?=$|[^\\p{L}\\p{N}])`, "giu"), " "); @@ -3822,7 +3848,9 @@ function sanitizeSearchQuery(query) { const SEARCH_MIN_GAP_MS = 1500; let lastSearchStartedAt = 0; -async function collectSources(query, maxMs, goodCount) { +// Exported for the import finder (import-audio.js): the pacing, poisoned- +// term hygiene and early-exit logic must exist ONCE, not be re-coded. +export async function collectSources(query, maxMs, goodCount) { const gap = lastSearchStartedAt + SEARCH_MIN_GAP_MS - performance.now(); if (gap > 0) await new Promise((r) => setTimeout(r, gap)); lastSearchStartedAt = performance.now(); @@ -4474,16 +4502,22 @@ export function tinyCover(url) { * art, badges, album-folder source preference, and length verification. */ export async function streamSong(artist, title, opts = {}) { if (resolver.active) return; - const local = await libraryTrackFor(artist, title); - if (local) { - playAlbum( - local.tracks, - `${local.album.artist} — ${local.album.title}`, - local.index, - local.album.art_path ?? null, - ); - player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null }; - return; + // opts.skipLocal: the import picker's A/B auditions the CANDIDATE — the + // local-library shortcut would find the user's own copy and play them + // against themselves (decision 11). The stream cache below still counts: + // those bytes came from a peer, so their quality is honestly "theirs". + if (!opts.skipLocal) { + const local = await libraryTrackFor(artist, title); + if (local) { + playAlbum( + local.tracks, + `${local.album.artist} — ${local.album.title}`, + local.index, + local.album.art_path ?? null, + ); + player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null }; + return; + } } // Streamed before and still byte-complete in the cache? Play those bytes // now — no search, no resolver popup, no network requirement. This is the diff --git a/src/views/Library.svelte b/src/views/Library.svelte index 6bfca60..66ef881 100644 --- a/src/views/Library.svelte +++ b/src/views/Library.svelte @@ -36,6 +36,8 @@ openCatalogAlbum, RECENTS_FLOOR_SECS, recentsStore, + importResume, + goToView, } from "../lib/state.svelte.js"; import { fileRelease, @@ -1378,6 +1380,10 @@ if (rescanning) return; rescanning = true; try { + // Prune deletions + index additions on the backend FIRST — the + // refetch below otherwise re-reads the same stale rows, which is + // exactly what this button used to do. + await api.rescanLibrary().catch((e) => console.warn("rescan:", e)); await refreshLibrary(); await loadStats(); } finally { @@ -1849,212 +1855,251 @@
{/if} - {#if libraryStore.albums.length === 0} -
-

Your music will live here

-

Download an album from Search and it lands here, tagged with art.

-
- {:else} -
-
-

Library

-
- - -
-
-
- {totals.albums.toLocaleString()}{totals.albums === 1 ? "album" : "albums"} - - {totals.artists}{totals.artists === 1 ? "artist" : "artists"} - - {totals.tracks.toLocaleString()}tracks - {#if totals.secs} - - {longDur(totals.secs)}of music - {/if} - {#if totals.bytes} - - {fmtBytes(totals.bytes)}on disk - {/if} - {#if totals.never} - - {totals.never}never played - {/if} -
-
- -
-
- {#each [["shelves", "Shelves"], ["grid", "Grid"], ["list", "List"]] as [k, l] (k)} - - {/each} -
-
- Group -
- {#each Object.entries(GROUPS) as [k, g] (k)} - - {/each} -
- Sort -
- - {#if menuOpen} - + +
+
+

Library

+
+ + + +
-
- {#if view === "list"} - -
- RecordArtistTypeYear# - LengthFormatRateSizeAdded +
+
+ {totals.albums.toLocaleString()}{totals.albums === 1 ? "album" : "albums"} + + {totals.artists}{totals.artists === 1 ? "artist" : "artists"} + + {totals.tracks.toLocaleString()}tracks + {#if totals.secs} + + {longDur(totals.secs)}of music + {/if} + {#if totals.bytes} + + {fmtBytes(totals.bytes)}on disk + {/if} + {#if totals.never} + + {totals.never}never played + {/if} +
+
+ +
+
+ {#each [["shelves", "Shelves"], ["grid", "Grid"], ["list", "List"]] as [k, l] (k)} + + {/each} +
+
+ Group +
+ {#each Object.entries(GROUPS) as [k, g] (k)} + + {/each} +
+ Sort +
+ + {#if menuOpen} + + {/if} +
+ +
+ {#if view === "list"} + +
+ RecordArtistTypeYear# + LengthFormatRateSizeAdded +
+ {/if} +
+ + {#if railItems.length} +
+ {#each railItems as [l, k] (k)} + + {/each} +
+ {/if} + + {#if corpus.length === 0} +
+ {#if q} + +

Nothing matches {q}

+ {:else} + + +

Your music will live here

+ Bring in a folder you already have, or download an album from + Search — either way it lands here, tagged with art. +
+ +
{/if}
- - {#if railItems.length} -
- {#each railItems as [l, k] (k)} - - {/each} -
- {/if} - - {#if corpus.length === 0} -
- -

Nothing matches {q}

-
- {:else if view === "shelves"} - {#each grouped as [name, list] (name)} - {@const shown = expandedShelves[name] ? list : list.slice(0, PER_SHELF)} -
- {#if name}{@render shelfHead(name, list)}{/if} - {#if group === "artist"} - {#each splitByCategory(shown) as [label, part] (label ?? "all")} - {#if label} -
{label}{part.length}
- {/if} - {@render cardGrid(part, `${name}:${label ?? "all"}`)} - {/each} - {:else} - {@render cardGrid(shown, name || "flat")} - {/if} - {#if list.length > shown.length} - - {/if} - {#if group === "artist"} - {@render gapSection(name, list)} - {/if} -
- {/each} - {:else if view === "grid"} + {:else if view === "shelves"} + {#each grouped as [name, list] (name)} + {@const shown = expandedShelves[name] ? list : list.slice(0, PER_SHELF)} +
+ {#if name}{@render shelfHead(name, list)}{/if} + {#if group === "artist"} + {#each splitByCategory(shown) as [label, part] (label ?? "all")} + {#if label} +
{label}{part.length}
+ {/if} + {@render cardGrid(part, `${name}:${label ?? "all"}`)} + {/each} + {:else} + {@render cardGrid(shown, name || "flat")} + {/if} + {#if list.length > shown.length} + + {/if} + {#if group === "artist"} + {@render gapSection(name, list)} + {/if} +
+ {/each} + {:else if view === "grid"} + {#each grouped as [name, list] (name)} +
+ {#if name} +
{name}{list.length}
+ {/if} + {@render cardGrid(list, name || "flat")} +
+ {/each} + {:else} +
{#each grouped as [name, list] (name)}
{#if name}
{name}{list.length}
{/if} - {@render cardGrid(list, name || "flat")} + {#each list as a (a.id)} +
(hoverId = a.id)} + onpointerleave={() => { if (hoverId === a.id) hoverId = null; }} + onclick={() => toggleExpander(a)} + onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)} + > + + {#if a.art_path && coverUrls[a.id]} + + {:else if !a.art_path} + {@const g = genFor(a)} + + {:else} + + {/if} + + {a.title} + {a.artist} + {CAT_ONE[a.cat]} + {a.year ?? "—"} + {a.track_count || "—"} + {a.secs ? dur(a.secs) : "—"} + + {#if a.formats[0]} + + {a.formats[0].toUpperCase()}{#if a.depth}{a.depth}{/if} + + {:else}—{/if} + + {a.avgKbps ? `${a.avgKbps}k` : "—"} + {fmtBytes(a.bytes)} + {since(a.added_at)} +
+ {#if openId === a.id} + {@render expander([a], "list")} + {/if} + {/each}
{/each} - {:else} -
- {#each grouped as [name, list] (name)} -
- {#if name} -
{name}{list.length}
- {/if} - {#each list as a (a.id)} -
(hoverId = a.id)} - onpointerleave={() => { if (hoverId === a.id) hoverId = null; }} - onclick={() => toggleExpander(a)} - onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)} - > - - {#if a.art_path && coverUrls[a.id]} - - {:else if !a.art_path} - {@const g = genFor(a)} - - {:else} - - {/if} - - {a.title} - {a.artist} - {CAT_ONE[a.cat]} - {a.year ?? "—"} - {a.track_count || "—"} - {a.secs ? dur(a.secs) : "—"} - - {#if a.formats[0]} - - {a.formats[0].toUpperCase()}{#if a.depth}{a.depth}{/if} - - {:else}—{/if} - - {a.avgKbps ? `${a.avgKbps}k` : "—"} - {fmtBytes(a.bytes)} - {since(a.added_at)} -
- {#if openId === a.id} - {@render expander([a], "list")} - {/if} - {/each} -
- {/each} -
- {/if} +
{/if}
@@ -2181,6 +2226,17 @@ .lib-search input::placeholder { color: var(--text-muted); font-weight: 500; } .lib-clear { display: flex; color: var(--text-muted); cursor: pointer; } .lib-clear:hover { color: var(--text-primary); } + /* The ongoing-import badge on the Import tool: quiet accent pill, the + percentage the review has covered. */ + .lib-import-pct { + font-size: 10px; + font-weight: 800; + font-variant-numeric: tabular-nums; + padding: 1px 7px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 26%, transparent); + color: var(--accent-hover, var(--accent)); + } .lib-tool { display: inline-flex; align-items: center; @@ -3327,16 +3383,21 @@ place-items: center; } .lib-empty b { color: var(--text-primary); } - .lib-empty-lib { - padding: 18vh 0; - text-align: center; - color: var(--text-muted); + .lib-empty small { margin-top: -4px; font-size: 13px; max-width: 52ch; line-height: 1.5; } + .lib-empty-acts { + margin-top: 8px; + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 10px; } - .lib-empty-lib .big { - font-size: 19px; - font-weight: 800; - color: var(--text-primary); - margin: 0 0 6px; + /* The one action that starts something. Same object as the toolbar's + tools, wearing the accent — nothing new invented for an empty page. */ + .lib-tool.primary { + background: var(--accent); + border-color: var(--accent); + color: #000; } - .lib-empty-lib .sub { margin: 0; font-size: 13px; } + .lib-tool.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #000; } + .lib-tool.primary .lib-import-pct { background: rgba(0, 0, 0, 0.22); color: #000; }