Compare commits

..

167 Commits

Author SHA1 Message Date
Oskar Dudziński 05d63d153e Added ac4 codecs support 2026-06-09 20:39:43 +02:00
Oskar Dudziński 04de8e677c 🐛 Fixed Auth errors (#374)
* 🐛 Fix core Auth API

* 🔖 Bump version to 3.4.3
2026-05-10 13:22:24 +02:00
Oskar Dudziński d0d146b87f 🔖 Bump version to 3.4.2 2026-05-10 13:18:00 +02:00
mvpetrico 77e488ff30 🐛 Fix core API errors (#367) 2026-05-10 13:16:48 +02:00
Oskar Dudziński 459d5a50b9 Added audio mode filter (Dolby Atmos or Stereo) (#363)
* prepare dolby atmos config

* add audio mode filter logic
2026-05-06 00:57:46 +02:00
Oskar Dudziński ee160fc5bc 📝 Added update instruction, removed depracated section 2026-05-05 21:22:52 +02:00
Oskar Dudziński 1a78d875fa 📝 Added Python 3.15 2026-05-05 21:13:48 +02:00
Oskar Dudziński b0ed7bd208 ♻️ Formatting 2026-05-05 21:13:29 +02:00
Oskar Dudziński e45628e15f 🚀 Bump version to 3.4.1 2026-05-05 20:57:05 +02:00
Oskar Dudziński d9c8984dfa 🚀 Bump version to 3.4.1 2026-05-05 20:56:47 +02:00
Oskar Dudziński c285be6ed2 Add missing auth command tests 2026-05-04 22:02:00 +02:00
Oskar Dudziński 5e4f9bdb6a 🚀 Bump to 3.4.0 2026-05-04 21:42:34 +02:00
Oskar Dudziński a282c1a4af Show audio type in search command 2026-05-04 21:35:26 +02:00
Oskar Dudziński 46a6e748da 🐛 Fix #360 2026-05-04 21:34:36 +02:00
Oskar Dudziński bf8ded5f60 Show audio mode in CLI while downloading (Dolby Atmos) 2026-05-04 01:46:26 +02:00
Oskar Dudziński 7e0fb9fb37 🚀 Bump to 3.4.0a8 2026-05-03 20:45:00 +02:00
Oskar Dudziński 880f6008b0 🐛 Fixed playlist cover saving (#358)
* add proper template for playlist covers

* cover size is now properly limited

* dont save cover if cover data is empty
2026-05-03 20:43:36 +02:00
Oskar Dudziński 0f9a4006f1 Bump to 3.4.0a7 2026-05-03 00:38:57 +02:00
Oskar Dudziński 3cfadd7795 🐛 Fixed album date format 2026-05-03 00:38:12 +02:00
Oskar Dudziński 47975e12bc 📢 Log tiddl version 2026-05-02 18:19:23 +02:00
Oskar Dudziński fbb32e735d 📝 Added version info to CLI 2026-05-02 18:09:15 +02:00
Oskar Dudziński 3cba05910b 📢 Log client id and if it was loaded from env 2026-05-02 17:35:52 +02:00
Oskar Dudziński c22cb2941d 📢 Log stream data 2026-05-02 17:31:15 +02:00
Oskar Dudziński 6b82c40fae Added Dolby Atmos support (needs testing) (#348)
* add dolby atmos support

* 🚀 Bump version to 3.4.0a5
2026-04-30 01:14:32 +02:00
Oskar Dudziński 9abf141411 🐛 Fixed missing releaseDate in albums (fix #260) 2026-04-29 14:00:43 +02:00
Oskar Dudziński 477b4b4635 🐛 Files are no longer written with restrictive permissions (#347)
* add chmod mask after file download (fix #265)

* 🚀 Bump version to 3.4.0a4
2026-04-29 13:49:44 +02:00
Oskar Dudziński debca2fc1d 🚀 Bump version from 3.4.0a2 to 3.4.0a3 2026-04-28 22:46:43 +02:00
Oskar Dudziński d830a8ed73 Updated auth credentials (#344)
* fix search command error

* update auth credentials

* log debug path at `add_flac_metadata`

* extract flac from high and max quality

* add --force option to auth logout
2026-04-28 22:46:08 +02:00
Oskar Dudziński 33b1e6c826 🔥 Removed unused print 2026-04-28 21:30:16 +02:00
Oskar Dudziński e32fde7794 🚀 Bump version from 3.4.0a1 to 3.4.0a2 2026-04-28 21:28:09 +02:00
Oskar Dudziński 99804c0304 🐛 Credits entries in metadata are now safe (#343) 2026-04-28 21:27:31 +02:00
Oskar Dudziński 410146bdcf ♻️ Refactored search command 2026-04-28 16:49:36 +02:00
Reyth 859d50772d Added search command (#315)
* feat: search command

* fix: top match resource type

* fix: top hit resource type parsing logic

* fix: SearchArtist as non-nested class
2026-04-28 16:43:04 +02:00
Reyth 79c21f7842 🐛 Fixed album/playlist validation errors (#338) 2026-04-25 16:03:53 +02:00
Oskar Dudziński 0f76845c35 🚀 Bump version to 3.4.0a1 2026-04-25 00:55:38 +02:00
Piotr Karbowski b1e28a8ae6 Added match_existing_path_case option (#335)
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".

Co-authored-by: Piotr Karbowski <git.throwaway941@simplelogin.com>
2026-04-25 00:55:02 +02:00
Francesco 658e4a81ab 🐛 Stream codec is now honored when picking track file extension (#336) 2026-04-25 00:35:45 +02:00
Oskar Dudziński 4b6b23225a 🚀 Bump version from 3.2.3 to 3.3.0 2026-04-23 23:03:02 +02:00
Mijael Viricochea ed9a05c666 Added option to write LRC file (#308) 2026-04-23 22:59:38 +02:00
xiliourt 8a2c30feaf 🐬 Remove hidden directories in Dockerfile (#326)
* Update Dockerfile

* Clears entire directory after install

Hidden folders such as .git, .vscode, etc were remaining in the docker build unintentionally. This update clears all files once tiddl is installed.
2026-04-13 22:35:38 +02:00
Oskar Dudziński cda1dc6a7a 🐛 Fixed missing metadata when downloading a single track 2026-04-11 17:04:17 +02:00
Oskar Dudziński 7de23cee1b Bump version from 3.2.3a1 to 3.2.3 2026-04-11 11:27:38 +02:00
Oskar Dudziński 89e4d5c08e ♻️ Skipping errors is now a default behaviour 2026-04-09 20:57:04 +02:00
Oskar Dudziński a1deba92cc Add tiddl version field to bug report template
Added a field for specifying the installed tiddl version.
2026-04-08 12:55:49 +02:00
Oskar Dudziński 0b11c63eba 📝 Added link fallbacks in main cli call 2026-04-06 19:04:33 +02:00
Oskar Dudziński fc074543d1 Bump version to 3.2.3a1 2026-04-06 09:44:21 +02:00
Magnetkopf d9e2314447 Added no browser opening option for authenticating command (#317)
* feat(auth): add no browser mode

* chore: merge print statements
2026-04-06 09:42:51 +02:00
Magnetkopf c3dd2d0606 Artist tag is now a list (#316) 2026-04-05 10:54:46 +02:00
xoconoch 401313cd27 Changed artist separator to ";"
* chore: change separator to "; "

* chore: finishing changin separators

* chore: continue changing separators

---------

Co-authored-by: Ohjne <er@le.com>
2026-04-05 10:48:16 +02:00
Oskar Dudziński b6ddd6b64e Bump version from 3.2.1 to 3.2.2 2026-03-08 07:53:39 +01:00
nikudaorg 3948c79412 🐛 Fixed auth errors (#300)
Resolves issue #299.

int | str is used instead of updating the type to only "str" because the API change appears to have been introduced quietly and may revert in the future. Since the exact type of these fields is not critical for the library, supporting both types provides a safer and more resilient approach.
2026-03-08 07:53:05 +01:00
Oskar Dudziński ee7e079a27 🚀 Bump version from 3.2.0 to 3.2.1 2026-02-26 14:42:48 +01:00
TooYoungTooSimp 3d1314e198 Enable trust_env in aiohttp.ClientSession (#294) 2026-02-26 14:41:56 +01:00
Filip Voska cf0d1cd362 fix: handle null/missing fields in Video API responses (#295)
* fix: handle null/missing fields in Video API responses

Tidal's API returns some Video objects (lyric/visualiser videos on
artist pages) with fields that don't match the current strict models:

- `imageId` can be null instead of a string
- The nested `album` object can be present but missing `id`, `title`,
  and `cover`

These validation failures cause the entire `ArtistVideosItems` page
to be rejected by Pydantic before any video can be parsed, resulting
in 0 downloads when targeting an artist with `--videos`.

A second independent bug causes an `AttributeError` on every video:
the default template `{album.artist}/{album.title}/{item.title}` is
shared with videos, but many videos have no album. When `album=None`
is passed to `format_template`, Python's `str.format()` evaluates
`None.artist` and raises `AttributeError: 'NoneType' object has no
attribute 'artist'`, which is caught and printed as an error for
every single video.

Fix:
- `resources.py`: make `Video.imageId` and `Video.Album.{id,title,
  cover}` optional so incomplete API responses pass validation
- `format.py`: give `AlbumTemplate` field defaults so it can be
  instantiated empty; use `AlbumTemplate()` as fallback instead of
  `None` when no album is present, so `{album.*}` tokens render as
  empty strings rather than raising AttributeError
- `download/__init__.py`: guard `video.album.id` accesses against
  `None` (now possible after the model fix) in both video code paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add tests for Video model null fields and AlbumTemplate fallback

Covers the two bugs fixed in the previous commit:

- Video model accepts null/missing imageId and partial album objects
- format_template does not raise AttributeError when album is None
  and the template references {album.*} tokens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 14:35:33 +01:00
Oskar Dudziński 381003097f 🚀 Bump version from 3.1.5 to 3.2.0 2026-01-18 17:38:08 +01:00
hexx.one 06f9d940d9 Added --skip-errors flag to skip unavailable items during download (#274)
* feat: Add `--skip-errors` flag to skip unavailable items during download

* feat: skip-error handling for unavailable items in albums, artists & mixes; fix some missing template variables

* fixes #267
2026-01-18 17:35:54 +01:00
M1chal3k28 fbf79d70e6 📝 Rename 'embed_lyrics' to 'lyrics' in example config (#280) 2026-01-18 17:31:42 +01:00
Oskar Dudziński 9f7d3aec1e 🐛 Album/artist download errors related to templates are now handled (#257)
* refactor variable name in _clean_segment function for clarity

* Add error handling documentation for format_template function

* Add error handling for album template formatting in download_callback
2025-12-05 14:11:47 +01:00
Oskar Dudziński c5d5b365c8 🚀 Bump version from 3.1.4 to 3.1.5 2025-12-03 01:12:23 +01:00
Oskar Dudziński f691c7ba0a 🐛 Fixed cover that can be None. 2025-12-03 01:12:07 +01:00
Oskar Dudziński 82c4ce0c37 🐛 Fixed covers that were fetched unnecessarily #245 (#254)
* fetch cover only when item is about to be downloaded

* rename cover method to `fetch_data`

* 🚀 Bump version from 3.1.4a3 to 3.1.4
2025-12-01 19:05:46 +01:00
Oskar Dudziński 145a776b63 🚀 Bump version from 3.1.4a2 to 3.1.4a3 2025-12-01 01:14:40 +01:00
Oskar Dudziński cf0ef2d304 Sort credits contributors surname alphabetically #247 2025-12-01 01:13:59 +01:00
Oskar Dudziński 2332708326 🚀 Bump version from 3.1.4a1 to 3.1.4a2 2025-11-29 22:59:16 +01:00
Oskar Dudziński cbcb55d14a 🐛 Item's path segments are now cleaned #251 2025-11-29 22:57:10 +01:00
Oskar Dudziński 1480be4aed 🏷️ Make audio properties optional in TrackStream model, closes #252 2025-11-29 19:07:07 +01:00
Oskar Dudziński 789fc6bb4a Bump version to 3.1.4a1 2025-11-24 21:04:19 +01:00
Oskar Dudziński a794fa0673 🐛 Fixed cover fetching 2025-11-24 20:00:35 +01:00
Oskar Dudziński d56398168e Revert " Added album lyrics download to .lrc file (#241)" (#248)
This reverts commit f2ee4f8fad.
2025-11-24 19:49:25 +01:00
Daniele Russo f2ee4f8fad Added album lyrics download to .lrc file (#241)
* adding flag for download lyrics

* adding sum debug info

* adding configuration part

* Update config.example.toml

* refactor: move lyrics to utility and apply code review feedback

* refactor: optimize lyrics download and apply code review feedback

- Pass album_items directly to lyrics function to avoid redundant API calls
- Remove unused Path import from api.py
- Remove duplicate debug logging already covered elsewhere
- Use last_album_items reference instead of fetching data again
- Move lyrics download logic to separate utility function in core.utils.lyrics
- Keep lyrics configuration only in docs/config.example.toml without modifying other settings

This optimization reduces API calls by reusing already fetched album items data instead of making new requests for lyrics download.
2025-11-24 19:25:13 +01:00
Oskar Dudziński b9f586574a 🚀 Bump to 3.1.3 2025-11-23 13:11:09 +01:00
Oskar Dudziński 94db88bd08 🚀 bump to 3.1.3a1 2025-11-22 23:18:18 +01:00
Oskar Dudziński d978e87641 🐛 Fixed invalid track covers when downloading playlist #244 2025-11-22 23:17:41 +01:00
Oskar Dudziński f605c628da 🚀 bump to 3.1.2 2025-11-21 22:17:24 +01:00
Oskar Dudziński 97f3fed079 Added album.release template 2025-11-21 22:16:38 +01:00
Oskar Dudziński 9a73fc33be Merge branch 'main' of https://github.com/oskvr37/tiddl 2025-11-21 22:14:19 +01:00
Oskar Dudziński 22730003df 🐛 Fixed metadata.cover config was not respected 2025-11-21 22:14:17 +01:00
Oskar Dudziński 63e7120060 🚀 Bump to 3.1.2a1 2025-11-21 21:23:13 +01:00
Oskar Dudziński 24ac3f04fc 📝 Added docs about template fields formatting #233 2025-11-21 21:03:54 +01:00
Oskar Dudziński 7a862d34ee Merge branch 'main' of https://github.com/oskvr37/tiddl 2025-11-21 20:49:03 +01:00
Oskar Dudziński aae9781f31 Track metadata title has now version of the track 2025-11-21 20:49:01 +01:00
Oskar Dudziński 24c4fae353 🚀 bump to 3.1.1 2025-11-21 14:14:10 +01:00
Oskar Dudziński c8a8939741 🐛 Fixed {album.master} template (#237)
* fix `predict_item_quality` logic

* update `album.master` template logic

* bump to 3.1a2
2025-11-20 18:13:06 +01:00
Oskar Dudziński 85088e737a 🐛 Fixed video downloading (#235)
* rename `VideoQuality` to `StreamVideoQuality`

* remove bad logic from predicting video quality

* print info when skipping video

* bump to 3.1.1 alpha
2025-11-20 16:13:53 +01:00
Oskar Dudziński 1873d512f1 Added fav subcommand 2025-11-17 17:53:23 +01:00
Oskar Dudziński a57869277f 🚀 bump to 3.1.0 2025-11-17 15:48:08 +01:00
Oskar Dudziński 74e45ef39a 🐛 Fixed album data that was not available while downloading playlists (#226)
* fetch album while handling playlist when there is "{album}" in template

* add note to the docs
2025-11-17 15:46:41 +01:00
Oskar Dudziński 02919e38e6 Added {album.master} template (#225)
* add `MediaMetadataTags` literal

* add `album.master` template

* add docs
2025-11-17 14:40:28 +01:00
Oskar Dudziński 06b1eded7c Added explicit and dolby templates (#223)
* strip formatted parts of path

* handle "None" explicit attribute

* add explicit field to items

* add explicit to template example

* add docs for explicit

* add `dolby` field to items

* add `dolby` docs
2025-11-17 02:58:13 +01:00
Oskar Dudziński 66b9f021ad 🐛 Fixed wrong quality in item.quality template (#222)
* get quality from arg instead from item

* add missing type

* create function that returns proper quality for item quality templating
2025-11-17 02:39:10 +01:00
Oskar Dudziński 7515753297 ♻️ Move quality literals to core 2025-11-16 22:54:17 +01:00
Oskar Dudziński 9a122ec630 🐛 Fixed missing embedded covers #220 2025-11-14 15:07:19 +01:00
Oskar Dudziński 96fb746fd9 New metadata.album_review config setting (#216)
* add `get_album_review` endpoint

* add `metadata.album_review` config option

* add comment to `add_track_metadata`

* move `AlbumReview` model to its own file and clean up imports

* fix API endpoint for fetching album reviews

* add normalized_text method to AlbumReview model for text processing

* add unit test for `normalize_review_text` function and refactor `AlbumReview` model

* add `album_review` to metadata

* update comment

* add comment
2025-11-13 13:38:04 +01:00
Oskar Dudziński e0fe7e1655 🐛 Fixed matching URLs when using url command (#215)
* refactor `from_string` method to improve resource type extraction logic
2025-11-12 23:37:41 +01:00
Oskar Dudziński eb0ae38df8 🐛 Fixed scan_path didnt update with download_path 2025-11-12 22:33:23 +01:00
Oskar Dudziński 46ddd8e4e1 🔊 Log if ffmpeg is installed 2025-11-12 18:18:51 +01:00
Oskar Dudziński f444ef2689 Auth Token is now refreshed mid-request (#213) 2025-11-12 18:09:41 +01:00
Oskar Dudziński 6ffb386990 Update bug report template by removing type field
Removed 'type: bug' from the bug report template.
2025-11-10 18:58:45 +01:00
Oskar Dudziński a567fffc69 Convert bug report template to YAML format
Updated bug report template to YAML format with improved structure and additional fields.
2025-11-10 18:56:51 +01:00
Oskar Dudziński 3b9c089d01 🔊 Added API error log 2025-11-10 18:47:19 +01:00
Oskar Dudziński 68f5e17a1b 🚀 bump to 3.0.1 2025-11-10 18:42:11 +01:00
Oskar Dudziński 0924b4c755 🐛 Other API exceptions are now handled (#212)
* add retry logic for `res.json()`

* add wrapper for handling resources

* lower api limits
2025-11-10 18:39:57 +01:00
Oskar Dudziński 244a4bc07b 🐛 Fixed temp file by: @fff7d1bc in #196 2025-11-10 17:24:14 +01:00
Oskar Dudziński dc2ff4da33 💄 Show item download path 2025-11-10 17:11:54 +01:00
Oskar Dudziński a8e3876dfd 🚑️ Handle API error at get_track_stream (#202) 2025-11-09 20:14:56 +01:00
Oskar Dudziński c7f0836566 🏷️ Update album type literals 2025-11-09 19:22:13 +01:00
Oskar Dudziński a525b676eb Added omit_cache and debug options from config 2025-11-09 17:14:57 +01:00
Oskar Dudziński 5160df3da1 📝 Added link to file templating 2025-11-09 01:45:37 +01:00
Oskar Dudziński dde611de45 🚀 bump to 3.0.1a1 2025-11-08 23:38:20 +01:00
Oskar Dudziński 4c345f8c28 🐛 Fix #196 2025-11-08 23:36:33 +01:00
Oskar Dudziński cd454a4220 🐛 Fix #195 2025-11-08 23:19:57 +01:00
Oskar Dudziński 75572bb043 🚀 bump to 3.0.0 2025-11-08 15:28:53 +01:00
Oskar Dudziński b87888536e tiddl3 (#194) 2025-11-08 15:18:44 +01:00
copey02 c255d42283 FLAC: set cover type to Front (3) and clear existing pictures (#190)
Co-authored-by: SC <SC>
2025-11-05 18:59:53 +01:00
Oskar Dudziński 3fdbdb5500 🐛 Fixed artists metadata 2025-11-05 18:55:47 +01:00
Oskar Dudziński 6d796db94c Revert "🐛 Fix artists order (#189)" (#191)
This reverts commit 11b8b28e81.
2025-11-05 18:53:56 +01:00
Oskar Dudziński 11b8b28e81 🐛 Fix artists order (#189) 2025-11-05 18:39:26 +01:00
Oskar Dudziński 01eeff3cac 🚀 2.8.0 (#182)
* bump version

* remove testing, add venv instructions, formatting

* remove tests and examples
2025-11-04 20:52:56 +01:00
Oskar Dudziński 9387a7f0b5 💸 Created funding.yml
Updated Buy Me a Coffee username to 'oskvr'.
2025-11-03 18:49:12 +01:00
Oskar Dudziński 1613f85978 Download only videos from an artist (#181)
* add `getArtistVideos` api endpoint

* add `--only-video` flag

* bump version
2025-11-03 16:59:54 +01:00
Oskar Dudziński 0bc8802c0e 🚀 bump to 2.7.0 2025-10-28 22:24:48 +01:00
Oskar Dudziński 4217833984 Update modification time on existing items (#178)
* added `update_mtime` to config

* update mtime on files

* update only existing files that wont be redownloaded

* allow updating mtime if `DOWNLOAD_VIDEO` is False

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

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

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

* add instruction for TIDDL_CLIENT

* add base64 for client creds
2025-10-15 22:18:09 +02:00
Oskar Dudziński 40e9198335 📝 Add note about ffmpeg 2025-10-12 17:00:39 +02:00
Oskar Dudziński 0f44f9780a 🚀 bump to 2.6.2 2025-10-02 17:54:49 +02:00
Oskar Dudziński 5d420eeec5 🐛 Fixed M3U config option (#161) 2025-10-02 17:52:46 +02:00
Oskar Dudziński 3053e91134 🚀 bump to 2.6.2a1 2025-10-02 00:17:18 +02:00
Oskar Dudziński 36daea61e0 Fix m3u file saving (#160) 2025-10-02 00:15:39 +02:00
Oskar Dudziński 12a2d4cf5f Added mix downloading 2025-09-29 19:45:05 +02:00
Oskar Dudziński 0c53783497 🚀 bump to 2.6.1 2025-09-25 19:14:17 +02:00
Oskar Dudziński 89a03c829a 🐛 Fixed concurrent playlist download (#159) 2025-09-25 19:12:16 +02:00
Oskar Dudziński 3e2c9373fb 🚀 bump to 2.6.0 2025-09-25 18:52:05 +02:00
Oskar Dudziński 3b12f92bd2 Added config and flag for saving m3u file (#158)
*  Added `save_playlist_m3u` flag

*  Changed scan path flag to `--scan-path`

* ♻️ Refactored, edited logs
2025-09-25 18:51:15 +02:00
Oskar Dudziński bc66861f94 ♻️ Refactor download CLI 2025-09-23 19:49:45 +02:00
Oskar Dudziński 1e1b384f39 🚀 bump to 2.6.0a1 2025-09-22 17:43:45 +02:00
Oskar Dudziński ee6bba1d30 Save playlist to M3U file (#157)
* depracated `trackExists`

* add function `savePlaylistM3U`

* add saving playlist m3u
2025-09-22 17:37:26 +02:00
Oskar Dudziński bea4bf32d0 🎨 Format code 2025-09-22 16:36:21 +02:00
Oskar Dudziński e407d7de41 close #155 (#156) 2025-09-22 16:32:42 +02:00
Oskar Dudziński bf6874d9e7 🚀 bump to 2.5.2 2025-09-07 19:56:54 +02:00
xiliourt 4204a4f6ad Added scan_path setting (#151)
* scan_path optional flag

* scan_path

* Update config.py

* Update __init__.py

---------

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

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

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

Ensures it awaits the return of 'path' before proceeding

* Updated to async convertFileExtension via ffmpeg_asyncio

* Changed to ffmpeg-asyncio dependency

Also requires ffmpeg installed at an OS level

* (Missed a comma)

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

* (Commit so my commit is verified)

---------

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

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

This should fix that logic

* Changed --video to capital -V flag

-v is verbose, I was wondering why verbose wasn't working lol
2025-07-18 16:39:22 +02:00
Oskar Dudziński 34c1b1fd4e 🚀 bump to 2.5.0 2025-07-17 11:23:36 +02:00
xiliourt d85fb96a19 Added video download flag and config (#134) 2025-07-17 11:21:41 +02:00
Oskar Dudziński a4a7e66b84 🚀 bump to 2.4.0 2025-06-03 16:41:10 +02:00
Oskar Dudziński 7258df8ec8 Added embedding lyrics to tracks (#129)
* add lyrics api endpoint

* embed lyrics in metadata

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

* create cover directory before saving

* prepare cover settings

* add cover settings

* add filename setting
2025-06-03 14:50:13 +02:00
Oskar Dudziński a147c94110 🐛 releaseDate can be optional (#127) 2025-05-30 13:07:45 +02:00
Oskar Dudziński 2eb25b81f9 🚀 bump to 2.3.5 2025-05-30 13:04:49 +02:00
Oskar Dudziński 1f1e89a97a 🚀 bump to 2.3.4 2025-05-23 10:41:03 +02:00
Oskar Dudziński f32bab434c 🐛 Fixed incorrect model fields 2025-05-23 10:39:49 +02:00
Oskar Dudziński 13b3c8b03b 🚀 bump to 2.3.3 2025-04-18 19:54:57 +02:00
Oskar Dudziński a2b9f8d5cf 🐛 changed copyright to Optional (#114) 2025-04-18 19:53:56 +02:00
Oskar Dudziński 526c8c5b0e 🐛 Fixed CLI exception at refreshing token 2025-03-20 15:07:06 +01:00
Oskar Dudziński 8e93e4ec9a Added 'TIDDL_PATH' env variable for custom HOME_PATH (#109) 2025-03-20 14:28:39 +01:00
oskvr37 a5a039f6a8 🎨 get rid of relative imports 2025-03-19 23:25:04 +01:00
97 changed files with 5609 additions and 2360 deletions
+15
View File
@@ -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']
-22
View File
@@ -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]
+59
View File
@@ -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
+39
View File
@@ -0,0 +1,39 @@
name: Push Docker Image to ghcr.io
# Run when release is published
on:
release:
types: [published]
workflow_dispatch: # Allow for manual push so I can test it
jobs:
build:
runs-on: ubuntu-latest
# Minimum required permissions
permissions:
contents: read
packages: write
steps:
# Checkout code
- name: Checkout code
uses: actions/checkout@v3
# Login to ghcr (automatically uses workflow actor and secret)
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Pushes to both :latest and :<versionTag>
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/tiddl:${{ github.event.release.tag_name }}
ghcr.io/${{ github.repository_owner }}/tiddl:latest
+17 -31
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,20 @@
# --- Optimised Layer Caching --- #
# Layer 1 (ffmpeg) will never regenerate
# Layer 2 (pip install) will regenerate if pyproject.toml is changed
# Layer 3 (build & install tiddl), rengerates on any code change
FROM python:alpine
WORKDIR /root
# -- Layer 1 - ffmpeg install (it'll stay cached as a layer always) --
RUN apk add --no-cache ffmpeg
# -- Layer 2 - pip install depenencies (remains cached unless pyproject.toml changes) --
# Exports 'depenencies' from pyproject.toml formatted to requirements.txt format, pipelined to pip install
COPY pyproject.toml .
RUN python -c "import tomllib; f=open('pyproject.toml','rb'); print('\n'.join(tomllib.load(f)['project']['dependencies']))" | xargs pip install
# -- Layer 3 - Uncached layer (regenerates anytime a new build is released) --
COPY . .
RUN pip install --no-deps .
RUN rm -rf -- ..?* .[!.]* *
+122 -59
View File
@@ -1,97 +1,156 @@
# Tidal Downloader
Download tracks and videos from Tidal with max quality! `tiddl` is CLI app written in Python.
> [!WARNING]
> `This app is for personal use only and is not affiliated with Tidal. Users must ensure their use complies with Tidal's terms of service and local copyright laws. Downloaded tracks are for personal use and may not be shared or redistributed. The developer assumes no responsibility for misuse of this app.`
![PyPI - Downloads](https://img.shields.io/pypi/dm/tiddl?style=for-the-badge&color=%2332af64)
![PyPI - Version](https://img.shields.io/pypi/v/tiddl?style=for-the-badge)
![GitHub commits since latest release](https://img.shields.io/github/commits-since/oskvr37/tiddl/latest?style=for-the-badge)
[<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.
For example, setting output flag to `"{album.artist}/{album.title}/{item.number:02d}. {item.title}"`
will download tracks like following:
```
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:
```sh
TIDDL_PATH=~/custom/tiddl tiddl auth login
```
### Auth stopped working?
Set `TIDDL_AUTH` environment variable to use another credentials.
TIDDL_AUTH=<CLIENT_ID>;<CLIENT_SECRET>
# Development
@@ -99,20 +158,24 @@ 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)
+163
View File
@@ -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}"
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

+151
View File
@@ -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)
-137
View File
@@ -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()
+37
View File
@@ -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
View File
@@ -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)
+47
View File
@@ -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`
+26
View File
@@ -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
View File
@@ -1,13 +1,13 @@
[build-system]
requires = ["setuptools>=42", "wheel"]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "tiddl"
version = "2.3.2"
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",
]
+245
View File
@@ -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__
+13
View File
@@ -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
+77
View File
@@ -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)
+20
View 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
+38
View File
@@ -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")
+85
View File
@@ -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"
+206
View File
@@ -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,
)
+93
View File
@@ -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")
+38
View File
@@ -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)
+8
View File
@@ -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
+105
View File
@@ -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")
+128
View File
@@ -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"},
)
+41
View File
@@ -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}"
+103
View File
@@ -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 == ""
-88
View File
@@ -1,88 +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)
if __name__ == "__main__":
unittest.main()
-139
View File
@@ -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()
-253
View File
@@ -1,253 +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,
)
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 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,
)
-80
View File
@@ -1,80 +0,0 @@
import logging
from requests import request
from .exceptions import AuthError
from .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 -74
View File
@@ -1,79 +1,15 @@
import click
import logging
from rich.logging import RichHandler
from tiddl.cli.const import APP_PATH
from .ctx import ContextObj, passContext, Context
from .auth import AuthGroup
from .download import UrlGroup, FavGroup, SearchGroup, FileGroup
from .config import ConfigCommand
from tiddl.config import HOME_PATH
from .auth import refresh
@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)
+61
View File
@@ -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)[/]"
)
-118
View File
@@ -1,118 +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 .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.")
refresh(ctx)
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!")
+16
View File
@@ -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)
+161
View File
@@ -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!")
+755
View File
@@ -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)
+251
View File
@@ -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
+103
View File
@@ -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}")
+40
View File
@@ -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)
+45
View File
@@ -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}")
+144
View File
@@ -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
+29
View File
@@ -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)
+114 -45
View File
@@ -1,55 +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.const import APP_PATH
from tiddl.core.utils.const import TRACK_QUALITY_LITERAL, VIDEO_QUALITY_LITERAL
from .ctx import Context, passContext
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=}")
+23
View File
@@ -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
View File
@@ -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
-413
View File
@@ -1,413 +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 typing import List, Union
from .fav import FavGroup
from .file import FileGroup
from .search import SearchGroup
from .url import UrlGroup
from ..ctx import Context, passContext
@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.",
)
@passContext
def DownloadCommand(
ctx: Context,
QUALITY: TrackArg | None,
TEMPLATE: str | None,
PATH: str | None,
THREADS_COUNT: int,
DO_NOT_SKIP: bool,
SINGLES_FILTER: SinglesFilter,
):
"""Download resources"""
SINGLES_FILTER = SINGLES_FILTER or ctx.obj.config.download.singles_filter
# TODO: pretty print
logging.debug(
(QUALITY, TEMPLATE, PATH, THREADS_COUNT, DO_NOT_SKIP, SINGLES_FILTER)
)
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
try:
addMetadata(
path, item, cover_data, credits, album_artist=album_artist
)
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():
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_data = Cover(album.cover).content if album.cover else b""
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,
)
submitItem(
item.item,
filename,
cover_data,
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)
-46
View File
@@ -1,46 +0,0 @@
import click
from tiddl.utils import TidalResource, ResourceTypeLiteral
from ..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}")
-40
View File
@@ -1,40 +0,0 @@
import click
import json
from io import TextIOWrapper
from os.path import splitext
from ..ctx import Context, passContext
from tiddl.utils import TidalResource
@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"))
-49
View File
@@ -1,49 +0,0 @@
import click
from tiddl.utils import TidalResource
from tiddl.models.resource import Artist, Album, Playlist, Track, Video
from ..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)
-27
View File
@@ -1,27 +0,0 @@
import click
from ..ctx import Context, passContext
from tiddl.utils import TidalResource
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)
+5
View File
@@ -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"]
+31
View File
@@ -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())
+9
View File
@@ -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
+26
View File
@@ -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
+38
View File
@@ -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
+55
View File
@@ -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}"
-54
View File
@@ -1,54 +0,0 @@
# 3.0 TODO: change config path to ~/.config/tiddl.json
from pydantic import BaseModel
from pathlib import Path
from tiddl.models.constants import TrackArg, SinglesFilter
HOME_PATH = Path.home()
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"
class AuthConfig(BaseModel):
token: str = ""
refresh_token: str = ""
expires: int = 0
user_id: str = ""
country_code: str = ""
class Config(BaseModel):
template: TemplateConfig = TemplateConfig()
download: DownloadConfig = DownloadConfig()
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
+5
View File
@@ -0,0 +1,5 @@
from .api import TidalAPI
from .client import TidalClient
from .exceptions import ApiError
__all__ = ["TidalAPI", "TidalClient", "ApiError"]
+255
View File
@@ -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,
)
+144
View File
@@ -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)
+8
View File
@@ -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}"
+43
View File
@@ -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,17 +1,15 @@
from typing import List, Literal, Optional, Union
from pydantic import BaseModel
from typing import Optional, List, Literal, Union
from .resource import Album, Artist, Playlist, Track, TrackQuality, Video
__all__ = [
"SessionResponse",
"ArtistAlbumsItems",
"AlbumItems",
"PlaylistItems",
"Favorites",
"TrackStream",
"Search",
]
from .resources import (
Album,
Playlist,
StreamVideoQuality,
Track,
TrackQuality,
Video,
)
class SessionResponse(BaseModel):
@@ -39,6 +37,10 @@ class ArtistAlbumsItems(Items):
items: List[Album]
class ArtistVideosItems(Items):
items: List[Video]
ItemType = Literal["track", "video"]
@@ -93,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"
@@ -101,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]
@@ -112,17 +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"
]
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
@@ -131,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]
@@ -160,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
@@ -169,3 +186,13 @@ class Search(BaseModel):
tracks: Tracks
videos: Videos
topHit: Optional[TopHit] = None
class TrackLyrics(BaseModel):
isRightToLeft: bool
lyrics: str
lyricsProvider: str
providerCommontrackId: str
providerLyricsId: str
subtitles: str
trackId: int
@@ -1,10 +1,14 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional, List, Literal, Dict
from .constants import TrackQuality
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel
__all__ = ["Track", "Video", "Album", "Playlist", "Artist"]
TrackQuality = Literal["LOW", "HIGH", "LOSSLESS", "HI_RES_LOSSLESS"]
# audio_only is not stable
StreamVideoQuality = Literal["AUDIO_ONLY", "LOW", "MEDIUM", "HIGH"]
MediaMetadataTags = Literal["LOSSLESS", "HIRES_LOSSLESS", "DOLBY_ATMOS"]
class Track(BaseModel):
@@ -22,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
@@ -46,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
@@ -63,10 +69,10 @@ class Video(BaseModel):
picture: Optional[str] = None
class Album(BaseModel):
id: int
title: str
cover: str
vibrantColor: str
id: Optional[int] = None
title: Optional[str] = None
cover: Optional[str] = None
vibrantColor: Optional[str] = None
videoCover: Optional[str] = None
id: int
@@ -75,10 +81,10 @@ class Video(BaseModel):
trackNumber: int
streamStartDate: Optional[datetime] = None
imagePath: Optional[str] = None
imageId: str
vibrantColor: str
imageId: Optional[str] = None
vibrantColor: Optional[str] = None
duration: int
quality: str
quality: Literal["MP4_1080P"] | str
streamReady: bool
adSupportedStreamReady: bool
djReady: bool
@@ -104,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
@@ -119,21 +125,22 @@ class Album(BaseModel):
numberOfTracks: int
numberOfVideos: int
numberOfVolumes: int
releaseDate: str
copyright: str
type: str
releaseDate: datetime | None = None
copyright: Optional[str] = None
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]
@@ -146,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
@@ -156,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
@@ -181,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
+30
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
from .api import AuthAPI
from .exceptions import AuthClientError
__all__ = ["AuthAPI", "AuthClientError"]
+26
View File
@@ -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)
+101
View File
@@ -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()
+17
View File
@@ -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}"
)
+52
View File
@@ -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
+5
View File
@@ -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"]
+62
View File
@@ -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}")
+210
View File
@@ -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}")
+31
View File
@@ -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)
+11
View File
@@ -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",
]
+19
View File
@@ -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",
}
+39
View File
@@ -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
+85
View File
@@ -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
+235
View File
@@ -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
+38
View File
@@ -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}")
+23 -39
View File
@@ -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]
+12
View File
@@ -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)
-21
View File
@@ -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})"
-189
View File
@@ -1,189 +0,0 @@
import logging
import requests
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="",
):
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
]
elif extension == ".m4a":
if cover_data:
metadata = MutagenMP4(track_path)
metadata["covr"] = [
MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG)
]
metadata.save(track_path)
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):
if not self.content:
logger.error("cover file content is empty")
return
file = directory_path / "cover.jpg"
if file.exists():
logger.debug(f"cover already exists ({file})")
return
try:
with file.open("wb") as f:
f.write(self.content)
except FileNotFoundError as e:
logger.error(f"could not save cover. {file} -> {e}")
-53
View File
@@ -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[str]
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
-14
View File
@@ -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
View File
@@ -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