dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice

This commit is contained in:
KeelrLeek committed 2026-08-24 10:49:10 -05:00
1 parent 4efe85b6bf
commit 1dd8573808
41 files changed
+9288 -647

No files matched your search

+576 -28
View File
@@ -3,23 +3,55 @@
// commit. The preview names what matched and what did not; unresolved
// rows stay visible placeholders unless the user explicitly leaves them
// out. Nothing touches the database until "Create playlist".
//
// Identified-but-unowned rows are VERIFIED against the CATALOG while
// the review is open (Kyle: match them to "the streaming library part,
// not Soulseek") — a strict name+duration match against Deezer-backed
// catalog search. A hit attaches the catalog track id + artwork to the
// row, so the committed playlist plays it as a catalog track.
import * as api from "../lib/api.js";
import {
setlistScan,
startYoutubeListen,
startFileListen,
} from "../lib/setlistScan.svelte.js";
import Icon from "./Icon.svelte";
let {
open = $bindable(false),
mode = "file", // "file": a probed draft arrives via `draft`; "paste": textarea first
// "file": a probed draft arrives via `draft`; "paste": textarea
// first; "youtube": a link field first — the set's own tracklist
// evidence (chapters/description/comments) becomes the draft.
mode = "file",
draft = null,
onimported = () => {},
} = $props();
let pasteText = $state("");
let youtubeUrl = $state("");
let localDraft = $state(null);
// The local listen door: the picked set recording + its optional
// pasted tracklist (B-flow: pick first, paste after).
let pickedFile = $state(null); // {path, name, duration_secs}
let fileTracklist = $state("");
let picking = $state(false);
let name = $state("");
let includeUnresolved = $state(true);
let committing = $state(false);
let parsing = $state(false);
let error = $state("");
// The listener: yt-dlp tool state + live scan progress.
let tool = $state(null);
let toolBusy = $state(false);
let denoBusy = $state(false);
// "deno" (managed or PATH) | "node"/"bun" (EJS may reject old ones —
// deno stays on offer) | null (no runtime at all).
const runtimeKind = $derived.by(() => {
const r = tool?.runtime;
if (!r) return null;
return r.startsWith("deno") ? "deno" : r;
});
let scanning = $state(null); // {stage, pct?, window?, total?, hit?}
// The working draft: probed (file mode) or built from the paste box.
const working = $derived(localDraft ?? draft);
@@ -31,8 +63,108 @@
committing = false;
localDraft = null;
pasteText = "";
youtubeUrl = "";
pickedFile = null;
fileTracklist = "";
includeUnresolved = true;
scanning = null;
name = draft?.name ?? "";
if (mode === "youtube") {
void api.ytdlpStatus().then((s) => (tool = s)).catch(() => {});
}
if (draft?.entries?.length) startChecks(draft);
});
$effect(() => {
return api.onEvent("playlist:scan-progress", (p) => {
if (scanning || p?.stage === "tool" || p?.stage === "runtime")
scanning = { ...scanning, ...p };
});
});
async function downloadTool() {
if (toolBusy) return;
toolBusy = true;
error = "";
scanning = { stage: "tool" };
try {
await api.ytdlpDownload();
tool = await api.ytdlpStatus();
} catch (e) {
error = String(e);
} finally {
toolBusy = false;
scanning = null;
}
}
async function downloadDeno() {
if (denoBusy) return;
denoBusy = true;
error = "";
scanning = { stage: "runtime" };
try {
await api.denoDownload();
tool = await api.ytdlpStatus();
} catch (e) {
error = String(e);
} finally {
denoBusy = false;
scanning = null;
}
}
// Listens run in the BACKGROUND: starting one closes this modal and
// hands the whole job to the setlistScan store — its chip narrates the
// progress, and the finished draft re-opens the review from there.
function listenYoutube() {
if (!youtubeUrl.trim()) return;
if (!startYoutubeListen(youtubeUrl.trim())) return;
close();
}
// The local door picks FIRST: the panel that follows shows the chosen
// file and offers an optional pasted tracklist — the same declared
// evidence the YouTube door fetches for itself — before the listen
// starts. An empty box listens purely by ear.
async function pickLocalFile() {
if (picking || setlistScan.active) return;
picking = true;
error = "";
try {
const f = await api.playlistPickSetFile();
if (f) {
pickedFile = f;
fileTracklist = "";
}
} catch (e) {
error = String(e);
} finally {
picking = false;
}
}
function listenPicked() {
if (!pickedFile) return;
if (!startFileListen(pickedFile.path, fileTracklist.trim() || null, pickedFile.name))
return;
close();
}
function cancelScan() {
void api.playlistScanCancel();
}
const scanLabel = $derived.by(() => {
if (!scanning) return "";
if (scanning.stage === "tool") return "Downloading yt-dlp…";
if (scanning.stage === "runtime")
return `Downloading deno…${scanning.total ? ` ${Math.round(((scanning.received ?? 0) / scanning.total) * 100)}%` : ""}`;
if (scanning.stage === "audio")
return `Fetching the set's audio…${scanning.pct != null ? ` ${Math.round(scanning.pct)}%` : ""}`;
if (scanning.stage === "decode") return "Decoding the set…";
if (scanning.stage === "listen")
return `Listening… window ${scanning.window ?? 0} of ${scanning.total ?? "?"}`;
return "Working…";
});
async function previewPaste() {
if (!pasteText.trim() || parsing) return;
@@ -45,6 +177,26 @@
} else {
localDraft = parsed;
if (!name) name = parsed.name ?? "";
startChecks(parsed);
}
} catch (e) {
error = String(e);
} finally {
parsing = false;
}
}
async function previewYoutube() {
if (!youtubeUrl.trim() || parsing) return;
parsing = true;
error = "";
try {
const parsed = await api.playlistImportYoutube(youtubeUrl.trim());
if (!parsed.entries.length) {
error = "The set's tracklist parsed to nothing usable.";
} else {
localDraft = parsed;
name = parsed.name ?? name;
startChecks(parsed);
}
} catch (e) {
error = String(e);
@@ -55,11 +207,109 @@
const kept = $derived.by(() => {
if (!working) return [];
// Streams are first-class (identified, playable) — the checkbox
// only gates the TRUE unknowns.
return includeUnresolved
? working.entries
: working.entries.filter((e) => e.resolution === "local");
: working.entries.filter((e) => e.resolution !== "unresolved");
});
// ---- catalog verification of stream rows, live in the review -------
let checks = $state({}); // ordinal → "checking" | {album, cover} | "none"
let checkGen = 0;
const norm = (s) =>
(s ?? "")
.toLowerCase()
.normalize("NFKD")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
/// Strict match: at least one exact side (title or artist), both at
/// least partial; duration within ±7 s strengthens; Deezer rank breaks
/// ties so a famous track beats a namesake.
function pickCatalogMatch(tracks, e) {
const wantT = norm(e.title);
const wantA = norm(e.artist);
let best = null;
let bestScore = 0;
for (const t of tracks ?? []) {
const tT = norm(t.title);
const tA = norm(t.artist);
let score = 0;
if (tT === wantT) score += 4;
else if (tT.includes(wantT) || wantT.includes(tT)) score += 2;
if (tA === wantA) score += 4;
else if (tA.includes(wantA) || wantA.includes(tA)) score += 2;
if (score < 6) continue;
if (
e.duration_secs &&
t.duration_secs &&
Math.abs(t.duration_secs - e.duration_secs) <= 7
)
score += 2;
score += Math.min((t.rank ?? 0) / 1_000_000, 1);
if (score > bestScore) {
bestScore = score;
best = t;
}
}
return best;
}
/// Kick the walk OUTSIDE any effect's tracking zone: verifyStreams
/// reads and writes `checks`, and doing that inside an $effect made
/// every row verdict re-trigger the effect, which bumped the
/// generation and killed its own walk — row one froze at "matching…"
/// forever. Explicit kicks from the draft-arrival sites, via a
/// microtask, keep reactivity out of the loop.
function startChecks(w) {
checks = {};
const gen = ++checkGen;
queueMicrotask(() => void verifyStreams(w, gen));
}
async function verifyStreams(w, gen) {
for (const e of w.entries) {
if (e.resolution !== "stream") continue;
if (gen !== checkGen || !open) return;
checks[e.ordinal] = "checking";
try {
// Anchor-exact first: a row the listener heard carries the
// matched release's ISRC, and one /track/isrc: lookup beats
// any name matcher. A miss falls through to the name walk.
let hit = null;
let anchored = false;
if (e.isrc) {
hit = await api.catalogTrackByIsrc(e.isrc);
if (gen !== checkGen) return;
anchored = !!hit;
}
if (!hit) {
const res = await api.catalogSearch(`${e.artist} ${e.title}`);
if (gen !== checkGen) return;
hit = pickCatalogMatch(res?.tracks, e);
}
if (hit) {
// Attach catalog identity to the row commit will send.
e.track_id = Number(hit.id);
e.art_url = hit.cover ?? null;
if (!e.album) e.album = hit.album ?? null;
if (!e.duration_secs) e.duration_secs = hit.duration_secs ?? null;
checks[e.ordinal] = {
album: hit.album ?? "",
cover: hit.cover ?? null,
anchored,
};
} else {
checks[e.ordinal] = "none";
}
} catch {
if (gen === checkGen) checks[e.ordinal] = "none";
}
await new Promise((r) => setTimeout(r, 250));
}
}
const checkedFound = $derived(
Object.values(checks).filter((v) => v && v !== "none" && v !== "checking").length,
);
async function commit() {
if (!working || committing) return;
const finalName = (name || working.name || "Imported playlist").trim();
@@ -71,6 +321,7 @@
working.description ?? null,
working.kind,
kept,
working.cover_url ?? null,
);
onimported(id, finalName);
} catch (e) {
@@ -80,6 +331,7 @@
}
function close() {
checkGen += 1; // stop any verification walk
open = false;
}
@@ -111,6 +363,8 @@
soulstream: "SoulStream backup",
text: "Pasted text",
folder: "Folder",
youtube: "YouTube",
listened: "Recognized by ear",
};
</script>
@@ -132,6 +386,10 @@
<h2>
{#if working}
Review import
{:else if pickedFile}
Listen to a set recording
{:else if mode === "youtube"}
Import a DJ set from YouTube
{:else}
Paste a tracklist
{/if}
@@ -142,35 +400,177 @@
</header>
{#if !working}
<p class="lead">
One track per line. Timestamps make it a setlist —
<span class="mono">00:00 Artist - Title</span> keeps its place in the mix.
</p>
<textarea
bind:value={pasteText}
placeholder={"00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby"}
spellcheck="false"
></textarea>
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={close}>Cancel</button>
<button
class="btn primary"
onclick={previewPaste}
disabled={!pasteText.trim() || parsing}
>
{parsing ? "Reading…" : "Preview"}
</button>
</footer>
{#if pickedFile}
<div class="srcfile">
<Icon name="music" size={14} />
<span class="fname" title={pickedFile.path}>{pickedFile.name}</span>
{#if pickedFile.duration_secs != null}
<span class="flen">{fmtTime(pickedFile.duration_secs * 1000)}</span>
{/if}
</div>
<p class="lead">
Listening identifies the tracks by sound. If the set came with a
tracklist, paste it too — it names the exact <b>version</b> played
where the ear can only name the release it matched, and fills
stretches nothing recognized. Leave it empty to listen purely by ear.
</p>
<textarea
class="tl"
bind:value={fileTracklist}
placeholder={"00:00 Worakls - Red Dressed (Ben Böhmer Remix)\n05:30 Overmono - So U Kno\n…"}
spellcheck="false"
></textarea>
<div class="hint">
Optional. Only timestamped lines count — the stamp anchors each
name to its place in the mix.
</div>
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={() => (pickedFile = null)}>
Back
</button>
<button
class="btn primary"
onclick={listenPicked}
disabled={setlistScan.active}
title={setlistScan.active
? "A listen is already running — see the chip"
: "Identify the tracks by sound — runs in the background"}
>
Listen
</button>
</footer>
{:else if mode === "youtube"}
{#if scanning}
<div class="scanbox">
<span class="ring"></span>
<div class="scanline">{scanLabel}</div>
{#if scanning.hit}
<div class="scanhit">{scanning.hit}</div>
{/if}
</div>
<footer>
<button class="btn ghost" onclick={cancelScan}>Cancel</button>
</footer>
{:else}
<p class="lead">
Paste a set's link. <b>Fetch tracklist</b> reads the video's own
chapters, description, or a commented tracklist — free and instant.
<b>Listen</b> downloads the audio and identifies the tracks by
sound: a few minutes, and unreleased material stays an honest ID.
</p>
<input
class="url"
type="text"
bind:value={youtubeUrl}
placeholder="https://www.youtube.com/watch?v=…"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && previewYoutube()}
/>
<div class="toolline">
{#if tool?.present}
<span class="toolok">
yt-dlp {tool.version ?? ""}{tool.managed ? "" : " (from PATH)"}
</span>
<button class="linkbtn" onclick={downloadTool} disabled={toolBusy}>
update
</button>
{:else}
<span>Listening needs the downloader tool:</span>
<button class="linkbtn" onclick={downloadTool} disabled={toolBusy}>
{toolBusy ? "downloading…" : "download yt-dlp (~17 MB, official nightly)"}
</button>
{/if}
<span class="toolsep">·</span>
{#if runtimeKind === "deno"}
<span class="toolok">deno</span>
{:else if runtimeKind}
<span>runtime: {runtimeKind}</span>
<button class="linkbtn" onclick={downloadDeno} disabled={denoBusy}>
{denoBusy ? "downloading…" : "get deno (recommended, ~40 MB)"}
</button>
{:else}
<span>and a JS runtime:</span>
<button class="linkbtn" onclick={downloadDeno} disabled={denoBusy}>
{denoBusy ? "downloading…" : "download deno (~40 MB, official release)"}
</button>
{/if}
<span class="toolsep">·</span>
<button
class="linkbtn"
onclick={pickLocalFile}
disabled={parsing || picking || setlistScan.active}
title={setlistScan.active
? "A listen is already running — see the chip"
: ""}
>
or listen to a local file…
</button>
</div>
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={close}>Cancel</button>
<button
class="btn ghost"
onclick={listenYoutube}
disabled={!youtubeUrl.trim() ||
parsing ||
!tool?.present ||
!tool?.runtime ||
setlistScan.active}
title={setlistScan.active
? "A listen is already running — see the chip"
: !tool?.present
? "Download yt-dlp first"
: !tool?.runtime
? "Download a JS runtime first"
: "Identify the tracks by sound — runs in the background"}
>
Listen
</button>
<button
class="btn primary"
onclick={previewYoutube}
disabled={!youtubeUrl.trim() || parsing}
>
{parsing ? "Fetching…" : "Fetch tracklist"}
</button>
</footer>
{/if}
{:else}
<p class="lead">
One track per line. Timestamps make it a setlist —
<span class="mono">00:00 Artist - Title</span> keeps its place in the mix.
</p>
<textarea
bind:value={pasteText}
placeholder={"00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby"}
spellcheck="false"
></textarea>
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={close}>Cancel</button>
<button
class="btn primary"
onclick={previewPaste}
disabled={!pasteText.trim() || parsing}
>
{parsing ? "Reading…" : "Preview"}
</button>
</footer>
{/if}
{:else}
<div class="srcline">
<span class="chip">{FORMAT_LABEL[working.format] ?? working.format}</span>
{#if working.kind === "setlist"}<span class="chip set">Setlist</span>{/if}
<span class="srcname" title={working.source_name}>{working.source_name}</span>
<span class="counts">
<b class="ok">{working.resolved_count}</b> matched
<b class="ok">{working.resolved_count}</b> in library
{#if working.stream_count > 0}
&thinsp;·&thinsp;<b class="st">{checkedFound}</b>/{working.stream_count} in catalog
{/if}
{#if working.unresolved_count > 0}
&thinsp;·&thinsp;<b class="un">{working.unresolved_count}</b> unresolved
&thinsp;·&thinsp;<b class="un">{working.unresolved_count}</b> unknown
{/if}
</span>
</div>
@@ -187,8 +587,8 @@
{#each working.entries as e (e.ordinal)}
<div
class="row"
class:unresolved={e.resolution !== "local"}
class:excluded={!includeUnresolved && e.resolution !== "local"}
class:unresolved={e.resolution === "unresolved"}
class:excluded={!includeUnresolved && e.resolution === "unresolved"}
>
<span class="n">{e.ordinal + 1}</span>
{#if working.kind === "setlist"}
@@ -215,8 +615,41 @@
? "relinked"
: "tags"}
</span>
{:else if e.resolution === "stream"}
{#if checks[e.ordinal] === "checking"}
<span class="tag st dim">matching…</span>
{:else if checks[e.ordinal] && checks[e.ordinal] !== "none"}
<span
class="tag st"
title={`${checks[e.ordinal].anchored ? "Exact recording match (ISRC)" : "Matched in the catalog"}${checks[e.ordinal].album ? ` — ${checks[e.ordinal].album}` : ""} · streams like any catalog track`}
>
<Icon name="check" size={11} />
catalog
</span>
{:else if checks[e.ordinal] === "none"}
<span
class="tag un"
title="No confident catalog match — still plays as a by-name stream"
>
not in catalog
</span>
{:else}
<span
class="tag st dim"
title="Identified but not in your library — plays as a stream"
>
stream
</span>
{/if}
{:else if e.resolution === "segment"}
<span
class="tag st dim"
title="Unidentified — will play this stretch of the set's own recording"
>
set audio
</span>
{:else}
<span class="tag un" title="No local match — kept as a placeholder">
<span class="tag un" title="Identity too thin to play — kept as a placeholder">
unresolved
</span>
{/if}
@@ -242,7 +675,7 @@
</div>
{#if error}<p class="error">{error}</p>{/if}
<footer>
{#if mode === "paste"}
{#if mode === "paste" || mode === "youtube"}
<button class="btn ghost" onclick={() => (localDraft = null)}>Back</button>
{:else}
<button class="btn ghost" onclick={close}>Cancel</button>
@@ -336,6 +769,111 @@
textarea:focus {
border-color: var(--accent);
}
input.url {
width: 100%;
background: var(--bg-base);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 11px 12px;
font-family: Consolas, monospace;
font-size: 12.5px;
outline: none;
}
input.url:focus {
border-color: var(--accent);
}
/* The local door's picked-file line (B-flow panel). */
.srcfile {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
font-size: 12.5px;
color: var(--text-muted);
}
.srcfile .fname {
font-family: Consolas, monospace;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.srcfile .flen {
color: var(--text-muted);
margin-left: auto;
flex: none;
}
textarea.tl {
min-height: 170px;
}
.hint {
font-size: 11.5px;
color: var(--text-muted);
margin-top: 7px;
}
.toolline {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
margin-top: 10px;
font-size: 12px;
color: var(--text-secondary);
}
.toolok {
color: #3ecf7e;
}
.toolsep {
opacity: 0.5;
}
.linkbtn {
background: none;
border: none;
padding: 0;
font-size: 12px;
color: var(--accent);
cursor: pointer;
}
.linkbtn:hover {
text-decoration: underline;
}
.linkbtn:disabled {
opacity: 0.5;
cursor: default;
}
.scanbox {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 42px 0 30px;
}
.scanline {
font-size: 13.5px;
color: var(--text-primary);
}
.scanhit {
font-size: 12.5px;
color: var(--text-secondary);
}
.ring {
width: 26px;
height: 26px;
border-radius: 50%;
border: 3px solid color-mix(in srgb, var(--accent) 30%, transparent);
border-top-color: var(--accent);
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.srcline {
display: flex;
@@ -376,6 +914,9 @@
.counts .ok {
color: #3ecf7e;
}
.counts .st {
color: #58b6e8;
}
.counts .un {
color: #e8a33d;
}
@@ -465,6 +1006,13 @@
background: rgba(62, 207, 126, 0.13);
color: #3ecf7e;
}
.tag.st {
background: rgba(88, 182, 232, 0.13);
color: #58b6e8;
}
.tag.dim {
opacity: 0.65;
}
.tag.un {
background: rgba(232, 163, 61, 0.15);
color: #e8a33d;