Discord report (netti93). The download flow runs `enhance_file_metadata` (clears all tags) then `generate_lrc_file` (writes .lrc sidecar AND embeds USLT). The retag flow only ran the first half — `enhance_file_metadata` cleared USLT and there was no follow-up to restore it. Two coordinated fixes (no new setting per kettui scope discipline — user described it as "might even be an idea," consistency was the load-bearing ask). Fix 1 — retag calls generate_lrc_file after enhance `core/library/retag.py:execute_retag` now invokes `deps.generate_lrc_file` right after the `enhance_file_metadata` call, mirroring the download pipeline. New `generate_lrc_file` field on `RetagDeps`, defaults to None for backward compat with any test caller that builds RetagDeps without it. Web_server's `_build_retag_deps()` factory wires in the real `core.metadata.lyrics.generate_lrc_file`. Placement matters — runs BEFORE `safe_move_file` so the helper sees the audio file at its current path with its existing sidecar (which retag hasn't moved yet). After the embed, the audio file gets moved with USLT now present; the sidecar move step that follows is unaffected. Fix 2 — create_lrc_file re-embeds from existing sidecar `core/lyrics_client.py:create_lrc_file` used to early-return True when an .lrc / .txt sidecar already existed (skipping the LRClib fetch). For the retag case the sidecar is already there, so the shortcut hit and USLT was never re-written. Now the helper reads the existing sidecar and calls `_embed_lyrics` with its content before returning. Empty / unreadable sidecars short-circuit silently — defensive, no crash. Download flow unaffected because no sidecar exists at fetch time. 7 boundary tests pin: existing .lrc triggers re-embed, existing .txt triggers re-embed, empty sidecar skips embed, unreadable sidecar swallows error, no sidecar falls through to LRClib (download path regression guard), RetagDeps.generate_lrc_file field accepted, field optional for backward compat. Full suite: 3120 passed.
187 lines
No EOL
7.8 KiB
Python
187 lines
No EOL
7.8 KiB
Python
#!/usr/bin/env python3
|
|
|
|
import os
|
|
from utils.logging_config import get_logger
|
|
|
|
logger = get_logger("lyrics_client")
|
|
|
|
class LyricsClient:
|
|
"""
|
|
Minimal, elegant LRClib client for automatic lyrics fetching.
|
|
Generates .lrc sidecar files during post-processing.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.api = None
|
|
self._init_api()
|
|
|
|
def _init_api(self):
|
|
"""Initialize LRClib API with graceful fallback"""
|
|
try:
|
|
from lrclib import LrcLibAPI
|
|
self.api = LrcLibAPI(user_agent="SoulSync/1.0 (WebUI)")
|
|
logger.debug("LRClib API client initialized")
|
|
except ImportError:
|
|
logger.warning("LRClib API not available - lyrics functionality disabled")
|
|
self.api = None
|
|
except Exception as e:
|
|
logger.error(f"Error initializing LRClib API: {e}")
|
|
self.api = None
|
|
|
|
def create_lrc_file(self, audio_file_path: str, track_name: str, artist_name: str,
|
|
album_name: str = None, duration_seconds: int = None) -> bool:
|
|
"""
|
|
Create .lrc sidecar file for the given audio file.
|
|
|
|
Args:
|
|
audio_file_path: Path to the audio file
|
|
track_name: Track title
|
|
artist_name: Artist name
|
|
album_name: Album name (optional)
|
|
duration_seconds: Track duration in seconds (optional)
|
|
|
|
Returns:
|
|
bool: True if LRC file was created successfully
|
|
"""
|
|
if not self.api:
|
|
logger.debug("LRClib API not available - skipping lyrics")
|
|
return False
|
|
|
|
try:
|
|
# Generate LRC file path (same name as audio file, .lrc extension)
|
|
lrc_path = os.path.splitext(audio_file_path)[0] + '.lrc'
|
|
txt_path = os.path.splitext(audio_file_path)[0] + '.txt'
|
|
|
|
# Sidecar already exists — skip the LRClib fetch but still
|
|
# re-embed the lyrics in the audio file's tag. The retag
|
|
# flow clears all tags including USLT and then runs this
|
|
# helper to restore them; without the embed step the
|
|
# LYRICS tag stays empty even though the .lrc is right
|
|
# there next to the file (Discord report — Netti93).
|
|
if os.path.exists(lrc_path) or os.path.exists(txt_path):
|
|
existing_path = lrc_path if os.path.exists(lrc_path) else txt_path
|
|
try:
|
|
with open(existing_path, 'r', encoding='utf-8') as f:
|
|
existing_lyrics = f.read().strip()
|
|
if existing_lyrics:
|
|
self._embed_lyrics(audio_file_path, existing_lyrics)
|
|
logger.debug(
|
|
"Re-embedded lyrics from existing %s for: %s",
|
|
os.path.basename(existing_path),
|
|
os.path.basename(audio_file_path),
|
|
)
|
|
except Exception as e:
|
|
logger.debug(
|
|
"Could not re-embed lyrics from existing sidecar %s: %s",
|
|
os.path.basename(existing_path), e,
|
|
)
|
|
return True
|
|
|
|
# Fetch lyrics from LRClib
|
|
logger.debug(f"Fetching lyrics for: {artist_name} - {track_name}")
|
|
|
|
lyrics_data = None
|
|
|
|
# Strategy 1: Exact match with duration (most accurate)
|
|
if duration_seconds and album_name:
|
|
try:
|
|
logger.debug(f"Trying exact match: {track_name} by {artist_name} from {album_name} ({duration_seconds}s)")
|
|
lyrics_data = self.api.get_lyrics(
|
|
track_name=track_name,
|
|
artist_name=artist_name,
|
|
album_name=album_name,
|
|
duration=duration_seconds
|
|
)
|
|
if lyrics_data:
|
|
logger.debug("Exact match found!")
|
|
except Exception as e:
|
|
logger.debug(f"Exact match failed: {e}")
|
|
|
|
# Strategy 2: Search without duration
|
|
if not lyrics_data:
|
|
try:
|
|
logger.debug(f"Trying search: {track_name} by {artist_name}")
|
|
search_results = self.api.search_lyrics(
|
|
track_name=track_name,
|
|
artist_name=artist_name
|
|
)
|
|
if search_results:
|
|
lyrics_data = search_results[0] # Take first result
|
|
logger.debug(f"Search found {len(search_results)} results, using first")
|
|
except Exception as e:
|
|
logger.debug(f"Search fallback failed: {e}")
|
|
|
|
# No lyrics found
|
|
if not lyrics_data:
|
|
logger.debug(f"No lyrics found for: {artist_name} - {track_name}")
|
|
return False
|
|
|
|
# LRClib API provides synced_lyrics (timestamped) and plain_lyrics (text only)
|
|
synced = getattr(lyrics_data, 'synced_lyrics', None)
|
|
plain = getattr(lyrics_data, 'plain_lyrics', None)
|
|
|
|
logger.debug(f"Synced lyrics available: {bool(synced)}")
|
|
logger.debug(f"Plain lyrics available: {bool(plain)}")
|
|
|
|
if not synced and not plain:
|
|
logger.debug(f"No usable lyrics content for: {artist_name} - {track_name}")
|
|
return False
|
|
|
|
if synced:
|
|
# Synced lyrics have timestamps → valid .lrc format
|
|
with open(lrc_path, 'w', encoding='utf-8') as f:
|
|
f.write(synced)
|
|
# Embed synced lyrics in audio tags
|
|
self._embed_lyrics(audio_file_path, synced)
|
|
logger.info(f"Created synced LRC + embedded: {os.path.basename(lrc_path)}")
|
|
else:
|
|
# Plain lyrics only → write as .txt (not .lrc, which requires timestamps)
|
|
with open(txt_path, 'w', encoding='utf-8') as f:
|
|
f.write(plain)
|
|
# Still embed plain lyrics in audio tags (players can display unsynced lyrics)
|
|
self._embed_lyrics(audio_file_path, plain)
|
|
logger.info(f"Created plain lyrics .txt + embedded: {os.path.basename(txt_path)}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating LRC file for {track_name}: {e}")
|
|
return False
|
|
|
|
|
|
def _embed_lyrics(self, audio_file_path: str, lyrics_text: str):
|
|
"""Embed lyrics directly into audio file tags."""
|
|
try:
|
|
from mutagen import File as MutagenFile
|
|
from mutagen.flac import FLAC
|
|
from mutagen.oggvorbis import OggVorbis
|
|
from mutagen.mp4 import MP4
|
|
from mutagen.id3 import ID3, USLT
|
|
|
|
audio = MutagenFile(audio_file_path)
|
|
if audio is None:
|
|
return
|
|
|
|
if audio.tags is None:
|
|
return # Don't create tags just for lyrics
|
|
|
|
if isinstance(audio.tags, ID3):
|
|
audio.tags.delall('USLT')
|
|
audio.tags.add(USLT(encoding=3, lang='eng', desc='', text=lyrics_text))
|
|
audio.save(v1=0, v2_version=4)
|
|
elif isinstance(audio, (FLAC, OggVorbis)) or type(audio).__name__ == 'OggOpus':
|
|
audio['lyrics'] = [lyrics_text]
|
|
if isinstance(audio, FLAC):
|
|
audio.save(deleteid3=True)
|
|
else:
|
|
audio.save()
|
|
elif isinstance(audio, MP4):
|
|
audio['\xa9lyr'] = [lyrics_text]
|
|
audio.save()
|
|
|
|
logger.debug(f"Embedded lyrics in: {os.path.basename(audio_file_path)}")
|
|
except Exception as e:
|
|
logger.warning(f"Could not embed lyrics in {os.path.basename(audio_file_path)}: {e}")
|
|
|
|
|
|
# Global instance for easy import
|
|
lyrics_client = LyricsClient() |