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>
This commit is contained in:
@@ -25,8 +25,20 @@ export type AlbumInfo = {
|
||||
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({
|
||||
@@ -43,6 +55,7 @@ export function AlbumModal({
|
||||
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);
|
||||
|
||||
@@ -100,6 +113,11 @@ export function AlbumModal({
|
||||
|
||||
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");
|
||||
@@ -145,8 +163,15 @@ export function AlbumModal({
|
||||
</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}
|
||||
{tracks === "error" ? (
|
||||
<p className="muted-note">No tracklist available.</p>
|
||||
) : null}
|
||||
{Array.isArray(tracks) ? (
|
||||
<ol className="tracks">
|
||||
{tracks.map((t) => (
|
||||
|
||||
Binary file not shown.
@@ -200,6 +200,10 @@ nav.contents .sep { flex: 1; }
|
||||
.modal-artist { display: inline-flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.modal-artist a { color: inherit; text-decoration: none; border-bottom: 1.5px solid var(--rule-2); }
|
||||
.modal-artist a:hover { border-bottom-color: var(--accent); color: var(--accent); }
|
||||
.tracks-source {
|
||||
font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--graphite); margin: 0 0 10px;
|
||||
}
|
||||
|
||||
/* ── Phase 2: page headers, list rows, tools ──────────── */
|
||||
.page-head { margin: 6px 0 26px; }
|
||||
|
||||
@@ -16,6 +16,7 @@ type Album = {
|
||||
year: string | null;
|
||||
primaryType: string | null;
|
||||
artistMbid: string | null;
|
||||
trackNames: string[];
|
||||
};
|
||||
|
||||
export function LibraryClient() {
|
||||
@@ -75,7 +76,7 @@ export function LibraryClient() {
|
||||
<AlbumModal
|
||||
open
|
||||
onClose={() => setOpen(null)}
|
||||
album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType, artistMbid: open.artistMbid }}
|
||||
album={{ rgMbid: open.rgMbid, album: open.album, artist: open.artist, year: open.year, type: open.primaryType, artistMbid: open.artistMbid, trackNames: open.trackNames }}
|
||||
status={<span className="chip done">In library · {open.format}</span>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user