mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user