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
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
+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
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.
+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)
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")
+5
View File
@@ -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,
+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.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
+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.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(
+26
View File
@@ -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(