Import a shared playlist link and build it in Navidrome

Paste a Spotify, Apple Music or Deezer playlist URL; muse resolves it, matches
every track through the same Deezer path a pasted line takes, and writes an
.m3u the library scanner picks up.

Written per import rather than at the end, so the playlist appears in Navidrome
with the first track that lands and grows as the rest arrive -- twenty minutes
of nothing, then everything, is the worse shape. A track rejected in review
drops out on the next rewrite, and one that never resolves is reported instead
of silently vanishing.

Resolve is split from queue on purpose: a shared link is opaque, you cannot
tell twelve tracks from four hundred by looking at it, and four hundred is a
quota decision.

Two of the three sources are scrapes and the code says so plainly. Deezer has a
real API. Spotify and Apple Music have no free way to read a public playlist --
Apple's MusicKit needs a paid developer account -- so those read the public page
and parse the JSON it already embeds for its own player. No key, no OAuth, no
account, and no guarantee: they break whenever either company reshuffles their
frontend. So the resolvers fail loudly and name themselves, because an empty
playlist and a changed page must not produce the same message.

The .m3u uses MUSE_PLAYLIST_PREFIX (/music), not muse's own library path. Both
containers mount the same directory at different points and a playlist written
with muse's view resolves to nothing on Navidrome's side.

Also reorders the hooks in search-bar so the URL-seeding effect is declared
after the state it sets, rather than closing over a binding declared below it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-09-02 15:40:29 -05:00
1 parent e9fba3bb1d
commit 4aaf3e0998
7 files changed
+815 -19

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
"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<string, string> = {
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<PlaylistPreview | null>(null);
const [busy, setBusy] = React.useState<"idle" | "resolving" | "queueing">("idle");
const [rows, setRows] = React.useState<PlaylistRow[] | null>(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 (
<div className="flex flex-col gap-4">
<Card pad="lg">
<CardTitle>Import a shared playlist</CardTitle>
<CardNote className="mt-1 mb-3.5 max-w-[64ch]">
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.
</CardNote>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && busy === "idle" && resolve()}
placeholder="https://open.spotify.com/playlist/…"
aria-label="Playlist link"
inputMode="url"
/>
<Button onClick={resolve} disabled={busy !== "idle"} className="shrink-0">
{busy === "resolving" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2 className="h-3.5 w-3.5" />}
Resolve
</Button>
</div>
<AnimatePresence>
{preview && (
<motion.div
initial={ok ? { opacity: 0, height: 0 } : false}
animate={{ opacity: 1, height: "auto" }}
exit={ok ? { opacity: 0, height: 0 } : { opacity: 0 }}
className="overflow-hidden"
>
<div className="mt-4 rounded-card border border-edge bg-[var(--surface-2)] p-3.5">
<div className="mb-2.5 flex flex-wrap items-center gap-2">
<Badge tone="accent">{SOURCE[preview.source] ?? preview.source}</Badge>
<span className="text-callout text-dim nums">
{preview.count} track{preview.count === 1 ? "" : "s"}
</span>
</div>
<label className="mb-1 block text-caption font-medium text-dim">
Name in Navidrome
</label>
<Input value={name} onChange={(e) => setName(e.target.value)} className="mb-3" />
<ol className="mb-3 max-h-52 overflow-y-auto text-callout">
{preview.tracks.map((t, i) => (
<li key={i} className="flex gap-2 border-b border-edge/60 py-1 last:border-0">
<span className="w-6 shrink-0 text-right text-mute nums">{i + 1}</span>
<span className="clamp1">
{t.title} <span className="text-dim">· {t.artist}</span>
</span>
</li>
))}
{preview.count > preview.tracks.length && (
<li className="py-1 text-caption text-mute">
…and {preview.count - preview.tracks.length} more
</li>
)}
</ol>
<Button onClick={queue} disabled={busy !== "idle"}>
{busy === "queueing" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
Queue all {preview.count}
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</Card>
{rows && rows.length > 0 && (
<Card pad="lg">
<CardTitle className="mb-3">Playlists</CardTitle>
<div className="flex flex-col gap-2.5">
{rows.map((p, i) => {
const total = p.tracks_total || 1;
const settled = p.done + p.failed;
return (
<motion.div
key={p.id}
{...item(i, ok)}
className="rounded-card border border-edge bg-[var(--surface-2)] p-3"
>
<div className="mb-1.5 flex items-center gap-2">
<ListMusic className="h-3.5 w-3.5 shrink-0 text-mute" />
<span className="min-w-0 flex-1 text-body font-semibold clamp1">{p.name}</span>
<Badge tone="neutral">{SOURCE[p.source] ?? p.source}</Badge>
</div>
<Progress
value={(p.done / total) * 100}
tone={p.failed && settled >= total ? "warn" : "accent"}
size="sm"
label={`${p.name} progress`}
/>
<div className="mt-1.5 flex flex-wrap gap-x-3 text-caption text-dim nums">
<span className="text-ok">{p.done} in library</span>
{p.pending > 0 && <span>{p.pending} in flight</span>}
{p.review > 0 && <span className="text-warn">{p.review} to review</span>}
{p.failed > 0 && <span className="text-err">{p.failed} failed</span>}
<span className="text-mute">of {p.tracks_total}</span>
</div>
{p.m3u_path && (
<div className="mt-1 flex items-center gap-1 text-caption text-mute">
<CheckCircle2 className="h-3 w-3 text-ok" />
in Navidrome with {p.tracks_written} track
{p.tracks_written === 1 ? "" : "s"}
</div>
)}
</motion.div>
);
})}
</div>
</Card>
)}
</div>
);
}
+15 -13
View File
@@ -23,24 +23,26 @@ export function SearchBar({
onArtist: (id: number) => void;
}) {
const [q, setQ] = React.useState(initial ?? "");
// Follows the URL on Back/Forward, but never fights what is being typed:
// only a genuinely different incoming query replaces the field.
const seeded = React.useRef(initial);
React.useEffect(() => {
if (initial !== undefined && initial !== seeded.current) {
seeded.current = initial;
typed.current = false;
setQ(initial);
setOpen(false);
}
}, [initial]);
const [items, setItems] = React.useState<Item[]>([]);
const [open, setOpen] = React.useState(false);
const [sel, setSel] = React.useState(-1);
const inputRef = React.useRef<HTMLInputElement>(null);
// A value seeded from the URL must not pop the suggestion list open over the
// results that link was for. Only typing counts as asking for suggestions.
const typed = React.useRef(false);
const [sel, setSel] = React.useState(-1);
const inputRef = React.useRef<HTMLInputElement>(null);
const seeded = React.useRef(initial);
// Follows the URL on Back/Forward, but never fights what is being typed:
// only a genuinely different incoming query replaces the field. Declared
// after the state it sets, or the effect closes over a binding that is not
// initialised yet.
React.useEffect(() => {
if (initial === undefined || initial === seeded.current) return;
seeded.current = initial;
typed.current = false;
setQ(initial);
setOpen(false);
}, [initial]);
// Debounced so a fast typist makes one upstream call, not eight. The
// AbortController matters more than the delay: without it a slow response