import: the whole feature, and releases put back together
Everything the import grew over the last week, landing in one go — the window, the picker, the audition pen, apply, and the review that stands between them. The fold is the new part. A release filed as a container of parts arrived as N albums, because the scan groups audio by its immediate parent and the container holds none of its own: Analord's eleven volumes were eleven records, and six more releases split the same way. Records now fold into the release above them, and apply is handed an explicit part list — never a recursive walk, which is what let one stray file swallow 288 descendants and duplicate 9 GB. Where releases live is read off the collection rather than assumed, so a genre-nested library moves the answer instead of breaking; below a dominant majority at one depth the pass refuses entirely, because no folding beats wrong folding. Three vetoes independent of depth catch a misread level. Every fold is listed in the review with a way out. research/import/fold-audit.mjs imports the shipping module, so the audited code is the running code: 9 folds, 0 swallows across four library shapes, and none of the 148 artist folders touched. It was that harness that killed the tempting version of this rule — matching a part against its container folded 40 artist folders, Mac Demarco's 8 albums into one, because album folders carry the artist's name. The regression test found a live bug on the way: parts number from 01 each, so flattening two discs collided, and "same name, same size" read the second file as already-imported and dropped it while still counting it. That reuse now also requires that this run has not already written the path. Also here: the picker's accent crossfades between records instead of cutting through the app's blue (ownership moved to the window, which survives the per-record re-key), the fullscreen viewer wears the album's accent instead of the one it left behind in the portal, Skip defers a record without deciding it, and Keep mine is gone — with the cards picked field by field it was Next under another name.
This commit is contained in:
1 parent
4b30c5e573
commit
e7d0971de2
23 files changed
+6498
-258
No files matched your search
+47
-13
@@ -213,6 +213,27 @@ export function announceFollow(on, artist) {
|
||||
followToast.portrait = artist?.picture ?? null;
|
||||
}
|
||||
|
||||
/** Background-import status, read by ImportToast. The import window keeps
|
||||
* scanning/copying after it is closed (the component stays mounted, only
|
||||
* its template goes), and closed must never mean invisible: while work
|
||||
* runs, the toast narrates it and clicks back into the window. Terminal
|
||||
* phases (scanned/applied/error) are only entered when the window is
|
||||
* CLOSED at that moment — an open window narrates its own completions —
|
||||
* and they stick until the window is reopened, which acknowledges them. */
|
||||
export const importStatus = $state({
|
||||
phase: "idle", // idle | scanning | scanned | applying | applied | error
|
||||
done: 0, // scanning/applying: progress; scanned/applied: final count
|
||||
total: 0,
|
||||
label: "", // applied: the "· 12 GB" tail; otherwise unused
|
||||
skipped: 0, // applied: records nothing was written for — the toast must
|
||||
// say so, because "Imported 0 records" on its own reads as a dead end
|
||||
});
|
||||
|
||||
/** A paused import's presence on the Library page: the Import button wears
|
||||
* a percentage badge while a saved review session exists. Fed by
|
||||
* ImportWindow (boot restore, every persist, cleared on completion). */
|
||||
export const importResume = $state({ active: false, pct: 0 });
|
||||
|
||||
export const ui = $state({
|
||||
view: "home",
|
||||
albumId: null, // selected album in Library view
|
||||
@@ -237,6 +258,11 @@ export const ui = $state({
|
||||
// of whatever cover to show — { src, local, title, subtitle } — so any
|
||||
// surface can open it, not only the playing track.
|
||||
artwork: null,
|
||||
// The import tool's window. `false` when closed; `true` opens it (resuming
|
||||
// any saved session); `{ root }` opens it with a source folder pre-picked.
|
||||
// Lives on ui rather than the Library page because importing is not a
|
||||
// first-run event — another drive can arrive any day.
|
||||
importOpen: false,
|
||||
// WebView2 keeps document.visibilityState="visible" while the native
|
||||
// window is minimized. This is the authoritative shared activity gate for
|
||||
// hidden animation, canvas, carousel, and high-frequency position work.
|
||||
@@ -3591,7 +3617,7 @@ export const resolver = $state({
|
||||
error: "",
|
||||
});
|
||||
|
||||
const RESOLVER_AUDIO_QUALITY = {
|
||||
export const RESOLVER_AUDIO_QUALITY = {
|
||||
flac: 4, ape: 4, aiff: 3.5, wav: 3.5,
|
||||
m4a: 2.5, ogg: 2.5, opus: 2.5, aac: 2.2, mp3: 2, wma: 1.5,
|
||||
};
|
||||
@@ -3794,7 +3820,7 @@ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
/** Query with server-excluded phrases removed (whole-word, any position) and
|
||||
* whitespace collapsed. Returns "" when nothing usable remains. */
|
||||
function sanitizeSearchQuery(query) {
|
||||
export function sanitizeSearchQuery(query) {
|
||||
let out = ` ${query ?? ""} `;
|
||||
for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) {
|
||||
out = out.replace(new RegExp(`(?<=^|[^\\p{L}\\p{N}])${escapeRegExp(phrase)}(?=$|[^\\p{L}\\p{N}])`, "giu"), " ");
|
||||
@@ -3822,7 +3848,9 @@ function sanitizeSearchQuery(query) {
|
||||
const SEARCH_MIN_GAP_MS = 1500;
|
||||
let lastSearchStartedAt = 0;
|
||||
|
||||
async function collectSources(query, maxMs, goodCount) {
|
||||
// Exported for the import finder (import-audio.js): the pacing, poisoned-
|
||||
// term hygiene and early-exit logic must exist ONCE, not be re-coded.
|
||||
export async function collectSources(query, maxMs, goodCount) {
|
||||
const gap = lastSearchStartedAt + SEARCH_MIN_GAP_MS - performance.now();
|
||||
if (gap > 0) await new Promise((r) => setTimeout(r, gap));
|
||||
lastSearchStartedAt = performance.now();
|
||||
@@ -4474,16 +4502,22 @@ export function tinyCover(url) {
|
||||
* art, badges, album-folder source preference, and length verification. */
|
||||
export async function streamSong(artist, title, opts = {}) {
|
||||
if (resolver.active) return;
|
||||
const local = await libraryTrackFor(artist, title);
|
||||
if (local) {
|
||||
playAlbum(
|
||||
local.tracks,
|
||||
`${local.album.artist} — ${local.album.title}`,
|
||||
local.index,
|
||||
local.album.art_path ?? null,
|
||||
);
|
||||
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
|
||||
return;
|
||||
// opts.skipLocal: the import picker's A/B auditions the CANDIDATE — the
|
||||
// local-library shortcut would find the user's own copy and play them
|
||||
// against themselves (decision 11). The stream cache below still counts:
|
||||
// those bytes came from a peer, so their quality is honestly "theirs".
|
||||
if (!opts.skipLocal) {
|
||||
const local = await libraryTrackFor(artist, title);
|
||||
if (local) {
|
||||
playAlbum(
|
||||
local.tracks,
|
||||
`${local.album.artist} — ${local.album.title}`,
|
||||
local.index,
|
||||
local.album.art_path ?? null,
|
||||
);
|
||||
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Streamed before and still byte-complete in the cache? Play those bytes
|
||||
// now — no search, no resolver popup, no network requirement. This is the
|
||||
|
||||
Reference in new issue
Block a user