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:
1 parent
e7d0971de2
commit
c4ff6269a3
14 files changed
+1326
-138
No files matched your search
+8
-4
@@ -130,7 +130,10 @@ export const smtcMetadata = (title, artist, album, art) =>
|
||||
});
|
||||
// The WebView blocks target="_blank"; external links open via the backend.
|
||||
export const openExternal = (url) => invoke("open_external", { url });
|
||||
export const albumArt = (path) => invoke("album_art", { path });
|
||||
// `maxEdge` asks the backend for a Lanczos3 thumbnail bounded to that many
|
||||
// pixels on the long side. Omit it only where true pixels matter (the
|
||||
// fullscreen viewer, the import picker's comparison and loupe).
|
||||
export const albumArt = (path, maxEdge = null) => invoke("album_art", { path, maxEdge });
|
||||
export const refreshAlbumArt = (albumId) => invoke("refresh_album_art", { albumId });
|
||||
export const refreshAllAlbumArt = () => invoke("refresh_all_album_art");
|
||||
export const getSettings = () => invoke("get_settings");
|
||||
@@ -244,9 +247,10 @@ export const importLocalArt = (sourceRoot, rel) => invoke("import_local_art", {
|
||||
// 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 });
|
||||
// comparison. Called track 1 first, then the rest — `startIndex` keeps the
|
||||
// second batch's indices continuing, since index 0 means "track 1".
|
||||
export const importAudition = (username, rel, files, startIndex = 0) =>
|
||||
invoke("import_audition", { username, rel, files, startIndex });
|
||||
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 });
|
||||
|
||||
Binary file not shown.
@@ -21,7 +21,13 @@ export const coreTitle = (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. */
|
||||
* fact the user can check, not a score they have to trust.
|
||||
*
|
||||
* `artist` and `title` must be the RESOLVED names — what the provider was
|
||||
* actually asked about (see import-title.js), never the raw folder. Judging a
|
||||
* candidate against a folder like "[WARP CD 156] Battles - Mirrored - 2007"
|
||||
* turns an exact answer into a mere containment, and files a clean match as
|
||||
* "close". */
|
||||
export function evidence(c, artist, title, trackCount) {
|
||||
if (!c) return { items: [], verdict: "none", edition: false };
|
||||
const artistOk = normx(c.artist) === normx(artist);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// What a scanned record calls ITSELF — the name the catalog should be asked
|
||||
// about.
|
||||
//
|
||||
// The folder is the ripper's naming. It carries catalog numbers, rip
|
||||
// descriptors, years and initials: "[WARP CD 156] Battles - Mirrored - 2007",
|
||||
// "AJJ_ChristmasIsland", "ACPP". The tags are the record's own claim:
|
||||
// "Mirrored", "Christmas Island", "Prospect Park, Brooklyn NY (7.12.2011)".
|
||||
// Across the reference library 501 of 515 records carry a tag album and 315 of
|
||||
// those disagree with their folder — so asking a provider about the folder is
|
||||
// asking the wrong question, and no amount of cleaning turns "ACPP" into the
|
||||
// right one.
|
||||
//
|
||||
// This is deliberately NOT used for the Soulseek search. Peers are searched by
|
||||
// FILE NAME, where the ripper's descriptors are exactly what finds a match —
|
||||
// the folder that fails at Deezer is the one that found "[WARP 156CD] Battles"
|
||||
// on the network. Catalog lookups want the claim; file search wants the naming.
|
||||
|
||||
/** Which generation of the identify logic produced a cached answer.
|
||||
*
|
||||
* A cached MATCH is good forever. A cached "nothing found" is only as good as
|
||||
* the thing that did the looking, so when identification learns a new way to
|
||||
* find a record, every stored negative becomes a stale opinion — and nothing
|
||||
* re-asks, because the cache is never revisited once written.
|
||||
*
|
||||
* Bump this whenever identify gains ground. Negatives stamped with an older
|
||||
* number are dropped on the next session load and asked again.
|
||||
*
|
||||
* 1. Deezer try-list, MusicBrainz fallback.
|
||||
* 2. + the artist's own catalogue, ranked by title similarity — finds records
|
||||
* whose folder name no release carries ("1989 Pauls Boutique"). */
|
||||
export const IDENTIFY_ENGINE = 2;
|
||||
|
||||
/** The value most of the list agrees on, ignoring blanks. Ties go to the first
|
||||
* value seen, which keeps the result stable for a re-render. */
|
||||
export function majority(values) {
|
||||
const tally = new Map();
|
||||
for (const v of values) if (v != null && v !== "") tally.set(v, (tally.get(v) ?? 0) + 1);
|
||||
let best = null;
|
||||
let n = 0;
|
||||
for (const [v, c] of tally) if (c > n) { best = v; n = c; }
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The album name to ask a provider about: the tags' majority, the folder as
|
||||
* fallback.
|
||||
*
|
||||
* A FOLDED release is the exception and takes its folder's name. Its parts'
|
||||
* tags describe the parts ("Analord 01"…"Analord 11"), so a tag majority
|
||||
* would title the whole set after whichever volume carried the most tracks.
|
||||
*
|
||||
* `record` is a scanned album: { parts?, title, tracks: [{ album }] }. */
|
||||
export function albumNameOf(record) {
|
||||
if (!record) return null;
|
||||
if (record.parts) return record.title;
|
||||
return majority((record.tracks ?? []).map((t) => t.album)) ?? record.title;
|
||||
}
|
||||
|
||||
/** The artist to ask about. A record nested under an artist folder is named by
|
||||
* that folder; a loose one has only its tags to go on. */
|
||||
export function artistNameOf(record) {
|
||||
if (!record) return null;
|
||||
if (record.depth >= 2) return record.artistFolder;
|
||||
return majority((record.tracks ?? []).map((t) => t.artist)) ?? record.artistFolder;
|
||||
}
|
||||
+67
-1
@@ -404,9 +404,22 @@ const ART_CACHE_MAX_CHARS = 32 * MIB;
|
||||
|
||||
export function evictArt(path) {
|
||||
artCache.delete(path);
|
||||
// The thumbnails are keyed by edge as well, so a replaced cover has to drop
|
||||
// every size it was cached at — otherwise refreshing art repaints the tiles
|
||||
// from the old picture while the viewer shows the new one.
|
||||
for (const key of [...artThumbCache.keys()]) {
|
||||
if (key.endsWith(`|${path}`)) artThumbCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getArt(path) {
|
||||
/** The art FILE, at whatever resolution it was stored — masters here run past
|
||||
* 4000px. Only for surfaces that must show true pixels: the fullscreen viewer
|
||||
* and the import picker's A/B comparison and loupe.
|
||||
*
|
||||
* Everything else wants `getArt`. Painting a master into a tile makes the
|
||||
* webview reduce it in one step, which is what made photographic covers look
|
||||
* aliased, and it carries megabytes of base64 to draw a 132px square. */
|
||||
export async function getArtMaster(path) {
|
||||
if (!path) return null;
|
||||
if (artCache.has(path)) return touchCache(artCache, path);
|
||||
// PlayerBar, NowPlaying, and accent extraction can all request the same
|
||||
@@ -442,6 +455,59 @@ export async function getArt(path) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
// `${edge}|${path}` -> data URL. Tile-sized art, resampled by Rust instead of
|
||||
// by the compositor: a 2000px master squeezed into a 132px card is a 15x
|
||||
// single-step reduction, which aliases badly on photographic covers. Held
|
||||
// separately from `artCache` because the two answer different questions —
|
||||
// this one is what a card should paint, that one is the actual file, and the
|
||||
// viewer and the import picker must keep getting the actual file.
|
||||
//
|
||||
// The entry budget is higher and the byte budget lower than the full-size
|
||||
// cache on purpose: thumbnails are ~50KB where masters run to megabytes, so
|
||||
// far more of them fit in far less memory.
|
||||
const artThumbCache = new Map();
|
||||
const ART_THUMB_CACHE_MAX = 512;
|
||||
const ART_THUMB_CACHE_MAX_CHARS = 16 * MIB;
|
||||
|
||||
/** How big a cover comes back when nobody says otherwise. Covers the largest
|
||||
* thing that paints one short of the viewer — the library expander's ~300px
|
||||
* rail — with room for a 2x display, and never upscales anything smaller. */
|
||||
export const ART_EDGE_DEFAULT = 512;
|
||||
|
||||
/** Album art as a data URL, bounded to `edge` px on the long side.
|
||||
*
|
||||
* This is the DEFAULT path on purpose. Asking for the master has to be the
|
||||
* deliberate act (`getArtMaster`), because there are ~60 places in this app
|
||||
* that paint a cover and only two of them want real pixels — with the polarity
|
||||
* the other way round, every surface anyone forgets is silently wrong.
|
||||
*
|
||||
* Aspect ratio is preserved, so a non-square cover arrives non-square and the
|
||||
* tile's own `object-fit` still decides the crop. Sources already within
|
||||
* `edge` come back untouched rather than re-encoded. Pass `null` to opt out
|
||||
* and get the master. */
|
||||
export async function getArt(path, edge = ART_EDGE_DEFAULT) {
|
||||
if (!path) return null;
|
||||
if (edge == null) return getArtMaster(path);
|
||||
const key = `${edge}|${path}`;
|
||||
if (artThumbCache.has(key)) return touchCache(artThumbCache, key);
|
||||
const pending = api.albumArt(path, edge).then(
|
||||
(url) => {
|
||||
if (artThumbCache.get(key) === pending) {
|
||||
setResolvedCache(artThumbCache, key, url, ART_THUMB_CACHE_MAX, ART_THUMB_CACHE_MAX_CHARS);
|
||||
}
|
||||
return url;
|
||||
},
|
||||
() => {
|
||||
if (artThumbCache.get(key) === pending) {
|
||||
setResolvedCache(artThumbCache, key, null, ART_THUMB_CACHE_MAX, ART_THUMB_CACHE_MAX_CHARS);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
artThumbCache.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
// Catalog cover URLs → data URLs, proxied through Rust (no CORS on CDNs).
|
||||
const catalogArtCache = new Map();
|
||||
const CATALOG_ART_CACHE_MAX = 160;
|
||||
|
||||
Reference in new issue
Block a user