From fde1a7d77e84f19365428702f1cd3dda72385fb4 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:28:41 -0700 Subject: [PATCH] Fix .lrc files written without timestamps for plain lyrics 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. --- core/lyrics_client.py | 43 ++++++++++++++++-------------- web_server.py | 59 ++++++++++++++++++++++++++---------------- webui/static/helper.js | 1 + 3 files changed, 61 insertions(+), 42 deletions(-) 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' },