"use client"; import * as React from "react"; import { AnimatePresence, motion } from "framer-motion"; import { Link2, ListMusic, Loader2, CheckCircle2 } from "lucide-react"; import { api, type PlaylistPreview, type PlaylistRow } from "@/lib/api"; import { useToast } from "@/components/toast"; import { Button } from "@/components/ui/button"; import { Card, CardTitle, CardNote } from "@/components/ui/card"; import { Input } from "@/components/ui/field"; import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; import { useMotionOK, item } from "@/lib/motion"; const SOURCE: Record = { spotify: "Spotify", apple: "Apple Music", deezer: "Deezer", }; /** * Paste a shared link, get the playlist. * * Resolve is deliberately split from queue. A shared link is opaque — you * cannot tell twelve tracks from four hundred by looking at it — and four * hundred is a quota decision, so the tracks are shown before anything is * committed. */ export function PlaylistImport({ onQueued }: { onQueued?: () => void }) { const toast = useToast(); const ok = useMotionOK(); const [url, setUrl] = React.useState(""); const [name, setName] = React.useState(""); const [preview, setPreview] = React.useState(null); const [busy, setBusy] = React.useState<"idle" | "resolving" | "queueing">("idle"); const [rows, setRows] = React.useState(null); // Not async, and resolving through .then: setState then happens in a promise // callback rather than synchronously inside the effect, which is both what // React wants and what the rest of the polling components here already do. const refresh = React.useCallback(() => { api .playlists() .then((d) => setRows(d.playlists)) .catch(() => { /* a 401 here just means the poll outlived the session */ }); }, []); React.useEffect(() => { refresh(); const t = setInterval(refresh, 5000); return () => clearInterval(t); }, [refresh]); const resolve = async () => { if (!url.trim()) return toast("Paste a playlist link first", true); setBusy("resolving"); setPreview(null); try { const d = await api.playlistPreview(url.trim()); setPreview(d); setName(d.name); } catch (e) { toast((e as Error).message, true); } setBusy("idle"); }; const queue = async () => { setBusy("queueing"); try { const d = await api.playlistImport(url.trim(), name.trim() || undefined); toast( `Queued ${d.queued.length} of ${d.queued.length + d.skipped.length} from ${d.name}`, ); setUrl(""); setPreview(null); onQueued?.(); refresh(); } catch (e) { toast((e as Error).message, true); } setBusy("idle"); }; return (
Import a shared playlist Paste a link from Spotify, Apple Music or Deezer. Every track is matched on Deezer and queued like any other request, and the playlist itself appears in Navidrome — growing as tracks arrive rather than all at once.
setUrl(e.target.value)} onKeyDown={(e) => e.key === "Enter" && busy === "idle" && resolve()} placeholder="https://open.spotify.com/playlist/…" aria-label="Playlist link" inputMode="url" />
{preview && (
{SOURCE[preview.source] ?? preview.source} {preview.count} track{preview.count === 1 ? "" : "s"}
setName(e.target.value)} className="mb-3" />
    {preview.tracks.map((t, i) => (
  1. {i + 1} {t.title} · {t.artist}
  2. ))} {preview.count > preview.tracks.length && (
  3. …and {preview.count - preview.tracks.length} more
  4. )}
)}
{rows && rows.length > 0 && ( Playlists
{rows.map((p, i) => { const total = p.tracks_total || 1; const settled = p.done + p.failed; return (
{p.name} {SOURCE[p.source] ?? p.source}
= total ? "warn" : "accent"} size="sm" label={`${p.name} progress`} />
{p.done} in library {p.pending > 0 && {p.pending} in flight} {p.review > 0 && {p.review} to review} {p.failed > 0 && {p.failed} failed} of {p.tracks_total}
{p.m3u_path && (
in Navidrome with {p.tracks_written} track {p.tracks_written === 1 ? "" : "s"}
)}
); })}
)}
); }