Files

778 lines
22 KiB
Python
Raw Permalink Normal View History

2026-06-08 00:01:55 +12:00
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()