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

+24 -5
View File
@@ -4114,12 +4114,31 @@ pub async fn refresh_all_album_art(app: tauri::AppHandle) -> Result<usize, Strin
}
/// Album art as a data URL (small files; avoids asset-protocol scopes).
///
/// With `max_edge` the art comes back as a Lanczos3 thumbnail bounded to that
/// many pixels on its long side — what every tile wants. Masters here run to
/// 2000px+, and handing one of those to a 132px card makes the webview do a
/// 15x single-step reduction; `thumbnail_jpeg` explains why that aliases. It
/// also cuts the base64 a tile carries from megabytes to tens of kilobytes.
///
/// Callers that must show TRUE pixels — the fullscreen viewer, the import
/// picker's A/B and its loupe — pass no `max_edge` and get the file untouched.
/// A source already within bounds is passed through rather than re-encoded, so
/// this never costs quality it was meant to protect.
#[tauri::command]
pub async fn album_art(path: String) -> Result<String, String> {
let bytes = tauri::async_runtime::spawn_blocking(move || std::fs::read(path))
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
pub async fn album_art(path: String, max_edge: Option<u32>) -> Result<String, String> {
let bytes = tauri::async_runtime::spawn_blocking(move || -> Result<Vec<u8>, String> {
let raw = std::fs::read(&path).map_err(|e| e.to_string())?;
let Some(edge) = max_edge else { return Ok(raw) };
// An undecodable or already-small source falls through to the original
// bytes: a thumbnail is an optimisation, never a reason to show nothing.
match pipeline::thumbnail_jpeg(&raw, edge) {
Ok(Some(thumb)) => Ok(thumb),
_ => Ok(raw),
}
})
.await
.map_err(|e| e.to_string())??;
use base64::Engine as _;
Ok(format!(
"data:image/jpeg;base64,{}",
+254 -20
View File
@@ -571,6 +571,58 @@ fn local_art_of(app: &tauri::AppHandle, source_root: &str, rel: &str) -> Option<
})
}
/// A record's own artwork, in the order the review means it: embedded in its
/// files first, then the best loose image sitting beside them.
///
/// Mirrors [`local_art_of`], which is what the picker shows as "yours" — the
/// two must agree, or the review offers a cover apply cannot file.
fn art_of(dir: &Path, files: &[PathBuf]) -> Option<Vec<u8>> {
for f in files.iter().take(5) {
if let Ok(Some(bytes)) = tagger::extract_art(f) {
return Some(bytes);
}
}
folder_image_of(dir).and_then(|p| std::fs::read(&p).ok())
}
/// The best loose image in a record's own folder, largest first, WMP's derived
/// thumbnails only when nothing else is there.
///
/// Shared with [`local_art_of`]'s rules on purpose: the picker offers this
/// picture as "yours", so apply has to be able to file the same one. Apply
/// used to look only for EMBEDDED art, which meant a record whose artwork
/// lives in a `Folder.jpg` was shown a cover in the review, accepted with
/// "yours", and then imported with no cover at all.
fn folder_image_of(dir: &Path) -> Option<PathBuf> {
let mut real: Vec<PathBuf> = Vec::new();
let mut derived: Vec<PathBuf> = Vec::new();
for e in std::fs::read_dir(dir).ok()?.flatten() {
let p = e.path();
let is_img = p.is_file()
&& p.extension()
.and_then(|x| x.to_str())
.map(|x| IMAGE_EXTS.contains(&x.to_ascii_lowercase().as_str()))
.unwrap_or(false);
if !is_img {
continue;
}
let name = p.file_name().unwrap_or_default().to_string_lossy().to_lowercase();
if name.starts_with("albumart") {
derived.push(p);
} else {
real.push(p);
}
}
let pool = if real.is_empty() { derived } else { real };
pool.into_iter()
.filter_map(|p| {
let (w, h) = image::image_dimensions(&p).ok()?;
Some((p, u64::from(w) * u64::from(h)))
})
.max_by_key(|(_, px)| *px)
.map(|(p, _)| p)
}
/* -------------------------------------------------------------- audition -- */
/// Transfer ids belonging to audition downloads, each mapped to the record it
@@ -623,9 +675,16 @@ pub(crate) fn audition_dir(download_root: &Path, rel: &str) -> PathBuf {
/// Download a found album copy into `(Cache)\_audition\<rel>\` for the
/// version comparison — the WHOLE album (his call: keeping it later is a
/// move, not a re-download, and one search's worth of requests). The caller
/// orders the list with track 1 first so the A/B can start while the rest
/// fills in. Returns transfer ids aligned with the input files.
/// move, not a re-download, and one search's worth of requests).
///
/// Called TWICE per copy: track 1 alone, then the rest once it lands. Asking
/// for all twelve at once put eleven of our own requests in front of the one
/// file the comparison needs — the peer serves its queue in its own order, so
/// "track 1 first" was only ever true of the list we sent. `start_index` keeps
/// the second batch's indices continuing from the first, because the UI reads
/// index 0 as "this is track 1".
///
/// Returns transfer ids aligned with the input files.
#[tauri::command]
pub async fn import_audition(
app: tauri::AppHandle,
@@ -633,6 +692,7 @@ pub async fn import_audition(
username: String,
rel: String,
files: Vec<AuditionFile>,
start_index: Option<usize>,
) -> Result<Vec<u64>, String> {
let guard = state.client.lock().await;
let client = guard.as_ref().ok_or("not connected")?;
@@ -647,8 +707,9 @@ pub async fn import_audition(
// immediately, and an event whose id is not yet registered is one the
// UI throws away — and worse, one commands.rs routes down the ordinary
// download path instead of the pen's.
let idx = start_index.unwrap_or(0) + i;
let id = client.download_to_claimed(&username, &f.path, f.size, target, |id| {
AUDITION_IDS.lock().unwrap().insert(id, (rel.clone(), i));
AUDITION_IDS.lock().unwrap().insert(id, (rel.clone(), idx));
});
ids.push(id);
}
@@ -1029,11 +1090,25 @@ fn apply_one(
if let Some(bytes) = chosen_art {
art_sources.push(bytes.to_vec());
} else {
for f in files.iter().take(5) {
if let Ok(Some(bytes)) = tagger::extract_art(f) {
art_sources.push(bytes);
break;
}
// No provider cover chosen means "keep the LOCAL artwork" — and local
// is the user's OWN folder, which is where the review found the
// picture it put on screen.
//
// This used to read whatever folder the AUDIO came from, which is a
// different folder entirely once a record keeps the found copy: the
// pen. A downloaded album frequently carries no embedded picture, so
// keeping someone else's copy threw away the artwork the review had
// just shown — three records imported with no cover at all while
// their own files held a 1500px one.
//
// Art follows the RECORD; only the audio follows the choice.
let own_dir = src_root.join(&a.rel);
let mut own_files = audio_files_in(&own_dir);
own_files.sort();
if let Some(bytes) =
art_of(&own_dir, &own_files).or_else(|| art_of(&src_dir, &files))
{
art_sources.push(bytes);
}
}
// The master cover is the album folder's cover.jpg — filed into the
@@ -1089,17 +1164,24 @@ fn apply_one(
// from an earlier apply: reuse it, so re-applying is idempotent
// rather than a " (2)" duplicate factory.
let mut dest = dest_dir.join(pipeline::safe_component(&name));
let src_len = std::fs::metadata(src).map(|m| m.len()).unwrap_or(0);
// "Same name, same size" means an EARLIER RUN already filed this very
// track — but only if this run has not just written that path itself.
// A folded release flattens its parts into one folder, and parts number
// from 01 each: `Disc 1/01.mp3` and `Disc 2/01.mp3` collide, and often
// at the same length, so the idempotency rule read the second one as
// already-imported and dropped it while still counting it. Two
// different files in one run are a collision, never a reuse.
let already = dest.exists()
&& !kept.contains(&dest.to_string_lossy().to_lowercase())
&& std::fs::metadata(&dest).map(|m| m.len()).unwrap_or(u64::MAX) == src_len;
// A file already at this path, that THIS RUN did not write, is this
// very track from an earlier apply.
//
// Decided by name alone. It used to also require the destination to be
// the same SIZE as the source, which was self-defeating: apply writes
// the review's tags and embeds the chosen cover INTO the copy, so an
// imported file is bigger than its source from the moment it is made
// (582 KB bigger, measured on a Tribe Called Quest track). The sizes
// could therefore never match again, `already` was false on every
// re-run, and each import laid down another " (n)" copy — three runs
// left three copies of every track on disk and three rows in the
// library. The rule meant to stop a duplicate factory was driving one.
//
// Within ONE run a repeated name is still a genuine collision: a folded
// release flattens parts that each number from 01, so `Disc 1/01.mp3`
// and `Disc 2/01.mp3` land in the same folder. `kept` — what this run
// has written — is what tells a collision from a reuse.
let already = dest.exists() && !kept.contains(&dest.to_string_lossy().to_lowercase());
if dest.exists() && !already {
let stem = dest.file_stem().unwrap_or_default().to_string_lossy().into_owned();
let ext = dest
@@ -1340,6 +1422,158 @@ mod tests {
out
}
/// Re-importing the same record must not copy it again — even though the
/// imported files no longer match their sources byte for byte.
///
/// They never can: apply writes the review's tags and embeds the chosen
/// cover into the copy, so every imported file is larger than the source
/// it came from. The old rule asked "same name AND same size?" to decide a
/// track was already filed, so after the first run the answer was always
/// no, and each import laid down another " (n)" copy with its own library
/// row. Three runs over one album left three of everything.
#[test]
fn re_importing_does_not_duplicate_a_track_apply_has_since_rewritten() {
let b = bed(0);
let a = album(&b, Some(choices("yours", None)));
run(&b, &a).unwrap();
assert_eq!(landed(&b).len(), 2, "first import files the album");
// What tagging does: the copy grows. Nothing else about it changed.
let dir = b.dest_root.join("Weezer").join("Pinkerton");
for n in 1..=2 {
let f = dir.join(format!("{n:02} track.mp3"));
let mut body = std::fs::read(&f).unwrap();
body.extend_from_slice(b"...embedded cover bytes...");
std::fs::write(&f, body).unwrap();
}
let (tracks, _, _) = run(&b, &a).unwrap();
let after = landed(&b);
assert_eq!(tracks, 2, "the record still reports its two tracks");
assert_eq!(
after.len(),
2,
"a second import must add no files, got {:?}",
after.iter().map(|(n, _)| n).collect::<Vec<_>>()
);
assert!(
!after.iter().any(|(n, _)| n.contains('(')),
"no \" (n)\" duplicate was made: {:?}",
after.iter().map(|(n, _)| n).collect::<Vec<_>>()
);
// …and the rewritten bytes are left alone, not clobbered by a re-copy.
assert!(
after.iter().all(|(_, body)| body.ends_with("...embedded cover bytes...")),
"the imported file keeps the tags apply wrote into it"
);
}
/// A record whose artwork is a loose file rather than a tag still gets a
/// cover. The review offers that picture as "yours", so accepting it has
/// to file it — apply used to look only at embedded art, and imported a
/// record it had just shown a cover for with no cover at all.
#[test]
fn a_folder_image_is_filed_when_nothing_is_embedded() {
let b = bed(0);
let src = b.src_root.join(&b.rel);
// A real JPEG, because the cover writer decodes what it is given.
let img = image::RgbImage::from_pixel(8, 8, image::Rgb([9, 9, 9]));
let mut jpeg = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_with_encoder(image::codecs::jpeg::JpegEncoder::new(&mut jpeg))
.unwrap();
std::fs::write(src.join("Folder.jpg"), jpeg.into_inner()).unwrap();
// The tracks carry no tags at all, so there is nothing to extract.
run(&b, &album(&b, None)).unwrap();
let dir = b.dest_root.join("Weezer").join("Pinkerton");
assert!(dir.join("cover.jpg").is_file(), "the folder image became the album cover");
assert!(dir.join("folder.jpg").is_file(), "and the second canonical name");
}
/// Keeping the FOUND COPY replaces the audio, not the artwork.
///
/// The review shows the picture from YOUR folder and calls it "yours"; a
/// downloaded copy often carries none at all. Apply used to read art from
/// whichever folder the audio came from, so choosing "theirs" imported the
/// record with no cover while a 1500px one sat in the source files.
#[test]
fn keeping_theirs_still_takes_the_artwork_from_your_own_folder() {
let b = bed(2);
// The pen's copy has no picture anywhere. The user's folder does.
let src = b.src_root.join(&b.rel);
let img = image::RgbImage::from_pixel(8, 8, image::Rgb([7, 7, 7]));
let mut jpeg = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_with_encoder(image::codecs::jpeg::JpegEncoder::new(&mut jpeg))
.unwrap();
let bytes = jpeg.into_inner();
std::fs::write(src.join("Folder.jpg"), &bytes).unwrap();
run(&b, &album(&b, Some(choices("theirs", Some(2))))).unwrap();
let dir = b.dest_root.join("Weezer").join("Pinkerton");
assert!(
dir.join("cover.jpg").is_file(),
"the record's own artwork is filed even though the audio came from the pen"
);
// And the audio really is the found copy, not a silent fallback.
let landed_names: Vec<String> = landed(&b).into_iter().map(|(n, _)| n).collect();
assert!(
landed_names.iter().any(|n| n.ends_with(".flac")),
"the pen's tracks are what landed: {landed_names:?}"
);
}
/// Windows Media Player's derived thumbnails are a last resort, never a
/// preference — a real picture beside them wins.
#[test]
fn a_real_folder_image_beats_a_media_player_thumbnail() {
let b = bed(0);
let src = b.src_root.join(&b.rel);
let write = |name: &str, size: u32| {
let img = image::RgbImage::from_pixel(size, size, image::Rgb([1, 2, 3]));
let mut out = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_with_encoder(image::codecs::jpeg::JpegEncoder::new(&mut out))
.unwrap();
std::fs::write(src.join(name), out.into_inner()).unwrap();
};
write("AlbumArt_Large.jpg", 64); // WMP's, and the bigger of the two
write("Folder.jpg", 16);
let picked = super::folder_image_of(&src).unwrap();
assert_eq!(
picked.file_name().unwrap(),
"Folder.jpg",
"a real image wins even when the derived one is larger"
);
}
/// The other half of the same rule: two DIFFERENT files that land on one
/// name inside a single run are a collision, and both must survive.
#[test]
fn one_run_still_disambiguates_two_files_with_the_same_name() {
let b = bed(0);
// A folded release: parts that each number from 01.
let part_a = b.src_root.join(&b.rel).join("Disc 1");
let part_b = b.src_root.join(&b.rel).join("Disc 2");
std::fs::create_dir_all(&part_a).unwrap();
std::fs::create_dir_all(&part_b).unwrap();
std::fs::write(part_a.join("01 track.mp3"), "disc one").unwrap();
std::fs::write(part_b.join("01 track.mp3"), "disc two").unwrap();
let mut a = album(&b, None);
a.parts = vec!["Disc 1".into(), "Disc 2".into()];
let (tracks, _, _) = run(&b, &a).unwrap();
let after = landed(&b);
assert_eq!(tracks, 2, "both discs' track 1 is imported");
let bodies: Vec<&str> = after.iter().map(|(_, body)| body.as_str()).collect();
assert!(bodies.contains(&"disc one") && bodies.contains(&"disc two"),
"neither disc was dropped as a false reuse: {after:?}");
}
#[test]
fn keeping_theirs_moves_the_pen_and_leaves_the_source_alone() {
let b = bed(2);
+32 -6
View File
@@ -768,16 +768,42 @@ async fn fetch_artist_art_cached(
Some(target.to_string_lossy().into_owned())
}
/// One definition of "a thumbnail" for every surface that shows art in a tile:
/// Lanczos3 down to `max_edge`, re-encoded JPEG q92.
///
/// The webview is a poor resampler for big reductions. It scales a decoded
/// source into the tile in ONE bilinear step, so a 2000px cover in a 132px
/// card samples roughly every fifteenth pixel — which reads as aliasing, and
/// is worst on photographic covers with fine detail or overlaid text. Lanczos3
/// on the Rust side averages the pixels that are actually being dropped.
///
/// `Ok(None)` means the source is already within `max_edge` and should be
/// handed through untouched; `Err(())` means it could not be decoded at all.
/// Aspect ratio is preserved (`resize` fits inside the box), so a non-square
/// cover stays non-square and the tile's `object-fit: cover` still does the
/// cropping — the thumbnail never decides the composition.
pub fn thumbnail_jpeg(bytes: &[u8], max_edge: u32) -> Result<Option<Vec<u8>>, ()> {
let decoded = image::load_from_memory(bytes).map_err(|_| ())?;
if decoded.width().max(decoded.height()) <= max_edge {
return Ok(None);
}
let thumb = decoded.resize(max_edge, max_edge, image::imageops::FilterType::Lanczos3);
let mut out = std::io::Cursor::new(Vec::new());
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 92);
thumb.write_with_encoder(encoder).map_err(|_| ())?;
Ok(Some(out.into_inner()))
}
/// Preserve the original portrait, but hand the webview a clean 512px
/// thumbnail generated with Lanczos3. This is large enough for a 2x card and
/// avoids browser scaling a huge compressed source directly into the tile.
fn write_artist_thumbnail(bytes: &[u8], target: &Path) -> Option<()> {
let decoded = image::load_from_memory(bytes).ok()?;
let thumb = decoded.resize(512, 512, image::imageops::FilterType::Lanczos3);
let mut out = std::io::Cursor::new(Vec::new());
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 92);
thumb.write_with_encoder(encoder).ok()?;
std::fs::write(target, out.into_inner()).ok()
let out = match thumbnail_jpeg(bytes, 512) {
Ok(Some(thumb)) => thumb,
Ok(None) => bytes.to_vec(),
Err(()) => return None,
};
std::fs::write(target, out).ok()
}
/// Refresh portraits for every distinct artist already represented in the