playlists + player: every service's list through one door with its names matched for real, and shuffle that readies exactly what it promises

This commit is contained in:
KeelrLeek committed 2026-08-24 18:10:34 -05:00
1 parent 30eb6fe025
commit 50414dae5c
16 files changed
+4157 -154

No files matched your search

+50 -9
View File
@@ -7,6 +7,7 @@
getArt,
getCatalogArt,
jumpToQueueIndex,
peekNextIndex,
streamSong,
discovery,
loadDiscovery,
@@ -101,11 +102,6 @@
// nothing, then a partial list.
const albumMode = $derived(!!player.catalogRef.albumId);
// Artist name for the Save action: album items carry "artist — album".
const artistName = $derived.by(() => {
const sub = currentItem?.sub ?? "";
return sub.includes(" — ") ? sub.split(" — ")[0].trim() : sub;
});
// Artist / album split for the separate, clickable identity lines.
// Per field, like the player bar's: the item's own artist/album win when
// present (the continuation fill writes them beside a bare-artist `sub`),
@@ -121,6 +117,16 @@
currentItem?.album ?? split[1] ?? "",
];
});
// The artist everything else in this panel is ABOUT — discovery, the
// chip, "More from", the in-library count, the Save action.
//
// It used to split `sub` on its own, which is only "artist — album" for
// album playback. A playlist item's `sub` is the PLAYLIST's name, so
// playing out of one loaded the whole Artist and About panes for an
// artist called "Modern Electro Ghetto Tech Mix" while the identity
// lines two rows above correctly read Harka. The item's own `artist`
// field is the answer, and `metaParts` already applies that rule.
const artistName = $derived(metaParts[0]);
const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]);
@@ -170,7 +176,9 @@
// tier, drop the album that's playing now, cap at 6.
const typeRank = (t) => (t === "album" ? 0 : t === "ep" ? 1 : 2);
const moreAlbums = $derived.by(() => {
const here = norm(currentItem?.sub?.split(" — ")[1] ?? "");
// The record playing now, by the same per-field rule — `sub` is the
// playlist's name, not "artist — album", whenever one is playing.
const here = norm(metaParts[1]);
return [...discovery.albums]
.filter((a) => norm(a.title) !== here)
.sort(
@@ -906,7 +914,26 @@
}
}
const upNext = $derived(player.queue[player.queueIndex + 1] ?? null);
// What actually plays next — shuffle included: peekNextIndex reads the
// predetermined order, so this card, the preloader and the advance all
// name the same track.
const upNextIndex = $derived(peekNextIndex());
const upNext = $derived(upNextIndex >= 0 ? (player.queue[upNextIndex] ?? null) : null);
// The NEXT track's own artwork — the card wore the CURRENT track's cover
// for a while, which read as "The Medic" having the Jets to Brazil sleeve.
let upNextArt = $state(null);
$effect(() => {
const item = upNext;
upNextArt = null;
if (!item) return;
let active = true;
if (item.art) {
getArt(item.art, 96).then((u) => active && (upNextArt = u)).catch(() => {});
} else if (item.artUrl) {
getCatalogArt(playerArtSource(item)).then((u) => active && (upNextArt = u)).catch(() => {});
}
return () => (active = false);
});
const fmtListeners = (n) => (n == null ? null : n.toLocaleString());
@@ -1249,8 +1276,12 @@
<div class="pane" style="--dir: {paneDir}px">
{#if pane === "queue"}
{#if upNext}
<button class="upnext" onclick={() => jumpToQueueIndex(player.queueIndex + 1)}>
{#if artUrl}<img src={artUrl} alt="" draggable="false" />{/if}
<button class="upnext" onclick={() => jumpToQueueIndex(upNextIndex)}>
{#if upNextArt}
<img src={upNextArt} alt="" draggable="false" />
{:else}
<span class="un-fallback"><Icon name="music" size={15} /></span>
{/if}
<span class="un-body">
<span class="un-lab">Up next</span>
<span class="un-title" title={upNext.title}>{upNext.title}</span>
@@ -2242,6 +2273,16 @@
border-color: color-mix(in srgb, var(--accent-hover) 45%, var(--border));
}
.upnext img { width: 34px; height: 34px; border-radius: 4px; flex: none; object-fit: cover; }
.un-fallback {
width: 34px;
height: 34px;
flex: none;
display: grid;
place-items: center;
border-radius: 6px;
background: var(--bg-highlight);
color: var(--text-tertiary);
}
.un-body { min-width: 0; flex: 1; display: block; }
.un-lab {
display: block;
+217 -46
View File
@@ -15,18 +15,31 @@
startYoutubeListen,
startFileListen,
} from "../lib/setlistScan.svelte.js";
import { findCatalogTrack } from "../lib/catalogMatch.js";
import Icon from "./Icon.svelte";
let {
open = $bindable(false),
// "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.
// evidence (chapters/description/comments) becomes the draft;
// "ytplaylist": a link field for an ordinary playlist link —
// YouTube (one row per video), Spotify, Apple Music, TIDAL, or
// Deezer (one row per track); metadata only, no tools involved.
mode = "file",
draft = null,
onimported = () => {},
} = $props();
// The door actually being shown. `mode` is what the caller opened; a
// capped Spotify import can hand itself over to the paste door from
// inside the review, and that switch is this component's own business.
let doorOverride = $state(null);
const door = $derived(doorOverride ?? mode);
// Set when the paste door was reached FROM a capped Spotify import, so it
// can say what to paste instead of leaving the reader to remember.
let pasteFromSpotify = $state(false);
let pasteText = $state("");
let youtubeUrl = $state("");
let localDraft = $state(null);
@@ -52,6 +65,14 @@
return r.startsWith("deno") ? "deno" : r;
});
let scanning = $state(null); // {stage, pct?, window?, total?, hit?}
// Pasted TRACK links are looked up a page each, so the paste door reports
// its own progress instead of freezing on "Reading…".
let linkProgress = $state(null); // {done, total, waiting}
// Mirrors the backend's detector closely enough to CHOOSE a door; the
// backend decides for real. A paste holding even one track link takes the
// link path, so a list copied out of Spotify never parses as track names.
const TRACK_LINK = /(?:spotify:track:[A-Za-z0-9]{22})|(?:\/\/(?:open\.spotify\.com\/(?:[\w-]+\/)*track\/|(?:www\.)?deezer\.com\/(?:[\w-]+\/)*track\/|(?:[\w-]+\.)?tidal\.com\/(?:[\w-]+\/)*track\/|music\.apple\.com\/[^\s]*(?:\/song\/|[?&]i=)))/i;
const pasteHasLinks = $derived(TRACK_LINK.test(pasteText));
// The working draft: probed (file mode) or built from the paste box.
const working = $derived(localDraft ?? draft);
@@ -68,12 +89,23 @@
fileTracklist = "";
includeUnresolved = true;
scanning = null;
linkProgress = null;
doorOverride = null;
pasteFromSpotify = false;
name = draft?.name ?? "";
// NOTE: `mode`, not `door`. This effect WRITES doorOverride, so
// reading the derived door here would make it re-trigger itself and
// wipe the hand-off the instant it was set.
if (mode === "youtube") {
void api.ytdlpStatus().then((s) => (tool = s)).catch(() => {});
}
if (draft?.entries?.length) startChecks(draft);
});
$effect(() => {
return api.onEvent("playlist:link-progress", (p) => {
if (parsing) linkProgress = p;
});
});
$effect(() => {
return api.onEvent("playlist:scan-progress", (p) => {
if (scanning || p?.stage === "tool" || p?.stage === "runtime")
@@ -170,8 +202,13 @@
if (!pasteText.trim() || parsing) return;
parsing = true;
error = "";
linkProgress = null;
try {
const parsed = await api.playlistImportText(pasteText);
// Track links get looked up one public page each — the path with
// no length ceiling. Plain text stays on the old parser.
const parsed = pasteHasLinks
? await api.playlistImportTrackLinks(pasteText, name.trim() || null)
: await api.playlistImportText(pasteText);
if (!parsed.entries.length) {
error = "No tracks recognized in that text.";
} else {
@@ -183,6 +220,7 @@
error = String(e);
} finally {
parsing = false;
linkProgress = null;
}
}
async function previewYoutube() {
@@ -205,6 +243,42 @@
}
}
async function previewYoutubePlaylist() {
if (!youtubeUrl.trim() || parsing) return;
parsing = true;
error = "";
try {
const parsed = await api.playlistImportLink(youtubeUrl.trim());
if (!parsed.entries.length) {
error = "The playlist yielded no importable tracks.";
} else {
localDraft = parsed;
name = parsed.name ?? name;
startChecks(parsed);
}
} catch (e) {
error = String(e);
} finally {
parsing = false;
}
}
// A Spotify import that came back at exactly the public ceiling. The
// real total is not exposed to anyone signed out, so this cannot say how
// many are missing — only that there are likely more.
const spotifyCapped = $derived(
working?.format === "spotify" && working.entries.length >= 100,
);
/// Hand the reader from the capped review to the door that has no
/// ceiling, with the paste box empty and waiting.
function bringTheRest() {
localDraft = null;
pasteText = "";
error = "";
doorOverride = "paste";
pasteFromSpotify = true;
}
const kept = $derived.by(() => {
if (!working) return [];
// Streams are first-class (identified, playable) — the checkbox
@@ -217,43 +291,9 @@
// ---- 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;
}
// The matcher moved to lib/catalogMatch.js when the player started
// needing the same bar to put artwork on a stream that arrived without
// any: strict is only meaningful if there is ONE definition of it.
/// 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
@@ -282,9 +322,20 @@
anchored = !!hit;
}
if (!hit) {
const res = await api.catalogSearch(`${e.artist} ${e.title}`);
// The hardened lookup: Deezer's field syntax with the
// version wording set aside, then wider rungs. A plain
// "artist title" query returns NOTHING for a great many
// real records (measured: 41 of 258 rows on one import).
hit = await findCatalogTrack(
(query) => api.catalogSearchTracks(query),
{
title: e.title,
artist: e.artist,
duration_secs: e.duration_secs ?? null,
},
(artist, title) => api.catalogArtistTrackCandidates(artist, title),
);
if (gen !== checkGen) return;
hit = pickCatalogMatch(res?.tracks, e);
}
if (hit) {
// Attach catalog identity to the row commit will send.
@@ -364,6 +415,12 @@
text: "Pasted text",
folder: "Folder",
youtube: "YouTube",
"youtube-playlist": "YouTube playlist",
spotify: "Spotify",
"apple-music": "Apple Music",
tidal: "TIDAL",
deezer: "Deezer",
links: "Pasted links",
listened: "Recognized by ear",
};
</script>
@@ -388,8 +445,10 @@
Review import
{:else if pickedFile}
Listen to a set recording
{:else if mode === "youtube"}
{:else if door === "youtube"}
Import a DJ set from YouTube
{:else if door === "ytplaylist"}
Import a playlist from a link
{:else}
Paste a tracklist
{/if}
@@ -440,7 +499,7 @@
Listen
</button>
</footer>
{:else if mode === "youtube"}
{:else if door === "youtube"}
{#if scanning}
<div class="scanbox">
<span class="ring"></span>
@@ -537,16 +596,72 @@
</button>
</footer>
{/if}
{:else if door === "ytplaylist"}
<p class="lead">
Paste a playlist link from <b>YouTube</b>, <b>Spotify</b>,
<b>Apple Music</b>, <b>TIDAL</b>, or <b>Deezer</b> — every
track arrives named by the service's own listing. Read
straight off the playlist's public page; nothing is
downloaded and no account is involved.
</p>
<input
class="url"
type="text"
bind:value={youtubeUrl}
placeholder="https://open.spotify.com/playlist/… · youtube.com/playlist?list=…"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && previewYoutubePlaylist()}
/>
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={close}>Cancel</button>
<button
class="btn primary"
onclick={previewYoutubePlaylist}
disabled={!youtubeUrl.trim() || parsing}
>
{parsing ? "Fetching…" : "Fetch tracks"}
</button>
</footer>
{:else}
{#if pasteFromSpotify}
<ol class="steps">
<li>Open the playlist in Spotify.</li>
<li>Click any track, then <b>Ctrl+A</b> to select them all.</li>
<li><b>Ctrl+C</b>, then paste below — every track comes over,
however many there are.</li>
</ol>
{/if}
<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.
<b>Track links work too</b>: in Spotify (or Apple Music, TIDAL,
Deezer) select every track and copy, then paste here — that path
has no length limit, unlike a playlist link.
</p>
<textarea
bind:value={pasteText}
placeholder={"00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby"}
placeholder={"00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby\n\n…or pasted track links:\nhttps://open.spotify.com/track/…"}
spellcheck="false"
></textarea>
{#if pasteHasLinks && !parsing}
<div class="hint">
Track links detected — each is read from its own public page.
</div>
{/if}
{#if linkProgress}
<div class="scanbox">
<span class="ring"></span>
<div class="scanline">
{#if linkProgress.waiting}
The service is throttling — waiting a minute, then
carrying on from {linkProgress.done} of {linkProgress.total}…
{:else}
Reading track {linkProgress.done} of {linkProgress.total}…
{/if}
</div>
</div>
{/if}
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={close}>Cancel</button>
@@ -555,7 +670,7 @@
onclick={previewPaste}
disabled={!pasteText.trim() || parsing}
>
{parsing ? "Reading…" : "Preview"}
{parsing ? (pasteHasLinks ? "Looking up…" : "Reading…") : "Preview"}
</button>
</footer>
{/if}
@@ -583,6 +698,19 @@
</div>
{/if}
{#if spotifyCapped}
<div class="capline">
<div class="capwords">
<b>This is Spotify's limit, not the playlist's.</b>
Pasted track links have none — and Spotify hands them all over
at once.
</div>
<button class="btn small" onclick={bringTheRest}>
Bring the whole playlist
</button>
</div>
{/if}
<div class="rows" tabindex="-1">
{#each working.entries as e (e.ordinal)}
<div
@@ -675,7 +803,7 @@
</div>
{#if error}<p class="error">{error}</p>{/if}
<footer>
{#if mode === "paste" || mode === "youtube"}
{#if door === "paste" || door === "youtube" || door === "ytplaylist"}
<button class="btn ghost" onclick={() => (localDraft = null)}>Back</button>
{:else}
<button class="btn ghost" onclick={close}>Cancel</button>
@@ -921,6 +1049,49 @@
color: #e8a33d;
}
/* The capped-import hand-off: a statement and the way out of it. */
.capline {
display: flex;
align-items: center;
gap: 14px;
margin: 0 0 12px;
padding: 11px 14px;
border-radius: 10px;
background: var(--bg-highlight);
border: 1px solid var(--border);
}
.capwords {
flex: 1;
font-size: 12.5px;
line-height: 1.45;
color: var(--text-secondary);
}
.capwords b {
color: var(--text-primary);
}
.btn.small {
flex: none;
padding: 7px 13px;
font-size: 12.5px;
border-radius: 8px;
background: var(--accent);
color: #04121d;
font-weight: 700;
white-space: nowrap;
}
.btn.small:hover {
background: var(--accent-hover);
}
.steps {
margin: 0 0 4px;
padding-left: 20px;
font-size: 12.5px;
line-height: 1.75;
color: var(--text-secondary);
}
.steps b {
color: var(--text-primary);
}
.warnings {
margin-bottom: 8px;
}