Files
Lyra/web/src/app/_ui/album-modal.tsx
T
Jonathan dcf0bb0696 feat: show real on-disk tracklist in the Library album modal (#9)
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>
2026-07-14 12:55:54 +02:00

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>
);
}