diff options
Diffstat (limited to 'packages/meshbay-node')
4 files changed, 261 insertions, 15 deletions
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 5225d4f..a15da6f 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py @@ -43,6 +43,20 @@ real data before writing the fix: filename/folder fallback that would have done better. `_clean_tag` below turns a known placeholder back into "absent" before anything else sees it. + +WMA and Musepack read their tags outside mutagen's generic "easy" interface +(neither format has an Easy* wrapper — `MutagenFile(path, easy=True)` just +hands back the raw tag object, whose keys are format-specific and don't +match the generic title/artist/album/tracknumber names the easy interface +normally exposes elsewhere). Confirmed against real files before writing +the fix: WMA's real keys are `Title`/`Author`/`WM/AlbumTitle`/ +`WM/TrackNumber`, not "artist"/"album"; Musepack's are plain APEv2 keys +(`Title`/`Artist`/`Album`/`Track`). Musepack also can't rely on mutagen's +format auto-detection (`MutagenFile(path)` with no explicit class) — it +misidentified a real .mpc file as MP3 often enough in a spot-check that the +extension is used to pick the class directly instead. Neither format has a +convenient embedded-cover path here, so both fall through to the sibling +image-file fallback for cover art, same as everything else. """ import asyncio @@ -54,6 +68,8 @@ from pathlib import Path import blake3 from meshbay_common.protocol import IndexEntry from mutagen import File as MutagenFile +from mutagen.asf import ASF +from mutagen.musepack import Musepack from meshbay_node.indexer import title_parse from meshbay_node.media_cache import MediaCache @@ -73,9 +89,13 @@ _TRACK_NO_RE = re.compile(r"\d+") # placeholder. Not exhaustive by construction; each entry here was seen in # a real file, not guessed. "Various Artists" is deliberately *not* here — # that one is a real, meaningful compilation credit worth keeping as its -# own bucket, not a placeholder. +# own bucket, not a placeholder. "Album inconnu (<date>)"/standalone +# "Inconnu" is a French ripping tool's own auto-generated placeholder, seen +# on real WMA files — same idea as "Unknown Artist", different language. _PLACEHOLDER_RE = re.compile( - r"^(no\s*artist|unknown(\s+artist)?|nouvel(le)?\s+artiste\s*\(\d+\)|" + r"^(no\s*artist|unknown(\s+artist)?|inconnu(e)?|" + r"album\s+inconnu\s*\([^)]*\)|" + r"nouvel(le)?\s+artiste\s*\(\d+\)|" r"nouveau\s+titre\s*\(\d+\)|track\s*\d*|<unknown>)$", re.IGNORECASE, ) @@ -141,15 +161,81 @@ def _find_sibling_cover(folder: Path) -> Path | None: return candidates[0] -def _read_tags_and_cover(path: Path) -> tuple[dict, float | None, bytes | None]: +def _first_tag_value(values) -> str | None: + return str(values[0]) if values else None + + +def _asf_tags_from_object(asf_file: ASF) -> tuple[dict, float | None]: """ - Synchronous — always called via asyncio.to_thread. Returns a partial - `tags` dict (only keys actually found and not a known placeholder: - title/artist/album/track_no), duration in seconds (None if unreadable), - and raw cover bytes (None if absent, embedded and sibling-file both - checked). Never raises for an unreadable/corrupt file — the caller - falls back to filename parsing entirely in that case. + Pure mapping from an already-opened ASF object into our normalized + dict — split out from the file-open call so the mapping itself can be + exercised directly. Real ASF key names, confirmed against a real + library sample and a freshly-encoded fixture alike: `Title`, `Author` + (not "Artist"), `WM/AlbumTitle`, `WM/TrackNumber`. + """ + tags: dict = {} + duration = getattr(asf_file.info, "length", None) if asf_file.info is not None else None + if asf_file.tags is not None: + title = _clean_tag(_first_tag_value(asf_file.tags.get("Title"))) + if title: + tags["title"] = title + artist = _clean_tag(_first_tag_value(asf_file.tags.get("Author"))) + if artist: + tags["artist"] = artist + album = _clean_tag(_first_tag_value(asf_file.tags.get("WM/AlbumTitle"))) + if album: + tags["album"] = album + track_raw = _first_tag_value(asf_file.tags.get("WM/TrackNumber")) + if track_raw: + m = _TRACK_NO_RE.match(track_raw) + if m: + tags["track_no"] = int(m.group()) + return tags, duration + + +def _musepack_tags_from_object(mpc_file: Musepack) -> tuple[dict, float | None]: """ + Pure mapping from an already-opened Musepack object's APEv2 tags into + our normalized dict — split out the same way as `_asf_tags_from_object` + (and for the same testability reason: there is no encoder available + here to produce a real .mpc fixture from tags alone, mutagen included). + """ + tags: dict = {} + duration = getattr(mpc_file.info, "length", None) if mpc_file.info is not None else None + if mpc_file.tags is not None: + for field, key in (("title", "Title"), ("artist", "Artist"), ("album", "Album")): + cleaned = _clean_tag(_first_tag_value(mpc_file.tags.get(key))) + if cleaned: + tags[field] = cleaned + track_raw = _first_tag_value(mpc_file.tags.get("Track")) + if track_raw: + m = _TRACK_NO_RE.match(track_raw) + if m: + tags["track_no"] = int(m.group()) + return tags, duration + + +def _read_format_specific_tags(path: Path) -> tuple[dict, float | None] | None: + """ + Returns None when the generic "easy" interface below already covers + this extension — only WMA and Musepack need a bypass (docstring at the + top of this module). + """ + suffix = path.suffix.lower() + if suffix == ".wma": + try: + return _asf_tags_from_object(ASF(str(path))) + except Exception: + return {}, None + if suffix == ".mpc": + try: + return _musepack_tags_from_object(Musepack(str(path))) + except Exception: + return {}, None + return None + + +def _read_generic_tags_and_cover(path: Path) -> tuple[dict, float | None, bytes | None]: tags: dict = {} duration: float | None = None try: @@ -169,11 +255,6 @@ def _read_tags_and_cover(path: Path) -> tuple[dict, float | None, bytes | None]: m = _TRACK_NO_RE.match(str(track_raw[0])) if m: tags["track_no"] = int(m.group()) - if "title" in tags: - # Some taggers copy the bare filename into `title` verbatim, track - # number included — a tag normally wins over the filename parse, so - # that pollution would otherwise beat a cleaner one (docstring above). - tags["title"] = title_parse.strip_track_prefix(tags["title"]) or tags["title"] cover: bytes | None = None try: @@ -185,6 +266,32 @@ def _read_tags_and_cover(path: Path) -> tuple[dict, float | None, bytes | None]: cover = _extract_cover(raw) except Exception: cover = None + + return tags, duration, cover + + +def _read_tags_and_cover(path: Path) -> tuple[dict, float | None, bytes | None]: + """ + Synchronous — always called via asyncio.to_thread. Returns a partial + `tags` dict (only keys actually found and not a known placeholder: + title/artist/album/track_no), duration in seconds (None if unreadable), + and raw cover bytes (None if absent, embedded and sibling-file both + checked). Never raises for an unreadable/corrupt file — the caller + falls back to filename parsing entirely in that case. + """ + format_specific = _read_format_specific_tags(path) + if format_specific is not None: + tags, duration = format_specific + cover = None # neither WMA nor Musepack has a convenient embedded-cover path here + else: + tags, duration, cover = _read_generic_tags_and_cover(path) + + if "title" in tags: + # Some taggers copy the bare filename into `title` verbatim, track + # number included — a tag normally wins over the filename parse, so + # that pollution would otherwise beat a cleaner one (docstring above). + tags["title"] = title_parse.strip_track_prefix(tags["title"]) or tags["title"] + if cover is None: sibling = _find_sibling_cover(path.parent) if sibling is not None: diff --git a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py index b643046..3ca34f6 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/indexer.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/indexer.py @@ -49,7 +49,12 @@ EXCLUDED_SUFFIXES = (".tmp", ".part", ".crdownload", ".download") MEDIA_EXTENSIONS = { "video": {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"}, - "audio": {".mp3", ".flac", ".ogg", ".wav", ".aac", ".m4a", ".opus"}, + # .wma and .mpc are real audio, tagged the same as anything else here + # (enrich_audio.py reads both), but neither one has native decode + # support in any mainstream browser's <audio> element — they show up, + # get metadata, and fail to play in-browser until/unless server-side + # transcoding is added (same gap video already has for HEVC). + "audio": {".mp3", ".flac", ".ogg", ".wav", ".aac", ".m4a", ".opus", ".wma", ".mpc"}, "image": {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".tiff"}, "document": {".pdf", ".epub", ".mobi", ".txt", ".md", ".docx", ".odt"}, "archive": {".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar"}, 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" |