"use client"; import { useEffect, useState, type ReactNode } from "react"; import { Modal } from "./modal"; import { CoverArt } from "./cover-art"; import { toast } from "./toast"; type Track = { position: number; title: string; lengthMs: number | null }; // Session-scoped, per-release-group tracklist cache: reopening the same album modal is // instant with zero network. Complements the server-side ApiCache (which spans sessions). const trackCache = new Map(); function fmt(ms: number | null): string { if (ms == null) return ""; const s = Math.round(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; } export type AlbumInfo = { rgMbid: string | null; album: string; artist: string; year?: string | null; type?: string | null; /** When present, the modal shows a Follow button + links the artist name to their page. */ artistMbid?: string | null; /** Real on-disk audio filenames (Library items). When present, the modal shows THESE — * revealing incomplete downloads / edition mismatches — instead of MusicBrainz's listing. */ trackNames?: string[] | null; }; /** Parse on-disk "## Title.ext" filenames into display tracks (sorted order = position). */ function parseOnDisk(names: string[]): Track[] { return names.map((name, i) => { const stem = name.replace(/\.[^.]+$/, ""); // drop extension const title = stem.replace(/^\s*\d+(?:[-.\s]+\d+)?[\s._-]+/, "").trim() || stem; // drop leading NN / N-NN return { position: i + 1, title, lengthMs: null }; }); } /** Reusable album modal: cover art + tracklist (fetched from MB by release-group MBID) with * slots for a status chip and context-specific action buttons. */ export function AlbumModal({ open, onClose, album, status, actions, }: { open: boolean; onClose: () => void; album: AlbumInfo; status?: ReactNode; actions?: ReactNode; }) { const [tracks, setTracks] = useState("loading"); const onDisk = (album.trackNames?.length ?? 0) > 0; // null = unknown/loading; only meaningful when album.artistMbid is set. const [followed, setFollowed] = useState(null); useEffect(() => { if (!open || !album.artistMbid) { setFollowed(false); return; } let cancelled = false; setFollowed(null); fetch(`/api/artists?mbid=${encodeURIComponent(album.artistMbid)}`) .then((r) => (r.ok ? r.json() : { followed: false })) .then((d) => !cancelled && setFollowed(!!d.followed)) .catch(() => !cancelled && setFollowed(false)); return () => { cancelled = true; }; }, [open, album.artistMbid]); async function follow() { if (!album.artistMbid) return; try { const res = await fetch("/api/artists", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ mbid: album.artistMbid, name: album.artist }), }); if (res.ok || res.status === 409) { setFollowed(true); toast(`Following ${album.artist}`); } else { toast(`Couldn't follow ${album.artist}`); } } catch { toast(`Couldn't follow ${album.artist}`); } } const titleNode: ReactNode = album.artistMbid ? ( {album.artist} ) : ( album.artist ); useEffect(() => { if (!open) return; // Prefer the REAL on-disk tracks (Library items) — no network, and reveals mismatches. if (album.trackNames?.length) { setTracks(parseOnDisk(album.trackNames)); return; } const rgMbid = album.rgMbid; if (!rgMbid) { setTracks("error"); return; } const hit = trackCache.get(rgMbid); if (hit) { setTracks(hit); return; } setTracks("loading"); let cancelled = false; fetch(`/api/mb/release-groups/${rgMbid}/tracks`) .then((r) => (r.ok ? r.json() : Promise.reject())) .then((d) => { if (cancelled) return; trackCache.set(rgMbid, d.tracks); setTracks(d.tracks); }) .catch(() => !cancelled && setTracks("error")); return () => { cancelled = true; }; }, [open, album.rgMbid]); return (

{album.album}

{album.type ? {album.type} : null} {album.year ? ( <> · {album.year} ) : null}
{status ?
{status}
: null} {actions ?
{actions}
: null}
{Array.isArray(tracks) ? (

{onDisk ? `On disk · ${tracks.length} tracks` : "MusicBrainz tracklist"}

) : null} {tracks === "loading" ?

Loading tracks…

: null} {tracks === "error" ? (

No tracklist available.

) : null} {Array.isArray(tracks) ? (
    {tracks.map((t) => (
  1. {t.position} {t.title} {t.lengthMs != null ? {fmt(t.lengthMs)} : null}
  2. ))}
) : null}
); }