2627 lines
130 KiB
Svelte
2627 lines
130 KiB
Svelte
<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 & 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} · {nfmt(deferred)} skipped for now{/if}</div>
|
||
<button class="pill ghost" onclick={close}>Save & 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>
|