diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-24 20:31:04 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-24 20:31:04 +0200 |
| commit | 6c56877675958e40ae526e0965266ab68aae44f2 (patch) | |
| tree | faa097bfeb0a20614481ffd071fb2470c2f4eb91 /packages/meshbay-node/tests | |
| parent | ed97f138cfc0aee5ac04e4d12b9e392277cbcc40 (diff) | |
| download | meshbay-6c56877675958e40ae526e0965266ab68aae44f2.tar.gz | |
feat(node): recognize WMA and Musepack as audio, read their real tag keys
A real-library scan turned up 250 .wma and 23 .mpc files that the indexer
was silently classifying as "other" — genuinely lost from the Music app,
not a consolidation-rule artifact (checked separately: the grouping logic
itself drops nothing). Both are now indexed as audio and tagged properly:
- WMA has no mutagen "easy" wrapper, so the generic tag reader was reading
nothing from it at all. Reads the real ASF keys directly instead
(Title/Author/WM-AlbumTitle/WM-TrackNumber), confirmed against a real
sample file before writing the mapping.
- Musepack's format auto-detection is unreliable enough (misidentified a
real .mpc as MP3 in spot checks) that it now always opens by its own
class instead of guessing from content.
- Filters out another placeholder value found along the way: a French
ripping tool's auto-generated "Album inconnu (<timestamp>)".
Neither format decodes natively in a browser's <audio> element, so this
gets them correctly visible, tagged, and covered — not yet playable
in-browser. That would need server-side transcoding, deliberately left
out of this change.
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_enrich_audio.py | 123 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_indexer.py | 11 |
2 files changed, 134 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_enrich_audio.py b/packages/meshbay-node/tests/test_enrich_audio.py index 0fd8e17..5d52317 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, + _musepack_tags_from_object, _split_top_level_folder, ) from meshbay_node.indexer.title_parse import parse_track_filename, strip_track_prefix @@ -138,6 +139,13 @@ def test_clean_tag_keeps_various_artists_as_a_real_credit(): assert _clean_tag("Various Artists") == "Various Artists" +def test_clean_tag_filters_a_french_ripper_unknown_album_placeholder(): + """Seen on a real WMA file: a French tool's auto-generated placeholder, + date stamp included — not a real album title.""" + assert _clean_tag("Album inconnu (20/11/2003 14:00:03)") is None + assert _clean_tag("Inconnu") is None + + def test_clean_tag_keeps_a_real_value(): # Non-ASCII must not be mistaken for a placeholder pattern. assert _clean_tag("Ünïqùé Ärtïst") == "Ünïqùé Ärtïst" @@ -293,3 +301,118 @@ def test_extract_cover_returns_none_when_no_apic_frame(tmp_path): mf = MutagenFile(str(clip)) assert _extract_cover(mf) is None + + +# ── Musepack (.mpc): APEv2 tag mapping, tested against a stub object ──────── +# +# mutagen has no Musepack encoder (only a decoder/tag-reader), and no +# command-line encoder was available to generate one either — there is no +# way to produce a real, valid .mpc fixture here. `_musepack_tags_from_object` +# is deliberately split out from the file-open call so the mapping itself +# can still be exercised directly, against a minimal stand-in shaped like +# mutagen's real Musepack object (an APEv2-like `.tags.get(key)` returning a +# list of values, `.info.length`). + +class _StubInfo: + def __init__(self, length): + self.length = length + + +class _StubMpcFile: + def __init__(self, tags, length=180.0): + self.tags = tags + self.info = _StubInfo(length) + + +def test_musepack_tags_from_object_reads_apev2_keys(): + stub = _StubMpcFile({ + "Title": ["A Track"], "Artist": ["Some Artist"], + "Album": ["Some Album"], "Track": ["4"], + }) + tags, duration = _musepack_tags_from_object(stub) + assert tags == {"title": "A Track", "artist": "Some Artist", + "album": "Some Album", "track_no": 4} + assert duration == 180.0 + + +def test_musepack_tags_from_object_filters_a_placeholder_artist(): + stub = _StubMpcFile({"Artist": ["Unknown Artist"]}) + tags, _ = _musepack_tags_from_object(stub) + assert "artist" not in tags + + +def test_musepack_tags_from_object_handles_no_tags_at_all(): + stub = _StubMpcFile(None) + tags, duration = _musepack_tags_from_object(stub) + assert tags == {} + assert duration == 180.0 + + +# ── WMA: real fixtures via ffmpeg's wmav2 encoder + asf muxer ─────────────── +# +# Unlike Musepack, ffmpeg can actually produce a real, decodable .wma file, +# so this path gets genuine end-to-end coverage instead of a stub. Key +# mapping confirmed directly against both a real library sample and one of +# these fixtures before being written: `Author` (not "artist"), +# `WM/AlbumTitle`, `WM/TrackNumber` — none of them what the generic "easy" +# interface (used for every other format here) would look for. + +def _make_wma_clip(path: Path, *, title=None, artist=None, album=None, track=None) -> None: + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "sine=frequency=440:duration=1", + "-c:a", "wmav2", "-b:a", "64k", + *(["-metadata", f"title={title}"] if title else []), + *(["-metadata", f"artist={artist}"] if artist else []), + *(["-metadata", f"album={album}"] if album else []), + *(["-metadata", f"track={track}"] if track else []), + str(path)], + check=True, capture_output=True, + ) + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_enricher_reads_wma_tags_via_the_asf_key_mapping(tmp_path, media_cache): + clip = tmp_path / "wrong title.wma" + _make_wma_clip(clip, title="Real Title", artist="Real Artist", album="Real Album", track=3) + entry = IndexEntry(id="fileid5", name=clip.name, path=clip.name, + size=clip.stat().st_size, type="audio", added_at=0) + + enricher = AudioEnricher(media_cache) + done = asyncio.get_event_loop().create_future() + + async def on_done(file_id, fields): + done.set_result((file_id, fields)) + + enricher.spawn(entry, clip, on_done) + _, fields = await asyncio.wait_for(done, timeout=30) + + assert fields["display_title"] == "Real Title" + assert fields["artist"] == "Real Artist" + assert fields["album"] == "Real Album" + assert fields["track_no"] == 3 + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_enricher_falls_back_to_folder_for_an_untagged_wma(tmp_path, media_cache): + folder = tmp_path / "Folder Artist" / "Folder Album" + folder.mkdir(parents=True) + clip = folder / "A Track.wma" + _make_wma_clip(clip) # no metadata tags at all + entry = IndexEntry(id="fileid6", name=clip.name, + path=str(clip.relative_to(tmp_path)), + size=clip.stat().st_size, type="audio", added_at=0) + + enricher = AudioEnricher(media_cache) + done = asyncio.get_event_loop().create_future() + + async def on_done(file_id, fields): + done.set_result((file_id, fields)) + + enricher.spawn(entry, clip, on_done, tmp_path) + _, fields = await asyncio.wait_for(done, timeout=30) + + assert fields["artist"] == "Folder Artist" + assert fields["album"] == "Folder Album" diff --git a/packages/meshbay-node/tests/test_indexer.py b/packages/meshbay-node/tests/test_indexer.py index 3eac39e..56571a4 100644 --- a/packages/meshbay-node/tests/test_indexer.py +++ b/packages/meshbay-node/tests/test_indexer.py @@ -146,6 +146,17 @@ async def test_type_detection(shared_dir, sk_node, gek): assert by_name["manual.pdf"] == "document" +def test_type_detection_covers_wma_and_musepack(): + """ + Both formats are real audio a real library can contain, even though + neither one plays natively in a browser <audio> element — that gap is + a separate, later concern (transcoding); being correctly typed and + tagged is not the same thing as being playable. + """ + assert indexer_mod._detect_type(Path("track.wma")) == "audio" + assert indexer_mod._detect_type(Path("track.mpc")) == "audio" + + @pytest.mark.asyncio async def test_hidden_files_excluded(tmp_path, sk_node, gek): d = tmp_path / "dir" |