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:
Jonathan
2026-07-14 12:55:54 +02:00
parent 746d61b264
commit dcf0bb0696
10 changed files with 89 additions and 12 deletions
+26 -1
View File
@@ -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) => (