soulsync/core/lyrics_client.py
Broque Thomas 2f284efa57 Retag now re-embeds LYRICS tag instead of leaving it empty
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.
2026-05-14 15:52:05 -07:00

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()