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:
1 parent
30eb6fe025
commit
50414dae5c
16 files changed
+4157
-154
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user