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

+264 -203
View File
@@ -36,6 +36,8 @@
openCatalogAlbum,
RECENTS_FLOOR_SECS,
recentsStore,
importResume,
goToView,
} from "../lib/state.svelte.js";
import {
fileRelease,
@@ -1378,6 +1380,10 @@
if (rescanning) return;
rescanning = true;
try {
// Prune deletions + index additions on the backend FIRST — the
// refetch below otherwise re-reads the same stale rows, which is
// exactly what this button used to do.
await api.rescanLibrary().catch((e) => console.warn("rescan:", e));
await refreshLibrary();
await loadStats();
} finally {
@@ -1849,212 +1855,251 @@
<div class="lib-ambience-glow" style={highlightAccent}></div>
{/if}
{#if libraryStore.albums.length === 0}
<div class="lib-empty-lib">
<p class="big">Your music will live here</p>
<p class="sub">Download an album from Search and it lands here, tagged with art.</p>
</div>
{:else}
<header class="lib-hero">
<div class="lib-hero-top">
<h1 class="lib-h1">Library</h1>
<div class="lib-tools">
<label class="lib-search">
<Icon name="search" size={15} />
<input type="text" placeholder="Filter your library" bind:value={q} />
{#if q}
<button class="lib-clear" title="Clear" onclick={() => (q = "")}><Icon name="x" size={13} /></button>
{/if}
</label>
<button class="lib-tool" title="Rescan the library" onclick={rescan} disabled={rescanning}>
<Icon name="refresh" size={15} />
</button>
</div>
</div>
<div class="lib-stats">
<span class="lib-stat"><b>{totals.albums.toLocaleString()}</b><i>{totals.albums === 1 ? "album" : "albums"}</i></span>
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.artists}</b><i>{totals.artists === 1 ? "artist" : "artists"}</i></span>
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.tracks.toLocaleString()}</b><i>tracks</i></span>
{#if totals.secs}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{longDur(totals.secs)}</b><i>of music</i></span>
{/if}
{#if totals.bytes}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{fmtBytes(totals.bytes)}</b><i>on disk</i></span>
{/if}
{#if totals.never}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.never}</b><i>never played</i></span>
{/if}
</div>
</header>
<div class="lib-toolbar" bind:this={toolbarEl} data-narrow={narrow ? "1" : "0"}>
<div class="disco-tabs" role="tablist">
{#each [["shelves", "Shelves"], ["grid", "Grid"], ["list", "List"]] as [k, l] (k)}
<button
class="disco-tab"
class:on={view === k}
role="tab"
aria-selected={view === k}
onclick={() => setView(k)}
>{l}</button>
{/each}
</div>
<div class="lib-controls">
<span class="lib-ctl-lab">Group</span>
<div class="lib-seg">
{#each Object.entries(GROUPS) as [k, g] (k)}
<button class:on={group === k} onclick={() => setGroup(k)}>{g.label}</button>
{/each}
</div>
<span class="lib-ctl-lab">Sort</span>
<div class="sortwrap">
<button class="sortbtn" onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}>
{SORTS[sortKey].label} <Icon name="playlist" size={15} />
</button>
{#if menuOpen}
<div class="menu" role="menu">
<div class="mgroup">Sort by</div>
{#each Object.entries(SORTS) as [k, s] (k)}
<button
role="menuitem"
onclick={() => {
if (sortKey === k) desc = !desc;
else { sortKey = k; desc = s.desc !== false; }
menuOpen = false;
}}
>
{s.label}
{#if sortKey === k}
<span class="mcheck" class:up={!desc}><Icon name="chevron-down" size={13} /></span>
{/if}
</button>
{/each}
</div>
<!-- There is no separate "nothing here" screen. An empty library is the
library page with nothing in it: same hero, same toolbar, same Import
button in the place it always lives. The takeover this replaced
REMOVED the header, so the one moment importing matters most was the
one moment there was no way to do it. -->
<header class="lib-hero">
<div class="lib-hero-top">
<h1 class="lib-h1">Library</h1>
<div class="lib-tools">
<label class="lib-search">
<Icon name="search" size={15} />
<input type="text" placeholder="Filter your library" bind:value={q} />
{#if q}
<button class="lib-clear" title="Clear" onclick={() => (q = "")}><Icon name="x" size={13} /></button>
{/if}
</label>
<button class="lib-tool" title="Rescan the library" onclick={rescan} disabled={rescanning}>
<Icon name="refresh" size={15} />
</button>
<!-- Importing is not a first-run event: another folder or
someone else's drive can arrive any day, so the way in
lives here, beside the library it feeds. -->
<button class="lib-tool"
title={importResume.active
? `An import is in progress — ${importResume.pct}% reviewed. Click to continue.`
: "Import music from a folder or drive"}
onclick={() => (ui.importOpen = importResume.active ? "continue" : true)}>
<Icon name="plus" size={15} />
Import
{#if importResume.active}
<span class="lib-import-pct">{importResume.pct}%</span>
{/if}
</div>
<button class="lib-dir" class:up={!desc} title="Reverse order" onclick={() => (desc = !desc)}>
<Icon name="chevron-down" size={14} />
</button>
</div>
{#if view === "list"}
<!-- The column header is the bar's own last row: one box, one
pin — it cannot drift from the bar or hide behind it at
any width (handoff §4b.3, Kyle's pick). -->
<div class="lb-tr lb-th">
<span></span><span>Record</span><span>Artist</span><span>Type</span><span>Year</span><span>#</span>
<span>Length</span><span>Format</span><span>Rate</span><span>Size</span><span>Added</span>
</div>
<div class="lib-stats">
<span class="lib-stat"><b>{totals.albums.toLocaleString()}</b><i>{totals.albums === 1 ? "album" : "albums"}</i></span>
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.artists}</b><i>{totals.artists === 1 ? "artist" : "artists"}</i></span>
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.tracks.toLocaleString()}</b><i>tracks</i></span>
{#if totals.secs}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{longDur(totals.secs)}</b><i>of music</i></span>
{/if}
{#if totals.bytes}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{fmtBytes(totals.bytes)}</b><i>on disk</i></span>
{/if}
{#if totals.never}
<span class="lib-stat-div"></span>
<span class="lib-stat"><b>{totals.never}</b><i>never played</i></span>
{/if}
</div>
</header>
<div class="lib-toolbar" bind:this={toolbarEl} data-narrow={narrow ? "1" : "0"}>
<div class="disco-tabs" role="tablist">
{#each [["shelves", "Shelves"], ["grid", "Grid"], ["list", "List"]] as [k, l] (k)}
<button
class="disco-tab"
class:on={view === k}
role="tab"
aria-selected={view === k}
onclick={() => setView(k)}
>{l}</button>
{/each}
</div>
<div class="lib-controls">
<span class="lib-ctl-lab">Group</span>
<div class="lib-seg">
{#each Object.entries(GROUPS) as [k, g] (k)}
<button class:on={group === k} onclick={() => setGroup(k)}>{g.label}</button>
{/each}
</div>
<span class="lib-ctl-lab">Sort</span>
<div class="sortwrap">
<button class="sortbtn" onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}>
{SORTS[sortKey].label} <Icon name="playlist" size={15} />
</button>
{#if menuOpen}
<div class="menu" role="menu">
<div class="mgroup">Sort by</div>
{#each Object.entries(SORTS) as [k, s] (k)}
<button
role="menuitem"
onclick={() => {
if (sortKey === k) desc = !desc;
else { sortKey = k; desc = s.desc !== false; }
menuOpen = false;
}}
>
{s.label}
{#if sortKey === k}
<span class="mcheck" class:up={!desc}><Icon name="chevron-down" size={13} /></span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<button class="lib-dir" class:up={!desc} title="Reverse order" onclick={() => (desc = !desc)}>
<Icon name="chevron-down" size={14} />
</button>
</div>
{#if view === "list"}
<!-- The column header is the bar's own last row: one box, one
pin — it cannot drift from the bar or hide behind it at
any width (handoff §4b.3, Kyle's pick). -->
<div class="lb-tr lb-th">
<span></span><span>Record</span><span>Artist</span><span>Type</span><span>Year</span><span>#</span>
<span>Length</span><span>Format</span><span>Rate</span><span>Size</span><span>Added</span>
</div>
{/if}
</div>
{#if railItems.length}
<div class="year-rail"><div class="yr-col">
{#each railItems as [l, k] (k)}
<button onclick={() => jumpTo(k)}>{l}</button>
{/each}
</div></div>
{/if}
{#if corpus.length === 0}
<div class="lib-empty">
{#if q}
<span class="icon"><Icon name="search" size={26} /></span>
<p>Nothing matches <b>{q}</b></p>
{:else}
<!-- Nothing in the library at all. Both ways in sit here, but
the toolbar's Import is still up there too — this is an
invitation, not the only door. -->
<span class="icon"><Icon name="library" size={26} /></span>
<p>Your music will live here</p>
<small>Bring in a folder you already have, or download an album from
Search — either way it lands here, tagged with art.</small>
<div class="lib-empty-acts">
<button class="lib-tool primary"
title={importResume.active
? `An import is in progress — ${importResume.pct}% reviewed. Click to continue.`
: "Import music from a folder or drive"}
onclick={() => (ui.importOpen = importResume.active ? "continue" : true)}>
<Icon name="plus" size={15} />
{importResume.active ? "Continue importing" : "Import from a folder"}
{#if importResume.active}
<span class="lib-import-pct">{importResume.pct}%</span>
{/if}
</button>
<button class="lib-tool" onclick={() => goToView("search")}>
<Icon name="search" size={15} />
Find music to download
</button>
</div>
{/if}
</div>
{#if railItems.length}
<div class="year-rail"><div class="yr-col">
{#each railItems as [l, k] (k)}
<button onclick={() => jumpTo(k)}>{l}</button>
{/each}
</div></div>
{/if}
{#if corpus.length === 0}
<div class="lib-empty">
<span class="icon"><Icon name="search" size={26} /></span>
<p>Nothing matches <b>{q}</b></p>
</div>
{:else if view === "shelves"}
{#each grouped as [name, list] (name)}
{@const shown = expandedShelves[name] ? list : list.slice(0, PER_SHELF)}
<section class="lib-shelf">
{#if name}{@render shelfHead(name, list)}{/if}
{#if group === "artist"}
{#each splitByCategory(shown) as [label, part] (label ?? "all")}
{#if label}
<div class="lib-subhead">{label}<span>{part.length}</span></div>
{/if}
{@render cardGrid(part, `${name}:${label ?? "all"}`)}
{/each}
{:else}
{@render cardGrid(shown, name || "flat")}
{/if}
{#if list.length > shown.length}
<button class="lib-more" onclick={() => (expandedShelves = { ...expandedShelves, [name]: true })}>
Show {list.length - shown.length} more <Icon name="chevron-down" size={13} />
</button>
{/if}
{#if group === "artist"}
{@render gapSection(name, list)}
{/if}
</section>
{/each}
{:else if view === "grid"}
{:else if view === "shelves"}
{#each grouped as [name, list] (name)}
{@const shown = expandedShelves[name] ? list : list.slice(0, PER_SHELF)}
<section class="lib-shelf">
{#if name}{@render shelfHead(name, list)}{/if}
{#if group === "artist"}
{#each splitByCategory(shown) as [label, part] (label ?? "all")}
{#if label}
<div class="lib-subhead">{label}<span>{part.length}</span></div>
{/if}
{@render cardGrid(part, `${name}:${label ?? "all"}`)}
{/each}
{:else}
{@render cardGrid(shown, name || "flat")}
{/if}
{#if list.length > shown.length}
<button class="lib-more" onclick={() => (expandedShelves = { ...expandedShelves, [name]: true })}>
Show {list.length - shown.length} more <Icon name="chevron-down" size={13} />
</button>
{/if}
{#if group === "artist"}
{@render gapSection(name, list)}
{/if}
</section>
{/each}
{:else if view === "grid"}
{#each grouped as [name, list] (name)}
<section class="lib-sec">
{#if name}
<div class="lib-group-rule" id={anchorId(name)}>{name}<span>{list.length}</span></div>
{/if}
{@render cardGrid(list, name || "flat")}
</section>
{/each}
{:else}
<div class="lib-table">
{#each grouped as [name, list] (name)}
<section class="lib-sec">
{#if name}
<div class="lib-group-rule" id={anchorId(name)}>{name}<span>{list.length}</span></div>
{/if}
{@render cardGrid(list, name || "flat")}
{#each list as a (a.id)}
<div
class="lb-tr"
class:active={openId === a.id}
style={accentFor(a)}
id={flatAnchors?.at.get(a.id) ? anchorId(flatAnchors.at.get(a.id)) : undefined}
role="button"
tabindex="0"
use:loadCover={a}
onpointerenter={() => (hoverId = a.id)}
onpointerleave={() => { if (hoverId === a.id) hoverId = null; }}
onclick={() => toggleExpander(a)}
onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)}
>
<span class="lb-cell lb-thumb">
{#if a.art_path && coverUrls[a.id]}
<img src={coverUrls[a.id]} alt="" loading="lazy" draggable="false" />
{:else if !a.art_path}
{@const g = genFor(a)}
<span class="lib-gen mini" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}"></span>
{:else}
<Icon name="disc" size={15} />
{/if}
</span>
<span class="lb-cell lb-strong">{a.title}</span>
<span class="lb-cell">{a.artist}</span>
<span class="lb-cell">{CAT_ONE[a.cat]}</span>
<span class="lb-cell num">{a.year ?? "—"}</span>
<span class="lb-cell num">{a.track_count || "—"}</span>
<span class="lb-cell num">{a.secs ? dur(a.secs) : "—"}</span>
<span class="lb-cell">
{#if a.formats[0]}
<span class="lb-fmt" class:hi={a.depth?.startsWith("24")}>
{a.formats[0].toUpperCase()}{#if a.depth}<i>{a.depth}</i>{/if}
</span>
{:else}—{/if}
</span>
<span class="lb-cell num">{a.avgKbps ? `${a.avgKbps}k` : "—"}</span>
<span class="lb-cell num">{fmtBytes(a.bytes)}</span>
<span class="lb-cell num">{since(a.added_at)}</span>
</div>
{#if openId === a.id}
{@render expander([a], "list")}
{/if}
{/each}
</section>
{/each}
{:else}
<div class="lib-table">
{#each grouped as [name, list] (name)}
<section class="lib-sec">
{#if name}
<div class="lib-group-rule" id={anchorId(name)}>{name}<span>{list.length}</span></div>
{/if}
{#each list as a (a.id)}
<div
class="lb-tr"
class:active={openId === a.id}
style={accentFor(a)}
id={flatAnchors?.at.get(a.id) ? anchorId(flatAnchors.at.get(a.id)) : undefined}
role="button"
tabindex="0"
use:loadCover={a}
onpointerenter={() => (hoverId = a.id)}
onpointerleave={() => { if (hoverId === a.id) hoverId = null; }}
onclick={() => toggleExpander(a)}
onkeydown={(e) => (e.key === "Enter" || e.key === " ") && toggleExpander(a)}
>
<span class="lb-cell lb-thumb">
{#if a.art_path && coverUrls[a.id]}
<img src={coverUrls[a.id]} alt="" loading="lazy" draggable="false" />
{:else if !a.art_path}
{@const g = genFor(a)}
<span class="lib-gen mini" data-pat={g.pat} style="--h1:{g.hue};--h2:{g.hue2}"></span>
{:else}
<Icon name="disc" size={15} />
{/if}
</span>
<span class="lb-cell lb-strong">{a.title}</span>
<span class="lb-cell">{a.artist}</span>
<span class="lb-cell">{CAT_ONE[a.cat]}</span>
<span class="lb-cell num">{a.year ?? "—"}</span>
<span class="lb-cell num">{a.track_count || "—"}</span>
<span class="lb-cell num">{a.secs ? dur(a.secs) : "—"}</span>
<span class="lb-cell">
{#if a.formats[0]}
<span class="lb-fmt" class:hi={a.depth?.startsWith("24")}>
{a.formats[0].toUpperCase()}{#if a.depth}<i>{a.depth}</i>{/if}
</span>
{:else}—{/if}
</span>
<span class="lb-cell num">{a.avgKbps ? `${a.avgKbps}k` : "—"}</span>
<span class="lb-cell num">{fmtBytes(a.bytes)}</span>
<span class="lb-cell num">{since(a.added_at)}</span>
</div>
{#if openId === a.id}
{@render expander([a], "list")}
{/if}
{/each}
</section>
{/each}
</div>
{/if}
</div>
{/if}
</div>
@@ -2181,6 +2226,17 @@
.lib-search input::placeholder { color: var(--text-muted); font-weight: 500; }
.lib-clear { display: flex; color: var(--text-muted); cursor: pointer; }
.lib-clear:hover { color: var(--text-primary); }
/* The ongoing-import badge on the Import tool: quiet accent pill, the
percentage the review has covered. */
.lib-import-pct {
font-size: 10px;
font-weight: 800;
font-variant-numeric: tabular-nums;
padding: 1px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 26%, transparent);
color: var(--accent-hover, var(--accent));
}
.lib-tool {
display: inline-flex;
align-items: center;
@@ -3327,16 +3383,21 @@
place-items: center;
}
.lib-empty b { color: var(--text-primary); }
.lib-empty-lib {
padding: 18vh 0;
text-align: center;
color: var(--text-muted);
.lib-empty small { margin-top: -4px; font-size: 13px; max-width: 52ch; line-height: 1.5; }
.lib-empty-acts {
margin-top: 8px;
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
}
.lib-empty-lib .big {
font-size: 19px;
font-weight: 800;
color: var(--text-primary);
margin: 0 0 6px;
/* The one action that starts something. Same object as the toolbar's
tools, wearing the accent — nothing new invented for an empty page. */
.lib-tool.primary {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.lib-empty-lib .sub { margin: 0; font-size: 13px; }
.lib-tool.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #000; }
.lib-tool.primary .lib-import-pct { background: rgba(0, 0, 0, 0.22); color: #000; }
</style>