Compare commits

...

47 Commits

Author SHA1 Message Date
Oskar Dudziński 0bc8802c0e 🚀 bump to 2.7.0 2025-10-28 22:24:48 +01:00
Oskar Dudziński 4217833984 Update modification time on existing items (#178)
* added `update_mtime` to config

* update mtime on files

* update only existing files that wont be redownloaded

* allow updating mtime if `DOWNLOAD_VIDEO` is False

* add custom message for skipped videos
2025-10-28 22:21:34 +01:00
Oskar Dudziński a41caf20fd 🚀 bump to 2.6.5 2025-10-26 14:41:34 +01:00
Oskar Dudziński 455129c4ca 🐛 Fixed error with missing lyrics (#176)
* add exception handling for getlyrics

* refactor logging
2025-10-26 14:07:19 +01:00
Oskar Dudziński eec05c4f09 Added track cover size from config 2025-10-17 22:44:52 +02:00
Oskar Dudziński f767f5ca41 🐛 Fix metadata 2025-10-17 09:53:52 +02:00
Oskar Dudziński 146dd6ae77 🚀 bump to 2.6.3 2025-10-16 18:08:37 +02:00
Oskar Dudziński da2b4b1199 🐛 Update auth client credentials to the hires one 2025-10-16 18:07:39 +02:00
Oskar Dudziński d3564f4139 🐛 Fix metadata for flac tracks (#168)
* quick fix for metadata that was not added for flac tracks

* bump version
2025-10-15 22:25:44 +02:00
Oskar Dudziński f8e3ce2a51 🐛 Fixed #166 (#167)
* set new default creds, add function to get creds from env

* add instruction for TIDDL_CLIENT

* add base64 for client creds
2025-10-15 22:18:09 +02:00
Oskar Dudziński 40e9198335 📝 Add note about ffmpeg 2025-10-12 17:00:39 +02:00
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
Oskar Dudziński bf6874d9e7 🚀 bump to 2.5.2 2025-09-07 19:56:54 +02:00
xiliourt 4204a4f6ad Added scan_path setting (#151)
* scan_path optional flag

* scan_path

* Update config.py

* Update __init__.py

---------

Co-authored-by: Tepyolas <Tepyolas>
2025-09-07 19:47:47 +02:00
Oskar Dudziński b899d0b286 🚀 bump to 2.5.1 2025-08-19 20:18:44 +02:00
Oskar Dudziński 016440e183 Added album_id to format string
close #146
2025-08-17 19:50:29 +02:00
xiliourt ea3571ae42 🐬 Added actual ghcr.io URL for docker commands (#139)
* Added actual ghcr.io URL for docker commands

Added ghcr.io/oskvr37/tiddl:latest in docker-compose.yml example code and docker run example code, in README.md

* Update Dockerfile
2025-07-26 01:12:13 +02:00
xiliourt f478e9f1d2 Changed FFmpeg to asynchronous (#137)
* Change cli/download to use asyncio.run() for the convert call

Ensures it awaits the return of 'path' before proceeding

* Updated to async convertFileExtension via ffmpeg_asyncio

* Changed to ffmpeg-asyncio dependency

Also requires ffmpeg installed at an OS level

* (Missed a comma)

* Update pyproject.toml
2025-07-19 23:45:29 +02:00
xiliourt 9a8c9d8d2d 🐬 Added Docker stuff (#138)
* Docker flow

* (Commit so my commit is verified)

---------

Co-authored-by: Xiliourt <admin@xiliourt.ovh>
2025-07-18 21:32:51 +02:00
xiliourt e91bf6e655 🐛 Fixed video download flag (#136)
* DOWNLOAD_VIDEO=false > DO_NOT_SKIP=true

DO_NOT_SKIP is intended logic for duplicate files; not intended to override a specific tag requesting not to download videos (my bad!)

This should fix that logic

* Changed --video to capital -V flag

-v is verbose, I was wondering why verbose wasn't working lol
2025-07-18 16:39:22 +02:00
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
23 changed files with 558 additions and 186 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Push Docker Image to ghcr.io
# Run when release is published
on:
release:
types: [published]
workflow_dispatch: # Allow for manual push so I can test it
jobs:
build:
runs-on: ubuntu-latest
# Minimum required permissions
permissions:
contents: read
packages: write
steps:
# Checkout code
- name: Checkout code
uses: actions/checkout@v3
# Login to ghcr (automatically uses workflow actor and secret)
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Pushes to both :latest and :<versionTag>
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/tiddl:${{ github.event.release.tag_name }}
ghcr.io/${{ github.repository_owner }}/tiddl:latest
+20
View File
@@ -0,0 +1,20 @@
# --- Optimised Layer Caching --- #
# Layer 1 (ffmpeg) will never regenerate
# Layer 2 (pip install) will regenerate if pyproject.toml is changed
# Layer 3 (build & install tiddl), rengerates on any code change
FROM python:alpine
WORKDIR /root
# -- Layer 1 - ffmpeg install (it'll stay cached as a layer always) --
RUN apk add --no-cache ffmpeg
# -- Layer 2 - pip install depenencies (remains cached unless pyproject.toml changes) --
# Exports 'depenencies' from pyproject.toml formatted to requirements.txt format, pipelined to pip install
COPY pyproject.toml .
RUN python -c "import tomllib; f=open('pyproject.toml','rb'); print('\n'.join(tomllib.load(f)['project']['dependencies']))" | xargs pip install
# -- Layer 3 - Uncached layer (regenerates anytime a new build is released) --
COPY . .
RUN pip install --no-deps .
RUN rm -rf *
+45
View File
@@ -46,6 +46,35 @@ Commands:
url Get Tidal URL.
```
> [!NOTE]
> Also make sure you have installed `ffmpeg` if you want to convert track file extensions.
## Dockerised Version (no Python required)
Based on python:alpine, slim build
**Docker run example (quickest / easiest)**
```
docker run -rm -v /downloads/dir:/root/Music/Tiddl/ -v ./config/tiddl/:/root/ ghcr.io/oskvr37/tiddl:latest
```
**docker-compose.yml example (not required, though allows for advanced configs)**
```
services:
tiddl:
container_name: tiddl
image: ghcr.io/oskvr37/tiddl:latest
volumes:
- /downloads/dir:/root/Music/Tiddl/ #default dir
- ./config/tiddl/:/root/ # Default location of config file
command: tail -f /dev/null # Keep it running in background
```
**Access the container:**
```
docker exec -it tiddl sh
```
_all other instructions match python version_
# Basic usage
## Login with Tidal account
@@ -93,6 +122,22 @@ 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
```
## Auth stopped working?
Set `TIDDL_AUTH` environment variable to use another credentials.
TIDDL_AUTH=<CLIENT_ID>;<CLIENT_SECRET>
# Development
Clone the repository
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tiddl"
version = "2.3.2"
version = "2.7.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",
"python-ffmpeg>=2.0.0",
"ffmpeg-asyncio>=0.1.3",
"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
@@ -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:
+26 -7
View File
@@ -26,6 +26,8 @@ from tiddl.models.api import (
TrackStream,
Video,
VideoStream,
Lyrics,
MixItems,
)
from tiddl.models.constants import TrackQuality
@@ -52,6 +54,7 @@ class Limits:
ALBUM_ITEMS = 10
ALBUM_ITEMS_MAX = 100
PLAYLIST = 50
MIX_ITEMS = 100
class TidalApi:
@@ -123,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",
@@ -176,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,
@@ -191,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",
@@ -214,8 +230,11 @@ class TidalApi:
)
def getSession(self):
return self.fetch(SessionResponse, "sessions", expire_after=DO_NOT_CACHE)
def getLyrics(self, track_id: str | int):
return self.fetch(
SessionResponse, "sessions", expire_after=DO_NOT_CACHE
Lyrics, f"tracks/{track_id}/lyrics", {"countryCode": self.country_code}
)
def getTrack(self, track_id: str | int):
+25 -4
View File
@@ -1,15 +1,36 @@
import logging
import base64
from os import environ
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"
CLIENT_SECRET = "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4="
def get_auth_credentials() -> tuple[str, str]:
ENV_KEY = "TIDDL_AUTH"
client_id, client_secret = (
base64.b64decode(
"ZlgySnhkbW50WldLMGl4VDsxTm45QWZEQWp4cmdKRkpiS05XTGVBeUtHVkdtSU51WFBQTEhWWEF2eEFnPQ=="
)
.decode()
.split(";")
)
env_value = environ.get(ENV_KEY, None)
if env_value:
client_id, client_secret = env_value.split(";")
return client_id, client_secret
CLIENT_ID, CLIENT_SECRET = get_auth_credentials()
logger = logging.getLogger(__name__)
+7 -13
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()
@@ -36,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(
+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")
+208 -78
View File
@@ -1,8 +1,10 @@
import os
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,17 +25,19 @@ from tiddl.utils import (
TidalResource,
formatResource,
convertFileExtension,
trackExists,
savePlaylistM3U,
findTrackFilename,
)
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 typing import List, Union
from .fav import FavGroup
from .file import FileGroup
from .search import SearchGroup
from .url import UrlGroup
from ..ctx import Context, passContext
logger = logging.getLogger(__name__)
@click.command("download")
@@ -81,28 +85,69 @@ from ..ctx import Context, passContext
type=click.Choice(SinglesFilter.__args__),
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",
)
@click.option(
"--scan-path",
"SCAN_PATH",
type=str,
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(
ctx: Context,
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,
SAVE_M3U: 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)
logger.debug(
(
QUALITY,
TEMPLATE,
PATH,
THREADS_COUNT,
DO_NOT_SKIP,
SINGLES_FILTER,
EMBED_LYRICS,
DOWNLOAD_VIDEO,
SCAN_PATH,
SAVE_M3U,
)
)
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()
@@ -124,7 +169,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 = (
@@ -136,9 +181,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"
@@ -172,11 +215,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,
@@ -192,41 +231,62 @@ def DownloadCommand(
f.write(stream_data)
if isinstance(item, Track):
if track_stream.audioQuality == "HI_RES_LOSSLESS":
path = 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
cover_data = Cover(
item.album.cover, size=ctx.obj.config.cover.size
).content
lyrics_subtitles = ""
if EMBED_LYRICS:
try:
lyrics_subtitles = api.getLyrics(item.id).subtitles
except Exception as e:
logger.error(e)
if track_stream.audioQuality in ["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
)
)
try:
addMetadata(
path, item, cover_data, credits, album_artist=album_artist
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}")
logger.error(f"Can not add metadata to: {path}, {e}")
elif isinstance(item, Video):
path = 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)
except Exception as e:
logging.error(f"Can not add metadata to: {path}, {e}")
logger.error(f"Can not add metadata to: {path}, {e}")
progress.remove_task(task_id)
logging.info(f"{item.title!r}{speed:.2f} Mbps • {size:.2f} MB")
logger.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
@@ -238,39 +298,69 @@ def DownloadCommand(
cover_data=b"",
credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
album_artist="",
):
) -> Future[Path] | None:
if not item.allowStreaming:
logging.warning(
logger.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}.*"
download_path = Path(PATH) if PATH else ctx.obj.config.download.path
download_path /= f"{filename}.*"
if not DO_NOT_SKIP: # check if item is already downloaded
if isinstance(item, Track):
if trackExists(item.audioQuality, DOWNLOAD_QUALITY, path):
logging.warning(f"Track '{item.title}' skipped")
return
elif isinstance(item, Video):
if path.with_suffix(".mp4").exists():
logging.warning(f"Video '{item.title}' skipped")
return
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
pool.submit(
if isinstance(item, Track):
existing_filename = findTrackFilename(
item.audioQuality, DOWNLOAD_QUALITY, scan_path
)
elif isinstance(item, Video):
existing_filename = scan_path.with_suffix(".mp4")
if existing_filename.exists():
if ctx.obj.config.update_mtime:
try:
os.utime(existing_filename, None)
except Exception:
logger.warning(f"Could not update mtime for {existing_filename}")
if not DO_NOT_SKIP:
logger.info(f"Item '{item.title}' skipped - exists")
future = Future()
future.set_result(existing_filename)
return future
if not DOWNLOAD_VIDEO and isinstance(item, Video):
logger.warning(
f"Video '{item.title}' skipped - video download is not allowed"
)
return
future = pool.submit(
handleItemDownload,
item=item,
path=path,
path=download_path,
cover_data=cover_data,
credits=credits,
album_artist=album_artist,
)
def downloadAlbum(album: Album):
logging.info(f"Album {album.title!r}")
return future
cover_data = Cover(album.cover).content if album.cover else b""
def downloadAlbum(album: Album):
logger.info(f"Album {album.title!r}")
cover = (
Cover(uid=album.cover, size=ctx.obj.config.cover.size)
if album.cover
else None
)
is_cover_saved = False
offset = 0
@@ -284,24 +374,27 @@ 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
def handleResource(resource: TidalResource) -> None:
logging.debug(f"Handling Resource '{resource}'")
logger.debug(f"'{resource}'")
match resource.type:
case "track":
@@ -325,9 +418,19 @@ 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}")
logger.info(f"Artist {artist.name!r}")
def getAllAlbums(singles: bool):
offset = 0
@@ -358,24 +461,28 @@ def DownloadCommand(
case "playlist":
playlist = api.getPlaylist(resource.id)
logging.info(f"Playlist {playlist.title!r}")
logger.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
)
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,
)
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
@@ -385,6 +492,29 @@ 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
@@ -393,11 +523,11 @@ def DownloadCommand(
handleResource(resource)
except AuthError as e:
logging.error(e)
logger.error(e)
break
except ApiError as e:
logging.error(e)
logger.error(e)
# session does not have streaming privileges
if e.sub_status == 4006:
+8 -2
View File
@@ -1,9 +1,15 @@
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"]
ResourceTypeList: list[ResourceTypeLiteral] = [
"track",
"video",
"album",
"artist",
"playlist",
]
@click.group("fav")
+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):
+21 -3
View File
@@ -1,11 +1,17 @@
# 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, SinglesFilter
HOME_PATH = Path.home()
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)
CONFIG_PATH = HOME_PATH / "tiddl.json"
CONFIG_INDENT = 2
@@ -22,6 +28,10 @@ class DownloadConfig(BaseModel):
path: Path = Path.home() / "Music" / "Tiddl"
threads: int = 4
singles_filter: SinglesFilter = "none"
embed_lyrics: bool = False
download_video: bool = False
scan_path: Path | None = path
save_playlist_m3u: bool = False
class AuthConfig(BaseModel):
@@ -32,11 +42,19 @@ 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
update_mtime: bool = False
def save(self):
with open(CONFIG_PATH, "w") as f:
+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
+34 -25
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,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)
@@ -56,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"))
@@ -75,13 +79,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(
@@ -91,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),
}
)
@@ -118,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 "",
}
@@ -151,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))
@@ -161,26 +170,26 @@ 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}")
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:
f.write(self.content)
+20 -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",
]
@@ -101,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]
@@ -114,9 +122,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 +175,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
+6 -5
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"]
@@ -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
+77 -26
View File
@@ -2,7 +2,8 @@ import re
import os
import logging
from ffmpeg import FFmpeg
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],
@@ -125,13 +131,14 @@ def formatResource(
"artists": ", ".join(features + [artist]),
"features": ", ".join(features),
"album": sanitizeString(resource.album.title if resource.album else ""),
"album_id": str(resource.album.id if resource.album else ""),
"number": resource.trackNumber,
"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,
@@ -166,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"]
@@ -182,10 +189,10 @@ def trackExists(
full_file_name = file_name.with_suffix(extension)
return full_file_name.exists()
return full_file_name
def convertFileExtension(
async def convertFileExtension(
source_file: Path,
extension: str,
remove_source=False,
@@ -204,27 +211,71 @@ def convertFileExtension(
logging.error(e)
return source_file
logging.debug((source_file, output_file, extension))
logging.debug((source_file, output_file, extension, copy_audio, is_video))
if extension == source_file.suffix:
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["c:a"] = "copy"
ffmpeg_args["acodec"] = "copy"
if is_video:
ffmpeg_args["c:v"] = "copy"
ffmpeg_args["vcodec"] = "copy"
(
FFmpeg()
.option("y")
.input(url=str(source_file))
.output(url=str(output_file), options=None, **ffmpeg_args)
).execute()
try:
logging.debug("Trying conversion")
ffmpeg = FFmpeg().option("y")
ffmpeg.input(str(source_file))
ffmpeg.output(str(output_file), ffmpeg_args)
if remove_source:
os.remove(source_file)
@ffmpeg.on("completed")
def on_completed():
logging.debug(f"converted {output_file}")
if remove_source:
try:
os.remove(source_file)
except OSError as e:
logging.error(f"can't remove source file {source_file}: {e}")
await ffmpeg.execute()
except Exception as 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}")