From b107bca1681ba80c2ca9259887c3fd93291a2a7a Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Wed, 14 Oct 2026 11:04:29 -0600 Subject: [PATCH] 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. --- docs/dev/service-architecture.md | 6 ++++-- docs/guide/output-and-naming.md | 4 ++++ tests/core/test_load_tracks_enrich.py | 7 +++++++ unshackle/commands/dl.py | 5 +++++ unshackle/core/api/handlers.py | 20 ++------------------ unshackle/core/remote_service.py | 5 +++-- unshackle/core/titles/music.py | 26 ++++++++++++++++++++++++++ 7 files changed, 51 insertions(+), 22 deletions(-) diff --git a/docs/dev/service-architecture.md b/docs/dev/service-architecture.md index 7b90e42e..dfbed817 100644 --- a/docs/dev/service-architecture.md +++ b/docs/dev/service-architecture.md @@ -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 read: `album_artist`, `release_type`, `total_tracks`, `total_discs`, `genre`, - `explicit`, `isrc`, `upc`, `copyright`, `label`, `lyrics`, and `artwork_url`. Read the - class for the current list. + `explicit`, `isrc`, `upc`, `copyright`, `label`, `lyrics`, `lyrics_suffix`, and + `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 as the composer or a full release date. It reads the top level of that dict and a diff --git a/docs/guide/output-and-naming.md b/docs/guide/output-and-naming.md index daed2a37..b880bc2f 100644 --- a/docs/guide/output-and-naming.md +++ b/docs/guide/output-and-naming.md @@ -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 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 warning and continues. diff --git a/tests/core/test_load_tracks_enrich.py b/tests/core/test_load_tracks_enrich.py index fabe9ca0..82f4145f 100644 --- a/tests/core/test_load_tracks_enrich.py +++ b/tests/core/test_load_tracks_enrich.py @@ -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) apply_enrich(song, "The Gold Seekers", 2017, Language.get("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") diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 134a973b..a77ddde8 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -3867,6 +3867,11 @@ class dl: ) if include_sample: 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( title, diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index 288029b8..9407b22d 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -34,6 +34,7 @@ from unshackle.core.proxies.resolve import initialize_proxy_providers, resolve_p from unshackle.core.services import Services from unshackle.core.session import utf8_text 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.utilities import declared_kwargs, kind_only_conflict 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: result["daily"] = title.daily if is_song: - for field in ( - "artist", - "album", - "track", - "disc", - "album_artist", - "release_type", - "total_tracks", - "total_discs", - "genre", - "explicit", - "isrc", - "upc", - "copyright", - "label", - "lyrics", - "artwork_url", - ): + for field in SONG_FIELDS: result[field] = getattr(title, field, None) if isinstance(title, (Episode, Movie)) and getattr(title, "anime", None) is not None: result["anime"] = title.anime diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 33387137..9fa80088 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -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.episode import Episode, Series 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.attachment import Attachment 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"): 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: 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"), lyrics=info.get("lyrics"), artwork_url=info.get("artwork_url"), + lyrics_suffix=info.get("lyrics_suffix"), ) else: raise click.ClickException( diff --git a/unshackle/core/titles/music.py b/unshackle/core/titles/music.py index 2796a4e1..4d2fd5a5 100644 --- a/unshackle/core/titles/music.py +++ b/unshackle/core/titles/music.py @@ -12,6 +12,27 @@ from unshackle.core.titles.title import Title from unshackle.core.utilities import sanitize_filename 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): def __init__( @@ -38,7 +59,9 @@ class Song(Title): label: Optional[str] = None, lyrics: Optional[str] = None, artwork_url: Optional[str] = None, + lyrics_suffix: Optional[str] = None, ) -> None: + """`lyrics_suffix` (".lrc", ".ttml") also saves `lyrics` beside the audio file with that suffix.""" super().__init__(id_, service, language, data) if not name: @@ -90,6 +113,8 @@ class Song(Title): raise TypeError(f"Expected lyrics to be a str, not {lyrics!r}") 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}") + 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() artist = artist.strip() @@ -135,6 +160,7 @@ class Song(Title): self.label = label self.lyrics = lyrics self.artwork_url = artwork_url + self.lyrics_suffix = lyrics_suffix def __str__(self) -> str: return "{artist} - {album} ({year}) / {track:02}. {name}".format(