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:
KeelrLeek committed 2026-08-10 07:55:59 -05:00
1 parent 4b30c5e573
commit e7d0971de2
23 files changed
+6498 -258

No files matched your search

+30
View File
@@ -223,6 +223,36 @@ export const playerFadePauseAcked = (fadeMs) =>
export const playerFadeResume = (fadeMs) =>
invoke("player_fade_resume", { fadeMs });
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) =>
invoke("preview_control", { action, value });
export const previewDispose = () => invoke("preview_dispose");
Binary file not shown.
+63
View File
@@ -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 };
}
+203
View File
@@ -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 };
}
+47 -13
View File
@@ -213,6 +213,27 @@ export function announceFollow(on, artist) {
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({
view: "home",
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
// surface can open it, not only the playing track.
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
// window is minimized. This is the authoritative shared activity gate for
// hidden animation, canvas, carousel, and high-frequency position work.
@@ -3591,7 +3617,7 @@ export const resolver = $state({
error: "",
});
const RESOLVER_AUDIO_QUALITY = {
export const RESOLVER_AUDIO_QUALITY = {
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,
};
@@ -3794,7 +3820,7 @@ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
/** Query with server-excluded phrases removed (whole-word, any position) and
* whitespace collapsed. Returns "" when nothing usable remains. */
function sanitizeSearchQuery(query) {
export function sanitizeSearchQuery(query) {
let out = ` ${query ?? ""} `;
for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) {
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;
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();
if (gap > 0) await new Promise((r) => setTimeout(r, gap));
lastSearchStartedAt = performance.now();
@@ -4474,16 +4502,22 @@ export function tinyCover(url) {
* art, badges, album-folder source preference, and length verification. */
export async function streamSong(artist, title, opts = {}) {
if (resolver.active) return;
const local = await libraryTrackFor(artist, title);
if (local) {
playAlbum(
local.tracks,
`${local.album.artist} — ${local.album.title}`,
local.index,
local.album.art_path ?? null,
);
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
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);
if (local) {
playAlbum(
local.tracks,
`${local.album.artist} — ${local.album.title}`,
local.index,
local.album.art_path ?? null,
);
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
return;
}
}
// Streamed before and still byte-complete in the cache? Play those bytes
// now — no search, no resolver popup, no network requirement. This is the