From f2ee4f8fad0833faca99e5c04da3bf0bd16cc11c Mon Sep 17 00:00:00 2001 From: Daniele Russo <105118746+DanyR2001@users.noreply.github.com> Date: Mon, 24 Nov 2025 19:25:13 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Added=20album=20lyrics=20download?= =?UTF-8?q?=20to=20`.lrc`=20file=20(#241)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * adding flag for download lyrics * adding sum debug info * adding configuration part * Update config.example.toml * refactor: move lyrics to utility and apply code review feedback * refactor: optimize lyrics download and apply code review feedback - Pass album_items directly to lyrics function to avoid redundant API calls - Remove unused Path import from api.py - Remove duplicate debug logging already covered elsewhere - Use last_album_items reference instead of fetching data again - Move lyrics download logic to separate utility function in core.utils.lyrics - Keep lyrics configuration only in docs/config.example.toml without modifying other settings This optimization reduces API calls by reusing already fetched album items data instead of making new requests for lyrics download. --- docs/config.example.toml | 6 ++ tiddl/cli/commands/download/__init__.py | 38 +++++++++++- tiddl/cli/config.py | 11 ++++ tiddl/core/api/api.py | 2 +- tiddl/core/utils/__init__.py | 2 + tiddl/core/utils/lyrics.py | 82 +++++++++++++++++++++++++ 6 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 tiddl/core/utils/lyrics.py diff --git a/docs/config.example.toml b/docs/config.example.toml index 0cc93cc..cff9cb7 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -95,6 +95,12 @@ cover = false # only works when downloading album album_review = false +[lyrics] +#please don't confuse de metadata lyrics with .lrc file that is a stand alone file +save = true # dowload file .lrc + +[lyrics.templates] +album = "{item.number:02d} - {item.title}" [cover] # please don't confuse the cover from metadata with cover as a distinct file. diff --git a/tiddl/cli/commands/download/__init__.py b/tiddl/cli/commands/download/__init__.py index 43493bb..5177790 100644 --- a/tiddl/cli/commands/download/__init__.py +++ b/tiddl/cli/commands/download/__init__.py @@ -25,6 +25,7 @@ from tiddl.cli.utils.resource import TidalResource from tiddl.cli.ctx import Context from tiddl.cli.commands.auth import refresh from tiddl.cli.commands.subcommands import register_subcommands +from tiddl.core.utils.lyrics import download_album_lyrics from .downloader import Downloader @@ -118,6 +119,14 @@ def download_callback( help="Videos handling: 'none' to exclude, 'allow' to include, 'only' to download videos only.", ), ] = CONFIG.download.videos_filter, + DOWNLOAD_LYRICS: Annotated[ + bool, + typer.Option( + "--lyrics", + "-l", + help="Download lyrics as .lrc files for albums.", + ), + ] = CONFIG.lyrics.save, ): """ Download Tidal resources. @@ -271,6 +280,7 @@ def download_callback( async def download_album(album: Album): offset = 0 futures = [] + album_items_copy = None cover: Cover | None = None save_cover = ("album" in CONFIG.cover.allowed) and CONFIG.cover.save @@ -293,6 +303,7 @@ def download_callback( album_items = ctx.obj.api.get_album_items_credits( album_id=album.id, offset=offset ) + album_items_copy = album_items for album_item in album_items.items: futures.append( @@ -336,7 +347,32 @@ def download_callback( / format_template( template=CONFIG.cover.templates.album, album=album ) - ) + ) + + # Download lyrics using last fetched album_items + if DOWNLOAD_LYRICS and album_items_copy: + try: + first_item = album_items_copy.items[0].item if album_items_copy.items else None + if first_item: + album_path = Path(format_template( + template=CONFIG.templates.album, + item=first_item, + album=album, + quality="" + )).parent + + full_album_path = DOWNLOAD_PATH / album_path + + download_album_lyrics( + get_track_lyrics=ctx.obj.api.get_track_lyrics, + album_items=album_items_copy, + song_dir=full_album_path, + skip_existing=not SKIP_EXISTING, + lyrics_template=CONFIG.lyrics.templates.album + ) + log.info("✓ Lyrics downloaded") + except Exception as e: + log.error(f"Could not download lyrics: {e}") # resources should be collected from a distinct function # that would yield the resources. diff --git a/tiddl/cli/config.py b/tiddl/cli/config.py index c2447a1..1f09ed8 100644 --- a/tiddl/cli/config.py +++ b/tiddl/cli/config.py @@ -44,6 +44,17 @@ class Config(BaseModel): cover: CoverConfig = CoverConfig() + class LyricsConfig(BaseModel): + save: bool = False # save file .lrc separete + + class LyricsTemplatesConfig(BaseModel): + album: str = "{item.number:02d} - {item.title}" + playlist: str = "{playlist.index:02d} - {item.title}" + + templates: LyricsTemplatesConfig = LyricsTemplatesConfig() + + lyrics: LyricsConfig = LyricsConfig() + class DownloadConfig(BaseModel): track_quality: TRACK_QUALITY_LITERAL = "high" video_quality: VIDEO_QUALITY_LITERAL = "fhd" diff --git a/tiddl/core/api/api.py b/tiddl/core/api/api.py index b14d5d8..a7f6889 100644 --- a/tiddl/core/api/api.py +++ b/tiddl/core/api/api.py @@ -103,7 +103,7 @@ class TidalAPI: {"countryCode": self.country_code}, expire_after=3600, ) - + def get_artist(self, artist_id: ID): return self.client.fetch( Artist, diff --git a/tiddl/core/utils/__init__.py b/tiddl/core/utils/__init__.py index f83e5db..68eac4a 100644 --- a/tiddl/core/utils/__init__.py +++ b/tiddl/core/utils/__init__.py @@ -1,6 +1,7 @@ from .parse import parse_track_stream, parse_video_stream from .download import get_track_stream_data, get_video_stream_data from .format import format_template +from .lyrics import download_album_lyrics __all__ = [ "parse_track_stream", @@ -8,4 +9,5 @@ __all__ = [ "get_track_stream_data", "get_video_stream_data", "format_template", + "download_album_lyrics", ] diff --git a/tiddl/core/utils/lyrics.py b/tiddl/core/utils/lyrics.py new file mode 100644 index 0000000..5b64ab1 --- /dev/null +++ b/tiddl/core/utils/lyrics.py @@ -0,0 +1,82 @@ +import re +from pathlib import Path +from logging import getLogger + +from tiddl.core.api.models import AlbumItems +from tiddl.core.utils.format import format_template + +log = getLogger(__name__) + + +def download_album_lyrics( + get_track_lyrics, + album_items: AlbumItems, + song_dir: Path, + skip_existing: bool = True, + lyrics_template: str = "{item.number:02d} - {item.title}", +) -> bool: + """ + Download lyrics for tracks in an album as .lrc files + + Args: + get_track_lyrics: Function to fetch lyrics for a track (api.get_track_lyrics) + album_items: AlbumItems object containing tracks + song_dir: Directory where lyrics files will be saved + skip_existing: Skip download if .lrc file already exists + lyrics_template: Template for lyrics filename formatting + + Returns: + True if any lyrics were downloaded, False otherwise + """ + + lyrics_downloaded = False + + for item in album_items.items: + track = item.item if hasattr(item, "item") else item + + if not hasattr(track, "trackNumber"): + continue + + filename = format_template( + template=lyrics_template, + item=track, + album=None, + quality="", + with_asterisk_ext=False, + ) + + filename = re.sub(r'[<>:"/\\|?*]', "_", filename) + lrc_path = song_dir / f"{filename}.lrc" + + if skip_existing and lrc_path.exists(): + continue + + try: + lyrics = get_track_lyrics(track.id) + + if not lyrics.subtitles and not lyrics.lyrics: + continue + + content = lyrics.subtitles if lyrics.subtitles else lyrics.lyrics + + if not content: + continue + + if not lyrics.subtitles and lyrics.lyrics: + lines = [] + for line in lyrics.lyrics.splitlines(): + if line.strip(): + lines.append(f"[00:00.00]{line}") + content = "\n".join(lines) + + lrc_path.parent.mkdir(parents=True, exist_ok=True) + with lrc_path.open("w", encoding="utf-8") as f: + f.write(content) + + lyrics_downloaded = True + + except Exception as e: + log.debug(f"Could not download lyrics for {track.title}: {e}") + continue + + return lyrics_downloaded \ No newline at end of file