diff options
3 files changed, 99 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js index ae482ad..dd354d9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js @@ -86,6 +86,46 @@ function groupMusicEntries(entries, audioRoot) { const byTitle = (a, b) => (a.display_title || a.name).localeCompare(b.display_title || b.name); tracks.sort(byTitle); + // A various-artists compilation (a real film/game soundtrack is the + // common shape: dozens of genuinely different per-track artists sharing + // one correctly-tagged album name, confirmed against a real ~20-track + // soundtrack rip with no separate "album artist" tag at all -- this era + // of rip never wrote one). Grouping by artist first, as above, can never + // recognize this: every track lands alone in its own artist's bucket as + // a one-track "album", each one then folded below into that artist's own + // singleton pile -- the same release rendered as a wall of disconnected + // one-track cards under a dozen different artist headings instead of one. + // Detected the only way the data actually supports here (no album-artist + // tag survived): the same album key reappears under two or more + // genuinely different artist keys. Pulled out and merged *before* the + // per-artist singleton folding below, so those tracks never reach it + // under their original artist bucket. + const albumKeyArtists = new Map(); // albumKey -> Set(artistKey) + for (const [artistKey, bucket] of byArtistKey) { + for (const albumKey of bucket.albumsByKey.keys()) { + if (!albumKeyArtists.has(albumKey)) albumKeyArtists.set(albumKey, new Set()); + albumKeyArtists.get(albumKey).add(artistKey); + } + } + const compilations = []; + for (const [albumKey, artistKeys] of albumKeyArtists) { + if (artistKeys.size < 2) continue; + const compTracks = []; + let albumDisplay = null; + for (const artistKey of artistKeys) { + const bucket = byArtistKey.get(artistKey); + const album = bucket.albumsByKey.get(albumKey); + if (albumDisplay == null) albumDisplay = album.album; + compTracks.push(...album.tracks); + bucket.albumsByKey.delete(albumKey); + } + compTracks.sort((a, b) => (trackNo(a) - trackNo(b)) || byTitle(a, b)); + compilations.push({ + artist: t('music.various'), album: albumDisplay, isUnknown: false, tracks: compTracks, + }); + } + compilations.sort((a, b) => a.album.localeCompare(b.album)); + const artists = [...byArtistKey.values()].map(({ artist, albumsByKey, loose }) => { const sortedAlbums = [...albumsByKey.values()].sort((a, b) => a.album.localeCompare(b.album)); @@ -123,7 +163,10 @@ function groupMusicEntries(entries, audioRoot) { }); } return { artist, albums: realAlbums }; - }).sort((a, b) => a.artist.localeCompare(b.artist)); + }).filter((a) => a.albums.length > 0); + + if (compilations.length > 0) artists.push({ artist: t('music.various'), albums: compilations }); + artists.sort((a, b) => a.artist.localeCompare(b.artist)); const albums = artists.flatMap((a) => a.albums); return { tracks, artists, albums }; diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py index a93df51..4fa08ef 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py @@ -141,6 +141,18 @@ def _extract_cover(mf) -> bytes | None: _COVER_STEM_RANK = ("cover", "folder", "front", "albumart") _COVER_EXTS = (".jpg", ".jpeg", ".png") +# Windows Media Player's per-folder thumbnail cache: one `AlbumArt_{guid}_*` +# pair per *release* it ever cached art for in that folder, keyed by a GUID +# tied to that release — never to a track. Real folder, real GUIDs (module +# docstring's revision note): a ~500-track flat "chart ranking" rip mixing +# dozens of unrelated artists carried seven distinct guids here, each an art +# leftover from one different original album. `Folder.jpg`/`AlbumArtSmall.jpg` +# are WMP's own copy of just *one* arbitrary one of those for the folder icon +# — fine when a folder really is one release (one guid, or none), meaningless +# once two or more show up: there is no way to tell which track it belongs to, +# so no candidate in the folder can be trusted as "the" cover for any of them. +_WMP_ALBUMART_GUID_RE = re.compile(r"^albumart_\{([0-9a-f-]{36})\}_(large|small)$", re.IGNORECASE) + def _find_sibling_cover(folder: Path) -> Path | None: try: @@ -151,6 +163,11 @@ def _find_sibling_cover(folder: Path) -> Path | None: if not candidates: return None + guids = {m.group(1).lower() for p in candidates + if (m := _WMP_ALBUMART_GUID_RE.match(p.stem))} + if len(guids) >= 2: + return None + def rank(p: Path) -> int: stem = p.stem.lower() for i, name in enumerate(_COVER_STEM_RANK): diff --git a/packages/meshbay-node/tests/test_enrich_audio.py b/packages/meshbay-node/tests/test_enrich_audio.py index 1485a1f..d69a097 100644 --- a/packages/meshbay-node/tests/test_enrich_audio.py +++ b/packages/meshbay-node/tests/test_enrich_audio.py @@ -12,6 +12,7 @@ from meshbay_node.indexer.enrich_audio import ( _artist_album_from_ancestors, _clean_tag, _extract_cover, + _find_sibling_cover, _musepack_tags_from_object, _split_top_level_folder, ) @@ -292,6 +293,43 @@ async def test_enricher_uses_a_sibling_cover_file_when_no_embedded_art(tmp_path, assert stored == b"\xff\xd8\xff\xe0fake-jpeg-bytes" +def test_find_sibling_cover_refuses_a_folder_that_mixes_several_releases(tmp_path): + """ + A real case: dozens of individually-ripped tracks by unrelated artists + sitting flat in one folder (an informal "chart ranking" compilation, + never a real per-album folder). The ripper left one `AlbumArt_{guid}_*` + thumbnail pair *per original release* it pulled a track from, plus its + own arbitrary `Folder.jpg` copy of just one of them. Two or more distinct + guids among the candidates is direct proof this folder is not a single + release, so no image here can be safely called "the" cover — picking + `Folder.jpg` anyway (the pre-fix behavior) silently attached one + unrelated release's art to every other track in the folder. + """ + folder = tmp_path / "_SomeChartFolder" + folder.mkdir() + (folder / "AlbumArt_{2C5E5D27-1075-43B9-92EE-351846034C67}_Large.jpg").write_bytes(b"one") + (folder / "AlbumArt_{2C5E5D27-1075-43B9-92EE-351846034C67}_Small.jpg").write_bytes(b"one") + (folder / "AlbumArt_{427CA566-E7E9-4ECD-9ED1-1DDADB9D07E8}_Large.jpg").write_bytes(b"two") + (folder / "AlbumArt_{427CA566-E7E9-4ECD-9ED1-1DDADB9D07E8}_Small.jpg").write_bytes(b"two") + (folder / "AlbumArtSmall.jpg").write_bytes(b"whichever") + (folder / "Folder.jpg").write_bytes(b"whichever") + + assert _find_sibling_cover(folder) is None + + +def test_find_sibling_cover_still_picks_folder_jpg_for_a_single_release(tmp_path): + """Regression guard: a real single-release folder (one guid, or none at + all, same shape as the existing sibling-cover test above) must keep + picking its Folder.jpg exactly as before this fix.""" + folder = tmp_path / "Some Artist" / "Some Album" + folder.mkdir(parents=True) + (folder / "AlbumArt_{2C5E5D27-1075-43B9-92EE-351846034C67}_Large.jpg").write_bytes(b"one") + (folder / "AlbumArt_{2C5E5D27-1075-43B9-92EE-351846034C67}_Small.jpg").write_bytes(b"one") + (folder / "Folder.jpg").write_bytes(b"one") + + assert _find_sibling_cover(folder) == folder / "Folder.jpg" + + async def _run(enricher, entry, path, root_path=None): done = asyncio.get_event_loop().create_future() |