From b87888536e4efdd2d45f4a16c9eb28301c4ed99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Dudzi=C5=84ski?= <56404247+oskvr37@users.noreply.github.com> Date: Sat, 8 Nov 2025 15:18:44 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20tiddl3=20(#194)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/publish.yml | 48 +- .gitignore | 12 +- README.md | 187 +++--- docs/config.example.toml | 141 +++++ docs/demo.gif | Bin 123105 -> 0 bytes docs/templating.md | 118 ++++ examples/download_track.py | 37 ++ examples/download_video.py | 43 ++ examples/fetch_api.py | 47 ++ examples/track_templating.py | 26 + pyproject.toml | 31 +- tests/cli/commands/auth/test_auth.py | 200 ++++++ tests/cli/commands/auth/test_auth_utils.py | 41 ++ tests/cli/commands/test_commands.py | 13 + tests/cli/test_config.py | 63 ++ tests/cli/test_const.py | 20 + tests/cli/test_utils.py | 68 +++ tests/core/api/test_api_api.py | 206 +++++++ tests/core/api/test_api_client.py | 93 +++ tests/core/api/test_api_exceptions.py | 38 ++ tests/core/auth/test_auth_api.py | 105 ++++ tests/core/auth/test_auth_client.py | 128 ++++ tests/core/auth/test_auth_exceptions.py | 41 ++ tiddl/api.py | 291 --------- tiddl/auth.py | 101 ---- tiddl/cli/__init__.py | 78 +-- tiddl/cli/app.py | 33 + tiddl/cli/auth.py | 115 ---- tiddl/cli/commands/__init__.py | 16 + tiddl/cli/commands/auth.py | 108 ++++ tiddl/cli/commands/download/__init__.py | 502 +++++++++++++++ tiddl/cli/commands/download/downloader.py | 195 ++++++ tiddl/cli/commands/download/output.py | 92 +++ tiddl/cli/commands/export.py | 40 ++ tiddl/cli/commands/subcommands/__init__.py | 11 + tiddl/cli/commands/subcommands/url.py | 29 + tiddl/cli/config.py | 150 +++-- tiddl/cli/const.py | 23 + tiddl/cli/ctx.py | 83 ++- tiddl/cli/download/__init__.py | 571 ------------------ tiddl/cli/download/fav.py | 52 -- tiddl/cli/download/file.py | 40 -- tiddl/cli/download/search.py | 48 -- tiddl/cli/download/url.py | 26 - tiddl/{models => cli/utils}/__init__.py | 0 tiddl/cli/utils/auth/__init__.py | 5 + tiddl/cli/utils/auth/core.py | 31 + tiddl/cli/utils/auth/models.py | 9 + tiddl/cli/utils/download.py | 26 + tiddl/cli/utils/resource.py | 47 ++ tiddl/config.py | 72 --- tiddl/core/__init__.py | 0 tiddl/core/api/__init__.py | 5 + tiddl/core/api/api.py | 247 ++++++++ tiddl/core/api/client.py | 86 +++ tiddl/core/api/exceptions.py | 8 + tiddl/core/api/models/__init__.py | 35 ++ .../api.py => core/api/models/base.py} | 33 +- .../api/models/resources.py} | 25 +- tiddl/core/auth/__init__.py | 4 + tiddl/core/auth/api.py | 26 + tiddl/core/auth/client.py | 96 +++ tiddl/core/auth/exceptions.py | 17 + tiddl/core/auth/models.py | 52 ++ tiddl/core/metadata/__init__.py | 5 + tiddl/core/metadata/cover.py | 55 ++ tiddl/core/metadata/track.py | 140 +++++ tiddl/core/metadata/video.py | 31 + tiddl/core/utils/__init__.py | 11 + tiddl/core/utils/download.py | 39 ++ tiddl/core/utils/ffmpeg.py | 39 ++ tiddl/core/utils/format.py | 151 +++++ tiddl/core/utils/m3u.py | 38 ++ tiddl/{download.py => core/utils/parse.py} | 54 +- tiddl/core/utils/sanitize.py | 12 + tiddl/exceptions.py | 21 - tiddl/metadata.py | 202 ------- tiddl/models/auth.py | 53 -- tiddl/models/constants.py | 14 - tiddl/utils.py | 234 ------- 80 files changed, 4029 insertions(+), 2204 deletions(-) create mode 100644 docs/config.example.toml delete mode 100644 docs/demo.gif create mode 100644 docs/templating.md create mode 100644 examples/download_track.py create mode 100644 examples/download_video.py create mode 100644 examples/fetch_api.py create mode 100644 examples/track_templating.py create mode 100644 tests/cli/commands/auth/test_auth.py create mode 100644 tests/cli/commands/auth/test_auth_utils.py create mode 100644 tests/cli/commands/test_commands.py create mode 100644 tests/cli/test_config.py create mode 100644 tests/cli/test_const.py create mode 100644 tests/cli/test_utils.py create mode 100644 tests/core/api/test_api_api.py create mode 100644 tests/core/api/test_api_client.py create mode 100644 tests/core/api/test_api_exceptions.py create mode 100644 tests/core/auth/test_auth_api.py create mode 100644 tests/core/auth/test_auth_client.py create mode 100644 tests/core/auth/test_auth_exceptions.py delete mode 100644 tiddl/api.py delete mode 100644 tiddl/auth.py create mode 100644 tiddl/cli/app.py delete mode 100644 tiddl/cli/auth.py create mode 100644 tiddl/cli/commands/__init__.py create mode 100644 tiddl/cli/commands/auth.py create mode 100644 tiddl/cli/commands/download/__init__.py create mode 100644 tiddl/cli/commands/download/downloader.py create mode 100644 tiddl/cli/commands/download/output.py create mode 100644 tiddl/cli/commands/export.py create mode 100644 tiddl/cli/commands/subcommands/__init__.py create mode 100644 tiddl/cli/commands/subcommands/url.py create mode 100644 tiddl/cli/const.py delete mode 100644 tiddl/cli/download/__init__.py delete mode 100644 tiddl/cli/download/fav.py delete mode 100644 tiddl/cli/download/file.py delete mode 100644 tiddl/cli/download/search.py delete mode 100644 tiddl/cli/download/url.py rename tiddl/{models => cli/utils}/__init__.py (100%) create mode 100644 tiddl/cli/utils/auth/__init__.py create mode 100644 tiddl/cli/utils/auth/core.py create mode 100644 tiddl/cli/utils/auth/models.py create mode 100644 tiddl/cli/utils/download.py create mode 100644 tiddl/cli/utils/resource.py delete mode 100644 tiddl/config.py create mode 100644 tiddl/core/__init__.py create mode 100644 tiddl/core/api/__init__.py create mode 100644 tiddl/core/api/api.py create mode 100644 tiddl/core/api/client.py create mode 100644 tiddl/core/api/exceptions.py create mode 100644 tiddl/core/api/models/__init__.py rename tiddl/{models/api.py => core/api/models/base.py} (85%) rename tiddl/{models/resource.py => core/api/models/resources.py} (88%) create mode 100644 tiddl/core/auth/__init__.py create mode 100644 tiddl/core/auth/api.py create mode 100644 tiddl/core/auth/client.py create mode 100644 tiddl/core/auth/exceptions.py create mode 100644 tiddl/core/auth/models.py create mode 100644 tiddl/core/metadata/__init__.py create mode 100644 tiddl/core/metadata/cover.py create mode 100644 tiddl/core/metadata/track.py create mode 100644 tiddl/core/metadata/video.py create mode 100644 tiddl/core/utils/__init__.py create mode 100644 tiddl/core/utils/download.py create mode 100644 tiddl/core/utils/ffmpeg.py create mode 100644 tiddl/core/utils/format.py create mode 100644 tiddl/core/utils/m3u.py rename tiddl/{download.py => core/utils/parse.py} (72%) create mode 100644 tiddl/core/utils/sanitize.py delete mode 100644 tiddl/exceptions.py delete mode 100644 tiddl/metadata.py delete mode 100644 tiddl/models/auth.py delete mode 100644 tiddl/models/constants.py delete mode 100644 tiddl/utils.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b7a704b..6d0723b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,39 +1,25 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -name: Upload Python Package +name: "Publish" on: release: types: [published] -permissions: - contents: read - jobs: - deploy: - + run: runs-on: ubuntu-latest - + environment: + name: pypi + permissions: + id-token: write + contents: read steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + - name: Checkout + uses: actions/checkout@v5 + - name: Install uv + uses: astral-sh/setup-uv@v6 + - name: Install Python 3.13 + run: uv python install 3.13 + - name: Build + run: uv build + - name: Publish + run: uv publish diff --git a/.gitignore b/.gitignore index 1cda0ae..f76bac4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,3 @@ -# TIDDL -tidal_download/ -.tiddl_config.json - # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -163,4 +159,10 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ \ No newline at end of file +#.idea/ + +# Ruff +.ruff_cache + +# UV +uv.lock diff --git a/README.md b/README.md index 7e7b643..568fede 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,138 @@ # Tidal Downloader +Download tracks and videos from Tidal with max quality! `tiddl` is CLI app written in Python. + +> [!WARNING] +> `This app is for personal use only and is not affiliated with Tidal. Users must ensure their use complies with Tidal's terms of service and local copyright laws. Downloaded tracks are for personal use and may not be shared or redistributed. The developer assumes no responsibility for misuse of this app.` + ![PyPI - Downloads](https://img.shields.io/pypi/dm/tiddl?style=for-the-badge&color=%2332af64) ![PyPI - Version](https://img.shields.io/pypi/v/tiddl?style=for-the-badge) -![GitHub commits since latest release](https://img.shields.io/github/commits-since/oskvr37/tiddl/latest?style=for-the-badge) [](https://gitmoji.dev) -TIDDL is the Python CLI application that allows downloading Tidal tracks and videos! - -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. - -> [!WARNING] -> This app is for personal use only and is not affiliated with Tidal. Users must ensure their use complies with Tidal's terms of service and local copyright laws. Downloaded tracks are for personal use and may not be shared or redistributed. The developer assumes no responsibility for misuse of this app. - # Installation -Install package using `pip` +`tiddl` is available at [python package index](https://pypi.org/project/tiddl/) and you can install it with your favorite Python package manager. + +> [!IMPORTANT] +> Also make sure you have installed [`ffmpeg`](https://ffmpeg.org/download.html) - it is used to convert downloaded tracks to proper format. + +## uv + +We recommend using [uv](https://docs.astral.sh/uv/) + +```bash +uv tool install tiddl +``` + +## pip + +You can also use [pip](https://packaging.python.org/en/latest/tutorials/installing-packages/) ```bash pip install tiddl ``` -Run the package cli with `tiddl` +## docker + +**coming soon** + +# Usage + +Run the app with `tiddl` ```bash $ tiddl -Usage: tiddl [OPTIONS] COMMAND [ARGS]... + Usage: tiddl [OPTIONS] COMMAND [ARGS]... - TIDDL - Tidal Downloader ♫ + tiddl - download tidal tracks ♫ -Options: - -v, --verbose Show debug logs. - -q, --quiet Suppress logs. - -nc, --no-cache Omit Tidal API requests caching. - --help Show this message and exit. - -Commands: - auth Manage Tidal token. - config Print path to the configuration file. - fav Get your Tidal favorites. - file Parse txt or JSON file with urls. - search Search on Tidal. - url Get Tidal URL. +╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ --omit-cache --no-omit-cache [default: no-omit-cache] │ +│ --debug --no-debug [default: no-debug] │ +│ --install-completion Install completion for the current shell. │ +│ --show-completion Show completion for the current shell, to copy it or customize │ +│ the installation. │ +│ --help Show this message and exit. │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Commands ──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ auth Manage Tidal authentication. │ +│ download Download Tidal resources. │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ``` -> [!NOTE] -> Also make sure you have installed `ffmpeg` if you want to convert track file extensions. +## Authentication -## 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 +Login to app with your Tidal account: run the command below and follow instructions. ```bash tiddl auth login ``` -## Download resource +## Downloading -You can download track / video / album / artist / playlist +You can download tracks / videos / albums / artists / playlists / mixes. ```bash -tiddl url https://listen.tidal.com/track/103805726 download -tiddl url https://listen.tidal.com/video/25747442 download -tiddl url https://listen.tidal.com/album/103805723 download -tiddl url https://listen.tidal.com/artist/25022 download -tiddl url https://listen.tidal.com/playlist/84974059-76af-406a-aede-ece2b78fa372 download +$ tiddl download url ``` > [!TIP] > You don't have to paste full urls, track/103805726, album/103805723 etc. will also work -## Download options +Run `tiddl download` to see available download options. -```bash -tiddl url track/103805726 download -q master -o "{artist}/{title} ({album})" -``` - -This command will: - -- download with highest quality (master) -- save track with title and album name in artist folder - -### Download quality +### Quality | Quality | File extension | Details | | :-----: | :------------: | :-------------------: | | LOW | .m4a | 96 kbps | | NORMAL | .m4a | 320 kbps | | HIGH | .flac | 16-bit, 44.1 kHz | -| MASTER | .flac | Up to 24-bit, 192 kHz | +| MAX | .flac | Up to 24-bit, 192 kHz | -### Output format +### Output -More about file templating [on wiki](https://github.com/oskvr37/tiddl/wiki/Template-formatting). +You can format filenames of your downloaded resources and put them in different directories. -## Custom tiddl home path +For example, setting output flag to `"{album.artist}/{album.title}/{item.number:02d}. {item.title}"` +will download tracks like following: -You can set `TIDDL_PATH` environment variable to use custom home path for tiddl. +``` +Music +└── Kanye West + └── Graduation + ├── 01. Good Morning.flac + ├── 02. Champion.flac + ├── 03. Stronger.flac + ├── 04. I Wonder.flac + ├── 05. Good Life.flac + ├── 06. Can't Tell Me Nothing.flac + ├── 07. Barry Bonds.flac + ├── 08. Drunk and Hot Girls.flac + ├── 09. Flashing Lights.flac + ├── 10. Everything I Am.flac + ├── 11. The Glory.flac + ├── 12. Homecoming.flac + ├── 13. Big Brother.flac + └── 14. Good Night.flac +``` + +> [!NOTE] +> Learn more about [file templating](/docs/templating.md) + +## Configuration files + +Files of the app are created in your home directory. By default, the app is located at `~/.tiddl`. + +You can (and should) create the `config.toml` file to configure the app how you want. + +You can copy example config from docs [config.example.toml](/docs/config.example.toml) + +## Environment variables + +### Custom app path + +You can set `TIDDL_PATH` environment variable to use custom path for `tiddl` app. Example CLI usage: @@ -136,7 +140,7 @@ Example CLI usage: TIDDL_PATH=~/custom/tiddl tiddl auth login ``` -## Auth stopped working? +### Auth stopped working? Set `TIDDL_AUTH` environment variable to use another credentials. @@ -148,21 +152,24 @@ Clone the repository ```bash git clone https://github.com/oskvr37/tiddl +cd tiddl ``` You should create virtual environment and activate it ```bash -python -m venv .venv +uv venv source .venv/Scripts/activate ``` Install package with `--editable` flag ```bash -pip install -e . +uv pip install -e . ``` # Resources -[Tidal API wiki](https://github.com/Fokka-Engineering/TIDAL) +[Tidal API wiki (api endpoints)](https://github.com/Fokka-Engineering/TIDAL) + +[Tidal-Media-Downloader (inspiration)](https://github.com/yaronzz/Tidal-Media-Downloader) diff --git a/docs/config.example.toml b/docs/config.example.toml new file mode 100644 index 0000000..c61503e --- /dev/null +++ b/docs/config.example.toml @@ -0,0 +1,141 @@ +# this is `config.toml` file, it is used to configure your tiddl app. +# if you don't create one on your machine, then app will use default settings. +# this file must be saved as `config.toml` at APP_PATH which by default is in your home directory. +# APP_PATH will be created when you install and run `tiddl` for the first time. +# Windows: C:/users//.tiddl +# Linux: ~/.tiddl +# you can set custom APP_PATH by setting environment variable: `TIDDL_PATH`. + +# cache API requests, used for improving speed of Tidal endpoints calls, recommended to leave it true. +# most of endpoints are cached for 1 hour, then they are called again. +# database for cached data is located at APP_PATH with filename `api_cache.sqlite`. +# sometimes you can delete the database to purge the cache, when the database file size is too large +# or something just broke. +enable_cache = true + +# debug option is used to save the calls of Tidal API endpoints +# to the `api_debug` directory at your APP_PATH. +# they are saved as directories to these endpoints with json data. +debug = false + + +[templates] +# read more about templates at: TODO add templating docs + +# if you don't specify the template for a resource +# then default template will be used. +default = "{album.artist}/{album.title}/{item.title}" + +# track = "tracks/{item.id}" +# video = "videos/{item.title}" +# album = "artists/{album.artist}/{album.title}/{item.title}" +# playlist = "{playlist.title}/{playlist.index}. {item.artist} - {item.title}" +# mix = "mixes/{mix_id}/{item.artist} - {item.title}" + + +[download] +# low - 96 kbps, m4a +# normal - 320 kbps, m4a +# high - 16 bit, 44.1 kHz, flac +# max - up to 24 bit, 192 kHz, flac +track_quality = "high" + +# sd - 360p +# hd - 720p +# fhd - 1080p +video_quality = "fhd" + +# will skip already downloaded files +skip_existing = true + +# how many items will be downloaded at once, recommended to keep it low +threads_count = 4 + +# base download directory, by default it is set to your home directory / Music / tiddl +# download_path = "" + +# if you moved the downloaded files to other directory, +# then you should specify the destination directory there. +# otherwise `tiddl` will not detect them and `skip_existing` will not skip +# already downloaded files. by default scan path is set to your download path. +# scan_path = "" + +# this option is used to determine if you want to include downloading singles from an artist. +# "none" download only full albums +# "only" download only singles +# "include" download both singles and full albums +singles_filter = "none" + +# "none" to disallow downloading videos (mostly from playlists) +# "only" to download only videos - will get all vids from playlists and from artists. +# "allow" to download tracks and videos +videos_filter = "none" + +# update the modification time of an existing file when `skip_existing` is on. +# this option is useful for user to automatically detect old local files +# that have been removed from a Tidal collection. +update_mtime = false + +# when enabled, it will write metadata to files that are already downloaded. +# could be useful when data on Tidal has changed. +rewrite_metadata = false + + +[metadata] +# embed metadata in files +enable = true + +# embed lyrics in metadata +embed_lyrics = false + +# embed track cover in the track file +cover = false + + +[cover] +# please don't confuse the cover from metadata with cover as a distinct file. + +# save cover to distinct file, default false +save = false + +# size of cover, default and max is 1280x1280 +size = 1280 + +# you can allow saving covers for tracks, albums and playlists. +# note that playlists max size is 1080x1080 +# (it will be set to proper size automatically) +# by default allowed is set to empty [] +allowed = [ + # "track", + # "album", + # "playlist" +] + + +[cover.templates] +# you must set path templates if you want to save cover files. + +# you can access: {item}, {album} +# track = "tracks/{item.id}" + +# you can access: {album} +# album = "albums/{album.artist} - {album.title}" + +# you can access: {playlist} +# playlist = "playlists/{title}" + + +[m3u] +# m3u is a text file that holds data about playlists. +save = false + +# "album", "mix", "playlist" +allowed = ["album", "mix", "playlist"] + +[m3u.templates] +# additional template values: +# {type} - album/playlist/mix + +album = "m3u/{type}/{album.artist} - {album.title}" +playlist = "m3u/{type}/{playlist.title}" +mix = "m3u/{type}/{now:%x}" diff --git a/docs/demo.gif b/docs/demo.gif deleted file mode 100644 index 1957aa29bf94115c48729602f2064ee1bbead583..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 123105 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 Ih{%Hf24!r)U;qFB diff --git a/docs/templating.md b/docs/templating.md new file mode 100644 index 0000000..ce64156 --- /dev/null +++ b/docs/templating.md @@ -0,0 +1,118 @@ +# 📝 File Templating + +Templates are text strings that describe folder and file structure. +They use placeholders (in `{curly_braces}`) that get replaced with actual metadata values from: + +- **Track / Video** → `item` +- **Album** → `album` +- **Playlist** → `playlist` +- Plus any **custom fields** + +A template like: + +``` +{album.artist}/{album.title}/{item.title} +``` + +becomes this: + +``` +Daft Punk/Discovery/Harder Better Faster Stronger +``` + +--- + +## 🧩 Template Variables + +Each object type exposes fields you can use inside templates. + +### `item` (Track or Video) + +| Field | Description | Example | +| ---------------------------- | ------------------------------- | ------------------------------- | +| `item.id` | Track/Video ID | `123456` | +| `item.title` | Title | `Harder Better Faster Stronger` | +| `item.title_version` | Title + version (if present) | `One More Time (Radio Edit)` | +| `item.number` | Track number | `3` | +| `item.volume` | Disc/volume number | `1` | +| `item.version` | Version string (track only) | `Remastered` | +| `item.copyright` | Copyright info (track only) | `© 2023 Sony Music` | +| `item.bpm` | Beats per minute (if available) | `120` | +| `item.isrc` | ISRC code (track only) | `USQX91501234` | +| `item.quality` | Audio/video quality | `HIGH` | +| `item.artist` | Primary artist name | `Daft Punk` | +| `item.artists` | All main artists | `Daft Punk, Pharrell Williams` | +| `item.features` | Featured artists | `Pharrell Williams` | +| `item.artists_with_features` | Main + featured artists | `Daft Punk, Pharrell Williams` | + +--- + +### `album` + +| Field | Description | Example | +| --------------- | ------------------------- | ------------ | +| `album.id` | Album ID | `98765` | +| `album.title` | Album title | `Discovery` | +| `album.artist` | Primary artist | `Daft Punk` | +| `album.artists` | All main artists | `Daft Punk` | +| `album.date` | Release date (`datetime`) | `2001-03-13` | + +--- + +### `playlist` + +| Field | Description | Example | +| ------------------ | ------------------------------ | --------------------- | +| `playlist.uuid` | Playlist unique ID | `b8f1d9f8-...` | +| `playlist.title` | Playlist name | `My Favorites` | +| `playlist.index` | Track index within playlist | `5` | +| `playlist.created` | Creation date (`datetime`) | `2024-01-15 10:42:00` | +| `playlist.updated` | Last updated date (`datetime`) | `2024-03-02 09:00:00` | + +--- + +### `extra` and `custom` fields + +You can also use: + +- `now` → current datetime +- Any key passed as `extra` in code. + +--- + +## 🧼 Sanitization + +All template segments are sanitized: + +- Invalid filesystem characters are removed or replaced. +- Empty placeholders are skipped cleanly. +- Each path component is treated separately (split by `/`). + +--- + +## ⚙️ Configuration Example + +Your `[templates]` section in `config.toml` defines templates per media type. + +```toml +[templates] +default = "{album.artist}/{album.title}/{item.title}" +track = "tracks/{item.id}" +video = "videos/{item.title}" +album = "artists/{album.artist}/{album.title}/{item.title}" +playlist = "{playlist.title}/{playlist.index}. {item.artist} - {item.title}" +mix = "mixes/{mix_id}/{item.artist} - {item.title}" +``` + +If no specific template is set, the `default` one is used. + +--- + +## 🧠 Tips + +- You can format datetime fields, e.g. `{album.date:%Y-%m-%d}`. +- You can build nested folders safely using `/` separators. + +## 🖥️ Source Code + +Source code is located at [`/tiddl/core/utils/format.py`](/tiddl/core/utils/format.py) diff --git a/examples/download_track.py b/examples/download_track.py new file mode 100644 index 0000000..2f3a9a4 --- /dev/null +++ b/examples/download_track.py @@ -0,0 +1,37 @@ +from pathlib import Path + +from tiddl.core.utils import get_track_stream_data +from tiddl.core.metadata import add_track_metadata +from tiddl.core.api.models import TrackQuality + +# we reuse Tidal API from another example +from .fetch_api import api + +# Congratulations by Post Malone +TRACK_ID = 77662595 +QUALITY: TrackQuality = "LOSSLESS" + +if __name__ == "__main__": + # fetch track_stream + track_stream = api.get_track_stream(TRACK_ID, QUALITY) + + # download bytes to stream_data and get the file extension + stream_data, file_extension = get_track_stream_data(track_stream) + + filename = f"{TRACK_ID}_{track_stream.audioQuality}" + + # get file path that is located at our current directory + # with filename: TRACK_ID_QUALITY.EXTENSION + track_path = Path(filename).with_suffix(file_extension) + + # write data from the track_stream to our file + track_path.write_bytes(stream_data) + + # fetch some informations about our track like title etc. + track = api.get_track(TRACK_ID) + + # add the metadata to our saved file. + # note that not every data is added such as cover or lyrics. + add_track_metadata(track_path, track) + + # Congratulations if it works on your machine diff --git a/examples/download_video.py b/examples/download_video.py new file mode 100644 index 0000000..161210b --- /dev/null +++ b/examples/download_video.py @@ -0,0 +1,43 @@ +from pathlib import Path + +from tiddl.core.metadata import add_video_metadata +from tiddl.core.api.models.base import VideoQuality +from tiddl.core.utils import get_video_stream_data +from tiddl.core.utils.ffmpeg import convert_to_mp4, is_ffmpeg_installed + +# we reuse Tidal API from another example +from .fetch_api import api + +# Old Town Road by Lil Nas X +VIDEO_ID = 113483426 +QUALITY: VideoQuality = "HIGH" + +if __name__ == "__main__": + print("fetching video_stream") + video_stream = api.get_video_stream(video_id=VIDEO_ID, quality=QUALITY) + + # download bytes to stream_data and get the file extension + print("downloading video_stream data") + stream_data = get_video_stream_data(video_stream) + + filename = f"{VIDEO_ID}_{QUALITY}" + + # get file path that is located at our current directory + video_path = Path(filename).with_suffix(".ts") + + # write data from the video_stream to our file + print(f"saving to {video_path}") + video_path.write_bytes(stream_data) + + if is_ffmpeg_installed(): + # convert the file from .ts to .mp4 + print("converting to mp4") + video_path = convert_to_mp4(video_path) + + # fetch some informations about our video like title etc. + print("getting video metadata") + video = api.get_video(VIDEO_ID) + + # add the metadata to our saved file. + print("saving metadata") + add_video_metadata(video_path, video) diff --git a/examples/fetch_api.py b/examples/fetch_api.py new file mode 100644 index 0000000..a8f974f --- /dev/null +++ b/examples/fetch_api.py @@ -0,0 +1,47 @@ +from tiddl.core.api import TidalAPI, TidalClient + +# we will utilize some functions from tiddl cli +# and use `APP_PATH` that is located at our /home_directory/.tiddl +from tiddl.cli.utils.auth import load_auth_data +from tiddl.cli.const import APP_PATH + +# !! remember to be logged in, use `tiddl auth login` +# it will save auth token in /home_directory/.tiddl/auth.json + +# in case your token expired, then use `tiddl auth refresh` + +# load our token, country code and user id from file +auth_data = load_auth_data() + +# we make sure auth_data is not empty = we are logged in + +assert auth_data.token +assert auth_data.country_code +assert auth_data.user_id + +# we create Client for our API. +# this is custom client that can cache requests +# to make the API more efficient + +client = TidalClient( + token=auth_data.token, + cache_name=APP_PATH / "api_cache", # path to cache api requests + debug_path=APP_PATH / "api_debug", # optional, used for debugging api +) + +# this is our Tidal API that will call the endpoints + +api = TidalAPI( + client, + country_code=auth_data.country_code, + user_id=auth_data.user_id, +) + +if __name__ == "__main__": + # make the API call + session = api.get_session() + + # every data from the api is `pydantic` model + print(f"session id: {session.sessionId}") + + # see every available endpoint at `tiddl.core.api` diff --git a/examples/track_templating.py b/examples/track_templating.py new file mode 100644 index 0000000..9f9fb2e --- /dev/null +++ b/examples/track_templating.py @@ -0,0 +1,26 @@ +from tiddl.core.utils.format import format_template + +# we reuse Tidal API from another example +from .fetch_api import api + +ALBUM_ID = 465173294 + + +if __name__ == "__main__": + album = api.get_album(ALBUM_ID) + album_items = api.get_album_items(ALBUM_ID) + + TEMPLATE = "{album.artists}/{album.title}, {album.date:%Y}/{item.number:02d}. {item.artists} - {item.title} ({custom_field})" + + for album_item in album_items.items: + track = album_item.item + + print( + format_template( + template=TEMPLATE, + item=track, + album=album, + with_asterisk_ext=False, + custom_field="custom_field", + ) + ) diff --git a/pyproject.toml b/pyproject.toml index 61461a3..a954f6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [build-system] -requires = ["setuptools>=42", "wheel"] +requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" [project] name = "tiddl" -version = "2.8.0" +version = "3.0.0a2" description = "Download Tidal tracks with CLI downloader." readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.13" authors = [{ name = "oskvr37" }] classifiers = [ "Environment :: Console", @@ -15,18 +15,27 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "pydantic>=2.9.2", - "requests>=2.20.0", - "requests-cache>=1.2.1", - "click>=8.1.7", - "mutagen>=1.47.0", - "ffmpeg-asyncio>=0.1.3", + "aiofiles>=25.1.0", + "aiohttp>=3.13.2", "m3u8>=6.0.0", - "rich>=13.9.4" + "mutagen>=1.47.0", + "pydantic>=2.12.4", + "requests>=2.32.5", + "requests-cache>=1.2.1", + "typer>=0.20.0", ] [project.urls] homepage = "https://github.com/oskvr37/tiddl" [project.scripts] -tiddl = "tiddl.cli:cli" +tiddl = "tiddl.cli.app:app" + +[tool.coverage.run] +omit = ["*/models/*", "*/models.py"] + +[dependency-groups] +dev = [ + "pytest>=8.4.2", + "pytest-mock>=3.15.1", +] diff --git a/tests/cli/commands/auth/test_auth.py b/tests/cli/commands/auth/test_auth.py new file mode 100644 index 0000000..7118dff --- /dev/null +++ b/tests/cli/commands/auth/test_auth.py @@ -0,0 +1,200 @@ +import pytest +from unittest.mock import patch, MagicMock +from time import time +from typer.testing import CliRunner + +from tiddl.core.auth import AuthClientError +from tiddl.cli.commands.auth import auth_command +from tiddl.cli.utils.auth import AuthData + +runner = CliRunner() + + +def test_login_already_logged(monkeypatch: pytest.MonkeyPatch): + """Should exit early if user is logged in.""" + + monkeypatch.setattr( + "tiddl.cli.commands.auth.load_auth_data", lambda: AuthData(token="token") + ) + + result = runner.invoke(auth_command, ["login"]) + + assert "Already logged in." in result.stdout + assert result.exit_code == 0 + + +def test_login_success(monkeypatch: pytest.MonkeyPatch): + """Should save user token.""" + + monkeypatch.setattr( + "tiddl.cli.commands.auth.load_auth_data", lambda: AuthData(token=None) + ) + + device_auth_mock = MagicMock() + device_auth_mock.verificationUriComplete = "verify.uri" + device_auth_mock.deviceCode = "device123" + device_auth_mock.expiresIn = 60 + device_auth_mock.interval = 1 + + auth_mock = MagicMock() + auth_mock.access_token = "newtoken" + auth_mock.refresh_token = "refreshtoken" + auth_mock.expires_in = 3600 + auth_mock.user_id = 123 + auth_mock.user.countryCode = "US" + + with ( + patch("tiddl.cli.commands.auth.AuthAPI") as MockAuthAPI, + patch("tiddl.cli.commands.auth.typer.launch") as mock_launch, + patch("tiddl.cli.commands.auth.save_auth_data") as mock_save, + patch("tiddl.cli.commands.auth.time", side_effect=lambda: 1000), + patch("tiddl.cli.commands.auth.sleep"), + ): + + auth_api = MockAuthAPI.return_value + auth_api.get_device_auth.return_value = device_auth_mock + auth_api.get_auth.side_effect = [ + AuthClientError(error="authorization_pending"), + auth_mock, + ] + + result = runner.invoke(auth_command, ["login"]) + + auth_api.get_device_auth.assert_called_once() + auth_api.get_auth.assert_called() + mock_launch.assert_called_once_with("https://verify.uri") + mock_save.assert_called_once() + saved_data = mock_save.call_args[0][0] + assert saved_data.token == "newtoken" + assert "Logged in!" in result.stdout + assert result.exit_code == 0 + + +def test_login_expired(monkeypatch: pytest.MonkeyPatch): + """Should not save token and exit.""" + + monkeypatch.setattr( + "tiddl.cli.commands.auth.load_auth_data", lambda: AuthData(token=None) + ) + + device_auth_mock = MagicMock() + device_auth_mock.verificationUriComplete = "verify.uri" + device_auth_mock.deviceCode = "device123" + device_auth_mock.expiresIn = 60 + device_auth_mock.interval = 1 + + with ( + patch("tiddl.cli.commands.auth.AuthAPI") as MockAuthAPI, + patch("tiddl.cli.commands.auth.typer.launch") as mock_launch, + patch("tiddl.cli.commands.auth.save_auth_data") as mock_save, + patch("tiddl.cli.commands.auth.time", side_effect=lambda: 1000), + patch("tiddl.cli.commands.auth.sleep"), + ): + + auth_api = MockAuthAPI.return_value + auth_api.get_device_auth.return_value = device_auth_mock + auth_api.get_auth.side_effect = [ + AuthClientError(error="expired_token"), + ] + + result = runner.invoke(auth_command, ["login"]) + + auth_api.get_device_auth.assert_called_once() + auth_api.get_auth.assert_called() + mock_launch.assert_called_once_with("https://verify.uri") + mock_save.assert_not_called() + assert "Time for authentication has expired." in result.stdout + assert result.exit_code == 0 + + +def test_logout_with_token(monkeypatch: pytest.MonkeyPatch): + """Should clear auth data and logout token in API.""" + + monkeypatch.setattr( + "tiddl.cli.commands.auth.load_auth_data", lambda: AuthData(token="token") + ) + + with ( + patch("tiddl.cli.commands.auth.AuthAPI") as MockAuthAPI, + patch("tiddl.cli.commands.auth.save_auth_data") as mock_save, + ): + mock_api_instance = MockAuthAPI.return_value + result = runner.invoke(auth_command, ["logout"]) + + mock_api_instance.logout_token.assert_called_once_with("token") + mock_save.assert_called_once_with(AuthData()) + + assert "Logged out!" in result.stdout + assert result.exit_code == 0 + + +def test_logout_no_token(monkeypatch: pytest.MonkeyPatch): + """Should only clear auth data.""" + + monkeypatch.setattr( + "tiddl.cli.commands.auth.load_auth_data", lambda: AuthData(token=None) + ) + + with ( + patch("tiddl.cli.commands.auth.AuthAPI") as MockAuthAPI, + patch("tiddl.cli.commands.auth.save_auth_data") as mock_save, + ): + result = runner.invoke(auth_command, ["logout"]) + + mock_save.assert_called_once_with(AuthData()) + MockAuthAPI.assert_not_called() + + assert "Logged out!" in result.stdout + assert result.exit_code == 0 + + +def test_refresh_not_logged_in(monkeypatch: pytest.MonkeyPatch): + """Should exit early if refresh_token is missing.""" + + monkeypatch.setattr( + "tiddl.cli.commands.auth.load_auth_data", lambda: AuthData(refresh_token=None) + ) + result = runner.invoke(auth_command, ["refresh"]) + + assert "Not logged in." in result.stdout + assert result.exit_code == 0 + + +def test_refresh_not_expired(monkeypatch: pytest.MonkeyPatch): + """Should exit early if token still valid.""" + + monkeypatch.setattr( + "tiddl.cli.commands.auth.load_auth_data", + lambda: AuthData( + token="abc", refresh_token="ref", expires_at=int(time()) + 3600 + ), + ) + result = runner.invoke(auth_command, ["refresh"]) + + assert "Auth token expires in" in result.stdout + assert result.exit_code == 0 + + +def test_refresh_success(monkeypatch: pytest.MonkeyPatch): + """Should refresh token if expired.""" + + expired_data = AuthData( + token="oldtoken", refresh_token="refreshtoken", expires_at=0 + ) + monkeypatch.setattr("tiddl.cli.commands.auth.load_auth_data", lambda: expired_data) + + mock_auth_response = MagicMock() + mock_auth_response.access_token = "newtoken" + + with ( + patch("tiddl.cli.commands.auth.AuthAPI") as MockAuthAPI, + patch("tiddl.cli.commands.auth.save_auth_data") as mock_save, + ): + + MockAuthAPI.return_value.refresh_token.return_value = mock_auth_response + + result = runner.invoke(auth_command, ["refresh"]) + + mock_save.assert_called_once_with(expired_data) + assert "Auth token has been refreshed!" in result.stdout + assert result.exit_code == 0 diff --git a/tests/cli/commands/auth/test_auth_utils.py b/tests/cli/commands/auth/test_auth_utils.py new file mode 100644 index 0000000..5cf50fd --- /dev/null +++ b/tests/cli/commands/auth/test_auth_utils.py @@ -0,0 +1,41 @@ +from pathlib import Path + +from tiddl.cli.utils.auth.core import load_auth_data, save_auth_data +from tiddl.cli.utils.auth.models import AuthData + + +def test_load_auth_data(tmp_path: Path): + file = tmp_path / "auth.json" + + auth_data = AuthData( + token="token", + refresh_token="refresh_token", + expires_at=0, + user_id="user_id", + country_code="country_code", + ) + + file.write_text(auth_data.model_dump_json()) + + loaded_auth_data = load_auth_data(file) + + assert isinstance(loaded_auth_data, AuthData) + assert loaded_auth_data.__dict__ == auth_data.__dict__ + + +def test_save_auth_data(tmp_path: Path): + file = tmp_path / "auth.json" + + auth_data = AuthData( + token="token", + refresh_token="refresh_token", + expires_at=0, + user_id="user_id", + country_code="country_code", + ) + + save_auth_data(auth_data=auth_data, file=file) + + loaded_auth_data = AuthData.model_validate_json(file.read_text()) + + assert loaded_auth_data.__dict__ == auth_data.__dict__ diff --git a/tests/cli/commands/test_commands.py b/tests/cli/commands/test_commands.py new file mode 100644 index 0000000..8469786 --- /dev/null +++ b/tests/cli/commands/test_commands.py @@ -0,0 +1,13 @@ +import typer + +from tiddl.cli.commands import register_commands, COMMANDS + + +def test_register_commands_adds_typers(): + app = typer.Typer() + register_commands(app) + + registered_names = [cmd.name for cmd in app.registered_groups + app.registered_commands] + + for command in COMMANDS: + assert command.info.name in registered_names diff --git a/tests/cli/test_config.py b/tests/cli/test_config.py new file mode 100644 index 0000000..30f27d7 --- /dev/null +++ b/tests/cli/test_config.py @@ -0,0 +1,63 @@ +from pathlib import Path +from pytest import raises + +from tiddl.cli.config import load_config_file, Config, CONFIG_FILENAME + + +def write_config(tmp_path: Path, content: str) -> Path: + cfg_path = tmp_path / CONFIG_FILENAME + cfg_path.write_text(content) + return cfg_path + + +def test_missing_file_default_config(tmp_path: Path): + cfg_file = tmp_path / "nonexistent.toml" + cfg = load_config_file(cfg_file) + + assert isinstance(cfg, Config) + + +def test_valid_config_file(tmp_path: Path): + cfg_file = write_config( + tmp_path, + """ + enable_cache = false + debug = true + + [download] + track_quality = "max" + threads_count = 8 + """, + ) + + cfg = load_config_file(cfg_file) + + assert cfg.enable_cache is False + assert cfg.debug is True + assert cfg.download.track_quality == "max" + assert cfg.download.threads_count == 8 + + +def test_invalid_type_raises(tmp_path: Path): + cfg_file = write_config( + tmp_path, + """ + enable_cache = "not_a_bool" + """, + ) + + with raises(Exception): + load_config_file(cfg_file) + + +def test_invalid_track_quality_raises(tmp_path: Path): + cfg_file = write_config( + tmp_path, + """ + [download] + track_quality = "ultra" + """, + ) + + with raises(Exception): + load_config_file(cfg_file) diff --git a/tests/cli/test_const.py b/tests/cli/test_const.py new file mode 100644 index 0000000..1e72e90 --- /dev/null +++ b/tests/cli/test_const.py @@ -0,0 +1,20 @@ +import pytest +from pathlib import Path + +from tiddl.cli.const import get_app_path, APP_DIR_NAME, ENV_KEY + + +def test_env_key_overrides(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + custom_path = tmp_path / "customdir" + monkeypatch.setenv(ENV_KEY, str(custom_path)) + app_path = get_app_path(ENV_KEY) + + assert app_path == custom_path + + +def test_default_path_if_unset(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv(ENV_KEY, raising=False) + app_path = get_app_path(ENV_KEY) + + assert str(Path.home()) in str(app_path) + assert app_path.name == APP_DIR_NAME diff --git a/tests/cli/test_utils.py b/tests/cli/test_utils.py new file mode 100644 index 0000000..96b57df --- /dev/null +++ b/tests/cli/test_utils.py @@ -0,0 +1,68 @@ +import pytest +from tiddl.cli.utils.resource import TidalResource, ResourceTypeLiteral + +valid_test_data = [ + ("track", "12345"), + ("album", "98765"), + ("video", "11111"), + ("artist", "22222"), + ("playlist", "abcde"), + ("mix", "xyz123"), +] + + +@pytest.mark.parametrize("resource_type, resource_id", valid_test_data) +def test_tidalresource_from_string_shorthand( + resource_type: ResourceTypeLiteral, resource_id: str +): + string = f"{resource_type}/{resource_id}" + res = TidalResource.from_string(string) + + assert res.type == resource_type + assert res.id == resource_id + assert str(res) == string + assert res.url == f"https://listen.tidal.com/{resource_type}/{resource_id}" + + +@pytest.mark.parametrize("resource_type, resource_id", valid_test_data) +def test_tidalresource_from_string_url( + resource_type: ResourceTypeLiteral, resource_id: str +): + url = f"https://listen.tidal.com/{resource_type}/{resource_id}" + res = TidalResource.from_string(url) + + assert res.type == resource_type + assert res.id == resource_id + assert str(res) == f"{resource_type}/{resource_id}" + assert res.url == url + + +def test_from_string_invalid_type(): + with pytest.raises(ValueError, match="Invalid resource type"): + TidalResource.from_string("invalid/123") + + +invalid_test_data = [ + ("track", "abc"), + ("album", "xyz"), + ("video", "id123"), + ("artist", "user1"), +] + + +@pytest.mark.parametrize("resource_type, invalid_id", invalid_test_data) +def test_from_string_invalid_digit_id( + resource_type: ResourceTypeLiteral, invalid_id: str +): + with pytest.raises(ValueError, match="Invalid resource id"): + TidalResource.from_string(f"{resource_type}/{invalid_id}") + + +def test_url_property(): + res = TidalResource(type="track", id="12345") + assert res.url == "https://listen.tidal.com/track/12345" + + +def test_str_method(): + res = TidalResource(type="album", id="67890") + assert str(res) == "album/67890" diff --git a/tests/core/api/test_api_api.py b/tests/core/api/test_api_api.py new file mode 100644 index 0000000..5951f14 --- /dev/null +++ b/tests/core/api/test_api_api.py @@ -0,0 +1,206 @@ +import pytest +from pytest_mock import MockerFixture, MockType + +from tiddl.core.api.api import ( + TidalAPI, + TidalClient, + Limits, + DO_NOT_CACHE, + EXPIRE_IMMEDIATELY, +) + +from tiddl.core.api.models import ( + Album, + Artist, + Playlist, + Track, + Video, + AlbumItems, + AlbumItemsCredits, + ArtistAlbumsItems, + Favorites, + TrackLyrics, + PlaylistItems, + MixItems, + Search, + SessionResponse, + TrackStream, + VideoStream, +) + + +def test_tidal_api_init(mocker: MockerFixture): + mock_client = mocker.Mock(spec=TidalClient) + + api = TidalAPI(client=mock_client, user_id="u123", country_code="US") + + assert api.client is mock_client + assert api.user_id == "u123" + assert api.country_code == "US" + + +@pytest.fixture +def mock_client(mocker: MockerFixture): + return mocker.Mock(spec=TidalClient) + + +@pytest.fixture +def api(mock_client: MockType): + return TidalAPI(client=mock_client, user_id="u123", country_code="US") + + +def test_get_album(api: TidalAPI, mock_client: MockType): + api.get_album(album_id=1) + + mock_client.fetch.assert_called_once_with( + Album, "albums/1", {"countryCode": "US"}, expire_after=3600 + ) + + +def test_get_album_items(api: TidalAPI, mock_client: MockType): + api.get_album_items(1) + mock_client.fetch.assert_called_once_with( + AlbumItems, + "albums/1/items", + {"countryCode": "US", "limit": Limits.ALBUM_ITEMS, "offset": 0}, + expire_after=3600, + ) + + +def test_get_album_items_credits(api: TidalAPI, mock_client: MockType): + api.get_album_items_credits(1) + mock_client.fetch.assert_called_once_with( + AlbumItemsCredits, + "albums/1/items/credits", + {"countryCode": "US", "limit": Limits.ALBUM_ITEMS, "offset": 0}, + expire_after=3600, + ) + + +def test_get_artist(api: TidalAPI, mock_client: MockType): + api.get_artist(1) + mock_client.fetch.assert_called_once_with( + Artist, "artists/1", {"countryCode": "US"}, expire_after=3600 + ) + + +def test_get_artist_albums(api: TidalAPI, mock_client: MockType): + api.get_artist_albums(1) + mock_client.fetch.assert_called_once_with( + ArtistAlbumsItems, + "artists/1/albums", + { + "countryCode": "US", + "limit": Limits.ARTIST_ALBUMS, + "offset": 0, + "filter": "ALBUMS", + }, + expire_after=3600, + ) + + +def test_get_mix(api: TidalAPI, mock_client: MockType): + api.get_mix_items("abcd-1234") + mock_client.fetch.assert_called_once_with( + MixItems, + "mixes/abcd-1234/items", + {"countryCode": "US", "limit": Limits.MIX_ITEMS, "offset": 0}, + expire_after=3600, + ) + + +def test_get_favorites(api: TidalAPI, mock_client: MockType): + api.get_favorites() + mock_client.fetch.assert_called_once_with( + Favorites, + "users/u123/favorites/ids", + {"countryCode": "US"}, + expire_after=EXPIRE_IMMEDIATELY, + ) + + +def test_get_playlist(api: TidalAPI, mock_client: MockType): + api.get_playlist("uuid") + mock_client.fetch.assert_called_once_with( + Playlist, + "playlists/uuid", + {"countryCode": "US"}, + expire_after=EXPIRE_IMMEDIATELY, + ) + + +def test_get_playlist_items(api: TidalAPI, mock_client: MockType): + api.get_playlist_items("uuid") + mock_client.fetch.assert_called_once_with( + PlaylistItems, + "playlists/uuid/items", + {"countryCode": "US", "limit": Limits.PLAYLIST_ITEMS, "offset": 0}, + expire_after=EXPIRE_IMMEDIATELY, + ) + + +def test_get_search(api: TidalAPI, mock_client: MockType): + api.get_search("query") + mock_client.fetch.assert_called_once_with( + Search, + "search", + {"countryCode": "US", "query": "query"}, + expire_after=DO_NOT_CACHE, + ) + + +def test_get_session(api: TidalAPI, mock_client: MockType): + api.get_session() + mock_client.fetch.assert_called_once_with( + SessionResponse, "sessions", expire_after=DO_NOT_CACHE + ) + + +def test_get_track_lyrics(api: TidalAPI, mock_client: MockType): + api.get_track_lyrics(1) + mock_client.fetch.assert_called_once_with( + TrackLyrics, + "tracks/1/lyrics", + {"countryCode": "US"}, + expire_after=3600, + ) + + +def test_get_track(api: TidalAPI, mock_client: MockType): + api.get_track(1) + mock_client.fetch.assert_called_once_with( + Track, + "tracks/1", + {"countryCode": "US"}, + expire_after=3600, + ) + + +def test_get_track_stream(api: TidalAPI, mock_client: MockType): + api.get_track_stream(1, "HIGH") + mock_client.fetch.assert_called_once_with( + TrackStream, + "tracks/1/playbackinfopostpaywall", + {"audioquality": "HIGH", "playbackmode": "STREAM", "assetpresentation": "FULL"}, + expire_after=DO_NOT_CACHE, + ) + + +def test_get_video(api: TidalAPI, mock_client: MockType): + api.get_video(1) + mock_client.fetch.assert_called_once_with( + Video, + "videos/1", + {"countryCode": "US"}, + expire_after=3600, + ) + + +def test_get_video_stream(api: TidalAPI, mock_client: MockType): + api.get_video_stream(1, "HIGH") + mock_client.fetch.assert_called_once_with( + VideoStream, + "videos/1/playbackinfopostpaywall", + {"videoquality": "HIGH", "playbackmode": "STREAM", "assetpresentation": "FULL"}, + expire_after=DO_NOT_CACHE, + ) diff --git a/tests/core/api/test_api_client.py b/tests/core/api/test_api_client.py new file mode 100644 index 0000000..d34d23f --- /dev/null +++ b/tests/core/api/test_api_client.py @@ -0,0 +1,93 @@ +import pytest +import json + +from pydantic import BaseModel +from pytest_mock import MockerFixture +from pathlib import Path + +from tiddl.core.api.client import TidalClient, ApiError + + +def test_tidal_client_init(mocker: MockerFixture): + mock_cached_session = mocker.patch("tiddl.core.api.client.CachedSession") + mock_session = mock_cached_session.return_value + + client = TidalClient( + token="test-token", + cache_name="test_cache", + omit_cache=True, + debug_path=Path("/tmp/debug"), + ) + + mock_cached_session.assert_called_once_with( + cache_name="test_cache", always_revalidate=True + ) + + assert client.token == "test-token" + assert client.debug_path == Path("/tmp/debug") + assert client.session is mock_session + assert mock_session.headers["Authorization"] == "Bearer test-token" + assert mock_session.headers["Accept"] == "application/json" + + +@pytest.mark.parametrize("omit_cache", [True, False]) +def test_omit_cache_flag(mocker: MockerFixture, omit_cache: bool): + mock_cached_session = mocker.patch("tiddl.core.api.client.CachedSession") + TidalClient("token", "cache", omit_cache=omit_cache) + mock_cached_session.assert_called_once_with( + cache_name="cache", always_revalidate=omit_cache + ) + + +class DummyModel(BaseModel): + foo: str + + +def test_fetch_success(mocker: MockerFixture, tmp_path: Path): + mock_session = mocker.Mock() + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.from_cache = False + mock_response.json.return_value = {"foo": "bar"} + mock_session.get.return_value = mock_response + + mocker.patch("tiddl.core.api.client.API_URL", "https://api.test") + client = TidalClient("token", tmp_path / "cache", debug_path=tmp_path) + client.session = mock_session + + result = client.fetch(DummyModel, "albums/123", {"limit": 10}, expire_after=999) + assert result.foo == "bar" + + mock_session.get.assert_called_once_with( + "https://api.test/albums/123", + params={"limit": 10}, + expire_after=999, + ) + + debug_file = tmp_path / "albums/123.json" + assert debug_file.exists() + + content = json.loads(debug_file.read_text()) + assert content["status_code"] == 200 + assert content["endpoint"] == "albums/123" + assert content["params"]["limit"] == 10 + assert content["data"]["foo"] == "bar" + + +def test_fetch_error_raises_api_error(mocker: MockerFixture, tmp_path: Path): + mock_session = mocker.Mock() + mock_response = mocker.Mock() + mock_response.status_code = 400 + mock_response.from_cache = False + mock_response.json.return_value = { + "status": 400, + "subStatus": "Bad request", + "userMessage": "user_message", + } + mock_session.get.return_value = mock_response + + client = TidalClient("token", tmp_path / "cache") + client.session = mock_session + + with pytest.raises(ApiError): + client.fetch(DummyModel, "bad/endpoint") diff --git a/tests/core/api/test_api_exceptions.py b/tests/core/api/test_api_exceptions.py new file mode 100644 index 0000000..3d79b60 --- /dev/null +++ b/tests/core/api/test_api_exceptions.py @@ -0,0 +1,38 @@ +import pytest +from typing import Any +from tiddl.core.api.exceptions import ApiError + + +def test_api_error_attributes(): + data: dict[str, Any] = { + "status": 1, + "subStatus": "sub_status", + "userMessage": "user_message", + } + + e = ApiError(**data) + + assert isinstance(e, Exception) + assert e.status == data["status"] + assert e.sub_status == data["subStatus"] + assert e.user_message == data["userMessage"] + + +def test_api_error_raises(): + with pytest.raises(ApiError) as exc: + raise ApiError(400, "bad_request", "invalid") + + assert exc.value.status == 400 + assert exc.value.sub_status == "bad_request" + + +def test_api_error_string(): + data: dict[str, Any] = { + "status": 1, + "subStatus": "sub_status", + "userMessage": "user_message", + } + + e = ApiError(**data) + + assert str(e) == f"{e.user_message}, {e.status}/{e.sub_status}" diff --git a/tests/core/auth/test_auth_api.py b/tests/core/auth/test_auth_api.py new file mode 100644 index 0000000..acb6b49 --- /dev/null +++ b/tests/core/auth/test_auth_api.py @@ -0,0 +1,105 @@ +from typing import Any +import pytest +from pytest_mock import MockerFixture +from tiddl.core.auth.api import AuthAPI +from tiddl.core.auth.models import ( + AuthDeviceResponse, + AuthResponseWithRefresh, + AuthResponse, +) + + +@pytest.fixture +def mock_auth_client(mocker: MockerFixture) -> Any: + client = mocker.Mock() + + client.get_device_auth.return_value = { + "deviceCode": "abc", + "userCode": "123", + "verificationUri": "https://verify", + "verificationUriComplete": "https://verify?code=123", + "expiresIn": 300, + "interval": 5, + } + + user_data: dict[str, Any] = { + "userId": 1, + "email": "test@example.com", + "countryCode": "US", + "fullName": None, + "firstName": None, + "lastName": None, + "nickname": None, + "username": "tester", + "address": None, + "city": None, + "postalcode": None, + "usState": None, + "phoneNumber": None, + "birthday": None, + "channelId": 0, + "parentId": 0, + "acceptedEULA": True, + "created": 0, + "updated": 0, + "facebookUid": 0, + "appleUid": None, + "googleUid": None, + "accountLinkCreated": True, + "emailVerified": True, + "newUser": True, + } + + auth_base: dict[str, Any] = { + "access_token": "token123", + "refresh_token": "refresh123", + "expires_in": 3600, + "user_id": 1, + "scope": "r_usr", + "clientName": "tidal", + "token_type": "Bearer", + "user": user_data, + } + + client.get_auth.return_value = auth_base.copy() + client.refresh_token.return_value = auth_base.copy() + client.logout_token.return_value = None + + return client + + +def test_get_device_auth_returns_model(mock_auth_client: Any) -> None: + api: AuthAPI = AuthAPI(client=mock_auth_client) + result: AuthDeviceResponse = api.get_device_auth() + + mock_auth_client.get_device_auth.assert_called_once() + assert isinstance(result, AuthDeviceResponse) + assert result.deviceCode == "abc" + assert result.interval == 5 + + +def test_get_auth_returns_model(mock_auth_client: Any) -> None: + api: AuthAPI = AuthAPI(client=mock_auth_client) + result: AuthResponseWithRefresh = api.get_auth("device123") + + mock_auth_client.get_auth.assert_called_once_with("device123") + assert isinstance(result, AuthResponseWithRefresh) + assert result.access_token == "token123" + assert result.refresh_token == "refresh123" + assert result.user.userId == 1 + + +def test_refresh_token_returns_model(mock_auth_client: Any) -> None: + api: AuthAPI = AuthAPI(client=mock_auth_client) + result: AuthResponse = api.refresh_token("refresh123") + + mock_auth_client.refresh_token.assert_called_once_with("refresh123") + assert isinstance(result, AuthResponse) + assert result.access_token == "token123" + + +def test_logout_token_calls_client(mock_auth_client: Any) -> None: + api: AuthAPI = AuthAPI(client=mock_auth_client) + api.logout_token("token123") + + mock_auth_client.logout_token.assert_called_once_with("token123") diff --git a/tests/core/auth/test_auth_client.py b/tests/core/auth/test_auth_client.py new file mode 100644 index 0000000..5e7c613 --- /dev/null +++ b/tests/core/auth/test_auth_client.py @@ -0,0 +1,128 @@ +import pytest +from pytest_mock import MockerFixture +from tiddl.core.auth.client import AuthClient +from tiddl.core.auth.exceptions import AuthClientError + + +def test_get_device_auth_calls_request(mocker: MockerFixture): + mock_request = mocker.patch("tiddl.core.auth.client.request") + + data = {"device_code": "abc"} + mock_response = mocker.Mock() + mock_response.json.return_value = data + mock_request.return_value = mock_response + + client = AuthClient() + result = client.get_device_auth() + + mock_request.assert_called_once_with( + "POST", + "https://auth.tidal.com/v1/oauth2/device_authorization", + data={"client_id": client.client_id, "scope": "r_usr+w_usr+w_sub"}, + ) + + assert result == data + + +def test_get_auth_returns_json_on_200(mocker: MockerFixture): + mock_request = mocker.patch("tiddl.core.auth.client.request") + mock_response = mocker.Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "token123", + "refresh_token": "refresh123", + "expires_in": 3600, + } + mock_request.return_value = mock_response + + client = AuthClient() + result = client.get_auth("device123") + + assert result["access_token"] == "token123" + assert result["refresh_token"] == "refresh123" + assert result["expires_in"] == 3600 + + mock_request.assert_called_once_with( + "POST", + "https://auth.tidal.com/v1/oauth2/token", + data={ + "client_id": client.client_id, + "device_code": "device123", + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "scope": "r_usr+w_usr+w_sub", + }, + auth=(client.client_id, client.client_secret), + ) + + +def test_get_auth_raises_on_non_200(mocker: MockerFixture): + mock_request = mocker.patch("tiddl.core.auth.client.request") + mock_response = mocker.Mock() + mock_response.status_code = 400 + mock_response.json.return_value = { + "error": "error", + "status": 400, + "sub_status": 1001, + "error_description": "invalid", + } + mock_request.return_value = mock_response + + client = AuthClient() + + with pytest.raises(AuthClientError): + client.get_auth("device123") + + mock_request.assert_called_once_with( + "POST", + "https://auth.tidal.com/v1/oauth2/token", + data={ + "client_id": client.client_id, + "device_code": "device123", + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "scope": "r_usr+w_usr+w_sub", + }, + auth=(client.client_id, client.client_secret), + ) + + +def test_refresh_token(mocker: MockerFixture): + mock_request = mocker.patch("tiddl.core.auth.client.request") + + mock_response = mocker.Mock() + mock_response.status_code = 400 + mock_response.json.return_value = { + "token": "abc", + } + mock_request.return_value = mock_response + + refresh_token = "token" + + client = AuthClient() + result = client.refresh_token(refresh_token) + + mock_request.assert_called_once_with( + "POST", + "https://auth.tidal.com/v1/oauth2/token", + data={ + "client_id": client.client_id, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + "scope": "r_usr+w_usr+w_sub", + }, + auth=(client.client_id, client.client_secret), + ) + + assert result["token"] == "abc" + + +def test_logout_token(mocker: MockerFixture): + mock_request = mocker.patch("tiddl.core.auth.client.request") + + client = AuthClient() + client.logout_token("token") + + mock_request.assert_called_once_with( + "POST", + "https://api.tidal.com/v1/logout", + headers={"authorization": "Bearer token"}, + ) diff --git a/tests/core/auth/test_auth_exceptions.py b/tests/core/auth/test_auth_exceptions.py new file mode 100644 index 0000000..0b21c38 --- /dev/null +++ b/tests/core/auth/test_auth_exceptions.py @@ -0,0 +1,41 @@ +import pytest +from typing import Any +from tiddl.core.auth.exceptions import AuthClientError + + +def test_auth_client_error_attributes(): + data: dict[str, Any] = { + "status": 1, + "error": "error", + "sub_status": "sub_status", + "error_description": "error_description", + } + + e = AuthClientError(**data) + + assert isinstance(e, Exception) + assert e.status == data["status"] + assert e.error == data["error"] + assert e.sub_status == data["sub_status"] + assert e.error_description == data["error_description"] + + +def test_auth_client_error_raises(): + with pytest.raises(AuthClientError) as exc: + raise AuthClientError(400, "bad_request", "invalid", "Malformed input") + + assert exc.value.status == 400 + assert exc.value.error == "bad_request" + + +def test_auth_client_error_string(): + data: dict[str, Any] = { + "status": 1, + "error": "error", + "sub_status": "sub_status", + "error_description": "error_description", + } + + e = AuthClientError(**data) + + assert str(e) == f"{e.error}, {e.error_description}, {e.status}/{e.sub_status}" diff --git a/tiddl/api.py b/tiddl/api.py deleted file mode 100644 index 66246df..0000000 --- a/tiddl/api.py +++ /dev/null @@ -1,291 +0,0 @@ -import json -import logging -from pathlib import Path -from typing import Any, Literal, Type, TypeVar - -from pydantic import BaseModel -from requests_cache import ( - CachedSession, - EXPIRE_IMMEDIATELY, - NEVER_EXPIRE, - DO_NOT_CACHE, -) - -from tiddl.models.api import ( - Album, - AlbumItems, - AlbumItemsCredits, - Artist, - ArtistAlbumsItems, - ArtistVideosItems, - Favorites, - Playlist, - PlaylistItems, - Search, - SessionResponse, - Track, - TrackStream, - Video, - VideoStream, - Lyrics, - MixItems, -) - -from tiddl.models.constants import TrackQuality -from tiddl.exceptions import ApiError -from tiddl.config import HOME_PATH - -DEBUG = False - -T = TypeVar("T", bound=BaseModel) - -logger = logging.getLogger(__name__) - - -def ensureLimit(limit: int, max_limit: int) -> int: - if limit > max_limit: - logger.warning(f"Max limit is {max_limit}") - return max_limit - - return limit - - -class Limits: - ARTIST_ALBUMS = 50 - ARTIST_VIDEOS = 50 - ALBUM_ITEMS = 10 - ALBUM_ITEMS_MAX = 100 - PLAYLIST = 50 - MIX_ITEMS = 100 - - -class TidalApi: - URL = "https://api.tidal.com/v1" - LIMITS = Limits - - def __init__( - self, token: str, user_id: str, country_code: str, omit_cache=False - ) -> None: - self.user_id = user_id - self.country_code = country_code - - # 3.0 TODO: change cache path - CACHE_NAME = "tiddl_api_cache" - - self.session = CachedSession( - cache_name=HOME_PATH / CACHE_NAME, always_revalidate=omit_cache - ) - self.session.headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/json", - } - - def fetch( - self, - model: Type[T], - endpoint: str, - params: dict[str, Any] = {}, - expire_after=NEVER_EXPIRE, - ) -> T: - """Fetch data from the API and parse it into the given Pydantic model.""" - - req = self.session.get( - f"{self.URL}/{endpoint}", params=params, expire_after=expire_after - ) - - logger.debug( - ( - endpoint, - params, - req.status_code, - "HIT" if req.from_cache else "MISS", - ) - ) - - data = req.json() - - if DEBUG: - debug_data = { - "status_code": req.status_code, - "endpoint": endpoint, - "params": params, - "data": data, - } - - path = Path(f"debug_data/{endpoint}.json") - path.parent.mkdir(parents=True, exist_ok=True) - - with path.open("w", encoding="utf-8") as f: - json.dump(debug_data, f, indent=2) - - if req.status_code != 200: - raise ApiError(**data) - - return model.model_validate(data) - - def getAlbum(self, album_id: str | int): - return self.fetch( - Album, f"albums/{album_id}", {"countryCode": self.country_code} - ) - - def getAlbumItems(self, album_id: str | int, limit=LIMITS.ALBUM_ITEMS, offset=0): - return self.fetch( - AlbumItems, - f"albums/{album_id}/items", - { - "countryCode": self.country_code, - "limit": ensureLimit(limit, self.LIMITS.ALBUM_ITEMS_MAX), - "offset": offset, - }, - ) - - def getAlbumItemsCredits( - self, album_id: str | int, limit=LIMITS.ALBUM_ITEMS, offset=0 - ): - return self.fetch( - AlbumItemsCredits, - f"albums/{album_id}/items/credits", - { - "countryCode": self.country_code, - "limit": ensureLimit(limit, self.LIMITS.ALBUM_ITEMS_MAX), - "offset": offset, - }, - ) - - def getArtist(self, artist_id: str | int): - return self.fetch( - Artist, - f"artists/{artist_id}", - {"countryCode": self.country_code}, - expire_after=3600, - ) - - def getArtistAlbums( - self, - artist_id: str | int, - limit=LIMITS.ARTIST_ALBUMS, - offset=0, - filter: Literal["ALBUMS", "EPSANDSINGLES"] = "ALBUMS", - ): - return self.fetch( - ArtistAlbumsItems, - f"artists/{artist_id}/albums", - { - "countryCode": self.country_code, - "limit": limit, # tested limit 10,000 - "offset": offset, - "filter": filter, - }, - expire_after=3600, - ) - - def getArtistVideos( - self, - artist_id: str | int, - limit: int = LIMITS.ARTIST_VIDEOS, - offset: int = 0, - ): - return self.fetch( - ArtistVideosItems, - f"artists/{artist_id}/videos", - { - "countryCode": self.country_code, - "limit": limit, - "offset": offset, - }, - 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, - f"users/{self.user_id}/favorites/ids", - {"countryCode": self.country_code}, - expire_after=EXPIRE_IMMEDIATELY, - ) - - def getPlaylist(self, playlist_uuid: str): - return self.fetch( - Playlist, - f"playlists/{playlist_uuid}", - {"countryCode": self.country_code}, - ) - - def getPlaylistItems(self, playlist_uuid: str, limit=LIMITS.PLAYLIST, offset=0): - return self.fetch( - PlaylistItems, - f"playlists/{playlist_uuid}/items", - { - "countryCode": self.country_code, - "limit": limit, - "offset": offset, - }, - expire_after=EXPIRE_IMMEDIATELY, - ) - - def getSearch(self, query: str): - return self.fetch( - Search, - "search", - {"countryCode": self.country_code, "query": query}, - expire_after=EXPIRE_IMMEDIATELY, - ) - - def getSession(self): - return self.fetch(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} - ) - - def getTrackStream(self, track_id: str | int, quality: TrackQuality): - return self.fetch( - TrackStream, - f"tracks/{track_id}/playbackinfo", - { - "audioquality": quality, - "playbackmode": "STREAM", - "assetpresentation": "FULL", - }, - expire_after=DO_NOT_CACHE, - ) - - def getVideo(self, video_id: str | int): - return self.fetch( - Video, f"videos/{video_id}", {"countryCode": self.country_code} - ) - - def getVideoStream(self, video_id: str | int): - return self.fetch( - VideoStream, - f"videos/{video_id}/playbackinfo", - { - "videoquality": "HIGH", - "playbackmode": "STREAM", - "assetpresentation": "FULL", - }, - expire_after=DO_NOT_CACHE, - ) diff --git a/tiddl/auth.py b/tiddl/auth.py deleted file mode 100644 index 7c28e22..0000000 --- a/tiddl/auth.py +++ /dev/null @@ -1,101 +0,0 @@ -import logging -import base64 -from os import environ - -from requests import request - -from tiddl.exceptions import AuthError -from tiddl.models import auth - -AUTH_URL = "https://auth.tidal.com/v1/oauth2" - - -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__) - - -def getDeviceAuth(): - req = request( - "POST", - f"{AUTH_URL}/device_authorization", - data={"client_id": CLIENT_ID, "scope": "r_usr+w_usr+w_sub"}, - ) - - data = req.json() - - if req.status_code == 200: - return auth.AuthDeviceResponse(**data) - - raise AuthError(**data) - - -def getToken(device_code: str): - req = request( - "POST", - f"{AUTH_URL}/token", - data={ - "client_id": CLIENT_ID, - "device_code": device_code, - "grant_type": "urn:ietf:params:oauth:grant-type:device_code", - "scope": "r_usr+w_usr+w_sub", - }, - auth=(CLIENT_ID, CLIENT_SECRET), - ) - - data = req.json() - - if req.status_code == 200: - return auth.AuthResponseWithRefresh(**data) - - raise AuthError(**data) - - -def refreshToken(refresh_token: str): - req = request( - "POST", - f"{AUTH_URL}/token", - data={ - "client_id": CLIENT_ID, - "refresh_token": refresh_token, - "grant_type": "refresh_token", - "scope": "r_usr+w_usr+w_sub", - }, - auth=(CLIENT_ID, CLIENT_SECRET), - ) - - data = req.json() - - if req.status_code == 200: - return auth.AuthResponse(**data) - - raise AuthError(**data) - - -def removeToken(access_token: str): - req = request( - "POST", - "https://api.tidal.com/v1/logout", - headers={"authorization": f"Bearer {access_token}"}, - ) - - logger.debug((req.status_code, req.text)) diff --git a/tiddl/cli/__init__.py b/tiddl/cli/__init__.py index b9b356e..c3ec16b 100644 --- a/tiddl/cli/__init__.py +++ b/tiddl/cli/__init__.py @@ -1,73 +1,15 @@ -import click import logging -from rich.logging import RichHandler +from tiddl.cli.const import APP_PATH -from tiddl.config import HOME_PATH -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() -@passContext -@click.option("--verbose", "-v", is_flag=True, help="Show debug logs.") -@click.option("--quiet", "-q", is_flag=True, help="Suppress logs.") -@click.option( - "--no-cache", "-nc", is_flag=True, help="Omit Tidal API requests caching." +file_handler = logging.FileHandler(APP_PATH / "latest.log", encoding="utf-8", mode="w") +file_handler.setLevel(logging.DEBUG) +file_handler.setFormatter( + logging.Formatter( + "%(asctime)s %(levelname)s\t[%(name)s.%(funcName)s] %(message)s" + ) ) -def cli(ctx: Context, verbose: bool, quiet: bool, no_cache: bool): - """TIDDL - Tidal Downloader \u266b""" - ctx.obj = ContextObj() - # latest logs - file_handler = logging.FileHandler( - HOME_PATH / "tiddl.log", mode="w", encoding="utf-8" - ) - - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter( - logging.Formatter( - "%(levelname)s [%(name)s.%(funcName)s] %(message)s", datefmt="[%X]" - ) - ) - - 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=[ - rich_handler, - file_handler, - ], - format="%(message)s", - datefmt="[%X]", - ) - - 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) -cli.add_command(UrlGroup) -cli.add_command(FavGroup) -cli.add_command(SearchGroup) -cli.add_command(FileGroup) - -if __name__ == "__main__": - cli() +log = logging.getLogger("tiddl") +log.setLevel(logging.DEBUG) +log.addHandler(file_handler) diff --git a/tiddl/cli/app.py b/tiddl/cli/app.py new file mode 100644 index 0000000..8df5f22 --- /dev/null +++ b/tiddl/cli/app.py @@ -0,0 +1,33 @@ +import typer +import logging +from rich.console import Console + +from tiddl.cli.config import APP_PATH +from tiddl.cli.ctx import ContextObject, Context +from tiddl.cli.commands import register_commands + +log = logging.getLogger("tiddl") + +app = typer.Typer(name="tiddl", no_args_is_help=True, rich_markup_mode="rich") +register_commands(app) + + +@app.callback() +def callback(ctx: Context, omit_cache: bool = False, debug: bool = False): + """ + tiddl - download tidal tracks \u266b + + [link=https://github.com/oskvr37/tiddl]github[/link] + [link=https://buymeacoffee.com/oskvr][yellow]buy me a coffee[/link] \u2764 + """ + + log.debug(f"{ctx.params=}") + + if debug: + debug_path = APP_PATH / "api_debug" + else: + debug_path = None + + ctx.obj = ContextObject( + api_omit_cache=omit_cache, console=Console(), debug_path=debug_path + ) diff --git a/tiddl/cli/auth.py b/tiddl/cli/auth.py deleted file mode 100644 index 05efd30..0000000 --- a/tiddl/cli/auth.py +++ /dev/null @@ -1,115 +0,0 @@ -import click -import logging - -from time import sleep, time - -from tiddl.config import AuthConfig -from tiddl.auth import ( - getDeviceAuth, - getToken, - refreshToken, - removeToken, - AuthError, -) -from tiddl.cli.ctx import passContext, Context - - -logger = logging.getLogger(__name__) - - -@click.group("auth") -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""" - - logger.debug("Invoked login command") - - if ctx.obj.config.auth.token: - logger.info("Already logged in.") - ctx.invoke(refresh) - return - - auth = getDeviceAuth() - - uri = f"https://{auth.verificationUriComplete}" - click.launch(uri) - - logger.info(f"Go to {uri} and complete authentication!") - - auth_end_at = time() + auth.expiresIn - - while True: - sleep(auth.interval) - - try: - token = getToken(auth.deviceCode) - except AuthError as e: - if e.error == "authorization_pending": - 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) - continue - - if e.error == "expired_token": - logger.info("\nTime for authentication has expired.") - break - - 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.save() - - logger.info("\nAuthenticated!") - - break - - -@AuthGroup.command("logout") -@passContext -def logout(ctx: Context): - """Remove token from config""" - - logger.debug("Invoked logout command") - - access_token = ctx.obj.config.auth.token - - if not access_token: - logger.info("Not logged in.") - return - - removeToken(access_token) - - ctx.obj.config.auth = AuthConfig() - ctx.obj.config.save() - - logger.info("Logged out!") diff --git a/tiddl/cli/commands/__init__.py b/tiddl/cli/commands/__init__.py new file mode 100644 index 0000000..2785152 --- /dev/null +++ b/tiddl/cli/commands/__init__.py @@ -0,0 +1,16 @@ +from typer import Typer + +from .auth import auth_command +from .download import download_command +# from .export import export_command + +COMMANDS = [ + auth_command, + download_command, + # export_command +] + + +def register_commands(app: Typer): + for command in COMMANDS: + app.add_typer(command, name=command.info.name) diff --git a/tiddl/cli/commands/auth.py b/tiddl/cli/commands/auth.py new file mode 100644 index 0000000..81dbcc4 --- /dev/null +++ b/tiddl/cli/commands/auth.py @@ -0,0 +1,108 @@ +import typer +from datetime import datetime +from time import time, sleep +from rich.console import Console + +from tiddl.cli.utils.auth.core import load_auth_data, save_auth_data, AuthData +from tiddl.core.auth import AuthAPI, AuthClientError + +console = Console() + +auth_command = typer.Typer( + name="auth", help="Manage Tidal authentication.", no_args_is_help=True +) + + +@auth_command.command(help="Login with your Tidal account.") +def login(): + loaded_auth_data = load_auth_data() + + if loaded_auth_data.token: + console.print("[cyan bold]Already logged in.") + raise typer.Exit() + + auth_api = AuthAPI() + device_auth = auth_api.get_device_auth() + + uri = f"https://{device_auth.verificationUriComplete}" + typer.launch(uri) + + console.print(f"Go to '{uri}' and complete authentication!") + + auth_end_at = time() + device_auth.expiresIn + + status_text = "Authenticating..." + + with console.status(status_text) as status: + while True: + sleep(device_auth.interval) + + try: + auth = auth_api.get_auth(device_auth.deviceCode) + auth_data = AuthData( + token=auth.access_token, + refresh_token=auth.refresh_token, + expires_at=auth.expires_in + int(time()), + user_id=str(auth.user_id), + country_code=auth.user.countryCode, + ) + save_auth_data(auth_data) + status.console.print("[bold green]Logged in!") + break + + except AuthClientError as e: + if e.error == "authorization_pending": + time_left = auth_end_at - time() + minutes, seconds = time_left // 60, int(time_left % 60) + status.update( + f"{status_text} time left: {minutes:.0f}:{seconds:02d}" + ) + continue + + if e.error == "expired_token": + status.console.print( + "\n[bold red]Time for authentication has expired." + ) + break + + +@auth_command.command(help="Logout and remove token from app.") +def logout(): + loaded_auth_data = load_auth_data() + + if loaded_auth_data.token: + auth_api = AuthAPI() + auth_api.logout_token(loaded_auth_data.token) + + save_auth_data(AuthData()) + + console.print("[bold green]Logged out!") + + +@auth_command.command(help="Refreshes your token in app.") +def refresh(): + loaded_auth_data = load_auth_data() + + if loaded_auth_data.refresh_token is None: + console.print("[bold red]Not logged in.") + raise typer.Exit() + + if time() < loaded_auth_data.expires_at: + expiry_time = datetime.fromtimestamp(loaded_auth_data.expires_at) + remaining = expiry_time - datetime.now() + hours, remainder = divmod(remaining.seconds, 3600) + minutes, _ = divmod(remainder, 60) + console.print( + f"[green]Auth token expires in {remaining.days}d {hours}h {minutes}m" + ) + return + + auth_api = AuthAPI() + auth_data = auth_api.refresh_token(loaded_auth_data.refresh_token) + + loaded_auth_data.token = auth_data.access_token + loaded_auth_data.expires_at = auth_data.expires_in + int(time()) + + save_auth_data(loaded_auth_data) + + console.print("[bold green]Auth token has been refreshed!") diff --git a/tiddl/cli/commands/download/__init__.py b/tiddl/cli/commands/download/__init__.py new file mode 100644 index 0000000..e43bf63 --- /dev/null +++ b/tiddl/cli/commands/download/__init__.py @@ -0,0 +1,502 @@ +import os +import typer +import asyncio + +from pathlib import Path +from logging import getLogger +from rich.live import Live + +from typing_extensions import Annotated + +from tiddl.core.metadata import add_track_metadata, add_video_metadata, Cover +from tiddl.core.api.models import Album, Track, Video, AlbumItemsCredits +from tiddl.core.utils.format import format_template +from tiddl.core.utils.m3u import save_tracks_to_m3u +from tiddl.cli.config import ( + CONFIG, + TRACK_QUALITY_LITERAL, + VIDEO_QUALITY_LITERAL, + ARTIST_SINGLES_FILTER_LITERAL, + VALID_M3U_RESOURCE_LITERAL, + VIDEOS_FILTER_LITERAL, +) +from tiddl.cli.utils.resource import TidalResource +from tiddl.cli.ctx import Context +from tiddl.cli.commands.auth import refresh +from tiddl.cli.commands.subcommands import url_subcommand + + +from .downloader import Downloader +from .output import RichOutput + +download_command = typer.Typer(name="download") +download_command.add_typer(url_subcommand) + +log = getLogger(__name__) + + +@download_command.callback(no_args_is_help=True) +def download_callback( + ctx: Context, + TRACK_QUALITY: Annotated[ + TRACK_QUALITY_LITERAL, + typer.Option( + "--track-quality", + "-q", + ), + ] = CONFIG.download.track_quality, + VIDEO_QUALITY: Annotated[ + VIDEO_QUALITY_LITERAL, + typer.Option( + "--video-quality", + "-vq", + ), + ] = CONFIG.download.video_quality, + SKIP_EXISTING: Annotated[ + bool, + typer.Option( + "--no-skip", + "-ns", + help="Don't skip downloading existing files.", + ), + ] = not CONFIG.download.skip_existing, + REWRITE_METADATA: Annotated[ + bool, + typer.Option( + "--rewrite-metadata", + "-r", + help="Rewrite metadata for already downloaded tracks.", + ), + ] = CONFIG.download.rewrite_metadata, + THREADS_COUNT: Annotated[ + int, + typer.Option( + "--threads-count", + "-t", + help="Number of concurrent download threads.", + min=1, + ), + ] = CONFIG.download.threads_count, + DOWNLOAD_PATH: Annotated[ + Path, + typer.Option( + "--path", + "-p", + help="Base directory path for all downloads.", + ), + ] = CONFIG.download.download_path, + SCAN_PATH: Annotated[ + Path, + typer.Option( + "--scan-path", + "--sp", + help="Directory to search for your existing downloads.", + ), + ] = CONFIG.download.scan_path, + TEMPLATE: Annotated[ + str, + typer.Option( + "--output", + "-o", + help="Format output file template.", + ), + ] = "", + SINGLES_FILTER: Annotated[ + ARTIST_SINGLES_FILTER_LITERAL, + typer.Option( + "--singles", + "-s", + help="Filter for including artists' singles, used while downloading artist.", + ), + ] = CONFIG.download.singles_filter, + VIDEOS_FILTER: Annotated[ + VIDEOS_FILTER_LITERAL, + typer.Option( + "--videos", + "-vid", + help="Videos handling: 'none' to exclude, 'allow' to include, 'only' to download videos only.", + ), + ] = CONFIG.download.videos_filter, +): + """ + Download Tidal resources. + """ + + ctx.invoke(refresh) + + log.debug(f"{ctx.params=}") + + def save_m3u( + resource_type: VALID_M3U_RESOURCE_LITERAL, + filename: str, + tracks_with_path: list[tuple[Path, Track]], + ): + if not CONFIG.m3u.save: + return + + if resource_type not in CONFIG.m3u.allowed: + return + + tracks_with_existing_paths = [ + (path, track) + for (path, track) in tracks_with_path + if path and isinstance(track, Track) + ] + + log.debug(f"{resource_type=}, {filename=}, {len(tracks_with_existing_paths)=}") + + save_tracks_to_m3u( + tracks_with_path=tracks_with_existing_paths, path=DOWNLOAD_PATH / filename + ) + + async def download_resources(): + rich_output = RichOutput(ctx.obj.console) + + downloader = Downloader( + tidal_api=ctx.obj.api, + threads_count=THREADS_COUNT, + rich_output=rich_output, + track_quality=TRACK_QUALITY, + video_quality=VIDEO_QUALITY, + videos_filter=VIDEOS_FILTER, + skip_existing=not SKIP_EXISTING, + download_path=DOWNLOAD_PATH, + scan_path=SCAN_PATH, + ) + + class Metadata: + def __init__( + self, + date: str = "", + artist: str = "", + credits: list[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [], + cover_data: bytes | None = None, + ) -> None: + self.date = date + self.artist = artist + self.credits = credits + self.cover_data = cover_data + + async def handle_resource(resource: TidalResource): + async def handle_item( + item: Track | Video, + file_path: str, + track_metadata: Metadata = Metadata(), + ) -> tuple[Path | None, Track | Video]: + log.debug(f"{item.id=}, {file_path=}") + rich_output.total_increment() + + download_path, was_downloaded = await downloader.download( + item=item, file_path=Path(file_path) + ) + + log.debug(f"{download_path=}, {was_downloaded=}") + + if ( + CONFIG.metadata.enable + and download_path + # rewrite metadata when track was skipped due to already existing + and (REWRITE_METADATA or was_downloaded) + ): + if isinstance(item, Track): + lyrics_subtitles = "" + + if CONFIG.metadata.lyrics: + try: + lyrics_subtitles = ctx.obj.api.get_track_lyrics( + item.id + ).subtitles + except Exception as e: + log.error(e) + + cover_data = track_metadata.cover_data + if not cover_data and item.album.cover: + cover_data = Cover(item.album.cover).data + + add_track_metadata( + path=download_path, + track=item, + lyrics=lyrics_subtitles, + album_artist=track_metadata.artist, + cover_data=cover_data, + date=track_metadata.date, + credits=track_metadata.credits, + ) + + elif isinstance(item, Video): + add_video_metadata(path=download_path, video=item) + + if download_path and CONFIG.download.update_mtime: + try: + os.utime(download_path, None) + except Exception: + log.warning(f"could not update mtime for {download_path}") + + return download_path, item + + async def download_album(album: Album): + offset = 0 + futures = [] + + cover: Cover | None = None + save_cover = ("album" in CONFIG.cover.allowed) and CONFIG.cover.save + + if album.cover and (CONFIG.metadata.cover or save_cover): + cover = Cover(album.cover, size=CONFIG.cover.size) + + while True: + album_items = ctx.obj.api.get_album_items_credits( + album_id=album.id, offset=offset + ) + + for album_item in album_items.items: + futures.append( + handle_item( + item=album_item.item, + file_path=format_template( + template=TEMPLATE or CONFIG.templates.album, + item=album_item.item, + album=album, + ), + track_metadata=Metadata( + cover_data=cover.data if cover else None, + date=str(album.releaseDate), + artist=album.artist.name if album.artist else "", + credits=album_item.credits, + ), + ) + ) + + offset += album_items.limit + if offset >= album_items.totalNumberOfItems: + break + + tracks_with_path = await asyncio.gather(*futures) + + save_m3u( + resource_type="album", + filename=format_template( + CONFIG.m3u.templates.album, + album=album, + type="album", + ), + tracks_with_path=tracks_with_path, + ) + + if save_cover and cover: + cover.save_to_directory( + path=DOWNLOAD_PATH + / format_template( + template=CONFIG.cover.templates.album, album=album + ) + ) + + # resources should be collected from a distinct function + # that would yield the resources. + # then we would be able to reuse the logic in the export command + + match resource.type: + + case "track": + track = ctx.obj.api.get_track(resource.id) + album = ctx.obj.api.get_album(track.album.id) + + await handle_item( + item=track, + file_path=format_template( + template=TEMPLATE or CONFIG.templates.track, + item=track, + album=album, + ), + ) + + if ( + CONFIG.cover.save + and ("track" in CONFIG.cover.allowed) + and track.album.cover + ): + Cover( + track.album.cover, size=CONFIG.cover.size + ).save_to_directory( + path=DOWNLOAD_PATH + / format_template( + CONFIG.cover.templates.track, item=track, album=album + ) + ) + + case "video": + video = ctx.obj.api.get_video(resource.id) + + await handle_item( + item=video, + file_path=format_template( + template=TEMPLATE or CONFIG.templates.video, + item=video, + ), + ) + + case "mix": + offset = 0 + futures = [] + + while True: + mix_items = ctx.obj.api.get_mix_items(resource.id, offset=0) + + for mix_item in mix_items.items: + futures.append( + handle_item( + item=mix_item.item, + file_path=format_template( + template=TEMPLATE or CONFIG.templates.mix, + item=mix_item.item, + mix_id=resource.id, + ), + ) + ) + + offset += mix_items.limit + if offset >= mix_items.totalNumberOfItems: + break + + tracks_with_path = await asyncio.gather(*futures) + + save_m3u( + resource_type="mix", + filename=format_template( + CONFIG.m3u.templates.mix, + type="mix", + ), + tracks_with_path=tracks_with_path, + ) + + case "album": + album = ctx.obj.api.get_album(album_id=resource.id) + await download_album(album) + + case "artist": + futures = [] + + def get_all_albums(singles: bool): + offset = 0 + + while True: + artist_albums = ctx.obj.api.get_artist_albums( + artist_id=resource.id, + offset=offset, + filter="EPSANDSINGLES" if singles else "ALBUMS", + ) + + for album in artist_albums.items: + futures.append(download_album(album)) + + offset += artist_albums.limit + if offset >= artist_albums.totalNumberOfItems: + break + + def get_all_videos(): + offset = 0 + + while True: + artist_videos = ctx.obj.api.get_artist_videos( + resource.id, offset=offset + ) + + for video in artist_videos.items: + futures.append( + handle_item( + item=video, + file_path=format_template( + template=TEMPLATE or CONFIG.templates.video, + item=video, + ), + ) + ) + + if offset > artist_videos.totalNumberOfItems: + break + + offset += artist_videos.limit + + if VIDEOS_FILTER != "none": + get_all_videos() + + if VIDEOS_FILTER != "only": + if SINGLES_FILTER == "include": + get_all_albums(False) + get_all_albums(True) + else: + get_all_albums(SINGLES_FILTER == "only") + + await asyncio.gather(*futures) + + case "playlist": + offset = 0 + futures = [] + playlist_index = 0 + playlist = ctx.obj.api.get_playlist(playlist_uuid=resource.id) + + while True: + playlist_items = ctx.obj.api.get_playlist_items( + playlist_uuid=resource.id, offset=offset + ) + + for playlist_item in playlist_items.items: + playlist_index += 1 + + futures.append( + handle_item( + item=playlist_item.item, + file_path=format_template( + template=TEMPLATE or CONFIG.templates.playlist, + item=playlist_item.item, + playlist=playlist, + playlist_index=playlist_index, + ), + ) + ) + + offset += playlist_items.limit + if offset >= playlist_items.totalNumberOfItems: + break + + tracks_with_path = await asyncio.gather(*futures) + + save_m3u( + resource_type="playlist", + filename=format_template( + CONFIG.m3u.templates.playlist, + playlist=playlist, + type="playlist", + ), + tracks_with_path=tracks_with_path, + ) + + if ( + CONFIG.cover.save + and ("playlist" in CONFIG.cover.allowed) + and playlist.squareImage + ): + Cover( + playlist.squareImage, size=max(CONFIG.cover.size, 1080) + ).save_to_directory( + path=DOWNLOAD_PATH + / format_template( + template=CONFIG.cover.templates.playlist, + playlist=playlist, + ) + ) + + with Live( + rich_output.group, + refresh_per_second=10, + console=ctx.obj.console, + transient=True, + ): + await asyncio.gather(*(handle_resource(r) for r in ctx.obj.resources)) + + rich_output.show_stats() + + def run(): + asyncio.run(download_resources()) + + ctx.call_on_close(run) diff --git a/tiddl/cli/commands/download/downloader.py b/tiddl/cli/commands/download/downloader.py new file mode 100644 index 0000000..bb6f91a --- /dev/null +++ b/tiddl/cli/commands/download/downloader.py @@ -0,0 +1,195 @@ +import asyncio +import aiohttp +import aiofiles + +from logging import getLogger + +from pathlib import Path + +from tiddl.core.api.models import TrackQuality, VideoQuality, Track, Video +from tiddl.core.api import TidalAPI +from tiddl.core.utils import parse_track_stream, parse_video_stream +from tiddl.core.utils.ffmpeg import convert_to_mp4, extract_flac +from tiddl.cli.config import ( + TRACK_QUALITY_LITERAL, + VIDEO_QUALITY_LITERAL, + VIDEOS_FILTER_LITERAL, +) +from tiddl.cli.utils.download import get_existing_track_filename + +from .output import RichOutput + +log = getLogger(__name__) + +CHUNK_SIZE = 1024**2 + +track_qualities: dict[TRACK_QUALITY_LITERAL, TrackQuality] = { + "low": "LOW", + "normal": "HIGH", + "high": "LOSSLESS", + "max": "HI_RES_LOSSLESS", +} + +track_qualities_color: dict[TrackQuality, str] = { + "LOW": "[gray]96 kbps", + "HIGH": "[gray]320 kbps", + "LOSSLESS": "[cyan]", + "HI_RES_LOSSLESS": "[yellow]", +} + +video_qualities: dict[VIDEO_QUALITY_LITERAL, VideoQuality] = { + "sd": "LOW", + "hd": "MEDIUM", + "fhd": "HIGH", +} + +video_qualities_color: dict[VideoQuality, str] = { + "LOW": "[gray]360p", + "MEDIUM": "[cyan]720p", + "HIGH": "[yellow]1080p", +} + + +class Downloader: + api: TidalAPI + rich_output: RichOutput + semaphore: asyncio.Semaphore + track_quality: TrackQuality + video_quality: VideoQuality + videos_filter: VIDEOS_FILTER_LITERAL + skip_existing: bool + download_path: Path + scan_path: Path + + def __init__( + self, + tidal_api: TidalAPI, + threads_count: int, + rich_output: RichOutput, + track_quality: TRACK_QUALITY_LITERAL, + video_quality: VIDEO_QUALITY_LITERAL, + videos_filter: VIDEOS_FILTER_LITERAL, + skip_existing: bool, + download_path: Path, + scan_path: Path, + ) -> None: + self.api = tidal_api + self.rich_output = rich_output + self.semaphore = asyncio.Semaphore(threads_count) + self.track_quality = track_qualities[track_quality] + self.video_quality = video_qualities[video_quality] + self.videos_filter = videos_filter + self.skip_existing = skip_existing + self.download_path = download_path + self.scan_path = scan_path + + async def download( + self, item: Track | Video, file_path: Path + ) -> tuple[Path | None, bool]: + """ + returns + - Path `item_path` path of existing/downloaded item + - bool `was_downloaded` + """ + + if not item.allowStreaming: + self.rich_output.console.print( + f"[red]Can't stream[/] {item.title} ({item.id})" + ) + return None, False + + if isinstance(item, Track): + filename = get_existing_track_filename( + item.audioQuality, self.track_quality, file_path + ) + vibrant_color = item.album.vibrantColor + + elif isinstance(item, Video): + filename = file_path.with_suffix(".mp4") + vibrant_color = item.vibrantColor + + vibrant_color = vibrant_color or "gray" + + existing_file_path = self.scan_path / filename + + log.debug(f"{file_path=}, {filename=}, {existing_file_path=}") + + result_message = "[green]Downloaded" + + if existing_file_path.exists(): + result_message = "[cyan]Overwrited" + + if self.skip_existing: + self.rich_output.console.print( + f"[yellow]Exists [{vibrant_color}][link={existing_file_path.as_uri()}]{item.title}[/link]" + ) + return existing_file_path, False + + elif (isinstance(item, Video) and self.videos_filter == "none") or ( + isinstance(item, Track) and self.videos_filter == "only" + ): + log.info(f"skipping {item.id} due to {self.videos_filter=}") + return None, False + + should_extract_flac = False + + async with self.semaphore: + if isinstance(item, Track): + stream = self.api.get_track_stream( + track_id=item.id, quality=self.track_quality + ) + + urls, _ = parse_track_stream(stream) + download_path = self.download_path / filename + + quality = track_qualities_color[stream.audioQuality] + + if stream.audioQuality in ["HI_RES_LOSSLESS", "LOSSLESS"]: + quality = f"{quality} {stream.bitDepth}-bit, {(stream.sampleRate or 0) / 1000:.1f} kHz" + + if stream.audioQuality == "HI_RES_LOSSLESS": + should_extract_flac = True + + elif isinstance(item, Video): + stream = self.api.get_video_stream( + video_id=item.id, quality=self.video_quality + ) + + urls, ext = parse_video_stream(stream), ".ts" + download_path = (self.download_path / filename).with_suffix(ext) + quality = video_qualities_color[stream.videoQuality] + + task_id = self.rich_output.download_start( + f"[{vibrant_color}]{item.title} {quality}" + ) + + download_path.parent.mkdir(exist_ok=True, parents=True) + + # TODO shouldnt session be reused instead of + # creating new one on every download? + + async with aiohttp.ClientSession() as session: + async with aiofiles.open(download_path, "wb") as f: + for url in urls: + async with session.get(url) as resp: + async for chunk in resp.content.iter_chunked(CHUNK_SIZE): + await f.write(chunk) + self.rich_output.download_advance( + task_id, size=len(chunk) + ) + + try: + if isinstance(item, Track) and should_extract_flac: + download_path = extract_flac(download_path) + elif isinstance(item, Video): + download_path = convert_to_mp4(download_path) + except Exception as exc: + log.error(f"{should_extract_flac=}, {exc=}") + + self.rich_output.download_finish( + task_id=task_id, + item_link=download_path.as_uri(), + result_message=result_message, + ) + + return download_path, True diff --git a/tiddl/cli/commands/download/output.py b/tiddl/cli/commands/download/output.py new file mode 100644 index 0000000..4d61e8f --- /dev/null +++ b/tiddl/cli/commands/download/output.py @@ -0,0 +1,92 @@ +from rich.console import Console, Group +from rich.progress import ( + Progress, + TransferSpeedColumn, + SpinnerColumn, + FileSizeColumn, + MofNCompleteColumn, + ProgressColumn, + BarColumn, + Task, + TaskID, +) +from rich.text import Text +from rich.panel import Panel + + +class TimeElapsedColumn(ProgressColumn): + """Renders time elapsed.""" + + def render(self, task: Task) -> Text: + """Show time elapsed.""" + elapsed = task.finished_time if task.finished else task.elapsed + if elapsed is None: + return Text("---", style="progress.elapsed") + return Text(f"{elapsed:.2f}s", style="progress.elapsed") + + +class RichOutput: + def __init__(self, console: Console, download_height: int | None = None) -> None: + self.console = console + + self.download_progress = Progress( + SpinnerColumn(), + "{task.description}", + FileSizeColumn(), + TransferSpeedColumn(), + console=self.console, + ) + self.total_progress = Progress( + TimeElapsedColumn(), + BarColumn(bar_width=None), + MofNCompleteColumn(), + console=self.console, + ) + + self.group = Group( + Panel( + self.download_progress, + title="Downloading", + border_style="magenta", + title_align="left", + height=download_height + 2 if download_height else None, + ), + Panel( + self.total_progress, + title="Total Progress", + border_style="green", + title_align="left", + ), + ) + + self.total_task = self.total_progress.add_task("Total", total=0, start=True) + self.total_downloads = 0 + + def total_increment(self, count: float = 1): + task = self.total_progress._tasks.get(self.total_task) + + assert task is not None + assert task.total is not None + + self.total_progress.update(self.total_task, total=task.total + count) + + def download_start(self, description: str) -> TaskID: + return self.download_progress.add_task(description=description, total=None) + + def download_advance(self, task_id: TaskID, size: float): + self.download_progress.update(task_id=task_id, advance=size, refresh=True) + + def download_finish(self, task_id: TaskID, item_link: str, result_message: str): + task = self.download_progress._tasks.get(task_id) + + assert task is not None + + self.download_progress.remove_task(task_id=task_id) + self.total_progress.advance(self.total_task, advance=1) + self.console.print( + f"{result_message} [link={item_link}]{task.description}[/link]" + ) + self.total_downloads += 1 + + def show_stats(self): + self.console.print(f"[green]Total downloads: {self.total_downloads}") diff --git a/tiddl/cli/commands/export.py b/tiddl/cli/commands/export.py new file mode 100644 index 0000000..5f3f995 --- /dev/null +++ b/tiddl/cli/commands/export.py @@ -0,0 +1,40 @@ +import typer +from logging import getLogger +from rich.console import Console + +# from typing_extensions import Annotated + +from tiddl.cli.ctx import Context +from tiddl.cli.commands.subcommands import url_subcommand +from tiddl.cli.commands.auth import refresh + +export_command = typer.Typer(name="export") +export_command.add_typer(url_subcommand) + +log = getLogger(__name__) +console = Console() + + +@export_command.callback(no_args_is_help=True) +def export_callback(ctx: Context): + """ + Export Tidal data. + + You can export the data to json file + or pipe it to another process. + """ + + ctx.invoke(refresh) + + # TODO implement export functionality + + # exported structure + # [{resource_type: str, resource_id: str|int, album: {...}, album_items: {...}}] + + # export to single files like id.json + # or export all in one + + def handle_export(): + console.print(ctx.obj.resources) + + ctx.call_on_close(handle_export) diff --git a/tiddl/cli/commands/subcommands/__init__.py b/tiddl/cli/commands/subcommands/__init__.py new file mode 100644 index 0000000..b3dfc3f --- /dev/null +++ b/tiddl/cli/commands/subcommands/__init__.py @@ -0,0 +1,11 @@ +from typer import Typer + +from .url import url_subcommand + + +SUBCOMMANDS: list[Typer] = [url_subcommand] + + +def register_subcommands(app: Typer): + for sub_command in SUBCOMMANDS: + app.add_typer(sub_command) diff --git a/tiddl/cli/commands/subcommands/url.py b/tiddl/cli/commands/subcommands/url.py new file mode 100644 index 0000000..31688a5 --- /dev/null +++ b/tiddl/cli/commands/subcommands/url.py @@ -0,0 +1,29 @@ +import typer +from typing_extensions import Annotated + +from tiddl.cli.ctx import Context +from tiddl.cli.utils.resource import TidalResource + + +url_subcommand = typer.Typer() + + +@url_subcommand.command( + no_args_is_help=True, +) +def url( + ctx: Context, + urls: Annotated[ + list[TidalResource], typer.Argument(parser=TidalResource.from_string) + ], +): + """ + Get Tidal URLs. + + It can be Tidal link or `resource_type/resource_id` format + e.g. track/12345, album/67890. + + Available resource types: track, video, album, playlist, artist, mix. + """ + + ctx.obj.resources.extend(urls) diff --git a/tiddl/cli/config.py b/tiddl/cli/config.py index 28182d4..b562350 100644 --- a/tiddl/cli/config.py +++ b/tiddl/cli/config.py @@ -1,54 +1,114 @@ -import click +from logging import getLogger +from pathlib import Path +from pydantic import BaseModel +from tomllib import loads as parse_toml +from typing import Literal -from tiddl.config import CONFIG_PATH -from tiddl.cli.ctx import Context, passContext +from tiddl.cli.const import APP_PATH + +CONFIG_FILENAME = "config.toml" + +TRACK_QUALITY_LITERAL = Literal["low", "normal", "high", "max"] +VIDEO_QUALITY_LITERAL = Literal["sd", "hd", "fhd"] +ARTIST_SINGLES_FILTER_LITERAL = Literal["none", "only", "include"] +VALID_M3U_RESOURCE_LITERAL = Literal["album", "playlist", "mix"] +VALID_RESOURCE_COVER_SAVE_LITERAL = Literal["track", "album", "playlist"] +VIDEOS_FILTER_LITERAL = Literal["none", "only", "allow"] + +log = getLogger(__name__) -@click.command("config") -@click.option( - "--open", - "-o", - "OPEN_CONFIG", - is_flag=True, - help="Open the configuration file with the default editor.", -) -@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. +class Config(BaseModel): + enable_cache: bool = True + debug: bool = False - By default it prints location of tiddl config file. + class MetadataConfig(BaseModel): + enable: bool = True + lyrics: bool = False + cover: bool = False - This command can be used in variable like `vim $(tiddl config)` - - this will open your config with vim editor. - """ + metadata: MetadataConfig = MetadataConfig() - if OPEN_CONFIG: - click.launch(str(CONFIG_PATH)) + class CoverConfig(BaseModel): + save: bool = False + size: int = 1280 + allowed: list[VALID_RESOURCE_COVER_SAVE_LITERAL] = [] - elif LOCATE_CONFIG: - click.launch(str(CONFIG_PATH), locate=True) + class CoverTemplatesConfig(BaseModel): + track: str = "" + album: str = "" + playlist: str = "" - 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)) + templates: CoverTemplatesConfig = CoverTemplatesConfig() - else: - click.echo(str(CONFIG_PATH)) + cover: CoverConfig = CoverConfig() + + class DownloadConfig(BaseModel): + track_quality: TRACK_QUALITY_LITERAL = "high" + video_quality: VIDEO_QUALITY_LITERAL = "fhd" + skip_existing: bool = True + threads_count: int = 4 + download_path: Path = Path.home() / "Music" / "tiddl" + scan_path: Path = download_path + singles_filter: ARTIST_SINGLES_FILTER_LITERAL = "none" + videos_filter: VIDEOS_FILTER_LITERAL = "none" + update_mtime: bool = False + rewrite_metadata: bool = False + + def model_post_init(self, __context): + # convert to absolute, expand ~, normalize + self.download_path = self.download_path.expanduser().resolve() + self.scan_path = self.scan_path.expanduser().resolve() + + download: DownloadConfig = DownloadConfig() + + class M3UConfig(BaseModel): + # m3u playlists + save: bool = False + allowed: list[VALID_M3U_RESOURCE_LITERAL] = [] + + class M3UTemplatesConfig(BaseModel): + album: str = "" + playlist: str = "" + mix: str = "" + + templates: M3UTemplatesConfig = M3UTemplatesConfig() + + m3u: M3UConfig = M3UConfig() + + class TemplatesConfig(BaseModel): + default: str = "{album.artist}/{album.title}/{item.title}" + track: str = "" + video: str = "" + album: str = "" + playlist: str = "" + mix: str = "" + + def model_post_init(self, __context): + assert self.default != "", "Default template cannot be empty." + + # override templates to default + for field in ["track", "video", "album", "playlist", "mix"]: + if getattr(self, field) == "": + setattr(self, field, self.default) + + templates: TemplatesConfig = TemplatesConfig() + + +def load_config_file(config_file: Path) -> Config: + log.debug(f"loading '{config_file}'") + + if not config_file.exists(): + log.debug("config file not found, loading default config") + return Config() + + toml_dict = parse_toml(config_file.read_text()) + config = Config.model_validate(toml_dict, strict=True) + + log.debug("loaded config from file") + + return config + + +CONFIG = load_config_file(APP_PATH / CONFIG_FILENAME) +log.debug(f"{CONFIG=}") diff --git a/tiddl/cli/const.py b/tiddl/cli/const.py new file mode 100644 index 0000000..68cb3b0 --- /dev/null +++ b/tiddl/cli/const.py @@ -0,0 +1,23 @@ +from os import environ +from pathlib import Path + + +ENV_KEY = "TIDDL_PATH" +APP_DIR_NAME = ".tiddl" + + +def get_app_path(env_key: str = ENV_KEY) -> Path: + if environ.get(env_key): + return Path(environ[env_key]) + + return Path.home() / APP_DIR_NAME + + +def create_app_path() -> Path: + app_path = get_app_path() + app_path.mkdir(exist_ok=True) + + return app_path + + +APP_PATH = create_app_path() diff --git a/tiddl/cli/ctx.py b/tiddl/cli/ctx.py index 7f0db57..a036f9e 100644 --- a/tiddl/cli/ctx.py +++ b/tiddl/cli/ctx.py @@ -1,59 +1,52 @@ -import functools -import click +import typer from rich.console import Console +from pathlib import Path -from typing import Callable, TypeVar, cast - -from tiddl.api import TidalApi -from tiddl.config import Config -from tiddl.utils import TidalResource +from tiddl.core.api import TidalClient, TidalAPI +from tiddl.cli.config import APP_PATH +from tiddl.cli.utils.auth.core import load_auth_data +from tiddl.cli.utils.resource import TidalResource -class ContextObj: - api: TidalApi | None - config: Config - resources: list[TidalResource] +class ContextObject: console: Console + resources: list[TidalResource] + _api: TidalAPI | None + api_omit_cache: bool + debug_path: Path | None - def __init__(self) -> None: - self.config = Config.fromFile() + def __init__( + self, api_omit_cache: bool, debug_path: Path | None, console: Console + ) -> None: + self.console = console self.resources = [] - self.api = None - self.console = Console() + self._api = None + self.api_omit_cache = api_omit_cache + self.debug_path = debug_path - def initApi(self, omit_cache=False): - auth = self.config.auth + @property + def api(self): + if self._api is not None: + return self._api - if auth.token and auth.user_id and auth.country_code: - self.api = TidalApi( - auth.token, - auth.user_id, - auth.country_code, - omit_cache=omit_cache or self.config.omit_cache, - ) + auth_data = load_auth_data() - def getApi(self) -> TidalApi: - if self.api is None: - raise click.UsageError("You must login first") + assert auth_data.token, "Auth Token is missing. Use `tiddl auth login`" + assert auth_data.user_id, "User ID is missing. Use `tiddl auth login`" + assert auth_data.country_code, "Country Code is missing. Use `tiddl auth login`" - return self.api + client = TidalClient( + token=auth_data.token, + cache_name=APP_PATH / "api_cache", + omit_cache=self.api_omit_cache, + debug_path=self.debug_path, + ) + + self._api = TidalAPI(client, auth_data.user_id, auth_data.country_code) + + return self._api -class Context(click.Context): - obj: ContextObj - - -F = TypeVar("F", bound=Callable[..., None]) - - -def passContext(func: F) -> F: - """Wrapper for @click.pass_context to use custom Context""" - - @click.pass_context - @functools.wraps(func) - def wrapper(ctx: click.Context, *args, **kwargs): - custom_ctx = cast(Context, ctx) - return func(custom_ctx, *args, **kwargs) - - return cast(F, wrapper) +class Context(typer.Context): + obj: ContextObject diff --git a/tiddl/cli/download/__init__.py b/tiddl/cli/download/__init__.py deleted file mode 100644 index 045268a..0000000 --- a/tiddl/cli/download/__init__.py +++ /dev/null @@ -1,571 +0,0 @@ -import os -import logging -import click -import asyncio - -from time import perf_counter -from concurrent.futures import ThreadPoolExecutor, Future -from pathlib import Path -from requests import Session - -from rich.highlighter import ReprHighlighter -from rich.progress import ( - SpinnerColumn, - Progress, - TextColumn, -) - -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, SinglesFilter -from tiddl.models.resource import Track, Video, Album -from tiddl.utils import ( - TidalResource, - formatResource, - convertFileExtension, - 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 - -logger = logging.getLogger(__name__) - - -@click.command("download") -@click.option( - "--quality", - "-q", - "QUALITY", - type=click.Choice(TrackArg.__args__), - help="Track quality.", -) -@click.option( - "--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", - "-t", - "THREADS_COUNT", - type=int, - help="Number of threads to use in concurrent download; use with caution.", -) -@click.option( - "--noskip", - "-ns", - "DO_NOT_SKIP", - is_flag=True, - default=False, - help="Do not skip already downloaded files.", -) -@click.option( - "--singles", - "-s", - "SINGLES_FILTER", - 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( - "--only-video", - "-ov", - "ONLY_VIDEO", - is_flag=True, - help="Download only videos from an artist.", -) -@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 | None, - DO_NOT_SKIP: bool, - SINGLES_FILTER: SinglesFilter, - EMBED_LYRICS: bool, - DOWNLOAD_VIDEO: bool, - ONLY_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 - 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] - - api = ctx.obj.getApi() - - progress = Progress( - 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, - ) - - def handleItemDownload( - item: Union[Track, Video], - path: Path, - 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 = ( - 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 ''}" - ) - - urls, extension = parseTrackStream(track_stream) - elif isinstance(item, Video): - video_stream = api.getVideoStream(item.id) - description = f"Video '{item.title}' {video_stream.videoQuality} quality" - - urls = parseVideoStream(video_stream) - extension = ".ts" - else: - raise TypeError( - f"Invalid item type: expected an instance of Track or Video, " - f"received an instance of {type(item).__name__}. " - ) - - task_id = progress.add_task( - description=description, - start=True, - visible=True, - 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) - - assert req.status_code == 200, ( - f"Could not download stream data for: " - f"{type(item).__name__} '{item.title}', " - f"status code: {req.status_code}" - ) - - stream_data += req.content - 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) - - with path.open("wb") as f: - f.write(stream_data) - - if isinstance(item, Track): - if not cover_data and item.album.cover: - 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, - lyrics=lyrics_subtitles, - ) - except Exception as e: - logger.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, - ) - ) - - try: - addVideoMetadata(path, item) - except Exception as e: - logger.error(f"Can not add metadata to: {path}, {e}") - - progress.remove_task(task_id) - 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 - ) - - def submitItem( - item: Union[Track, Video], - filename: str, - cover_data=b"", - credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [], - album_artist="", - ) -> Future[Path] | None: - if not item.allowStreaming: - logger.warning( - f"✖ {type(item).__name__} '{item.title}' does not allow streaming" - ) - return - - 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 - - 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=download_path, - cover_data=cover_data, - credits=credits, - album_artist=album_artist, - ) - - return future - - 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 - - while True: - album_items = api.getAlbumItemsCredits(album.id, offset=offset) - - for item in album_items.items: - filename = formatResource( - template=TEMPLATE or ctx.obj.config.template.album, - resource=item.item, - 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.content if cover else b"", - item.credits, - album.artist.name, - ) - - if album_items.limit + album_items.offset > album_items.totalNumberOfItems: - break - - offset += album_items.limit - - def handleResource(resource: TidalResource) -> None: - logger.debug(f"'{resource}'") - - match resource.type: - case "track": - track = api.getTrack(resource.id) - filename = formatResource( - TEMPLATE or ctx.obj.config.template.track, track - ) - - submitItem(track, filename) - - case "video": - video = api.getVideo(resource.id) - filename = formatResource( - TEMPLATE or ctx.obj.config.template.video, video - ) - - submitItem(video, filename) - - case "album": - album = api.getAlbum(resource.id) - - 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) - logger.info(f"Artist {artist.name!r}") - - if ONLY_VIDEO: - offset = 0 - - while True: - artist_videos = api.getArtistVideos(resource.id, offset=offset) - - for video in artist_videos.items: - filename = formatResource( - TEMPLATE or ctx.obj.config.template.video, video - ) - - submitItem(video, filename) - - if offset > artist_videos.totalNumberOfItems: - break - - offset += artist_videos.limit - - return - - def getAllAlbums(singles: bool): - offset = 0 - - while True: - artist_albums = api.getArtistAlbums( - resource.id, - offset=offset, - filter="EPSANDSINGLES" if singles else "ALBUMS", - ) - - for album in artist_albums.items: - downloadAlbum(album) - - if ( - artist_albums.limit + artist_albums.offset - > artist_albums.totalNumberOfItems - ): - break - - offset += artist_albums.limit - - if SINGLES_FILTER == "include": - getAllAlbums(False) - getAllAlbums(True) - else: - getAllAlbums(SINGLES_FILTER == "only") - - case "playlist": - playlist = api.getPlaylist(resource.id) - 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) - - for item in playlist_items.items: - filename = formatResource( - template=TEMPLATE or ctx.obj.config.template.playlist, - resource=item.item, - playlist_title=playlist.title, - playlist_index=item.item.index // 100000, - ) - - future = submitItem(item.item, filename) - if future: - futures.append((future, item.item)) - - playlist_path = Path(filename).parent - - if ( - playlist_items.limit + playlist_items.offset - > playlist_items.totalNumberOfItems - ): - break - - 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 - for resource in ctx.obj.resources: - try: - handleResource(resource) - - except AuthError as e: - logger.error(e) - break - - except ApiError as e: - logger.error(e) - - # session does not have streaming privileges - if e.sub_status == 4006: - break - - pool.shutdown(wait=True) - progress.stop() - - -UrlGroup.add_command(DownloadCommand) -SearchGroup.add_command(DownloadCommand) -FavGroup.add_command(DownloadCommand) -FileGroup.add_command(DownloadCommand) diff --git a/tiddl/cli/download/fav.py b/tiddl/cli/download/fav.py deleted file mode 100644 index 286180b..0000000 --- a/tiddl/cli/download/fav.py +++ /dev/null @@ -1,52 +0,0 @@ -import click - -from tiddl.utils import TidalResource, ResourceTypeLiteral -from tiddl.cli.ctx import Context, passContext - -ResourceTypeList: list[ResourceTypeLiteral] = [ - "track", - "video", - "album", - "artist", - "playlist", -] - - -@click.group("fav") -@click.option( - "--resource", - "-r", - "resource_types", - multiple=True, - type=click.Choice(ResourceTypeList), -) -@passContext -def FavGroup(ctx: Context, resource_types: list[ResourceTypeLiteral]): - """Get your Tidal favorites.""" - - api = ctx.obj.getApi() - - favorites = api.getFavorites() - favorites_dict = favorites.model_dump() - - click.echo(type(resource_types)) - - if not resource_types: - resource_types = ResourceTypeList - - stats: dict[ResourceTypeLiteral, int] = dict() - - for resource_type in resource_types: - resources = favorites_dict[resource_type.upper()] - - stats[resource_type] = len(resources) - - for resource_id in resources: - ctx.obj.resources.append(TidalResource(id=resource_id, type=resource_type)) - - # TODO: show pretty message - - click.echo(click.style(f"Loaded {len(ctx.obj.resources)} resources", "green")) - - for resource_type, count in stats.items(): - click.echo(f"{resource_type} - {count}") diff --git a/tiddl/cli/download/file.py b/tiddl/cli/download/file.py deleted file mode 100644 index d6542aa..0000000 --- a/tiddl/cli/download/file.py +++ /dev/null @@ -1,40 +0,0 @@ -import click -import json - -from io import TextIOWrapper -from os.path import splitext - -from tiddl.utils import TidalResource -from tiddl.cli.ctx import Context, passContext - - -@click.group("file") -@click.argument("filename", type=click.File(mode="r")) -@passContext -def FileGroup(ctx: Context, filename: TextIOWrapper): - """Parse txt or JSON file with urls.""" - - _, extension = splitext(filename.name) - - resource_strings: list[str] - - match extension: - case ".json": - try: - resource_strings = json.load(filename) - except json.JSONDecodeError as e: - raise click.UsageError(f"Cant decode JSON file - {e.msg}") - - case ".txt": - resource_strings = [line.strip() for line in filename.readlines()] - - case _: - raise click.UsageError(f"Unsupported file extension - {extension}") - - for string in resource_strings: - try: - ctx.obj.resources.append(TidalResource.fromString(string)) - except ValueError as e: - click.echo(click.style(e, "red")) - - click.echo(click.style(f"Loaded {len(ctx.obj.resources)} resources", "green")) diff --git a/tiddl/cli/download/search.py b/tiddl/cli/download/search.py deleted file mode 100644 index bdb027f..0000000 --- a/tiddl/cli/download/search.py +++ /dev/null @@ -1,48 +0,0 @@ -import click - -from tiddl.utils import TidalResource -from tiddl.models.resource import Artist, Album, Playlist, Track, Video -from tiddl.cli.ctx import Context, passContext - - -@click.group("search") -@click.argument("query") -@passContext -def SearchGroup(ctx: Context, query: str): - """Search on Tidal.""" - - # TODO: give user interactive choice what to select - - api = ctx.obj.getApi() - - search = api.getSearch(query) - - # issue is that we get resource data in search api call, - # in download we refetch that data. - # it's not that big deal as we refetch one resource at most, - # but it should be redesigned - - if not search.topHit: - click.echo(f"No search results for '{query}'") - return - - value = search.topHit.value - icon = click.style("\u2bcc", "magenta") - - if isinstance(value, Album): - resource = TidalResource(type="album", id=str(value.id)) - click.echo(f"{icon} Album {value.title}") - elif isinstance(value, Artist): - resource = TidalResource(type="artist", id=str(value.id)) - click.echo(f"{icon} Artist {value.name}") - elif isinstance(value, Track): - resource = TidalResource(type="track", id=str(value.id)) - click.echo(f"{icon} Track {value.title}") - elif isinstance(value, Playlist): - resource = TidalResource(type="playlist", id=str(value.uuid)) - click.echo(f"{icon} Playlist {value.title}") - elif isinstance(value, Video): - resource = TidalResource(type="video", id=str(value.id)) - click.echo(f"{icon} Video {value.title}") - - ctx.obj.resources.append(resource) diff --git a/tiddl/cli/download/url.py b/tiddl/cli/download/url.py deleted file mode 100644 index d935aa6..0000000 --- a/tiddl/cli/download/url.py +++ /dev/null @@ -1,26 +0,0 @@ -import click - -from tiddl.utils import TidalResource -from tiddl.cli.ctx import Context, passContext - - -class TidalURL(click.ParamType): - def convert(self, value: str, param, ctx) -> TidalResource: - try: - return TidalResource.fromString(value) - except ValueError as e: - self.fail(message=str(e), param=param, ctx=ctx) - - -@click.group("url") -@click.argument("url", type=TidalURL()) -@passContext -def UrlGroup(ctx: Context, url: TidalResource): - """ - Get Tidal URL. - - It can be Tidal link or `resource_type/resource_id` format. - The resource can be a track, video, album, playlist or artist. - """ - - ctx.obj.resources.append(url) diff --git a/tiddl/models/__init__.py b/tiddl/cli/utils/__init__.py similarity index 100% rename from tiddl/models/__init__.py rename to tiddl/cli/utils/__init__.py diff --git a/tiddl/cli/utils/auth/__init__.py b/tiddl/cli/utils/auth/__init__.py new file mode 100644 index 0000000..98c9f3a --- /dev/null +++ b/tiddl/cli/utils/auth/__init__.py @@ -0,0 +1,5 @@ +from .core import load_auth_data, save_auth_data +from .models import AuthData + + +__all__ = ["load_auth_data", "save_auth_data", "AuthData"] diff --git a/tiddl/cli/utils/auth/core.py b/tiddl/cli/utils/auth/core.py new file mode 100644 index 0000000..21db8e6 --- /dev/null +++ b/tiddl/cli/utils/auth/core.py @@ -0,0 +1,31 @@ +from pathlib import Path +from logging import getLogger + +from tiddl.cli.config import APP_PATH +from .models import AuthData + + +AUTH_DATA_FILE = APP_PATH / "auth.json" + + +log = getLogger(__name__) + + +def load_auth_data(file: Path = AUTH_DATA_FILE) -> AuthData: + log.debug(f"loading from '{AUTH_DATA_FILE}'") + + try: + file_content = file.read_text() + except FileNotFoundError: + return AuthData() + + auth_data = AuthData.model_validate_json(file_content) + + return auth_data + + +def save_auth_data(auth_data: AuthData, file: Path = AUTH_DATA_FILE): + log.debug(f"saving to '{file}'") + + with file.open("w") as f: + f.write(auth_data.model_dump_json()) diff --git a/tiddl/cli/utils/auth/models.py b/tiddl/cli/utils/auth/models.py new file mode 100644 index 0000000..c1d93e7 --- /dev/null +++ b/tiddl/cli/utils/auth/models.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class AuthData(BaseModel): + token: str | None = None + refresh_token: str | None = None + expires_at: int = 0 + user_id: str | None = None + country_code: str | None = None diff --git a/tiddl/cli/utils/download.py b/tiddl/cli/utils/download.py new file mode 100644 index 0000000..726a046 --- /dev/null +++ b/tiddl/cli/utils/download.py @@ -0,0 +1,26 @@ +from logging import getLogger +from pathlib import Path +from tiddl.core.api.models import TrackQuality + +log = getLogger(__name__) + + +def get_existing_track_filename( + track_quality: TrackQuality, download_quality: TrackQuality, file_name: Path +) -> Path: + """ + Predict track extension. + """ + + FLAC_QUALITIES: list[TrackQuality] = ["LOSSLESS", "HI_RES_LOSSLESS"] + + if download_quality in FLAC_QUALITIES and track_quality in FLAC_QUALITIES: + extension = ".flac" + else: + extension = ".m4a" + + full_file_name = file_name.with_suffix(extension) + + log.debug(f"{track_quality=}, {download_quality=}, {file_name=}, {full_file_name=}") + + return full_file_name diff --git a/tiddl/cli/utils/resource.py b/tiddl/cli/utils/resource.py new file mode 100644 index 0000000..52af97b --- /dev/null +++ b/tiddl/cli/utils/resource.py @@ -0,0 +1,47 @@ +from pydantic import BaseModel +from urllib.parse import urlparse + +from typing import Literal, get_args + + +ResourceTypeLiteral = Literal["track", "video", "album", "playlist", "artist", "mix"] + + +class TidalResource(BaseModel): + type: ResourceTypeLiteral + id: str + + @property + def url(self) -> str: + return f"https://listen.tidal.com/{self.type}/{self.id}" + + @classmethod + def from_string(cls, string: str): + """ + Extracts the resource type (e.g., "track", "album") + and resource ID from a given input string. + + The input string can either be a full URL or a shorthand string + in the format `resource_type/resource_id` (e.g., `track/12345678`). + """ + + path = urlparse(string).path + resource_type, resource_id = path.split("/")[-2:] + + if resource_type not in get_args(ResourceTypeLiteral): + raise ValueError(f"Invalid resource type: {resource_type}") + + 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 + + def __str__(self) -> str: + return f"{self.type}/{self.id}" diff --git a/tiddl/config.py b/tiddl/config.py deleted file mode 100644 index 745ed82..0000000 --- a/tiddl/config.py +++ /dev/null @@ -1,72 +0,0 @@ -from os import environ, makedirs -from pydantic import BaseModel -from pathlib import Path - -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) - -CONFIG_PATH = HOME_PATH / "tiddl.json" -CONFIG_INDENT = 2 - - -class TemplateConfig(BaseModel): - track: str = "{artist} - {title}" - video: str = "{artist} - {title}" - album: str = "{album_artist}/{album}/{number:02d}. {title}" - playlist: str = "{playlist}/{playlist_number:02d}. {artist} - {title}" - - -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 - scan_path: Path | None = path - save_playlist_m3u: bool = False - - -class AuthConfig(BaseModel): - token: str = "" - refresh_token: str = "" - expires: int = 0 - user_id: str = "" - 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: - f.write(self.model_dump_json(indent=CONFIG_INDENT)) - - @classmethod - def fromFile(cls): - try: - with CONFIG_PATH.open() as f: - config = cls.model_validate_json(f.read()) - except FileNotFoundError: - config = cls() - - config.save() - return config diff --git a/tiddl/core/__init__.py b/tiddl/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tiddl/core/api/__init__.py b/tiddl/core/api/__init__.py new file mode 100644 index 0000000..3c22a1d --- /dev/null +++ b/tiddl/core/api/__init__.py @@ -0,0 +1,5 @@ +from .api import TidalAPI +from .client import TidalClient +from .exceptions import ApiError + +__all__ = ["TidalAPI", "TidalClient", "ApiError"] diff --git a/tiddl/core/api/api.py b/tiddl/core/api/api.py new file mode 100644 index 0000000..a72c741 --- /dev/null +++ b/tiddl/core/api/api.py @@ -0,0 +1,247 @@ +from requests_cache import DO_NOT_CACHE, EXPIRE_IMMEDIATELY + +from typing import Literal, TypeAlias + +from .client import TidalClient +from .models.resources import ( + Album, + Artist, + Playlist, + Track, + Video, + TrackQuality, + VideoQuality, +) +from .models.base import ( + AlbumItems, + AlbumItemsCredits, + ArtistAlbumsItems, + ArtistVideosItems, + Favorites, + TrackLyrics, + PlaylistItems, + MixItems, + Search, + SessionResponse, + TrackStream, + VideoStream, +) + + +ID: TypeAlias = str | int + + +class Limits: + # TODO test every max limit + + ARTIST_ALBUMS = 50 + ARTIST_ALBUMS_MAX = 200 + + ARTIST_VIDEOS = 50 + ARTIST_VIDEOS_MAX = 200 + + ALBUM_ITEMS = 100 + ALBUM_ITEMS_MAX = 100 + + PLAYLIST_ITEMS = 50 + PLAYLIST_ITEMS_MAX = 200 + + MIX_ITEMS = 100 + MIX_ITEMS_MAX = 200 + + +class TidalAPI: + client: TidalClient + user_id: str + country_code: str + + def __init__(self, client: TidalClient, user_id: str, country_code: str) -> None: + self.client = client + self.user_id = user_id + self.country_code = country_code + + def get_album(self, album_id: ID): + return self.client.fetch( + Album, + f"albums/{album_id}", + {"countryCode": self.country_code}, + expire_after=3600, + ) + + def get_album_items( + self, album_id: ID, limit: int = Limits.ALBUM_ITEMS, offset: int = 0 + ): + return self.client.fetch( + AlbumItems, + f"albums/{album_id}/items", + { + "countryCode": self.country_code, + "limit": min(limit, Limits.ALBUM_ITEMS_MAX), + "offset": offset, + }, + expire_after=3600, + ) + + def get_album_items_credits( + self, album_id: ID, limit: int = Limits.ALBUM_ITEMS, offset: int = 0 + ): + return self.client.fetch( + AlbumItemsCredits, + f"albums/{album_id}/items/credits", + { + "countryCode": self.country_code, + "limit": min(limit, Limits.ALBUM_ITEMS_MAX), + "offset": offset, + }, + expire_after=3600, + ) + + def get_artist(self, artist_id: ID): + return self.client.fetch( + Artist, + f"artists/{artist_id}", + {"countryCode": self.country_code}, + expire_after=3600, + ) + + def get_artist_videos( + self, + artist_id: ID, + limit: int = Limits.ARTIST_VIDEOS, + offset: int = 0, + ): + return self.client.fetch( + ArtistVideosItems, + f"artists/{artist_id}/videos", + { + "countryCode": self.country_code, + "limit": limit, + "offset": offset, + }, + expire_after=3600, + ) + + def get_artist_albums( + self, + artist_id: ID, + limit: int = Limits.ARTIST_ALBUMS, + offset: int = 0, + filter: Literal["ALBUMS", "EPSANDSINGLES"] = "ALBUMS", + ): + return self.client.fetch( + ArtistAlbumsItems, + f"artists/{artist_id}/albums", + { + "countryCode": self.country_code, + "limit": min(limit, Limits.ARTIST_ALBUMS_MAX), + "offset": offset, + "filter": filter, + }, + expire_after=3600, + ) + + def get_mix_items( + self, + mix_id: str, + limit: int = Limits.MIX_ITEMS, + offset: int = 0, + ): + return self.client.fetch( + MixItems, + f"mixes/{mix_id}/items", + { + "countryCode": self.country_code, + "limit": min(limit, Limits.MIX_ITEMS_MAX), + "offset": offset, + }, + expire_after=3600, + ) + + def get_favorites(self): + return self.client.fetch( + Favorites, + f"users/{self.user_id}/favorites/ids", + {"countryCode": self.country_code}, + expire_after=EXPIRE_IMMEDIATELY, + ) + + def get_playlist(self, playlist_uuid: str): + return self.client.fetch( + Playlist, + f"playlists/{playlist_uuid}", + {"countryCode": self.country_code}, + expire_after=EXPIRE_IMMEDIATELY, + ) + + def get_playlist_items( + self, playlist_uuid: str, limit: int = Limits.PLAYLIST_ITEMS, offset: int = 0 + ): + return self.client.fetch( + PlaylistItems, + f"playlists/{playlist_uuid}/items", + { + "countryCode": self.country_code, + "limit": min(limit, Limits.PLAYLIST_ITEMS_MAX), + "offset": offset, + }, + expire_after=EXPIRE_IMMEDIATELY, + ) + + def get_search(self, query: str): + return self.client.fetch( + Search, + "search", + {"countryCode": self.country_code, "query": query}, + expire_after=DO_NOT_CACHE, + ) + + def get_session(self): + return self.client.fetch(SessionResponse, "sessions", expire_after=DO_NOT_CACHE) + + def get_track_lyrics(self, track_id: ID): + return self.client.fetch( + TrackLyrics, + f"tracks/{track_id}/lyrics", + {"countryCode": self.country_code}, + expire_after=3600, + ) + + def get_track(self, track_id: ID): + return self.client.fetch( + Track, + f"tracks/{track_id}", + {"countryCode": self.country_code}, + expire_after=3600, + ) + + def get_track_stream(self, track_id: ID, quality: TrackQuality): + return self.client.fetch( + TrackStream, + f"tracks/{track_id}/playbackinfopostpaywall", + { + "audioquality": quality, + "playbackmode": "STREAM", + "assetpresentation": "FULL", + }, + expire_after=DO_NOT_CACHE, + ) + + def get_video(self, video_id: ID): + return self.client.fetch( + Video, + f"videos/{video_id}", + {"countryCode": self.country_code}, + expire_after=3600, + ) + + def get_video_stream(self, video_id: ID, quality: VideoQuality): + return self.client.fetch( + VideoStream, + f"videos/{video_id}/playbackinfopostpaywall", + { + "videoquality": quality, + "playbackmode": "STREAM", + "assetpresentation": "FULL", + }, + expire_after=DO_NOT_CACHE, + ) diff --git a/tiddl/core/api/client.py b/tiddl/core/api/client.py new file mode 100644 index 0000000..685163f --- /dev/null +++ b/tiddl/core/api/client.py @@ -0,0 +1,86 @@ +import json +from logging import getLogger +from pathlib import Path +from typing import Any, Type, TypeVar + +from pydantic import BaseModel +from requests_cache import ( + CachedSession, + StrOrPath, + NEVER_EXPIRE, +) + +from .exceptions import ApiError + +T = TypeVar("T", bound=BaseModel) + +API_URL = "https://api.tidal.com/v1" + +log = getLogger(__name__) + + +class TidalClient: + token: str + debug_path: Path | None + session: CachedSession + + def __init__( + self, + token: str, + cache_name: StrOrPath, + omit_cache: bool = False, + debug_path: Path | None = None, + ) -> None: + self.token = token + self.debug_path = debug_path + + self.session = CachedSession( + cache_name=cache_name, always_revalidate=omit_cache + ) + self.session.headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } + + def fetch( + self, + model: Type[T], + endpoint: str, + params: dict[str, Any] = {}, + expire_after: int = NEVER_EXPIRE, + ) -> T: + """ + Fetch data from the API endpoint + and parse it into the given Pydantic model. + """ + + res = self.session.get( + f"{API_URL}/{endpoint}", params=params, expire_after=expire_after + ) + + log.debug( + f"{endpoint} {params} '{'HIT' if res.from_cache else 'MISS'}' [{res.status_code}]", + ) + + data = res.json() + + if self.debug_path: + file = self.debug_path / f"{endpoint}.json" + file.parent.mkdir(parents=True, exist_ok=True) + + file.write_text( + json.dumps( + { + "status_code": res.status_code, + "endpoint": endpoint, + "params": params, + "data": data, + }, + indent=2, + ) + ) + + if res.status_code != 200: + raise ApiError(**data) + + return model.model_validate(data) diff --git a/tiddl/core/api/exceptions.py b/tiddl/core/api/exceptions.py new file mode 100644 index 0000000..b0132ed --- /dev/null +++ b/tiddl/core/api/exceptions.py @@ -0,0 +1,8 @@ +class ApiError(Exception): + def __init__(self, status: int, subStatus: str, userMessage: str): + self.status = status + self.sub_status = subStatus + self.user_message = userMessage + + def __str__(self): + return f"{self.user_message}, {self.status}/{self.sub_status}" diff --git a/tiddl/core/api/models/__init__.py b/tiddl/core/api/models/__init__.py new file mode 100644 index 0000000..be78c46 --- /dev/null +++ b/tiddl/core/api/models/__init__.py @@ -0,0 +1,35 @@ +from .resources import Album, Artist, Playlist, Track, Video, TrackQuality, VideoQuality +from .base import ( + AlbumItems, + AlbumItemsCredits, + ArtistAlbumsItems, + Favorites, + TrackLyrics, + PlaylistItems, + MixItems, + Search, + SessionResponse, + TrackStream, + VideoStream, +) + +__all__ = [ + "Album", + "Artist", + "Playlist", + "Track", + "Video", + "TrackQuality", + "VideoQuality", + "AlbumItems", + "AlbumItemsCredits", + "ArtistAlbumsItems", + "Favorites", + "TrackLyrics", + "PlaylistItems", + "MixItems", + "Search", + "SessionResponse", + "TrackStream", + "VideoStream" +] diff --git a/tiddl/models/api.py b/tiddl/core/api/models/base.py similarity index 85% rename from tiddl/models/api.py rename to tiddl/core/api/models/base.py index 3bf34a6..60665dd 100644 --- a/tiddl/models/api.py +++ b/tiddl/core/api/models/base.py @@ -1,19 +1,7 @@ from pydantic import BaseModel from typing import Optional, List, Literal, Union -from tiddl.models.resource import Album, Artist, Playlist, Track, TrackQuality, Video - -__all__ = [ - "SessionResponse", - "ArtistAlbumsItems", - "ArtistVideosItems", - "AlbumItems", - "PlaylistItems", - "Favorites", - "TrackStream", - "Search", - "Lyrics", -] +from .resources import Album, Artist, Playlist, Track, TrackQuality, Video, VideoQuality class SessionResponse(BaseModel): @@ -97,6 +85,8 @@ class PlaylistItems(Items): dateAdded: str index: int itemUuid: str + # playlist tracks albums have releasedate, + # but tracks alone do not lol item: PlaylistTrack type: ItemType = "track" @@ -112,6 +102,7 @@ class MixItems(Items): items: List[MixItem] + class Favorites(BaseModel): PLAYLIST: List[str] ALBUM: List[str] @@ -140,24 +131,20 @@ class VideoStream(BaseModel): videoId: int streamType: Literal["ON_DEMAND"] assetPresentation: Literal["FULL"] - videoQuality: Literal["HIGH", "MEDIUM"] + videoQuality: VideoQuality # streamingSessionId: str # only in web? - manifestMimeType: Literal["application/vnd.tidal.emu"] + manifestMimeType: Literal["application/dash+xml", "application/vnd.tidal.emu"] manifestHash: str manifest: str -class SearchAlbum(Album): - # TODO: remove the artist field instead of making it None - artist: None = None - - class Search(BaseModel): + class Artists(Items): items: List[Artist] class Albums(Items): - items: List[SearchAlbum] + items: List[Album] class Playlists(Items): items: List[Playlist] @@ -169,7 +156,7 @@ class Search(BaseModel): items: List[Video] class TopHit(BaseModel): - value: Union[Artist, Track, Playlist, SearchAlbum] + value: Union[Artist, Track, Playlist, Album] type: Literal["ARTISTS", "TRACKS", "PLAYLISTS", "ALBUMS"] artists: Artists @@ -180,7 +167,7 @@ class Search(BaseModel): topHit: Optional[TopHit] = None -class Lyrics(BaseModel): +class TrackLyrics(BaseModel): isRightToLeft: bool lyrics: str lyricsProvider: str diff --git a/tiddl/models/resource.py b/tiddl/core/api/models/resources.py similarity index 88% rename from tiddl/models/resource.py rename to tiddl/core/api/models/resources.py index 0be90d0..7c11666 100644 --- a/tiddl/models/resource.py +++ b/tiddl/core/api/models/resources.py @@ -1,11 +1,11 @@ from pydantic import BaseModel from datetime import datetime -from typing import Optional, List, Literal, Dict +from typing import Optional, List, Literal, Dict, Any -from tiddl.models.constants import TrackQuality +TrackQuality = Literal["LOW", "HIGH", "LOSSLESS", "HI_RES_LOSSLESS"] - -__all__ = ["Track", "Video", "Album", "Playlist", "Artist"] +# audio_only is not stable +VideoQuality = Literal["AUDIO_ONLY", "LOW", "MEDIUM", "HIGH"] class Track(BaseModel): @@ -23,6 +23,9 @@ class Track(BaseModel): vibrantColor: Optional[str] = None videoCover: Optional[str] = None + class MediaMetadata(BaseModel): + tags: list[str] + id: int title: str duration: int @@ -47,8 +50,7 @@ class Track(BaseModel): explicit: bool audioQuality: TrackQuality audioModes: List[str] - mediaMetadata: Dict[str, List[str]] - # for real, artist can be None? + mediaMetadata: MediaMetadata artist: Optional[Artist] = None artists: List[Artist] album: Album @@ -120,7 +122,7 @@ class Album(BaseModel): numberOfTracks: int numberOfVideos: int numberOfVolumes: int - releaseDate: Optional[str] = None + releaseDate: datetime copyright: Optional[str] = None type: str version: Optional[str] = None @@ -134,7 +136,8 @@ class Album(BaseModel): audioQuality: str audioModes: List[str] mediaMetadata: MediaMetadata - artist: Artist + # artist is none in search query + artist: Optional[Artist] = None artists: List[Artist] @@ -147,7 +150,7 @@ class Playlist(BaseModel): title: str numberOfTracks: int numberOfVideos: int - creator: Creator | Dict + creator: Creator | Dict[Any, Any] description: Optional[str] = None duration: int lastUpdated: str @@ -182,11 +185,11 @@ class Artist(BaseModel): id: int name: str + type: Literal["MAIN", "FEATURED"] artistTypes: Optional[List[Literal["ARTIST", "CONTRIBUTOR"]]] = None url: Optional[str] = None picture: Optional[str] = None - # only in search i guess selectedAlbumCoverFallback: Optional[str] = None popularity: Optional[int] = None artistRoles: Optional[List[Role]] = None - mixes: Optional[Mix | Dict] = None + mixes: Optional[Mix | Dict[Any, Any]] = None diff --git a/tiddl/core/auth/__init__.py b/tiddl/core/auth/__init__.py new file mode 100644 index 0000000..be1101a --- /dev/null +++ b/tiddl/core/auth/__init__.py @@ -0,0 +1,4 @@ +from .api import AuthAPI +from .exceptions import AuthClientError + +__all__ = ["AuthAPI", "AuthClientError"] diff --git a/tiddl/core/auth/api.py b/tiddl/core/auth/api.py new file mode 100644 index 0000000..fbe2856 --- /dev/null +++ b/tiddl/core/auth/api.py @@ -0,0 +1,26 @@ +from tiddl.core.auth.client import AuthClient +from tiddl.core.auth.models import ( + AuthDeviceResponse, + AuthResponse, + AuthResponseWithRefresh, +) + + +class AuthAPI: + def __init__(self, client: AuthClient | None = None) -> None: + self._client = client or AuthClient() + + def get_device_auth(self) -> AuthDeviceResponse: + json_data = self._client.get_device_auth() + return AuthDeviceResponse.model_validate(json_data) + + def get_auth(self, device_code: str) -> AuthResponseWithRefresh: + json_data = self._client.get_auth(device_code) + return AuthResponseWithRefresh.model_validate(json_data) + + def refresh_token(self, refresh_token: str) -> AuthResponse: + json_data = self._client.refresh_token(refresh_token) + return AuthResponse.model_validate(json_data) + + def logout_token(self, access_token: str) -> None: + self._client.logout_token(access_token) diff --git a/tiddl/core/auth/client.py b/tiddl/core/auth/client.py new file mode 100644 index 0000000..d08e37f --- /dev/null +++ b/tiddl/core/auth/client.py @@ -0,0 +1,96 @@ +import base64 +from os import environ +from requests import request +from typing import Any, TypeAlias + +from tiddl.core.auth.exceptions import AuthClientError + + +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 + + +AUTH_URL = "https://auth.tidal.com/v1/oauth2" +CLIENT_ID, CLIENT_SECRET = get_auth_credentials() + +JSON: TypeAlias = dict[str, Any] + + +class AuthClient: + + def __init__(self) -> None: + self.auth_url = AUTH_URL + self.client_id = CLIENT_ID + self.client_secret = CLIENT_SECRET + + def get_device_auth(self) -> JSON: + res = request( + "POST", + f"{self.auth_url}/device_authorization", + data={"client_id": self.client_id, "scope": "r_usr+w_usr+w_sub"}, + ) + + res.raise_for_status() + + return res.json() + + def get_auth(self, device_code: str) -> JSON: + res = request( + "POST", + f"{self.auth_url}/token", + data={ + "client_id": self.client_id, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "scope": "r_usr+w_usr+w_sub", + }, + auth=(self.client_id, self.client_secret), + ) + + json_data = res.json() + + if res.status_code != 200: + raise AuthClientError(**json_data) + + return json_data + + def refresh_token(self, refresh_token: str) -> JSON: + res = request( + "POST", + f"{self.auth_url}/token", + data={ + "client_id": self.client_id, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + "scope": "r_usr+w_usr+w_sub", + }, + auth=(self.client_id, self.client_secret), + ) + + res.raise_for_status() + + return res.json() + + def logout_token(self, access_token: str) -> None: + res = request( + "POST", + "https://api.tidal.com/v1/logout", + headers={"authorization": f"Bearer {access_token}"}, + ) + + res.raise_for_status() diff --git a/tiddl/core/auth/exceptions.py b/tiddl/core/auth/exceptions.py new file mode 100644 index 0000000..201ea06 --- /dev/null +++ b/tiddl/core/auth/exceptions.py @@ -0,0 +1,17 @@ +class AuthClientError(Exception): + def __init__( + self, + status: int | None = None, + error: str | None = None, + sub_status: str | None = None, + error_description: str | None = None, + ): + self.status = status + self.error = error + self.sub_status = sub_status + self.error_description = error_description + + def __str__(self): + return ( + f"{self.error}, {self.error_description}, {self.status}/{self.sub_status}" + ) diff --git a/tiddl/core/auth/models.py b/tiddl/core/auth/models.py new file mode 100644 index 0000000..cd5c647 --- /dev/null +++ b/tiddl/core/auth/models.py @@ -0,0 +1,52 @@ +from typing import Optional +from pydantic import BaseModel + + +class AuthResponse(BaseModel): + class User(BaseModel): + userId: int + email: str + countryCode: str + fullName: Optional[str] + firstName: Optional[str] + lastName: Optional[str] + nickname: Optional[str] + username: str + address: Optional[str] + city: Optional[str] + postalcode: Optional[str] + usState: Optional[str] + phoneNumber: Optional[str] + birthday: Optional[int] + channelId: int + parentId: int + acceptedEULA: bool + created: int + updated: int + facebookUid: int + appleUid: Optional[str] + googleUid: Optional[str] + accountLinkCreated: bool + emailVerified: bool + newUser: bool + + user: User + scope: str + clientName: str + token_type: str + access_token: str + expires_in: int + user_id: int + + +class AuthResponseWithRefresh(AuthResponse): + refresh_token: str + + +class AuthDeviceResponse(BaseModel): + deviceCode: str + userCode: str + verificationUri: str + verificationUriComplete: str + expiresIn: int + interval: int diff --git a/tiddl/core/metadata/__init__.py b/tiddl/core/metadata/__init__.py new file mode 100644 index 0000000..cf3f9ee --- /dev/null +++ b/tiddl/core/metadata/__init__.py @@ -0,0 +1,5 @@ +from .track import add_track_metadata +from .video import add_video_metadata +from .cover import Cover + +__all__ = ["add_track_metadata", "add_video_metadata", "Cover"] diff --git a/tiddl/core/metadata/cover.py b/tiddl/core/metadata/cover.py new file mode 100644 index 0000000..6cbe1f2 --- /dev/null +++ b/tiddl/core/metadata/cover.py @@ -0,0 +1,55 @@ +import requests + +from pathlib import Path +from logging import getLogger + +log = getLogger(__name__) + + +class Cover: + uid: str + url: str + data: bytes | None + + def __init__(self, uid: str, size=1280) -> None: + self.uid = uid + + if size > 1280: + log.warning(f"can not set cover size higher than 1280 (user set: {size})") + size = 1280 + + formatted_uid = uid.replace("-", "/") + + self.url = ( + f"https://resources.tidal.com/images/{formatted_uid}/{size}x{size}.jpg" + ) + + self.data = None + + def _get_data(self) -> bytes: + req = requests.get(self.url) + + if req.status_code != 200: + log.error(f"could not download cover. ({req.status_code}) {self.url}") + return b"" + + log.debug(f"got cover {self.url}") + + return req.content + + def save_to_directory(self, path: Path): + file = path.with_suffix(".jpg") + + if file.exists(): + log.debug(f"cover exists ({file})") + return + + if not self.data: + self.data = self._get_data() + + file.parent.mkdir(parents=True, exist_ok=True) + + try: + file.write_bytes(self.data) + except FileNotFoundError as e: + log.error(f"could not save cover. {file} -> {e}") diff --git a/tiddl/core/metadata/track.py b/tiddl/core/metadata/track.py new file mode 100644 index 0000000..e9d5642 --- /dev/null +++ b/tiddl/core/metadata/track.py @@ -0,0 +1,140 @@ +from dataclasses import dataclass, field +from pathlib import Path +from datetime import datetime + +from mutagen.flac import FLAC as MutagenFLAC, Picture +from mutagen.easymp4 import EasyMP4 as MutagenEasyMP4 +from mutagen.mp4 import MP4 as MutagenMP4, MP4Cover + +from tiddl.core.api.models import AlbumItemsCredits, Track + + +@dataclass(slots=True) +class Metadata: + title: str + track_number: str + disc_number: str + copyright: str | None + album_artist: str + artists: str + album_title: str + date: str + isrc: str + bpm: str | None = None + lyrics: str | None = None + credits: list[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = field( + default_factory=list + ) + cover_data: bytes | None = None + + +def add_flac_metadata(track_path: Path, metadata: Metadata) -> None: + mutagen = MutagenFLAC(track_path) + + if metadata.cover_data: + picture = Picture() + picture.data = metadata.cover_data + picture.mime = "image/jpeg" + picture.type = 3 # front cover + mutagen.add_picture(picture) + + if metadata.date: + date = datetime.fromisoformat(metadata.date) + else: + date = None + + mutagen.update( + { + "TITLE": metadata.title, + "TRACKNUMBER": metadata.track_number, + "DISCNUMBER": metadata.disc_number, + "ALBUM": metadata.album_title, + "ALBUMARTIST": metadata.album_artist, + "ARTIST": metadata.artists, + "DATE": str(date) if date else "", + "YEAR": (str(date.year) if date else ""), + "COPYRIGHT": metadata.copyright or "", + "ISRC": metadata.isrc, + } + ) + + if metadata.bpm: + mutagen["BPM"] = metadata.bpm + if metadata.lyrics: + mutagen["LYRICS"] = metadata.lyrics + + for entry in metadata.credits: + mutagen[entry.type.upper()] = [c.name for c in entry.contributors] + + mutagen.save() + + +def add_m4a_metadata(track_path: Path, metadata: Metadata) -> None: + mutagen = MutagenMP4(track_path) + + if metadata.cover_data: + mutagen["covr"] = [ + MP4Cover(metadata.cover_data, imageformat=MP4Cover.FORMAT_JPEG) + ] + + if metadata.lyrics: + mutagen["\xa9lyr"] = [metadata.lyrics] + + mutagen.save() + + mutagen = MutagenEasyMP4(track_path) + + mutagen.update( + { + "title": metadata.title, + "tracknumber": metadata.track_number, + "discnumber": metadata.disc_number, + "album": metadata.album_title, + "albumartist": metadata.album_artist, + "artist": metadata.artists, + "date": metadata.date, + "copyright": metadata.copyright or "", + } + ) + + if metadata.bpm: + mutagen["bpm"] = metadata.bpm + + mutagen.save() + + +def add_track_metadata( + path: Path, + track: Track, + date: str = "", + album_artist: str = "", + lyrics: str = "", + cover_data: bytes | None = None, + credits: list[AlbumItemsCredits.ItemWithCredits.CreditsEntry] | None = None, +) -> None: + """Add FLAC or M4A metadata based on file extension.""" + + metadata = Metadata( + title=track.title, + track_number=str(track.trackNumber), + disc_number=str(track.volumeNumber), + copyright=track.copyright, + album_artist=album_artist, + artists=", ".join(sorted(a.name.strip() for a in track.artists)), + album_title=track.album.title, + date=date, + isrc=track.isrc, + bpm=str(track.bpm or ""), + lyrics=lyrics or None, + cover_data=cover_data, + credits=credits or [], + ) + + ext = path.suffix.lower() + + if ext == ".flac": + add_flac_metadata(path, metadata) + elif ext == ".m4a": + add_m4a_metadata(path, metadata) + else: + raise ValueError(f"Unsupported file extension: {ext}") diff --git a/tiddl/core/metadata/video.py b/tiddl/core/metadata/video.py new file mode 100644 index 0000000..43e988e --- /dev/null +++ b/tiddl/core/metadata/video.py @@ -0,0 +1,31 @@ +from pathlib import Path +from mutagen.easymp4 import EasyMP4 as MutagenEasyMP4 +from tiddl.core.api.models import Video + + +def add_video_metadata(path: Path, video: Video): + mutagen = MutagenEasyMP4(path) + + mutagen.update( + { + "title": video.title, + "artist": ";".join([artist.name.strip() for artist in video.artists]), + } + ) + + if video.artist: + mutagen["albumartist"] = video.artist.name + + if video.album: + mutagen["album"] = video.album.title + + if video.streamStartDate: + mutagen["date"] = str(video.streamStartDate) + + if video.trackNumber: + mutagen["tracknumber"] = str(video.trackNumber) + + if video.volumeNumber: + mutagen["discnumber"] = str(video.volumeNumber) + + mutagen.save(path) diff --git a/tiddl/core/utils/__init__.py b/tiddl/core/utils/__init__.py new file mode 100644 index 0000000..f83e5db --- /dev/null +++ b/tiddl/core/utils/__init__.py @@ -0,0 +1,11 @@ +from .parse import parse_track_stream, parse_video_stream +from .download import get_track_stream_data, get_video_stream_data +from .format import format_template + +__all__ = [ + "parse_track_stream", + "parse_video_stream", + "get_track_stream_data", + "get_video_stream_data", + "format_template", +] diff --git a/tiddl/core/utils/download.py b/tiddl/core/utils/download.py new file mode 100644 index 0000000..a5552f8 --- /dev/null +++ b/tiddl/core/utils/download.py @@ -0,0 +1,39 @@ +from requests import Session + +from tiddl.core.api.models import TrackStream, VideoStream +from .parse import parse_track_stream, parse_video_stream + + +def download(urls: list[str]) -> bytes: + with Session() as s: + stream_data = b"" + + for url in urls: + req = s.get(url) + stream_data += req.content + + return stream_data + + +def get_track_stream_data(track_stream: TrackStream) -> tuple[bytes, str]: + """Download data from track stream and return it with file extension.""" + + urls, file_extension = parse_track_stream(track_stream) + + stream_data = download(urls) + + return stream_data, file_extension + + +def get_video_stream_data(video_stream: VideoStream) -> bytes: + """Download data from video stream""" + + # there can be issue with memory. + # currently we are loading data into ram + # instead of writing it to file right away. + + urls = parse_video_stream(video_stream) + + stream_data = download(urls) + + return stream_data diff --git a/tiddl/core/utils/ffmpeg.py b/tiddl/core/utils/ffmpeg.py new file mode 100644 index 0000000..4794326 --- /dev/null +++ b/tiddl/core/utils/ffmpeg.py @@ -0,0 +1,39 @@ +import subprocess +from pathlib import Path + + +def run(cmd: list[str]): + """Run process without printing to terminal""" + subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def is_ffmpeg_installed() -> bool: + try: + run(["ffmpeg", "-version"]) + return True + except FileNotFoundError: + return False + + +def convert_to_mp4(source: Path) -> Path: + output_path = source.with_suffix(".mp4") + + run(["ffmpeg", "-y", "-i", str(source), "-c", "copy", str(output_path)]) + + source.unlink() + + return output_path + + +def extract_flac(source: Path) -> Path: + """ + Extracts flac audio from mp4 container + """ + + tmp = source.with_suffix(".tmp.flac") + + run(["ffmpeg", "-y", "-i", str(source), "-c", "copy", str(tmp)]) + + tmp.replace(source.with_suffix(".flac")) + + return source.with_suffix(".flac") diff --git a/tiddl/core/utils/format.py b/tiddl/core/utils/format.py new file mode 100644 index 0000000..f3c26f1 --- /dev/null +++ b/tiddl/core/utils/format.py @@ -0,0 +1,151 @@ +from dataclasses import dataclass +from datetime import datetime + +from tiddl.core.api.models import Track, Video, Album, Playlist +from tiddl.core.utils.sanitize import sanitize_string + + +@dataclass(slots=True) +class AlbumTemplate: + id: int + title: str + artist: str + artists: str + date: datetime + + +@dataclass(slots=True) +class ItemTemplate: + id: int + title: str + title_version: str + number: int + volume: int + version: str + copyright: str + bpm: int + isrc: str + quality: str + artist: str + artists: str + features: str + artists_with_features: str + + +@dataclass(slots=True) +class PlaylistTemplate: + uuid: str + title: str + index: int + created: datetime + updated: datetime + + +def generate_template_data( + item: Track | Video | None = None, + album: Album | None = None, + playlist: Playlist | None = None, + playlist_index: int = 0, +) -> dict[str, ItemTemplate | AlbumTemplate | PlaylistTemplate | None]: + """Normalize Tidal API Track/Video + Album data into safe templates.""" + + item_template = None + if item: + main_artists = sorted( + [a.name for a in (item.artists or []) if a.type == "MAIN"] + ) + featured_artists = sorted( + [a.name for a in (item.artists or []) if a.type == "FEATURED"] + ) + + if isinstance(item, Track): + version = item.version or "" + copyright_ = item.copyright or "" + bpm = item.bpm or 0 + isrc = item.isrc or "" + quality = item.audioQuality or "" + else: # Video + version = "" + copyright_ = "" + bpm = 0 + isrc = "" + quality = item.quality or "" + + item_template = ItemTemplate( + id=item.id, + title=item.title, + title_version=f"{item.title} ({version})" if version else item.title, + number=item.trackNumber, + volume=item.volumeNumber, + version=version, + copyright=copyright_, + bpm=bpm, + isrc=isrc, + quality=quality, + artist=item.artist.name if item.artist else "", + artists=", ".join(main_artists), + features=", ".join(featured_artists), + artists_with_features=", ".join(main_artists + featured_artists), + ) + + album_template = None + if album: + album_template = AlbumTemplate( + id=album.id, + title=album.title, + artist=album.artist.name if album.artist else "", + artists=", ".join( + a.name for a in (album.artists or []) if a.type == "MAIN" + ), + date=album.releaseDate, + ) + + playlist_template = None + if playlist: + playlist_template = PlaylistTemplate( + uuid=playlist.uuid, + title=playlist.title, + index=playlist_index, + created=datetime.fromisoformat(playlist.created), + updated=datetime.fromisoformat(playlist.lastUpdated), + ) + + templates: dict[str, ItemTemplate | AlbumTemplate | PlaylistTemplate | None] = { + "item": item_template, + "album": album_template, + "playlist": playlist_template, + } + + return templates + + +def format_template( + template: str, + item: Track | Video | None = None, + album: Album | None = None, + playlist: Playlist | None = None, + playlist_index: int = 0, + with_asterisk_ext=True, + **extra, +) -> str: + custom_fields = {"now": datetime.now()} + + data = ( + generate_template_data( + item=item, + album=album, + playlist=playlist, + playlist_index=playlist_index, + ) + | extra + | custom_fields + ) + + path: str = "/".join( + [sanitize_string(segment.format(**data)) for segment in template.split("/")] + ) + + if with_asterisk_ext: + path += ".*" + + return path diff --git a/tiddl/core/utils/m3u.py b/tiddl/core/utils/m3u.py new file mode 100644 index 0000000..7638248 --- /dev/null +++ b/tiddl/core/utils/m3u.py @@ -0,0 +1,38 @@ +from logging import getLogger +from pathlib import Path + +from tiddl.core.api.models import Track + +log = getLogger(__name__) + + +def save_tracks_to_m3u( + tracks_with_path: list[tuple[Path, Track]], path: Path +): + """ + tracks_with_path: [track_path, Track] + path: m3u file location + filename: name of the m3u file + """ + + file = path.with_suffix(".m3u") + log.debug(f"{path=}, {file=}") + + if not tracks_with_path: + log.warning(f"can't save '{file}', no tracks") + return + + try: + file.parent.mkdir(parents=True, exist_ok=True) + + with file.open("w", encoding="utf-8") as f: + f.write("#EXTM3U\n") + for track_path, track in tracks_with_path: + f.write( + f"#EXTINF:{track.duration},{track.artist.name if track.artist else ''} - {track.title}\n{track_path}\n" + ) + + log.debug(f"saved m3u file as '{file}' with {len(tracks_with_path)} tracks") + + except Exception as e: + log.error(f"can't save m3u file: {e}") diff --git a/tiddl/download.py b/tiddl/core/utils/parse.py similarity index 72% rename from tiddl/download.py rename to tiddl/core/utils/parse.py index ed19ca6..78c72d8 100644 --- a/tiddl/download.py +++ b/tiddl/core/utils/parse.py @@ -1,18 +1,13 @@ -import logging - from m3u8 import M3U8 from requests import Session from pydantic import BaseModel from base64 import b64decode from xml.etree.ElementTree import fromstring -from tiddl.models.api import TrackStream, VideoStream +from tiddl.core.api.models import TrackStream, VideoStream -logger = logging.getLogger(__name__) - - -def parseManifestXML(xml_content: str): +def parse_manifest_XML(xml_content: str): """ Parses XML manifest file of the track. """ @@ -53,15 +48,23 @@ def parseManifestXML(xml_content: str): return urls, codecs -class TrackManifest(BaseModel): - mimeType: str - codecs: str - encryptionType: str - urls: list[str] +def parse_track_stream(track_stream: TrackStream) -> tuple[list[str], str]: + """ + Parse URLs and file extension from `track_stream` + | Quality Level | Codec Type | Manifest MIME Type | MIME Type | + | --------------- | ---------- | ------------------------- | ---------- | + | LOW | m4a | application/vnd.tidal.bts | audio/mp4 | + | HIGH | m4a | application/vnd.tidal.bts | audio/mp4 | + | LOSSLESS | flac | application/vnd.tidal.bts | audio/flac | + | HI_RES_LOSSLESS | m4a | application/dash+xml | audio/mp4 | + """ -def parseTrackStream(track_stream: TrackStream) -> tuple[list[str], str]: - """Parse URLs and file extension from `track_stream`""" + class TrackManifest(BaseModel): + mimeType: str + codecs: str + encryptionType: str + urls: list[str] decoded_manifest = b64decode(track_stream.manifest).decode() @@ -71,7 +74,7 @@ def parseTrackStream(track_stream: TrackStream) -> tuple[list[str], str]: urls, codecs = track_manifest.urls, track_manifest.codecs case "application/dash+xml": - urls, codecs = parseManifestXML(decoded_manifest) + urls, codecs = parse_manifest_XML(decoded_manifest) if codecs == "flac": file_extension = ".flac" @@ -85,28 +88,9 @@ def parseTrackStream(track_stream: TrackStream) -> tuple[list[str], str]: return urls, file_extension -def downloadTrackStream(track_stream: TrackStream) -> tuple[bytes, str]: - """Download data from track stream and return it with file extension.""" - - urls, file_extension = parseTrackStream(track_stream) - - with Session() as s: - stream_data = b"" - - for url in urls: - req = s.get(url) - stream_data += req.content - - return stream_data, file_extension - - -def parseVideoStream(video_stream: VideoStream) -> list[str]: +def parse_video_stream(video_stream: VideoStream) -> list[str]: """Parse `video_stream` manifest and return video urls""" - # TODO: add video quality arg, - # for now we download the highest quality. - # -vq option in download command - class VideoManifest(BaseModel): mimeType: str urls: list[str] diff --git a/tiddl/core/utils/sanitize.py b/tiddl/core/utils/sanitize.py new file mode 100644 index 0000000..07c6b96 --- /dev/null +++ b/tiddl/core/utils/sanitize.py @@ -0,0 +1,12 @@ +import re + + +def sanitize_string(string: str) -> str: + """ + Function used to sanitize file paths. + Sometimes resources from Tidal contain + forbidden characters that we need to remove. + """ + + pattern = r'[\\/:"*?<>|]+' + return re.sub(pattern, "", string) diff --git a/tiddl/exceptions.py b/tiddl/exceptions.py deleted file mode 100644 index 7cb9f6f..0000000 --- a/tiddl/exceptions.py +++ /dev/null @@ -1,21 +0,0 @@ -class AuthError(Exception): - def __init__( - self, status: int, error: str, sub_status: str, error_description: str - ): - self.status = status - self.error = error - self.sub_status = sub_status - self.error_description = error_description - - def __str__(self): - return f"{self.status}: {self.error} - {self.error_description}" - - -class ApiError(Exception): - def __init__(self, status: int, subStatus: str, userMessage: str): - self.status = status - self.sub_status = subStatus - self.user_message = userMessage - - def __str__(self): - return f"{self.user_message} ({self.status} - {self.sub_status})" diff --git a/tiddl/metadata.py b/tiddl/metadata.py deleted file mode 100644 index de97105..0000000 --- a/tiddl/metadata.py +++ /dev/null @@ -1,202 +0,0 @@ -import logging -import requests - -from os import makedirs -from pathlib import Path - -from mutagen.easymp4 import EasyMP4 as MutagenEasyMP4 -from mutagen.flac import FLAC as MutagenFLAC -from mutagen.flac import Picture -from mutagen.mp4 import MP4 as MutagenMP4 -from mutagen.mp4 import MP4Cover - -from tiddl.models.resource import Track, Video -from tiddl.models.api import AlbumItemsCredits - -from typing import List - -logger = logging.getLogger(__name__) - - -def addMetadata( - track_path: Path, - track: Track, - cover_data=b"", - credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [], - album_artist="", - lyrics="", -): - logger.debug((track_path, track.id)) - - extension = track_path.suffix - - # TODO: handle mutagen exceptions - - if extension == ".flac": - metadata = MutagenFLAC(track_path) - if cover_data: - picture = Picture() - picture.data = cover_data - picture.mime = "image/jpeg" - picture.type = 3 - metadata.clear_pictures() - 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["TRACKNUMBER"] = str(track.trackNumber) - metadata["DISCNUMBER"] = str(track.volumeNumber) - - metadata["ALBUM"] = track.album.title - - 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")) - - if track.copyright: - metadata["COPYRIGHT"] = track.copyright - - metadata["ISRC"] = track.isrc - - if track.bpm: - metadata["BPM"] = str(track.bpm) - - for entry in credits: - metadata[entry.type.upper()] = [ - contributor.name for contributor in entry.contributors - ] - - if lyrics: - metadata["LYRICS"] = lyrics - - elif extension == ".m4a": - if lyrics or cover_data: - metadata = MutagenMP4(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( - { - "title": track.title, - "tracknumber": str(track.trackNumber), - "discnumber": str(track.volumeNumber), - "copyright": track.copyright if track.copyright else "", - "albumartist": track.artist.name if track.artist else "", - "artist": ", ".join( - sorted([artist.name.strip() for artist in track.artists]) - ), - "album": track.album.title, - "date": str(track.streamStartDate) if track.streamStartDate else "", - "bpm": str(track.bpm or 0), - } - ) - - else: - raise ValueError(f"Unknown file extension: {extension}") - - try: - metadata.save(track_path) - except Exception as e: - logger.error(f"Failed to add metadata to {track_path}: {e}") - - -def addVideoMetadata(path: Path, video: Video): - metadata = MutagenEasyMP4(path) - - metadata.update( - { - "title": video.title, - "albumartist": video.artist.name if video.artist else "", - "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 "", - } - ) - - if video.trackNumber: - metadata["tracknumber"] = str(video.trackNumber) - - if video.volumeNumber: - metadata["discnumber"] = str(video.volumeNumber) - - try: - metadata.save(path) - except Exception as e: - logger.error(f"Failed to add metadata to {path}: {e}") - - -class Cover: - # TODO: cache covers - - def __init__(self, uid: str, size=1280) -> None: - if size > 1280: - logger.warning( - f"can not set cover size higher than 1280 (user set: {size})" - ) - size = 1280 - - self.uid = uid - - formatted_uid = uid.replace("-", "/") - self.url = ( - f"https://resources.tidal.com/images/{formatted_uid}/{size}x{size}.jpg" - ) - - logger.debug((self.uid, self.url)) - - self.content = self._get() - - def _get(self) -> bytes: - req = requests.get(self.url) - - if req.status_code != 200: - 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, filename="cover.jpg"): - 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: - with file.open("wb") as f: - f.write(self.content) - - except FileNotFoundError as e: - logger.error(f"could not save cover. {file} -> {e}") diff --git a/tiddl/models/auth.py b/tiddl/models/auth.py deleted file mode 100644 index 95c9a5d..0000000 --- a/tiddl/models/auth.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import Optional -from pydantic import BaseModel - - -class AuthUser(BaseModel): - userId: int - email: str - countryCode: str - fullName: Optional[str] - firstName: Optional[str] - lastName: Optional[str] - nickname: Optional[str] - username: str - address: Optional[str] - city: Optional[str] - postalcode: Optional[str] - usState: Optional[str] - phoneNumber: Optional[str] - birthday: Optional[int] - channelId: int - parentId: int - acceptedEULA: bool - created: int - updated: int - facebookUid: int - appleUid: Optional[str] - googleUid: Optional[str] - accountLinkCreated: bool - emailVerified: bool - newUser: bool - - -class AuthResponse(BaseModel): - user: AuthUser - scope: str - clientName: str - token_type: str - access_token: str - expires_in: int - user_id: int - - -class AuthResponseWithRefresh(AuthResponse): - refresh_token: str - - -class AuthDeviceResponse(BaseModel): - deviceCode: str - userCode: str - verificationUri: str - verificationUriComplete: str - expiresIn: int - interval: int diff --git a/tiddl/models/constants.py b/tiddl/models/constants.py deleted file mode 100644 index 2268298..0000000 --- a/tiddl/models/constants.py +++ /dev/null @@ -1,14 +0,0 @@ -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", - "normal": "HIGH", - "high": "LOSSLESS", - "master": "HI_RES_LOSSLESS", -} - -QUALITY_TO_ARG = {v: k for k, v in ARG_TO_QUALITY.items()} diff --git a/tiddl/utils.py b/tiddl/utils.py deleted file mode 100644 index 3cbddac..0000000 --- a/tiddl/utils.py +++ /dev/null @@ -1,234 +0,0 @@ -import re -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 -from pathlib import Path - -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", "mix"] - - -class TidalResource(BaseModel): - type: ResourceTypeLiteral - id: str - - @property - def url(self) -> str: - return f"https://listen.tidal.com/{self.type}/{self.id}" - - @classmethod - def fromString(cls, string: str): - """ - Extracts the resource type (e.g., "track", "album") - and resource ID from a given input string. - - The input string can either be a full URL or a shorthand string - in the format `resource_type/resource_id` (e.g., `track/12345678`). - """ - - path = urlparse(string).path - resource_type, resource_id = path.split("/")[-2:] - - if resource_type not in get_args(ResourceTypeLiteral): - raise ValueError(f"Invalid resource type: {resource_type}") - - 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 - - def __str__(self) -> str: - return f"{self.type}/{self.id}" - - -def sanitizeString(string: str) -> str: - pattern = r'[\\/:"*?<>|]+' - return re.sub(pattern, "", string) - - -def formatResource( - template: str, - resource: Union[Track, Video], - album_artist="", - playlist_title="", - playlist_index=0, -) -> str: - artist = sanitizeString(resource.artist.name) if resource.artist else "" - - features = [ - sanitizeString(item_artist.name) - for item_artist in resource.artists - if item_artist.name != artist - ] - - resource_dict = { - "id": str(resource.id), - "title": sanitizeString(resource.title), - "artist": artist, - "artists": ", ".join(sorted(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 "" - ), - "playlist": sanitizeString(playlist_title), - "album_artist": sanitizeString(album_artist), - "playlist_number": playlist_index or 0, - "quality": "", - "version": "", - "bpm": "", - } - - if isinstance(resource, Track): - resource_dict.update( - { - "version": sanitizeString(resource.version or ""), - "quality": QUALITY_TO_ARG[resource.audioQuality], - "bpm": resource.bpm or "", - } - ) - - elif isinstance(resource, Video): - resource_dict.update({"quality": resource.quality}) - - formatted_template = template.format(**resource_dict).strip() - - disallowed_chars = r'[\\:"*?<>|]+' - invalid_chars = re.findall(disallowed_chars, formatted_template) - - if invalid_chars: - raise ValueError( - f"Template '{template}' and formatted resource '{formatted_template}'" - f"contains disallowed characters: {' '.join(sorted(set(invalid_chars)))}" - ) - - return formatted_template - - -def findTrackFilename( - track_quality: TrackQuality, download_quality: TrackQuality, file_name: Path -) -> Path: - """ - Predict track extension. - """ - - FLAC_QUALITIES: list[TrackQuality] = ["LOSSLESS", "HI_RES_LOSSLESS"] - - if download_quality in FLAC_QUALITIES and track_quality in FLAC_QUALITIES: - extension = ".flac" - else: - extension = ".m4a" - - full_file_name = file_name.with_suffix(extension) - - return full_file_name - - -async def convertFileExtension( - source_file: Path, - extension: str, - remove_source=False, - is_video=False, - copy_audio=False, -) -> Path: - """ - Converts `source_file` extension and returns `Path` of file with new `extension`. - - Removes `source_file` when `remove_source` is truthy. - """ - - try: - output_file = source_file.with_suffix(extension) - except ValueError as e: - logging.error(e) - return source_file - - 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: dict[str, FFmpegOption | None] = {"loglevel": "error"} - - if copy_audio: - ffmpeg_args["acodec"] = "copy" - - if is_video: - ffmpeg_args["vcodec"] = "copy" - - try: - logging.debug("Trying conversion") - ffmpeg = FFmpeg().option("y") - ffmpeg.input(str(source_file)) - ffmpeg.output(str(output_file), ffmpeg_args) - - @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}")