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
+6498
-258
No files matched your search
@@ -748,6 +748,19 @@ impl Library {
|
||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||
}
|
||||
|
||||
/// Drop specific track rows by id. Used when the import tool replaces an
|
||||
/// album's audio with another version: the rows for the copy that was
|
||||
/// replaced go with its files. The album row is deliberately left alone —
|
||||
/// the caller is mid-apply and has already registered the replacement.
|
||||
pub fn remove_tracks(&self, ids: &[i64]) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut removed = 0;
|
||||
for id in ids {
|
||||
removed += conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Paths of tracks missing the audio-props backfill (size is the sentinel:
|
||||
/// always available from the filesystem, so a non-null size means scanned).
|
||||
pub fn tracks_missing_props(&self) -> Result<Vec<(i64, String)>> {
|
||||
|
||||
@@ -8154,6 +8154,242 @@ impl MetadataClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Identify one album record against the providers — the import review's
|
||||
/// on-demand "which record is this?" (research/import/enrich.mjs
|
||||
/// providerLookup, ported). Deezer is asked down the cleaned try-list
|
||||
/// until something answers; when its answer is missing — or is a
|
||||
/// DIFFERENT record (Bastard → Cherry Bomb: free release, Deezer search
|
||||
/// happily answers with another album) — MusicBrainz answers for the
|
||||
/// album itself, the same supplement the artist page already leans on.
|
||||
/// A title that is merely an edition of ours does NOT read as wrong
|
||||
/// here: containment either way counts as a match, and the edition rule
|
||||
/// lives with the judge, not the fetcher.
|
||||
pub async fn identify_album(&self, artist: &str, raw_title: &str) -> IdentifyResult {
|
||||
let tries = search_titles(artist, raw_title);
|
||||
let mut candidate = None;
|
||||
let mut used = None;
|
||||
for t in &tries {
|
||||
if let Some(c) = self.deezer_identify(artist, t).await {
|
||||
candidate = Some(c);
|
||||
used = Some(t.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
let deezer_wrong = candidate
|
||||
.as_ref()
|
||||
.is_some_and(|c| !tries.iter().any(|t| normx_overlap(&c.title, t)));
|
||||
if candidate.is_none() || deezer_wrong {
|
||||
if let Some((mb, t)) = self.mb_identify(artist, &tries).await {
|
||||
candidate = Some(mb);
|
||||
used = Some(t);
|
||||
}
|
||||
// MB missing too leaves the wrong Deezer answer in place ON
|
||||
// PURPOSE: the judge shows it as "Wrong record" honestly, which
|
||||
// beats an empty card (Jenny Death stays wrong, deliberately).
|
||||
}
|
||||
IdentifyResult {
|
||||
tries,
|
||||
used,
|
||||
candidate,
|
||||
deezer_wrong,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deezer's answer for one cleaned query: first search hit, hydrated
|
||||
/// with the album detail (label/date/genre) and the full paginated
|
||||
/// disc-aware tracklist — `/album/{id}` embeds only the first ~25.
|
||||
async fn deezer_identify(&self, artist: &str, t: &str) -> Option<IdentifyCandidate> {
|
||||
let q = urlencode(&format!("{artist} {t}"));
|
||||
let found = self
|
||||
.deezer(&format!("https://api.deezer.com/search/album?q={q}&limit=3"))
|
||||
.await?;
|
||||
let hit = found.get("data")?.as_array()?.first()?.clone();
|
||||
let id = hit.get("id")?.as_u64()?;
|
||||
|
||||
let full = self.deezer(&format!("https://api.deezer.com/album/{id}")).await;
|
||||
let mut tracklist: Vec<IdentifyTrack> = Vec::new();
|
||||
let mut next = Some(format!("https://api.deezer.com/album/{id}/tracks?limit=200"));
|
||||
while let Some(url) = next {
|
||||
let Some(page) = self.deezer(&url).await else { break };
|
||||
for tr in page.get("data").and_then(|d| d.as_array()).into_iter().flatten() {
|
||||
tracklist.push(IdentifyTrack {
|
||||
n: tr.get("track_position").and_then(|v| v.as_u64()).map(|v| v as u32),
|
||||
title: tr
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
secs: tr.get("duration").and_then(|v| v.as_u64()).map(|v| v as u32),
|
||||
});
|
||||
}
|
||||
next = page
|
||||
.get("next")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
}
|
||||
|
||||
let s = |v: &serde_json::Value, k: &str| {
|
||||
v.get(k).and_then(|x| x.as_str()).map(String::from)
|
||||
};
|
||||
Some(IdentifyCandidate {
|
||||
id: id.to_string(),
|
||||
source: "Deezer".into(),
|
||||
title: s(&hit, "title").unwrap_or_default(),
|
||||
artist: hit
|
||||
.pointer("/artist/name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
cover: s(&hit, "cover_xl").or_else(|| s(&hit, "cover_big")),
|
||||
cover_big: s(&hit, "cover_big"),
|
||||
cover_medium: s(&hit, "cover_medium"),
|
||||
label: full.as_ref().and_then(|f| s(f, "label")),
|
||||
release_date: full.as_ref().and_then(|f| s(f, "release_date")),
|
||||
genre: full
|
||||
.as_ref()
|
||||
.and_then(|f| f.pointer("/genres/data/0/name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
track_count: tracklist.len(),
|
||||
tracklist,
|
||||
})
|
||||
}
|
||||
|
||||
/// MusicBrainz release-group as the album RECORD, not just as cover art
|
||||
/// (the mock's mbCandidate). Exact-normalized title first, containment
|
||||
/// second; one official release with recordings gives the tracklist.
|
||||
/// Paced by the crate's own MusicBrainz gate.
|
||||
async fn mb_identify(
|
||||
&self,
|
||||
artist: &str,
|
||||
tries: &[String],
|
||||
) -> Option<(IdentifyCandidate, String)> {
|
||||
for t in tries {
|
||||
let query = format!("releasegroup:\"{}\" AND artist:\"{}\"", escape(t), escape(artist));
|
||||
let url = format!(
|
||||
"https://musicbrainz.org/ws/2/release-group?query={}&fmt=json&limit=3",
|
||||
urlencode(&query)
|
||||
);
|
||||
let Some(body) = self.mb_get(&url).await else { continue };
|
||||
let empty = Vec::new();
|
||||
let groups = body
|
||||
.get("release-groups")
|
||||
.and_then(|v| v.as_array())
|
||||
.unwrap_or(&empty);
|
||||
let rg = groups
|
||||
.iter()
|
||||
.find(|g| {
|
||||
g.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|title| normx(title) == normx(t))
|
||||
})
|
||||
.or_else(|| {
|
||||
groups.iter().find(|g| {
|
||||
g.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|title| normx_overlap(title, t))
|
||||
})
|
||||
});
|
||||
let Some(rg) = rg else { continue };
|
||||
let Some(rgid) = rg.get("id").and_then(|v| v.as_str()) else { continue };
|
||||
|
||||
let rel_url = format!(
|
||||
"https://musicbrainz.org/ws/2/release?release-group={rgid}\
|
||||
&inc=recordings&status=official&fmt=json&limit=1"
|
||||
);
|
||||
let rel = self.mb_get(&rel_url).await;
|
||||
let tracklist: Vec<IdentifyTrack> = rel
|
||||
.as_ref()
|
||||
.and_then(|r| r.pointer("/releases/0/media"))
|
||||
.and_then(|m| m.as_array())
|
||||
.map(|media| {
|
||||
media
|
||||
.iter()
|
||||
.flat_map(|m| {
|
||||
m.get("tracks").and_then(|t| t.as_array()).cloned().unwrap_or_default()
|
||||
})
|
||||
.map(|tr| IdentifyTrack {
|
||||
n: tr.get("position").and_then(|v| v.as_u64()).map(|v| v as u32),
|
||||
title: tr
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
secs: tr
|
||||
.get("length")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|ms| ((ms as f64) / 1000.0).round() as u32),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let candidate = IdentifyCandidate {
|
||||
id: format!("mb:{rgid}"),
|
||||
source: "MusicBrainz".into(),
|
||||
title: rg
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
artist: rg
|
||||
.pointer("/artist-credit/0/name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| Some(artist.to_string())),
|
||||
cover: Some(format!(
|
||||
"https://coverartarchive.org/release-group/{rgid}/front"
|
||||
)),
|
||||
cover_big: None,
|
||||
cover_medium: None,
|
||||
label: None,
|
||||
release_date: rg
|
||||
.get("first-release-date")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
genre: None,
|
||||
track_count: tracklist.len(),
|
||||
tracklist,
|
||||
};
|
||||
return Some((candidate, t.clone()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Fetch a chosen cover's bytes for the import apply — a plain GET, the
|
||||
/// caller decides what the pixels are for.
|
||||
pub async fn fetch_cover_bytes(&self, url: &str) -> Option<Vec<u8>> {
|
||||
let resp = self.http.get(url).send().await.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
resp.bytes().await.ok().map(|b| b.to_vec())
|
||||
}
|
||||
|
||||
/// Throttled MusicBrainz GET for identify, with one retry. Status-aware
|
||||
/// on purpose: a 503 from MB's rate limiter — routine right after app
|
||||
/// start, when the identity warmer shares the 1 req/s budget — must
|
||||
/// read as a retryable failure, never as "record absent" (the paced-
|
||||
/// probe-contamination lesson, now inside the app). The retry passes
|
||||
/// back through the throttle, so it is properly spaced, and a second
|
||||
/// failure surfaces as an honest miss the user can re-open.
|
||||
async fn mb_get(&self, url: &str) -> Option<serde_json::Value> {
|
||||
for attempt in 0..2 {
|
||||
self.mb_throttle().await;
|
||||
match self.http.get(url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(v) = resp.json().await {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
Ok(resp) if attempt == 0 => {
|
||||
log::debug!("mb identify: {} from MusicBrainz, retrying", resp.status());
|
||||
}
|
||||
Ok(_) | Err(_) => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Production hot-path cover upgrade for a catalog release. Returns the URL
|
||||
/// of the highest-resolution image that is *verifiably the same artwork* as
|
||||
/// the cover the UI already shows (`reference_url`), or `None` to keep it.
|
||||
@@ -11114,6 +11350,239 @@ pub struct CoverReport {
|
||||
pub chosen_url: Option<String>,
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- identify --
|
||||
The import review's record identification, ported from the approved mock
|
||||
(research/import/enrich.mjs). The folder name is not the album name: this
|
||||
kind of library writes rip descriptors into it — "Heartleap - WEB V0",
|
||||
"(1997.07.24) (album) Uchu Nippon Setagaya [128kbps]" — and sometimes the
|
||||
artist again. Sent verbatim, those queries miss records that are plainly
|
||||
in every catalogue (58 of 100 in the mock's first pass; the cleaned
|
||||
try-list + MB fallback resolved 50 of 65). The try-list mirrors the app's
|
||||
own simplifyTitle (state.svelte.js:3739); the judge still decides whatever
|
||||
comes back, so better recall cannot smuggle in a wrong record. */
|
||||
|
||||
/// One track of an identify candidate's tracklist.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IdentifyTrack {
|
||||
pub n: Option<u32>,
|
||||
pub title: String,
|
||||
pub secs: Option<u32>,
|
||||
}
|
||||
|
||||
/// A provider's answer for "which record is this?", in the shape the mock's
|
||||
/// candidates.json established (the picker UI is built against it). `id` is
|
||||
/// a string on purpose: Deezer ids are numeric ("302127"), MusicBrainz ones
|
||||
/// are "mb:<release-group uuid>".
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IdentifyCandidate {
|
||||
pub id: String,
|
||||
pub source: String, // "Deezer" | "MusicBrainz"
|
||||
pub title: String,
|
||||
pub artist: Option<String>,
|
||||
/// Biggest artwork URL (Deezer xl / Cover Art Archive front).
|
||||
pub cover: Option<String>,
|
||||
pub cover_big: Option<String>,
|
||||
pub cover_medium: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub release_date: Option<String>,
|
||||
pub genre: Option<String>,
|
||||
pub track_count: usize,
|
||||
pub tracklist: Vec<IdentifyTrack>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IdentifyResult {
|
||||
/// The cleaned queries actually sent, least- to most-aggressive.
|
||||
pub tries: Vec<String>,
|
||||
/// Which try produced the accepted candidate.
|
||||
pub used: Option<String>,
|
||||
pub candidate: Option<IdentifyCandidate>,
|
||||
/// Deezer answered with a record whose title matches no form of ours,
|
||||
/// so MusicBrainz was consulted. When MB also missed, `candidate` still
|
||||
/// carries Deezer's wrong answer — shown honestly as "Wrong record".
|
||||
pub deezer_wrong: bool,
|
||||
}
|
||||
|
||||
/// ASCII-normalize for title matching, exactly as the mock's normx: only
|
||||
/// lowercase ASCII alphanumerics survive. A fully non-Latin title
|
||||
/// normalizes to "" and therefore matches anything — the Kuchu Camp gap,
|
||||
/// kept faithfully (script-aware retry is a someday).
|
||||
fn normx(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The mock's match rule: normalized equality or containment either way.
|
||||
/// Containment is what keeps an edition ("... 10th Anniversary Edition")
|
||||
/// from reading as a different record.
|
||||
fn normx_overlap(a: &str, b: &str) -> bool {
|
||||
let (na, nb) = (normx(a), normx(b));
|
||||
na == nb || na.contains(nb.as_str()) || nb.contains(na.as_str())
|
||||
}
|
||||
|
||||
/// Case-insensitive prefix strip, char-boundary safe.
|
||||
fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
let mut end = 0;
|
||||
let mut sc = s.chars();
|
||||
for p in prefix.chars() {
|
||||
let c = sc.next()?;
|
||||
if !c.to_lowercase().eq(p.to_lowercase()) {
|
||||
return None;
|
||||
}
|
||||
end += c.len_utf8();
|
||||
}
|
||||
Some(&s[end..])
|
||||
}
|
||||
|
||||
/// `^[\[(]\d{4}[^\])]*[\])]\s*-?\s*` — a leading "[2015] - " or
|
||||
/// "(1997.07.24) " group, dates included.
|
||||
fn strip_leading_year_group(s: &str) -> String {
|
||||
if !(s.starts_with('[') || s.starts_with('(')) {
|
||||
return s.to_string();
|
||||
}
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() < 6 || !bytes[1..5].iter().all(u8::is_ascii_digit) {
|
||||
return s.to_string();
|
||||
}
|
||||
// The group runs to the FIRST close bracket of either kind, as in the
|
||||
// mock's character class.
|
||||
let Some(close) = s.find([']', ')']) else {
|
||||
return s.to_string();
|
||||
};
|
||||
if close < 5 {
|
||||
return s.to_string();
|
||||
}
|
||||
let rest = s[close + 1..].trim_start();
|
||||
let rest = rest.strip_prefix('-').unwrap_or(rest).trim_start();
|
||||
rest.to_string()
|
||||
}
|
||||
|
||||
/// Bracket groups that carry rip descriptors — "[V0]", "(320kbps)",
|
||||
/// "[WEB FLAC]" — removed whole (substring match inside the group, as the
|
||||
/// mock's alternation behaves).
|
||||
fn strip_rip_groups(s: &str) -> String {
|
||||
const TOKENS: [&str; 10] = [
|
||||
"kbps", "v0", "v2", "web", "flac", "mp3", "320", "256", "192", "128",
|
||||
];
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(open) = rest.find(['[', '(']) {
|
||||
let after = &rest[open + 1..];
|
||||
let Some(close) = after.find([']', ')']) else { break };
|
||||
let low = after[..close].to_lowercase();
|
||||
if TOKENS.iter().any(|t| low.contains(t)) {
|
||||
out.push_str(&rest[..open]);
|
||||
out.push(' ');
|
||||
} else {
|
||||
out.push_str(&rest[..open + 1 + close + 1]);
|
||||
}
|
||||
rest = &after[close + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Free-standing rip tokens (`\b`-bounded in the mock): whole words only,
|
||||
/// so "320kbps" the word survives while "320" alone goes.
|
||||
fn strip_rip_words(s: &str) -> String {
|
||||
const WORDS: [&str; 14] = [
|
||||
"web", "v0", "v2", "flac", "mp3", "320", "320k", "256", "192", "128", "128kbps",
|
||||
"kbps", "vinyl", "rip",
|
||||
];
|
||||
fn flush(word: &mut String, out: &mut String) {
|
||||
if word.is_empty() {
|
||||
return;
|
||||
}
|
||||
let low = word.to_lowercase();
|
||||
let user_n = low
|
||||
.strip_prefix("user")
|
||||
.is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()));
|
||||
if WORDS.contains(&low.as_str()) || user_n {
|
||||
out.push(' ');
|
||||
} else {
|
||||
out.push_str(word);
|
||||
}
|
||||
word.clear();
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut word = String::new();
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() || c == '_' {
|
||||
word.push(c);
|
||||
} else {
|
||||
flush(&mut word, &mut out);
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
flush(&mut word, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The cleaned query try-list for one folder title, least- to most-
|
||||
/// aggressive, deduped; may be empty when nothing survives cleaning.
|
||||
pub fn search_titles(artist: &str, raw_title: &str) -> Vec<String> {
|
||||
// Invisible marks break matching.
|
||||
let t: String = raw_title
|
||||
.chars()
|
||||
.filter(|c| !matches!(c, '\u{200e}' | '\u{200f}' | '\u{feff}'))
|
||||
.collect();
|
||||
let mut t = t.trim().to_string();
|
||||
|
||||
t = strip_leading_year_group(&t);
|
||||
for tag in ["(album)", "(single)", "(ep)"] {
|
||||
if let Some(rest) = strip_prefix_ci(&t, tag) {
|
||||
t = rest.trim_start().to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The artist repeated at the front of the album folder (one separator
|
||||
// dash at most, as the mock's regex).
|
||||
if let Some(rest) = strip_prefix_ci(&t, artist) {
|
||||
let rest = rest.trim_start();
|
||||
let rest = rest.strip_prefix(['-', '–', '—']).unwrap_or(rest);
|
||||
let rest = rest.trim_start();
|
||||
if !rest.is_empty() {
|
||||
t = rest.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Least aggressive: rip tokens and their bracket groups stripped.
|
||||
let cleaned = {
|
||||
let g = strip_rip_groups(&t);
|
||||
let w = strip_rip_words(&g);
|
||||
let d = w.trim_end();
|
||||
let d = d.strip_suffix(['-', '–', '—']).unwrap_or(d);
|
||||
d.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
};
|
||||
// The app's simplifyTitle: cut at the first ; : ( [ and cap at six words.
|
||||
let base = if cleaned.is_empty() { t.clone() } else { cleaned.clone() };
|
||||
let first = base
|
||||
.split([';', ':', '(', '['])
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let simp_src = if first.is_empty() { base } else { first };
|
||||
let simplified = simp_src
|
||||
.split_whitespace()
|
||||
.take(6)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let mut out = Vec::new();
|
||||
for v in [cleaned, simplified] {
|
||||
if !v.is_empty() && !out.contains(&v) {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct CoverCandidate {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
width: u32,
|
||||
@@ -13875,6 +14344,44 @@ fn urlencode(s: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// The try-list port (`search_titles`) against the exact folder shapes
|
||||
/// the mock proved on: rip descriptors, date/type prefixes, doubled
|
||||
/// artists (research/import/enrich.mjs searchTitles).
|
||||
#[test]
|
||||
fn search_titles_cleans_rip_descriptors() {
|
||||
use super::search_titles;
|
||||
assert_eq!(
|
||||
search_titles("Vashti Bunyan", "Heartleap - WEB V0"),
|
||||
vec!["Heartleap".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
search_titles("Vashti Bunyan", "Just Another Diamond Day [V0]"),
|
||||
vec!["Just Another Diamond Day".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
search_titles("Fishmans", "(1997.07.24) (album) Uchu Nippon Setagaya [128kbps]"),
|
||||
vec!["Uchu Nippon Setagaya".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
search_titles("Smog", "Smog - The Doctor Came At Dawn (1996)"),
|
||||
vec![
|
||||
"The Doctor Came At Dawn (1996)".to_string(),
|
||||
"The Doctor Came At Dawn".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The edition rule's foundation: containment matches, disjoint titles
|
||||
/// don't (Bastard → Cherry Bomb is the canonical wrong record).
|
||||
#[test]
|
||||
fn normx_overlap_editions_match_wrong_records_do_not() {
|
||||
assert!(super::normx_overlap(
|
||||
"Random Access Memories (10th Anniversary Edition)",
|
||||
"Random Access Memories"
|
||||
));
|
||||
assert!(!super::normx_overlap("Cherry Bomb", "Bastard"));
|
||||
}
|
||||
|
||||
/// Is a provider's image host actually failing, or is this machine?
|
||||
///
|
||||
/// Run this the next time a `failed a liveness probe` warning appears. It
|
||||
|
||||
@@ -379,13 +379,29 @@ impl Client {
|
||||
/// Start downloading `filename` (full remote path) from `username`.
|
||||
/// Progress streams out as [`Event::Download`]. Returns the download id.
|
||||
pub fn download(&self, username: &str, filename: &str, size: u64) -> u64 {
|
||||
transfer::start_download(self.inner.clone(), username, filename, size, None)
|
||||
transfer::start_download(self.inner.clone(), username, filename, size, None, |_| {})
|
||||
}
|
||||
|
||||
/// Like [`download`](Self::download) but writing to an explicit local
|
||||
/// path (used for stream-to-cache).
|
||||
pub fn download_to(&self, username: &str, filename: &str, size: u64, target: PathBuf) -> u64 {
|
||||
transfer::start_download(self.inner.clone(), username, filename, size, Some(target))
|
||||
transfer::start_download(self.inner.clone(), username, filename, size, Some(target), |_| {})
|
||||
}
|
||||
|
||||
/// `download_to`, with `claim` run on the new id BEFORE the transfer task
|
||||
/// is spawned. A download emits `Connecting` the instant it starts, so a
|
||||
/// caller that must recognise its own transfers — the import tool's
|
||||
/// audition pen — has to be able to register the id first. Doing it on the
|
||||
/// returned value is a race the emitter usually wins.
|
||||
pub fn download_to_claimed(
|
||||
&self,
|
||||
username: &str,
|
||||
filename: &str,
|
||||
size: u64,
|
||||
target: PathBuf,
|
||||
claim: impl FnOnce(u64),
|
||||
) -> u64 {
|
||||
transfer::start_download(self.inner.clone(), username, filename, size, Some(target), claim)
|
||||
}
|
||||
|
||||
/// Cancel an active download by id.
|
||||
|
||||
@@ -160,6 +160,11 @@ fn walk(dir: &Path, base: &str, root: &Path, index: &mut Index) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
// The download staging pen is not part of anyone's library —
|
||||
// half-finished transfers are not shareable records.
|
||||
if path.file_name().is_some_and(|n| n == "(Cache)") {
|
||||
continue;
|
||||
}
|
||||
walk(&path, base, root, index);
|
||||
} else if is_audio(&path) {
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
|
||||
@@ -57,10 +57,15 @@ pub(crate) fn start_download(
|
||||
filename: &str,
|
||||
size: u64,
|
||||
target: Option<PathBuf>,
|
||||
claim: impl FnOnce(u64),
|
||||
) -> u64 {
|
||||
let id = inner
|
||||
.download_counter
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
// Before the spawn below, never after: the task's first act is to emit
|
||||
// Connecting, and a listener that has not yet claimed this id cannot tell
|
||||
// the event is theirs.
|
||||
claim(id);
|
||||
let cancel = CancellationToken::new();
|
||||
inner.downloads_by_file.lock().unwrap().insert(
|
||||
(username.to_string(), filename.to_string()),
|
||||
@@ -362,6 +367,11 @@ impl Download {
|
||||
None => ("", self.filename.as_str()),
|
||||
};
|
||||
let mut dir = self.inner.downloads_dir.clone();
|
||||
// Staging lives in ONE visible pen — "(Cache)" — never loose in the
|
||||
// library root: a half-finished transfer is not a record, and a
|
||||
// peer's username is not an artist. organize_download lifts the
|
||||
// finished file out; cleanup_staging tidies what it leaves behind.
|
||||
dir.push("(Cache)");
|
||||
dir.push(sanitize(&self.username));
|
||||
if !folder.is_empty() {
|
||||
dir.push(sanitize(folder));
|
||||
|
||||
@@ -70,6 +70,8 @@ pub struct TagWrite {
|
||||
pub title: Option<String>,
|
||||
pub track_no: Option<u32>,
|
||||
pub year: Option<u32>,
|
||||
pub label: Option<String>,
|
||||
pub genre: Option<String>,
|
||||
}
|
||||
|
||||
/// Cover bytes come from several providers; PNG originals exist (Cover Art
|
||||
@@ -117,6 +119,12 @@ pub fn write_tags(
|
||||
if let Some(v) = &write.album_artist {
|
||||
tag.insert_text(lofty::tag::ItemKey::AlbumArtist, v.clone());
|
||||
}
|
||||
if let Some(v) = &write.genre {
|
||||
tag.set_genre(v.clone());
|
||||
}
|
||||
if let Some(v) = &write.label {
|
||||
tag.insert_text(lofty::tag::ItemKey::Label, v.clone());
|
||||
}
|
||||
|
||||
if let Some(bytes) = art_jpeg {
|
||||
if replace_art {
|
||||
@@ -139,6 +147,26 @@ pub fn write_tags(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The embedded artwork's bytes, front cover first, any picture otherwise.
|
||||
///
|
||||
/// This is the import tool's "yours" side: in the reference library 449 of
|
||||
/// 515 records carry their best cover INSIDE the files, and a scan that only
|
||||
/// looks at loose images sees Windows Media Player's 200px cache instead.
|
||||
/// lofty parses the whole tag, so the 20MB tags that defeated a capped read
|
||||
/// (a 586MB single-file live set) are handled like any other.
|
||||
pub fn extract_art(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
let tagged = Probe::open(path)?.read()?;
|
||||
let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let pics = tag.pictures();
|
||||
let best = pics
|
||||
.iter()
|
||||
.find(|p| p.pic_type() == PictureType::CoverFront)
|
||||
.or_else(|| pics.first());
|
||||
Ok(best.map(|p| p.data().to_vec()))
|
||||
}
|
||||
|
||||
/// Replace any existing embedded pictures with the given front cover.
|
||||
pub fn replace_art(path: &Path, art_jpeg: &[u8]) -> Result<()> {
|
||||
let mut tagged = Probe::open(path)?.read()?;
|
||||
|
||||
Reference in new issue
Block a user