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) => (
|
||||
|
||||
Reference in New Issue
Block a user