summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src')
-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
2 files changed, 127 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"},