diff options
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py | 135 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/indexer.py | 7 |
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"}, |