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()