From 8de9df07e7bf84caaf74b4aea89764ac7b7c1b03 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Mon, 22 Sep 2025 16:04:06 -0700 Subject: [PATCH] auto lyric download --- core/lyrics_client.py | 124 +++++++++++++++++++++++++++++++++++++++++ requirements-webui.txt | 3 + requirements.txt | 3 +- web_server.py | 64 ++++++++++++++++++++- 4 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 core/lyrics_client.py diff --git a/core/lyrics_client.py b/core/lyrics_client.py new file mode 100644 index 00000000..029bd027 --- /dev/null +++ b/core/lyrics_client.py @@ -0,0 +1,124 @@ +#!/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' + + # Skip if LRC file already exists + if os.path.exists(lrc_path): + logger.debug(f"LRC file already exists: {os.path.basename(lrc_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 + + # Prefer synced lyrics, fallback to plain text + # LRClib API uses synced_lyrics and plain_lyrics attributes + lrc_content = getattr(lyrics_data, 'synced_lyrics', None) or getattr(lyrics_data, 'plain_lyrics', None) + + logger.debug(f"Synced lyrics available: {bool(getattr(lyrics_data, 'synced_lyrics', None))}") + logger.debug(f"Plain lyrics available: {bool(getattr(lyrics_data, 'plain_lyrics', None))}") + logger.debug(f"LRC content found: {bool(lrc_content)}") + + if not lrc_content: + logger.debug(f"No usable lyrics content for: {artist_name} - {track_name}") + return False + + # Write LRC file + with open(lrc_path, 'w', encoding='utf-8') as f: + f.write(lrc_content) + + lyrics_type = "synced" if getattr(lyrics_data, 'synced_lyrics', None) else "plain" + logger.info(f"✅ Created {lyrics_type} LRC file: {os.path.basename(lrc_path)}") + return True + + except Exception as e: + logger.error(f"Error creating LRC file for {track_name}: {e}") + return False + + +# Global instance for easy import +lyrics_client = LyricsClient() \ No newline at end of file diff --git a/requirements-webui.txt b/requirements-webui.txt index b2563ce4..465cfd2c 100644 --- a/requirements-webui.txt +++ b/requirements-webui.txt @@ -31,5 +31,8 @@ psutil>=6.0.0 # YouTube support yt-dlp>=2024.12.13 +# Lyrics support +lrclibapi>=0.3.1 + # Optional: MQTT support (for future features) asyncio-mqtt>=0.16.0 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 8e89f6f4..6d54c95c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,5 @@ Pillow>=10.0.0 aiohttp>=3.9.0 unidecode>=1.3.8 yt-dlp>=2024.12.13 -Flask>=3.0.0 \ No newline at end of file +Flask>=3.0.0 +lrclibapi>=0.3.1 \ No newline at end of file diff --git a/web_server.py b/web_server.py index f95e8a1f..b832c617 100644 --- a/web_server.py +++ b/web_server.py @@ -33,6 +33,7 @@ from core.tidal_client import TidalClient # Added import for Tidal from core.matching_engine import MusicMatchingEngine from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker from core.web_scan_manager import WebScanManager +from core.lyrics_client import lyrics_client from database.music_database import get_database from services.sync_service import PlaylistSyncService from datetime import datetime @@ -4762,6 +4763,60 @@ def _enhance_file_metadata(file_path: str, context: dict, artist: dict, album_in print(f"❌ Error enhancing metadata for {file_path}: {e}") return False +def _generate_lrc_file(file_path: str, context: dict, artist: dict, album_info: dict) -> bool: + """ + Generate LRC lyrics file using LRClib API. + Elegant addition to post-processing - extracts metadata from existing context. + """ + try: + # Extract track information from existing context (same as metadata enhancement) + original_search = context.get("original_search_result", {}) + spotify_album = context.get("spotify_album") + + # Get track metadata + track_name = (original_search.get('spotify_clean_title') or + original_search.get('title', 'Unknown Track')) + + # Handle artist parameter (can be dict or object) + if isinstance(artist, dict): + artist_name = artist.get('name', 'Unknown Artist') + elif hasattr(artist, 'name'): + artist_name = artist.name + else: + artist_name = str(artist) if artist else 'Unknown Artist' + album_name = None + duration_seconds = None + + # Get album name if available + if album_info.get('is_album'): + album_name = (original_search.get('spotify_clean_album') or + album_info.get('album_name') or + (spotify_album.get('name') if spotify_album else None)) + + # Get duration from original search context + if original_search.get('duration_ms'): + duration_seconds = int(original_search['duration_ms'] / 1000) + + # Generate LRC file using lyrics client + success = lyrics_client.create_lrc_file( + audio_file_path=file_path, + track_name=track_name, + artist_name=artist_name, + album_name=album_name, + duration_seconds=duration_seconds + ) + + if success: + print(f"🎵 LRC file generated for: {track_name}") + else: + print(f"🎵 No lyrics found for: {track_name}") + + return success + + except Exception as e: + print(f"❌ Error generating LRC file for {file_path}: {e}") + return False + def _extract_spotify_metadata(context: dict, artist: dict, album_info: dict) -> dict: """Extracts a comprehensive metadata dictionary from the provided context.""" metadata = {} @@ -5267,7 +5322,7 @@ def _post_process_matched_download(context_key, context, file_path): # 3. Enhance metadata, move file, download art, and cleanup _enhance_file_metadata(file_path, context, spotify_artist, album_info) - + print(f"🚚 Moving '{os.path.basename(file_path)}' to '{final_path}'") if os.path.exists(final_path): # PROTECTION: Check if existing file already has metadata enhancement @@ -5290,9 +5345,12 @@ def _post_process_matched_download(context_key, context, file_path): os.remove(final_path) shutil.move(file_path, final_path) - + _download_cover_art(album_info, os.path.dirname(final_path)) - + + # 4. Generate LRC lyrics file at final location (elegant addition) + _generate_lrc_file(final_path, context, spotify_artist, album_info) + downloads_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')) _cleanup_empty_directories(downloads_path, file_path)