// 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} [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 }; }