dcf0bb0696
The Library modal showed MusicBrainz's canonical tracklist, hiding incomplete downloads / edition or track-order mismatches, and showed nothing for albums without an rgMbid. Now it shows the REAL files on disk. - LibraryItem gains trackNames String[] (migration add_library_track_names): the album's on-disk "## Title.ext" audio filenames. - Captured at import (pipeline._import lists the final album folder) and at scan (scan._record_owned now also refreshes trackNames on re-scan via ON CONFLICT DO UPDATE + xmax=0 to preserve the newly-imported flag). New shared library.list_audio_files() helper. - /api/library exposes trackNames; the AlbumModal prefers on-disk tracks when present (parsed "## Title" → position/title, zero network, labeled "On disk · N tracks"), falling back to the MusicBrainz listing otherwise. Items imported before this column show the MB fallback until re-scanned. worker 221 tests / 7-skip, web 153, tsc + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
190 lines
6.1 KiB
TypeScript
190 lines
6.1 KiB
TypeScript
"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<string, Track[]>();
|
|
|
|
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<Track[] | "loading" | "error">("loading");
|
|
const onDisk = (album.trackNames?.length ?? 0) > 0;
|
|
// null = unknown/loading; only meaningful when album.artistMbid is set.
|
|
const [followed, setFollowed] = useState<boolean | null>(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 ? (
|
|
<span className="modal-artist">
|
|
<a href={`/discover/artist/${album.artistMbid}`}>{album.artist}</a>
|
|
<button
|
|
type="button"
|
|
className="btn sm ghost"
|
|
onClick={follow}
|
|
disabled={followed !== false}
|
|
aria-label={followed ? "following" : "follow artist"}
|
|
>
|
|
{followed === null ? "…" : followed ? "Following" : "Follow"}
|
|
</button>
|
|
</span>
|
|
) : (
|
|
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 (
|
|
<Modal open={open} onClose={onClose} title={titleNode} wide>
|
|
<div className="album-modal-head">
|
|
<CoverArt rgMbid={album.rgMbid} alt={album.album} size={250} className="album-modal-cover" />
|
|
<div>
|
|
<h2 className="album-modal-title">{album.album}</h2>
|
|
<div className="album-modal-meta">
|
|
{album.type ? <span>{album.type}</span> : null}
|
|
{album.year ? (
|
|
<>
|
|
<span className="dot">·</span>
|
|
<span>{album.year}</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
{status ? <div className="album-modal-status">{status}</div> : null}
|
|
{actions ? <div className="actions album-modal-actions">{actions}</div> : null}
|
|
</div>
|
|
</div>
|
|
<div className="album-modal-tracks">
|
|
{Array.isArray(tracks) ? (
|
|
<p className="tracks-source">
|
|
{onDisk ? `On disk · ${tracks.length} tracks` : "MusicBrainz tracklist"}
|
|
</p>
|
|
) : null}
|
|
{tracks === "loading" ? <p className="muted-note">Loading tracks…</p> : null}
|
|
{tracks === "error" ? (
|
|
<p className="muted-note">No tracklist available.</p>
|
|
) : null}
|
|
{Array.isArray(tracks) ? (
|
|
<ol className="tracks">
|
|
{tracks.map((t) => (
|
|
<li key={t.position}>
|
|
<span className="tnum">{t.position}</span>
|
|
<span className="tname">{t.title}</span>
|
|
{t.lengthMs != null ? <span>{fmt(t.lengthMs)}</span> : null}
|
|
</li>
|
|
))}
|
|
</ol>
|
|
) : null}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|