playlists + lyrics: setlists with pages of their own, and the words timed to the voice
This commit is contained in:
1 parent
c4ff6269a3
commit
df3d15553a
49 files changed
+14419
-599
No files matched your search
@@ -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"}
|
||||
|
||||
Reference in new issue
Block a user