"""Music tag maintenance — the metadata sibling to :mod:`music_covers`. Where ``music_covers`` renames folders/files and fetches sidecar art, this module rewrites the *tags inside* audio files. Three jobs, all honouring ``dry_run``: * **Genres** — look the album up on MusicBrainz and write a single canonical genre to every track, so the library's genre facet stays consistent. * **Junk** — strip comment / encoder / URL / embedded-lyrics frames that rippers and stores leave behind. * **Track numbers** — normalize the ``tracknumber`` / ``discnumber`` tag value (drop the ``/total`` suffix, zero-pad to two digits). Format-aware: MP3 (ID3), FLAC/Ogg (Vorbis comments) and M4A (MP4 atoms) each get their own handlers, dispatched on the loaded tag object. Like ``music_covers``, everything is synchronous (filesystem + blocking HTTP) — call from FastAPI via ``asyncio.to_thread`` or the streaming generator below. """ from __future__ import annotations import logging import queue import re import threading import time from dataclasses import dataclass from pathlib import Path from typing import Iterator from mutagen import File as MutagenFile, MutagenError from mutagen.id3 import ID3 from mutagen.mp4 import MP4Tags from . import db from .music_covers import ( AUDIO_EXTENSIONS, MUSIC_ROOT, DEFAULT_RECENT_WINDOW_SECONDS, LogCallback, _Recorder, _iter_album_folders, _was_created_recently, clean_track_number, get_album_metadata_from_files, ) logger = logging.getLogger("homelabtoolkit.music_metadata") try: # Optional: only needed for the online genre lookups. import musicbrainzngs _HAS_MUSICBRAINZ = True except Exception: # pragma: no cover - optional dependency _HAS_MUSICBRAINZ = False # ── options ─────────────────────────────────────────────────────────────────── @dataclass class MetadataOptions: genres: bool = False strip_junk: bool = False normalize_tracks: bool = False dry_run: bool = True recent_only: bool = False recent_window_seconds: int = DEFAULT_RECENT_WINDOW_SECONDS @classmethod def from_dict(cls, data: dict) -> "MetadataOptions": try: window = int(data.get("recent_window_seconds") or DEFAULT_RECENT_WINDOW_SECONDS) except (TypeError, ValueError): window = DEFAULT_RECENT_WINDOW_SECONDS return cls( genres=bool(data.get("genres", False)), strip_junk=bool(data.get("strip_junk", False)), normalize_tracks=bool(data.get("normalize_tracks", False)), dry_run=bool(data.get("dry_run", True)), recent_only=bool(data.get("recent_only", False)), recent_window_seconds=window, ) @property def any_mode(self) -> bool: return self.genres or self.strip_junk or self.normalize_tracks # ── genre canonicalization ──────────────────────────────────────────────────── # MusicBrainz genres are lowercase; map the messy/spaced forms to a clean label. _GENRE_CANON = { "hip hop": "Hip-Hop", "hip-hop": "Hip-Hop", "rnb": "R&B", "r and b": "R&B", "rhythm and blues": "R&B", "drum and bass": "Drum & Bass", "dnb": "Drum & Bass", "edm": "EDM", "idm": "IDM", "uk garage": "UK Garage", } _ACRONYMS = {"edm", "idm", "uk", "us", "dj"} def canonical_genre(name: str | None) -> str | None: if not name: return None key = name.strip().lower() if not key: return None if key in _GENRE_CANON: return _GENRE_CANON[key] words = [w.upper() if w in _ACRONYMS else w.capitalize() for w in re.split(r"\s+", key)] return " ".join(words) def find_album_genre(artist: str, album: str) -> str | None: """Return a single canonical genre for an album via MusicBrainz, or None. Prefers the release-group's curated genres (highest vote count); falls back to its folksonomy tags, then to the artist's genres. """ if not _HAS_MUSICBRAINZ or not (artist and album): return None try: result = musicbrainzngs.search_release_groups(artist=artist, releasegroup=album, limit=3) groups = result.get("release-group-list", []) if not groups: return None rgid = groups[0]["id"] genre = _genre_from_release_group(rgid) if genre: return genre artist_credit = groups[0].get("artist-credit") or [] for credit in artist_credit: mbid = (credit.get("artist") or {}).get("id") if isinstance(credit, dict) else None if mbid: genre = _genre_from_artist(mbid) if genre: return genre except Exception as exc: # network / parse / lookup failure — never fatal logger.debug("Genre lookup failed for %s - %s: %s", artist, album, exc) return None return None def find_artist_genre(artist: str) -> str | None: """Return a single canonical genre for an *artist* via MusicBrainz, or None. Resolved once per artist so every album by that artist gets the same genre (consistency over per-album accuracy — e.g. all A Perfect Circle albums land on one genre rather than a mix of Alternative Rock / Alternative Metal). """ if not _HAS_MUSICBRAINZ or not artist: return None try: result = musicbrainzngs.search_artists(artist=artist, limit=3) matches = result.get("artist-list", []) if not matches: return None return _genre_from_artist(matches[0]["id"]) except Exception as exc: # network / parse / lookup failure — never fatal logger.debug("Artist genre lookup failed for %s: %s", artist, exc) return None # ── manual genre overrides (user-editable, persisted) ───────────────────────── def _artist_key(artist: str) -> str: return (artist or "").strip().lower() def list_genre_overrides() -> list[dict]: with db.connect() as conn: rows = conn.execute( "SELECT artist, genre, updated_at FROM genre_overrides ORDER BY artist COLLATE NOCASE" ).fetchall() return [dict(r) for r in rows] def set_genre_override(artist: str, genre: str) -> dict: artist = (artist or "").strip() genre = (genre or "").strip() if not artist or not genre: raise ValueError("Both artist and genre are required.") with db.connect() as conn: conn.execute( "INSERT INTO genre_overrides(artist_key, artist, genre, updated_at) VALUES(?,?,?,?) " "ON CONFLICT(artist_key) DO UPDATE SET artist=excluded.artist, genre=excluded.genre, " "updated_at=excluded.updated_at", (_artist_key(artist), artist, genre, db.now_iso()), ) return {"artist": artist, "genre": genre} def delete_genre_override(artist: str) -> None: with db.connect() as conn: conn.execute("DELETE FROM genre_overrides WHERE artist_key=?", (_artist_key(artist),)) def _overrides_map() -> dict[str, str]: return {_artist_key(o["artist"]): o["genre"] for o in list_genre_overrides()} def _best_genre(entries: list[dict] | None) -> str | None: if not entries: return None best = max(entries, key=lambda g: int(g.get("count") or 0)) return canonical_genre(best.get("name")) def _genre_from_release_group(rgid: str) -> str | None: try: detail = musicbrainzngs.get_release_group_by_id(rgid, includes=["genres"]) except Exception: try: detail = musicbrainzngs.get_release_group_by_id(rgid, includes=["tags"]) except Exception: return None rg = detail.get("release-group") or {} return _best_genre(rg.get("genre-list")) or _best_genre(rg.get("tag-list")) def _genre_from_artist(mbid: str) -> str | None: try: detail = musicbrainzngs.get_artist_by_id(mbid, includes=["genres"]) except Exception: try: detail = musicbrainzngs.get_artist_by_id(mbid, includes=["tags"]) except Exception: return None artist = detail.get("artist") or {} return _best_genre(artist.get("genre-list")) or _best_genre(artist.get("tag-list")) # ── junk frame classification ───────────────────────────────────────────────── # ID3 (MP3): match by 4-char frame id prefix. TXXX/PRIV are handled specially. _ID3_JUNK_PREFIXES = ( "COMM", # comments "USLT", "SYLT", # embedded lyrics "WXXX", "WCOM", "WCOP", "WOAF", "WOAR", "WOAS", "WORS", "WPAY", "WPUB", # URLs "TENC", "TSSE", # encoded-by / encoder settings ) # TXXX descriptions worth dropping (store/encoder junk). ReplayGain is preserved. _ID3_TXXX_JUNK = ("itun", "cddb", "purchase", "comment", "encoder", "encoded by", "www", "url") # Vorbis (FLAC/Ogg): lowercase comment keys. _VORBIS_JUNK_KEYS = { "comment", "comments", "description", "lyrics", "unsyncedlyrics", "unsynced lyrics", "encoder", "encodedby", "encoded_by", "encoder_options", "encoding", "tool", } _VORBIS_JUNK_SUBSTRINGS = ("url", "www", "purchase", "itun", "cddb") # MP4 (M4A): atom keys. _MP4_JUNK_KEYS = { "\xa9cmt", # comment "\xa9lyr", # lyrics "\xa9too", "tool", # encoder "purd", # purchase date } # Embedded artwork is NEVER stripped. These are belt-and-braces guards so no # current or future junk rule can ever match a picture frame/atom/comment. _ID3_PROTECTED = ("APIC", "PIC") # ID3v2.3/2.4 and ID3v2.2 attached pictures _MP4_PROTECTED = {"covr"} # MP4 cover atom _VORBIS_PROTECTED = {"metadata_block_picture", "coverart", "cover art"} # FLAC/Ogg art def _clean_label(text: str) -> str: """A short, printable tag name for logs. ID3 ``PRIV``/``COMM``/``TXXX`` dict keys embed the frame's raw payload (which can be arbitrary binary — Traktor blobs, embedded art, etc.). Strip anything non-printable and truncate so the activity log stays readable. """ text = "".join(ch for ch in str(text) if ch.isprintable()).strip() return (text[:45] + "…") if len(text) > 46 else text def _id3_frame_label(key: str, frame) -> str: """Human-readable id for an ID3 frame, never including its binary data.""" fid = getattr(frame, "FrameID", None) or key.split(":", 1)[0] if fid == "PRIV": owner = getattr(frame, "owner", "") or "" return _clean_label(f"PRIV:{owner}" if owner else "PRIV") if fid in ("TXXX", "COMM", "USLT", "SYLT", "WXXX"): desc = getattr(frame, "desc", "") or "" return _clean_label(f"{fid}:{desc}" if desc else fid) return _clean_label(fid) def _strip_id3_junk(tags: ID3) -> list[str]: removed: list[str] = [] for key in list(tags.keys()): if key.startswith(_ID3_PROTECTED): continue # never touch embedded artwork (APIC/PIC) drop = key.startswith(_ID3_JUNK_PREFIXES) if not drop and key.startswith("TXXX:"): desc = key.split(":", 1)[1].lower() drop = any(token in desc for token in _ID3_TXXX_JUNK) if not drop and key.startswith("PRIV"): drop = True # Windows Media / player breadcrumbs if drop: removed.append(_id3_frame_label(key, tags[key])) del tags[key] return removed def _strip_vorbis_junk(tags) -> list[str]: removed: list[str] = [] for key in list(tags.keys()): low = key.lower() if low in _VORBIS_PROTECTED: continue # never touch embedded artwork if low in _VORBIS_JUNK_KEYS or any(token in low for token in _VORBIS_JUNK_SUBSTRINGS): del tags[key] removed.append(_clean_label(key)) return removed def _strip_mp4_junk(tags: MP4Tags) -> list[str]: removed: list[str] = [] for key in list(tags.keys()): if key in _MP4_PROTECTED: continue # never touch the cover atom drop = key in _MP4_JUNK_KEYS if not drop and key.startswith("----"): low = key.lower() drop = any(token in low for token in ("itun", "purchase", "url", "www", "comment")) if drop: del tags[key] removed.append(_clean_label(key)) return removed # ── track-number normalization ──────────────────────────────────────────────── def _normalize_id3_tracks(tags: ID3) -> list[str]: changes: list[str] = [] for frame_id, label in (("TRCK", "track"), ("TPOS", "disc")): frame = tags.get(frame_id) if frame is None: continue current = str(frame.text[0]) if frame.text else "" normalized = clean_track_number(current) if normalized and normalized != current: frame.text = [normalized] prefix = "" if label == "track" else f"{label} " changes.append(f"{prefix}{current} → {normalized}") return changes def _normalize_vorbis_tracks(tags) -> list[str]: changes: list[str] = [] for key, label in (("tracknumber", "track"), ("discnumber", "disc")): values = tags.get(key) if not values: continue current = str(values[0]) normalized = clean_track_number(current) if normalized and normalized != current: tags[key] = [normalized] prefix = "" if label == "track" else f"{label} " changes.append(f"{prefix}{current} → {normalized}") return changes # ── per-file processing ─────────────────────────────────────────────────────── def _set_genre(audio, tags, genre: str) -> bool: """Write ``genre`` across formats. Returns True if it changed.""" if isinstance(tags, ID3): from mutagen.id3 import TCON existing = tags.get("TCON") if existing is not None and existing.text == [genre]: return False tags.setall("TCON", [TCON(encoding=3, text=[genre])]) return True if isinstance(tags, MP4Tags): if tags.get("\xa9gen") == [genre]: return False tags["\xa9gen"] = [genre] return True # Vorbis comment (FLAC/Ogg) if list(tags.get("genre", [])) == [genre]: return False tags["genre"] = [genre] return True def _process_file( path: Path, opts: MetadataOptions, genre: str | None, rec: _Recorder, *, group: str, subgroup: str, ) -> None: try: audio = MutagenFile(path) except (MutagenError, OSError) as exc: rec.emit("warn", "tag", f"Could not read {path.name}: {exc}") return if audio is None: return if audio.tags is None: try: audio.add_tags() except (MutagenError, Exception): rec.emit("skip", "tag", f"No tags and none could be added: {path.name}") return tags = audio.tags removed: list[str] = [] track_changes: list[str] = [] genre_set = False if opts.strip_junk: if isinstance(tags, ID3): removed = _strip_id3_junk(tags) elif isinstance(tags, MP4Tags): removed = _strip_mp4_junk(tags) else: removed = _strip_vorbis_junk(tags) if opts.normalize_tracks: if isinstance(tags, ID3): track_changes = _normalize_id3_tracks(tags) elif isinstance(tags, MP4Tags): track_changes = [] # MP4 stores track as an integer tuple; nothing to pad else: track_changes = _normalize_vorbis_tracks(tags) if opts.genres and genre and _set_genre(audio, tags, genre): genre_set = True if not (removed or track_changes or genre_set): return # Human-readable summary (also used by the collapsible raw log). parts: list[str] = [] if removed: parts.append(f"strip {len(removed)} junk tag(s): {', '.join(removed[:6])}") if track_changes: parts.append("; ".join(track_changes)) if genre_set: parts.append(f"genre → {genre}") rec.emit( "dry" if opts.dry_run else "ok", "tag", f"{path.name}: " + " | ".join(parts), path=str(path), file=path.name, group=group, subgroup=subgroup, junk=removed, track="; ".join(track_changes) or None, genre=genre if genre_set else None, ) if opts.dry_run: return try: audio.save() except (MutagenError, OSError) as exc: rec.emit("warn", "tag", f"Could not save {path.name}: {exc}") def _process_album( album_folder: Path, opts: MetadataOptions, rec: _Recorder, genre_cache: dict[str, str | None], overrides: dict[str, str], ) -> None: audio_files = [ f for f in album_folder.iterdir() if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS ] if not audio_files: return group = album_folder.name subgroup = album_folder.parent.name genre = None if opts.genres: artist, album, _year = get_album_metadata_from_files(album_folder) if artist and album: group = album subgroup = artist if artist: genre = _resolve_artist_genre(artist, album, genre_cache, overrides, rec) for file in audio_files: _process_file(file, opts, genre, rec, group=group, subgroup=subgroup) def _resolve_artist_genre( artist: str, album: str | None, cache: dict[str, str | None], overrides: dict[str, str], rec: _Recorder, ) -> str | None: """One genre per artist, resolved once and cached for the whole run. Priority: a user override (always wins, no lookup) → the artist's MusicBrainz genre → one album lookup as fallback. The result is cached under the artist so every later album by the same artist reuses it and the library stays consistent. """ key = artist.strip().lower() if key in cache: return cache[key] override = overrides.get(key) if override: cache[key] = override rec.emit("info", "genre", f"{artist} → {override} (override)") return override genre = find_artist_genre(artist) if not genre and album: genre = find_album_genre(artist, album) cache[key] = genre if genre: rec.emit("info", "genre", f"{artist} → {genre}") else: rec.emit("skip", "genre", f"No MusicBrainz genre: {artist}") return genre def process_library( options: MetadataOptions, *, root: Path | None = None, log: LogCallback | None = None, album_paths: list[str] | None = None, ) -> dict: """Run the selected tag-maintenance modes. Honours ``options.dry_run``.""" root = root or MUSIC_ROOT rec = _Recorder(sink=log) if not options.any_mode: rec.emit("warn", "start", "No metadata modes selected.") return {"root": str(root), "dry_run": options.dry_run, "actions": rec.actions, "counts": rec.counts} if not root.exists(): rec.emit("warn", "root", f"Music root does not exist: {root}") return {"root": str(root), "dry_run": options.dry_run, "actions": rec.actions, "counts": rec.counts} if album_paths: wanted = {str(Path(p)) for p in album_paths} folders = [Path(p) for p in album_paths] if all(Path(p).exists() for p in album_paths) else [ f for f in _iter_album_folders(root) if str(f) in wanted ] else: folders = list(_iter_album_folders(root)) if options.recent_only and not album_paths: before = len(folders) folders = [f for f in folders if _was_created_recently(f, options.recent_window_seconds)] rec.emit( "info", "recent", f"Recent-only: {len(folders)} of {before} albums modified in the last " f"{options.recent_window_seconds // 3600}h", ) if options.genres and not _HAS_MUSICBRAINZ: rec.emit("warn", "genre", "MusicBrainz library not available — genre lookups skipped.") rec.emit( "info", "start", f"{'Dry run' if options.dry_run else 'Applying'} tag maintenance across {len(folders)} album(s)", ) genre_cache: dict[str, str | None] = {} # one genre per artist, for the whole run overrides = _overrides_map() if options.genres else {} for album_folder in folders: try: _process_album(album_folder, options, rec, genre_cache, overrides) except OSError as exc: rec.emit("warn", "album", f"Error processing {album_folder.name}: {exc}") rec.emit("info", "done", "Finished") return { "root": str(root), "dry_run": options.dry_run, "actions": rec.actions, "counts": rec.counts, } def process_library_stream( options: MetadataOptions, *, root: Path | None = None, album_paths: list[str] | None = None, ) -> Iterator[dict]: """Run tag maintenance and yield each action the moment it happens.""" events: queue.Queue = queue.Queue() sentinel = object() def worker(): try: process_library(options, root=root, log=events.put, album_paths=album_paths) except Exception as exc: # pragma: no cover - defensive events.put({"level": "warn", "action": "error", "message": str(exc)}) finally: events.put(sentinel) threading.Thread(target=worker, name="music_metadata_stream", daemon=True).start() while True: item = events.get() if item is sentinel: break yield item