196 lines
5.7 KiB
Python
196 lines
5.7 KiB
Python
"""LRC generation, parsing, embedding, and reading embedded lyrics.
|
|
|
|
Reading embedded lyrics checks all tag locations for a given audio format:
|
|
- MP3 -> ID3 USLT / SYLT
|
|
- FLAC/OGG -> LYRICS, UNSYNCEDLYRICS (Vorbis comments)
|
|
- M4A/MP4 -> `©lyr` atom
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
|
|
_LRC_LINE_RE = re.compile(r"^\s*\[\d{1,3}:\d{2}(?:\.\d{1,3})?\]")
|
|
_LRC_PARSE_RE = re.compile(r"\[(\d+):(\d+)\.(\d+)\](.*)$")
|
|
|
|
|
|
@dataclass
|
|
class LrcLine:
|
|
start: float
|
|
text: str
|
|
|
|
|
|
def format_timestamp(seconds: float) -> str:
|
|
if seconds < 0:
|
|
seconds = 0.0
|
|
minutes = int(seconds // 60)
|
|
secs = seconds - minutes * 60
|
|
return f"[{minutes:02d}:{secs:05.2f}]"
|
|
|
|
|
|
def segments_to_lrc(segments: list[dict]) -> str:
|
|
lines: list[str] = []
|
|
for seg in segments:
|
|
text = (seg.get("text") or "").strip()
|
|
if not text:
|
|
continue
|
|
start = float(seg.get("start", 0.0))
|
|
lines.append(f"{format_timestamp(start)}{text}")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def parse_lrc(content: str) -> list[LrcLine]:
|
|
out: list[LrcLine] = []
|
|
for line in content.splitlines():
|
|
m = _LRC_PARSE_RE.match(line.strip())
|
|
if not m:
|
|
continue
|
|
mm, ss, frac, text = m.groups()
|
|
frac_sec = float(f"0.{frac}") if frac else 0.0
|
|
out.append(LrcLine(start=int(mm) * 60 + int(ss) + frac_sec, text=text.strip()))
|
|
return out
|
|
|
|
|
|
def is_lrc(content: str) -> bool:
|
|
"""True if content contains at least one LRC-style timestamp line."""
|
|
if not content:
|
|
return False
|
|
for line in content.splitlines():
|
|
if _LRC_LINE_RE.match(line):
|
|
return True
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------- read embedded
|
|
|
|
def read_embedded_lyrics(audio_path: Path) -> Optional[str]:
|
|
"""Return lyrics stored in the file's tags, or None.
|
|
|
|
Order: prefer synced (LRC-like) -> plain. Never raises.
|
|
"""
|
|
suffix = audio_path.suffix.lower()
|
|
try:
|
|
if suffix in (".mp3", ".mp2"):
|
|
return _read_mp3(audio_path)
|
|
if suffix in (".flac", ".ogg", ".opus"):
|
|
return _read_vorbis(audio_path)
|
|
if suffix in (".m4a", ".mp4", ".m4b", ".aac"):
|
|
return _read_mp4(audio_path)
|
|
except Exception: # noqa: BLE001 — never break the pipeline on tag issues
|
|
return None
|
|
return None
|
|
|
|
|
|
def _read_mp3(path: Path) -> Optional[str]:
|
|
from mutagen.id3 import ID3, ID3NoHeaderError
|
|
try:
|
|
tags = ID3(str(path))
|
|
except ID3NoHeaderError:
|
|
return None
|
|
|
|
# SYLT = synced lyrics (rare); USLT = unsynced (common, may contain LRC text)
|
|
sylt = tags.getall("SYLT")
|
|
for tag in sylt:
|
|
if tag.text:
|
|
try:
|
|
content = "\n".join(
|
|
f"{format_timestamp(ms / 1000.0)}{txt}"
|
|
for txt, ms in tag.text
|
|
)
|
|
if content.strip():
|
|
return content
|
|
except Exception: # noqa: BLE001
|
|
continue
|
|
|
|
uslt = tags.getall("USLT")
|
|
for tag in uslt:
|
|
if tag.text and str(tag.text).strip():
|
|
return str(tag.text)
|
|
|
|
return None
|
|
|
|
|
|
def _read_vorbis(path: Path) -> Optional[str]:
|
|
suffix = path.suffix.lower()
|
|
if suffix == ".flac":
|
|
from mutagen.flac import FLAC as Audio
|
|
elif suffix == ".ogg":
|
|
from mutagen.oggvorbis import OggVorbis as Audio
|
|
else:
|
|
from mutagen.oggopus import OggOpus as Audio
|
|
audio = Audio(str(path))
|
|
for key in ("LYRICS", "UNSYNCEDLYRICS", "LYRIC", "SYNCEDLYRICS"):
|
|
if key in audio:
|
|
values = audio[key]
|
|
if isinstance(values, list) and values:
|
|
text = str(values[0])
|
|
else:
|
|
text = str(values)
|
|
if text.strip():
|
|
return text
|
|
return None
|
|
|
|
|
|
def _read_mp4(path: Path) -> Optional[str]:
|
|
from mutagen.mp4 import MP4
|
|
audio = MP4(str(path))
|
|
for key in ("\xa9lyr", "©lyr"):
|
|
if key in audio:
|
|
values = audio[key]
|
|
if isinstance(values, list) and values:
|
|
text = str(values[0])
|
|
else:
|
|
text = str(values)
|
|
if text.strip():
|
|
return text
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------- embedding
|
|
|
|
def embed_lrc(audio_path: Path, lrc_content: str) -> None:
|
|
suffix = audio_path.suffix.lower()
|
|
if suffix in (".mp3", ".mp2"):
|
|
_embed_mp3(audio_path, lrc_content)
|
|
elif suffix in (".flac", ".ogg", ".opus"):
|
|
_embed_vorbis(audio_path, lrc_content)
|
|
elif suffix in (".m4a", ".mp4", ".m4b", ".aac"):
|
|
_embed_mp4(audio_path, lrc_content)
|
|
else:
|
|
raise ValueError(f"Unsupported format for LRC embedding: {suffix}")
|
|
|
|
|
|
def _embed_mp3(path: Path, lrc_content: str) -> None:
|
|
from mutagen.id3 import ID3, USLT, ID3NoHeaderError
|
|
try:
|
|
tags = ID3(str(path))
|
|
except ID3NoHeaderError:
|
|
tags = ID3()
|
|
tags.delall("USLT")
|
|
tags.add(USLT(encoding=3, lang="rus", desc="", text=lrc_content))
|
|
tags.save(str(path), v2_version=3)
|
|
|
|
|
|
def _embed_vorbis(path: Path, lrc_content: str) -> None:
|
|
suffix = path.suffix.lower()
|
|
if suffix == ".flac":
|
|
from mutagen.flac import FLAC as Audio
|
|
elif suffix == ".ogg":
|
|
from mutagen.oggvorbis import OggVorbis as Audio
|
|
else:
|
|
from mutagen.oggopus import OggOpus as Audio
|
|
audio = Audio(str(path))
|
|
audio["LYRICS"] = lrc_content
|
|
audio["UNSYNCEDLYRICS"] = lrc_content
|
|
audio.save()
|
|
|
|
|
|
def _embed_mp4(path: Path, lrc_content: str) -> None:
|
|
from mutagen.mp4 import MP4
|
|
audio = MP4(str(path))
|
|
audio["\xa9lyr"] = [lrc_content]
|
|
audio.save()
|