Homelabtoolkit v1
This commit is contained in:
+777
@@ -0,0 +1,777 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user