playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14419 -599

No files matched your search

+136 -14
View File
@@ -16,7 +16,7 @@
import { onDestroy } from "svelte";
import { getArtMaster, playLocalFile, player, togglePlayPause } from "../lib/state.svelte.js";
import { evidence as matchEvidence, normx } from "../lib/import-evidence.js";
import { IDENTIFY_ENGINE } from "../lib/import-title.js";
import { IDENTIFY_ENGINE, majority } from "../lib/import-title.js";
let {
album: a, // derived album from ImportWindow (rel, title, …)
@@ -101,14 +101,79 @@
(async () => {
try {
const ask = albumName ?? a.title;
const r = await api.importIdentify(artist, ask);
// The folder rides along as the second witness: `ask` is the
// tags' claim, and tags carry typos the folder does not
// ("Nonagon Infiniry" on all nine files). Asked after it, and
// deduped away when they agree.
//
// The SHAPE goes too — count, track titles, year, and where the
// files live. A name is only one fact about a record, and it is
// the one most often wrong; the discography pass compares the
// rest when no phrase can find it.
const shape = {
tracks: a.tracks?.length ?? null,
trackTitles: (a.tracks ?? []).map((t) => t.title).filter(Boolean),
year: a.year ?? null,
// `parts` bounds the artwork hash to the record's OWN audio.
// Without it the backend walked the folder recursively, and
// a record sharing a folder with another one hashed the
// neighbour's cover — see own_audio_files in import.rs.
sourceRoot, rel, parts: a.parts ?? [],
};
let r = await api.importIdentify(artist, ask, a.title, shape);
let askedArtist = artist;
// The FOLDER is asked first, and stays the default: it is one
// deliberate decision, where track artist tags carry featured
// guests, compilation contributors and classical performers.
//
// But a folder is sometimes not the artist at all — "Band Of
// Gypsys" holding six files that every one of them tags
// "Jimi Hendrix" — and then the ask is wrong twice over,
// because the try-list strips that "artist" off the front of
// the title and asks the providers for what is left of it.
//
// Only when the first ask came back with NOTHING, or with a
// record the judge calls wrong. A candidate that already
// stands is not this function's business.
const judge = (res, who) => (res?.candidate
? matchEvidence(res.candidate, who, ask, a.tracks.length).verdict
: "none");
if (["none", "bad"].includes(judge(r, artist))) {
const tagged = majority((a.tracks ?? []).map((t) => t.artist));
if (tagged && normx(tagged) !== normx(artist)) {
const alt = await api.importIdentify(tagged, ask, a.title, shape);
// Taken only if it is actually better. A second wrong
// answer is not an improvement on the first.
if (["ok", "warn"].includes(judge(alt, tagged))) {
r = alt;
askedArtist = tagged;
}
}
}
// Stamped with the engine that produced it: a cached "nothing
// found" is only trustworthy while the thing that looked has
// not learned anything new. See IDENTIFY_ENGINE.
found[rel] = { state: "done", result: r, engine: IDENTIFY_ENGINE };
// `askedArtist` rides along so the judge compares against the
// name the provider was ASKED about — the §3.2 rule, which
// would otherwise call this record's own match a wrong one.
// A lookup that FAILED is not an answer about the record. MB's
// 1 req/s limiter makes a 503 routine, and filing one as "done"
// made "Murder of the Universe" a permanent deep cut — the
// record MB holds, scored 100, and gave up on the next ask.
// `persist()` only saves `done`, so an error re-asks; nothing
// else here treats the two alike.
if (r.incomplete && !r.candidate) {
found[rel] = {
state: "error", result: null,
error: "the provider did not answer — this record has not been looked up yet",
};
return;
}
found[rel] = { state: "done", result: { ...r, askedArtist }, engine: IDENTIFY_ENGINE };
if (r.candidate?.cover) {
const rep = await api.importCoverReport(
artist, r.used ?? ask, r.candidate.cover);
askedArtist, r.used ?? ask, r.candidate.cover);
reports[rel] = rep;
}
} catch (e) {
@@ -133,7 +198,10 @@
const rel = a.rel;
if (localArts[rel] !== undefined) return;
localArts[rel] = null;
api.importLocalArt(sourceRoot, rel).then(
// `parts` for the same reason apply takes them: the record's audio is
// its own folder and the parts it named, never a walk of what happens
// to sit underneath.
api.importLocalArt(sourceRoot, rel, a.parts ?? []).then(
(v) => { localArts[rel] = v; },
() => {});
});
@@ -226,7 +294,10 @@
// an exact hit look doubtful: "Mirrored" against
// "[WARP CD 156] Battles - Mirrored - 2007" is a containment, so a clean
// match wore a "!" on its title row.
const ev = $derived(matchEvidence(candidate, artist, albumName ?? a.title, a.tracks.length));
// Against the artist the provider was ASKED about — which is the folder
// unless the tags found the record where the folder could not.
const ev = $derived(matchEvidence(
candidate, idn?.result?.askedArtist ?? artist, albumName ?? a.title, a.tracks.length));
const verdictLine = $derived.by(() => {
const c = candidate;
@@ -285,15 +356,57 @@
const resolvedTag = (k) => choice?.tags?.[k] ?? proposal.tags[k] ?? "yours";
const resolvedTrack = (n) => choice?.tracks?.[n] ?? "yours";
/** Row order is the RECORD's order; `n` stays the SCAN's.
*
* The scan lists a folder with a plain byte sort (import.rs `paths.sort()`),
* where '0' (0x30) beats '_' (0x5F) — so "10_LONELY WANDERER" precedes
* "1_SEQUENTIAL CIRCUITS" and a record numbered without zero-padding reads
* 10, 11, 12, 13, 1, 2 … 9. Pairing that against a provider tracklist by
* position compares track 10 with track 1 the whole way down, and the
* length differences it reports are the arithmetic of that mispairing.
*
* Ordering is therefore for DISPLAY ONLY. `n` remains each track's index
* in `a.tracks`, because that is the index apply writes by: `track_titles`
* is positional against apply's own `f.sort()` of the same folder
* (import.rs:1238). Reordering `a.tracks` itself would move the review
* without moving apply, and land every chosen title on the wrong file.
*
* A folded release is left in the order the fold built: its tracks are in
* PART order and every part numbers its own tracks from 1, so ordering the
* flattened list by track number would interleave the discs — the trap
* import.rs:1235 and import-fold.js:172 both already guard. */
const trackRows = $derived.by(() => {
const rows = a.tracks.map((t, n) => ({ t, n }));
if ((a.parts?.length ?? 0) > 1) return rows;
// The tags decide when they ALL carry a distinct number: a rarities
// folder where every file claims track 1 orders by nothing, and a
// partial set would sort the numbered ahead of the unnumbered.
const nos = rows.map((r) => r.t.trackNo);
if (nos.every((x) => typeof x === "number" && x > 0)
&& new Set(nos).size === nos.length) {
return [...rows].sort((x, y) => x.t.trackNo - y.t.trackNo);
}
// Otherwise the filename, compared with its digit runs as NUMBERS —
// which is the same comparison the fold already uses to order parts.
return [...rows].sort((x, y) => x.t.name.localeCompare(
y.t.name, undefined, { numeric: true, sensitivity: "base" }));
});
/** What the file calls the track. The tag first — it is the thing apply
* writes and compares — falling back to the filename for a file carrying
* no title at all. */
const titleOfTrack = (t) => (t.title ?? "").trim()
|| t.name.replace(/\.[a-z0-9]+$/i, "").replace(/^\d+\s*[-.]\s*/, "");
const trackPairs = $derived.by(() => {
const theirs = candidate?.tracklist ?? [];
return a.tracks.map((t, n) => {
const mine = t.name.replace(/\.[a-z0-9]+$/i, "").replace(/^\d+\s*[-.]\s*/, "");
const their = theirs[n];
return trackRows.map(({ t, n }, row) => {
const mine = titleOfTrack(t);
const their = theirs[row];
const dY = t.seconds ?? null;
const dT = their?.secs ?? null;
const drift = dY && dT ? Math.abs(dY - dT) : null;
return { n, mine, their: their?.title ?? null, dY, dT, drift,
return { n, row, mine, their: their?.title ?? null, dY, dT, drift,
aligned: drift !== null && drift <= 2 };
});
});
@@ -542,8 +655,15 @@
const tags = {};
for (const [k, , theirs] of tagPairs)
if (resolvedTag(k) === "theirs" && theirs) tags[k] = theirs;
const trackTitles = trackPairs.map((p) =>
p.their && resolvedTrack(p.n) === "theirs" ? p.their : null);
// Built by SCAN index, never by row: apply matches `trackTitles`
// position-for-position against its own sorted file list
// (import.rs:1238), and the rows above are ordered for reading. Writing
// this in row order would land every chosen title on the wrong file —
// silently, and on the user's actual music.
const trackTitles = a.tracks.map(() => null);
for (const p of trackPairs) {
if (p.their && resolvedTrack(p.n) === "theirs") trackTitles[p.n] = p.their;
}
onSnapshot({ artUrl, tags, trackTitles, audio, audioTracks });
});
</script>
@@ -718,7 +838,9 @@
<div class="im-ab-find">
<button class="pill ghost" disabled={audition?.status === "searching"} onclick={onFindCopy}>Find a better copy</button>
<small>{audition?.status === "none"
? "No peer offered this album just now — worth asking again another day."
? audition.silent
? "Soulseek answered nothing at all — the server refuses some queries outright, and quiet spells happen. Worth asking again."
: "No peer offered this album just now — worth asking again another day."
: audition?.status === "failed"
? "The peer stopped answering — try the search again."
: "Searches Soulseek for this whole album; the best copy found downloads into the cache so you hear and compare the real thing before choosing."}</small>
@@ -864,7 +986,7 @@
<div class="im-tl-h">{candidate.source}</div>
<div class="im-tl-h"></div>
{#each trackPairs as p (p.n)}
<div class="im-tl-n">{p.n + 1}</div>
<div class="im-tl-n">{p.row + 1}</div>
<button class="im-tag-side" class:on={resolvedTrack(p.n) === "yours"}
onclick={() => onChoose({ tracks: { [p.n]: "yours" } })}>{p.mine}</button>
<button class="im-tag-side" class:on={resolvedTrack(p.n) === "theirs"}