summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 20:31:04 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 20:31:04 +0200
commit6c56877675958e40ae526e0965266ab68aae44f2 (patch)
treefaa097bfeb0a20614481ffd071fb2470c2f4eb91 /packages/meshbay-node/tests
parented97f138cfc0aee5ac04e4d12b9e392277cbcc40 (diff)
downloadmeshbay-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.py123
-rw-r--r--packages/meshbay-node/tests/test_indexer.py11
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"