Plain (unsynced) lyrics were being saved with .lrc extension despite having no timestamps, making them invalid for Plex and other players that expect LRC format. Synced lyrics now write as .lrc, plain lyrics write as .txt. Both types still get embedded in audio file tags. Updated all file move/rename operations to handle .txt sidecars alongside .lrc.
167 lines
No EOL
6.7 KiB
Python
167 lines
No EOL
6.7 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'
|
|
|
|
# Skip if lyrics file already exists (either .lrc or .txt)
|
|
if os.path.exists(lrc_path) or os.path.exists(txt_path):
|
|
logger.debug(f"Lyrics file already exists for: {os.path.basename(audio_file_path)}")
|
|
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() |