import: the whole feature, and releases put back together

Everything the import grew over the last week, landing in one go — the
window, the picker, the audition pen, apply, and the review that stands
between them.

The fold is the new part. A release filed as a container of parts arrived
as N albums, because the scan groups audio by its immediate parent and the
container holds none of its own: Analord's eleven volumes were eleven
records, and six more releases split the same way. Records now fold into
the release above them, and apply is handed an explicit part list — never a
recursive walk, which is what let one stray file swallow 288 descendants
and duplicate 9 GB.

Where releases live is read off the collection rather than assumed, so a
genre-nested library moves the answer instead of breaking; below a dominant
majority at one depth the pass refuses entirely, because no folding beats
wrong folding. Three vetoes independent of depth catch a misread level.
Every fold is listed in the review with a way out.

research/import/fold-audit.mjs imports the shipping module, so the audited
code is the running code: 9 folds, 0 swallows across four library shapes,
and none of the 148 artist folders touched. It was that harness that killed
the tempting version of this rule — matching a part against its container
folded 40 artist folders, Mac Demarco's 8 albums into one, because album
folders carry the artist's name.

The regression test found a live bug on the way: parts number from 01 each,
so flattening two discs collided, and "same name, same size" read the
second file as already-imported and dropped it while still counting it.
That reuse now also requires that this run has not already written the
path.

Also here: the picker's accent crossfades between records instead of
cutting through the app's blue (ownership moved to the window, which
survives the per-record re-key), the fullscreen viewer wears the album's
accent instead of the one it left behind in the portal, Skip defers a
record without deciding it, and Keep mine is gone — with the cards picked
field by field it was Next under another name.
This commit is contained in:
KeelrLeek committed 2026-08-10 07:55:59 -05:00
1 parent 4b30c5e573
commit e7d0971de2
23 files changed
+6498 -258

No files matched your search

Generated
+1
View File
@@ -180,6 +180,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tempfile",
"tokio",
"windows 0.61.3",
]
+13
View File
@@ -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<usize> {
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<Vec<(i64, String)>> {
+507
View File
@@ -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<IdentifyCandidate> {
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<IdentifyTrack> = 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<IdentifyTrack> = 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<Vec<u8>> {
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<serde_json::Value> {
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<String>,
}
/* ---------------------------------------------------------------- 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<u32>,
pub title: String,
pub secs: Option<u32>,
}
/// 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:<release-group uuid>".
#[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<String>,
/// Biggest artwork URL (Deezer xl / Cover Art Archive front).
pub cover: Option<String>,
pub cover_big: Option<String>,
pub cover_medium: Option<String>,
pub label: Option<String>,
pub release_date: Option<String>,
pub genre: Option<String>,
pub track_count: usize,
pub tracklist: Vec<IdentifyTrack>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IdentifyResult {
/// The cleaned queries actually sent, least- to most-aggressive.
pub tries: Vec<String>,
/// Which try produced the accepted candidate.
pub used: Option<String>,
pub candidate: Option<IdentifyCandidate>,
/// 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<String> {
// 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::<Vec<_>>().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::<Vec<_>>()
.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<Vec<u8>>,
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
+18 -2
View File
@@ -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.
+5
View File
@@ -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 };
+10
View File
@@ -57,10 +57,15 @@ pub(crate) fn start_download(
filename: &str,
size: u64,
target: Option<PathBuf>,
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));
+28
View File
@@ -70,6 +70,8 @@ pub struct TagWrite {
pub title: Option<String>,
pub track_no: Option<u32>,
pub year: Option<u32>,
pub label: Option<String>,
pub genre: Option<String>,
}
/// 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<Option<Vec<u8>>> {
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()?;
+5
View File
@@ -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"
+74
View File
@@ -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
+6
View File
@@ -84,6 +84,12 @@ pub struct AppConfig {
/// tag values are flattened to `_`).
#[serde(default)]
pub library_layout: Option<String>,
/// 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<String>,
/// Extra folders to share (the downloads folder is always shared).
#[serde(default)]
pub shared_folders: Vec<String>,
File diff suppressed because it is too large. Load diff
+14
View File
@@ -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,
+214 -40
View File
@@ -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<Library>,
@@ -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<HashMap<(String, String), Option<AlbumMeta>>>,
/// 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\<album>\` folders that have sat untouched past
/// the retention window. Claiming one moves it into the library, so anything
/// still here was passed over; the newest file in the folder is what ages,
/// so a transfer still filling in is never swept out from under itself.
pub(crate) fn sweep_stale_auditions(root: &Path) {
let pen = root.join("(Cache)").join("_audition");
let Ok(entries) = std::fs::read_dir(&pen) else { return };
let (mut albums, mut bytes) = (0u32, 0u64);
for e in entries.flatten() {
let dir = e.path();
if !dir.is_dir() {
continue;
}
// The FOLDER's own mtime is the floor. import_audition creates the
// directory before the first byte lands, so an empty one is usually a
// transfer that just started — ageing it from the epoch would have a
// Rescan in that window delete the pen out from under it. An empty
// folder that has genuinely sat a week still ages out on its own date.
let mut newest = e
.metadata()
.and_then(|m| m.modified())
.unwrap_or_else(|_| std::time::SystemTime::now());
let mut size = 0u64;
// Every file in the folder ages it, strays included — a peer's .m3u
// lands at the same moment as the audio, so nothing here can hold a
// spent folder open.
for f in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
if let Ok(m) = f.metadata() {
size += m.len();
if let Ok(t) = m.modified() {
newest = newest.max(t);
}
}
}
let stale = newest
.elapsed()
.ok()
.is_some_and(|age| age.as_secs() > STALE_AUDITION_SECS);
if stale && std::fs::remove_dir_all(&dir).is_ok() {
albums += 1;
bytes += size;
}
}
if albums > 0 {
log::info!(
"audition sweep: reclaimed {albums} unclaimed album(s), {:.1} MB",
bytes as f64 / 1_048_576.0
);
}
}
/// Remove empty directory trees below `dir` (post-order; `dir` itself stays).
fn sweep_empty_dirs(dir: &Path, depth: u8) {
if depth > 6 {
@@ -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::<LibraryState>();
let lib = state.library.clone();
let _ = tauri::async_runtime::spawn_blocking(move || lib.prune_missing())
.await
.map_err(|e| e.to_string())?;
let root = downloads_dir(&app);
sweep_stale_partials(&root);
sweep_stale_auditions(&root);
sweep_empty_dirs(&root, 0);
scan_downloads(&app).await;
Ok(())
}
/// Index every audio file under the downloads dir that isn't known yet.
async fn scan_downloads(app: &tauri::AppHandle) {
let root = downloads_dir(app);
@@ -330,7 +446,7 @@ async fn repair_artist_portraits(app: &tauri::AppHandle) {
}
}
fn collect_audio_files(dir: &Path, out: &mut Vec<PathBuf>, depth: u8) {
pub(crate) fn collect_audio_files(dir: &Path, out: &mut Vec<PathBuf>, depth: u8) {
if depth > 6 {
return;
}
@@ -340,6 +456,12 @@ fn collect_audio_files(dir: &Path, out: &mut Vec<PathBuf>, 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<u32>,
) -> PathBuf {
let target_dir = album_layout_dir(app, artist, album, release_date, year);
if std::fs::create_dir_all(&target_dir).is_err() {
return source.to_path_buf();
}
@@ -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<u32>) -> String {
pub(crate) fn render_layout(layout: &str, artist: &str, album: &str, date: &str, year: Option<u32>) -> String {
layout
.replace("{artist}", &clean_chars(artist))
.replace("{album}", &clean_chars(album))
@@ -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 {
/// `<art_dir>/<artist - album>.jpg`, fetched at most once per album.
/// `force` refetches even when a cached file exists.
/// The album's master cover lives IN the album folder as `cover.jpg` — art
/// travels with the music, one copy exists, and deleting the album deletes
/// its art (his call, 2026-08-07; AppData keeps only artist portraits and
/// the import picker's transient extractions). `replace` stomps whatever
/// cover is there (a fresh download's canonical art, a forced refresh);
/// without it an existing folder cover IS the master and no fetch happens.
pub async fn fetch_art_cached(
state: &tauri::State<'_, LibraryState>,
album_dir: &Path,
artist: &str,
album: &str,
mbid: Option<&str>,
rg_mbid: Option<&str>,
force: bool,
replace: bool,
) -> Option<PathBuf> {
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<u8>, max_edge: u32) -> Vec<u8> {
pub(crate) fn prepare_embed_art(bytes: Vec<u8>, max_edge: u32) -> Vec<u8> {
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<PathBuf> {
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();
+4
View File
@@ -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 @@
<NowPlaying />
<PlayerBar />
<FollowToast />
<ImportToast />
<PreviewOverlay />
<ArtworkViewer />
<ImportWindow />
<LoginModal />
<DebugOverlay />
</div>
File diff suppressed because it is too large. Load diff
+146
View File
@@ -0,0 +1,146 @@
<script>
// The import's presence while its window is closed — the same dark glass
// pill as the follow toast (approved T2), in the same berth above the
// player bar, but clickable and persistent: it stays up exactly as long
// as there is something to know (a scan or copy running, or a completion
// nobody has seen yet) and a click steps back into the import window.
//
// Visibility is pure derivation — the window OPEN hides it (the window
// narrates its own work), and reopening resets terminal phases to idle
// (ImportWindow's acknowledgment effect), so the pill never nags about
// a state the user has already looked at.
import { ui, importStatus } from "../lib/state.svelte.js";
const nfmt = (n) => n.toLocaleString("en-US");
const shown = $derived(!ui.importOpen && importStatus.phase !== "idle");
const busy = $derived(
importStatus.phase === "scanning" || importStatus.phase === "applying");
</script>
{#if shown}
<div class="layer">
<button
class="toast"
onclick={() => { ui.importOpen = true; }}
title="Back to the import"
>
{#if busy}
<span class="mark pulse">▸</span>
{:else if importStatus.phase === "error" || (importStatus.phase === "applied" && importStatus.skipped)}
<span class="mark warn">!</span>
{:else}
<span class="mark ok">✓</span>
{/if}
{#if importStatus.phase === "scanning"}
<span>Reading your music — <b>{nfmt(importStatus.done)} of
{importStatus.total ? nfmt(importStatus.total) : "…"}</b> folders</span>
{:else if importStatus.phase === "scanned"}
<span>Scan finished — <b>{nfmt(importStatus.done)} records</b> to review</span>
{:else if importStatus.phase === "applying"}
<span>Importing — <b>{nfmt(importStatus.done)} of
{nfmt(importStatus.total)}</b> records copied</span>
{:else if importStatus.phase === "applied"}
<!-- A bare "Imported 0 records · 0.0 GB" is the worst thing
this pill can say: something was refused and the reason
is a click away, but nothing here admits it. The count
that matters leads. -->
{#if importStatus.skipped && !importStatus.done}
<span><b>{nfmt(importStatus.skipped)} record{importStatus.skipped === 1
? "" : "s"}</b> could not be imported — <b>click to see why</b></span>
{:else if importStatus.skipped}
<span>Imported <b>{nfmt(importStatus.done)} records</b>{importStatus.label
? ` · ${importStatus.label}` : ""} · <b>{nfmt(importStatus.skipped)}
left out</b></span>
{:else}
<span>Imported <b>{nfmt(importStatus.done)} records</b>{importStatus.label
? ` · ${importStatus.label}` : ""}</span>
{/if}
{:else}
<span>The import hit a problem — <b>click to see</b></span>
{/if}
</button>
</div>
{/if}
<style>
/* FollowToast's berth verbatim — player bar + 8px shell gutter + 26px of
air — except the layer lets clicks through and the pill takes them. */
.layer {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--player-height, 62px) + 8px + 26px);
display: flex;
justify-content: center;
z-index: 80;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 9px 16px;
border-radius: 99px;
background: rgba(22, 23, 28, 0.94);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
font-size: 12.5px;
font-weight: 700;
color: #fff;
font-family: inherit;
cursor: pointer;
animation: toast-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.toast:hover {
background: rgba(34, 35, 42, 0.96);
border-color: rgba(255, 255, 255, 0.2);
}
.toast:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(16px) scale(0.95);
}
to {
opacity: 1;
transform: none;
}
}
.mark {
flex: none;
font-style: normal;
}
.mark.pulse {
color: var(--accent);
animation: mark-pulse 1s ease-in-out infinite;
}
@keyframes mark-pulse {
50% {
opacity: 0.35;
}
}
.mark.ok {
color: var(--accent);
}
.mark.warn {
color: #f0cd77;
}
b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation-duration: 1ms;
}
.mark.pulse {
animation: none;
}
}
</style>
File diff suppressed because it is too large. Load diff
+30
View File
@@ -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\<rel>\ 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");
Binary file not shown.
+63
View File
@@ -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 };
}
+203
View File
@@ -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<string>} [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 };
}
+47 -13
View File
@@ -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
+264 -203
View File
@@ -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 @@
<div class="lib-ambience-glow" style={highlightAccent}></div>
{/if}
{#if libraryStore.albums.length === 0}
<div class="lib-empty-lib">
<p class="big">Your music will live here</p>
<p class="sub">Download an album from Search and it lands here, tagged with art.</p>
</div>
{:else}
<header class="lib-hero">
<div class="lib-hero-top">
<h1 class="lib-h1">Library</h1>
<div class="lib-tools">
<label class="lib-search">
<Icon name="search" size={15} />
<input type="text" placeholder="Filter your library" bind:value={q} />
{#if q}
<button class="lib-clear" title="Clear" onclick={() => (q = "")}><Icon name="x" size={13} /></button>
{/if}
</label>
<button class="lib-tool" title="Rescan the library" onclick={rescan} disabled={rescanning}>
<Icon name="refresh" size={15} />
</button>
</div>
</div>
<div class="lib-stats">
<span class="lib-stat"><b>{totals.albums.toLocaleString()}</b><i>{totals.albums === 1 ? "album" : "albums"}</i></span>
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.artists}</b><i>{totals.artists === 1 ? "artist" : "artists"}</i></span>
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.tracks.toLocaleString()}</b><i>tracks</i></span>
{#if totals.secs}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{longDur(totals.secs)}</b><i>of music</i></span>
{/if}
{#if totals.bytes}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{fmtBytes(totals.bytes)}</b><i>on disk</i></span>
{/if}
{#if totals.never}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.never}</b><i>never played</i></span>
{/if}
</div>
</header>
<div class="lib-toolbar" bind:this={toolbarEl} data-narrow={narrow ? "1" : "0"}>
<div class="disco-tabs" role="tablist">
{#each [["shelves", "Shelves"], ["grid", "Grid"], ["list", "List"]] as [k, l] (k)}
<button
class="disco-tab"
class:on={view === k}
role="tab"
aria-selected={view === k}
onclick={() => setView(k)}
>{l}</button>
{/each}
</div>
<div class="lib-controls">
<span class="lib-ctl-lab">Group</span>
<div class="lib-seg">
{#each Object.entries(GROUPS) as [k, g] (k)}
<button class:on={group === k} onclick={() => setGroup(k)}>{g.label}</button>
{/each}
</div>
<span class="lib-ctl-lab">Sort</span>
<div class="sortwrap">
<button class="sortbtn" onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}>
{SORTS[sortKey].label} <Icon name="playlist" size={15} />
</button>
{#if menuOpen}
<div class="menu" role="menu">
<div class="mgroup">Sort by</div>
{#each Object.entries(SORTS) as [k, s] (k)}
<button
role="menuitem"
onclick={() => {
if (sortKey === k) desc = !desc;
else { sortKey = k; desc = s.desc !== false; }
menuOpen = false;
}}
>
{s.label}
{#if sortKey === k}
<span class="mcheck" class:up={!desc}><Icon name="chevron-down" size={13} /></span>
{/if}
</button>
{/each}
</div>
<!-- There is no separate "nothing here" screen. An empty library is the
library page with nothing in it: same hero, same toolbar, same Import
button in the place it always lives. The takeover this replaced
REMOVED the header, so the one moment importing matters most was the
one moment there was no way to do it. -->
<header class="lib-hero">
<div class="lib-hero-top">
<h1 class="lib-h1">Library</h1>
<div class="lib-tools">
<label class="lib-search">
<Icon name="search" size={15} />
<input type="text" placeholder="Filter your library" bind:value={q} />
{#if q}
<button class="lib-clear" title="Clear" onclick={() => (q = "")}><Icon name="x" size={13} /></button>
{/if}
</label>
<button class="lib-tool" title="Rescan the library" onclick={rescan} disabled={rescanning}>
<Icon name="refresh" size={15} />
</button>
<!-- Importing is not a first-run event: another folder or
someone else's drive can arrive any day, so the way in
lives here, beside the library it feeds. -->
<button class="lib-tool"
title={importResume.active
? `An import is in progress — ${importResume.pct}% reviewed. Click to continue.`
: "Import music from a folder or drive"}
onclick={() => (ui.importOpen = importResume.active ? "continue" : true)}>
<Icon name="plus" size={15} />
Import
{#if importResume.active}
<span class="lib-import-pct">{importResume.pct}%</span>
{/if}
</div>
<button class="lib-dir" class:up={!desc} title="Reverse order" onclick={() => (desc = !desc)}>
<Icon name="chevron-down" size={14} />
</button>
</div>
{#if view === "list"}
<!-- The column header is the bar's own last row: one box, one
pin — it cannot drift from the bar or hide behind it at
any width (handoff §4b.3, Kyle's pick). -->
<div class="lb-tr lb-th">
<span></span><span>Record</span><span>Artist</span><span>Type</span><span>Year</span><span>#</span>
<span>Length</span><span>Format</span><span>Rate</span><span>Size</span><span>Added</span>
</div>
<div class="lib-stats">
<span class="lib-stat"><b>{totals.albums.toLocaleString()}</b><i>{totals.albums === 1 ? "album" : "albums"}</i></span>
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.artists}</b><i>{totals.artists === 1 ? "artist" : "artists"}</i></span>
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.tracks.toLocaleString()}</b><i>tracks</i></span>
{#if totals.secs}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{longDur(totals.secs)}</b><i>of music</i></span>
{/if}
{#if totals.bytes}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{fmtBytes(totals.bytes)}</b><i>on disk</i></span>
{/if}
{#if totals.never}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.never}</b><i>never played</i></span>
{/if}
</div>
</header>
<div class="lib-toolbar" bind:this={toolbarEl} data-narrow={narrow ? "1" : "0"}>
<div class="disco-tabs" role="tablist">
{#each [["shelves", "Shelves"], ["grid", "Grid"], ["list", "List"]] as [k, l] (k)}
<button
class="disco-tab"
class:on={view === k}
role="tab"
aria-selected={view === k}
onclick={() => setView(k)}
>{l}</button>
{/each}
</div>
<div class="lib-controls">
<span class="lib-ctl-lab">Group</span>
<div class="lib-seg">
{#each Object.entries(GROUPS) as [k, g] (k)}
<button class:on={group === k} onclick={() => setGroup(k)}>{g.label}</button>
{/each}
</div>
<span class="lib-ctl-lab">Sort</span>
<div class="sortwrap">
<button class="sortbtn" onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}>
{SORTS[sortKey].label} <Icon name="playlist" size={15} />
</button>
{#if menuOpen}
<div class="menu" role="menu">
<div class="mgroup">Sort by</div>
{#each Object.entries(SORTS) as [k, s] (k)}
<button
role="menuitem"
onclick={() => {
if (sortKey === k) desc = !desc;
else { sortKey = k; desc = s.desc !== false; }
menuOpen = false;
}}
>
{s.label}
{#if sortKey === k}
<span class="mcheck" class:up={!desc}><Icon name="chevron-down" size={13} /></span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<button class="lib-dir" class:up={!desc} title="Reverse order" onclick={() => (desc = !desc)}>
<Icon name="chevron-down" size={14} />
</button>
</div>
{#if view === "list"}
<!-- The column header is the bar's own last row: one box, one
pin — it cannot drift from the bar or hide behind it at
any width (handoff §4b.3, Kyle's pick). -->
<div class="lb-tr lb-th">
<span></span><span>Record</span><span>Artist</span><span>Type</span><span>Year</span><span>#</span>
<span>Length</span><span>Format</span><span>Rate</span><span>Size</span><span>Added</span>
</div>
{/if}
</div>
{#if railItems.length}
<div class="year-rail"><div class="yr-col">
{#each railItems as [l, k] (k)}
<button onclick={() => jumpTo(k)}>{l}</button>
{/each}
</div></div>
{/if}
{#if corpus.length === 0}
<div class="lib-empty">
{#if q}
<span class="icon"><Icon name="search" size={26} /></span>
<p>Nothing matches <b>{q}</b></p>
{:else}
<!-- Nothing in the library at all. Both ways in sit here, but
the toolbar's Import is still up there too — this is an
invitation, not the only door. -->
<span class="icon"><Icon name="library" size={26} /></span>
<p>Your music will live here</p>
<small>Bring in a folder you already have, or download an album from
Search — either way it lands here, tagged with art.</small>
<div class="lib-empty-acts">
<button class="lib-tool primary"
title={importResume.active
? `An import is in progress — ${importResume.pct}% reviewed. Click to continue.`
: "Import music from a folder or drive"}
onclick={() => (ui.importOpen = importResume.active ? "continue" : true)}>
<Icon name="plus" size={15} />
{importResume.active ? "Continue importing" : "Import from a folder"}
{#if importResume.active}
<span class="lib-import-pct">{importResume.pct}%</span>
{/if}
</button>
<button class="lib-tool" onclick={() => goToView("search")}>
<Icon name="search" size={15} />
Find music to download
</button>
</div>
{/if}
</div>
{#if railItems.length}
<div class="year-rail"><div class="yr-col">
{#each railItems as [l, k] (k)}
<button onclick={() => jumpTo(k)}>{l}</button>
{/each}
</div></div>
{/if}
{#if corpus.length === 0}
<div class="lib-empty">
<span class="icon"><Icon name="search" size={26} /></span>
<p>Nothing matches <b>{q}</b></p>
</div>
{:else if view === "shelves"}
{#each grouped as [name, list] (name)}
{@const shown = expandedShelves[name] ? list : list.slice(0, PER_SHELF)}
<section class="lib-shelf">
{#if name}{@render shelfHead(name, list)}{/if}
{#if group === "artist"}
{#each splitByCategory(shown) as [label, part] (label ?? "all")}
{#if label}
<div class="lib-subhead">{label}<span>{part.length}</span></div>
{/if}
{@render cardGrid(part, `${name}:${label ?? "all"}`)}
{/each}
{:else}
{@render cardGrid(shown, name || "flat")}
{/if}
{#if list.length > shown.length}
<button class="lib-more" onclick={() => (expandedShelves = { ...expandedShelves, [name]: true })}>
Show {list.length - shown.length} more <Icon name="chevron-down" size={13} />
</button>
{/if}
{#if group === "artist"}
{@render gapSection(name, list)}
{/if}
</section>
{/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)}
<section class="lib-shelf">
{#if name}{@render shelfHead(name, list)}{/if}
{#if group === "artist"}
{#each splitByCategory(shown) as [label, part] (label ?? "all")}
{#if label}
<div class="lib-subhead">{label}<span>{part.length}</span></div>
{/if}
{@render cardGrid(part, `${name}:${label ?? "all"}`)}
{/each}
{:else}
{@render cardGrid(shown, name || "flat")}
{/if}
{#if list.length > shown.length}
<button class="lib-more" onclick={() => (expandedShelves = { ...expandedShelves, [name]: true })}>
Show {list.length - shown.length} more <Icon name="chevron-down" size={13} />
</button>
{/if}
{#if group === "artist"}
{@render gapSection(name, list)}
{/if}
</section>
{/each}
{:else if view === "grid"}
{#each grouped as [name, list] (name)}
<section class="lib-sec">
{#if name}
<div class="lib-group-rule" id={anchorId(name)}>{name}<span>{list.length}</span></div>
{/if}
{@render cardGrid(list, name || "flat")}
</section>
{/each}
{:else}
<div class="lib-table">
{#each grouped as [name, list] (name)}
<section class="lib-sec">
{#if name}
<div class="lib-group-rule" id={anchorId(name)}>{name}<span>{list.length}</span></div>
{/if}
{@render cardGrid(list, name || "flat")}
{#each list as a (a.id)}
<div
class="lb-tr"
class:active={openId === a.id}
style={accentFor(a)}
id={flatAnchors?.at.get(a.id) ? anchorId(flatAnchors.at.get(a.id)) : undefined}
role="button"
tabindex="0"
use:loadCover={a}
onpointerenter={() => (hoverId = a.id)}
onpointerleave={() => { if (hoverId === a.id) hoverId = null; }}
onclick={() => toggleExpander(a)}
onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)}
>
<span class="lb-cell lb-thumb">
{#if a.art_path && coverUrls[a.id]}
<img src={coverUrls[a.id]} alt="" loading="lazy" draggable="false" />
{:else if !a.art_path}
{@const g = genFor(a)}
<span class="lib-gen mini" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}"></span>
{:else}
<Icon name="disc" size={15} />
{/if}
</span>
<span class="lb-cell lb-strong">{a.title}</span>
<span class="lb-cell">{a.artist}</span>
<span class="lb-cell">{CAT_ONE[a.cat]}</span>
<span class="lb-cell num">{a.year ?? "—"}</span>
<span class="lb-cell num">{a.track_count || "—"}</span>
<span class="lb-cell num">{a.secs ? dur(a.secs) : "—"}</span>
<span class="lb-cell">
{#if a.formats[0]}
<span class="lb-fmt" class:hi={a.depth?.startsWith("24")}>
{a.formats[0].toUpperCase()}{#if a.depth}<i>{a.depth}</i>{/if}
</span>
{:else}—{/if}
</span>
<span class="lb-cell num">{a.avgKbps ? `${a.avgKbps}k` : "—"}</span>
<span class="lb-cell num">{fmtBytes(a.bytes)}</span>
<span class="lb-cell num">{since(a.added_at)}</span>
</div>
{#if openId === a.id}
{@render expander([a], "list")}
{/if}
{/each}
</section>
{/each}
{:else}
<div class="lib-table">
{#each grouped as [name, list] (name)}
<section class="lib-sec">
{#if name}
<div class="lib-group-rule" id={anchorId(name)}>{name}<span>{list.length}</span></div>
{/if}
{#each list as a (a.id)}
<div
class="lb-tr"
class:active={openId === a.id}
style={accentFor(a)}
id={flatAnchors?.at.get(a.id) ? anchorId(flatAnchors.at.get(a.id)) : undefined}
role="button"
tabindex="0"
use:loadCover={a}
onpointerenter={() => (hoverId = a.id)}
onpointerleave={() => { if (hoverId === a.id) hoverId = null; }}
onclick={() => toggleExpander(a)}
onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)}
>
<span class="lb-cell lb-thumb">
{#if a.art_path && coverUrls[a.id]}
<img src={coverUrls[a.id]} alt="" loading="lazy" draggable="false" />
{:else if !a.art_path}
{@const g = genFor(a)}
<span class="lib-gen mini" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}"></span>
{:else}
<Icon name="disc" size={15} />
{/if}
</span>
<span class="lb-cell lb-strong">{a.title}</span>
<span class="lb-cell">{a.artist}</span>
<span class="lb-cell">{CAT_ONE[a.cat]}</span>
<span class="lb-cell num">{a.year ?? "—"}</span>
<span class="lb-cell num">{a.track_count || "—"}</span>
<span class="lb-cell num">{a.secs ? dur(a.secs) : "—"}</span>
<span class="lb-cell">
{#if a.formats[0]}
<span class="lb-fmt" class:hi={a.depth?.startsWith("24")}>
{a.formats[0].toUpperCase()}{#if a.depth}<i>{a.depth}</i>{/if}
</span>
{:else}—{/if}
</span>
<span class="lb-cell num">{a.avgKbps ? `${a.avgKbps}k` : "—"}</span>
<span class="lb-cell num">{fmtBytes(a.bytes)}</span>
<span class="lb-cell num">{since(a.added_at)}</span>
</div>
{#if openId === a.id}
{@render expander([a], "list")}
{/if}
{/each}
</section>
{/each}
</div>
{/if}
</div>
{/if}
</div>
@@ -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; }
</style>