"use client"; import { useEffect, useMemo, useState } from "react"; import { PageHead } from "../_ui/page-head"; import { SectionHeader } from "../_ui/section-header"; import { CoverArt } from "../_ui/cover-art"; import { AlbumModal } from "../_ui/album-modal"; import { toast } from "../_ui/toast"; import { ImportAlbum } from "./import-album"; type Album = { id: string; artist: string; album: string; format: string; qualityClass: number; importedAt: string; rgMbid: string | null; year: string | null; primaryType: string | null; artistMbid: string | null; monitoredReleaseId: string | null; trackNames: string[]; }; type SortKey = "added" | "release" | "album" | "artist"; const SORTS: { key: SortKey; label: string; cmp: (a: Album, b: Album) => number }[] = [ { key: "added", label: "Recently added", cmp: (a, b) => b.importedAt.localeCompare(a.importedAt) }, // Release date, newest first; albums with an unknown year sort to the bottom. { key: "release", label: "Release date", cmp: (a, b) => (b.year ?? "0000").localeCompare(a.year ?? "0000") || a.album.localeCompare(b.album) }, { key: "album", label: "Album A–Z", cmp: (a, b) => a.album.localeCompare(b.album) }, { key: "artist", label: "Artist A–Z", cmp: (a, b) => a.artist.localeCompare(b.artist) || (a.year ?? "").localeCompare(b.year ?? "") }, ]; type Unmatched = { id: string; artist: string; album: string; path: string; reason: string }; type DupItem = { id: string; artist: string; album: string; format: string; qualityClass: number; year: string | null }; type IntegrityIssue = { id: string; artist: string; album: string; issues: string[] }; export function LibraryClient() { const [albums, setAlbums] = useState([]); const [unmatched, setUnmatched] = useState([]); const [duplicates, setDuplicates] = useState([]); const [integrity, setIntegrity] = useState<{ checked: number; issues: IntegrityIssue[] } | null>(null); const [checking, setChecking] = useState(false); const [selectMode, setSelectMode] = useState(false); const [selected, setSelected] = useState>(new Set()); const [bulkConfirmDelete, setBulkConfirmDelete] = useState(false); function toggleSelect(id: string) { setSelected((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; }); } function exitSelect() { setSelectMode(false); setSelected(new Set()); setBulkConfirmDelete(false); } async function bulk(action: "delete" | "ignore") { const ids = [...selected]; if (ids.length === 0) return; const res = await fetch("/api/library/bulk", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, ids }), }).catch(() => null); if (res?.ok) { const body = await res.json(); toast( `${action === "delete" ? "Deleted" : "Ignoring"} ${body.ok} album${body.ok === 1 ? "" : "s"}` + (body.failed?.length ? ` · ${body.failed.length} failed` : ""), ); exitSelect(); refresh(); } else { toast(`Bulk ${action} failed`); } } const [q, setQ] = useState(""); const [sort, setSort] = useState("added"); const [open, setOpen] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const [editing, setEditing] = useState(false); const [edit, setEdit] = useState({ artist: "", album: "", year: "" }); const [saving, setSaving] = useState(false); function show(a: Album | null) { setOpen(a); setConfirmDelete(false); setEditing(false); } function startEdit() { if (!open) return; setEdit({ artist: open.artist, album: open.album, year: open.year ?? "" }); setEditing(true); } async function saveEdit() { if (!open) return; if (!edit.artist.trim() || !edit.album.trim()) { toast("Artist and album are required"); return; } setSaving(true); try { const res = await fetch(`/api/library/${open.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ artist: edit.artist.trim(), album: edit.album.trim(), year: edit.year.trim() }), }).catch(() => null); if (res?.ok) { toast(`Updated ${edit.album.trim()}`); setEditing(false); show(null); refresh(); } else { toast((res && (await res.json().catch(() => null))?.error) || "Couldn't update album"); } } finally { setSaving(false); } } async function remove() { if (!open) return; const target = open; const res = await fetch(`/api/library/${target.id}`, { method: "DELETE" }).catch(() => null); if (res?.ok) { setAlbums((list) => list.filter((a) => a.id !== target.id)); toast(`Deleted ${target.album}`); show(null); } else { toast(`Couldn't delete ${target.album}`); } } function refresh() { fetch("/api/library") .then((r) => r.json()) .then((d) => setAlbums(d.albums ?? [])); fetch("/api/library/unmatched") .then((r) => r.json()) .then((d) => setUnmatched(d.unmatched ?? [])); fetch("/api/library/duplicates") .then((r) => r.json()) .then((d) => setDuplicates(d.duplicates ?? [])); } useEffect(() => { refresh(); }, []); async function upgrade() { if (!open) return; const res = await fetch(`/api/library/${open.id}/upgrade`, { method: "POST" }).catch(() => null); if (res && (res.status === 202 || res.ok)) { const body = await res.json().catch(() => null); toast(body?.enqueued === false ? `Already searching for ${open.album}` : `Re-acquiring ${open.album} — keeps it only if better`); show(null); } else { toast((res && (await res.json().catch(() => null))?.error) || "Couldn't start the upgrade"); } } async function checkIntegrity() { setChecking(true); try { const res = await fetch("/api/library/integrity").catch(() => null); if (res?.ok) { setIntegrity(await res.json()); } else { toast("Couldn't run the integrity check"); } } finally { setChecking(false); } } async function dismissUnmatched(u: Unmatched) { const res = await fetch(`/api/library/unmatched?id=${u.id}`, { method: "DELETE" }).catch(() => null); if (res?.ok) { setUnmatched((list) => list.filter((x) => x.id !== u.id)); } else { toast("Couldn't dismiss"); } } const shown = useMemo(() => { const needle = q.trim().toLowerCase(); const filtered = needle ? albums.filter((a) => `${a.artist} ${a.album}`.toLowerCase().includes(needle)) : albums; const cmp = (SORTS.find((s) => s.key === sort) ?? SORTS[0]).cmp; return [...filtered].sort(cmp); }, [albums, q, sort]); return (
e.preventDefault()}> {albums.length === 0 ? (

Nothing pressed yet. Scan your library in Settings, or queue a release from The Floor.

) : ( {q ? `${shown.length} of ${albums.length}` : `${albums.length} albums`} } /> )} {selectMode ? (
{selected.size} selected {bulkConfirmDelete ? ( <> Delete {selected.size} album{selected.size === 1 ? "" : "s"} and their files? ) : ( <> )}
) : null} {albums.length > 0 ? (
{shown.map((a) => ( ))}
) : null} {albums.length > 0 ? ( <> {checking ? "Checking…" : "Check now"} } /> {integrity ? ( integrity.issues.length === 0 ? (

Checked {integrity.checked} albums — no problems found.

) : (
    {integrity.issues.map((it) => { const album = albums.find((a) => a.id === it.id); return (
  • {album ? ( ) : (
    {it.artist} — {it.album}
    )}
    {it.issues.join(" · ")}
  • ); })}
) ) : (

Check every album folder on disk for missing folders, empty/truncated files, and track-count drift.

)} ) : null} {duplicates.length > 0 ? ( <>

Albums that look like the same release held more than once (same MusicBrainz release, or the same title ignoring edition wording). Open one to edit or delete it.

{duplicates.map((group, gi) => (
    {group.map((d) => { const album = albums.find((a) => a.id === d.id); return (
  • {album ? ( ) : (
    {d.artist} — {d.album}
    )}
    {d.format} {d.qualityClass >= 3 ? " · hi-res" : ""} {d.year ? ` · ${d.year}` : ""}
  • ); })}
))} ) : null} {unmatched.length > 0 ? ( <>

Album folders on disk the scan couldn’t resolve to a MusicBrainz release. Fix the folder name (Artist / Album (Year)) or re-add them manually, then re-scan. Dismiss to hide an entry.

    {unmatched.map((u) => (
  • {u.artist ? `${u.artist} — ` : ""} {u.album}
    {u.path} ·{" "} {u.reason}
  • ))}
) : null} {open ? ( show(null)} album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType, artistMbid: open.artistMbid, trackNames: open.trackNames }} status={In library · {open.format}} actions={ editing ? (

Renames the folder on disk and re-resolves cover art. Embedded file tags are left as-is.

) : confirmDelete ? ( <> Delete this album and its files from disk? ) : ( <> {open.monitoredReleaseId ? ( ) : null} ) } /> ) : null}
); }