Embed lyrics directly in audio file tags

Lyrics from LRClib are now embedded in audio file tags (USLT for MP3,
lyrics for FLAC/OGG, ©lyr for M4A) in addition to the .lrc sidecar
file. Navidrome, Jellyfin, and Plex can read embedded lyrics but not
all support .lrc files.

Embedding is best-effort — if it fails, the .lrc file still exists.
Only adds to existing tags, never creates tags from scratch.
This commit is contained in:
Broque Thomas 2026-03-29 20:15:26 -07:00
parent f68cae64a7
commit 7cff379aa7
3 changed files with 49 additions and 1 deletions

View file

@ -111,8 +111,11 @@ class LyricsClient:
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: {os.path.basename(lrc_path)}")
logger.info(f"✅ Created {lyrics_type} LRC file + embedded: {os.path.basename(lrc_path)}")
return True
except Exception as e:
@ -120,5 +123,40 @@ class LyricsClient:
return False
def _embed_lyrics(self, audio_file_path: str, lyrics_text: str):
"""Embed lyrics directly into audio file tags."""
try:
from mutagen import File as MutagenFile
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.mp4 import MP4
from mutagen.id3 import ID3, USLT
audio = MutagenFile(audio_file_path)
if audio is None:
return
if audio.tags is None:
return # Don't create tags just for lyrics
if isinstance(audio.tags, ID3):
audio.tags.delall('USLT')
audio.tags.add(USLT(encoding=3, lang='eng', desc='', text=lyrics_text))
audio.save(v1=0, v2_version=4)
elif isinstance(audio, (FLAC, OggVorbis)) or type(audio).__name__ == 'OggOpus':
audio['lyrics'] = [lyrics_text]
if isinstance(audio, FLAC):
audio.save(deleteid3=True)
else:
audio.save()
elif isinstance(audio, MP4):
audio['\xa9lyr'] = [lyrics_text]
audio.save()
logger.debug(f"Embedded lyrics in: {os.path.basename(audio_file_path)}")
except Exception as e:
logger.warning(f"Could not embed lyrics in {os.path.basename(audio_file_path)}: {e}")
# Global instance for easy import
lyrics_client = LyricsClient()

View file

@ -19009,6 +19009,15 @@ def get_version_info():
"title": "What's New in SoulSync",
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
"sections": [
{
"title": "🎵 Embedded Lyrics in Audio Files",
"description": "Lyrics are now embedded directly in audio file tags alongside the .lrc sidecar file",
"features": [
"• Lyrics embedded as USLT (MP3), lyrics (FLAC/OGG), or ©lyr (M4A) tags",
"• Navidrome, Jellyfin, and Plex can now display lyrics without .lrc file support",
"• .lrc sidecar files are still created for compatibility with other players"
]
},
{
"title": "🔧 Fix AcoustID False Positives for Non-English Tracks",
"description": "AcoustID no longer quarantines correct files when titles are in different languages",

View file

@ -3403,6 +3403,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.1': [
// Newest features first
{ title: 'Embedded Lyrics', desc: 'Lyrics now embedded directly in audio file tags — Navidrome, Jellyfin, and Plex can display them' },
{ title: 'Fix AcoustID False Positives', desc: 'High-confidence fingerprints no longer quarantine correct files when titles are in different languages' },
{ title: 'Fix Junk Tags Surviving', desc: 'Soulseek source tags are now wiped to disk immediately — no more album fragmentation from partial metadata' },
{ title: 'Watch All Preview Modal', desc: 'Watch All Unwatched now opens a modal showing which artists will be added before confirming', page: 'library', selector: '#library-watchlist-all-btn' },