diff --git a/docs/dev/service-architecture.md b/docs/dev/service-architecture.md index dfbed817..c0c5f81d 100644 --- a/docs/dev/service-architecture.md +++ b/docs/dev/service-architecture.md @@ -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 diff --git a/docs/guide/output-and-naming.md b/docs/guide/output-and-naming.md index b880bc2f..ada3dd5c 100644 --- a/docs/guide/output-and-naming.md +++ b/docs/guide/output-and-naming.md @@ -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. diff --git a/tests/core/test_load_tracks_enrich.py b/tests/core/test_load_tracks_enrich.py index 82f4145f..3fe4c94a 100644 --- a/tests/core/test_load_tracks_enrich.py +++ b/tests/core/test_load_tracks_enrich.py @@ -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") diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index a77ddde8..bbcb0d52 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -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}") diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 9fa80088..96f0d0c8 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -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: diff --git a/unshackle/core/titles/music.py b/unshackle/core/titles/music.py index 4d2fd5a5..65702ef7 100644 --- a/unshackle/core/titles/music.py +++ b/unshackle/core/titles/music.py @@ -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: