auto lyric download
This commit is contained in:
parent
a73d89bc9e
commit
8de9df07e7
4 changed files with 190 additions and 4 deletions
124
core/lyrics_client.py
Normal file
124
core/lyrics_client.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -31,5 +31,8 @@ psutil>=6.0.0
|
||||||
# YouTube support
|
# YouTube support
|
||||||
yt-dlp>=2024.12.13
|
yt-dlp>=2024.12.13
|
||||||
|
|
||||||
|
# Lyrics support
|
||||||
|
lrclibapi>=0.3.1
|
||||||
|
|
||||||
# Optional: MQTT support (for future features)
|
# Optional: MQTT support (for future features)
|
||||||
asyncio-mqtt>=0.16.0
|
asyncio-mqtt>=0.16.0
|
||||||
|
|
@ -11,3 +11,4 @@ aiohttp>=3.9.0
|
||||||
unidecode>=1.3.8
|
unidecode>=1.3.8
|
||||||
yt-dlp>=2024.12.13
|
yt-dlp>=2024.12.13
|
||||||
Flask>=3.0.0
|
Flask>=3.0.0
|
||||||
|
lrclibapi>=0.3.1
|
||||||
|
|
@ -33,6 +33,7 @@ from core.tidal_client import TidalClient # Added import for Tidal
|
||||||
from core.matching_engine import MusicMatchingEngine
|
from core.matching_engine import MusicMatchingEngine
|
||||||
from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker
|
from core.database_update_worker import DatabaseUpdateWorker, DatabaseStatsWorker
|
||||||
from core.web_scan_manager import WebScanManager
|
from core.web_scan_manager import WebScanManager
|
||||||
|
from core.lyrics_client import lyrics_client
|
||||||
from database.music_database import get_database
|
from database.music_database import get_database
|
||||||
from services.sync_service import PlaylistSyncService
|
from services.sync_service import PlaylistSyncService
|
||||||
from datetime import datetime
|
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}")
|
print(f"❌ Error enhancing metadata for {file_path}: {e}")
|
||||||
return False
|
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:
|
def _extract_spotify_metadata(context: dict, artist: dict, album_info: dict) -> dict:
|
||||||
"""Extracts a comprehensive metadata dictionary from the provided context."""
|
"""Extracts a comprehensive metadata dictionary from the provided context."""
|
||||||
metadata = {}
|
metadata = {}
|
||||||
|
|
@ -5293,6 +5348,9 @@ def _post_process_matched_download(context_key, context, file_path):
|
||||||
|
|
||||||
_download_cover_art(album_info, os.path.dirname(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'))
|
downloads_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
|
||||||
_cleanup_empty_directories(downloads_path, file_path)
|
_cleanup_empty_directories(downloads_path, file_path)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue