├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── config.yml │ ├── feature.yml │ └── help.yml ├── actions │ └── setup-poetry-env │ │ └── action.yml └── workflows │ ├── master.yml │ └── on-release-master.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── assets └── app.png ├── docs ├── index.md └── modules.md ├── mkdocs.yml ├── poetry.lock ├── poetry.toml ├── pyproject.toml ├── setup.cfg ├── tests └── test_foo.py ├── tidal_dl_ng ├── __init__.py ├── api.py ├── cli.py ├── config.py ├── constants.py ├── dialog.py ├── download.py ├── gui.py ├── helper │ ├── __init__.py │ ├── decorator.py │ ├── decryption.py │ ├── exceptions.py │ ├── gui.py │ ├── path.py │ ├── tidal.py │ └── wrapper.py ├── logger.py ├── metadata.py ├── model │ ├── __init__.py │ ├── cfg.py │ ├── downloader.py │ ├── gui_data.py │ └── meta.py ├── ui │ ├── __init__.py │ ├── default_album_image.png │ ├── dialog_login.py │ ├── dialog_login.ui │ ├── dialog_settings.py │ ├── dialog_settings.ui │ ├── dialog_version.py │ ├── dialog_version.ui │ ├── dummy_register.py │ ├── dummy_wiggly.py │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── main.py │ ├── main.ui │ └── spinner.py └── worker.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | max_line_length = 120 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | trim_trailing_whitespace = true 11 | 12 | [*.{py, pyi}] 13 | indent_style = space 14 | indent_size = 4 15 | 16 | [Makefile] 17 | indent_style = space 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [*.{diff,patch}] 23 | trim_trailing_whitespace = false 24 | 25 | [*.json] 26 | indent_style = space 27 | indent_size = 4 28 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: exislow 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | custom: ["https://www.buymeacoffee.com/exislow"] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report. 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this bug report! You must use a clear title for this bug. 9 | - type: checkboxes 10 | id: search-done 11 | attributes: 12 | label: You must use the search before you create an issue! 13 | description: "**Search [here](https://github.com/exislow/tidal-dl-ng/issues?q=is%3Aissue+is%3Aopen%2Cclosed).** Otherwise this issue gets closed without any comment. Please keep in mind that everyone's time is very precious." 14 | options: 15 | - label: I did use the search, I promise! 16 | - type: textarea 17 | id: what-happened 18 | attributes: 19 | label: What happened? 20 | description: Also tell us, what did you expect to happen? 21 | placeholder: Use three sentences (!) or more. Otherwise your report gets rejected. Provide all necessary information to be able to **reproduce** this bug, for instance, links and exact names to your items to tried to download, images of the error, and the command you have used. If you do not this issue will be rejected and closed. 22 | validations: 23 | required: true 24 | - type: input 25 | id: version-app 26 | attributes: 27 | label: Version App 28 | description: What version of our software are you running? 29 | placeholder: vX.Y.Z 30 | validations: 31 | required: true 32 | - type: dropdown 33 | id: os 34 | attributes: 35 | label: What operating system do you use? 36 | multiple: false 37 | options: 38 | - macOS 39 | - Linux 40 | - Windows 41 | - type: input 42 | id: version-os 43 | attributes: 44 | label: Version OS 45 | description: What is the version of your operating system? 46 | placeholder: Ubuntu 24.04.1 LTS / macOS 15.2 / Windows 10 22H2 47 | validations: 48 | required: true 49 | - type: textarea 50 | id: logs 51 | attributes: 52 | label: Relevant log output 53 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. No logs? Issue will be rejected and closed. 54 | render: shell 55 | - type: textarea 56 | id: config 57 | attributes: 58 | label: Your settings 59 | description: Please copy and paste your settings from `~/.config/tidal_dl_ng/settings.json` (macOS & Linux) / `%HOMEPATH%\.config\tidal_dl_ng\settings.json` (Windows). This will be automatically formatted into code, so no need for backticks. 60 | render: json 61 | validations: 62 | required: true 63 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: File a feature request. 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | We appreciate your feedback on how to improve this project. Please be sure to include as much details & any images if possible! Otherwise this issue gets rejected and closed. 9 | - type: checkboxes 10 | id: search-done 11 | attributes: 12 | label: You must use the search before you create an issue! 13 | description: "**Search [here](https://github.com/exislow/tidal-dl-ng/issues?q=is%3Aissue+is%3Aopen%2Cclosed).** Otherwise this issue gets closed without any comment. Please keep in mind that everyone's time is very precious." 14 | options: 15 | - label: I did use the search, I promise! 16 | - type: textarea 17 | id: situation 18 | attributes: 19 | label: Current Situation 20 | description: Describe the current situation, which is lacking of something you like to have. 21 | placeholder: Write at least three sentences (!) and be as precise as possible. 22 | validations: 23 | required: true 24 | - type: textarea 25 | id: suggestion 26 | attributes: 27 | label: Suggestion / Feature Request 28 | description: Describe the feature(s) you would like to see added. 29 | placeholder: Write three sentences (!) or more and be as precise as possible. 30 | validations: 31 | required: true 32 | - type: dropdown 33 | id: os 34 | attributes: 35 | label: What operating system do you use? 36 | multiple: false 37 | options: 38 | - macOS 39 | - Linux 40 | - Windows 41 | - type: input 42 | id: version-os 43 | attributes: 44 | label: Version OS 45 | description: What is the version of your operating system? 46 | placeholder: Ubuntu 24.04.1 LTS / macOS 15.2 / Windows 10 22H2 47 | validations: 48 | required: true 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/help.yml: -------------------------------------------------------------------------------- 1 | name: Help 2 | description: File a question for help. 3 | labels: ["help wanted"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | If no other issue template fits please use this. 9 | - type: checkboxes 10 | id: search-done 11 | attributes: 12 | label: You must use the search before you create an issue! 13 | description: "**Search [here](https://github.com/exislow/tidal-dl-ng/issues?q=is%3Aissue+is%3Aopen%2Cclosed).** Otherwise this issue gets closed without any comment. Please keep in mind that everyone's time is very precious." 14 | options: 15 | - label: I did use the search, I promise! 16 | - type: textarea 17 | id: help 18 | attributes: 19 | label: I need Help. 20 | description: Describe the current situation and state your question. 21 | placeholder: Write at least three sentences and be as precise as possible. **Provide all necessary information** to be able to **reproduce** your case / what you try to describe. Otherwise this issue will be closed! 22 | validations: 23 | required: true 24 | - type: dropdown 25 | id: os 26 | attributes: 27 | label: What operating system do you use? 28 | multiple: false 29 | options: 30 | - macOS 31 | - Linux 32 | - Windows 33 | - type: input 34 | id: version-os 35 | attributes: 36 | label: Version OS 37 | description: What is the version of your operating system? 38 | placeholder: Ubuntu 24.04.1 LTS / macOS 15.2 / Windows 10 22H2 39 | validations: 40 | required: true 41 | - type: textarea 42 | id: config 43 | attributes: 44 | label: Your settings 45 | description: Please copy and paste your settings from `~/.config/tidal_dl_ng/settings.json` (macOS & Linux) / `%HOMEPATH%\.config\tidal_dl_ng\settings.json` (Windows). This will be automatically formatted into code, so no need for backticks. 46 | render: json 47 | validations: 48 | required: true 49 | -------------------------------------------------------------------------------- /.github/actions/setup-poetry-env/action.yml: -------------------------------------------------------------------------------- 1 | name: "setup-poetry-env" 2 | description: "Composite action to setup the Python and poetry environment." 3 | 4 | inputs: 5 | python-version: 6 | required: false 7 | description: "The python version to use" 8 | default: "3.12" 9 | 10 | runs: 11 | using: "composite" 12 | steps: 13 | - name: Set up python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: ${{ inputs.python-version }} 17 | 18 | - name: Install Poetry 19 | uses: snok/install-poetry@v1 20 | with: 21 | virtualenvs-in-project: true 22 | virtualenvs-create: true 23 | installer-parallel: true 24 | 25 | # - name: Load cached venv 26 | # id: cached-poetry-dependencies 27 | # uses: actions/cache@v4 28 | # with: 29 | # path: .venv 30 | # key: venv-${{ runner.os }}-${{ inputs.python-version }}-${{ hashFiles('poetry.lock') }} 31 | 32 | - name: Install dependencies 33 | # if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 34 | run: poetry install --no-interaction --all-extras --with dev,docs 35 | shell: bash 36 | -------------------------------------------------------------------------------- /.github/workflows/master.yml: -------------------------------------------------------------------------------- 1 | name: master 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: [opened, synchronize, reopened] 9 | 10 | jobs: 11 | quality: 12 | runs-on: ubuntu-24.04 13 | steps: 14 | - name: Check out 15 | uses: actions/checkout@v4 16 | 17 | - uses: actions/cache@v4 18 | with: 19 | path: ~/.cache/pre-commit 20 | key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} 21 | 22 | - name: Set up the environment 23 | uses: ./.github/actions/setup-poetry-env 24 | 25 | - name: Run checks 26 | run: make check 27 | 28 | tox: 29 | runs-on: ubuntu-24.04 30 | strategy: 31 | matrix: 32 | python-version: ["3.12"] 33 | fail-fast: false 34 | steps: 35 | - name: Check out 36 | uses: actions/checkout@v4 37 | 38 | - name: Set up python 39 | uses: actions/setup-python@v5 40 | with: 41 | python-version: ${{ matrix.python-version }} 42 | 43 | - name: Install Poetry 44 | uses: snok/install-poetry@v1 45 | 46 | - name: Load cached venv 47 | uses: actions/cache@v4 48 | with: 49 | path: .tox 50 | key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }} 51 | 52 | - name: Install tox 53 | run: | 54 | python -m pip install --upgrade pip 55 | python -m pip install tox tox-gh-actions 56 | 57 | - name: Test with tox 58 | run: tox 59 | 60 | check-docs: 61 | runs-on: ubuntu-24.04 62 | steps: 63 | - name: Check out 64 | uses: actions/checkout@v4 65 | 66 | - name: Set up the environment 67 | uses: ./.github/actions/setup-poetry-env 68 | 69 | - name: Check if documentation can be built 70 | run: poetry run mkdocs build -s 71 | -------------------------------------------------------------------------------- /.github/workflows/on-release-master.yml: -------------------------------------------------------------------------------- 1 | name: release-master 2 | 3 | permissions: 4 | contents: write 5 | 6 | defaults: 7 | run: 8 | shell: bash 9 | 10 | on: 11 | # release: 12 | # types: [published] 13 | # branches: [master] 14 | push: 15 | tags: 16 | - "v*.*.*" 17 | 18 | env: 19 | ASSET_EXTENSION: ".zip" 20 | OUT_NAME_FILE: "TIDAL-Downloader-NG" 21 | ARCH_MACOS_X64: "macos-x64" 22 | ARCH_MACOS_ARM64: "macos-arm64" 23 | ARCH_LINUX_X64: "linux-x64" 24 | ARCH_LINUX_ARM64: "linux-arm64" 25 | ARCH_WINDOWS_X64: "windows-x64" 26 | 27 | jobs: 28 | publish: 29 | runs-on: ubuntu-24.04 30 | steps: 31 | - name: Check out 32 | uses: actions/checkout@v4 33 | - name: Set up the environment 34 | uses: ./.github/actions/setup-poetry-env 35 | - name: Export tag 36 | id: vars 37 | run: echo tag=${GITHUB_REF#refs/*/} >> $GITHUB_OUTPUT 38 | - name: Build and publish (PyPi) 39 | run: | 40 | source .venv/bin/activate 41 | poetry version $RELEASE_VERSION 42 | make build-and-publish 43 | env: 44 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 45 | RELEASE_VERSION: ${{ steps.vars.outputs.tag }} 46 | POETRY_REQUESTS_TIMEOUT: 120 47 | - name: GitHub Release -- Create 48 | id: release_create 49 | uses: softprops/action-gh-release@v2 50 | with: 51 | draft: false 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | deploy-docs: 55 | needs: publish 56 | runs-on: ubuntu-24.04 57 | steps: 58 | - name: Check out 59 | uses: actions/checkout@v4 60 | - name: Set up the environment 61 | uses: ./.github/actions/setup-poetry-env 62 | - name: Deploy documentation 63 | run: poetry run mkdocs gh-deploy --force 64 | compute: 65 | # Workaround to be able to use variables in matrix. 66 | # See: https://stackoverflow.com/questions/74072206/github-actions-use-variables-in-matrix-definition 67 | runs-on: ubuntu-24.04 68 | outputs: 69 | ASSET_EXTENSION: ${{ env.ASSET_EXTENSION }} 70 | OUT_NAME_FILE: ${{ env.OUT_NAME_FILE }} 71 | ARCH_MACOS_X64: ${{ env.ARCH_MACOS_X64 }} 72 | ARCH_MACOS_ARM64: ${{ env.ARCH_MACOS_ARM64 }} 73 | ARCH_LINUX_X64: ${{ env.ARCH_LINUX_X64 }} 74 | ARCH_LINUX_ARM64: ${{ env.ARCH_LINUX_ARM64 }} 75 | ARCH_WINDOWS_X64: ${{ env.ARCH_WINDOWS_X64 }} 76 | steps: 77 | - name: Compute outputs 78 | run: | 79 | echo "ASSET_EXTENSION=${{ env.ASSET_EXTENSION }}" >> $GITHUB_OUTPUT 80 | echo "OUT_NAME_FILE=${{ env.OUT_NAME_FILE }}" >> $GITHUB_OUTPUT 81 | echo "ARCH_MACOS_X64=${{ env.ARCH_MACOS_X64 }}" >> $GITHUB_OUTPUT 82 | echo "ARCH_MACOS_ARM64=${{ env.ARCH_MACOS_ARM64 }}" >> $GITHUB_OUTPUT 83 | echo "ARCH_LINUX_X64=${{ env.ARCH_LINUX_X64 }}" >> $GITHUB_OUTPUT 84 | echo "ARCH_LINUX_ARM64=${{ env.ARCH_LINUX_ARM64 }}" >> $GITHUB_OUTPUT 85 | echo "ARCH_WINDOWS_X64=${{ env.ARCH_WINDOWS_X64 }}" >> $GITHUB_OUTPUT 86 | build-gui-and-upload-assets: 87 | needs: ["publish", "compute"] 88 | strategy: 89 | fail-fast: false 90 | matrix: 91 | include: 92 | - os: "macos-13" 93 | TARGET: "macos-13" 94 | OS_ARCH: ${{needs.compute.outputs.ARCH_MACOS_X64}} 95 | CMD_BUILD: > 96 | brew install create-dmg && 97 | source $VENV && 98 | poetry run make gui-macos-dmg APP_NAME=${{needs.compute.outputs.OUT_NAME_FILE}} && 99 | cd dist && 100 | zip -r ${{needs.compute.outputs.OUT_NAME_FILE}}_${{needs.compute.outputs.ARCH_MACOS_X64}}${{needs.compute.outputs.ASSET_EXTENSION}} ${{needs.compute.outputs.OUT_NAME_FILE}}.dmg -x "*.DS_Store" && 101 | cd .. 102 | - os: "macos-14" 103 | TARGET: "macos-14 M1" 104 | OS_ARCH: ${{needs.compute.outputs.ARCH_MACOS_ARM64}} 105 | CMD_BUILD: > 106 | brew install create-dmg && 107 | source $VENV && 108 | poetry run make gui-macos-dmg APP_NAME=${{needs.compute.outputs.OUT_NAME_FILE}} && 109 | cd dist && 110 | zip -r ${{needs.compute.outputs.OUT_NAME_FILE}}_${{needs.compute.outputs.ARCH_MACOS_ARM64}}${{needs.compute.outputs.ASSET_EXTENSION}} ${{needs.compute.outputs.OUT_NAME_FILE}}.dmg -x "*.DS_Store" && 111 | cd .. 112 | - os: "ubuntu-20.04" 113 | TARGET: "ubuntu-20.04" 114 | OS_ARCH: ${{needs.compute.outputs.ARCH_LINUX_X64}} 115 | CMD_BUILD: > 116 | source $VENV && 117 | poetry run make gui-linux APP_NAME=${{needs.compute.outputs.OUT_NAME_FILE}} && 118 | cd dist && 119 | zip -r ${{needs.compute.outputs.OUT_NAME_FILE}}_${{needs.compute.outputs.ARCH_LINUX_X64}}${{needs.compute.outputs.ASSET_EXTENSION}} ${{needs.compute.outputs.OUT_NAME_FILE}} && 120 | cd .. 121 | - os: "ubuntu-22.04-arm" 122 | TARGET: "ubuntu-22.04-arm" 123 | OS_ARCH: ${{needs.compute.outputs.ARCH_LINUX_ARM64}} 124 | CMD_BUILD: > 125 | source $VENV && 126 | poetry run make gui-linux APP_NAME=${{needs.compute.outputs.OUT_NAME_FILE}} && 127 | cd dist && 128 | zip -r ${{needs.compute.outputs.OUT_NAME_FILE}}_${{needs.compute.outputs.ARCH_LINUX_ARM64}}${{needs.compute.outputs.ASSET_EXTENSION}} ${{needs.compute.outputs.OUT_NAME_FILE}} && 129 | cd .. 130 | - os: "windows-2019" 131 | TARGET: "windows-2019" 132 | OS_ARCH: ${{needs.compute.outputs.ARCH_WINDOWS_X64}} 133 | CMD_BUILD: > 134 | choco install zip make -y && 135 | source $VENV && 136 | poetry run make gui-windows APP_NAME=${{needs.compute.outputs.OUT_NAME_FILE}} && 137 | cd dist && 138 | zip -r ${{needs.compute.outputs.OUT_NAME_FILE}}_${{needs.compute.outputs.ARCH_WINDOWS_X64}}${{needs.compute.outputs.ASSET_EXTENSION}} ${{needs.compute.outputs.OUT_NAME_FILE}} && 139 | cd .. 140 | runs-on: ${{ matrix.os }} 141 | steps: 142 | - name: Check out 143 | uses: actions/checkout@v4 144 | - name: Set up the environment 145 | uses: ./.github/actions/setup-poetry-env 146 | - name: Build with pyinstaller for ${{matrix.TARGET}} 147 | run: ${{matrix.CMD_BUILD}} 148 | - name: Upload Release Asset 149 | id: upload-release-asset 150 | uses: softprops/action-gh-release@v2 151 | env: 152 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 153 | with: 154 | files: | 155 | ./dist/${{needs.compute.outputs.OUT_NAME_FILE}}_${{matrix.OS_ARCH}}${{needs.compute.outputs.ASSET_EXTENSION}} 156 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/source 2 | 3 | # From https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore 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/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # Vscode config files 160 | .vscode/ 161 | 162 | # PyCharm 163 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 164 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 165 | # and can be added to the global gitignore or merged into this file. For a more nuclear 166 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 167 | #.idea/ 168 | 169 | # Custom 170 | 171 | download/ 172 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.12 3 | 4 | default_stages: [commit, push] 5 | 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: "v4.5.0" 9 | hooks: 10 | - id: check-case-conflict 11 | - id: check-merge-conflict 12 | - id: check-toml 13 | - id: check-yaml 14 | - id: end-of-file-fixer 15 | - id: trailing-whitespace 16 | 17 | - repo: https://github.com/charliermarsh/ruff-pre-commit 18 | rev: "v0.1.12" 19 | hooks: 20 | - id: ruff 21 | 22 | - repo: https://github.com/pre-commit/mirrors-prettier 23 | rev: "v3.1.0" 24 | hooks: 25 | - id: prettier 26 | 27 | - repo: https://github.com/pre-commit/pre-commit-hooks 28 | rev: v4.5.0 29 | hooks: 30 | - id: check-yaml 31 | - id: end-of-file-fixer 32 | exclude: LICENSE 33 | 34 | # Due to UP007 and Typer this stays disabled: https://github.com/tiangolo/typer/issues/533 35 | # - repo: local 36 | # hooks: 37 | # - id: pyupgrade 38 | # name: pyupgrade 39 | # entry: poetry run pyupgrade --py310-plus 40 | # types: [python] 41 | # language: system 42 | 43 | - repo: https://github.com/asottile/seed-isort-config 44 | rev: v2.2.0 45 | hooks: 46 | - id: seed-isort-config 47 | 48 | # Disabled du to conflicts with black and I like the style of black more. 49 | # - repo: local 50 | # hooks: 51 | # - id: isort 52 | # name: isort 53 | # entry: poetry run isort --settings-path pyproject.toml 54 | # types: [python] 55 | # language: system 56 | 57 | - repo: local 58 | hooks: 59 | - id: black 60 | name: black 61 | entry: poetry run black --config pyproject.toml 62 | types: [python] 63 | language: system 64 | # TODO: Use https://github.com/pre-commit/mirrors-mypy 65 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.4.11 2 | 3 | - Fixes regarding empty metadata tags (also fixes #1). 4 | - CLI downloader extended to handle playlists and mixes. 5 | - `Download.track()` now handles logger functions. 6 | - GUI build for macOS and asset upload. 7 | 8 | # v0.4.9 9 | 10 | - Fixed: Exception on missing file read (config, token) instead of creation. 11 | 12 | # v0.4.8 13 | 14 | - Fixed: GUI dependencies are treated as extra now (pip). 15 | 16 | # v0.4.7 17 | 18 | - Fixed: GUI dependencies are treated as extra now (pip). 19 | 20 | # v0.4.6 21 | 22 | - GUI dependencies are treated as extra now (pip). 23 | 24 | # v0.4.5 25 | 26 | - Fixed "Mix" download. 27 | - Fixed relative imports. 28 | - Fixed PyPi build. 29 | - Fixed crash on lyrics error. 30 | 31 | # v0.4.2 32 | 33 | - Added more basic features. 34 | 35 | # v0.4.1 36 | 37 | - Initial featured running version. 38 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `tidal-dl-ng` 2 | 3 | Contributions are welcome, and they are greatly appreciated! 4 | Every little bit helps, and credit will always be given. 5 | 6 | You can contribute in many ways: 7 | 8 | # Types of Contributions 9 | 10 | ## Report Bugs 11 | 12 | Report bugs at https://github.com/exislow/tidal-dl-ng/issues 13 | 14 | If you are reporting a bug, please include: 15 | 16 | - Your operating system name and version. 17 | - Any details about your local setup that might be helpful in troubleshooting. 18 | - Detailed steps to reproduce the bug. 19 | 20 | ## Fix Bugs 21 | 22 | Look through the GitHub issues for bugs. 23 | Anything tagged with "bug" and "help wanted" is open to whoever wants to implement a fix for it. 24 | 25 | ## Implement Features 26 | 27 | Look through the GitHub issues for features. 28 | Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. 29 | 30 | ## Write Documentation 31 | 32 | Cookiecutter PyPackage could always use more documentation, whether as part of the official docs, in docstrings, or even on the web in blog posts, articles, and such. 33 | 34 | ## Submit Feedback 35 | 36 | The best way to send feedback is to file an issue at https://github.com/exislow/tidal-dl-ng/issues. 37 | 38 | If you are proposing a new feature: 39 | 40 | - Explain in detail how it would work. 41 | - Keep the scope as narrow as possible, to make it easier to implement. 42 | - Remember that this is a volunteer-driven project, and that contributions 43 | are welcome :) 44 | 45 | # Get Started! 46 | 47 | Ready to contribute? Here's how to set up `tidal-dl-ng` for local development. 48 | Please note this documentation assumes you already have `poetry` and `Git` installed and ready to go. 49 | 50 | 1. Fork the `tidal-dl-ng` repo on GitHub. 51 | 52 | 2. Clone your fork locally: 53 | 54 | ```bash 55 | cd 56 | git clone git@github.com:YOUR_NAME/tidal-dl-ng.git 57 | ``` 58 | 59 | 3. Now we need to install the environment. Navigate into the directory 60 | 61 | ```bash 62 | cd tidal-dl-ng 63 | ``` 64 | 65 | If you are using `pyenv`, select a version to use locally. (See installed versions with `pyenv versions`) 66 | 67 | ```bash 68 | pyenv local 69 | ``` 70 | 71 | Then, install and activate the environment with: 72 | 73 | ```bash 74 | poetry install 75 | poetry shell 76 | ``` 77 | 78 | 4. Install pre-commit to run linters/formatters at commit time: 79 | 80 | ```bash 81 | poetry run pre-commit install 82 | ``` 83 | 84 | 5. Create a branch for local development: 85 | 86 | ```bash 87 | git checkout -b name-of-your-bugfix-or-feature 88 | ``` 89 | 90 | Now you can make your changes locally. 91 | 92 | 6. Don't forget to add test cases for your added functionality to the `tests` directory. 93 | 94 | 7. When you're done making changes, check that your changes pass the formatting tests. 95 | 96 | ```bash 97 | make check 98 | ``` 99 | 100 | Now, validate that all unit tests are passing: 101 | 102 | ```bash 103 | make test 104 | ``` 105 | 106 | 9. Before raising a pull request you should also run tox. 107 | This will run the tests across different versions of Python: 108 | 109 | ```bash 110 | tox 111 | ``` 112 | 113 | This requires you to have multiple versions of python installed. 114 | This step is also triggered in the CI/CD pipeline, so you could also choose to skip this step locally. 115 | 116 | 10. Commit your changes and push your branch to GitHub: 117 | 118 | ```bash 119 | git add . 120 | git commit -m "Your detailed description of your changes." 121 | git push origin name-of-your-bugfix-or-feature 122 | ``` 123 | 124 | 11. Submit a pull request through the GitHub website. 125 | 126 | # Pull Request Guidelines 127 | 128 | Before you submit a pull request, check that it meets these guidelines: 129 | 130 | 1. The pull request should include tests. 131 | 132 | 2. If the pull request adds functionality, the docs should be updated. 133 | Put your new functionality into a function with a docstring, and add the feature to the list in `README.md`. 134 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | APP_NAME = "TIDAL-Downloader-NG" 2 | APP_VERSION=`grep -m 1 'version =' pyproject.toml | tr -s ' ' | tr -d '"' | tr -d "'" | cut -d' ' -f3` 3 | app_path_dist = "dist" 4 | path_asset = "tidal_dl_ng/ui" 5 | APP_BUNDLE_NAME="gui" 6 | DMG_NAME="dmg" 7 | 8 | .PHONY: install 9 | install: ## Install the poetry environment and install the pre-commit hooks 10 | @echo "🚀 Creating virtual environment using pyenv and poetry" 11 | @poetry install --all-extras --with dev,docs 12 | @poetry run pre-commit install 13 | @poetry shell 14 | 15 | .PHONY: check 16 | check: ## Run code quality tools. 17 | @echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry lock --check" 18 | @poetry check --lock 19 | @echo "🚀 Linting code: Running pre-commit" 20 | @poetry run pre-commit run -a 21 | # @echo "🚀 Static type checking: Running mypy" 22 | # @poetry run mypy 23 | @echo "🚀 Checking for obsolete dependencies: Running deptry" 24 | @poetry run deptry . 25 | 26 | .PHONY: test 27 | test: ## Test the code with pytest 28 | @echo "🚀 Testing code: Running pytest" 29 | @poetry run pytest --doctest-modules 30 | 31 | .PHONY: build 32 | build: clean-build ## Build wheel file using poetry 33 | @echo "🚀 Creating wheel file" 34 | @poetry build 35 | 36 | .PHONY: clean-build 37 | clean-build: ## clean build artifacts 38 | @rm -rf dist 39 | 40 | .PHONY: publish 41 | publish: ## publish a release to pypi. 42 | @echo "🚀 Publishing: Dry run." 43 | @poetry config pypi-token.pypi $(PYPI_TOKEN) 44 | @poetry publish --dry-run 45 | @echo "🚀 Publishing." 46 | @poetry publish 47 | 48 | .PHONY: build-and-publish 49 | build-and-publish: build publish ## Build and publish. 50 | 51 | .PHONY: docs-test 52 | docs-test: ## Test if documentation can be built without warnings or errors 53 | @poetry run mkdocs build -s 54 | 55 | .PHONY: docs 56 | docs: ## Build and serve the documentation 57 | @poetry run mkdocs serve 58 | 59 | .PHONY: help 60 | help: 61 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 62 | 63 | .PHONY: gui 64 | gui: ## Build GUI app 65 | @poetry run python -m nuitka \ 66 | --macos-app-version=$(APP_VERSION) \ 67 | --file-version=$(APP_VERSION) \ 68 | --product-version=$(APP_VERSION) \ 69 | --macos-app-name=$(APP_NAME) \ 70 | --output-filename=$(APP_NAME) \ 71 | --product-name=$(APP_NAME) \ 72 | tidal_dl_ng/gui.py 73 | 74 | .PHONY: gui-windows 75 | gui-windows: gui ## Build GUI app 76 | @poetry run mv "$(app_path_dist)/$(APP_BUNDLE_NAME).dist" "$(app_path_dist)/$(APP_NAME)" 77 | 78 | .PHONY: gui-linux 79 | gui-linux: gui ## Build GUI app 80 | @poetry run mv "$(app_path_dist)/$(APP_BUNDLE_NAME).dist" "$(app_path_dist)/$(APP_NAME)" 81 | 82 | # TODO: macos Signing: https://gist.github.com/txoof/0636835d3cc65245c6288b2374799c43 83 | .PHONY: gui-macos-dmg 84 | gui-macos-dmg: gui ## Package GUI in a *.dmg file 85 | @poetry run mkdir -p $(app_path_dist)/dmg 86 | @poetry run mv "$(app_path_dist)/$(APP_BUNDLE_NAME).app" "$(app_path_dist)/$(DMG_NAME)/$(APP_NAME).app" 87 | @poetry run create-dmg \ 88 | --volname "$(APP_NAME)" \ 89 | --volicon "$(path_asset)/icon.icns" \ 90 | --window-pos 200 120 \ 91 | --window-size 800 600 \ 92 | --icon-size 100 \ 93 | --icon "$(APP_NAME).app" 175 120 \ 94 | --hide-extension "$(APP_NAME).app" \ 95 | --app-drop-link 425 120 \ 96 | "$(app_path_dist)/$(APP_NAME).dmg" \ 97 | "$(app_path_dist)/$(DMG_NAME)/" 98 | 99 | .DEFAULT_GOAL := help 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔰 TIDAL Downloader Next Generation! (tidal-dl-ng) 2 | 3 | [![Release](https://img.shields.io/github/v/release/exislow/tidal-dl-ng)](https://img.shields.io/github/v/release/exislow/tidal-dl-ng) 4 | [![Build status](https://img.shields.io/github/actions/workflow/status/exislow/tidal-dl-ng/on-release-master.yml)](https://github.com/exislow/tidal-dl-ng/actions/workflows/on-release-master.yml) 5 | [![Commit activity](https://img.shields.io/github/commit-activity/m/exislow/tidal-dl-ng)](https://img.shields.io/github/commit-activity/m/exislow/tidal-dl-ng) 6 | [![License](https://img.shields.io/github/license/exislow/tidal-dl-ng)](https://img.shields.io/github/license/exislow/tidal-dl-ng) 7 | 8 | This tool allows to download songs and videos from TIDAL. Multithreaded and multi-chunked downloads are supported. 9 | 10 | ⚠️ **Windows** Defender / **Anti Virus** software / web browser alerts, while you try to download the app binary: This is a **false positive**. Please read [this issue](https://github.com/exislow/tidal-dl-ng/issues/231), [PyInstaller (used by this project) statement ](https://github.com/pyinstaller/pyinstaller/blob/develop/.github/ISSUE_TEMPLATE/antivirus.md) and [the alternative installation solution](https://github.com/exislow/tidal-dl-ng/?tab=readme-ov-file#-installation--upgrade). ⚠️ 11 | 12 | **A paid TIDAL plan is required!** Audio quality varies up to HiRes Lossless / TIDAL MAX 24-bit, 192 kHz depending on the song available. You can use the command line or GUI version of this tool. 13 | 14 | ![App Image](assets/app.png) 15 | 16 | ```bash 17 | $ tidal-dl-ng --help 18 | 19 | Usage: tidal-dl-ng [OPTIONS] COMMAND [ARGS]... 20 | 21 | ╭─ Options ────────────────────────────────────────────────────────────────────╮ 22 | │ --version -v │ 23 | │ --help -h Show this message and exit. │ 24 | ╰──────────────────────────────────────────────────────────────────────────────╯ 25 | ╭─ Commands ───────────────────────────────────────────────────────────────────╮ 26 | │ cfg Print or set an option. If no arguments are given, all options will │ 27 | │ be listed. If only one argument is given, the value will be printed │ 28 | │ for this option. To set a value for an option simply pass the value │ 29 | │ as the second argument │ 30 | │ dl │ 31 | │ dl_fav Download from a favorites collection. │ 32 | │ gui │ 33 | │ login │ 34 | │ logout │ 35 | ╰──────────────────────────────────────────────────────────────────────────────╯ 36 | ``` 37 | 38 | If you like this projects and want to support it, feel free to buy me a coffee 🙃✌️ 39 | 40 | Buy Me A Coffee 41 | 61e11d430afb112ea33c3aa5_Button-1-p-500 42 | 43 | ## 💻 Installation / Upgrade 44 | 45 | **Requirements**: Python == 3.12 (other versions might work but are not tested!) 46 | 47 | ```bash 48 | pip install --upgrade tidal-dl-ng 49 | # If you like to have the GUI as well use this command instead 50 | pip install --upgrade tidal-dl-ng[gui] 51 | ``` 52 | 53 | ## ⌨️ Usage 54 | 55 | You can use the command line (CLI) version to download media by URL: 56 | 57 | ```bash 58 | tidal-dl-ng dl https://tidal.com/browse/track/46755209 59 | # OR 60 | tdn dl https://tidal.com/browse/track/46755209 61 | ``` 62 | 63 | Or by your favorites collections: 64 | 65 | ```bash 66 | tidal-dl-ng dl_fav tracks 67 | tidal-dl-ng dl_fav artists 68 | tidal-dl-ng dl_fav albums 69 | tidal-dl-ng dl_fav videos 70 | ``` 71 | 72 | You can also use the GUI: 73 | 74 | ```bash 75 | tidal-dl-ng-gui 76 | # OR 77 | tdng 78 | # OR 79 | tidal-dl-ng gui 80 | ``` 81 | 82 | If you like to have the GUI version only as a binary, have a look at the 83 | [release page](https://github.com/exislow/tidal-dl-ng/releases) and download the correct version for your platform. 84 | 85 | ## 🧁 Features 86 | 87 | - Download tracks, videos, albums, playlists, your favorites etc. 88 | - Multithreaded and multi-chunked downloads 89 | - Metadata for songs 90 | - Adjustable audio and video download quality. 91 | - FLAC extraction from MP4 containers 92 | - Lyrics and album art / cover download 93 | - Creates playlist files 94 | - Can symlink tracks instead of having several copies, if added to different playlist 95 | 96 | ## ▶️ Getting started with development 97 | 98 | ### 🚰 Install dependencies 99 | 100 | Clone this repository and install the dependencies: 101 | 102 | ```bash 103 | # First, install Poetry. On some operating systems you need to use `pip` instead of `pipx` 104 | pipx install --upgrade poetry 105 | poetry install --all-extras --with dev,docs 106 | ``` 107 | 108 | The main entry points are: 109 | 110 | ```bash 111 | tidal_ng_dl/cli.py 112 | tidal_ng_dl/gui.py 113 | ``` 114 | 115 | ### 📺 GUI Builder 116 | 117 | The GUI is build with `PySide6` using the [Qt Designer](https://doc.qt.io/qt-6/qtdesigner-manual.html): 118 | 119 | ```bash 120 | PYSIDE_DESIGNER_PLUGINS=tidal_dl_ng/ui pyside6-designer 121 | ``` 122 | 123 | After all changes are saved you need to translate the Qt Designer `*.ui` file into Python code, for instance: 124 | 125 | ``` 126 | pyside6-uic tidal_dl_ng/ui/main.ui -o tidal_dl_ng/ui/main.py 127 | ``` 128 | 129 | This needs to be done for each created / modified `*.ui` file accordingly. 130 | 131 | ### 🏗 Build the project 132 | 133 | To build the project use this command: 134 | 135 | ```bash 136 | # Install virtual environment and dependencies if not already done 137 | make install 138 | # Build macOS GUI 139 | make gui-macos 140 | # Check build output 141 | ls dist/ 142 | ``` 143 | 144 | See the `Makefile` for all available build commands. 145 | 146 | The CI/CD pipeline will be triggered when you open a pull request, merge to main, or when you create a new release. 147 | 148 | To finalize the set-up for publishing to PyPi or Artifactory, see [here](https://fpgmaas.github.io/cookiecutter-poetry/features/publishing/#set-up-for-pypi). 149 | For activating the automatic documentation with MkDocs, see [here](https://fpgmaas.github.io/cookiecutter-poetry/features/mkdocs/#enabling-the-documentation-on-github). 150 | To enable the code coverage reports, see [here](https://fpgmaas.github.io/cookiecutter-poetry/features/codecov/). 151 | 152 | ## ❓ FAQ 153 | 154 | ### macOS Error Message: File/App is damaged and cannot be opened. You should move it to Trash.. 155 | 156 | If you download an (unsigned) app from any source other than those that Apple seems suited, the application gets an extended attribute "com.apple.Quarantine". This triggers the message: " is damaged and can't be opened. You should move it to the Bin." 157 | 158 | Remove the attribute and you can launch the application. [Source 1](https://discussions.apple.com/thread/253714860?sortBy=rank) [Source 2](https://www.reddit.com/r/macsysadmin/comments/13vu7f3/app_is_damaged_and_cant_be_opened_error_on_ventura/) 159 | 160 | ``` 161 | $ sudo xattr -dr com.apple.quarantine /Applications/TIDAL-Downloader-NG.app/ 162 | ``` 163 | 164 | Why is this app unsigned? Only developer enrolled in the paid Apple developer program are allowed to sign (legal) apps. Without this subscription app signing is not possible. 165 | 166 | Gatekeeper really annoys you, and you like to disable it completely? Follow this [link](https://iboysoft.com/tips/how-to-disable-gatekeeper-macos-sequoia.html) 167 | 168 | ### My (Windows) antivirus app XYZ says the GUI version of this app is harmful. 169 | 170 | Short answer: It is a lie. Get rid of your antivirus app. 171 | 172 | Long answer: See [here](https://github.com/exislow/tidal-dl-ng/issues/231) 173 | 174 | ### I get an error when `extract_flac` is enabled. 175 | 176 | Your `path_binary_ffmpeg` is probably wrong. Please read over and over again the help of this particular option until you get it right what path to put for `path_binary_ffmpeg`. 177 | 178 | ## ‼️ Disclaimer 179 | 180 | - For educational purposes only. I am not liable and responsible for any damage that happens. 181 | - You should not use this method to distribute or pirate music. 182 | - It may be illegal to use this app in your country. 183 | 184 | ## 🫂 Contributors 185 | 186 | Thanks to all, who have contributed to this project! 187 | 188 | This project is based on: 189 | 190 | - https://fpgmaas.github.io/cookiecutter-poetry/ 191 | 192 | 193 | -------------------------------------------------------------------------------- /assets/app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exislow/tidal-dl-ng/d9ab28d8840c8616d11f5610a78a02b90fb7e696/assets/app.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # tidal-dl-ng 2 | 3 | [![Release](https://img.shields.io/github/v/release/exislow/tidal-dl-ng)](https://img.shields.io/github/v/release/exislow/tidal-dl-ng) 4 | [![Build status](https://img.shields.io/github/actions/workflow/status/exislow/tidal-dl-ng/main.yml?branch=main)](https://github.com/exislow/tidal-dl-ng/actions/workflows/main.yml?query=branch%3Amain) 5 | [![Commit activity](https://img.shields.io/github/commit-activity/m/exislow/tidal-dl-ng)](https://img.shields.io/github/commit-activity/m/exislow/tidal-dl-ng) 6 | [![License](https://img.shields.io/github/license/exislow/tidal-dl-ng)](https://img.shields.io/github/license/exislow/tidal-dl-ng) 7 | 8 | tidal-dl-ng 9 | -------------------------------------------------------------------------------- /docs/modules.md: -------------------------------------------------------------------------------- 1 | ::: tidal-dl-ng 2 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: tidal-dl-ng 2 | repo_url: https://github.com/exislow/tidal-dl-ng 3 | site_url: https://exislow.github.io/tidal-dl-ng 4 | site_description: TIDAL Medial Downloader Next Generation! 5 | site_author: Robert Honz 6 | edit_uri: edit/main/docs/ 7 | repo_name: exislow/tidal-dl-ng 8 | copyright: Maintained by Robby. 9 | 10 | nav: 11 | - Home: index.md 12 | - Modules: modules.md 13 | plugins: 14 | - search 15 | - mkdocstrings: 16 | handlers: 17 | python: 18 | setup_commands: 19 | - import sys 20 | - sys.path.append('../') 21 | theme: 22 | name: material 23 | feature: 24 | tabs: true 25 | palette: 26 | - media: "(prefers-color-scheme: light)" 27 | scheme: default 28 | primary: white 29 | accent: deep orange 30 | toggle: 31 | icon: material/brightness-7 32 | name: Switch to dark mode 33 | - media: "(prefers-color-scheme: dark)" 34 | scheme: slate 35 | primary: black 36 | accent: deep orange 37 | toggle: 38 | icon: material/brightness-4 39 | name: Switch to light mode 40 | icon: 41 | repo: fontawesome/brands/github 42 | 43 | extra: 44 | social: 45 | - icon: fontawesome/brands/github 46 | link: https://github.com/exislow/tidal-dl-ng 47 | - icon: fontawesome/brands/python 48 | link: https://pypi.org/project/tidal-dl-ng 49 | 50 | markdown_extensions: 51 | - toc: 52 | permalink: true 53 | - pymdownx.arithmatex: 54 | generic: true 55 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "tidal-dl-ng" 3 | authors = [ 4 | {name = "Robert Honz",email = ""} 5 | ] 6 | version = "0.25.6" 7 | description = "TIDAL Medial Downloader Next Generation!" 8 | repository = "https://github.com/exislow/tidal-dl-ng" 9 | documentation = "https://exislow.github.io/tidal-dl-ng/" 10 | readme = "README.md" 11 | packages = [ 12 | { include = "tidal_dl_ng" } 13 | ] 14 | classifiers = [ 15 | "Development Status :: 4 - Beta", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: GNU Affero General Public License v3", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.12", 20 | ] 21 | requires-python = ">=3.12,<3.13" 22 | dynamic = ["dependencies"] 23 | 24 | [project.scripts] 25 | tidal-dl-ng = "tidal_dl_ng.cli:app" 26 | tidal-dl-ng-gui = "tidal_dl_ng.gui:gui_activate" 27 | tdn = "tidal_dl_ng.cli:app" 28 | tdng = "tidal_dl_ng.gui:gui_activate" 29 | 30 | [tool.poetry.dependencies] 31 | requests = "~2.32.3" 32 | mutagen = "^1.47.0" 33 | dataclasses-json = "^0.6.7" 34 | pathvalidate = "^3.2.3" 35 | m3u8 = "^6.0.0" 36 | coloredlogs = "^15.0.1" 37 | pyside6 = {version = "6.8.0.1", optional = true} 38 | pyqtdarktheme-fork = {version = "^2.3.2", optional = true} 39 | mpegdash = "^0.4.0" 40 | rich = "^13.9.4" 41 | toml = "^0.10.2" 42 | typer = "^0.15.2" 43 | tidalapi = "^0.8.3" 44 | python-ffmpeg = "^2.0.12" 45 | pycryptodome = "^3.22.0" 46 | 47 | [project.optional-dependencies] 48 | gui = ["pyside6", "pyqtdarktheme-fork"] 49 | 50 | [tool.poetry.group.dev] 51 | optional = true 52 | 53 | [tool.poetry.group.dev.dependencies] 54 | pytest = "^8.3.3" 55 | deptry = "^0.20.0" 56 | mypy = "^1.13.0" 57 | pre-commit = "^4.0.1" 58 | tox = "^4.23.2" 59 | pyupgrade = "^3.19.0" 60 | bandit = "^1.7.10" 61 | darglint = "^1.8.1" 62 | isort = { extras = ["colors"], version = "^5.13.2" } 63 | mypy-extensions = "^1.0.0" 64 | pydocstyle = "^6.3.0" 65 | pylint = "^3.3.6" 66 | pillow = "^11.1.0" 67 | pytest-cov = "^6.0.0" 68 | black = "^25.1.0" 69 | nuitka = "^2.6.9" 70 | 71 | [tool.poetry.group.docs] 72 | optional = true 73 | 74 | [tool.poetry.group.docs.dependencies] 75 | mkdocs = "^1.6.1" 76 | mkdocs-material = "^9.5.44" 77 | mkdocstrings = { extras = ["python"], version = "^0.27.0" } 78 | griffe = "^1.5.1" 79 | 80 | [build-system] 81 | requires = ["poetry-core>=2.0.0,<3.0.0"] 82 | build-backend = "poetry.core.masonry.api" 83 | 84 | [tool.pytest.ini_options] 85 | testpaths = ["tests"] 86 | 87 | [tool.ruff] 88 | target-version = "py312" 89 | line-length = 120 90 | fix = true 91 | # Overview: https://docs.astral.sh/ruff/rules/ 92 | select = [ 93 | # flake8-2020 94 | "YTT", 95 | # flake8-bandit 96 | "S", 97 | # flake8-bugbear 98 | "B", 99 | # flake8-builtins 100 | "A", 101 | # flake8-comprehensions 102 | "C4", 103 | # flake8-debugger 104 | "T10", 105 | # flake8-simplify 106 | "SIM", 107 | # isort 108 | "I", 109 | # mccabe 110 | "C90", 111 | # pycodestyle 112 | "E", "W", 113 | # pyflakes 114 | "F", 115 | # pygrep-hooks 116 | "PGH", 117 | # pyupgrade 118 | "UP", 119 | # ruff 120 | "RUF", 121 | # tryceratops 122 | "TRY", 123 | ] 124 | ignore = [ 125 | # LineTooLong 126 | "E501", 127 | # DoNotAssignLambda 128 | "E731", 129 | # Do not use bare `except` 130 | "E722", 131 | # PEP 484 prohibits implicit `Optional` 132 | # Disabled only temporarely. 133 | "RUF013" 134 | ] 135 | 136 | [tool.ruff.per-file-ignores] 137 | "tests/*" = ["S101"] 138 | "tidal_dl_ng/cli.py" = ["UP007"] 139 | 140 | [tool.black] 141 | # https://github.com/psf/black 142 | target-version = ["py312"] 143 | line-length = 120 144 | color = true 145 | preview = true 146 | 147 | exclude = ''' 148 | /( 149 | \.git 150 | | \.mypy_cache 151 | | \.tox 152 | | \.venv 153 | | _build 154 | | buck-out 155 | | build 156 | | dist 157 | | env 158 | | venv 159 | | site 160 | | docs 161 | | tidal_dl_ng/ui/main.py 162 | | tidal_dl_ng/ui/dialog_version.py 163 | )/ 164 | ''' 165 | 166 | [tool.isort] 167 | # https://github.com/timothycrosley/isort/ 168 | py_version = 312 169 | line_length = 120 170 | known_typing = ["typing", "types", "typing_extensions", "mypy", "mypy_extensions"] 171 | sections = ["FUTURE", "TYPING", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] 172 | include_trailing_comma = true 173 | profile = "black" 174 | multi_line_output = 3 175 | indent = 4 176 | color_output = true 177 | known_third_party = ["Crypto", "PySide6", "coloredlogs", "dataclasses_json", "ffmpeg", "m3u8", "mutagen", "pathvalidate", "requests", "rich", "tidalapi", "toml", "typer"] 178 | 179 | [tool.mypy] 180 | # https://mypy.readthedocs.io/en/latest/config_file.html#using-a-pyproject-toml-file 181 | files = ["tidal_dl_ng"] 182 | python_version = "3.12" 183 | pretty = true 184 | show_traceback = true 185 | color_output = true 186 | allow_redefinition = false 187 | check_untyped_defs = true 188 | disallow_any_generics = true 189 | disallow_incomplete_defs = true 190 | ignore_missing_imports = true 191 | implicit_reexport = false 192 | no_implicit_optional = true 193 | show_column_numbers = true 194 | show_error_codes = true 195 | show_error_context = true 196 | strict_equality = true 197 | strict_optional = true 198 | warn_no_return = true 199 | warn_redundant_casts = true 200 | warn_return_any = true 201 | warn_unreachable = true 202 | warn_unused_configs = true 203 | warn_unused_ignores = true 204 | disallow_untyped_defs = "True" 205 | disallow_any_unimported = "True" 206 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [darglint] 2 | # https://github.com/terrencepreilly/darglint 3 | strictness = long 4 | docstring_style = google 5 | -------------------------------------------------------------------------------- /tests/test_foo.py: -------------------------------------------------------------------------------- 1 | def test_foo(): 2 | assert "foo" == "foo" 3 | -------------------------------------------------------------------------------- /tidal_dl_ng/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import importlib.metadata 3 | from pathlib import Path 4 | from urllib.parse import urlparse 5 | 6 | import requests 7 | import toml 8 | 9 | from tidal_dl_ng.constants import REQUESTS_TIMEOUT_SEC 10 | from tidal_dl_ng.model.meta import ProjectInformation, ReleaseLatest 11 | 12 | 13 | def metadata_project() -> ProjectInformation: 14 | result: ProjectInformation 15 | file_path: Path = Path(__file__) 16 | tmp_result: dict = {} 17 | 18 | paths: [Path] = [ 19 | file_path.parent, 20 | file_path.parent.parent, 21 | file_path.parent.parent.parent, 22 | ] 23 | 24 | for pyproject_toml_dir in paths: 25 | pyproject_toml_file: Path = pyproject_toml_dir / "pyproject.toml" 26 | 27 | if pyproject_toml_file.exists() and pyproject_toml_file.is_file(): 28 | tmp_result = toml.load(pyproject_toml_file) 29 | 30 | break 31 | 32 | if tmp_result: 33 | result = ProjectInformation( 34 | version=tmp_result["project"]["version"], repository_url=tmp_result["project"]["repository"] 35 | ) 36 | else: 37 | try: 38 | meta_info = importlib.metadata.metadata(name_package()) 39 | result = ProjectInformation(version=meta_info["Version"], repository_url=meta_info["Home-page"]) 40 | except: 41 | result = ProjectInformation(version="0.0.0", repository_url="https://anerroroccur.ed/sorry/for/that") 42 | 43 | return result 44 | 45 | 46 | def version_app() -> str: 47 | metadata: ProjectInformation = metadata_project() 48 | version: str = metadata.version 49 | 50 | return version 51 | 52 | 53 | def repository_url() -> str: 54 | metadata: ProjectInformation = metadata_project() 55 | url_repo: str = metadata.repository_url 56 | 57 | return url_repo 58 | 59 | 60 | def repository_path() -> str: 61 | url_repo: str = repository_url() 62 | url_path: str = urlparse(url_repo).path 63 | 64 | return url_path 65 | 66 | 67 | def latest_version_information() -> ReleaseLatest: 68 | release_info: ReleaseLatest 69 | repo_path: str = repository_path() 70 | url: str = f"https://api.github.com/repos{repo_path}/releases/latest" 71 | 72 | try: 73 | response = requests.get(url, timeout=REQUESTS_TIMEOUT_SEC) 74 | release_info: str = response.json() 75 | 76 | release_info = ReleaseLatest( 77 | version=release_info["tag_name"], url=release_info["html_url"], release_info=release_info["body"] 78 | ) 79 | except: 80 | release_info = ReleaseLatest( 81 | version="v0.0.0", 82 | url=url, 83 | release_info=f"Something went wrong calling {url}. Check your internet connection.", 84 | ) 85 | 86 | return release_info 87 | 88 | 89 | def name_package() -> str: 90 | package_name: str = __package__ or __name__ 91 | 92 | return package_name 93 | 94 | 95 | def is_dev_env() -> bool: 96 | package_name: str = name_package() 97 | result: bool = False 98 | 99 | # Check if package is running from source code == dev mode 100 | # If package is not running in Nuitka environment, try to import it from pip libraries. 101 | # If this also fails, it is dev mode. 102 | if "__compiled__" not in globals(): 103 | try: 104 | importlib.metadata.version(package_name) 105 | except: 106 | # If package is not installed 107 | result = True 108 | 109 | return result 110 | 111 | 112 | def name_app() -> str: 113 | app_name: str = name_package() 114 | is_dev: bool = is_dev_env() 115 | 116 | if is_dev: 117 | app_name = app_name + "-dev" 118 | 119 | return app_name 120 | 121 | 122 | __name_display__ = name_app() 123 | __version__ = version_app() 124 | 125 | 126 | def update_available() -> (bool, ReleaseLatest): 127 | latest_info: ReleaseLatest = latest_version_information() 128 | result: bool = False 129 | version_current: str = "v" + __version__ 130 | 131 | if version_current != latest_info.version and version_current != "v0.0.0": 132 | result = True 133 | 134 | return result, latest_info 135 | -------------------------------------------------------------------------------- /tidal_dl_ng/api.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import requests 4 | 5 | # See also 6 | # https://github.com/yaronzz/Tidal-Media-Downloader/commit/1d5b8cd8f65fd1def45d6406778248249d6dfbdf 7 | # https://github.com/yaronzz/Tidal-Media-Downloader/pull/840 8 | # https://github.com/nathom/streamrip/tree/main/streamrip 9 | # https://github.com/arnesongit/plugin.audio.tidal2/blob/e9429d601d0c303d775d05a19a66415b57479d87/resources/lib/tidal2/tidalapi/__init__.py#L86 10 | 11 | # TODO: Implement this into `Download`: Session should randomize the usage. 12 | __KEYS_JSON__ = """ 13 | { 14 | "version": "1.0.1", 15 | "keys": [ 16 | { 17 | "platform": "Fire TV", 18 | "formats": "Normal/High/HiFi(No Master)", 19 | "clientId": "OmDtrzFgyVVL6uW56OnFA2COiabqm", 20 | "clientSecret": "zxen1r3pO0hgtOC7j6twMo9UAqngGrmRiWpV7QC1zJ8=", 21 | "valid": "False", 22 | "from": "Fokka-Engineering (https://github.com/Fokka-Engineering/libopenTIDAL/blob/655528e26e4f3ee2c426c06ea5b8440cf27abc4a/README.md#example)" 23 | }, 24 | { 25 | "platform": "Fire TV", 26 | "formats": "Master-Only(Else Error)", 27 | "clientId": "7m7Ap0JC9j1cOM3n", 28 | "clientSecret": "vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY=", 29 | "valid": "True", 30 | "from": "Dniel97 (https://github.com/Dniel97/RedSea/blob/4ba02b88cee33aeb735725cb854be6c66ff372d4/config/settings.example.py#L68)" 31 | }, 32 | { 33 | "platform": "Android TV", 34 | "formats": "Normal/High/HiFi(No Master)", 35 | "clientId": "Pzd0ExNVHkyZLiYN", 36 | "clientSecret": "W7X6UvBaho+XOi1MUeCX6ewv2zTdSOV3Y7qC3p3675I=", 37 | "valid": "False", 38 | "from": "" 39 | }, 40 | { 41 | "platform": "TV", 42 | "formats": "Normal/High/HiFi/Master", 43 | "clientId": "8SEZWa4J1NVC5U5Y", 44 | "clientSecret": "owUYDkxddz+9FpvGX24DlxECNtFEMBxipU0lBfrbq60=", 45 | "valid": "False", 46 | "from": "morguldir (https://github.com/morguldir/python-tidal/commit/50f1afcd2079efb2b4cf694ef5a7d67fdf619d09)" 47 | }, 48 | { 49 | "platform": "Android Auto", 50 | "formats": "Normal/High/HiFi/Master", 51 | "clientId": "zU4XHVVkc2tDPo4t", 52 | "clientSecret": "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4=", 53 | "valid": "True", 54 | "from": "1nikolas (https://github.com/yaronzz/Tidal-Media-Downloader/pull/840)" 55 | } 56 | ] 57 | } 58 | """ 59 | __API_KEYS__ = json.loads(__KEYS_JSON__) 60 | __ERROR_KEY__ = ( 61 | { 62 | "platform": "None", 63 | "formats": "", 64 | "clientId": "", 65 | "clientSecret": "", 66 | "valid": "False", 67 | }, 68 | ) 69 | 70 | from tidal_dl_ng.constants import REQUESTS_TIMEOUT_SEC 71 | 72 | 73 | def getNum(): 74 | return len(__API_KEYS__["keys"]) 75 | 76 | 77 | def getItem(index: int): 78 | if index < 0 or index >= len(__API_KEYS__["keys"]): 79 | return __ERROR_KEY__ 80 | return __API_KEYS__["keys"][index] 81 | 82 | 83 | def isItemValid(index: int): 84 | item = getItem(index) 85 | return item["valid"] == "True" 86 | 87 | 88 | def getItems(): 89 | return __API_KEYS__["keys"] 90 | 91 | 92 | def getLimitIndexs(): 93 | array = [] 94 | for i in range(len(__API_KEYS__["keys"])): 95 | array.append(str(i)) 96 | return array 97 | 98 | 99 | def getVersion(): 100 | return __API_KEYS__["version"] 101 | 102 | 103 | # Load from gist 104 | try: 105 | respond = requests.get( 106 | "https://api.github.com/gists/48d01f5a24b4b7b37f19443977c22cd6", timeout=REQUESTS_TIMEOUT_SEC 107 | ) 108 | if respond.status_code == 200: 109 | content = respond.json()["files"]["tidal-api-key.json"]["content"] 110 | __API_KEYS__ = json.loads(content) 111 | except Exception as e: 112 | # TODO: Implement proper logging. 113 | print(e) 114 | pass 115 | -------------------------------------------------------------------------------- /tidal_dl_ng/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import signal 3 | from collections.abc import Callable 4 | from pathlib import Path 5 | from typing import Annotated, Optional 6 | 7 | import typer 8 | from rich.console import Group 9 | from rich.live import Live 10 | from rich.progress import ( 11 | BarColumn, 12 | Console, 13 | Progress, 14 | SpinnerColumn, 15 | TaskProgressColumn, 16 | TextColumn, 17 | ) 18 | from rich.table import Table 19 | 20 | from tidal_dl_ng import __version__ 21 | from tidal_dl_ng.config import HandlingApp, Settings, Tidal 22 | from tidal_dl_ng.constants import CTX_TIDAL, MediaType 23 | from tidal_dl_ng.download import Download 24 | from tidal_dl_ng.helper.path import get_format_template, path_file_settings 25 | from tidal_dl_ng.helper.tidal import ( 26 | all_artist_album_ids, 27 | get_tidal_media_id, 28 | get_tidal_media_type, 29 | instantiate_media, 30 | ) 31 | from tidal_dl_ng.helper.wrapper import LoggerWrapped 32 | from tidal_dl_ng.model.cfg import HelpSettings 33 | 34 | app = typer.Typer(context_settings={"help_option_names": ["-h", "--help"]}, add_completion=False) 35 | dl_fav_group = typer.Typer( 36 | context_settings={"help_option_names": ["-h", "--help"]}, 37 | add_completion=True, 38 | help="Download from a favorites collection.", 39 | ) 40 | 41 | app.add_typer(dl_fav_group, name="dl_fav") 42 | 43 | 44 | def version_callback(value: bool): 45 | if value: 46 | print(f"{__version__}") 47 | raise typer.Exit() 48 | 49 | 50 | def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bool: 51 | """Invokes download function and tracks progress. 52 | 53 | :param ctx: The typer context object. 54 | :type ctx: typer.Context 55 | :param urls: The list of URLs to download. 56 | :type urls: list[str] 57 | :param try_login: If true, attempts to login to TIDAL. 58 | :type try_login: bool 59 | :return: True if ran successfully. 60 | :rtype: bool 61 | """ 62 | if try_login: 63 | # Call login method to validate the token. 64 | ctx.invoke(login, ctx) 65 | 66 | # Create initial objects. 67 | settings: Settings = Settings() 68 | handling_app: HandlingApp = HandlingApp() 69 | progress: Progress = Progress( 70 | TextColumn("[progress.description]{task.description}"), 71 | SpinnerColumn(), 72 | BarColumn(), 73 | TaskProgressColumn(), 74 | refresh_per_second=20, 75 | auto_refresh=True, 76 | expand=True, 77 | transient=False, # Prevent progress from disappearing 78 | ) 79 | progress_overall = Progress( 80 | TextColumn("[progress.description]{task.description}"), 81 | SpinnerColumn(), 82 | BarColumn(), 83 | TaskProgressColumn(), 84 | refresh_per_second=20, 85 | auto_refresh=True, 86 | expand=True, 87 | transient=False, # Prevent progress from disappearing 88 | ) 89 | fn_logger = LoggerWrapped(progress.print) 90 | dl = Download( 91 | session=ctx.obj[CTX_TIDAL].session, 92 | skip_existing=ctx.obj[CTX_TIDAL].settings.data.skip_existing, 93 | path_base=settings.data.download_base_path, 94 | fn_logger=fn_logger, 95 | progress=progress, 96 | progress_overall=progress_overall, 97 | event_abort=handling_app.event_abort, 98 | event_run=handling_app.event_run, 99 | ) 100 | progress_table = Table.grid() 101 | 102 | # Style Progress display. 103 | progress_table.add_row(progress) 104 | progress_table.add_row(progress_overall) 105 | 106 | progress_group = Group( 107 | progress_table, 108 | ) 109 | 110 | urls_pos_last = len(urls) - 1 111 | 112 | # Use a single Live display for both progress and table 113 | with Live(progress_group, refresh_per_second=20, vertical_overflow="visible"): 114 | try: 115 | for item in urls: 116 | media_type: MediaType | bool = False 117 | 118 | # Exit loop if abort signal is set. 119 | if handling_app.event_abort.is_set(): 120 | return False 121 | 122 | # Extract media name and id from link. 123 | if "http" in item: 124 | media_type = get_tidal_media_type(item) 125 | item_id = get_tidal_media_id(item) 126 | file_template = get_format_template(media_type, settings) 127 | else: 128 | print(f"It seems like that you have supplied an invalid URL: {item}") 129 | 130 | continue 131 | 132 | # Download media. 133 | if media_type in [MediaType.TRACK, MediaType.VIDEO]: 134 | download_delay: bool = bool(settings.data.download_delay and urls.index(item) < urls_pos_last) 135 | 136 | dl.item( 137 | media_id=item_id, 138 | media_type=media_type, 139 | file_template=file_template, 140 | download_delay=download_delay, 141 | quality_audio=settings.data.quality_audio, 142 | quality_video=settings.data.quality_video, 143 | ) 144 | elif media_type in [MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX, MediaType.ARTIST]: 145 | item_ids: [int] = [] 146 | 147 | if media_type == MediaType.ARTIST: 148 | media = instantiate_media(ctx.obj[CTX_TIDAL].session, media_type, item_id) 149 | media_type = MediaType.ALBUM 150 | item_ids = item_ids + all_artist_album_ids(media) 151 | else: 152 | item_ids.append(item_id) 153 | 154 | for item_id in item_ids: 155 | # Exit loop if abort signal is set. 156 | if handling_app.event_abort.is_set(): 157 | return False 158 | 159 | dl.items( 160 | media_id=item_id, 161 | media_type=media_type, 162 | file_template=file_template, 163 | video_download=ctx.obj[CTX_TIDAL].settings.data.video_download, 164 | download_delay=settings.data.download_delay, 165 | ) 166 | finally: 167 | # Clear and stop progress display 168 | progress.refresh() 169 | progress.stop() 170 | 171 | return True 172 | 173 | 174 | @app.callback() 175 | def callback_app( 176 | ctx: typer.Context, 177 | version: Annotated[ 178 | Optional[bool], typer.Option("--version", "-v", callback=version_callback, is_eager=True) 179 | ] = None, 180 | ): 181 | ctx.obj = {"tidal": None} 182 | 183 | 184 | @app.command(name="cfg") 185 | def settings_management( 186 | names: Annotated[Optional[list[str]], typer.Argument()] = None, 187 | editor: Annotated[ 188 | bool, typer.Option("--editor", "-e", help="Open the settings file in your default editor.") 189 | ] = False, 190 | ): 191 | """ 192 | Print or set an option. 193 | If no arguments are given, all options will be listed. 194 | If only one argument is given, the value will be printed for this option. 195 | To set a value for an option simply pass the value as the second argument 196 | 197 | :param editor: If set, your favorite system editor will be opened. 198 | :param names: (Optional) None (list all options), one (list the value only for this option) or two arguments 199 | (set the value for the option). 200 | """ 201 | if editor: 202 | config_path: Path = Path(path_file_settings()) 203 | 204 | if not config_path.is_file(): 205 | config_path.write_text('{"version": "1.0.0"}') 206 | 207 | config_file_str = str(config_path) 208 | 209 | typer.launch(config_file_str) 210 | else: 211 | settings = Settings() 212 | d_settings = settings.data.to_dict() 213 | 214 | if names: 215 | if names[0] not in d_settings: 216 | print(f'Option "{names[0]}" is not valid!') 217 | else: 218 | if len(names) == 1: 219 | print(f'{names[0]}: "{d_settings[names[0]]}"') 220 | elif len(names) > 1: 221 | settings.set_option(names[0], names[1]) 222 | settings.save() 223 | else: 224 | help_settings: dict = HelpSettings().to_dict() 225 | table = Table(title=f"Config: {path_file_settings()}") 226 | table.add_column("Key", style="cyan", no_wrap=True) 227 | table.add_column("Value", style="magenta") 228 | table.add_column("Description", style="green") 229 | 230 | # Iterate over the attributes of the dataclass 231 | for key, value in sorted(d_settings.items()): 232 | table.add_row(key, str(value), help_settings[key]) 233 | 234 | console = Console() 235 | console.print(table) 236 | 237 | 238 | @app.command(name="login") 239 | def login(ctx: typer.Context) -> bool: 240 | print("Let us check, if you are already logged in... ", end="") 241 | 242 | settings = Settings() 243 | tidal = Tidal(settings) 244 | result = tidal.login(fn_print=print) 245 | ctx.obj[CTX_TIDAL] = tidal 246 | 247 | return result 248 | 249 | 250 | @app.command(name="logout") 251 | def logout() -> bool: 252 | settings = Settings() 253 | tidal = Tidal(settings) 254 | result = tidal.logout() 255 | 256 | if result: 257 | print("You have been successfully logged out.") 258 | 259 | return result 260 | 261 | 262 | @app.command(name="dl") 263 | def download( 264 | ctx: typer.Context, 265 | urls: Annotated[Optional[list[str]], typer.Argument()] = None, 266 | file_urls: Annotated[ 267 | Optional[Path], 268 | typer.Option( 269 | "--list", 270 | "-l", 271 | exists=True, 272 | file_okay=True, 273 | dir_okay=False, 274 | writable=False, 275 | readable=True, 276 | resolve_path=True, 277 | help="List with URLs to download. One per line", 278 | ), 279 | ] = None, 280 | ) -> bool: 281 | if not urls: 282 | # Read the text file provided. 283 | if file_urls: 284 | text: str = file_urls.read_text() 285 | urls = text.splitlines() 286 | else: 287 | print("Provide either URLs, IDs or a file containing URLs (one per line).") 288 | 289 | raise typer.Abort() 290 | 291 | return _download(ctx, urls) 292 | 293 | 294 | @dl_fav_group.command( 295 | name="tracks", 296 | help="Download your favorite track collection.", 297 | ) 298 | def download_fav_tracks(ctx: typer.Context) -> bool: 299 | """Download your favorite track collection. 300 | 301 | :param ctx: Typer context object. 302 | :type ctx: typer.Context 303 | :return: Download result. 304 | :rtype: bool 305 | """ 306 | # Method name 307 | func_name_favorites: str = "tracks" 308 | 309 | return _download_fav_factory(ctx, func_name_favorites) 310 | 311 | 312 | @dl_fav_group.command( 313 | name="artists", 314 | help="Download your favorite artist collection.", 315 | ) 316 | def download_fav_artists(ctx: typer.Context) -> bool: 317 | """Download your favorite artist collection. 318 | 319 | :param ctx: Typer context object. 320 | :type ctx: typer.Context 321 | :return: Download result. 322 | :rtype: bool 323 | """ 324 | # Method name 325 | func_name_favorites: str = "artists" 326 | 327 | return _download_fav_factory(ctx, func_name_favorites) 328 | 329 | 330 | @dl_fav_group.command( 331 | name="albums", 332 | help="Download your favorite album collection.", 333 | ) 334 | def download_fav_albums(ctx: typer.Context) -> bool: 335 | """Download your favorite album collection. 336 | 337 | :param ctx: Typer context object. 338 | :type ctx: typer.Context 339 | :return: Download result. 340 | :rtype: bool 341 | """ 342 | # Method name 343 | func_name_favorites: str = "albums" 344 | 345 | return _download_fav_factory(ctx, func_name_favorites) 346 | 347 | 348 | @dl_fav_group.command( 349 | name="videos", 350 | help="Download your favorite video collection.", 351 | ) 352 | def download_fav_videos(ctx: typer.Context) -> bool: 353 | """Download your favorite video collection. 354 | 355 | :param ctx: Typer context object. 356 | :type ctx: typer.Context 357 | :return: Download result. 358 | :rtype: bool 359 | """ 360 | # Method name 361 | func_name_favorites: str = "videos" 362 | 363 | return _download_fav_factory(ctx, func_name_favorites) 364 | 365 | 366 | def _download_fav_factory(ctx: typer.Context, func_name_favorites: str) -> bool: 367 | """Factory which helps to download items from the favorites collections. 368 | 369 | :param ctx: Typer context object. 370 | :type ctx: typer.Context 371 | :param func_name_favorites: Method name to call from `tidalapi` favorites object. 372 | :type func_name_favorites: str 373 | :return: Download result. 374 | :rtype: bool 375 | """ 376 | # Call login method to validate the token. 377 | ctx.invoke(login, ctx) 378 | 379 | # Get the method from the module 380 | func_favorites: Callable = getattr(ctx.obj[CTX_TIDAL].session.user.favorites, func_name_favorites) 381 | # Get favorite videos 382 | media_urls: [str] = [media.share_url for media in func_favorites()] 383 | 384 | return _download(ctx, media_urls, try_login=False) 385 | 386 | 387 | @app.command() 388 | def gui(ctx: typer.Context): 389 | from tidal_dl_ng.gui import gui_activate 390 | 391 | ctx.invoke(login, ctx) 392 | gui_activate(ctx.obj[CTX_TIDAL]) 393 | 394 | 395 | def handle_sigint_term(signum, frame): 396 | """Set app abort event, so threads can check it and shutdown. 397 | 398 | :param signum: 399 | :param frame: 400 | :return: 401 | """ 402 | handling_app: HandlingApp = HandlingApp() 403 | 404 | handling_app.event_abort.set() 405 | 406 | 407 | if __name__ == "__main__": 408 | # Catch CTRL+C 409 | signal.signal(signal.SIGINT, handle_sigint_term) 410 | signal.signal(signal.SIGTERM, handle_sigint_term) 411 | 412 | app() 413 | -------------------------------------------------------------------------------- /tidal_dl_ng/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import shutil 4 | from collections.abc import Callable 5 | from json import JSONDecodeError 6 | from pathlib import Path 7 | from threading import Event 8 | from typing import Any 9 | 10 | import tidalapi 11 | from requests import HTTPError 12 | 13 | from tidal_dl_ng.helper.decorator import SingletonMeta 14 | from tidal_dl_ng.helper.path import path_config_base, path_file_settings, path_file_token 15 | from tidal_dl_ng.model.cfg import Settings as ModelSettings 16 | from tidal_dl_ng.model.cfg import Token as ModelToken 17 | 18 | 19 | class BaseConfig: 20 | data: ModelSettings | ModelToken 21 | file_path: str 22 | cls_model: ModelSettings | ModelToken 23 | path_base: str = path_config_base() 24 | 25 | def save(self, config_to_compare: str = None) -> None: 26 | data_json = self.data.to_json() 27 | 28 | # If old and current config is equal, skip the write operation. 29 | if config_to_compare == data_json: 30 | return 31 | 32 | # Try to create the base folder. 33 | os.makedirs(self.path_base, exist_ok=True) 34 | 35 | with open(self.file_path, encoding="utf-8", mode="w") as f: 36 | # Save it in a pretty format 37 | obj_json_config = json.loads(data_json) 38 | json.dump(obj_json_config, f, indent=4) 39 | 40 | def set_option(self, key: str, value: Any) -> None: 41 | value_old: Any = getattr(self.data, key) 42 | 43 | if type(value_old) == bool: # noqa: E721 44 | value = True if value.lower() in ("true", "1", "yes", "y") else False # noqa: SIM210 45 | elif type(value_old) == int and type(value) != int: # noqa: E721 46 | value = int(value) 47 | 48 | setattr(self.data, key, value) 49 | 50 | def read(self, path: str) -> bool: 51 | result: bool = False 52 | settings_json: str = "" 53 | 54 | try: 55 | with open(path, encoding="utf-8") as f: 56 | settings_json = f.read() 57 | 58 | self.data = self.cls_model.from_json(settings_json) 59 | result = True 60 | except (JSONDecodeError, TypeError, FileNotFoundError, ValueError) as e: 61 | if isinstance(e, ValueError): 62 | path_bak = path + ".bak" 63 | 64 | # First check if a backup file already exists. If yes, remove it. 65 | if os.path.exists(path_bak): 66 | os.remove(path_bak) 67 | 68 | # Move the invalid config file to the backup location. 69 | shutil.move(path, path_bak) 70 | # TODO: Implement better global logger. 71 | print( 72 | "Something is wrong with your config. Maybe it is not compatible anymore due to a new app version." 73 | f" You can find a backup of your old config here: '{path_bak}'. A new default config was created." 74 | ) 75 | 76 | self.data = self.cls_model() 77 | 78 | # Call save in case of we need to update the saved config, due to changes in code. 79 | self.save(settings_json) 80 | 81 | return result 82 | 83 | 84 | class Settings(BaseConfig, metaclass=SingletonMeta): 85 | def __init__(self): 86 | self.cls_model = ModelSettings 87 | self.file_path = path_file_settings() 88 | self.read(self.file_path) 89 | 90 | 91 | class Tidal(BaseConfig, metaclass=SingletonMeta): 92 | session: tidalapi.Session 93 | token_from_storage: bool = False 94 | settings: Settings 95 | is_pkce: bool 96 | 97 | def __init__(self, settings: Settings = None): 98 | self.cls_model = ModelToken 99 | tidal_config: tidalapi.Config = tidalapi.Config(item_limit=10000) 100 | self.session = tidalapi.Session(tidal_config) 101 | # self.session.config.client_id = "km8T1xS355y7dd3H" 102 | # self.session.config.client_secret = "vcmeGW1OuZ0fWYMCSZ6vNvSLJlT3XEpW0ambgYt5ZuI=" 103 | self.file_path = path_file_token() 104 | self.token_from_storage = self.read(self.file_path) 105 | 106 | if settings: 107 | self.settings = settings 108 | self.settings_apply() 109 | 110 | def settings_apply(self, settings: Settings = None) -> bool: 111 | if settings: 112 | self.settings = settings 113 | 114 | self.session.audio_quality = self.settings.data.quality_audio 115 | self.session.video_quality = tidalapi.VideoQuality.high 116 | 117 | return True 118 | 119 | def login_token(self, do_pkce: bool = False) -> bool: 120 | result = False 121 | self.is_pkce = do_pkce 122 | 123 | if self.token_from_storage: 124 | try: 125 | result = self.session.load_oauth_session( 126 | self.data.token_type, 127 | self.data.access_token, 128 | self.data.refresh_token, 129 | self.data.expiry_time, 130 | is_pkce=do_pkce, 131 | ) 132 | except (HTTPError, JSONDecodeError): 133 | result = False 134 | # Remove token file. Probably corrupt or invalid. 135 | if os.path.exists(self.file_path): 136 | os.remove(self.file_path) 137 | 138 | print( 139 | "Either there is something wrong with your credentials / account or some server problems on TIDALs " 140 | "side. Anyway... Try to login again by re-starting this app." 141 | ) 142 | 143 | return result 144 | 145 | def login_finalize(self) -> bool: 146 | result = self.session.check_login() 147 | 148 | if result: 149 | self.token_persist() 150 | 151 | return result 152 | 153 | def token_persist(self) -> None: 154 | self.set_option("token_type", self.session.token_type) 155 | self.set_option("access_token", self.session.access_token) 156 | self.set_option("refresh_token", self.session.refresh_token) 157 | self.set_option("expiry_time", self.session.expiry_time) 158 | self.save() 159 | 160 | def login(self, fn_print: Callable) -> bool: 161 | is_token = self.login_token() 162 | result = False 163 | 164 | if is_token: 165 | fn_print("Yep, looks good! You are logged in.") 166 | 167 | result = True 168 | elif not is_token: 169 | fn_print("You either do not have a token or your token is invalid.") 170 | fn_print("No worries, we will handle this...") 171 | # Login method: Device linking 172 | self.session.login_oauth_simple(fn_print) 173 | # Login method: PKCE authorization (was necessary for HI_RES_LOSSLESS streaming earlier) 174 | # self.session.login_pkce(fn_print) 175 | 176 | is_login = self.login_finalize() 177 | 178 | if is_login: 179 | fn_print("The login was successful. I have stored your credentials (token).") 180 | 181 | result = True 182 | else: 183 | fn_print("Something went wrong. Did you login using your browser correctly? May try again...") 184 | 185 | return result 186 | 187 | def logout(self): 188 | Path(self.file_path).unlink(missing_ok=True) 189 | self.token_from_storage = False 190 | del self.session 191 | 192 | return True 193 | 194 | 195 | class HandlingApp(metaclass=SingletonMeta): 196 | event_abort: Event = Event() 197 | event_run: Event = Event() 198 | 199 | def __init__(self): 200 | self.event_run.set() 201 | -------------------------------------------------------------------------------- /tidal_dl_ng/constants.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | CTX_TIDAL: str = "tidal" 4 | REQUESTS_TIMEOUT_SEC: int = 45 5 | EXTENSION_LYRICS: str = ".lrc" 6 | UNIQUIFY_THRESHOLD: int = 99 7 | FILENAME_SANITIZE_PLACEHOLDER: str = "_" 8 | COVER_NAME: str = "cover.jpg" 9 | BLOCK_SIZE: int = 4096 10 | BLOCKS: int = 1024 11 | CHUNK_SIZE: int = BLOCK_SIZE * BLOCKS 12 | PLAYLIST_EXTENSION: str = ".m3u" 13 | PLAYLIST_PREFIX: str = "_" 14 | FILENAME_LENGTH_MAX: int = 255 15 | 16 | 17 | class QualityVideo(StrEnum): 18 | P360: str = "360" 19 | P480: str = "480" 20 | P720: str = "720" 21 | P1080: str = "1080" 22 | 23 | 24 | class MediaType(StrEnum): 25 | TRACK: str = "track" 26 | VIDEO: str = "video" 27 | PLAYLIST: str = "playlist" 28 | ALBUM: str = "album" 29 | MIX: str = "mix" 30 | ARTIST: str = "artist" 31 | 32 | 33 | class CoverDimensions(StrEnum): 34 | Px80: str = "80" 35 | Px160: str = "160" 36 | Px320: str = "320" 37 | Px640: str = "640" 38 | Px1280: str = "1280" 39 | 40 | 41 | class TidalLists(StrEnum): 42 | Playlists: str = "Playlists" 43 | Favorites: str = "Favorites" 44 | Mixes: str = "Mixes" 45 | 46 | 47 | class QueueDownloadStatus(StrEnum): 48 | Waiting: str = "⏳️" 49 | Downloading: str = "▶️" 50 | Finished: str = "✅" 51 | Failed: str = "❌" 52 | Skipped: str = "↪️" 53 | 54 | 55 | FAVORITES: {} = { 56 | "fav_videos": {"name": "Videos", "function_name": "videos"}, 57 | "fav_tracks": {"name": "Tracks", "function_name": "tracks"}, 58 | "fav_mixes": {"name": "Mixes & Radio", "function_name": "mixes"}, 59 | "fav_artists": {"name": "Artists", "function_name": "artists"}, 60 | "fav_albums": {"name": "Albums", "function_name": "albums"}, 61 | } 62 | -------------------------------------------------------------------------------- /tidal_dl_ng/dialog.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os.path 3 | import shutil 4 | import webbrowser 5 | from enum import Enum, StrEnum 6 | from pathlib import Path 7 | 8 | from PySide6 import QtCore, QtGui, QtWidgets 9 | from tidalapi import Quality as QualityAudio 10 | 11 | from tidal_dl_ng import __version__ 12 | from tidal_dl_ng.config import Settings 13 | from tidal_dl_ng.constants import CoverDimensions, QualityVideo 14 | from tidal_dl_ng.model.cfg import HelpSettings 15 | from tidal_dl_ng.model.cfg import Settings as ModelSettings 16 | from tidal_dl_ng.model.meta import ReleaseLatest 17 | from tidal_dl_ng.ui.dialog_login import Ui_DialogLogin 18 | from tidal_dl_ng.ui.dialog_settings import Ui_DialogSettings 19 | from tidal_dl_ng.ui.dialog_version import Ui_DialogVersion 20 | 21 | 22 | class DialogVersion(QtWidgets.QDialog): 23 | """Version dialog.""" 24 | 25 | ui: Ui_DialogVersion 26 | 27 | def __init__( 28 | self, parent=None, update_check: bool = False, update_available: bool = False, update_info: ReleaseLatest = None 29 | ): 30 | super().__init__(parent) 31 | 32 | # Create an instance of the GUI 33 | self.ui = Ui_DialogVersion() 34 | 35 | # Run the .setupUi() method to show the GUI 36 | self.ui.setupUi(self) 37 | # Set the version. 38 | self.ui.l_version.setText("v" + __version__) 39 | 40 | if not update_check: 41 | self.update_info_hide() 42 | self.error_hide() 43 | else: 44 | self.update_info(update_available, update_info) 45 | 46 | # Show 47 | self.exec() 48 | 49 | def update_info(self, update_available: bool, update_info: ReleaseLatest): 50 | if not update_available and update_info.version == "v0.0.0": 51 | self.update_info_hide() 52 | self.ui.l_error_details.setText( 53 | "Cannot retrieve update information. Maybe something is wrong with your internet connection." 54 | ) 55 | else: 56 | self.error_hide() 57 | 58 | if not update_available: 59 | self.ui.l_h_version_new.setText("Latest available version:") 60 | self.changelog_hide() 61 | else: 62 | self.ui.l_changelog_details.setText(update_info.release_info) 63 | self.ui.pb_download.clicked.connect(lambda: webbrowser.open(update_info.url)) 64 | 65 | self.ui.l_version_new.setText(update_info.version) 66 | 67 | def error_hide(self): 68 | self.ui.l_error.setHidden(True) 69 | self.ui.l_error_details.setHidden(True) 70 | 71 | def update_info_hide(self): 72 | self.ui.l_h_version_new.setHidden(True) 73 | self.ui.l_version_new.setHidden(True) 74 | self.changelog_hide() 75 | 76 | def changelog_hide(self): 77 | self.ui.l_changelog.setHidden(True) 78 | self.ui.l_changelog_details.setHidden(True) 79 | self.ui.pb_download.setHidden(True) 80 | 81 | 82 | class DialogLogin(QtWidgets.QDialog): 83 | """Version dialog.""" 84 | 85 | ui: Ui_DialogLogin 86 | url_redirect: str 87 | 88 | def __init__(self, url_login: str, hint: str, expires_in: int, parent=None): 89 | super().__init__(parent) 90 | 91 | datetime_current: datetime.datetime = datetime.datetime.now() 92 | datetime_expires: datetime.datetime = datetime_current + datetime.timedelta(0, expires_in) 93 | 94 | # Create an instance of the GUI 95 | self.ui = Ui_DialogLogin() 96 | 97 | # Run the .setupUi() method to show the GUI 98 | self.ui.setupUi(self) 99 | # Set data. 100 | self.ui.tb_url_login.setText(f'https://{url_login}') 101 | self.ui.l_hint.setText(hint) 102 | self.ui.l_expires_date_time.setText(datetime_expires.strftime("%Y-%m-%d %H:%M")) 103 | # Show 104 | self.return_code = self.exec() 105 | 106 | 107 | class DialogPreferences(QtWidgets.QDialog): 108 | """Preferences dialog.""" 109 | 110 | ui: Ui_DialogSettings 111 | settings: Settings 112 | data: ModelSettings 113 | s_settings_save: QtCore.Signal 114 | icon: QtGui.QIcon 115 | help_settings: HelpSettings 116 | parameters_checkboxes: [str] 117 | parameters_combo: [(str, StrEnum)] 118 | parameters_line_edit: [str] 119 | parameters_spin_box: [str] 120 | prefix_checkbox: str = "cb_" 121 | prefix_label: str = "l_" 122 | prefix_icon: str = "icon_" 123 | prefix_line_edit: str = "le_" 124 | prefix_combo: str = "c_" 125 | prefix_spin_box: str = "sb_" 126 | 127 | def __init__(self, settings: Settings, settings_save: QtCore.Signal, parent=None): 128 | super().__init__(parent) 129 | 130 | self.settings = settings 131 | self.data = settings.data 132 | self.s_settings_save = settings_save 133 | self.help_settings = HelpSettings() 134 | pixmapi: QtWidgets.QStyle.StandardPixmap = QtWidgets.QStyle.SP_MessageBoxQuestion 135 | self.icon = self.style().standardIcon(pixmapi) 136 | 137 | self._init_checkboxes() 138 | self._init_comboboxes() 139 | self._init_line_edit() 140 | self._init_spin_box() 141 | 142 | # Create an instance of the GUI 143 | self.ui = Ui_DialogSettings() 144 | 145 | # Run the .setupUi() method to show the GUI 146 | self.ui.setupUi(self) 147 | # Set data. 148 | self.gui_populate() 149 | # Post setup 150 | 151 | self.exec() 152 | 153 | def _init_line_edit(self): 154 | self.parameters_line_edit = [ 155 | "download_base_path", 156 | "format_album", 157 | "format_playlist", 158 | "format_mix", 159 | "format_track", 160 | "format_video", 161 | "path_binary_ffmpeg", 162 | ] 163 | 164 | def _init_spin_box(self): 165 | self.parameters_spin_box = ["album_track_num_pad_min", "downloads_concurrent_max"] 166 | 167 | def _init_comboboxes(self): 168 | self.parameters_combo = [ 169 | ("quality_audio", QualityAudio), 170 | ("quality_video", QualityVideo), 171 | ("metadata_cover_dimension", CoverDimensions), 172 | ] 173 | 174 | def _init_checkboxes(self): 175 | self.parameters_checkboxes = [ 176 | "lyrics_embed", 177 | "lyrics_file", 178 | "video_download", 179 | "download_delay", 180 | "video_convert_mp4", 181 | "extract_flac", 182 | "metadata_cover_embed", 183 | "cover_album_file", 184 | "skip_existing", 185 | "symlink_to_track", 186 | "playlist_create", 187 | ] 188 | 189 | def gui_populate(self): 190 | self.populate_checkboxes() 191 | self.populate_combo() 192 | self.populate_line_edit() 193 | self.populate_spin_box() 194 | 195 | def dialog_chose_file( 196 | self, 197 | obj_line_edit: QtWidgets.QLineEdit, 198 | file_mode: QtWidgets.QFileDialog | QtWidgets.QFileDialog.FileMode = QtWidgets.QFileDialog.Directory, 199 | path_default: str = None, 200 | ): 201 | # If a path is set, use it otherwise the users home directory. 202 | path_settings: str = os.path.expanduser(obj_line_edit.text()) if obj_line_edit.text() else "" 203 | # Check if obj_line_edit is empty but path_default can be usd instead 204 | path_settings = ( 205 | path_settings if path_settings else os.path.expanduser(path_default) if path_default else path_settings 206 | ) 207 | dir_current: str = path_settings if path_settings and os.path.exists(path_settings) else str(Path.home()) 208 | dialog: QtWidgets.QFileDialog = QtWidgets.QFileDialog() 209 | 210 | # Set to directory mode only but show files. 211 | dialog.setFileMode(file_mode) 212 | dialog.setViewMode(QtWidgets.QFileDialog.Detail) 213 | dialog.setOption(QtWidgets.QFileDialog.ShowDirsOnly, False) 214 | dialog.setOption(QtWidgets.QFileDialog.DontResolveSymlinks, True) 215 | 216 | # There is a bug in the PyQt implementation, which hides files in Directory mode. 217 | # Thus, we need to use the PyQt dialog instead of the native dialog. 218 | if os.name == "nt" and file_mode == QtWidgets.QFileDialog.Directory: 219 | dialog.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True) 220 | 221 | dialog.setDirectory(dir_current) 222 | 223 | # Execute dialog and set path is something is choosen. 224 | if dialog.exec(): 225 | dir_name: str = dialog.selectedFiles()[0] 226 | path: Path = Path(dir_name) 227 | obj_line_edit.setText(str(path)) 228 | 229 | def populate_line_edit(self): 230 | for pn in self.parameters_line_edit: 231 | label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn) 232 | label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn) 233 | line_edit: QtWidgets.QLineEdit = getattr(self.ui, self.prefix_line_edit + pn) 234 | 235 | label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16)))) 236 | label_icon.setToolTip(getattr(self.help_settings, pn)) 237 | label.setText(pn) 238 | line_edit.setText(str(getattr(self.data, pn))) 239 | 240 | # Base Path File Dialog 241 | self.ui.pb_download_base_path.clicked.connect(lambda x: self.dialog_chose_file(self.ui.le_download_base_path)) 242 | self.ui.pb_path_binary_ffmpeg.clicked.connect( 243 | lambda x: self.dialog_chose_file( 244 | self.ui.le_path_binary_ffmpeg, 245 | file_mode=QtWidgets.QFileDialog.FileMode.ExistingFiles, 246 | path_default=shutil.which("ffmpeg"), 247 | ) 248 | ) 249 | 250 | def populate_combo(self): 251 | for p in self.parameters_combo: 252 | pn: str = p[0] 253 | values: Enum = p[1] 254 | label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn) 255 | label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn) 256 | combo: QtWidgets.QComboBox = getattr(self.ui, self.prefix_combo + pn) 257 | setting_current = getattr(self.data, pn) 258 | 259 | label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16)))) 260 | label_icon.setToolTip(getattr(self.help_settings, pn)) 261 | label.setText(pn) 262 | 263 | for index, v in enumerate(values): 264 | combo.addItem(v.name, v) 265 | 266 | if v == setting_current: 267 | combo.setCurrentIndex(index) 268 | 269 | def populate_checkboxes(self): 270 | for pn in self.parameters_checkboxes: 271 | checkbox: QtWidgets.QCheckBox = getattr(self.ui, self.prefix_checkbox + pn) 272 | 273 | checkbox.setText(pn) 274 | checkbox.setToolTip(getattr(self.help_settings, pn)) 275 | checkbox.setIcon(self.icon) 276 | checkbox.setChecked(getattr(self.data, pn)) 277 | 278 | def populate_spin_box(self): 279 | for pn in self.parameters_spin_box: 280 | label_icon: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + self.prefix_icon + pn) 281 | label: QtWidgets.QLabel = getattr(self.ui, self.prefix_label + pn) 282 | spin_box: QtWidgets.QSpinBox = getattr(self.ui, self.prefix_spin_box + pn) 283 | 284 | label_icon.setPixmap(QtGui.QPixmap(self.icon.pixmap(QtCore.QSize(16, 16)))) 285 | label_icon.setToolTip(getattr(self.help_settings, pn)) 286 | label.setText(pn) 287 | spin_box.setValue(getattr(self.data, pn)) 288 | 289 | def accept(self): 290 | # Get settings. 291 | self.to_settings() 292 | self.done(1) 293 | 294 | def to_settings(self): 295 | for item in self.parameters_checkboxes: 296 | setattr(self.settings.data, item, getattr(self.ui, self.prefix_checkbox + item).isChecked()) 297 | 298 | for item in self.parameters_line_edit: 299 | setattr(self.settings.data, item, getattr(self.ui, self.prefix_line_edit + item).text()) 300 | 301 | for item in self.parameters_combo: 302 | setattr(self.settings.data, item[0], getattr(self.ui, self.prefix_combo + item[0]).currentData()) 303 | 304 | for item in self.parameters_spin_box: 305 | setattr(self.settings.data, item, getattr(self.ui, self.prefix_spin_box + item).value()) 306 | 307 | self.s_settings_save.emit() 308 | -------------------------------------------------------------------------------- /tidal_dl_ng/helper/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exislow/tidal-dl-ng/d9ab28d8840c8616d11f5610a78a02b90fb7e696/tidal_dl_ng/helper/__init__.py -------------------------------------------------------------------------------- /tidal_dl_ng/helper/decorator.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | 4 | class SingletonMeta(type): 5 | """ 6 | The Singleton class can be implemented in different ways in Python. Some 7 | possible methods include: base class, decorator, metaclass. We will use the 8 | metaclass because it is best suited for this purpose. 9 | """ 10 | 11 | _instances: ClassVar[dict] = {} 12 | 13 | def __call__(cls, *args, **kwargs): 14 | """ 15 | Possible changes to the value of the `__init__` argument do not affect 16 | the returned instance. 17 | """ 18 | if cls not in cls._instances: 19 | instance = super().__call__(*args, **kwargs) 20 | cls._instances[cls] = instance 21 | 22 | return cls._instances[cls] 23 | -------------------------------------------------------------------------------- /tidal_dl_ng/helper/decryption.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import pathlib 3 | 4 | from Crypto.Cipher import AES 5 | from Crypto.Util import Counter 6 | 7 | 8 | def decrypt_security_token(security_token: str) -> (str, str): 9 | """ 10 | Decrypts security token into key and nonce pair 11 | 12 | security_token should match the securityToken value from the web response 13 | """ 14 | 15 | # Do not change this 16 | master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754=" 17 | 18 | # Decode the base64 strings to ascii strings 19 | master_key = base64.b64decode(master_key) 20 | security_token = base64.b64decode(security_token) 21 | 22 | # Get the IV from the first 16 bytes of the securityToken 23 | iv = security_token[:16] 24 | encrypted_st = security_token[16:] 25 | 26 | # Initialize decryptor 27 | decryptor = AES.new(master_key, AES.MODE_CBC, iv) 28 | 29 | # Decrypt the security token 30 | decrypted_st = decryptor.decrypt(encrypted_st) 31 | 32 | # Get the audio stream decryption key and nonce from the decrypted security token 33 | key = decrypted_st[:16] 34 | nonce = decrypted_st[16:24] 35 | 36 | return key, nonce 37 | 38 | 39 | def decrypt_file(path_file_encrypted: pathlib.Path, path_file_destination: pathlib.Path, key: str, nonce: str) -> None: 40 | """ 41 | Decrypts an encrypted MQA file given the file, key and nonce. 42 | TODO: Is it really only necessary for MQA of for all other formats, too? 43 | """ 44 | 45 | # Initialize counter and file decryptor 46 | counter = Counter.new(64, prefix=nonce, initial_value=0) 47 | decryptor = AES.new(key, AES.MODE_CTR, counter=counter) 48 | 49 | # Open and decrypt 50 | with path_file_encrypted.open("rb") as f_src: 51 | audio_decrypted = decryptor.decrypt(f_src.read()) 52 | 53 | # Replace with decrypted file 54 | with path_file_destination.open("wb") as f_dst: 55 | f_dst.write(audio_decrypted) 56 | -------------------------------------------------------------------------------- /tidal_dl_ng/helper/exceptions.py: -------------------------------------------------------------------------------- 1 | class LoginError(Exception): 2 | pass 3 | 4 | 5 | class MediaUnknown(Exception): 6 | pass 7 | 8 | 9 | class UnknownManifestFormat(Exception): 10 | pass 11 | 12 | 13 | class MediaMissing(Exception): 14 | pass 15 | -------------------------------------------------------------------------------- /tidal_dl_ng/helper/gui.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import cast 3 | 4 | from PySide6 import QtCore, QtGui, QtWidgets 5 | from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video 6 | from tidalapi.artist import Artist 7 | from tidalapi.media import Quality 8 | 9 | from tidal_dl_ng.constants import QualityVideo 10 | 11 | 12 | def get_table_data( 13 | item: QtWidgets.QTreeWidgetItem, column: int 14 | ) -> Track | Video | Album | Artist | Mix | Playlist | UserPlaylist: 15 | result: Track | Video | Album | Artist = item.data(column, QtCore.Qt.ItemDataRole.UserRole) 16 | 17 | return result 18 | 19 | 20 | def get_table_text(item: QtWidgets.QTreeWidgetItem, column: int) -> str: 21 | result: str = item.text(column) 22 | 23 | return result 24 | 25 | 26 | def get_results_media_item( 27 | index: QtCore.QModelIndex, proxy: QtCore.QSortFilterProxyModel, model: QtGui.QStandardItemModel 28 | ) -> Track | Video | Album | Artist | Playlist | Mix: 29 | # Switch column to "obj" column and map proxy data to our model. 30 | item: QtGui.QStandardItem = model.itemFromIndex(proxy.mapToSource(index.siblingAtColumn(1))) 31 | result: Track | Video | Album | Artist = item.data(QtCore.Qt.ItemDataRole.UserRole) 32 | 33 | return result 34 | 35 | 36 | def get_user_list_media_item(item: QtWidgets.QTreeWidgetItem) -> Mix | Playlist | UserPlaylist: 37 | result: Mix | Playlist | UserPlaylist = get_table_data(item, 1) 38 | 39 | return result 40 | 41 | 42 | def get_queue_download_media( 43 | item: QtWidgets.QTreeWidgetItem, 44 | ) -> Mix | Playlist | UserPlaylist | Track | Video | Album | Artist: 45 | result: Mix | Playlist | UserPlaylist | Track | Video | Album | Artist = get_table_data(item, 1) 46 | 47 | return result 48 | 49 | 50 | def get_queue_download_quality( 51 | item: QtWidgets.QTreeWidgetItem, 52 | column: int, 53 | ) -> str: 54 | result: str = get_table_text(item, column) 55 | 56 | return result 57 | 58 | 59 | def get_queue_download_quality_audio( 60 | item: QtWidgets.QTreeWidgetItem, 61 | ) -> Quality: 62 | result: Quality = cast(Quality, get_queue_download_quality(item, 4)) 63 | 64 | return result 65 | 66 | 67 | def get_queue_download_quality_video( 68 | item: QtWidgets.QTreeWidgetItem, 69 | ) -> QualityVideo: 70 | result: QualityVideo = cast(QualityVideo, get_queue_download_quality(item, 5)) 71 | 72 | return result 73 | 74 | 75 | def set_table_data( 76 | item: QtWidgets.QTreeWidgetItem, data: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist, column: int 77 | ): 78 | item.setData(column, QtCore.Qt.ItemDataRole.UserRole, data) 79 | 80 | 81 | def set_results_media(item: QtWidgets.QTreeWidgetItem, media: Track | Video | Album | Artist): 82 | set_table_data(item, media, 1) 83 | 84 | 85 | def set_user_list_media( 86 | item: QtWidgets.QTreeWidgetItem, media: Track | Video | Album | Artist | Mix | Playlist | UserPlaylist 87 | ): 88 | set_table_data(item, media, 1) 89 | 90 | 91 | def set_queue_download_media( 92 | item: QtWidgets.QTreeWidgetItem, media: Mix | Playlist | UserPlaylist | Track | Video | Album | Artist 93 | ): 94 | set_table_data(item, media, 1) 95 | 96 | 97 | class FilterHeader(QtWidgets.QHeaderView): 98 | filter_activated = QtCore.Signal() 99 | 100 | def __init__(self, parent): 101 | super().__init__(QtCore.Qt.Horizontal, parent) 102 | self._editors = [] 103 | self._padding = 4 104 | self.setCascadingSectionResizes(True) 105 | self.setSectionResizeMode(QtWidgets.QHeaderView.Interactive) 106 | self.setStretchLastSection(True) 107 | self.setDefaultAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter) 108 | self.setSortIndicatorShown(False) 109 | self.setSectionsMovable(True) 110 | self.sectionResized.connect(self.adjust_positions) 111 | parent.horizontalScrollBar().valueChanged.connect(self.adjust_positions) 112 | 113 | def set_filter_boxes(self, count): 114 | while self._editors: 115 | editor = self._editors.pop() 116 | editor.deleteLater() 117 | 118 | for _ in range(count): 119 | editor = QtWidgets.QLineEdit(self.parent()) 120 | editor.setPlaceholderText("Filter") 121 | editor.setClearButtonEnabled(True) 122 | editor.returnPressed.connect(self.filter_activated.emit) 123 | self._editors.append(editor) 124 | 125 | self.adjust_positions() 126 | 127 | def sizeHint(self): 128 | size = super().sizeHint() 129 | 130 | if self._editors: 131 | height = self._editors[0].sizeHint().height() 132 | 133 | size.setHeight(size.height() + height + self._padding) 134 | 135 | return size 136 | 137 | def updateGeometries(self): 138 | if self._editors: 139 | height = self._editors[0].sizeHint().height() 140 | 141 | self.setViewportMargins(0, 0, 0, height + self._padding) 142 | else: 143 | self.setViewportMargins(0, 0, 0, 0) 144 | 145 | super().updateGeometries() 146 | self.adjust_positions() 147 | 148 | def adjust_positions(self): 149 | for index, editor in enumerate(self._editors): 150 | height = editor.sizeHint().height() 151 | 152 | editor.move(self.sectionPosition(index) - self.offset() + 2, height + (self._padding // 2)) 153 | editor.resize(self.sectionSize(index), height) 154 | 155 | def filter_text(self, index) -> str: 156 | if 0 <= index < len(self._editors): 157 | return self._editors[index].text() 158 | 159 | return "" 160 | 161 | def set_filter_text(self, index, text): 162 | if 0 <= index < len(self._editors): 163 | self._editors[index].setText(text) 164 | 165 | def clear_filters(self): 166 | for editor in self._editors: 167 | editor.clear() 168 | 169 | 170 | class HumanProxyModel(QtCore.QSortFilterProxyModel): 171 | def _human_key(self, key): 172 | parts = re.split(r"(\d*\.\d+|\d+)", key) 173 | 174 | return tuple((e.swapcase() if i % 2 == 0 else float(e)) for i, e in enumerate(parts)) 175 | 176 | def lessThan(self, source_left, source_right): 177 | data_left = source_left.data() 178 | data_right = source_right.data() 179 | 180 | if isinstance(data_left, str) and isinstance(data_right, str): 181 | return self._human_key(data_left) < self._human_key(data_right) 182 | 183 | return super().lessThan(source_left, source_right) 184 | 185 | @property 186 | def filters(self): 187 | if not hasattr(self, "_filters"): 188 | self._filters = [] 189 | 190 | return self._filters 191 | 192 | @filters.setter 193 | def filters(self, filters): 194 | self._filters = filters 195 | 196 | self.invalidateFilter() 197 | 198 | def filterAcceptsRow(self, source_row: int, source_parent: QtCore.QModelIndex) -> bool: 199 | model = self.sourceModel() 200 | source_index = model.index(source_row, 0, source_parent) 201 | result: [bool] = [] 202 | 203 | # Show top level children 204 | for child_row in range(model.rowCount(source_index)): 205 | if self.filterAcceptsRow(child_row, source_index): 206 | return True 207 | 208 | # Filter for actual needle 209 | for i, text in self.filters: 210 | if 0 <= i < self.columnCount(): 211 | ix = self.sourceModel().index(source_row, i, source_parent) 212 | data = ix.data() 213 | 214 | # Append results to list to enable an AND operator for filtering. 215 | result.append(bool(re.search(rf"{text}", data, re.MULTILINE | re.IGNORECASE)) if data else False) 216 | 217 | # If no filter set, just set the result to True. 218 | if not result: 219 | result.append(True) 220 | 221 | return all(result) 222 | -------------------------------------------------------------------------------- /tidal_dl_ng/helper/path.py: -------------------------------------------------------------------------------- 1 | import math 2 | import os 3 | import pathlib 4 | import posixpath 5 | import re 6 | import sys 7 | from copy import deepcopy 8 | from urllib.parse import unquote, urlsplit 9 | 10 | from pathvalidate import sanitize_filename, sanitize_filepath 11 | from pathvalidate.error import ValidationError 12 | from tidalapi import Album, Mix, Playlist, Track, UserPlaylist, Video 13 | from tidalapi.media import AudioExtensions 14 | 15 | from tidal_dl_ng import __name_display__ 16 | from tidal_dl_ng.constants import FILENAME_LENGTH_MAX, FILENAME_SANITIZE_PLACEHOLDER, UNIQUIFY_THRESHOLD, MediaType 17 | from tidal_dl_ng.helper.tidal import name_builder_album_artist, name_builder_artist, name_builder_title 18 | 19 | 20 | def path_home() -> str: 21 | if "XDG_CONFIG_HOME" in os.environ: 22 | return os.environ["XDG_CONFIG_HOME"] 23 | elif "HOME" in os.environ: 24 | return os.environ["HOME"] 25 | elif "HOMEDRIVE" in os.environ and "HOMEPATH" in os.environ: 26 | return os.path.join(os.environ["HOMEDRIVE"], os.environ["HOMEPATH"]) 27 | else: 28 | return os.path.abspath("./") 29 | 30 | 31 | def path_config_base() -> str: 32 | # https://wiki.archlinux.org/title/XDG_Base_Directory 33 | # X11 workaround: If user specified config path is set, do not point to "~/.config" 34 | path_user_custom: str = os.environ.get("XDG_CONFIG_HOME", "") 35 | path_config: str = ".config" if not path_user_custom else "" 36 | path_base: str = os.path.join(path_home(), path_config, __name_display__) 37 | 38 | return path_base 39 | 40 | 41 | def path_file_log() -> str: 42 | return os.path.join(path_config_base(), "app.log") 43 | 44 | 45 | def path_file_token() -> str: 46 | return os.path.join(path_config_base(), "token.json") 47 | 48 | 49 | def path_file_settings() -> str: 50 | return os.path.join(path_config_base(), "settings.json") 51 | 52 | 53 | def format_path_media( 54 | fmt_template: str, 55 | media: Track | Album | Playlist | UserPlaylist | Video | Mix, 56 | album_track_num_pad_min: int = 0, 57 | list_pos: int = 0, 58 | list_total: int = 0, 59 | ) -> str: 60 | result = fmt_template 61 | 62 | # Search track format template for placeholder. 63 | regex = r"\{(.+?)\}" 64 | matches = re.finditer(regex, fmt_template, re.MULTILINE) 65 | 66 | for _matchNum, match in enumerate(matches, start=1): 67 | template_str = match.group() 68 | result_fmt = format_str_media(match.group(1), media, album_track_num_pad_min, list_pos, list_total) 69 | 70 | if result_fmt != match.group(1): 71 | value = sanitize_filename(result_fmt) 72 | result = result.replace(template_str, value) 73 | 74 | return result 75 | 76 | 77 | def format_str_media( 78 | name: str, 79 | media: Track | Album | Playlist | UserPlaylist | Video | Mix, 80 | album_track_num_pad_min: int = 0, 81 | list_pos: int = 0, 82 | list_total: int = 0, 83 | ) -> str: 84 | result: str = name 85 | 86 | try: 87 | match name: 88 | case "artist_name": 89 | if isinstance(media, Track | Video): 90 | if hasattr(media, "artists"): 91 | result = name_builder_artist(media) 92 | elif hasattr(media, "artist"): 93 | result = media.artist.name 94 | case "album_artist": 95 | result = name_builder_album_artist(media) 96 | case "track_title": 97 | if isinstance(media, Track | Video): 98 | result = name_builder_title(media) 99 | case "mix_name": 100 | if isinstance(media, Mix): 101 | result = media.title 102 | case "playlist_name": 103 | if isinstance(media, Playlist | UserPlaylist): 104 | result = media.name 105 | case "album_title": 106 | if isinstance(media, Album): 107 | result = media.name 108 | elif isinstance(media, Track): 109 | result = media.album.name 110 | case "album_track_num": 111 | if isinstance(media, Track | Video): 112 | result = calculate_number_padding( 113 | album_track_num_pad_min, 114 | media.track_num, 115 | media.album.num_tracks if hasattr(media, "album") else 1, 116 | ) 117 | case "album_num_tracks": 118 | if isinstance(media, Track | Video): 119 | result = str(media.album.num_tracks if hasattr(media, "album") else 1) 120 | case "track_id": 121 | if isinstance(media, Track | Video): 122 | result = str(media.id) 123 | case "playlist_id": 124 | if isinstance(media, Playlist): 125 | result = str(media.id) 126 | case "album_id": 127 | if isinstance(media, Album): 128 | result = str(media.id) 129 | elif isinstance(media, Track): 130 | result = str(media.album.id) 131 | case "track_duration_seconds": 132 | if isinstance(media, Track | Video): 133 | result = str(media.duration) 134 | case "track_duration_minutes": 135 | if isinstance(media, Track | Video): 136 | m, s = divmod(media.duration, 60) 137 | result = f"{m:01d}:{s:02d}" 138 | case "album_duration_seconds": 139 | if isinstance(media, Album): 140 | result = str(media.duration) 141 | case "album_duration_minutes": 142 | if isinstance(media, Album): 143 | m, s = divmod(media.duration, 60) 144 | result = f"{m:01d}:{s:02d}" 145 | case "playlist_duration_seconds": 146 | if isinstance(media, Album): 147 | result = str(media.duration) 148 | case "playlist_duration_minutes": 149 | if isinstance(media, Album): 150 | m, s = divmod(media.duration, 60) 151 | result = f"{m:01d}:{s:02d}" 152 | case "album_year": 153 | if isinstance(media, Album): 154 | result = str(media.year) 155 | elif isinstance(media, Track): 156 | result = str(media.album.year) 157 | case "video_quality": 158 | if isinstance(media, Video): 159 | result = media.video_quality 160 | case "track_quality": 161 | if isinstance(media, Track): 162 | result = ", ".join(tag for tag in media.media_metadata_tags) 163 | case "track_explicit": 164 | if isinstance(media, Track | Video): 165 | result = " (Explicit)" if media.explicit else "" 166 | case "album_explicit": 167 | if isinstance(media, Album): 168 | result = " (Explicit)" if media.explicit else "" 169 | case "album_num_volumes": 170 | if isinstance(media, Album): 171 | result = str(media.num_volumes) 172 | case "track_volume_num": 173 | if isinstance(media, Track | Video): 174 | result = str(media.volume_num) 175 | case "track_volume_num_optional": 176 | if isinstance(media, Track | Video): 177 | num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1 178 | result = "" if num_volumes == 1 else str(media.volume_num) 179 | case "track_volume_num_optional_CD": 180 | if isinstance(media, Track | Video): 181 | num_volumes: int = media.album.num_volumes if hasattr(media, "album") else 1 182 | result = "" if num_volumes == 1 else f"CD{media.volume_num!s}" 183 | case "isrc": 184 | if isinstance(media, Track): 185 | result = media.isrc 186 | case "list_pos": 187 | if isinstance(media, Track | Video): 188 | # TODO: Rename `album_track_num_pad_min` globally. 189 | result = calculate_number_padding(album_track_num_pad_min, list_pos, list_total) 190 | except Exception as e: 191 | # TODO: Implement better exception logging. 192 | print(e) 193 | 194 | pass 195 | 196 | return result 197 | 198 | 199 | def calculate_number_padding(padding_minimum: int, item_position: int, items_max: int) -> str: 200 | result: str 201 | 202 | if items_max > 0: 203 | count_digits: int = int(math.log10(items_max)) + 1 204 | count_digits_computed: int = count_digits if count_digits > padding_minimum else padding_minimum 205 | result = str(item_position).zfill(count_digits_computed) 206 | else: 207 | result = str(item_position) 208 | 209 | return result 210 | 211 | 212 | def get_format_template( 213 | media: Track | Album | Playlist | UserPlaylist | Video | Mix | MediaType, settings 214 | ) -> str | bool: 215 | result = False 216 | 217 | if isinstance(media, Track) or media == MediaType.TRACK: 218 | result = settings.data.format_track 219 | elif isinstance(media, Album) or media == MediaType.ALBUM or media == MediaType.ARTIST: 220 | result = settings.data.format_album 221 | elif isinstance(media, Playlist | UserPlaylist) or media == MediaType.PLAYLIST: 222 | result = settings.data.format_playlist 223 | elif isinstance(media, Mix) or media == MediaType.MIX: 224 | result = settings.data.format_mix 225 | elif isinstance(media, Video) or media == MediaType.VIDEO: 226 | result = settings.data.format_video 227 | 228 | return result 229 | 230 | 231 | def path_file_sanitize(path_file: pathlib.Path, adapt: bool = False, uniquify: bool = False) -> pathlib.Path: 232 | sanitized_filename: str = path_file.name 233 | sanitized_path: pathlib.Path = path_file.parent 234 | result: pathlib.Path 235 | 236 | # Sanitize filename and make sure it does not exceed FILENAME_LENGTH_MAX 237 | try: 238 | # sanitize_filename can shorten the file name actually 239 | sanitized_filename = sanitize_filename( 240 | sanitized_filename, replacement_text="_", validate_after_sanitize=True, platform="auto" 241 | ) 242 | 243 | # Check if the file extension was removed by shortening the filename length 244 | if not sanitized_filename.endswith(path_file.suffix): 245 | # Add the original file extension 246 | file_suffix: str = FILENAME_SANITIZE_PLACEHOLDER + path_file.suffix 247 | sanitized_filename = sanitized_filename[: -len(file_suffix)] + file_suffix 248 | except ValidationError as e: 249 | if adapt: 250 | # TODO: Implement proper exception handling and logging. 251 | # Hacky stuff, since the sanitizing function does not shorten the filename (filename too long) 252 | if str(e).startswith("[PV1101]"): 253 | byte_ct: int = len(sanitized_filename.encode(sys.getfilesystemencoding())) - FILENAME_LENGTH_MAX 254 | sanitized_filename = ( 255 | sanitized_filename[: -byte_ct - len(FILENAME_SANITIZE_PLACEHOLDER) - len(path_file.suffix)] 256 | + FILENAME_SANITIZE_PLACEHOLDER 257 | + path_file.suffix 258 | ) 259 | else: 260 | raise 261 | else: 262 | raise 263 | 264 | # Sanitize the path. 265 | try: 266 | sanitized_path: pathlib.Path = sanitize_filepath( 267 | sanitized_path, replacement_text="_", validate_after_sanitize=True, platform="auto" 268 | ) 269 | except ValidationError as e: 270 | # If adaption of path is allowed in case of an error set path to HOME. 271 | if adapt: 272 | if str(e).startswith("[PV1101]"): 273 | sanitized_path = pathlib.Path.home() 274 | else: 275 | raise 276 | else: 277 | raise 278 | 279 | result = sanitized_path / sanitized_filename 280 | 281 | # Uniquify 282 | if uniquify: 283 | result = path_file_uniquify(result) 284 | 285 | return result 286 | 287 | 288 | def path_file_uniquify(path_file: pathlib.Path) -> pathlib.Path: 289 | """Checks whether the file exists, if so it tries to return an unique name suffix. 290 | 291 | :param path_file: Path to file name which shall be unique. 292 | :type path_file: pathlib.Path 293 | :return: Unique file name with path for given input. 294 | :rtype: pathlib.Path 295 | """ 296 | unique_suffix: str = file_unique_suffix(path_file) 297 | 298 | if unique_suffix: 299 | file_suffix = unique_suffix + path_file.suffix 300 | # For most OS filename has a character limit of 255. 301 | path_file = ( 302 | path_file.parent / (str(path_file.stem)[: -len(file_suffix)] + file_suffix) 303 | if len(str(path_file.parent / (path_file.stem + unique_suffix))) > FILENAME_LENGTH_MAX 304 | else path_file.parent / (path_file.stem + unique_suffix) 305 | ) 306 | 307 | return path_file 308 | 309 | 310 | def file_unique_suffix(path_file: pathlib.Path, seperator: str = "_") -> str: 311 | threshold_zfill: int = len(str(UNIQUIFY_THRESHOLD)) 312 | count: int = 0 313 | path_file_tmp: pathlib.Path = deepcopy(path_file) 314 | unique_suffix: str = "" 315 | 316 | while check_file_exists(path_file_tmp) and count < UNIQUIFY_THRESHOLD: 317 | count += 1 318 | unique_suffix = seperator + str(count).zfill(threshold_zfill) 319 | path_file_tmp = path_file.parent / (path_file.stem + unique_suffix + path_file.suffix) 320 | 321 | return unique_suffix 322 | 323 | 324 | def check_file_exists(path_file: pathlib.Path, extension_ignore: bool = False) -> bool: 325 | if extension_ignore: 326 | path_file_stem: str = pathlib.Path(path_file).stem 327 | path_parent: pathlib.Path = pathlib.Path(path_file).parent 328 | path_files: [str] = [] 329 | 330 | for extension in AudioExtensions: 331 | path_files.append(str(path_parent.joinpath(path_file_stem + extension))) 332 | else: 333 | path_files: [str] = [path_file] 334 | 335 | result = bool(sum([[True] if os.path.isfile(_file) else [] for _file in path_files], [])) 336 | 337 | return result 338 | 339 | 340 | def resource_path(relative_path): 341 | try: 342 | base_path = sys._MEIPASS 343 | except Exception: 344 | base_path = os.path.abspath(".") 345 | 346 | return os.path.join(base_path, relative_path) 347 | 348 | 349 | def url_to_filename(url: str) -> str: 350 | """Return basename corresponding to url. 351 | >>> print(url_to_filename('http://example.com/path/to/file%C3%80?opt=1')) 352 | fileÀ 353 | >>> print(url_to_filename('http://example.com/slash%2fname')) # '/' in name 354 | Taken from https://gist.github.com/zed/c2168b9c52b032b5fb7d 355 | Traceback (most recent call last): 356 | ... 357 | ValueError 358 | """ 359 | urlpath: str = urlsplit(url).path 360 | basename: str = posixpath.basename(unquote(urlpath)) 361 | 362 | if os.path.basename(basename) != basename or unquote(posixpath.basename(urlpath)) != basename: 363 | raise ValueError # reject '%2f' or 'dir%5Cbasename.ext' on Windows 364 | 365 | return basename 366 | -------------------------------------------------------------------------------- /tidal_dl_ng/helper/tidal.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video 4 | from tidalapi.artist import Artist, Role 5 | from tidalapi.media import MediaMetadataTags, Quality 6 | from tidalapi.session import SearchTypes 7 | from tidalapi.user import LoggedInUser 8 | 9 | from tidal_dl_ng.constants import FAVORITES, MediaType 10 | from tidal_dl_ng.helper.exceptions import MediaUnknown 11 | 12 | 13 | def name_builder_artist(media: Track | Video | Album) -> str: 14 | return ", ".join(artist.name for artist in media.artists) 15 | 16 | 17 | def name_builder_album_artist(media: Track | Album) -> str: 18 | artists_tmp: [str] = [] 19 | artists: [Artist] = media.album.artists if isinstance(media, Track) else media.artists 20 | 21 | for artist in artists: 22 | if Role.main in artist.roles: 23 | artists_tmp.append(artist.name) 24 | 25 | return ", ".join(artists_tmp) 26 | 27 | 28 | def name_builder_title(media: Track | Video | Mix | Playlist | Album | Video) -> str: 29 | result: str = ( 30 | media.title if isinstance(media, Mix) else media.full_name if hasattr(media, "full_name") else media.name 31 | ) 32 | 33 | return result 34 | 35 | 36 | def name_builder_item(media: Track | Video) -> str: 37 | return f"{name_builder_artist(media)} - {name_builder_title(media)}" 38 | 39 | 40 | def get_tidal_media_id(url_or_id_media: str) -> str: 41 | id_dirty = url_or_id_media.rsplit("/", 1)[-1] 42 | id_media = id_dirty.rsplit("?", 1)[0] 43 | 44 | return id_media 45 | 46 | 47 | def get_tidal_media_type(url_media: str) -> MediaType | bool: 48 | result: MediaType | bool = False 49 | url_split = url_media.split("/")[-2] 50 | 51 | if len(url_split) > 1: 52 | media_name = url_media.split("/")[-2] 53 | 54 | if media_name == "track": 55 | result = MediaType.TRACK 56 | elif media_name == "video": 57 | result = MediaType.VIDEO 58 | elif media_name == "album": 59 | result = MediaType.ALBUM 60 | elif media_name == "playlist": 61 | result = MediaType.PLAYLIST 62 | elif media_name == "mix": 63 | result = MediaType.MIX 64 | elif media_name == "artist": 65 | result = MediaType.ARTIST 66 | 67 | return result 68 | 69 | 70 | def search_results_all(session: Session, needle: str, types_media: SearchTypes = None) -> dict[str, [SearchTypes]]: 71 | limit: int = 300 72 | offset: int = 0 73 | done: bool = False 74 | result: dict[str, [SearchTypes]] = {} 75 | 76 | while not done: 77 | tmp_result: dict[str, [SearchTypes]] = session.search( 78 | query=needle, models=types_media, limit=limit, offset=offset 79 | ) 80 | tmp_done: bool = True 81 | 82 | for key, value in tmp_result.items(): 83 | # Append pagination results, if there are any 84 | if offset == 0: 85 | result = tmp_result 86 | tmp_done = False 87 | elif bool(value): 88 | result[key] += value 89 | tmp_done = False 90 | 91 | # Next page 92 | offset += limit 93 | done = tmp_done 94 | 95 | return result 96 | 97 | 98 | def items_results_all( 99 | media_list: [Mix | Playlist | Album | Artist], videos_include: bool = True 100 | ) -> [Track | Video | Album]: 101 | result: [Track | Video | Album] = [] 102 | 103 | if isinstance(media_list, Mix): 104 | result = media_list.items() 105 | else: 106 | func_get_items_media: [Callable] = [] 107 | 108 | if isinstance(media_list, Playlist | Album): 109 | if videos_include: 110 | func_get_items_media.append(media_list.items) 111 | else: 112 | func_get_items_media.append(media_list.tracks) 113 | else: 114 | func_get_items_media.append(media_list.get_albums) 115 | func_get_items_media.append(media_list.get_ep_singles) 116 | 117 | result = paginate_results(func_get_items_media) 118 | 119 | return result 120 | 121 | 122 | def all_artist_album_ids(media_artist: Artist) -> [int | None]: 123 | result: [int] = [] 124 | func_get_items_media: [Callable] = [media_artist.get_albums, media_artist.get_ep_singles] 125 | albums: [Album] = paginate_results(func_get_items_media) 126 | 127 | for album in albums: 128 | result.append(album.id) 129 | 130 | return result 131 | 132 | 133 | def paginate_results(func_get_items_media: [Callable]) -> [Track | Video | Album | Playlist | UserPlaylist]: 134 | result: [Track | Video | Album] = [] 135 | 136 | for func_media in func_get_items_media: 137 | limit: int = 100 138 | offset: int = 0 139 | done: bool = False 140 | 141 | if func_media.__func__ == LoggedInUser.playlist_and_favorite_playlists: 142 | limit: int = 50 143 | 144 | while not done: 145 | tmp_result: [Track | Video | Album | Playlist | UserPlaylist] = func_media(limit=limit, offset=offset) 146 | 147 | if bool(tmp_result): 148 | result += tmp_result 149 | # Get the next page in the next iteration. 150 | offset += limit 151 | else: 152 | done = True 153 | 154 | return result 155 | 156 | 157 | def user_media_lists(session: Session) -> [Playlist | UserPlaylist | Mix]: 158 | user_playlists: [Playlist | UserPlaylist] = paginate_results([session.user.playlist_and_favorite_playlists]) 159 | user_mixes: [Mix] = session.mixes().categories[0].items 160 | result: [Playlist | UserPlaylist | Mix] = user_playlists + user_mixes 161 | 162 | return result 163 | 164 | 165 | def instantiate_media( 166 | session: Session, 167 | media_type: type[MediaType.TRACK, MediaType.VIDEO, MediaType.ALBUM, MediaType.PLAYLIST, MediaType.MIX], 168 | id_media: str, 169 | ) -> Track | Video | Album | Playlist | Mix | Artist: 170 | if media_type == MediaType.TRACK: 171 | media = session.track(id_media, with_album=True) 172 | elif media_type == MediaType.VIDEO: 173 | media = session.video(id_media) 174 | elif media_type == MediaType.ALBUM: 175 | media = session.album(id_media) 176 | elif media_type == MediaType.PLAYLIST: 177 | media = session.playlist(id_media) 178 | elif media_type == MediaType.MIX: 179 | media = session.mix(id_media) 180 | elif media_type == MediaType.ARTIST: 181 | media = session.artist(id_media) 182 | else: 183 | raise MediaUnknown 184 | 185 | return media 186 | 187 | 188 | def quality_audio_highest(media: Track | Album) -> Quality: 189 | quality: Quality 190 | 191 | if MediaMetadataTags.hi_res_lossless in media.media_metadata_tags: 192 | quality = Quality.hi_res_lossless 193 | elif MediaMetadataTags.lossless in media.media_metadata_tags: 194 | quality = Quality.high_lossless 195 | else: 196 | quality = media.audio_quality 197 | 198 | return quality 199 | 200 | 201 | def favorite_function_factory(tidal, favorite_item: str): 202 | function_name: str = FAVORITES[favorite_item]["function_name"] 203 | function_list: Callable = getattr(tidal.session.user.favorites, function_name) 204 | 205 | return function_list 206 | -------------------------------------------------------------------------------- /tidal_dl_ng/helper/wrapper.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | 4 | class LoggerWrapped: 5 | fn_print: Callable = None 6 | 7 | def __init__(self, fn_print: Callable): 8 | self.fn_print = fn_print 9 | 10 | def debug(self, value): 11 | self.fn_print(value) 12 | 13 | def warning(self, value): 14 | self.fn_print(value) 15 | 16 | def info(self, value): 17 | self.fn_print(value) 18 | 19 | def error(self, value): 20 | self.fn_print(value) 21 | 22 | def critical(self, value): 23 | self.fn_print(value) 24 | 25 | def exception(self, value): 26 | self.fn_print(value) 27 | -------------------------------------------------------------------------------- /tidal_dl_ng/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import coloredlogs 5 | from PySide6 import QtCore 6 | 7 | 8 | class XStream(QtCore.QObject): 9 | _stdout = None 10 | _stderr = None 11 | messageWritten = QtCore.Signal(str) 12 | 13 | def flush(self): 14 | pass 15 | 16 | def fileno(self): 17 | return -1 18 | 19 | def write(self, msg): 20 | if not self.signalsBlocked(): 21 | self.messageWritten.emit(msg) 22 | 23 | @staticmethod 24 | def stdout(): 25 | if not XStream._stdout: 26 | XStream._stdout = XStream() 27 | sys.stdout = XStream._stdout 28 | return XStream._stdout 29 | 30 | @staticmethod 31 | def stderr(): 32 | if not XStream._stderr: 33 | XStream._stderr = XStream() 34 | sys.stderr = XStream._stderr 35 | return XStream._stderr 36 | 37 | 38 | class QtHandler(logging.Handler): 39 | def __init__(self): 40 | logging.Handler.__init__(self) 41 | 42 | def emit(self, record): 43 | record = self.format(record) 44 | 45 | if record: 46 | # originally: XStream.stdout().write("{}\n".format(record)) 47 | XStream.stdout().write("%s\n" % record) 48 | 49 | 50 | logger_gui = logging.getLogger(__name__) 51 | handler_qt: QtHandler = QtHandler() 52 | # log_fmt: str = "[%(asctime)s] %(levelname)s: %(message)s" 53 | log_fmt: str = "> %(message)s" 54 | # formatter = logging.Formatter(log_fmt) 55 | formatter = coloredlogs.ColoredFormatter(fmt=log_fmt) 56 | handler_qt.setFormatter(formatter) 57 | logger_gui.addHandler(handler_qt) 58 | logger_gui.setLevel(logging.DEBUG) 59 | 60 | logger_cli = logging.getLogger(__name__) 61 | handler_stream: logging.StreamHandler = logging.StreamHandler() 62 | formatter = coloredlogs.ColoredFormatter(fmt=log_fmt) 63 | handler_stream.setFormatter(formatter) 64 | logger_cli.addHandler(handler_stream) 65 | logger_cli.setLevel(logging.DEBUG) 66 | -------------------------------------------------------------------------------- /tidal_dl_ng/metadata.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import mutagen 4 | from mutagen import flac, id3, mp4 5 | from mutagen.id3 import APIC, TALB, TCOM, TCOP, TDRC, TIT2, TOPE, TPE1, TRCK, TSRC, TXXX, USLT, WOAS 6 | 7 | 8 | class Metadata: 9 | path_file: str | pathlib.Path 10 | title: str 11 | album: str 12 | albumartist: str 13 | artists: str 14 | copy_right: str 15 | tracknumber: int 16 | discnumber: int 17 | totaldisc: int 18 | totaltrack: int 19 | date: str 20 | composer: str 21 | isrc: str 22 | lyrics: str 23 | path_cover: str 24 | cover_data: bytes 25 | album_replay_gain: float 26 | album_peak_amplitude: float 27 | track_replay_gain: float 28 | track_peak_amplitude: float 29 | url_share: str 30 | replay_gain_write: bool 31 | m: mutagen.mp4.MP4 | mutagen.mp4.MP4 | mutagen.flac.FLAC 32 | 33 | def __init__( 34 | self, 35 | path_file: str | pathlib.Path, 36 | album: str = "", 37 | title: str = "", 38 | artists: str = "", 39 | copy_right: str = "", 40 | tracknumber: int = 0, 41 | discnumber: int = 0, 42 | totaltrack: int = 0, 43 | totaldisc: int = 0, 44 | composer: str = "", 45 | isrc: str = "", 46 | albumartist: str = "", 47 | date: str = "", 48 | lyrics: str = "", 49 | cover_data: bytes = None, 50 | album_replay_gain: float = 1.0, 51 | album_peak_amplitude: float = 1.0, 52 | track_replay_gain: float = 1.0, 53 | track_peak_amplitude: float = 1.0, 54 | url_share: str = "", 55 | replay_gain_write: bool = True, 56 | ): 57 | self.path_file = path_file 58 | self.title = title 59 | self.album = album 60 | self.albumartist = albumartist 61 | self.artists = artists 62 | self.copy_right = copy_right 63 | self.tracknumber = tracknumber 64 | self.discnumber = discnumber 65 | self.totaldisc = totaldisc 66 | self.totaltrack = totaltrack 67 | self.date = date 68 | self.composer = composer 69 | self.isrc = isrc 70 | self.lyrics = lyrics 71 | self.cover_data = cover_data 72 | self.album_replay_gain = album_replay_gain 73 | self.album_peak_amplitude = album_peak_amplitude 74 | self.track_replay_gain = track_replay_gain 75 | self.track_peak_amplitude = track_peak_amplitude 76 | self.url_share = url_share 77 | self.replay_gain_write = replay_gain_write 78 | self.m: mutagen.FileType = mutagen.File(self.path_file) 79 | 80 | def _cover(self) -> bool: 81 | result: bool = False 82 | 83 | if self.cover_data: 84 | if isinstance(self.m, mutagen.flac.FLAC): 85 | flac_cover = flac.Picture() 86 | flac_cover.type = id3.PictureType.COVER_FRONT 87 | flac_cover.data = self.cover_data 88 | flac_cover.mime = "image/jpeg" 89 | 90 | self.m.clear_pictures() 91 | self.m.add_picture(flac_cover) 92 | elif isinstance(self.m, mutagen.mp3.MP3): 93 | self.m.tags.add(APIC(encoding=3, data=self.cover_data)) 94 | elif isinstance(self.m, mutagen.mp4.MP4): 95 | cover_mp4 = mp4.MP4Cover(self.cover_data) 96 | self.m.tags["covr"] = [cover_mp4] 97 | 98 | result = True 99 | 100 | return result 101 | 102 | def save(self): 103 | if not self.m.tags: 104 | self.m.add_tags() 105 | 106 | if isinstance(self.m, mutagen.flac.FLAC): 107 | self.set_flac() 108 | elif isinstance(self.m, mutagen.mp3.MP3): 109 | self.set_mp3() 110 | elif isinstance(self.m, mutagen.mp4.MP4): 111 | self.set_mp4() 112 | 113 | self._cover() 114 | self.m.save() 115 | 116 | return True 117 | 118 | def set_flac(self): 119 | self.m.tags["TITLE"] = self.title 120 | self.m.tags["ALBUM"] = self.album 121 | self.m.tags["ALBUMARTIST"] = self.albumartist 122 | self.m.tags["ARTIST"] = self.artists 123 | self.m.tags["COPYRIGHT"] = self.copy_right 124 | self.m.tags["TRACKNUMBER"] = str(self.tracknumber) 125 | self.m.tags["TRACKTOTAL"] = str(self.totaltrack) 126 | self.m.tags["DISCNUMBER"] = str(self.discnumber) 127 | self.m.tags["DISCTOTAL"] = str(self.totaldisc) 128 | self.m.tags["DATE"] = self.date 129 | self.m.tags["COMPOSER"] = self.composer 130 | self.m.tags["ISRC"] = self.isrc 131 | self.m.tags["LYRICS"] = self.lyrics 132 | self.m.tags["URL"] = self.url_share 133 | 134 | if self.replay_gain_write: 135 | self.m.tags["REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain) 136 | self.m.tags["REPLAYGAIN_ALBUM_PEAK"] = str(self.album_peak_amplitude) 137 | self.m.tags["REPLAYGAIN_TRACK_GAIN"] = str(self.track_replay_gain) 138 | self.m.tags["REPLAYGAIN_TRACK_PEAK"] = str(self.track_peak_amplitude) 139 | 140 | def set_mp3(self): 141 | # ID3 Frame (tags) overview: https://exiftool.org/TagNames/ID3.html / https://id3.org/id3v2.3.0 142 | # Mapping overview: https://docs.mp3tag.de/mapping/ 143 | self.m.tags.add(TIT2(encoding=3, text=self.title)) 144 | self.m.tags.add(TALB(encoding=3, text=self.album)) 145 | self.m.tags.add(TOPE(encoding=3, text=self.albumartist)) 146 | self.m.tags.add(TPE1(encoding=3, text=self.artists)) 147 | self.m.tags.add(TCOP(encoding=3, text=self.copy_right)) 148 | self.m.tags.add(TRCK(encoding=3, text=str(self.tracknumber))) 149 | self.m.tags.add(TRCK(encoding=3, text=self.discnumber)) 150 | self.m.tags.add(TDRC(encoding=3, text=self.date)) 151 | self.m.tags.add(TCOM(encoding=3, text=self.composer)) 152 | self.m.tags.add(TSRC(encoding=3, text=self.isrc)) 153 | self.m.tags.add(USLT(encoding=3, lang="eng", desc="desc", text=self.lyrics)) 154 | self.m.tags.add(WOAS(encoding=3, text=self.isrc)) 155 | 156 | if self.replay_gain_write: 157 | self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_ALBUM_GAIN", text=str(self.album_replay_gain))) 158 | self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_ALBUM_PEAK", text=str(self.album_peak_amplitude))) 159 | self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_TRACK_GAIN", text=str(self.track_replay_gain))) 160 | self.m.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_TRACK_PEAK", text=str(self.track_peak_amplitude))) 161 | 162 | def set_mp4(self): 163 | self.m.tags["\xa9nam"] = self.title 164 | self.m.tags["\xa9alb"] = self.album 165 | self.m.tags["aART"] = self.albumartist 166 | self.m.tags["\xa9ART"] = self.artists 167 | self.m.tags["cprt"] = self.copy_right 168 | self.m.tags["trkn"] = [[self.tracknumber, self.totaltrack]] 169 | self.m.tags["disk"] = [[self.discnumber, self.totaldisc]] 170 | # self.m.tags['\xa9gen'] = self.genre 171 | self.m.tags["\xa9day"] = self.date 172 | self.m.tags["\xa9wrt"] = self.composer 173 | self.m.tags["\xa9lyr"] = self.lyrics 174 | self.m.tags["isrc"] = self.isrc 175 | self.m.tags["url"] = self.url_share 176 | 177 | if self.replay_gain_write: 178 | self.m.tags["----:com.apple.iTunes:REPLAYGAIN_ALBUM_GAIN"] = str(self.album_replay_gain).encode("utf-8") 179 | self.m.tags["----:com.apple.iTunes:REPLAYGAIN_ALBUM_PEAK"] = str(self.album_peak_amplitude).encode("utf-8") 180 | self.m.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN"] = str(self.track_replay_gain).encode("utf-8") 181 | self.m.tags["----:com.apple.iTunes:REPLAYGAIN_TRACK_PEAK"] = str(self.track_peak_amplitude).encode("utf-8") 182 | -------------------------------------------------------------------------------- /tidal_dl_ng/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exislow/tidal-dl-ng/d9ab28d8840c8616d11f5610a78a02b90fb7e696/tidal_dl_ng/model/__init__.py -------------------------------------------------------------------------------- /tidal_dl_ng/model/cfg.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from dataclasses_json import dataclass_json 4 | from tidalapi import Quality 5 | 6 | from tidal_dl_ng.constants import CoverDimensions, QualityVideo 7 | 8 | 9 | @dataclass_json 10 | @dataclass 11 | class Settings: 12 | skip_existing: bool = True 13 | lyrics_embed: bool = False 14 | lyrics_file: bool = False 15 | # TODO: Implement API KEY selection. 16 | # api_key_index: bool = 0 17 | # TODO: Implement album info download to separate file. 18 | # album_info_save: bool = False 19 | video_download: bool = True 20 | # TODO: Implement multi threading for downloads. 21 | # multi_thread: bool = False 22 | download_delay: bool = True 23 | download_base_path: str = "~/download" 24 | quality_audio: Quality = Quality.low_320k 25 | quality_video: QualityVideo = QualityVideo.P480 26 | format_album: str = ( 27 | "Albums/{album_artist} - {album_title}{album_explicit}/{track_volume_num_optional}" 28 | "{album_track_num}. {artist_name} - {track_title}{album_explicit}" 29 | ) 30 | format_playlist: str = "Playlists/{playlist_name}/{list_pos}. {artist_name} - {track_title}" 31 | format_mix: str = "Mix/{mix_name}/{artist_name} - {track_title}" 32 | format_track: str = "Tracks/{artist_name} - {track_title}{track_explicit}" 33 | format_video: str = "Videos/{artist_name} - {track_title}{track_explicit}" 34 | video_convert_mp4: bool = True 35 | path_binary_ffmpeg: str = "" 36 | metadata_cover_dimension: CoverDimensions = CoverDimensions.Px320 37 | metadata_cover_embed: bool = True 38 | cover_album_file: bool = True 39 | extract_flac: bool = True 40 | downloads_simultaneous_per_track_max: int = 20 41 | download_delay_sec_min: float = 3.0 42 | download_delay_sec_max: float = 5.0 43 | album_track_num_pad_min: int = 1 44 | downloads_concurrent_max: int = 3 45 | symlink_to_track: bool = False 46 | playlist_create: bool = False 47 | metadata_replay_gain: bool = True 48 | 49 | 50 | @dataclass_json 51 | @dataclass 52 | class HelpSettings: 53 | skip_existing: str = "Skip download if file already exists." 54 | album_cover_save: str = "Safe cover to album folder." 55 | lyrics_embed: str = "Embed lyrics in audio file, if lyrics are available." 56 | lyrics_file: str = "Save lyrics to separate *.lrc file, if lyrics are available." 57 | api_key_index: str = "Set the device API KEY." 58 | album_info_save: str = "Save album info to track?" 59 | video_download: str = "Allow download of videos." 60 | multi_thread: str = "Download several tracks in parallel." 61 | download_delay: str = "Activate randomized download delay to mimic human behaviour." 62 | download_base_path: str = "Where to store the downloaded media." 63 | quality_audio: str = ( 64 | 'Desired audio download quality: "LOW" (96kbps), "HIGH" (320kbps), ' 65 | '"LOSSLESS" (16 Bit, 44,1 kHz), ' 66 | '"HI_RES_LOSSLESS" (up to 24 Bit, 192 kHz)' 67 | ) 68 | quality_video: str = 'Desired video download quality: "360", "480", "720", "1080"' 69 | # TODO: Describe possible variables. 70 | format_album: str = "Where to download albums and how to name the items." 71 | format_playlist: str = "Where to download playlists and how to name the items." 72 | format_mix: str = "Where to download mixes and how to name the items." 73 | format_track: str = "Where to download tracks and how to name the items." 74 | format_video: str = "Where to download videos and how to name the items." 75 | video_convert_mp4: str = ( 76 | "Videos are downloaded as MPEG Transport Stream (TS) files. With this option each video " 77 | "will be converted to MP4. FFmpeg must be installed." 78 | ) 79 | path_binary_ffmpeg: str = ( 80 | "Path to FFmpeg binary file (executable). Only necessary if FFmpeg not set in $PATH. Mandatory for Windows: " 81 | "The directory of `ffmpeg.exe`must be set in %PATH%." 82 | ) 83 | metadata_cover_dimension: str = ( 84 | "The dimensions of the cover image embedded into the track. Possible values: 320x320, 640x640x 1280x1280." 85 | ) 86 | metadata_cover_embed: str = "Embed album cover into file." 87 | cover_album_file: str = "Save cover to 'cover.jpg', if an album is downloaded." 88 | extract_flac: str = "Extract FLAC audio tracks from MP4 containers and save them as `*.flac` (uses FFmpeg)." 89 | downloads_simultaneous_per_track_max: str = "Maximum number of simultaneous chunk downloads per track." 90 | download_delay_sec_min: str = "Lower boundary for the calculation of the download delay in seconds." 91 | download_delay_sec_max: str = "Upper boundary for the calculation of the download delay in seconds." 92 | album_track_num_pad_min: str = ( 93 | "Minimum length of the album track count, will be padded with zeroes (0). To disable padding set this to 1." 94 | ) 95 | downloads_concurrent_max: str = "Maximum concurrent number of downloads (threads)." 96 | symlink_to_track: str = ( 97 | "If enabled the tracks of albums, playlists and mixes will be downloaded to the track directory but symlinked " 98 | "accordingly." 99 | ) 100 | playlist_create: str = "Creates a '_playlist.m3u8' file for downloaded albums, playlists and mixes." 101 | metadata_replay_gain: str = "Replay gain information will be written to metadata." 102 | 103 | 104 | @dataclass_json 105 | @dataclass 106 | class Token: 107 | token_type: str | None = None 108 | access_token: str | None = None 109 | refresh_token: str | None = None 110 | expiry_time: float = 0.0 111 | -------------------------------------------------------------------------------- /tidal_dl_ng/model/downloader.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from dataclasses import dataclass 3 | 4 | from requests import HTTPError 5 | 6 | 7 | @dataclass 8 | class DownloadSegmentResult: 9 | result: bool 10 | url: str 11 | path_segment: pathlib.Path 12 | id_segment: int 13 | error: HTTPError | None = None 14 | -------------------------------------------------------------------------------- /tidal_dl_ng/model/gui_data.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from tidalapi.media import Quality 4 | 5 | from tidal_dl_ng.constants import QualityVideo 6 | 7 | try: 8 | from PySide6 import QtCore 9 | 10 | @dataclass 11 | class ProgressBars: 12 | item: QtCore.Signal 13 | item_name: QtCore.Signal 14 | list_item: QtCore.Signal 15 | list_name: QtCore.Signal 16 | 17 | except ModuleNotFoundError: 18 | 19 | class ProgressBars: 20 | pass 21 | 22 | 23 | @dataclass 24 | class ResultItem: 25 | position: int 26 | artist: str 27 | title: str 28 | album: str 29 | duration_sec: int 30 | obj: object 31 | quality: str 32 | explicit: bool 33 | date_user_added: str 34 | 35 | 36 | @dataclass 37 | class StatusbarMessage: 38 | message: str 39 | timout: int = 0 40 | 41 | 42 | @dataclass 43 | class QueueDownloadItem: 44 | status: str 45 | name: str 46 | type_media: str 47 | quality_audio: Quality 48 | quality_video: QualityVideo 49 | obj: object 50 | -------------------------------------------------------------------------------- /tidal_dl_ng/model/meta.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class ReleaseLatest: 6 | version: str 7 | url: str 8 | release_info: str 9 | 10 | 11 | @dataclass 12 | class ProjectInformation: 13 | version: str 14 | repository_url: str 15 | -------------------------------------------------------------------------------- /tidal_dl_ng/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exislow/tidal-dl-ng/d9ab28d8840c8616d11f5610a78a02b90fb7e696/tidal_dl_ng/ui/__init__.py -------------------------------------------------------------------------------- /tidal_dl_ng/ui/default_album_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exislow/tidal-dl-ng/d9ab28d8840c8616d11f5610a78a02b90fb7e696/tidal_dl_ng/ui/default_album_image.png -------------------------------------------------------------------------------- /tidal_dl_ng/ui/dialog_login.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | ## Form generated from reading UI file 'dialog_login.ui' 3 | ## 4 | ## Created by: Qt User Interface Compiler version 6.8.0 5 | ## 6 | ## WARNING! All changes made in this file will be lost when recompiling UI file! 7 | ################################################################################ 8 | 9 | from PySide6.QtCore import QCoreApplication, QMetaObject, QRect, Qt 10 | from PySide6.QtGui import QFont 11 | from PySide6.QtWidgets import QDialogButtonBox, QHBoxLayout, QLabel, QSizePolicy, QTextBrowser, QVBoxLayout, QWidget 12 | 13 | 14 | class Ui_DialogLogin: 15 | def setupUi(self, DialogLogin): 16 | if not DialogLogin.objectName(): 17 | DialogLogin.setObjectName("DialogLogin") 18 | DialogLogin.resize(451, 400) 19 | sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 20 | sizePolicy.setHorizontalStretch(0) 21 | sizePolicy.setVerticalStretch(0) 22 | sizePolicy.setHeightForWidth(DialogLogin.sizePolicy().hasHeightForWidth()) 23 | DialogLogin.setSizePolicy(sizePolicy) 24 | self.bb_dialog = QDialogButtonBox(DialogLogin) 25 | self.bb_dialog.setObjectName("bb_dialog") 26 | self.bb_dialog.setGeometry(QRect(20, 350, 411, 32)) 27 | sizePolicy.setHeightForWidth(self.bb_dialog.sizePolicy().hasHeightForWidth()) 28 | self.bb_dialog.setSizePolicy(sizePolicy) 29 | self.bb_dialog.setLayoutDirection(Qt.LayoutDirection.LeftToRight) 30 | self.bb_dialog.setStyleSheet("") 31 | self.bb_dialog.setOrientation(Qt.Orientation.Horizontal) 32 | self.bb_dialog.setStandardButtons(QDialogButtonBox.StandardButton.Cancel | QDialogButtonBox.StandardButton.Ok) 33 | self.verticalLayoutWidget = QWidget(DialogLogin) 34 | self.verticalLayoutWidget.setObjectName("verticalLayoutWidget") 35 | self.verticalLayoutWidget.setGeometry(QRect(20, 20, 411, 325)) 36 | self.lv_main = QVBoxLayout(self.verticalLayoutWidget) 37 | self.lv_main.setObjectName("lv_main") 38 | self.lv_main.setContentsMargins(0, 0, 0, 0) 39 | self.l_header = QLabel(self.verticalLayoutWidget) 40 | self.l_header.setObjectName("l_header") 41 | sizePolicy.setHeightForWidth(self.l_header.sizePolicy().hasHeightForWidth()) 42 | self.l_header.setSizePolicy(sizePolicy) 43 | font = QFont() 44 | font.setPointSize(23) 45 | font.setBold(True) 46 | self.l_header.setFont(font) 47 | 48 | self.lv_main.addWidget(self.l_header) 49 | 50 | self.l_description = QLabel(self.verticalLayoutWidget) 51 | self.l_description.setObjectName("l_description") 52 | sizePolicy.setHeightForWidth(self.l_description.sizePolicy().hasHeightForWidth()) 53 | self.l_description.setSizePolicy(sizePolicy) 54 | font1 = QFont() 55 | font1.setItalic(True) 56 | self.l_description.setFont(font1) 57 | self.l_description.setWordWrap(True) 58 | 59 | self.lv_main.addWidget(self.l_description) 60 | 61 | self.tb_url_login = QTextBrowser(self.verticalLayoutWidget) 62 | self.tb_url_login.setObjectName("tb_url_login") 63 | self.tb_url_login.setOpenExternalLinks(True) 64 | 65 | self.lv_main.addWidget(self.tb_url_login) 66 | 67 | self.horizontalLayout = QHBoxLayout() 68 | self.horizontalLayout.setObjectName("horizontalLayout") 69 | self.l_expires_description = QLabel(self.verticalLayoutWidget) 70 | self.l_expires_description.setObjectName("l_expires_description") 71 | 72 | self.horizontalLayout.addWidget(self.l_expires_description) 73 | 74 | self.l_expires_date_time = QLabel(self.verticalLayoutWidget) 75 | self.l_expires_date_time.setObjectName("l_expires_date_time") 76 | font2 = QFont() 77 | font2.setBold(True) 78 | self.l_expires_date_time.setFont(font2) 79 | self.l_expires_date_time.setAlignment( 80 | Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTrailing | Qt.AlignmentFlag.AlignVCenter 81 | ) 82 | 83 | self.horizontalLayout.addWidget(self.l_expires_date_time) 84 | 85 | self.lv_main.addLayout(self.horizontalLayout) 86 | 87 | self.l_hint = QLabel(self.verticalLayoutWidget) 88 | self.l_hint.setObjectName("l_hint") 89 | sizePolicy.setHeightForWidth(self.l_hint.sizePolicy().hasHeightForWidth()) 90 | self.l_hint.setSizePolicy(sizePolicy) 91 | self.l_hint.setFont(font1) 92 | self.l_hint.setWordWrap(True) 93 | 94 | self.lv_main.addWidget(self.l_hint) 95 | 96 | self.retranslateUi(DialogLogin) 97 | self.bb_dialog.accepted.connect(DialogLogin.accept) 98 | self.bb_dialog.rejected.connect(DialogLogin.reject) 99 | 100 | QMetaObject.connectSlotsByName(DialogLogin) 101 | 102 | # setupUi 103 | 104 | def retranslateUi(self, DialogLogin): 105 | DialogLogin.setWindowTitle(QCoreApplication.translate("DialogLogin", "Dialog", None)) 106 | self.l_header.setText(QCoreApplication.translate("DialogLogin", "TIDAL Login (as Device)", None)) 107 | self.l_description.setText( 108 | QCoreApplication.translate( 109 | "DialogLogin", 110 | "Click the link below and login with your TIDAL credentials. TIDAL will ask you, if you like to add this app as a new device. You need to confirm this.", 111 | None, 112 | ) 113 | ) 114 | self.tb_url_login.setPlaceholderText(QCoreApplication.translate("DialogLogin", "Copy this login URL...", None)) 115 | self.l_expires_description.setText(QCoreApplication.translate("DialogLogin", "This link expires at:", None)) 116 | self.l_expires_date_time.setText(QCoreApplication.translate("DialogLogin", "COMPUTING", None)) 117 | self.l_hint.setText(QCoreApplication.translate("DialogLogin", "Waiting...", None)) 118 | 119 | # retranslateUi 120 | -------------------------------------------------------------------------------- /tidal_dl_ng/ui/dialog_login.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | DialogLogin 4 | 5 | 6 | 7 | 0 8 | 0 9 | 451 10 | 400 11 | 12 | 13 | 14 | 15 | 0 16 | 0 17 | 18 | 19 | 20 | Dialog 21 | 22 | 23 | 24 | 25 | 20 26 | 350 27 | 411 28 | 32 29 | 30 | 31 | 32 | 33 | 0 34 | 0 35 | 36 | 37 | 38 | Qt::LayoutDirection::LeftToRight 39 | 40 | 41 | 42 | 43 | 44 | Qt::Orientation::Horizontal 45 | 46 | 47 | QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok 48 | 49 | 50 | 51 | 52 | 53 | 20 54 | 20 55 | 411 56 | 325 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 0 65 | 0 66 | 67 | 68 | 69 | 70 | 23 71 | true 72 | 73 | 74 | 75 | TIDAL Login (as Device) 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 0 84 | 0 85 | 86 | 87 | 88 | 89 | true 90 | 91 | 92 | 93 | Click the link below and login with your TIDAL credentials. TIDAL will ask you, if you like to add this app as a new device. You need to confirm this. 94 | 95 | 96 | true 97 | 98 | 99 | 100 | 101 | 102 | 103 | Copy this login URL... 104 | 105 | 106 | true 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | This link expires at: 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | true 124 | 125 | 126 | 127 | COMPUTING 128 | 129 | 130 | Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 0 141 | 0 142 | 143 | 144 | 145 | 146 | true 147 | 148 | 149 | 150 | Waiting... 151 | 152 | 153 | true 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | bb_dialog 164 | accepted() 165 | DialogLogin 166 | accept() 167 | 168 | 169 | 248 170 | 254 171 | 172 | 173 | 157 174 | 274 175 | 176 | 177 | 178 | 179 | bb_dialog 180 | rejected() 181 | DialogLogin 182 | reject() 183 | 184 | 185 | 316 186 | 260 187 | 188 | 189 | 286 190 | 274 191 | 192 | 193 | 194 | 195 | 196 | -------------------------------------------------------------------------------- /tidal_dl_ng/ui/dialog_version.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | ## Form generated from reading UI file 'dialog_version.ui' 3 | ## 4 | ## Created by: Qt User Interface Compiler version 6.6.1 5 | ## 6 | ## WARNING! All changes made in this file will be lost when recompiling UI file! 7 | ################################################################################ 8 | 9 | from PySide6.QtCore import QCoreApplication, QMetaObject, QSize, Qt 10 | from PySide6.QtGui import QFont 11 | from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QSizePolicy, QSpacerItem, QVBoxLayout 12 | 13 | 14 | class Ui_DialogVersion: 15 | def setupUi(self, DialogVersion): 16 | if not DialogVersion.objectName(): 17 | DialogVersion.setObjectName("DialogVersion") 18 | DialogVersion.resize(436, 235) 19 | sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) 20 | sizePolicy.setHorizontalStretch(0) 21 | sizePolicy.setVerticalStretch(0) 22 | sizePolicy.setHeightForWidth(DialogVersion.sizePolicy().hasHeightForWidth()) 23 | DialogVersion.setSizePolicy(sizePolicy) 24 | DialogVersion.setMaximumSize(QSize(436, 235)) 25 | self.verticalLayout = QVBoxLayout(DialogVersion) 26 | self.verticalLayout.setObjectName("verticalLayout") 27 | self.l_name_app = QLabel(DialogVersion) 28 | self.l_name_app.setObjectName("l_name_app") 29 | sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) 30 | sizePolicy1.setHorizontalStretch(0) 31 | sizePolicy1.setVerticalStretch(0) 32 | sizePolicy1.setHeightForWidth(self.l_name_app.sizePolicy().hasHeightForWidth()) 33 | self.l_name_app.setSizePolicy(sizePolicy1) 34 | font = QFont() 35 | font.setBold(True) 36 | self.l_name_app.setFont(font) 37 | self.l_name_app.setAlignment(Qt.AlignCenter) 38 | self.l_name_app.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) 39 | 40 | self.verticalLayout.addWidget(self.l_name_app) 41 | 42 | self.lv_version = QVBoxLayout() 43 | self.lv_version.setObjectName("lv_version") 44 | self.lh_version = QHBoxLayout() 45 | self.lh_version.setObjectName("lh_version") 46 | self.l_h_version = QLabel(DialogVersion) 47 | self.l_h_version.setObjectName("l_h_version") 48 | self.l_h_version.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) 49 | 50 | self.lh_version.addWidget(self.l_h_version) 51 | 52 | self.l_version = QLabel(DialogVersion) 53 | self.l_version.setObjectName("l_version") 54 | self.l_version.setFont(font) 55 | self.l_version.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) 56 | 57 | self.lh_version.addWidget(self.l_version) 58 | 59 | self.lv_version.addLayout(self.lh_version) 60 | 61 | self.verticalLayout.addLayout(self.lv_version) 62 | 63 | self.lv_update = QVBoxLayout() 64 | self.lv_update.setObjectName("lv_update") 65 | self.l_error = QLabel(DialogVersion) 66 | self.l_error.setObjectName("l_error") 67 | self.l_error.setFont(font) 68 | self.l_error.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) 69 | 70 | self.lv_update.addWidget(self.l_error) 71 | 72 | self.l_error_details = QLabel(DialogVersion) 73 | self.l_error_details.setObjectName("l_error_details") 74 | self.l_error_details.setFont(font) 75 | self.l_error_details.setAlignment(Qt.AlignCenter) 76 | self.l_error_details.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) 77 | 78 | self.lv_update.addWidget(self.l_error_details) 79 | 80 | self.lh_update_version = QHBoxLayout() 81 | self.lh_update_version.setObjectName("lh_update_version") 82 | self.l_h_version_new = QLabel(DialogVersion) 83 | self.l_h_version_new.setObjectName("l_h_version_new") 84 | self.l_h_version_new.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) 85 | 86 | self.lh_update_version.addWidget(self.l_h_version_new) 87 | 88 | self.l_version_new = QLabel(DialogVersion) 89 | self.l_version_new.setObjectName("l_version_new") 90 | self.l_version_new.setFont(font) 91 | self.l_version_new.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) 92 | 93 | self.lh_update_version.addWidget(self.l_version_new) 94 | 95 | self.lv_update.addLayout(self.lh_update_version) 96 | 97 | self.l_changelog = QLabel(DialogVersion) 98 | self.l_changelog.setObjectName("l_changelog") 99 | self.l_changelog.setFont(font) 100 | self.l_changelog.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) 101 | 102 | self.lv_update.addWidget(self.l_changelog) 103 | 104 | self.l_changelog_details = QLabel(DialogVersion) 105 | self.l_changelog_details.setObjectName("l_changelog_details") 106 | self.l_changelog_details.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) 107 | 108 | self.lv_update.addWidget(self.l_changelog_details) 109 | 110 | self.lv_download = QHBoxLayout() 111 | self.lv_download.setObjectName("lv_download") 112 | self.lv_download.setContentsMargins(-1, 20, -1, -1) 113 | self.pb_download = QPushButton(DialogVersion) 114 | self.pb_download.setObjectName("pb_download") 115 | self.pb_download.setFlat(False) 116 | 117 | self.lv_download.addWidget(self.pb_download) 118 | 119 | self.sh_download = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) 120 | 121 | self.lv_download.addItem(self.sh_download) 122 | 123 | self.lv_update.addLayout(self.lv_download) 124 | 125 | self.verticalLayout.addLayout(self.lv_update) 126 | 127 | self.l_url_github = QLabel(DialogVersion) 128 | self.l_url_github.setObjectName("l_url_github") 129 | self.l_url_github.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter) 130 | self.l_url_github.setOpenExternalLinks(True) 131 | self.l_url_github.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.TextSelectableByMouse) 132 | 133 | self.verticalLayout.addWidget(self.l_url_github) 134 | 135 | self.retranslateUi(DialogVersion) 136 | 137 | QMetaObject.connectSlotsByName(DialogVersion) 138 | 139 | # setupUi 140 | 141 | def retranslateUi(self, DialogVersion): 142 | DialogVersion.setWindowTitle(QCoreApplication.translate("DialogVersion", "Version", None)) 143 | self.l_name_app.setText(QCoreApplication.translate("DialogVersion", "TIDAL Downloader Next Generation!", None)) 144 | self.l_h_version.setText(QCoreApplication.translate("DialogVersion", "Installed Version:", None)) 145 | self.l_version.setText(QCoreApplication.translate("DialogVersion", "v1.2.3", None)) 146 | self.l_error.setText(QCoreApplication.translate("DialogVersion", "ERROR", None)) 147 | self.l_error_details.setText(QCoreApplication.translate("DialogVersion", "", None)) 148 | self.l_h_version_new.setText(QCoreApplication.translate("DialogVersion", "New Version Available:", None)) 149 | self.l_version_new.setText(QCoreApplication.translate("DialogVersion", "v0.0.0", None)) 150 | self.l_changelog.setText(QCoreApplication.translate("DialogVersion", "Changelog", None)) 151 | self.l_changelog_details.setText(QCoreApplication.translate("DialogVersion", "", None)) 152 | self.pb_download.setText(QCoreApplication.translate("DialogVersion", "Download", None)) 153 | self.l_url_github.setText( 154 | QCoreApplication.translate( 155 | "DialogVersion", 156 | 'https://github.com/exislow/tidal-dl-ng/', 157 | None, 158 | ) 159 | ) 160 | 161 | # retranslateUi 162 | -------------------------------------------------------------------------------- /tidal_dl_ng/ui/dialog_version.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | DialogVersion 4 | 5 | 6 | 7 | 0 8 | 0 9 | 436 10 | 235 11 | 12 | 13 | 14 | 15 | 0 16 | 0 17 | 18 | 19 | 20 | 21 | 436 22 | 235 23 | 24 | 25 | 26 | Version 27 | 28 | 29 | 30 | 31 | 32 | 33 | 0 34 | 0 35 | 36 | 37 | 38 | 39 | true 40 | 41 | 42 | 43 | TIDAL Downloader Next Generation! 44 | 45 | 46 | Qt::AlignCenter 47 | 48 | 49 | Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | Installed Version: 61 | 62 | 63 | Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | true 72 | 73 | 74 | 75 | v1.2.3 76 | 77 | 78 | Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | true 93 | 94 | 95 | 96 | ERROR 97 | 98 | 99 | Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | true 108 | 109 | 110 | 111 | <ERROR> 112 | 113 | 114 | Qt::AlignCenter 115 | 116 | 117 | Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | New Version Available: 127 | 128 | 129 | Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | true 138 | 139 | 140 | 141 | v0.0.0 142 | 143 | 144 | Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | true 155 | 156 | 157 | 158 | Changelog 159 | 160 | 161 | Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse 162 | 163 | 164 | 165 | 166 | 167 | 168 | <CHANGELOG> 169 | 170 | 171 | Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse 172 | 173 | 174 | 175 | 176 | 177 | 178 | 20 179 | 180 | 181 | 182 | 183 | Download 184 | 185 | 186 | false 187 | 188 | 189 | 190 | 191 | 192 | 193 | Qt::Horizontal 194 | 195 | 196 | 197 | 40 198 | 20 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | <a href="https://github.com/exislow/tidal-dl-ng/">https://github.com/exislow/tidal-dl-ng/</a> 211 | 212 | 213 | Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter 214 | 215 | 216 | true 217 | 218 | 219 | Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | -------------------------------------------------------------------------------- /tidal_dl_ng/ui/dummy_register.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2022 The Qt Company Ltd. 2 | # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause 3 | 4 | from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection 5 | 6 | from .dummy_wiggly import WigglyWidget 7 | 8 | # Set PYSIDE_DESIGNER_PLUGINS to point to this directory and load the plugin 9 | 10 | 11 | TOOLTIP = "A cool wiggly widget (Python)" 12 | DOM_XML = """ 13 | 14 | 15 | 16 | 17 | 0 18 | 0 19 | 400 20 | 200 21 | 22 | 23 | 24 | Hello, world 25 | 26 | 27 | 28 | """ 29 | 30 | if __name__ == "__main__": 31 | QPyDesignerCustomWidgetCollection.registerCustomWidget( 32 | WigglyWidget, module="wigglywidget", tool_tip=TOOLTIP, xml=DOM_XML 33 | ) 34 | -------------------------------------------------------------------------------- /tidal_dl_ng/ui/dummy_wiggly.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2022 The Qt Company Ltd. 2 | # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause 3 | 4 | from PySide6.QtCore import Property, QBasicTimer 5 | from PySide6.QtGui import QColor, QFontMetrics, QPainter, QPalette 6 | from PySide6.QtWidgets import QWidget 7 | 8 | 9 | class WigglyWidget(QWidget): 10 | def __init__(self, parent=None): 11 | super().__init__(parent) 12 | self._step = 0 13 | self._text = "" 14 | self.setBackgroundRole(QPalette.Midlight) 15 | self.setAutoFillBackground(True) 16 | 17 | new_font = self.font() 18 | new_font.setPointSize(new_font.pointSize() + 20) 19 | self.setFont(new_font) 20 | self._timer = QBasicTimer() 21 | 22 | def isRunning(self): 23 | return self._timer.isActive() 24 | 25 | def setRunning(self, r): 26 | if r == self.isRunning(): 27 | return 28 | if r: 29 | self._timer.start(60, self) 30 | else: 31 | self._timer.stop() 32 | 33 | def paintEvent(self, event): 34 | if not self._text: 35 | return 36 | 37 | sineTable = [0, 38, 71, 92, 100, 92, 71, 38, 0, -38, -71, -92, -100, -92, -71, -38] 38 | 39 | metrics = QFontMetrics(self.font()) 40 | x = (self.width() - metrics.horizontalAdvance(self.text)) / 2 41 | y = (self.height() + metrics.ascent() - metrics.descent()) / 2 42 | color = QColor() 43 | 44 | with QPainter(self) as painter: 45 | for i in range(len(self.text)): 46 | index = (self._step + i) % 16 47 | color.setHsv((15 - index) * 16, 255, 191) 48 | painter.setPen(color) 49 | dy = (sineTable[index] * metrics.height()) / 400 50 | c = self._text[i] 51 | painter.drawText(x, y - dy, str(c)) 52 | x += metrics.horizontalAdvance(c) 53 | 54 | def timerEvent(self, event): 55 | if event.timerId() == self._timer.timerId(): 56 | self._step += 1 57 | self.update() 58 | else: 59 | QWidget.timerEvent(event) 60 | 61 | def text(self): 62 | return self._text 63 | 64 | def setText(self, text): 65 | self._text = text 66 | 67 | running = Property(bool, isRunning, setRunning) 68 | text = Property(str, text, setText) 69 | -------------------------------------------------------------------------------- /tidal_dl_ng/ui/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exislow/tidal-dl-ng/d9ab28d8840c8616d11f5610a78a02b90fb7e696/tidal_dl_ng/ui/icon.icns -------------------------------------------------------------------------------- /tidal_dl_ng/ui/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exislow/tidal-dl-ng/d9ab28d8840c8616d11f5610a78a02b90fb7e696/tidal_dl_ng/ui/icon.ico -------------------------------------------------------------------------------- /tidal_dl_ng/ui/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exislow/tidal-dl-ng/d9ab28d8840c8616d11f5610a78a02b90fb7e696/tidal_dl_ng/ui/icon.png -------------------------------------------------------------------------------- /tidal_dl_ng/ui/main.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | MainWindow 4 | 5 | 6 | 7 | 0 8 | 0 9 | 1200 10 | 800 11 | 12 | 13 | 14 | MainWindow 15 | 16 | 17 | 18 | true 19 | 20 | 21 | 22 | 100 23 | 100 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | QAbstractItemView::EditTrigger::NoEditTriggers 63 | 64 | 65 | false 66 | 67 | 68 | QAbstractItemView::SelectionMode::ExtendedSelection 69 | 70 | 71 | 10 72 | 73 | 74 | true 75 | 76 | 77 | true 78 | 79 | 80 | true 81 | 82 | 83 | true 84 | 85 | 86 | true 87 | 88 | 89 | 90 | Name 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | obj 105 | 106 | 107 | 108 | 109 | Info 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | Playlists 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | ItemIsEnabled 133 | 134 | 135 | 136 | 137 | Mixes 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | ItemIsEnabled 147 | 148 | 149 | 150 | 151 | Favorites 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | ItemIsEnabled 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 0 172 | 0 173 | 174 | 175 | 176 | Reload 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 0 185 | 0 186 | 187 | 188 | 189 | Download List 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | -1 201 | 202 | 203 | 204 | 205 | 206 | 207 | false 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | Type and press ENTER to search... 232 | 233 | 234 | true 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 100 243 | 0 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | Search 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | QAbstractItemView::EditTrigger::NoEditTriggers 300 | 301 | 302 | false 303 | 304 | 305 | false 306 | 307 | 308 | false 309 | 310 | 311 | QAbstractItemView::SelectionMode::ExtendedSelection 312 | 313 | 314 | 10 315 | 316 | 317 | true 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | Audio 342 | 343 | 344 | Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 140 353 | 0 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow 376 | 377 | 378 | 379 | 380 | 381 | true 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | Video 404 | 405 | 406 | Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 100 415 | 0 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | QComboBox::SizeAdjustPolicy::AdjustToContentsOnFirstShow 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | Download 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | true 475 | 476 | 477 | 478 | 0 479 | 0 480 | 481 | 482 | 483 | 484 | 16777215 485 | 16777215 486 | 487 | 488 | 489 | false 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | false 508 | 509 | 510 | true 511 | 512 | 513 | Logs... 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 | 527 | 528 | 529 | 0 530 | 0 531 | 532 | 533 | 534 | 535 | 280 536 | 280 537 | 538 | 539 | 540 | 541 | 0 542 | 0 543 | 544 | 545 | 546 | QFrame::Shape::NoFrame 547 | 548 | 549 | 550 | 551 | 552 | default_album_image.png 553 | 554 | 555 | true 556 | 557 | 558 | Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | false 573 | true 574 | true 575 | 576 | 577 | 578 | Download Queue 579 | 580 | 581 | 582 | 583 | 584 | 585 | QAbstractItemView::EditTrigger::NoEditTriggers 586 | 587 | 588 | false 589 | 590 | 591 | false 592 | 593 | 594 | false 595 | 596 | 597 | QAbstractItemView::SelectionMode::ExtendedSelection 598 | 599 | 600 | QAbstractItemView::SelectionBehavior::SelectRows 601 | 602 | 603 | false 604 | 605 | 606 | false 607 | 608 | 609 | false 610 | 611 | 612 | false 613 | 614 | 615 | false 616 | 617 | 618 | false 619 | 620 | 621 | 622 | 🧑‍💻️ 623 | 624 | 625 | 626 | 627 | obj 628 | 629 | 630 | 631 | 632 | Name 633 | 634 | 635 | 636 | 637 | Type 638 | 639 | 640 | 641 | 642 | Quality Audio 643 | 644 | 645 | 646 | 647 | Quality Video 648 | 649 | 650 | 651 | 652 | 653 | 654 | 655 | 656 | 657 | true 658 | 659 | 660 | Remove 661 | 662 | 663 | 664 | 665 | 666 | 667 | 668 | 669 | 670 | Queue 671 | 672 | 673 | 674 | 675 | 676 | 677 | 678 | 679 | 680 | 681 | 682 | 683 | 684 | Clear Finished 685 | 686 | 687 | 688 | 689 | 690 | 691 | Clear All 692 | 693 | 694 | 695 | 696 | 697 | 698 | 699 | 700 | 701 | 702 | 703 | 704 | 705 | 706 | 0 707 | 0 708 | 1200 709 | 24 710 | 711 | 712 | 713 | 714 | 715 | 716 | 717 | 718 | 719 | 720 | 721 | 722 | 723 | 724 | 725 | 726 | 727 | 728 | 729 | 730 | 731 | 732 | 733 | 734 | 735 | 736 | 737 | 738 | 739 | 740 | 741 | 742 | 743 | 744 | File 745 | 746 | 747 | 748 | 749 | 750 | 751 | 752 | Help 753 | 754 | 755 | 756 | 757 | 758 | 759 | 760 | 761 | 762 | 763 | 764 | 765 | 766 | 767 | 768 | 769 | 770 | 771 | 772 | 773 | 774 | 775 | 776 | 777 | Qt::LayoutDirection::LeftToRight 778 | 779 | 780 | 781 | 782 | true 783 | 784 | 785 | Preferences... 786 | 787 | 788 | Preferences... 789 | 790 | 791 | Preferences... 792 | 793 | 794 | 795 | 796 | 797 | 798 | 799 | 800 | 801 | 802 | Version 803 | 804 | 805 | 806 | 807 | Quit TIDAL-Downloader-NG 808 | 809 | 810 | 811 | 812 | Logout 813 | 814 | 815 | 816 | 817 | Check for Updates 818 | 819 | 820 | 821 | 822 | 823 | 824 | -------------------------------------------------------------------------------- /tidal_dl_ng/ui/spinner.py: -------------------------------------------------------------------------------- 1 | """ 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2012-2014 Alexander Turkin 5 | Copyright (c) 2014 William Hallatt 6 | Copyright (c) 2015 Jacob Dawid 7 | Copyright (c) 2016 Luca Weiss 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | """ 27 | 28 | import math 29 | 30 | from PySide6.QtCore import QRect, Qt, QTimer 31 | from PySide6.QtGui import QColor, QPainter 32 | from PySide6.QtWidgets import QWidget 33 | 34 | 35 | # Taken from https://github.com/COOLMSF/QtWaitingSpinnerForPyQt6 and adapted for PySide6. 36 | class QtWaitingSpinner(QWidget): 37 | def __init__( 38 | self, parent, centerOnParent=True, disableParentWhenSpinning=False, modality=Qt.WindowModality.NonModal 39 | ): 40 | super().__init__(parent) 41 | 42 | self._centerOnParent = centerOnParent 43 | self._disableParentWhenSpinning = disableParentWhenSpinning 44 | 45 | # WAS IN initialize() 46 | self._color = QColor(Qt.GlobalColor.black) 47 | self._roundness = 100.0 48 | self._minimumTrailOpacity = 3.14159265358979323846 49 | self._trailFadePercentage = 80.0 50 | self._revolutionsPerSecond = 1.57079632679489661923 51 | self._numberOfLines = 20 52 | self._lineLength = 10 53 | self._lineWidth = 2 54 | self._innerRadius = 10 55 | self._currentCounter = 0 56 | self._isSpinning = False 57 | 58 | self._timer = QTimer(self) 59 | self._timer.timeout.connect(self.rotate) 60 | self.updateSize() 61 | self.updateTimer() 62 | self.hide() 63 | # END initialize() 64 | 65 | self.setWindowModality(modality) 66 | self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) 67 | 68 | def paintEvent(self, QPaintEvent): 69 | self.updatePosition() 70 | painter = QPainter(self) 71 | painter.fillRect(self.rect(), Qt.GlobalColor.transparent) 72 | # Can't found in Qt6 73 | # painter.setRenderHint(QPainter.Antialiasing, True) 74 | 75 | if self._currentCounter >= self._numberOfLines: 76 | self._currentCounter = 0 77 | 78 | painter.setPen(Qt.PenStyle.NoPen) 79 | for i in range(0, self._numberOfLines): 80 | painter.save() 81 | painter.translate(self._innerRadius + self._lineLength, self._innerRadius + self._lineLength) 82 | rotateAngle = float(360 * i) / float(self._numberOfLines) 83 | painter.rotate(rotateAngle) 84 | painter.translate(self._innerRadius, 0) 85 | distance = self.lineCountDistanceFromPrimary(i, self._currentCounter, self._numberOfLines) 86 | color = self.currentLineColor( 87 | distance, self._numberOfLines, self._trailFadePercentage, self._minimumTrailOpacity, self._color 88 | ) 89 | painter.setBrush(color) 90 | rect = QRect(0, int(-self._lineWidth / 2), int(self._lineLength), int(self._lineWidth)) 91 | painter.drawRoundedRect(rect, self._roundness, self._roundness, Qt.SizeMode.RelativeSize) 92 | painter.restore() 93 | 94 | def start(self): 95 | self.updatePosition() 96 | self._isSpinning = True 97 | self.show() 98 | 99 | if self.parentWidget and self._disableParentWhenSpinning: 100 | self.parentWidget().setEnabled(False) 101 | 102 | if not self._timer.isActive(): 103 | self._timer.start() 104 | self._currentCounter = 0 105 | 106 | def stop(self): 107 | self._isSpinning = False 108 | self.hide() 109 | 110 | if self.parentWidget() and self._disableParentWhenSpinning: 111 | self.parentWidget().setEnabled(True) 112 | 113 | if self._timer.isActive(): 114 | self._timer.stop() 115 | self._currentCounter = 0 116 | 117 | def setNumberOfLines(self, lines): 118 | self._numberOfLines = lines 119 | self._currentCounter = 0 120 | self.updateTimer() 121 | 122 | def setLineLength(self, length): 123 | self._lineLength = length 124 | self.updateSize() 125 | 126 | def setLineWidth(self, width): 127 | self._lineWidth = width 128 | self.updateSize() 129 | 130 | def setInnerRadius(self, radius): 131 | self._innerRadius = radius 132 | self.updateSize() 133 | 134 | def color(self): 135 | return self._color 136 | 137 | def roundness(self): 138 | return self._roundness 139 | 140 | def minimumTrailOpacity(self): 141 | return self._minimumTrailOpacity 142 | 143 | def trailFadePercentage(self): 144 | return self._trailFadePercentage 145 | 146 | def revolutionsPersSecond(self): 147 | return self._revolutionsPerSecond 148 | 149 | def numberOfLines(self): 150 | return self._numberOfLines 151 | 152 | def lineLength(self): 153 | return self._lineLength 154 | 155 | def lineWidth(self): 156 | return self._lineWidth 157 | 158 | def innerRadius(self): 159 | return self._innerRadius 160 | 161 | def isSpinning(self): 162 | return self._isSpinning 163 | 164 | def setRoundness(self, roundness): 165 | self._roundness = max(0.0, min(100.0, roundness)) 166 | 167 | def setColor(self, color=Qt.GlobalColor.black): 168 | self._color = QColor(color) 169 | 170 | def setRevolutionsPerSecond(self, revolutionsPerSecond): 171 | self._revolutionsPerSecond = revolutionsPerSecond 172 | self.updateTimer() 173 | 174 | def setTrailFadePercentage(self, trail): 175 | self._trailFadePercentage = trail 176 | 177 | def setMinimumTrailOpacity(self, minimumTrailOpacity): 178 | self._minimumTrailOpacity = minimumTrailOpacity 179 | 180 | def rotate(self): 181 | self._currentCounter += 1 182 | if self._currentCounter >= self._numberOfLines: 183 | self._currentCounter = 0 184 | self.update() 185 | 186 | def updateSize(self): 187 | size = int((self._innerRadius + self._lineLength) * 2) 188 | self.setFixedSize(size, size) 189 | 190 | def updateTimer(self): 191 | self._timer.setInterval(int(1000 / (self._numberOfLines * self._revolutionsPerSecond))) 192 | 193 | def updatePosition(self): 194 | if self.parentWidget() and self._centerOnParent: 195 | self.move( 196 | int(self.parentWidget().width() / 2 - self.width() / 2), 197 | int(self.parentWidget().height() / 2 - self.height() / 2), 198 | ) 199 | 200 | def lineCountDistanceFromPrimary(self, current, primary, totalNrOfLines): 201 | distance = primary - current 202 | if distance < 0: 203 | distance += totalNrOfLines 204 | return distance 205 | 206 | def currentLineColor(self, countDistance, totalNrOfLines, trailFadePerc, minOpacity, colorinput): 207 | color = QColor(colorinput) 208 | if countDistance == 0: 209 | return color 210 | minAlphaF = minOpacity / 100.0 211 | distanceThreshold = int(math.ceil((totalNrOfLines - 1) * trailFadePerc / 100.0)) 212 | if countDistance > distanceThreshold: 213 | color.setAlphaF(minAlphaF) 214 | else: 215 | alphaDiff = color.alphaF() - minAlphaF 216 | gradient = alphaDiff / float(distanceThreshold + 1) 217 | resultAlpha = color.alphaF() - gradient * countDistance 218 | # If alpha is out of bounds, clip it. 219 | resultAlpha = min(1.0, max(0.0, resultAlpha)) 220 | color.setAlphaF(resultAlpha) 221 | return color 222 | -------------------------------------------------------------------------------- /tidal_dl_ng/worker.py: -------------------------------------------------------------------------------- 1 | from PySide6 import QtCore 2 | 3 | 4 | # Taken from https://www.pythonguis.com/tutorials/multithreading-pyside6-applications-qthreadpool/ 5 | class Worker(QtCore.QRunnable): 6 | """ 7 | Worker thread 8 | 9 | Inherits from QRunnable to handler worker thread setup, signals and wrap-up. 10 | 11 | :param callback: The function callback to run on this worker thread. Supplied args and 12 | kwargs will be passed through to the runner. 13 | :type callback: function 14 | :param args: Arguments to pass to the callback function 15 | :param kwargs: Keywords to pass to the callback function 16 | 17 | """ 18 | 19 | def __init__(self, fn, *args, **kwargs): 20 | super().__init__() 21 | # Store constructor arguments (re-used for processing) 22 | self.fn = fn 23 | self.args = args 24 | self.kwargs = kwargs 25 | 26 | @QtCore.Slot() # QtCore.Slot 27 | def run(self): 28 | """ 29 | Initialise the runner function with passed args, kwargs. 30 | """ 31 | self.fn(*self.args, **self.kwargs) 32 | 33 | def thread(self) -> QtCore.QThread: 34 | return QtCore.QThread.currentThread() 35 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = true 3 | envlist = py312 4 | 5 | [gh-actions] 6 | python = 7 | 3.12: py312 8 | 9 | [testenv] 10 | passenv = PYTHON_VERSION 11 | allowlist_externals = poetry, pytest 12 | commands = 13 | poetry install -v --no-interaction --all-extras --with dev,docs 14 | pytest --doctest-modules tests --cov --cov-config=pyproject.toml --cov-report=xml 15 | --------------------------------------------------------------------------------