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

+4
View File
@@ -4,6 +4,7 @@
import PlayerBar from "./components/PlayerBar.svelte";
import NowPlaying from "./components/NowPlaying.svelte";
import ArtworkViewer from "./components/ArtworkViewer.svelte";
import ImportWindow from "./components/ImportWindow.svelte";
import LoginModal from "./components/LoginModal.svelte";
import PreviewOverlay from "./components/PreviewOverlay.svelte";
import DebugOverlay from "./components/DebugOverlay.svelte";
@@ -21,6 +22,7 @@
import CatalogAlbum from "./views/CatalogAlbum.svelte";
import CatalogDiscography from "./views/CatalogDiscography.svelte";
import FollowToast from "./components/FollowToast.svelte";
import ImportToast from "./components/ImportToast.svelte";
import {
ui,
initEvents,
@@ -122,8 +124,10 @@
<NowPlaying />
<PlayerBar />
<FollowToast />
<ImportToast />
<PreviewOverlay />
<ArtworkViewer />
<ImportWindow />
<LoginModal />
<DebugOverlay />
</div>
File diff suppressed because it is too large. Load diff
+146
View File
@@ -0,0 +1,146 @@
<script>
// The import's presence while its window is closed — the same dark glass
// pill as the follow toast (approved T2), in the same berth above the
// player bar, but clickable and persistent: it stays up exactly as long
// as there is something to know (a scan or copy running, or a completion
// nobody has seen yet) and a click steps back into the import window.
//
// Visibility is pure derivation — the window OPEN hides it (the window
// narrates its own work), and reopening resets terminal phases to idle
// (ImportWindow's acknowledgment effect), so the pill never nags about
// a state the user has already looked at.
import { ui, importStatus } from "../lib/state.svelte.js";
const nfmt = (n) => n.toLocaleString("en-US");
const shown = $derived(!ui.importOpen && importStatus.phase !== "idle");
const busy = $derived(
importStatus.phase === "scanning" || importStatus.phase === "applying");
</script>
{#if shown}
<div class="layer">
<button
class="toast"
onclick={() => { ui.importOpen = true; }}
title="Back to the import"
>
{#if busy}
<span class="mark pulse">▸</span>
{:else if importStatus.phase === "error" || (importStatus.phase === "applied" && importStatus.skipped)}
<span class="mark warn">!</span>
{:else}
<span class="mark ok">✓</span>
{/if}
{#if importStatus.phase === "scanning"}
<span>Reading your music — <b>{nfmt(importStatus.done)} of
{importStatus.total ? nfmt(importStatus.total) : "…"}</b> folders</span>
{:else if importStatus.phase === "scanned"}
<span>Scan finished — <b>{nfmt(importStatus.done)} records</b> to review</span>
{:else if importStatus.phase === "applying"}
<span>Importing — <b>{nfmt(importStatus.done)} of
{nfmt(importStatus.total)}</b> records copied</span>
{:else if importStatus.phase === "applied"}
<!-- A bare "Imported 0 records · 0.0 GB" is the worst thing
this pill can say: something was refused and the reason
is a click away, but nothing here admits it. The count
that matters leads. -->
{#if importStatus.skipped && !importStatus.done}
<span><b>{nfmt(importStatus.skipped)} record{importStatus.skipped === 1
? "" : "s"}</b> could not be imported — <b>click to see why</b></span>
{:else if importStatus.skipped}
<span>Imported <b>{nfmt(importStatus.done)} records</b>{importStatus.label
? ` · ${importStatus.label}` : ""} · <b>{nfmt(importStatus.skipped)}
left out</b></span>
{:else}
<span>Imported <b>{nfmt(importStatus.done)} records</b>{importStatus.label
? ` · ${importStatus.label}` : ""}</span>
{/if}
{:else}
<span>The import hit a problem — <b>click to see</b></span>
{/if}
</button>
</div>
{/if}
<style>
/* FollowToast's berth verbatim — player bar + 8px shell gutter + 26px of
air — except the layer lets clicks through and the pill takes them. */
.layer {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--player-height, 62px) + 8px + 26px);
display: flex;
justify-content: center;
z-index: 80;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 9px 16px;
border-radius: 99px;
background: rgba(22, 23, 28, 0.94);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
font-size: 12.5px;
font-weight: 700;
color: #fff;
font-family: inherit;
cursor: pointer;
animation: toast-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.toast:hover {
background: rgba(34, 35, 42, 0.96);
border-color: rgba(255, 255, 255, 0.2);
}
.toast:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(16px) scale(0.95);
}
to {
opacity: 1;
transform: none;
}
}
.mark {
flex: none;
font-style: normal;
}
.mark.pulse {
color: var(--accent);
animation: mark-pulse 1s ease-in-out infinite;
}
@keyframes mark-pulse {
50% {
opacity: 0.35;
}
}
.mark.ok {
color: var(--accent);
}
.mark.warn {
color: #f0cd77;
}
b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation-duration: 1ms;
}
.mark.pulse {
animation: none;
}
}
</style>
File diff suppressed because it is too large. Load diff
+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
+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>