Compare commits

...

26 Commits

Author SHA1 Message Date
Oskar Dudziński 5374d1f64f 🚀 bump to 2.3.0 2025-03-03 21:07:00 +01:00
Oskar Dudziński b6607ce64d add singles_filter to config (#105) 2025-03-03 21:05:42 +01:00
Oskar Dudziński b385722946 Track.mixes can be None (#103) 2025-02-26 10:09:36 +01:00
Oskar Dudziński 40f82b51a2 🚀 bump to 2.2.2 2025-02-24 14:55:07 +01:00
Oskar Dudziński a3c744b06c 🐛 ALBUMARTIST metadata tag is now correct (#97) 2025-02-24 14:53:18 +01:00
Oskar Dudziński ab57b700f0 🐛 API token is now refreshing correctly (#99) 2025-02-24 14:47:18 +01:00
Oskar Dudziński e41181e502 📝 Update bug_report.md 2025-02-22 13:25:59 +01:00
Oskar Dudziński e2777faa89 🚀 bump to 2.2.1 2025-02-13 20:18:13 +01:00
Oskar Dudziński 680b9b9760 CLI is now displaying download speed and file size (#93) 2025-02-13 20:01:38 +01:00
Oskar Dudziński 56968be9a2 🐛 refreshing token should now work with context (#91) 2025-02-13 12:40:50 +01:00
oskvr37 92f3feda2e 💬 enable new debug logging format in rich handler 2025-02-13 12:39:33 +01:00
oskvr37 4289875599 ♻️ logger instead of echo 2025-02-13 12:38:21 +01:00
Oskar Dudziński 01b06b480c 🐛 fix copyright can be None (#87) 2025-02-10 18:07:12 +01:00
oskvr37 1908b81334 Merge branch 'main' of https://github.com/oskvr37/tiddl 2025-02-10 18:04:49 +01:00
oskvr37 970dfd016a 📝 update options docs 2025-02-10 17:35:26 +01:00
oskvr37 733b51dd33 automatically refresh token 2025-02-09 17:44:05 +01:00
oskvr37 84358f3537 🐛 fix auth time left 2025-02-09 17:16:55 +01:00
Oskar Dudziński 7a6a742cbb 📝 Update README.md 2025-02-09 16:47:13 +01:00
oskvr37 c4e5486372 ♻️ use console in print 2025-02-09 16:42:35 +01:00
oskvr37 8518e69a9f config command, add --show option 2025-02-09 16:41:05 +01:00
oskvr37 6565ff19c7 Merge branch 'main' of https://github.com/oskvr37/tiddl 2025-02-09 15:22:41 +01:00
oskvr37 3158f795cc config docs, add --locate to config 2025-02-09 15:22:36 +01:00
oskvr37 7f7cfe6b4c add --path option 2025-02-09 15:21:56 +01:00
oskvr37 87e7073f62 💡 add TODO 2025-02-09 15:21:30 +01:00
Oskar Dudziński 02cee273a6 📝 add demo.gif to README 2025-02-09 01:46:44 +01:00
oskvr37 78a382e83e 📝 add demo.gif 2025-02-09 01:40:18 +01:00
14 changed files with 212 additions and 78 deletions
+2 -1
View File
@@ -11,11 +11,12 @@ assignees: oskvr37
Describe what happened.
**To Reproduce**
Which command was used?
What command was used?
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Software (please complete the following information):**
- tiddl version: [e.g. v2.0.1]
- python version: [e.g. 3.11]
- OS: [e.g. Linux, Windows, iOS]
+3 -1
View File
@@ -5,7 +5,9 @@
![GitHub commits since latest release](https://img.shields.io/github/commits-since/oskvr37/tiddl/latest?style=for-the-badge)
[<img src="https://img.shields.io/badge/gitmoji-%20😜%20😍-FFDD67.svg?style=for-the-badge" />](https://gitmoji.dev)
TIDDL is Python CLI application that allows downloading Tidal tracks.
TIDDL is the Python CLI application that allows downloading Tidal tracks and videos!
<img src="https://raw.githubusercontent.com/oskvr37/tiddl/refs/heads/main/docs/demo.gif" alt="tiddl album download in 6 seconds" />
It's inspired by [Tidal-Media-Downloader](https://github.com/yaronzz/Tidal-Media-Downloader) - currently not mantained project.
This repository will contain features requests from that project and will be the enhanced version.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tiddl"
version = "2.2.0"
version = "2.3.0"
description = "Download Tidal tracks with CLI downloader."
readme = "README.md"
requires-python = ">=3.11"
+19 -3
View File
@@ -10,6 +10,8 @@ from .config import ConfigCommand
from tiddl.config import HOME_PATH
from .auth import refresh
@click.group()
@passContext
@@ -20,7 +22,7 @@ from tiddl.config import HOME_PATH
)
def cli(ctx: Context, verbose: bool, quiet: bool, no_cache: bool):
"""TIDDL - Tidal Downloader \u266b"""
ctx.obj = ContextObj(omit_cache=no_cache)
ctx.obj = ContextObj()
# latest logs
file_handler = logging.FileHandler(
@@ -34,11 +36,20 @@ def cli(ctx: Context, verbose: bool, quiet: bool, no_cache: bool):
)
)
rich_handler = RichHandler(console=ctx.obj.console, rich_tracebacks=True)
rich_handler.setLevel(
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.basicConfig(
level=logging.DEBUG,
handlers=[
@@ -51,6 +62,11 @@ def cli(ctx: Context, verbose: bool, quiet: bool, no_cache: bool):
logging.getLogger("urllib3").setLevel(logging.ERROR)
if ctx.invoked_subcommand in ("fav", "file", "search", "url"):
ctx.invoke(refresh)
ctx.obj.initApi(omit_cache=no_cache)
cli.add_command(ConfigCommand)
cli.add_command(AuthGroup)
+47 -27
View File
@@ -1,11 +1,17 @@
import click
import logging
from click import style
from time import sleep, time
from tiddl.auth import getDeviceAuth, getToken, refreshToken, removeToken, AuthError
from tiddl.config import AuthConfig
from tiddl.auth import (
getDeviceAuth,
getToken,
refreshToken,
removeToken,
AuthError,
)
from .ctx import passContext, Context
@@ -17,33 +23,46 @@ def AuthGroup():
"""Manage Tidal token."""
@AuthGroup.command("refresh")
@passContext
def refresh(ctx: Context):
"""Refresh auth token when is expired"""
logger.debug("Invoked refresh command")
auth = ctx.obj.config.auth
if auth.refresh_token and time() > auth.expires:
logger.info("Refreshing token...")
token = refreshToken(auth.refresh_token)
ctx.obj.config.auth.expires = token.expires_in + int(time())
ctx.obj.config.auth.token = token.access_token
ctx.obj.config.save()
logger.info("Refreshed auth token!")
@AuthGroup.command("login")
@passContext
def login(ctx: Context):
"""Add token to the config"""
auth = ctx.obj.config.auth
logger.debug("Invoked login command")
if auth.token:
if auth.refresh_token and time() > auth.expires:
click.echo(style("Refreshing token...", fg="yellow"))
token = refreshToken(auth.refresh_token)
ctx.obj.config.auth.expires = token.expires_in + int(time())
ctx.obj.config.auth.token = token.access_token
ctx.obj.config.save()
click.echo(style("Authenticated!", fg="green"))
if ctx.obj.config.auth.token:
logger.info("Already logged in.")
refresh(ctx)
return
auth = getDeviceAuth()
uri = f"https://{auth.verificationUriComplete}"
click.launch(uri)
click.echo(f"Go to {style(uri, fg='cyan')} and complete authentication!")
time_left = time() + auth.expiresIn
logger.info(f"Go to {uri} and complete authentication!")
auth_end_at = time() + auth.expiresIn
while True:
sleep(auth.interval)
@@ -52,29 +71,28 @@ def login(ctx: Context):
token = getToken(auth.deviceCode)
except AuthError as e:
if e.error == "authorization_pending":
# FIX: `Time left: 0 secondsss` 🐍
time_left = auth_end_at - time()
minutes, seconds = time_left // 60, int(time_left % 60)
click.echo(f"\rTime left: {time_left - time():.0f} seconds", nl=False)
click.echo(
f"\rTime left: {minutes:.0f}:{seconds:02d}", nl=False
)
continue
if e.error == "expired_token":
click.echo(
f"\nTime for authentication {style('has expired', fg='red')}."
)
logger.info("\nTime for authentication has expired.")
break
new_auth = AuthConfig(
ctx.obj.config.auth = AuthConfig(
token=token.access_token,
refresh_token=token.refresh_token,
expires=token.expires_in + int(time()),
user_id=str(token.user.userId),
country_code=token.user.countryCode,
)
ctx.obj.config.auth = new_auth
ctx.obj.config.save()
click.echo(style("\nAuthenticated!", fg="green"))
logger.info("\nAuthenticated!")
break
@@ -84,10 +102,12 @@ def login(ctx: Context):
def logout(ctx: Context):
"""Remove token from config"""
logger.debug("Invoked logout command")
access_token = ctx.obj.config.auth.token
if not access_token:
click.echo(style("Not logged in", fg="yellow"))
logger.info("Not logged in.")
return
removeToken(access_token)
@@ -95,4 +115,4 @@ def logout(ctx: Context):
ctx.obj.config.auth = AuthConfig()
ctx.obj.config.save()
click.echo(style("Logged out!", fg="green"))
logger.info("Logged out!")
+41 -5
View File
@@ -2,18 +2,54 @@ import click
from tiddl.config import CONFIG_PATH
from .ctx import Context, passContext
@click.command("config")
@click.option(
"--open",
"-o",
"OPEN_CONFIG",
is_flag=True,
help="Open the configuration file with the default editor",
help="Open the configuration file with the default editor.",
)
def ConfigCommand(open: bool):
"""Print path to the configuration file."""
@click.option(
"--locate",
"-l",
"LOCATE_CONFIG",
is_flag=True,
help="Launch a file manager with the located configuration file.",
)
@click.option(
"--print",
"-p",
"PRINT_CONFIG",
is_flag=True,
help="Show current configuration.",
)
@passContext
def ConfigCommand(
ctx: Context, OPEN_CONFIG: bool, LOCATE_CONFIG: bool, PRINT_CONFIG: bool
):
"""
Configuration file options.
click.echo(str(CONFIG_PATH))
By default it prints location of tiddl config file.
if open:
This command can be used in variable like `vim $(tiddl config)`
- this will open your config with vim editor.
"""
if OPEN_CONFIG:
click.launch(str(CONFIG_PATH))
elif LOCATE_CONFIG:
click.launch(str(CONFIG_PATH), locate=True)
elif PRINT_CONFIG:
config_without_auth = ctx.obj.config.model_copy()
del config_without_auth.auth
ctx.obj.console.print(config_without_auth.model_dump_json(indent=2))
else:
click.echo(str(CONFIG_PATH))
+2 -1
View File
@@ -16,12 +16,13 @@ class ContextObj:
resources: list[TidalResource]
console: Console
def __init__(self, omit_cache=False) -> None:
def __init__(self) -> None:
self.config = Config.fromFile()
self.resources = []
self.api = None
self.console = Console()
def initApi(self, omit_cache=False):
auth = self.config.auth
if auth.token and auth.user_id and auth.country_code:
+76 -27
View File
@@ -1,12 +1,14 @@
import logging
import click
from time import perf_counter
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from requests import Session
from rich.highlighter import ReprHighlighter
from rich.progress import (
BarColumn,
SpinnerColumn,
Progress,
TextColumn,
)
@@ -15,7 +17,7 @@ from tiddl.download import parseTrackStream, parseVideoStream
from tiddl.exceptions import ApiError, AuthError
from tiddl.metadata import Cover, addMetadata, addVideoMetadata
from tiddl.models.api import AlbumItemsCredits
from tiddl.models.constants import ARG_TO_QUALITY, TrackArg
from tiddl.models.constants import ARG_TO_QUALITY, TrackArg, SinglesFilter
from tiddl.models.resource import Track, Video, Album
from tiddl.utils import (
TidalResource,
@@ -24,7 +26,7 @@ from tiddl.utils import (
trackExists,
)
from typing import List, Literal, Union
from typing import List, Union
from .fav import FavGroup
from .file import FileGroup
@@ -33,15 +35,29 @@ from .url import UrlGroup
from ..ctx import Context, passContext
SinglesFilter = Literal["none", "only", "include"]
@click.command("download")
@click.option(
"--quality", "-q", "QUALITY", type=click.Choice(TrackArg.__args__)
"--quality",
"-q",
"QUALITY",
type=click.Choice(TrackArg.__args__),
help="Track quality.",
)
@click.option(
"--output", "-o", "TEMPLATE", type=str, help="Format track file template."
"--output",
"-o",
"TEMPLATE",
type=str,
help="Format output file template. "
"This will be used instead of your config templates.",
)
@click.option(
"--path",
"-p",
"PATH",
type=str,
help="Base path of download directory. Default is ~/Music/Tiddl.",
)
@click.option(
"--threads",
@@ -56,30 +72,32 @@ SinglesFilter = Literal["none", "only", "include"]
"DO_NOT_SKIP",
is_flag=True,
default=False,
help="Do not skip already downloaded tracks.",
help="Do not skip already downloaded files.",
)
@click.option(
"--singles",
"-s",
"SINGLES_FILTER",
type=click.Choice(SinglesFilter.__args__),
default="none",
help="Defines how to treat artist EPs and singles.",
help="Defines how to treat artist EPs and singles, used while downloading artist.",
)
@passContext
def DownloadCommand(
ctx: Context,
QUALITY: TrackArg | None,
TEMPLATE: str | None,
PATH: str | None,
THREADS_COUNT: int,
DO_NOT_SKIP: bool,
SINGLES_FILTER: SinglesFilter,
):
"""Download resources"""
SINGLES_FILTER = SINGLES_FILTER or ctx.obj.config.download.singles_filter
# TODO: pretty print
logging.debug(
(QUALITY, TEMPLATE, THREADS_COUNT, DO_NOT_SKIP, SINGLES_FILTER)
(QUALITY, TEMPLATE, PATH, THREADS_COUNT, DO_NOT_SKIP, SINGLES_FILTER)
)
DOWNLOAD_QUALITY = ARG_TO_QUALITY[
@@ -89,8 +107,12 @@ def DownloadCommand(
api = ctx.obj.getApi()
progress = Progress(
TextColumn("{task.description}"),
BarColumn(bar_width=40),
SpinnerColumn(),
TextColumn(
"{task.description}"
"{task.fields[speed]:.2f} Mbps • {task.fields[size]:.2f} MB",
highlighter=ReprHighlighter(),
),
console=ctx.obj.console,
transient=True,
auto_refresh=True,
@@ -101,11 +123,12 @@ def DownloadCommand(
path: Path,
cover_data=b"",
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
album_artist="",
):
if isinstance(item, Track):
track_stream = api.getTrackStream(item.id, quality=DOWNLOAD_QUALITY)
logging.info(
f"Track '{item.title}' "
description = (
f"Track '{item.title}' "
f"{(str(track_stream.bitDepth) + ' bit') if track_stream.bitDepth else ''} "
f"{str(track_stream.sampleRate) + ' kHz' if track_stream.sampleRate else ''}"
)
@@ -113,8 +136,8 @@ def DownloadCommand(
urls, extension = parseTrackStream(track_stream)
elif isinstance(item, Video):
video_stream = api.getVideoStream(item.id)
logging.info(
f"Video '{item.title}' {video_stream.videoQuality} quality"
description = (
f"Video '{item.title}' {video_stream.videoQuality} quality"
)
urls = parseVideoStream(video_stream)
@@ -126,14 +149,18 @@ def DownloadCommand(
)
task_id = progress.add_task(
description=f"{type(item).__name__}: {item.title}",
description=description,
start=True,
visible=True,
total=len(urls),
total=None,
# fields
speed=0,
size=0,
)
with Session() as s:
stream_data = b""
time_start = perf_counter()
for url in urls:
req = s.get(url)
@@ -145,7 +172,18 @@ def DownloadCommand(
)
stream_data += req.content
progress.advance(task_id)
speed = (
len(stream_data)
/ (perf_counter() - time_start)
/ (1024 * 128)
)
size = len(stream_data) / 1024**2
progress.update(
task_id,
advance=len(req.content),
speed=speed,
size=size,
)
path = path.with_suffix(extension)
path.parent.mkdir(parents=True, exist_ok=True)
@@ -167,7 +205,9 @@ def DownloadCommand(
cover_data = Cover(item.album.cover).content
try:
addMetadata(path, item, cover_data, credits)
addMetadata(
path, item, cover_data, credits, album_artist=album_artist
)
except Exception as e:
logging.error(f"Can not add metadata to: {path}, {e}")
@@ -186,7 +226,7 @@ def DownloadCommand(
logging.error(f"Can not add metadata to: {path}, {e}")
progress.remove_task(task_id)
logging.info(f"'{item.title}'")
logging.info(f"{item.title!r}{speed:.2f} Mbps • {size:.2f} MB")
pool = ThreadPoolExecutor(
max_workers=THREADS_COUNT or ctx.obj.config.download.threads
@@ -197,6 +237,7 @@ def DownloadCommand(
filename: str,
cover_data=b"",
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
album_artist="",
):
if not item.allowStreaming:
logging.warning(
@@ -204,7 +245,8 @@ def DownloadCommand(
)
return
path = ctx.obj.config.download.path / f"{filename}.*"
path = Path(PATH) if PATH else ctx.obj.config.download.path
path /= f"{filename}.*"
if not DO_NOT_SKIP: # check if item is already downloaded
if isinstance(item, Track):
@@ -222,10 +264,11 @@ def DownloadCommand(
path=path,
cover_data=cover_data,
credits=credits,
album_artist=album_artist,
)
def downloadAlbum(album: Album):
logging.info(f"Album '{album.title}'")
logging.info(f"Album {album.title!r}")
cover_data = Cover(album.cover).content if album.cover else b""
@@ -241,7 +284,13 @@ def DownloadCommand(
album_artist=album.artist.name,
)
submitItem(item.item, filename, cover_data, item.credits)
submitItem(
item.item,
filename,
cover_data,
item.credits,
album.artist.name,
)
if (
album_items.limit + album_items.offset
@@ -278,7 +327,7 @@ def DownloadCommand(
case "artist":
artist = api.getArtist(resource.id)
logging.info(f"Artist '{artist.name}'")
logging.info(f"Artist {artist.name!r}")
def getAllAlbums(singles: bool):
offset = 0
@@ -309,7 +358,7 @@ def DownloadCommand(
case "playlist":
playlist = api.getPlaylist(resource.id)
logging.info(f"Playlist '{playlist.title}'")
logging.info(f"Playlist {playlist.title!r}")
offset = 0
while True:
+2 -1
View File
@@ -3,7 +3,7 @@
from pydantic import BaseModel
from pathlib import Path
from tiddl.models.constants import TrackArg
from tiddl.models.constants import TrackArg, SinglesFilter
HOME_PATH = Path.home()
CONFIG_PATH = HOME_PATH / "tiddl.json"
@@ -21,6 +21,7 @@ class DownloadConfig(BaseModel):
quality: TrackArg = "high"
path: Path = Path.home() / "Music" / "Tiddl"
threads: int = 4
singles_filter: SinglesFilter = "none"
class AuthConfig(BaseModel):
+3 -2
View File
@@ -105,8 +105,9 @@ def downloadTrackStream(track_stream: TrackStream) -> tuple[bytes, str]:
def parseVideoStream(video_stream: VideoStream) -> list[str]:
"""Parse `video_stream` manifest and return video urls"""
# TOOD: add video quality arg.
# for now we download the highest quality
# TODO: add video quality arg,
# for now we download the highest quality.
# -vq option in download command
class VideoManifest(BaseModel):
mimeType: str
+13 -7
View File
@@ -22,6 +22,7 @@ def addMetadata(
track: Track,
cover_data=b"",
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
album_artist="",
):
logger.debug((track_path, track.id))
@@ -42,23 +43,28 @@ def addMetadata(
metadata["TRACKNUMBER"] = str(track.trackNumber)
metadata["DISCNUMBER"] = str(track.volumeNumber)
if track.artist:
metadata["ARTIST"] = track.artist.name
metadata["ARTISTS"] = [artist.name for artist in track.artists]
metadata["ALBUM"] = track.album.title
metadata["ALBUMARTIST"] = ", ".join(
metadata["ARTIST"] = "; ".join(
[artist.name.strip() for artist in track.artists]
)
if album_artist:
metadata["ALBUMARTIST"] = album_artist
elif track.artist:
metadata["ALBUMARTIST"] = track.artist.name
if track.streamStartDate:
metadata["DATE"] = 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"))
metadata["COPYRIGHT"] = track.copyright
if track.copyright:
metadata["COPYRIGHT"] = track.copyright
metadata["ISRC"] = track.isrc
if track.bpm:
@@ -83,7 +89,7 @@ def addMetadata(
"title": track.title,
"tracknumber": str(track.trackNumber),
"discnumber": str(track.volumeNumber),
"copyright": track.copyright,
"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]
+1
View File
@@ -2,6 +2,7 @@ from typing import Literal
TrackQuality = Literal["LOW", "HIGH", "LOSSLESS", "HI_RES_LOSSLESS"]
TrackArg = Literal["low", "normal", "high", "master"]
SinglesFilter = Literal["none", "only", "include"]
ARG_TO_QUALITY: dict[TrackArg, TrackQuality] = {
"low": "LOW",
+2 -2
View File
@@ -38,7 +38,7 @@ class Track(BaseModel):
volumeNumber: int
version: Optional[str] = None
popularity: int
copyright: str
copyright: Optional[str] = None
bpm: Optional[int] = None
url: str
isrc: str
@@ -51,7 +51,7 @@ class Track(BaseModel):
artist: Optional[Artist] = None
artists: List[Artist]
album: Album
mixes: Dict[str, str]
mixes: Optional[Dict[str, str]] = None
class Video(BaseModel):