mirror of
https://github.com/oskvr37/tiddl.git
synced 2026-06-13 12:15:13 +03:00
Compare commits
153 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05d63d153e | |||
| 04de8e677c | |||
| d0d146b87f | |||
| 77e488ff30 | |||
| 459d5a50b9 | |||
| ee160fc5bc | |||
| 1a78d875fa | |||
| b0ed7bd208 | |||
| e45628e15f | |||
| d9c8984dfa | |||
| c285be6ed2 | |||
| 5e4f9bdb6a | |||
| a282c1a4af | |||
| 46a6e748da | |||
| bf8ded5f60 | |||
| 7e0fb9fb37 | |||
| 880f6008b0 | |||
| 0f9a4006f1 | |||
| 3cfadd7795 | |||
| 47975e12bc | |||
| fbb32e735d | |||
| 3cba05910b | |||
| c22cb2941d | |||
| 6b82c40fae | |||
| 9abf141411 | |||
| 477b4b4635 | |||
| debca2fc1d | |||
| d830a8ed73 | |||
| 33b1e6c826 | |||
| e32fde7794 | |||
| 99804c0304 | |||
| 410146bdcf | |||
| 859d50772d | |||
| 79c21f7842 | |||
| 0f76845c35 | |||
| b1e28a8ae6 | |||
| 658e4a81ab | |||
| 4b6b23225a | |||
| ed9a05c666 | |||
| 8a2c30feaf | |||
| cda1dc6a7a | |||
| 7de23cee1b | |||
| 89e4d5c08e | |||
| a1deba92cc | |||
| 0b11c63eba | |||
| fc074543d1 | |||
| d9e2314447 | |||
| c3dd2d0606 | |||
| 401313cd27 | |||
| b6ddd6b64e | |||
| 3948c79412 | |||
| ee7e079a27 | |||
| 3d1314e198 | |||
| cf0d1cd362 | |||
| 381003097f | |||
| 06f9d940d9 | |||
| fbf79d70e6 | |||
| 9f7d3aec1e | |||
| c5d5b365c8 | |||
| f691c7ba0a | |||
| 82c4ce0c37 | |||
| 145a776b63 | |||
| cf0ef2d304 | |||
| 2332708326 | |||
| cbcb55d14a | |||
| 1480be4aed | |||
| 789fc6bb4a | |||
| a794fa0673 | |||
| d56398168e | |||
| f2ee4f8fad | |||
| b9f586574a | |||
| 94db88bd08 | |||
| d978e87641 | |||
| f605c628da | |||
| 97f3fed079 | |||
| 9a73fc33be | |||
| 22730003df | |||
| 63e7120060 | |||
| 24ac3f04fc | |||
| 7a862d34ee | |||
| aae9781f31 | |||
| 24c4fae353 | |||
| c8a8939741 | |||
| 85088e737a | |||
| 1873d512f1 | |||
| a57869277f | |||
| 74e45ef39a | |||
| 02919e38e6 | |||
| 06b1eded7c | |||
| 66b9f021ad | |||
| 7515753297 | |||
| 9a122ec630 | |||
| 96fb746fd9 | |||
| e0fe7e1655 | |||
| eb0ae38df8 | |||
| 46ddd8e4e1 | |||
| f444ef2689 | |||
| 6ffb386990 | |||
| a567fffc69 | |||
| 3b9c089d01 | |||
| 68f5e17a1b | |||
| 0924b4c755 | |||
| 244a4bc07b | |||
| dc2ff4da33 | |||
| a8e3876dfd | |||
| c7f0836566 | |||
| a525b676eb | |||
| 5160df3da1 | |||
| dde611de45 | |||
| 4c345f8c28 | |||
| cd454a4220 | |||
| 75572bb043 | |||
| b87888536e | |||
| c255d42283 | |||
| 3fdbdb5500 | |||
| 6d796db94c | |||
| 11b8b28e81 | |||
| 01eeff3cac | |||
| 9387a7f0b5 | |||
| 1613f85978 | |||
| 0bc8802c0e | |||
| 4217833984 | |||
| a41caf20fd | |||
| 455129c4ca | |||
| eec05c4f09 | |||
| f767f5ca41 | |||
| 146dd6ae77 | |||
| da2b4b1199 | |||
| d3564f4139 | |||
| f8e3ce2a51 | |||
| 40e9198335 | |||
| 0f44f9780a | |||
| 5d420eeec5 | |||
| 3053e91134 | |||
| 36daea61e0 | |||
| 12a2d4cf5f | |||
| 0c53783497 | |||
| 89a03c829a | |||
| 3e2c9373fb | |||
| 3b12f92bd2 | |||
| bc66861f94 | |||
| 1e1b384f39 | |||
| ee6bba1d30 | |||
| bea4bf32d0 | |||
| e407d7de41 | |||
| bf6874d9e7 | |||
| 4204a4f6ad | |||
| b899d0b286 | |||
| 016440e183 | |||
| ea3571ae42 | |||
| f478e9f1d2 | |||
| 9a8c9d8d2d | |||
| e91bf6e655 |
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: oskvr
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: oskvr37
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
Describe what happened.
|
||||
|
||||
**To Reproduce**
|
||||
What command was used?
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Software (please complete the following information):**
|
||||
- tiddl version: [e.g. v2.0.1]
|
||||
- python version: [e.g. 3.11]
|
||||
- OS: [e.g. Linux, Windows, iOS]
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Bug Report
|
||||
description: File a bug report.
|
||||
title: "🐛 "
|
||||
labels: ["bug"]
|
||||
assignees:
|
||||
- oskvr37
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
- type: textarea
|
||||
id: command
|
||||
attributes:
|
||||
label: What command was used?
|
||||
placeholder: tiddl
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Also tell us, what did you expect to happen?
|
||||
placeholder: Tell us what you see!
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: python
|
||||
attributes:
|
||||
label: Python version
|
||||
description: What version of Python are you using?
|
||||
options:
|
||||
- 3.13
|
||||
- 3.14
|
||||
- 3.15
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: What operating system are you using?
|
||||
multiple: true
|
||||
options:
|
||||
- Windows
|
||||
- Linux
|
||||
- MacOS
|
||||
- type: textarea
|
||||
id: tiddl
|
||||
attributes:
|
||||
label: tiddl version
|
||||
description: tiddl version you have installed
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output from `./tiddl/latest.log`.
|
||||
render: shell
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Push Docker Image to ghcr.io
|
||||
|
||||
# Run when release is published
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch: # Allow for manual push so I can test it
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Minimum required permissions
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
# Checkout code
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Login to ghcr (automatically uses workflow actor and secret)
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Pushes to both :latest and :<versionTag>
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository_owner }}/tiddl:${{ github.event.release.tag_name }}
|
||||
ghcr.io/${{ github.repository_owner }}/tiddl:latest
|
||||
@@ -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
|
||||
|
||||
+7
-5
@@ -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/
|
||||
#.idea/
|
||||
|
||||
# Ruff
|
||||
.ruff_cache
|
||||
|
||||
# UV
|
||||
uv.lock
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# --- Optimised Layer Caching --- #
|
||||
# Layer 1 (ffmpeg) will never regenerate
|
||||
# Layer 2 (pip install) will regenerate if pyproject.toml is changed
|
||||
# Layer 3 (build & install tiddl), rengerates on any code change
|
||||
|
||||
FROM python:alpine
|
||||
WORKDIR /root
|
||||
|
||||
# -- Layer 1 - ffmpeg install (it'll stay cached as a layer always) --
|
||||
RUN apk add --no-cache ffmpeg
|
||||
|
||||
# -- Layer 2 - pip install depenencies (remains cached unless pyproject.toml changes) --
|
||||
# Exports 'depenencies' from pyproject.toml formatted to requirements.txt format, pipelined to pip install
|
||||
COPY pyproject.toml .
|
||||
RUN python -c "import tomllib; f=open('pyproject.toml','rb'); print('\n'.join(tomllib.load(f)['project']['dependencies']))" | xargs pip install
|
||||
|
||||
# -- Layer 3 - Uncached layer (regenerates anytime a new build is released) --
|
||||
COPY . .
|
||||
RUN pip install --no-deps .
|
||||
RUN rm -rf -- ..?* .[!.]* *
|
||||
@@ -1,101 +1,144 @@
|
||||
# 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.`
|
||||
|
||||

|
||||

|
||||

|
||||
[<img src="https://img.shields.io/badge/gitmoji-%20😜%20😍-FFDD67.svg?style=for-the-badge" />](https://gitmoji.dev)
|
||||
|
||||
TIDDL is the Python CLI application that allows downloading Tidal tracks and videos!
|
||||
|
||||
<img src="https://raw.githubusercontent.com/oskvr37/tiddl/refs/heads/main/docs/demo.gif" alt="tiddl album download in 6 seconds" />
|
||||
|
||||
It's inspired by [Tidal-Media-Downloader](https://github.com/yaronzz/Tidal-Media-Downloader) - currently not mantained project.
|
||||
This repository will contain features requests from that project and will be the enhanced version.
|
||||
|
||||
> [!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
|
||||
```
|
||||
|
||||
To install exact version e.g. 3.4.1
|
||||
|
||||
```bash
|
||||
uv tool install tiddl==3.4.1
|
||||
```
|
||||
|
||||
## 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. │
|
||||
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
# Basic usage
|
||||
## Authentication
|
||||
|
||||
## 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 <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:
|
||||
|
||||
@@ -103,26 +146,36 @@ Example CLI usage:
|
||||
TIDDL_PATH=~/custom/tiddl tiddl auth login
|
||||
```
|
||||
|
||||
### Auth stopped working?
|
||||
|
||||
Set `TIDDL_AUTH` environment variable to use another credentials.
|
||||
|
||||
TIDDL_AUTH=<CLIENT_ID>;<CLIENT_SECRET>
|
||||
|
||||
# Development
|
||||
|
||||
Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/oskvr37/tiddl
|
||||
cd tiddl
|
||||
```
|
||||
|
||||
You should create virtual environment and activate it
|
||||
|
||||
```bash
|
||||
uv venv
|
||||
source .venv/Scripts/activate
|
||||
```
|
||||
|
||||
Install package with `--editable` flag
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
Run tests
|
||||
|
||||
```bash
|
||||
python -m unittest
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# 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/<your_username>/.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 file templating at https://github.com/oskvr37/tiddl/blob/main/docs/templating.md
|
||||
|
||||
# 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
|
||||
|
||||
# if this option is set to true, an .lrc file will be created alongside the
|
||||
# track file with the same name
|
||||
write_lrc_file = false
|
||||
|
||||
# when enabled, existing path components are reused even if Tidal returns
|
||||
# different casing. This avoids creating separate paths on case-sensitive
|
||||
# filesystems that would conflict later when moved to case-insensitive systems.
|
||||
# For example, if "FooBar" already exists and the API returns "foobar",
|
||||
# downloads will continue under "FooBar".
|
||||
match_existing_path_case = false
|
||||
|
||||
# Dolby Atmos filter
|
||||
# none - download only STEREO tracks
|
||||
# only - download only DOLBY_ATMOS tracks
|
||||
# allow - download both
|
||||
# (both versions won't be downloaded at a time, it depends on what Tidal returns)
|
||||
atmos_filter = "none"
|
||||
|
||||
|
||||
[metadata]
|
||||
# embed metadata in files
|
||||
enable = true
|
||||
|
||||
# embed lyrics in metadata
|
||||
lyrics = false
|
||||
|
||||
# embed track cover in the track file
|
||||
cover = false
|
||||
|
||||
# embed album review text to track COMMENT metadata field.
|
||||
# only works when downloading album
|
||||
album_review = 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/{playlist.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}"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 120 KiB |
@@ -0,0 +1,151 @@
|
||||
# 📝 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 | Type |
|
||||
| ---------------------------- | -------------------------------------- | ------------------------------- | ---- |
|
||||
| `item.id` | Track/Video ID | `123456` | int |
|
||||
| `item.title` | Title | `Harder Better Faster Stronger` | str |
|
||||
| `item.title_version` | Title + version (if present) | `One More Time (Radio Edit)` | str |
|
||||
| `item.number` | Track number | `3` | int |
|
||||
| `item.volume` | Disc/volume number | `1` | int |
|
||||
| `item.version` | Version string (track only) | `Remastered` | str |
|
||||
| `item.copyright` | Copyright info (track only) | `© 2023 Sony Music` | str |
|
||||
| `item.bpm` | Beats per minute (if available) | `120` | int |
|
||||
| `item.isrc` | ISRC code (track only) | `USQX91501234` | str |
|
||||
| `item.quality` | Audio/video quality | `HIGH` | str |
|
||||
| `item.artist` | Primary artist name | `Daft Punk` | str |
|
||||
| `item.artists` | All main artists | `Daft Punk, Pharrell Williams` | str |
|
||||
| `item.features` | Featured artists | `Pharrell Williams` | str |
|
||||
| `item.artists_with_features` | Main + featured artists | `Daft Punk, Pharrell Williams` | str |
|
||||
| `item.explicit` | Explicit content | `E` | str |
|
||||
| `item.dolby:(Dolby Atmos)` | Dolby Atmos (track only, `UserFormat`) | `(Dolby Atmos)` | str |
|
||||
|
||||
---
|
||||
|
||||
### `album`
|
||||
|
||||
| Field | Description | Example | Type |
|
||||
| -------------------- | ----------------------------------- | ----------------- | -------- |
|
||||
| `album.id` | Album ID | `98765` | int |
|
||||
| `album.title` | Album title | `Discovery` | str |
|
||||
| `album.artist` | Primary artist | `Daft Punk` | str |
|
||||
| `album.artists` | All main artists | `Daft Punk` | str |
|
||||
| `album.date` | Release date | `2001-03-13` | datetime |
|
||||
| `album.explicit` | Explicit content | `clean` | str |
|
||||
| `album.master:[MAX]` | Is album max quality (`UserFormat`) | `[MAX]` | str |
|
||||
| `album.release` | Release type | `ALBUM/EP/SINGLE` | str |
|
||||
|
||||
---
|
||||
|
||||
### `playlist`
|
||||
|
||||
| Field | Description | Example | Type |
|
||||
| ------------------ | ------------------------------ | --------------------- | -------- |
|
||||
| `playlist.uuid` | Playlist unique ID | `b8f1d9f8-...` | str |
|
||||
| `playlist.title` | Playlist name | `My Favorites` | str |
|
||||
| `playlist.index` | Track index within playlist | `5` | int |
|
||||
| `playlist.created` | Creation date (`datetime`) | `2024-01-15 10:42:00` | datetime |
|
||||
| `playlist.updated` | Last updated date (`datetime`) | `2024-03-02 09:00:00` | datetime |
|
||||
|
||||
> [!NOTE]
|
||||
> Tidal API does not provide full album data for playlist tracks,
|
||||
> if you are downloading a playlist with template that contains `{album...}`,
|
||||
> then `tiddl` is making additional request to the API to fetch album data for a track.
|
||||
> The download may take a little longer but it's not a big deal - just one more request for every playlist track.
|
||||
> If there are multiple tracks from the same album, then the album data is cached locally,
|
||||
> and there is only one request per album. Related issue: #217
|
||||
|
||||
---
|
||||
|
||||
### Explicit
|
||||
|
||||
| Format | True Value | False Value |
|
||||
| ---------------- | ---------- | ----------- |
|
||||
| `.explicit` | E | |
|
||||
| `.explicit:long` | explicit | |
|
||||
| `.explicit:full` | explicit | clean |
|
||||
|
||||
### User Format
|
||||
|
||||
You can format `UserFormat` fields how you want:
|
||||
|
||||
| Format | True Value | False Value |
|
||||
| ---------------------------- | ------------- | ----------- |
|
||||
| `item.dolby:D` | D | |
|
||||
| `item.dolby:DOLBY` | DOLBY | |
|
||||
| `item.dolby:dolby` | dolby | |
|
||||
| `album.master:(Max Quality)` | [Max Quality] | |
|
||||
|
||||
### `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.
|
||||
- You can format string and integer fields, [learn more](https://www.pythonmorsels.com/string-formatting/#floating-point-numbers-and-integers)
|
||||
|
||||
## 🖥️ Source Code
|
||||
|
||||
Source code is located at [`/tiddl/core/utils/format.py`](/tiddl/core/utils/format.py)
|
||||
@@ -1,137 +0,0 @@
|
||||
"""
|
||||
Example of concurrent album + playlist downloading with ThreadPoolExecutor and rich.
|
||||
This will download tracks and videos.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from typing import Union
|
||||
|
||||
from pathlib import Path
|
||||
from requests import Session
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
Progress,
|
||||
TextColumn,
|
||||
)
|
||||
|
||||
from tiddl.api import TidalApi
|
||||
from tiddl.download import parseTrackStream, parseVideoStream
|
||||
from tiddl.config import Config
|
||||
from tiddl.models.resource import Track, Video
|
||||
from tiddl.utils import convertFileExtension
|
||||
|
||||
|
||||
WORKERS_COUNT = 4
|
||||
PLAYLIST_UUID = "84974059-76af-406a-aede-ece2b78fa372"
|
||||
ALBUM_ID = 103805723
|
||||
QUALITY = "HI_RES_LOSSLESS"
|
||||
|
||||
console = Console()
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG, handlers=[RichHandler(console=console)]
|
||||
)
|
||||
|
||||
logging.getLogger("urllib3").setLevel(logging.ERROR)
|
||||
|
||||
config = Config.fromFile() # load config from default directory
|
||||
|
||||
api = TidalApi(config.auth.token, config.auth.user_id, config.auth.country_code)
|
||||
|
||||
progress = Progress(
|
||||
TextColumn("{task.description}"),
|
||||
BarColumn(bar_width=40),
|
||||
console=console,
|
||||
transient=True,
|
||||
auto_refresh=True,
|
||||
)
|
||||
|
||||
|
||||
def handleItemDownload(item: Union[Track, Video]):
|
||||
if isinstance(item, Track):
|
||||
track_stream = api.getTrackStream(item.id, quality=QUALITY)
|
||||
urls, extension = parseTrackStream(track_stream)
|
||||
elif isinstance(item, Video):
|
||||
video_stream = api.getVideoStream(item.id)
|
||||
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=f"{type(item).__name__} {item.title}",
|
||||
start=True,
|
||||
visible=True,
|
||||
total=len(urls),
|
||||
)
|
||||
|
||||
with Session() as s:
|
||||
stream_data = b""
|
||||
|
||||
for url in urls:
|
||||
req = s.get(url)
|
||||
stream_data += req.content
|
||||
progress.advance(task_id)
|
||||
|
||||
path = Path("examples") / "downloads" / f"{item.id}{extension}"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with path.open("wb") as f:
|
||||
f.write(stream_data)
|
||||
|
||||
if isinstance(item, Track):
|
||||
if item.audioQuality == "HI_RES_LOSSLESS":
|
||||
convertFileExtension(
|
||||
source_file=path,
|
||||
extension=".flac",
|
||||
remove_source=True,
|
||||
is_video=False,
|
||||
copy_audio=True, # extract flac from m4a container
|
||||
)
|
||||
|
||||
elif isinstance(item, Video):
|
||||
convertFileExtension(
|
||||
source_file=path,
|
||||
extension=".mp4",
|
||||
remove_source=True,
|
||||
is_video=True,
|
||||
copy_audio=True,
|
||||
)
|
||||
|
||||
console.log(item.title)
|
||||
progress.remove_task(task_id)
|
||||
|
||||
|
||||
progress.start()
|
||||
|
||||
pool = ThreadPoolExecutor(max_workers=WORKERS_COUNT)
|
||||
|
||||
|
||||
def submitItem(item: Union[Track, Video]):
|
||||
pool.submit(handleItemDownload, item=item)
|
||||
|
||||
|
||||
# NOTE: these api requests will run one by one,
|
||||
# we will need to add some sleep between requests
|
||||
|
||||
playlist_items = api.getPlaylistItems(playlist_uuid=PLAYLIST_UUID, limit=10)
|
||||
|
||||
for item in playlist_items.items:
|
||||
submitItem(item.item)
|
||||
|
||||
album_items = api.getAlbumItems(album_id=ALBUM_ID, limit=5)
|
||||
|
||||
for item in album_items.items:
|
||||
submitItem(item.item)
|
||||
|
||||
# cleanup
|
||||
|
||||
pool.shutdown(wait=True)
|
||||
progress.stop()
|
||||
@@ -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
|
||||
+31
-26
@@ -1,38 +1,43 @@
|
||||
"""Example of downloading a video from Tidal"""
|
||||
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
from requests import Session
|
||||
|
||||
from tiddl.api import TidalApi
|
||||
from tiddl.config import Config
|
||||
from tiddl.download import parseVideoStream
|
||||
from tiddl.utils import convertFileExtension
|
||||
from tiddl.core.api.models.base import StreamVideoQuality
|
||||
from tiddl.core.metadata import add_video_metadata
|
||||
from tiddl.core.utils import get_video_stream_data
|
||||
from tiddl.core.utils.ffmpeg import convert_to_mp4, is_ffmpeg_installed
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
# we reuse Tidal API from another example
|
||||
from .fetch_api import api
|
||||
|
||||
VIDEO_ID = 373513584
|
||||
# Old Town Road by Lil Nas X
|
||||
VIDEO_ID = 113483426
|
||||
QUALITY: StreamVideoQuality = "HIGH"
|
||||
|
||||
config = Config.fromFile() # load config from default directory
|
||||
if __name__ == "__main__":
|
||||
print("fetching video_stream")
|
||||
video_stream = api.get_video_stream(video_id=VIDEO_ID, quality=QUALITY)
|
||||
|
||||
api = TidalApi(config.auth.token, config.auth.user_id, config.auth.country_code)
|
||||
# download bytes to stream_data and get the file extension
|
||||
print("downloading video_stream data")
|
||||
stream_data = get_video_stream_data(video_stream)
|
||||
|
||||
video_stream = api.getVideoStream(VIDEO_ID)
|
||||
filename = f"{VIDEO_ID}_{QUALITY}"
|
||||
|
||||
urls = parseVideoStream(video_stream)
|
||||
# get file path that is located at our current directory
|
||||
video_path = Path(filename).with_suffix(".ts")
|
||||
|
||||
with Session() as s:
|
||||
video_data = b""
|
||||
# write data from the video_stream to our file
|
||||
print(f"saving to {video_path}")
|
||||
video_path.write_bytes(stream_data)
|
||||
|
||||
for url in urls:
|
||||
req = s.get(url)
|
||||
video_data += req.content
|
||||
if is_ffmpeg_installed():
|
||||
# convert the file from .ts to .mp4
|
||||
print("converting to mp4")
|
||||
video_path = convert_to_mp4(video_path)
|
||||
|
||||
path = Path("videos") / f"{VIDEO_ID}.ts"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# fetch some informations about our video like title etc.
|
||||
print("getting video metadata")
|
||||
video = api.get_video(VIDEO_ID)
|
||||
|
||||
with path.open("wb") as f:
|
||||
f.write(video_data)
|
||||
|
||||
convertFileExtension(path, ".mp4", True, True)
|
||||
# add the metadata to our saved file.
|
||||
print("saving metadata")
|
||||
add_video_metadata(video_path, video)
|
||||
|
||||
@@ -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`
|
||||
@@ -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}, {album.explicit}/{item.number:02d}. {item.artists} - {item.title} ({custom_field}) {item.explicit:full}"
|
||||
|
||||
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",
|
||||
)
|
||||
)
|
||||
+20
-11
@@ -1,13 +1,13 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=42", "wheel"]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tiddl"
|
||||
version = "2.5.0"
|
||||
version = "3.4.4a1"
|
||||
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",
|
||||
"python-ffmpeg>=2.0.0",
|
||||
"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",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
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 successfully!\n" in result.stdout
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_logout_no_token(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Should do nothing."""
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tiddl.cli.commands.auth.load_auth_data", lambda: AuthData(token=None)
|
||||
)
|
||||
|
||||
with (patch("tiddl.cli.commands.auth.AuthAPI") as MockAuthAPI,):
|
||||
result = runner.invoke(auth_command, ["logout"])
|
||||
|
||||
MockAuthAPI.assert_not_called()
|
||||
|
||||
assert "No active session found." in result.stdout
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_logout_force(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Should remove local token even when the API request raises an error."""
|
||||
|
||||
# 1. Mock existing session
|
||||
monkeypatch.setattr(
|
||||
"tiddl.cli.commands.auth.load_auth_data", lambda: AuthData(token="fake-token")
|
||||
)
|
||||
|
||||
with (
|
||||
patch("tiddl.cli.commands.auth.AuthAPI") as MockAuthAPI,
|
||||
patch("tiddl.cli.commands.auth.save_auth_data") as mock_save,
|
||||
):
|
||||
# 2. Configure the mock to RAISE an exception
|
||||
mock_api_instance = MockAuthAPI.return_value
|
||||
mock_api_instance.logout_token.side_effect = Exception("Server Down")
|
||||
|
||||
# 3. Invoke with --force
|
||||
result = runner.invoke(auth_command, ["logout", "--force"])
|
||||
|
||||
# 4. Assertions
|
||||
# API was still called
|
||||
mock_api_instance.logout_token.assert_called_once_with("fake-token")
|
||||
|
||||
# Local data was still wiped (this is the core of --force)
|
||||
mock_save.assert_called_once_with(AuthData())
|
||||
|
||||
# Check for your specific "force" success message
|
||||
assert "Token removed!" in result.stdout
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_logout_fails_without_force(monkeypatch: pytest.MonkeyPatch):
|
||||
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,
|
||||
):
|
||||
|
||||
MockAuthAPI.return_value.logout_token.side_effect = Exception("Error")
|
||||
|
||||
result = runner.invoke(auth_command, ["logout"])
|
||||
|
||||
assert "Local session retained" in result.stdout
|
||||
mock_save.assert_not_called() # Ensure data wasn't wiped
|
||||
|
||||
|
||||
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
|
||||
@@ -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__
|
||||
@@ -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
|
||||
@@ -0,0 +1,77 @@
|
||||
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_match_existing_path_case_config(tmp_path: Path):
|
||||
cfg_file = write_config(
|
||||
tmp_path,
|
||||
"""
|
||||
[download]
|
||||
match_existing_path_case = true
|
||||
""",
|
||||
)
|
||||
|
||||
cfg = load_config_file(cfg_file)
|
||||
|
||||
assert cfg.download.match_existing_path_case is True
|
||||
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tiddl.cli.utils.path import resolve_existing_path_case
|
||||
|
||||
|
||||
def test_resolve_existing_path_case_reuses_existing_directories(tmp_path: Path):
|
||||
existing_album = tmp_path / "FooBar" / "[2024.01.02] Album"
|
||||
existing_album.mkdir(parents=True)
|
||||
|
||||
path = resolve_existing_path_case(
|
||||
tmp_path,
|
||||
Path("foobar") / "[2024.01.02] album" / "01 - Track.flac",
|
||||
)
|
||||
|
||||
assert path == existing_album / "01 - Track.flac"
|
||||
|
||||
|
||||
def test_resolve_existing_path_case_reuses_existing_file(tmp_path: Path):
|
||||
existing_file = tmp_path / "FooBar" / "01 - Track.flac"
|
||||
existing_file.parent.mkdir()
|
||||
existing_file.touch()
|
||||
|
||||
path = resolve_existing_path_case(tmp_path, Path("foobar") / "01 - track.flac")
|
||||
|
||||
assert path == existing_file
|
||||
|
||||
|
||||
def test_resolve_existing_path_case_keeps_new_components(tmp_path: Path):
|
||||
path = resolve_existing_path_case(tmp_path, Path("FooBar") / "New Album")
|
||||
|
||||
assert path == tmp_path / "FooBar" / "New Album"
|
||||
|
||||
|
||||
def test_resolve_existing_path_case_rejects_absolute_path(tmp_path: Path):
|
||||
with pytest.raises(ValueError, match="relative_path"):
|
||||
resolve_existing_path_case(tmp_path, tmp_path / "FooBar")
|
||||
@@ -0,0 +1,85 @@
|
||||
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}")
|
||||
|
||||
|
||||
urls_data = [
|
||||
("https://tidal.com/album/321", "album", "321"),
|
||||
("https://tidal.com/album/321/", "album", "321"),
|
||||
("https://tidal.com/album/321/u", "album", "321"),
|
||||
("https://listen.tidal.com/track/12345", "track", "12345"),
|
||||
("https://listen.tidal.com/track/12345/", "track", "12345"),
|
||||
("https://listen.tidal.com/track/12345/u", "track", "12345"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url, resource_type, resource_id", urls_data)
|
||||
def test_url_fromstring(url: str, resource_type: str, resource_id: str):
|
||||
res = TidalResource.from_string(url)
|
||||
assert res.type == resource_type
|
||||
assert res.id == resource_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"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,86 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from tiddl.core.api.models.resources import Video
|
||||
|
||||
|
||||
# Minimal valid payload shared across tests
|
||||
BASE_VIDEO = {
|
||||
"id": 123,
|
||||
"title": "Test Video",
|
||||
"volumeNumber": 1,
|
||||
"trackNumber": 1,
|
||||
"duration": 180,
|
||||
"quality": "MP4_1080P",
|
||||
"streamReady": True,
|
||||
"adSupportedStreamReady": False,
|
||||
"djReady": False,
|
||||
"stemReady": False,
|
||||
"allowStreaming": True,
|
||||
"explicit": False,
|
||||
"popularity": 50,
|
||||
"type": "Music Video",
|
||||
"adsPrePaywallOnly": False,
|
||||
"artists": [],
|
||||
}
|
||||
|
||||
|
||||
def test_video_null_image_id():
|
||||
"""imageId=null should be accepted (Tidal returns this for some videos)."""
|
||||
video = Video.model_validate({**BASE_VIDEO, "imageId": None})
|
||||
assert video.imageId is None
|
||||
|
||||
|
||||
def test_video_missing_image_id():
|
||||
"""imageId absent entirely should default to None."""
|
||||
video = Video.model_validate(BASE_VIDEO)
|
||||
assert video.imageId is None
|
||||
|
||||
|
||||
def test_video_valid_image_id():
|
||||
"""A normal imageId string should still be accepted."""
|
||||
video = Video.model_validate({**BASE_VIDEO, "imageId": "abc123"})
|
||||
assert video.imageId == "abc123"
|
||||
|
||||
|
||||
def test_video_album_missing_required_fields():
|
||||
"""album object present but missing id/title/cover should be accepted."""
|
||||
payload = {
|
||||
**BASE_VIDEO,
|
||||
"album": {"vibrantColor": None},
|
||||
}
|
||||
video = Video.model_validate(payload)
|
||||
assert video.album is not None
|
||||
assert video.album.id is None
|
||||
assert video.album.title is None
|
||||
assert video.album.cover is None
|
||||
|
||||
|
||||
def test_video_album_none():
|
||||
"""album=null should still be accepted (existing behaviour)."""
|
||||
video = Video.model_validate({**BASE_VIDEO, "album": None})
|
||||
assert video.album is None
|
||||
|
||||
|
||||
def test_video_album_fully_populated():
|
||||
"""A fully populated album object should still parse correctly."""
|
||||
payload = {
|
||||
**BASE_VIDEO,
|
||||
"album": {
|
||||
"id": 42,
|
||||
"title": "Greatest Hits",
|
||||
"cover": "cover-uuid",
|
||||
},
|
||||
}
|
||||
video = Video.model_validate(payload)
|
||||
assert video.album is not None
|
||||
assert video.album.id == 42
|
||||
assert video.album.title == "Greatest Hits"
|
||||
assert video.album.cover == "cover-uuid"
|
||||
|
||||
|
||||
def test_video_still_requires_core_fields():
|
||||
"""Removing a genuinely required field (title) should still raise."""
|
||||
payload = {k: v for k, v in BASE_VIDEO.items() if k != "title"}
|
||||
with pytest.raises(ValidationError):
|
||||
Video.model_validate(payload)
|
||||
@@ -0,0 +1,8 @@
|
||||
from tiddl.core.api.models.review import normalize_review_text
|
||||
|
||||
|
||||
def test_normalize_review_text():
|
||||
text_before = 'Dropping on Halloween of 2017 with only a single day\'s advance notice, [wimpLink albumId="80611906"]Without Warning[/wimpLink] is a collaborative full-length between [wimpLink artistId="7279286"]21 Savage[/wimpLink], [wimpLink artistId="3958646"]Offset[/wimpLink] (of [wimpLink artistId="5024748"]Migos[/wimpLink]), and producer [wimpLink artistId="5012586"]Metro Boomin[/wimpLink], three of the most successful rap artists of the year. The release plays up its Halloween theme, with [wimpLink artistId="5012586"]Metro Boomin[/wimpLink] filling the tracks with scary sound effects and ominous beats, and the MCs delivering ghastly, violent lyrics. [wimpLink artistId="5198891"]Travis Scott[/wimpLink] and [wimpLink artistId="5906497"]Quavo[/wimpLink] contribute guest verses, and additional producers include Dre Moon, [wimpLink artistId="25917"]Southside[/wimpLink], and Cubeatz. [wimpLink albumId="80611906"]Without Warning[/wimpLink] was an immediate success, hitting the Top Five of the Billboard 200 albums chart following its release.'
|
||||
text_after = "Dropping on Halloween of 2017 with only a single day's advance notice, Without Warning is a collaborative full-length between 21 Savage, Offset (of Migos), and producer Metro Boomin, three of the most successful rap artists of the year. The release plays up its Halloween theme, with Metro Boomin filling the tracks with scary sound effects and ominous beats, and the MCs delivering ghastly, violent lyrics. Travis Scott and Quavo contribute guest verses, and additional producers include Dre Moon, Southside, and Cubeatz. Without Warning was an immediate success, hitting the Top Five of the Billboard 200 albums chart following its release."
|
||||
|
||||
assert normalize_review_text(text=text_before) == text_after
|
||||
@@ -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")
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,103 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from tiddl.core.utils.format import AlbumTemplate, format_template, generate_template_data
|
||||
from tiddl.core.api.models.resources import Video
|
||||
|
||||
|
||||
# Minimal Video instance used across format tests
|
||||
BASE_VIDEO = Video.model_validate(
|
||||
{
|
||||
"id": 1,
|
||||
"title": "My Video",
|
||||
"volumeNumber": 1,
|
||||
"trackNumber": 1,
|
||||
"duration": 200,
|
||||
"quality": "MP4_1080P",
|
||||
"streamReady": True,
|
||||
"adSupportedStreamReady": False,
|
||||
"djReady": False,
|
||||
"stemReady": False,
|
||||
"allowStreaming": True,
|
||||
"explicit": False,
|
||||
"popularity": 10,
|
||||
"type": "Music Video",
|
||||
"adsPrePaywallOnly": False,
|
||||
"artists": [{"id": 1, "name": "Gorillaz", "type": "MAIN"}],
|
||||
"artist": {"id": 1, "name": "Gorillaz", "type": "MAIN"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestAlbumTemplateDefaults:
|
||||
def test_can_be_instantiated_with_no_args(self):
|
||||
t = AlbumTemplate()
|
||||
assert t.id == 0
|
||||
assert t.title == ""
|
||||
assert t.artist == ""
|
||||
assert t.artists == ""
|
||||
assert t.release == ""
|
||||
|
||||
def test_date_defaults_to_datetime_min(self):
|
||||
assert AlbumTemplate().date == datetime.min
|
||||
|
||||
def test_explicit_formats_to_empty_string(self):
|
||||
assert f"{AlbumTemplate().explicit}" == ""
|
||||
|
||||
def test_master_formats_to_empty_string(self):
|
||||
assert f"{AlbumTemplate().master:MASTER}" == ""
|
||||
|
||||
|
||||
class TestFormatTemplateNoAlbum:
|
||||
def test_album_artist_token_does_not_raise(self):
|
||||
"""Default template must not raise AttributeError when album is None."""
|
||||
result = format_template(
|
||||
template="{album.artist}/{album.title}/{item.title}",
|
||||
item=BASE_VIDEO,
|
||||
album=None,
|
||||
with_asterisk_ext=False,
|
||||
)
|
||||
# album tokens render as "_" (empty string → sanitised fallback)
|
||||
assert result == "_/_/My Video"
|
||||
|
||||
def test_album_title_token_does_not_raise(self):
|
||||
result = format_template(
|
||||
template="{album.title}",
|
||||
item=BASE_VIDEO,
|
||||
album=None,
|
||||
with_asterisk_ext=False,
|
||||
)
|
||||
assert result == "_"
|
||||
|
||||
def test_item_title_still_rendered(self):
|
||||
result = format_template(
|
||||
template="{item.title}",
|
||||
item=BASE_VIDEO,
|
||||
album=None,
|
||||
with_asterisk_ext=False,
|
||||
)
|
||||
assert result == "My Video"
|
||||
|
||||
def test_item_artist_still_rendered(self):
|
||||
result = format_template(
|
||||
template="{item.artist}",
|
||||
item=BASE_VIDEO,
|
||||
album=None,
|
||||
with_asterisk_ext=False,
|
||||
)
|
||||
assert result == "Gorillaz"
|
||||
|
||||
|
||||
class TestGenerateTemplateDataAlbumFallback:
|
||||
def test_album_template_is_never_none(self):
|
||||
"""generate_template_data should always return an AlbumTemplate, never None."""
|
||||
data = generate_template_data(item=BASE_VIDEO, album=None)
|
||||
assert data["album"] is not None
|
||||
assert isinstance(data["album"], AlbumTemplate)
|
||||
|
||||
def test_album_template_has_empty_fields_when_no_album(self):
|
||||
data = generate_template_data(item=BASE_VIDEO, album=None)
|
||||
album = data["album"]
|
||||
assert album.title == ""
|
||||
assert album.artist == ""
|
||||
@@ -1,93 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from tiddl.config import Config
|
||||
from tiddl.api import TidalApi
|
||||
|
||||
|
||||
class TestApi(unittest.TestCase):
|
||||
api: TidalApi
|
||||
|
||||
def setUp(self):
|
||||
config = Config.fromFile()
|
||||
auth = config.auth
|
||||
|
||||
token, user_id, country_code = (
|
||||
auth.token,
|
||||
auth.user_id,
|
||||
auth.country_code,
|
||||
)
|
||||
|
||||
assert token, "No token found in config file"
|
||||
assert user_id, "No user_id found in config file"
|
||||
assert country_code, "No country_code found in config file"
|
||||
|
||||
self.api = TidalApi(token, user_id, country_code)
|
||||
|
||||
def test_ready(self):
|
||||
session = self.api.getSession()
|
||||
|
||||
self.assertEqual(session.userId, int(self.api.user_id))
|
||||
self.assertEqual(session.countryCode, self.api.country_code)
|
||||
|
||||
def test_track(self):
|
||||
track = self.api.getTrack(103805726)
|
||||
self.assertEqual(track.title, "Stronger")
|
||||
|
||||
def test_artist(self):
|
||||
artist = self.api.getArtist(25022)
|
||||
self.assertEqual(artist.name, "Kanye West")
|
||||
|
||||
def test_artist_albums(self):
|
||||
self.api.getArtistAlbums(25022, filter="ALBUMS")
|
||||
self.api.getArtistAlbums(25022, filter="EPSANDSINGLES")
|
||||
|
||||
def test_album(self):
|
||||
album = self.api.getAlbum(103805723)
|
||||
self.assertEqual(album.title, "Graduation")
|
||||
|
||||
def test_album_items(self):
|
||||
album_items = self.api.getAlbumItems(103805723, limit=10)
|
||||
self.assertEqual(len(album_items.items), 10)
|
||||
|
||||
album_items = self.api.getAlbumItems(103805723, limit=10, offset=10)
|
||||
self.assertEqual(len(album_items.items), 4)
|
||||
|
||||
def test_album_items_credits(self):
|
||||
album_items = self.api.getAlbumItemsCredits(103805723, limit=10)
|
||||
self.assertEqual(len(album_items.items), 10)
|
||||
|
||||
def test_playlist(self):
|
||||
playlist = self.api.getPlaylist("84974059-76af-406a-aede-ece2b78fa372")
|
||||
self.assertEqual(playlist.title, "Kanye West Essentials")
|
||||
|
||||
def test_playlist_items(self):
|
||||
playlist_items = self.api.getPlaylistItems(
|
||||
"84974059-76af-406a-aede-ece2b78fa372"
|
||||
)
|
||||
self.assertEqual(len(playlist_items.items), 25)
|
||||
|
||||
def test_favorites(self):
|
||||
favorites = self.api.getFavorites()
|
||||
self.assertGreaterEqual(len(favorites.PLAYLIST), 0)
|
||||
self.assertGreaterEqual(len(favorites.ALBUM), 0)
|
||||
self.assertGreaterEqual(len(favorites.VIDEO), 0)
|
||||
self.assertGreaterEqual(len(favorites.TRACK), 0)
|
||||
self.assertGreaterEqual(len(favorites.ARTIST), 0)
|
||||
|
||||
def test_search(self):
|
||||
self.api.getSearch("Kanye West")
|
||||
|
||||
def test_video(self):
|
||||
self.api.getVideo(373513584)
|
||||
|
||||
def test_video_stream(self):
|
||||
self.api.getVideoStream(373513584)
|
||||
|
||||
def test_lyrics(self):
|
||||
track_id = 103805726
|
||||
lyrics = self.api.getLyrics(track_id)
|
||||
self.assertEqual(lyrics.trackId, track_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,139 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from tiddl.models.resource import Track
|
||||
from tiddl.utils import TidalResource, formatTrack
|
||||
|
||||
|
||||
class TestTidalResource(unittest.TestCase):
|
||||
def test_resource_parsing(self):
|
||||
positive_cases = [
|
||||
("https://tidal.com/browse/track/12345678", "track", "12345678"),
|
||||
("track/12345678", "track", "12345678"),
|
||||
("https://tidal.com/browse/video/12345678", "video", "12345678"),
|
||||
("video/12345678", "video", "12345678"),
|
||||
("https://tidal.com/browse/album/12345678", "album", "12345678"),
|
||||
("album/12345678", "album", "12345678"),
|
||||
("https://tidal.com/browse/playlist/12345678", "playlist", "12345678"),
|
||||
("playlist/12345678", "playlist", "12345678"),
|
||||
("https://tidal.com/browse/artist/12345678", "artist", "12345678"),
|
||||
("artist/12345678", "artist", "12345678"),
|
||||
]
|
||||
|
||||
for resource, expected_type, expected_id in positive_cases:
|
||||
with self.subTest(resource=resource):
|
||||
tidal_resource = TidalResource.fromString(resource)
|
||||
self.assertEqual(tidal_resource.type, expected_type)
|
||||
self.assertEqual(tidal_resource.id, expected_id)
|
||||
|
||||
def test_failing_cases(self):
|
||||
failing_cases = [
|
||||
"https://tidal.com/browse/invalid/12345678",
|
||||
"invalid/12345678",
|
||||
"https://tidal.com/browse/track/invalid",
|
||||
"track/invalid",
|
||||
"",
|
||||
"invalid",
|
||||
"https://tidal.com/browse/track/",
|
||||
"track/",
|
||||
"/12345678",
|
||||
]
|
||||
|
||||
for resource in failing_cases:
|
||||
with self.subTest(resource=resource):
|
||||
with self.assertRaises(ValueError):
|
||||
TidalResource.fromString(resource)
|
||||
|
||||
|
||||
class TestFormatTrack(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.track = Track(
|
||||
**{
|
||||
"id": 66421438,
|
||||
"title": "Shutdown",
|
||||
"duration": 189,
|
||||
"replayGain": -9.95,
|
||||
"peak": 0.966051,
|
||||
"allowStreaming": True,
|
||||
"streamReady": True,
|
||||
"adSupportedStreamReady": True,
|
||||
"djReady": True,
|
||||
"stemReady": False,
|
||||
"streamStartDate": "2016-11-15T00:00:00.000+0000",
|
||||
"premiumStreamingOnly": False,
|
||||
"trackNumber": 9,
|
||||
"volumeNumber": 1,
|
||||
"version": None,
|
||||
"popularity": 24,
|
||||
"copyright": "(P) 2016 Boy Better Know",
|
||||
"bpm": 69,
|
||||
"url": "http://www.tidal.com/track/66421438",
|
||||
"isrc": "GB7QY1500024",
|
||||
"editable": False,
|
||||
"explicit": True,
|
||||
"audioQuality": "LOSSLESS",
|
||||
"audioModes": ["STEREO"],
|
||||
"mediaMetadata": {"tags": ["LOSSLESS", "HIRES_LOSSLESS"]},
|
||||
"artist": {
|
||||
"id": 3566984,
|
||||
"name": "Skepta",
|
||||
"type": "MAIN",
|
||||
"picture": "747af850-fa9c-4178-a3e6-49259b67df86",
|
||||
},
|
||||
"artists": [
|
||||
{
|
||||
"id": 3566984,
|
||||
"name": "Skepta",
|
||||
"type": "MAIN",
|
||||
"picture": "747af850-fa9c-4178-a3e6-49259b67df86",
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"id": 66421429,
|
||||
"title": "Konnichiwa",
|
||||
"cover": "e0c2f05e-e21f-47c5-9c37-2993437df27d",
|
||||
"vibrantColor": "#ae3b31",
|
||||
"videoCover": None,
|
||||
},
|
||||
"mixes": {"TRACK_MIX": "001aa4abeb471e8f55f5784772b478"},
|
||||
"playlistNumber": None,
|
||||
}
|
||||
)
|
||||
|
||||
def test_templating(self):
|
||||
test_cases = [
|
||||
("{id}", "66421438"),
|
||||
("{title}", "Shutdown"),
|
||||
("{version}", ""),
|
||||
("{artist}", "Skepta"),
|
||||
("{artists}", "Skepta"),
|
||||
("{album}", "Konnichiwa"),
|
||||
("{number}", "9"),
|
||||
("{disc}", "1"),
|
||||
("{date:%m-%d-%y}", "11-15-16"),
|
||||
("{date:%Y}", "2016"),
|
||||
("{year}", "2016"),
|
||||
("{playlist_number}", "0"),
|
||||
("{playlist_number:02d}", "00"),
|
||||
("{bpm}", "69"),
|
||||
("{quality}", "high"),
|
||||
("{artist}/{album}/{title}", "Skepta/Konnichiwa/Shutdown"),
|
||||
("{number:02d}. {title}", "09. Shutdown"),
|
||||
]
|
||||
|
||||
for template, expected_result in test_cases:
|
||||
with self.subTest(template=template, expected_result=expected_result):
|
||||
result = formatTrack(template, self.track)
|
||||
self.assertEqual(result, expected_result)
|
||||
|
||||
def test_invalid_characters(self):
|
||||
test_cases = ["\\", ":", '"', "?", "<", ">", "|", "{number}:{title}", "{date}"]
|
||||
|
||||
for template in test_cases:
|
||||
with self.subTest(template=template):
|
||||
with self.assertRaises(ValueError):
|
||||
formatTrack(template, self.track)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
-259
@@ -1,259 +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,
|
||||
Favorites,
|
||||
Playlist,
|
||||
PlaylistItems,
|
||||
Search,
|
||||
SessionResponse,
|
||||
Track,
|
||||
TrackStream,
|
||||
Video,
|
||||
VideoStream,
|
||||
Lyrics
|
||||
)
|
||||
|
||||
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
|
||||
ALBUM_ITEMS = 10
|
||||
ALBUM_ITEMS_MAX = 100
|
||||
PLAYLIST = 50
|
||||
|
||||
|
||||
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 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,
|
||||
)
|
||||
@@ -1,80 +0,0 @@
|
||||
import logging
|
||||
|
||||
from requests import request
|
||||
|
||||
from tiddl.exceptions import AuthError
|
||||
from tiddl.models import auth
|
||||
|
||||
AUTH_URL = "https://auth.tidal.com/v1/oauth2"
|
||||
CLIENT_ID = "zU4XHVVkc2tDPo4t"
|
||||
CLIENT_SECRET = "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4="
|
||||
|
||||
|
||||
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))
|
||||
+10
-72
@@ -1,77 +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)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import typer
|
||||
import logging
|
||||
from rich.console import Console
|
||||
from typing_extensions import Annotated
|
||||
|
||||
from tiddl.cli.config import APP_PATH, CONFIG
|
||||
from tiddl.cli.ctx import ContextObject, Context
|
||||
from tiddl.cli.commands import register_commands
|
||||
from tiddl.core.utils.ffmpeg import is_ffmpeg_installed as ifs
|
||||
|
||||
log = logging.getLogger("tiddl")
|
||||
|
||||
app = typer.Typer(name="tiddl", no_args_is_help=True, rich_markup_mode="rich")
|
||||
register_commands(app)
|
||||
|
||||
VERSION = "v3.4.4a1"
|
||||
|
||||
|
||||
@app.callback()
|
||||
def callback(
|
||||
ctx: Context,
|
||||
OMIT_CACHE: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--omit-cache",
|
||||
),
|
||||
] = not CONFIG.enable_cache,
|
||||
DEBUG: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--debug",
|
||||
),
|
||||
] = CONFIG.debug,
|
||||
):
|
||||
f"""
|
||||
tiddl {VERSION} - download tidal tracks \u266b
|
||||
|
||||
[link=https://github.com/oskvr37/tiddl]github (https://github.com/oskvr37/tiddl)[/link]
|
||||
[link=https://buymeacoffee.com/oskvr][yellow]buy me a coffee (https://buymeacoffee.com/oskvr)[/link]
|
||||
"""
|
||||
|
||||
log.debug(f"{VERSION=}")
|
||||
log.debug(f"{ctx.params=}")
|
||||
|
||||
is_ffmpeg_installed = ifs()
|
||||
log.debug(f"{is_ffmpeg_installed=}")
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
if not is_ffmpeg_installed:
|
||||
ctx.obj.console.print(
|
||||
"[yellow]WARNING ffmpeg is not installed, tiddl might not work properly, "
|
||||
+ "[link=https://github.com/oskvr37/tiddl/blob/main/README.md#installation]read README.md (https://github.com/oskvr37/tiddl/blob/main/README.md#installation)[/]"
|
||||
)
|
||||
@@ -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!")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,161 @@
|
||||
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
|
||||
|
||||
from typing_extensions import Annotated
|
||||
|
||||
console = Console()
|
||||
|
||||
auth_command = typer.Typer(
|
||||
name="auth", help="Manage Tidal authentication.", no_args_is_help=True
|
||||
)
|
||||
|
||||
|
||||
# TODO add context and load auth data from ctx
|
||||
@auth_command.command(help="Login with your Tidal account.")
|
||||
def login(
|
||||
NO_BROWSER: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--no-browser", "-n", help="Do not open browser."
|
||||
),
|
||||
] = False,
|
||||
):
|
||||
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}"
|
||||
|
||||
if not NO_BROWSER:
|
||||
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(
|
||||
force: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--force",
|
||||
"-f",
|
||||
help="Clears local auth data even if the server request fails.",
|
||||
),
|
||||
] = False,
|
||||
):
|
||||
auth_data = load_auth_data()
|
||||
|
||||
# If there's no token, we are effectively already logged out locally
|
||||
if not auth_data.token:
|
||||
console.print("[yellow]No active session found.")
|
||||
return
|
||||
|
||||
try:
|
||||
api = AuthAPI()
|
||||
api.logout_token(auth_data.token)
|
||||
success = True
|
||||
except Exception as error:
|
||||
console.print(f"[bold red]Logout request failed: {error}")
|
||||
success = False
|
||||
|
||||
if not (success or force):
|
||||
console.print("[bold yellow]Local session retained. Use --force to override.")
|
||||
return
|
||||
|
||||
save_auth_data(AuthData())
|
||||
|
||||
if success:
|
||||
console.print("[bold green]Logged out successfully!")
|
||||
elif force:
|
||||
console.print("[bold green]Token removed!")
|
||||
|
||||
|
||||
@auth_command.command(help="Refreshes your token in app.")
|
||||
def refresh(
|
||||
FORCE: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--force", "-f", help="Refresh token even when it is still valid."
|
||||
),
|
||||
] = False,
|
||||
EARLY_EXPIRE_TIME: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--early-expire",
|
||||
"-e",
|
||||
help="Time to expire the token earlier",
|
||||
metavar="seconds",
|
||||
),
|
||||
] = 0,
|
||||
):
|
||||
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 - EARLY_EXPIRE_TIME) and not FORCE:
|
||||
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!")
|
||||
@@ -0,0 +1,755 @@
|
||||
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 import ApiError
|
||||
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,
|
||||
ATMOS_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 register_subcommands
|
||||
|
||||
|
||||
from .downloader import Downloader
|
||||
from .output import RichOutput
|
||||
|
||||
download_command = typer.Typer(name="download")
|
||||
register_subcommands(download_command)
|
||||
|
||||
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,
|
||||
RAISE_ERRORS: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--raise-errors",
|
||||
"-err",
|
||||
help="Raise an error on resource download failure. Use for debugging",
|
||||
),
|
||||
] = False,
|
||||
DOLBY_ATMOS_FILTER: Annotated[
|
||||
ATMOS_FILTER_LITERAL,
|
||||
typer.Option(
|
||||
"--dolby-atmos",
|
||||
"-da",
|
||||
help="Dolby Atmos filter, 'none' to exclude, 'allow' to include, 'only' to download only Dolby Atmos, if available.",
|
||||
),
|
||||
] = CONFIG.download.atmos_filter,
|
||||
):
|
||||
"""
|
||||
Download Tidal resources.
|
||||
"""
|
||||
|
||||
ctx.invoke(refresh, EARLY_EXPIRE_TIME=600)
|
||||
|
||||
log.debug(f"{ctx.params=}")
|
||||
|
||||
def write_lrc_file(track: Track, lyrics: str, file_path: Path):
|
||||
if not CONFIG.download.write_lrc_file or not lyrics.strip():
|
||||
return
|
||||
|
||||
lrc_file_path = file_path.with_suffix(".lrc")
|
||||
|
||||
try:
|
||||
with open(lrc_file_path, "w", encoding="utf-8") as f:
|
||||
f.write(lyrics)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Failed to write LRC file for track {track.title} (ID: {track.id}): {e}"
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
def get_item_quality(item: Track | Video):
|
||||
def predict_item_quality() -> TRACK_QUALITY_LITERAL | VIDEO_QUALITY_LITERAL:
|
||||
if isinstance(item, Track):
|
||||
if TRACK_QUALITY in ["low", "normal"]:
|
||||
return TRACK_QUALITY
|
||||
|
||||
if (
|
||||
TRACK_QUALITY == "max"
|
||||
and "HIRES_LOSSLESS" not in item.mediaMetadata.tags
|
||||
):
|
||||
return "high"
|
||||
|
||||
return TRACK_QUALITY
|
||||
|
||||
elif isinstance(item, Video):
|
||||
# TODO add missing Video.quality literals so this function can work properly
|
||||
return VIDEO_QUALITY
|
||||
|
||||
raise TypeError("Unsupported item type")
|
||||
|
||||
return predict_item_quality().upper()
|
||||
|
||||
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,
|
||||
match_existing_path_case=CONFIG.download.match_existing_path_case,
|
||||
dolby_atmos_filter=DOLBY_ATMOS_FILTER,
|
||||
)
|
||||
|
||||
class Metadata:
|
||||
def __init__(
|
||||
self,
|
||||
date: str = "",
|
||||
artist: str = "",
|
||||
credits: list[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [],
|
||||
cover: Cover | None = None,
|
||||
album_review: str = "",
|
||||
) -> None:
|
||||
self.date = date
|
||||
self.artist = artist
|
||||
self.credits = credits
|
||||
self.cover = cover
|
||||
self.album_review = album_review
|
||||
|
||||
async def handle_resource(resource: TidalResource):
|
||||
async def handle_item(
|
||||
item: Track | Video,
|
||||
file_path: str,
|
||||
track_metadata: Metadata | None = None,
|
||||
) -> tuple[Path | None, Track | Video]:
|
||||
log.debug(f"{item.id=}, {file_path=}")
|
||||
rich_output.total_increment()
|
||||
|
||||
if not track_metadata:
|
||||
track_metadata = Metadata()
|
||||
|
||||
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 or CONFIG.download.write_lrc_file:
|
||||
try:
|
||||
lyrics_subtitles = ctx.obj.api.get_track_lyrics(
|
||||
item.id
|
||||
).subtitles
|
||||
except Exception as e:
|
||||
log.error(e)
|
||||
|
||||
if (
|
||||
not track_metadata.cover
|
||||
and item.album.cover
|
||||
and CONFIG.metadata.cover
|
||||
):
|
||||
track_metadata.cover = Cover(item.album.cover)
|
||||
|
||||
if track_metadata.cover and track_metadata.cover.data is None:
|
||||
track_metadata.cover.fetch_data()
|
||||
|
||||
write_lrc_file(item, lyrics_subtitles, download_path)
|
||||
|
||||
add_track_metadata(
|
||||
path=download_path,
|
||||
track=item,
|
||||
lyrics=lyrics_subtitles,
|
||||
album_artist=track_metadata.artist,
|
||||
cover_data=(
|
||||
track_metadata.cover.data
|
||||
if track_metadata.cover
|
||||
else None
|
||||
),
|
||||
date=track_metadata.date,
|
||||
credits_contributors=track_metadata.credits,
|
||||
comment=track_metadata.album_review,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
album_review = ""
|
||||
|
||||
if CONFIG.metadata.album_review:
|
||||
try:
|
||||
album_review = ctx.obj.api.get_album_review(
|
||||
album_id=resource.id
|
||||
).normalized_text()
|
||||
except Exception as e:
|
||||
log.error(e)
|
||||
|
||||
while True:
|
||||
album_items = ctx.obj.api.get_album_items_credits(
|
||||
album_id=album.id, offset=offset
|
||||
)
|
||||
|
||||
for album_item in album_items.items:
|
||||
try:
|
||||
template = TEMPLATE or CONFIG.templates.album
|
||||
file_path = format_template(
|
||||
template=template,
|
||||
item=album_item.item,
|
||||
album=album,
|
||||
quality=get_item_quality(album_item.item),
|
||||
)
|
||||
|
||||
except AttributeError as exc:
|
||||
log.error(f"{exc=}")
|
||||
ctx.obj.console.print(
|
||||
f"[red]Wrong Album Template:[/] {exc} ({template=}, {album.id=}, {album_item.item.id=})"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
futures.append(
|
||||
handle_item(
|
||||
item=album_item.item,
|
||||
file_path=file_path,
|
||||
track_metadata=Metadata(
|
||||
cover=cover,
|
||||
date=str(album.releaseDate),
|
||||
artist=(
|
||||
album.artist.name if album.artist else ""
|
||||
),
|
||||
credits=album_item.credits,
|
||||
album_review=album_review,
|
||||
),
|
||||
)
|
||||
)
|
||||
except ApiError as e:
|
||||
item = album_item.item
|
||||
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
||||
if hasattr(item, "album") and item.album:
|
||||
track_info += f", Album ID: {item.album.id}"
|
||||
ctx.obj.console.print(
|
||||
f"[red]API Error:[/] {e} ({track_info})"
|
||||
)
|
||||
if RAISE_ERRORS:
|
||||
raise
|
||||
except Exception as e:
|
||||
item = album_item.item
|
||||
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
||||
ctx.obj.console.print(f"[red]Error:[/] {e} ({track_info})")
|
||||
if RAISE_ERRORS:
|
||||
raise
|
||||
|
||||
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)
|
||||
|
||||
cover: Cover | None = None
|
||||
save_cover = ("track" 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)
|
||||
|
||||
await handle_item(
|
||||
item=track,
|
||||
file_path=format_template(
|
||||
template=TEMPLATE or CONFIG.templates.track,
|
||||
item=track,
|
||||
album=album,
|
||||
quality=get_item_quality(track),
|
||||
),
|
||||
track_metadata=Metadata(
|
||||
cover=cover,
|
||||
date=str(album.releaseDate),
|
||||
artist=album.artist.name if album.artist else "",
|
||||
# credits are missing
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
template = TEMPLATE or CONFIG.templates.video
|
||||
|
||||
if (
|
||||
"{album" in template
|
||||
and video.album
|
||||
and video.album.id is not None
|
||||
):
|
||||
album = ctx.obj.api.get_album(video.album.id)
|
||||
else:
|
||||
album = None
|
||||
|
||||
await handle_item(
|
||||
item=video,
|
||||
file_path=format_template(
|
||||
template=template,
|
||||
item=video,
|
||||
album=album,
|
||||
quality=get_item_quality(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:
|
||||
template = TEMPLATE or CONFIG.templates.mix
|
||||
|
||||
try:
|
||||
if "{album" in template:
|
||||
album = ctx.obj.api.get_album(
|
||||
mix_item.item.album.id
|
||||
)
|
||||
else:
|
||||
album = None
|
||||
|
||||
futures.append(
|
||||
handle_item(
|
||||
item=mix_item.item,
|
||||
file_path=format_template(
|
||||
template=template,
|
||||
item=mix_item.item,
|
||||
album=album,
|
||||
mix_id=resource.id,
|
||||
quality=get_item_quality(mix_item.item),
|
||||
),
|
||||
)
|
||||
)
|
||||
except ApiError as e:
|
||||
item = mix_item.item
|
||||
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
||||
ctx.obj.console.print(
|
||||
f"[red]API Error:[/] {e} ({track_info})"
|
||||
)
|
||||
if RAISE_ERRORS:
|
||||
raise
|
||||
except Exception as e:
|
||||
item = mix_item.item
|
||||
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
||||
ctx.obj.console.print(
|
||||
f"[red]Error:[/] {e} ({track_info})"
|
||||
)
|
||||
if RAISE_ERRORS:
|
||||
raise
|
||||
|
||||
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,
|
||||
mix_id=resource.id,
|
||||
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 = []
|
||||
|
||||
async def safe_download_album(album: Album):
|
||||
try:
|
||||
await download_album(album)
|
||||
except ApiError as e:
|
||||
ctx.obj.console.print(
|
||||
f"[red]API Error:[/] {e} (Album: {album.title}, ID: {album.id})"
|
||||
)
|
||||
if RAISE_ERRORS:
|
||||
raise
|
||||
except Exception as e:
|
||||
ctx.obj.console.print(
|
||||
f"[red]Error:[/] {e} (Album: {album.title}, ID: {album.id})"
|
||||
)
|
||||
if RAISE_ERRORS:
|
||||
raise
|
||||
|
||||
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(safe_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:
|
||||
template = TEMPLATE or CONFIG.templates.video
|
||||
|
||||
try:
|
||||
if "{album" in template and video.album:
|
||||
album = ctx.obj.api.get_album(video.album.id)
|
||||
else:
|
||||
album = None
|
||||
|
||||
futures.append(
|
||||
handle_item(
|
||||
item=video,
|
||||
file_path=format_template(
|
||||
template=template,
|
||||
item=video,
|
||||
album=album,
|
||||
quality=get_item_quality(video),
|
||||
),
|
||||
)
|
||||
)
|
||||
except ApiError as e:
|
||||
ctx.obj.console.print(
|
||||
f"[red]API Error:[/] {e} (Video: {video.title}, ID: {video.id})"
|
||||
)
|
||||
if RAISE_ERRORS:
|
||||
raise
|
||||
except Exception as e:
|
||||
ctx.obj.console.print(
|
||||
f"[red]Error:[/] {e} (Video: {video.title}, ID: {video.id})"
|
||||
)
|
||||
if RAISE_ERRORS:
|
||||
raise
|
||||
|
||||
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
|
||||
template = TEMPLATE or CONFIG.templates.playlist
|
||||
|
||||
try:
|
||||
if "{album" in template:
|
||||
album = ctx.obj.api.get_album(
|
||||
playlist_item.item.album.id
|
||||
)
|
||||
else:
|
||||
album = None
|
||||
|
||||
futures.append(
|
||||
handle_item(
|
||||
item=playlist_item.item,
|
||||
file_path=format_template(
|
||||
template=template,
|
||||
item=playlist_item.item,
|
||||
album=album,
|
||||
playlist=playlist,
|
||||
playlist_index=playlist_index,
|
||||
quality=get_item_quality(
|
||||
playlist_item.item
|
||||
),
|
||||
),
|
||||
track_metadata=Metadata(),
|
||||
)
|
||||
)
|
||||
except ApiError as e:
|
||||
item = playlist_item.item
|
||||
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
||||
if hasattr(item, "album") and item.album:
|
||||
track_info += f", Album ID: {item.album.id}"
|
||||
ctx.obj.console.print(
|
||||
f"[red]API Error:[/] {e} ({track_info})"
|
||||
)
|
||||
if RAISE_ERRORS:
|
||||
raise
|
||||
except Exception as e:
|
||||
item = playlist_item.item
|
||||
track_info = f"Track: {getattr(item, 'title', 'Unknown')} (ID: {item.id})"
|
||||
ctx.obj.console.print(
|
||||
f"[red]Error:[/] {e} ({track_info})"
|
||||
)
|
||||
if RAISE_ERRORS:
|
||||
raise
|
||||
|
||||
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=min(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,
|
||||
):
|
||||
|
||||
async def wrapper(r: TidalResource):
|
||||
try:
|
||||
await handle_resource(r)
|
||||
except ApiError as e:
|
||||
ctx.obj.console.print(f"[red]API Error:[/] {e} ({r})")
|
||||
if RAISE_ERRORS:
|
||||
raise
|
||||
except Exception as e:
|
||||
ctx.obj.console.print(f"[red]Error:[/] {e} ({r})")
|
||||
if RAISE_ERRORS:
|
||||
raise
|
||||
|
||||
await asyncio.gather(*(wrapper(r) for r in ctx.obj.resources))
|
||||
|
||||
rich_output.show_stats()
|
||||
|
||||
def run():
|
||||
asyncio.run(download_resources())
|
||||
|
||||
ctx.call_on_close(run)
|
||||
@@ -0,0 +1,251 @@
|
||||
import asyncio
|
||||
import shutil
|
||||
from logging import getLogger
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
|
||||
from tiddl.cli.config import VIDEOS_FILTER_LITERAL, ATMOS_FILTER_LITERAL
|
||||
from tiddl.cli.utils.download import get_existing_track_filename
|
||||
from tiddl.cli.utils.path import resolve_existing_path_case
|
||||
from tiddl.core.api import ApiError, TidalAPI
|
||||
from tiddl.core.api.models import StreamVideoQuality, Track, TrackQuality, Video
|
||||
from tiddl.core.utils import parse_track_stream, parse_video_stream
|
||||
from tiddl.core.utils.const import (
|
||||
TRACK_QUALITY_LITERAL,
|
||||
VIDEO_QUALITY_LITERAL,
|
||||
track_qualities,
|
||||
video_qualities,
|
||||
)
|
||||
from tiddl.core.utils.ffmpeg import convert_to_mp4, extract_flac
|
||||
|
||||
from .output import RichOutput
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = 1024**2
|
||||
|
||||
track_qualities_color: dict[TrackQuality, str] = {
|
||||
"LOW": "[gray]96 kbps",
|
||||
"HIGH": "[gray]320 kbps",
|
||||
"LOSSLESS": "[cyan]",
|
||||
"HI_RES_LOSSLESS": "[yellow]",
|
||||
}
|
||||
|
||||
video_qualities_color: dict[StreamVideoQuality, 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: StreamVideoQuality
|
||||
videos_filter: VIDEOS_FILTER_LITERAL
|
||||
skip_existing: bool
|
||||
download_path: Path
|
||||
scan_path: Path
|
||||
match_existing_path_case: bool
|
||||
dolby_atmos_filter: ATMOS_FILTER_LITERAL
|
||||
|
||||
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,
|
||||
match_existing_path_case: bool = False,
|
||||
dolby_atmos_filter: ATMOS_FILTER_LITERAL = "none",
|
||||
) -> 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
|
||||
self.match_existing_path_case = match_existing_path_case
|
||||
self.dolby_atmos_filter = dolby_atmos_filter
|
||||
|
||||
def get_path(self, base_path: Path, relative_path: Path) -> Path:
|
||||
if self.match_existing_path_case:
|
||||
return resolve_existing_path_case(base_path, relative_path)
|
||||
|
||||
return base_path / relative_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
|
||||
)
|
||||
existing_file_path = self.get_path(self.scan_path, filename)
|
||||
vibrant_color = item.album.vibrantColor
|
||||
|
||||
elif isinstance(item, Video):
|
||||
filename = file_path.with_suffix(".mp4")
|
||||
existing_file_path = self.get_path(self.scan_path, filename)
|
||||
vibrant_color = item.vibrantColor
|
||||
|
||||
vibrant_color = vibrant_color or "gray"
|
||||
|
||||
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.show_item_result(
|
||||
result_message="[yellow]Exists",
|
||||
item_description=f"[{vibrant_color}]{item.title}",
|
||||
item_path=existing_file_path,
|
||||
)
|
||||
return existing_file_path, False
|
||||
|
||||
elif (isinstance(item, Video) and self.videos_filter == "none") or (
|
||||
isinstance(item, Track) and self.videos_filter == "only"
|
||||
):
|
||||
log.debug(f"skipping {item.id} due to {self.videos_filter=}")
|
||||
self.rich_output.console.print(
|
||||
f"Skipping '{item.title}' due to video filter set to '{self.videos_filter}'"
|
||||
)
|
||||
return None, False
|
||||
|
||||
should_extract_flac = False
|
||||
|
||||
async with self.semaphore:
|
||||
if isinstance(item, Track):
|
||||
try:
|
||||
stream = self.api.get_track_stream(
|
||||
track_id=item.id, quality=self.track_quality
|
||||
)
|
||||
|
||||
log.debug(
|
||||
f"{stream.trackId=}, {stream.audioQuality=}, {stream.audioMode=}"
|
||||
)
|
||||
|
||||
if (
|
||||
self.dolby_atmos_filter == "none"
|
||||
and stream.audioMode == "DOLBY_ATMOS"
|
||||
) or (
|
||||
self.dolby_atmos_filter == "only"
|
||||
and stream.audioMode == "STEREO"
|
||||
):
|
||||
self.rich_output.console.print(
|
||||
f"[blue]Skipping[/] [gray]{item.title}[/] [blue]due to Dolby Atmos filter[/] {self.dolby_atmos_filter}"
|
||||
)
|
||||
return None, False
|
||||
|
||||
except ApiError as e:
|
||||
log.error(f"{item.id=} {e=}")
|
||||
self.rich_output.console.print(
|
||||
f"[red]Error [{vibrant_color}]{item.title}[/] - {e.user_message}"
|
||||
)
|
||||
return None, False
|
||||
|
||||
urls, _ = parse_track_stream(stream)
|
||||
download_path = self.get_path(self.download_path, filename)
|
||||
|
||||
quality_string = track_qualities_color[stream.audioQuality]
|
||||
|
||||
if (
|
||||
stream.audioQuality in ["HI_RES_LOSSLESS", "LOSSLESS"]
|
||||
and stream.audioMode == "STEREO"
|
||||
):
|
||||
quality_string = f"{quality_string} {stream.bitDepth}-bit, {(stream.sampleRate or 0) / 1000:.1f} kHz"
|
||||
should_extract_flac = True
|
||||
else:
|
||||
download_path = download_path.with_suffix(".m4a")
|
||||
|
||||
if stream.audioMode == "DOLBY_ATMOS":
|
||||
quality_string = "[blue]Dolby Atmos[/]"
|
||||
|
||||
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.get_path(self.download_path, filename).with_suffix(
|
||||
ext
|
||||
)
|
||||
quality_string = video_qualities_color[stream.videoQuality]
|
||||
|
||||
task_id = self.rich_output.download_start(
|
||||
f"[{vibrant_color}]{item.title} {quality_string}"
|
||||
)
|
||||
|
||||
download_path.parent.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
# TODO shouldnt session be reused instead of
|
||||
# creating new one on every download?
|
||||
|
||||
with NamedTemporaryFile(
|
||||
"wb", delete=False, dir=download_path.parent
|
||||
) as tmp:
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with aiofiles.open(tmp.name, "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)
|
||||
)
|
||||
|
||||
shutil.move(tmp.name, download_path)
|
||||
|
||||
try:
|
||||
download_path.chmod(0o644)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
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=}")
|
||||
|
||||
task = self.rich_output.download_finish(
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
self.rich_output.show_item_result(
|
||||
result_message=result_message,
|
||||
item_description=task.description,
|
||||
item_path=download_path,
|
||||
)
|
||||
|
||||
return download_path, True
|
||||
@@ -0,0 +1,103 @@
|
||||
from pathlib import Path
|
||||
|
||||
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) -> Task:
|
||||
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.total_downloads += 1
|
||||
|
||||
return task
|
||||
|
||||
def show_stats(self):
|
||||
self.console.print(f"[green]Total downloads: {self.total_downloads}")
|
||||
|
||||
def show_item_result(
|
||||
self, result_message: str, item_description: str, item_path: Path | None
|
||||
):
|
||||
if item_path:
|
||||
description = f"[link={item_path.as_uri()}]{item_description}[/link] [link={item_path.parent.as_uri()}]{item_path.parent}[/link]"
|
||||
else:
|
||||
description = item_description
|
||||
|
||||
self.console.print(f"{result_message} {description}")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,13 @@
|
||||
from typer import Typer
|
||||
|
||||
from .url import url_subcommand
|
||||
from .fav import fav_subcommand
|
||||
from .search import search_subcommand
|
||||
|
||||
|
||||
SUBCOMMANDS: list[Typer] = [url_subcommand, fav_subcommand, search_subcommand]
|
||||
|
||||
|
||||
def register_subcommands(app: Typer):
|
||||
for sub_command in SUBCOMMANDS:
|
||||
app.add_typer(sub_command)
|
||||
@@ -0,0 +1,45 @@
|
||||
import typer
|
||||
from typing import cast
|
||||
from typing_extensions import Annotated
|
||||
|
||||
from tiddl.cli.ctx import Context
|
||||
from tiddl.cli.utils.resource import ResourceTypeLiteral, TidalResource
|
||||
|
||||
|
||||
fav_subcommand = typer.Typer()
|
||||
|
||||
|
||||
@fav_subcommand.command()
|
||||
def fav(
|
||||
ctx: Context,
|
||||
TYPES: Annotated[
|
||||
list[str],
|
||||
typer.Option(
|
||||
"-t",
|
||||
"--types",
|
||||
metavar="<resource>",
|
||||
help="Narrow resource types, usage: -t track -t album etc. Available resources: track, video, album, playlist, artist.",
|
||||
),
|
||||
] = ["track", "video", "album", "playlist", "artist"],
|
||||
):
|
||||
"""
|
||||
Get your Tidal favorites. You can narrow them to any type of your choice.
|
||||
"""
|
||||
|
||||
favorites = ctx.obj.api.get_favorites()
|
||||
favorites_dict = favorites.model_dump()
|
||||
|
||||
stats: dict[ResourceTypeLiteral, int] = dict()
|
||||
|
||||
for resource_type in cast(list[ResourceTypeLiteral], 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))
|
||||
|
||||
ctx.obj.console.print(f"[green]Loaded {len(ctx.obj.resources)} resources")
|
||||
|
||||
for resource_type, count in stats.items():
|
||||
ctx.obj.console.print(f"{resource_type.title()}s: {count}")
|
||||
@@ -0,0 +1,144 @@
|
||||
import typer
|
||||
from typing_extensions import Annotated
|
||||
|
||||
from tiddl.cli.ctx import Context
|
||||
from tiddl.cli.utils.resource import TidalResource
|
||||
from tiddl.core.api.models.base import Search, SearchArtist
|
||||
from tiddl.core.api.models.resources import Track, Album, Playlist, Video
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
search_subcommand = typer.Typer()
|
||||
|
||||
|
||||
@search_subcommand.command(
|
||||
no_args_is_help=True,
|
||||
)
|
||||
def search(
|
||||
ctx: Context,
|
||||
query: Annotated[str, typer.Argument()],
|
||||
resource_types: Annotated[
|
||||
list[str],
|
||||
typer.Option(
|
||||
"-t",
|
||||
"--types",
|
||||
metavar="<resource>",
|
||||
help="Narrow resource types, usage: -t track -t album etc. Available resources: track, video, album, playlist, artist.",
|
||||
),
|
||||
] = ["track", "video", "album", "playlist", "artist"],
|
||||
number_top_results: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--num-top",
|
||||
"-n",
|
||||
help="Number of top results to display per resource type.",
|
||||
),
|
||||
] = 3,
|
||||
pick_top_hit: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--top",
|
||||
"-T",
|
||||
help="Automatically pick the top hit if it exists and matches the specified resource types.",
|
||||
),
|
||||
] = False,
|
||||
):
|
||||
"""
|
||||
Search Tidal for tracks, videos, albums, playlists, artists, and mixes.
|
||||
|
||||
By default, it searches for all resource types. You can specify which resource types to search for using the `--type` option.
|
||||
"""
|
||||
|
||||
results: Search = ctx.obj.api.get_search(query=query)
|
||||
table = _prepare_table(query)
|
||||
|
||||
results_to_display = []
|
||||
if results.topHit is not None:
|
||||
top_hit = results.topHit
|
||||
top_hit_type = top_hit.type.rstrip("S").lower() # "ARTISTS" -> "artist"
|
||||
if top_hit_type in resource_types:
|
||||
if pick_top_hit:
|
||||
ctx.obj.resources.append(
|
||||
TidalResource.from_string(
|
||||
f"{top_hit_type}/{_display_id(top_hit.value)}"
|
||||
)
|
||||
)
|
||||
ctx.obj.console.print(
|
||||
f"[green]Automatically added top hit: {top_hit.type.title()} '{_display_name(top_hit.value)}'"
|
||||
)
|
||||
return
|
||||
else:
|
||||
results_to_display.append(
|
||||
(
|
||||
top_hit_type.title(),
|
||||
_display_name(top_hit.value),
|
||||
_display_id(top_hit.value),
|
||||
)
|
||||
)
|
||||
|
||||
type_to_items = {
|
||||
"artist": results.artists.items,
|
||||
"album": results.albums.items,
|
||||
"playlist": results.playlists.items,
|
||||
"track": results.tracks.items,
|
||||
"video": results.videos.items,
|
||||
}
|
||||
|
||||
for resource_type, items in type_to_items.items():
|
||||
if resource_type in resource_types:
|
||||
results_to_display.extend(
|
||||
(resource_type.title(), _display_name(item), _display_id(item))
|
||||
for item in items[:number_top_results]
|
||||
)
|
||||
|
||||
for i, (resource_type, name, id) in enumerate(results_to_display, start=1):
|
||||
table.add_row(str(i), resource_type, name, id)
|
||||
|
||||
panel = Panel(table, title="Search Results", highlight=True, expand=True)
|
||||
ctx.obj.console.print(panel)
|
||||
selection = ctx.obj.console.input(
|
||||
"[bold green]Enter the number of the resource to add to your list (comma-separated for multiple, q/empty = quit): "
|
||||
)
|
||||
selected_numbers = [s.strip() for s in selection.split(",")]
|
||||
|
||||
for num in selected_numbers:
|
||||
if num.lower() == "q":
|
||||
return
|
||||
|
||||
if not num.isdigit() or int(num) < 1 or int(num) > len(results_to_display):
|
||||
ctx.obj.console.print(f"[red]Invalid selection: {num}")
|
||||
continue
|
||||
|
||||
selected_resource = results_to_display[int(num) - 1]
|
||||
resource_type, name, id = selected_resource
|
||||
ctx.obj.resources.append(
|
||||
TidalResource.from_string(f"{resource_type.lower()}/{id}")
|
||||
)
|
||||
ctx.obj.console.print(f"[green]Added {resource_type} '{name}' to your list")
|
||||
|
||||
|
||||
def _display_name(item) -> str:
|
||||
if isinstance(item, SearchArtist):
|
||||
return item.name
|
||||
elif isinstance(item, Video):
|
||||
return f"{item.artist or item.artists[0].name or ""} - {item.title}"
|
||||
elif isinstance(item, (Track, Album)):
|
||||
return f"{item.artist or item.artists[0].name or ""} - {item.title} [blue][{', '.join(item.audioModes)}][/]"
|
||||
elif isinstance(item, (Playlist)):
|
||||
return item.title
|
||||
else:
|
||||
raise ValueError("Unknown item type")
|
||||
|
||||
|
||||
def _display_id(item) -> str:
|
||||
return item.uuid if isinstance(item, Playlist) else str(item.id)
|
||||
|
||||
|
||||
def _prepare_table(query: str) -> Table:
|
||||
table = Table(title=f"{query}", expand=True)
|
||||
table.add_column("#", style="yellow", ratio=1)
|
||||
table.add_column("Type", style="cyan", ratio=1)
|
||||
table.add_column("Title", style="green", ratio=8)
|
||||
table.add_column("ID", style="magenta", ratio=2)
|
||||
return table
|
||||
@@ -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)
|
||||
+115
-45
@@ -1,54 +1,124 @@
|
||||
import click
|
||||
from logging import getLogger
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel, field_validator
|
||||
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
|
||||
from tiddl.core.utils.const import TRACK_QUALITY_LITERAL, VIDEO_QUALITY_LITERAL
|
||||
|
||||
CONFIG_FILENAME = "config.toml"
|
||||
DEFAULT_DOWNLOAD_PATH = Path.home() / "Music" / "tiddl"
|
||||
|
||||
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"]
|
||||
ATMOS_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
|
||||
album_review: 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 = DEFAULT_DOWNLOAD_PATH
|
||||
scan_path: Path = DEFAULT_DOWNLOAD_PATH
|
||||
singles_filter: ARTIST_SINGLES_FILTER_LITERAL = "none"
|
||||
videos_filter: VIDEOS_FILTER_LITERAL = "none"
|
||||
update_mtime: bool = False
|
||||
rewrite_metadata: bool = False
|
||||
write_lrc_file: bool = False
|
||||
match_existing_path_case: bool = False
|
||||
atmos_filter: ATMOS_FILTER_LITERAL = "none"
|
||||
|
||||
def model_post_init(self, __context):
|
||||
# set scan path to download path when download path is non default
|
||||
if self.scan_path == DEFAULT_DOWNLOAD_PATH and self.download_path != DEFAULT_DOWNLOAD_PATH:
|
||||
self.scan_path = self.download_path
|
||||
|
||||
@field_validator("download_path", "scan_path", mode="before")
|
||||
def str_to_path(cls, v):
|
||||
# convert to absolute, expand ~, normalize
|
||||
return Path(v).expanduser().resolve() if isinstance(v, str) else v
|
||||
|
||||
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=}")
|
||||
|
||||
@@ -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()
|
||||
+58
-45
@@ -1,59 +1,72 @@
|
||||
import functools
|
||||
import click
|
||||
import typer
|
||||
from time import time
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
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.core.auth import AuthAPI
|
||||
from tiddl.cli.utils.auth.core import load_auth_data, save_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]
|
||||
auth_api: AuthAPI
|
||||
_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.auth_api = AuthAPI()
|
||||
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
|
||||
refresh_token = auth_data.refresh_token
|
||||
assert refresh_token, "Refresh Token is missing. Use `tiddl auth login`"
|
||||
|
||||
def on_token_expiry() -> str | None:
|
||||
auth_response = self.auth_api.refresh_token(refresh_token)
|
||||
auth_data.token = auth_response.access_token
|
||||
auth_data.expires_at = auth_response.expires_in + int(time())
|
||||
|
||||
save_auth_data(auth_data=auth_data)
|
||||
|
||||
if auth_response:
|
||||
return auth_response.access_token
|
||||
|
||||
return None
|
||||
|
||||
client = TidalClient(
|
||||
token=auth_data.token,
|
||||
cache_name=APP_PATH / "api_cache",
|
||||
omit_cache=self.api_omit_cache,
|
||||
debug_path=self.debug_path,
|
||||
on_token_expiry=on_token_expiry,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
import logging
|
||||
import click
|
||||
|
||||
from time import perf_counter
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from requests import Session
|
||||
|
||||
from rich.highlighter import ReprHighlighter
|
||||
from rich.progress import (
|
||||
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,
|
||||
trackExists,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
@passContext
|
||||
def DownloadCommand(
|
||||
ctx: Context,
|
||||
QUALITY: TrackArg | None,
|
||||
TEMPLATE: str | None,
|
||||
PATH: str | None,
|
||||
THREADS_COUNT: int,
|
||||
DO_NOT_SKIP: bool,
|
||||
SINGLES_FILTER: SinglesFilter,
|
||||
EMBED_LYRICS: bool,
|
||||
DOWNLOAD_VIDEO: bool
|
||||
):
|
||||
"""Download resources"""
|
||||
DOWNLOAD_VIDEO = DOWNLOAD_VIDEO or ctx.obj.config.download.download_video
|
||||
SINGLES_FILTER = SINGLES_FILTER or ctx.obj.config.download.singles_filter
|
||||
EMBED_LYRICS = EMBED_LYRICS or ctx.obj.config.download.embed_lyrics
|
||||
|
||||
# TODO: pretty print
|
||||
logging.debug(
|
||||
(
|
||||
QUALITY,
|
||||
TEMPLATE,
|
||||
PATH,
|
||||
THREADS_COUNT,
|
||||
DO_NOT_SKIP,
|
||||
SINGLES_FILTER,
|
||||
EMBED_LYRICS
|
||||
)
|
||||
)
|
||||
|
||||
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="",
|
||||
):
|
||||
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 track_stream.audioQuality == "HI_RES_LOSSLESS":
|
||||
path = convertFileExtension(
|
||||
source_file=path,
|
||||
extension=".flac",
|
||||
remove_source=True,
|
||||
is_video=False,
|
||||
copy_audio=True, # extract flac from m4a container
|
||||
)
|
||||
|
||||
if not cover_data and item.album.cover:
|
||||
cover_data = Cover(item.album.cover).content
|
||||
|
||||
if EMBED_LYRICS:
|
||||
lyrics_subtitles = api.getLyrics(item.id).subtitles
|
||||
else:
|
||||
lyrics_subtitles = ""
|
||||
|
||||
try:
|
||||
addMetadata(path, item, cover_data, credits, album_artist=album_artist, lyrics=lyrics_subtitles)
|
||||
except Exception as e:
|
||||
logging.error(f"Can not add metadata to: {path}, {e}")
|
||||
|
||||
elif isinstance(item, Video):
|
||||
path = convertFileExtension(
|
||||
source_file=path,
|
||||
extension=".mp4",
|
||||
remove_source=True,
|
||||
is_video=True,
|
||||
copy_audio=True,
|
||||
)
|
||||
|
||||
try:
|
||||
addVideoMetadata(path, item)
|
||||
except Exception as e:
|
||||
logging.error(f"Can not add metadata to: {path}, {e}")
|
||||
|
||||
progress.remove_task(task_id)
|
||||
logging.info(f"{item.title!r} • {speed:.2f} Mbps • {size:.2f} MB")
|
||||
|
||||
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="",
|
||||
):
|
||||
if not item.allowStreaming:
|
||||
logging.warning(
|
||||
f"✖ {type(item).__name__} '{item.title}' does not allow streaming"
|
||||
)
|
||||
return
|
||||
|
||||
path = Path(PATH) if PATH else ctx.obj.config.download.path
|
||||
path /= f"{filename}.*"
|
||||
|
||||
if not DO_NOT_SKIP: # check if item is already downloaded
|
||||
if isinstance(item, Track):
|
||||
if trackExists(item.audioQuality, DOWNLOAD_QUALITY, path):
|
||||
logging.warning(f"Track '{item.title}' skipped")
|
||||
return
|
||||
elif isinstance(item, Video):
|
||||
if path.with_suffix(".mp4").exists() or not DOWNLOAD_VIDEO:
|
||||
logging.warning(f"Video '{item.title}' skipped")
|
||||
return
|
||||
|
||||
pool.submit(
|
||||
handleItemDownload,
|
||||
item=item,
|
||||
path=path,
|
||||
cover_data=cover_data,
|
||||
credits=credits,
|
||||
album_artist=album_artist,
|
||||
)
|
||||
|
||||
def downloadAlbum(album: Album):
|
||||
logging.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:
|
||||
logging.debug(f"Handling Resource '{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 "artist":
|
||||
artist = api.getArtist(resource.id)
|
||||
logging.info(f"Artist {artist.name!r}")
|
||||
|
||||
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)
|
||||
logging.info(f"Playlist {playlist.title!r}")
|
||||
offset = 0
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
submitItem(item.item, filename)
|
||||
|
||||
if (
|
||||
playlist_items.limit + playlist_items.offset
|
||||
> playlist_items.totalNumberOfItems
|
||||
):
|
||||
break
|
||||
|
||||
offset += playlist_items.limit
|
||||
|
||||
progress.start()
|
||||
|
||||
# TODO: make sure every resource is unique
|
||||
for resource in ctx.obj.resources:
|
||||
try:
|
||||
handleResource(resource)
|
||||
|
||||
except AuthError as e:
|
||||
logging.error(e)
|
||||
break
|
||||
|
||||
except ApiError as e:
|
||||
logging.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)
|
||||
@@ -1,46 +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}")
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def resolve_existing_path_case(base_path: Path, relative_path: Path) -> Path:
|
||||
"""
|
||||
Return base_path / relative_path, reusing existing path component casing.
|
||||
"""
|
||||
|
||||
if relative_path.is_absolute():
|
||||
raise ValueError("relative_path must not be absolute")
|
||||
|
||||
resolved_path = base_path
|
||||
|
||||
for part in relative_path.parts:
|
||||
if part in ("", "."):
|
||||
continue
|
||||
|
||||
existing_part = find_existing_child_case(resolved_path, part)
|
||||
resolved_path = resolved_path / (existing_part or part)
|
||||
|
||||
return resolved_path
|
||||
|
||||
|
||||
def find_existing_child_case(parent: Path, name: str) -> str | None:
|
||||
if not parent.is_dir():
|
||||
return None
|
||||
|
||||
casefolded_name = name.casefold()
|
||||
fallback: str | None = None
|
||||
|
||||
for child in parent.iterdir():
|
||||
if child.name == name:
|
||||
return child.name
|
||||
|
||||
if fallback is None and child.name.casefold() == casefolded_name:
|
||||
fallback = child.name
|
||||
|
||||
return fallback
|
||||
@@ -0,0 +1,55 @@
|
||||
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`).
|
||||
"""
|
||||
|
||||
segments = [seg for seg in urlparse(string).path.split("/") if seg]
|
||||
|
||||
resource_type = next(
|
||||
(seg for seg in segments if seg in get_args(ResourceTypeLiteral)), None
|
||||
)
|
||||
|
||||
if not resource_type:
|
||||
raise ValueError(f"Invalid resource type: {resource_type}")
|
||||
|
||||
try:
|
||||
resource_id = segments[segments.index(resource_type) + 1]
|
||||
except IndexError:
|
||||
raise ValueError(f"No resource ID found {resource_type=} {string=}")
|
||||
|
||||
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}"
|
||||
@@ -1,68 +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
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
@@ -0,0 +1,5 @@
|
||||
from .api import TidalAPI
|
||||
from .client import TidalClient
|
||||
from .exceptions import ApiError
|
||||
|
||||
__all__ = ["TidalAPI", "TidalClient", "ApiError"]
|
||||
@@ -0,0 +1,255 @@
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
from requests_cache import DO_NOT_CACHE, EXPIRE_IMMEDIATELY
|
||||
|
||||
from .client import TidalClient
|
||||
from .models.base import (
|
||||
AlbumItems,
|
||||
AlbumItemsCredits,
|
||||
ArtistAlbumsItems,
|
||||
ArtistVideosItems,
|
||||
Favorites,
|
||||
MixItems,
|
||||
PlaylistItems,
|
||||
Search,
|
||||
SessionResponse,
|
||||
TrackLyrics,
|
||||
TrackStream,
|
||||
VideoStream,
|
||||
)
|
||||
from .models.resources import (
|
||||
Album,
|
||||
Artist,
|
||||
Playlist,
|
||||
StreamVideoQuality,
|
||||
Track,
|
||||
TrackQuality,
|
||||
Video,
|
||||
)
|
||||
from .models.review import AlbumReview
|
||||
|
||||
ID: TypeAlias = str | int
|
||||
|
||||
|
||||
class Limits:
|
||||
# TODO test every max limit
|
||||
|
||||
ARTIST_ALBUMS = 10
|
||||
ARTIST_ALBUMS_MAX = 100
|
||||
|
||||
ARTIST_VIDEOS = 10
|
||||
ARTIST_VIDEOS_MAX = 100
|
||||
|
||||
ALBUM_ITEMS = 20
|
||||
ALBUM_ITEMS_MAX = 100
|
||||
|
||||
PLAYLIST_ITEMS = 20
|
||||
PLAYLIST_ITEMS_MAX = 100
|
||||
|
||||
MIX_ITEMS = 20
|
||||
MIX_ITEMS_MAX = 100
|
||||
|
||||
|
||||
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_album_review(self, album_id: ID):
|
||||
return self.client.fetch(
|
||||
AlbumReview,
|
||||
f"albums/{album_id}/review",
|
||||
{"countryCode": self.country_code},
|
||||
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: StreamVideoQuality):
|
||||
return self.client.fetch(
|
||||
VideoStream,
|
||||
f"videos/{video_id}/playbackinfopostpaywall",
|
||||
{
|
||||
"videoquality": quality,
|
||||
"playbackmode": "STREAM",
|
||||
"assetpresentation": "FULL",
|
||||
},
|
||||
expire_after=DO_NOT_CACHE,
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
import json
|
||||
from logging import getLogger
|
||||
from pathlib import Path
|
||||
from typing import Any, Type, TypeVar, Callable, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from time import sleep
|
||||
|
||||
from requests.exceptions import JSONDecodeError
|
||||
from requests_cache import (
|
||||
CachedSession,
|
||||
StrOrPath,
|
||||
NEVER_EXPIRE,
|
||||
)
|
||||
|
||||
from .exceptions import ApiError
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
|
||||
API_URL = "https://api.tidal.com/v1"
|
||||
MAX_RETRIES = 5
|
||||
RETRY_DELAY = 2
|
||||
|
||||
log = getLogger(__name__)
|
||||
|
||||
|
||||
# TODO add token expiry check
|
||||
# maybe refactor to aiohttp.ClientSession
|
||||
class TidalClient:
|
||||
_token: str
|
||||
debug_path: Path | None
|
||||
session: CachedSession
|
||||
on_token_expiry: Optional[Callable[[], str | None]]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
cache_name: StrOrPath,
|
||||
omit_cache: bool = False,
|
||||
debug_path: Path | None = None,
|
||||
on_token_expiry: Optional[Callable[[], str | None]] = None,
|
||||
) -> None:
|
||||
self.on_token_expiry = on_token_expiry
|
||||
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",
|
||||
}
|
||||
self._token = token
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
return self._token
|
||||
|
||||
@token.setter
|
||||
def token(self, token: str):
|
||||
self._token = token
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {token}",
|
||||
}
|
||||
)
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
model: Type[T],
|
||||
endpoint: str,
|
||||
params: dict[str, Any] = {},
|
||||
expire_after: int = NEVER_EXPIRE,
|
||||
_attempt: int = 1,
|
||||
) -> 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
|
||||
)
|
||||
|
||||
if res.status_code == 401 and self.on_token_expiry:
|
||||
token = self.on_token_expiry()
|
||||
|
||||
if token:
|
||||
self.token = token
|
||||
|
||||
return self.fetch(
|
||||
model=model,
|
||||
endpoint=endpoint,
|
||||
params=params,
|
||||
expire_after=expire_after,
|
||||
_attempt=MAX_RETRIES - 1,
|
||||
)
|
||||
|
||||
log.debug(
|
||||
f"{endpoint} {params} '{'HIT' if res.from_cache else 'MISS'}' [{res.status_code}]",
|
||||
)
|
||||
|
||||
try:
|
||||
data = res.json()
|
||||
except JSONDecodeError as e:
|
||||
if _attempt >= MAX_RETRIES:
|
||||
log.error(f"JSON decode failed after {MAX_RETRIES} attempts: {e}")
|
||||
raise ApiError(
|
||||
status=res.status_code,
|
||||
subStatus="0",
|
||||
userMessage="Response body does not contain valid json.",
|
||||
)
|
||||
|
||||
log.warning(f"JSON decode error, retrying {_attempt}/{MAX_RETRIES}")
|
||||
sleep(RETRY_DELAY)
|
||||
|
||||
return self.fetch(
|
||||
model=model,
|
||||
endpoint=endpoint,
|
||||
params=params,
|
||||
expire_after=expire_after,
|
||||
_attempt=_attempt + 1,
|
||||
)
|
||||
|
||||
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:
|
||||
log.error(f"{endpoint=}, {params=}, {data=}")
|
||||
raise ApiError(**data)
|
||||
|
||||
return model.model_validate(data)
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,43 @@
|
||||
from .base import (
|
||||
AlbumItems,
|
||||
AlbumItemsCredits,
|
||||
ArtistAlbumsItems,
|
||||
Favorites,
|
||||
MixItems,
|
||||
PlaylistItems,
|
||||
Search,
|
||||
SessionResponse,
|
||||
TrackLyrics,
|
||||
TrackStream,
|
||||
VideoStream,
|
||||
)
|
||||
from .resources import (
|
||||
Album,
|
||||
Artist,
|
||||
Playlist,
|
||||
StreamVideoQuality,
|
||||
Track,
|
||||
TrackQuality,
|
||||
Video,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Album",
|
||||
"Artist",
|
||||
"Playlist",
|
||||
"Track",
|
||||
"Video",
|
||||
"TrackQuality",
|
||||
"StreamVideoQuality",
|
||||
"AlbumItems",
|
||||
"AlbumItemsCredits",
|
||||
"ArtistAlbumsItems",
|
||||
"Favorites",
|
||||
"TrackLyrics",
|
||||
"PlaylistItems",
|
||||
"MixItems",
|
||||
"Search",
|
||||
"SessionResponse",
|
||||
"TrackStream",
|
||||
"VideoStream"
|
||||
]
|
||||
@@ -1,18 +1,15 @@
|
||||
from typing import List, Literal, Optional, Union
|
||||
|
||||
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",
|
||||
"AlbumItems",
|
||||
"PlaylistItems",
|
||||
"Favorites",
|
||||
"TrackStream",
|
||||
"Search",
|
||||
"Lyrics"
|
||||
]
|
||||
from .resources import (
|
||||
Album,
|
||||
Playlist,
|
||||
StreamVideoQuality,
|
||||
Track,
|
||||
TrackQuality,
|
||||
Video,
|
||||
)
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
@@ -40,6 +37,10 @@ class ArtistAlbumsItems(Items):
|
||||
items: List[Album]
|
||||
|
||||
|
||||
class ArtistVideosItems(Items):
|
||||
items: List[Video]
|
||||
|
||||
|
||||
ItemType = Literal["track", "video"]
|
||||
|
||||
|
||||
@@ -94,6 +95,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"
|
||||
@@ -102,6 +105,14 @@ class PlaylistItems(Items):
|
||||
items: List[Union[PlaylistTrackItem, PlaylistVideoItem]]
|
||||
|
||||
|
||||
class MixItems(Items):
|
||||
class MixItem(BaseModel):
|
||||
item: Track
|
||||
type: ItemType = "track"
|
||||
|
||||
items: List[MixItem]
|
||||
|
||||
|
||||
class Favorites(BaseModel):
|
||||
PLAYLIST: List[str]
|
||||
ALBUM: List[str]
|
||||
@@ -113,15 +124,15 @@ class Favorites(BaseModel):
|
||||
class TrackStream(BaseModel):
|
||||
trackId: int
|
||||
assetPresentation: Literal["FULL"]
|
||||
audioMode: Literal["STEREO"]
|
||||
audioMode: Literal["STEREO", "DOLBY_ATMOS"]
|
||||
audioQuality: TrackQuality
|
||||
manifestMimeType: Literal["application/dash+xml", "application/vnd.tidal.bts"]
|
||||
manifestHash: str
|
||||
manifest: str
|
||||
albumReplayGain: float
|
||||
albumPeakAmplitude: float
|
||||
trackReplayGain: float
|
||||
trackPeakAmplitude: float
|
||||
albumReplayGain: Optional[float] = None
|
||||
albumPeakAmplitude: Optional[float] = None
|
||||
trackReplayGain: Optional[float] = None
|
||||
trackPeakAmplitude: Optional[float] = None
|
||||
bitDepth: Optional[int] = None
|
||||
sampleRate: Optional[int] = None
|
||||
|
||||
@@ -130,24 +141,31 @@ class VideoStream(BaseModel):
|
||||
videoId: int
|
||||
streamType: Literal["ON_DEMAND"]
|
||||
assetPresentation: Literal["FULL"]
|
||||
videoQuality: Literal["HIGH", "MEDIUM"]
|
||||
videoQuality: StreamVideoQuality
|
||||
# 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
|
||||
|
||||
# It seemed like the search API doesn't return `artist.type`, so this is used instead of resources.Artist for search results to avoid validation errors.
|
||||
# FIXME: This can be discarded if we are okay with making the `type` field optional in resources.Artist, but I don't think it's my decision to make lol
|
||||
class SearchArtist(BaseModel): # search-specific, fewer required fields
|
||||
id: int
|
||||
name: str
|
||||
type: Optional[Literal["MAIN", "FEATURED"]] = None
|
||||
url: Optional[str] = None
|
||||
picture: Optional[str] = None
|
||||
popularity: Optional[int] = None
|
||||
|
||||
class Search(BaseModel):
|
||||
|
||||
|
||||
class Artists(Items):
|
||||
items: List[Artist]
|
||||
items: List[SearchArtist] # ← uses the inner model, not resources.Artist
|
||||
|
||||
class Albums(Items):
|
||||
items: List[SearchAlbum]
|
||||
items: List[Album]
|
||||
|
||||
class Playlists(Items):
|
||||
items: List[Playlist]
|
||||
@@ -159,7 +177,7 @@ class Search(BaseModel):
|
||||
items: List[Video]
|
||||
|
||||
class TopHit(BaseModel):
|
||||
value: Union[Artist, Track, Playlist, SearchAlbum]
|
||||
value: Union[SearchArtist, Track, Playlist, Album]
|
||||
type: Literal["ARTISTS", "TRACKS", "PLAYLISTS", "ALBUMS"]
|
||||
|
||||
artists: Artists
|
||||
@@ -170,7 +188,7 @@ class Search(BaseModel):
|
||||
topHit: Optional[TopHit] = None
|
||||
|
||||
|
||||
class Lyrics(BaseModel):
|
||||
class TrackLyrics(BaseModel):
|
||||
isRightToLeft: bool
|
||||
lyrics: str
|
||||
lyricsProvider: str
|
||||
@@ -1,11 +1,14 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Literal, Dict
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from tiddl.models.constants import TrackQuality
|
||||
from pydantic import BaseModel
|
||||
|
||||
TrackQuality = Literal["LOW", "HIGH", "LOSSLESS", "HI_RES_LOSSLESS"]
|
||||
|
||||
__all__ = ["Track", "Video", "Album", "Playlist", "Artist"]
|
||||
# audio_only is not stable
|
||||
StreamVideoQuality = Literal["AUDIO_ONLY", "LOW", "MEDIUM", "HIGH"]
|
||||
|
||||
MediaMetadataTags = Literal["LOSSLESS", "HIRES_LOSSLESS", "DOLBY_ATMOS"]
|
||||
|
||||
|
||||
class Track(BaseModel):
|
||||
@@ -23,6 +26,9 @@ class Track(BaseModel):
|
||||
vibrantColor: Optional[str] = None
|
||||
videoCover: Optional[str] = None
|
||||
|
||||
class MediaMetadata(BaseModel):
|
||||
tags: list[MediaMetadataTags]
|
||||
|
||||
id: int
|
||||
title: str
|
||||
duration: int
|
||||
@@ -47,8 +53,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
|
||||
@@ -64,9 +69,9 @@ class Video(BaseModel):
|
||||
picture: Optional[str] = None
|
||||
|
||||
class Album(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
cover: str
|
||||
id: Optional[int] = None
|
||||
title: Optional[str] = None
|
||||
cover: Optional[str] = None
|
||||
vibrantColor: Optional[str] = None
|
||||
videoCover: Optional[str] = None
|
||||
|
||||
@@ -76,10 +81,10 @@ class Video(BaseModel):
|
||||
trackNumber: int
|
||||
streamStartDate: Optional[datetime] = None
|
||||
imagePath: Optional[str] = None
|
||||
imageId: str
|
||||
imageId: Optional[str] = None
|
||||
vibrantColor: Optional[str] = None
|
||||
duration: int
|
||||
quality: str
|
||||
quality: Literal["MP4_1080P"] | str
|
||||
streamReady: bool
|
||||
adSupportedStreamReady: bool
|
||||
djReady: bool
|
||||
@@ -105,7 +110,7 @@ class Album(BaseModel):
|
||||
picture: Optional[str] = None
|
||||
|
||||
class MediaMetadata(BaseModel):
|
||||
tags: List[Literal["LOSSLESS", "HIRES_LOSSLESS", "DOLBY_ATMOS"]]
|
||||
tags: List[MediaMetadataTags]
|
||||
|
||||
id: int
|
||||
title: str
|
||||
@@ -120,21 +125,22 @@ class Album(BaseModel):
|
||||
numberOfTracks: int
|
||||
numberOfVideos: int
|
||||
numberOfVolumes: int
|
||||
releaseDate: Optional[str] = None
|
||||
releaseDate: datetime | None = None
|
||||
copyright: Optional[str] = None
|
||||
type: str
|
||||
type: Literal["ALBUM", "SINGLE", "EP"]
|
||||
version: Optional[str] = None
|
||||
url: str
|
||||
cover: Optional[str] = None
|
||||
vibrantColor: Optional[str] = None
|
||||
videoCover: Optional[str] = None
|
||||
explicit: bool
|
||||
upc: str
|
||||
upc: Optional[str] = None
|
||||
popularity: int
|
||||
audioQuality: str
|
||||
audioModes: List[str]
|
||||
mediaMetadata: MediaMetadata
|
||||
artist: Artist
|
||||
# artist is none in search query
|
||||
artist: Optional[Artist] = None
|
||||
artists: List[Artist]
|
||||
|
||||
|
||||
@@ -147,7 +153,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
|
||||
@@ -157,7 +163,7 @@ class Playlist(BaseModel):
|
||||
url: str
|
||||
image: Optional[str] = None
|
||||
popularity: int
|
||||
squareImage: str
|
||||
squareImage: Optional[str] = None
|
||||
promotedArtists: List[Album.Artist]
|
||||
lastItemAddedAt: Optional[str] = None
|
||||
|
||||
@@ -182,11 +188,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
|
||||
@@ -0,0 +1,30 @@
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
def normalize_review_text(text: str | None = None) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
text = re.sub(
|
||||
r"\[wimpLink\b[^\]]*\](.*?)\[/wimpLink\]",
|
||||
r"\1",
|
||||
text,
|
||||
flags=re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
|
||||
text = re.sub(r"\[/?wimpLink\b[^\]]*\]", "", text, flags=re.IGNORECASE)
|
||||
|
||||
return text.strip()
|
||||
|
||||
|
||||
class AlbumReview(BaseModel):
|
||||
source: str
|
||||
lastUpdated: datetime
|
||||
text: str
|
||||
summary: str
|
||||
|
||||
def normalized_text(self) -> str:
|
||||
return normalize_review_text(self.text)
|
||||
@@ -0,0 +1,4 @@
|
||||
from .api import AuthAPI
|
||||
from .exceptions import AuthClientError
|
||||
|
||||
__all__ = ["AuthAPI", "AuthClientError"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,101 @@
|
||||
import base64
|
||||
import logging
|
||||
from os import environ
|
||||
from requests import request
|
||||
from typing import Any, TypeAlias
|
||||
|
||||
from tiddl.core.auth.exceptions import AuthClientError
|
||||
|
||||
log = logging.getLogger("tiddl")
|
||||
|
||||
|
||||
def get_auth_credentials() -> tuple[str, str]:
|
||||
ENV_KEY = "TIDDL_AUTH"
|
||||
|
||||
client_id, client_secret = (
|
||||
base64.b64decode(
|
||||
"NE4zbjZRMXg5NUxMNUs3cDtvS09YZkpXMzcxY1g2eGFaMFB5aGdHTkJkTkxsQlpkNEFLS1lvdWdNamlrPQ=="
|
||||
)
|
||||
.decode()
|
||||
.split(";")
|
||||
)
|
||||
|
||||
env_value = environ.get(ENV_KEY, None)
|
||||
|
||||
if env_value:
|
||||
client_id, client_secret = env_value.split(";")
|
||||
|
||||
log.debug(f"{client_id=}, {bool(env_value)=}")
|
||||
|
||||
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()
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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 | str]
|
||||
channelId: int
|
||||
parentId: int
|
||||
acceptedEULA: bool
|
||||
created: int | str
|
||||
updated: int | str
|
||||
facebookUid: Optional[int] = None
|
||||
appleUid: Optional[str] = None
|
||||
googleUid: Optional[str] = None
|
||||
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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,62 @@
|
||||
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 fetch_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}")
|
||||
self.data = b""
|
||||
return b""
|
||||
|
||||
log.debug(f"got cover data of {self.url}")
|
||||
|
||||
self.data = req.content
|
||||
|
||||
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.fetch_data()
|
||||
|
||||
if not self.data:
|
||||
log.debug(f"cover data is empty ({file})")
|
||||
return
|
||||
|
||||
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}")
|
||||
@@ -0,0 +1,210 @@
|
||||
import logging
|
||||
import unicodedata
|
||||
|
||||
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
|
||||
|
||||
|
||||
log = logging.getLogger("tiddl")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Metadata:
|
||||
title: str
|
||||
track_number: str
|
||||
disc_number: str
|
||||
copyright: str | None
|
||||
album_artist: str
|
||||
artists: list[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
|
||||
comment: str = ""
|
||||
|
||||
|
||||
def add_flac_metadata(track_path: Path, metadata: Metadata) -> None:
|
||||
log.debug(f"{track_path=}")
|
||||
|
||||
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,
|
||||
"COMMENT": metadata.comment,
|
||||
}
|
||||
)
|
||||
|
||||
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": ["; ".join(metadata.artists)],
|
||||
"date": metadata.date,
|
||||
"copyright": metadata.copyright or "",
|
||||
"comment": metadata.comment,
|
||||
}
|
||||
)
|
||||
|
||||
if metadata.bpm:
|
||||
mutagen["bpm"] = metadata.bpm
|
||||
|
||||
mutagen.save()
|
||||
|
||||
|
||||
def sort_credits_contributors(
|
||||
entries: list[AlbumItemsCredits.ItemWithCredits.CreditsEntry],
|
||||
):
|
||||
"""
|
||||
Sorts the contributors within each CreditsEntry alphabetically by surname.
|
||||
|
||||
It assumes the surname is the last word in the contributor's name.
|
||||
"""
|
||||
|
||||
def get_surname(name: str) -> str:
|
||||
parts = name.split()
|
||||
return parts[-1] if parts else ""
|
||||
|
||||
for entry in entries:
|
||||
entry.contributors.sort(
|
||||
key=lambda contributor: get_surname(contributor.name).lower()
|
||||
)
|
||||
|
||||
|
||||
def normalize_credits_keys(
|
||||
entries: list[AlbumItemsCredits.ItemWithCredits.CreditsEntry],
|
||||
) -> None:
|
||||
valid_entries: list[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = []
|
||||
|
||||
for entry in entries:
|
||||
try:
|
||||
raw_key = entry.type.upper()
|
||||
|
||||
safe_key = (
|
||||
# NFKD splits accented chars (É → E + combining accent),
|
||||
unicodedata.normalize("NFKD", raw_key)
|
||||
.encode("ascii", "ignore")
|
||||
.decode("ascii")
|
||||
.replace("=", "")
|
||||
.strip()
|
||||
)
|
||||
|
||||
entry.type = safe_key
|
||||
|
||||
if safe_key:
|
||||
valid_entries.append(entry)
|
||||
|
||||
except Exception as e:
|
||||
log.debug(f"Skipping invalid credit tag '{entry.type}': {e}")
|
||||
|
||||
# replace the contents of the original list
|
||||
entries[:] = valid_entries
|
||||
|
||||
|
||||
def add_track_metadata(
|
||||
path: Path,
|
||||
track: Track,
|
||||
date: str = "",
|
||||
album_artist: str = "",
|
||||
lyrics: str = "",
|
||||
cover_data: bytes | None = None,
|
||||
credits_contributors: (
|
||||
list[AlbumItemsCredits.ItemWithCredits.CreditsEntry] | None
|
||||
) = None,
|
||||
comment: str = "",
|
||||
) -> None:
|
||||
"""Add FLAC or M4A metadata based on file extension."""
|
||||
|
||||
if credits_contributors is None:
|
||||
credits_contributors = []
|
||||
|
||||
sort_credits_contributors(credits_contributors)
|
||||
normalize_credits_keys(credits_contributors)
|
||||
|
||||
metadata = Metadata(
|
||||
title=f"{track.title} ({track.version})" if track.version else track.title,
|
||||
track_number=str(track.trackNumber),
|
||||
disc_number=str(track.volumeNumber),
|
||||
copyright=track.copyright,
|
||||
album_artist=album_artist,
|
||||
artists=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_contributors,
|
||||
comment=comment,
|
||||
)
|
||||
|
||||
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}")
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
from typing import Literal
|
||||
|
||||
from tiddl.core.api.models import StreamVideoQuality, TrackQuality
|
||||
|
||||
TRACK_QUALITY_LITERAL = Literal["low", "normal", "high", "max"]
|
||||
VIDEO_QUALITY_LITERAL = Literal["sd", "hd", "fhd"]
|
||||
|
||||
track_qualities: dict[TRACK_QUALITY_LITERAL, TrackQuality] = {
|
||||
"low": "LOW",
|
||||
"normal": "HIGH",
|
||||
"high": "LOSSLESS",
|
||||
"max": "HI_RES_LOSSLESS",
|
||||
}
|
||||
|
||||
video_qualities: dict[VIDEO_QUALITY_LITERAL, StreamVideoQuality] = {
|
||||
"sd": "LOW",
|
||||
"hd": "MEDIUM",
|
||||
"fhd": "HIGH",
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,85 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FFmpegError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def run(cmd: list[str]) -> subprocess.CompletedProcess:
|
||||
"""Run a process; raise `FFmpegError` on non-zero exit with stderr."""
|
||||
# Force UTF-8 encoding to prevent UnicodeDecodeError on Windows
|
||||
r = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace" # Added as a safety net
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise FFmpegError(
|
||||
f"{cmd[0]} failed (rc={r.returncode}): {r.stderr.strip()}"
|
||||
)
|
||||
return r
|
||||
|
||||
|
||||
def is_ffmpeg_installed() -> bool:
|
||||
"""Checks if `ffmpeg` is installed."""
|
||||
|
||||
try:
|
||||
run(["ffmpeg", "-version"])
|
||||
return True
|
||||
except (FileNotFoundError, FFmpegError):
|
||||
return False
|
||||
|
||||
|
||||
def _probe_audio_codec(source: Path) -> str:
|
||||
"""Return first audio stream's codec_name, or "" if ffprobe is unavailable."""
|
||||
try:
|
||||
r = run([
|
||||
"ffprobe", "-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_name",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
str(source),
|
||||
])
|
||||
return r.stdout.strip()
|
||||
except (FileNotFoundError, FFmpegError):
|
||||
return ""
|
||||
|
||||
|
||||
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:
|
||||
"""
|
||||
Extract FLAC audio from an MP4 container.
|
||||
|
||||
Tidal can serve AAC-in-MP4 for tracks without a lossless master, so the
|
||||
input may not actually contain FLAC.
|
||||
"""
|
||||
|
||||
codec = _probe_audio_codec(source)
|
||||
if codec and codec != "flac":
|
||||
target = source.with_suffix(".m4a")
|
||||
if target != source:
|
||||
source.replace(target)
|
||||
return target
|
||||
|
||||
target = source.with_suffix(".flac")
|
||||
tmp = source.with_suffix(".tmp.flac")
|
||||
|
||||
run(["ffmpeg", "-y", "-i", str(source), "-c", "copy", str(tmp)])
|
||||
|
||||
tmp.replace(target)
|
||||
if source != target and source.exists():
|
||||
source.unlink()
|
||||
|
||||
return target
|
||||
@@ -0,0 +1,235 @@
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from tiddl.core.api.models import Track, Video, Album, Playlist
|
||||
from tiddl.core.utils.sanitize import sanitize_string
|
||||
|
||||
|
||||
def _clean_segment(text: str) -> str:
|
||||
"""
|
||||
Clean a single path segment using sanitize_string plus extra rules
|
||||
to keep it safe for Windows / NAS filesystems.
|
||||
|
||||
- Uses sanitize_string for base cleanup.
|
||||
- Collapses multiple dots ("..", "...") into a single dot.
|
||||
- Removes trailing dots and spaces (Windows forbids them).
|
||||
- Collapses multiple spaces into one.
|
||||
- Ensures the segment is never empty (uses "_" as fallback).
|
||||
"""
|
||||
|
||||
text = sanitize_string(text)
|
||||
text = re.sub(r"\.{2,}", ".", text)
|
||||
text = text.rstrip(" .")
|
||||
text = re.sub(r"\s{2,}", " ", text)
|
||||
text = text.strip()
|
||||
|
||||
return text or "_"
|
||||
|
||||
|
||||
class Explicit:
|
||||
def __init__(self, value: bool | None):
|
||||
self.value = value
|
||||
|
||||
def __format__(self, format_spec: str):
|
||||
if self.value is None:
|
||||
return ""
|
||||
|
||||
features = format_spec.split("; ")
|
||||
|
||||
def get_base():
|
||||
for feature in features:
|
||||
match feature:
|
||||
case "long":
|
||||
return "explicit" if self.value else ""
|
||||
case "full":
|
||||
return "explicit" if self.value else "clean"
|
||||
|
||||
return "E" if self.value else ""
|
||||
|
||||
base = get_base()
|
||||
|
||||
for feature in features:
|
||||
match feature:
|
||||
case "upper":
|
||||
return base.upper()
|
||||
|
||||
return base
|
||||
|
||||
|
||||
class UserFormat:
|
||||
def __init__(self, value: bool) -> None:
|
||||
self.value = value
|
||||
|
||||
def __format__(self, format_spec: str) -> str:
|
||||
return format_spec if self.value is True else ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AlbumTemplate:
|
||||
id: int = 0
|
||||
title: str = ""
|
||||
artist: str = ""
|
||||
artists: str = ""
|
||||
date: datetime = datetime.min
|
||||
explicit: Explicit = field(default_factory=lambda: Explicit(None))
|
||||
master: UserFormat = field(default_factory=lambda: UserFormat(False))
|
||||
release: str = ""
|
||||
|
||||
|
||||
@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
|
||||
explicit: Explicit
|
||||
dolby: UserFormat
|
||||
|
||||
|
||||
@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,
|
||||
quality: str = "",
|
||||
) -> 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 ""
|
||||
dolby = UserFormat("DOLBY_ATMOS" in item.mediaMetadata.tags)
|
||||
else: # Video
|
||||
version = ""
|
||||
copyright_ = ""
|
||||
bpm = 0
|
||||
isrc = ""
|
||||
dolby = UserFormat(False)
|
||||
|
||||
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),
|
||||
explicit=Explicit(getattr(item, "explicit", None)),
|
||||
dolby=dolby,
|
||||
)
|
||||
|
||||
album_template = AlbumTemplate()
|
||||
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 or datetime.min,
|
||||
explicit=Explicit(getattr(album, "explicit", None)),
|
||||
master=UserFormat(
|
||||
"HIRES_LOSSLESS" in album.mediaMetadata.tags and quality == "MAX"
|
||||
),
|
||||
release=album.type,
|
||||
)
|
||||
|
||||
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,
|
||||
quality: str = "",
|
||||
with_asterisk_ext: bool = True,
|
||||
**extra,
|
||||
) -> str:
|
||||
"""
|
||||
Raises `AttributeError` on invalid template.
|
||||
"""
|
||||
|
||||
custom_fields = {"now": datetime.now()}
|
||||
|
||||
data = (
|
||||
generate_template_data(
|
||||
item=item,
|
||||
album=album,
|
||||
playlist=playlist,
|
||||
playlist_index=playlist_index,
|
||||
quality=quality,
|
||||
)
|
||||
| extra
|
||||
| custom_fields
|
||||
)
|
||||
|
||||
segments: list[str] = []
|
||||
|
||||
for raw_segment in template.split("/"):
|
||||
formatted = raw_segment.format(**data)
|
||||
cleaned = _clean_segment(formatted)
|
||||
segments.append(cleaned)
|
||||
|
||||
path = "/".join(segments)
|
||||
|
||||
if with_asterisk_ext:
|
||||
path += ".*"
|
||||
|
||||
return path
|
||||
@@ -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}")
|
||||
@@ -1,18 +1,15 @@
|
||||
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
|
||||
|
||||
DOLBY_CODECS = ["eac3", "ac4"]
|
||||
|
||||
|
||||
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 +50,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,44 +76,23 @@ 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"
|
||||
if track_stream.audioQuality == "HI_RES_LOSSLESS":
|
||||
file_extension = ".m4a"
|
||||
elif codecs.startswith("mp4"):
|
||||
elif codecs.startswith("mp4") or codecs in DOLBY_CODECS:
|
||||
file_extension = ".m4a"
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown codecs `{codecs}` (trackId {track_stream.trackId}"
|
||||
)
|
||||
raise ValueError(f"Unknown codecs `{codecs}` (trackId {track_stream.trackId}")
|
||||
|
||||
return urls, file_extension
|
||||
|
||||
|
||||
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]
|
||||
@@ -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)
|
||||
@@ -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})"
|
||||
@@ -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"
|
||||
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(
|
||||
[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}")
|
||||
@@ -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
|
||||
@@ -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()}
|
||||
-230
@@ -1,230 +0,0 @@
|
||||
import re
|
||||
import os
|
||||
import logging
|
||||
|
||||
from ffmpeg import FFmpeg
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
if not resource_id.isdigit() and resource_type != "playlist":
|
||||
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 formatTrack(
|
||||
template: str,
|
||||
track: Track,
|
||||
album_artist="",
|
||||
playlist_title="",
|
||||
playlist_index=0,
|
||||
) -> str:
|
||||
artist = sanitizeString(track.artist.name) if track.artist else ""
|
||||
features = [
|
||||
sanitizeString(track_artist.name)
|
||||
for track_artist in track.artists
|
||||
if track_artist.name != artist
|
||||
]
|
||||
|
||||
track_dict = {
|
||||
"id": str(track.id),
|
||||
"title": sanitizeString(track.title),
|
||||
"version": sanitizeString(track.version or ""),
|
||||
"artist": artist,
|
||||
"artists": ", ".join(features + [artist]),
|
||||
"features": ", ".join(features),
|
||||
"album": sanitizeString(track.album.title),
|
||||
"number": track.trackNumber,
|
||||
"disc": track.volumeNumber,
|
||||
"date": (track.streamStartDate if track.streamStartDate else ""),
|
||||
# i think we can remove year as we are able to format date
|
||||
"year": track.streamStartDate.strftime("%Y")
|
||||
if track.streamStartDate
|
||||
else "",
|
||||
"playlist": sanitizeString(playlist_title),
|
||||
"bpm": track.bpm or "",
|
||||
"quality": QUALITY_TO_ARG[track.audioQuality],
|
||||
"album_artist": sanitizeString(album_artist),
|
||||
"playlist_number": playlist_index or 0,
|
||||
}
|
||||
|
||||
formatted_track = template.format(**track_dict).strip()
|
||||
|
||||
disallowed_chars = r'[\\:"*?<>|]+'
|
||||
invalid_chars = re.findall(disallowed_chars, formatted_track)
|
||||
|
||||
if invalid_chars:
|
||||
raise ValueError(
|
||||
f"Template '{template}' and formatted track '{formatted_track}' contains disallowed characters: {' '.join(sorted(set(invalid_chars)))}"
|
||||
)
|
||||
|
||||
return formatted_track
|
||||
|
||||
|
||||
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(features + [artist]),
|
||||
"features": ", ".join(features),
|
||||
"album": sanitizeString(resource.album.title 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 trackExists(
|
||||
track_quality: TrackQuality, download_quality: TrackQuality, file_name: Path
|
||||
):
|
||||
"""
|
||||
Predict track extension and check if track file exists.
|
||||
"""
|
||||
|
||||
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.exists()
|
||||
|
||||
|
||||
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))
|
||||
|
||||
if extension == source_file.suffix:
|
||||
return source_file
|
||||
|
||||
ffmpeg_args = {"loglevel": "error"}
|
||||
|
||||
if copy_audio:
|
||||
ffmpeg_args["c:a"] = "copy"
|
||||
|
||||
if is_video:
|
||||
ffmpeg_args["c:v"] = "copy"
|
||||
|
||||
(
|
||||
FFmpeg()
|
||||
.option("y")
|
||||
.input(url=str(source_file))
|
||||
.output(url=str(output_file), options=None, **ffmpeg_args)
|
||||
).execute()
|
||||
|
||||
if remove_source:
|
||||
os.remove(source_file)
|
||||
|
||||
return output_file
|
||||
Reference in New Issue
Block a user