Homelabtoolkit v2

This commit is contained in:
2026-06-08 21:58:16 +12:00
parent 040fbacc70
commit 3c77066beb
75 changed files with 16945 additions and 374 deletions
+628
View File
@@ -0,0 +1,628 @@
"""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