aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py135
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/indexer.py7
-rw-r--r--packages/meshbay-node/tests/test_enrich_audio.py123
-rw-r--r--packages/meshbay-node/tests/test_indexer.py11
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"