Compare commits

...

21 Commits

Author SHA1 Message Date
Oskar Dudziński 34c1b1fd4e 🚀 bump to 2.5.0 2025-07-17 11:23:36 +02:00
xiliourt d85fb96a19 Added video download flag and config (#134) 2025-07-17 11:21:41 +02:00
Oskar Dudziński a4a7e66b84 🚀 bump to 2.4.0 2025-06-03 16:41:10 +02:00
Oskar Dudziński 7258df8ec8 Added embedding lyrics to tracks (#129)
* add lyrics api endpoint

* embed lyrics in metadata

* add embed lyrics option
2025-06-03 16:40:14 +02:00
Oskar Dudziński ed0918e7b0 Save album covers on download (#128)
* save cover

* create cover directory before saving

* prepare cover settings

* add cover settings

* add filename setting
2025-06-03 14:50:13 +02:00
Oskar Dudziński a147c94110 🐛 releaseDate can be optional (#127) 2025-05-30 13:07:45 +02:00
Oskar Dudziński 2eb25b81f9 🚀 bump to 2.3.5 2025-05-30 13:04:49 +02:00
Oskar Dudziński 1f1e89a97a 🚀 bump to 2.3.4 2025-05-23 10:41:03 +02:00
Oskar Dudziński f32bab434c 🐛 Fixed incorrect model fields 2025-05-23 10:39:49 +02:00
Oskar Dudziński 13b3c8b03b 🚀 bump to 2.3.3 2025-04-18 19:54:57 +02:00
Oskar Dudziński a2b9f8d5cf 🐛 changed copyright to Optional (#114) 2025-04-18 19:53:56 +02:00
Oskar Dudziński 526c8c5b0e 🐛 Fixed CLI exception at refreshing token 2025-03-20 15:07:06 +01:00
Oskar Dudziński 8e93e4ec9a Added 'TIDDL_PATH' env variable for custom HOME_PATH (#109) 2025-03-20 14:28:39 +01:00
oskvr37 a5a039f6a8 🎨 get rid of relative imports 2025-03-19 23:25:04 +01:00
Oskar Dudziński 7a18b0f6b8 🚀 bump to 2.3.2 2025-03-17 15:36:35 +01:00
lynxstarshine a95645b3fc Added track version to title (#107) 2025-03-15 00:53:12 +01:00
Oskar Dudziński c53b8ce1fa 🚀 bump to 2.3.1 2025-03-06 00:01:24 +01:00
kcrkor 78a0aee1b7 Changes to python-ffmpeg for python3.12 compatibility (#106) 2025-03-05 23:20:17 +01:00
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
20 changed files with 174 additions and 95 deletions
+10
View File
@@ -93,6 +93,16 @@ This command will:
More about file templating [on wiki](https://github.com/oskvr37/tiddl/wiki/Template-formatting).
## Custom tiddl home path
You can set `TIDDL_PATH` environment variable to use custom home path for tiddl.
Example CLI usage:
```sh
TIDDL_PATH=~/custom/tiddl tiddl auth login
```
# Development
Clone the repository
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tiddl"
version = "2.2.2"
version = "2.5.0"
description = "Download Tidal tracks with CLI downloader."
readme = "README.md"
requires-python = ">=3.11"
@@ -20,7 +20,7 @@ dependencies = [
"requests-cache>=1.2.1",
"click>=8.1.7",
"mutagen>=1.47.0",
"ffmpeg-python>=0.2.0",
"python-ffmpeg>=2.0.0",
"m3u8>=6.0.0",
"rich>=13.9.4"
]
+5
View File
@@ -83,6 +83,11 @@ class TestApi(unittest.TestCase):
def test_video_stream(self):
self.api.getVideoStream(373513584)
def test_lyrics(self):
track_id = 103805726
lyrics = self.api.getLyrics(track_id)
self.assertEqual(lyrics.trackId, track_id)
if __name__ == "__main__":
unittest.main()
+6
View File
@@ -26,6 +26,7 @@ from tiddl.models.api import (
TrackStream,
Video,
VideoStream,
Lyrics
)
from tiddl.models.constants import TrackQuality
@@ -218,6 +219,11 @@ class TidalApi:
SessionResponse, "sessions", expire_after=DO_NOT_CACHE
)
def getLyrics(self, track_id: str | int):
return self.fetch(
Lyrics, f"tracks/{track_id}/lyrics", {"countryCode": self.country_code}
)
def getTrack(self, track_id: str | int):
return self.fetch(
Track, f"tracks/{track_id}", {"countryCode": self.country_code}
+2 -2
View File
@@ -2,8 +2,8 @@ import logging
from requests import request
from .exceptions import AuthError
from .models import auth
from tiddl.exceptions import AuthError
from tiddl.models import auth
AUTH_URL = "https://auth.tidal.com/v1/oauth2"
CLIENT_ID = "zU4XHVVkc2tDPo4t"
+5 -7
View File
@@ -3,14 +3,12 @@ import logging
from rich.logging import RichHandler
from .ctx import ContextObj, passContext, Context
from .auth import AuthGroup
from .download import UrlGroup, FavGroup, SearchGroup, FileGroup
from .config import ConfigCommand
from tiddl.config import HOME_PATH
from .auth import refresh
from tiddl.cli.ctx import ContextObj, passContext, Context
from tiddl.cli.auth import AuthGroup
from tiddl.cli.download import UrlGroup, FavGroup, SearchGroup, FileGroup
from tiddl.cli.config import ConfigCommand
from tiddl.cli.auth import refresh
@click.group()
+3 -6
View File
@@ -11,8 +11,7 @@ from tiddl.auth import (
removeToken,
AuthError,
)
from .ctx import passContext, Context
from tiddl.cli.ctx import passContext, Context
logger = logging.getLogger(__name__)
@@ -52,7 +51,7 @@ def login(ctx: Context):
if ctx.obj.config.auth.token:
logger.info("Already logged in.")
refresh(ctx)
ctx.invoke(refresh)
return
auth = getDeviceAuth()
@@ -74,9 +73,7 @@ def login(ctx: Context):
time_left = auth_end_at - time()
minutes, seconds = time_left // 60, int(time_left % 60)
click.echo(
f"\rTime left: {minutes:.0f}:{seconds:02d}", nl=False
)
click.echo(f"\rTime left: {minutes:.0f}:{seconds:02d}", nl=False)
continue
if e.error == "expired_token":
+1 -2
View File
@@ -1,8 +1,7 @@
import click
from tiddl.config import CONFIG_PATH
from .ctx import Context, passContext
from tiddl.cli.ctx import Context, passContext
@click.command("config")
+61 -38
View File
@@ -17,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,
@@ -26,16 +26,13 @@ from tiddl.utils import (
trackExists,
)
from typing import List, Literal, Union
from tiddl.cli.ctx import Context, passContext
from tiddl.cli.download.fav import FavGroup
from tiddl.cli.download.file import FileGroup
from tiddl.cli.download.search import SearchGroup
from tiddl.cli.download.url import UrlGroup
from .fav import FavGroup
from .file import FileGroup
from .search import SearchGroup
from .url import UrlGroup
from ..ctx import Context, passContext
SinglesFilter = Literal["none", "only", "include"]
from typing import List, Union
@click.command("download")
@@ -81,9 +78,22 @@ SinglesFilter = Literal["none", "only", "include"]
"-s",
"SINGLES_FILTER",
type=click.Choice(SinglesFilter.__args__),
default="none",
help="Defines how to treat artist EPs and singles, used while downloading artist.",
)
@click.option(
"--lyrics",
"-l",
"EMBED_LYRICS",
is_flag=True,
help="Embed track lyrics in file metadata.",
)
@click.option(
"--video",
"-v",
"DOWNLOAD_VIDEO",
is_flag=True,
help="Enable downloading videos",
)
@passContext
def DownloadCommand(
ctx: Context,
@@ -93,17 +103,28 @@ def DownloadCommand(
THREADS_COUNT: int,
DO_NOT_SKIP: bool,
SINGLES_FILTER: SinglesFilter,
EMBED_LYRICS: bool,
DOWNLOAD_VIDEO: bool
):
"""Download resources"""
DOWNLOAD_VIDEO = DOWNLOAD_VIDEO or ctx.obj.config.download.download_video
SINGLES_FILTER = SINGLES_FILTER or ctx.obj.config.download.singles_filter
EMBED_LYRICS = EMBED_LYRICS or ctx.obj.config.download.embed_lyrics
# TODO: pretty print
logging.debug(
(QUALITY, TEMPLATE, PATH, THREADS_COUNT, DO_NOT_SKIP, SINGLES_FILTER)
(
QUALITY,
TEMPLATE,
PATH,
THREADS_COUNT,
DO_NOT_SKIP,
SINGLES_FILTER,
EMBED_LYRICS
)
)
DOWNLOAD_QUALITY = ARG_TO_QUALITY[
QUALITY or ctx.obj.config.download.quality
]
DOWNLOAD_QUALITY = ARG_TO_QUALITY[QUALITY or ctx.obj.config.download.quality]
api = ctx.obj.getApi()
@@ -137,9 +158,7 @@ def DownloadCommand(
urls, extension = parseTrackStream(track_stream)
elif isinstance(item, Video):
video_stream = api.getVideoStream(item.id)
description = (
f"Video '{item.title}' {video_stream.videoQuality} quality"
)
description = f"Video '{item.title}' {video_stream.videoQuality} quality"
urls = parseVideoStream(video_stream)
extension = ".ts"
@@ -173,11 +192,7 @@ def DownloadCommand(
)
stream_data += req.content
speed = (
len(stream_data)
/ (perf_counter() - time_start)
/ (1024 * 128)
)
speed = len(stream_data) / (perf_counter() - time_start) / (1024 * 128)
size = len(stream_data) / 1024**2
progress.update(
task_id,
@@ -204,11 +219,14 @@ def DownloadCommand(
if not cover_data and item.album.cover:
cover_data = Cover(item.album.cover).content
if EMBED_LYRICS:
lyrics_subtitles = api.getLyrics(item.id).subtitles
else:
lyrics_subtitles = ""
try:
addMetadata(
path, item, cover_data, credits, album_artist=album_artist
)
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}")
@@ -255,7 +273,7 @@ def DownloadCommand(
logging.warning(f"Track '{item.title}' skipped")
return
elif isinstance(item, Video):
if path.with_suffix(".mp4").exists():
if path.with_suffix(".mp4").exists() or not DOWNLOAD_VIDEO:
logging.warning(f"Video '{item.title}' skipped")
return
@@ -271,7 +289,12 @@ def DownloadCommand(
def downloadAlbum(album: Album):
logging.info(f"Album {album.title!r}")
cover_data = Cover(album.cover).content if album.cover else b""
cover = (
Cover(uid=album.cover, size=ctx.obj.config.cover.size)
if album.cover
else None
)
is_cover_saved = False
offset = 0
@@ -285,18 +308,21 @@ def DownloadCommand(
album_artist=album.artist.name,
)
if cover and not is_cover_saved and ctx.obj.config.cover.save:
path = Path(PATH) if PATH else ctx.obj.config.download.path
cover_path = path / Path(filename).parent
cover.save(cover_path, ctx.obj.config.cover.filename)
is_cover_saved = True
submitItem(
item.item,
filename,
cover_data,
cover.content if cover else b"",
item.credits,
album.artist.name,
)
if (
album_items.limit + album_items.offset
> album_items.totalNumberOfItems
):
if album_items.limit + album_items.offset > album_items.totalNumberOfItems:
break
offset += album_items.limit
@@ -363,14 +389,11 @@ def DownloadCommand(
offset = 0
while True:
playlist_items = api.getPlaylistItems(
playlist.uuid, offset=offset
)
playlist_items = api.getPlaylistItems(playlist.uuid, offset=offset)
for item in playlist_items.items:
filename = formatResource(
template=TEMPLATE
or ctx.obj.config.template.playlist,
template=TEMPLATE or ctx.obj.config.template.playlist,
resource=item.item,
playlist_title=playlist.title,
playlist_index=item.item.index // 100000,
+1 -1
View File
@@ -1,7 +1,7 @@
import click
from tiddl.utils import TidalResource, ResourceTypeLiteral
from ..ctx import Context, passContext
from tiddl.cli.ctx import Context, passContext
ResourceTypeList: list[ResourceTypeLiteral] = ["track", "video", "album", "artist", "playlist"]
+1 -1
View File
@@ -4,8 +4,8 @@ import json
from io import TextIOWrapper
from os.path import splitext
from ..ctx import Context, passContext
from tiddl.utils import TidalResource
from tiddl.cli.ctx import Context, passContext
@click.group("file")
+1 -2
View File
@@ -2,8 +2,7 @@ import click
from tiddl.utils import TidalResource
from tiddl.models.resource import Artist, Album, Playlist, Track, Video
from ..ctx import Context, passContext
from tiddl.cli.ctx import Context, passContext
@click.group("search")
+1 -2
View File
@@ -1,8 +1,7 @@
import click
from ..ctx import Context, passContext
from tiddl.utils import TidalResource
from tiddl.cli.ctx import Context, passContext
class TidalURL(click.ParamType):
+20 -5
View File
@@ -1,15 +1,20 @@
# 3.0 TODO: change config path to ~/.config/tiddl.json
from os import environ, makedirs
from pydantic import BaseModel
from pathlib import Path
from tiddl.models.constants import TrackArg
from tiddl.models.constants import TrackArg, SinglesFilter
TIDDL_ENV_KEY = "TIDDL_PATH"
# 3.0 TODO: rename HOME_PATH to TIDDL_PATH
# 3.0 TODO: add /tiddl to Path.home()
HOME_PATH = Path(environ[TIDDL_ENV_KEY]) if environ.get(TIDDL_ENV_KEY) else Path.home()
makedirs(HOME_PATH, exist_ok=True)
HOME_PATH = Path.home()
CONFIG_PATH = HOME_PATH / "tiddl.json"
CONFIG_INDENT = 2
class TemplateConfig(BaseModel):
track: str = "{artist} - {title}"
video: str = "{artist} - {title}"
@@ -21,6 +26,9 @@ class DownloadConfig(BaseModel):
quality: TrackArg = "high"
path: Path = Path.home() / "Music" / "Tiddl"
threads: int = 4
singles_filter: SinglesFilter = "none"
embed_lyrics: bool = False
download_video: bool = False
class AuthConfig(BaseModel):
@@ -31,9 +39,16 @@ class AuthConfig(BaseModel):
country_code: str = ""
class CoverConfig(BaseModel):
save: bool = False
size: int = 1280
filename: str = "cover.jpg"
class Config(BaseModel):
template: TemplateConfig = TemplateConfig()
download: DownloadConfig = DownloadConfig()
cover: CoverConfig = CoverConfig()
auth: AuthConfig = AuthConfig()
omit_cache: bool = False
+23 -10
View File
@@ -1,6 +1,7 @@
import logging
import requests
from os import makedirs
from pathlib import Path
from mutagen.easymp4 import EasyMP4 as MutagenEasyMP4
@@ -23,6 +24,7 @@ def addMetadata(
cover_data=b"",
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
album_artist="",
lyrics="",
):
logger.debug((track_path, track.id))
@@ -38,8 +40,8 @@ def addMetadata(
picture.mime = "image/jpeg"
metadata.add_picture(picture)
metadata["TITLE"] = track.title
metadata["WORK"] = track.title
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)
@@ -75,13 +77,22 @@ def addMetadata(
contributor.name for contributor in entry.contributors
]
if lyrics:
metadata["LYRICS"] = lyrics
elif extension == ".m4a":
if cover_data:
if lyrics or cover_data:
metadata = MutagenMP4(track_path)
metadata["covr"] = [
MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG)
]
metadata.save(track_path)
if lyrics:
metadata["\xa9lyr"] = [lyrics]
if cover_data:
metadata["covr"] = [
MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG)
]
metadata.save()
metadata = MutagenEasyMP4(track_path)
metadata.update(
@@ -170,16 +181,18 @@ class Cover:
return req.content
def save(self, directory_path: Path):
def save(self, directory_path: Path, filename="cover.jpg"):
if not self.content:
logger.error("cover file content is empty")
return
file = directory_path / "cover.jpg"
file = directory_path / filename
if file.exists():
logger.debug(f"cover already exists ({file})")
return
makedirs(directory_path, exist_ok=True)
try:
with file.open("wb") as f:
+13 -4
View File
@@ -1,7 +1,7 @@
from pydantic import BaseModel
from typing import Optional, List, Literal, Union
from .resource import Album, Artist, Playlist, Track, TrackQuality, Video
from tiddl.models.resource import Album, Artist, Playlist, Track, TrackQuality, Video
__all__ = [
"SessionResponse",
@@ -11,6 +11,7 @@ __all__ = [
"Favorites",
"TrackStream",
"Search",
"Lyrics"
]
@@ -114,9 +115,7 @@ class TrackStream(BaseModel):
assetPresentation: Literal["FULL"]
audioMode: Literal["STEREO"]
audioQuality: TrackQuality
manifestMimeType: Literal[
"application/dash+xml", "application/vnd.tidal.bts"
]
manifestMimeType: Literal["application/dash+xml", "application/vnd.tidal.bts"]
manifestHash: str
manifest: str
albumReplayGain: float
@@ -169,3 +168,13 @@ class Search(BaseModel):
tracks: Tracks
videos: Videos
topHit: Optional[TopHit] = None
class Lyrics(BaseModel):
isRightToLeft: bool
lyrics: str
lyricsProvider: str
providerCommontrackId: str
providerLyricsId: str
subtitles: str
trackId: int
+1 -1
View File
@@ -16,7 +16,7 @@ class AuthUser(BaseModel):
postalcode: Optional[str]
usState: Optional[str]
phoneNumber: Optional[str]
birthday: Optional[str]
birthday: Optional[int]
channelId: int
parentId: int
acceptedEULA: bool
+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",
+7 -6
View File
@@ -1,7 +1,8 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional, List, Literal, Dict
from .constants import TrackQuality
from tiddl.models.constants import TrackQuality
__all__ = ["Track", "Video", "Album", "Playlist", "Artist"]
@@ -51,7 +52,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):
@@ -66,7 +67,7 @@ class Video(BaseModel):
id: int
title: str
cover: str
vibrantColor: str
vibrantColor: Optional[str] = None
videoCover: Optional[str] = None
id: int
@@ -76,7 +77,7 @@ class Video(BaseModel):
streamStartDate: Optional[datetime] = None
imagePath: Optional[str] = None
imageId: str
vibrantColor: str
vibrantColor: Optional[str] = None
duration: int
quality: str
streamReady: bool
@@ -119,8 +120,8 @@ class Album(BaseModel):
numberOfTracks: int
numberOfVideos: int
numberOfVolumes: int
releaseDate: str
copyright: str
releaseDate: Optional[str] = None
copyright: Optional[str] = None
type: str
version: Optional[str] = None
url: str
+10 -6
View File
@@ -1,8 +1,9 @@
import re
import os
import ffmpeg
import logging
from ffmpeg import FFmpeg
from pydantic import BaseModel
from urllib.parse import urlparse
from pathlib import Path
@@ -89,7 +90,7 @@ def formatTrack(
"playlist_number": playlist_index or 0,
}
formatted_track = template.format(**track_dict)
formatted_track = template.format(**track_dict).strip()
disallowed_chars = r'[\\:"*?<>|]+'
invalid_chars = re.findall(disallowed_chars, formatted_track)
@@ -151,7 +152,7 @@ def formatResource(
elif isinstance(resource, Video):
resource_dict.update({"quality": resource.quality})
formatted_template = template.format(**resource_dict)
formatted_template = template.format(**resource_dict).strip()
disallowed_chars = r'[\\:"*?<>|]+'
invalid_chars = re.findall(disallowed_chars, formatted_template)
@@ -216,9 +217,12 @@ def convertFileExtension(
if is_video:
ffmpeg_args["c:v"] = "copy"
ffmpeg.input(str(source_file)).output(str(output_file), **ffmpeg_args).run(
overwrite_output=1
)
(
FFmpeg()
.option("y")
.input(url=str(source_file))
.output(url=str(output_file), options=None, **ffmpeg_args)
).execute()
if remove_source:
os.remove(source_file)