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>
223 lines
7.8 KiB
TypeScript
223 lines
7.8 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { motion } from "framer-motion";
|
|
import { ArrowLeft, SearchX } from "lucide-react";
|
|
|
|
import { api, type Me, type Track } from "@/lib/api";
|
|
import { tint } from "@/lib/colour";
|
|
import { useNav, type Tab } from "@/lib/nav";
|
|
import { panel, useMotionOK } from "@/lib/motion";
|
|
|
|
import { SearchBar } from "@/components/search-bar";
|
|
import { TrackRow } from "@/components/track-row";
|
|
import { AlbumView } from "@/components/album-view";
|
|
import { ArtistView } from "@/components/artist-view";
|
|
import { BulkImport } from "@/components/bulk-import";
|
|
import { PlaylistImport } from "@/components/playlist-import";
|
|
import { Requests, type JobsSnapshot } from "@/components/requests";
|
|
import { Review } from "@/components/review";
|
|
import { DiscoverPanel } from "@/components/discover";
|
|
import { Settings } from "@/components/settings";
|
|
import { Welcome } from "@/components/welcome";
|
|
import { PlayerProvider } from "@/components/player";
|
|
import { ToastProvider } from "@/components/toast";
|
|
import { AdminPanel } from "@/components/admin/admin-panel";
|
|
|
|
import { Sidebar } from "@/components/shell/sidebar";
|
|
import { TabBar } from "@/components/shell/tabbar";
|
|
import { Toolbar } from "@/components/shell/toolbar";
|
|
import { StatusBanner } from "@/components/shell/status-banner";
|
|
import { Button } from "@/components/ui/button";
|
|
import { EmptyState } from "@/components/ui/empty-state";
|
|
import { SkeletonRows } from "@/components/ui/skeleton";
|
|
|
|
export default function Page() {
|
|
return (
|
|
<ToastProvider>
|
|
<PlayerProvider>
|
|
<Muse />
|
|
</PlayerProvider>
|
|
</ToastProvider>
|
|
);
|
|
}
|
|
|
|
/** Tailwind's `md`. Decides sidebar-vs-rail, and rail-vs-bottom-bar. */
|
|
function useBreakpoint() {
|
|
const [w, setW] = React.useState(1280);
|
|
React.useEffect(() => {
|
|
const on = () => setW(window.innerWidth);
|
|
on();
|
|
window.addEventListener("resize", on);
|
|
return () => window.removeEventListener("resize", on);
|
|
}, []);
|
|
return { rail: w >= 768 && w < 1024 };
|
|
}
|
|
|
|
function Muse() {
|
|
const [nav, go] = useNav();
|
|
const { rail } = useBreakpoint();
|
|
const ok = useMotionOK();
|
|
|
|
const [results, setResults] = React.useState<Track[] | null>(null);
|
|
const [busy, setBusy] = React.useState(false);
|
|
const [me, setMe] = React.useState<Me | null>(null);
|
|
const [snap, setSnap] = React.useState<JobsSnapshot | null>(null);
|
|
|
|
const doSearch = React.useCallback(
|
|
async (q: string) => {
|
|
if (!q.trim()) return;
|
|
go({ tab: "search", q });
|
|
setResults(null);
|
|
setBusy(true);
|
|
try {
|
|
const r = await api.search(q);
|
|
setResults(r.results);
|
|
if (r.results[0]) void tint(r.results[0].cover);
|
|
} catch {
|
|
setResults([]);
|
|
}
|
|
setBusy(false);
|
|
},
|
|
[go],
|
|
);
|
|
|
|
// Re-runs the query when the URL carries one, so a shared link and a browser
|
|
// Back both restore an actual result set rather than an empty screen.
|
|
const lastQ = React.useRef<string | null>(null);
|
|
React.useEffect(() => {
|
|
if (nav.tab !== "search" || nav.view || !nav.q) return;
|
|
if (lastQ.current === nav.q) return;
|
|
lastQ.current = nav.q;
|
|
void doSearch(nav.q);
|
|
}, [nav.tab, nav.view, nav.q, doSearch]);
|
|
|
|
const openAlbum = React.useCallback(
|
|
(id: number) => go({ tab: "search", view: "album", id: String(id), q: nav.q }),
|
|
[go, nav.q],
|
|
);
|
|
const openArtist = React.useCallback(
|
|
(id: number) => go({ tab: "search", view: "artist", id: String(id), q: nav.q }),
|
|
[go, nav.q],
|
|
);
|
|
|
|
const refresh = React.useCallback(async () => {
|
|
try {
|
|
setSnap(await api.jobs());
|
|
} catch {
|
|
/* a 401 here just means the poll outlived the session */
|
|
}
|
|
// Refetched alongside jobs rather than once at mount so the quota chip
|
|
// counts down as you queue, and so a role change or a disable lands within
|
|
// one poll instead of at next sign-in.
|
|
try {
|
|
setMe(await api.me());
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
void refresh();
|
|
const t = setInterval(refresh, 4000);
|
|
return () => clearInterval(t);
|
|
}, [refresh]);
|
|
|
|
const active =
|
|
(snap?.counts.queued ?? 0) +
|
|
(snap?.counts.searching ?? 0) +
|
|
(snap?.counts.downloading ?? 0);
|
|
const held = snap?.counts.review ?? 0;
|
|
const isAdmin = me?.role === "admin";
|
|
|
|
// Only worth showing when a limit actually exists — a permanent "0 / 0" chip
|
|
// is noise on a single-user install, which is most of them.
|
|
const quotaLeft =
|
|
me?.quota && me.usage ? Math.max(0, me.quota - me.usage.requests) : null;
|
|
|
|
const badges: Partial<Record<Tab, number>> = { jobs: active, review: held };
|
|
// Review appears exactly when something is waiting on you; an always-present
|
|
// tab that is empty nine days in ten is chrome.
|
|
const visible = (t: Tab) =>
|
|
t === "admin" ? !!isAdmin : t === "review" ? held > 0 || nav.tab === "review" : true;
|
|
|
|
const onBack = nav.view
|
|
? () => go({ tab: "search", q: nav.q })
|
|
: null;
|
|
|
|
return (
|
|
<div className="flex min-h-dvh">
|
|
<Sidebar nav={nav} go={go} badges={badges} rail={rail} visible={visible} />
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<Toolbar me={me} quotaLeft={quotaLeft}>
|
|
<SearchBar initial={nav.q} busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
|
</Toolbar>
|
|
<StatusBanner />
|
|
|
|
<main className="mx-auto w-full max-w-[var(--container-3xl)] flex-1 px-4 pb-28 pt-5 md:pb-16">
|
|
<motion.div key={nav.tab + (nav.view ?? "") + (nav.id ?? "")} {...panel(ok)}>
|
|
{nav.tab === "search" && (
|
|
<>
|
|
{onBack && (
|
|
<Button variant="quiet" size="sm" onClick={onBack} className="mb-3 -ml-2">
|
|
<ArrowLeft className="h-3.5 w-3.5" /> Back
|
|
</Button>
|
|
)}
|
|
|
|
{nav.view === "album" && nav.id && (
|
|
<AlbumView id={Number(nav.id)} onArtist={openArtist} onQueued={refresh} />
|
|
)}
|
|
{nav.view === "artist" && nav.id && (
|
|
<ArtistView id={Number(nav.id)} onAlbum={openAlbum} onQueued={refresh} />
|
|
)}
|
|
|
|
{!nav.view && !nav.q && <Welcome onPick={doSearch} recent={snap?.jobs ?? []} />}
|
|
|
|
{!nav.view &&
|
|
nav.q &&
|
|
(results === null ? (
|
|
<SkeletonRows n={6} />
|
|
) : results.length === 0 ? (
|
|
<EmptyState
|
|
icon={SearchX}
|
|
title="No matches"
|
|
body="Try adding the artist name, or fewer words."
|
|
/>
|
|
) : (
|
|
<div className="flex flex-col gap-row">
|
|
{results.map((t, i) => (
|
|
<TrackRow
|
|
key={t.deezer_id}
|
|
track={t}
|
|
index={i}
|
|
onAlbum={openAlbum}
|
|
onArtist={openArtist}
|
|
onQueued={refresh}
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{nav.tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
|
|
{nav.tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
|
{nav.tab === "review" && <Review onDecided={refresh} />}
|
|
{nav.tab === "bulk" && (
|
|
<div className="flex flex-col gap-4">
|
|
<PlaylistImport onQueued={refresh} />
|
|
<BulkImport onQueued={refresh} />
|
|
</div>
|
|
)}
|
|
{nav.tab === "settings" && me && <Settings me={me} refresh={refresh} />}
|
|
{nav.tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
|
</motion.div>
|
|
</main>
|
|
</div>
|
|
|
|
<TabBar nav={nav} go={go} badges={badges} visible={visible} />
|
|
</div>
|
|
);
|
|
}
|