UGREEN-NAS-Lyrics-Downloader/app/lyrics_downloader.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""
UGREEN NAS Lyrics Downloader
Copyright (c) 2026 Roman Glos
"""
from __future__ import annotations
import json
import logging
import os
import re
import sys
import time
import unicodedata
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import urljoin
import requests
from mutagen import File as MutagenFile
from mutagen.flac import FLAC
from mutagen.id3 import ID3, ID3NoHeaderError, USLT
from mutagen.mp3 import MP3
from mutagen.mp4 import MP4
from mutagen.oggopus import OggOpus
from mutagen.oggvorbis import OggVorbis
APP_NAME = "UGREEN NAS Lyrics Downloader"
APP_VERSION = "1.0.0"
LRCLIB_BASE_URL = "https://lrclib.net/"
@dataclass
class Config:
music_dir: Path
audio_extensions: set[str]
require_synced_lyrics: bool
write_plain_as_lrc: bool
write_sidecar_lrc: bool
write_embedded_tags: bool
skip_existing_lrc: bool
touch_audio_on_write: bool
dry_run: bool
request_delay_seconds: float
scan_interval_seconds: int
max_files_per_run: int
save_report: bool
report_path: Path
user_agent: str
log_level: str
@dataclass
class TrackInfo:
path: str
title: str
artist: str
album: str
duration: int | None
extension: str
@dataclass
class FileResult:
path: str
status: str
message: str
lrc_path: str | None = None
source_id: int | None = None
synced: bool = False
def env_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
def env_int(name: str, default: int) -> int:
value = os.getenv(name)
if value is None or value.strip() == "":
return default
try:
return int(value.strip())
except ValueError:
return default
def env_float(name: str, default: float) -> float:
value = os.getenv(name)
if value is None or value.strip() == "":
return default
try:
return float(value.strip().replace(",", "."))
except ValueError:
return default
def load_config() -> Config:
extensions = {
ext.strip().lower() if ext.strip().startswith(".") else f".{ext.strip().lower()}"
for ext in os.getenv("AUDIO_EXTENSIONS", ".mp3,.flac,.m4a,.mp4,.ogg,.opus").split(",")
if ext.strip()
}
return Config(
music_dir=Path(os.getenv("MUSIC_DIR", "/music")),
audio_extensions=extensions,
require_synced_lyrics=env_bool("REQUIRE_SYNCED_LYRICS", True),
write_plain_as_lrc=env_bool("WRITE_PLAIN_AS_LRC", False),
write_sidecar_lrc=env_bool("WRITE_SIDECAR_LRC", True),
write_embedded_tags=env_bool("WRITE_EMBEDDED_TAGS", False),
skip_existing_lrc=env_bool("SKIP_EXISTING_LRC", True),
touch_audio_on_write=env_bool("TOUCH_AUDIO_ON_WRITE", True),
dry_run=env_bool("DRY_RUN", False),
request_delay_seconds=env_float("REQUEST_DELAY_SECONDS", 1.0),
scan_interval_seconds=env_int("SCAN_INTERVAL_SECONDS", 0),
max_files_per_run=env_int("MAX_FILES_PER_RUN", 0),
save_report=env_bool("SAVE_REPORT", True),
report_path=Path(os.getenv("REPORT_PATH", "/reports/last_report.json")),
user_agent=os.getenv("USER_AGENT", f"{APP_NAME}/{APP_VERSION}"),
log_level=os.getenv("LOG_LEVEL", "INFO").upper(),
)
def configure_logging(level: str) -> None:
logging.basicConfig(
level=getattr(logging, level, logging.INFO),
format="%(asctime)s %(levelname)s %(message)s",
stream=sys.stdout,
)
def first_text(values: Any) -> str:
if values is None:
return ""
if isinstance(values, (list, tuple)):
for value in values:
text = str(value).strip()
if text:
return text
return ""
return str(values).strip()
def normalize_text(value: str) -> str:
value = unicodedata.normalize("NFKD", value or "")
value = "".join(ch for ch in value if not unicodedata.combining(ch))
value = value.lower()
value = re.sub(r"\([^)]*\)|\[[^]]*]", " ", value)
value = re.sub(r"[^a-z0-9äöüß]+", " ", value)
return re.sub(r"\s+", " ", value).strip()
def clean_filename_title(stem: str) -> tuple[str, str]:
text = re.sub(r"^\s*\d+\s*[.\-_)]\s*", "", stem).strip()
if " - " in text:
artist, title = text.split(" - ", 1)
return title.strip(), artist.strip()
return text, ""
def get_easy_tag(audio: Any, key: str) -> str:
try:
return first_text(audio.tags.get(key)) if audio.tags else ""
except Exception:
return ""
def read_track_info(path: Path) -> TrackInfo | None:
try:
audio = MutagenFile(path, easy=True)
raw_audio = MutagenFile(path, easy=False)
except Exception as exc:
logging.warning("Metadaten konnten nicht gelesen werden: %s (%s)", path, exc)
return None
if audio is None and raw_audio is None:
logging.warning("Keine Audiodatei erkannt: %s", path)
return None
title = get_easy_tag(audio, "title") if audio else ""
artist = get_easy_tag(audio, "artist") if audio else ""
album = get_easy_tag(audio, "album") if audio else ""
# Fallback for MP4 atoms when easy tags are incomplete.
try:
if raw_audio and raw_audio.tags:
title = title or first_text(raw_audio.tags.get("\xa9nam"))
artist = artist or first_text(raw_audio.tags.get("\xa9ART"))
album = album or first_text(raw_audio.tags.get("\xa9alb"))
except Exception:
pass
fallback_title, fallback_artist = clean_filename_title(path.stem)
title = title or fallback_title
artist = artist or fallback_artist
duration = None
try:
if raw_audio and raw_audio.info and getattr(raw_audio.info, "length", None):
duration = int(round(float(raw_audio.info.length)))
except Exception:
duration = None
if not title or not artist:
logging.info("Übersprungen, Titel oder Künstler fehlt: %s", path)
return None
return TrackInfo(
path=str(path),
title=title.strip(),
artist=artist.strip(),
album=album.strip(),
duration=duration,
extension=path.suffix.lower(),
)
def iter_audio_files(music_dir: Path, extensions: set[str]) -> Iterable[Path]:
for path in music_dir.rglob("*"):
if path.is_file() and path.suffix.lower() in extensions:
yield path
def lrclib_get_exact(session: requests.Session, track: TrackInfo) -> dict[str, Any] | None:
params: dict[str, Any] = {
"artist_name": track.artist,
"track_name": track.title,
}
if track.album:
params["album_name"] = track.album
if track.duration:
params["duration"] = track.duration
response = session.get(urljoin(LRCLIB_BASE_URL, "api/get"), params=params, timeout=30)
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
def result_score(track: TrackInfo, item: dict[str, Any]) -> int:
score = 0
if normalize_text(track.title) == normalize_text(str(item.get("trackName", ""))):
score += 50
elif normalize_text(track.title) in normalize_text(str(item.get("trackName", ""))):
score += 20
if normalize_text(track.artist) == normalize_text(str(item.get("artistName", ""))):
score += 40
elif normalize_text(track.artist) in normalize_text(str(item.get("artistName", ""))):
score += 15
if track.album and normalize_text(track.album) == normalize_text(str(item.get("albumName", ""))):
score += 10
try:
result_duration = int(round(float(item.get("duration") or 0)))
if track.duration and result_duration:
delta = abs(track.duration - result_duration)
if delta <= 2:
score += 20
elif delta <= 5:
score += 10
except Exception:
pass
return score
def lrclib_search_fallback(session: requests.Session, track: TrackInfo) -> dict[str, Any] | None:
params = {
"artist_name": track.artist,
"track_name": track.title,
}
response = session.get(urljoin(LRCLIB_BASE_URL, "api/search"), params=params, timeout=30)
if response.status_code == 404:
return None
response.raise_for_status()
results = response.json()
if not isinstance(results, list) or not results:
return None
ranked = sorted(results, key=lambda item: result_score(track, item), reverse=True)
best = ranked[0]
if result_score(track, best) < 60:
return None
return best
def find_lyrics(session: requests.Session, track: TrackInfo) -> dict[str, Any] | None:
try:
exact = lrclib_get_exact(session, track)
if exact:
return exact
except requests.HTTPError as exc:
logging.debug("Exakte LRCLIB-Suche fehlgeschlagen für %s - %s: %s", track.artist, track.title, exc)
except requests.RequestException as exc:
logging.warning("LRCLIB-Anfrage fehlgeschlagen für %s - %s: %s", track.artist, track.title, exc)
return None
try:
return lrclib_search_fallback(session, track)
except requests.RequestException as exc:
logging.warning("LRCLIB-Fallback fehlgeschlagen für %s - %s: %s", track.artist, track.title, exc)
return None
def ensure_lrc_header(content: str, track: TrackInfo) -> str:
content = (content or "").replace("\r\n", "\n").replace("\r", "\n").strip()
header: list[str] = []
existing = content.lower()
if "[ti:" not in existing:
header.append(f"[ti:{track.title}]")
if "[ar:" not in existing:
header.append(f"[ar:{track.artist}]")
if track.album and "[al:" not in existing:
header.append(f"[al:{track.album}]")
if track.duration and "[length:" not in existing:
minutes, seconds = divmod(track.duration, 60)
header.append(f"[length:{minutes:02d}:{seconds:02d}]")
if "[by:" not in existing:
header.append(f"[by:{APP_NAME}]")
if header:
return "\n".join(header) + "\n" + content + "\n"
return content + "\n"
def plain_to_lrc(plain: str, track: TrackInfo) -> str:
lines = [line.strip() for line in (plain or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")]
lines = [line for line in lines if line]
header = [f"[ti:{track.title}]", f"[ar:{track.artist}]", f"[by:{APP_NAME}]"]
if track.album:
header.append(f"[al:{track.album}]")
if not lines:
return "\n".join(header) + "\n"
rendered = [f"[00:00.00]{lines[0]}"]
for line in lines[1:]:
rendered.append(line)
return "\n".join(header + rendered) + "\n"
def write_sidecar_lrc(path: Path, content: str, config: Config) -> Path:
lrc_path = path.with_suffix(".lrc")
if config.dry_run:
logging.info("DRY_RUN: Würde LRC schreiben: %s", lrc_path)
return lrc_path
lrc_path.write_text(content, encoding="utf-8", newline="\n")
return lrc_path
def write_embedded_tags(path: Path, plain_lyrics: str, synced_lyrics: str | None, config: Config) -> None:
if config.dry_run:
logging.info("DRY_RUN: Würde Tags schreiben: %s", path)
return
suffix = path.suffix.lower()
lyrics_text = plain_lyrics or synced_lyrics or ""
if not lyrics_text.strip():
return
if suffix == ".mp3":
try:
tags = ID3(path)
except ID3NoHeaderError:
tags = ID3()
tags.delall("USLT")
tags.add(USLT(encoding=3, lang="XXX", desc="", text=lyrics_text))
tags.save(path)
return
audio = MutagenFile(path)
if audio is None:
return
if isinstance(audio, FLAC | OggVorbis | OggOpus):
audio["LYRICS"] = lyrics_text
if synced_lyrics:
audio["LYRICS_SYNCED"] = synced_lyrics
audio.save()
return
if isinstance(audio, MP4):
audio["\xa9lyr"] = [lyrics_text]
audio.save()
def process_file(path: Path, session: requests.Session, config: Config) -> FileResult:
lrc_path = path.with_suffix(".lrc")
if config.write_sidecar_lrc and config.skip_existing_lrc and lrc_path.exists():
return FileResult(str(path), "skipped", "LRC existiert bereits", str(lrc_path))
track = read_track_info(path)
if track is None:
return FileResult(str(path), "skipped", "Metadaten unvollständig")
logging.info("Suche Lyrics: %s - %s", track.artist, track.title)
lyrics = find_lyrics(session, track)
if not lyrics:
return FileResult(str(path), "not_found", "Keine Lyrics bei LRCLIB gefunden")
synced = str(lyrics.get("syncedLyrics") or "").strip()
plain = str(lyrics.get("plainLyrics") or "").strip()
source_id = lyrics.get("id")
if synced:
lrc_content = ensure_lrc_header(synced, track)
is_synced = True
elif plain and config.write_plain_as_lrc and not config.require_synced_lyrics:
lrc_content = plain_to_lrc(plain, track)
is_synced = False
elif plain and config.require_synced_lyrics:
return FileResult(str(path), "skipped", "Nur unsynchronisierte Lyrics gefunden", source_id=source_id, synced=False)
else:
return FileResult(str(path), "not_found", "Kein nutzbarer Lyrics-Inhalt gefunden", source_id=source_id)
written_lrc_path: Path | None = None
if config.write_sidecar_lrc:
written_lrc_path = write_sidecar_lrc(path, lrc_content, config)
if config.write_embedded_tags:
write_embedded_tags(path, plain or lrc_content, synced, config)
if config.touch_audio_on_write and not config.dry_run:
now = time.time()
os.utime(path, (now, now))
return FileResult(
path=str(path),
status="written" if not config.dry_run else "dry_run",
message="Synchronisierte LRC geschrieben" if is_synced else "Unsynchronisierte Lyrics als LRC geschrieben",
lrc_path=str(written_lrc_path) if written_lrc_path else None,
source_id=int(source_id) if isinstance(source_id, int) else None,
synced=is_synced,
)
def save_report(config: Config, started_at: float, results: list[FileResult]) -> None:
if not config.save_report:
return
report = {
"app": APP_NAME,
"version": APP_VERSION,
"copyright": "Copyright (c) 2026 Roman Glos",
"started_at": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(started_at)),
"finished_at": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime()),
"music_dir": str(config.music_dir),
"summary": {
"total": len(results),
"written": sum(1 for item in results if item.status == "written"),
"dry_run": sum(1 for item in results if item.status == "dry_run"),
"skipped": sum(1 for item in results if item.status == "skipped"),
"not_found": sum(1 for item in results if item.status == "not_found"),
"error": sum(1 for item in results if item.status == "error"),
},
"results": [asdict(result) for result in results],
}
if config.dry_run:
logging.info("DRY_RUN: Würde Bericht speichern: %s", config.report_path)
return
config.report_path.parent.mkdir(parents=True, exist_ok=True)
config.report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
logging.info("Bericht gespeichert: %s", config.report_path)
def run_once(config: Config) -> int:
if not config.music_dir.exists():
logging.error("Musikordner existiert nicht: %s", config.music_dir)
return 2
started_at = time.time()
session = requests.Session()
session.headers.update({"User-Agent": config.user_agent, "Accept": "application/json"})
results: list[FileResult] = []
processed = 0
logging.info("%s %s gestartet", APP_NAME, APP_VERSION)
logging.info("Copyright (c) 2026 Roman Glos")
logging.info("Musikordner: %s", config.music_dir)
logging.info("Erweiterungen: %s", ", ".join(sorted(config.audio_extensions)))
for path in iter_audio_files(config.music_dir, config.audio_extensions):
if config.max_files_per_run > 0 and processed >= config.max_files_per_run:
logging.info("MAX_FILES_PER_RUN erreicht: %s", config.max_files_per_run)
break
processed += 1
try:
result = process_file(path, session, config)
except Exception as exc:
logging.exception("Fehler bei Datei: %s", path)
result = FileResult(str(path), "error", str(exc))
results.append(result)
logging.info("%s: %s - %s", result.status.upper(), path, result.message)
if config.request_delay_seconds > 0:
time.sleep(config.request_delay_seconds)
save_report(config, started_at, results)
written = sum(1 for item in results if item.status in {"written", "dry_run"})
errors = sum(1 for item in results if item.status == "error")
logging.info("Fertig. Dateien geprüft: %s, geschrieben: %s, Fehler: %s", len(results), written, errors)
return 1 if errors else 0
def main() -> int:
config = load_config()
configure_logging(config.log_level)
if config.scan_interval_seconds <= 0:
return run_once(config)
exit_code = 0
while True:
exit_code = run_once(config)
logging.info("Nächster Scan in %s Sekunden", config.scan_interval_seconds)
time.sleep(config.scan_interval_seconds)
return exit_code
if __name__ == "__main__":
raise SystemExit(main())