/* Importing a playlist on the phone — the desktop's PlaylistImportModal's rules, on the doors that need no tool and no file: a link (YouTube · Spotify · Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines, a timestamped setlist, or pasted track links), a YouTube DJ set's posted tracklist. The backend stages a draft (parsing and resolution against the library); nothing is written until commit. While the review is up the stream rows are verified against the catalog — the desktop's verifyStreams, verbatim: the ISRC first, then the hardened name walk — so a committed row plays as a catalog track with artwork. */ import * as api from "../lib/api.js"; import { listen } from "../lib/events.js"; import { findCatalogTrack } from "../lib/catalogMatch.js"; import { refreshPlaylists } from "./lib.svelte.js"; export const importer = $state({ door: null, // "link" | "text" | "youtube" source: "", // what was pasted busy: false, error: "", progress: null, // { done, total, waiting } while pasted links resolve draft: null, // the backend's ImportDraft name: "", includeUnresolved: true, checks: {}, // ordinal → "checking" | { album, cover, anchored } | "none" committing: false, }); export 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", }; /** [door, icon, title, hint] — the desktop's Import menu, minus the file * and folder doors (the files are on the desktop). */ export const DOORS = [ ["link", "link", "Playlist from a link…", "YouTube · Spotify · Apple Music · TIDAL · Deezer"], ["text", "pencil", "Paste a tracklist…", "plain lines, a timestamped setlist, or track links"], ["youtube", "brand-youtube", "YouTube DJ set…", "tracks from chapters, description or comments"], ]; let gen = 0; /** Open a door: the backend reads and resolves, the review shows the draft. */ export async function start(door, source) { const g = ++gen; Object.assign(importer, { door, source, busy: true, error: "", progress: null, draft: null, name: "", includeUnresolved: true, checks: {}, committing: false }); try { const draft = door === "link" ? await api.playlistImportLink(source) : door === "youtube" ? await api.playlistImportYoutube(source) : await api.playlistImportText(source); if (g !== gen) return; importer.draft = draft; importer.name = draft?.name ?? ""; importer.busy = false; if (draft?.entries?.length) queueMicrotask(() => void verifyStreams(g)); } catch (e) { if (g === gen) { importer.error = String(e); importer.busy = false; } } } /** The rows commit will send: streams are first-class; the switch only * gates the TRUE unknowns. */ export function kept() { const w = importer.draft; if (!w) return []; return importer.includeUnresolved ? w.entries : w.entries.filter((e) => e.resolution !== "unresolved"); } export function checkedFound() { return Object.values(importer.checks).filter((v) => v && v !== "none" && v !== "checking").length; } /** Make the playlist. Returns { id, name }, or null (the error is on the store). */ export async function commit() { const w = importer.draft; if (!w || importer.committing) return null; const finalName = (importer.name || w.name || "Imported playlist").trim(); importer.committing = true; importer.error = ""; try { const rows = kept().map((e) => $state.snapshot(e)); const id = await api.playlistImportCommit(finalName, w.description ?? null, w.kind, rows, w.cover_url ?? null); ++gen; // the verification walk stops here await refreshPlaylists(); return { id, name: finalName }; } catch (e) { importer.error = String(e); return null; } finally { importer.committing = false; } } export function cancel() { ++gen; Object.assign(importer, { busy: false, draft: null, error: "", progress: null, checks: {} }); } let bound = false; export async function bindImporter() { if (bound) return; bound = true; try { /* the pasted-links door looks each link up, a page each */ await listen("playlist:link-progress", (ev) => { if (importer.busy) importer.progress = ev.payload || null; }); } catch { /* not a phone */ } } async function verifyStreams(g) { const w = importer.draft; if (!w) return; for (const e of w.entries) { if (e.resolution !== "stream") continue; if (g !== gen) return; importer.checks[e.ordinal] = "checking"; try { /* anchor-exact first: a row that carries the recording's ISRC beats any name matcher; a miss falls through to the walk */ let hit = null; let anchored = false; if (e.isrc) { hit = await api.catalogTrackByIsrc(e.isrc); if (g !== gen) return; anchored = !!hit; } if (!hit) { 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 (g !== gen) return; } if (hit) { /* the catalog identity rides into the committed row */ 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; importer.checks[e.ordinal] = { album: hit.album ?? "", cover: hit.cover ?? null, anchored }; } else { importer.checks[e.ordinal] = "none"; } } catch { if (g === gen) importer.checks[e.ordinal] = "none"; } await new Promise((r) => setTimeout(r, 250)); } }