forked from Keelrleek/SoulStream
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:
1 parent
4b30c5e573
commit
e7d0971de2
23 files changed
+6302
-62
No files matched your search
Generated
+1
@@ -180,6 +180,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"windows 0.61.3",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -748,6 +748,19 @@ impl Library {
|
|||||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
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:
|
/// Paths of tracks missing the audio-props backfill (size is the sentinel:
|
||||||
/// always available from the filesystem, so a non-null size means scanned).
|
/// always available from the filesystem, so a non-null size means scanned).
|
||||||
pub fn tracks_missing_props(&self) -> Result<Vec<(i64, String)>> {
|
pub fn tracks_missing_props(&self) -> Result<Vec<(i64, String)>> {
|
||||||
|
|||||||
@@ -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
|
/// Production hot-path cover upgrade for a catalog release. Returns the URL
|
||||||
/// of the highest-resolution image that is *verifiably the same artwork* as
|
/// 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.
|
/// 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>,
|
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 {
|
struct CoverCandidate {
|
||||||
bytes: Arc<Vec<u8>>,
|
bytes: Arc<Vec<u8>>,
|
||||||
width: u32,
|
width: u32,
|
||||||
@@ -13875,6 +14344,44 @@ fn urlencode(s: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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?
|
/// 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
|
/// Run this the next time a `failed a liveness probe` warning appears. It
|
||||||
|
|||||||
@@ -379,13 +379,29 @@ impl Client {
|
|||||||
/// Start downloading `filename` (full remote path) from `username`.
|
/// Start downloading `filename` (full remote path) from `username`.
|
||||||
/// Progress streams out as [`Event::Download`]. Returns the download id.
|
/// Progress streams out as [`Event::Download`]. Returns the download id.
|
||||||
pub fn download(&self, username: &str, filename: &str, size: u64) -> u64 {
|
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
|
/// Like [`download`](Self::download) but writing to an explicit local
|
||||||
/// path (used for stream-to-cache).
|
/// path (used for stream-to-cache).
|
||||||
pub fn download_to(&self, username: &str, filename: &str, size: u64, target: PathBuf) -> u64 {
|
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.
|
/// Cancel an active download by id.
|
||||||
|
|||||||
@@ -160,6 +160,11 @@ fn walk(dir: &Path, base: &str, root: &Path, index: &mut Index) {
|
|||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_dir() {
|
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);
|
walk(&path, base, root, index);
|
||||||
} else if is_audio(&path) {
|
} else if is_audio(&path) {
|
||||||
let Ok(meta) = entry.metadata() else { continue };
|
let Ok(meta) = entry.metadata() else { continue };
|
||||||
|
|||||||
@@ -57,10 +57,15 @@ pub(crate) fn start_download(
|
|||||||
filename: &str,
|
filename: &str,
|
||||||
size: u64,
|
size: u64,
|
||||||
target: Option<PathBuf>,
|
target: Option<PathBuf>,
|
||||||
|
claim: impl FnOnce(u64),
|
||||||
) -> u64 {
|
) -> u64 {
|
||||||
let id = inner
|
let id = inner
|
||||||
.download_counter
|
.download_counter
|
||||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
.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();
|
let cancel = CancellationToken::new();
|
||||||
inner.downloads_by_file.lock().unwrap().insert(
|
inner.downloads_by_file.lock().unwrap().insert(
|
||||||
(username.to_string(), filename.to_string()),
|
(username.to_string(), filename.to_string()),
|
||||||
@@ -362,6 +367,11 @@ impl Download {
|
|||||||
None => ("", self.filename.as_str()),
|
None => ("", self.filename.as_str()),
|
||||||
};
|
};
|
||||||
let mut dir = self.inner.downloads_dir.clone();
|
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));
|
dir.push(sanitize(&self.username));
|
||||||
if !folder.is_empty() {
|
if !folder.is_empty() {
|
||||||
dir.push(sanitize(folder));
|
dir.push(sanitize(folder));
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ pub struct TagWrite {
|
|||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub track_no: Option<u32>,
|
pub track_no: Option<u32>,
|
||||||
pub year: 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
|
/// 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 {
|
if let Some(v) = &write.album_artist {
|
||||||
tag.insert_text(lofty::tag::ItemKey::AlbumArtist, v.clone());
|
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 let Some(bytes) = art_jpeg {
|
||||||
if replace_art {
|
if replace_art {
|
||||||
@@ -139,6 +147,26 @@ pub fn write_tags(
|
|||||||
Ok(())
|
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.
|
/// Replace any existing embedded pictures with the given front cover.
|
||||||
pub fn replace_art(path: &Path, art_jpeg: &[u8]) -> Result<()> {
|
pub fn replace_art(path: &Path, art_jpeg: &[u8]) -> Result<()> {
|
||||||
let mut tagged = Probe::open(path)?.read()?;
|
let mut tagged = Probe::open(path)?.read()?;
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
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]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
|
|||||||
@@ -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 {
|
} else {
|
||||||
let _ = event_app.emit("soulseek:download", download_payload(&update));
|
let _ = event_app.emit("soulseek:download", download_payload(&update));
|
||||||
// Completed real downloads go through the tag/art
|
// Completed real downloads go through the tag/art
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ pub struct AppConfig {
|
|||||||
/// tag values are flattened to `_`).
|
/// tag values are flattened to `_`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub library_layout: Option<String>,
|
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).
|
/// Extra folders to share (the downloads folder is always shared).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub shared_folders: Vec<String>,
|
pub shared_folders: Vec<String>,
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@ mod config;
|
|||||||
mod cover_logo;
|
mod cover_logo;
|
||||||
mod diagnostics;
|
mod diagnostics;
|
||||||
mod hero_crop;
|
mod hero_crop;
|
||||||
|
mod import;
|
||||||
mod hero_detector;
|
mod hero_detector;
|
||||||
mod loudness;
|
mod loudness;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
@@ -99,6 +100,19 @@ pub fn run() {
|
|||||||
commands::add_to_playlist,
|
commands::add_to_playlist,
|
||||||
commands::remove_from_playlist,
|
commands::remove_from_playlist,
|
||||||
commands::catalog_search,
|
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,
|
||||||
commands::catalog_artist_art,
|
commands::catalog_artist_art,
|
||||||
commands::catalog_artist_logos,
|
commands::catalog_artist_logos,
|
||||||
|
|||||||
+214
-40
@@ -11,7 +11,11 @@ use library::Library;
|
|||||||
use metadata::{AlbumMeta, MetadataClient};
|
use metadata::{AlbumMeta, MetadataClient};
|
||||||
use tagger::{TagSummary, TagWrite};
|
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 struct LibraryState {
|
||||||
pub library: Arc<Library>,
|
pub library: Arc<Library>,
|
||||||
@@ -20,7 +24,7 @@ pub struct LibraryState {
|
|||||||
/// identity warmer only works while this has been quiet, so background
|
/// identity warmer only works while this has been quiet, so background
|
||||||
/// warmth never queues ahead of a page the user is actually looking at.
|
/// warmth never queues ahead of a page the user is actually looking at.
|
||||||
pub(crate) catalog_activity: std::sync::atomic::AtomicU64,
|
pub(crate) catalog_activity: std::sync::atomic::AtomicU64,
|
||||||
art_dir: PathBuf,
|
pub(crate) art_dir: PathBuf,
|
||||||
/// (artist, album) lowercase → resolved metadata, deduping MB lookups.
|
/// (artist, album) lowercase → resolved metadata, deduping MB lookups.
|
||||||
album_cache: tokio::sync::Mutex<HashMap<(String, String), Option<AlbumMeta>>>,
|
album_cache: tokio::sync::Mutex<HashMap<(String, String), Option<AlbumMeta>>>,
|
||||||
/// Serializes art fetch per album key.
|
/// Serializes art fetch per album key.
|
||||||
@@ -223,13 +227,106 @@ pub fn init(app: &tauri::AppHandle) -> Result<(), String> {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
// Old staging leftovers: folders emptied by organizing are removed
|
// 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);
|
sweep_empty_dirs(&downloads_dir(&handle), 0);
|
||||||
scan_downloads(&handle).await;
|
scan_downloads(&handle).await;
|
||||||
});
|
});
|
||||||
Ok(())
|
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).
|
/// Remove empty directory trees below `dir` (post-order; `dir` itself stays).
|
||||||
fn sweep_empty_dirs(dir: &Path, depth: u8) {
|
fn sweep_empty_dirs(dir: &Path, depth: u8) {
|
||||||
if depth > 6 {
|
if depth > 6 {
|
||||||
@@ -253,6 +350,25 @@ fn downloads_dir(app: &tauri::AppHandle) -> PathBuf {
|
|||||||
crate::config::download_dir(app, &cfg)
|
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.
|
/// Index every audio file under the downloads dir that isn't known yet.
|
||||||
async fn scan_downloads(app: &tauri::AppHandle) {
|
async fn scan_downloads(app: &tauri::AppHandle) {
|
||||||
let root = downloads_dir(app);
|
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 {
|
if depth > 6 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -340,6 +456,12 @@ fn collect_audio_files(dir: &Path, out: &mut Vec<PathBuf>, depth: u8) {
|
|||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_dir() {
|
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);
|
collect_audio_files(&path, out, depth + 1);
|
||||||
} else if path
|
} else if path
|
||||||
.extension()
|
.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).
|
// 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 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(
|
let art_path = fetch_art_cached(
|
||||||
&state,
|
&state,
|
||||||
|
&album_dir,
|
||||||
&final_artist,
|
&final_artist,
|
||||||
&final_album,
|
&final_album,
|
||||||
mbid.as_deref(),
|
mbid.as_deref(),
|
||||||
rg_mbid.as_deref(),
|
rg_mbid.as_deref(),
|
||||||
false,
|
remote_path.is_some() && crate::config::write_folder_cover(&cfg),
|
||||||
)
|
)
|
||||||
.await;
|
.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
|
// whatever picture the uploader embedded (config-gated) so the file, the
|
||||||
// library, and the catalog all show the same artwork. Startup-scanned
|
// library, and the catalog all show the same artwork. Startup-scanned
|
||||||
// files are never stripped: they only gain art when they have none.
|
// 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 replace_embedded = remote_path.is_some() && crate::config::replace_embedded_art(&cfg);
|
||||||
let embed_max_edge = crate::config::embed_art_max_edge(&cfg);
|
let embed_max_edge = crate::config::embed_art_max_edge(&cfg);
|
||||||
let art_bytes = match (&art_path, replace_embedded || !summary.has_art) {
|
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()),
|
title: summary.title.is_none().then_some(title.clone()),
|
||||||
track_no: summary.track_no.or(parsed.track_no),
|
track_no: summary.track_no.or(parsed.track_no),
|
||||||
year: summary.year.is_none().then(|| final_year).flatten(),
|
year: summary.year.is_none().then(|| final_year).flatten(),
|
||||||
|
label: None,
|
||||||
|
genre: None,
|
||||||
};
|
};
|
||||||
{
|
{
|
||||||
let p = local_path.to_path_buf();
|
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()
|
local_path.to_path_buf()
|
||||||
};
|
};
|
||||||
|
|
||||||
// 6b. The canonical cover also becomes the album folder's cover.jpg /
|
// (The folder cover needs no separate write any more — step 4 filed the
|
||||||
// folder.jpg, so Explorer and any other player show the artwork the app
|
// master INTO the album folder, which is the same folder organize just
|
||||||
// does — stale uploader art under those names is replaced.
|
// moved the audio into.)
|
||||||
if remote_path.is_some() && crate::config::write_folder_cover(&cfg) {
|
|
||||||
if let (Some(album_dir), Some(art)) = (library_path.parent(), &art_path) {
|
|
||||||
let (dir, art) = (album_dir.to_path_buf(), art.clone());
|
|
||||||
let _ =
|
|
||||||
tauri::async_runtime::spawn_blocking(move || write_album_folder_cover(&dir, &art))
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Library rows.
|
// 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
|
/// Move a newly completed download into one album directory under the managed
|
||||||
/// root. The filename remains intact, preserving track numbering supplied by
|
/// root. The filename remains intact, preserving track numbering supplied by
|
||||||
/// the source. If a matching file exists, retain both files with a suffix.
|
/// 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,
|
app: &tauri::AppHandle,
|
||||||
source: &Path,
|
|
||||||
artist: &str,
|
artist: &str,
|
||||||
album: &str,
|
album: &str,
|
||||||
release_date: Option<&str>,
|
release_date: Option<&str>,
|
||||||
@@ -741,6 +875,18 @@ fn organize_download(
|
|||||||
if depth == 0 {
|
if depth == 0 {
|
||||||
target_dir = target_dir.join("Unknown Album");
|
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() {
|
if std::fs::create_dir_all(&target_dir).is_err() {
|
||||||
return source.to_path_buf();
|
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
|
layout
|
||||||
.replace("{artist}", &clean_chars(artist))
|
.replace("{artist}", &clean_chars(artist))
|
||||||
.replace("{album}", &clean_chars(album))
|
.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
|
/// One path segment: cleaned and trimmed; Windows rejects trailing dots and
|
||||||
/// spaces. May return an empty string (caller decides the fallback).
|
/// 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)
|
clean_chars(value)
|
||||||
.trim()
|
.trim()
|
||||||
.trim_end_matches([' ', '.'])
|
.trim_end_matches([' ', '.'])
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn safe_component(value: &str) -> String {
|
pub(crate) fn safe_component(value: &str) -> String {
|
||||||
let trimmed = safe_segment(value);
|
let trimmed = safe_segment(value);
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
"Unknown Album".into()
|
"Unknown Album".into()
|
||||||
@@ -911,13 +1057,20 @@ fn safe_component(value: &str) -> String {
|
|||||||
|
|
||||||
/// `<art_dir>/<artist - album>.jpg`, fetched at most once per album.
|
/// `<art_dir>/<artist - album>.jpg`, fetched at most once per album.
|
||||||
/// `force` refetches even when a cached file exists.
|
/// `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(
|
pub async fn fetch_art_cached(
|
||||||
state: &tauri::State<'_, LibraryState>,
|
state: &tauri::State<'_, LibraryState>,
|
||||||
|
album_dir: &Path,
|
||||||
artist: &str,
|
artist: &str,
|
||||||
album: &str,
|
album: &str,
|
||||||
mbid: Option<&str>,
|
mbid: Option<&str>,
|
||||||
rg_mbid: Option<&str>,
|
rg_mbid: Option<&str>,
|
||||||
force: bool,
|
replace: bool,
|
||||||
) -> Option<PathBuf> {
|
) -> Option<PathBuf> {
|
||||||
let key: String = format!("{artist} - {album}")
|
let key: String = format!("{artist} - {album}")
|
||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
@@ -928,8 +1081,6 @@ pub async fn fetch_art_cached(
|
|||||||
c => c,
|
c => c,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let target = state.art_dir.join(format!("{key}.jpg"));
|
|
||||||
|
|
||||||
let lock = {
|
let lock = {
|
||||||
let mut locks = state.art_locks.lock().unwrap();
|
let mut locks = state.art_locks.lock().unwrap();
|
||||||
locks
|
locks
|
||||||
@@ -939,20 +1090,26 @@ pub async fn fetch_art_cached(
|
|||||||
};
|
};
|
||||||
let _guard = lock.lock().await;
|
let _guard = lock.lock().await;
|
||||||
|
|
||||||
if target.exists() && !force {
|
if !replace {
|
||||||
return Some(target);
|
for name in ["cover.jpg", "folder.jpg"] {
|
||||||
|
let existing = album_dir.join(name);
|
||||||
|
if existing.exists() {
|
||||||
|
return Some(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let bytes = state
|
let bytes = state
|
||||||
.metadata
|
.metadata
|
||||||
.fetch_art(mbid, rg_mbid, artist, album)
|
.fetch_art(mbid, rg_mbid, artist, album)
|
||||||
.await?;
|
.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!(
|
log::info!(
|
||||||
"album art saved: {} ({} KB)",
|
"album art saved: {} ({} KB)",
|
||||||
target.display(),
|
cover.display(),
|
||||||
bytes.len() / 1024
|
bytes.len() / 1024
|
||||||
);
|
);
|
||||||
Some(target)
|
Some(cover)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downscale the canonical cover for embedding into audio files. `max_edge`
|
/// 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
|
/// 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
|
/// 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.
|
/// 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 {
|
if max_edge == 0 {
|
||||||
return bytes;
|
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 {
|
let Ok(bytes) = std::fs::read(art_file) else {
|
||||||
return;
|
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 jpeg = if bytes.starts_with(b"\x89PNG") {
|
||||||
let Ok(img) = image::load_from_memory(&bytes) else {
|
let Ok(img) = image::load_from_memory(&bytes) else {
|
||||||
return;
|
return None;
|
||||||
};
|
};
|
||||||
let mut output = std::io::Cursor::new(Vec::new());
|
let mut output = std::io::Cursor::new(Vec::new());
|
||||||
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95);
|
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95);
|
||||||
if img.into_rgb8().write_with_encoder(encoder).is_err() {
|
if img.into_rgb8().write_with_encoder(encoder).is_err() {
|
||||||
return;
|
return None;
|
||||||
}
|
}
|
||||||
output.into_inner()
|
output.into_inner()
|
||||||
} else {
|
} 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());
|
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
|
/// 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 mbid = meta.as_ref().and_then(|m| m.release_mbid.clone());
|
||||||
let rg_mbid = meta.as_ref().and_then(|m| m.release_group_mbid.clone());
|
let 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(
|
let art_path = fetch_art_cached(
|
||||||
&state,
|
&state,
|
||||||
|
&album_dir,
|
||||||
&album.artist,
|
&album.artist,
|
||||||
&album.title,
|
&album.title,
|
||||||
mbid.as_deref(),
|
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())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-embed into every track, replacing the old picture, and refresh the
|
// Re-embed into every track, replacing the old picture; tracks living in
|
||||||
// album folder's cover.jpg/folder.jpg so every surface agrees again.
|
// OTHER folders (multi-disc spreads) get their own cover copies below.
|
||||||
let cfg = crate::config::load(app);
|
let cfg = crate::config::load(app);
|
||||||
let embed_max_edge = crate::config::embed_art_max_edge(&cfg);
|
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 bytes = std::fs::read(&art_path).map_err(|e| e.to_string())?;
|
||||||
let embed = {
|
let embed = {
|
||||||
let b = bytes.clone();
|
let b = bytes.clone();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import PlayerBar from "./components/PlayerBar.svelte";
|
import PlayerBar from "./components/PlayerBar.svelte";
|
||||||
import NowPlaying from "./components/NowPlaying.svelte";
|
import NowPlaying from "./components/NowPlaying.svelte";
|
||||||
import ArtworkViewer from "./components/ArtworkViewer.svelte";
|
import ArtworkViewer from "./components/ArtworkViewer.svelte";
|
||||||
|
import ImportWindow from "./components/ImportWindow.svelte";
|
||||||
import LoginModal from "./components/LoginModal.svelte";
|
import LoginModal from "./components/LoginModal.svelte";
|
||||||
import PreviewOverlay from "./components/PreviewOverlay.svelte";
|
import PreviewOverlay from "./components/PreviewOverlay.svelte";
|
||||||
import DebugOverlay from "./components/DebugOverlay.svelte";
|
import DebugOverlay from "./components/DebugOverlay.svelte";
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
import CatalogAlbum from "./views/CatalogAlbum.svelte";
|
import CatalogAlbum from "./views/CatalogAlbum.svelte";
|
||||||
import CatalogDiscography from "./views/CatalogDiscography.svelte";
|
import CatalogDiscography from "./views/CatalogDiscography.svelte";
|
||||||
import FollowToast from "./components/FollowToast.svelte";
|
import FollowToast from "./components/FollowToast.svelte";
|
||||||
|
import ImportToast from "./components/ImportToast.svelte";
|
||||||
import {
|
import {
|
||||||
ui,
|
ui,
|
||||||
initEvents,
|
initEvents,
|
||||||
@@ -122,8 +124,10 @@
|
|||||||
<NowPlaying />
|
<NowPlaying />
|
||||||
<PlayerBar />
|
<PlayerBar />
|
||||||
<FollowToast />
|
<FollowToast />
|
||||||
|
<ImportToast />
|
||||||
<PreviewOverlay />
|
<PreviewOverlay />
|
||||||
<ArtworkViewer />
|
<ArtworkViewer />
|
||||||
|
<ImportWindow />
|
||||||
<LoginModal />
|
<LoginModal />
|
||||||
<DebugOverlay />
|
<DebugOverlay />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -223,6 +223,36 @@ export const playerFadePauseAcked = (fadeMs) =>
|
|||||||
export const playerFadeResume = (fadeMs) =>
|
export const playerFadeResume = (fadeMs) =>
|
||||||
invoke("player_fade_resume", { fadeMs });
|
invoke("player_fade_resume", { fadeMs });
|
||||||
export const debugStats = () => invoke("debug_stats");
|
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) =>
|
export const previewControl = (action, value = null) =>
|
||||||
invoke("preview_control", { action, value });
|
invoke("preview_control", { action, value });
|
||||||
export const previewDispose = () => invoke("preview_dispose");
|
export const previewDispose = () => invoke("preview_dispose");
|
||||||
|
|||||||
Binary file not shown.
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
+37
-3
@@ -213,6 +213,27 @@ export function announceFollow(on, artist) {
|
|||||||
followToast.portrait = artist?.picture ?? null;
|
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({
|
export const ui = $state({
|
||||||
view: "home",
|
view: "home",
|
||||||
albumId: null, // selected album in Library view
|
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
|
// of whatever cover to show — { src, local, title, subtitle } — so any
|
||||||
// surface can open it, not only the playing track.
|
// surface can open it, not only the playing track.
|
||||||
artwork: null,
|
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
|
// WebView2 keeps document.visibilityState="visible" while the native
|
||||||
// window is minimized. This is the authoritative shared activity gate for
|
// window is minimized. This is the authoritative shared activity gate for
|
||||||
// hidden animation, canvas, carousel, and high-frequency position work.
|
// hidden animation, canvas, carousel, and high-frequency position work.
|
||||||
@@ -3591,7 +3617,7 @@ export const resolver = $state({
|
|||||||
error: "",
|
error: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const RESOLVER_AUDIO_QUALITY = {
|
export const RESOLVER_AUDIO_QUALITY = {
|
||||||
flac: 4, ape: 4, aiff: 3.5, wav: 3.5,
|
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,
|
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
|
/** Query with server-excluded phrases removed (whole-word, any position) and
|
||||||
* whitespace collapsed. Returns "" when nothing usable remains. */
|
* whitespace collapsed. Returns "" when nothing usable remains. */
|
||||||
function sanitizeSearchQuery(query) {
|
export function sanitizeSearchQuery(query) {
|
||||||
let out = ` ${query ?? ""} `;
|
let out = ` ${query ?? ""} `;
|
||||||
for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) {
|
for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) {
|
||||||
out = out.replace(new RegExp(`(?<=^|[^\\p{L}\\p{N}])${escapeRegExp(phrase)}(?=$|[^\\p{L}\\p{N}])`, "giu"), " ");
|
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;
|
const SEARCH_MIN_GAP_MS = 1500;
|
||||||
let lastSearchStartedAt = 0;
|
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();
|
const gap = lastSearchStartedAt + SEARCH_MIN_GAP_MS - performance.now();
|
||||||
if (gap > 0) await new Promise((r) => setTimeout(r, gap));
|
if (gap > 0) await new Promise((r) => setTimeout(r, gap));
|
||||||
lastSearchStartedAt = performance.now();
|
lastSearchStartedAt = performance.now();
|
||||||
@@ -4474,6 +4502,11 @@ export function tinyCover(url) {
|
|||||||
* art, badges, album-folder source preference, and length verification. */
|
* art, badges, album-folder source preference, and length verification. */
|
||||||
export async function streamSong(artist, title, opts = {}) {
|
export async function streamSong(artist, title, opts = {}) {
|
||||||
if (resolver.active) return;
|
if (resolver.active) 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);
|
const local = await libraryTrackFor(artist, title);
|
||||||
if (local) {
|
if (local) {
|
||||||
playAlbum(
|
playAlbum(
|
||||||
@@ -4485,6 +4518,7 @@ export async function streamSong(artist, title, opts = {}) {
|
|||||||
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
|
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Streamed before and still byte-complete in the cache? Play those bytes
|
// Streamed before and still byte-complete in the cache? Play those bytes
|
||||||
// now — no search, no resolver popup, no network requirement. This is the
|
// now — no search, no resolver popup, no network requirement. This is the
|
||||||
// second-long "finding best source…" beat that used to sit between songs
|
// second-long "finding best source…" beat that used to sit between songs
|
||||||
|
|||||||
+78
-17
@@ -36,6 +36,8 @@
|
|||||||
openCatalogAlbum,
|
openCatalogAlbum,
|
||||||
RECENTS_FLOOR_SECS,
|
RECENTS_FLOOR_SECS,
|
||||||
recentsStore,
|
recentsStore,
|
||||||
|
importResume,
|
||||||
|
goToView,
|
||||||
} from "../lib/state.svelte.js";
|
} from "../lib/state.svelte.js";
|
||||||
import {
|
import {
|
||||||
fileRelease,
|
fileRelease,
|
||||||
@@ -1378,6 +1380,10 @@
|
|||||||
if (rescanning) return;
|
if (rescanning) return;
|
||||||
rescanning = true;
|
rescanning = true;
|
||||||
try {
|
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 refreshLibrary();
|
||||||
await loadStats();
|
await loadStats();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1849,12 +1855,11 @@
|
|||||||
<div class="lib-ambience-glow" style={highlightAccent}></div>
|
<div class="lib-ambience-glow" style={highlightAccent}></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if libraryStore.albums.length === 0}
|
<!-- There is no separate "nothing here" screen. An empty library is the
|
||||||
<div class="lib-empty-lib">
|
library page with nothing in it: same hero, same toolbar, same Import
|
||||||
<p class="big">Your music will live here</p>
|
button in the place it always lives. The takeover this replaced
|
||||||
<p class="sub">Download an album from Search and it lands here, tagged with art.</p>
|
REMOVED the header, so the one moment importing matters most was the
|
||||||
</div>
|
one moment there was no way to do it. -->
|
||||||
{:else}
|
|
||||||
<header class="lib-hero">
|
<header class="lib-hero">
|
||||||
<div class="lib-hero-top">
|
<div class="lib-hero-top">
|
||||||
<h1 class="lib-h1">Library</h1>
|
<h1 class="lib-h1">Library</h1>
|
||||||
@@ -1869,6 +1874,20 @@
|
|||||||
<button class="lib-tool" title="Rescan the library" onclick={rescan} disabled={rescanning}>
|
<button class="lib-tool" title="Rescan the library" onclick={rescan} disabled={rescanning}>
|
||||||
<Icon name="refresh" size={15} />
|
<Icon name="refresh" size={15} />
|
||||||
</button>
|
</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}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lib-stats">
|
<div class="lib-stats">
|
||||||
@@ -1962,8 +1981,35 @@
|
|||||||
|
|
||||||
{#if corpus.length === 0}
|
{#if corpus.length === 0}
|
||||||
<div class="lib-empty">
|
<div class="lib-empty">
|
||||||
|
{#if q}
|
||||||
<span class="icon"><Icon name="search" size={26} /></span>
|
<span class="icon"><Icon name="search" size={26} /></span>
|
||||||
<p>Nothing matches <b>{q}</b></p>
|
<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>
|
</div>
|
||||||
{:else if view === "shelves"}
|
{:else if view === "shelves"}
|
||||||
{#each grouped as [name, list] (name)}
|
{#each grouped as [name, list] (name)}
|
||||||
@@ -2055,7 +2101,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -2181,6 +2226,17 @@
|
|||||||
.lib-search input::placeholder { color: var(--text-muted); font-weight: 500; }
|
.lib-search input::placeholder { color: var(--text-muted); font-weight: 500; }
|
||||||
.lib-clear { display: flex; color: var(--text-muted); cursor: pointer; }
|
.lib-clear { display: flex; color: var(--text-muted); cursor: pointer; }
|
||||||
.lib-clear:hover { color: var(--text-primary); }
|
.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 {
|
.lib-tool {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3327,16 +3383,21 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
.lib-empty b { color: var(--text-primary); }
|
.lib-empty b { color: var(--text-primary); }
|
||||||
.lib-empty-lib {
|
.lib-empty small { margin-top: -4px; font-size: 13px; max-width: 52ch; line-height: 1.5; }
|
||||||
padding: 18vh 0;
|
.lib-empty-acts {
|
||||||
text-align: center;
|
margin-top: 8px;
|
||||||
color: var(--text-muted);
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.lib-empty-lib .big {
|
/* The one action that starts something. Same object as the toolbar's
|
||||||
font-size: 19px;
|
tools, wearing the accent — nothing new invented for an empty page. */
|
||||||
font-weight: 800;
|
.lib-tool.primary {
|
||||||
color: var(--text-primary);
|
background: var(--accent);
|
||||||
margin: 0 0 6px;
|
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>
|
</style>
|
||||||
Reference in new issue
Block a user