"""Music library maintenance, refactored from the original ``music-covers.py`` CLI. The interactive terminal UI is gone; what remains is pure, importable logic the web app drives. Two entry points matter: * :func:`scan_library` — read-only analysis. Walks ``MUSIC_ROOT`` and reports, per album, what each maintenance mode *would* do. Safe to call any time. * :func:`process_library` — performs the work. Honours ``dry_run`` (the web UI default) so nothing is renamed, deleted, or downloaded unless the caller opts in. Both are synchronous (filesystem + blocking HTTP); call them from FastAPI via ``asyncio.to_thread``. Progress is reported through an optional ``log`` callback. """ from __future__ import annotations import logging import os import queue import re import threading import time from dataclasses import dataclass, field from pathlib import Path from typing import Callable, Iterator import requests try: # Optional: only needed for the "find missing year/cover" online lookups. import musicbrainzngs musicbrainzngs.set_useragent("HomelabToolkit", "1.0", "homelab-toolkit@example.com") # musicbrainzngs logs "uncaught attribute"/"uncaught tag" at INFO whenever the # MusicBrainz XML carries fields it doesn't model (e.g. release-group type-id). # Harmless noise — keep only real warnings. logging.getLogger("musicbrainzngs").setLevel(logging.WARNING) _HAS_MUSICBRAINZ = True except Exception: # pragma: no cover - optional dependency _HAS_MUSICBRAINZ = False from mutagen import File as MutagenFile, MutagenError MUSIC_ROOT = Path(os.environ.get("MUSIC_ROOT", r"\\Matt-htpc\d\Music")) COVER_NAME_PRIORITY = ("cover.jpg", "folder.jpg", "front.jpg") COVER_NAMES = set(COVER_NAME_PRIORITY) COVER_MISSING_MARKER = ".cover-not-found" LYRICS_MISSING_MARKER = ".lyrics-not-found" LYRICS_SIDECAR_EXTENSIONS = {".lrc", ".txt"} AUDIO_EXTENSIONS = {".mp3", ".flac", ".m4a"} YEAR_ALBUM_FOLDER_RE = re.compile(r"^\s*(\d{4})\s*-\s*(.+?)\s*$") LogCallback = Callable[[dict], None] DEFAULT_RECENT_WINDOW_SECONDS = 2 * 60 * 60 @dataclass class ProcessOptions: folder_cleanup: bool = False rename: bool = False file_cleanup: bool = False lyrics: bool = False covers: bool = True dry_run: bool = True recent_only: bool = False recent_window_seconds: int = DEFAULT_RECENT_WINDOW_SECONDS @classmethod def from_dict(cls, data: dict) -> "ProcessOptions": try: window = int(data.get("recent_window_seconds") or DEFAULT_RECENT_WINDOW_SECONDS) except (TypeError, ValueError): window = DEFAULT_RECENT_WINDOW_SECONDS return cls( folder_cleanup=bool(data.get("folder_cleanup", False)), rename=bool(data.get("rename", False)), file_cleanup=bool(data.get("file_cleanup", False)), lyrics=bool(data.get("lyrics", False)), covers=bool(data.get("covers", True)), dry_run=bool(data.get("dry_run", True)), recent_only=bool(data.get("recent_only", False)), recent_window_seconds=window, ) @dataclass class _Recorder: """Collects structured action records and forwards them to an optional sink.""" sink: LogCallback | None = None actions: list[dict] = field(default_factory=list) counts: dict[str, int] = field(default_factory=dict) def emit(self, level: str, action: str, message: str, **extra) -> None: record = {"level": level, "action": action, "message": message, **extra} self.actions.append(record) self.counts[action] = self.counts.get(action, 0) + 1 if self.sink: self.sink(record) # ── pure string helpers ────────────────────────────────────────────────────── def clean_name(text: str) -> str: text = re.sub(r"\[(.*?)\]|\((.*?)\)", "", text) text = text.replace("_", " ").replace("-", " ") return " ".join(text.split()).strip() def clean_album_folder_name(text: str) -> str: match = YEAR_ALBUM_FOLDER_RE.match(text) if match: text = match.group(2) return clean_name(text) def get_year_from_album_folder_name(text: str) -> str | None: match = YEAR_ALBUM_FOLDER_RE.match(text) return match.group(1) if match else None def safe_filename(text: str) -> str: text = re.sub(r'[<>:"/\\|?*]', "", text) text = text.strip().rstrip(".") return " ".join(text.split()) def clean_track_number(value) -> str | None: if not value: return None text = str(value[0] if isinstance(value, list) else value).strip() text = text.split("/")[0].strip() return text.zfill(2) if text.isdigit() else None def extract_year(value: str | None) -> str | None: if not value: return None match = re.search(r"\b(19\d{2}|20\d{2})\b", str(value)) return match.group(1) if match else None # ── metadata reading ───────────────────────────────────────────────────────── def _load_audio(path: Path): try: return MutagenFile(path, easy=True) except (MutagenError, OSError): return None def _first_tag(audio, names): for name in names: value = audio.get(name) if value: return str(value[0]).strip() return None def get_album_metadata_from_files(album_folder: Path): for file in album_folder.iterdir(): if not file.is_file() or file.suffix.lower() not in AUDIO_EXTENSIONS: continue audio = _load_audio(file) if audio is None: continue artist = _first_tag(audio, ["albumartist", "artist"]) album = _first_tag(audio, ["album"]) year = extract_year(_first_tag(audio, ["date", "originaldate", "year"])) if artist or album or year: return artist, album, year return None, None, None def get_track_metadata(path: Path): audio = _load_audio(path) if audio is None: return None, None, None, None artist = _first_tag(audio, ["artist", "albumartist"]) album = _first_tag(audio, ["album"]) title = _first_tag(audio, ["title"]) duration = None info = getattr(audio, "info", None) if info and getattr(info, "length", None): duration = round(info.length) return artist, album, title, duration # ── album inspection ───────────────────────────────────────────────────────── def _cover_to_keep(album_folder: Path) -> str | None: existing = {p.name.lower() for p in album_folder.iterdir() if p.is_file()} for name in COVER_NAME_PRIORITY: if name in existing: return name return None def _has_cover(album_folder: Path) -> bool: existing = {p.name.lower() for p in album_folder.iterdir() if p.is_file()} return any(name in existing for name in COVER_NAMES) def _should_keep_file(path: Path, cover_to_keep: str | None) -> bool: name = path.name.lower() suffix = path.suffix.lower() return ( suffix in AUDIO_EXTENSIONS or suffix in LYRICS_SIDECAR_EXTENSIONS or name == cover_to_keep ) def _has_lyrics(audio_file: Path) -> bool: return any( audio_file.with_suffix(extension).exists() for extension in LYRICS_SIDECAR_EXTENSIONS ) def analyze_album(album_folder: Path) -> dict: """Read-only summary of an album folder and the pending maintenance work.""" tag_artist, tag_album, tag_year = get_album_metadata_from_files(album_folder) artist = tag_artist or clean_name(album_folder.parent.name) album = tag_album or clean_album_folder_name(album_folder.name) year = tag_year or get_year_from_album_folder_name(album_folder.name) audio_files = [ f for f in album_folder.iterdir() if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS ] cover_to_keep = _cover_to_keep(album_folder) extra_files = [ f.name for f in album_folder.iterdir() if f.is_file() and not _should_keep_file(f, cover_to_keep) ] suggested_folder = None if album and year: candidate = f"{year} - {safe_filename(album)}" if candidate != album_folder.name: suggested_folder = candidate missing_lyrics = sum(1 for f in audio_files if not _has_lyrics(f)) return { "path": str(album_folder), "folder_name": album_folder.name, "artist": artist or "", "album": album or "", "year": year, "track_count": len(audio_files), "has_cover": _has_cover(album_folder), "suggested_folder": suggested_folder, "needs_folder_rename": suggested_folder is not None, "extra_files": extra_files, "extra_file_count": len(extra_files), "missing_lyrics_count": missing_lyrics, } def _was_created_recently(folder: Path, window_seconds: int) -> bool: """True if the folder was created/modified within the window (recent-only mode).""" try: stat = folder.stat() except OSError: return False age = time.time() - max(stat.st_ctime, stat.st_mtime) return 0 <= age <= window_seconds def _iter_album_folders(root: Path): for first_level in root.iterdir(): if not first_level.is_dir(): continue try: has_audio = any( p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS for p in first_level.iterdir() ) except OSError: continue if has_audio: # Top-level folder holding audio directly is not an Artist/Album tree. continue for album_folder in first_level.iterdir(): if album_folder.is_dir(): yield album_folder def scan_library(root: Path | None = None) -> dict: root = root or MUSIC_ROOT if not root.exists(): return {"root": str(root), "exists": False, "albums": []} albums = [] for album_folder in _iter_album_folders(root): try: albums.append(analyze_album(album_folder)) except OSError: continue albums.sort(key=lambda a: (a["artist"].lower(), a["year"] or "", a["album"].lower())) return { "root": str(root), "exists": True, "album_count": len(albums), "missing_cover_count": sum(1 for a in albums if not a["has_cover"]), "needs_rename_count": sum(1 for a in albums if a["needs_folder_rename"]), "extra_file_count": sum(a["extra_file_count"] for a in albums), "albums": albums, } # ── online lookups (year / cover / lyrics) ─────────────────────────────────── def find_album_year(artist: str, album: str) -> str | None: if not _HAS_MUSICBRAINZ: return None try: result = musicbrainzngs.search_releases(artist=artist, release=album, limit=5) for release in result.get("release-list", []): year = extract_year(release.get("date")) if year: return year except Exception: return None return None def find_album_cover(artist: str, album: str) -> bytes | None: if not _HAS_MUSICBRAINZ: return None try: result = musicbrainzngs.search_releases(artist=artist, release=album, limit=3) releases = result.get("release-list", []) if not releases: return None mbid = releases[0]["id"] url = f"https://coverartarchive.org/release/{mbid}/front-500" response = requests.get(url, timeout=20, allow_redirects=True) if response.status_code == 200 and response.headers.get("content-type", "").startswith("image"): return response.content except Exception: return None return None def find_track_lyrics(artist: str, album: str, title: str, duration: int | None): if not duration: return None try: response = requests.get( "https://lrclib.net/api/get", params={ "artist_name": artist, "track_name": title, "album_name": album, "duration": duration, }, headers={"User-Agent": "HomelabToolkit/1.0 (local music library tool)"}, timeout=20, ) if response.status_code != 200: return None data = response.json() if data.get("syncedLyrics"): return ".lrc", data["syncedLyrics"].strip() + "\n" if data.get("plainLyrics"): return ".txt", data["plainLyrics"].strip() + "\n" except Exception: return None return None # ── mutating operations (respect dry_run) ──────────────────────────────────── def _is_top_level(folder: Path, root: Path) -> bool: try: return folder.resolve().parent == root.resolve() except OSError: return folder.parent == root def _rename_album_folder(album_folder, artist, album, year, root, opts, rec) -> Path: if not album or not year or _is_top_level(album_folder, root): return album_folder new_name = f"{year} - {safe_filename(album)}" if album_folder.name == new_name: return album_folder new_folder = album_folder.with_name(new_name) if new_folder.exists(): rec.emit("skip", "folder", f"Rename target already exists: {new_name}") return album_folder rec.emit( "dry" if opts.dry_run else "ok", "folder", f"{album_folder.name} → {new_name}", path=str(album_folder), ) if opts.dry_run: return album_folder album_folder.rename(new_folder) return new_folder def _rename_track(path: Path, opts, rec) -> Path: audio = _load_audio(path) if audio is None: return path artist = _first_tag(audio, ["artist", "albumartist"]) album = _first_tag(audio, ["album"]) title = _first_tag(audio, ["title"]) track = clean_track_number(audio.get("tracknumber")) if not (artist and album and title and track): return path new_name = f"{track} - {safe_filename(title)}{path.suffix.lower()}" if path.name == new_name: return path new_path = path.with_name(new_name) if new_path.exists(): rec.emit("skip", "rename", f"Target exists: {new_name}") return path rec.emit("dry" if opts.dry_run else "ok", "rename", f"{path.name} → {new_name}") if opts.dry_run: return path path.rename(new_path) return new_path def _clean_files(album_folder: Path, opts, rec) -> None: cover_to_keep = _cover_to_keep(album_folder) for file in album_folder.iterdir(): if not file.is_file() or _should_keep_file(file, cover_to_keep): continue rec.emit("dry" if opts.dry_run else "ok", "remove", f"Remove {file.name}", path=str(file)) if opts.dry_run: continue try: file.unlink() except OSError as exc: rec.emit("warn", "remove", f"Could not remove {file.name}: {exc}") def _fetch_cover(album_folder, artist, album, opts, rec) -> None: if _has_cover(album_folder): return if not (artist and album): return rec.emit("info", "cover", f"Looking up cover: {artist} - {album}") image = find_album_cover(artist, album) if not image: rec.emit("skip", "cover", f"No cover found: {artist} - {album}") return output = album_folder / "cover.jpg" rec.emit("dry" if opts.dry_run else "ok", "cover", f"Save cover.jpg for {album}") if not opts.dry_run: output.write_bytes(image) time.sleep(1) def _fetch_lyrics(audio_file, album_artist, album_name, opts, rec) -> None: if _has_lyrics(audio_file): return t_artist, t_album, title, duration = get_track_metadata(audio_file) artist = t_artist or album_artist album = t_album or album_name if not (artist and album and title): return lyrics = find_track_lyrics(artist, album, title, duration) if not lyrics: rec.emit("skip", "lyrics", f"No lyrics: {artist} - {title}") return extension, text = lyrics output = audio_file.with_suffix(extension) rec.emit("dry" if opts.dry_run else "ok", "lyrics", f"Save lyrics for {title}") if not opts.dry_run: output.write_text(text, encoding="utf-8") time.sleep(1) def _process_album(album_folder: Path, root: Path, opts: ProcessOptions, rec: _Recorder) -> None: tag_artist, tag_album, tag_year = get_album_metadata_from_files(album_folder) artist = tag_artist or clean_name(album_folder.parent.name) album = tag_album or clean_album_folder_name(album_folder.name) year = tag_year or get_year_from_album_folder_name(album_folder.name) if opts.folder_cleanup and not year and artist and album: year = find_album_year(artist, album) if opts.folder_cleanup: album_folder = _rename_album_folder(album_folder, artist, album, year, root, opts, rec) audio_files = [ f for f in album_folder.iterdir() if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS ] if opts.rename: audio_files = [_rename_track(f, opts, rec) for f in audio_files] if opts.lyrics: for file in audio_files: _fetch_lyrics(file, artist, album, opts, rec) if opts.covers: _fetch_cover(album_folder, artist, album, opts, rec) if opts.file_cleanup: _clean_files(album_folder, opts, rec) def process_library( options: ProcessOptions, *, root: Path | None = None, log: LogCallback | None = None, album_paths: list[str] | None = None, ) -> dict: """Run the selected maintenance modes. Honours ``options.dry_run``. ``album_paths`` optionally limits the run to specific album folders. """ root = root or MUSIC_ROOT rec = _Recorder(sink=log) 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", ) rec.emit( "info", "start", f"{'Dry run' if options.dry_run else 'Applying'} across {len(folders)} album(s)", ) for album_folder in folders: try: _process_album(album_folder, root, options, rec) 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, } # ── streaming variants (disk-efficient; yield as work happens) ──────────────── def scan_library_stream(root: Path | None = None) -> Iterator[dict]: """Yield one album analysis at a time so the UI can render incrementally. Memory stays flat (no full list is accumulated) and the slow NAS walk streams results to the caller as each album folder is inspected. """ root = root or MUSIC_ROOT if not root.exists(): yield {"type": "error", "message": f"Music root not found: {root}"} return yield {"type": "start", "root": str(root)} album_count = missing_cover = needs_rename = extra_files = 0 for album_folder in _iter_album_folders(root): try: album = analyze_album(album_folder) except OSError: continue album_count += 1 if not album["has_cover"]: missing_cover += 1 if album["needs_folder_rename"]: needs_rename += 1 extra_files += album["extra_file_count"] yield {"type": "album", "album": album, "scanned": album_count} yield { "type": "summary", "root": str(root), "album_count": album_count, "missing_cover_count": missing_cover, "needs_rename_count": needs_rename, "extra_file_count": extra_files, } def process_library_stream( options: ProcessOptions, *, root: Path | None = None, album_paths: list[str] | None = None, ) -> Iterator[dict]: """Run maintenance and yield each action the moment it happens. ``process_library`` already reports through a ``log`` callback; we bridge that to a queue drained by this generator so the HTTP response streams live. """ 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_process_stream", daemon=True).start() while True: item = events.get() if item is sentinel: break yield item