fix some bugs, add LRC synchronizer, text saver (if text not in LIBLRC, for upgrade library)
This commit is contained in:
195
core/lrc.py
Normal file
195
core/lrc.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user