Homelabtoolkit v2
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user