Compare commits

...

14 Commits

Author SHA1 Message Date
Oskar Dudziński 0f44f9780a 🚀 bump to 2.6.2 2025-10-02 17:54:49 +02:00
Oskar Dudziński 5d420eeec5 🐛 Fixed M3U config option (#161) 2025-10-02 17:52:46 +02:00
Oskar Dudziński 3053e91134 🚀 bump to 2.6.2a1 2025-10-02 00:17:18 +02:00
Oskar Dudziński 36daea61e0 Fix m3u file saving (#160) 2025-10-02 00:15:39 +02:00
Oskar Dudziński 12a2d4cf5f Added mix downloading 2025-09-29 19:45:05 +02:00
Oskar Dudziński 0c53783497 🚀 bump to 2.6.1 2025-09-25 19:14:17 +02:00
Oskar Dudziński 89a03c829a 🐛 Fixed concurrent playlist download (#159) 2025-09-25 19:12:16 +02:00
Oskar Dudziński 3e2c9373fb 🚀 bump to 2.6.0 2025-09-25 18:52:05 +02:00
Oskar Dudziński 3b12f92bd2 Added config and flag for saving m3u file (#158)
*  Added `save_playlist_m3u` flag

*  Changed scan path flag to `--scan-path`

* ♻️ Refactored, edited logs
2025-09-25 18:51:15 +02:00
Oskar Dudziński bc66861f94 ♻️ Refactor download CLI 2025-09-23 19:49:45 +02:00
Oskar Dudziński 1e1b384f39 🚀 bump to 2.6.0a1 2025-09-22 17:43:45 +02:00
Oskar Dudziński ee6bba1d30 Save playlist to M3U file (#157)
* depracated `trackExists`

* add function `savePlaylistM3U`

* add saving playlist m3u
2025-09-22 17:37:26 +02:00
Oskar Dudziński bea4bf32d0 🎨 Format code 2025-09-22 16:36:21 +02:00
Oskar Dudziński e407d7de41 close #155 (#156) 2025-09-22 16:32:42 +02:00
11 changed files with 242 additions and 98 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tiddl"
version = "2.5.2"
version = "2.6.2"
description = "Download Tidal tracks with CLI downloader."
readme = "README.md"
requires-python = ">=3.11"
+6
View File
@@ -17,6 +17,12 @@ class TestTidalResource(unittest.TestCase):
("playlist/12345678", "playlist", "12345678"),
("https://tidal.com/browse/artist/12345678", "artist", "12345678"),
("artist/12345678", "artist", "12345678"),
(
"https://tidal.com/browse/mix/f93b015796bf93b015796b",
"mix",
"f93b015796bf93b015796b",
),
("mix/f93b015796bf93b015796b", "mix", "f93b015796bf93b015796b"),
]
for resource, expected_type, expected_id in positive_cases:
+23 -10
View File
@@ -26,7 +26,8 @@ from tiddl.models.api import (
TrackStream,
Video,
VideoStream,
Lyrics
Lyrics,
MixItems,
)
from tiddl.models.constants import TrackQuality
@@ -53,6 +54,7 @@ class Limits:
ALBUM_ITEMS = 10
ALBUM_ITEMS_MAX = 100
PLAYLIST = 50
MIX_ITEMS = 100
class TidalApi:
@@ -124,9 +126,7 @@ class TidalApi:
Album, f"albums/{album_id}", {"countryCode": self.country_code}
)
def getAlbumItems(
self, album_id: str | int, limit=LIMITS.ALBUM_ITEMS, offset=0
):
def getAlbumItems(self, album_id: str | int, limit=LIMITS.ALBUM_ITEMS, offset=0):
return self.fetch(
AlbumItems,
f"albums/{album_id}/items",
@@ -177,6 +177,23 @@ class TidalApi:
expire_after=3600,
)
def getMix(
self,
mix_id: str | int,
limit=LIMITS.MIX_ITEMS,
offset=0,
):
return self.fetch(
MixItems,
f"mixes/{mix_id}/items",
{
"countryCode": self.country_code,
"limit": limit,
"offset": offset,
},
expire_after=3600,
)
def getFavorites(self):
return self.fetch(
Favorites,
@@ -192,9 +209,7 @@ class TidalApi:
{"countryCode": self.country_code},
)
def getPlaylistItems(
self, playlist_uuid: str, limit=LIMITS.PLAYLIST, offset=0
):
def getPlaylistItems(self, playlist_uuid: str, limit=LIMITS.PLAYLIST, offset=0):
return self.fetch(
PlaylistItems,
f"playlists/{playlist_uuid}/items",
@@ -215,9 +230,7 @@ class TidalApi:
)
def getSession(self):
return self.fetch(
SessionResponse, "sessions", expire_after=DO_NOT_CACHE
)
return self.fetch(SessionResponse, "sessions", expire_after=DO_NOT_CACHE)
def getLyrics(self, track_id: str | int):
return self.fetch(
+2 -6
View File
@@ -34,18 +34,14 @@ def cli(ctx: Context, verbose: bool, quiet: bool, no_cache: bool):
)
)
LEVEL = (
logging.DEBUG if verbose else logging.ERROR if quiet else logging.INFO
)
LEVEL = logging.DEBUG if verbose else logging.ERROR if quiet else logging.INFO
rich_handler = RichHandler(console=ctx.obj.console, rich_tracebacks=True)
rich_handler.setLevel(LEVEL)
if LEVEL == logging.DEBUG:
rich_handler.setFormatter(
logging.Formatter(
"[%(name)s.%(funcName)s] %(message)s", datefmt="[%X]"
)
logging.Formatter("[%(name)s.%(funcName)s] %(message)s", datefmt="[%X]")
)
logging.basicConfig(
+115 -37
View File
@@ -1,8 +1,9 @@
import logging
import click
import asyncio
from time import perf_counter
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor, Future
from pathlib import Path
from requests import Session
@@ -23,7 +24,8 @@ from tiddl.utils import (
TidalResource,
formatResource,
convertFileExtension,
trackExists,
savePlaylistM3U,
findTrackFilename,
)
from tiddl.cli.ctx import Context, passContext
@@ -95,10 +97,17 @@ from typing import List, Union
help="Enable downloading videos",
)
@click.option(
"--scan_path",
"--scan-path",
"SCAN_PATH",
type=str,
help="Base music directory to scan for existing. Default is 'path'",
help="Base directory to scan for existing tracks. Default is 'path'",
)
@click.option(
"--save-m3u",
"-m3u",
"SAVE_M3U",
is_flag=True,
help="Save M3U file for playlists.",
)
@passContext
def DownloadCommand(
@@ -106,12 +115,13 @@ def DownloadCommand(
QUALITY: TrackArg | None,
TEMPLATE: str | None,
PATH: str | None,
THREADS_COUNT: int,
THREADS_COUNT: int | None,
DO_NOT_SKIP: bool,
SINGLES_FILTER: SinglesFilter,
EMBED_LYRICS: bool,
DOWNLOAD_VIDEO: bool,
SCAN_PATH: str | None
SCAN_PATH: str | None,
SAVE_M3U: bool,
):
"""Download resources"""
DOWNLOAD_VIDEO = DOWNLOAD_VIDEO or ctx.obj.config.download.download_video
@@ -127,7 +137,10 @@ def DownloadCommand(
THREADS_COUNT,
DO_NOT_SKIP,
SINGLES_FILTER,
EMBED_LYRICS
EMBED_LYRICS,
DOWNLOAD_VIDEO,
SCAN_PATH,
SAVE_M3U,
)
)
@@ -153,7 +166,7 @@ def DownloadCommand(
cover_data=b"",
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
album_artist="",
):
) -> Path:
if isinstance(item, Track):
track_stream = api.getTrackStream(item.id, quality=DOWNLOAD_QUALITY)
description = (
@@ -216,13 +229,15 @@ def DownloadCommand(
if isinstance(item, Track):
if track_stream.audioQuality == "HI_RES_LOSSLESS":
path = asyncio.run(convertFileExtension(
source_file=path,
extension=".flac",
remove_source=True,
is_video=False,
copy_audio=True, # extract flac from m4a container
))
path = asyncio.run(
convertFileExtension(
source_file=path,
extension=".flac",
remove_source=True,
is_video=False,
copy_audio=True, # extract flac from m4a container
)
)
if not cover_data and item.album.cover:
cover_data = Cover(item.album.cover).content
@@ -233,18 +248,27 @@ def DownloadCommand(
lyrics_subtitles = ""
try:
addMetadata(path, item, cover_data, credits, album_artist=album_artist, lyrics=lyrics_subtitles)
addMetadata(
path,
item,
cover_data,
credits,
album_artist=album_artist,
lyrics=lyrics_subtitles,
)
except Exception as e:
logging.error(f"Can not add metadata to: {path}, {e}")
elif isinstance(item, Video):
path = asyncio.run(convertFileExtension(
source_file=path,
extension=".mp4",
remove_source=True,
is_video=True,
copy_audio=True,
))
path = asyncio.run(
convertFileExtension(
source_file=path,
extension=".mp4",
remove_source=True,
is_video=True,
copy_audio=True,
)
)
try:
addVideoMetadata(path, item)
@@ -254,6 +278,8 @@ def DownloadCommand(
progress.remove_task(task_id)
logging.info(f"{item.title!r}{speed:.2f} Mbps • {size:.2f} MB")
return path
pool = ThreadPoolExecutor(
max_workers=THREADS_COUNT or ctx.obj.config.download.threads
)
@@ -264,41 +290,55 @@ def DownloadCommand(
cover_data=b"",
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
album_artist="",
):
) -> Future[Path] | None:
if not item.allowStreaming:
logging.warning(
f"{type(item).__name__} '{item.title}' does not allow streaming"
)
return
path = Path(PATH) if PATH else ctx.obj.config.download.path
path /= f"{filename}.*"
scan_path = Path(SCAN_PATH or ctx.obj.config.download.scan_path) / f"{filename}.*" if (SCAN_PATH or ctx.obj.config.download.scan_path) else path # Scan scan_path if set, else scans 'path'.
download_path = Path(PATH) if PATH else ctx.obj.config.download.path
download_path /= f"{filename}.*"
scan_path = Path(SCAN_PATH) if SCAN_PATH else ctx.obj.config.download.scan_path
if scan_path:
scan_path /= f"{filename}.*"
else:
scan_path = download_path
# Respect DOWNLOAD_VIDEO = FALSE over DO_NOT_SKIP (as it's for the file exists check)
if isinstance(item, Video) and not DOWNLOAD_VIDEO:
logging.warning(f"Video '{item.title}' skipped as DOWNLOAD_VIDEO is false")
return
if not DO_NOT_SKIP: # check if item is already downloaded (unless DO_NOT_SKIP is set, then override anything)
# check if item is already downloaded (unless DO_NOT_SKIP is set, then override anything)
if not DO_NOT_SKIP:
if isinstance(item, Track):
if trackExists(item.audioQuality, DOWNLOAD_QUALITY, scan_path):
logging.warning(f"Track '{item.title}' skipped - exists")
return
track_path = findTrackFilename(
item.audioQuality, DOWNLOAD_QUALITY, scan_path
)
if track_path.exists():
logging.info(f"Track '{item.title}' skipped - exists")
future = Future()
future.set_result(track_path)
return future
elif isinstance(item, Video):
if scan_path.with_suffix(".mp4").exists():
logging.warning(f"Video '{item.title}' skipped - exists")
logging.info(f"Video '{item.title}' skipped - exists")
return
pool.submit(
future = pool.submit(
handleItemDownload,
item=item,
path=path,
path=download_path,
cover_data=cover_data,
credits=credits,
album_artist=album_artist,
)
return future
def downloadAlbum(album: Album):
logging.info(f"Album {album.title!r}")
@@ -341,7 +381,7 @@ def DownloadCommand(
offset += album_items.limit
def handleResource(resource: TidalResource) -> None:
logging.debug(f"Handling Resource '{resource}'")
logging.debug(f"'{resource}'")
match resource.type:
case "track":
@@ -365,6 +405,16 @@ def DownloadCommand(
downloadAlbum(album)
case "mix":
mix = api.getMix(resource.id)
for mix_item in mix.items:
filename = formatResource(
TEMPLATE or ctx.obj.config.template.track, mix_item.item
)
submitItem(mix_item.item, filename)
case "artist":
artist = api.getArtist(resource.id)
logging.info(f"Artist {artist.name!r}")
@@ -398,8 +448,11 @@ def DownloadCommand(
case "playlist":
playlist = api.getPlaylist(resource.id)
logging.info(f"Playlist {playlist.title!r}")
logging.info(f"downloading playlist {playlist.title!r}")
offset = 0
playlist_path = None
futures: list[tuple[Future[Path], Track]] = []
while True:
playlist_items = api.getPlaylistItems(playlist.uuid, offset=offset)
@@ -412,7 +465,11 @@ def DownloadCommand(
playlist_index=item.item.index // 100000,
)
submitItem(item.item, filename)
future = submitItem(item.item, filename)
if future:
futures.append((future, item.item))
playlist_path = Path(filename).parent
if (
playlist_items.limit + playlist_items.offset
@@ -422,6 +479,27 @@ def DownloadCommand(
offset += playlist_items.limit
playlist_tracks: list[tuple[Path, Track]] = []
for future, track in futures:
track_path = future.result()
playlist_tracks.append((track_path, track))
path = Path(PATH) if PATH else ctx.obj.config.download.path
if playlist_path and (SAVE_M3U or ctx.obj.config.download.save_playlist_m3u):
savePlaylistM3U(
playlist_tracks=playlist_tracks,
path=path / playlist_path,
filename=f"{playlist.title}.m3u",
)
if playlist.squareImage and playlist_path:
cover = Cover(
uid=playlist.squareImage,
size=1080, # playlist cover must be 1080x1080
)
cover.save(path / playlist_path, ctx.obj.config.cover.filename)
progress.start()
# TODO: make sure every resource is unique
+7 -1
View File
@@ -3,7 +3,13 @@ import click
from tiddl.utils import TidalResource, ResourceTypeLiteral
from tiddl.cli.ctx import Context, passContext
ResourceTypeList: list[ResourceTypeLiteral] = ["track", "video", "album", "artist", "playlist"]
ResourceTypeList: list[ResourceTypeLiteral] = [
"track",
"video",
"album",
"artist",
"playlist",
]
@click.group("fav")
+3 -1
View File
@@ -15,6 +15,7 @@ makedirs(HOME_PATH, exist_ok=True)
CONFIG_PATH = HOME_PATH / "tiddl.json"
CONFIG_INDENT = 2
class TemplateConfig(BaseModel):
track: str = "{artist} - {title}"
video: str = "{artist} - {title}"
@@ -29,7 +30,8 @@ class DownloadConfig(BaseModel):
singles_filter: SinglesFilter = "none"
embed_lyrics: bool = False
download_video: bool = False
scan_path: Path | None = None
scan_path: Path | None = path
save_playlist_m3u: bool = False
class AuthConfig(BaseModel):
+1 -3
View File
@@ -80,9 +80,7 @@ def parseTrackStream(track_stream: TrackStream) -> tuple[list[str], str]:
elif codecs.startswith("mp4"):
file_extension = ".m4a"
else:
raise ValueError(
f"Unknown codecs `{codecs}` (trackId {track_stream.trackId}"
)
raise ValueError(f"Unknown codecs `{codecs}` (trackId {track_stream.trackId}")
return urls, file_extension
+16 -20
View File
@@ -40,8 +40,12 @@ def addMetadata(
picture.mime = "image/jpeg"
metadata.add_picture(picture)
metadata["TITLE"] = track.title + (" ({})".format(track.version) if track.version else "")
metadata["WORK"] = track.title + (" ({})".format(track.version) if track.version else "")
metadata["TITLE"] = track.title + (
" ({})".format(track.version) if track.version else ""
)
metadata["WORK"] = track.title + (
" ({})".format(track.version) if track.version else ""
)
metadata["TRACKNUMBER"] = str(track.trackNumber)
metadata["DISCNUMBER"] = str(track.volumeNumber)
@@ -58,9 +62,7 @@ def addMetadata(
if track.streamStartDate:
metadata["DATE"] = track.streamStartDate.strftime("%Y-%m-%d")
metadata["ORIGINALDATE"] = track.streamStartDate.strftime(
"%Y-%m-%d"
)
metadata["ORIGINALDATE"] = track.streamStartDate.strftime("%Y-%m-%d")
metadata["YEAR"] = str(track.streamStartDate.strftime("%Y"))
metadata["ORIGINALYEAR"] = str(track.streamStartDate.strftime("%Y"))
@@ -102,13 +104,9 @@ def addMetadata(
"discnumber": str(track.volumeNumber),
"copyright": track.copyright if track.copyright else "",
"albumartist": track.artist.name if track.artist else "",
"artist": ";".join(
[artist.name.strip() for artist in track.artists]
),
"artist": ";".join([artist.name.strip() for artist in track.artists]),
"album": track.album.title,
"date": str(track.streamStartDate)
if track.streamStartDate
else "",
"date": str(track.streamStartDate) if track.streamStartDate else "",
"bpm": str(track.bpm or 0),
}
)
@@ -129,9 +127,7 @@ def addVideoMetadata(path: Path, video: Video):
{
"title": video.title,
"albumartist": video.artist.name if video.artist else "",
"artist": ";".join(
[artist.name.strip() for artist in video.artists]
),
"artist": ";".join([artist.name.strip() for artist in video.artists]),
"album": video.album.title if video.album else "",
"date": str(video.streamStartDate) if video.streamStartDate else "",
}
@@ -162,7 +158,9 @@ class Cover:
self.uid = uid
formatted_uid = uid.replace("-", "/")
self.url = f"https://resources.tidal.com/images/{formatted_uid}/{size}x{size}.jpg"
self.url = (
f"https://resources.tidal.com/images/{formatted_uid}/{size}x{size}.jpg"
)
logger.debug((self.uid, self.url))
@@ -172,9 +170,7 @@ class Cover:
req = requests.get(self.url)
if req.status_code != 200:
logger.error(
f"could not download cover. ({req.status_code}) {self.url}"
)
logger.error(f"could not download cover. ({req.status_code}) {self.url}")
return b""
logger.debug(f"got cover: {self.uid}")
@@ -185,13 +181,13 @@ class Cover:
if not self.content:
logger.error("cover file content is empty")
return
file = directory_path / filename
if file.exists():
logger.debug(f"cover already exists ({file})")
return
makedirs(directory_path, exist_ok=True)
try:
+8 -1
View File
@@ -11,7 +11,7 @@ __all__ = [
"Favorites",
"TrackStream",
"Search",
"Lyrics"
"Lyrics",
]
@@ -102,6 +102,13 @@ class PlaylistItems(Items):
items: List[Union[PlaylistTrackItem, PlaylistVideoItem]]
class MixItems(Items):
class MixItem(BaseModel):
item: Track
type: ItemType = "track"
items: List[MixItem]
class Favorites(BaseModel):
PLAYLIST: List[str]
ALBUM: List[str]
+60 -18
View File
@@ -3,6 +3,7 @@ import os
import logging
from ffmpeg_asyncio import FFmpeg
from ffmpeg_asyncio.types import Option as FFmpegOption
from pydantic import BaseModel
from urllib.parse import urlparse
@@ -13,7 +14,7 @@ from typing import Literal, Union, get_args
from tiddl.models.constants import TrackQuality, QUALITY_TO_ARG
from tiddl.models.resource import Track, Video
ResourceTypeLiteral = Literal["track", "video", "album", "playlist", "artist"]
ResourceTypeLiteral = Literal["track", "video", "album", "playlist", "artist", "mix"]
class TidalResource(BaseModel):
@@ -40,7 +41,14 @@ class TidalResource(BaseModel):
if resource_type not in get_args(ResourceTypeLiteral):
raise ValueError(f"Invalid resource type: {resource_type}")
if not resource_id.isdigit() and resource_type != "playlist":
digit_resource_types: list[ResourceTypeLiteral] = [
"track",
"album",
"video",
"artist",
]
if resource_type in digit_resource_types and not resource_id.isdigit():
raise ValueError(f"Invalid resource id: {resource_id}")
return cls(type=resource_type, id=resource_id) # type: ignore
@@ -80,9 +88,7 @@ def formatTrack(
"disc": track.volumeNumber,
"date": (track.streamStartDate if track.streamStartDate else ""),
# i think we can remove year as we are able to format date
"year": track.streamStartDate.strftime("%Y")
if track.streamStartDate
else "",
"year": track.streamStartDate.strftime("%Y") if track.streamStartDate else "",
"playlist": sanitizeString(playlist_title),
"bpm": track.bpm or "",
"quality": QUALITY_TO_ARG[track.audioQuality],
@@ -130,9 +136,9 @@ def formatResource(
"disc": resource.volumeNumber,
"date": (resource.streamStartDate if resource.streamStartDate else ""),
# i think we can remove year as we are able to format date
"year": resource.streamStartDate.strftime("%Y")
if resource.streamStartDate
else "",
"year": (
resource.streamStartDate.strftime("%Y") if resource.streamStartDate else ""
),
"playlist": sanitizeString(playlist_title),
"album_artist": sanitizeString(album_artist),
"playlist_number": playlist_index or 0,
@@ -167,11 +173,11 @@ def formatResource(
return formatted_template
def trackExists(
def findTrackFilename(
track_quality: TrackQuality, download_quality: TrackQuality, file_name: Path
):
) -> Path:
"""
Predict track extension and check if track file exists.
Predict track extension.
"""
FLAC_QUALITIES: list[TrackQuality] = ["LOSSLESS", "HI_RES_LOSSLESS"]
@@ -183,7 +189,7 @@ def trackExists(
full_file_name = file_name.with_suffix(extension)
return full_file_name.exists()
return full_file_name
async def convertFileExtension(
@@ -211,9 +217,11 @@ async def convertFileExtension(
logging.debug("Conversion not required, already %s", extension)
return source_file
ffmpeg_args = {"loglevel": "error"}
ffmpeg_args: dict[str, FFmpegOption | None] = {"loglevel": "error"}
if copy_audio:
ffmpeg_args["acodec"] = "copy"
if is_video:
ffmpeg_args["vcodec"] = "copy"
@@ -221,19 +229,53 @@ async def convertFileExtension(
logging.debug("Trying conversion")
ffmpeg = FFmpeg().option("y")
ffmpeg.input(str(source_file))
ffmpeg.output(str(output_file), **ffmpeg_args)
ffmpeg.output(str(output_file), ffmpeg_args)
@ffmpeg.on("completed")
def on_completed():
logging.debug("Conversion successful for: %s", output_file)
logging.debug(f"converted {output_file}")
if remove_source:
try:
os.remove(source_file)
except OSError as e:
logging.error(f"Error removing source file {source_file}: {e}")
logging.error(f"can't remove source file {source_file}: {e}")
await ffmpeg.execute()
except Exception as e:
logging.error(f"FFMPEG Error during conversion of {source_file}: {e}")
logging.error(f"can't convert file {source_file}: {e}")
return source_file
return output_file
def savePlaylistM3U(
playlist_tracks: list[tuple[Path, Track]], path: Path, filename="playlist.m3u"
):
"""
playlist_tracks: [track_path, Track]
path: m3u file location
filename: name of the m3u file
"""
file = path / sanitizeString(filename)
logging.debug(f"saving m3u file at {file}")
if not playlist_tracks:
logging.warning(f"playlist {file} is empty")
return
try:
with file.open("w", encoding="utf-8") as f:
f.write("#EXTM3U\n")
for track_path, track in playlist_tracks:
f.write(
f"#EXTINF:{track.duration},{track.artist.name if track.artist else ''} - {track.title}\n{track_path}\n"
)
logging.debug(
f"saved m3u file as {file} with {len(playlist_tracks)} tracks"
)
except Exception as e:
logging.error(f"can't save playlist m3u file: {e}")