Files

641 lines
22 KiB
Python
Raw Permalink Normal View History

2026-06-08 00:01:55 +12:00
"""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
2026-06-08 21:58:16 +12:00
import logging
2026-06-08 00:01:55 +12:00
import os
2026-06-08 21:58:16 +12:00
import queue
2026-06-08 00:01:55 +12:00
import re
2026-06-08 21:58:16 +12:00
import threading
2026-06-08 00:01:55 +12:00
import time
from dataclasses import dataclass, field
from pathlib import Path
2026-06-08 21:58:16 +12:00
from typing import Callable, Iterator
2026-06-08 00:01:55 +12:00
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")
2026-06-08 21:58:16 +12:00
# 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)
2026-06-08 00:01:55 +12:00
_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]
2026-06-08 21:58:16 +12:00
DEFAULT_RECENT_WINDOW_SECONDS = 2 * 60 * 60
2026-06-08 00:01:55 +12:00
@dataclass
class ProcessOptions:
folder_cleanup: bool = False
rename: bool = False
file_cleanup: bool = False
lyrics: bool = False
covers: bool = True
dry_run: bool = True
2026-06-08 21:58:16 +12:00
recent_only: bool = False
recent_window_seconds: int = DEFAULT_RECENT_WINDOW_SECONDS
2026-06-08 00:01:55 +12:00
@classmethod
def from_dict(cls, data: dict) -> "ProcessOptions":
2026-06-08 21:58:16 +12:00
try:
window = int(data.get("recent_window_seconds") or DEFAULT_RECENT_WINDOW_SECONDS)
except (TypeError, ValueError):
window = DEFAULT_RECENT_WINDOW_SECONDS
2026-06-08 00:01:55 +12:00
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)),
2026-06-08 21:58:16 +12:00
recent_only=bool(data.get("recent_only", False)),
recent_window_seconds=window,
2026-06-08 00:01:55 +12:00
)
@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,
}
2026-06-08 21:58:16 +12:00
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
2026-06-08 00:01:55 +12:00
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))
2026-06-08 21:58:16 +12:00
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",
)
2026-06-08 00:01:55 +12:00
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,
}
2026-06-08 21:58:16 +12:00
# ── 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