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 b060ee4..b8bcf43 100644 Binary files a/web/src/app/api/library/route.ts and b/web/src/app/api/library/route.ts differ diff --git a/web/src/app/design.css b/web/src/app/design.css index a35fbf8..2583061 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -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; } diff --git a/web/src/app/library/library-client.tsx b/web/src/app/library/library-client.tsx index 0da32b4..3e4e662 100644 --- a/web/src/app/library/library-client.tsx +++ b/web/src/app/library/library-client.tsx @@ -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() { 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)) == []