diff --git a/core/lyrics_client.py b/core/lyrics_client.py index 4def5b75..052e2329 100644 --- a/core/lyrics_client.py +++ b/core/lyrics_client.py @@ -50,10 +50,11 @@ class LyricsClient: 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 LRC file already exists - if os.path.exists(lrc_path): - logger.debug(f"LRC file already exists: {os.path.basename(lrc_path)}") + # 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 @@ -95,27 +96,31 @@ class LyricsClient: 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) + # 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(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)}") + logger.debug(f"Synced lyrics available: {bool(synced)}") + logger.debug(f"Plain lyrics available: {bool(plain)}") - if not lrc_content: + if not synced and not plain: 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) - - # Embed lyrics directly in audio file tags (Navidrome/Jellyfin read these) - self._embed_lyrics(audio_file_path, lrc_content) - - lyrics_type = "synced" if getattr(lyrics_data, 'synced_lyrics', None) else "plain" - logger.info(f"✅ Created {lyrics_type} LRC file + embedded: {os.path.basename(lrc_path)}") + 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: diff --git a/web_server.py b/web_server.py index d641a2ab..555edab9 100644 --- a/web_server.py +++ b/web_server.py @@ -15393,11 +15393,12 @@ def _downsample_hires_flac(final_path, context): try: os.rename(final_path, new_path) print(f"📝 [Downsample] Renamed: {os.path.basename(final_path)} → {new_basename}") - # Rename matching LRC file if it exists - old_lrc = os.path.splitext(final_path)[0] + '.lrc' - if os.path.isfile(old_lrc): - new_lrc = os.path.splitext(new_path)[0] + '.lrc' - os.rename(old_lrc, new_lrc) + # Rename matching lyrics sidecar file if it exists (.lrc or .txt) + for lyrics_ext in ('.lrc', '.txt'): + old_lyrics = os.path.splitext(final_path)[0] + lyrics_ext + if os.path.isfile(old_lyrics): + new_lyrics = os.path.splitext(new_path)[0] + lyrics_ext + os.rename(old_lyrics, new_lyrics) return new_path except Exception as rename_err: print(f"⚠️ [Downsample] Could not rename file: {rename_err}") @@ -15551,15 +15552,16 @@ def _create_lossy_copy(final_path): if test_audio is not None: os.remove(final_path) print(f"🔥 [Blasphemy Mode] Deleted original: {os.path.basename(final_path)}") - # Rename LRC file to match the output filename - flac_lrc = os.path.splitext(final_path)[0] + '.lrc' - if os.path.isfile(flac_lrc): - out_lrc = os.path.splitext(out_path)[0] + '.lrc' - try: - os.rename(flac_lrc, out_lrc) - print(f"🔥 [Blasphemy Mode] Renamed LRC: {os.path.basename(flac_lrc)} -> {os.path.basename(out_lrc)}") - except Exception as lrc_err: - print(f"⚠️ [Blasphemy Mode] Could not rename LRC: {lrc_err}") + # Rename lyrics sidecar file to match the output filename + for lyrics_ext in ('.lrc', '.txt'): + src_lyrics = os.path.splitext(final_path)[0] + lyrics_ext + if os.path.isfile(src_lyrics): + dst_lyrics = os.path.splitext(out_path)[0] + lyrics_ext + try: + os.rename(src_lyrics, dst_lyrics) + print(f"🔥 [Blasphemy Mode] Renamed {lyrics_ext}: {os.path.basename(src_lyrics)} -> {os.path.basename(dst_lyrics)}") + except Exception as lrc_err: + print(f"⚠️ [Blasphemy Mode] Could not rename {lyrics_ext}: {lrc_err}") return out_path else: print(f"⚠️ [Blasphemy Mode] Output failed audio validation, keeping original: {os.path.basename(final_path)}") @@ -18802,15 +18804,16 @@ def _execute_retag(group_id, album_id): os.makedirs(os.path.dirname(new_path), exist_ok=True) _safe_move_file(current_file_path, new_path) - # Move .lrc file alongside audio file if it exists - old_lrc = os.path.splitext(current_file_path)[0] + '.lrc' - if os.path.exists(old_lrc): - new_lrc = os.path.splitext(new_path)[0] + '.lrc' - try: - _safe_move_file(old_lrc, new_lrc) - print(f"📝 [Retag] Moved .lrc file alongside audio") - except Exception as lrc_err: - print(f"⚠️ [Retag] Failed to move .lrc file: {lrc_err}") + # Move lyrics sidecar file alongside audio file if it exists + for lyrics_ext in ('.lrc', '.txt'): + old_lyrics = os.path.splitext(current_file_path)[0] + lyrics_ext + if os.path.exists(old_lyrics): + new_lyrics = os.path.splitext(new_path)[0] + lyrics_ext + try: + _safe_move_file(old_lyrics, new_lyrics) + print(f"📝 [Retag] Moved {lyrics_ext} file alongside audio") + except Exception as lrc_err: + print(f"⚠️ [Retag] Failed to move {lyrics_ext} file: {lrc_err}") # Remove old cover.jpg if directory changed and old dir is now empty of audio new_dir = os.path.dirname(new_path) @@ -19204,6 +19207,16 @@ def get_version_info(): "title": "What's New in SoulSync", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "sections": [ + { + "title": "🔧 Fix .LRC Files Written Without Timestamps", + "description": "Plain lyrics now saved as .txt instead of invalid .lrc files", + "features": [ + "• Synced (timestamped) lyrics → .lrc file — valid format for Plex, Navidrome, Jellyfin", + "• Plain (unsynced) lyrics → .txt file — no longer written with incorrect .lrc extension", + "• Lyrics still embedded in audio file tags regardless of type (players can display both)", + "• File move/rename operations updated to handle both .lrc and .txt sidecars" + ] + }, { "title": "🔧 Fix Collaborative Album Artist Not Applied to Singles (#215)", "description": "Single path template now respects the First Listed Artist setting", diff --git a/webui/static/helper.js b/webui/static/helper.js index c24aea18..6beffab0 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3403,6 +3403,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.1': [ // Newest features first + { title: 'Fix Invalid .LRC Lyrics Files', desc: 'Plain lyrics now saved as .txt — only synced (timestamped) lyrics get the .lrc extension' }, { title: 'Fix Collab Artist on Singles', desc: 'Single/playlist path templates now respect First Listed Artist setting — $albumartist available for all template types' }, { title: 'Fix Enrichment Breaking Manual Matches', desc: 'Enriching a manually matched artist no longer reverts status to not_found — uses stored ID for direct lookup' }, { title: 'Fix Spotify OAuth Empty Response', desc: 'OAuth callback server now always sends a response in Docker — added health check and proper logging' },