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:
parent
c5652b0d4b
commit
fde1a7d77e
3 changed files with 61 additions and 42 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue