feat(music): embed plain lyrics and save timed lyrics from Song.synced_lyrics

This commit is contained in:
imSp4rky
2026-10-14 12:37:43 -06:00
parent b107bca168
commit 8a1fbf2de1
6 changed files with 22 additions and 11 deletions
+5 -3
View File
@@ -197,9 +197,11 @@ Each subclass adds its own required fields on top of `id_` and `service`:
A `Song` also takes optional metadata keywords that the tagger and the naming templates A `Song` also takes optional metadata keywords that the tagger and the naming templates
read: `album_artist`, `release_type`, `total_tracks`, `total_discs`, `genre`, read: `album_artist`, `release_type`, `total_tracks`, `total_discs`, `genre`,
`explicit`, `isrc`, `upc`, `copyright`, `label`, `lyrics`, `lyrics_suffix`, and `explicit`, `isrc`, `upc`, `copyright`, `label`, `lyrics`, `synced_lyrics`,
`artwork_url`. Read the class for the current list. With `lyrics_suffix` (`".lrc"`, `lyrics_suffix`, and `artwork_url`. Read the class for the current list. Give `lyrics` as
`".ttml"`), unshackle also saves `lyrics` beside the final audio file under that suffix. plain text: the tagger embeds it as is. With `synced_lyrics` (timed LRC or TTML) and
`lyrics_suffix` (`".lrc"`, `".ttml"`), unshackle also saves the timed lyrics beside the
final audio file under that suffix.
You can set these fields in `get_tracks()` too, and a remote client gets them back. You can set these fields in `get_tracks()` too, and a remote client gets them back.
The tagger also falls back to `Song.data` for fields the constructor did not get, such The tagger also falls back to `Song.data` for fields the constructor did not get, such
+3 -3
View File
@@ -269,9 +269,9 @@ unshackle writes lyrics when the service supplies them: a `LYRICS` Vorbis commen
`USLT` frame for MP3, and a `©lyr` atom for M4A. Ogg uses the same `LYRICS` field as `USLT` frame for MP3, and a `©lyr` atom for M4A. Ogg uses the same `LYRICS` field as
FLAC. unshackle does not write synchronized lyrics (`SYLT`). FLAC. unshackle does not write synchronized lyrics (`SYLT`).
A service can also ask for a lyrics file beside the audio, for example The embedded lyrics are plain text. A service can also save timed lyrics in a file beside
`08.Get.Lucky.lrc` next to `08.Get.Lucky.m4a`. A music player reads the timed lines from the audio, for example `08.Get.Lucky.lrc` next to `08.Get.Lucky.m4a`. A music player reads
it. The file also comes back to the client from a remote server. the timed lines from that file. The file also comes back to the client from a remote server.
A metadata fault does not stop the run. The file is already in place, so unshackle writes a A metadata fault does not stop the run. The file is already in place, so unshackle writes a
warning and continues. warning and continues.
+2 -2
View File
@@ -46,6 +46,6 @@ def test_a_song_takes_only_the_language():
def test_a_song_takes_the_lyrics_set_in_get_tracks(): def test_a_song_takes_the_lyrics_set_in_get_tracks():
song = Song(id_="song-0001", service=SERVICE, name="Track 1", artist="A", album="B", track=1, disc=1) song = Song(id_="song-0001", service=SERVICE, name="Track 1", artist="A", album="B", track=1, disc=1)
info = {"name": "Track 1", "lyrics": "[00:01.00]Line", "lyrics_suffix": ".lrc"} info = {"name": "Track 1", "lyrics": "Line", "synced_lyrics": "[00:01.00]Line", "lyrics_suffix": ".lrc"}
load_tracks(refreshing_service(info), song) load_tracks(refreshing_service(info), song)
assert (song.lyrics, song.lyrics_suffix) == ("[00:01.00]Line", ".lrc") assert (song.lyrics, song.synced_lyrics, song.lyrics_suffix) == ("Line", "[00:01.00]Line", ".lrc")
+2 -2
View File
@@ -3867,9 +3867,9 @@ class dl:
) )
if include_sample: if include_sample:
self.write_sample(final_path, title, media_info) self.write_sample(final_path, title, media_info)
if isinstance(title, Song) and title.lyrics and title.lyrics_suffix: if isinstance(title, Song) and title.synced_lyrics and title.lyrics_suffix:
lyrics_path = final_path.with_suffix(title.lyrics_suffix) lyrics_path = final_path.with_suffix(title.lyrics_suffix)
lyrics_path.write_text(f"{title.lyrics}\n", encoding="utf-8") lyrics_path.write_text(f"{title.synced_lyrics}\n", encoding="utf-8")
sidecar_files.setdefault(muxed_path, []).append(lyrics_path) sidecar_files.setdefault(muxed_path, []).append(lyrics_path)
self.log.info(f"Saved lyrics to {lyrics_path.name}") self.log.info(f"Saved lyrics to {lyrics_path.name}")
+1
View File
@@ -654,6 +654,7 @@ def build_title(info: Dict[str, Any], service_tag: str, fallback_id: str) -> Tit
label=info.get("label"), label=info.get("label"),
lyrics=info.get("lyrics"), lyrics=info.get("lyrics"),
artwork_url=info.get("artwork_url"), artwork_url=info.get("artwork_url"),
synced_lyrics=info.get("synced_lyrics"),
lyrics_suffix=info.get("lyrics_suffix"), lyrics_suffix=info.get("lyrics_suffix"),
) )
else: else:
+9 -1
View File
@@ -28,6 +28,7 @@ SONG_FIELDS = (
"copyright", "copyright",
"label", "label",
"lyrics", "lyrics",
"synced_lyrics",
"lyrics_suffix", "lyrics_suffix",
"artwork_url", "artwork_url",
) )
@@ -59,9 +60,13 @@ class Song(Title):
label: Optional[str] = None, label: Optional[str] = None,
lyrics: Optional[str] = None, lyrics: Optional[str] = None,
artwork_url: Optional[str] = None, artwork_url: Optional[str] = None,
synced_lyrics: Optional[str] = None,
lyrics_suffix: Optional[str] = None, lyrics_suffix: Optional[str] = None,
) -> None: ) -> None:
"""`lyrics_suffix` (".lrc", ".ttml") also saves `lyrics` beside the audio file with that suffix.""" """`lyrics` is the plain text the tagger embeds.
`synced_lyrics` (timed LRC or TTML) is saved beside the audio file with `lyrics_suffix` (".lrc", ".ttml").
"""
super().__init__(id_, service, language, data) super().__init__(id_, service, language, data)
if not name: if not name:
@@ -113,6 +118,8 @@ class Song(Title):
raise TypeError(f"Expected lyrics to be a str, not {lyrics!r}") raise TypeError(f"Expected lyrics to be a str, not {lyrics!r}")
if artwork_url is not None and not isinstance(artwork_url, str): if artwork_url is not None and not isinstance(artwork_url, str):
raise TypeError(f"Expected artwork_url to be a str, not {artwork_url!r}") raise TypeError(f"Expected artwork_url to be a str, not {artwork_url!r}")
if synced_lyrics is not None and not isinstance(synced_lyrics, str):
raise TypeError(f"Expected synced_lyrics to be a str, not {synced_lyrics!r}")
if lyrics_suffix is not None and not (isinstance(lyrics_suffix, str) and lyrics_suffix.startswith(".")): if lyrics_suffix is not None and not (isinstance(lyrics_suffix, str) and lyrics_suffix.startswith(".")):
raise TypeError(f"Expected lyrics_suffix to be a str starting with '.', not {lyrics_suffix!r}") raise TypeError(f"Expected lyrics_suffix to be a str starting with '.', not {lyrics_suffix!r}")
@@ -160,6 +167,7 @@ class Song(Title):
self.label = label self.label = label
self.lyrics = lyrics self.lyrics = lyrics
self.artwork_url = artwork_url self.artwork_url = artwork_url
self.synced_lyrics = synced_lyrics.strip() if synced_lyrics else None
self.lyrics_suffix = lyrics_suffix self.lyrics_suffix = lyrics_suffix
def __str__(self) -> str: def __str__(self) -> str: