1274 lines
47 KiB
Svelte
1274 lines
47 KiB
Svelte
<script>
|
|
// Staged playlist import: parse → resolve → REVIEW → one-transaction
|
|
// 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 { 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;
|
|
// "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);
|
|
// 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?}
|
|
// 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);
|
|
|
|
// Re-arm the form whenever the modal opens with a new draft.
|
|
$effect(() => {
|
|
if (!open) return;
|
|
error = "";
|
|
committing = false;
|
|
localDraft = null;
|
|
pasteText = "";
|
|
youtubeUrl = "";
|
|
pickedFile = null;
|
|
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")
|
|
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;
|
|
parsing = true;
|
|
error = "";
|
|
linkProgress = null;
|
|
try {
|
|
// 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 {
|
|
localDraft = parsed;
|
|
if (!name) name = parsed.name ?? "";
|
|
startChecks(parsed);
|
|
}
|
|
} catch (e) {
|
|
error = String(e);
|
|
} finally {
|
|
parsing = false;
|
|
linkProgress = null;
|
|
}
|
|
}
|
|
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);
|
|
} finally {
|
|
parsing = false;
|
|
}
|
|
}
|
|
|
|
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
|
|
// only gates the TRUE unknowns.
|
|
return includeUnresolved
|
|
? working.entries
|
|
: 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;
|
|
// 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
|
|
/// 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) {
|
|
// 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;
|
|
}
|
|
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();
|
|
committing = true;
|
|
error = "";
|
|
try {
|
|
const id = await api.playlistImportCommit(
|
|
finalName,
|
|
working.description ?? null,
|
|
working.kind,
|
|
kept,
|
|
working.cover_url ?? null,
|
|
);
|
|
onimported(id, finalName);
|
|
} catch (e) {
|
|
error = String(e);
|
|
committing = false;
|
|
}
|
|
}
|
|
|
|
function close() {
|
|
checkGen += 1; // stop any verification walk
|
|
open = false;
|
|
}
|
|
|
|
function fmtTime(ms) {
|
|
if (ms == null) return "";
|
|
const total = Math.floor(ms / 1000);
|
|
const h = Math.floor(total / 3600);
|
|
const m = Math.floor((total % 3600) / 60);
|
|
const s = total % 60;
|
|
return h > 0
|
|
? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
|
: `${m}:${String(s).padStart(2, "0")}`;
|
|
}
|
|
|
|
function fmtDur(secs) {
|
|
if (!secs) return "";
|
|
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`;
|
|
}
|
|
|
|
function onkeydown(e) {
|
|
if (e.key === "Escape") close();
|
|
}
|
|
|
|
const FORMAT_LABEL = {
|
|
m3u: "M3U",
|
|
pls: "PLS",
|
|
xspf: "XSPF",
|
|
csv: "CSV",
|
|
soulstream: "SoulStream backup",
|
|
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>
|
|
|
|
<svelte:window onkeydown={open ? onkeydown : undefined} />
|
|
|
|
{#if open}
|
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
<div class="scrim" onclick={close} role="presentation">
|
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
|
<div
|
|
class="dialog"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Import playlist"
|
|
tabindex="-1"
|
|
onclick={(e) => e.stopPropagation()}
|
|
>
|
|
<header>
|
|
<h2>
|
|
{#if working}
|
|
Review import
|
|
{:else if pickedFile}
|
|
Listen to a set recording
|
|
{: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}
|
|
</h2>
|
|
<button class="x" title="Close" onclick={close}>
|
|
<Icon name="x" size={16} />
|
|
</button>
|
|
</header>
|
|
|
|
{#if !working}
|
|
{#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 door === "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 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\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>
|
|
<button
|
|
class="btn primary"
|
|
onclick={previewPaste}
|
|
disabled={!pasteText.trim() || parsing}
|
|
>
|
|
{parsing ? (pasteHasLinks ? "Looking up…" : "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> in library
|
|
{#if working.stream_count > 0}
|
|
 · <b class="st">{checkedFound}</b>/{working.stream_count} in catalog
|
|
{/if}
|
|
{#if working.unresolved_count > 0}
|
|
 · <b class="un">{working.unresolved_count}</b> unknown
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
|
|
{#if working.warnings?.length}
|
|
<div class="warnings">
|
|
{#each working.warnings as w, wi (wi)}
|
|
<p><Icon name="alert" size={12} /> {w}</p>
|
|
{/each}
|
|
</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
|
|
class="row"
|
|
class:unresolved={e.resolution === "unresolved"}
|
|
class:excluded={!includeUnresolved && e.resolution === "unresolved"}
|
|
>
|
|
<span class="n">{e.ordinal + 1}</span>
|
|
{#if working.kind === "setlist"}
|
|
<span class="t">{fmtTime(e.start_ms)}</span>
|
|
{/if}
|
|
<span class="who" title={e.raw}>
|
|
<span class="ti">{e.title}</span>
|
|
{#if e.artist}<span class="ar">{e.artist}</span>{/if}
|
|
</span>
|
|
<span class="ev">
|
|
{#if e.resolution === "local"}
|
|
<span
|
|
class="tag ok"
|
|
title={e.matched_by === "path"
|
|
? "Matched by exact file path"
|
|
: e.matched_by === "relinked"
|
|
? "File found under the current library folder"
|
|
: "Matched by artist and title tags"}
|
|
>
|
|
<Icon name="check" size={11} />
|
|
{e.matched_by === "path" || e.matched_by === "file"
|
|
? "file"
|
|
: e.matched_by === "relinked"
|
|
? "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="Identity too thin to play — kept as a placeholder">
|
|
unresolved
|
|
</span>
|
|
{/if}
|
|
</span>
|
|
<span class="du">{fmtDur(e.duration_secs)}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="form">
|
|
<input
|
|
class="name"
|
|
bind:value={name}
|
|
placeholder="Playlist name"
|
|
spellcheck="false"
|
|
/>
|
|
{#if working.unresolved_count > 0}
|
|
<label class="keep">
|
|
<input type="checkbox" bind:checked={includeUnresolved} />
|
|
Keep unresolved entries as placeholders
|
|
</label>
|
|
{/if}
|
|
</div>
|
|
{#if error}<p class="error">{error}</p>{/if}
|
|
<footer>
|
|
{#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>
|
|
{/if}
|
|
<button
|
|
class="btn primary"
|
|
onclick={commit}
|
|
disabled={committing || kept.length === 0}
|
|
>
|
|
{committing
|
|
? "Creating…"
|
|
: `Create playlist (${kept.length} ${kept.length === 1 ? "track" : "tracks"})`}
|
|
</button>
|
|
</footer>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.scrim {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 120;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
display: grid;
|
|
place-items: center;
|
|
backdrop-filter: blur(3px);
|
|
}
|
|
.dialog {
|
|
width: min(640px, calc(100vw - 64px));
|
|
max-height: min(78vh, 760px);
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--bg-elevated);
|
|
border: 1px solid var(--border);
|
|
border-radius: 14px;
|
|
padding: 20px 22px 18px;
|
|
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6);
|
|
}
|
|
header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 12px;
|
|
}
|
|
h2 {
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
}
|
|
.x {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 6px;
|
|
color: var(--text-secondary);
|
|
}
|
|
.x:hover {
|
|
background: var(--bg-highlight);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.lead {
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 10px;
|
|
}
|
|
.mono {
|
|
font-family: Consolas, monospace;
|
|
font-size: 12px;
|
|
color: var(--text-primary);
|
|
background: var(--bg-highlight);
|
|
border-radius: 4px;
|
|
padding: 1px 5px;
|
|
}
|
|
textarea {
|
|
width: 100%;
|
|
min-height: 220px;
|
|
resize: vertical;
|
|
background: var(--bg-base);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
font-family: Consolas, monospace;
|
|
font-size: 12.5px;
|
|
line-height: 1.6;
|
|
outline: none;
|
|
}
|
|
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;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
min-width: 0;
|
|
}
|
|
.chip {
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
background: var(--bg-highlight);
|
|
color: var(--text-secondary);
|
|
padding: 3px 8px;
|
|
border-radius: 8px;
|
|
flex: none;
|
|
}
|
|
.chip.set {
|
|
background: var(--accent-soft);
|
|
color: var(--accent-hover);
|
|
}
|
|
.srcname {
|
|
font-size: 12.5px;
|
|
color: var(--text-muted);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
min-width: 0;
|
|
}
|
|
.counts {
|
|
margin-left: auto;
|
|
flex: none;
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
.counts .ok {
|
|
color: #3ecf7e;
|
|
}
|
|
.counts .st {
|
|
color: #58b6e8;
|
|
}
|
|
.counts .un {
|
|
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;
|
|
}
|
|
.warnings p {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 11.5px;
|
|
color: #e8a33d;
|
|
}
|
|
|
|
.rows {
|
|
flex: 1;
|
|
min-height: 120px;
|
|
overflow-y: auto;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: var(--bg-base);
|
|
padding: 4px;
|
|
}
|
|
.row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 6px 8px;
|
|
border-radius: 5px;
|
|
font-size: 12.5px;
|
|
}
|
|
.row:hover {
|
|
background: var(--bg-highlight);
|
|
}
|
|
.row.excluded {
|
|
opacity: 0.35;
|
|
text-decoration: line-through;
|
|
}
|
|
.n {
|
|
width: 26px;
|
|
flex: none;
|
|
text-align: right;
|
|
color: var(--text-muted);
|
|
font-variant-numeric: tabular-nums;
|
|
font-size: 11px;
|
|
}
|
|
.t {
|
|
width: 52px;
|
|
flex: none;
|
|
color: var(--accent-hover);
|
|
font-variant-numeric: tabular-nums;
|
|
font-size: 11.5px;
|
|
}
|
|
.who {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.ti {
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.ar {
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.ev {
|
|
flex: none;
|
|
}
|
|
.tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
padding: 2.5px 7px;
|
|
border-radius: 8px;
|
|
}
|
|
.tag.ok {
|
|
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;
|
|
}
|
|
.du {
|
|
width: 40px;
|
|
flex: none;
|
|
text-align: right;
|
|
color: var(--text-muted);
|
|
font-variant-numeric: tabular-nums;
|
|
font-size: 11.5px;
|
|
}
|
|
|
|
.form {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
margin-top: 12px;
|
|
}
|
|
.name {
|
|
flex: 1;
|
|
background: var(--bg-base);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 9px 12px;
|
|
font-size: 13.5px;
|
|
font-weight: 600;
|
|
outline: none;
|
|
}
|
|
.name:focus {
|
|
border-color: var(--accent);
|
|
}
|
|
.keep {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
flex: none;
|
|
}
|
|
.keep input {
|
|
accent-color: var(--accent);
|
|
}
|
|
|
|
.error {
|
|
margin-top: 8px;
|
|
font-size: 12.5px;
|
|
color: #f15e6c;
|
|
}
|
|
|
|
footer {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 10px;
|
|
margin-top: 14px;
|
|
}
|
|
.btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 7px;
|
|
padding: 9px 18px;
|
|
border-radius: 20px;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
transition: background 0.14s;
|
|
}
|
|
.btn.primary {
|
|
background: var(--accent);
|
|
color: #000;
|
|
}
|
|
.btn.primary:hover:not(:disabled) {
|
|
background: var(--accent-hover);
|
|
}
|
|
.btn.primary:disabled {
|
|
opacity: 0.5;
|
|
}
|
|
.btn.ghost {
|
|
background: var(--bg-highlight);
|
|
color: var(--text-secondary);
|
|
}
|
|
.btn.ghost:hover {
|
|
background: var(--bg-press);
|
|
color: var(--text-primary);
|
|
}
|
|
</style>
|