Files
embycovers/services/music_covers.py
T
2026-06-08 00:01:55 +12:00

534 lines
18 KiB
Python

"""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 os
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
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")
_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]
@dataclass
class ProcessOptions:
folder_cleanup: bool = False
rename: bool = False
file_cleanup: bool = False
lyrics: bool = False
covers: bool = True
dry_run: bool = True
@classmethod
def from_dict(cls, data: dict) -> "ProcessOptions":
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)),
)
@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 _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))
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,
}