From dcf0bb06969c09520adf8ba10732a8ecc737d672 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 14 Jul 2026 12:55:54 +0200 Subject: [PATCH] feat: show real on-disk tracklist in the Library album modal (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../migration.sql | 2 ++ web/prisma/schema.prisma | 5 ++++ web/src/app/_ui/album-modal.tsx | 27 +++++++++++++++++- web/src/app/api/library/route.ts | Bin 1204 -> 1239 bytes web/src/app/design.css | 4 +++ web/src/app/library/library-client.tsx | 3 +- worker/lyra_worker/library.py | 14 +++++++++ worker/lyra_worker/pipeline.py | 11 +++---- worker/lyra_worker/scan.py | 15 ++++++---- worker/tests/test_list_audio_files.py | 20 +++++++++++++ 10 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 web/prisma/migrations/20260714104932_add_library_track_names/migration.sql create mode 100644 worker/tests/test_list_audio_files.py diff --git a/web/prisma/migrations/20260714104932_add_library_track_names/migration.sql b/web/prisma/migrations/20260714104932_add_library_track_names/migration.sql new file mode 100644 index 0000000..4ca309f --- /dev/null +++ b/web/prisma/migrations/20260714104932_add_library_track_names/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LibraryItem" ADD COLUMN "trackNames" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index cbc6bdf..e17461a 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -82,6 +82,11 @@ model LibraryItem { format String qualityClass Int importedAt DateTime @default(now()) + // The album's on-disk audio filenames ("## Title.ext"), captured at import + scan, so the + // Library modal can show the REAL tracks on disk (revealing incomplete/edition mismatches) + // instead of MusicBrainz's canonical listing. Empty for items imported before this existed + // (re-scan to populate). + trackNames String[] @default([]) @@unique([artist, album]) } diff --git a/web/src/app/_ui/album-modal.tsx b/web/src/app/_ui/album-modal.tsx index 266ac9a..e14e332 100644 --- a/web/src/app/_ui/album-modal.tsx +++ b/web/src/app/_ui/album-modal.tsx @@ -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("loading"); + const onDisk = (album.trackNames?.length ?? 0) > 0; // null = unknown/loading; only meaningful when album.artistMbid is set. const [followed, setFollowed] = useState(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({
+ {Array.isArray(tracks) ? ( +

+ {onDisk ? `On disk · ${tracks.length} tracks` : "MusicBrainz tracklist"} +

+ ) : null} {tracks === "loading" ?

Loading tracks…

: null} - {tracks === "error" ?

No tracklist available.

: null} + {tracks === "error" ? ( +

No tracklist available.

+ ) : null} {Array.isArray(tracks) ? (
    {tracks.map((t) => ( diff --git a/web/src/app/api/library/route.ts b/web/src/app/api/library/route.ts index b060ee47d295bae2d426763886f3714810d31e73..b8bcf4373a3b789212abfd5e6d8509c821ebf6ea 100644 GIT binary patch delta 39 mcmdnOd7X2^LKbNSg_5Gg 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={In library · {open.format}} /> ) : null} diff --git a/worker/lyra_worker/library.py b/worker/lyra_worker/library.py index de0efbb..b699984 100644 --- a/worker/lyra_worker/library.py +++ b/worker/lyra_worker/library.py @@ -26,6 +26,20 @@ _COVER_STEMS = {"cover", "folder", "front"} _COVER_EXT = {".jpg", ".jpeg", ".png"} +def list_audio_files(folder: str) -> list[str]: + """The album's on-disk audio filenames (basenames, sorted) — captured into + LibraryItem.trackNames so the UI can show the real tracks on disk. Top-level only + (albums are one flat folder here); returns [] if the folder is missing/unreadable.""" + try: + names = [ + n for n in os.listdir(folder) + if os.path.isfile(os.path.join(folder, n)) and os.path.splitext(n)[1].lower() in _AUDIO_EXT + ] + except OSError: + return [] + return sorted(names) + + def staging_dir(staging_root: str, job_id: str) -> str: """A per-job download staging directory under `staging_root`. The staging root may be a volume separate from the library (e.g. local disk while the library is an SMB share): diff --git a/worker/lyra_worker/pipeline.py b/worker/lyra_worker/pipeline.py index 8e853fb..0527eae 100644 --- a/worker/lyra_worker/pipeline.py +++ b/worker/lyra_worker/pipeline.py @@ -9,7 +9,7 @@ import psycopg from lyra_worker.adapters.base import SourceAdapter from lyra_worker.confidence import score_confidence -from lyra_worker.library import album_dir, import_album, staging_dir +from lyra_worker.library import album_dir, import_album, list_audio_files, staging_dir from lyra_worker.quality import quality_class from lyra_worker.ranker import rank_candidates from lyra_worker.types import Candidate, MBTarget @@ -186,18 +186,19 @@ def _mark_chosen(conn: psycopg.Connection, job_id: str, source_ref: str) -> None def _import(conn: psycopg.Connection, job_id: str, target: MBTarget, winner: Candidate, path: str) -> None: request_id = _request_id(conn, job_id) + track_names = list_audio_files(path) # the real files just imported into the album folder with conn.cursor() as cur: cur.execute( 'INSERT INTO "LibraryItem" (id, "requestId", artist, album, path, source, ' - 'format, "qualityClass", "importedAt") ' - "VALUES (gen_random_uuid()::text, %s, %s, %s, %s, %s, %s, %s, now()) " + 'format, "qualityClass", "trackNames", "importedAt") ' + "VALUES (gen_random_uuid()::text, %s, %s, %s, %s, %s, %s, %s, %s, now()) " 'ON CONFLICT (artist, album) DO UPDATE SET ' ' "requestId" = EXCLUDED."requestId", path = EXCLUDED.path, source = EXCLUDED.source, ' ' format = EXCLUDED.format, "qualityClass" = EXCLUDED."qualityClass", ' - ' "importedAt" = now() ' + ' "trackNames" = EXCLUDED."trackNames", "importedAt" = now() ' 'WHERE EXCLUDED."qualityClass" > "LibraryItem"."qualityClass"', (request_id, target.artist, target.album, path, winner.source, - winner.quality.fmt, quality_class(winner.quality)), + winner.quality.fmt, quality_class(winner.quality), track_names), ) cur.execute( "UPDATE \"Request\" SET status = 'completed' WHERE id = %s", (request_id,) diff --git a/worker/lyra_worker/scan.py b/worker/lyra_worker/scan.py index 88b8c70..820b7b5 100644 --- a/worker/lyra_worker/scan.py +++ b/worker/lyra_worker/scan.py @@ -5,6 +5,7 @@ from dataclasses import dataclass import psycopg +from lyra_worker.library import list_audio_files from lyra_worker.probe import AudioProbe _AUDIO_EXT = {".flac", ".mp3", ".m4a", ".opus", ".ogg", ".aac", ".wav"} @@ -80,14 +81,18 @@ def _record_owned(conn, target, quality_class: int, fmt: str, path: str, watched ' "watchedArtistId" = COALESCE("MonitoredRelease"."watchedArtistId", EXCLUDED."watchedArtistId")', (watched_id, target.artist_mbid, target.artist, target.rg_mbid, target.album, quality_class), ) + track_names = list_audio_files(path) # capture the real on-disk tracks cur.execute( 'INSERT INTO "LibraryItem" (id, "requestId", artist, album, path, source, format, ' - '"qualityClass", "importedAt") ' - "VALUES (gen_random_uuid()::text, NULL, %s, %s, %s, 'scan', %s, %s, now()) " - 'ON CONFLICT (artist, album) DO NOTHING', - (target.artist, target.album, path, fmt, quality_class), + '"qualityClass", "trackNames", "importedAt") ' + "VALUES (gen_random_uuid()::text, NULL, %s, %s, %s, 'scan', %s, %s, %s, now()) " + # refresh trackNames on re-scan (populates items imported before this column); + # xmax=0 ⇒ this was an INSERT (a genuinely new library item) vs an UPDATE. + 'ON CONFLICT (artist, album) DO UPDATE SET "trackNames" = EXCLUDED."trackNames" ' + "RETURNING (xmax = 0) AS inserted", + (target.artist, target.album, path, fmt, quality_class, track_names), ) - newly = cur.rowcount == 1 # a new LibraryItem means this album wasn't recorded before + newly = cur.fetchone()[0] # a new LibraryItem means this album wasn't recorded before conn.commit() return newly diff --git a/worker/tests/test_list_audio_files.py b/worker/tests/test_list_audio_files.py new file mode 100644 index 0000000..5dc9646 --- /dev/null +++ b/worker/tests/test_list_audio_files.py @@ -0,0 +1,20 @@ +from lyra_worker.library import list_audio_files + + +def test_lists_sorted_audio_basenames_only(tmp_path): + (tmp_path / "02 Two.flac").write_bytes(b"") + (tmp_path / "01 One.flac").write_bytes(b"") + (tmp_path / "10 Ten.mp3").write_bytes(b"") + (tmp_path / "cover.jpg").write_bytes(b"") # non-audio excluded + (tmp_path / "notes.txt").write_bytes(b"") # non-audio excluded + (tmp_path / "Disc 1").mkdir() # a subdir is not a file + + assert list_audio_files(str(tmp_path)) == ["01 One.flac", "02 Two.flac", "10 Ten.mp3"] + + +def test_missing_dir_returns_empty(): + assert list_audio_files("/no/such/album/dir") == [] + + +def test_empty_dir_returns_empty(tmp_path): + assert list_audio_files(str(tmp_path)) == []