import: records named by their own tags, and covers resampled where the pixels are

Two threads from one session, both of them the same shape: something was
being decided from the wrong source.

THE PROVIDERS WERE ASKED ABOUT A FOLDER. The picker sent the directory name
to Deezer and MusicBrainz — the ripper's naming, carrying catalogue numbers,
years and initials. "[WARP CD 156] Battles - Mirrored - 2007" matches
nothing anywhere; "Mirrored" matches instantly. Across the reference library
501 of 515 records carry a tag album and 315 disagree with their folder, and
no amount of cleaning turns "ACPP" into "Prospect Park, Brooklyn NY". The
record's own claim now leads and the folder is the fallback, in one place
(src/lib/import-title.js) that apply and the picker both read — apply
already resolved it this way, and only the picker did not. The verdict judge
had the same split: it compared candidates against the folder too, so an
exact answer scored as mere containment and a clean match wore a warning.

Identification also stops searching when searching cannot work. A folder
named "1989 Pauls Boutique" is a hopeless query and an easy comparison, so
after Deezer and MusicBrainz come back empty the artist's own catalogue is
ranked by title similarity. Scoring rather than stripping is the point: 1989,
1984 and 2001 are real albums, and any cleaner aggressive enough to rescue
Paul's Boutique would have destroyed them. Words split on whitespace with
punctuation folded inside them, so "Paul's" stays one token; pressing
vocabulary is ignored for scoring only; coverage is measured from both sides.
15 assertions pin the cases that would break it.

Cached verdicts now carry the engine that produced them. A cached match is
good forever, a cached "nothing found" only as good as the thing that
looked — and nothing ever re-asked. Negatives from an older engine, or from
a query that has since changed, are dropped and asked again.

Soulseek was asked the same wrong question, with the artist said twice:
"battles battles - mirrored - 2007". The server matches on ALL terms, so
every leftover token narrows the search — that copy was only found because
the peer happened to have 2007 in its path. The ladder now leads with the
record's name and keeps the folder as the narrower rung behind it.

AVAILABILITY OUTRANKS ENCODING. Candidate copies were ranked with avgBitrate
above hasFreeSlot, and queue depth — captured on every candidate — was never
read at all. A peer with no free slot and 277 people ahead beat a free peer
with a queue of two, because its files reported a bitrate and the others
reported none, and `?? 0` scores "didn't say" as the worst possible answer.
Bitrate is now ignored between two fully-lossless copies, where it describes
the material rather than the quality.

And nothing ever gave up. Track 1 is requested alone so it is not queued
behind eleven of our own files, the rest follow once the peer proves it will
serve us, and a copy that makes no progress for 45s is abandoned for the next
ranked one — cancelling the transfers it leaves, which used to trickle on
forever. A refusal falls through to the next peer instead of ending the
record. "Try another peer" is reachable while one is loading, which it never
was.

RE-IMPORTING BUILT A DUPLICATE FACTORY. Apply decided a track was already
filed by comparing the destination's size to its source — but apply itself
rewrites that file, embedding the chosen cover, so it is 582 KB larger from
the moment it is written and the sizes can never match again. Every run laid
down another " (n)" copy with its own library row: three runs left three of
everything, 2.16 GB. A file at the destination that this run did not write is
now simply an earlier import; within one run a repeated name is still a
collision, which is what `kept` is for.

ARTWORK FOLLOWS THE RECORD, ONLY THE AUDIO FOLLOWS THE CHOICE. Apply read
art from whichever folder the audio came from, which for a kept found copy is
the pen — and a downloaded album often carries no picture, so three records
imported with no cover while their own files held a 1500px one. It also only
ever looked at tags, never at the Folder.jpg the review had just offered as
"yours".

COVERS ARE RESAMPLED BY RUST, NOT BY THE COMPOSITOR. The webview was handed
2000px masters to paint 132px tiles — a 15x single-step reduction that reads
as aliasing on photographic covers, and carries megabytes of base64 to draw a
thumbnail. Artist portraits already had this exact fix; album covers never
got it. getArt is now bounded by default and getArtMaster is the deliberate
act, because ~60 places paint a cover and only two want real pixels: with the
polarity the other way round, every surface anyone forgets is silently wrong.
The fullscreen viewer also framed non-square art inside a square element, so
its rounded corners and shadow wrapped 96px of nothing; it takes the
artwork's own ratio now, bounded on both axes.

Smaller, from the same passes: the preview play button is a real transport
rather than a restart, and leaving a record silences its own preview without
touching music started elsewhere; bucket chips navigate with per-lens memory
instead of overwriting the saved resume position; Next counts from the
record's real place in a queue that re-sorts under it, which was skipping one
record for every one reviewed under a filter; a cover URL with nothing behind
it is shown as the empty answer it is rather than a broken image in a
pickable frame; write_tags returns early when there is nothing to change,
instead of rewriting the whole file to write values it already had; and the
import window has a Start over, which says what it costs before it costs it.
This commit is contained in:
KeelrLeek committed 2026-08-10 18:08:58 -05:00
1 parent e7d0971de2
commit c4ff6269a3
14 files changed
+1326 -138

No files matched your search

+279
View File
@@ -8187,6 +8187,23 @@ impl MetadataClient {
// PURPOSE: the judge shows it as "Wrong record" honestly, which
// beats an empty card (Jenny Death stays wrong, deliberately).
}
// Still nothing: stop SEARCHING for the title and start COMPARING it.
// Both passes above ask a provider to find a phrase, so a folder whose
// name no release carries — "1989 Pauls Boutique" — cannot be found by
// either, however it is cleaned. Ranking the artist's own catalogue
// asks a different question, and one that tolerates a stray year, a
// missing apostrophe and a rip descriptor at once.
//
// Only when there is NOTHING: a wrong Deezer answer is left standing
// for the judge to call wrong, exactly as above.
if candidate.is_none() {
if let Some((c, matched, _score)) =
self.deezer_artist_catalogue(artist, raw_title, &tries).await
{
candidate = Some(c);
used = Some(matched);
}
}
IdentifyResult {
tries,
used,
@@ -8205,8 +8222,23 @@ impl MetadataClient {
.await?;
let hit = found.get("data")?.as_array()?.first()?.clone();
let id = hit.get("id")?.as_u64()?;
self.deezer_album_by_id(id, Some(&hit)).await
}
/// One album, fully hydrated: detail (label/date/genre) plus the full
/// paginated disc-aware tracklist — `/album/{id}` embeds only the first
/// ~25. `hit` is the search result when there was one; the album detail
/// carries the same fields, so the catalogue path passes None.
async fn deezer_album_by_id(
&self,
id: u64,
hit: Option<&serde_json::Value>,
) -> Option<IdentifyCandidate> {
let full = self.deezer(&format!("https://api.deezer.com/album/{id}")).await;
let hit = match hit {
Some(h) => h.clone(),
None => full.clone()?,
};
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 {
@@ -8254,6 +8286,94 @@ impl MetadataClient {
})
}
/// The artist's own catalogue, ranked against what we call this record.
///
/// The last resort, and the one that answers "the search found nothing but
/// I can see the album exists". A folder named `1989 Pauls Boutique` is a
/// hopeless SEARCH — Deezer is asked for a phrase no release carries — but
/// it is an easy COMPARISON once you have the fifty-odd titles Deezer files
/// under Beastie Boys and can score each one.
///
/// Anchored on an exactly-matching artist name, never a name-alike: the
/// same guard `select_audiodb_artist` applies, because the whole value of
/// this pass is that the candidate set is already known to be the right
/// artist's. Every name we have for the record is scored, and the best
/// title across all of them wins if it clears `MIN`.
async fn deezer_artist_catalogue(
&self,
artist: &str,
raw_title: &str,
tries: &[String],
) -> Option<(IdentifyCandidate, String, f32)> {
/// Below this, "best match" is just "least bad" — better to say
/// nothing and let the record read as unmatched.
const MIN: f32 = 0.72;
let want = normalize(artist);
if want.is_empty() {
return None;
}
let q = urlencode(artist);
let found = self
.deezer(&format!("https://api.deezer.com/search/artist?q={q}&limit=5"))
.await?;
let artist_id = found
.get("data")?
.as_array()?
.iter()
.find(|a| {
a.get("name")
.and_then(|v| v.as_str())
.is_some_and(|n| normalize(n) == want)
})
.and_then(|a| a.get("id"))
.and_then(|v| v.as_u64())?;
// The catalogue, bounded: a prolific artist with hundreds of singles
// must not turn one unmatched record into an unbounded walk.
let mut albums: Vec<(u64, String)> = Vec::new();
let mut next = Some(format!(
"https://api.deezer.com/artist/{artist_id}/albums?limit=100"
));
while let Some(url) = next {
let Some(page) = self.deezer(&url).await else { break };
for al in page.get("data").and_then(|d| d.as_array()).into_iter().flatten() {
if let (Some(id), Some(t)) = (
al.get("id").and_then(|v| v.as_u64()),
al.get("title").and_then(|v| v.as_str()),
) {
albums.push((id, t.to_string()));
}
}
if albums.len() >= 300 {
break;
}
next = page.get("next").and_then(|v| v.as_str()).map(String::from);
}
// Score every name we have for this record against every album.
let mut names: Vec<&str> = vec![raw_title];
for t in tries {
names.push(t.as_str());
}
let mut best: Option<(u64, String, f32)> = None;
for (id, title) in &albums {
let score = names
.iter()
.map(|n| title_match_score(n, title))
.fold(0.0_f32, f32::max);
if best.as_ref().is_none_or(|(_, _, b)| score > *b) {
best = Some((*id, title.clone(), score));
}
}
let (id, title, score) = best?;
if score < MIN {
return None;
}
let candidate = self.deezer_album_by_id(id, None).await?;
Some((candidate, title, score))
}
/// 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.
@@ -11417,6 +11537,115 @@ fn normx(s: &str) -> String {
.collect()
}
/// A title's words, punctuation folded INSIDE each word rather than split on.
/// "Paul's Boutique" is two words, not three — splitting on the apostrophe
/// would leave "paul" and "s" and stop it matching a folder spelled "Pauls".
fn title_words(s: &str) -> Vec<String> {
s.split_whitespace()
.map(|w| {
w.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(char::to_lowercase)
.collect::<String>()
})
.filter(|w| !w.is_empty())
.collect()
}
/// How strongly two album titles name the same record, 0.0 to 1.0.
///
/// This is a COMPARISON, never a rewrite. Nothing is stripped from either
/// side, which is the whole point: an album genuinely called "1989" still
/// scores 1.0 against "1989", while a folder called "1989 Pauls Boutique"
/// scores high against "Paul's Boutique" and zero against "Licensed to Ill".
/// Stripping a leading year would have destroyed the first case to fix the
/// second.
///
/// Scored on the EDITION-FOLDED form, so a provider selling "Paul's Boutique
/// (20th Anniversary Edition / Remastered)" is not penalised for the four
/// words that say which pressing it is. Coverage is measured from BOTH sides
/// and averaged: covering all of their title matters as much as covering all
/// of ours, so a short title contained in a long one cannot score full marks.
fn title_match_score(ours: &str, theirs: &str) -> f32 {
if ours.trim().is_empty() || theirs.trim().is_empty() {
return 0.0;
}
if normx(ours) == normx(theirs) || album_title_identity(ours) == album_title_identity(theirs) {
return 1.0;
}
let a = comparable_words(ours);
let b = comparable_words(theirs);
if a.is_empty() || b.is_empty() {
return 0.0;
}
// Multiset intersection: a word repeated in one title only counts as many
// times as the other title actually repeats it.
let mut pool: Vec<&String> = b.iter().collect();
let mut shared = 0usize;
for w in &a {
if let Some(at) = pool.iter().position(|x| *x == w) {
pool.remove(at);
shared += 1;
}
}
if shared == 0 {
return 0.0;
}
// Half for covering the shorter title, half for covering the longer. The
// first half is what lets a folder carry an extra year and still match;
// the second is what stops a two-word title matching everything that
// contains it.
let (small, large) = if a.len() <= b.len() { (a.len(), b.len()) } else { (b.len(), a.len()) };
let shared = shared as f32;
0.5 * (shared / small as f32) + 0.5 * (shared / large as f32)
}
/// Words that say which PRESSING this is rather than which record. Mirrors the
/// frontend's `EDITION_WORD` vocabulary (`import-evidence.js`), so a provider
/// selling "(20th Anniversary Edition / Remastered)" is compared on the four
/// words that name the album and not the four that name the pressing.
///
/// Deliberately narrower than that list: "original" and "special" are dropped
/// from it because they are ordinary title words ("Original Pirate Material").
const EDITION_NOISE: [&str; 14] = [
"deluxe",
"expanded",
"remaster",
"remastered",
"anniversary",
"edition",
"editions",
"reissue",
"bonus",
"version",
"explicit",
"clean",
"mono",
"stereo",
];
/// A bare ordinal — the "20th" of "20th Anniversary". Alone it identifies
/// nothing, and left in it drags a real match below the threshold.
fn is_ordinal(w: &str) -> bool {
let (digits, suffix) = w.split_at(w.len().saturating_sub(2));
!digits.is_empty()
&& digits.chars().all(|c| c.is_ascii_digit())
&& matches!(suffix, "st" | "nd" | "rd" | "th")
}
/// The words worth comparing. Falls back to the full set when stripping the
/// pressing vocabulary would leave nothing — a record really called "Deluxe"
/// still has to be comparable to itself.
fn comparable_words(s: &str) -> Vec<String> {
let all = title_words(s);
let kept: Vec<String> = all
.iter()
.filter(|w| !EDITION_NOISE.contains(&w.as_str()) && !is_ordinal(w))
.cloned()
.collect();
if kept.is_empty() { all } else { kept }
}
/// 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.
@@ -14371,6 +14600,56 @@ mod tests {
);
}
/// The ranking rule that replaced "strip the leading year".
///
/// The point of scoring rather than stripping: a title that IS a year has
/// to survive. `1989`, `1984`, `2001` and `1999` are all real albums, and
/// any cleaner aggressive enough to rescue "1989 Pauls Boutique" would
/// have reduced every one of them to nothing.
#[test]
fn title_match_score_ranks_without_stripping() {
use super::title_match_score as score;
const MIN: f32 = 0.72; // the threshold deezer_artist_catalogue uses
// The case this exists for: a year the folder added, an apostrophe the
// folder dropped, and a provider selling the anniversary pressing.
assert!(score("1989 Pauls Boutique", "Paul's Boutique") >= MIN);
assert!(
score(
"1989 Pauls Boutique",
"Paul's Boutique (20th Anniversary Edition / Remastered)"
) >= MIN
);
// …and it must not drag in the rest of the same artist's catalogue.
assert!(score("1989 Pauls Boutique", "Licensed to Ill") < MIN);
assert!(score("1989 Pauls Boutique", "Check Your Head") < MIN);
assert!(score("1989 Pauls Boutique", "Ill Communication") < MIN);
// A title that is a year keeps its own identity, and does not become a
// free match for every record that happens to carry one.
assert_eq!(score("1989", "1989"), 1.0);
assert!(score("1989", "Paul's Boutique") < MIN);
assert!(score("1984", "1989") < MIN);
assert!(score("2001", "2001") >= MIN);
// The re-recording is a different work and must not beat the original.
assert!(score("1989", "1989") > score("1989", "1989 (Taylor's Version)"));
// Rip descriptors. A short title buried in a long descriptor is the
// try-list's job, not the scorer's — `search_titles` hands its cleaned
// forms in as extra names to score, so "Heartleap - WEB V0" is matched
// through the try "Heartleap" rather than by loosening this.
assert!(score("Sung Tongs [FLAC]", "Sung Tongs") >= MIN);
// The canonical wrong record stays wrong.
assert_eq!(score("Bastard", "Cherry Bomb"), 0.0);
// A shared word is not a match.
assert!(score("Greatest Hits", "Greatest Hits II") < 1.0);
assert!(score("Kid A", "Kid Amnesiae") < MIN);
// Nothing to compare is not a match with everything.
assert_eq!(score("", "Paul's Boutique"), 0.0);
assert_eq!(score("Paul's Boutique", ""), 0.0);
}
/// The edition rule's foundation: containment matches, disjoint titles
/// don't (Bastard → Cherry Bomb is the canonical wrong record).
#[test]
+34
View File
@@ -93,8 +93,42 @@ pub fn write_tags(
art_jpeg: Option<&[u8]>,
replace_art: bool,
) -> Result<()> {
// Saving REWRITES the file, so a call with nothing to say costs the whole
// length of it twice — read and write — on top of the copy that just put
// it there. The import calls this for every track of every record that
// carries any choice at all, so a record whose review changed nothing was
// paying a full second pass over the album to write exactly the values it
// already had.
//
// Art is the subtle half: with `replace_art` false a picture is only added
// when the file HAS none, so a file that already carries art has nothing to
// do either. Checking that needs the file open, so the cheap fields are
// tested first and the probe only happens when something might change.
let writes_field = write.artist.is_some()
|| write.album_artist.is_some()
|| write.album.is_some()
|| write.title.is_some()
|| write.track_no.is_some()
|| write.year.is_some()
|| write.label.is_some()
|| write.genre.is_some();
if !writes_field && art_jpeg.is_none() {
return Ok(());
}
let mut tagged = Probe::open(path)?.read()?;
// Nothing to write, and the art it would add is already there.
if !writes_field && !replace_art {
let has_art = tagged
.primary_tag()
.or_else(|| tagged.first_tag())
.is_some_and(|t| !t.pictures().is_empty());
if has_art {
return Ok(());
}
}
if tagged.primary_tag().is_none() && tagged.first_tag().is_none() {
let tag_type = tagged.primary_tag_type();
tagged.insert_tag(Tag::new(tag_type));