Files
SoulStream/src/components/ImportWindow.svelte
T

2627 lines
130 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
// The import tool: scan a folder you point it at, review what it holds,
// copy the records into the managed library root, register them. Phase 1
// of the design settled in research/import/ — the per-field upgrades
// picker arrives on top of this shell as phase 2.
//
// Three decisions carried in from the review of the prototype:
// - always a window over the app, never a page;
// - the source folder is only ever read from — apply copies;
// - closing SAVES. The session (source, scan, position) persists via
// import_session_* and reopening resumes it. A 100-record review was
// never going to be one sitting.
import { ui, importStatus, importResume, getArt } from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import { accentFromImage } from "../lib/color.js";
import ImportPicker from "./ImportPicker.svelte";
import { evidence as matchEvidence } from "../lib/import-evidence.js";
import { foldReleases } from "../lib/import-fold.js";
import { majority, albumNameOf, artistNameOf, IDENTIFY_ENGINE } from "../lib/import-title.js";
import { findAlbumCopies, startAudition } from "../lib/import-audio.js";
const STEPS = [
["source", "Source"],
["scanning", "Scan"],
["review", "Review"],
["upgrades", "Upgrades"],
["done", "Done"],
];
let step = $state("source");
let root = $state(null); // the folder being imported
// Where `root` sits relative to the LIBRARY folder. An absolute path is
// only true on the machine that wrote it; this is what survives the drive
// being carried somewhere else and coming back at another letter.
let rootRel = $state(null);
let rootMissing = $state(false); // saved source folder could not be found
let dest = $state(null); // { dir, freeBytes, layout } from the backend
let scan = $state(null); // ScanResult
let scanProg = $state(null); // latest per-file tick; running totals ride along
let ledger = $state([]); // the scan stream: last LEDGER_ROWS rows, oldest first
let scanEta = $state(null); // "about 40s left", from the measured per-file rate
let ledgerSeq = 0; // row identity for keyed each
let curRel = null; // folder whose header is currently live
let scanStart = 0; // performance.now() at scan start, for the ETA
let applying = $state(false);
let applyProg = $state(null); // { done, total, album }
let report = $state(null); // ApplyReport
let error = $state(null);
let restored = $state(false);
// ---- the upgrades picker's state (mock's S, the app's subset) --------
// choices: rel -> { art, tags:{}, tracks:{}, seen } — departures only.
// found: rel -> { state, result } — on-demand identify, cached so a
// revisit (or a resumed session) never refetches.
let choices = $state({});
// Containers the user told the review to leave alone. The fold is a rule
// about folder shape, and a rule about folder shape will meet a folder it
// is wrong about — this is the per-release override, kept in the session
// so it survives "save & finish later".
let keepSeparate = $state([]);
let found = $state({});
let localArts = $state({}); // rel -> LocalArt | null (not persisted)
let reports = $state({}); // rel -> CoverReport (not persisted)
let pick = $state(0);
// WHERE the cursor is, by identity. `pick` is an index into a queue that
// the outcome filter reshapes and identify results re-bucket, so an index
// is worthless the moment anything moves — saving one is what made
// "finish later" resume at record 1. The rel is the record itself.
let pickRel = $state(null);
// rel -> the found-copy audition: { status: searching|downloading|ready|
// complete|none|failed, candidate, ids, arrived, track1Path, probe }.
// "ready" = track 1 is on disk (the A/B can start); "complete" = the
// whole album is cached, so keeping it is a move, not a re-download.
let auditions = $state({});
const open = $derived(!!ui.importOpen);
// ---- session ---------------------------------------------------------
// Saved on every meaningful transition, cleared on Done. The scan result
// rides along — a re-scan costs minutes and resuming must not.
async function persist() {
try {
// Identify results ride along (they cost provider calls); only
// settled ones — a "loading" entry would resurrect as a lie.
const done = Object.fromEntries(
Object.entries(found).filter(([, v]) => v?.state === "done"));
// `xfer` is stripped: a phase, a queue place and a byte count are
// true for a few seconds. Restored from a file days later they
// would dress a transfer that is not running as one that is —
// reading either "53% of track 1" or, once the stall clock caught
// up, "the peer went quiet" about a peer nobody is talking to.
// `switching` goes too: it is a re-entry guard for one in-flight
// cancel-and-retry. Persisted true, it would come back as a record
// that can never switch peers again.
const auditionsToSave = Object.fromEntries(
Object.entries(auditions).map(([rel, au]) => {
const { xfer, switching, ...rest } = au ?? {};
return [rel, rest];
}));
await api.importSessionSet(JSON.stringify({
step, root, rootRel, scan, layout: layoutChoice, choices, found: done, pick, pickRel,
bucket, bucketRel, auditions: auditionsToSave, keepSeparate,
}));
} catch (e) {
console.warn("import: session save failed", e);
}
updateResume();
}
async function restore() {
try {
const raw = await api.importSessionGet();
if (!raw) return;
const v = JSON.parse(raw);
if (v?.root) {
// The saved path first — nothing has moved in the common case.
// Then the same folder relative to where the library is NOW,
// which is how a review saved at one machine finds its source
// again at another. Both `root` and the scan's own copy are
// repointed: apply passes `scan.root`, so leaving that stale
// would resume onto a folder that is not there.
// NOT `found` — that name is the identify-results state, and
// shadowing it here throws on the assignment further down.
const liveRoot = await api
.importResolveRoot(v.root, v.rootRel ?? null)
.catch(() => null);
root = liveRoot ?? v.root;
// A session saved before the portable form existed learns it
// now, while its source is still findable — waiting until the
// next save would strand any review already in progress.
rootRel = v.rootRel
?? (liveRoot ? await api.importPortableRoot(liveRoot).catch(() => null) : null);
rootMissing = !liveRoot;
scan = v.scan ?? null;
if (scan && liveRoot) scan.root = liveRoot;
// Learned or repointed: write it back now rather than waiting
// for a transition that may never come.
const learned = rootRel !== (v.rootRel ?? null) || root !== v.root;
layoutChoice = v.layout ?? null;
choices = v.choices ?? {};
found = v.found ?? {};
pick = v.pick ?? 0;
pickRel = v.pickRel ?? null;
bucket = v.bucket ?? "all";
bucketRel = v.bucketRel ?? {};
// Auditions survive by their files: ids are stale after a
// restart, but a cached track 1 (or full album) on disk is
// still every bit as comparable and keepable.
auditions = v.auditions ?? {};
keepSeparate = v.keepSeparate ?? [];
step = v.step === "scanning" ? "source" : (v.step ?? "source");
if (learned) schedulePersist();
}
} catch (e) {
console.warn("import: session restore failed", e);
}
}
// Opening the window acknowledges any finished-while-closed toast: the
// window itself now shows that state, so the pill has nothing to add.
$effect(() => {
if (open && ["scanned", "applied", "error"].includes(importStatus.phase)) {
importStatus.phase = "idle";
}
});
// Restore at BOOT, not first-open: the Library button's resume badge
// needs the session summary before the window is ever opened.
$effect(() => {
if (restored) return;
restored = true;
(async () => {
dest = await api.importGetDest().catch(() => null);
await restore();
updateResume();
})();
});
// The Library badge opens with "continue", which is what the badge has
// always PROMISED ("click to continue") and never did — it reopened
// whatever step was saved, and an ejected session had saved "review".
// Held until the scan is restored; the intent is consumed once so a
// later close/reopen is an ordinary open.
$effect(() => {
if (!open || ui.importOpen !== "continue" || !scan) return;
ui.importOpen = true;
if (step !== "done" && step !== "scanning") openUpgrades();
});
// The dev seed ({ root } form) still overrides the source on open.
$effect(() => {
if (open && typeof ui.importOpen === "object" && ui.importOpen?.root) {
setRoot(ui.importOpen.root);
step = "source";
}
});
/** The Library button's badge: is a review in progress, how far along.
* Measured against the WHOLE queue, never the bucket-filtered view — the
* outcome strip is a lens on the work, not the size of it. Dividing all
* the decisions made by whatever the lens happened to be showing is how
* this read "500%": 5 records decided, one record in the "bad" bucket.
* Only records still IN the queue count, and the result is clamped: a
* progress badge must not be able to lie again. */
function updateResume() {
const active = !!(root && scan) && step !== "done";
importResume.active = active;
const queued = new Set(pickerBase.map((x) => x.rel));
const seen = Object.entries(choices)
.filter(([rel, c]) => c?.seen && queued.has(rel)).length;
importResume.pct = active && pickerBase.length
? Math.min(100, Math.round((100 * seen) / pickerBase.length))
: 0;
}
function close() {
// Closing saves — the ✕ is "finish later", never "abandon". From the
// picker it also BANKS what is already sorted: seen records land in
// the library now (apply is idempotent, so the eventual full import
// remains safe), and the toast narrates the copy.
if (step === "upgrades") applyPartial();
if (root && step !== "done") persist();
ui.importOpen = false;
}
function onKey(e) {
if (e.key === "Escape" && open) {
// The fullscreen artwork viewer owns Escape while it is up — at
// the window target, listener order is registration order, so
// this handler must yield by looking rather than by phase.
if (document.querySelector(".im-fs")) return;
close();
}
}
// ---- source ----------------------------------------------------------
/** Every route to a source folder goes through here, so the portable form
* is recorded at the same moment as the absolute one and cannot drift. */
async function setRoot(p) {
root = p;
scan = null;
rootMissing = false;
rootRel = p ? await api.importPortableRoot(p).catch(() => null) : null;
}
async function pickSource() {
const p = await api.pickFolder().catch(() => null);
if (p) await setRoot(p);
}
async function pickDest() {
const p = await api.pickFolder().catch(() => null);
if (!p) return;
try {
dest = await api.importSetDest(p);
error = null;
} catch (e) {
error = String(e);
}
}
/** Accept the proposed default — the one-click path past the cold
* "Choose…" on an install that has never picked a library folder. */
async function useSuggested() {
if (!dest?.suggested) return;
try {
dest = await api.importSetDest(dest.suggested);
error = null;
} catch (e) {
error = String(e);
}
}
// ---- scan ------------------------------------------------------------
// The ledger (design A3, research mock): every file streams past with a
// quality chip, a folder folds into its one-line summary when it
// finishes, and live meters + an honest ETA sit above the stream. File
// ticks are throttled backend-side, so rows are a SAMPLE of the files —
// the meters read the totals the tick carries, never accumulate.
// Enough rows to fill the tallest window the ledger will meet — it is
// bottom-anchored and clipped at the top, so extra rows cost nothing.
const LEDGER_ROWS = 48;
function pushRow(row) {
ledger.push({ id: ledgerSeq++, ...row });
while (ledger.length > LEDGER_ROWS) ledger.shift();
}
const mmss = (s) => Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
const relLabel = (rel) => rel.replaceAll("\\", " — ");
function fileChip(p) {
if (p.fileLossless) return { t: "FLAC", cls: "fl" };
if (p.fileKbps == null) return null;
return { t: p.fileKbps + "k", cls: p.fileKbps >= 300 ? "hi" : "" };
}
function folderChip(p) {
if (p.tracks > 0 && p.lossless === p.tracks) return { t: "FLAC", cls: "fl" };
if (p.avgKbps == null) return null;
return { t: p.avgKbps + "k", cls: p.avgKbps >= 300 ? "hi" : "" };
}
function onScanFile(p) {
scanProg = p;
importStatus.done = p.done;
importStatus.total = p.total;
if (p.rel !== curRel) {
curRel = p.rel;
pushRow({ kind: "head", rel: p.rel, label: relLabel(p.rel) });
}
pushRow({ kind: "file", name: p.file, secs: p.fileSecs, chip: fileChip(p) });
if (p.filesDone >= 8 && p.filesTotal > p.filesDone) {
const secsLeft = Math.round(
((p.filesTotal - p.filesDone) * (performance.now() - scanStart)) /
p.filesDone / 1000);
scanEta = secsLeft < 3 ? null
: "about " + (secsLeft >= 90 ? Math.round(secsLeft / 60) + " min" : secsLeft + "s") + " left";
}
}
function onScanFolder(p) {
// Folder counts advance here — file ticks can be throttled away
// entirely for a small folder, this event never is.
if (scanProg) { scanProg.done = p.done; scanProg.total = p.total; }
importStatus.done = p.done;
importStatus.total = p.total;
const sum = {
kind: "sum",
rel: p.rel,
label: p.artist && p.artist !== p.album ? p.artist + " — " + p.album : p.album,
tracks: p.tracks,
secs: p.secs,
mb: Math.round(p.bytes / 1048576),
chip: folderChip(p),
};
const head = ledger.find((r) => r.kind === "head" && r.rel === p.rel);
if (head) Object.assign(head, sum);
else pushRow(sum);
}
let unScan = null;
let unScanFolder = null;
let scanning = false; // re-entry guard — a second Scan while one runs
// stacked a second listener set and DOUBLED every
// ledger row (seen live, three deep)
let liveScanToken = 0; // stale-completion guard: a superseded scan must
// not resolve into state, unsubscribe the new
// scan's listeners, or persist its session
async function startScan() {
if (!root || scanning) return;
scanning = true;
const token = ++liveScanToken;
step = "scanning";
scanProg = null;
ledger = [];
scanEta = null;
curRel = null;
ledgerSeq = 0;
scanStart = performance.now();
error = null;
importStatus.phase = "scanning";
importStatus.done = 0;
importStatus.total = 0;
unScan?.(); unScanFolder?.();
unScan = await api.onImportScan(onScanFile);
unScanFolder = await api.onImportScanFolder(onScanFolder);
try {
const result = await api.importScan(root);
if (token !== liveScanToken) return;
scan = result;
step = "review";
// A closed window hands off to the toast; an open one narrates
// the arrival itself, and the toast has nothing to add.
importStatus.done = result.albums.length;
importStatus.phase = open ? "idle" : "scanned";
persist();
} catch (e) {
if (token !== liveScanToken) return;
error = String(e);
step = "source";
importStatus.phase = open ? "idle" : "error";
} finally {
if (token === liveScanToken) {
scanning = false;
unScan?.(); unScan = null;
unScanFolder?.(); unScanFolder = null;
}
}
}
// ---- derivation (the prototype's derive(), phase-1 subset) -----------
const JUNK = /^(__MACOSX|\.DS_Store|@eaDir|System Volume Information)$/i;
const DISC = /^(cd|disc|disk)\s*_?-?\s*\d+$/i;
const stripYear = (s) =>
s.replace(/^[([]\d{4}[)\]]\s*-?\s*/, "").replace(/^\d{4}\s*-\s*/, "").trim() || s;
// Releases scanned as their parts are put back together BEFORE anything
// else sees the list — see lib/import-fold.js, and re-run
// `node research/import/fold-audit.mjs` after touching it. Containers the
// user has un-folded in the review are passed through so the rule can be
// overruled per release without turning the whole pass off.
const folding = $derived(
scan ? foldReleases(scan.albums, new Set(keepSeparate)) : null);
const albums = $derived.by(() => {
if (!folding) return [];
return folding.records.map((a) => {
const br = a.tracks.map((t) => t.bitrate).filter(Boolean);
return {
...a,
title: stripYear(a.albumFolder),
junk: JUNK.test(a.albumFolder),
disc: DISC.test(a.albumFolder),
loose: a.depth === 1,
bytes: a.tracks.reduce((n, t) => n + t.bytes, 0),
avgBitrate: br.length ? Math.round(br.reduce((n, b) => n + b, 0) / br.length) : null,
embedded: a.tracks.some((t) => t.embeddedArt),
};
});
});
const issues = $derived.by(() => {
// Junk never reaches `albums` — the fold drops it first, because a
// zip's `__MACOSX` fork otherwise reads as disc two — so the count
// comes back from there rather than from a filter that can't match.
const junk = folding?.dropped ?? [];
const disc = albums.filter((a) => !a.junk && a.disc);
const loose = albums.filter((a) => !a.junk && !a.disc && a.loose);
const noArt = albums.filter((a) => !a.junk && !a.embedded && !a.folderArt.length);
const clean = albums.filter((a) => !a.junk && !a.disc && !a.loose);
return { junk, disc, loose, noArt, clean };
});
const totals = $derived.by(() => {
const real = albums.filter((a) => !a.junk);
return {
albums: real.length,
tracks: real.reduce((n, a) => n + a.tracks.length, 0),
bytes: real.reduce((n, a) => n + a.bytes, 0),
};
});
/** What apply receives: everything that is not junk, with the values the
* folder + tags agree on. Track-level choice is phase 2. */
const plan = $derived.by(() =>
albums
.filter((a) => !a.junk)
.map((a) => {
const tagYear = majority(a.tracks.map((t) => t.year));
// The album NAME comes from the tags when they agree on one —
// the folder is the ripper's naming ("Heartleap - WEB V0"),
// the tags are the record's own claim ("Heartleap"), and the
// row title is what the catalog joins on. Folder as fallback.
// One definition, in import-title.js, so the picker asks the
// provider the same question apply writes (they disagreed: the
// picker searched the folder and found nothing for records
// whose tags name them perfectly well).
return {
rel: a.rel,
artist: artistNameOf(a),
album: albumNameOf(a),
year: a.year ?? tagYear ?? null,
// Where the audio actually is, relative to `rel`. Empty for
// an ordinary record; apply reads its own folder then.
...(a.parts ? { parts: a.parts } : {}),
};
}));
// ---- the upgrades picker's queue -------------------------------------
// Artist A–Z, then chronological within the artist — the shelf order,
// his call (2026-08-07) over the mock's worst-bitrate-first. The artist
// is the PLAN's resolution (tag majority for loose folders), so a
// depth-1 record files under its real artist, not its folder name.
const pickerBase = $derived.by(() => {
const artistOf = new Map(plan.map((p) => [p.rel, p.artist]));
const yearOf = new Map(plan.map((p) => [p.rel, p.year]));
return albums.filter((x) => !x.junk).slice().sort((x, y) =>
(artistOf.get(x.rel) ?? x.artistFolder).localeCompare(
artistOf.get(y.rel) ?? y.artistFolder, undefined, { sensitivity: "base" })
|| (yearOf.get(x.rel) ?? 9999) - (yearOf.get(y.rel) ?? 9999)
|| x.title.localeCompare(y.title));
});
// Outcome buckets, read off the SAME evidence the picker's verdict panel
// shows. Identification is on-demand, so unopened records sit honestly
// in "Not looked yet" rather than being guessed at.
const BUCKETS = [
["all", "All"],
["ok", "Clean match"],
["warn", "Close"],
["bad", "Wrong record"],
["none", "No match"],
["pending", "Not looked yet"],
];
let bucket = $state("all");
const bucketByRel = $derived.by(() => {
const artistOf = new Map(plan.map((p) => [p.rel, p.artist]));
// Judged against the name the provider was ASKED about, so the bucket
// and the picker's verdict panel agree — and so a record whose folder
// is a rip descriptor is not filed as merely "close" to its own match.
const albumOf = new Map(plan.map((p) => [p.rel, p.album]));
const m = new Map();
for (const x of pickerBase) {
const f = found[x.rel];
let k;
if (!f || f.state !== "done") k = "pending";
else if (!f.result?.candidate) k = "none";
else
k = matchEvidence(
f.result.candidate,
// The artist the answer was found under — the folder,
// unless the tags found what the folder could not.
f.result.askedArtist ?? artistOf.get(x.rel) ?? x.artistFolder,
albumOf.get(x.rel) ?? x.title,
x.tracks.length,
).verdict;
m.set(x.rel, k);
}
return m;
});
const bucketCounts = $derived.by(() => {
const n = {};
for (const k of bucketByRel.values()) n[k] = (n[k] ?? 0) + 1;
return n;
});
// A bucket emptied under the cursor falls back to the whole queue — the
// screen must always show a record (the mock's rule).
const pickerQueue = $derived.by(() => {
if (bucket === "all") return pickerBase;
const list = pickerBase.filter((x) => bucketByRel.get(x.rel) === bucket);
return list.length ? list : pickerBase;
});
// ---- the cursor ------------------------------------------------------
// What is ON SCREEN is decided by identity, and resolved against the whole
// base rather than the filtered queue. Identification lands asynchronously
// and re-buckets records, so a record can leave the current lens WHILE YOU
// ARE LOOKING AT IT — with an index-addressed cursor the picker silently
// swapped in whatever slid into that slot, taking an in-flight audition
// off screen with it. The lens chooses what Next WALKS, never what you can
// still see.
const curPick = $derived(
(pickRel && pickerBase.find((x) => x.rel === pickRel))
?? pickerQueue[Math.min(pick, Math.max(0, pickerQueue.length - 1))]);
// The index Next and Back count from follows the record on screen whenever
// the lens still holds it. One direction only: index chases record.
$effect(() => {
if (step !== "upgrades" || !curPick) return;
if (pickRel !== curPick.rel) pickRel = curPick.rel;
const i = pickerQueue.findIndex((x) => x.rel === curPick.rel);
if (i >= 0 && i !== pick) pick = i;
});
/** Move the cursor to a queue POSITION. Navigation sets both halves — the
* index it counts from and the record it means — so the effect above
* never drags it back. */
function seekIndex(i) {
if (!pickerQueue.length) return;
const at = Math.max(0, Math.min(i, pickerQueue.length - 1));
pick = at;
pickRel = pickerQueue[at]?.rel ?? null;
}
/** Put the cursor on `rel`; false if the current lens does not show it. */
function seekTo(rel) {
const i = pickerQueue.findIndex((x) => x.rel === rel);
if (i < 0) return false;
seekIndex(i);
return true;
}
/** Index of the first record still awaiting a decision, -1 if none. */
const nextUndecided = (list) => list.findIndex((x) => !choices[x.rel]?.seen);
/** Enter (or re-enter) the picker where the review left off: the saved
* record first — that is literally where you were — then the first one
* still undecided. A filter with nothing left to decide behind it steps
* aside rather than stranding the resume on a finished bucket. */
function openUpgrades() {
step = "upgrades";
if (!(pickRel && seekTo(pickRel))) {
let i = nextUndecided(pickerQueue);
if (i < 0 && bucket !== "all" && nextUndecided(pickerBase) >= 0) {
bucket = "all";
i = nextUndecided(pickerBase);
}
seekIndex(Math.max(0, i));
}
persist();
}
/** The count the "continue" affordances quote. */
const reviewed = $derived(pickerBase.filter((x) => choices[x.rel]?.seen).length);
/** Passed over on purpose and still undecided. Counted off the QUEUE, not
* off `choices`, so a deferral left behind by a record that is no longer
* in the walk cannot inflate it. */
const deferred = $derived(pickerBase.filter(
(x) => choices[x.rel]?.deferred && !choices[x.rel]?.seen).length);
/** Overrule the fold for one release, both ways. Keyed by container rel,
* which is also the folded record's rel — so a record already reviewed
* under one shape keeps its choices only while that shape holds. */
function toggleFold(container) {
keepSeparate = keepSeparate.includes(container)
? keepSeparate.filter((r) => r !== container)
: [...keepSeparate, container];
schedulePersist();
}
// A cached verdict is an answer to the question that was ASKED. The picker
// used to ask about the folder, so every "nothing found" in a resumed
// session answers a question we no longer ask — and because the cache is
// never revisited (`if (found[rel]) return`), those records would stay
// unmatched forever behind a stale no.
//
// Drop exactly the ones whose question changed, and only when the answer
// was NOTHING: a real match is left alone, so this cannot cost a good
// answer, and re-asking is deferred until the record is actually opened.
let sweptStale = false;
$effect(() => {
if (sweptStale || !albums.length) return;
const rels = Object.keys(found);
if (!rels.length) return;
sweptStale = true;
const byRel = new Map(albums.map((a) => [a.rel, a]));
let dropped = 0;
for (const rel of rels) {
const f = found[rel];
if (f?.result?.candidate) continue; // a real answer stands
// Looked with an older engine: the question may be unchanged but
// the answer is not, so it gets asked again.
if ((f?.engine ?? 1) < IDENTIFY_ENGINE) {
delete found[rel];
dropped++;
continue;
}
const rec = byRel.get(rel);
if (!rec) continue;
const asked = f?.result?.tries?.[0] ?? null;
const asksNow = albumNameOf(rec);
if (asked != null && asksNow != null && asked !== asksNow) {
delete found[rel];
dropped++;
}
}
if (dropped) {
console.info(`import: re-asking ${dropped} record(s) — the query changed`);
schedulePersist();
}
});
function pickerArtist(x) {
return plan.find((p) => p.rel === x.rel)?.artist ?? x.artistFolder;
}
/** The name the picker asks the PROVIDERS about. The artist already came
* from the plan rather than the folder; the album did not, which is why a
* record whose tags say "Mirrored" was searched for as
* "[WARP CD 156] Battles - Mirrored - 2007" and matched nothing. */
function pickerAlbum(x) {
return plan.find((p) => p.rel === x.rel)?.album ?? x.title;
}
let persistTimer = 0;
function schedulePersist() {
clearTimeout(persistTimer);
persistTimer = setTimeout(persist, 800);
}
function choose(rel, patch) {
const c = choices[rel] ?? { art: null, tags: {}, tracks: {}, seen: false };
if ("art" in patch) c.art = patch.art;
if ("audio" in patch) c.audio = patch.audio;
if (patch.tags) Object.assign(c.tags, patch.tags);
if (patch.tracks) Object.assign(c.tracks, patch.tracks);
c.seen = true;
// Deciding retires a deferral: the record was passed over once, and it
// has now been dealt with, so the walk must stop coming back to it.
delete c.deferred;
choices[rel] = c;
schedulePersist();
}
/** Search Soulseek for a whole-album copy of this record and start the
* audition download (whole album into the cache pen, track 1 first).
* Identification never gates this — deep cuts get their find too; a
* TRUSTED provider match just strengthens the query ladder. */
/** How many ranked runners-up to keep, so a bad peer costs a retry rather
* than a whole new search. */
const AUDITION_ALTS = 4;
/** How long track 1 may make NO progress before we stop waiting on this
* peer. A queue place that never moves reads exactly like a live transfer
* otherwise — there was nothing that ever gave up, so a peer with 277
* people ahead held the card indefinitely. */
const AUDITION_WAIT_MS = 45000;
/** Only what the card and a retry need — the raw search result carries the
* whole response object. */
const slimCandidate = (c) => ({
username: c.username,
folderLeaf: c.folderLeaf,
trackCount: c.trackCount,
avgBitrate: c.avgBitrate,
lossless: c.lossless,
bytes: c.bytes,
countMatch: c.countMatch,
queue: c.queue ?? null,
hasFreeSlot: !!c.hasFreeSlot,
files: (c.files ?? []).map((f) => ({ path: f.path, name: f.name, size: f.size ?? 0 })),
});
/** Begin (or re-begin) an audition on `cand`, keeping `alts` as fallbacks.
* TRACK 1 ONLY — the rest is requested once it lands. */
async function beginAudition(rel, cand, alts, { attempt = 1, dead = [], switchedFrom = null, reason = null } = {}) {
const { ids } = await startAudition(rel, cand, { from: 0, count: 1 });
auditions[rel] = {
status: "downloading",
candidate: cand,
alts,
attempt,
switchedFrom,
reason,
// The album's real length. `ids` only holds track 1 until the rest
// are asked for, so it cannot be what "all of it arrived" counts.
expected: cand.trackCount,
ids,
dead,
arrived: 0,
track1Path: null,
probe: null,
startedAt: Date.now(),
};
schedulePersist();
}
/** The remaining tracks, once track 1 has proved the peer will actually
* serve us. */
async function startRest(rel) {
const au = auditions[rel];
if (!au || au.restStarted || !au.candidate?.files?.length) return;
au.restStarted = true;
if (au.candidate.files.length <= 1) return;
try {
const { ids } = await startAudition(rel, au.candidate, { from: 1 });
const cur = auditions[rel];
if (cur && cur.candidate?.username === au.candidate.username) {
cur.ids = [...(cur.ids ?? []), ...ids];
schedulePersist();
}
} catch (e) {
console.warn("import: rest of album failed to start", e);
}
}
/** Ask again for the tracks a found copy still owes us.
*
* Transfers do not survive the app; the files they wrote do. So a session
* restored after a crash, a power cut or an ordinary restart brings back
* an audition that LOOKS live and is not: `restStarted` is already true,
* so nothing ever asks for the tracks that never landed, and every future
* apply refuses the record for the same reason — "the found copy is still
* arriving" — with nothing arriving. Two records sat at 12 of 13 and 6 of
* 11 that way.
*
* The pen decides how far it got, not the counter: `arrived` is a running
* total kept by events, and those events stopped with the process that
* was listening for them. A count that was already right costs one stat
* per file to confirm; a count that was wrong is corrected before it can
* refuse anything again.
*
* Asked at most once per record per run. A peer that has gone away should
* cost one round of requests, not a fresh one every time the record is
* looked at — "Try another peer" is the answer to that, and it is a
* deliberate act. */
const resumeAsked = new Set();
async function resumeAudition(rel) {
const au = auditions[rel];
if (!au || resumeAsked.has(rel)) return;
const files = au.candidate?.files ?? [];
if (!files.length || ["searching", "none", "failed"].includes(au.status)) return;
resumeAsked.add(rel);
const peer = au.candidate.username;
let missing;
try {
missing = await api.importAuditionMissing(
rel,
files.map((f) => ({ path: f.path, size: f.size ?? 0 })),
);
} catch (e) {
console.warn("import: could not read the pen", e);
resumeAsked.delete(rel);
return;
}
// Anything could have happened across the await — a switch to another
// peer, a fresh search. Credit this answer only to the copy it is about.
const cur = auditions[rel];
if (!cur || cur.candidate?.username !== peer) return;
cur.expected = files.length;
cur.arrived = files.length - missing.length;
// WHICH files are owed, not just how many — the strip states a size
// only when it knows exactly which files make it up.
cur.missingIdx = missing;
if (!missing.length) {
// Everything is already here. The record was refused by a stale
// number, and nothing needs downloading to unstick it.
cur.status = "complete";
schedulePersist();
return;
}
for (const i of missing) {
try {
const { ids } = await startAudition(rel, cur.candidate, { from: i, count: 1 });
cur.ids = [...(cur.ids ?? []), ...ids];
} catch (e) {
console.warn("import: could not re-request a missing track", e);
break;
}
}
cur.status = cur.track1Path ? "ready" : "downloading";
cur.xfer = { ...(cur.xfer ?? {}), phase: "requested", at: Date.now() };
schedulePersist();
}
/** Ask again for every refusal that is a found copy still owed tracks.
*
* Called from BOTH applies. They refuse the same way and have to recover
* the same way: wiring only the full one left the picker's partial apply
* — the one that runs when you import from the review itself — repeating
* "it imports as soon as the rest land" over records nothing was asking
* for. No-ops on a refusal that is not about an audition. */
function resumeRefused(skipped) {
for (const s of skipped ?? []) resumeAudition(s.rel);
}
// Opening a record is when we find out whether its found copy is still
// owed anything — and the only moment a resumed transfer has somewhere to
// be seen. Deliberately not a sweep at window-open: that would put a
// dozen peers' worth of requests on the wire for records nobody is
// looking at. The refused set after an apply is handled there.
$effect(() => {
if (step !== "upgrades" || !open) return;
const rel = curPick?.rel;
const au = rel ? auditions[rel] : null;
const want = au?.candidate?.files?.length ?? 0;
if (!want || (au.arrived ?? 0) >= (au.expected ?? want)) return;
resumeAudition(rel);
});
/** Throw away what the abandoned peer left in the pen.
*
* The cancel stops its transfers; this removes what they already wrote.
* The pen is keyed by RECORD, not by peer, so without it the dropped
* copy's files sit beside the replacement's until the session is cleared
* — 189 MB of a stranger's Centipede Hz did exactly that, and apply filed
* all 17 tracks. Apply takes the chosen copy's files by name now, so this
* is about disk rather than correctness; the ORDER still matters, because
* a file written after the clear would be a stray nothing asked for.
*
* Deliberately unconditional: "another peer" means this copy is not the
* one, and a partial copy of a rip you have walked away from is not worth
* the gigabytes. The automatic switch only fires before track 1 lands, so
* it can never discard more than one unfinished file. */
async function clearPen(rel, why) {
try {
const freed = await api.importAuditionClear(rel);
if (freed) {
console.info(`import: pen cleared for ${rel} — ${Math.round(freed / 1e6)} MB (${why})`);
}
} catch (e) {
console.warn("import: could not clear the pen", e);
}
}
/** Stop waiting on this peer and drop to the next ranked copy.
*
* Cancels the transfers first — without that, a switch left the old peer
* trickling in the background forever. Their ids go on a `dead` list so a
* late event from a cancelled transfer cannot be credited to the new one. */
async function switchPeer(rel, reason) {
const au = auditions[rel];
if (!au || au.switching || au.status === "searching") return;
au.switching = true;
const dead = [...(au.dead ?? []), ...(au.ids ?? [])];
for (const id of au.ids ?? []) {
try { await api.cancelDownload(id); } catch {}
}
await clearPen(rel, reason ?? "peer switched");
const [next, ...rest] = au.alts ?? [];
if (!next) {
auditions[rel] = { status: "none", dead, exhausted: true, reason };
schedulePersist();
return;
}
try {
await beginAudition(rel, next, rest, {
attempt: (au.attempt ?? 1) + 1,
dead,
switchedFrom: au.candidate?.username ?? null,
reason,
});
} catch (e) {
console.warn("import: switching peer failed", e);
auditions[rel] = { status: "failed", error: String(e), dead };
schedulePersist();
}
}
async function findBetterCopy(rel) {
const x = pickerBase.find((r) => r.rel === rel);
if (!x || auditions[rel]?.status === "searching") return;
// A re-search from a live audition stops the old peer first; otherwise
// its transfers keep running with nothing watching them.
const prev = auditions[rel];
const dead = [...(prev?.dead ?? []), ...(prev?.ids ?? [])];
for (const id of prev?.ids ?? []) {
try { await api.cancelDownload(id); } catch {}
}
// A fresh search abandons the current copy just as squarely as a
// switch does; whatever answers will be a different peer's rip.
if (prev?.candidate) await clearPen(rel, "searched again");
auditions[rel] = { status: "searching", dead };
try {
const trusted = ["ok", "warn"].includes(bucketByRel.get(rel));
const { copies, silent } = await findAlbumCopies({
artist: pickerArtist(x),
// The record's own name leads; its folder is the narrower
// fallback for when the tags are wrong or absent.
album: pickerAlbum(x),
folder: x.title,
candidate: trusted ? found[rel]?.result?.candidate : null,
wantTracks: x.tracks.length,
});
if (!copies.length) {
// `silent` separates "the network never answered" from "peers
// answered and none had it" — they read identically on the
// card otherwise, and the first one is not about this record.
auditions[rel] = { status: "none", dead, silent };
schedulePersist();
return;
}
await beginAudition(
rel,
slimCandidate(copies[0]),
copies.slice(1, 1 + AUDITION_ALTS).map(slimCandidate),
{ dead },
);
} catch (e) {
console.warn("import: find copy failed", e);
auditions[rel] = { status: "failed", error: String(e), dead };
}
}
async function probeAudition(rel, path) {
try {
const pr = await api.importProbeFile(path);
if (auditions[rel]) auditions[rel].probe = pr;
} catch {}
}
// Audition transfers narrate through their own event — match ids back
// to the record and advance its card.
//
// The in-flight ticks are what the row reads while it waits. They are
// deliberately NOT persisted: a phase, a queue place and a byte count are
// true for a few seconds, and restoring them from a file days later would
// dress a dead transfer as a live one.
api.onImportAudition((p) => {
// Matched on the RECORD the backend names, not by looking the id up in
// a list this component may not have yet. `import_audition` starts
// every transfer before it returns those ids, and a transfer emits
// Connecting at once — so id-matching silently dropped the whole
// opening of every download, and the client sends nothing further
// while a peer keeps you queued. That is why the row sat on
// "arriving…" with nothing to say.
const rel = p.rel;
const au = rel ? auditions[rel] : null;
const idx = typeof p.idx === "number" ? p.idx : (au?.ids?.indexOf(p.id) ?? -1);
if (!au || idx < 0) return;
// A peer we walked away from can still emit — the cancel and its last
// event race. Crediting those to the copy that replaced it would
// inflate its count and, for a Complete, fake a track it never sent.
if (p.id != null && (au.dead ?? []).includes(p.id)) return;
if (p.done) {
au.arrived = (au.arrived ?? 0) + 1;
if (au.missingIdx) au.missingIdx = au.missingIdx.filter((i) => i !== idx);
if (idx === 0) {
au.track1Path = p.localPath;
au.status = "ready";
probeAudition(rel, p.localPath);
// The peer has proved it will serve us; ask for the rest now.
startRest(rel);
}
if (au.arrived >= (au.expected ?? au.ids?.length ?? 0)) au.status = "complete";
au.xfer = { ...(au.xfer ?? {}), phase: "transferring", at: Date.now(), bytesAt: Date.now() };
schedulePersist();
} else if (p.failed && idx === 0 && !au.track1Path) {
// A refusal is an answer, and the answer is "not this peer". Giving
// up here wasted the four ranked copies already in hand — the same
// dead end as waiting forever, reached faster.
au.error = p.failed;
if ((au.alts?.length ?? 0) && !au.switching) {
switchPeer(rel, `the peer could not send it — ${p.failed}`);
} else {
au.status = "failed";
}
} else if (!p.failed) {
// Progress. Before track 1 lands the row speaks for track 1
// specifically — that is the file the comparison needs — and
// afterwards for whatever is still moving.
const prev = au.xfer;
const forTrack1 = idx === 0 && !au.track1Path;
if (forTrack1 || au.track1Path) {
const grew = !prev || (p.received ?? 0) > (prev.received ?? 0) || prev.id !== p.id;
au.xfer = {
id: p.id,
phase: p.phase ?? "working",
place: p.place ?? null,
received: p.received ?? 0,
total: p.total ?? 0,
speedBps: p.speedBps ?? 0,
at: Date.now(),
// Only a byte that actually landed resets the stall
// clock. A tick that repeats the same count is the
// shape a silent peer makes.
bytesAt: grew ? Date.now() : (prev?.bytesAt ?? Date.now()),
};
}
}
});
// A clock, only while something is actually in flight. Stall detection and
// the ETA both need time to pass on its own — without this the row would
// freeze on its last event and look identical to a peer that went quiet.
let now = $state(Date.now());
$effect(() => {
const live = Object.values(auditions).some(
(a) => a?.status === "downloading" || a?.status === "ready");
if (!live || !open) return;
const t = setInterval(() => (now = Date.now()), 1000);
return () => clearInterval(t);
});
// Nothing used to give up. A peer that accepts the request and then parks
// you at #277 looks identical to one about to send — the row said "queued
// at the peer · nothing to do but wait", and meant it literally.
//
// The clock is on PROGRESS, not on the attempt: a copy that is actually
// transferring keeps its slot however long it takes, and only silence is
// punished. `bytesAt` already tracks the last byte that really landed.
$effect(() => {
const t = now;
for (const [rel, au] of Object.entries(auditions)) {
if (!au || au.status !== "downloading" || au.track1Path || au.switching) continue;
const since = au.xfer?.bytesAt ?? au.startedAt ?? t;
if (t - since > AUDITION_WAIT_MS) {
switchPeer(rel, `no progress for ${Math.round(AUDITION_WAIT_MS / 1000)}s`);
}
}
});
/** No bytes for this long, while actually transferring, is a stall. A
* QUEUED transfer is not stalled — it is behaving exactly as told. */
const STALL_MS = 30_000;
function liveOf(au) {
if (!au?.xfer) return null;
const x = au.xfer;
const quiet = now - (x.bytesAt ?? x.at ?? now);
return {
...x,
quietMs: quiet,
stalled: x.phase === "transferring" && quiet > STALL_MS,
};
}
/* ---- what the upgrade step is still waiting on ---------------------- */
/** Records whose found copy is not fully in the pen yet, plus ones that
* ARE and have not been imported — a finished copy belongs here too,
* because it is the thing standing between you and pressing Import.
*
* Deliberately not "every audition": a record you listened to and then
* kept your own copy of is not something you are waiting for. */
const arrivals = $derived(
Object.entries(auditions)
.flatMap(([rel, au]) => {
const files = au?.candidate?.files ?? [];
const want = au?.expected ?? files.length;
if (!files.length || !want) return [];
const have = Math.max(0, Math.min(au.arrived ?? 0, want));
const owed = want - have;
const theirs = choices[rel]?.audio === "theirs";
if (choices[rel]?.applied || (!owed && !theirs)) return [];
// Bytes only when the pen check told us WHICH files are owed.
// Owed-count times an average would be a number that looks
// measured and is not.
const idx = au.missingIdx;
return [{
rel, au, want, have, owed, theirs,
name: rel.split(/[\\/]/).pop() || rel,
peer: au.candidate.username,
live: liveOf(au),
bytes: idx ? idx.reduce((n, i) => n + (files[i]?.size ?? 0), 0) : null,
}];
})
.sort((a, b) => b.owed - a.owed || a.name.localeCompare(b.name)));
const arrivalsOwed = $derived(arrivals.filter((a) => a.owed > 0));
const arrivalFiles = $derived(arrivalsOwed.reduce((n, a) => n + a.owed, 0));
const arrivalStalled = $derived(arrivalsOwed.some((a) => a.live?.stalled));
const arrivalBytes = $derived(
arrivalsOwed.length && arrivalsOwed.every((a) => a.bytes != null)
? arrivalsOwed.reduce((n, a) => n + a.bytes, 0) : null);
let trayOpen = $state(false);
/** What one row says, in the language the picker already uses. Nothing
* here invents a state: every branch is something the transfer events
* actually said, or the plain absence of them — after a restart there is
* no `xfer` at all, and "asked, no answer yet" is the truth then. */
function arrivalWords(a) {
if (!a.owed) return { cls: "ok", label: `all ${a.want} cached`, detail: "ready to import" };
const x = a.live;
if (x?.stalled) {
const mins = Math.max(1, Math.round((x.quietMs ?? 0) / 60000));
return { cls: "stall", label: `nothing for ${mins} min`, detail: `${a.peer} stopped sending` };
}
if (x?.phase === "transferring") {
const mbs = (x.speedBps ?? 0) / 1e6;
return { cls: "move", label: "arriving", detail: mbs >= 0.05 ? `${mbs.toFixed(1)} MB/s` : "slowly" };
}
if (x?.place != null || x?.phase === "queued") {
return {
cls: "wait", label: `queued at ${a.peer}`,
detail: x.place != null ? `${nfmt(x.place)} ahead of you` : "nothing moving yet",
};
}
return { cls: "wait", label: `asked ${a.peer}`, detail: "no answer yet" };
}
/** The container is what the peer sent, the depth and bitrate are what the
* probe MEASURED of the file that landed. */
function arrivalFormat(a) {
const first = a.au.candidate.files?.[0];
const ext = String(first?.name ?? first?.path ?? "").split(".").pop().toUpperCase();
const p = a.au.probe;
if (a.au.candidate.lossless === a.au.candidate.trackCount) {
return p?.bitDepth ? `${ext} ${p.bitDepth}-bit` : ext;
}
const k = p?.bitrate ?? a.au.candidate.avgBitrate;
return k ? `${ext} ${nfmt(k)}k` : ext;
}
const arrivalInitials = (name) =>
name.replace(/^.*?[-–—]\s*/, "").split(/\s+/).slice(0, 2).map((w) => w[0] ?? "").join("").toUpperCase();
/** Jump the picker to a record in the tray. The lens can be narrower than
* the tray — a record filtered out of the current bucket is still one you
* are waiting for — so widen rather than fail silently. */
function goToArrival(rel) {
if (!seekTo(rel)) {
bucket = "all";
seekTo(rel);
}
trayOpen = false;
schedulePersist();
}
/** Refusals the tray is not already speaking for. A record whose found
* copy is still arriving is IN the tray — live, with the peer and the
* count — and saying it again in the past tense above is the same fact
* twice, the staler of the two winning the eye. */
const leftBehind = $derived(
(report?.skipped ?? []).filter((s) => !arrivals.some((a) => a.rel === s.rel)));
/** The picker's resolved snapshot — what apply writes. Stored beside the
* choices but NOT a "seen": looking is not deciding. */
function snapshot(rel, snap) {
const c = choices[rel] ?? { art: null, tags: {}, tracks: {}, seen: false };
c.resolved = snap;
choices[rel] = c;
schedulePersist();
}
/** Take the record as it stands: whatever is lit in the cards is what gets
* imported. There used to be a "Keep mine" beside this that forced every
* field back to yours in one go; with the cards picked field by field it
* said nothing you could not already see and do, and on any record the
* proposal left alone it was Next under another name. */
function pickerAdvance() {
if (curPick) choose(curPick.rel, {});
advanceCursor();
}
/** Pass without deciding. `seen` is deliberately NOT set: it is what
* `nextUndecided` walks to and what `applyPartial` banks on close, and a
* record you skipped is neither reviewed nor something to import behind
* your back. The record stays in the plan and imports as it stands if the
* run happens first — skipping defers the DECISION, not the record.
* `deferred` only records that you passed it on purpose, so the picker
* can admit it and the footer can count it. */
function pickerSkip() {
if (curPick) {
const c = choices[curPick.rel]
?? { art: null, tags: {}, tracks: {}, seen: false };
// Nothing to defer on a record already decided — you can reach one
// with Back. Writing the flag anyway would leave data that says
// "undecided" next to a decision, inert but a lie to read later.
if (!c.seen) c.deferred = true;
choices[curPick.rel] = c;
}
advanceCursor();
}
/** Where the cursor goes once a record has been dealt with.
*
* Walking off the end of a FILTERED view is not the end of the review.
* Ejecting to Review here is what made "finish later" resume at the
* beginning: the "bad" bucket held ONE record, so the very first Next
* left the picker, and "review" + index 0 is what got saved. Widen the
* lens instead — visibly, back to "All".
*
* And with records skipped it is not the end of the WALK either: the
* queue wraps to the first one still undecided, which is the whole
* promise Skip makes. Itself excluded — skipping the last undecided
* record would otherwise seek to where it already is and look like a
* dead button. Only with nothing left undecided does Review get it. */
function advanceCursor() {
// Where "next" is depends on whether the record you just dealt with is
// still IN the lens. A filtered queue re-sorts itself under you: in
// "Not looked yet", merely opening a record identifies it, so it leaves
// the bucket while you are looking at it and every later index shifts
// down by one. `pick` does not follow (the cursor effect only tracks a
// record the queue still contains), so `pick + 1` stepped over the
// record that had just slid into the vacated slot — one skipped for
// every record reviewed under that filter.
const at = pickerQueue.findIndex((x) => x.rel === curPick?.rel);
const next = at >= 0 ? at + 1 : pick;
if (next < pickerQueue.length) seekIndex(next);
else if (!widenPastBucket()) {
// Exclude the record just dealt with by its position in THIS queue.
// `pick` is not it once the record has left the lens, and excluding
// the wrong index is how a wrap could hand back the very record it
// meant to skip past.
const back = pickerQueue.findIndex(
(x, i) => i !== at && !choices[x.rel]?.seen);
if (back >= 0) seekIndex(back);
else step = "review";
}
schedulePersist();
}
/** The filtered walk is done but the review is not: drop the filter and
* carry on at the next undecided record. Returns false when the whole
* queue is settled — that is a real end, and Review is where it goes. */
function widenPastBucket() {
if (bucket === "all") return false;
const next = nextUndecided(pickerBase);
if (next < 0) return false;
bucket = "all";
seekIndex(next);
return true;
}
function pickerBack() {
if (pick <= 0) step = "review";
else seekIndex(pick - 1);
}
/** Where each lens was last looking. A chip is a place you go, so it has to
* be a place you can come BACK to — otherwise browsing "Not looked yet"
* from the middle of a review costs you your position, which is exactly
* what the old `seekIndex` fallback did (it writes `pickRel`, the saved
* resume point, so the place was not merely lost on screen but persisted
* over). Remembering each lens separately means the chips can navigate —
* visibly do something — without any of them being able to lose a place. */
let bucketRel = $state({});
/** Changing the lens takes you into it, and remembers where you were.
*
* Where you land, in order:
* 1. Where you last were IN THIS LENS. Tabs that keep their own place
* are the whole point — "All" has to be able to hand you back the
* record you were reviewing, and it can only do that from memory:
* "keep the current record if the lens shows it" can never lose for
* All, which contains everything, so it would win every time and the
* memory would be dead code.
* 2. The record already under the cursor, when this lens shows it and
* has no memory of its own — switching lenses on the record you are
* reading should not move you for no reason.
* 3. The lens's first undecided record. */
function setBucket(k) {
if (k === bucket) return;
const keep = curPick?.rel;
if (keep) bucketRel[bucket] = keep;
bucket = k;
if (bucketRel[k] && seekTo(bucketRel[k])) { schedulePersist(); return; }
if (keep && seekTo(keep)) { schedulePersist(); return; }
seekIndex(Math.max(0, nextUndecided(pickerQueue)));
schedulePersist();
}
// The record under review casts the window's ambience — the library
// page's wash, from the same cover the hero shows.
let ambSrc = $state(null);
$effect(() => {
if (step !== "upgrades") { ambSrc = null; return; }
const p = localArts[curPick?.rel]?.path;
if (!p) { ambSrc = null; return; }
getArt(p).then((u) => { ambSrc = u; });
});
// …and the window's accent, off that same picture. This lives HERE rather
// than in the picker because the picker is re-keyed per record: anything
// it set was torn down on the way out, so every move went record A →
// the app's blue → record B, which is the cut he saw. Owned by the window
// the value only ever steps from one record's colour to the next, and
// `.im-window` CSS-transitions it — `--accent` is a registered <color>,
// the same mechanism as the library's ambience wash.
let accent = $state(null); // { accent, hover } | null
let accentSrc = null; // the picture already sampled — see below
const accentStyle = $derived(
accent ? `--accent: ${accent.accent}; --accent-hover: ${accent.hover};` : undefined,
);
$effect(() => {
if (step !== "upgrades") { accent = null; accentSrc = null; return; }
// Same source the picker's hero shows, same order: your own cover
// first, the matched service's only when you have none.
const rel = curPick?.rel;
const rep = reports[rel];
const src = ambSrc
?? rep?.reference?.url
?? rep?.candidates?.[0]?.url
?? found[rel]?.result?.candidate?.cover
?? null;
// Deliberately NOT cleared while the next one is extracted — going
// null mid-move reinstates the very flash this exists to remove.
// `found` and `reports` both mutate throughout identification and this
// effect reads them, so guard on the picture itself: without it every
// identify tick re-decodes a cover to reach the colour it already has.
if (!src || src === accentSrc) return;
accentSrc = src;
let stale = false;
accentFromImage(src).then((c) => { if (!stale && c) accent = c; });
return () => { stale = true; };
});
// ---- filing detection (research/import/render.js, ported) ------------
// How the source is ALREADY organised, read off the scan. The winning
// folder shape is proposed as the import layout because the app default
// may match almost none of it — importing on the default would refile
// the collection into a pattern the owner does not use. File names are
// detected too but only reported: phase 1 copies them verbatim.
const APP_DEFAULT_LAYOUT = "{artist}\\{year} - {album}";
const FOLDER_SHAPES = [
["{artist}\\{album}", /^(?!.*(19|20)\d{2}).+$/],
["{artist}\\({year}) - {album}", /^\((19|20)\d{2}\)\s*-\s*.+/],
["{artist}\\{year} - {album}", /^(19|20)\d{2}\s*-\s*.+/],
["{artist}\\{album} ({year})", /.+\((19|20)\d{2}\)\s*$/],
["{artist}\\{album} [{year}]", /.+\[(19|20)\d{2}\]\s*$/],
];
const FILE_SHAPES = [
["{n} {title}", /^\d{1,3}\s[^-.\s].+/],
["{n} - {title}", /^\d{1,3}\s-\s.+/],
["{title}", /^(?!\d).+/],
["{n}. {title}", /^\d{1,3}\.\s+.+/],
["{n}-{title}", /^\d{1,3}-[^\s].+/],
];
const filing = $derived.by(() => {
if (!scan) return null;
const folder = new Map(FOLDER_SHAPES.map(([k]) => [k, 0]));
let folderTotal = 0;
for (const a of albums) {
if (a.junk || a.depth !== 2) continue;
folderTotal++;
const hit = FOLDER_SHAPES.find(([, re]) => re.test(a.albumFolder));
const k = hit ? hit[0] : "{artist}\\{album}";
folder.set(k, folder.get(k) + 1);
}
const file = new Map(FILE_SHAPES.map(([k]) => [k, 0]));
let fileTotal = 0;
for (const a of albums) {
if (a.junk) continue;
for (const t of a.tracks) {
fileTotal++;
const stem = t.name.replace(/\.[a-z0-9]+$/i, "");
const hit = FILE_SHAPES.find(([, re]) => re.test(stem));
file.set(hit ? hit[0] : "{title}", file.get(hit ? hit[0] : "{title}") + 1);
}
}
const rank = (m, total) => [...m.entries()]
.map(([shape, n]) => ({ shape, n, pct: total ? Math.round((n / total) * 100) : 0 }))
.filter((x) => x.n)
.sort((x, y) => y.n - x.n);
return {
folders: rank(folder, folderTotal), folderTotal,
files: rank(file, fileTotal), fileTotal,
};
});
let layoutChoice = $state(null); // a departure from the detected winner
const chosenLayout = $derived(
layoutChoice ?? filing?.folders[0]?.shape ?? dest?.layout ?? APP_DEFAULT_LAYOUT);
/** The record the path preview is built from — a real one, so what gets
* approved is a path that will actually exist, not "Artist\Album". */
const previewAlbum = $derived(
albums.find((a) => !a.junk && a.depth === 2 && a.year != null)
?? albums.find((a) => !a.junk && a.depth === 2)
?? null);
function layoutPreview(shape) {
const a = previewAlbum;
if (!a) return shape;
return shape
.replace("{artist}", a.artistFolder)
.replace("{album}", a.title)
.replace("{year}", a.year != null ? String(a.year) : "Unknown year");
}
const fmtGB = (b) => (b / 1024 ** 3 >= 10 ? (b / 1024 ** 3).toFixed(0) : (b / 1024 ** 3).toFixed(1)) + " GB";
const nfmt = (n) => n.toLocaleString("en-US");
const destFits = $derived(
dest?.freeBytes == null || !scan ? null : dest.freeBytes > totals.bytes);
// ---- apply -----------------------------------------------------------
let unApply = null;
async function runApply() {
if (!dest?.dir || !plan.length || applying) return;
applying = true;
error = null;
importStatus.phase = "applying";
importStatus.done = 0;
importStatus.total = plan.length;
unApply = await api.onImportApply((p) => {
applyProg = p;
importStatus.done = p.done;
importStatus.total = p.total;
});
try {
// The chosen filing shape must be in config before apply reads it.
if (chosenLayout !== dest.layout) {
dest = await api.importSetDest(dest.dir, chosenLayout);
}
report = await api.importApply(scan.root, buildPayload());
step = "done";
importStatus.done = report.albums;
importStatus.label = fmtGB(report.bytes);
importStatus.skipped = report.skipped?.length ?? 0;
importStatus.phase = open ? "idle" : "applied";
// Clearing the session also WIPES THE AUDITION PEN. Doing that
// with records still unimported would delete the very copies they
// are waiting on — a 974 MB album 93/100 of the way down, gone,
// and no record that it was ever owed. The run is only over when
// nothing was left behind; otherwise keep the session so the
// stragglers can be applied again once they finish arriving.
const refused = new Set((report.skipped ?? []).map((s) => s.rel));
for (const [rel, c] of Object.entries(choices))
if (c && !refused.has(rel)) c.applied = true;
if (refused.size) {
await persist();
// A refusal is usually "the found copy is short", and the copy
// is short because a transfer died. Ask for the rest NOW, so
// "it imports as soon as the rest land" is something that can
// actually happen — otherwise the next run refuses the same
// records for the same reason, forever.
resumeRefused(report.skipped);
} else {
await api.importSessionClear().catch(() => {});
importResume.active = false;
importResume.pct = 0;
}
} catch (e) {
error = String(e);
importStatus.phase = open ? "idle" : "error";
} finally {
applying = false;
unApply?.(); unApply = null;
}
}
/** The review's resolved departures ride along per record; a record
* never opened imports exactly as phase 1 did. */
function buildPayload() {
return plan.map((p) => {
const r = choices[p.rel]?.resolved;
if (!r) return p;
const year = r.tags?.Year ? Number(r.tags.Year) : null;
return {
...p,
artist: r.tags?.Artist ?? p.artist,
album: r.tags?.Album ?? p.album,
year: year ?? p.year,
choices: {
artUrl: r.artUrl ?? null,
artist: r.tags?.Artist ?? null,
album: r.tags?.Album ?? null,
year,
label: r.tags?.Label ?? null,
genre: r.tags?.Genre ?? null,
trackTitles: r.trackTitles?.some(Boolean) ? r.trackTitles : null,
// Which version the library keeps. "theirs" makes the
// apply a MOVE out of the audition pen instead of a
// copy out of the source folder.
audio: r.audio === "theirs" ? "theirs" : null,
audioTracks: r.audio === "theirs" ? (r.audioTracks ?? null) : null,
// WHICH files the chosen copy is made of. The pen is
// keyed by RECORD, not by peer, so a record that
// changed peers has both copies sitting in it — the
// abandoned peer's transfers were cancelled, but the
// files they had already written stayed. Apply took
// everything in the folder and filed Centipede Hz as
// 17 tracks: deletia's 11 plus the 6 smash2 had
// managed before it was dropped.
audioFiles: r.audio === "theirs"
? (auditions[p.rel]?.candidate?.files ?? []).map((f) => f.path)
: null,
},
};
});
}
/** Bank what's already sorted: apply only the SEEN records, keep the
* session and the position, and mark them applied so another close
* doesn't redo them. The eventual full apply still covers everything —
* idempotent copies make the banked files no-ops. */
async function applyPartial() {
// Banking on the way out must never fail in silence. Closing the
// picker after deciding 25 records and finding nothing in the library —
// with no toast, no error and nothing in the log — is what a bare
// `return` here looks like from the outside.
if (applying || !scan) return;
if (!dest?.dir) {
importStatus.phase = "error";
importStatus.error = "No library folder is set, so nothing could be imported.";
console.warn("import: banking skipped — no destination");
return;
}
const seen = new Set(Object.entries(choices)
.filter(([, c]) => c?.seen && !c.applied).map(([r]) => r));
if (!seen.size) return;
const payload = buildPayload().filter((p) => seen.has(p.rel));
applying = true;
importStatus.phase = "applying";
importStatus.done = 0;
importStatus.total = payload.length;
unApply = await api.onImportApply((p) => {
applyProg = p;
importStatus.done = p.done;
importStatus.total = p.total;
});
try {
const rep = await api.importApply(scan.root, payload);
// ONLY what actually landed is marked done. Marking every seen
// record regardless is how a refused one ("still arriving — 93 of
// 100 tracks cached") was banked as imported and then excluded
// from every later apply: nothing was written and nothing would
// ever write it. A skipped record stays exactly as it was.
const refused = new Set((rep.skipped ?? []).map((s) => s.rel));
for (const r of seen)
if (choices[r] && !refused.has(r)) choices[r].applied = true;
resumeRefused(rep.skipped);
importStatus.done = rep.albums;
importStatus.label = fmtGB(rep.bytes);
importStatus.skipped = refused.size;
importStatus.phase = open ? "idle" : "applied";
report = rep;
persist();
} catch (e) {
console.warn("import: partial apply failed", e);
importStatus.phase = open ? "idle" : "error";
} finally {
applying = false;
unApply?.(); unApply = null;
}
}
const stepIndex = $derived(STEPS.findIndex(([k]) => k === step));
// ---- start over ------------------------------------------------------
// A session is sticky by design — it survives closing the window, and the
// resume machinery exists to put you back exactly where you were. That
// left no way to say "forget all this and scan something else": the only
// route was deleting import-session.json by hand.
let confirmReset = $state(false);
/** What the reset actually costs, counted rather than guessed at. */
const resetCost = $derived.by(() => ({
records: pickerBase.length,
decided: Object.values(choices).filter((c) => c?.seen).length,
cached: Object.values(auditions).filter(
(a) => a?.track1Path || a?.status === "complete" || a?.status === "ready").length,
}));
async function startOver() {
// Clears the session file AND the audition pen — every penned copy is
// one this run downloaded and did not keep, so the run ending is what
// makes it collectable.
try {
await api.importSessionClear();
} catch (e) {
console.warn("import: could not clear the session", e);
error = String(e);
}
step = "source";
root = null;
rootRel = null;
rootMissing = false;
scan = null;
scanProg = null;
scanEta = null;
ledger = [];
choices = {};
found = {};
auditions = {};
keepSeparate = [];
localArts = {};
reports = {};
pick = 0;
pickRel = null;
bucket = "all";
bucketRel = {};
report = null;
confirmReset = false;
updateResume();
}
</script>
<svelte:window onkeydown={onKey} />
{#if open}
<div class="im-veil" onclick={close} aria-hidden="true"></div>
<div class="im-window" role="dialog" aria-label="Import music" style={accentStyle}>
{#if ambSrc}<div class="im-amb" style:background-image="url({ambSrc})"></div>{/if}
<div class="im-amb-glow"></div>
<div class="im-rail">
{#each STEPS as [k, label], i}
{#if i > 0}<div class="im-rail-sep"></div>{/if}
<div class="im-step" class:on={k === step} class:done={i < stepIndex}>
<i>{i < stepIndex ? "✓" : i + 1}</i>{label}
</div>
{/each}
<!-- Only once there is a run to throw away. On the empty Source
step there is nothing to start over FROM. -->
{#if root || scan}
<button class="im-rail-reset" onclick={() => (confirmReset = !confirmReset)}
aria-expanded={confirmReset}
title="Forget this scan and every decision in it, and begin a new import">
Start over</button>
{/if}
<button class="im-rail-close" onclick={close}
title="Save and close — nothing is lost">✕</button>
</div>
<div class="im-body">
<!-- Says what it costs before it costs it. The counts are real, so
the sentence cannot quietly become untrue as the run grows. -->
{#if confirmReset}
<div class="im-left im-reset">
<b>Start over?</b>
This forgets the scan of {nfmt(resetCost.records)} record{resetCost.records === 1 ? "" : "s"}{#if resetCost.decided}, the
{nfmt(resetCost.decided)} decision{resetCost.decided === 1 ? "" : "s"} made so far{/if}{#if resetCost.cached}, and
{nfmt(resetCost.cached)} downloaded cop{resetCost.cached === 1 ? "y" : "ies"} waiting in the cache{/if}.
<b>Your music is not touched</b> — not the source folder, and not what is already in the library.
<div class="im-reset-row">
<button class="pill ghost" onclick={() => (confirmReset = false)}>Keep this run</button>
<button class="pill danger" onclick={startOver}>Discard it and start over</button>
</div>
</div>
{/if}
<!-- What the last apply left behind, carried into whatever step
you come back to. A background bank has no Done screen to
show it on, so without this the only trace of a refusal was
a toast reading "Imported 0 records". Not dismissible while
it is still true — it clears itself when they import. -->
{#if step !== "done" && leftBehind.length}
<div class="im-left">
<b>{nfmt(leftBehind.length)} record{leftBehind.length === 1 ? "" : "s"}
{leftBehind.length === 1 ? "was" : "were"} not imported.</b>
Nothing was written for {leftBehind.length === 1 ? "it" : "them"} and
{leftBehind.length === 1 ? "it is" : "they are"} still on the list —
importing again once the reason below is gone will pick
{leftBehind.length === 1 ? "it" : "them"} up.
<div class="im-left-rows">
<!-- The FOLDER, not the tag album name: this record's
tags call it "user48736353001", which names
nothing you could go and look at. -->
{#each report.skipped.slice(0, 6) as s (s.rel)}
<div><b>{s.rel.split(/[\\/]/).pop() || s.album}</b> — {s.reason}</div>
{/each}
{#if report.skipped.length > 6}
<div>and {nfmt(report.skipped.length - 6)} more</div>
{/if}
</div>
</div>
{/if}
{#if step === "source"}
<h2 class="im-h">Bring in your music</h2>
<p class="im-sub">Two folders: the one the music is in now, and the one SoulStream
keeps its copy in. The source is only ever read from — importing copies, so the
original folder survives anything the review decides.</p>
<div class="im-pair" class:solo={!root}>
<button class="im-drop" onclick={pickSource}>
<div class="im-drop-ico">▤</div>
<div class="im-drop-t">
<div class="im-drop-h">The music — read from</div>
<div class="im-path">{root ?? "Choose a folder…"}</div>
{#if rootMissing}
<!-- A resumed review whose source could not be
found: say so HERE, where the folder is
named, rather than letting apply fail on
a path the review still displays. -->
<small class="missing">this folder isn't here — choose where it lives now</small>
{:else if scan}
<small>{nfmt(totals.albums)} folders · {nfmt(totals.tracks)} files ·
{fmtGB(totals.bytes)} · scanned</small>
{:else if root}
<small>not scanned yet</small>
{:else}
<small>your own library, or someone else's drive — any folder of music</small>
{/if}
</div>
<span class="pill ghost">Change…</span>
</button>
{#if root}
<div class="im-pair-arrow" title="Copied, never moved">→</div>
<div class="im-drop">
<div class="im-drop-ico">▤</div>
<div class="im-drop-t">
<div class="im-drop-h">SoulStream's library — written to</div>
<div class="im-path">{dest?.dir ?? dest?.suggested ?? "Choose where the library lives…"}</div>
{#if dest?.dir}
<small class:no={destFits === false}>
{#if scan}{fmtGB(totals.bytes)} to copy · {/if}
{dest.freeBytes != null ? fmtGB(dest.freeBytes) + " free" : ""}
{destFits === false ? " — not enough room" : ""}
</small>
{:else if dest?.suggested}
<small class:no={destFits === false}>suggested —
{#if scan}{fmtGB(totals.bytes)} to copy · {/if}
{dest.freeBytes != null ? fmtGB(dest.freeBytes) + " free · " : ""}
{destFits === false ? "not enough room — choose elsewhere" : "nothing is created until you take it"}
</small>
{:else}
<small>required before anything is imported</small>
{/if}
</div>
{#if !dest?.dir && dest?.suggested}
<button class="pill" onclick={useSuggested}>Use this</button>
{/if}
<button class="pill ghost" onclick={pickDest}>{dest?.dir ? "Change…" : "Choose…"}</button>
</div>
{/if}
</div>
{#if !root}
<div class="im-how">
<div class="im-how-step"><i>1</i><div><b>Point it at a folder</b>
Your own collection or a friend's drive — the folder is only ever
read from, whatever the review decides.</div></div>
<div class="im-how-step"><i>2</i><div><b>Scan and review</b>
Headers only — duration, quality, tags, artwork. You see what's
there before anything is written anywhere.</div></div>
<div class="im-how-step"><i>3</i><div><b>Import copies</b>
The records you approve are copied into SoulStream's library,
filed the way the collection already files itself.</div></div>
</div>
{/if}
<div class="im-why">
<b>Why two folders.</b> Upgrading replaces files — a better cover written
into the tags, a higher-quality rip in place of a worn one, a corrected
year. Those writes land on <b>SoulStream's copy</b>.
{root ?? "The folder you point it at"} is never written to, so if an
upgrade turns out to be the wrong record or a worse master, the original
is still sitting there untouched.
{#if scan}It costs {fmtGB(totals.bytes)} to hold the second copy.{/if}
</div>
{#if error}<div class="im-err">{error}</div>{/if}
{:else if step === "scanning"}
<div class="im-scanwrap">
<h2 class="im-h">Reading the music</h2>
<p class="im-sub">Headers only — nothing is decoded and nothing is written.</p>
<div class="im-scan-line">
<div class="im-scan-n">{scanProg?.done ?? 0}</div>
<div class="im-scan-l">of {scanProg?.total ?? "…"} folders</div>
{#if scanEta}<div class="im-scan-eta">{scanEta}</div>{/if}
</div>
<div class="im-bar live"><i style:width="{scanProg?.filesTotal ? Math.min(100, (scanProg.filesDone / scanProg.filesTotal) * 100) : 2}%"></i></div>
<div class="im-stats loose">
<div class="im-stat"><b>{nfmt(scanProg?.filesDone ?? 0)}</b><i>tracks</i></div>
<div class="im-stat"><b>{nfmt(Math.round((scanProg?.tracksSecs ?? 0) / 60))}</b><i>minutes of music</i></div>
<div class="im-stat"><b>{((scanProg?.bytes ?? 0) / 1024 ** 3).toFixed(2)}</b><i>GB</i></div>
<div class="im-stat good"><b>{nfmt(scanProg?.lossless ?? 0)}</b><i>lossless</i></div>
<div class="im-stat"><b>{nfmt(scanProg?.artists ?? 0)}</b><i>artists</i></div>
</div>
<div class="im-ledger">
{#each ledger as r, i (r.id)}
<div class="im-led-row" class:is-head={r.kind !== "file"}
style:opacity={0.35 + (0.65 * (i + 1)) / ledger.length}>
{#if r.kind === "head"}
<span class="led-mark acc led-pulse">▸</span>
<span class="led-name led-title">{r.label}</span>
{:else if r.kind === "sum"}
<span class="led-mark ok">✓</span>
<span class="led-name led-title">{r.label}</span>
<span class="led-end">{r.tracks} tracks · {mmss(r.secs)} · {r.mb} MB</span>
{#if r.chip}<span class="led-chip {r.chip.cls}">{r.chip.t}</span>{/if}
{:else}
<span class="led-mark">·</span>
<span class="led-name">{r.name}</span>
{#if r.secs != null}<span class="led-end">{mmss(r.secs)}</span>{/if}
{#if r.chip}<span class="led-chip {r.chip.cls}">{r.chip.t}</span>{/if}
{/if}
</div>
{/each}
</div>
</div>
{:else if step === "review"}
<h2 class="im-h">Here's what's there</h2>
<p class="im-sub">{nfmt(issues.clean.length)} records are ready to import as they
are. Nothing below is an error — they are folders that don't say what they are.</p>
<div class="im-stats">
<div class="im-stat"><b>{nfmt(totals.albums)}</b><i>records</i></div>
<div class="im-stat"><b>{nfmt(totals.tracks)}</b><i>tracks</i></div>
<div class="im-stat"><b>{fmtGB(totals.bytes)}</b><i>to copy</i></div>
{#if issues.junk.length}<div class="im-stat warn"><b>{issues.junk.length}</b><i>skipped as junk</i></div>{/if}
{#if folding?.folds.length}<div class="im-stat good"><b>{folding.folds.filter((f) => !f.off).length}</b><i>rejoined from parts</i></div>{/if}
</div>
<!-- Nothing folds silently. A rule about folder shape will meet
a folder it is wrong about, and the last time grouping was
wrong it duplicated 9 GB before anyone saw it — so every
fold is listed with the parts it swallowed and a way out. -->
{#if folding?.folds.length}
<div class="im-card">
<div class="im-card-h">Put back together <em>{folding.folds.length}</em>
<span class="im-card-hint">scanned as separate folders, imported as one record</span></div>
<div class="im-rows">
{#each folding.folds as f (f.container)}
<div class="im-row" class:im-row-off={f.off}>
<b>{f.container.includes("\\") ? f.container.split("\\")[0] + " — " : ""}{f.name}</b>
<small>{f.off
? `${f.parts} separate records`
: `${f.parts} ${f.parts === 1 ? "folder" : "parts"} · ${nfmt(f.tracks)} tracks`}</small>
<button class="im-undo" onclick={() => toggleFold(f.container)}
title={f.off
? "Import these as one record after all"
: `Import ${f.members.join(", ")} as separate records`}
>{f.off ? "join them" : "keep separate"}</button>
</div>
{/each}
</div>
</div>
{/if}
{#if folding?.refused}
<div class="im-card">
<div class="im-card-h">Folders nested unevenly
<em>{Math.round(folding.share * 100)}% at one level</em></div>
<div class="im-why">Records sit at several different depths here, so there is no
one level that means "a release" — and guessing one would merge albums under
the wrong parent. Anything filed as parts of a record imports as separate
records instead. Nothing is lost; they just arrive unjoined.</div>
</div>
{/if}
{#each [["Named like a disc, filed as a record", issues.disc, "these sit at the top of their artist folder, so there is no record above them to be joined to — they import under this name"],["No artist folder", issues.loose, "the artist comes from the tags"], ["No artwork anywhere", issues.noArt, "a cover is generated from the name until one is found"]] as [title, list, sub]}
{#if list.length}
<div class="im-card">
<div class="im-card-h">{title} <em>{list.length}</em></div>
<div class="im-rows">
{#each list.slice(0, 6) as a}
<div class="im-row"><b>{a.depth >= 2 ? a.artistFolder + " — " : ""}{a.title}</b>
<small>{a.tracks.length} tracks</small></div>
{/each}
{#if list.length > 6}<div class="im-more">and {list.length - 6} more</div>{/if}
</div>
</div>
{/if}
{/each}
{#if filing?.folders.length}
{@const top = filing.folders[0]}
{@const topFile = filing.files[0]}
{@const noNumber = filing.files.find((f) => f.shape === "{title}")}
{@const defaultShare = filing.folders.find((f) => f.shape === APP_DEFAULT_LAYOUT)?.pct ?? 0}
<div class="im-layout">
<div class="im-layout-h">How this music is already filed
<em>{top.pct}% of {nfmt(filing.folderTotal)} records use one shape</em></div>
<div class="im-shapes">
{#each filing.folders as f (f.shape)}
<button class="im-shape" class:on={chosenLayout === f.shape}
onclick={() => { layoutChoice = f.shape; persist(); }}>
<code>{f.shape}</code>
<span>{f.pct}% · {nfmt(f.n)} record{f.n === 1 ? "" : "s"}</span>
</button>
{/each}
</div>
{#if chosenLayout === APP_DEFAULT_LAYOUT && defaultShare < 20}
<div class="im-layout-note"><b class="im-amber">That is SoulStream's
default, and only {defaultShare}% of this music is filed that way</b>
— importing on it would refile the other
{nfmt(filing.folderTotal - (filing.folders.find((f) => f.shape === chosenLayout)?.n ?? 0))}
records.</div>
{/if}
{#if topFile}
<div class="im-layout-h second">And how the files are named
<em>{topFile.pct < 50
? `no single shape — the most common is ${topFile.pct}% of ${nfmt(filing.fileTotal)} tracks`
: `${topFile.pct}% of ${nfmt(filing.fileTotal)} tracks read one way`}</em></div>
<div class="im-shapes">
{#each filing.files as f (f.shape)}
<div class="im-shape static">
<code>{f.shape}</code>
<span>{f.pct}% · {nfmt(f.n)} file{f.n === 1 ? "" : "s"}</span>
</div>
{/each}
</div>
{/if}
<div class="im-preview"><b>{dest?.dir ?? "library"}\</b>{layoutPreview(chosenLayout)}\<u>{previewAlbum?.tracks[0]?.name ?? ""}</u></div>
<div class="im-layout-note">
The folders are rebuilt from the chosen shape; file names are copied
verbatim — the same thing SoulStream does with its own downloads.
{#if noNumber}
<br /><b class="im-amber">{nfmt(noNumber.n)} of the files carry no
track number in their name</b> — those albums play in the order
the tags say, falling back to alphabetical where a tag is missing.
{/if}
</div>
</div>
{/if}
{#if error}<div class="im-err">{error}</div>{/if}
{#if applying}
<div class="im-apply">
<div class="im-bar live"><i style:width="{applyProg ? Math.round((applyProg.done / Math.max(1, applyProg.total)) * 100) : 2}%"></i></div>
<small>Copying {applyProg?.album ?? "…"} · {applyProg?.done ?? 0} of {applyProg?.total ?? plan.length}</small>
</div>
{/if}
{:else if step === "upgrades"}
<div class="im-buckets">
{#each BUCKETS as [k, label] (k)}
{@const n = k === "all" ? pickerBase.length : (bucketCounts[k] ?? 0)}
<button class="im-bucket" data-out={k}
aria-selected={bucket === k}
disabled={k !== "all" && !n}
onclick={() => setBucket(k)}>
{label}<i>{nfmt(n)}</i>
</button>
{/each}
<!-- Not a lens: the outcome chips choose what the picker
WALKS, this one discloses what the found copies still
owe. It rides here because it belongs to the same
glance, and it costs no space at all when nothing is
owed — which is the normal case. -->
{#if arrivals.length}
<button class="im-bucket ar-chip" class:stalled={arrivalStalled}
aria-expanded={trayOpen}
onclick={() => (trayOpen = !trayOpen)}
title={arrivalFiles
? "What the found copies still owe"
: "Copies cached and waiting to be imported"}>
{#if arrivalFiles}<span class="ar-dot"></span>{/if}
{arrivalStalled ? "Stalled" : arrivalFiles ? "Arriving" : "Arrived"}
<i>{nfmt(arrivalFiles || arrivals.length)}</i>
</button>
{/if}
</div>
{#if trayOpen && arrivals.length}
<div class="ar-tray" class:stalled={arrivalStalled} class:done={!arrivalFiles}>
<div class="ar-h">
{#if !arrivalFiles}
<b>Everything arrived.</b>
{arrivals.length === 1 ? "This copy is" : `All ${nfmt(arrivals.length)} copies are`}
cached — importing now takes {arrivals.length === 1 ? "it" : "them"}.
{:else if arrivalStalled}
<b>Stalled.</b> {nfmt(arrivalFiles)} file{arrivalFiles === 1 ? "" : "s"}
still owed, and nothing is moving.
{:else}
<b>Still arriving.</b> {nfmt(arrivalsOwed.length)}
record{arrivalsOwed.length === 1 ? "" : "s"} ·
{nfmt(arrivalFiles)} file{arrivalFiles === 1 ? "" : "s"}{#if arrivalBytes} ·
{nfmt(Math.round(arrivalBytes / 1e6))} MB to come{/if}.
{/if}
</div>
<div class="ar-rows">
{#each arrivals as a (a.rel)}
{@const w = arrivalWords(a)}
<div class="ar-row">
<div class="ar-art" class:ok={!a.owed}>{arrivalInitials(a.name)}</div>
<div class="ar-nm">
<b title={a.rel}>{a.name}</b>
<div class="ar-meta">
<div class="im-bar" class:live={a.owed > 0 && a.live?.phase === "transferring"}>
<i style:width="{Math.round((a.have / a.want) * 100)}%"></i>
</div>
<span>{nfmt(a.have)} of {nfmt(a.want)} cached</span>
<span>·</span><span>{arrivalFormat(a)}</span>
<b class="ar-state {w.cls}">{w.label}</b>
<span>{w.detail}</span>
</div>
</div>
<div class="ar-acts">
<button class="pill ghost" onclick={() => goToArrival(a.rel)}>Go to record</button>
{#if a.owed}
<button class="pill ghost"
onclick={() => switchPeer(a.rel, "you asked for another peer")}
>Try another peer</button>
{/if}
</div>
</div>
{/each}
</div>
<div class="ar-foot">
{#if arrivalFiles}
Nothing here needs you — they import themselves on the next run once the
files land. "Keep mine" on the record takes your own copy instead.
{:else}
<!-- The picker's own bank, not the full run:
it applies what has been decided and not
yet written, which is exactly these. -->
<button class="pill" onclick={applyPartial} disabled={applying}>
Import {nfmt(arrivals.length)} record{arrivals.length === 1 ? "" : "s"}</button>
{/if}
</div>
</div>
{/if}
{#if curPick}
{#key curPick.rel}
<ImportPicker
album={curPick}
artist={pickerArtist(curPick)}
albumName={pickerAlbum(curPick)}
sourceRoot={scan?.root ?? root}
index={Math.min(pick, pickerQueue.length - 1)}
total={pickerQueue.length}
{found} {localArts} {reports} {accentStyle}
choice={choices[curPick.rel]}
audition={auditions[curPick.rel]}
live={liveOf(auditions[curPick.rel])}
onFindCopy={() => findBetterCopy(curPick.rel)}
onTryAnother={() => switchPeer(curPick.rel, "you asked for another peer")}
onChoose={(patch) => choose(curPick.rel, patch)}
onSnapshot={(snap) => snapshot(curPick.rel, snap)}
onReset={() => { delete choices[curPick.rel]; schedulePersist(); }}
/>
{/key}
{:else}
<h2 class="im-h">Nothing to compare</h2>
{/if}
{:else if step === "done"}
<h2 class="im-h">The library is in</h2>
<p class="im-sub">{nfmt(report?.albums ?? 0)} records · {nfmt(report?.tracks ?? 0)}
tracks · {fmtGB(report?.bytes ?? 0)} copied.</p>
<p class="im-sub">The originals in {scan?.root} were not touched.</p>
<!-- A replacement deletes files from the library, so it says
so on the way out. Never a silent count. -->
{#if report?.replaced}
<p class="im-sub">{nfmt(report.replaced)} file{report.replaced === 1 ? "" : "s"}
of a previous version {report.replaced === 1 ? "was" : "were"} removed —
those records now hold the copy the review chose.</p>
{/if}
<!-- Skipped records used to say only "their names are in the
log". They carry a REASON now — a found copy that never
finished arriving is fixable, and you can only fix what
you can read. -->
{#if report?.skipped?.length}
<div class="im-card">
<div class="im-card-h">Not imported <em>{report.skipped.length}</em>
<span class="im-card-hint">nothing was written for these</span></div>
<div class="im-rows">
{#each report.skipped.slice(0, 12) as s (s.rel)}
<div class="im-row"><b>{s.rel.split(/[\\/]/).pop() || s.album}</b>
<small>{s.reason}</small></div>
{/each}
{#if report.skipped.length > 12}
<div class="im-more">and {report.skipped.length - 12} more</div>
{/if}
</div>
</div>
{/if}
{/if}
</div>
<div class="im-foot">
{#if step === "source"}
<div class="im-foot-note">Nothing is copied or written until the review says so.</div>
<button class="pill grow" disabled={!root} onclick={startScan}>Scan</button>
{:else if step === "scanning"}
<div class="im-foot-note">Reading headers — safe to leave; it keeps going.</div>
{:else if step === "review"}
<div class="im-foot-note">
{#if !dest?.dir}Choose where the library lives (Source step) before importing.
{:else}Copies <b>{fmtGB(totals.bytes)}</b> into {dest.dir} · the source is only read from.{/if}
</div>
<button class="pill ghost" onclick={close}>Save &amp; finish later</button>
<button class="pill ghost" onclick={() => { step = "source"; }}>Back</button>
<!-- Says which it is: a fresh walk or an unfinished one. It
used to read "Review upgrades" and restart at record 1
however far in you already were. -->
<button class="pill ghost" disabled={!pickerQueue.length}
onclick={openUpgrades}>{reviewed
? `Continue upgrades — ${nfmt(reviewed)} of ${nfmt(pickerBase.length)} reviewed`
: "Review upgrades"}</button>
<button class="pill grow" disabled={!dest?.dir || applying || !plan.length}
onclick={runApply}>{applying ? "Importing…" : `Import ${nfmt(plan.length)} records`}</button>
{:else if step === "upgrades"}
<div class="im-foot-note"><b>{Math.min(pick, Math.max(0, pickerQueue.length - 1)) + 1}</b>
of {nfmt(pickerQueue.length)} ·
{nfmt(Object.values(found).filter((f) => f?.result?.candidate).length)} matched so far{#if deferred}&nbsp;·&nbsp;{nfmt(deferred)} skipped for now{/if}</div>
<button class="pill ghost" onclick={close}>Save &amp; finish later</button>
<button class="pill ghost" onclick={pickerBack}>Back</button>
<button class="pill ghost"
title="Decide later — it stays in the run, and the walk comes back to it before you're done"
onclick={pickerSkip}>Skip</button>
<button class="pill grow" title="What's lit in the cards is what gets imported"
onclick={pickerAdvance}>Next</button>
{:else if step === "done"}
<div class="im-foot-note"></div>
<button class="pill grow" onclick={() => { ui.importOpen = false; ui.view = "library"; }}>
Open my library</button>
{/if}
</div>
</div>
{/if}
<style>
/* The prototype's chrome (research/import/import.css), the app's tokens.
Glass rail and footer over the window; cards carry the app's one
section heading. */
.im-veil {
position: fixed; inset: 0; z-index: 80;
background: rgba(6, 6, 8, 0.62);
backdrop-filter: blur(3px);
}
.im-window {
position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 94%; height: 92%;
z-index: 81;
display: flex; flex-direction: column;
background: var(--bg-base);
border-radius: 14px;
box-shadow: 0 40px 90px -20px rgba(0, 0, 0, 0.7),
inset 0 0 0 1px rgba(255, 255, 255, 0.09);
overflow: hidden;
/* Paging the upgrades queue re-tints the whole window, and a cut
between two saturated covers reads as a strobe. --accent is a
registered @property (<color>), so the scoped override this element
carries crossfades instead. Slower than the library's 420ms hover
wash on purpose: that one chases a pointer, this one marks a move
you chose to make. Dropping the override fades back to the app's
accent for free, so leaving the picker doesn't cut either. */
transition: --accent 900ms ease, --accent-hover 900ms ease;
}
@media (prefers-reduced-motion: reduce) {
.im-window { transition: none; }
}
.im-amb-glow {
position: absolute; z-index: 0; pointer-events: none;
left: -28px; right: -28px; top: 0; height: 300px;
background: radial-gradient(640px 260px at 14% 0%,
color-mix(in srgb, var(--accent) 13%, transparent), transparent 70%);
}
/* The library page's wash, cast by the record under review: blurred
cover at the top of the window, faded before the content, fixed to
the window rather than the scroll (the body scrolls under it). */
.im-amb {
position: absolute; z-index: 0; pointer-events: none;
left: -28px; right: -28px; top: 0;
height: 420px;
background-size: cover; background-position: center 30%;
filter: blur(90px) saturate(1.5);
opacity: 0.22;
transform: translateZ(0);
-webkit-mask-image: linear-gradient(180deg, #000 35%, transparent);
mask-image: linear-gradient(180deg, #000 35%, transparent);
}
.im-rail {
flex: none; position: relative; z-index: 2;
display: flex; align-items: center; gap: 4px;
height: 48px; padding: 0 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
background: rgba(10, 10, 14, 0.55);
backdrop-filter: blur(10px) saturate(1.4);
}
.im-step {
display: flex; align-items: center; gap: 7px;
padding: 5px 10px; border-radius: 999px;
font-size: 12px; font-weight: 700; color: var(--text-muted);
}
.im-step i {
width: 18px; height: 18px; border-radius: 50%;
display: grid; place-items: center;
font-style: normal; font-size: 10px; font-weight: 800;
background: rgba(255, 255, 255, 0.09); color: var(--text-secondary);
}
.im-step.done { color: var(--text-secondary); }
.im-step.done i { background: color-mix(in srgb, var(--accent) 34%, transparent); color: #fff; }
.im-step.on {
color: #fff;
background: color-mix(in srgb, var(--accent) 34%, rgba(10, 10, 12, 0.5));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
}
.im-step.on i { background: var(--accent); color: #000; }
.im-rail-sep { width: 12px; height: 1px; background: rgba(255, 255, 255, 0.12); flex: none; }
.im-rail-close {
margin-left: auto; border: 0; background: none;
color: var(--text-muted); padding: 4px 6px; cursor: pointer;
font-size: 14px; font-family: inherit;
}
.im-rail-close:hover { color: var(--text-primary); }
/* Sits before the ✕ and takes its `margin-left: auto`, so the pair stays
pinned right however many steps the rail carries. */
.im-rail-reset {
margin-left: auto; border: 0; background: none;
color: var(--text-muted); padding: 4px 8px; cursor: pointer;
font-family: inherit; font-size: 11px; font-weight: 800;
letter-spacing: 0.05em; text-transform: uppercase;
}
.im-rail-reset:hover, .im-rail-reset[aria-expanded="true"] { color: #f0a0a0; }
.im-rail-reset + .im-rail-close { margin-left: 0; }
.im-body {
flex: 1; min-height: 0; position: relative; z-index: 1;
overflow-y: auto; padding: 22px 26px 24px;
}
.im-h { margin: 0 0 4px; font-size: 24px; font-weight: 800; letter-spacing: -0.02em; }
.im-sub { margin: 0 0 18px; font-size: 13px; color: var(--text-secondary); line-height: 1.5; max-width: 74ch; }
.im-pair { display: grid; grid-template-columns: 1fr auto 1fr; gap: 12px; align-items: stretch; }
.im-pair.solo { grid-template-columns: 1fr; }
.im-pair-arrow {
align-self: center; width: 34px; height: 34px; border-radius: 50%;
display: grid; place-items: center;
background: rgba(10, 10, 14, 0.72);
backdrop-filter: blur(10px) saturate(1.4);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09);
color: var(--text-secondary); font-weight: 700;
}
.im-drop {
min-width: 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius);
padding: 22px;
display: flex; align-items: center; gap: 16px;
background: rgba(255, 255, 255, 0.02);
text-align: left; font-family: inherit;
color: inherit; font-size: inherit;
}
/* Only the card that IS a picker invites a click; the dest card carries
its own buttons instead. */
button.im-drop { cursor: pointer; }
button.im-drop:hover { background: rgba(255, 255, 255, 0.05); }
button.im-drop:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.im-drop-ico {
width: 46px; height: 46px; flex: none; border-radius: 10px;
display: grid; place-items: center;
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent-hover);
}
.im-drop-t { flex: 1; min-width: 0; }
.im-drop-h {
font-size: 10px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--text-secondary); margin-bottom: 5px;
}
.im-path {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 12.5px; color: var(--text-primary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.im-drop small { display: block; color: var(--text-muted); font-size: 12px; margin-top: 3px; }
.im-drop small.no { color: #f19292; }
.im-drop .pill { flex: none; }
.im-scan-line { display: flex; align-items: baseline; gap: 8px; margin-bottom: 14px; }
.im-scan-n { font-size: 42px; font-weight: 800; letter-spacing: -0.02em; line-height: 1; font-variant-numeric: tabular-nums; }
.im-scan-l { font-size: 12px; color: var(--text-muted); font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
.im-bar { height: 4px; border-radius: 999px; background: rgba(255, 255, 255, 0.10); overflow: hidden; }
.im-bar i { display: block; height: 100%; background: var(--accent); border-radius: 999px; transition: width 0.15s linear; }
.im-bar.live i {
background: linear-gradient(90deg, var(--accent),
color-mix(in srgb, var(--accent-hover) 70%, #fff) 50%, var(--accent));
background-size: 200% 100%;
animation: im-bar-sheen 1.6s linear infinite;
}
@keyframes im-bar-sheen { to { background-position: -200% 0; } }
.im-scan-eta {
margin-left: auto; align-self: center;
font-size: 12px; font-weight: 700; color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
/* The scanning step fills the body so the ledger can take every pixel
under the meters instead of a strip near the top. */
.im-scanwrap {
height: 100%; min-height: 320px;
display: flex; flex-direction: column;
}
/* The ledger: a windowed stream with no scrollbar — rows enter at the
bottom, age toward the top, and are clipped away. */
.im-ledger {
flex: 1; min-height: 120px;
overflow: hidden;
display: flex; flex-direction: column; justify-content: flex-end;
margin-top: 14px; padding: 8px 12px; border-radius: 6px;
background: rgba(0, 0, 0, 0.28);
}
.im-led-row {
flex: none;
display: flex; gap: 8px; align-items: baseline;
white-space: nowrap; overflow: hidden;
line-height: 21.6px;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 11.5px; color: var(--text-muted);
}
.im-led-row.is-head { color: var(--text-primary); }
.led-title {
font-family: var(--font-body, inherit);
font-size: 12.5px; font-weight: 650;
}
.led-mark { flex: none; }
.led-mark.acc { color: var(--accent); }
.led-mark.ok { color: #9ecf8e; }
.led-pulse { animation: im-led-pulse 1s ease-in-out infinite; }
@keyframes im-led-pulse { 50% { opacity: 0.35; } }
.led-name { overflow: hidden; text-overflow: ellipsis; }
.led-end {
margin-left: auto; flex: none;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 11.5px; color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.led-chip {
flex: none; align-self: center;
font-size: 10px; font-weight: 700; line-height: 1.6;
padding: 0 6px; border-radius: 999px;
background: rgba(255, 255, 255, 0.08); color: var(--text-muted);
}
.led-chip.fl { background: rgba(158, 207, 142, 0.16); color: #9ecf8e; }
.led-chip.hi {
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent-hover, var(--accent));
}
.im-stats {
display: flex; flex-wrap: wrap; gap: 0 26px;
padding-bottom: 16px; margin-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
.im-stat b {
display: block; font-size: 20px; font-weight: 800;
font-variant-numeric: tabular-nums; line-height: 1.15;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
.im-stat i {
font-style: normal; font-size: 10.5px; font-weight: 800;
letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted);
}
.im-stat.warn b { color: #e9b949; }
.im-stat.good b { color: #9ecf8e; }
.im-stats.loose { border-bottom: 0; padding-bottom: 0; margin: 18px 0 0; }
.im-card {
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.03);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
padding: 16px; margin-bottom: 12px;
}
.im-card-h {
display: flex; align-items: center; gap: 8px;
font-size: 11px; font-weight: 800; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--text-muted); margin-bottom: 12px;
}
.im-card-h::after {
content: ""; order: 1; flex: 1; height: 1px;
background: rgba(255, 255, 255, 0.1); margin-left: 4px;
}
.im-card-h em { font-style: normal; color: var(--text-secondary); font-weight: 700; letter-spacing: 0; text-transform: none; font-size: 12px; }
.im-rows { display: flex; flex-direction: column; gap: 6px; }
.im-row { display: flex; align-items: baseline; gap: 10px; font-size: 12.5px; }
.im-row b { font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.im-row small { color: var(--text-muted); font-size: 11.5px; flex: none; }
.im-more { font-size: 12px; font-weight: 700; color: var(--text-muted); margin-top: 2px; }
.im-row-off b, .im-row-off small { color: var(--text-muted); }
.im-undo {
margin-left: auto; flex: none; border: 0; padding: 0; cursor: pointer;
background: none; font-family: inherit; font-size: 11.5px; font-weight: 700;
color: var(--text-muted);
text-decoration: underline; text-underline-offset: 3px;
}
.im-undo:hover { color: var(--accent-hover); }
.im-undo:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Source: the reason for the copy, said once and plainly, and the
three-step empty state that fills the void before a folder is chosen. */
.im-why {
margin-top: 14px; padding: 13px 15px; border-radius: var(--radius);
font-size: 12.5px; line-height: 1.55; color: var(--text-secondary);
background: rgba(255, 255, 255, 0.03);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
max-width: 96ch;
}
.im-why b { color: var(--text-primary); font-weight: 700; }
.im-how { margin-top: 18px; display: flex; flex-direction: column; gap: 12px; }
.im-how-step {
display: flex; align-items: flex-start; gap: 10px;
font-size: 13px; color: var(--text-secondary); line-height: 1.45;
max-width: 96ch;
}
.im-how-step b { color: var(--text-primary); font-weight: 700; display: block; }
.im-how-step i {
width: 20px; height: 20px; flex: none; border-radius: 50%;
display: grid; place-items: center; margin-top: 1px;
font-style: normal; font-size: 10px; font-weight: 800;
background: rgba(255, 255, 255, 0.09); color: var(--text-secondary);
}
/* Review: how the collection already files itself. The folder shapes are
the one decision on the page; the file shapes are a report. */
.im-layout {
margin-top: 14px; padding: 14px 15px; border-radius: var(--radius);
background: rgba(255, 255, 255, 0.03);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
.im-layout-h {
display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
font-size: 11px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--text-muted); margin-bottom: 11px;
}
.im-layout-h.second { margin-top: 16px; }
.im-layout-h em {
font-style: normal; text-transform: none; letter-spacing: 0;
font-size: 12px; font-weight: 700; color: var(--text-secondary);
}
.im-shapes { display: flex; flex-wrap: wrap; gap: 7px; }
.im-shape {
display: flex; flex-direction: column; gap: 3px; align-items: flex-start;
padding: 8px 11px; border-radius: 8px; cursor: pointer;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
font-family: inherit; text-align: left; color: inherit; font-size: inherit;
}
.im-shape:hover { background: rgba(255, 255, 255, 0.07); }
.im-shape.on {
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
.im-shape.static, .im-shape.static:hover {
cursor: default; background: rgba(255, 255, 255, 0.03);
}
.im-shape:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.im-shape code {
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 12px; color: var(--text-primary);
}
.im-shape span { font-size: 11px; color: var(--text-muted); }
.im-preview {
margin-top: 11px; padding: 9px 11px; border-radius: 6px;
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 12px; color: var(--text-secondary);
background: rgba(0, 0, 0, 0.28);
overflow-x: auto; white-space: nowrap;
}
.im-preview b { color: var(--text-muted); font-weight: 400; }
.im-preview u { color: var(--text-primary); text-decoration: none; }
.im-layout-note {
margin-top: 10px; font-size: 12px; line-height: 1.5; color: var(--text-muted);
max-width: 96ch;
}
.im-amber { color: #f0cd77; }
/* The outcome strip: which slice of the queue the picker walks. The
chips speak the verdict palette (green agrees, amber close, red wrong,
grey unjudged); the count rides muted beside each label. */
.im-buckets {
position: relative; z-index: 1;
display: flex; flex-wrap: wrap; gap: 7px;
margin-bottom: 16px;
}
.im-bucket {
display: inline-flex; align-items: center; gap: 7px;
padding: 5px 11px; border-radius: 999px; border: 0; cursor: pointer;
font-family: inherit; font-size: 11px; font-weight: 800;
letter-spacing: 0.05em; text-transform: uppercase;
background: rgba(255, 255, 255, 0.06); color: var(--text-secondary);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.09);
}
.im-bucket i {
font-style: normal; font-weight: 700; font-size: 11px;
font-variant-numeric: tabular-nums; opacity: 0.65;
}
.im-bucket:hover:not([disabled]) { background: rgba(255, 255, 255, 0.1); }
.im-bucket[disabled] { opacity: 0.35; cursor: default; }
.im-bucket[aria-selected="true"] {
background: color-mix(in srgb, var(--accent) 30%, rgba(10, 10, 12, 0.5));
color: #fff;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
}
.im-bucket[data-out="ok"]:not([disabled]):not([aria-selected="true"]) { color: #7fe0a4; }
.im-bucket[data-out="warn"]:not([disabled]):not([aria-selected="true"]) { color: #f0cd77; }
.im-bucket[data-out="bad"]:not([disabled]):not([aria-selected="true"]) { color: #f19292; }
.im-bucket:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---- what the found copies still owe ------------------------------
The chip rides in the outcome row but is NOT one of them: the others
choose what the picker walks, this one discloses a state. Accent fill
and a pulse say so, and it is absent entirely when nothing is owed. */
.im-bucket.ar-chip {
background: color-mix(in srgb, var(--accent) 26%, rgba(10, 10, 12, 0.5));
color: #fff;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent);
}
.im-bucket.ar-chip:hover {
background: color-mix(in srgb, var(--accent) 34%, rgba(10, 10, 12, 0.5));
}
.im-bucket.ar-chip.stalled {
background: rgba(233, 185, 73, 0.2); color: #f0cd77;
box-shadow: inset 0 0 0 1px rgba(233, 185, 73, 0.4);
}
.ar-dot {
width: 6px; height: 6px; border-radius: 50%; background: currentColor;
animation: ar-pulse 1.4s ease-in-out infinite;
}
@keyframes ar-pulse { 50% { opacity: 0.25; } }
.ar-tray {
position: relative; z-index: 1;
margin: -6px 0 16px; padding: 11px 13px; border-radius: var(--radius);
max-width: 96ch;
background: rgba(0, 0, 0, 0.28);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07);
}
.ar-h {
display: flex; align-items: baseline; gap: 9px;
font-size: 12.5px; line-height: 1.5; color: var(--text-secondary);
}
.ar-h b { color: color-mix(in srgb, var(--accent) 55%, #fff); font-weight: 800; }
.ar-tray.stalled .ar-h b { color: #f0cd77; }
.ar-tray.done .ar-h b { color: #7fe0a4; }
.ar-rows { margin-top: 9px; display: flex; flex-direction: column; gap: 7px; }
.ar-row {
display: grid; grid-template-columns: 34px 1fr auto; gap: 11px; align-items: center;
padding: 8px 9px; border-radius: 6px; background: rgba(255, 255, 255, 0.03);
}
.ar-art {
width: 34px; height: 34px; border-radius: 4px; display: grid; place-items: center;
font-size: 10px; font-weight: 800; color: rgba(255, 255, 255, 0.78);
background: color-mix(in srgb, var(--accent) 30%, rgba(255, 255, 255, 0.06));
}
.ar-art.ok { background: rgba(127, 224, 164, 0.22); }
.ar-nm { min-width: 0; }
.ar-nm > b {
display: block; font-size: 13px; font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ar-meta {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
margin-top: 3px; font-size: 11.5px; color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.ar-meta .im-bar { width: 120px; flex: none; }
.ar-state { font-weight: 700; }
.ar-state.wait { color: var(--text-secondary); }
.ar-state.move { color: color-mix(in srgb, var(--accent) 60%, #fff); }
.ar-state.stall { color: #f0cd77; }
.ar-state.ok { color: #7fe0a4; }
.ar-acts { display: flex; gap: 6px; align-items: center; }
.ar-foot { margin-top: 9px; font-size: 11.5px; color: var(--text-muted); line-height: 1.5; }
/* Left behind, not broken — amber, the picker's .im-tl-warn language. */
.im-left {
margin-bottom: 16px; padding: 11px 13px; border-radius: var(--radius);
font-size: 12.5px; line-height: 1.5; color: var(--text-secondary);
background: rgba(233, 185, 73, 0.1);
box-shadow: inset 0 0 0 1px rgba(233, 185, 73, 0.32);
max-width: 96ch;
}
.im-left b { color: #f0cd77; }
.im-left-rows { margin-top: 7px; display: flex; flex-direction: column; gap: 3px; }
.im-left-rows b { color: var(--text-primary); font-weight: 700; }
/* Red rather than amber: the band above reports something that already
happened, this one asks permission to destroy something. */
.im-reset {
background: rgba(233, 90, 90, 0.1);
box-shadow: inset 0 0 0 1px rgba(233, 90, 90, 0.32);
}
.im-reset b { color: #f0a0a0; }
.im-reset-row { margin-top: 10px; display: flex; gap: 8px; }
/* The source folder a resumed review could not find. Amber, not red: it
reports something that happened rather than asking to destroy anything. */
.im-drop-t small.missing { color: rgb(232, 178, 96); }
.pill.danger { background: rgba(233, 90, 90, 0.9); color: #1a0d0d; }
.pill.danger:hover { background: rgba(233, 90, 90, 1); }
.im-err {
margin-top: 14px; padding: 11px 13px; border-radius: var(--radius);
font-size: 12.5px; line-height: 1.5;
color: #f0a0a0; background: rgba(233, 90, 90, 0.1);
box-shadow: inset 0 0 0 1px rgba(233, 90, 90, 0.25);
max-width: 96ch;
}
.im-apply { margin-top: 16px; }
.im-apply small { display: block; margin-top: 8px; color: var(--text-muted); font-size: 12px; }
.im-foot {
flex: none; position: relative; z-index: 2;
display: flex; align-items: center; gap: 10px;
padding: 13px 22px;
border-top: 1px solid rgba(255, 255, 255, 0.07);
background: rgba(10, 10, 14, 0.55);
backdrop-filter: blur(10px) saturate(1.4);
}
.im-foot-note { font-size: 12px; color: var(--text-muted); margin-right: auto; }
.im-foot-note b { color: var(--text-secondary); }
.pill {
background: var(--accent); color: #000;
font-size: 11px; font-weight: 700;
padding: 6px 14px; border-radius: 14px; white-space: nowrap;
border: 0; cursor: pointer; font-family: inherit; line-height: 1.35;
flex: none;
}
.pill:hover { background: var(--accent-hover); }
.pill.ghost { background: var(--bg-highlight); color: var(--text-primary); }
.pill.ghost:hover { background: var(--bg-press, #333); }
.pill:disabled { background: var(--bg-highlight); color: var(--text-muted); cursor: default; opacity: 0.6; }
.pill:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.im-bar.live i { animation: none; }
.led-pulse { animation: none; }
.ar-dot { animation: none; }
}
</style>