778 lines
22 KiB
Python
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()
|