├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── publish.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── docs └── demo.gif ├── examples ├── concurrent_download_rich.py └── download_video.py ├── pyproject.toml ├── tests ├── __init__.py ├── test_api.py └── test_utils.py └── tiddl ├── __init__.py ├── api.py ├── auth.py ├── cli ├── __init__.py ├── auth.py ├── config.py ├── ctx.py └── download │ ├── __init__.py │ ├── fav.py │ ├── file.py │ ├── search.py │ └── url.py ├── config.py ├── download.py ├── exceptions.py ├── metadata.py ├── models ├── __init__.py ├── api.py ├── auth.py ├── constants.py └── resource.py └── utils.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: oskvr37 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | Describe what happened. 12 | 13 | **To Reproduce** 14 | What command was used? 15 | 16 | **Screenshots** 17 | If applicable, add screenshots to help explain your problem. 18 | 19 | **Software (please complete the following information):** 20 | - tiddl version: [e.g. v2.0.1] 21 | - python version: [e.g. 3.11] 22 | - OS: [e.g. Linux, Windows, iOS] 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: new feature 6 | assignees: oskvr37 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...], It would be cool to [...] 12 | 13 | **Describe the solution you'd like** 14 | 15 | **Describe alternatives you've considered** 16 | 17 | **Additional context** 18 | Add any other context or screenshots about the feature request here. 19 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # TIDDL 2 | tidal_download/ 3 | .tiddl_config.json 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 114 | .pdm.toml 115 | .pdm-python 116 | .pdm-build/ 117 | 118 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 119 | __pypackages__/ 120 | 121 | # Celery stuff 122 | celerybeat-schedule 123 | celerybeat.pid 124 | 125 | # SageMath parsed files 126 | *.sage.py 127 | 128 | # Environments 129 | .env 130 | .venv 131 | env/ 132 | venv/ 133 | ENV/ 134 | env.bak/ 135 | venv.bak/ 136 | 137 | # Spyder project settings 138 | .spyderproject 139 | .spyproject 140 | 141 | # Rope project settings 142 | .ropeproject 143 | 144 | # mkdocs documentation 145 | /site 146 | 147 | # mypy 148 | .mypy_cache/ 149 | .dmypy.json 150 | dmypy.json 151 | 152 | # Pyre type checker 153 | .pyre/ 154 | 155 | # pytype static type analyzer 156 | .pytype/ 157 | 158 | # Cython debug symbols 159 | cython_debug/ 160 | 161 | # PyCharm 162 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 163 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 164 | # and can be added to the global gitignore or merged into this file. For a more nuclear 165 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 166 | #.idea/ -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "basic", 3 | "[python]": { 4 | "editor.codeActionsOnSave": { 5 | "source.organizeImports.ruff": "explicit", 6 | } 7 | }, 8 | "ruff.lineLength": 80, 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tidal Downloader 2 | 3 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/tiddl?style=for-the-badge&color=%2332af64) 4 | ![PyPI - Version](https://img.shields.io/pypi/v/tiddl?style=for-the-badge) 5 | ![GitHub commits since latest release](https://img.shields.io/github/commits-since/oskvr37/tiddl/latest?style=for-the-badge) 6 | [](https://gitmoji.dev) 7 | 8 | TIDDL is the Python CLI application that allows downloading Tidal tracks and videos! 9 | 10 | tiddl album download in 6 seconds 11 | 12 | It's inspired by [Tidal-Media-Downloader](https://github.com/yaronzz/Tidal-Media-Downloader) - currently not mantained project. 13 | This repository will contain features requests from that project and will be the enhanced version. 14 | 15 | > [!WARNING] 16 | > 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. 17 | 18 | # Installation 19 | 20 | Install package using `pip` 21 | 22 | ```bash 23 | pip install tiddl 24 | ``` 25 | 26 | Run the package cli with `tiddl` 27 | 28 | ```bash 29 | $ tiddl 30 | Usage: tiddl [OPTIONS] COMMAND [ARGS]... 31 | 32 | TIDDL - Tidal Downloader ♫ 33 | 34 | Options: 35 | -v, --verbose Show debug logs. 36 | -q, --quiet Suppress logs. 37 | -nc, --no-cache Omit Tidal API requests caching. 38 | --help Show this message and exit. 39 | 40 | Commands: 41 | auth Manage Tidal token. 42 | config Print path to the configuration file. 43 | fav Get your Tidal favorites. 44 | file Parse txt or JSON file with urls. 45 | search Search on Tidal. 46 | url Get Tidal URL. 47 | ``` 48 | 49 | # Basic usage 50 | 51 | ## Login with Tidal account 52 | 53 | ```bash 54 | tiddl auth login 55 | ``` 56 | 57 | ## Download resource 58 | 59 | You can download track / video / album / artist / playlist 60 | 61 | ```bash 62 | tiddl url https://listen.tidal.com/track/103805726 download 63 | tiddl url https://listen.tidal.com/video/25747442 download 64 | tiddl url https://listen.tidal.com/album/103805723 download 65 | tiddl url https://listen.tidal.com/artist/25022 download 66 | tiddl url https://listen.tidal.com/playlist/84974059-76af-406a-aede-ece2b78fa372 download 67 | ``` 68 | 69 | > [!TIP] 70 | > You don't have to paste full urls, track/103805726, album/103805723 etc. will also work 71 | 72 | ## Download options 73 | 74 | ```bash 75 | tiddl url track/103805726 download -q master -o "{artist}/{title} ({album})" 76 | ``` 77 | 78 | This command will: 79 | 80 | - download with highest quality (master) 81 | - save track with title and album name in artist folder 82 | 83 | ### Download quality 84 | 85 | | Quality | File extension | Details | 86 | | :-----: | :------------: | :-------------------: | 87 | | LOW | .m4a | 96 kbps | 88 | | NORMAL | .m4a | 320 kbps | 89 | | HIGH | .flac | 16-bit, 44.1 kHz | 90 | | MASTER | .flac | Up to 24-bit, 192 kHz | 91 | 92 | ### Output format 93 | 94 | More about file templating [on wiki](https://github.com/oskvr37/tiddl/wiki/Template-formatting). 95 | 96 | ## Custom tiddl home path 97 | 98 | You can set `TIDDL_PATH` environment variable to use custom home path for tiddl. 99 | 100 | Example CLI usage: 101 | 102 | ```sh 103 | TIDDL_PATH=~/custom/tiddl tiddl auth login 104 | ``` 105 | 106 | # Development 107 | 108 | Clone the repository 109 | 110 | ```bash 111 | git clone https://github.com/oskvr37/tiddl 112 | ``` 113 | 114 | Install package with `--editable` flag 115 | 116 | ```bash 117 | pip install -e . 118 | ``` 119 | 120 | Run tests 121 | 122 | ```bash 123 | python -m unittest 124 | ``` 125 | 126 | # Resources 127 | 128 | [Tidal API wiki](https://github.com/Fokka-Engineering/TIDAL) 129 | -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oskvr37/tiddl/a4a7e66b841eecd16b3c5e4807d869dd7a34f1e7/docs/demo.gif -------------------------------------------------------------------------------- /examples/concurrent_download_rich.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of concurrent album + playlist downloading with ThreadPoolExecutor and rich. 3 | This will download tracks and videos. 4 | """ 5 | 6 | import logging 7 | 8 | from typing import Union 9 | 10 | from pathlib import Path 11 | from requests import Session 12 | from concurrent.futures import ThreadPoolExecutor 13 | 14 | from rich.console import Console 15 | from rich.logging import RichHandler 16 | from rich.progress import ( 17 | BarColumn, 18 | Progress, 19 | TextColumn, 20 | ) 21 | 22 | from tiddl.api import TidalApi 23 | from tiddl.download import parseTrackStream, parseVideoStream 24 | from tiddl.config import Config 25 | from tiddl.models.resource import Track, Video 26 | from tiddl.utils import convertFileExtension 27 | 28 | 29 | WORKERS_COUNT = 4 30 | PLAYLIST_UUID = "84974059-76af-406a-aede-ece2b78fa372" 31 | ALBUM_ID = 103805723 32 | QUALITY = "HI_RES_LOSSLESS" 33 | 34 | console = Console() 35 | logging.basicConfig( 36 | level=logging.DEBUG, handlers=[RichHandler(console=console)] 37 | ) 38 | 39 | logging.getLogger("urllib3").setLevel(logging.ERROR) 40 | 41 | config = Config.fromFile() # load config from default directory 42 | 43 | api = TidalApi(config.auth.token, config.auth.user_id, config.auth.country_code) 44 | 45 | progress = Progress( 46 | TextColumn("{task.description}"), 47 | BarColumn(bar_width=40), 48 | console=console, 49 | transient=True, 50 | auto_refresh=True, 51 | ) 52 | 53 | 54 | def handleItemDownload(item: Union[Track, Video]): 55 | if isinstance(item, Track): 56 | track_stream = api.getTrackStream(item.id, quality=QUALITY) 57 | urls, extension = parseTrackStream(track_stream) 58 | elif isinstance(item, Video): 59 | video_stream = api.getVideoStream(item.id) 60 | urls = parseVideoStream(video_stream) 61 | extension = ".ts" 62 | else: 63 | raise TypeError( 64 | f"Invalid item type: expected an instance of Track or Video, " 65 | f"received an instance of {type(item).__name__}. " 66 | ) 67 | 68 | task_id = progress.add_task( 69 | description=f"{type(item).__name__} {item.title}", 70 | start=True, 71 | visible=True, 72 | total=len(urls), 73 | ) 74 | 75 | with Session() as s: 76 | stream_data = b"" 77 | 78 | for url in urls: 79 | req = s.get(url) 80 | stream_data += req.content 81 | progress.advance(task_id) 82 | 83 | path = Path("examples") / "downloads" / f"{item.id}{extension}" 84 | path.parent.mkdir(parents=True, exist_ok=True) 85 | 86 | with path.open("wb") as f: 87 | f.write(stream_data) 88 | 89 | if isinstance(item, Track): 90 | if item.audioQuality == "HI_RES_LOSSLESS": 91 | convertFileExtension( 92 | source_file=path, 93 | extension=".flac", 94 | remove_source=True, 95 | is_video=False, 96 | copy_audio=True, # extract flac from m4a container 97 | ) 98 | 99 | elif isinstance(item, Video): 100 | convertFileExtension( 101 | source_file=path, 102 | extension=".mp4", 103 | remove_source=True, 104 | is_video=True, 105 | copy_audio=True, 106 | ) 107 | 108 | console.log(item.title) 109 | progress.remove_task(task_id) 110 | 111 | 112 | progress.start() 113 | 114 | pool = ThreadPoolExecutor(max_workers=WORKERS_COUNT) 115 | 116 | 117 | def submitItem(item: Union[Track, Video]): 118 | pool.submit(handleItemDownload, item=item) 119 | 120 | 121 | # NOTE: these api requests will run one by one, 122 | # we will need to add some sleep between requests 123 | 124 | playlist_items = api.getPlaylistItems(playlist_uuid=PLAYLIST_UUID, limit=10) 125 | 126 | for item in playlist_items.items: 127 | submitItem(item.item) 128 | 129 | album_items = api.getAlbumItems(album_id=ALBUM_ID, limit=5) 130 | 131 | for item in album_items.items: 132 | submitItem(item.item) 133 | 134 | # cleanup 135 | 136 | pool.shutdown(wait=True) 137 | progress.stop() 138 | -------------------------------------------------------------------------------- /examples/download_video.py: -------------------------------------------------------------------------------- 1 | """Example of downloading a video from Tidal""" 2 | 3 | import logging 4 | 5 | from pathlib import Path 6 | from requests import Session 7 | 8 | from tiddl.api import TidalApi 9 | from tiddl.config import Config 10 | from tiddl.download import parseVideoStream 11 | from tiddl.utils import convertFileExtension 12 | 13 | logging.basicConfig(level=logging.DEBUG) 14 | 15 | VIDEO_ID = 373513584 16 | 17 | config = Config.fromFile() # load config from default directory 18 | 19 | api = TidalApi(config.auth.token, config.auth.user_id, config.auth.country_code) 20 | 21 | video_stream = api.getVideoStream(VIDEO_ID) 22 | 23 | urls = parseVideoStream(video_stream) 24 | 25 | with Session() as s: 26 | video_data = b"" 27 | 28 | for url in urls: 29 | req = s.get(url) 30 | video_data += req.content 31 | 32 | path = Path("videos") / f"{VIDEO_ID}.ts" 33 | path.parent.mkdir(parents=True, exist_ok=True) 34 | 35 | with path.open("wb") as f: 36 | f.write(video_data) 37 | 38 | convertFileExtension(path, ".mp4", True, True) 39 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "tiddl" 7 | version = "2.4.0" 8 | description = "Download Tidal tracks with CLI downloader." 9 | readme = "README.md" 10 | requires-python = ">=3.11" 11 | authors = [{ name = "oskvr37" }] 12 | classifiers = [ 13 | "Environment :: Console", 14 | "Programming Language :: Python :: 3", 15 | "Operating System :: OS Independent", 16 | ] 17 | dependencies = [ 18 | "pydantic>=2.9.2", 19 | "requests>=2.20.0", 20 | "requests-cache>=1.2.1", 21 | "click>=8.1.7", 22 | "mutagen>=1.47.0", 23 | "python-ffmpeg>=2.0.0", 24 | "m3u8>=6.0.0", 25 | "rich>=13.9.4" 26 | ] 27 | 28 | [project.urls] 29 | homepage = "https://github.com/oskvr37/tiddl" 30 | 31 | [project.scripts] 32 | tiddl = "tiddl.cli:cli" 33 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oskvr37/tiddl/a4a7e66b841eecd16b3c5e4807d869dd7a34f1e7/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tiddl.config import Config 4 | from tiddl.api import TidalApi 5 | 6 | 7 | class TestApi(unittest.TestCase): 8 | api: TidalApi 9 | 10 | def setUp(self): 11 | config = Config.fromFile() 12 | auth = config.auth 13 | 14 | token, user_id, country_code = ( 15 | auth.token, 16 | auth.user_id, 17 | auth.country_code, 18 | ) 19 | 20 | assert token, "No token found in config file" 21 | assert user_id, "No user_id found in config file" 22 | assert country_code, "No country_code found in config file" 23 | 24 | self.api = TidalApi(token, user_id, country_code) 25 | 26 | def test_ready(self): 27 | session = self.api.getSession() 28 | 29 | self.assertEqual(session.userId, int(self.api.user_id)) 30 | self.assertEqual(session.countryCode, self.api.country_code) 31 | 32 | def test_track(self): 33 | track = self.api.getTrack(103805726) 34 | self.assertEqual(track.title, "Stronger") 35 | 36 | def test_artist(self): 37 | artist = self.api.getArtist(25022) 38 | self.assertEqual(artist.name, "Kanye West") 39 | 40 | def test_artist_albums(self): 41 | self.api.getArtistAlbums(25022, filter="ALBUMS") 42 | self.api.getArtistAlbums(25022, filter="EPSANDSINGLES") 43 | 44 | def test_album(self): 45 | album = self.api.getAlbum(103805723) 46 | self.assertEqual(album.title, "Graduation") 47 | 48 | def test_album_items(self): 49 | album_items = self.api.getAlbumItems(103805723, limit=10) 50 | self.assertEqual(len(album_items.items), 10) 51 | 52 | album_items = self.api.getAlbumItems(103805723, limit=10, offset=10) 53 | self.assertEqual(len(album_items.items), 4) 54 | 55 | def test_album_items_credits(self): 56 | album_items = self.api.getAlbumItemsCredits(103805723, limit=10) 57 | self.assertEqual(len(album_items.items), 10) 58 | 59 | def test_playlist(self): 60 | playlist = self.api.getPlaylist("84974059-76af-406a-aede-ece2b78fa372") 61 | self.assertEqual(playlist.title, "Kanye West Essentials") 62 | 63 | def test_playlist_items(self): 64 | playlist_items = self.api.getPlaylistItems( 65 | "84974059-76af-406a-aede-ece2b78fa372" 66 | ) 67 | self.assertEqual(len(playlist_items.items), 25) 68 | 69 | def test_favorites(self): 70 | favorites = self.api.getFavorites() 71 | self.assertGreaterEqual(len(favorites.PLAYLIST), 0) 72 | self.assertGreaterEqual(len(favorites.ALBUM), 0) 73 | self.assertGreaterEqual(len(favorites.VIDEO), 0) 74 | self.assertGreaterEqual(len(favorites.TRACK), 0) 75 | self.assertGreaterEqual(len(favorites.ARTIST), 0) 76 | 77 | def test_search(self): 78 | self.api.getSearch("Kanye West") 79 | 80 | def test_video(self): 81 | self.api.getVideo(373513584) 82 | 83 | def test_video_stream(self): 84 | self.api.getVideoStream(373513584) 85 | 86 | def test_lyrics(self): 87 | track_id = 103805726 88 | lyrics = self.api.getLyrics(track_id) 89 | self.assertEqual(lyrics.trackId, track_id) 90 | 91 | 92 | if __name__ == "__main__": 93 | unittest.main() 94 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from tiddl.models.resource import Track 4 | from tiddl.utils import TidalResource, formatTrack 5 | 6 | 7 | class TestTidalResource(unittest.TestCase): 8 | def test_resource_parsing(self): 9 | positive_cases = [ 10 | ("https://tidal.com/browse/track/12345678", "track", "12345678"), 11 | ("track/12345678", "track", "12345678"), 12 | ("https://tidal.com/browse/video/12345678", "video", "12345678"), 13 | ("video/12345678", "video", "12345678"), 14 | ("https://tidal.com/browse/album/12345678", "album", "12345678"), 15 | ("album/12345678", "album", "12345678"), 16 | ("https://tidal.com/browse/playlist/12345678", "playlist", "12345678"), 17 | ("playlist/12345678", "playlist", "12345678"), 18 | ("https://tidal.com/browse/artist/12345678", "artist", "12345678"), 19 | ("artist/12345678", "artist", "12345678"), 20 | ] 21 | 22 | for resource, expected_type, expected_id in positive_cases: 23 | with self.subTest(resource=resource): 24 | tidal_resource = TidalResource.fromString(resource) 25 | self.assertEqual(tidal_resource.type, expected_type) 26 | self.assertEqual(tidal_resource.id, expected_id) 27 | 28 | def test_failing_cases(self): 29 | failing_cases = [ 30 | "https://tidal.com/browse/invalid/12345678", 31 | "invalid/12345678", 32 | "https://tidal.com/browse/track/invalid", 33 | "track/invalid", 34 | "", 35 | "invalid", 36 | "https://tidal.com/browse/track/", 37 | "track/", 38 | "/12345678", 39 | ] 40 | 41 | for resource in failing_cases: 42 | with self.subTest(resource=resource): 43 | with self.assertRaises(ValueError): 44 | TidalResource.fromString(resource) 45 | 46 | 47 | class TestFormatTrack(unittest.TestCase): 48 | @classmethod 49 | def setUpClass(cls): 50 | cls.track = Track( 51 | **{ 52 | "id": 66421438, 53 | "title": "Shutdown", 54 | "duration": 189, 55 | "replayGain": -9.95, 56 | "peak": 0.966051, 57 | "allowStreaming": True, 58 | "streamReady": True, 59 | "adSupportedStreamReady": True, 60 | "djReady": True, 61 | "stemReady": False, 62 | "streamStartDate": "2016-11-15T00:00:00.000+0000", 63 | "premiumStreamingOnly": False, 64 | "trackNumber": 9, 65 | "volumeNumber": 1, 66 | "version": None, 67 | "popularity": 24, 68 | "copyright": "(P) 2016 Boy Better Know", 69 | "bpm": 69, 70 | "url": "http://www.tidal.com/track/66421438", 71 | "isrc": "GB7QY1500024", 72 | "editable": False, 73 | "explicit": True, 74 | "audioQuality": "LOSSLESS", 75 | "audioModes": ["STEREO"], 76 | "mediaMetadata": {"tags": ["LOSSLESS", "HIRES_LOSSLESS"]}, 77 | "artist": { 78 | "id": 3566984, 79 | "name": "Skepta", 80 | "type": "MAIN", 81 | "picture": "747af850-fa9c-4178-a3e6-49259b67df86", 82 | }, 83 | "artists": [ 84 | { 85 | "id": 3566984, 86 | "name": "Skepta", 87 | "type": "MAIN", 88 | "picture": "747af850-fa9c-4178-a3e6-49259b67df86", 89 | } 90 | ], 91 | "album": { 92 | "id": 66421429, 93 | "title": "Konnichiwa", 94 | "cover": "e0c2f05e-e21f-47c5-9c37-2993437df27d", 95 | "vibrantColor": "#ae3b31", 96 | "videoCover": None, 97 | }, 98 | "mixes": {"TRACK_MIX": "001aa4abeb471e8f55f5784772b478"}, 99 | "playlistNumber": None, 100 | } 101 | ) 102 | 103 | def test_templating(self): 104 | test_cases = [ 105 | ("{id}", "66421438"), 106 | ("{title}", "Shutdown"), 107 | ("{version}", ""), 108 | ("{artist}", "Skepta"), 109 | ("{artists}", "Skepta"), 110 | ("{album}", "Konnichiwa"), 111 | ("{number}", "9"), 112 | ("{disc}", "1"), 113 | ("{date:%m-%d-%y}", "11-15-16"), 114 | ("{date:%Y}", "2016"), 115 | ("{year}", "2016"), 116 | ("{playlist_number}", "0"), 117 | ("{playlist_number:02d}", "00"), 118 | ("{bpm}", "69"), 119 | ("{quality}", "high"), 120 | ("{artist}/{album}/{title}", "Skepta/Konnichiwa/Shutdown"), 121 | ("{number:02d}. {title}", "09. Shutdown"), 122 | ] 123 | 124 | for template, expected_result in test_cases: 125 | with self.subTest(template=template, expected_result=expected_result): 126 | result = formatTrack(template, self.track) 127 | self.assertEqual(result, expected_result) 128 | 129 | def test_invalid_characters(self): 130 | test_cases = ["\\", ":", '"', "?", "<", ">", "|", "{number}:{title}", "{date}"] 131 | 132 | for template in test_cases: 133 | with self.subTest(template=template): 134 | with self.assertRaises(ValueError): 135 | formatTrack(template, self.track) 136 | 137 | 138 | if __name__ == "__main__": 139 | unittest.main() 140 | -------------------------------------------------------------------------------- /tiddl/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oskvr37/tiddl/a4a7e66b841eecd16b3c5e4807d869dd7a34f1e7/tiddl/__init__.py -------------------------------------------------------------------------------- /tiddl/api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from pathlib import Path 4 | from typing import Any, Literal, Type, TypeVar 5 | 6 | from pydantic import BaseModel 7 | from requests_cache import ( 8 | CachedSession, 9 | EXPIRE_IMMEDIATELY, 10 | NEVER_EXPIRE, 11 | DO_NOT_CACHE, 12 | ) 13 | 14 | from tiddl.models.api import ( 15 | Album, 16 | AlbumItems, 17 | AlbumItemsCredits, 18 | Artist, 19 | ArtistAlbumsItems, 20 | Favorites, 21 | Playlist, 22 | PlaylistItems, 23 | Search, 24 | SessionResponse, 25 | Track, 26 | TrackStream, 27 | Video, 28 | VideoStream, 29 | Lyrics 30 | ) 31 | 32 | from tiddl.models.constants import TrackQuality 33 | from tiddl.exceptions import ApiError 34 | from tiddl.config import HOME_PATH 35 | 36 | DEBUG = False 37 | 38 | T = TypeVar("T", bound=BaseModel) 39 | 40 | logger = logging.getLogger(__name__) 41 | 42 | 43 | def ensureLimit(limit: int, max_limit: int) -> int: 44 | if limit > max_limit: 45 | logger.warning(f"Max limit is {max_limit}") 46 | return max_limit 47 | 48 | return limit 49 | 50 | 51 | class Limits: 52 | ARTIST_ALBUMS = 50 53 | ALBUM_ITEMS = 10 54 | ALBUM_ITEMS_MAX = 100 55 | PLAYLIST = 50 56 | 57 | 58 | class TidalApi: 59 | URL = "https://api.tidal.com/v1" 60 | LIMITS = Limits 61 | 62 | def __init__( 63 | self, token: str, user_id: str, country_code: str, omit_cache=False 64 | ) -> None: 65 | self.user_id = user_id 66 | self.country_code = country_code 67 | 68 | # 3.0 TODO: change cache path 69 | CACHE_NAME = "tiddl_api_cache" 70 | 71 | self.session = CachedSession( 72 | cache_name=HOME_PATH / CACHE_NAME, always_revalidate=omit_cache 73 | ) 74 | self.session.headers = { 75 | "Authorization": f"Bearer {token}", 76 | "Accept": "application/json", 77 | } 78 | 79 | def fetch( 80 | self, 81 | model: Type[T], 82 | endpoint: str, 83 | params: dict[str, Any] = {}, 84 | expire_after=NEVER_EXPIRE, 85 | ) -> T: 86 | """Fetch data from the API and parse it into the given Pydantic model.""" 87 | 88 | req = self.session.get( 89 | f"{self.URL}/{endpoint}", params=params, expire_after=expire_after 90 | ) 91 | 92 | logger.debug( 93 | ( 94 | endpoint, 95 | params, 96 | req.status_code, 97 | "HIT" if req.from_cache else "MISS", 98 | ) 99 | ) 100 | 101 | data = req.json() 102 | 103 | if DEBUG: 104 | debug_data = { 105 | "status_code": req.status_code, 106 | "endpoint": endpoint, 107 | "params": params, 108 | "data": data, 109 | } 110 | 111 | path = Path(f"debug_data/{endpoint}.json") 112 | path.parent.mkdir(parents=True, exist_ok=True) 113 | 114 | with path.open("w", encoding="utf-8") as f: 115 | json.dump(debug_data, f, indent=2) 116 | 117 | if req.status_code != 200: 118 | raise ApiError(**data) 119 | 120 | return model.model_validate(data) 121 | 122 | def getAlbum(self, album_id: str | int): 123 | return self.fetch( 124 | Album, f"albums/{album_id}", {"countryCode": self.country_code} 125 | ) 126 | 127 | def getAlbumItems( 128 | self, album_id: str | int, limit=LIMITS.ALBUM_ITEMS, offset=0 129 | ): 130 | return self.fetch( 131 | AlbumItems, 132 | f"albums/{album_id}/items", 133 | { 134 | "countryCode": self.country_code, 135 | "limit": ensureLimit(limit, self.LIMITS.ALBUM_ITEMS_MAX), 136 | "offset": offset, 137 | }, 138 | ) 139 | 140 | def getAlbumItemsCredits( 141 | self, album_id: str | int, limit=LIMITS.ALBUM_ITEMS, offset=0 142 | ): 143 | return self.fetch( 144 | AlbumItemsCredits, 145 | f"albums/{album_id}/items/credits", 146 | { 147 | "countryCode": self.country_code, 148 | "limit": ensureLimit(limit, self.LIMITS.ALBUM_ITEMS_MAX), 149 | "offset": offset, 150 | }, 151 | ) 152 | 153 | def getArtist(self, artist_id: str | int): 154 | return self.fetch( 155 | Artist, 156 | f"artists/{artist_id}", 157 | {"countryCode": self.country_code}, 158 | expire_after=3600, 159 | ) 160 | 161 | def getArtistAlbums( 162 | self, 163 | artist_id: str | int, 164 | limit=LIMITS.ARTIST_ALBUMS, 165 | offset=0, 166 | filter: Literal["ALBUMS", "EPSANDSINGLES"] = "ALBUMS", 167 | ): 168 | return self.fetch( 169 | ArtistAlbumsItems, 170 | f"artists/{artist_id}/albums", 171 | { 172 | "countryCode": self.country_code, 173 | "limit": limit, # tested limit 10,000 174 | "offset": offset, 175 | "filter": filter, 176 | }, 177 | expire_after=3600, 178 | ) 179 | 180 | def getFavorites(self): 181 | return self.fetch( 182 | Favorites, 183 | f"users/{self.user_id}/favorites/ids", 184 | {"countryCode": self.country_code}, 185 | expire_after=EXPIRE_IMMEDIATELY, 186 | ) 187 | 188 | def getPlaylist(self, playlist_uuid: str): 189 | return self.fetch( 190 | Playlist, 191 | f"playlists/{playlist_uuid}", 192 | {"countryCode": self.country_code}, 193 | ) 194 | 195 | def getPlaylistItems( 196 | self, playlist_uuid: str, limit=LIMITS.PLAYLIST, offset=0 197 | ): 198 | return self.fetch( 199 | PlaylistItems, 200 | f"playlists/{playlist_uuid}/items", 201 | { 202 | "countryCode": self.country_code, 203 | "limit": limit, 204 | "offset": offset, 205 | }, 206 | expire_after=EXPIRE_IMMEDIATELY, 207 | ) 208 | 209 | def getSearch(self, query: str): 210 | return self.fetch( 211 | Search, 212 | "search", 213 | {"countryCode": self.country_code, "query": query}, 214 | expire_after=EXPIRE_IMMEDIATELY, 215 | ) 216 | 217 | def getSession(self): 218 | return self.fetch( 219 | SessionResponse, "sessions", expire_after=DO_NOT_CACHE 220 | ) 221 | 222 | def getLyrics(self, track_id: str | int): 223 | return self.fetch( 224 | Lyrics, f"tracks/{track_id}/lyrics", {"countryCode": self.country_code} 225 | ) 226 | 227 | def getTrack(self, track_id: str | int): 228 | return self.fetch( 229 | Track, f"tracks/{track_id}", {"countryCode": self.country_code} 230 | ) 231 | 232 | def getTrackStream(self, track_id: str | int, quality: TrackQuality): 233 | return self.fetch( 234 | TrackStream, 235 | f"tracks/{track_id}/playbackinfo", 236 | { 237 | "audioquality": quality, 238 | "playbackmode": "STREAM", 239 | "assetpresentation": "FULL", 240 | }, 241 | expire_after=DO_NOT_CACHE, 242 | ) 243 | 244 | def getVideo(self, video_id: str | int): 245 | return self.fetch( 246 | Video, f"videos/{video_id}", {"countryCode": self.country_code} 247 | ) 248 | 249 | def getVideoStream(self, video_id: str | int): 250 | return self.fetch( 251 | VideoStream, 252 | f"videos/{video_id}/playbackinfo", 253 | { 254 | "videoquality": "HIGH", 255 | "playbackmode": "STREAM", 256 | "assetpresentation": "FULL", 257 | }, 258 | expire_after=DO_NOT_CACHE, 259 | ) 260 | -------------------------------------------------------------------------------- /tiddl/auth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from requests import request 4 | 5 | from tiddl.exceptions import AuthError 6 | from tiddl.models import auth 7 | 8 | AUTH_URL = "https://auth.tidal.com/v1/oauth2" 9 | CLIENT_ID = "zU4XHVVkc2tDPo4t" 10 | CLIENT_SECRET = "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4=" 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def getDeviceAuth(): 17 | req = request( 18 | "POST", 19 | f"{AUTH_URL}/device_authorization", 20 | data={"client_id": CLIENT_ID, "scope": "r_usr+w_usr+w_sub"}, 21 | ) 22 | 23 | data = req.json() 24 | 25 | if req.status_code == 200: 26 | return auth.AuthDeviceResponse(**data) 27 | 28 | raise AuthError(**data) 29 | 30 | 31 | def getToken(device_code: str): 32 | req = request( 33 | "POST", 34 | f"{AUTH_URL}/token", 35 | data={ 36 | "client_id": CLIENT_ID, 37 | "device_code": device_code, 38 | "grant_type": "urn:ietf:params:oauth:grant-type:device_code", 39 | "scope": "r_usr+w_usr+w_sub", 40 | }, 41 | auth=(CLIENT_ID, CLIENT_SECRET), 42 | ) 43 | 44 | data = req.json() 45 | 46 | if req.status_code == 200: 47 | return auth.AuthResponseWithRefresh(**data) 48 | 49 | raise AuthError(**data) 50 | 51 | 52 | def refreshToken(refresh_token: str): 53 | req = request( 54 | "POST", 55 | f"{AUTH_URL}/token", 56 | data={ 57 | "client_id": CLIENT_ID, 58 | "refresh_token": refresh_token, 59 | "grant_type": "refresh_token", 60 | "scope": "r_usr+w_usr+w_sub", 61 | }, 62 | auth=(CLIENT_ID, CLIENT_SECRET), 63 | ) 64 | 65 | data = req.json() 66 | 67 | if req.status_code == 200: 68 | return auth.AuthResponse(**data) 69 | 70 | raise AuthError(**data) 71 | 72 | 73 | def removeToken(access_token: str): 74 | req = request( 75 | "POST", 76 | "https://api.tidal.com/v1/logout", 77 | headers={"authorization": f"Bearer {access_token}"}, 78 | ) 79 | 80 | logger.debug((req.status_code, req.text)) 81 | -------------------------------------------------------------------------------- /tiddl/cli/__init__.py: -------------------------------------------------------------------------------- 1 | import click 2 | import logging 3 | 4 | from rich.logging import RichHandler 5 | 6 | from tiddl.config import HOME_PATH 7 | from tiddl.cli.ctx import ContextObj, passContext, Context 8 | from tiddl.cli.auth import AuthGroup 9 | from tiddl.cli.download import UrlGroup, FavGroup, SearchGroup, FileGroup 10 | from tiddl.cli.config import ConfigCommand 11 | from tiddl.cli.auth import refresh 12 | 13 | 14 | @click.group() 15 | @passContext 16 | @click.option("--verbose", "-v", is_flag=True, help="Show debug logs.") 17 | @click.option("--quiet", "-q", is_flag=True, help="Suppress logs.") 18 | @click.option( 19 | "--no-cache", "-nc", is_flag=True, help="Omit Tidal API requests caching." 20 | ) 21 | def cli(ctx: Context, verbose: bool, quiet: bool, no_cache: bool): 22 | """TIDDL - Tidal Downloader \u266b""" 23 | ctx.obj = ContextObj() 24 | 25 | # latest logs 26 | file_handler = logging.FileHandler( 27 | HOME_PATH / "tiddl.log", mode="w", encoding="utf-8" 28 | ) 29 | 30 | file_handler.setLevel(logging.DEBUG) 31 | file_handler.setFormatter( 32 | logging.Formatter( 33 | "%(levelname)s [%(name)s.%(funcName)s] %(message)s", datefmt="[%X]" 34 | ) 35 | ) 36 | 37 | LEVEL = ( 38 | logging.DEBUG if verbose else logging.ERROR if quiet else logging.INFO 39 | ) 40 | 41 | rich_handler = RichHandler(console=ctx.obj.console, rich_tracebacks=True) 42 | rich_handler.setLevel(LEVEL) 43 | 44 | if LEVEL == logging.DEBUG: 45 | rich_handler.setFormatter( 46 | logging.Formatter( 47 | "[%(name)s.%(funcName)s] %(message)s", datefmt="[%X]" 48 | ) 49 | ) 50 | 51 | logging.basicConfig( 52 | level=logging.DEBUG, 53 | handlers=[ 54 | rich_handler, 55 | file_handler, 56 | ], 57 | format="%(message)s", 58 | datefmt="[%X]", 59 | ) 60 | 61 | logging.getLogger("urllib3").setLevel(logging.ERROR) 62 | 63 | if ctx.invoked_subcommand in ("fav", "file", "search", "url"): 64 | ctx.invoke(refresh) 65 | 66 | ctx.obj.initApi(omit_cache=no_cache) 67 | 68 | 69 | cli.add_command(ConfigCommand) 70 | cli.add_command(AuthGroup) 71 | cli.add_command(UrlGroup) 72 | cli.add_command(FavGroup) 73 | cli.add_command(SearchGroup) 74 | cli.add_command(FileGroup) 75 | 76 | if __name__ == "__main__": 77 | cli() 78 | -------------------------------------------------------------------------------- /tiddl/cli/auth.py: -------------------------------------------------------------------------------- 1 | import click 2 | import logging 3 | 4 | from time import sleep, time 5 | 6 | from tiddl.config import AuthConfig 7 | from tiddl.auth import ( 8 | getDeviceAuth, 9 | getToken, 10 | refreshToken, 11 | removeToken, 12 | AuthError, 13 | ) 14 | from tiddl.cli.ctx import passContext, Context 15 | 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | @click.group("auth") 21 | def AuthGroup(): 22 | """Manage Tidal token.""" 23 | 24 | 25 | @AuthGroup.command("refresh") 26 | @passContext 27 | def refresh(ctx: Context): 28 | """Refresh auth token when is expired""" 29 | 30 | logger.debug("Invoked refresh command") 31 | 32 | auth = ctx.obj.config.auth 33 | 34 | if auth.refresh_token and time() > auth.expires: 35 | logger.info("Refreshing token...") 36 | token = refreshToken(auth.refresh_token) 37 | 38 | ctx.obj.config.auth.expires = token.expires_in + int(time()) 39 | ctx.obj.config.auth.token = token.access_token 40 | 41 | ctx.obj.config.save() 42 | logger.info("Refreshed auth token!") 43 | 44 | 45 | @AuthGroup.command("login") 46 | @passContext 47 | def login(ctx: Context): 48 | """Add token to the config""" 49 | 50 | logger.debug("Invoked login command") 51 | 52 | if ctx.obj.config.auth.token: 53 | logger.info("Already logged in.") 54 | ctx.invoke(refresh) 55 | return 56 | 57 | auth = getDeviceAuth() 58 | 59 | uri = f"https://{auth.verificationUriComplete}" 60 | click.launch(uri) 61 | 62 | logger.info(f"Go to {uri} and complete authentication!") 63 | 64 | auth_end_at = time() + auth.expiresIn 65 | 66 | while True: 67 | sleep(auth.interval) 68 | 69 | try: 70 | token = getToken(auth.deviceCode) 71 | except AuthError as e: 72 | if e.error == "authorization_pending": 73 | time_left = auth_end_at - time() 74 | minutes, seconds = time_left // 60, int(time_left % 60) 75 | 76 | click.echo(f"\rTime left: {minutes:.0f}:{seconds:02d}", nl=False) 77 | continue 78 | 79 | if e.error == "expired_token": 80 | logger.info("\nTime for authentication has expired.") 81 | break 82 | 83 | ctx.obj.config.auth = AuthConfig( 84 | token=token.access_token, 85 | refresh_token=token.refresh_token, 86 | expires=token.expires_in + int(time()), 87 | user_id=str(token.user.userId), 88 | country_code=token.user.countryCode, 89 | ) 90 | ctx.obj.config.save() 91 | 92 | logger.info("\nAuthenticated!") 93 | 94 | break 95 | 96 | 97 | @AuthGroup.command("logout") 98 | @passContext 99 | def logout(ctx: Context): 100 | """Remove token from config""" 101 | 102 | logger.debug("Invoked logout command") 103 | 104 | access_token = ctx.obj.config.auth.token 105 | 106 | if not access_token: 107 | logger.info("Not logged in.") 108 | return 109 | 110 | removeToken(access_token) 111 | 112 | ctx.obj.config.auth = AuthConfig() 113 | ctx.obj.config.save() 114 | 115 | logger.info("Logged out!") 116 | -------------------------------------------------------------------------------- /tiddl/cli/config.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from tiddl.config import CONFIG_PATH 4 | from tiddl.cli.ctx import Context, passContext 5 | 6 | 7 | @click.command("config") 8 | @click.option( 9 | "--open", 10 | "-o", 11 | "OPEN_CONFIG", 12 | is_flag=True, 13 | help="Open the configuration file with the default editor.", 14 | ) 15 | @click.option( 16 | "--locate", 17 | "-l", 18 | "LOCATE_CONFIG", 19 | is_flag=True, 20 | help="Launch a file manager with the located configuration file.", 21 | ) 22 | @click.option( 23 | "--print", 24 | "-p", 25 | "PRINT_CONFIG", 26 | is_flag=True, 27 | help="Show current configuration.", 28 | ) 29 | @passContext 30 | def ConfigCommand( 31 | ctx: Context, OPEN_CONFIG: bool, LOCATE_CONFIG: bool, PRINT_CONFIG: bool 32 | ): 33 | """ 34 | Configuration file options. 35 | 36 | By default it prints location of tiddl config file. 37 | 38 | This command can be used in variable like `vim $(tiddl config)` 39 | - this will open your config with vim editor. 40 | """ 41 | 42 | if OPEN_CONFIG: 43 | click.launch(str(CONFIG_PATH)) 44 | 45 | elif LOCATE_CONFIG: 46 | click.launch(str(CONFIG_PATH), locate=True) 47 | 48 | elif PRINT_CONFIG: 49 | config_without_auth = ctx.obj.config.model_copy() 50 | del config_without_auth.auth 51 | ctx.obj.console.print(config_without_auth.model_dump_json(indent=2)) 52 | 53 | else: 54 | click.echo(str(CONFIG_PATH)) 55 | -------------------------------------------------------------------------------- /tiddl/cli/ctx.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import click 3 | 4 | from rich.console import Console 5 | 6 | from typing import Callable, TypeVar, cast 7 | 8 | from tiddl.api import TidalApi 9 | from tiddl.config import Config 10 | from tiddl.utils import TidalResource 11 | 12 | 13 | class ContextObj: 14 | api: TidalApi | None 15 | config: Config 16 | resources: list[TidalResource] 17 | console: Console 18 | 19 | def __init__(self) -> None: 20 | self.config = Config.fromFile() 21 | self.resources = [] 22 | self.api = None 23 | self.console = Console() 24 | 25 | def initApi(self, omit_cache=False): 26 | auth = self.config.auth 27 | 28 | if auth.token and auth.user_id and auth.country_code: 29 | self.api = TidalApi( 30 | auth.token, 31 | auth.user_id, 32 | auth.country_code, 33 | omit_cache=omit_cache or self.config.omit_cache, 34 | ) 35 | 36 | def getApi(self) -> TidalApi: 37 | if self.api is None: 38 | raise click.UsageError("You must login first") 39 | 40 | return self.api 41 | 42 | 43 | class Context(click.Context): 44 | obj: ContextObj 45 | 46 | 47 | F = TypeVar("F", bound=Callable[..., None]) 48 | 49 | 50 | def passContext(func: F) -> F: 51 | """Wrapper for @click.pass_context to use custom Context""" 52 | 53 | @click.pass_context 54 | @functools.wraps(func) 55 | def wrapper(ctx: click.Context, *args, **kwargs): 56 | custom_ctx = cast(Context, ctx) 57 | return func(custom_ctx, *args, **kwargs) 58 | 59 | return cast(F, wrapper) 60 | -------------------------------------------------------------------------------- /tiddl/cli/download/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import click 3 | 4 | from time import perf_counter 5 | from concurrent.futures import ThreadPoolExecutor 6 | from pathlib import Path 7 | from requests import Session 8 | 9 | from rich.highlighter import ReprHighlighter 10 | from rich.progress import ( 11 | SpinnerColumn, 12 | Progress, 13 | TextColumn, 14 | ) 15 | 16 | from tiddl.download import parseTrackStream, parseVideoStream 17 | from tiddl.exceptions import ApiError, AuthError 18 | from tiddl.metadata import Cover, addMetadata, addVideoMetadata 19 | from tiddl.models.api import AlbumItemsCredits 20 | from tiddl.models.constants import ARG_TO_QUALITY, TrackArg, SinglesFilter 21 | from tiddl.models.resource import Track, Video, Album 22 | from tiddl.utils import ( 23 | TidalResource, 24 | formatResource, 25 | convertFileExtension, 26 | trackExists, 27 | ) 28 | 29 | from tiddl.cli.ctx import Context, passContext 30 | from tiddl.cli.download.fav import FavGroup 31 | from tiddl.cli.download.file import FileGroup 32 | from tiddl.cli.download.search import SearchGroup 33 | from tiddl.cli.download.url import UrlGroup 34 | 35 | from typing import List, Union 36 | 37 | 38 | @click.command("download") 39 | @click.option( 40 | "--quality", 41 | "-q", 42 | "QUALITY", 43 | type=click.Choice(TrackArg.__args__), 44 | help="Track quality.", 45 | ) 46 | @click.option( 47 | "--output", 48 | "-o", 49 | "TEMPLATE", 50 | type=str, 51 | help="Format output file template. " 52 | "This will be used instead of your config templates.", 53 | ) 54 | @click.option( 55 | "--path", 56 | "-p", 57 | "PATH", 58 | type=str, 59 | help="Base path of download directory. Default is ~/Music/Tiddl.", 60 | ) 61 | @click.option( 62 | "--threads", 63 | "-t", 64 | "THREADS_COUNT", 65 | type=int, 66 | help="Number of threads to use in concurrent download; use with caution.", 67 | ) 68 | @click.option( 69 | "--noskip", 70 | "-ns", 71 | "DO_NOT_SKIP", 72 | is_flag=True, 73 | default=False, 74 | help="Do not skip already downloaded files.", 75 | ) 76 | @click.option( 77 | "--singles", 78 | "-s", 79 | "SINGLES_FILTER", 80 | type=click.Choice(SinglesFilter.__args__), 81 | help="Defines how to treat artist EPs and singles, used while downloading artist.", 82 | ) 83 | @click.option( 84 | "--lyrics", 85 | "-l", 86 | "EMBED_LYRICS", 87 | is_flag=True, 88 | help="Embed track lyrics in file metadata.", 89 | ) 90 | @passContext 91 | def DownloadCommand( 92 | ctx: Context, 93 | QUALITY: TrackArg | None, 94 | TEMPLATE: str | None, 95 | PATH: str | None, 96 | THREADS_COUNT: int, 97 | DO_NOT_SKIP: bool, 98 | SINGLES_FILTER: SinglesFilter, 99 | EMBED_LYRICS: bool 100 | ): 101 | """Download resources""" 102 | 103 | SINGLES_FILTER = SINGLES_FILTER or ctx.obj.config.download.singles_filter 104 | EMBED_LYRICS = EMBED_LYRICS or ctx.obj.config.download.embed_lyrics 105 | 106 | # TODO: pretty print 107 | logging.debug( 108 | ( 109 | QUALITY, 110 | TEMPLATE, 111 | PATH, 112 | THREADS_COUNT, 113 | DO_NOT_SKIP, 114 | SINGLES_FILTER, 115 | EMBED_LYRICS 116 | ) 117 | ) 118 | 119 | DOWNLOAD_QUALITY = ARG_TO_QUALITY[QUALITY or ctx.obj.config.download.quality] 120 | 121 | api = ctx.obj.getApi() 122 | 123 | progress = Progress( 124 | SpinnerColumn(), 125 | TextColumn( 126 | "{task.description} • " 127 | "{task.fields[speed]:.2f} Mbps • {task.fields[size]:.2f} MB", 128 | highlighter=ReprHighlighter(), 129 | ), 130 | console=ctx.obj.console, 131 | transient=True, 132 | auto_refresh=True, 133 | ) 134 | 135 | def handleItemDownload( 136 | item: Union[Track, Video], 137 | path: Path, 138 | cover_data=b"", 139 | credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [], 140 | album_artist="", 141 | ): 142 | if isinstance(item, Track): 143 | track_stream = api.getTrackStream(item.id, quality=DOWNLOAD_QUALITY) 144 | description = ( 145 | f"Track '{item.title}' " 146 | f"{(str(track_stream.bitDepth) + ' bit') if track_stream.bitDepth else ''} " 147 | f"{str(track_stream.sampleRate) + ' kHz' if track_stream.sampleRate else ''}" 148 | ) 149 | 150 | urls, extension = parseTrackStream(track_stream) 151 | elif isinstance(item, Video): 152 | video_stream = api.getVideoStream(item.id) 153 | description = f"Video '{item.title}' {video_stream.videoQuality} quality" 154 | 155 | urls = parseVideoStream(video_stream) 156 | extension = ".ts" 157 | else: 158 | raise TypeError( 159 | f"Invalid item type: expected an instance of Track or Video, " 160 | f"received an instance of {type(item).__name__}. " 161 | ) 162 | 163 | task_id = progress.add_task( 164 | description=description, 165 | start=True, 166 | visible=True, 167 | total=None, 168 | # fields 169 | speed=0, 170 | size=0, 171 | ) 172 | 173 | with Session() as s: 174 | stream_data = b"" 175 | time_start = perf_counter() 176 | 177 | for url in urls: 178 | req = s.get(url) 179 | 180 | assert req.status_code == 200, ( 181 | f"Could not download stream data for: " 182 | f"{type(item).__name__} '{item.title}', " 183 | f"status code: {req.status_code}" 184 | ) 185 | 186 | stream_data += req.content 187 | speed = len(stream_data) / (perf_counter() - time_start) / (1024 * 128) 188 | size = len(stream_data) / 1024**2 189 | progress.update( 190 | task_id, 191 | advance=len(req.content), 192 | speed=speed, 193 | size=size, 194 | ) 195 | 196 | path = path.with_suffix(extension) 197 | path.parent.mkdir(parents=True, exist_ok=True) 198 | 199 | with path.open("wb") as f: 200 | f.write(stream_data) 201 | 202 | if isinstance(item, Track): 203 | if track_stream.audioQuality == "HI_RES_LOSSLESS": 204 | path = convertFileExtension( 205 | source_file=path, 206 | extension=".flac", 207 | remove_source=True, 208 | is_video=False, 209 | copy_audio=True, # extract flac from m4a container 210 | ) 211 | 212 | if not cover_data and item.album.cover: 213 | cover_data = Cover(item.album.cover).content 214 | 215 | if EMBED_LYRICS: 216 | lyrics_subtitles = api.getLyrics(item.id).subtitles 217 | else: 218 | lyrics_subtitles = "" 219 | 220 | try: 221 | addMetadata(path, item, cover_data, credits, album_artist=album_artist, lyrics=lyrics_subtitles) 222 | except Exception as e: 223 | logging.error(f"Can not add metadata to: {path}, {e}") 224 | 225 | elif isinstance(item, Video): 226 | path = convertFileExtension( 227 | source_file=path, 228 | extension=".mp4", 229 | remove_source=True, 230 | is_video=True, 231 | copy_audio=True, 232 | ) 233 | 234 | try: 235 | addVideoMetadata(path, item) 236 | except Exception as e: 237 | logging.error(f"Can not add metadata to: {path}, {e}") 238 | 239 | progress.remove_task(task_id) 240 | logging.info(f"{item.title!r} • {speed:.2f} Mbps • {size:.2f} MB") 241 | 242 | pool = ThreadPoolExecutor( 243 | max_workers=THREADS_COUNT or ctx.obj.config.download.threads 244 | ) 245 | 246 | def submitItem( 247 | item: Union[Track, Video], 248 | filename: str, 249 | cover_data=b"", 250 | credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [], 251 | album_artist="", 252 | ): 253 | if not item.allowStreaming: 254 | logging.warning( 255 | f"✖ {type(item).__name__} '{item.title}' does not allow streaming" 256 | ) 257 | return 258 | 259 | path = Path(PATH) if PATH else ctx.obj.config.download.path 260 | path /= f"{filename}.*" 261 | 262 | if not DO_NOT_SKIP: # check if item is already downloaded 263 | if isinstance(item, Track): 264 | if trackExists(item.audioQuality, DOWNLOAD_QUALITY, path): 265 | logging.warning(f"Track '{item.title}' skipped") 266 | return 267 | elif isinstance(item, Video): 268 | if path.with_suffix(".mp4").exists(): 269 | logging.warning(f"Video '{item.title}' skipped") 270 | return 271 | 272 | pool.submit( 273 | handleItemDownload, 274 | item=item, 275 | path=path, 276 | cover_data=cover_data, 277 | credits=credits, 278 | album_artist=album_artist, 279 | ) 280 | 281 | def downloadAlbum(album: Album): 282 | logging.info(f"Album {album.title!r}") 283 | 284 | cover = ( 285 | Cover(uid=album.cover, size=ctx.obj.config.cover.size) 286 | if album.cover 287 | else None 288 | ) 289 | is_cover_saved = False 290 | 291 | offset = 0 292 | 293 | while True: 294 | album_items = api.getAlbumItemsCredits(album.id, offset=offset) 295 | 296 | for item in album_items.items: 297 | filename = formatResource( 298 | template=TEMPLATE or ctx.obj.config.template.album, 299 | resource=item.item, 300 | album_artist=album.artist.name, 301 | ) 302 | 303 | if cover and not is_cover_saved and ctx.obj.config.cover.save: 304 | path = Path(PATH) if PATH else ctx.obj.config.download.path 305 | cover_path = path / Path(filename).parent 306 | cover.save(cover_path, ctx.obj.config.cover.filename) 307 | is_cover_saved = True 308 | 309 | submitItem( 310 | item.item, 311 | filename, 312 | cover.content if cover else b"", 313 | item.credits, 314 | album.artist.name, 315 | ) 316 | 317 | if album_items.limit + album_items.offset > album_items.totalNumberOfItems: 318 | break 319 | 320 | offset += album_items.limit 321 | 322 | def handleResource(resource: TidalResource) -> None: 323 | logging.debug(f"Handling Resource '{resource}'") 324 | 325 | match resource.type: 326 | case "track": 327 | track = api.getTrack(resource.id) 328 | filename = formatResource( 329 | TEMPLATE or ctx.obj.config.template.track, track 330 | ) 331 | 332 | submitItem(track, filename) 333 | 334 | case "video": 335 | video = api.getVideo(resource.id) 336 | filename = formatResource( 337 | TEMPLATE or ctx.obj.config.template.video, video 338 | ) 339 | 340 | submitItem(video, filename) 341 | 342 | case "album": 343 | album = api.getAlbum(resource.id) 344 | 345 | downloadAlbum(album) 346 | 347 | case "artist": 348 | artist = api.getArtist(resource.id) 349 | logging.info(f"Artist {artist.name!r}") 350 | 351 | def getAllAlbums(singles: bool): 352 | offset = 0 353 | 354 | while True: 355 | artist_albums = api.getArtistAlbums( 356 | resource.id, 357 | offset=offset, 358 | filter="EPSANDSINGLES" if singles else "ALBUMS", 359 | ) 360 | 361 | for album in artist_albums.items: 362 | downloadAlbum(album) 363 | 364 | if ( 365 | artist_albums.limit + artist_albums.offset 366 | > artist_albums.totalNumberOfItems 367 | ): 368 | break 369 | 370 | offset += artist_albums.limit 371 | 372 | if SINGLES_FILTER == "include": 373 | getAllAlbums(False) 374 | getAllAlbums(True) 375 | else: 376 | getAllAlbums(SINGLES_FILTER == "only") 377 | 378 | case "playlist": 379 | playlist = api.getPlaylist(resource.id) 380 | logging.info(f"Playlist {playlist.title!r}") 381 | offset = 0 382 | 383 | while True: 384 | playlist_items = api.getPlaylistItems(playlist.uuid, offset=offset) 385 | 386 | for item in playlist_items.items: 387 | filename = formatResource( 388 | template=TEMPLATE or ctx.obj.config.template.playlist, 389 | resource=item.item, 390 | playlist_title=playlist.title, 391 | playlist_index=item.item.index // 100000, 392 | ) 393 | 394 | submitItem(item.item, filename) 395 | 396 | if ( 397 | playlist_items.limit + playlist_items.offset 398 | > playlist_items.totalNumberOfItems 399 | ): 400 | break 401 | 402 | offset += playlist_items.limit 403 | 404 | progress.start() 405 | 406 | # TODO: make sure every resource is unique 407 | for resource in ctx.obj.resources: 408 | try: 409 | handleResource(resource) 410 | 411 | except AuthError as e: 412 | logging.error(e) 413 | break 414 | 415 | except ApiError as e: 416 | logging.error(e) 417 | 418 | # session does not have streaming privileges 419 | if e.sub_status == 4006: 420 | break 421 | 422 | pool.shutdown(wait=True) 423 | progress.stop() 424 | 425 | 426 | UrlGroup.add_command(DownloadCommand) 427 | SearchGroup.add_command(DownloadCommand) 428 | FavGroup.add_command(DownloadCommand) 429 | FileGroup.add_command(DownloadCommand) 430 | -------------------------------------------------------------------------------- /tiddl/cli/download/fav.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from tiddl.utils import TidalResource, ResourceTypeLiteral 4 | from tiddl.cli.ctx import Context, passContext 5 | 6 | ResourceTypeList: list[ResourceTypeLiteral] = ["track", "video", "album", "artist", "playlist"] 7 | 8 | 9 | @click.group("fav") 10 | @click.option( 11 | "--resource", 12 | "-r", 13 | "resource_types", 14 | multiple=True, 15 | type=click.Choice(ResourceTypeList), 16 | ) 17 | @passContext 18 | def FavGroup(ctx: Context, resource_types: list[ResourceTypeLiteral]): 19 | """Get your Tidal favorites.""" 20 | 21 | api = ctx.obj.getApi() 22 | 23 | favorites = api.getFavorites() 24 | favorites_dict = favorites.model_dump() 25 | 26 | click.echo(type(resource_types)) 27 | 28 | if not resource_types: 29 | resource_types = ResourceTypeList 30 | 31 | stats: dict[ResourceTypeLiteral, int] = dict() 32 | 33 | for resource_type in resource_types: 34 | resources = favorites_dict[resource_type.upper()] 35 | 36 | stats[resource_type] = len(resources) 37 | 38 | for resource_id in resources: 39 | ctx.obj.resources.append(TidalResource(id=resource_id, type=resource_type)) 40 | 41 | # TODO: show pretty message 42 | 43 | click.echo(click.style(f"Loaded {len(ctx.obj.resources)} resources", "green")) 44 | 45 | for resource_type, count in stats.items(): 46 | click.echo(f"{resource_type} - {count}") 47 | -------------------------------------------------------------------------------- /tiddl/cli/download/file.py: -------------------------------------------------------------------------------- 1 | import click 2 | import json 3 | 4 | from io import TextIOWrapper 5 | from os.path import splitext 6 | 7 | from tiddl.utils import TidalResource 8 | from tiddl.cli.ctx import Context, passContext 9 | 10 | 11 | @click.group("file") 12 | @click.argument("filename", type=click.File(mode="r")) 13 | @passContext 14 | def FileGroup(ctx: Context, filename: TextIOWrapper): 15 | """Parse txt or JSON file with urls.""" 16 | 17 | _, extension = splitext(filename.name) 18 | 19 | resource_strings: list[str] 20 | 21 | match extension: 22 | case ".json": 23 | try: 24 | resource_strings = json.load(filename) 25 | except json.JSONDecodeError as e: 26 | raise click.UsageError(f"Cant decode JSON file - {e.msg}") 27 | 28 | case ".txt": 29 | resource_strings = [line.strip() for line in filename.readlines()] 30 | 31 | case _: 32 | raise click.UsageError(f"Unsupported file extension - {extension}") 33 | 34 | for string in resource_strings: 35 | try: 36 | ctx.obj.resources.append(TidalResource.fromString(string)) 37 | except ValueError as e: 38 | click.echo(click.style(e, "red")) 39 | 40 | click.echo(click.style(f"Loaded {len(ctx.obj.resources)} resources", "green")) 41 | -------------------------------------------------------------------------------- /tiddl/cli/download/search.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from tiddl.utils import TidalResource 4 | from tiddl.models.resource import Artist, Album, Playlist, Track, Video 5 | from tiddl.cli.ctx import Context, passContext 6 | 7 | 8 | @click.group("search") 9 | @click.argument("query") 10 | @passContext 11 | def SearchGroup(ctx: Context, query: str): 12 | """Search on Tidal.""" 13 | 14 | # TODO: give user interactive choice what to select 15 | 16 | api = ctx.obj.getApi() 17 | 18 | search = api.getSearch(query) 19 | 20 | # issue is that we get resource data in search api call, 21 | # in download we refetch that data. 22 | # it's not that big deal as we refetch one resource at most, 23 | # but it should be redesigned 24 | 25 | if not search.topHit: 26 | click.echo(f"No search results for '{query}'") 27 | return 28 | 29 | value = search.topHit.value 30 | icon = click.style("\u2bcc", "magenta") 31 | 32 | if isinstance(value, Album): 33 | resource = TidalResource(type="album", id=str(value.id)) 34 | click.echo(f"{icon} Album {value.title}") 35 | elif isinstance(value, Artist): 36 | resource = TidalResource(type="artist", id=str(value.id)) 37 | click.echo(f"{icon} Artist {value.name}") 38 | elif isinstance(value, Track): 39 | resource = TidalResource(type="track", id=str(value.id)) 40 | click.echo(f"{icon} Track {value.title}") 41 | elif isinstance(value, Playlist): 42 | resource = TidalResource(type="playlist", id=str(value.uuid)) 43 | click.echo(f"{icon} Playlist {value.title}") 44 | elif isinstance(value, Video): 45 | resource = TidalResource(type="video", id=str(value.id)) 46 | click.echo(f"{icon} Video {value.title}") 47 | 48 | ctx.obj.resources.append(resource) 49 | -------------------------------------------------------------------------------- /tiddl/cli/download/url.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from tiddl.utils import TidalResource 4 | from tiddl.cli.ctx import Context, passContext 5 | 6 | 7 | class TidalURL(click.ParamType): 8 | def convert(self, value: str, param, ctx) -> TidalResource: 9 | try: 10 | return TidalResource.fromString(value) 11 | except ValueError as e: 12 | self.fail(message=str(e), param=param, ctx=ctx) 13 | 14 | 15 | @click.group("url") 16 | @click.argument("url", type=TidalURL()) 17 | @passContext 18 | def UrlGroup(ctx: Context, url: TidalResource): 19 | """ 20 | Get Tidal URL. 21 | 22 | It can be Tidal link or `resource_type/resource_id` format. 23 | The resource can be a track, video, album, playlist or artist. 24 | """ 25 | 26 | ctx.obj.resources.append(url) 27 | -------------------------------------------------------------------------------- /tiddl/config.py: -------------------------------------------------------------------------------- 1 | from os import environ, makedirs 2 | from pydantic import BaseModel 3 | from pathlib import Path 4 | 5 | from tiddl.models.constants import TrackArg, SinglesFilter 6 | 7 | TIDDL_ENV_KEY = "TIDDL_PATH" 8 | 9 | # 3.0 TODO: rename HOME_PATH to TIDDL_PATH 10 | # 3.0 TODO: add /tiddl to Path.home() 11 | HOME_PATH = Path(environ[TIDDL_ENV_KEY]) if environ.get(TIDDL_ENV_KEY) else Path.home() 12 | 13 | makedirs(HOME_PATH, exist_ok=True) 14 | 15 | CONFIG_PATH = HOME_PATH / "tiddl.json" 16 | CONFIG_INDENT = 2 17 | 18 | class TemplateConfig(BaseModel): 19 | track: str = "{artist} - {title}" 20 | video: str = "{artist} - {title}" 21 | album: str = "{album_artist}/{album}/{number:02d}. {title}" 22 | playlist: str = "{playlist}/{playlist_number:02d}. {artist} - {title}" 23 | 24 | 25 | class DownloadConfig(BaseModel): 26 | quality: TrackArg = "high" 27 | path: Path = Path.home() / "Music" / "Tiddl" 28 | threads: int = 4 29 | singles_filter: SinglesFilter = "none" 30 | embed_lyrics: bool = False 31 | 32 | 33 | class AuthConfig(BaseModel): 34 | token: str = "" 35 | refresh_token: str = "" 36 | expires: int = 0 37 | user_id: str = "" 38 | country_code: str = "" 39 | 40 | 41 | class CoverConfig(BaseModel): 42 | save: bool = False 43 | size: int = 1280 44 | filename: str = "cover.jpg" 45 | 46 | 47 | class Config(BaseModel): 48 | template: TemplateConfig = TemplateConfig() 49 | download: DownloadConfig = DownloadConfig() 50 | cover: CoverConfig = CoverConfig() 51 | auth: AuthConfig = AuthConfig() 52 | omit_cache: bool = False 53 | 54 | def save(self): 55 | with open(CONFIG_PATH, "w") as f: 56 | f.write(self.model_dump_json(indent=CONFIG_INDENT)) 57 | 58 | @classmethod 59 | def fromFile(cls): 60 | try: 61 | with CONFIG_PATH.open() as f: 62 | config = cls.model_validate_json(f.read()) 63 | except FileNotFoundError: 64 | config = cls() 65 | 66 | config.save() 67 | return config 68 | -------------------------------------------------------------------------------- /tiddl/download.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from m3u8 import M3U8 4 | from requests import Session 5 | from pydantic import BaseModel 6 | from base64 import b64decode 7 | from xml.etree.ElementTree import fromstring 8 | 9 | from tiddl.models.api import TrackStream, VideoStream 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def parseManifestXML(xml_content: str): 16 | """ 17 | Parses XML manifest file of the track. 18 | """ 19 | 20 | NS = "{urn:mpeg:dash:schema:mpd:2011}" 21 | 22 | tree = fromstring(xml_content) 23 | 24 | representationElement = tree.find( 25 | f"{NS}Period/{NS}AdaptationSet/{NS}Representation" 26 | ) 27 | if representationElement is None: 28 | raise ValueError("Representation element not found") 29 | 30 | codecs = representationElement.get("codecs", "") 31 | 32 | segmentElement = representationElement.find(f"{NS}SegmentTemplate") 33 | if segmentElement is None: 34 | raise ValueError("SegmentTemplate element not found") 35 | 36 | url_template = segmentElement.get("media") 37 | if url_template is None: 38 | raise ValueError("No `media` attribute in SegmentTemplate") 39 | 40 | timelineElements = segmentElement.findall(f"{NS}SegmentTimeline/{NS}S") 41 | if not timelineElements: 42 | raise ValueError("SegmentTimeline elements not found") 43 | 44 | total = 0 45 | for element in timelineElements: 46 | total += 1 47 | count = element.get("r") 48 | if count is not None: 49 | total += int(count) 50 | 51 | urls = [url_template.replace("$Number$", str(i)) for i in range(0, total + 1)] 52 | 53 | return urls, codecs 54 | 55 | 56 | class TrackManifest(BaseModel): 57 | mimeType: str 58 | codecs: str 59 | encryptionType: str 60 | urls: list[str] 61 | 62 | 63 | def parseTrackStream(track_stream: TrackStream) -> tuple[list[str], str]: 64 | """Parse URLs and file extension from `track_stream`""" 65 | 66 | decoded_manifest = b64decode(track_stream.manifest).decode() 67 | 68 | match track_stream.manifestMimeType: 69 | case "application/vnd.tidal.bts": 70 | track_manifest = TrackManifest.model_validate_json(decoded_manifest) 71 | urls, codecs = track_manifest.urls, track_manifest.codecs 72 | 73 | case "application/dash+xml": 74 | urls, codecs = parseManifestXML(decoded_manifest) 75 | 76 | if codecs == "flac": 77 | file_extension = ".flac" 78 | if track_stream.audioQuality == "HI_RES_LOSSLESS": 79 | file_extension = ".m4a" 80 | elif codecs.startswith("mp4"): 81 | file_extension = ".m4a" 82 | else: 83 | raise ValueError( 84 | f"Unknown codecs `{codecs}` (trackId {track_stream.trackId}" 85 | ) 86 | 87 | return urls, file_extension 88 | 89 | 90 | def downloadTrackStream(track_stream: TrackStream) -> tuple[bytes, str]: 91 | """Download data from track stream and return it with file extension.""" 92 | 93 | urls, file_extension = parseTrackStream(track_stream) 94 | 95 | with Session() as s: 96 | stream_data = b"" 97 | 98 | for url in urls: 99 | req = s.get(url) 100 | stream_data += req.content 101 | 102 | return stream_data, file_extension 103 | 104 | 105 | def parseVideoStream(video_stream: VideoStream) -> list[str]: 106 | """Parse `video_stream` manifest and return video urls""" 107 | 108 | # TODO: add video quality arg, 109 | # for now we download the highest quality. 110 | # -vq option in download command 111 | 112 | class VideoManifest(BaseModel): 113 | mimeType: str 114 | urls: list[str] 115 | 116 | decoded_manifest = b64decode(video_stream.manifest).decode() 117 | manifest = VideoManifest.model_validate_json(decoded_manifest) 118 | 119 | with Session() as s: 120 | # get all qualities 121 | req = s.get(manifest.urls[0]) 122 | m3u8 = M3U8(req.text) 123 | 124 | # get highest quality 125 | uri = m3u8.playlists[-1].uri 126 | 127 | if not uri: 128 | raise ValueError("M3U8 Playlist does not have `uri`.") 129 | 130 | req = s.get(uri) 131 | video = M3U8(req.text) 132 | 133 | if not video.files: 134 | raise ValueError("M3U8 Playlist is empty.") 135 | 136 | urls = [url for url in video.files if url] 137 | 138 | return urls 139 | -------------------------------------------------------------------------------- /tiddl/exceptions.py: -------------------------------------------------------------------------------- 1 | class AuthError(Exception): 2 | def __init__( 3 | self, status: int, error: str, sub_status: str, error_description: str 4 | ): 5 | self.status = status 6 | self.error = error 7 | self.sub_status = sub_status 8 | self.error_description = error_description 9 | 10 | def __str__(self): 11 | return f"{self.status}: {self.error} - {self.error_description}" 12 | 13 | 14 | class ApiError(Exception): 15 | def __init__(self, status: int, subStatus: str, userMessage: str): 16 | self.status = status 17 | self.sub_status = subStatus 18 | self.user_message = userMessage 19 | 20 | def __str__(self): 21 | return f"{self.user_message} ({self.status} - {self.sub_status})" 22 | -------------------------------------------------------------------------------- /tiddl/metadata.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | 4 | from os import makedirs 5 | from pathlib import Path 6 | 7 | from mutagen.easymp4 import EasyMP4 as MutagenEasyMP4 8 | from mutagen.flac import FLAC as MutagenFLAC 9 | from mutagen.flac import Picture 10 | from mutagen.mp4 import MP4 as MutagenMP4 11 | from mutagen.mp4 import MP4Cover 12 | 13 | from tiddl.models.resource import Track, Video 14 | from tiddl.models.api import AlbumItemsCredits 15 | 16 | from typing import List 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def addMetadata( 22 | track_path: Path, 23 | track: Track, 24 | cover_data=b"", 25 | credits: List[AlbumItemsCredits.ItemWithCredits.CreditsEntry] = [], 26 | album_artist="", 27 | lyrics="", 28 | ): 29 | logger.debug((track_path, track.id)) 30 | 31 | extension = track_path.suffix 32 | 33 | # TODO: handle mutagen exceptions 34 | 35 | if extension == ".flac": 36 | metadata = MutagenFLAC(track_path) 37 | if cover_data: 38 | picture = Picture() 39 | picture.data = cover_data 40 | picture.mime = "image/jpeg" 41 | metadata.add_picture(picture) 42 | 43 | metadata["TITLE"] = track.title + (" ({})".format(track.version) if track.version else "") 44 | metadata["WORK"] = track.title + (" ({})".format(track.version) if track.version else "") 45 | metadata["TRACKNUMBER"] = str(track.trackNumber) 46 | metadata["DISCNUMBER"] = str(track.volumeNumber) 47 | 48 | metadata["ALBUM"] = track.album.title 49 | 50 | metadata["ARTIST"] = "; ".join( 51 | [artist.name.strip() for artist in track.artists] 52 | ) 53 | 54 | if album_artist: 55 | metadata["ALBUMARTIST"] = album_artist 56 | elif track.artist: 57 | metadata["ALBUMARTIST"] = track.artist.name 58 | 59 | if track.streamStartDate: 60 | metadata["DATE"] = track.streamStartDate.strftime("%Y-%m-%d") 61 | metadata["ORIGINALDATE"] = track.streamStartDate.strftime( 62 | "%Y-%m-%d" 63 | ) 64 | metadata["YEAR"] = str(track.streamStartDate.strftime("%Y")) 65 | metadata["ORIGINALYEAR"] = str(track.streamStartDate.strftime("%Y")) 66 | 67 | if track.copyright: 68 | metadata["COPYRIGHT"] = track.copyright 69 | 70 | metadata["ISRC"] = track.isrc 71 | 72 | if track.bpm: 73 | metadata["BPM"] = str(track.bpm) 74 | 75 | for entry in credits: 76 | metadata[entry.type.upper()] = [ 77 | contributor.name for contributor in entry.contributors 78 | ] 79 | 80 | if lyrics: 81 | metadata["LYRICS"] = lyrics 82 | 83 | elif extension == ".m4a": 84 | if lyrics or cover_data: 85 | metadata = MutagenMP4(track_path) 86 | 87 | if lyrics: 88 | metadata["\xa9lyr"] = [lyrics] 89 | 90 | if cover_data: 91 | metadata["covr"] = [ 92 | MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG) 93 | ] 94 | 95 | metadata.save() 96 | 97 | metadata = MutagenEasyMP4(track_path) 98 | metadata.update( 99 | { 100 | "title": track.title, 101 | "tracknumber": str(track.trackNumber), 102 | "discnumber": str(track.volumeNumber), 103 | "copyright": track.copyright if track.copyright else "", 104 | "albumartist": track.artist.name if track.artist else "", 105 | "artist": ";".join( 106 | [artist.name.strip() for artist in track.artists] 107 | ), 108 | "album": track.album.title, 109 | "date": str(track.streamStartDate) 110 | if track.streamStartDate 111 | else "", 112 | "bpm": str(track.bpm or 0), 113 | } 114 | ) 115 | 116 | else: 117 | raise ValueError(f"Unknown file extension: {extension}") 118 | 119 | try: 120 | metadata.save(track_path) 121 | except Exception as e: 122 | logger.error(f"Failed to add metadata to {track_path}: {e}") 123 | 124 | 125 | def addVideoMetadata(path: Path, video: Video): 126 | metadata = MutagenEasyMP4(path) 127 | 128 | metadata.update( 129 | { 130 | "title": video.title, 131 | "albumartist": video.artist.name if video.artist else "", 132 | "artist": ";".join( 133 | [artist.name.strip() for artist in video.artists] 134 | ), 135 | "album": video.album.title if video.album else "", 136 | "date": str(video.streamStartDate) if video.streamStartDate else "", 137 | } 138 | ) 139 | 140 | if video.trackNumber: 141 | metadata["tracknumber"] = str(video.trackNumber) 142 | 143 | if video.volumeNumber: 144 | metadata["discnumber"] = str(video.volumeNumber) 145 | 146 | try: 147 | metadata.save(path) 148 | except Exception as e: 149 | logger.error(f"Failed to add metadata to {path}: {e}") 150 | 151 | 152 | class Cover: 153 | # TODO: cache covers 154 | 155 | def __init__(self, uid: str, size=1280) -> None: 156 | if size > 1280: 157 | logger.warning( 158 | f"can not set cover size higher than 1280 (user set: {size})" 159 | ) 160 | size = 1280 161 | 162 | self.uid = uid 163 | 164 | formatted_uid = uid.replace("-", "/") 165 | self.url = f"https://resources.tidal.com/images/{formatted_uid}/{size}x{size}.jpg" 166 | 167 | logger.debug((self.uid, self.url)) 168 | 169 | self.content = self._get() 170 | 171 | def _get(self) -> bytes: 172 | req = requests.get(self.url) 173 | 174 | if req.status_code != 200: 175 | logger.error( 176 | f"could not download cover. ({req.status_code}) {self.url}" 177 | ) 178 | return b"" 179 | 180 | logger.debug(f"got cover: {self.uid}") 181 | 182 | return req.content 183 | 184 | def save(self, directory_path: Path, filename="cover.jpg"): 185 | if not self.content: 186 | logger.error("cover file content is empty") 187 | return 188 | 189 | file = directory_path / filename 190 | 191 | if file.exists(): 192 | logger.debug(f"cover already exists ({file})") 193 | return 194 | 195 | makedirs(directory_path, exist_ok=True) 196 | 197 | try: 198 | with file.open("wb") as f: 199 | f.write(self.content) 200 | 201 | except FileNotFoundError as e: 202 | logger.error(f"could not save cover. {file} -> {e}") 203 | -------------------------------------------------------------------------------- /tiddl/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oskvr37/tiddl/a4a7e66b841eecd16b3c5e4807d869dd7a34f1e7/tiddl/models/__init__.py -------------------------------------------------------------------------------- /tiddl/models/api.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import Optional, List, Literal, Union 3 | 4 | from tiddl.models.resource import Album, Artist, Playlist, Track, TrackQuality, Video 5 | 6 | __all__ = [ 7 | "SessionResponse", 8 | "ArtistAlbumsItems", 9 | "AlbumItems", 10 | "PlaylistItems", 11 | "Favorites", 12 | "TrackStream", 13 | "Search", 14 | "Lyrics" 15 | ] 16 | 17 | 18 | class SessionResponse(BaseModel): 19 | class Client(BaseModel): 20 | id: int 21 | name: str 22 | authorizedForOffline: bool 23 | authorizedForOfflineDate: Optional[str] 24 | 25 | sessionId: str 26 | userId: int 27 | countryCode: str 28 | channelId: int 29 | partnerId: int 30 | client: Client 31 | 32 | 33 | class Items(BaseModel): 34 | limit: int 35 | offset: int 36 | totalNumberOfItems: int 37 | 38 | 39 | class ArtistAlbumsItems(Items): 40 | items: List[Album] 41 | 42 | 43 | ItemType = Literal["track", "video"] 44 | 45 | 46 | class AlbumItems(Items): 47 | class VideoItem(BaseModel): 48 | item: Video 49 | type: ItemType = "video" 50 | 51 | class TrackItem(BaseModel): 52 | item: Track 53 | type: ItemType = "track" 54 | 55 | items: List[Union[TrackItem, VideoItem]] 56 | 57 | 58 | class AlbumItemsCredits(Items): 59 | class ItemWithCredits(BaseModel): 60 | class CreditsEntry(BaseModel): 61 | class Contributor(BaseModel): 62 | name: str 63 | id: Optional[int] = None 64 | 65 | type: str 66 | contributors: List[Contributor] 67 | 68 | credits: List[CreditsEntry] 69 | 70 | class VideoItem(ItemWithCredits): 71 | item: Video 72 | type: ItemType = "video" 73 | 74 | class TrackItem(ItemWithCredits): 75 | item: Track 76 | type: ItemType = "track" 77 | 78 | items: List[Union[TrackItem, VideoItem]] 79 | 80 | 81 | class PlaylistItems(Items): 82 | class PlaylistVideoItem(BaseModel): 83 | class PlaylistVideo(Video): 84 | dateAdded: str 85 | index: int 86 | itemUuid: str 87 | 88 | item: PlaylistVideo 89 | type: ItemType = "video" 90 | cut: None 91 | 92 | class PlaylistTrackItem(BaseModel): 93 | class PlaylistTrack(Track): 94 | dateAdded: str 95 | index: int 96 | itemUuid: str 97 | 98 | item: PlaylistTrack 99 | type: ItemType = "track" 100 | cut: None 101 | 102 | items: List[Union[PlaylistTrackItem, PlaylistVideoItem]] 103 | 104 | 105 | class Favorites(BaseModel): 106 | PLAYLIST: List[str] 107 | ALBUM: List[str] 108 | VIDEO: List[str] 109 | TRACK: List[str] 110 | ARTIST: List[str] 111 | 112 | 113 | class TrackStream(BaseModel): 114 | trackId: int 115 | assetPresentation: Literal["FULL"] 116 | audioMode: Literal["STEREO"] 117 | audioQuality: TrackQuality 118 | manifestMimeType: Literal["application/dash+xml", "application/vnd.tidal.bts"] 119 | manifestHash: str 120 | manifest: str 121 | albumReplayGain: float 122 | albumPeakAmplitude: float 123 | trackReplayGain: float 124 | trackPeakAmplitude: float 125 | bitDepth: Optional[int] = None 126 | sampleRate: Optional[int] = None 127 | 128 | 129 | class VideoStream(BaseModel): 130 | videoId: int 131 | streamType: Literal["ON_DEMAND"] 132 | assetPresentation: Literal["FULL"] 133 | videoQuality: Literal["HIGH", "MEDIUM"] 134 | # streamingSessionId: str # only in web? 135 | manifestMimeType: Literal["application/vnd.tidal.emu"] 136 | manifestHash: str 137 | manifest: str 138 | 139 | 140 | class SearchAlbum(Album): 141 | # TODO: remove the artist field instead of making it None 142 | artist: None = None 143 | 144 | 145 | class Search(BaseModel): 146 | class Artists(Items): 147 | items: List[Artist] 148 | 149 | class Albums(Items): 150 | items: List[SearchAlbum] 151 | 152 | class Playlists(Items): 153 | items: List[Playlist] 154 | 155 | class Tracks(Items): 156 | items: List[Track] 157 | 158 | class Videos(Items): 159 | items: List[Video] 160 | 161 | class TopHit(BaseModel): 162 | value: Union[Artist, Track, Playlist, SearchAlbum] 163 | type: Literal["ARTISTS", "TRACKS", "PLAYLISTS", "ALBUMS"] 164 | 165 | artists: Artists 166 | albums: Albums 167 | playlists: Playlists 168 | tracks: Tracks 169 | videos: Videos 170 | topHit: Optional[TopHit] = None 171 | 172 | 173 | class Lyrics(BaseModel): 174 | isRightToLeft: bool 175 | lyrics: str 176 | lyricsProvider: str 177 | providerCommontrackId: str 178 | providerLyricsId: str 179 | subtitles: str 180 | trackId: int 181 | -------------------------------------------------------------------------------- /tiddl/models/auth.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from pydantic import BaseModel 3 | 4 | 5 | class AuthUser(BaseModel): 6 | userId: int 7 | email: str 8 | countryCode: str 9 | fullName: Optional[str] 10 | firstName: Optional[str] 11 | lastName: Optional[str] 12 | nickname: Optional[str] 13 | username: str 14 | address: Optional[str] 15 | city: Optional[str] 16 | postalcode: Optional[str] 17 | usState: Optional[str] 18 | phoneNumber: Optional[str] 19 | birthday: Optional[int] 20 | channelId: int 21 | parentId: int 22 | acceptedEULA: bool 23 | created: int 24 | updated: int 25 | facebookUid: int 26 | appleUid: Optional[str] 27 | googleUid: Optional[str] 28 | accountLinkCreated: bool 29 | emailVerified: bool 30 | newUser: bool 31 | 32 | 33 | class AuthResponse(BaseModel): 34 | user: AuthUser 35 | scope: str 36 | clientName: str 37 | token_type: str 38 | access_token: str 39 | expires_in: int 40 | user_id: int 41 | 42 | 43 | class AuthResponseWithRefresh(AuthResponse): 44 | refresh_token: str 45 | 46 | 47 | class AuthDeviceResponse(BaseModel): 48 | deviceCode: str 49 | userCode: str 50 | verificationUri: str 51 | verificationUriComplete: str 52 | expiresIn: int 53 | interval: int 54 | -------------------------------------------------------------------------------- /tiddl/models/constants.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | TrackQuality = Literal["LOW", "HIGH", "LOSSLESS", "HI_RES_LOSSLESS"] 4 | TrackArg = Literal["low", "normal", "high", "master"] 5 | SinglesFilter = Literal["none", "only", "include"] 6 | 7 | ARG_TO_QUALITY: dict[TrackArg, TrackQuality] = { 8 | "low": "LOW", 9 | "normal": "HIGH", 10 | "high": "LOSSLESS", 11 | "master": "HI_RES_LOSSLESS", 12 | } 13 | 14 | QUALITY_TO_ARG = {v: k for k, v in ARG_TO_QUALITY.items()} 15 | -------------------------------------------------------------------------------- /tiddl/models/resource.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from datetime import datetime 3 | from typing import Optional, List, Literal, Dict 4 | 5 | from tiddl.models.constants import TrackQuality 6 | 7 | 8 | __all__ = ["Track", "Video", "Album", "Playlist", "Artist"] 9 | 10 | 11 | class Track(BaseModel): 12 | 13 | class Artist(BaseModel): 14 | id: int 15 | name: str 16 | type: str 17 | picture: Optional[str] = None 18 | 19 | class Album(BaseModel): 20 | id: int 21 | title: str 22 | cover: Optional[str] = None 23 | vibrantColor: Optional[str] = None 24 | videoCover: Optional[str] = None 25 | 26 | id: int 27 | title: str 28 | duration: int 29 | replayGain: float 30 | peak: float 31 | allowStreaming: bool 32 | streamReady: bool 33 | adSupportedStreamReady: bool 34 | djReady: bool 35 | stemReady: bool 36 | streamStartDate: Optional[datetime] = None 37 | premiumStreamingOnly: bool 38 | trackNumber: int 39 | volumeNumber: int 40 | version: Optional[str] = None 41 | popularity: int 42 | copyright: Optional[str] = None 43 | bpm: Optional[int] = None 44 | url: str 45 | isrc: str 46 | editable: bool 47 | explicit: bool 48 | audioQuality: TrackQuality 49 | audioModes: List[str] 50 | mediaMetadata: Dict[str, List[str]] 51 | # for real, artist can be None? 52 | artist: Optional[Artist] = None 53 | artists: List[Artist] 54 | album: Album 55 | mixes: Optional[Dict[str, str]] = None 56 | 57 | 58 | class Video(BaseModel): 59 | 60 | class Artist(BaseModel): 61 | id: int 62 | name: str 63 | type: str 64 | picture: Optional[str] = None 65 | 66 | class Album(BaseModel): 67 | id: int 68 | title: str 69 | cover: str 70 | vibrantColor: Optional[str] = None 71 | videoCover: Optional[str] = None 72 | 73 | id: int 74 | title: str 75 | volumeNumber: int 76 | trackNumber: int 77 | streamStartDate: Optional[datetime] = None 78 | imagePath: Optional[str] = None 79 | imageId: str 80 | vibrantColor: Optional[str] = None 81 | duration: int 82 | quality: str 83 | streamReady: bool 84 | adSupportedStreamReady: bool 85 | djReady: bool 86 | stemReady: bool 87 | streamStartDate: Optional[datetime] = None 88 | allowStreaming: bool 89 | explicit: bool 90 | popularity: int 91 | type: str 92 | adsUrl: Optional[str] = None 93 | adsPrePaywallOnly: bool 94 | artist: Optional[Artist] = None 95 | artists: List[Artist] 96 | album: Optional[Album] = None 97 | 98 | 99 | class Album(BaseModel): 100 | 101 | class Artist(BaseModel): 102 | id: int 103 | name: str 104 | type: Literal["MAIN", "FEATURED"] 105 | picture: Optional[str] = None 106 | 107 | class MediaMetadata(BaseModel): 108 | tags: List[Literal["LOSSLESS", "HIRES_LOSSLESS", "DOLBY_ATMOS"]] 109 | 110 | id: int 111 | title: str 112 | duration: int 113 | streamReady: bool 114 | adSupportedStreamReady: bool 115 | djReady: bool 116 | stemReady: bool 117 | streamStartDate: Optional[datetime] = None 118 | allowStreaming: bool 119 | premiumStreamingOnly: bool 120 | numberOfTracks: int 121 | numberOfVideos: int 122 | numberOfVolumes: int 123 | releaseDate: Optional[str] = None 124 | copyright: Optional[str] = None 125 | type: str 126 | version: Optional[str] = None 127 | url: str 128 | cover: Optional[str] = None 129 | vibrantColor: Optional[str] = None 130 | videoCover: Optional[str] = None 131 | explicit: bool 132 | upc: str 133 | popularity: int 134 | audioQuality: str 135 | audioModes: List[str] 136 | mediaMetadata: MediaMetadata 137 | artist: Artist 138 | artists: List[Artist] 139 | 140 | 141 | class Playlist(BaseModel): 142 | 143 | class Creator(BaseModel): 144 | id: int 145 | 146 | uuid: str 147 | title: str 148 | numberOfTracks: int 149 | numberOfVideos: int 150 | creator: Creator | Dict 151 | description: Optional[str] = None 152 | duration: int 153 | lastUpdated: str 154 | created: str 155 | type: str 156 | publicPlaylist: bool 157 | url: str 158 | image: Optional[str] = None 159 | popularity: int 160 | squareImage: str 161 | promotedArtists: List[Album.Artist] 162 | lastItemAddedAt: Optional[str] = None 163 | 164 | 165 | class Artist(BaseModel): 166 | 167 | class Role(BaseModel): 168 | categoryId: int 169 | category: Literal[ 170 | "Artist", 171 | "Songwriter", 172 | "Performer", 173 | "Producer", 174 | "Engineer", 175 | "Production team", 176 | "Misc", 177 | ] 178 | 179 | class Mix(BaseModel): 180 | ARTIST_MIX: str 181 | MASTER_ARTIST_MIX: Optional[str] = None 182 | 183 | id: int 184 | name: str 185 | artistTypes: Optional[List[Literal["ARTIST", "CONTRIBUTOR"]]] = None 186 | url: Optional[str] = None 187 | picture: Optional[str] = None 188 | # only in search i guess 189 | selectedAlbumCoverFallback: Optional[str] = None 190 | popularity: Optional[int] = None 191 | artistRoles: Optional[List[Role]] = None 192 | mixes: Optional[Mix | Dict] = None 193 | -------------------------------------------------------------------------------- /tiddl/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | import logging 4 | 5 | from ffmpeg import FFmpeg 6 | 7 | from pydantic import BaseModel 8 | from urllib.parse import urlparse 9 | from pathlib import Path 10 | 11 | from typing import Literal, Union, get_args 12 | 13 | from tiddl.models.constants import TrackQuality, QUALITY_TO_ARG 14 | from tiddl.models.resource import Track, Video 15 | 16 | ResourceTypeLiteral = Literal["track", "video", "album", "playlist", "artist"] 17 | 18 | 19 | class TidalResource(BaseModel): 20 | type: ResourceTypeLiteral 21 | id: str 22 | 23 | @property 24 | def url(self) -> str: 25 | return f"https://listen.tidal.com/{self.type}/{self.id}" 26 | 27 | @classmethod 28 | def fromString(cls, string: str): 29 | """ 30 | Extracts the resource type (e.g., "track", "album") 31 | and resource ID from a given input string. 32 | 33 | The input string can either be a full URL or a shorthand string 34 | in the format `resource_type/resource_id` (e.g., `track/12345678`). 35 | """ 36 | 37 | path = urlparse(string).path 38 | resource_type, resource_id = path.split("/")[-2:] 39 | 40 | if resource_type not in get_args(ResourceTypeLiteral): 41 | raise ValueError(f"Invalid resource type: {resource_type}") 42 | 43 | if not resource_id.isdigit() and resource_type != "playlist": 44 | raise ValueError(f"Invalid resource id: {resource_id}") 45 | 46 | return cls(type=resource_type, id=resource_id) # type: ignore 47 | 48 | def __str__(self) -> str: 49 | return f"{self.type}/{self.id}" 50 | 51 | 52 | def sanitizeString(string: str) -> str: 53 | pattern = r'[\\/:"*?<>|]+' 54 | return re.sub(pattern, "", string) 55 | 56 | 57 | def formatTrack( 58 | template: str, 59 | track: Track, 60 | album_artist="", 61 | playlist_title="", 62 | playlist_index=0, 63 | ) -> str: 64 | artist = sanitizeString(track.artist.name) if track.artist else "" 65 | features = [ 66 | sanitizeString(track_artist.name) 67 | for track_artist in track.artists 68 | if track_artist.name != artist 69 | ] 70 | 71 | track_dict = { 72 | "id": str(track.id), 73 | "title": sanitizeString(track.title), 74 | "version": sanitizeString(track.version or ""), 75 | "artist": artist, 76 | "artists": ", ".join(features + [artist]), 77 | "features": ", ".join(features), 78 | "album": sanitizeString(track.album.title), 79 | "number": track.trackNumber, 80 | "disc": track.volumeNumber, 81 | "date": (track.streamStartDate if track.streamStartDate else ""), 82 | # i think we can remove year as we are able to format date 83 | "year": track.streamStartDate.strftime("%Y") 84 | if track.streamStartDate 85 | else "", 86 | "playlist": sanitizeString(playlist_title), 87 | "bpm": track.bpm or "", 88 | "quality": QUALITY_TO_ARG[track.audioQuality], 89 | "album_artist": sanitizeString(album_artist), 90 | "playlist_number": playlist_index or 0, 91 | } 92 | 93 | formatted_track = template.format(**track_dict).strip() 94 | 95 | disallowed_chars = r'[\\:"*?<>|]+' 96 | invalid_chars = re.findall(disallowed_chars, formatted_track) 97 | 98 | if invalid_chars: 99 | raise ValueError( 100 | f"Template '{template}' and formatted track '{formatted_track}' contains disallowed characters: {' '.join(sorted(set(invalid_chars)))}" 101 | ) 102 | 103 | return formatted_track 104 | 105 | 106 | def formatResource( 107 | template: str, 108 | resource: Union[Track, Video], 109 | album_artist="", 110 | playlist_title="", 111 | playlist_index=0, 112 | ) -> str: 113 | artist = sanitizeString(resource.artist.name) if resource.artist else "" 114 | 115 | features = [ 116 | sanitizeString(item_artist.name) 117 | for item_artist in resource.artists 118 | if item_artist.name != artist 119 | ] 120 | 121 | resource_dict = { 122 | "id": str(resource.id), 123 | "title": sanitizeString(resource.title), 124 | "artist": artist, 125 | "artists": ", ".join(features + [artist]), 126 | "features": ", ".join(features), 127 | "album": sanitizeString(resource.album.title if resource.album else ""), 128 | "number": resource.trackNumber, 129 | "disc": resource.volumeNumber, 130 | "date": (resource.streamStartDate if resource.streamStartDate else ""), 131 | # i think we can remove year as we are able to format date 132 | "year": resource.streamStartDate.strftime("%Y") 133 | if resource.streamStartDate 134 | else "", 135 | "playlist": sanitizeString(playlist_title), 136 | "album_artist": sanitizeString(album_artist), 137 | "playlist_number": playlist_index or 0, 138 | "quality": "", 139 | "version": "", 140 | "bpm": "", 141 | } 142 | 143 | if isinstance(resource, Track): 144 | resource_dict.update( 145 | { 146 | "version": sanitizeString(resource.version or ""), 147 | "quality": QUALITY_TO_ARG[resource.audioQuality], 148 | "bpm": resource.bpm or "", 149 | } 150 | ) 151 | 152 | elif isinstance(resource, Video): 153 | resource_dict.update({"quality": resource.quality}) 154 | 155 | formatted_template = template.format(**resource_dict).strip() 156 | 157 | disallowed_chars = r'[\\:"*?<>|]+' 158 | invalid_chars = re.findall(disallowed_chars, formatted_template) 159 | 160 | if invalid_chars: 161 | raise ValueError( 162 | f"Template '{template}' and formatted resource '{formatted_template}'" 163 | f"contains disallowed characters: {' '.join(sorted(set(invalid_chars)))}" 164 | ) 165 | 166 | return formatted_template 167 | 168 | 169 | def trackExists( 170 | track_quality: TrackQuality, download_quality: TrackQuality, file_name: Path 171 | ): 172 | """ 173 | Predict track extension and check if track file exists. 174 | """ 175 | 176 | FLAC_QUALITIES: list[TrackQuality] = ["LOSSLESS", "HI_RES_LOSSLESS"] 177 | 178 | if download_quality in FLAC_QUALITIES and track_quality in FLAC_QUALITIES: 179 | extension = ".flac" 180 | else: 181 | extension = ".m4a" 182 | 183 | full_file_name = file_name.with_suffix(extension) 184 | 185 | return full_file_name.exists() 186 | 187 | 188 | def convertFileExtension( 189 | source_file: Path, 190 | extension: str, 191 | remove_source=False, 192 | is_video=False, 193 | copy_audio=False, 194 | ) -> Path: 195 | """ 196 | Converts `source_file` extension and returns `Path` of file with new `extension`. 197 | 198 | Removes `source_file` when `remove_source` is truthy. 199 | """ 200 | 201 | try: 202 | output_file = source_file.with_suffix(extension) 203 | except ValueError as e: 204 | logging.error(e) 205 | return source_file 206 | 207 | logging.debug((source_file, output_file, extension)) 208 | 209 | if extension == source_file.suffix: 210 | return source_file 211 | 212 | ffmpeg_args = {"loglevel": "error"} 213 | 214 | if copy_audio: 215 | ffmpeg_args["c:a"] = "copy" 216 | 217 | if is_video: 218 | ffmpeg_args["c:v"] = "copy" 219 | 220 | ( 221 | FFmpeg() 222 | .option("y") 223 | .input(url=str(source_file)) 224 | .output(url=str(output_file), options=None, **ffmpeg_args) 225 | ).execute() 226 | 227 | if remove_source: 228 | os.remove(source_file) 229 | 230 | return output_file 231 | --------------------------------------------------------------------------------