feat(music): save lyrics beside the audio and keep song fields over remote

Song.lyrics_suffix writes the lyrics file; song fields set in get_tracks now reach a remote client.
This commit is contained in:
imSp4rky
2026-10-14 11:04:29 -06:00
parent d41bb73d9e
commit b107bca168
7 changed files with 51 additions and 22 deletions
+4 -2
View File
@@ -197,8 +197,10 @@ 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`, and `artwork_url`. Read the `explicit`, `isrc`, `upc`, `copyright`, `label`, `lyrics`, `lyrics_suffix`, and
class for the current list. `artwork_url`. Read the class for the current list. With `lyrics_suffix` (`".lrc"`,
`".ttml"`), unshackle also saves `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.
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
as the composer or a full release date. It reads the top level of that dict and a as the composer or a full release date. It reads the top level of that dict and a
+4
View File
@@ -269,6 +269,10 @@ 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
`08.Get.Lucky.lrc` next to `08.Get.Lucky.m4a`. A music player reads the timed lines from
it. 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.
+7
View File
@@ -42,3 +42,10 @@ def test_a_song_takes_only_the_language():
song = Song(id_="song-0001", service=SERVICE, name="Track 1", artist="A", album="B", track=1, disc=1, year=1999) song = Song(id_="song-0001", service=SERVICE, name="Track 1", artist="A", album="B", track=1, disc=1, year=1999)
apply_enrich(song, "The Gold Seekers", 2017, Language.get("es")) apply_enrich(song, "The Gold Seekers", 2017, Language.get("es"))
assert (song.name, song.year, str(song.language)) == ("Track 1", 1999, "es") assert (song.name, song.year, str(song.language)) == ("Track 1", 1999, "es")
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)
info = {"name": "Track 1", "lyrics": "[00:01.00]Line", "lyrics_suffix": ".lrc"}
load_tracks(refreshing_service(info), song)
assert (song.lyrics, song.lyrics_suffix) == ("[00:01.00]Line", ".lrc")
+5
View File
@@ -3867,6 +3867,11 @@ 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:
lyrics_path = final_path.with_suffix(title.lyrics_suffix)
lyrics_path.write_text(f"{title.lyrics}\n", encoding="utf-8")
sidecar_files.setdefault(muxed_path, []).append(lyrics_path)
self.log.info(f"Saved lyrics to {lyrics_path.name}")
post_script_context = build_context( post_script_context = build_context(
title, title,
+2 -18
View File
@@ -34,6 +34,7 @@ from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_p
from unshackle.core.services import Services from unshackle.core.services import Services
from unshackle.core.session import utf8_text from unshackle.core.session import utf8_text
from unshackle.core.titles import Episode, Movie, Song, Title_T from unshackle.core.titles import Episode, Movie, Song, Title_T
from unshackle.core.titles.music import SONG_FIELDS
from unshackle.core.tracks import Audio, Subtitle, Tracks, Video from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
from unshackle.core.utilities import declared_kwargs, kind_only_conflict from unshackle.core.utilities import declared_kwargs, kind_only_conflict
from unshackle.core.utils.click_types import AUDIO_CODEC_LIST, SUBTITLE_CODEC, VIDEO_CODEC_LIST from unshackle.core.utils.click_types import AUDIO_CODEC_LIST, SUBTITLE_CODEC, VIDEO_CODEC_LIST
@@ -1158,24 +1159,7 @@ def serialize_title(title: Title_T) -> Dict[str, Any]:
if getattr(title, "daily", None) is not None: if getattr(title, "daily", None) is not None:
result["daily"] = title.daily result["daily"] = title.daily
if is_song: if is_song:
for field in ( for field in SONG_FIELDS:
"artist",
"album",
"track",
"disc",
"album_artist",
"release_type",
"total_tracks",
"total_discs",
"genre",
"explicit",
"isrc",
"upc",
"copyright",
"label",
"lyrics",
"artwork_url",
):
result[field] = getattr(title, field, None) result[field] = getattr(title, field, None)
if isinstance(title, (Episode, Movie)) and getattr(title, "anime", None) is not None: if isinstance(title, (Episode, Movie)) and getattr(title, "anime", None) is not None:
result["anime"] = title.anime result["anime"] = title.anime
+3 -2
View File
@@ -44,7 +44,7 @@ from unshackle.core.session import MAX_BACKOFF
from unshackle.core.titles import Title_T, Titles_T, remap_titles from unshackle.core.titles import Title_T, Titles_T, remap_titles
from unshackle.core.titles.episode import Episode, Series from unshackle.core.titles.episode import Episode, Series
from unshackle.core.titles.movie import Movie, Movies from unshackle.core.titles.movie import Movie, Movies
from unshackle.core.titles.music import Album, Song from unshackle.core.titles.music import SONG_FIELDS, Album, Song
from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks, Video from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Tracks, Video
from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.attachment import Attachment
from unshackle.core.tracks.track import Track from unshackle.core.tracks.track import Track
@@ -599,7 +599,7 @@ def apply_title_fields(title: Title_T, info: Dict[str, Any]) -> None:
elif info.get("name"): elif info.get("name"):
title.name = info["name"] title.name = info["name"]
for key in ("anime", "daily"): for key in ("anime", "daily", *(SONG_FIELDS if isinstance(title, Song) else ())):
if info.get(key) is not None: if info.get(key) is not None:
setattr(title, key, info[key]) setattr(title, key, info[key])
@@ -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"),
lyrics_suffix=info.get("lyrics_suffix"),
) )
else: else:
raise click.ClickException( raise click.ClickException(
+26
View File
@@ -12,6 +12,27 @@ from unshackle.core.titles.title import Title
from unshackle.core.utilities import sanitize_filename from unshackle.core.utilities import sanitize_filename
from unshackle.core.utils.template_formatter import TemplateFormatter, detect_spacer from unshackle.core.utils.template_formatter import TemplateFormatter, detect_spacer
SONG_FIELDS = (
"artist",
"album",
"track",
"disc",
"album_artist",
"release_type",
"total_tracks",
"total_discs",
"genre",
"explicit",
"isrc",
"upc",
"copyright",
"label",
"lyrics",
"lyrics_suffix",
"artwork_url",
)
"""The `Song` attributes a remote session sends, beyond the fields every title has."""
class Song(Title): class Song(Title):
def __init__( def __init__(
@@ -38,7 +59,9 @@ 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,
lyrics_suffix: Optional[str] = None,
) -> None: ) -> None:
"""`lyrics_suffix` (".lrc", ".ttml") also saves `lyrics` beside the audio file with that suffix."""
super().__init__(id_, service, language, data) super().__init__(id_, service, language, data)
if not name: if not name:
@@ -90,6 +113,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 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}")
name = name.strip() name = name.strip()
artist = artist.strip() artist = artist.strip()
@@ -135,6 +160,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.lyrics_suffix = lyrics_suffix
def __str__(self) -> str: def __str__(self) -> str:
return "{artist} - {album} ({year}) / {track:02}. {name}".format( return "{artist} - {album} ({year}) / {track:02}. {name}".format(