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

778 lines
22 KiB
Python

from pathlib import Path
import itertools
import shutil
import sys
import re
import threading
import time
import requests
import musicbrainzngs
from mutagen import File, MutagenError
MUSIC_ROOT = Path(r"\\Matt-htpc\d\Music")
DRY_RUN = False # keep True first. Set False only after checking output.
ENABLE_LYRICS = False
ENABLE_FOLDER_CLEANUP = True
ENABLE_RENAME = True
ENABLE_FILE_CLEANUP = True
RECENT_FOLDERS_ONLY = False
RECENT_FOLDER_WINDOW_SECONDS = 2 * 60 * 60
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*$")
musicbrainzngs.set_useragent(
"NavidromeCoverDownloader",
"1.0",
"your-email@example.com"
)
class UI:
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
WHITE = "\033[37m"
ORANGE = "\033[38;5;208m"
MUTED = "\033[38;5;244m"
BG = "\033[48;5;236m"
enabled = sys.stdout.isatty()
def enable_terminal_colors():
if not UI.enabled:
return
if sys.platform != "win32":
return
try:
import ctypes
kernel32 = ctypes.windll.kernel32
handle = kernel32.GetStdHandle(-11)
mode = ctypes.c_uint()
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
kernel32.SetConsoleMode(handle, mode.value | 0x0004)
except Exception:
UI.enabled = False
def style(text: str, *codes: str) -> str:
if not UI.enabled:
return text
return "".join(codes) + text + UI.RESET
def line(char: str = "-") -> str:
width = shutil.get_terminal_size((88, 20)).columns
return style(char * min(width, 88), UI.MUTED)
def print_banner():
print()
print(style("Music Covers", UI.BOLD, UI.ORANGE))
print(style("Clean albums, rename tracks, fetch lyrics, and fill missing covers.", UI.DIM))
print(line())
def info(message: str):
print(f"{style('>', UI.CYAN)} {message}")
def success(message: str):
print(f"{style('OK', UI.GREEN, UI.BOLD)} {message}")
def warn(message: str):
print(f"{style('WARN', UI.YELLOW, UI.BOLD)} {message}")
def skip(message: str):
print(f"{style('SKIP', UI.MUTED, UI.BOLD)} {message}")
def action(label: str, message: str):
print(f"{style(label, UI.ORANGE, UI.BOLD)} {message}")
class Spinner:
def __init__(self, message: str):
self.message = message
self.done = threading.Event()
self.thread = threading.Thread(target=self._spin, daemon=True)
def __enter__(self):
if UI.enabled:
self.thread.start()
else:
info(self.message)
return self
def __exit__(self, exc_type, exc, tb):
if not UI.enabled:
return
self.done.set()
self.thread.join()
sys.stdout.write("\r" + " " * shutil.get_terminal_size((88, 20)).columns + "\r")
sys.stdout.flush()
def _spin(self):
for frame in itertools.cycle("-\\|/"):
if self.done.is_set():
break
sys.stdout.write(f"\r{style(frame, UI.ORANGE)} {style(self.message, UI.DIM)}")
sys.stdout.flush()
time.sleep(0.08)
def get_key() -> str:
if sys.platform == "win32":
import msvcrt
key = msvcrt.getch()
if key in (b"\x00", b"\xe0"):
key = msvcrt.getch()
return key.decode(errors="ignore")
import termios
import tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
key = sys.stdin.read(1)
if key == "\x1b":
key += sys.stdin.read(2)
return key
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def choose_modes():
global ENABLE_LYRICS, ENABLE_FOLDER_CLEANUP, ENABLE_RENAME, ENABLE_FILE_CLEANUP, RECENT_FOLDERS_ONLY
options = [
{
"label": "Lyric mode",
"description": "Fetch missing .lrc or .txt sidecar lyrics",
"enabled": ENABLE_LYRICS,
},
{
"label": "Folder cleanup mode",
"description": "Normalize album folder names to 'YEAR - Album'",
"enabled": ENABLE_FOLDER_CLEANUP,
},
{
"label": "Rename mode",
"description": "Rename audio files to 'NN - Title.mp3'",
"enabled": ENABLE_RENAME,
},
{
"label": "File cleanup mode",
"description": "Remove files except audio, cover art, and lyric sidecars",
"enabled": ENABLE_FILE_CLEANUP,
},
{
"label": "Recent folders only",
"description": "Process album folders created in the last 2 hours",
"enabled": RECENT_FOLDERS_ONLY,
},
]
if not sys.stdin.isatty():
return
if not UI.enabled:
print("Select modes. Press Enter to keep defaults, or type numbers to toggle, e.g. 1 3.")
for index, option in enumerate(options, 1):
marker = "x" if option["enabled"] else " "
print(f"[{marker}] {index}. {option['label']} - {option['description']}")
answer = input("> ").strip()
for token in answer.replace(",", " ").split():
if token.isdigit() and 1 <= int(token) <= len(options):
options[int(token) - 1]["enabled"] = not options[int(token) - 1]["enabled"]
else:
selected = 0
instructions = "Space toggles Up/Down moves Enter starts"
while True:
sys.stdout.write("\033[?25l")
sys.stdout.write("\033[H\033[J")
print_banner()
print(style("Startup Modes", UI.BOLD, UI.WHITE))
print(style(instructions, UI.DIM))
print()
for index, option in enumerate(options):
pointer = style(">", UI.ORANGE, UI.BOLD) if index == selected else " "
checkbox = style("[x]", UI.GREEN, UI.BOLD) if option["enabled"] else style("[ ]", UI.MUTED)
label_color = UI.WHITE if index == selected else UI.RESET
print(f"{pointer} {checkbox} {style(option['label'], UI.BOLD, label_color)}")
print(f" {style(option['description'], UI.DIM)}")
key = get_key()
if key in ("\r", "\n"):
break
if key in (" ",):
options[selected]["enabled"] = not options[selected]["enabled"]
elif key in ("H", "\x1b[A"):
selected = (selected - 1) % len(options)
elif key in ("P", "\x1b[B"):
selected = (selected + 1) % len(options)
sys.stdout.write("\033[?25h")
sys.stdout.write("\033[H\033[J")
ENABLE_LYRICS = options[0]["enabled"]
ENABLE_FOLDER_CLEANUP = options[1]["enabled"]
ENABLE_RENAME = options[2]["enabled"]
ENABLE_FILE_CLEANUP = options[3]["enabled"]
RECENT_FOLDERS_ONLY = options[4]["enabled"]
print_banner()
enabled_modes = ", ".join(option["label"] for option in options if option["enabled"]) or "none"
info(f"Enabled modes: {enabled_modes}")
print(line())
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)
if match:
return match.group(1)
return 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()
# handles "1/12", "01/12", "1"
text = text.split("/")[0].strip()
if not text.isdigit():
return None
return text.zfill(2)
def get_first_tag(audio, names):
for name in names:
value = audio.get(name)
if value:
return str(value[0]).strip()
return None
def load_audio_metadata(path: Path):
try:
audio = File(path, easy=True)
except (MutagenError, OSError) as e:
warn(f"could not read metadata ({type(e).__name__}): {path}")
return None
if audio is None:
skip(f"unsupported audio metadata: {path}")
return audio
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))
if match:
return match.group(1)
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_metadata(file)
if audio is None:
continue
artist = get_first_tag(audio, ["albumartist", "artist"])
album = get_first_tag(audio, ["album"])
year = extract_year(get_first_tag(audio, ["date", "originaldate", "year"]))
if artist or album or year:
return artist, album, year
return None, None, None
def find_album_year(artist: str, album: str) -> str | 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 as e:
warn(f"Error finding album year: {e}")
return None
def is_top_level_music_folder(folder: Path) -> bool:
try:
return folder.resolve().parent == MUSIC_ROOT.resolve()
except OSError:
return folder.parent == MUSIC_ROOT
def ensure_album_folder_name(album_folder: Path, artist: str, album: str, year: str | None) -> Path:
if not album or not year:
return album_folder
if is_top_level_music_folder(album_folder):
skip(f"refusing to rename top-level folder as album: {album_folder}")
return album_folder
new_name = f"{year} - {safe_filename(album)}"
new_folder = album_folder.with_name(new_name)
if album_folder.name == new_name:
return album_folder
if new_folder.exists():
skip(f"folder rename target exists: {new_folder}")
return album_folder
action("FOLDER", f"{artist}")
print(f" {style('From', UI.DIM)} {album_folder.name}")
print(f" {style('To', UI.DIM)} {new_name}")
if DRY_RUN:
return album_folder
album_folder.rename(new_folder)
return new_folder
def rename_audio_file(path: Path):
if path.suffix.lower() not in AUDIO_EXTENSIONS:
return path
audio = load_audio_metadata(path)
if audio is None:
return path
artist = get_first_tag(audio, ["artist", "albumartist"])
album = get_first_tag(audio, ["album"])
title = get_first_tag(audio, ["title"])
track = clean_track_number(audio.get("tracknumber"))
if not artist or not album or not title or not track:
skip(f"missing metadata: {path}")
return path
# only rename files already inside Artist\Album structure
album_folder = path.parent
artist_folder = album_folder.parent
if not artist_folder.exists() or not album_folder.exists():
return path
new_name = f"{track} - {safe_filename(title)}{path.suffix.lower()}"
new_path = path.with_name(new_name)
if path.name == new_name:
return path
if new_path.exists():
skip(f"target exists: {new_path}")
return path
action("RENAME", path.name)
print(f" {style('To', UI.DIM)} {new_name}")
if not DRY_RUN:
path.rename(new_path)
return new_path
return path
def get_album_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 should_keep_album_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 clean_album_files(album_folder: Path):
cover_to_keep = get_album_cover_to_keep(album_folder)
for file in album_folder.iterdir():
if not file.is_file() or should_keep_album_file(file, cover_to_keep):
continue
action("REMOVE", str(file))
if DRY_RUN:
warn(f"DRY RUN: would remove {file}")
continue
try:
file.unlink()
success(f"Removed {file}")
except OSError as e:
warn(f"could not remove {file}: {e}")
def get_track_metadata(path: Path):
audio = load_audio_metadata(path)
if audio is None:
return None, None, None, None
artist = get_first_tag(audio, ["artist", "albumartist"])
album = get_first_tag(audio, ["album"])
title = get_first_tag(audio, ["title"])
duration = None
info = getattr(audio, "info", None)
if info and info.length:
duration = round(info.length)
return artist, album, title, duration
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 cover_lookup_previously_failed(album_folder: Path, artist: str, album: str) -> bool:
marker = album_folder / COVER_MISSING_MARKER
if not marker.exists():
return False
try:
return marker.read_text(encoding="utf-8").strip() == f"{artist}\n{album}"
except OSError:
return False
def mark_cover_lookup_failed(album_folder: Path, artist: str, album: str):
if DRY_RUN:
return
marker = album_folder / COVER_MISSING_MARKER
marker.write_text(f"{artist}\n{album}", encoding="utf-8")
def lyrics_lookup_key(artist: str, album: str, title: str, duration: int | None) -> str:
duration_text = str(duration) if duration else ""
return f"{artist}\t{album}\t{title}\t{duration_text}"
def get_failed_lyrics_lookups(album_folder: Path) -> set[str]:
marker = album_folder / LYRICS_MISSING_MARKER
if not marker.exists():
return set()
try:
return {
line.strip()
for line in marker.read_text(encoding="utf-8").splitlines()
if line.strip()
}
except OSError:
return set()
def mark_lyrics_lookup_failed(album_folder: Path, key: str):
if DRY_RUN:
return
marker = album_folder / LYRICS_MISSING_MARKER
failed = get_failed_lyrics_lookups(album_folder)
failed.add(key)
marker.write_text("\n".join(sorted(failed)) + "\n", encoding="utf-8")
def has_lyrics(audio_file: Path) -> bool:
return any(
audio_file.with_suffix(extension).exists()
for extension in LYRICS_SIDECAR_EXTENSIONS
)
def find_album_cover(artist: str, album: str):
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 as e:
warn(f"Error finding cover: {e}")
return None
def find_track_lyrics(artist: str, album: str, title: str, duration: int | None):
if not duration:
skip("lyrics lookup missing 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": "NavidromeCoverDownloader/1.0 (local music library script)"
},
timeout=20,
)
if response.status_code == 404:
return None
if response.status_code != 200:
warn(f"Lyrics lookup failed: HTTP {response.status_code}")
return None
data = response.json()
synced_lyrics = data.get("syncedLyrics")
plain_lyrics = data.get("plainLyrics")
if synced_lyrics:
return ".lrc", synced_lyrics.strip() + "\n"
if plain_lyrics:
return ".txt", plain_lyrics.strip() + "\n"
except Exception as e:
warn(f"Error finding lyrics: {e}")
return None
def download_lyrics_for_track(audio_file: Path, album_artist: str, album_name: str):
if has_lyrics(audio_file):
return
track_artist, track_album, title, duration = get_track_metadata(audio_file)
artist = track_artist or album_artist
album = track_album or album_name
if not artist or not album or not title:
skip(f"lyrics missing metadata: {audio_file}")
return
key = lyrics_lookup_key(artist, album, title, duration)
failed = get_failed_lyrics_lookups(audio_file.parent)
if key in failed:
skip(f"lyrics lookup already failed: {artist} - {title}")
return
action("LYRICS", f"{artist} - {title}")
with Spinner("Searching LRCLIB"):
lyrics = find_track_lyrics(artist, album, title, duration)
if not lyrics:
skip("no lyrics found")
mark_lyrics_lookup_failed(audio_file.parent, key)
return
extension, text = lyrics
output_file = audio_file.with_suffix(extension)
if DRY_RUN:
warn(f"DRY RUN: would save {output_file}")
else:
output_file.write_text(text, encoding="utf-8")
success(f"Saved {output_file}")
time.sleep(1)
def process_album_folder(album_folder: Path):
print()
action("ALBUM", str(album_folder))
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 ENABLE_FOLDER_CLEANUP and not year and artist and album:
with Spinner("Finding album year"):
year = find_album_year(artist, album)
if ENABLE_FOLDER_CLEANUP:
album_folder = ensure_album_folder_name(album_folder, artist, album, year)
audio_files = []
for file in album_folder.iterdir():
if file.is_file() and file.suffix.lower() in AUDIO_EXTENSIONS:
if ENABLE_RENAME:
audio_files.append(rename_audio_file(file))
else:
audio_files.append(file)
if ENABLE_LYRICS:
for file in audio_files:
download_lyrics_for_track(file, artist, album)
if has_cover(album_folder):
pass
elif cover_lookup_previously_failed(album_folder, artist, album):
skip(f"cover lookup already failed: {artist} - {album}")
else:
action("COVER", f"{artist} - {album}")
with Spinner("Searching Cover Art Archive"):
image_data = find_album_cover(artist, album)
if not image_data:
skip("no cover found")
mark_cover_lookup_failed(album_folder, artist, album)
else:
output_file = album_folder / "cover.jpg"
if DRY_RUN:
warn(f"DRY RUN: would save {output_file}")
else:
output_file.write_bytes(image_data)
success(f"Saved {output_file}")
time.sleep(1)
if ENABLE_FILE_CLEANUP:
clean_album_files(album_folder)
def was_created_within_recent_window(folder: Path) -> bool:
try:
created_at = folder.stat().st_ctime
except OSError as e:
warn(f"could not read folder timestamps: {folder} ({e})")
return False
age_seconds = time.time() - created_at
return 0 <= age_seconds <= RECENT_FOLDER_WINDOW_SECONDS
def main():
enable_terminal_colors()
choose_modes()
info(f"MUSIC_ROOT: {MUSIC_ROOT}")
info(f"Exists: {MUSIC_ROOT.exists()}")
info(f"Is folder: {MUSIC_ROOT.is_dir()}")
if not MUSIC_ROOT.exists():
warn("Music root does not exist.")
return
if any(p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS for p in MUSIC_ROOT.iterdir()):
warn("Music root contains audio files directly; skipping root-level album processing.")
for first_level_folder in MUSIC_ROOT.iterdir():
if not first_level_folder.is_dir():
continue
if any(p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS for p in first_level_folder.iterdir()):
warn(f"Skipping top-level folder with audio files: {first_level_folder}")
continue
# Process only Artist\Album folders. Album folders should never be created
# or renamed directly under MUSIC_ROOT.
for album_folder in first_level_folder.iterdir():
if not album_folder.is_dir():
continue
if RECENT_FOLDERS_ONLY and not was_created_within_recent_window(album_folder):
skip(f"outside 2-hour creation window: {album_folder}")
continue
process_album_folder(album_folder)
if __name__ == "__main__":
main()