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

+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