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.
This commit is contained in:
Broque Thomas 2026-03-30 13:28:41 -07:00
parent c5652b0d4b
commit fde1a7d77e
3 changed files with 61 additions and 42 deletions

View file

@ -50,10 +50,11 @@ class LyricsClient:
try: try:
# Generate LRC file path (same name as audio file, .lrc extension) # Generate LRC file path (same name as audio file, .lrc extension)
lrc_path = os.path.splitext(audio_file_path)[0] + '.lrc' 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 # Skip if lyrics file already exists (either .lrc or .txt)
if os.path.exists(lrc_path): if os.path.exists(lrc_path) or os.path.exists(txt_path):
logger.debug(f"LRC file already exists: {os.path.basename(lrc_path)}") logger.debug(f"Lyrics file already exists for: {os.path.basename(audio_file_path)}")
return True return True
# Fetch lyrics from LRClib # Fetch lyrics from LRClib
@ -95,27 +96,31 @@ class LyricsClient:
logger.debug(f"No lyrics found for: {artist_name} - {track_name}") logger.debug(f"No lyrics found for: {artist_name} - {track_name}")
return False return False
# Prefer synced lyrics, fallback to plain text # LRClib API provides synced_lyrics (timestamped) and plain_lyrics (text only)
# LRClib API uses synced_lyrics and plain_lyrics attributes synced = getattr(lyrics_data, 'synced_lyrics', None)
lrc_content = getattr(lyrics_data, 'synced_lyrics', None) or getattr(lyrics_data, 'plain_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"Synced lyrics available: {bool(synced)}")
logger.debug(f"Plain lyrics available: {bool(getattr(lyrics_data, 'plain_lyrics', None))}") logger.debug(f"Plain lyrics available: {bool(plain)}")
logger.debug(f"LRC content found: {bool(lrc_content)}")
if not lrc_content: if not synced and not plain:
logger.debug(f"No usable lyrics content for: {artist_name} - {track_name}") logger.debug(f"No usable lyrics content for: {artist_name} - {track_name}")
return False return False
# Write LRC file if synced:
with open(lrc_path, 'w', encoding='utf-8') as f: # Synced lyrics have timestamps → valid .lrc format
f.write(lrc_content) with open(lrc_path, 'w', encoding='utf-8') as f:
f.write(synced)
# Embed lyrics directly in audio file tags (Navidrome/Jellyfin read these) # Embed synced lyrics in audio tags
self._embed_lyrics(audio_file_path, lrc_content) self._embed_lyrics(audio_file_path, synced)
logger.info(f"✅ Created synced LRC + embedded: {os.path.basename(lrc_path)}")
lyrics_type = "synced" if getattr(lyrics_data, 'synced_lyrics', None) else "plain" else:
logger.info(f"✅ Created {lyrics_type} LRC file + embedded: {os.path.basename(lrc_path)}") # 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 return True
except Exception as e: except Exception as e:

View file

@ -15393,11 +15393,12 @@ def _downsample_hires_flac(final_path, context):
try: try:
os.rename(final_path, new_path) os.rename(final_path, new_path)
print(f"📝 [Downsample] Renamed: {os.path.basename(final_path)}{new_basename}") print(f"📝 [Downsample] Renamed: {os.path.basename(final_path)}{new_basename}")
# Rename matching LRC file if it exists # Rename matching lyrics sidecar file if it exists (.lrc or .txt)
old_lrc = os.path.splitext(final_path)[0] + '.lrc' for lyrics_ext in ('.lrc', '.txt'):
if os.path.isfile(old_lrc): old_lyrics = os.path.splitext(final_path)[0] + lyrics_ext
new_lrc = os.path.splitext(new_path)[0] + '.lrc' if os.path.isfile(old_lyrics):
os.rename(old_lrc, new_lrc) new_lyrics = os.path.splitext(new_path)[0] + lyrics_ext
os.rename(old_lyrics, new_lyrics)
return new_path return new_path
except Exception as rename_err: except Exception as rename_err:
print(f"⚠️ [Downsample] Could not rename file: {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: if test_audio is not None:
os.remove(final_path) os.remove(final_path)
print(f"🔥 [Blasphemy Mode] Deleted original: {os.path.basename(final_path)}") print(f"🔥 [Blasphemy Mode] Deleted original: {os.path.basename(final_path)}")
# Rename LRC file to match the output filename # Rename lyrics sidecar file to match the output filename
flac_lrc = os.path.splitext(final_path)[0] + '.lrc' for lyrics_ext in ('.lrc', '.txt'):
if os.path.isfile(flac_lrc): src_lyrics = os.path.splitext(final_path)[0] + lyrics_ext
out_lrc = os.path.splitext(out_path)[0] + '.lrc' if os.path.isfile(src_lyrics):
try: dst_lyrics = os.path.splitext(out_path)[0] + lyrics_ext
os.rename(flac_lrc, out_lrc) try:
print(f"🔥 [Blasphemy Mode] Renamed LRC: {os.path.basename(flac_lrc)} -> {os.path.basename(out_lrc)}") os.rename(src_lyrics, dst_lyrics)
except Exception as lrc_err: print(f"🔥 [Blasphemy Mode] Renamed {lyrics_ext}: {os.path.basename(src_lyrics)} -> {os.path.basename(dst_lyrics)}")
print(f"⚠️ [Blasphemy Mode] Could not rename LRC: {lrc_err}") except Exception as lrc_err:
print(f"⚠️ [Blasphemy Mode] Could not rename {lyrics_ext}: {lrc_err}")
return out_path return out_path
else: else:
print(f"⚠️ [Blasphemy Mode] Output failed audio validation, keeping original: {os.path.basename(final_path)}") 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) os.makedirs(os.path.dirname(new_path), exist_ok=True)
_safe_move_file(current_file_path, new_path) _safe_move_file(current_file_path, new_path)
# Move .lrc file alongside audio file if it exists # Move lyrics sidecar file alongside audio file if it exists
old_lrc = os.path.splitext(current_file_path)[0] + '.lrc' for lyrics_ext in ('.lrc', '.txt'):
if os.path.exists(old_lrc): old_lyrics = os.path.splitext(current_file_path)[0] + lyrics_ext
new_lrc = os.path.splitext(new_path)[0] + '.lrc' if os.path.exists(old_lyrics):
try: new_lyrics = os.path.splitext(new_path)[0] + lyrics_ext
_safe_move_file(old_lrc, new_lrc) try:
print(f"📝 [Retag] Moved .lrc file alongside audio") _safe_move_file(old_lyrics, new_lyrics)
except Exception as lrc_err: print(f"📝 [Retag] Moved {lyrics_ext} file alongside audio")
print(f"⚠️ [Retag] Failed to move .lrc file: {lrc_err}") 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 # Remove old cover.jpg if directory changed and old dir is now empty of audio
new_dir = os.path.dirname(new_path) new_dir = os.path.dirname(new_path)
@ -19204,6 +19207,16 @@ def get_version_info():
"title": "What's New in SoulSync", "title": "What's New in SoulSync",
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes", "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
"sections": [ "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)", "title": "🔧 Fix Collaborative Album Artist Not Applied to Singles (#215)",
"description": "Single path template now respects the First Listed Artist setting", "description": "Single path template now respects the First Listed Artist setting",

View file

@ -3403,6 +3403,7 @@ function closeHelperSearch() {
const WHATS_NEW = { const WHATS_NEW = {
'2.1': [ '2.1': [
// Newest features first // 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 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 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' }, { title: 'Fix Spotify OAuth Empty Response', desc: 'OAuth callback server now always sends a response in Docker — added health check and proper logging' },