mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +00:00
feat(music): embed plain lyrics and save timed lyrics from Song.synced_lyrics
This commit is contained in:
@@ -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
|
||||
read: `album_artist`, `release_type`, `total_tracks`, `total_discs`, `genre`,
|
||||
`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.
|
||||
`explicit`, `isrc`, `upc`, `copyright`, `label`, `lyrics`, `synced_lyrics`,
|
||||
`lyrics_suffix`, and `artwork_url`. Read the class for the current list. Give `lyrics` as
|
||||
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.
|
||||
|
||||
The tagger also falls back to `Song.data` for fields the constructor did not get, such
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
The embedded lyrics are plain text. A service can also save timed lyrics in a file beside
|
||||
the audio, for example `08.Get.Lucky.lrc` next to `08.Get.Lucky.m4a`. A music player reads
|
||||
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
|
||||
warning and continues.
|
||||
|
||||
@@ -46,6 +46,6 @@ def test_a_song_takes_only_the_language():
|
||||
|
||||
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"}
|
||||
info = {"name": "Track 1", "lyrics": "Line", "synced_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")
|
||||
assert (song.lyrics, song.synced_lyrics, song.lyrics_suffix) == ("Line", "[00:01.00]Line", ".lrc")
|
||||
|
||||
@@ -3867,9 +3867,9 @@ class dl:
|
||||
)
|
||||
if include_sample:
|
||||
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.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)
|
||||
self.log.info(f"Saved lyrics to {lyrics_path.name}")
|
||||
|
||||
|
||||
@@ -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"),
|
||||
synced_lyrics=info.get("synced_lyrics"),
|
||||
lyrics_suffix=info.get("lyrics_suffix"),
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -28,6 +28,7 @@ SONG_FIELDS = (
|
||||
"copyright",
|
||||
"label",
|
||||
"lyrics",
|
||||
"synced_lyrics",
|
||||
"lyrics_suffix",
|
||||
"artwork_url",
|
||||
)
|
||||
@@ -59,9 +60,13 @@ class Song(Title):
|
||||
label: Optional[str] = None,
|
||||
lyrics: Optional[str] = None,
|
||||
artwork_url: Optional[str] = None,
|
||||
synced_lyrics: Optional[str] = None,
|
||||
lyrics_suffix: Optional[str] = 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)
|
||||
|
||||
if not name:
|
||||
@@ -113,6 +118,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 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(".")):
|
||||
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.lyrics = lyrics
|
||||
self.artwork_url = artwork_url
|
||||
self.synced_lyrics = synced_lyrics.strip() if synced_lyrics else None
|
||||
self.lyrics_suffix = lyrics_suffix
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
Reference in New Issue
Block a user