summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRobert Honz <exislow+github@gmail.com>2023-12-19 23:58:57 +0100
committerRobert Honz <exislow+github@gmail.com>2023-12-19 23:58:57 +0100
commit0d4a9d6b8740035346d098f5bc28ebca64f6cd4d (patch)
treef52c9569509565e581b03b6e60fe32e611b79ad2
parent82a8fe96aa08903919a90a76030bacba9f93b8e0 (diff)
downloadtidal-dl-ng-0d4a9d6b8740035346d098f5bc28ebca64f6cd4d.tar.gz
🌟 Re-add all project files.
-rw-r--r--.editorconfig27
-rw-r--r--.github/FUNDING.yml13
-rw-r--r--.github/actions/setup-poetry-env/action.yml33
-rw-r--r--.github/workflows/master.yml70
-rw-r--r--.github/workflows/on-release-master.yml56
-rw-r--r--.pre-commit-config.yaml56
-rw-r--r--CHANGELOG.md30
-rw-r--r--CONTRIBUTING.md133
-rw-r--r--LICENSE661
-rw-r--r--Makefile66
-rw-r--r--README.md114
-rw-r--r--mkdocs.yml54
-rw-r--r--poetry.lock2024
-rw-r--r--poetry.toml2
-rw-r--r--pyproject.toml191
-rw-r--r--setup.cfg4
-rw-r--r--tidal_dl_ng/__init__.py8
-rw-r--r--tidal_dl_ng/api.py102
-rw-r--r--tidal_dl_ng/cli.py217
-rw-r--r--tidal_dl_ng/config.py132
-rw-r--r--tidal_dl_ng/constants.py18
-rw-r--r--tidal_dl_ng/download.py331
-rw-r--r--tidal_dl_ng/gui.py481
-rw-r--r--tidal_dl_ng/helper/__init__.py0
-rw-r--r--tidal_dl_ng/helper/decorator.py19
-rw-r--r--tidal_dl_ng/helper/decryption.py53
-rw-r--r--tidal_dl_ng/helper/exceptions.py6
-rw-r--r--tidal_dl_ng/helper/path.py177
-rw-r--r--tidal_dl_ng/helper/url.py15
-rw-r--r--tidal_dl_ng/helper/wrapper.py23
-rw-r--r--tidal_dl_ng/logger.py65
-rw-r--r--tidal_dl_ng/metadata.py163
-rw-r--r--tidal_dl_ng/model/__init__.py0
-rw-r--r--tidal_dl_ng/model/cfg.py71
-rw-r--r--tidal_dl_ng/model/gui_data.py32
-rw-r--r--tidal_dl_ng/ui/__init__.py0
-rw-r--r--tidal_dl_ng/ui/icon.pngbin0 -> 54166 bytes
-rw-r--r--tidal_dl_ng/ui/main.py456
-rw-r--r--tidal_dl_ng/ui/main.ui590
-rw-r--r--tidal_dl_ng/ui/spinner.py221
-rw-r--r--tidal_dl_ng/worker.py31
-rw-r--r--tox.ini17
42 files changed, 6762 insertions, 0 deletions
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..1765ddb
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,27 @@
+max_line_length = 120
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true
+
+[*.{py, pyi}]
+indent_style = space
+indent_size = 4
+
+[Makefile]
+indent_style = space
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.{diff,patch}]
+trim_trailing_whitespace = false
+
+[*.json]
+indent_style = space
+indent_size = 4
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..e46cbf8
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,13 @@
+# These are supported funding model platforms
+
+github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
+patreon: # Replace with a single Patreon username
+open_collective: # Replace with a single Open Collective username
+ko_fi: exislow
+tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
+community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
+liberapay: # Replace with a single Liberapay username
+issuehunt: # Replace with a single IssueHunt username
+otechie: # Replace with a single Otechie username
+# Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+custom: ["https://www.buymeacoffee.com/exislow"]
diff --git a/.github/actions/setup-poetry-env/action.yml b/.github/actions/setup-poetry-env/action.yml
new file mode 100644
index 0000000..22e4053
--- /dev/null
+++ b/.github/actions/setup-poetry-env/action.yml
@@ -0,0 +1,33 @@
+name: "setup-poetry-env"
+description: "Composite action to setup the Python and poetry environment."
+
+inputs:
+ python-version:
+ required: false
+ description: "The python version to use"
+ default: "3.11"
+
+runs:
+ using: "composite"
+ steps:
+ - name: Set up python
+ uses: actions/setup-python@v4
+ with:
+ python-version: ${{ inputs.python-version }}
+
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+ with:
+ virtualenvs-in-project: true
+
+ - name: Load cached venv
+ id: cached-poetry-dependencies
+ uses: actions/cache@v3
+ with:
+ path: .venv
+ key: venv-${{ runner.os }}-${{ inputs.python-version }}-${{ hashFiles('poetry.lock') }}
+
+ - name: Install dependencies
+ if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
+ run: poetry install --no-interaction
+ shell: bash
diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml
new file mode 100644
index 0000000..866460b
--- /dev/null
+++ b/.github/workflows/master.yml
@@ -0,0 +1,70 @@
+name: master
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+jobs:
+ quality:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out
+ uses: actions/checkout@v3
+
+ - uses: actions/cache@v3
+ with:
+ path: ~/.cache/pre-commit
+ key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
+
+ - name: Set up the environment
+ uses: ./.github/actions/setup-poetry-env
+
+ - name: Run checks
+ run: make check
+
+ tox:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.10", "3.11", "3.12"]
+ fail-fast: false
+ steps:
+ - name: Check out
+ uses: actions/checkout@v3
+
+ - name: Set up python
+ uses: actions/setup-python@v4
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+
+ - name: Load cached venv
+ uses: actions/cache@v3
+ with:
+ path: .tox
+ key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }}
+
+ - name: Install tox
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install tox tox-gh-actions
+
+ - name: Test with tox
+ run: tox
+
+ check-docs:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out
+ uses: actions/checkout@v3
+
+ - name: Set up the environment
+ uses: ./.github/actions/setup-poetry-env
+
+ - name: Check if documentation can be built
+ run: poetry run mkdocs build -s
diff --git a/.github/workflows/on-release-master.yml b/.github/workflows/on-release-master.yml
new file mode 100644
index 0000000..4ea640a
--- /dev/null
+++ b/.github/workflows/on-release-master.yml
@@ -0,0 +1,56 @@
+name: release-master
+
+permissions:
+ contents: write
+
+on:
+ # release:
+ # types: [published]
+ # branches: [master]
+ push:
+ tags:
+ - "v*.*.*"
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out
+ uses: actions/checkout@v3
+
+ - name: Set up the environment
+ uses: ./.github/actions/setup-poetry-env
+
+ - name: Export tag
+ id: vars
+ run: echo tag=${GITHUB_REF#refs/*/} >> $GITHUB_OUTPUT
+
+ - name: Build and publish
+ run: |
+ source .venv/bin/activate
+ poetry version $RELEASE_VERSION
+ make build-and-publish
+ env:
+ PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
+ RELEASE_VERSION: ${{ steps.vars.outputs.tag }}
+
+ - name: GitHub Release
+ uses: softprops/action-gh-release@v1
+ with:
+ draft: false
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ deploy-docs:
+ needs: publish
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out
+ uses: actions/checkout@v3
+
+ - name: Set up the environment
+ uses: ./.github/actions/setup-poetry-env
+
+ - name: Deploy documentation
+ run: poetry run mkdocs gh-deploy --force
+# TODO: Build Qt GUI Standalone.
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..0f56a8f
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,56 @@
+default_language_version:
+ python: python3.11
+
+default_stages: [commit, push]
+
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: "v4.4.0"
+ hooks:
+ - id: check-case-conflict
+ - id: check-merge-conflict
+ - id: check-toml
+ - id: check-yaml
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+
+ - repo: https://github.com/charliermarsh/ruff-pre-commit
+ rev: "v0.0.230"
+ hooks:
+ - id: ruff
+
+ - repo: https://github.com/pre-commit/mirrors-prettier
+ rev: "v3.0.3"
+ hooks:
+ - id: prettier
+
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v2.5.0
+ hooks:
+ - id: check-yaml
+ - id: end-of-file-fixer
+ exclude: LICENSE
+
+ - repo: local
+ hooks:
+ - id: pyupgrade
+ name: pyupgrade
+ entry: poetry run pyupgrade --py310-plus
+ types: [python]
+ language: system
+
+ - repo: local
+ hooks:
+ - id: isort
+ name: isort
+ entry: poetry run isort --settings-path pyproject.toml
+ types: [python]
+ language: system
+
+ - repo: local
+ hooks:
+ - id: black
+ name: black
+ entry: poetry run black --config pyproject.toml
+ types: [python]
+ language: system
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..9687355
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,30 @@
+# v0.4.9
+
+- Fixed: Exception on missing file read (config, token) instead of creation.
+
+# v0.4.8
+
+- Fixed: GUI dependencies are treated as extra now (pip).
+
+# v0.4.7
+
+- Fixed: GUI dependencies are treated as extra now (pip).
+
+# v0.4.6
+
+- GUI dependencies are treated as extra now (pip).
+
+# v0.4.5
+
+- Fixed "Mix" download.
+- Fixed relative imports.
+- Fixed PyPi build.
+- Fixed crash on lyrics error.
+
+# v0.4.2
+
+- Added more basic features.
+
+# v0.4.1
+
+- Initial featured running version.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..169089c
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,133 @@
+# Contributing to `tidal-dl-ng`
+
+Contributions are welcome, and they are greatly appreciated!
+Every little bit helps, and credit will always be given.
+
+You can contribute in many ways:
+
+# Types of Contributions
+
+## Report Bugs
+
+Report bugs at https://github.com/exislow/tidal-dl-ng/issues
+
+If you are reporting a bug, please include:
+
+- Your operating system name and version.
+- Any details about your local setup that might be helpful in troubleshooting.
+- Detailed steps to reproduce the bug.
+
+## Fix Bugs
+
+Look through the GitHub issues for bugs.
+Anything tagged with "bug" and "help wanted" is open to whoever wants to implement a fix for it.
+
+## Implement Features
+
+Look through the GitHub issues for features.
+Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it.
+
+## Write Documentation
+
+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.
+
+## Submit Feedback
+
+The best way to send feedback is to file an issue at https://github.com/exislow/tidal-dl-ng/issues.
+
+If you are proposing a new feature:
+
+- Explain in detail how it would work.
+- Keep the scope as narrow as possible, to make it easier to implement.
+- Remember that this is a volunteer-driven project, and that contributions
+ are welcome :)
+
+# Get Started!
+
+Ready to contribute? Here's how to set up `tidal-dl-ng` for local development.
+Please note this documentation assumes you already have `poetry` and `Git` installed and ready to go.
+
+1. Fork the `tidal-dl-ng` repo on GitHub.
+
+2. Clone your fork locally:
+
+```bash
+cd <directory_in_which_repo_should_be_created>
+git clone git@github.com:YOUR_NAME/tidal-dl-ng.git
+```
+
+3. Now we need to install the environment. Navigate into the directory
+
+```bash
+cd tidal-dl-ng
+```
+
+If you are using `pyenv`, select a version to use locally. (See installed versions with `pyenv versions`)
+
+```bash
+pyenv local <x.y.z>
+```
+
+Then, install and activate the environment with:
+
+```bash
+poetry install
+poetry shell
+```
+
+4. Install pre-commit to run linters/formatters at commit time:
+
+```bash
+poetry run pre-commit install
+```
+
+5. Create a branch for local development:
+
+```bash
+git checkout -b name-of-your-bugfix-or-feature
+```
+
+Now you can make your changes locally.
+
+6. Don't forget to add test cases for your added functionality to the `tests` directory.
+
+7. When you're done making changes, check that your changes pass the formatting tests.
+
+```bash
+make check
+```
+
+Now, validate that all unit tests are passing:
+
+```bash
+make test
+```
+
+9. Before raising a pull request you should also run tox.
+ This will run the tests across different versions of Python:
+
+```bash
+tox
+```
+
+This requires you to have multiple versions of python installed.
+This step is also triggered in the CI/CD pipeline, so you could also choose to skip this step locally.
+
+10. Commit your changes and push your branch to GitHub:
+
+```bash
+git add .
+git commit -m "Your detailed description of your changes."
+git push origin name-of-your-bugfix-or-feature
+```
+
+11. Submit a pull request through the GitHub website.
+
+# Pull Request Guidelines
+
+Before you submit a pull request, check that it meets these guidelines:
+
+1. The pull request should include tests.
+
+2. If the pull request adds functionality, the docs should be updated.
+ Put your new functionality into a function with a docstring, and add the feature to the list in `README.md`.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0ad25db
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..7f23fa7
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,66 @@
+.PHONY: install
+install: ## Install the poetry environment and install the pre-commit hooks
+ @echo "🚀 Creating virtual environment using pyenv and poetry"
+ @poetry install
+ @poetry run pre-commit install
+ @poetry shell
+
+.PHONY: check
+check: ## Run code quality tools.
+ @echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry lock --check"
+ @poetry check --lock
+ @echo "🚀 Linting code: Running pre-commit"
+ @poetry run pre-commit run -a
+ @echo "🚀 Static type checking: Running mypy"
+ @poetry run mypy
+ @echo "🚀 Checking for obsolete dependencies: Running deptry"
+ @poetry run deptry .
+
+.PHONY: test
+test: ## Test the code with pytest
+ @echo "🚀 Testing code: Running pytest"
+ @poetry run pytest --doctest-modules
+
+.PHONY: build
+build: clean-build ## Build wheel file using poetry
+ @echo "🚀 Creating wheel file"
+ @poetry build
+
+.PHONY: clean-build
+clean-build: ## clean build artifacts
+ @rm -rf dist
+
+.PHONY: publish
+publish: ## publish a release to pypi.
+ @echo "🚀 Publishing: Dry run."
+ @poetry config pypi-token.pypi $(PYPI_TOKEN)
+ @poetry publish --dry-run
+ @echo "🚀 Publishing."
+ @poetry publish
+
+.PHONY: build-and-publish
+build-and-publish: build publish ## Build and publish.
+
+.PHONY: docs-test
+docs-test: ## Test if documentation can be built without warnings or errors
+ @poetry run mkdocs build -s
+
+.PHONY: docs
+docs: ## Build and serve the documentation
+ @poetry run mkdocs serve
+
+.PHONY: help
+help:
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
+
+# TODO: macos Signing: https://gist.github.com/txoof/0636835d3cc65245c6288b2374799c43
+.PHONY: gui
+gui: ## Build and serve the documentation
+ @poetry run pyinstaller --noconfirm \
+ --windowed --onefile \
+ --name "TIDAL Downloader NG" \
+ --noupx \
+ --icon tidal_dl_ng/ui/icon.png \
+ tidal_dl_ng/gui.py
+
+.DEFAULT_GOAL := help
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8360728
--- /dev/null
+++ b/README.md
@@ -0,0 +1,114 @@
+# 🔰 TIDAL Downloader Next Generation! (tidal-dl-ng)
+
+[![Release](https://img.shields.io/github/v/release/exislow/tidal-dl-ng)](https://img.shields.io/github/v/release/exislow/tidal-dl-ng)
+[![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)
+[![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)
+[![License](https://img.shields.io/github/license/exislow/tidal-dl-ng)](https://img.shields.io/github/license/exislow/tidal-dl-ng)
+
+This tool allows to download songs and videos from TIDAL (a paid plan is required!). You can use the command line or GUI
+version of this tool.
+
+If you like this projects and want to support it, you can buy me a coffee :-)
+
+<a href="https://www.buymeacoffee.com/exislow" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/arial-orange.png" alt="Buy Me A Coffee" style="height: 51px !important;width: 217px !important;" ></a>
+<a href="https://ko-fi.com/exislow" target="_blank" rel="noopener noreferrer"><img src="https://help.ko-fi.com/hc/article_attachments/11833788361117" alt="61e11d430afb112ea33c3aa5_Button-1-p-500"></a>
+
+## 💻 Installation / Upgrade
+
+**Requirements**: Python 3.11 (other versions might work but are not tested!)
+
+```bash
+pip install --upgrade tidal-dl-ng
+# AND if you like to have the GUI as well
+pip install --upgrade tidal-dl-ng[gui]
+```
+
+You can use the command line (CLI) version to download media:
+
+```bash
+tidal-dl-ng dl https://tidal.com/browse/track/46755209
+# OR
+tdn dl https://tidal.com/browse/track/46755209
+```
+
+But also the GUI:
+
+```bash
+tidal-dl-ng-gui
+# OR
+tdng
+# OR
+tidal-dl-ng gui
+```
+
+## 🧁 Features
+
+- Download Tracks, Videos, Albums, Playlists
+- Metadata for songs
+- Adjustable audio and video download quality.
+
+## ▶️ Getting started with development
+
+### 🚰 Install dependecies
+
+Clone this repository and install the dependencies:
+
+```bash
+poetry install --all-extras --with dev,docs
+```
+
+The main entry points are:
+
+```bash
+tidal_ng_dl/cli.py
+tidal_ng_dl/gui.py
+```
+
+### 📺 GUI Builder
+
+The GUI is build with `PySide6` using the [Qt Designer](https://doc.qt.io/qt-6/qtdesigner-manual.html):
+
+```bash
+pyside6-designer
+```
+
+After all changes are saved you need to translate the Qt Designer `*.ui` file into Python code:
+
+```
+pyside6-uic tidal_dl_ng/ui/main.ui -o tidal_dl_ng/ui/main.py
+```
+
+### 🏗 Build the project
+
+To build the project use this command:
+
+```bash
+make install
+```
+
+The CI/CD pipeline will be triggered when you open a pull request, merge to main, or when you create a new release.
+
+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).
+For activating the automatic documentation with MkDocs, see [here](https://fpgmaas.github.io/cookiecutter-poetry/features/mkdocs/#enabling-the-documentation-on-github).
+To enable the code coverage reports, see [here](https://fpgmaas.github.io/cookiecutter-poetry/features/codecov/).
+
+## ✨ Releasing a new version
+
+- Create an API Token on [Pypi](https://pypi.org/).
+- Add the API Token to your projects secrets with the name `PYPI_TOKEN` by visiting [this page](https://github.com/exislow/tidal-dl-ng/settings/secrets/actions/new).
+- Create a [new release](https://github.com/exislow/tidal-dl-ng/releases/new) on Github.
+- Create a new tag in the form `*.*.*`.
+
+For more details, see [here](https://fpgmaas.github.io/cookiecutter-poetry/features/cicd/#how-to-trigger-a-release).
+
+## ‼️ Dislaimer
+
+- For educational purposes only. I am not liable and responsible for any damage that happens.
+- You should not use this method to distribute or pirate music.
+- It may be illegal to use this app in your country.
+
+## 🫂 Contributors
+
+Thanks to all, who have contributed to this project!
+1
+<a href="https://github.com/exislow/tidal-dl-ng/graphs/contributors"><img src="https://contributors-img.web.app/image?repo=exislow/tidal-dl-ng" /></a>
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..0f1c0e6
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,54 @@
+site_name: tidal-dl-ng
+repo_url: https://github.com/exislow/tidal-dl-ng
+site_url: https://exislow.github.io/tidal-dl-ng
+site_description: TIDAL Medial Downloader Next Generation!
+site_author: Robert Honz
+edit_uri: edit/main/docs/
+repo_name: exislow/tidal-dl-ng
+copyright: Maintained by <a href="https://example.com">Robby</a>.
+
+nav:
+ - Home: index.md
+ - Modules: modules.md
+plugins:
+ - search
+ - mkdocstrings:
+ handlers:
+ python:
+ setup_commands:
+ - import sys
+ - sys.path.append('../')
+theme:
+ name: material
+ feature:
+ tabs: true
+ palette:
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ primary: white
+ accent: deep orange
+ toggle:
+ icon: material/brightness-7
+ name: Switch to dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ primary: black
+ accent: deep orange
+ toggle:
+ icon: material/brightness-4
+ name: Switch to light mode
+ icon:
+ repo: fontawesome/brands/github
+
+extra:
+ social:
+ - icon: fontawesome/brands/github
+ link: https://github.com/exislow/tidal-dl-ng
+ - icon: fontawesome/brands/python
+ link: https://pypi.org/project/tidal-dl-ng
+
+markdown_extensions:
+ - toc:
+ permalink: true
+ - pymdownx.arithmatex:
+ generic: true
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..3194215
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,2024 @@
+# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
+
+[[package]]
+name = "altgraph"
+version = "0.17.4"
+description = "Python graph (network) package"
+optional = false
+python-versions = "*"
+files = [
+ {file = "altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff"},
+ {file = "altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406"},
+]
+
+[[package]]
+name = "astroid"
+version = "3.0.2"
+description = "An abstract syntax tree for Python with inference support."
+optional = false
+python-versions = ">=3.8.0"
+files = [
+ {file = "astroid-3.0.2-py3-none-any.whl", hash = "sha256:d6e62862355f60e716164082d6b4b041d38e2a8cf1c7cd953ded5108bac8ff5c"},
+ {file = "astroid-3.0.2.tar.gz", hash = "sha256:4a61cf0a59097c7bb52689b0fd63717cd2a8a14dc9f1eee97b82d814881c8c91"},
+]
+
+[package.dependencies]
+typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""}
+
+[[package]]
+name = "babel"
+version = "2.14.0"
+description = "Internationalization utilities"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"},
+ {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"},
+]
+
+[package.extras]
+dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"]
+
+[[package]]
+name = "backports-datetime-fromisoformat"
+version = "2.0.1"
+description = "Backport of Python 3.11's datetime.fromisoformat"
+optional = false
+python-versions = ">3"
+files = [
+ {file = "backports-datetime-fromisoformat-2.0.1.tar.gz", hash = "sha256:1b6afca7f47019c22df43062cde73c1af65fbdebc66520f352c690d52fd27127"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b739ccd3f36244f618f1fbc21d89894d9dc9d1d75a68762fcf917d433df38ae3"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:afd072ca32f2ca4e838e0f7b61a56168d98837ee9a182c567a49a834e07c2b98"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a136d85f8b1db4747aa9e56a8caa0ba77c5c25b761b18e2169ea7b1b516f012"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d3a0579958ade7db62c8238163e05d46a4de61c99cebb40031ed7409a44d5f6"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:199df62af8feff5da0f4953fdc4a6994bcd7dbfe1db95901d8b93d05feda2ab5"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:afe32e60a471831058ede14fc226d9f14120e6dc67d66fbbd36e1724826ad70b"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:a1ba7e226a9694b20b713867f71b5ed2f662603c39875f14f968608d331fc96a"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:403f155deecbe94d43d0679a74abb5c9ac441422a9ececcfde030fb133865659"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4d2ee049997d3aa2e714489cb3c34864fb0f25786e7a4ff04ac9d82af58b453"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:20aa422769af9f72ca41d83238d4a3a008d6cd74bcff0a08befb11b0018d6aa5"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8ea8d85c3c9afa4ad51b6644d26516d43493f44c2131c12a2ba959433f4417f6"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:812b8c34e88a7d9615c604f1a0473a4e6d664aba94086bffb0c55627f9e3fb68"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:df5365930320b7a9d404cd6f7bc13988e28355e812aa42e21aa5c93443dcdd2e"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fe3e3968c8dce4a44da2da81a6031e992a4ee62d130c2536696d215a4db2ce3c"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:36a4abb678ab0d6a1965d70e21e424bcf7a52086a7afb1c5f13243a3d44fa2dd"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96b7e806ade09a91d8ce195c197fc799d8fbe6b8ea9cde21f8a01f1090e51e33"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:002a77bd4f549ff5e80f1ef4a9b69982746dd6190786b90abe3d9c69c9883ce4"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7b4ad0013a96b656ebf85079921ffb211623a1e28ff4981b3927690a2ed6df54"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:065421723e735ce8f68dbb4486f07562ce8556ed543ceaa012189b9aa209f303"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a4bf1bec08bc84095ee379202466c948fe12cff1442f58ee1a91fac4c5164c97"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1836aff09b8317e179cc7288856b61a450515d4b411f0ab723dc9692dfa5362e"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:815f85a581b142bcf34632c3ce26f7e21003f101ce88b5649631330e334bbe35"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a6986cfd3bc40b41465a6c54c18a30ca8110333d0b71f6062af136db11c8ff0"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:82741e732d71f78b44a8c3b95f33b3630e7bfbdb02e3fede3938cdf15d5b6a83"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4eac27abb51ee84e08d1dd1e908c16cae2078c217ff5b54092e6cb92107b4c6c"},
+ {file = "backports_datetime_fromisoformat-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:3b730d72061523be9600bcd281ef353f7f73b1df095adbbdc364aac8f430c44c"},
+ {file = "backports_datetime_fromisoformat-2.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e8f28f4a68539192473f427ed86794931502d186e2fffa1926250550c1335a"},
+ {file = "backports_datetime_fromisoformat-2.0.1-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef151f1df77e413dc179607edb5bee11949ca5890e81c0bb742d96fec753fe"},
+ {file = "backports_datetime_fromisoformat-2.0.1-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c28c95d6df2a44fa3540e18e484596c03e8ff7112e2f93b664f482fe3a88720b"},
+ {file = "backports_datetime_fromisoformat-2.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91042b53de903e3725209ad6d69b6994ae4819614c0decd62d05dfea23f35e2b"},
+]
+
+[[package]]
+name = "bandit"
+version = "1.7.6"
+description = "Security oriented static analyser for python code."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "bandit-1.7.6-py3-none-any.whl", hash = "sha256:36da17c67fc87579a5d20c323c8d0b1643a890a2b93f00b3d1229966624694ff"},
+ {file = "bandit-1.7.6.tar.gz", hash = "sha256:72ce7bc9741374d96fb2f1c9a8960829885f1243ffde743de70a19cee353e8f3"},
+]
+
+[package.dependencies]
+colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""}
+GitPython = ">=3.1.30"
+PyYAML = ">=5.3.1"
+rich = "*"
+stevedore = ">=1.20.0"
+
+[package.extras]
+test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "tomli (>=1.1.0)"]
+toml = ["tomli (>=1.1.0)"]
+yaml = ["PyYAML"]
+
+[[package]]
+name = "black"
+version = "23.12.0"
+description = "The uncompromising code formatter."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "black-23.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:67f19562d367468ab59bd6c36a72b2c84bc2f16b59788690e02bbcb140a77175"},
+ {file = "black-23.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bbd75d9f28a7283b7426160ca21c5bd640ca7cd8ef6630b4754b6df9e2da8462"},
+ {file = "black-23.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:593596f699ca2dcbbbdfa59fcda7d8ad6604370c10228223cd6cf6ce1ce7ed7e"},
+ {file = "black-23.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:12d5f10cce8dc27202e9a252acd1c9a426c83f95496c959406c96b785a92bb7d"},
+ {file = "black-23.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e73c5e3d37e5a3513d16b33305713237a234396ae56769b839d7c40759b8a41c"},
+ {file = "black-23.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba09cae1657c4f8a8c9ff6cfd4a6baaf915bb4ef7d03acffe6a2f6585fa1bd01"},
+ {file = "black-23.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ace64c1a349c162d6da3cef91e3b0e78c4fc596ffde9413efa0525456148873d"},
+ {file = "black-23.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:72db37a2266b16d256b3ea88b9affcdd5c41a74db551ec3dd4609a59c17d25bf"},
+ {file = "black-23.12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fdf6f23c83078a6c8da2442f4d4eeb19c28ac2a6416da7671b72f0295c4a697b"},
+ {file = "black-23.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39dda060b9b395a6b7bf9c5db28ac87b3c3f48d4fdff470fa8a94ab8271da47e"},
+ {file = "black-23.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7231670266ca5191a76cb838185d9be59cfa4f5dd401b7c1c70b993c58f6b1b5"},
+ {file = "black-23.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:193946e634e80bfb3aec41830f5d7431f8dd5b20d11d89be14b84a97c6b8bc75"},
+ {file = "black-23.12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bcf91b01ddd91a2fed9a8006d7baa94ccefe7e518556470cf40213bd3d44bbbc"},
+ {file = "black-23.12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:996650a89fe5892714ea4ea87bc45e41a59a1e01675c42c433a35b490e5aa3f0"},
+ {file = "black-23.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdbff34c487239a63d86db0c9385b27cdd68b1bfa4e706aa74bb94a435403672"},
+ {file = "black-23.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:97af22278043a6a1272daca10a6f4d36c04dfa77e61cbaaf4482e08f3640e9f0"},
+ {file = "black-23.12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ead25c273adfad1095a8ad32afdb8304933efba56e3c1d31b0fee4143a1e424a"},
+ {file = "black-23.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c71048345bdbced456cddf1622832276d98a710196b842407840ae8055ade6ee"},
+ {file = "black-23.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a832b6e00eef2c13b3239d514ea3b7d5cc3eaa03d0474eedcbbda59441ba5d"},
+ {file = "black-23.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:6a82a711d13e61840fb11a6dfecc7287f2424f1ca34765e70c909a35ffa7fb95"},
+ {file = "black-23.12.0-py3-none-any.whl", hash = "sha256:a7c07db8200b5315dc07e331dda4d889a56f6bf4db6a9c2a526fa3166a81614f"},
+ {file = "black-23.12.0.tar.gz", hash = "sha256:330a327b422aca0634ecd115985c1c7fd7bdb5b5a2ef8aa9888a82e2ebe9437a"},
+]
+
+[package.dependencies]
+click = ">=8.0.0"
+mypy-extensions = ">=0.4.3"
+packaging = ">=22.0"
+pathspec = ">=0.9.0"
+platformdirs = ">=2"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""}
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"]
+jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
+uvloop = ["uvloop (>=0.15.2)"]
+
+[[package]]
+name = "cachetools"
+version = "5.3.2"
+description = "Extensible memoizing collections and decorators"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "cachetools-5.3.2-py3-none-any.whl", hash = "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1"},
+ {file = "cachetools-5.3.2.tar.gz", hash = "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2"},
+]
+
+[[package]]
+name = "certifi"
+version = "2023.11.17"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"},
+ {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"},
+]
+
+[[package]]
+name = "cfgv"
+version = "3.4.0"
+description = "Validate configuration and produce human readable error messages."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
+ {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
+]
+
+[[package]]
+name = "chardet"
+version = "5.2.0"
+description = "Universal encoding detector for Python 3"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"},
+ {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"},
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.3.2"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+optional = false
+python-versions = ">=3.7.0"
+files = [
+ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"},
+ {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"},
+]
+
+[[package]]
+name = "click"
+version = "8.1.7"
+description = "Composable command line interface toolkit"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
+ {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "coloredlogs"
+version = "15.0.1"
+description = "Colored terminal output for Python's logging module"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+files = [
+ {file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"},
+ {file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"},
+]
+
+[package.dependencies]
+humanfriendly = ">=9.1"
+
+[package.extras]
+cron = ["capturer (>=2.4)"]
+
+[[package]]
+name = "darglint"
+version = "1.8.1"
+description = "A utility for ensuring Google-style docstrings stay up to date with the source code."
+optional = false
+python-versions = ">=3.6,<4.0"
+files = [
+ {file = "darglint-1.8.1-py3-none-any.whl", hash = "sha256:5ae11c259c17b0701618a20c3da343a3eb98b3bc4b5a83d31cdd94f5ebdced8d"},
+ {file = "darglint-1.8.1.tar.gz", hash = "sha256:080d5106df149b199822e7ee7deb9c012b49891538f14a11be681044f0bb20da"},
+]
+
+[[package]]
+name = "darkdetect"
+version = "0.7.1"
+description = "Detect OS Dark Mode from Python"
+optional = true
+python-versions = "*"
+files = [
+ {file = "darkdetect-0.7.1-py2.py3-none-any.whl", hash = "sha256:3efe69f8ecd5f1b7f4fbb0d1d93f656b0e493c45cc49222380ffe2a529cbc866"},
+ {file = "darkdetect-0.7.1.tar.gz", hash = "sha256:47be3cf5134432ddb616bbffc927237718407914993c82809983e7ccebf49013"},
+]
+
+[[package]]
+name = "dataclasses-json"
+version = "0.6.3"
+description = "Easily serialize dataclasses to and from JSON."
+optional = false
+python-versions = ">=3.7,<4.0"
+files = [
+ {file = "dataclasses_json-0.6.3-py3-none-any.whl", hash = "sha256:4aeb343357997396f6bca1acae64e486c3a723d8f5c76301888abeccf0c45176"},
+ {file = "dataclasses_json-0.6.3.tar.gz", hash = "sha256:35cb40aae824736fdf959801356641836365219cfe14caeb115c39136f775d2a"},
+]
+
+[package.dependencies]
+marshmallow = ">=3.18.0,<4.0.0"
+typing-inspect = ">=0.4.0,<1"
+
+[[package]]
+name = "deptry"
+version = "0.12.0"
+description = "A command line utility to check for unused, missing and transitive dependencies in a Python project."
+optional = false
+python-versions = ">=3.8,<4.0"
+files = [
+ {file = "deptry-0.12.0-py3-none-any.whl", hash = "sha256:69c801a6ae1b39c7b8e0daf40dbe8b75f1f161277d206dd8f921f32cd22dad91"},
+ {file = "deptry-0.12.0.tar.gz", hash = "sha256:ac3cd32d149c92a9af12f63cd9486ddd1760f0277ed0cf306c6ef0388f57ff0a"},
+]
+
+[package.dependencies]
+chardet = ">=4.0.0"
+click = ">=8.0.0,<9.0.0"
+colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""}
+pathspec = ">=0.9.0"
+tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version < \"3.11\""}
+
+[[package]]
+name = "dill"
+version = "0.3.7"
+description = "serialize all of Python"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "dill-0.3.7-py3-none-any.whl", hash = "sha256:76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e"},
+ {file = "dill-0.3.7.tar.gz", hash = "sha256:cc1c8b182eb3013e24bd475ff2e9295af86c1a38eb1aff128dac8962a9ce3c03"},
+]
+
+[package.extras]
+graph = ["objgraph (>=1.7.2)"]
+
+[[package]]
+name = "distlib"
+version = "0.3.8"
+description = "Distribution utilities"
+optional = false
+python-versions = "*"
+files = [
+ {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"},
+ {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"},
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.2.0"
+description = "Backport of PEP 654 (exception groups)"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"},
+ {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"},
+]
+
+[package.extras]
+test = ["pytest (>=6)"]
+
+[[package]]
+name = "ffmpeg-python"
+version = "0.2.0"
+description = "Python bindings for FFmpeg - with complex filtering support"
+optional = false
+python-versions = "*"
+files = [
+ {file = "ffmpeg-python-0.2.0.tar.gz", hash = "sha256:65225db34627c578ef0e11c8b1eb528bb35e024752f6f10b78c011f6f64c4127"},
+ {file = "ffmpeg_python-0.2.0-py3-none-any.whl", hash = "sha256:ac441a0404e053f8b6a1113a77c0f452f1cfc62f6344a769475ffdc0f56c23c5"},
+]
+
+[package.dependencies]
+future = "*"
+
+[package.extras]
+dev = ["Sphinx (==2.1.0)", "future (==0.17.1)", "numpy (==1.16.4)", "pytest (==4.6.1)", "pytest-mock (==1.10.4)", "tox (==3.12.1)"]
+
+[[package]]
+name = "filelock"
+version = "3.13.1"
+description = "A platform independent file lock."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"},
+ {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"},
+]
+
+[package.extras]
+docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"]
+testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"]
+typing = ["typing-extensions (>=4.8)"]
+
+[[package]]
+name = "future"
+version = "0.18.3"
+description = "Clean single-source support for Python 3 and 2"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+ {file = "future-0.18.3.tar.gz", hash = "sha256:34a17436ed1e96697a86f9de3d15a3b0be01d8bc8de9c1dffd59fb8234ed5307"},
+]
+
+[[package]]
+name = "ghp-import"
+version = "2.1.0"
+description = "Copy your docs directly to the gh-pages branch."
+optional = false
+python-versions = "*"
+files = [
+ {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"},
+ {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"},
+]
+
+[package.dependencies]
+python-dateutil = ">=2.8.1"
+
+[package.extras]
+dev = ["flake8", "markdown", "twine", "wheel"]
+
+[[package]]
+name = "gitdb"
+version = "4.0.11"
+description = "Git Object Database"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "gitdb-4.0.11-py3-none-any.whl", hash = "sha256:81a3407ddd2ee8df444cbacea00e2d038e40150acfa3001696fe0dcf1d3adfa4"},
+ {file = "gitdb-4.0.11.tar.gz", hash = "sha256:bf5421126136d6d0af55bc1e7c1af1c397a34f5b7bd79e776cd3e89785c2b04b"},
+]
+
+[package.dependencies]
+smmap = ">=3.0.1,<6"
+
+[[package]]
+name = "gitpython"
+version = "3.1.40"
+description = "GitPython is a Python library used to interact with Git repositories"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "GitPython-3.1.40-py3-none-any.whl", hash = "sha256:cf14627d5a8049ffbf49915732e5eddbe8134c3bdb9d476e6182b676fc573f8a"},
+ {file = "GitPython-3.1.40.tar.gz", hash = "sha256:22b126e9ffb671fdd0c129796343a02bf67bf2994b35449ffc9321aa755e18a4"},
+]
+
+[package.dependencies]
+gitdb = ">=4.0.1,<5"
+
+[package.extras]
+test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock", "mypy", "pre-commit", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-sugar"]
+
+[[package]]
+name = "griffe"
+version = "0.38.1"
+description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "griffe-0.38.1-py3-none-any.whl", hash = "sha256:334c79d3b5964ade65c05dfcaf53518c576dedd387aaba5c9fd71212f34f1483"},
+ {file = "griffe-0.38.1.tar.gz", hash = "sha256:bd68d7da7f3d87bc57eb9962b250db123efd9bbcc06c11c1a91b6e583b2a9361"},
+]
+
+[package.dependencies]
+colorama = ">=0.4"
+
+[[package]]
+name = "humanfriendly"
+version = "10.0"
+description = "Human friendly output for text interfaces using Python"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+files = [
+ {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"},
+ {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"},
+]
+
+[package.dependencies]
+pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""}
+
+[[package]]
+name = "identify"
+version = "2.5.33"
+description = "File identification library for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "identify-2.5.33-py2.py3-none-any.whl", hash = "sha256:d40ce5fcd762817627670da8a7d8d8e65f24342d14539c59488dc603bf662e34"},
+ {file = "identify-2.5.33.tar.gz", hash = "sha256:161558f9fe4559e1557e1bff323e8631f6a0e4837f7497767c1782832f16b62d"},
+]
+
+[package.extras]
+license = ["ukkonen"]
+
+[[package]]
+name = "idna"
+version = "3.6"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"},
+ {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"},
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
+[[package]]
+name = "isort"
+version = "5.13.2"
+description = "A Python utility / library to sort Python imports."
+optional = false
+python-versions = ">=3.8.0"
+files = [
+ {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"},
+ {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"},
+]
+
+[package.dependencies]
+colorama = {version = ">=0.4.6", optional = true, markers = "extra == \"colors\""}
+
+[package.extras]
+colors = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "jinja2"
+version = "3.1.2"
+description = "A very fast and expressive template engine."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
+ {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "m3u8"
+version = "4.0.0"
+description = "Python m3u8 parser"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "m3u8-4.0.0-py3-none-any.whl", hash = "sha256:f4fbb2874c0c1632833a789025d9ce90ee43e3ad417150b1d61741bcfd747243"},
+ {file = "m3u8-4.0.0.tar.gz", hash = "sha256:32cb3300c702e802944427ecefbe5006e09f7fb5578334ce33f01e2e4bf05470"},
+]
+
+[package.dependencies]
+backports-datetime-fromisoformat = {version = "*", markers = "python_version < \"3.11\""}
+
+[[package]]
+name = "macholib"
+version = "1.16.3"
+description = "Mach-O header analysis and editing"
+optional = false
+python-versions = "*"
+files = [
+ {file = "macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c"},
+ {file = "macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30"},
+]
+
+[package.dependencies]
+altgraph = ">=0.17"
+
+[[package]]
+name = "markdown"
+version = "3.5.1"
+description = "Python implementation of John Gruber's Markdown."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "Markdown-3.5.1-py3-none-any.whl", hash = "sha256:5874b47d4ee3f0b14d764324d2c94c03ea66bee56f2d929da9f2508d65e722dc"},
+ {file = "Markdown-3.5.1.tar.gz", hash = "sha256:b65d7beb248dc22f2e8a31fb706d93798093c308dc1aba295aedeb9d41a813bd"},
+]
+
+[package.extras]
+docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
+testing = ["coverage", "pyyaml"]
+
+[[package]]
+name = "markdown-it-py"
+version = "3.0.0"
+description = "Python port of markdown-it. Markdown parsing, done right!"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
+ {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
+]
+
+[package.dependencies]
+mdurl = ">=0.1,<1.0"
+
+[package.extras]
+benchmarking = ["psutil", "pytest", "pytest-benchmark"]
+code-style = ["pre-commit (>=3.0,<4.0)"]
+compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
+linkify = ["linkify-it-py (>=1,<3)"]
+plugins = ["mdit-py-plugins"]
+profiling = ["gprof2dot"]
+rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
+testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
+
+[[package]]
+name = "markupsafe"
+version = "2.1.3"
+description = "Safely add untrusted strings to HTML/XML markup."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"},
+ {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"},
+]
+
+[[package]]
+name = "marshmallow"
+version = "3.20.1"
+description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "marshmallow-3.20.1-py3-none-any.whl", hash = "sha256:684939db93e80ad3561392f47be0230743131560a41c5110684c16e21ade0a5c"},
+ {file = "marshmallow-3.20.1.tar.gz", hash = "sha256:5d2371bbe42000f2b3fb5eaa065224df7d8f8597bc19a1bbfa5bfe7fba8da889"},
+]
+
+[package.dependencies]
+packaging = ">=17.0"
+
+[package.extras]
+dev = ["flake8 (==6.0.0)", "flake8-bugbear (==23.7.10)", "mypy (==1.4.1)", "pre-commit (>=2.4,<4.0)", "pytest", "pytz", "simplejson", "tox"]
+docs = ["alabaster (==0.7.13)", "autodocsumm (==0.2.11)", "sphinx (==7.0.1)", "sphinx-issues (==3.0.1)", "sphinx-version-warning (==1.1.2)"]
+lint = ["flake8 (==6.0.0)", "flake8-bugbear (==23.7.10)", "mypy (==1.4.1)", "pre-commit (>=2.4,<4.0)"]
+tests = ["pytest", "pytz", "simplejson"]
+
+[[package]]
+name = "mccabe"
+version = "0.7.0"
+description = "McCabe checker, plugin for flake8"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
+ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+description = "Markdown URL utilities"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
+ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
+]
+
+[[package]]
+name = "mergedeep"
+version = "1.3.4"
+description = "A deep merge function for 🐍."
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"},
+ {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"},
+]
+
+[[package]]
+name = "mkdocs"
+version = "1.5.3"
+description = "Project documentation with Markdown."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "mkdocs-1.5.3-py3-none-any.whl", hash = "sha256:3b3a78e736b31158d64dbb2f8ba29bd46a379d0c6e324c2246c3bc3d2189cfc1"},
+ {file = "mkdocs-1.5.3.tar.gz", hash = "sha256:eb7c99214dcb945313ba30426c2451b735992c73c2e10838f76d09e39ff4d0e2"},
+]
+
+[package.dependencies]
+click = ">=7.0"
+colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""}
+ghp-import = ">=1.0"
+jinja2 = ">=2.11.1"
+markdown = ">=3.2.1"
+markupsafe = ">=2.0.1"
+mergedeep = ">=1.3.4"
+packaging = ">=20.5"
+pathspec = ">=0.11.1"
+platformdirs = ">=2.2.0"
+pyyaml = ">=5.1"
+pyyaml-env-tag = ">=0.1"
+watchdog = ">=2.0"
+
+[package.extras]
+i18n = ["babel (>=2.9.0)"]
+min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.3)", "jinja2 (==2.11.1)", "markdown (==3.2.1)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "packaging (==20.5)", "pathspec (==0.11.1)", "platformdirs (==2.2.0)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "typing-extensions (==3.10)", "watchdog (==2.0)"]
+
+[[package]]
+name = "mkdocs-autorefs"
+version = "0.5.0"
+description = "Automatically link across pages in MkDocs."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "mkdocs_autorefs-0.5.0-py3-none-any.whl", hash = "sha256:7930fcb8ac1249f10e683967aeaddc0af49d90702af111a5e390e8b20b3d97ff"},
+ {file = "mkdocs_autorefs-0.5.0.tar.gz", hash = "sha256:9a5054a94c08d28855cfab967ada10ed5be76e2bfad642302a610b252c3274c0"},
+]
+
+[package.dependencies]
+Markdown = ">=3.3"
+mkdocs = ">=1.1"
+
+[[package]]
+name = "mkdocs-material"
+version = "9.5.2"
+description = "Documentation that simply works"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "mkdocs_material-9.5.2-py3-none-any.whl", hash = "sha256:6ed0fbf4682491766f0ec1acc955db6901c2fd424c7ab343964ef51b819741f5"},
+ {file = "mkdocs_material-9.5.2.tar.gz", hash = "sha256:ca8b9cd2b3be53e858e5a1a45ac9668bd78d95d77a30288bb5ebc1a31db6184c"},
+]
+
+[package.dependencies]
+babel = ">=2.10,<3.0"
+colorama = ">=0.4,<1.0"
+jinja2 = ">=3.0,<4.0"
+markdown = ">=3.2,<4.0"
+mkdocs = ">=1.5.3,<2.0"
+mkdocs-material-extensions = ">=1.3,<2.0"
+paginate = ">=0.5,<1.0"
+pygments = ">=2.16,<3.0"
+pymdown-extensions = ">=10.2,<11.0"
+regex = ">=2022.4"
+requests = ">=2.26,<3.0"
+
+[package.extras]
+git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2,<2.0)"]
+imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=9.4,<10.0)"]
+recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"]
+
+[[package]]
+name = "mkdocs-material-extensions"
+version = "1.3.1"
+description = "Extension pack for Python Markdown and MkDocs Material."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"},
+ {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"},
+]
+
+[[package]]
+name = "mkdocstrings"
+version = "0.24.0"
+description = "Automatic documentation from sources, for MkDocs."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "mkdocstrings-0.24.0-py3-none-any.whl", hash = "sha256:f4908560c10f587326d8f5165d1908817b2e280bbf707607f601c996366a2264"},
+ {file = "mkdocstrings-0.24.0.tar.gz", hash = "sha256:222b1165be41257b494a9d29b14135d2b7ca43f38161d5b10caae03b87bd4f7e"},
+]
+
+[package.dependencies]
+click = ">=7.0"
+Jinja2 = ">=2.11.1"
+Markdown = ">=3.3"
+MarkupSafe = ">=1.1"
+mkdocs = ">=1.4"
+mkdocs-autorefs = ">=0.3.1"
+mkdocstrings-python = {version = ">=0.5.2", optional = true, markers = "extra == \"python\""}
+platformdirs = ">=2.2.0"
+pymdown-extensions = ">=6.3"
+
+[package.extras]
+crystal = ["mkdocstrings-crystal (>=0.3.4)"]
+python = ["mkdocstrings-python (>=0.5.2)"]
+python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"]
+
+[[package]]
+name = "mkdocstrings-python"
+version = "1.7.5"
+description = "A Python handler for mkdocstrings."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "mkdocstrings_python-1.7.5-py3-none-any.whl", hash = "sha256:5f6246026353f0c0785135db70c3fe9a5d9318990fc7ceb11d62097b8ffdd704"},
+ {file = "mkdocstrings_python-1.7.5.tar.gz", hash = "sha256:c7d143728257dbf1aa550446555a554b760dcd40a763f077189d298502b800be"},
+]
+
+[package.dependencies]
+griffe = ">=0.37"
+mkdocstrings = ">=0.20"
+
+[[package]]
+name = "mutagen"
+version = "1.47.0"
+description = "read and write audio tags for many formats"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719"},
+ {file = "mutagen-1.47.0.tar.gz", hash = "sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99"},
+]
+
+[[package]]
+name = "mypy"
+version = "1.7.1"
+description = "Optional static typing for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "mypy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12cce78e329838d70a204293e7b29af9faa3ab14899aec397798a4b41be7f340"},
+ {file = "mypy-1.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1484b8fa2c10adf4474f016e09d7a159602f3239075c7bf9f1627f5acf40ad49"},
+ {file = "mypy-1.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31902408f4bf54108bbfb2e35369877c01c95adc6192958684473658c322c8a5"},
+ {file = "mypy-1.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f2c2521a8e4d6d769e3234350ba7b65ff5d527137cdcde13ff4d99114b0c8e7d"},
+ {file = "mypy-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:fcd2572dd4519e8a6642b733cd3a8cfc1ef94bafd0c1ceed9c94fe736cb65b6a"},
+ {file = "mypy-1.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b901927f16224d0d143b925ce9a4e6b3a758010673eeded9b748f250cf4e8f7"},
+ {file = "mypy-1.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f7f6985d05a4e3ce8255396df363046c28bea790e40617654e91ed580ca7c51"},
+ {file = "mypy-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:944bdc21ebd620eafefc090cdf83158393ec2b1391578359776c00de00e8907a"},
+ {file = "mypy-1.7.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9c7ac372232c928fff0645d85f273a726970c014749b924ce5710d7d89763a28"},
+ {file = "mypy-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:f6efc9bd72258f89a3816e3a98c09d36f079c223aa345c659622f056b760ab42"},
+ {file = "mypy-1.7.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6dbdec441c60699288adf051f51a5d512b0d818526d1dcfff5a41f8cd8b4aaf1"},
+ {file = "mypy-1.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fc3d14ee80cd22367caaaf6e014494415bf440980a3045bf5045b525680ac33"},
+ {file = "mypy-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c6e4464ed5f01dc44dc9821caf67b60a4e5c3b04278286a85c067010653a0eb"},
+ {file = "mypy-1.7.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d9b338c19fa2412f76e17525c1b4f2c687a55b156320acb588df79f2e6fa9fea"},
+ {file = "mypy-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:204e0d6de5fd2317394a4eff62065614c4892d5a4d1a7ee55b765d7a3d9e3f82"},
+ {file = "mypy-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:84860e06ba363d9c0eeabd45ac0fde4b903ad7aa4f93cd8b648385a888e23200"},
+ {file = "mypy-1.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8c5091ebd294f7628eb25ea554852a52058ac81472c921150e3a61cdd68f75a7"},
+ {file = "mypy-1.7.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40716d1f821b89838589e5b3106ebbc23636ffdef5abc31f7cd0266db936067e"},
+ {file = "mypy-1.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cf3f0c5ac72139797953bd50bc6c95ac13075e62dbfcc923571180bebb662e9"},
+ {file = "mypy-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:78e25b2fd6cbb55ddfb8058417df193f0129cad5f4ee75d1502248e588d9e0d7"},
+ {file = "mypy-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:75c4d2a6effd015786c87774e04331b6da863fc3fc4e8adfc3b40aa55ab516fe"},
+ {file = "mypy-1.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2643d145af5292ee956aa0a83c2ce1038a3bdb26e033dadeb2f7066fb0c9abce"},
+ {file = "mypy-1.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75aa828610b67462ffe3057d4d8a4112105ed211596b750b53cbfe182f44777a"},
+ {file = "mypy-1.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ee5d62d28b854eb61889cde4e1dbc10fbaa5560cb39780c3995f6737f7e82120"},
+ {file = "mypy-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:72cf32ce7dd3562373f78bd751f73c96cfb441de147cc2448a92c1a308bd0ca6"},
+ {file = "mypy-1.7.1-py3-none-any.whl", hash = "sha256:f7c5d642db47376a0cc130f0de6d055056e010debdaf0707cd2b0fc7e7ef30ea"},
+ {file = "mypy-1.7.1.tar.gz", hash = "sha256:fcb6d9afb1b6208b4c712af0dafdc650f518836065df0d4fb1d800f5d6773db2"},
+]
+
+[package.dependencies]
+mypy-extensions = ">=1.0.0"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typing-extensions = ">=4.1.0"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+install-types = ["pip"]
+mypyc = ["setuptools (>=50)"]
+reports = ["lxml"]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
+ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.8.0"
+description = "Node.js virtual environment builder"
+optional = false
+python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
+files = [
+ {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"},
+ {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"},
+]
+
+[package.dependencies]
+setuptools = "*"
+
+[[package]]
+name = "packaging"
+version = "23.2"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"},
+ {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
+]
+
+[[package]]
+name = "paginate"
+version = "0.5.6"
+description = "Divides large result sets into pages for easier browsing"
+optional = false
+python-versions = "*"
+files = [
+ {file = "paginate-0.5.6.tar.gz", hash = "sha256:5e6007b6a9398177a7e1648d04fdd9f8c9766a1a945bceac82f1929e8c78af2d"},
+]
+
+[[package]]
+name = "pathspec"
+version = "0.12.1"
+description = "Utility library for gitignore style pattern matching of file paths."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
+ {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
+]
+
+[[package]]
+name = "pathvalidate"
+version = "3.2.0"
+description = "pathvalidate is a Python library to sanitize/validate a string such as filenames/file-paths/etc."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pathvalidate-3.2.0-py3-none-any.whl", hash = "sha256:cc593caa6299b22b37f228148257997e2fa850eea2daf7e4cc9205cef6908dee"},
+ {file = "pathvalidate-3.2.0.tar.gz", hash = "sha256:5e8378cf6712bff67fbe7a8307d99fa8c1a0cb28aa477056f8fc374f0dff24ad"},
+]
+
+[package.extras]
+docs = ["Sphinx (>=2.4)", "sphinx-rtd-theme (>=1.2.2)", "urllib3 (<2)"]
+test = ["Faker (>=1.0.8)", "allpairspy (>=2)", "click (>=6.2)", "pytest (>=6.0.1)", "pytest-discord (>=0.1.4)", "pytest-md-report (>=0.4.1)"]
+
+[[package]]
+name = "pbr"
+version = "6.0.0"
+description = "Python Build Reasonableness"
+optional = false
+python-versions = ">=2.6"
+files = [
+ {file = "pbr-6.0.0-py2.py3-none-any.whl", hash = "sha256:4a7317d5e3b17a3dccb6a8cfe67dab65b20551404c52c8ed41279fa4f0cb4cda"},
+ {file = "pbr-6.0.0.tar.gz", hash = "sha256:d1377122a5a00e2f940ee482999518efe16d745d423a670c27773dfbc3c9a7d9"},
+]
+
+[[package]]
+name = "pefile"
+version = "2023.2.7"
+description = "Python PE parsing module"
+optional = false
+python-versions = ">=3.6.0"
+files = [
+ {file = "pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6"},
+ {file = "pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc"},
+]
+
+[[package]]
+name = "pillow"
+version = "10.1.0"
+description = "Python Imaging Library (Fork)"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "Pillow-10.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1ab05f3db77e98f93964697c8efc49c7954b08dd61cff526b7f2531a22410106"},
+ {file = "Pillow-10.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6932a7652464746fcb484f7fc3618e6503d2066d853f68a4bd97193a3996e273"},
+ {file = "Pillow-10.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f63b5a68daedc54c7c3464508d8c12075e56dcfbd42f8c1bf40169061ae666"},
+ {file = "Pillow-10.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0949b55eb607898e28eaccb525ab104b2d86542a85c74baf3a6dc24002edec2"},
+ {file = "Pillow-10.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ae88931f93214777c7a3aa0a8f92a683f83ecde27f65a45f95f22d289a69e593"},
+ {file = "Pillow-10.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b0eb01ca85b2361b09480784a7931fc648ed8b7836f01fb9241141b968feb1db"},
+ {file = "Pillow-10.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d27b5997bdd2eb9fb199982bb7eb6164db0426904020dc38c10203187ae2ff2f"},
+ {file = "Pillow-10.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7df5608bc38bd37ef585ae9c38c9cd46d7c81498f086915b0f97255ea60c2818"},
+ {file = "Pillow-10.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:41f67248d92a5e0a2076d3517d8d4b1e41a97e2df10eb8f93106c89107f38b57"},
+ {file = "Pillow-10.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1fb29c07478e6c06a46b867e43b0bcdb241b44cc52be9bc25ce5944eed4648e7"},
+ {file = "Pillow-10.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2cdc65a46e74514ce742c2013cd4a2d12e8553e3a2563c64879f7c7e4d28bce7"},
+ {file = "Pillow-10.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50d08cd0a2ecd2a8657bd3d82c71efd5a58edb04d9308185d66c3a5a5bed9610"},
+ {file = "Pillow-10.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:062a1610e3bc258bff2328ec43f34244fcec972ee0717200cb1425214fe5b839"},
+ {file = "Pillow-10.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:61f1a9d247317fa08a308daaa8ee7b3f760ab1809ca2da14ecc88ae4257d6172"},
+ {file = "Pillow-10.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a646e48de237d860c36e0db37ecaecaa3619e6f3e9d5319e527ccbc8151df061"},
+ {file = "Pillow-10.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:47e5bf85b80abc03be7455c95b6d6e4896a62f6541c1f2ce77a7d2bb832af262"},
+ {file = "Pillow-10.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a92386125e9ee90381c3369f57a2a50fa9e6aa8b1cf1d9c4b200d41a7dd8e992"},
+ {file = "Pillow-10.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:0f7c276c05a9767e877a0b4c5050c8bee6a6d960d7f0c11ebda6b99746068c2a"},
+ {file = "Pillow-10.1.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:a89b8312d51715b510a4fe9fc13686283f376cfd5abca8cd1c65e4c76e21081b"},
+ {file = "Pillow-10.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:00f438bb841382b15d7deb9a05cc946ee0f2c352653c7aa659e75e592f6fa17d"},
+ {file = "Pillow-10.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d929a19f5469b3f4df33a3df2983db070ebb2088a1e145e18facbc28cae5b27"},
+ {file = "Pillow-10.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a92109192b360634a4489c0c756364c0c3a2992906752165ecb50544c251312"},
+ {file = "Pillow-10.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:0248f86b3ea061e67817c47ecbe82c23f9dd5d5226200eb9090b3873d3ca32de"},
+ {file = "Pillow-10.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:9882a7451c680c12f232a422730f986a1fcd808da0fd428f08b671237237d651"},
+ {file = "Pillow-10.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1c3ac5423c8c1da5928aa12c6e258921956757d976405e9467c5f39d1d577a4b"},
+ {file = "Pillow-10.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:806abdd8249ba3953c33742506fe414880bad78ac25cc9a9b1c6ae97bedd573f"},
+ {file = "Pillow-10.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:eaed6977fa73408b7b8a24e8b14e59e1668cfc0f4c40193ea7ced8e210adf996"},
+ {file = "Pillow-10.1.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:fe1e26e1ffc38be097f0ba1d0d07fcade2bcfd1d023cda5b29935ae8052bd793"},
+ {file = "Pillow-10.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7a7e3daa202beb61821c06d2517428e8e7c1aab08943e92ec9e5755c2fc9ba5e"},
+ {file = "Pillow-10.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24fadc71218ad2b8ffe437b54876c9382b4a29e030a05a9879f615091f42ffc2"},
+ {file = "Pillow-10.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1d323703cfdac2036af05191b969b910d8f115cf53093125e4058f62012c9a"},
+ {file = "Pillow-10.1.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:912e3812a1dbbc834da2b32299b124b5ddcb664ed354916fd1ed6f193f0e2d01"},
+ {file = "Pillow-10.1.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:7dbaa3c7de82ef37e7708521be41db5565004258ca76945ad74a8e998c30af8d"},
+ {file = "Pillow-10.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9d7bc666bd8c5a4225e7ac71f2f9d12466ec555e89092728ea0f5c0c2422ea80"},
+ {file = "Pillow-10.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:baada14941c83079bf84c037e2d8b7506ce201e92e3d2fa0d1303507a8538212"},
+ {file = "Pillow-10.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:2ef6721c97894a7aa77723740a09547197533146fba8355e86d6d9a4a1056b14"},
+ {file = "Pillow-10.1.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0a026c188be3b443916179f5d04548092e253beb0c3e2ee0a4e2cdad72f66099"},
+ {file = "Pillow-10.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:04f6f6149f266a100374ca3cc368b67fb27c4af9f1cc8cb6306d849dcdf12616"},
+ {file = "Pillow-10.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb40c011447712d2e19cc261c82655f75f32cb724788df315ed992a4d65696bb"},
+ {file = "Pillow-10.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a8413794b4ad9719346cd9306118450b7b00d9a15846451549314a58ac42219"},
+ {file = "Pillow-10.1.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c9aeea7b63edb7884b031a35305629a7593272b54f429a9869a4f63a1bf04c34"},
+ {file = "Pillow-10.1.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b4005fee46ed9be0b8fb42be0c20e79411533d1fd58edabebc0dd24626882cfd"},
+ {file = "Pillow-10.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4d0152565c6aa6ebbfb1e5d8624140a440f2b99bf7afaafbdbf6430426497f28"},
+ {file = "Pillow-10.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d921bc90b1defa55c9917ca6b6b71430e4286fc9e44c55ead78ca1a9f9eba5f2"},
+ {file = "Pillow-10.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfe96560c6ce2f4c07d6647af2d0f3c54cc33289894ebd88cfbb3bcd5391e256"},
+ {file = "Pillow-10.1.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:937bdc5a7f5343d1c97dc98149a0be7eb9704e937fe3dc7140e229ae4fc572a7"},
+ {file = "Pillow-10.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1c25762197144e211efb5f4e8ad656f36c8d214d390585d1d21281f46d556ba"},
+ {file = "Pillow-10.1.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:afc8eef765d948543a4775f00b7b8c079b3321d6b675dde0d02afa2ee23000b4"},
+ {file = "Pillow-10.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:883f216eac8712b83a63f41b76ddfb7b2afab1b74abbb413c5df6680f071a6b9"},
+ {file = "Pillow-10.1.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b920e4d028f6442bea9a75b7491c063f0b9a3972520731ed26c83e254302eb1e"},
+ {file = "Pillow-10.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c41d960babf951e01a49c9746f92c5a7e0d939d1652d7ba30f6b3090f27e412"},
+ {file = "Pillow-10.1.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1fafabe50a6977ac70dfe829b2d5735fd54e190ab55259ec8aea4aaea412fa0b"},
+ {file = "Pillow-10.1.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3b834f4b16173e5b92ab6566f0473bfb09f939ba14b23b8da1f54fa63e4b623f"},
+ {file = "Pillow-10.1.0.tar.gz", hash = "sha256:e6bf8de6c36ed96c86ea3b6e1d5273c53f46ef518a062464cd7ef5dd2cf92e38"},
+]
+
+[package.extras]
+docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"]
+tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
+
+[[package]]
+name = "platformdirs"
+version = "4.1.0"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"},
+ {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"},
+]
+
+[package.extras]
+docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"]
+
+[[package]]
+name = "pluggy"
+version = "1.3.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"},
+ {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "pre-commit"
+version = "3.6.0"
+description = "A framework for managing and maintaining multi-language pre-commit hooks."
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "pre_commit-3.6.0-py2.py3-none-any.whl", hash = "sha256:c255039ef399049a5544b6ce13d135caba8f2c28c3b4033277a788f434308376"},
+ {file = "pre_commit-3.6.0.tar.gz", hash = "sha256:d30bad9abf165f7785c15a21a1f46da7d0677cb00ee7ff4c579fd38922efe15d"},
+]
+
+[package.dependencies]
+cfgv = ">=2.0.0"
+identify = ">=1.0.0"
+nodeenv = ">=0.11.1"
+pyyaml = ">=5.1"
+virtualenv = ">=20.10.0"
+
+[[package]]
+name = "pycryptodome"
+version = "3.19.0"
+description = "Cryptographic library for Python"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+files = [
+ {file = "pycryptodome-3.19.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3006c44c4946583b6de24fe0632091c2653d6256b99a02a3db71ca06472ea1e4"},
+ {file = "pycryptodome-3.19.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:7c760c8a0479a4042111a8dd2f067d3ae4573da286c53f13cf6f5c53a5c1f631"},
+ {file = "pycryptodome-3.19.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:08ce3558af5106c632baf6d331d261f02367a6bc3733086ae43c0f988fe042db"},
+ {file = "pycryptodome-3.19.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45430dfaf1f421cf462c0dd824984378bef32b22669f2635cb809357dbaab405"},
+ {file = "pycryptodome-3.19.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:a9bcd5f3794879e91970f2bbd7d899780541d3ff439d8f2112441769c9f2ccea"},
+ {file = "pycryptodome-3.19.0-cp27-cp27m-win32.whl", hash = "sha256:190c53f51e988dceb60472baddce3f289fa52b0ec38fbe5fd20dd1d0f795c551"},
+ {file = "pycryptodome-3.19.0-cp27-cp27m-win_amd64.whl", hash = "sha256:22e0ae7c3a7f87dcdcf302db06ab76f20e83f09a6993c160b248d58274473bfa"},
+ {file = "pycryptodome-3.19.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:7822f36d683f9ad7bc2145b2c2045014afdbbd1d9922a6d4ce1cbd6add79a01e"},
+ {file = "pycryptodome-3.19.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:05e33267394aad6db6595c0ce9d427fe21552f5425e116a925455e099fdf759a"},
+ {file = "pycryptodome-3.19.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:829b813b8ee00d9c8aba417621b94bc0b5efd18c928923802ad5ba4cf1ec709c"},
+ {file = "pycryptodome-3.19.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:fc7a79590e2b5d08530175823a242de6790abc73638cc6dc9d2684e7be2f5e49"},
+ {file = "pycryptodome-3.19.0-cp35-abi3-macosx_10_9_universal2.whl", hash = "sha256:542f99d5026ac5f0ef391ba0602f3d11beef8e65aae135fa5b762f5ebd9d3bfb"},
+ {file = "pycryptodome-3.19.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:61bb3ccbf4bf32ad9af32da8badc24e888ae5231c617947e0f5401077f8b091f"},
+ {file = "pycryptodome-3.19.0-cp35-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d49a6c715d8cceffedabb6adb7e0cbf41ae1a2ff4adaeec9432074a80627dea1"},
+ {file = "pycryptodome-3.19.0-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e249a784cc98a29c77cea9df54284a44b40cafbfae57636dd2f8775b48af2434"},
+ {file = "pycryptodome-3.19.0-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d033947e7fd3e2ba9a031cb2d267251620964705a013c5a461fa5233cc025270"},
+ {file = "pycryptodome-3.19.0-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:84c3e4fffad0c4988aef0d5591be3cad4e10aa7db264c65fadbc633318d20bde"},
+ {file = "pycryptodome-3.19.0-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:139ae2c6161b9dd5d829c9645d781509a810ef50ea8b657e2257c25ca20efe33"},
+ {file = "pycryptodome-3.19.0-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5b1986c761258a5b4332a7f94a83f631c1ffca8747d75ab8395bf2e1b93283d9"},
+ {file = "pycryptodome-3.19.0-cp35-abi3-win32.whl", hash = "sha256:536f676963662603f1f2e6ab01080c54d8cd20f34ec333dcb195306fa7826997"},
+ {file = "pycryptodome-3.19.0-cp35-abi3-win_amd64.whl", hash = "sha256:04dd31d3b33a6b22ac4d432b3274588917dcf850cc0c51c84eca1d8ed6933810"},
+ {file = "pycryptodome-3.19.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:8999316e57abcbd8085c91bc0ef75292c8618f41ca6d2b6132250a863a77d1e7"},
+ {file = "pycryptodome-3.19.0-pp27-pypy_73-win32.whl", hash = "sha256:a0ab84755f4539db086db9ba9e9f3868d2e3610a3948cbd2a55e332ad83b01b0"},
+ {file = "pycryptodome-3.19.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0101f647d11a1aae5a8ce4f5fad6644ae1b22bb65d05accc7d322943c69a74a6"},
+ {file = "pycryptodome-3.19.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c1601e04d32087591d78e0b81e1e520e57a92796089864b20e5f18c9564b3fa"},
+ {file = "pycryptodome-3.19.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:506c686a1eee6c00df70010be3b8e9e78f406af4f21b23162bbb6e9bdf5427bc"},
+ {file = "pycryptodome-3.19.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7919ccd096584b911f2a303c593280869ce1af9bf5d36214511f5e5a1bed8c34"},
+ {file = "pycryptodome-3.19.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:560591c0777f74a5da86718f70dfc8d781734cf559773b64072bbdda44b3fc3e"},
+ {file = "pycryptodome-3.19.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cc2f2ae451a676def1a73c1ae9120cd31af25db3f381893d45f75e77be2400"},
+ {file = "pycryptodome-3.19.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17940dcf274fcae4a54ec6117a9ecfe52907ed5e2e438fe712fe7ca502672ed5"},
+ {file = "pycryptodome-3.19.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d04f5f623a280fbd0ab1c1d8ecbd753193ab7154f09b6161b0f857a1a676c15f"},
+ {file = "pycryptodome-3.19.0.tar.gz", hash = "sha256:bc35d463222cdb4dbebd35e0784155c81e161b9284e567e7e933d722e533331e"},
+]
+
+[[package]]
+name = "pydocstyle"
+version = "6.3.0"
+description = "Python docstring style checker"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"},
+ {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"},
+]
+
+[package.dependencies]
+snowballstemmer = ">=2.2.0"
+
+[package.extras]
+toml = ["tomli (>=1.2.3)"]
+
+[[package]]
+name = "pygments"
+version = "2.17.2"
+description = "Pygments is a syntax highlighting package written in Python."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"},
+ {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"},
+]
+
+[package.extras]
+plugins = ["importlib-metadata"]
+windows-terminal = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "pyinstaller"
+version = "6.3.0"
+description = "PyInstaller bundles a Python application and all its dependencies into a single package."
+optional = false
+python-versions = "<3.13,>=3.8"
+files = [
+ {file = "pyinstaller-6.3.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:75a6f2a6f835a2e6e0899d10e60c10caf5defd25aced38b1dd48fbbabc89de07"},
+ {file = "pyinstaller-6.3.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:de25beb176f73a944758553caacec46cc665bf3910ad8a174706d79cf6e95340"},
+ {file = "pyinstaller-6.3.0-py3-none-manylinux2014_i686.whl", hash = "sha256:e436fcc0ea87c3f132baac916d508c24c84a8f6d8a06c3154fbc753f169b76c7"},
+ {file = "pyinstaller-6.3.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:b721d793a33b6d9946c7dd95d3ea7589c0424b51cf1b9fe580f03c544f1336b2"},
+ {file = "pyinstaller-6.3.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:96c37a1ee5b2fd5bb25c098ef510661d6d17b6515d0b86d8fc93727dd2475ba3"},
+ {file = "pyinstaller-6.3.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:abe91106a3bbccc3f3a27af4325676ecdb6f46cb842ac663625002a870fc503b"},
+ {file = "pyinstaller-6.3.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:41c937fe8f07ae02009b3b5a96ac3eb0800a4f8a97af142d4100060fe2135bb9"},
+ {file = "pyinstaller-6.3.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:886b3b995b674905a20ad5b720b47cc395897d7b391117831027a4c8c5d67a58"},
+ {file = "pyinstaller-6.3.0-py3-none-win32.whl", hash = "sha256:0597fb04337695e5cc5250253e0655530bf14f264b7a5b7d219cc65f6889c4bd"},
+ {file = "pyinstaller-6.3.0-py3-none-win_amd64.whl", hash = "sha256:156b32ba943e0090bcc68e40ae1cb68fd92b7f1ab6fe0bdf8faf3d3cfc4e12dd"},
+ {file = "pyinstaller-6.3.0-py3-none-win_arm64.whl", hash = "sha256:1eadbd1fae84e2e6c678d8b4ed6a232ec5c8fe3a839aea5a3071c4c0282f98cc"},
+ {file = "pyinstaller-6.3.0.tar.gz", hash = "sha256:914d4c96cc99472e37ac552fdd82fbbe09e67bb592d0717fcffaa99ea74273df"},
+]
+
+[package.dependencies]
+altgraph = "*"
+macholib = {version = ">=1.8", markers = "sys_platform == \"darwin\""}
+packaging = ">=22.0"
+pefile = {version = ">=2022.5.30", markers = "sys_platform == \"win32\""}
+pyinstaller-hooks-contrib = ">=2021.4"
+pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""}
+setuptools = ">=42.0.0"
+
+[package.extras]
+completion = ["argcomplete"]
+hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"]
+
+[[package]]
+name = "pyinstaller-hooks-contrib"
+version = "2023.10"
+description = "Community maintained hooks for PyInstaller"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pyinstaller-hooks-contrib-2023.10.tar.gz", hash = "sha256:4b4a998036abb713774cb26534ca06b7e6e09e4c628196017a10deb11a48747f"},
+ {file = "pyinstaller_hooks_contrib-2023.10-py2.py3-none-any.whl", hash = "sha256:6dc1786a8f452941245d5bb85893e2a33632ebdcbc4c23eea41f2ee08281b0c0"},
+]
+
+[[package]]
+name = "pylint"
+version = "3.0.3"
+description = "python code static checker"
+optional = false
+python-versions = ">=3.8.0"
+files = [
+ {file = "pylint-3.0.3-py3-none-any.whl", hash = "sha256:7a1585285aefc5165db81083c3e06363a27448f6b467b3b0f30dbd0ac1f73810"},
+ {file = "pylint-3.0.3.tar.gz", hash = "sha256:58c2398b0301e049609a8429789ec6edf3aabe9b6c5fec916acd18639c16de8b"},
+]
+
+[package.dependencies]
+astroid = ">=3.0.1,<=3.1.0-dev0"
+colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
+dill = [
+ {version = ">=0.2", markers = "python_version < \"3.11\""},
+ {version = ">=0.3.6", markers = "python_version >= \"3.11\""},
+]
+isort = ">=4.2.5,<5.13.0 || >5.13.0,<6"
+mccabe = ">=0.6,<0.8"
+platformdirs = ">=2.2.0"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+tomlkit = ">=0.10.1"
+
+[package.extras]
+spelling = ["pyenchant (>=3.2,<4.0)"]
+testutils = ["gitpython (>3)"]
+
+[[package]]
+name = "pymdown-extensions"
+version = "10.5"
+description = "Extension pack for Python Markdown."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pymdown_extensions-10.5-py3-none-any.whl", hash = "sha256:1f0ca8bb5beff091315f793ee17683bc1390731f6ac4c5eb01e27464b80fe879"},
+ {file = "pymdown_extensions-10.5.tar.gz", hash = "sha256:1b60f1e462adbec5a1ed79dac91f666c9c0d241fa294de1989f29d20096cfd0b"},
+]
+
+[package.dependencies]
+markdown = ">=3.5"
+pyyaml = "*"
+
+[package.extras]
+extra = ["pygments (>=2.12)"]
+
+[[package]]
+name = "pyproject-api"
+version = "1.6.1"
+description = "API to interact with the python pyproject.toml based projects"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pyproject_api-1.6.1-py3-none-any.whl", hash = "sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675"},
+ {file = "pyproject_api-1.6.1.tar.gz", hash = "sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538"},
+]
+
+[package.dependencies]
+packaging = ">=23.1"
+tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
+
+[package.extras]
+docs = ["furo (>=2023.8.19)", "sphinx (<7.2)", "sphinx-autodoc-typehints (>=1.24)"]
+testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "setuptools (>=68.1.2)", "wheel (>=0.41.2)"]
+
+[[package]]
+name = "pyqtdarktheme"
+version = "2.1.0"
+description = "Flat dark theme for PySide and PyQt."
+optional = true
+python-versions = ">=3.7,<3.12"
+files = [
+ {file = "pyqtdarktheme-2.1.0-py3-none-any.whl", hash = "sha256:8739d99502230fbaca42551ea033c9ae31c81c4ebfec2f1ffde38f32a18bea7a"},
+ {file = "pyqtdarktheme-2.1.0.tar.gz", hash = "sha256:5f8274ddfa3a5481ed9743cdb0f9debfeb7ff695b3a0d202a8104361d17dadb8"},
+]
+
+[package.dependencies]
+darkdetect = ">=0.7.1,<0.8.0"
+
+[[package]]
+name = "pyreadline3"
+version = "3.4.1"
+description = "A python implementation of GNU readline."
+optional = false
+python-versions = "*"
+files = [
+ {file = "pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"},
+ {file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"},
+]
+
+[[package]]
+name = "pyside6"
+version = "6.6.1"
+description = "Python bindings for the Qt cross-platform application and UI framework"
+optional = true
+python-versions = "<3.13,>=3.8"
+files = [
+ {file = "PySide6-6.6.1-cp38-abi3-macosx_11_0_universal2.whl", hash = "sha256:3c348948fe3957b18164c9c7b8942fe065bdb39648b326f212bc114326679fa9"},
+ {file = "PySide6-6.6.1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a67587c088cb80e90d4ce3023b02466ea858c93a6dc9c4e062b13314e03d464"},
+ {file = "PySide6-6.6.1-cp38-abi3-manylinux_2_31_aarch64.whl", hash = "sha256:ed3822150f0d7a06b68bf4ceebe287515b5e8309bb256e9b49ae405afd062b18"},
+ {file = "PySide6-6.6.1-cp38-abi3-win_amd64.whl", hash = "sha256:3593d605175e83e6952cf3b428ecc9c146af97effb36de921ecf3da2752de082"},
+]
+
+[package.dependencies]
+PySide6-Addons = "6.6.1"
+PySide6-Essentials = "6.6.1"
+shiboken6 = "6.6.1"
+
+[[package]]
+name = "pyside6-addons"
+version = "6.6.1"
+description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
+optional = true
+python-versions = "<3.13,>=3.8"
+files = [
+ {file = "PySide6_Addons-6.6.1-cp38-abi3-macosx_11_0_universal2.whl", hash = "sha256:7cb7af1b050c40f7ac891b0e61c758c1923863173932f5b92dc47bdfb4158b42"},
+ {file = "PySide6_Addons-6.6.1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0982da4033319667f9df5ed6fa8eff300a88216aec103a1fff6751a172b19a0"},
+ {file = "PySide6_Addons-6.6.1-cp38-abi3-manylinux_2_31_aarch64.whl", hash = "sha256:5a63a8a943724ce5acd2df72e5ab04982b6906963278cbabb216656b9a26ee18"},
+ {file = "PySide6_Addons-6.6.1-cp38-abi3-win_amd64.whl", hash = "sha256:a223575c81e9a13173136c044c3447e25f6d656b462b4d71fc3c6bd9c935a709"},
+]
+
+[package.dependencies]
+PySide6-Essentials = "6.6.1"
+shiboken6 = "6.6.1"
+
+[[package]]
+name = "pyside6-essentials"
+version = "6.6.1"
+description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
+optional = true
+python-versions = "<3.13,>=3.8"
+files = [
+ {file = "PySide6_Essentials-6.6.1-cp38-abi3-macosx_11_0_universal2.whl", hash = "sha256:0c8917b15236956957178a8c9854641b12b11dad79ba0caf26147119164c30cf"},
+ {file = "PySide6_Essentials-6.6.1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c7185616083eab6f42eaed598d97d49fac4f60ae2e7415194140d54f58c2b42c"},
+ {file = "PySide6_Essentials-6.6.1-cp38-abi3-manylinux_2_31_aarch64.whl", hash = "sha256:a383c3d60298392cfb621ec1a0cf24b4799321e6c5bbafc021d4cc8076ea1315"},
+ {file = "PySide6_Essentials-6.6.1-cp38-abi3-win_amd64.whl", hash = "sha256:13da926e9e9ee3e26e3f66883a9d5e43726ddee70cdabddca02a07aa1ccf9484"},
+]
+
+[package.dependencies]
+shiboken6 = "6.6.1"
+
+[[package]]
+name = "pytest"
+version = "7.4.3"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"},
+ {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<2.0"
+tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
+
+[package.extras]
+testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "python-dateutil"
+version = "2.8.2"
+description = "Extensions to the standard Python datetime module"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
+ {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "pyupgrade"
+version = "3.15.0"
+description = "A tool to automatically upgrade syntax for newer versions."
+optional = false
+python-versions = ">=3.8.1"
+files = [
+ {file = "pyupgrade-3.15.0-py2.py3-none-any.whl", hash = "sha256:8dc8ebfaed43566e2c65994162795017c7db11f531558a74bc8aa077907bc305"},
+ {file = "pyupgrade-3.15.0.tar.gz", hash = "sha256:a7fde381060d7c224f55aef7a30fae5ac93bbc428367d27e70a603bc2acd4f00"},
+]
+
+[package.dependencies]
+tokenize-rt = ">=5.2.0"
+
+[[package]]
+name = "pywin32-ctypes"
+version = "0.2.2"
+description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"},
+ {file = "pywin32_ctypes-0.2.2-py3-none-any.whl", hash = "sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7"},
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.1"
+description = "YAML parser and emitter for Python"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
+ {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
+ {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
+ {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
+ {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
+ {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
+ {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
+ {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
+ {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
+ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
+ {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
+ {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
+ {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
+ {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
+ {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
+ {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
+ {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
+ {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
+ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
+]
+
+[[package]]
+name = "pyyaml-env-tag"
+version = "0.1"
+description = "A custom YAML tag for referencing environment variables in YAML files. "
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"},
+ {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"},
+]
+
+[package.dependencies]
+pyyaml = "*"
+
+[[package]]
+name = "regex"
+version = "2023.10.3"
+description = "Alternative regular expression module, to replace re."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "regex-2023.10.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4c34d4f73ea738223a094d8e0ffd6d2c1a1b4c175da34d6b0de3d8d69bee6bcc"},
+ {file = "regex-2023.10.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8f4e49fc3ce020f65411432183e6775f24e02dff617281094ba6ab079ef0915"},
+ {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cd1bccf99d3ef1ab6ba835308ad85be040e6a11b0977ef7ea8c8005f01a3c29"},
+ {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:81dce2ddc9f6e8f543d94b05d56e70d03a0774d32f6cca53e978dc01e4fc75b8"},
+ {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c6b4d23c04831e3ab61717a707a5d763b300213db49ca680edf8bf13ab5d91b"},
+ {file = "regex-2023.10.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c15ad0aee158a15e17e0495e1e18741573d04eb6da06d8b84af726cfc1ed02ee"},
+ {file = "regex-2023.10.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6239d4e2e0b52c8bd38c51b760cd870069f0bdf99700a62cd509d7a031749a55"},
+ {file = "regex-2023.10.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4a8bf76e3182797c6b1afa5b822d1d5802ff30284abe4599e1247be4fd6b03be"},
+ {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9c727bbcf0065cbb20f39d2b4f932f8fa1631c3e01fcedc979bd4f51fe051c5"},
+ {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3ccf2716add72f80714b9a63899b67fa711b654be3fcdd34fa391d2d274ce767"},
+ {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:107ac60d1bfdc3edb53be75e2a52aff7481b92817cfdddd9b4519ccf0e54a6ff"},
+ {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:00ba3c9818e33f1fa974693fb55d24cdc8ebafcb2e4207680669d8f8d7cca79a"},
+ {file = "regex-2023.10.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f0a47efb1dbef13af9c9a54a94a0b814902e547b7f21acb29434504d18f36e3a"},
+ {file = "regex-2023.10.3-cp310-cp310-win32.whl", hash = "sha256:36362386b813fa6c9146da6149a001b7bd063dabc4d49522a1f7aa65b725c7ec"},
+ {file = "regex-2023.10.3-cp310-cp310-win_amd64.whl", hash = "sha256:c65a3b5330b54103e7d21cac3f6bf3900d46f6d50138d73343d9e5b2900b2353"},
+ {file = "regex-2023.10.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90a79bce019c442604662d17bf69df99090e24cdc6ad95b18b6725c2988a490e"},
+ {file = "regex-2023.10.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c7964c2183c3e6cce3f497e3a9f49d182e969f2dc3aeeadfa18945ff7bdd7051"},
+ {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ef80829117a8061f974b2fda8ec799717242353bff55f8a29411794d635d964"},
+ {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5addc9d0209a9afca5fc070f93b726bf7003bd63a427f65ef797a931782e7edc"},
+ {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c148bec483cc4b421562b4bcedb8e28a3b84fcc8f0aa4418e10898f3c2c0eb9b"},
+ {file = "regex-2023.10.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d1f21af4c1539051049796a0f50aa342f9a27cde57318f2fc41ed50b0dbc4ac"},
+ {file = "regex-2023.10.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0b9ac09853b2a3e0d0082104036579809679e7715671cfbf89d83c1cb2a30f58"},
+ {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ebedc192abbc7fd13c5ee800e83a6df252bec691eb2c4bedc9f8b2e2903f5e2a"},
+ {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d8a993c0a0ffd5f2d3bda23d0cd75e7086736f8f8268de8a82fbc4bd0ac6791e"},
+ {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:be6b7b8d42d3090b6c80793524fa66c57ad7ee3fe9722b258aec6d0672543fd0"},
+ {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4023e2efc35a30e66e938de5aef42b520c20e7eda7bb5fb12c35e5d09a4c43f6"},
+ {file = "regex-2023.10.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d47840dc05e0ba04fe2e26f15126de7c755496d5a8aae4a08bda4dd8d646c54"},
+ {file = "regex-2023.10.3-cp311-cp311-win32.whl", hash = "sha256:9145f092b5d1977ec8c0ab46e7b3381b2fd069957b9862a43bd383e5c01d18c2"},
+ {file = "regex-2023.10.3-cp311-cp311-win_amd64.whl", hash = "sha256:b6104f9a46bd8743e4f738afef69b153c4b8b592d35ae46db07fc28ae3d5fb7c"},
+ {file = "regex-2023.10.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:bff507ae210371d4b1fe316d03433ac099f184d570a1a611e541923f78f05037"},
+ {file = "regex-2023.10.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be5e22bbb67924dea15039c3282fa4cc6cdfbe0cbbd1c0515f9223186fc2ec5f"},
+ {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a992f702c9be9c72fa46f01ca6e18d131906a7180950958f766c2aa294d4b41"},
+ {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7434a61b158be563c1362d9071358f8ab91b8d928728cd2882af060481244c9e"},
+ {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2169b2dcabf4e608416f7f9468737583ce5f0a6e8677c4efbf795ce81109d7c"},
+ {file = "regex-2023.10.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9e908ef5889cda4de038892b9accc36d33d72fb3e12c747e2799a0e806ec841"},
+ {file = "regex-2023.10.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12bd4bc2c632742c7ce20db48e0d99afdc05e03f0b4c1af90542e05b809a03d9"},
+ {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bc72c231f5449d86d6c7d9cc7cd819b6eb30134bb770b8cfdc0765e48ef9c420"},
+ {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bce8814b076f0ce5766dc87d5a056b0e9437b8e0cd351b9a6c4e1134a7dfbda9"},
+ {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:ba7cd6dc4d585ea544c1412019921570ebd8a597fabf475acc4528210d7c4a6f"},
+ {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b0c7d2f698e83f15228ba41c135501cfe7d5740181d5903e250e47f617eb4292"},
+ {file = "regex-2023.10.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5a8f91c64f390ecee09ff793319f30a0f32492e99f5dc1c72bc361f23ccd0a9a"},
+ {file = "regex-2023.10.3-cp312-cp312-win32.whl", hash = "sha256:ad08a69728ff3c79866d729b095872afe1e0557251da4abb2c5faff15a91d19a"},
+ {file = "regex-2023.10.3-cp312-cp312-win_amd64.whl", hash = "sha256:39cdf8d141d6d44e8d5a12a8569d5a227f645c87df4f92179bd06e2e2705e76b"},
+ {file = "regex-2023.10.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4a3ee019a9befe84fa3e917a2dd378807e423d013377a884c1970a3c2792d293"},
+ {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76066d7ff61ba6bf3cb5efe2428fc82aac91802844c022d849a1f0f53820502d"},
+ {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe50b61bab1b1ec260fa7cd91106fa9fece57e6beba05630afe27c71259c59b"},
+ {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fd88f373cb71e6b59b7fa597e47e518282455c2734fd4306a05ca219a1991b0"},
+ {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ab05a182c7937fb374f7e946f04fb23a0c0699c0450e9fb02ef567412d2fa3"},
+ {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dac37cf08fcf2094159922edc7a2784cfcc5c70f8354469f79ed085f0328ebdf"},
+ {file = "regex-2023.10.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e54ddd0bb8fb626aa1f9ba7b36629564544954fff9669b15da3610c22b9a0991"},
+ {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3367007ad1951fde612bf65b0dffc8fd681a4ab98ac86957d16491400d661302"},
+ {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:16f8740eb6dbacc7113e3097b0a36065a02e37b47c936b551805d40340fb9971"},
+ {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:f4f2ca6df64cbdd27f27b34f35adb640b5d2d77264228554e68deda54456eb11"},
+ {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:39807cbcbe406efca2a233884e169d056c35aa7e9f343d4e78665246a332f597"},
+ {file = "regex-2023.10.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7eece6fbd3eae4a92d7c748ae825cbc1ee41a89bb1c3db05b5578ed3cfcfd7cb"},
+ {file = "regex-2023.10.3-cp37-cp37m-win32.whl", hash = "sha256:ce615c92d90df8373d9e13acddd154152645c0dc060871abf6bd43809673d20a"},
+ {file = "regex-2023.10.3-cp37-cp37m-win_amd64.whl", hash = "sha256:0f649fa32fe734c4abdfd4edbb8381c74abf5f34bc0b3271ce687b23729299ed"},
+ {file = "regex-2023.10.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9b98b7681a9437262947f41c7fac567c7e1f6eddd94b0483596d320092004533"},
+ {file = "regex-2023.10.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:91dc1d531f80c862441d7b66c4505cd6ea9d312f01fb2f4654f40c6fdf5cc37a"},
+ {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82fcc1f1cc3ff1ab8a57ba619b149b907072e750815c5ba63e7aa2e1163384a4"},
+ {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7979b834ec7a33aafae34a90aad9f914c41fd6eaa8474e66953f3f6f7cbd4368"},
+ {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef71561f82a89af6cfcbee47f0fabfdb6e63788a9258e913955d89fdd96902ab"},
+ {file = "regex-2023.10.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd829712de97753367153ed84f2de752b86cd1f7a88b55a3a775eb52eafe8a94"},
+ {file = "regex-2023.10.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00e871d83a45eee2f8688d7e6849609c2ca2a04a6d48fba3dff4deef35d14f07"},
+ {file = "regex-2023.10.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:706e7b739fdd17cb89e1fbf712d9dc21311fc2333f6d435eac2d4ee81985098c"},
+ {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cc3f1c053b73f20c7ad88b0d1d23be7e7b3901229ce89f5000a8399746a6e039"},
+ {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6f85739e80d13644b981a88f529d79c5bdf646b460ba190bffcaf6d57b2a9863"},
+ {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:741ba2f511cc9626b7561a440f87d658aabb3d6b744a86a3c025f866b4d19e7f"},
+ {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e77c90ab5997e85901da85131fd36acd0ed2221368199b65f0d11bca44549711"},
+ {file = "regex-2023.10.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:979c24cbefaf2420c4e377ecd1f165ea08cc3d1fbb44bdc51bccbbf7c66a2cb4"},
+ {file = "regex-2023.10.3-cp38-cp38-win32.whl", hash = "sha256:58837f9d221744d4c92d2cf7201c6acd19623b50c643b56992cbd2b745485d3d"},
+ {file = "regex-2023.10.3-cp38-cp38-win_amd64.whl", hash = "sha256:c55853684fe08d4897c37dfc5faeff70607a5f1806c8be148f1695be4a63414b"},
+ {file = "regex-2023.10.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2c54e23836650bdf2c18222c87f6f840d4943944146ca479858404fedeb9f9af"},
+ {file = "regex-2023.10.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:69c0771ca5653c7d4b65203cbfc5e66db9375f1078689459fe196fe08b7b4930"},
+ {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ac965a998e1388e6ff2e9781f499ad1eaa41e962a40d11c7823c9952c77123e"},
+ {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c0e8fae5b27caa34177bdfa5a960c46ff2f78ee2d45c6db15ae3f64ecadde14"},
+ {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c56c3d47da04f921b73ff9415fbaa939f684d47293f071aa9cbb13c94afc17d"},
+ {file = "regex-2023.10.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ef1e014eed78ab650bef9a6a9cbe50b052c0aebe553fb2881e0453717573f52"},
+ {file = "regex-2023.10.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d29338556a59423d9ff7b6eb0cb89ead2b0875e08fe522f3e068b955c3e7b59b"},
+ {file = "regex-2023.10.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:9c6d0ced3c06d0f183b73d3c5920727268d2201aa0fe6d55c60d68c792ff3588"},
+ {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:994645a46c6a740ee8ce8df7911d4aee458d9b1bc5639bc968226763d07f00fa"},
+ {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:66e2fe786ef28da2b28e222c89502b2af984858091675044d93cb50e6f46d7af"},
+ {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:11175910f62b2b8c055f2b089e0fedd694fe2be3941b3e2633653bc51064c528"},
+ {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:06e9abc0e4c9ab4779c74ad99c3fc10d3967d03114449acc2c2762ad4472b8ca"},
+ {file = "regex-2023.10.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fb02e4257376ae25c6dd95a5aec377f9b18c09be6ebdefa7ad209b9137b73d48"},
+ {file = "regex-2023.10.3-cp39-cp39-win32.whl", hash = "sha256:3b2c3502603fab52d7619b882c25a6850b766ebd1b18de3df23b2f939360e1bd"},
+ {file = "regex-2023.10.3-cp39-cp39-win_amd64.whl", hash = "sha256:adbccd17dcaff65704c856bd29951c58a1bd4b2b0f8ad6b826dbd543fe740988"},
+ {file = "regex-2023.10.3.tar.gz", hash = "sha256:3fef4f844d2290ee0ba57addcec17eec9e3df73f10a2748485dfd6a3a188cc0f"},
+]
+
+[[package]]
+name = "requests"
+version = "2.31.0"
+description = "Python HTTP for Humans."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
+ {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
+]
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset-normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.21.1,<3"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "rich"
+version = "13.7.0"
+description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+optional = false
+python-versions = ">=3.7.0"
+files = [
+ {file = "rich-13.7.0-py3-none-any.whl", hash = "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235"},
+ {file = "rich-13.7.0.tar.gz", hash = "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa"},
+]
+
+[package.dependencies]
+markdown-it-py = ">=2.2.0"
+pygments = ">=2.13.0,<3.0.0"
+
+[package.extras]
+jupyter = ["ipywidgets (>=7.5.1,<9)"]
+
+[[package]]
+name = "setuptools"
+version = "69.0.2"
+description = "Easily download, build, install, upgrade, and uninstall Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "setuptools-69.0.2-py3-none-any.whl", hash = "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2"},
+ {file = "setuptools-69.0.2.tar.gz", hash = "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
+testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
+
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+description = "Tool to Detect Surrounding Shell"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"},
+ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"},
+]
+
+[[package]]
+name = "shiboken6"
+version = "6.6.1"
+description = "Python/C++ bindings helper module"
+optional = true
+python-versions = "<3.13,>=3.8"
+files = [
+ {file = "shiboken6-6.6.1-cp38-abi3-macosx_11_0_universal2.whl", hash = "sha256:d756fd1fa945b787e8eef142f2eb571da0b4c4dc2f2eec1a7c12a474a2cf84e4"},
+ {file = "shiboken6-6.6.1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:fb102e4bc210006f0cdd0ce38e1aaaaf792bd871f02a2b3f01d07922c5cf4c59"},
+ {file = "shiboken6-6.6.1-cp38-abi3-manylinux_2_31_aarch64.whl", hash = "sha256:a605960e72af5eef915991cee7eef4cc72f5cabe63b9ae1a955ceb3d3b0a00b9"},
+ {file = "shiboken6-6.6.1-cp38-abi3-win_amd64.whl", hash = "sha256:072c35c4fe46ec13b364d9dc47b055bb2277ee3aeaab18c23650280ec362f62a"},
+]
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+
+[[package]]
+name = "smmap"
+version = "5.0.1"
+description = "A pure Python implementation of a sliding window memory map manager"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da"},
+ {file = "smmap-5.0.1.tar.gz", hash = "sha256:dceeb6c0028fdb6734471eb07c0cd2aae706ccaecab45965ee83f11c8d3b1f62"},
+]
+
+[[package]]
+name = "snowballstemmer"
+version = "2.2.0"
+description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
+optional = false
+python-versions = "*"
+files = [
+ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
+ {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
+]
+
+[[package]]
+name = "stevedore"
+version = "5.1.0"
+description = "Manage dynamic plugins for Python applications"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "stevedore-5.1.0-py3-none-any.whl", hash = "sha256:8cc040628f3cea5d7128f2e76cf486b2251a4e543c7b938f58d9a377f6694a2d"},
+ {file = "stevedore-5.1.0.tar.gz", hash = "sha256:a54534acf9b89bc7ed264807013b505bf07f74dbe4bcfa37d32bd063870b087c"},
+]
+
+[package.dependencies]
+pbr = ">=2.0.0,<2.1.0 || >2.1.0"
+
+[[package]]
+name = "tidalapi"
+version = "0.7.3"
+description = "Unofficial API for TIDAL music streaming service."
+optional = false
+python-versions = ">=3.8,<4.0"
+files = [
+ {file = "tidalapi-0.7.3-py3-none-any.whl", hash = "sha256:04fa50c969a30a6e951a19a1f7f8234d142970a57abd3fb062180f508c5133bd"},
+ {file = "tidalapi-0.7.3.tar.gz", hash = "sha256:b083eea3591dd3c42e7e57fba05c68a351f9e9c7540e56cd4df1649fc8f78d51"},
+]
+
+[package.dependencies]
+python-dateutil = ">=2.8.2,<3.0.0"
+requests = ">=2.28.0,<3.0.0"
+
+[[package]]
+name = "tokenize-rt"
+version = "5.2.0"
+description = "A wrapper around the stdlib `tokenize` which roundtrips."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "tokenize_rt-5.2.0-py2.py3-none-any.whl", hash = "sha256:b79d41a65cfec71285433511b50271b05da3584a1da144a0752e9c621a285289"},
+ {file = "tokenize_rt-5.2.0.tar.gz", hash = "sha256:9fe80f8a5c1edad2d3ede0f37481cc0cc1538a2f442c9c2f9e4feacd2792d054"},
+]
+
+[[package]]
+name = "tomli"
+version = "2.0.1"
+description = "A lil' TOML parser"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
+
+[[package]]
+name = "tomlkit"
+version = "0.12.3"
+description = "Style preserving TOML library"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "tomlkit-0.12.3-py3-none-any.whl", hash = "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba"},
+ {file = "tomlkit-0.12.3.tar.gz", hash = "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4"},
+]
+
+[[package]]
+name = "tox"
+version = "4.11.4"
+description = "tox is a generic virtualenv management and test command line tool"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "tox-4.11.4-py3-none-any.whl", hash = "sha256:2adb83d68f27116812b69aa36676a8d6a52249cb0d173649de0e7d0c2e3e7229"},
+ {file = "tox-4.11.4.tar.gz", hash = "sha256:73a7240778fabf305aeb05ab8ea26e575e042ab5a18d71d0ed13e343a51d6ce1"},
+]
+
+[package.dependencies]
+cachetools = ">=5.3.1"
+chardet = ">=5.2"
+colorama = ">=0.4.6"
+filelock = ">=3.12.3"
+packaging = ">=23.1"
+platformdirs = ">=3.10"
+pluggy = ">=1.3"
+pyproject-api = ">=1.6.1"
+tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
+virtualenv = ">=20.24.3"
+
+[package.extras]
+docs = ["furo (>=2023.8.19)", "sphinx (>=7.2.4)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.24)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
+testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.1.1)", "devpi-process (>=1)", "diff-cover (>=7.7)", "distlib (>=0.3.7)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.18)", "psutil (>=5.9.5)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-xdist (>=3.3.1)", "re-assert (>=1.1)", "time-machine (>=2.12)", "wheel (>=0.41.2)"]
+
+[[package]]
+name = "typer"
+version = "0.9.0"
+description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "typer-0.9.0-py3-none-any.whl", hash = "sha256:5d96d986a21493606a358cae4461bd8cdf83cbf33a5aa950ae629ca3b51467ee"},
+ {file = "typer-0.9.0.tar.gz", hash = "sha256:50922fd79aea2f4751a8e0408ff10d2662bd0c8bbfa84755a699f3bada2978b2"},
+]
+
+[package.dependencies]
+click = ">=7.1.1,<9.0.0"
+colorama = {version = ">=0.4.3,<0.5.0", optional = true, markers = "extra == \"all\""}
+rich = {version = ">=10.11.0,<14.0.0", optional = true, markers = "extra == \"all\""}
+shellingham = {version = ">=1.3.0,<2.0.0", optional = true, markers = "extra == \"all\""}
+typing-extensions = ">=3.7.4.3"
+
+[package.extras]
+all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"]
+dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"]
+doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"]
+test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"]
+
+[[package]]
+name = "typing-extensions"
+version = "4.9.0"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"},
+ {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"},
+]
+
+[[package]]
+name = "typing-inspect"
+version = "0.9.0"
+description = "Runtime inspection utilities for typing module."
+optional = false
+python-versions = "*"
+files = [
+ {file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"},
+ {file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"},
+]
+
+[package.dependencies]
+mypy-extensions = ">=0.3.0"
+typing-extensions = ">=3.7.4"
+
+[[package]]
+name = "urllib3"
+version = "2.1.0"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"},
+ {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"},
+]
+
+[package.extras]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["zstandard (>=0.18.0)"]
+
+[[package]]
+name = "virtualenv"
+version = "20.25.0"
+description = "Virtual Python Environment builder"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "virtualenv-20.25.0-py3-none-any.whl", hash = "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3"},
+ {file = "virtualenv-20.25.0.tar.gz", hash = "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b"},
+]
+
+[package.dependencies]
+distlib = ">=0.3.7,<1"
+filelock = ">=3.12.2,<4"
+platformdirs = ">=3.9.1,<5"
+
+[package.extras]
+docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
+test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
+
+[[package]]
+name = "watchdog"
+version = "3.0.0"
+description = "Filesystem events monitoring"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"},
+ {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"},
+ {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"},
+ {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"},
+ {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"},
+ {file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"},
+ {file = "watchdog-3.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674"},
+ {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f"},
+ {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc"},
+ {file = "watchdog-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3"},
+ {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3"},
+ {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0"},
+ {file = "watchdog-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8"},
+ {file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"},
+ {file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"},
+ {file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"},
+ {file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"},
+ {file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"},
+ {file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"},
+ {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"},
+ {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"},
+ {file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"},
+ {file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"},
+ {file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"},
+ {file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"},
+ {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"},
+ {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"},
+]
+
+[package.extras]
+watchmedo = ["PyYAML (>=3.10)"]
+
+[extras]
+gui = ["pyqtdarktheme", "pyside6"]
+
+[metadata]
+lock-version = "2.0"
+python-versions = ">=3.10,<3.12"
+content-hash = "078b13ade16c096dadb774d90b1b737b4563d249e7d3c370d1207365742cadd8"
diff --git a/poetry.toml b/poetry.toml
new file mode 100644
index 0000000..ab1033b
--- /dev/null
+++ b/poetry.toml
@@ -0,0 +1,2 @@
+[virtualenvs]
+in-project = true
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..e66bea9
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,191 @@
+[project]
+name = "tidal-dl-ng"
+version = "0.4.11"
+description = "TIDAL Medial Downloader Next Generation!"
+authors = ["Robert Honz <cen.sored@gmail.com>"]
+repository = "https://github.com/exislow/tidal-dl-ng"
+documentation = "https://exislow.github.io/tidal-dl-ng/"
+readme = "README.md"
+packages = [
+ { include = "tidal_dl_ng" }
+]
+
+[tool.poetry]
+name = "tidal-dl-ng"
+authors = ["Robert Honz <cen.sored@gmail.com>"]
+version = "0.4.11"
+description = "TIDAL Medial Downloader Next Generation!"
+repository = "https://github.com/exislow/tidal-dl-ng"
+documentation = "https://exislow.github.io/tidal-dl-ng/"
+readme = "README.md"
+packages = [
+ { include = "tidal_dl_ng" }
+]
+
+[tool.poetry.scripts]
+tidal-dl-ng = "tidal_dl_ng.cli:app"
+tidal-dl-ng-gui = "tidal_dl_ng.gui:gui_activate"
+tdn = "tidal_dl_ng.cli:app"
+tdng = "tidal_dl_ng.gui:gui_activate"
+
+[tool.poetry.dependencies]
+python = ">=3.10,<3.12"
+requests = "2.31.0"
+mutagen = "1.47.0"
+pycryptodome = "3.19.0"
+tidalapi = "0.7.3"
+dataclasses-json = "^0.6.3"
+pathvalidate = "^3.2.0"
+typer = {extras = ["all"], version = "^0.9.0"}
+m3u8 = "^4.0.0"
+ffmpeg-python = "^0.2.0"
+coloredlogs = "^15.0.1"
+pyside6 = {version = "^6.6.1", optional = true}
+pyqtdarktheme = {version = "^2.1.0", optional = true}
+
+[tool.poetry.extras]
+gui = ["pyside6", "pyqtdarktheme"]
+
+[tool.poetry.group.dev]
+optional = true
+
+[tool.poetry.group.dev.dependencies]
+pytest = "^7.4.3"
+deptry = "^0.12.0"
+mypy = "^1.7.1"
+pre-commit = "^3.5.0"
+tox = "^4.11.4"
+pyupgrade = "^3.15.0"
+bandit = "^1.7.5"
+black = "^23.11.0"
+darglint = "^1.8.1"
+isort = { extras = ["colors"], version = "^5.12.0" }
+mypy-extensions = "^1.0.0"
+pydocstyle = "^6.3.0"
+pylint = "^3.0.2"
+pyinstaller = "^6.3.0"
+pillow = "^10.1.0"
+
+[tool.poetry.group.docs]
+optional = true
+
+[tool.poetry.group.docs.dependencies]
+mkdocs = "^1.5.3"
+mkdocs-material = "^9.4.14"
+mkdocstrings = { extras = ["python"], version = "^0.24.0" }
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+
+[tool.ruff]
+target-version = "py311"
+line-length = 120
+fix = true
+select = [
+ # flake8-2020
+ "YTT",
+ # flake8-bandit
+ "S",
+ # flake8-bugbear
+ "B",
+ # flake8-builtins
+ "A",
+ # flake8-comprehensions
+ "C4",
+ # flake8-debugger
+ "T10",
+ # flake8-simplify
+ "SIM",
+ # isort
+ "I",
+ # mccabe
+ "C90",
+ # pycodestyle
+ "E", "W",
+ # pyflakes
+ "F",
+ # pygrep-hooks
+ "PGH",
+ # pyupgrade
+ "UP",
+ # ruff
+ "RUF",
+ # tryceratops
+ "TRY",
+]
+ignore = [
+ # LineTooLong
+ "E501",
+ # DoNotAssignLambda
+ "E731",
+]
+
+[tool.ruff.per-file-ignores]
+"tests/*" = ["S101"]
+
+[tool.black]
+# https://github.com/psf/black
+target-version = ["py311"]
+line-length = 120
+color = true
+preview = true
+
+exclude = '''
+/(
+ \.git
+ | \.hg
+ | \.mypy_cache
+ | \.tox
+ | \.venv
+ | _build
+ | buck-out
+ | build
+ | dist
+ | env
+ | venv
+)/
+'''
+
+[tool.isort]
+# https://github.com/timothycrosley/isort/
+py_version = 311
+line_length = 120
+known_typing = ["typing", "types", "typing_extensions", "mypy", "mypy_extensions"]
+sections = ["FUTURE", "TYPING", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"]
+include_trailing_comma = true
+profile = "black"
+multi_line_output = 3
+indent = 4
+color_output = true
+
+[tool.mypy]
+# https://mypy.readthedocs.io/en/latest/config_file.html#using-a-pyproject-toml-file
+files = ["tidal_dl_ng"]
+python_version = "3.11"
+pretty = true
+show_traceback = true
+color_output = true
+allow_redefinition = false
+check_untyped_defs = true
+disallow_any_generics = true
+disallow_incomplete_defs = true
+ignore_missing_imports = true
+implicit_reexport = false
+no_implicit_optional = true
+show_column_numbers = true
+show_error_codes = true
+show_error_context = true
+strict_equality = true
+strict_optional = true
+warn_no_return = true
+warn_redundant_casts = true
+warn_return_any = true
+warn_unreachable = true
+warn_unused_configs = true
+warn_unused_ignores = true
+disallow_untyped_defs = "True"
+disallow_any_unimported = "True"
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..3c46a08
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,4 @@
+[darglint]
+# https://github.com/terrencepreilly/darglint
+strictness = long
+docstring_style = google
diff --git a/tidal_dl_ng/__init__.py b/tidal_dl_ng/__init__.py
new file mode 100644
index 0000000..a0dbac5
--- /dev/null
+++ b/tidal_dl_ng/__init__.py
@@ -0,0 +1,8 @@
+#!/usr/bin/env python
+import importlib.metadata
+
+try:
+ # __package__ allows for the case where __name__ is "__main__"
+ __version__ = importlib.metadata.version(__package__ or __name__)
+except importlib.metadata.PackageNotFoundError:
+ __version__ = "0.0.0"
diff --git a/tidal_dl_ng/api.py b/tidal_dl_ng/api.py
new file mode 100644
index 0000000..c68665e
--- /dev/null
+++ b/tidal_dl_ng/api.py
@@ -0,0 +1,102 @@
+import json
+
+import requests
+
+# TODO: Implement this into `Download`: Session should randomize the usage.
+__KEYS_JSON__ = """
+{
+ "version": "1.0.1",
+ "keys": [
+ {
+ "platform": "Fire TV",
+ "formats": "Normal/High/HiFi(No Master)",
+ "clientId": "OmDtrzFgyVVL6uW56OnFA2COiabqm",
+ "clientSecret": "zxen1r3pO0hgtOC7j6twMo9UAqngGrmRiWpV7QC1zJ8=",
+ "valid": "False",
+ "from": "Fokka-Engineering (https://github.com/Fokka-Engineering/libopenTIDAL/blob/655528e26e4f3ee2c426c06ea5b8440cf27abc4a/README.md#example)"
+ },
+ {
+ "platform": "Fire TV",
+ "formats": "Master-Only(Else Error)",
+ "clientId": "7m7Ap0JC9j1cOM3n",
+ "clientSecret": "vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY=",
+ "valid": "True",
+ "from": "Dniel97 (https://github.com/Dniel97/RedSea/blob/4ba02b88cee33aeb735725cb854be6c66ff372d4/config/settings.example.py#L68)"
+ },
+ {
+ "platform": "Android TV",
+ "formats": "Normal/High/HiFi(No Master)",
+ "clientId": "Pzd0ExNVHkyZLiYN",
+ "clientSecret": "W7X6UvBaho+XOi1MUeCX6ewv2zTdSOV3Y7qC3p3675I=",
+ "valid": "False",
+ "from": ""
+ },
+ {
+ "platform": "TV",
+ "formats": "Normal/High/HiFi/Master",
+ "clientId": "8SEZWa4J1NVC5U5Y",
+ "clientSecret": "owUYDkxddz+9FpvGX24DlxECNtFEMBxipU0lBfrbq60=",
+ "valid": "False",
+ "from": "morguldir (https://github.com/morguldir/python-tidal/commit/50f1afcd2079efb2b4cf694ef5a7d67fdf619d09)"
+ },
+ {
+ "platform": "Android Auto",
+ "formats": "Normal/High/HiFi/Master",
+ "clientId": "zU4XHVVkc2tDPo4t",
+ "clientSecret": "VJKhDFqJPqvsPVNBV6ukXTJmwlvbttP7wlMlrc72se4=",
+ "valid": "True",
+ "from": "1nikolas (https://github.com/yaronzz/Tidal-Media-Downloader/pull/840)"
+ }
+ ]
+}
+"""
+__API_KEYS__ = json.loads(__KEYS_JSON__)
+__ERROR_KEY__ = (
+ {
+ "platform": "None",
+ "formats": "",
+ "clientId": "",
+ "clientSecret": "",
+ "valid": "False",
+ },
+)
+
+
+def getNum():
+ return len(__API_KEYS__["keys"])
+
+
+def getItem(index: int):
+ if index < 0 or index >= len(__API_KEYS__["keys"]):
+ return __ERROR_KEY__
+ return __API_KEYS__["keys"][index]
+
+
+def isItemValid(index: int):
+ item = getItem(index)
+ return item["valid"] == "True"
+
+
+def getItems():
+ return __API_KEYS__["keys"]
+
+
+def getLimitIndexs():
+ array = []
+ for i in range(len(__API_KEYS__["keys"])):
+ array.append(str(i))
+ return array
+
+
+def getVersion():
+ return __API_KEYS__["version"]
+
+
+# Load from gist
+try:
+ respond = requests.get("https://api.github.com/gists/48d01f5a24b4b7b37f19443977c22cd6")
+ if respond.status_code == 200:
+ content = respond.json()["files"]["tidal-api-key.json"]["content"]
+ __API_KEYS__ = json.loads(content)
+except:
+ pass
diff --git a/tidal_dl_ng/cli.py b/tidal_dl_ng/cli.py
new file mode 100644
index 0000000..c021d86
--- /dev/null
+++ b/tidal_dl_ng/cli.py
@@ -0,0 +1,217 @@
+#!/usr/bin/env python
+from typing import Annotated, Optional
+
+from pathlib import Path
+
+import typer
+from tidal_dl_ng.config import Settings, Tidal
+from tidal_dl_ng.constants import CTX_TIDAL, MediaType
+from tidal_dl_ng.helper.path import path_file_settings
+from tidal_dl_ng.helper.url import get_tidal_media_id, get_tidal_media_type
+from tidal_dl_ng.helper.wrapper import WrapperLogger
+from tidal_dl_ng.model.cfg import HelpSettings
+from rich.live import Live
+from rich.panel import Panel
+from rich.progress import BarColumn, Console, Progress, SpinnerColumn, TextColumn
+from rich.table import Table
+
+from tidal_dl_ng.download import Download
+from tidal_dl_ng import __version__
+
+app = typer.Typer()
+
+
+def version_callback(value: bool):
+ if value:
+ print(f"{__version__}")
+ raise typer.Exit()
+
+
+@app.callback()
+def callback_app(
+ ctx: typer.Context,
+ version: Annotated[Optional[bool], typer.Option("--version", "-v", callback=version_callback, is_eager=True)] = None,
+):
+ ctx.obj = {"tidal": None}
+
+
+@app.command(name="cfg")
+def settings_management(
+ names: list[str] = None,
+ editor: Annotated[
+ bool, typer.Option("--editor", "-e", help="Open the settings file in your default editor.")
+ ] = False,
+):
+ """
+ Print or set an option.
+ If no arguments are given, all options will be listed.
+ If only one argument is given, the value will be printed for this option.
+ To set a value for an option simply pass the value as the second argument
+
+ :param names: (Optional) None (list all options), one (list the value only for this option) or two arguments
+ (set the value for the option).
+ """
+ if editor:
+ config_path: Path = Path(path_file_settings())
+
+ if not config_path.is_file():
+ config_path.write_text('{"version": "1.0.0"}')
+
+ config_file_str = str(config_path)
+
+ typer.launch(config_file_str)
+ else:
+ settings = Settings()
+ d_settings = settings.data.to_dict()
+
+ if names:
+ if names[0] not in d_settings:
+ print(f'Option "{names[0]}" is not valid!')
+ else:
+ if len(names) == 1:
+ print(f'{names[0]}: "{d_settings[names[0]]}"')
+ elif len(names) > 1:
+ settings.set_option(names[0], names[1])
+ settings.save()
+ else:
+ help_settings: dict = HelpSettings().to_dict()
+ table = Table(title=f"Config: {path_file_settings()}")
+ table.add_column("Key", style="cyan", no_wrap=True)
+ table.add_column("Value", style="magenta")
+ table.add_column("Description", style="green")
+
+ # Iterate over the attributes of the dataclass
+ for key, value in sorted(d_settings.items()):
+ table.add_row(key, str(value), help_settings[key])
+
+ console = Console()
+ console.print(table)
+
+
+@app.command(name="login")
+def login(ctx: typer.Context) -> bool:
+ print("Let us check, if you are already logged in... ", end="")
+
+ settings = Settings()
+ tidal = Tidal(settings)
+ result = tidal.login(print)
+ ctx.obj[CTX_TIDAL] = tidal
+
+ return result
+
+
+@app.command(name="dl")
+def download(
+ ctx: typer.Context,
+ urls_or_ids: Annotated[Optional[list[str]], typer.Argument()] = None,
+ list_urls: Annotated[
+ Optional[Path],
+ typer.Option(
+ "--list",
+ "-l",
+ exists=True,
+ file_okay=True,
+ dir_okay=False,
+ writable=False,
+ readable=True,
+ resolve_path=True,
+ help="List with URLs to download. One per line",
+ ),
+ ] = None,
+):
+ if not urls_or_ids:
+ if list_urls:
+ text = list_urls.read_text()
+ urls_or_ids = text.splitlines()
+ else:
+ print("Provide either URLs, IDs or a file containing URLs (one per line).")
+
+ raise typer.Abort()
+
+ ctx.invoke(login, ctx)
+
+ dl = Download(ctx.obj[CTX_TIDAL].session, ctx.obj[CTX_TIDAL].settings.data.skip_existing)
+ settings: Settings = Settings()
+ media_type: MediaType = None
+ progress: Progress = Progress(
+ "{task.description}",
+ SpinnerColumn(),
+ BarColumn(),
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
+ )
+ progress_table = Table.grid()
+ fn_logger = WrapperLogger(progress.print)
+
+ progress_table.add_row(Panel.fit(progress, title="Download Progress", border_style="green", padding=(2, 2)))
+
+ for item in urls_or_ids:
+ if "http" in item:
+ media_name = get_tidal_media_type(item)
+ id_item = get_tidal_media_id(item)
+ else:
+ media_name = False
+ id_item = item
+
+ if not media_name:
+ print(f"It seems like that you have supplied an invalid URL: {item}")
+
+ continue
+
+ with Live(progress_table, refresh_per_second=10):
+ # TODO: Fix media type
+ if media_name in ["track", "video"]:
+ if media_name == "track":
+ file_template = settings.data.format_track
+ media_type = MediaType.Track
+ elif media_name == "video":
+ file_template = settings.data.format_video
+ media_type = MediaType.Video
+
+ dl.item(
+ id=id_item,
+ media_type=media_type,
+ path_base=settings.data.download_base_path,
+ file_template=file_template,
+ progress=progress,
+ fn_logger=fn_logger
+ )
+ elif media_name in ["album", "playlist", "mix"]:
+ # TODO: Handle mixes.
+ file_template: str | bool = False
+
+ if media_name == "album":
+ file_template = settings.data.format_album
+ media_type = MediaType.Album
+ elif media_name == "playlist":
+ file_template = settings.data.format_playlist
+ media_type = MediaType.Playlist
+ elif media_name == "mix":
+ file_template = settings.data.format_mix
+ media_type = MediaType.Mix
+
+ dl.list(
+ id_media=id_item,
+ media_type=media_type,
+ path_base=settings.data.download_base_path,
+ file_template=file_template,
+ video_download=ctx.obj[CTX_TIDAL].settings.data.video_download,
+ progress=progress,
+ download_delay=settings.data.download_delay,
+ fn_logger=fn_logger,
+ )
+
+ progress.stop()
+
+ return True
+
+
+@app.command()
+def gui(ctx: typer.Context):
+ from tidal_dl_ng.gui import gui_activate
+
+ ctx.invoke(login, ctx)
+ gui_activate(ctx.obj[CTX_TIDAL])
+
+
+if __name__ == "__main__":
+ app()
diff --git a/tidal_dl_ng/config.py b/tidal_dl_ng/config.py
new file mode 100644
index 0000000..bdcc70d
--- /dev/null
+++ b/tidal_dl_ng/config.py
@@ -0,0 +1,132 @@
+from typing import Any
+
+from collections.abc import Callable
+from json import JSONDecodeError
+
+import tidalapi
+from tidal_dl_ng.helper.decorator import SingletonMeta
+from tidal_dl_ng.helper.path import path_file_settings, path_file_token
+from tidal_dl_ng.model.cfg import Settings as ModelSettings
+from tidal_dl_ng.model.cfg import Token as ModelToken
+from requests import HTTPError
+
+
+class BaseConfig:
+ data: ModelSettings | ModelToken = None
+ file_path: str = None
+ cls_model: object = None
+
+ def save(self) -> None:
+ data_json = self.data.to_json()
+
+ with open(self.file_path, encoding="utf-8", mode="w") as f:
+ f.write(data_json)
+
+ def set_option(self, key: str, value: Any) -> None:
+ setattr(self.data, key, value)
+
+ def read(self, path: str) -> bool:
+ result = False
+
+ try:
+ with open(path, encoding="utf-8") as f:
+ settings_json = f.read()
+
+ self.data = self.cls_model.from_json(settings_json)
+ result = True
+ except (JSONDecodeError, TypeError, FileNotFoundError):
+ self.data = self.cls_model()
+ self.save()
+
+ return result
+
+
+class Settings(BaseConfig, metaclass=SingletonMeta):
+ cls_model = ModelSettings
+ data = None
+
+ def __init__(self):
+ self.file_path = path_file_settings()
+ self.read(self.file_path)
+
+
+class Tidal(BaseConfig, metaclass=SingletonMeta):
+ cls_model = ModelToken
+ session: tidalapi.Session = None
+ data: ModelToken = None
+ token_from_storage: bool = False
+ settings: Settings = None
+
+ def __init__(self, settings: Settings = None):
+ self.session = tidalapi.Session()
+ self.session.video_quality = tidalapi.VideoQuality.high
+ self.file_path = path_file_token()
+ self.token_from_storage = self.read(self.file_path)
+ self.login_token()
+
+ if settings:
+ self.settings = settings
+ self.settings_apply()
+
+ def settings_apply(self, settings: Settings = None) -> bool:
+ if settings:
+ self.settings = settings
+
+ self.session.audio_quality = self.settings.data.quality_audio
+
+ return True
+
+ def login_token(self) -> bool:
+ result = False
+
+ if self.token_from_storage:
+ try:
+ result = self.session.load_oauth_session(
+ self.data.token_type, self.data.access_token, self.data.refresh_token, self.data.expiry_time
+ )
+ except HTTPError:
+ result = False
+
+ return result
+
+ def login_oauth_start(self, function=print) -> None:
+ self.session.login_oauth_simple(function)
+
+ def login_oauth_finish(self) -> bool:
+ result = self.session.check_login()
+
+ if result:
+ self.token_persist()
+
+ return result
+
+ def token_persist(self) -> None:
+ self.set_option("token_type", self.session.token_type)
+ self.set_option("access_token", self.session.access_token)
+ self.set_option("refresh_token", self.session.refresh_token)
+ self.set_option("expiry_time", self.session.expiry_time)
+ self.save()
+
+ def login(self, fn_print: Callable) -> bool:
+ is_token = self.login_token()
+ result = False
+
+ if is_token:
+ fn_print("Yep, looks good! You are logged in.")
+
+ result = True
+ elif not is_token:
+ fn_print("You either do not have a token or your token is invalid.")
+ fn_print("No worries, we will handle this...")
+ self.login_oauth_start(fn_print)
+
+ is_login = self.login_oauth_finish()
+
+ if is_login:
+ fn_print("The login was successful. I have stored your credentials (token).")
+
+ result = True
+ else:
+ fn_print("Something went wrong. Did you login using your browser correctly? May try again...")
+
+ return result
diff --git a/tidal_dl_ng/constants.py b/tidal_dl_ng/constants.py
new file mode 100644
index 0000000..41564db
--- /dev/null
+++ b/tidal_dl_ng/constants.py
@@ -0,0 +1,18 @@
+from enum import Enum
+
+CTX_TIDAL: str = "tidal"
+
+
+class QualityVideo(Enum):
+ P360 = 360
+ P480 = 480
+ P720 = 720
+ P1080 = 1080
+
+
+class MediaType(Enum):
+ Track = "track"
+ Video = "video"
+ Playlist = "playlist"
+ Album = "album"
+ Mix = "mix"
diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py
new file mode 100644
index 0000000..ba9fde0
--- /dev/null
+++ b/tidal_dl_ng/download.py
@@ -0,0 +1,331 @@
+import base64
+import json
+import os
+import random
+import shutil
+import tempfile
+import time
+from logging import Logger
+from requests.exceptions import HTTPError
+from uuid import uuid4
+
+import ffmpeg
+import m3u8
+import requests
+from tidal_dl_ng.config import Settings
+from tidal_dl_ng.constants import MediaType
+from tidal_dl_ng.helper.decryption import decrypt_file, decrypt_security_token
+from tidal_dl_ng.helper.exceptions import MediaUnknown
+from tidal_dl_ng.helper.path import check_file_exists, format_path_media, path_validate
+from tidal_dl_ng.helper.wrapper import WrapperLogger
+from tidal_dl_ng.metadata import Metadata
+from tidal_dl_ng.model.gui_data import ProgressBars
+from pathvalidate import sanitize_filepath
+from rich.progress import Progress
+from tidalapi import Album, Playlist, Session, Track, UserPlaylist, Video, Mix
+
+
+# TODO: Set appropriate client string and use it for video download.
+# https://github.com/globocom/m3u8#using-different-http-clients
+class RequestsClient:
+ def download(self, uri, timeout=None, headers={}, verify_ssl=True):
+ o = requests.get(uri, timeout=timeout, headers=headers)
+
+ return o.text, o.url
+
+
+class Download:
+ # TODO: Implement download cover 1280.
+ session: Session = None
+ skip_existing: bool = False
+
+ def __init__(self, session: Session, skip_existing: bool = False):
+ self.session = session
+ self.skip_existing = skip_existing
+
+ def _video(self, video: Video, path_file: str) -> str | None:
+ result: str | None = None
+ m3u8_variant: m3u8.M3U8 = m3u8.load(video.get_url())
+ m3u8_playlist: m3u8.M3U8 | bool = False
+ settings: Settings = Settings()
+ resolution_best: int = 0
+
+ if m3u8_variant.is_variant:
+ for playlist in m3u8_variant.playlists:
+ if resolution_best < playlist.stream_info.resolution[1]:
+ resolution_best = playlist.stream_info.resolution[1]
+ m3u8_playlist = m3u8.load(playlist.uri)
+
+ if settings.data.quality_video.value == playlist.stream_info.resolution[1]:
+ break
+
+ if m3u8_playlist:
+ with open(path_file, "wb") as f:
+ for segment in m3u8_playlist.data["segments"]:
+ url = segment["uri"]
+ r = requests.get(url)
+
+ f.write(r.content)
+
+ result = path_file
+
+ return result
+
+ def item(
+ self,
+ path_base: str,
+ fn_logger: Logger | WrapperLogger,
+ id: str = None,
+ file_template: str = None,
+ media: Track | Video = None,
+ media_type: MediaType = None,
+ video_download: bool = True,
+ progress_gui: ProgressBars = None,
+ progress: Progress = None
+ ) -> (bool, str):
+ if id:
+ if media_type == MediaType.Track:
+ media = Track(self.session, id)
+ elif media_type == MediaType.Video:
+ media = Video(self.session, id)
+
+ # If video download is not allowed
+ if not video_download:
+ return False, ""
+ else:
+ raise MediaUnknown
+ else:
+ media = media
+
+ if file_template:
+ file_name_relative = format_path_media(file_template, media)
+ path_file = os.path.abspath(os.path.normpath(os.path.join(path_base, file_name_relative)))
+ else:
+ path_file = os.path.abspath(os.path.normpath(format_path_media(path_base, media)))
+
+ if isinstance(media, Track):
+ stream = media.stream()
+ # TODO: Check for `manifest_mime_type'. It could be also xml.
+ stream_manifest = json.loads(base64.b64decode(stream.manifest).decode("utf-8"))
+ # TODO: Handle more than one dowload URL
+ stream_url = stream_manifest["urls"][0]
+ file_extension = self.get_file_extension(stream_url, stream_manifest["codecs"])
+ elif isinstance(media, Video):
+ file_extension = ".ts"
+
+ path_file = sanitize_filepath(path_file + file_extension)
+ # Check if path & filename longer than the OS allows. Shorten if necessary.
+ validation_result, path_file = path_validate(path_file, True)
+
+ download_skip = check_file_exists(path_file) if self.skip_existing else False
+
+ if not download_skip:
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp_path_dir:
+ tmp_path_file = os.path.join(tmp_path_dir, str(uuid4()) + file_extension)
+
+ if isinstance(media, Track):
+ # TODO: Refactor to separate method.
+ if progress_gui is None:
+ progress_stdout: bool = True
+ else:
+ progress_stdout: bool = False
+ progress_gui.item_name.emit(media.name)
+
+ try:
+ # Streaming, so we can iterate over the response.
+ r = requests.get(stream_url, stream=True)
+ r.raise_for_status()
+ total_size_in_bytes = int(r.headers.get("content-length", 0))
+ block_size = 4096
+ p_task = progress.add_task(
+ f"[blue]Item '{media.name[:30]}'",
+ total=total_size_in_bytes / block_size,
+ visible=progress_stdout,
+ )
+
+ while not progress.tasks[p_task].finished:
+ with open(tmp_path_file, "wb") as f:
+ for data in r.iter_content(chunk_size=block_size):
+ f.write(data)
+ progress.advance(p_task, advance=1)
+
+ if not progress_stdout:
+ progress_gui.item.emit(progress.tasks[p_task].percentage)
+ except HTTPError:
+ # TODO: Handle Exception...
+ pass
+
+ needs_decryption = self.is_encrypted(stream_manifest)
+
+ if needs_decryption:
+ key, nonce = decrypt_security_token(stream_manifest["encryptionKey"])
+ tmp_path_file_decrypted = tmp_path_file + "_decrypted"
+ decrypt_file(tmp_path_file, tmp_path_file_decrypted, key, nonce)
+ else:
+ tmp_path_file_decrypted = tmp_path_file
+
+ self.metadata_write(media, tmp_path_file_decrypted)
+ elif isinstance(media, Video):
+ tmp_path_file_decrypted = self._video(media, tmp_path_file)
+
+ # TODO: Check if is possible to write metadata to MPEG Transport Stream files.
+ # TODO: Make optional.
+ if True:
+ tmp_path_file_decrypted = self._video_convert(tmp_path_file_decrypted)
+ path_file = os.path.splitext(path_file)[0] + ".mp4"
+
+ os.makedirs(os.path.dirname(path_file), exist_ok=True)
+ shutil.move(tmp_path_file_decrypted, path_file)
+ else:
+ fn_logger.debug(f"Download skipped, since file exists: '{path_file}'")
+
+ return not download_skip, path_file
+
+ def cover_url(self, sid: str, width: int = 320, height: int = 320):
+ if sid is None:
+ return ""
+
+ return f"https://resources.tidal.com/images/{sid.replace('-', '/')}/{int(width)}x{int(height)}.jpg"
+
+ def cover_data(self, sid, width: int = 320, height: int = 320):
+ url = self.cover_url(sid, width, height)
+
+ try:
+ result: str = requests.get(url).content
+ except:
+ result: str = ""
+
+ return result
+
+ def metadata_write(self, track: Track, path_file: str):
+ result: bool = False
+ release_date: str = track.album.release_date.strftime("%Y-%m-%d") if track.album.release_date else ""
+ copy_right: str = track.copyright if track.copyright else ""
+
+ try:
+ lyrics: str = track.lyrics().text if hasattr(track, "lyrics") else ""
+ except HTTPError:
+ lyrics: str = ""
+
+ m: Metadata = Metadata(
+ path_file=path_file,
+ lyrics=lyrics,
+ copy_right=copy_right,
+ title=track.name,
+ artists=[artist.name for artist in track.artists],
+ album=track.album.name,
+ tracknumber=track.track_num,
+ date=release_date,
+ isrc=track.isrc,
+ albumartist=track.artist.name,
+ totaltrack=track.album.num_tracks if track.album.num_tracks else 1,
+ totaldisc=track.album.num_volumes if track.album.num_volumes else 1,
+ discnumber=track.volume_num,
+ url_cover=self.cover_url(track.album.cover),
+ )
+
+ m.save()
+
+ result = True
+
+ return result
+
+ def list(
+ self,
+ path_base: str,
+ fn_logger: Logger | WrapperLogger,
+ id_media: str = None,
+ media_type: MediaType = None,
+ file_template: str = None,
+ list_media: Album | Playlist | UserPlaylist | Mix = None,
+ video_download: bool = False,
+ progress_gui: ProgressBars = None,
+ progress: Progress = None,
+ download_delay: bool = True,
+ ):
+ if not list_media:
+ if media_type == MediaType.Album:
+ list_media = Album(self.session, id_media)
+ elif media_type == MediaType.Playlist:
+ list_media = Playlist(self.session, id_media)
+ elif media_type == MediaType.Mix:
+ list_media = Mix(self.session, id_media)
+ else:
+ raise MediaUnknown
+
+ if file_template:
+ file_name_relative = format_path_media(file_template, list_media)
+ path_file = path_base
+ else:
+ file_name_relative = file_template
+ path_file = format_path_media(path_base, list_media)
+
+ # TODO: Extend with pagination support: Iterate through `items` and `tracks`until len(returned list) == 0
+ if isinstance(list_media, Mix):
+ items = list_media.items()
+ list_media_name = list_media.title[:30]
+ elif video_download:
+ items = list_media.items(limit=100)
+ list_media_name = list_media.name[:30]
+ else:
+ items = list_media.tracks(limit=999)
+ list_media_name = list_media.name[:30]
+
+ if progress_gui is None:
+ progress_stdout: bool = True
+ else:
+ progress_stdout: bool = False
+
+ p_task1 = progress.add_task(f"[green]List '{list_media_name}'", total=len(items), visible=progress_stdout)
+
+ while not progress.finished:
+ for media in items:
+ Progress()
+ # TODO: Handle return value of `track` method.
+ self.item(
+ path_base=path_file,
+ file_template=file_name_relative,
+ media=media,
+ progress_gui=progress_gui,
+ progress=progress,
+ fn_logger=fn_logger
+ )
+ progress.advance(p_task1)
+
+ if not progress_stdout:
+ progress_gui.list_item.emit(progress.tasks[p_task1].percentage)
+
+ if download_delay:
+ time_sleep: float = round(random.SystemRandom().uniform(2, 5), 1)
+
+ # TODO: Fix logging. Is not displayed in debug window.
+ fn_logger.debug(f"Next download will start in {time_sleep} seconds.")
+ time.sleep(time_sleep)
+
+ def is_encrypted(self, manifest: dict) -> bool:
+ result = manifest["encryptionType"] != "NONE"
+
+ return result
+
+ def get_file_extension(self, stream_url: str, stream_codec: str) -> str:
+ result = None
+
+ if ".flac" in stream_url:
+ result = ".flac"
+ elif ".mp4" in stream_url:
+ if "ac4" in stream_codec or "mha1" in stream_codec:
+ result = ".mp4"
+ elif "flac" in stream_codec:
+ result = ".flac"
+ else:
+ result = ".m4a"
+ else:
+ result = ".m4a"
+
+ return result
+
+ def _video_convert(self, path_file: str) -> str:
+ path_file_out = os.path.splitext(path_file)[0] + ".mp4"
+ result, _ = ffmpeg.input(path_file).output(path_file_out, map=0, c="copy").run()
+
+ return path_file_out
diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py
new file mode 100644
index 0000000..a995c01
--- /dev/null
+++ b/tidal_dl_ng/gui.py
@@ -0,0 +1,481 @@
+import math
+import sys
+from collections.abc import Callable
+
+try:
+ import qdarktheme
+ from PySide6 import QtCore, QtGui, QtWidgets
+except ImportError as e:
+ print(e)
+ print("Qt dependencies missing. Cannot start GUI. Please execute: 'pip install pyside6 pyqtdarktheme'")
+ sys.exit(1)
+
+import coloredlogs.converter
+from rich.progress import Progress
+from tidalapi import Album, Mix, Playlist, Quality, Track, UserPlaylist, Video
+from tidalapi.session import SearchTypes
+from tidal_dl_ng.config import Settings, Tidal
+from tidal_dl_ng.constants import QualityVideo
+from tidal_dl_ng.download import Download
+from tidal_dl_ng.logger import XStream, logger_cli, logger_gui
+from tidal_dl_ng.model.gui_data import ProgressBars, ResultSearch
+from tidal_dl_ng.ui.main import Ui_MainWindow
+from tidal_dl_ng.ui.spinner import QtWaitingSpinner
+from tidal_dl_ng.worker import Worker
+
+
+# TODO: Make more use of Exceptions
+class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
+ settings: Settings = None
+ tidal: Tidal = None
+ download: Download = None
+ threadpool: QtCore.QThreadPool = None
+ spinner: QtWaitingSpinner = None
+ spinner_start: QtCore.Signal = QtCore.Signal(QtWidgets.QWidget)
+ spinner_stop: QtCore.Signal = QtCore.Signal()
+ pb_item: QtWidgets.QProgressBar = None
+ s_item_advance: QtCore.Signal = QtCore.Signal(float)
+ s_item_name: QtCore.Signal = QtCore.Signal(str)
+ pb_list: QtWidgets.QProgressBar = None
+ s_list_advance: QtCore.Signal = QtCore.Signal(float)
+
+ def __init__(self, tidal: Tidal | None = None):
+ super().__init__()
+ self.setupUi(self)
+ # self.setGeometry(50, 50, 500, 300)
+ self.setWindowTitle("TIDAL Downloader Next Gen!")
+ # TODO: Fix icons (make them visible).
+ my_pixmap = QtGui.QPixmap("tidal_dl_ng/ui/icon.png")
+ my_icon = QtGui.QIcon("tidal_dl_ng/ui/icon.png")
+ self.setWindowIcon(my_icon)
+ tray = QtWidgets.QSystemTrayIcon()
+ tray.setIcon(my_icon)
+ tray.setVisible(True)
+
+ # Logging redirect.
+ XStream.stdout().messageWritten.connect(self._log_output)
+ # XStream.stderr().messageWritten.connect(self._log_output)
+
+ self.settings = Settings()
+ self.threadpool = QtCore.QThreadPool()
+
+ # TODO: Show GUI, create a progress bar showing the TIDAL querying progress.
+ self._init_tree_results(self.tr_results)
+ self._init_tree_lists(self.tr_lists_user)
+ self._init_progressbar()
+ self._populate_quality(self.cb_quality_audio, Quality)
+ self._populate_quality(self.cb_quality_video, QualityVideo)
+ self._populate_search_types(self.cb_search_type, SearchTypes)
+ self.apply_settings(self.settings)
+ self._init_signals()
+ self.thread_it(self.init_tidal, tidal)
+
+ logger_gui.debug("Multithreading with maximum %d threads" % self.threadpool.maxThreadCount())
+ logger_gui.debug("All setup.")
+
+ def init_tidal(self, tidal: Tidal = None):
+ result: bool = False
+
+ if tidal:
+ self.tidal = tidal
+ result = True
+ else:
+ self.tidal = Tidal(self.settings)
+
+ while True:
+ result = self.tidal.login(logger_gui.info)
+
+ if result:
+ break
+
+ if result:
+ self.download = Download(self.tidal.session, self.tidal.settings.data.skip_existing)
+
+ self.thread_it(self.populate_tree_lists, self.tidal)
+
+ def _init_progressbar(self):
+ self.pb_list = QtWidgets.QProgressBar()
+ self.pb_item = QtWidgets.QProgressBar()
+ pbs = [self.pb_list, self.pb_item]
+
+ for pb in pbs:
+ pb.setRange(0, 100)
+ # self.pb_progress.setVisible()
+ self.statusbar.addPermanentWidget(pb)
+
+ def progress_reset(self):
+ self.pb_list.setValue(0)
+ self.pb_item.setValue(0)
+
+ def _log_output(self, text):
+ display_msg = coloredlogs.converter.convert(text)
+
+ cursor: QtGui.QTextCursor = self.te_debug.textCursor()
+ cursor.movePosition(QtGui.QTextCursor.End)
+ cursor.insertHtml(display_msg)
+
+ self.te_debug.setTextCursor(cursor)
+ self.te_debug.ensureCursorVisible()
+
+ def _populate_quality(self, ui_target: QtWidgets.QComboBox, options: type[Quality | QualityVideo]):
+ for item in options:
+ ui_target.addItem(item.name, item)
+
+ def _populate_search_types(self, ui_target: QtWidgets.QComboBox, options: SearchTypes):
+ for item in options:
+ if item and not item.__name__ == "Artist":
+ ui_target.addItem(item.__name__, item)
+
+ self.cb_search_type.setCurrentIndex(1)
+
+ def _init_tree_results(self, tree: QtWidgets.QTableWidget):
+ tree.setColumnHidden(5, True)
+
+ def populate_tree_lists(self, tidal: Tidal):
+ # Start loading spinner
+ self.spinner_start.emit(self.tr_lists_user)
+ self.tr_results.clear()
+
+ # TODO: Refactor list item names to constants.
+ twi_playlists: QtWidgets.QTableWidgetItem = self.tr_lists_user.findItems(
+ "Playlists", QtCore.Qt.MatchExactly, 0
+ )[0]
+ twi_mixes: QtWidgets.QTableWidgetItem = self.tr_lists_user.findItems("Favorites", QtCore.Qt.MatchExactly, 0)[0]
+ twi_favorites: QtWidgets.QTableWidgetItem = self.tr_lists_user.findItems("Mixes", QtCore.Qt.MatchExactly, 0)[0]
+
+ user_playlists: [Playlist | UserPlaylist] = tidal.session.user.playlist_and_favorite_playlists()
+ user_mixes: [Mix] = tidal.session.mixes().categories[0].items
+ user_all: [Playlist | UserPlaylist | Mix] = user_playlists + user_mixes
+
+ for item in user_all:
+ if isinstance(item, UserPlaylist):
+ twi_child = QtWidgets.QTreeWidgetItem(twi_playlists)
+ name: str = item.name
+ info: str = f"({item.num_tracks + item.num_videos} Tracks)"
+ elif isinstance(item, Playlist):
+ twi_child = QtWidgets.QTreeWidgetItem(twi_mixes)
+ name: str = item.name
+ info: str = f"({item.num_tracks + item.num_videos} Tracks) {item.description}"
+ elif isinstance(item, Mix):
+ twi_child = QtWidgets.QTreeWidgetItem(twi_favorites)
+ name: str = item.title
+ info: str = item.sub_title
+
+ twi_child.setText(0, name)
+ twi_child.setText(1, info)
+ twi_child.setData(3, QtCore.Qt.ItemDataRole.UserRole, item)
+
+ # Stop load spinner
+ self.spinner_stop.emit()
+
+ def _init_tree_lists(self, tree: QtWidgets.QTreeWidget):
+ tree.setColumnWidth(0, 200)
+ tree.setColumnWidth(1, 300)
+ tree.setColumnHidden(2, True)
+ tree.expandAll()
+
+ # Connect the contextmenu
+ tree.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ tree.customContextMenuRequested.connect(self.menu_context_tree_lists)
+
+ def apply_settings(self, settings: Settings):
+ l_cb = [
+ {"element": self.cb_quality_audio, "setting": settings.data.quality_audio.name, "default_id": 1},
+ {"element": self.cb_quality_video, "setting": settings.data.quality_video.name, "default_id": 0},
+ ]
+
+ for item in l_cb:
+ idx = item["element"].findText(item["setting"])
+ if idx > -1:
+ item["element"].setCurrentIndex(idx)
+ else:
+ item["element"].setCurrentIndex(item["default_id"])
+
+ @QtCore.Slot(QtWidgets.QWidget)
+ def on_spinner_start(self, parent: QtWidgets.QWidget):
+ self.spinner = QtWaitingSpinner(parent, True, True)
+ self.spinner.setColor(QtGui.QColor(255, 255, 255))
+ self.spinner.start()
+
+ @QtCore.Slot()
+ def on_spinner_stop(self):
+ self.spinner.stop()
+ self.spinner = None
+
+ def menu_context_tree_lists(self, point):
+ # Infos about the node selected.
+ index = self.tr_lists_user.indexAt(point)
+
+ # Do not open menu if something went wrong or a parent node is clicked.
+ if not index.isValid() or not index.parent().data():
+ return
+
+ # We build the menu.
+ menu = QtWidgets.QMenu()
+ action = menu.addAction("Dowloading Playlist", lambda: self.thread_download_list_media(point))
+
+ menu.exec(self.tr_lists_user.mapToGlobal(point))
+
+ def thread_download_list_media(self, point):
+ self.thread_it(self.on_download_list_media, point)
+ self.thread_it(self.list_items_show, point=point)
+
+ def on_download_list_media(self, point: QtCore.QPoint):
+ item = self.tr_lists_user.itemAt(point)
+ media = item.data(3, QtCore.Qt.ItemDataRole.UserRole)
+
+ # TODO: Implement disable download button etc.
+ self.download_item(media, self.download)
+
+ def search_populate_results(self, query: str, type_media: SearchTypes):
+ self.tr_results.clear()
+
+ results: [ResultSearch] = self.search(query, [type_media])
+
+ self.populate_tree_results(results)
+
+ def populate_tree_results(self, results: [ResultSearch]):
+ self.tr_results.clear()
+
+ for item in results:
+ # Format seconds to mm:ss.
+ m, s = divmod(item.duration_sec, 60)
+ duration: str = f"{m:02d}:{s:02d}"
+ child = QtWidgets.QTreeWidgetItem()
+
+ child.setText(0, str(item.position))
+ child.setText(1, item.artist)
+ child.setText(2, item.title)
+ child.setText(3, item.album)
+ child.setText(4, duration)
+ child.setData(5, QtCore.Qt.ItemDataRole.UserRole, item.obj)
+
+ self.tr_results.addTopLevelItem(child)
+
+ def search(self, query: str, types_media: SearchTypes) -> [ResultSearch]:
+ result_search: [dict[str, *SearchTypes]] = self.tidal.session.search(query, models=types_media, limit=999)
+ result: [ResultSearch] = []
+
+ for media_type, l_media in result_search.items():
+ if isinstance(l_media, list):
+ result = result + self.search_result_to_model(l_media)
+
+ return result
+
+ def search_result_to_model(self, items: [*SearchTypes]) -> [ResultSearch]:
+ result = []
+
+ for idx, item in enumerate(items):
+ if isinstance(item, Track):
+ result_item: ResultSearch = ResultSearch(
+ position=idx,
+ artist=item.artist.name,
+ title=item.name,
+ album=item.album.name,
+ duration_sec=item.duration,
+ obj=item,
+ )
+
+ result.append(result_item)
+ elif isinstance(item, Video):
+ result_item: ResultSearch = ResultSearch(
+ position=idx,
+ artist=item.artist.name,
+ title=item.name,
+ album=item.album.name if item.album else "",
+ duration_sec=item.duration,
+ obj=item,
+ )
+
+ result.append(result_item)
+ elif isinstance(item, Playlist):
+ result_item: ResultSearch = ResultSearch(
+ position=idx,
+ artist=f"{item.promoted_artists[0].name}, ..." if item.promoted_artists else "",
+ title=item.name,
+ album="",
+ duration_sec=item.duration,
+ obj=item,
+ )
+
+ result.append(result_item)
+ elif isinstance(item, Album):
+ result_item: ResultSearch = ResultSearch(
+ position=idx,
+ artist=item.artist.name,
+ title="",
+ album=item.name,
+ duration_sec=item.duration,
+ obj=item,
+ )
+
+ result.append(result_item)
+
+ return result
+
+ def _init_signals(self):
+ self.b_download.clicked.connect(lambda: self.thread_it(self.on_download_results))
+ self.l_search.returnPressed.connect(
+ lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData())
+ )
+ self.b_search.clicked.connect(
+ lambda: self.search_populate_results(self.l_search.text(), self.cb_search_type.currentData())
+ )
+ self.cb_quality_audio.currentIndexChanged.connect(self.quality_set_audio)
+ self.cb_quality_video.currentIndexChanged.connect(self.quality_set_video)
+ self.tr_lists_user.itemClicked.connect(self.on_list_items_show)
+ self.spinner_start[QtWidgets.QWidget].connect(self.on_spinner_start)
+ self.spinner_stop.connect(self.on_spinner_stop)
+ self.s_item_advance.connect(self.progress_item)
+ self.s_item_name.connect(self.progress_item_name)
+ self.s_list_advance.connect(self.progress_list)
+
+ def progress_list(self, value: float):
+ self.pb_list.setValue(int(math.ceil(value)))
+
+ def progress_item(self, value: float):
+ self.pb_item.setValue(int(math.ceil(value)))
+
+ def progress_item_name(self, value: str):
+ self.pb_item.setFormat(f"%p% {value}")
+
+ def progress_list_name(self, value: str):
+ self.pb_list.setFormat(f"%p% {value}")
+
+ def quality_set_audio(self, index):
+ self.settings.data.quality_audio = Quality(self.cb_quality_audio.itemData(index).value)
+ self.settings.save()
+
+ if self.tidal:
+ self.tidal.settings_apply()
+
+ def quality_set_video(self, index):
+ self.settings.data.quality_video = QualityVideo(self.cb_quality_video.itemData(index).value)
+ self.settings.save()
+
+ if self.tidal:
+ self.tidal.settings_apply()
+
+ def on_list_items_show(self, item: QtWidgets.QTreeWidgetItem):
+ media_list: Album | Playlist = item.data(3, QtCore.Qt.ItemDataRole.UserRole)
+
+ # Only if clicked item is not a top level item.
+ if media_list:
+ self.list_items_show(media_list)
+
+ def list_items_show(self, media_list: Album | Playlist | None = None, point: QtCore.QPoint | None = None):
+ if point:
+ item = self.tr_lists_user.itemAt(point)
+ media_list = item.data(3, QtCore.Qt.ItemDataRole.UserRole)
+
+ media_items = media_list.items()
+ result = self.search_result_to_model(media_items)
+
+ self.populate_tree_results(result)
+
+ def thread_it(self, fn: Callable, *args, **kwargs):
+ # Any other args, kwargs are passed to the run function
+ worker = Worker(fn, *args, **kwargs)
+
+ # Execute
+ self.threadpool.start(worker)
+
+ def on_download_results(self):
+ self.b_download.setEnabled(False)
+ self.b_download.setText(f"Downloading...")
+
+ items: [QtWidgets.QTreeWidgetItem] = self.tr_results.selectedItems()
+
+ if len(items) == 0:
+ logger_gui.error("Please select a row first.")
+ else:
+ for item in items:
+ media: Track | Album | Playlist | Video = item.data(5, QtCore.Qt.ItemDataRole.UserRole)
+
+ result_dl: bool = self.download_item(media, self.download)
+
+ self.b_download.setText(f"Download")
+ self.b_download.setEnabled(True)
+
+ def download_item(self, media: Track | Album | Playlist | Video | Mix, dl: Download) -> bool:
+ # TODO: Refactor this. Move this logic to `Download` class and provide a generic interface.
+ self.progress_reset()
+ self.statusbar.showMessage("Download started...")
+
+ data_pb: ProgressBars = ProgressBars(
+ item=self.s_item_advance, list_item=self.s_list_advance, item_name=self.s_item_name
+ )
+ progress: Progress = Progress()
+
+ if isinstance(media, Track) or isinstance(media, Video):
+ if isinstance(media, Track):
+ file_template: str = self.settings.data.format_track
+ elif isinstance(media, Video):
+ file_template: str = self.settings.data.format_video
+
+ result_download, download_path_file = dl.item(
+ media=media,
+ path_base=self.settings.data.download_base_path,
+ file_template=file_template,
+ progress_gui=data_pb,
+ progress=progress,
+ fn_logger=logger_gui
+ )
+
+ if result_download:
+ logger_gui.info(f"Download successful: {download_path_file}")
+ else:
+ logger_gui.info(f"Download skipped (file exists): {download_path_file}")
+ elif isinstance(media, Album) or isinstance(media, Playlist) or isinstance(media, Mix):
+ file_template: str | bool = False
+
+ if isinstance(media, Album):
+ file_template: str = self.settings.data.format_album
+ progress_name = media.name
+ elif isinstance(media, Playlist):
+ file_template: str = self.settings.data.format_playlist
+ progress_name = media.name
+ elif isinstance(media, Mix):
+ file_template: str = self.settings.data.format_mix
+ progress_name = media.title
+ else:
+ progress_name = "List N/A"
+
+ self.progress_list_name(progress_name)
+
+ dl.list(
+ path_base=self.settings.data.download_base_path,
+ file_template=file_template,
+ list_media=media,
+ video_download=self.settings.data.video_download,
+ progress_gui=data_pb,
+ progress=progress,
+ download_delay=self.settings.data.download_delay,
+ fn_logger=logger_gui,
+ )
+
+ self.statusbar.showMessage("Download finished.", 2000)
+ progress.stop()
+
+ # TODO: Refactor to useful return value.
+ return True
+
+ def progress_reset(self):
+ self.pb_list.setValue(0)
+ self.pb_item.setValue(0)
+
+
+# TODO: Comment with Google Docstrings.
+def gui_activate(tidal: Tidal | None = None):
+ qdarktheme.enable_hi_dpi()
+ app = QtWidgets.QApplication(sys.argv)
+ qdarktheme.setup_theme()
+
+ window = MainWindow(tidal)
+ window.show()
+
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ gui_activate()
diff --git a/tidal_dl_ng/helper/__init__.py b/tidal_dl_ng/helper/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tidal_dl_ng/helper/__init__.py
diff --git a/tidal_dl_ng/helper/decorator.py b/tidal_dl_ng/helper/decorator.py
new file mode 100644
index 0000000..545f73b
--- /dev/null
+++ b/tidal_dl_ng/helper/decorator.py
@@ -0,0 +1,19 @@
+class SingletonMeta(type):
+ """
+ The Singleton class can be implemented in different ways in Python. Some
+ possible methods include: base class, decorator, metaclass. We will use the
+ metaclass because it is best suited for this purpose.
+ """
+
+ _instances = {}
+
+ def __call__(cls, *args, **kwargs):
+ """
+ Possible changes to the value of the `__init__` argument do not affect
+ the returned instance.
+ """
+ if cls not in cls._instances:
+ instance = super().__call__(*args, **kwargs)
+ cls._instances[cls] = instance
+
+ return cls._instances[cls]
diff --git a/tidal_dl_ng/helper/decryption.py b/tidal_dl_ng/helper/decryption.py
new file mode 100644
index 0000000..2fa2ec0
--- /dev/null
+++ b/tidal_dl_ng/helper/decryption.py
@@ -0,0 +1,53 @@
+import base64
+
+from Crypto.Cipher import AES
+from Crypto.Util import Counter
+
+
+def decrypt_security_token(security_token: str) -> (str, str):
+ """
+ Decrypts security token into key and nonce pair
+
+ security_token should match the securityToken value from the web response
+ """
+
+ # Do not change this
+ master_key = "UIlTTEMmmLfGowo/UC60x2H45W6MdGgTRfo/umg4754="
+
+ # Decode the base64 strings to ascii strings
+ master_key = base64.b64decode(master_key)
+ security_token = base64.b64decode(security_token)
+
+ # Get the IV from the first 16 bytes of the securityToken
+ iv = security_token[:16]
+ encrypted_st = security_token[16:]
+
+ # Initialize decryptor
+ decryptor = AES.new(master_key, AES.MODE_CBC, iv)
+
+ # Decrypt the security token
+ decrypted_st = decryptor.decrypt(encrypted_st)
+
+ # Get the audio stream decryption key and nonce from the decrypted security token
+ key = decrypted_st[:16]
+ nonce = decrypted_st[16:24]
+
+ return key, nonce
+
+
+def decrypt_file(path_file_encrypted: str, path_file_destination: str, key: str, nonce: str) -> None:
+ """
+ Decrypts an encrypted MQA file given the file, key and nonce
+ """
+
+ # Initialize counter and file decryptor
+ counter = Counter.new(64, prefix=nonce, initial_value=0)
+ decryptor = AES.new(key, AES.MODE_CTR, counter=counter)
+
+ # Open and decrypt
+ with open(path_file_encrypted, "rb") as f_src:
+ audio_decrypted = decryptor.decrypt(f_src.read())
+
+ # Replace with decrypted file
+ with open(path_file_destination, "wb") as f_dst:
+ f_dst.write(audio_decrypted)
diff --git a/tidal_dl_ng/helper/exceptions.py b/tidal_dl_ng/helper/exceptions.py
new file mode 100644
index 0000000..9ef6ddc
--- /dev/null
+++ b/tidal_dl_ng/helper/exceptions.py
@@ -0,0 +1,6 @@
+class LoginError(Exception):
+ pass
+
+
+class MediaUnknown(Exception):
+ pass
diff --git a/tidal_dl_ng/helper/path.py b/tidal_dl_ng/helper/path.py
new file mode 100644
index 0000000..c5bf988
--- /dev/null
+++ b/tidal_dl_ng/helper/path.py
@@ -0,0 +1,177 @@
+import os
+import pathlib
+import platform
+import re
+from collections.abc import Callable
+from pathlib import Path
+
+from pathvalidate import sanitize_filename
+from tidalapi import Album, Playlist, Track, UserPlaylist, Video, Mix
+
+
+def path_base():
+ if "XDG_CONFIG_HOME" in os.environ:
+ return os.environ["XDG_CONFIG_HOME"]
+ elif "HOME" in os.environ:
+ return os.environ["HOME"]
+ elif "HOMEDRIVE" in os.environ and "HOMEPATH" in os.environ:
+ return os.path.join(os.environ["HOMEDRIVE"], os.environ["HOMEPATH"])
+ else:
+ return os.path.abspath("./")
+
+
+def path_file_log():
+ return os.path.join(path_base(), ".tidal-dl-ng.log")
+
+
+def path_file_token():
+ return os.path.join(path_base(), ".tidal-dl-ng_token.json")
+
+
+def path_file_settings():
+ return os.path.join(path_base(), ".tidal-dl-ng_settings.json")
+
+
+def format_path_media(fmt_template: str, media: Track | Album | Playlist | UserPlaylist | Video | Mix) -> str:
+ result = fmt_template
+
+ # Search track format template for placeholder.
+ regex = r"\{(.+?)\}"
+ matches = re.finditer(regex, fmt_template, re.MULTILINE)
+ fn_format = get_fn_format(media)
+
+ for _matchNum, match in enumerate(matches, start=1):
+ template_str = match.group()
+ result_fmt = fn_format(match.group(1), media)
+
+ if result_fmt:
+ value = sanitize_filename(result_fmt)
+ result = result.replace(template_str, value)
+
+ return result
+
+
+def format_str_track(name: str, media: Track) -> str | bool:
+ result: str | bool = False
+
+ if name == "track_num":
+ result = str(media.track_num).rjust(2, "0")
+ elif name == "artist_name":
+ result = ", ".join(artist.name for artist in media.artists)
+ elif name == "track_title":
+ result = media.name
+
+ return result
+
+
+def format_str_album(name: str, media: Album) -> str | bool:
+ result: str | bool = False
+
+ if name == "album_title":
+ result = media.name
+ elif name == "artist_name":
+ result = media.artist.name
+
+ return result
+
+
+def format_str_playlist(name: str, media: Playlist) -> str | bool:
+ result: str | bool = False
+
+ if name == "playlist_name":
+ result = media.name
+
+ return result
+
+
+def format_str_mix(name: str, media: Mix) -> str | bool:
+ result: str | bool = False
+
+ if name == "mix_name":
+ result = media.title
+
+ return result
+
+
+def format_str_video(name: str, media: Video) -> str | bool:
+ result: str | bool = False
+
+ if name == "artist_name":
+ result = ", ".join(artist.name for artist in media.artists)
+ elif name == "track_title":
+ result = media.name
+
+ return result
+
+
+def get_fn_format(media: Track | Album | Playlist | UserPlaylist | Video | Mix) -> Callable:
+ result = None
+
+ if isinstance(media, Track):
+ result = format_str_track
+ elif isinstance(media, Album):
+ result = format_str_album
+ elif isinstance(media, (Playlist, UserPlaylist)):
+ result = format_str_playlist
+ elif isinstance(media, Mix):
+ result = format_str_mix
+ elif isinstance(media, Video):
+ result = format_str_video
+
+ return result
+
+
+def length_max_name_file() -> int:
+ system = platform.system()
+ result: int = 255
+
+ try:
+ if system in ['Darwin', 'Linux']:
+ result: int = os.pathconf('/', 'PC_NAME_MAX')
+ except Exception as e:
+ pass
+
+ return result
+
+
+def length_max_name_path() -> int:
+ system = platform.system()
+ result: int = 255
+
+ try:
+ if system in ['Darwin', 'Linux']:
+ result: int = os.pathconf('/', 'PC_PATH_MAX')
+ except Exception as e:
+ pass
+
+ return result
+
+
+def path_validate(path_file: str, adapt: bool = False) -> (bool, str):
+ result: bool = False
+ length_max_path: int = length_max_name_path()
+ length_max_file: int = length_max_name_file()
+ path, file = os.path.split(path_file)
+ filename, extension = os.path.splitext(file)
+
+ if len(path) >= length_max_path:
+ result = False
+
+ if adapt:
+ path = Path.home()
+
+ if len(file) >= length_max_file:
+ result = False
+
+ if adapt:
+ file = f'{filename[:length_max_file - len(extension + "_")]}_{extension}'
+
+ path_file = os.path.join(path, file)
+
+ return result, path_file
+
+
+def check_file_exists(path_file: str):
+ result = pathlib.Path(path_file).is_file()
+
+ return result
diff --git a/tidal_dl_ng/helper/url.py b/tidal_dl_ng/helper/url.py
new file mode 100644
index 0000000..cb52238
--- /dev/null
+++ b/tidal_dl_ng/helper/url.py
@@ -0,0 +1,15 @@
+def get_tidal_media_id(url_or_id_media: str) -> str:
+ id_dirty = url_or_id_media.rsplit("/", 1)[-1]
+ id = id_dirty.rsplit("?", 1)[0]
+
+ return id
+
+
+def get_tidal_media_type(url_media: str) -> str | bool:
+ result = False
+ url_split = url_media.split("/")[-2]
+
+ if len(url_split) > 1:
+ result = url_media.split("/")[-2]
+
+ return result
diff --git a/tidal_dl_ng/helper/wrapper.py b/tidal_dl_ng/helper/wrapper.py
new file mode 100644
index 0000000..5b88f59
--- /dev/null
+++ b/tidal_dl_ng/helper/wrapper.py
@@ -0,0 +1,23 @@
+from collections.abc import Callable
+
+
+class WrapperLogger:
+ fn_print: Callable = None
+
+ def __init__(self, fn_print: Callable):
+ self.fn_print = fn_print
+
+ def debug(self, value):
+ self.fn_print(value)
+
+ def warning(self, value):
+ self.fn_print(value)
+
+ def info(self, value):
+ self.fn_print(value)
+
+ def error(self, value):
+ self.fn_print(value)
+
+ def critical(self, value):
+ self.fn_print(value)
diff --git a/tidal_dl_ng/logger.py b/tidal_dl_ng/logger.py
new file mode 100644
index 0000000..83477a7
--- /dev/null
+++ b/tidal_dl_ng/logger.py
@@ -0,0 +1,65 @@
+import logging
+import sys
+
+import coloredlogs
+from PySide6 import QtCore
+
+
+class XStream(QtCore.QObject):
+ _stdout = None
+ _stderr = None
+ messageWritten = QtCore.Signal(str)
+
+ def flush(self):
+ pass
+
+ def fileno(self):
+ return -1
+
+ def write(self, msg):
+ if not self.signalsBlocked():
+ self.messageWritten.emit(msg)
+
+ @staticmethod
+ def stdout():
+ if not XStream._stdout:
+ XStream._stdout = XStream()
+ sys.stdout = XStream._stdout
+ return XStream._stdout
+
+ @staticmethod
+ def stderr():
+ if not XStream._stderr:
+ XStream._stderr = XStream()
+ sys.stderr = XStream._stderr
+ return XStream._stderr
+
+
+class QtHandler(logging.Handler):
+ def __init__(self):
+ logging.Handler.__init__(self)
+
+ def emit(self, record):
+ record = self.format(record)
+
+ if record:
+ # originally: XStream.stdout().write("{}\n".format(record))
+ XStream.stdout().write("%s\n" % record)
+
+
+logger_gui = logging.getLogger(__name__)
+handler_qt: QtHandler = QtHandler()
+# log_fmt: str = "[%(asctime)s] %(levelname)s: %(message)s"
+log_fmt: str = "> %(message)s"
+# formatter = logging.Formatter(log_fmt)
+formatter = coloredlogs.ColoredFormatter(fmt=log_fmt)
+handler_qt.setFormatter(formatter)
+logger_gui.addHandler(handler_qt)
+logger_gui.setLevel(logging.DEBUG)
+
+logger_cli = logging.getLogger(__name__)
+handler_stream: logging.StreamHandler = logging.StreamHandler()
+formatter = coloredlogs.ColoredFormatter(fmt=log_fmt)
+handler_stream.setFormatter(formatter)
+logger_cli.addHandler(handler_stream)
+logger_cli.setLevel(logging.DEBUG)
diff --git a/tidal_dl_ng/metadata.py b/tidal_dl_ng/metadata.py
new file mode 100644
index 0000000..1b53dea
--- /dev/null
+++ b/tidal_dl_ng/metadata.py
@@ -0,0 +1,163 @@
+import pathlib
+
+import mutagen
+import requests
+from mutagen import flac, mp4
+from mutagen.id3 import APIC, TALB, TCOM, TCOP, TDRC, TIT2, TOPE, TPE1, TRCK, TSRC, USLT
+
+
+class Metadata:
+ path_file: str = None
+ type_audio: str = None
+ title: str = None
+ album: str = None
+ albumartist: str = None
+ artists: [str] = None
+ copy_right: str = None
+ tracknumber: int = None
+ discnumber: int = None
+ totaldisc: int = None
+ totaltrack: int = None
+ date: str = None
+ composer: [str] = None
+ isrc: str = None
+ lyrics: str = None
+ path_cover: str = None
+ url_cover: str = None
+ m: mutagen.mp4.MP4 | mutagen.mp4.MP4 | mutagen.flac.FLAC = None
+
+ # TODO: What about videos?
+ def __init__(
+ self,
+ path_file: str,
+ type_audio: str = "",
+ album: str = "",
+ title: str = "",
+ artists: [str] = [""],
+ copy_right: str = "",
+ tracknumber: int = 0,
+ discnumber: int = 0,
+ totaltrack: int = 0,
+ totaldisc: int = 0,
+ composer: [str] = [""],
+ isrc: str = "",
+ albumartist: str = "",
+ date: str = "",
+ lyrics: str = "",
+ path_cover: str = "",
+ url_cover: str = "",
+ ):
+ self.path_file = path_file
+
+ if type_audio:
+ self.type_audio = type_audio
+ else:
+ self.type_audio = pathlib.Path(self.path_file).suffix[1:]
+
+ self.title = title
+ self.album = album
+ self.albumartist = albumartist
+ self.artists = artists
+ self.copy_right = copy_right
+ self.tracknumber = tracknumber
+ self.discnumber = discnumber
+ self.totaldisc = totaldisc
+ self.totaltrack = totaltrack
+ self.date = date
+ self.composer = composer
+ self.isrc = isrc
+ self.lyrics = lyrics
+ self.path_cover = path_cover
+ self.url_cover = url_cover
+ self.m: mutagen.MP4.MP4 | mutagen.m4a.M4A | mutagen.flac.FLAC | mutagen.mp3.MP3 = mutagen.File(self.path_file)
+
+ def _cover(self) -> bool:
+ result = False
+ data_cover = None
+
+ if self.url_cover:
+ try:
+ data_cover = requests.get(self.url_cover).content
+ except:
+ data_cover = ""
+ elif self.path_cover:
+ with open(self.path_cover, "rb") as f:
+ data_cover = f.read()
+
+ if data_cover:
+ if self.type_audio == "flac":
+ flac_cover = flac.Picture()
+ flac_cover.data = data_cover
+ flac_cover.mime = "image/jpeg"
+
+ self.m.clear_pictures()
+ self.m.add_picture(flac_cover)
+ elif self.type_audio == "mp3":
+ self.m.tags.add(APIC(encoding=3, data=data_cover))
+ elif self.type_audio in ["mp4", "m4a"]:
+ cover_mp4 = mp4.MP4Cover(data_cover)
+ self.m.tags["covr"] = [cover_mp4]
+
+ result = True
+
+ return result
+
+ def save(self):
+ if self.type_audio == "flac":
+ self.set_flac()
+ elif self.type_audio in ["mp3", "ts"]:
+ self.set_mp3()
+ elif self.type_audio in ["mp4", "m4a"]:
+ self.set_mp4()
+
+ self._cover()
+ self.m.save()
+
+ return True
+
+ def set_flac(self):
+ if not self.m.tags:
+ self.m.add_tags()
+
+ self.m.tags["title"] = self.title
+ self.m.tags["album"] = self.album
+ self.m.tags["albumartist"] = self.albumartist
+ self.m.tags["artist"] = ", ".join(self.artists)
+ self.m.tags["copyright"] = self.copy_right
+ self.m.tags["tracknumber"] = str(self.tracknumber)
+ self.m.tags["tracktotal"] = str(self.totaltrack)
+ self.m.tags["discnumber"] = str(self.discnumber)
+ self.m.tags["disctotal"] = str(self.totaldisc)
+ self.m.tags["date"] = self.date
+ self.m.tags["composer"] = ", ".join(self.composer)
+ self.m.tags["isrc"] = self.isrc
+ self.m.tags["lyrics"] = self.lyrics
+
+ def set_mp3(self):
+ if not self.m.tags:
+ self.m.add_tags()
+
+ self.m.tags.add(TIT2(encoding=3, text=self.title))
+ self.m.tags.add(TALB(encoding=3, text=self.album))
+ self.m.tags.add(TOPE(encoding=3, text=self.albumartist))
+ self.m.tags.add(TPE1(encoding=3, text=", ".join(self.artists)))
+ self.m.tags.add(TCOP(encoding=3, text=self.copy_right))
+ self.m.tags.add(TRCK(encoding=3, text=str(self.tracknumber)))
+ self.m.tags.add(TRCK(encoding=3, text=self.discnumber))
+ self.m.tags.add(TDRC(encoding=3, text=self.date))
+ self.m.tags.add(TCOM(encoding=3, text=", ".join(self.composer)))
+ self.m.tags.add(TSRC(encoding=3, text=self.isrc))
+ self.m.tags.add(USLT(encoding=3, lang="eng", desc="desc", text=self.lyrics))
+
+ def set_mp4(self):
+ self.m.tags["\xa9nam"] = self.title
+ self.m.tags["\xa9alb"] = self.album
+ self.m.tags["aART"] = self.albumartist
+ self.m.tags["\xa9ART"] = ", ".join(self.artists)
+ self.m.tags["cprt"] = self.copy_right
+ self.m.tags["trkn"] = [[self.tracknumber, self.totaltrack]]
+ self.m.tags["disk"] = [[self.discnumber, self.totaldisc]]
+ # self.m.tags['\xa9gen'] = self.genre
+ self.m.tags["\xa9day"] = self.date
+ self.m.tags["\xa9wrt"] = ", ".join(self.composer)
+ self.m.tags["\xa9lyr"] = self.lyrics
diff --git a/tidal_dl_ng/model/__init__.py b/tidal_dl_ng/model/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tidal_dl_ng/model/__init__.py
diff --git a/tidal_dl_ng/model/cfg.py b/tidal_dl_ng/model/cfg.py
new file mode 100644
index 0000000..e885074
--- /dev/null
+++ b/tidal_dl_ng/model/cfg.py
@@ -0,0 +1,71 @@
+from dataclasses import dataclass
+
+from tidal_dl_ng.constants import QualityVideo
+from dataclasses_json import dataclass_json
+from tidalapi import Quality
+
+
+@dataclass_json
+@dataclass
+class Settings:
+ skip_existing: bool = False
+ album_cover_save: bool = True
+ lyrics_save: bool = False
+ # TODO: Implement API KEY selection.
+ api_key_index: bool = 0
+ album_info_save: bool = False
+ video_download: bool = True
+ multi_thread: bool = False
+ download_delay: bool = True
+ download_base_path: str = "./download/"
+ quality_audio: Quality = Quality.low_320k
+ quality_video: QualityVideo = QualityVideo.P480
+ use_playlist_folder: bool = True
+ format_album: str = "Albums/{artist_name} - {title}/{track_num}. {artist_name} - {track_title}"
+ format_playlist: str = "Playlists/{playlist_name}/{artist_name} - {track_title}"
+ format_mix: str = "Mix/{mix_name}/{artist_name} - {track_title}"
+ format_track: str = "Tracks/{artist_name} - {track_title}"
+ format_video: str = "Videos/{artist_name} - {track_title}"
+ video_convert_mp4: bool = True
+
+
+@dataclass_json
+@dataclass
+class HelpSettings:
+ skip_existing: str = "Do not download, if file already exists."
+ album_cover_save: str = "Safe cover to album folder."
+ lyrics_save: str = "Safe lyrics to audio file."
+ api_key_index: str = "Set the device API KEY."
+ album_info_save: str = "Save album info to track?"
+ video_download: str = "Allow download of videos."
+ multi_thread: str = "Download several tracks in parallel."
+ download_delay: str = "Activate randomized download delay to mimic human behaviour."
+ download_base_path: str = "Where to store the downloaded media."
+ quality_audio: str = (
+ 'Desired audio download quality: "LOW" (96kbps), "HIGH" (320kbps), '
+ '"LOSSLESS" (16 Bit, 44,1 kHz), '
+ '"HI_RES" (MQA 24 Bit, 96 kHz), "HI_RES_LOSSLESS" (up to 24 Bit, 192 kHz)'
+ )
+ quality_video: str = 'Desired video download quality: "360", "480", "720", "1080"'
+ use_playlist_folder: str = (
+ "Should items of playlist be downloaded to a separate playlist folder or to the tracks folder?"
+ )
+ # TODO: Describe possible variables.
+ format_album: str = "Where to download albums and how to name the items."
+ format_playlist: str = "Where to download playlists and how to name the items."
+ format_mix: str = "Where to download mixes and how to name the items."
+ format_track: str = "Where to download tracks and how to name the items."
+ format_video: str = "Where to download videos and how to name the items."
+ video_convert_mp4: str = (
+ "Videos are downloaded as MPEG Transport Stream (TS) files. With this option each video "
+ "will be converted to MP4. FFMPEG must be installed and added to your 'PATH' variable."
+ )
+
+
+@dataclass_json
+@dataclass
+class Token:
+ token_type: str | None = None
+ access_token: str | None = None
+ refresh_token: str | None = None
+ expiry_time: float = 0.0
diff --git a/tidal_dl_ng/model/gui_data.py b/tidal_dl_ng/model/gui_data.py
new file mode 100644
index 0000000..0830011
--- /dev/null
+++ b/tidal_dl_ng/model/gui_data.py
@@ -0,0 +1,32 @@
+from dataclasses import dataclass
+
+try:
+ from PySide6 import QtCore
+
+ @dataclass
+ class ProgressBars:
+ item: QtCore.Signal
+ item_name: QtCore.Signal
+ list_item: QtCore.Signal
+except ModuleNotFoundError:
+ class ProgressBars:
+ pass
+
+
+# TODO: Remove if not used?
+@dataclass
+class UserList:
+ name: str
+ count: str
+ obj: object
+
+
+@dataclass
+class ResultSearch:
+ position: int
+ artist: str
+ title: str
+ album: str
+ duration_sec: int
+ obj: object
+
diff --git a/tidal_dl_ng/ui/__init__.py b/tidal_dl_ng/ui/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tidal_dl_ng/ui/__init__.py
diff --git a/tidal_dl_ng/ui/icon.png b/tidal_dl_ng/ui/icon.png
new file mode 100644
index 0000000..8bc9e0d
--- /dev/null
+++ b/tidal_dl_ng/ui/icon.png
Binary files differ
diff --git a/tidal_dl_ng/ui/main.py b/tidal_dl_ng/ui/main.py
new file mode 100644
index 0000000..c7d86a4
--- /dev/null
+++ b/tidal_dl_ng/ui/main.py
@@ -0,0 +1,456 @@
+################################################################################
+## Form generated from reading UI file 'main.ui'
+##
+## Created by: Qt User Interface Compiler version 6.6.1
+##
+## WARNING! All changes made in this file will be lost when recompiling UI file!
+################################################################################
+
+from PySide6.QtCore import QCoreApplication, QLocale, QMetaObject, QRect, QSize, Qt
+from PySide6.QtGui import QAction
+from PySide6.QtWidgets import (
+ QAbstractItemView,
+ QComboBox,
+ QHBoxLayout,
+ QLabel,
+ QLayout,
+ QLineEdit,
+ QMenu,
+ QMenuBar,
+ QPlainTextEdit,
+ QPushButton,
+ QSizePolicy,
+ QStatusBar,
+ QTreeWidget,
+ QTreeWidgetItem,
+ QVBoxLayout,
+ QWidget,
+)
+
+
+class Ui_MainWindow:
+ def setupUi(self, MainWindow):
+ if not MainWindow.objectName():
+ MainWindow.setObjectName("MainWindow")
+ MainWindow.resize(900, 700)
+ self.a_options = QAction(MainWindow)
+ self.a_options.setObjectName("a_options")
+ self.a_options.setEnabled(False)
+ self.a_options.setText("Options")
+ self.a_options.setIconText("Options")
+ # if QT_CONFIG(tooltip)
+ self.a_options.setToolTip("Options")
+ # endif // QT_CONFIG(tooltip)
+ # if QT_CONFIG(statustip)
+ self.a_options.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.a_options.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ self.w_central = QWidget(MainWindow)
+ self.w_central.setObjectName("w_central")
+ self.w_central.setEnabled(True)
+ sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
+ sizePolicy.setHorizontalStretch(100)
+ sizePolicy.setVerticalStretch(100)
+ sizePolicy.setHeightForWidth(self.w_central.sizePolicy().hasHeightForWidth())
+ self.w_central.setSizePolicy(sizePolicy)
+ # if QT_CONFIG(tooltip)
+ self.w_central.setToolTip("")
+ # endif // QT_CONFIG(tooltip)
+ # if QT_CONFIG(statustip)
+ self.w_central.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.w_central.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ # if QT_CONFIG(accessibility)
+ self.w_central.setAccessibleName("")
+ # endif // QT_CONFIG(accessibility)
+ # if QT_CONFIG(accessibility)
+ self.w_central.setAccessibleDescription("")
+ # endif // QT_CONFIG(accessibility)
+ self.horizontalLayout = QHBoxLayout(self.w_central)
+ self.horizontalLayout.setObjectName("horizontalLayout")
+ self.lh_main = QHBoxLayout()
+ self.lh_main.setObjectName("lh_main")
+ self.lh_main.setSizeConstraint(QLayout.SetNoConstraint)
+ self.tr_lists_user = QTreeWidget(self.w_central)
+ __qtreewidgetitem = QTreeWidgetItem()
+ __qtreewidgetitem.setText(1, "Info")
+ __qtreewidgetitem.setText(0, "Playlist")
+ self.tr_lists_user.setHeaderItem(__qtreewidgetitem)
+ __qtreewidgetitem1 = QTreeWidgetItem(self.tr_lists_user)
+ __qtreewidgetitem1.setFlags(Qt.ItemIsEnabled)
+ __qtreewidgetitem2 = QTreeWidgetItem(self.tr_lists_user)
+ __qtreewidgetitem2.setFlags(Qt.ItemIsEnabled)
+ __qtreewidgetitem3 = QTreeWidgetItem(self.tr_lists_user)
+ __qtreewidgetitem3.setFlags(Qt.ItemIsEnabled)
+ self.tr_lists_user.setObjectName("tr_lists_user")
+ # if QT_CONFIG(tooltip)
+ self.tr_lists_user.setToolTip("")
+ # endif // QT_CONFIG(tooltip)
+ # if QT_CONFIG(statustip)
+ self.tr_lists_user.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.tr_lists_user.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ # if QT_CONFIG(accessibility)
+ self.tr_lists_user.setAccessibleName("")
+ # endif // QT_CONFIG(accessibility)
+ # if QT_CONFIG(accessibility)
+ self.tr_lists_user.setAccessibleDescription("")
+ # endif // QT_CONFIG(accessibility)
+ self.tr_lists_user.setEditTriggers(QAbstractItemView.NoEditTriggers)
+ self.tr_lists_user.setProperty("showDropIndicator", False)
+ self.tr_lists_user.setIndentation(10)
+ self.tr_lists_user.setUniformRowHeights(True)
+ self.tr_lists_user.setSortingEnabled(True)
+ self.tr_lists_user.header().setCascadingSectionResizes(True)
+ self.tr_lists_user.header().setHighlightSections(True)
+ self.tr_lists_user.header().setProperty("showSortIndicator", True)
+
+ self.lh_main.addWidget(self.tr_lists_user)
+
+ self.lv_search_result = QVBoxLayout()
+ # ifndef Q_OS_MAC
+ self.lv_search_result.setSpacing(-1)
+ # endif
+ self.lv_search_result.setObjectName("lv_search_result")
+ self.lh_search = QHBoxLayout()
+ self.lh_search.setObjectName("lh_search")
+ self.l_search = QLineEdit(self.w_central)
+ self.l_search.setObjectName("l_search")
+ self.l_search.setAcceptDrops(False)
+ # if QT_CONFIG(tooltip)
+ self.l_search.setToolTip("")
+ # endif // QT_CONFIG(tooltip)
+ # if QT_CONFIG(statustip)
+ self.l_search.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.l_search.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ # if QT_CONFIG(accessibility)
+ self.l_search.setAccessibleName("")
+ # endif // QT_CONFIG(accessibility)
+ # if QT_CONFIG(accessibility)
+ self.l_search.setAccessibleDescription("")
+ # endif // QT_CONFIG(accessibility)
+ self.l_search.setLocale(QLocale(QLocale.English, QLocale.UnitedStates))
+ self.l_search.setText("")
+ self.l_search.setPlaceholderText("Type and press ENTER to search...")
+ self.l_search.setClearButtonEnabled(True)
+
+ self.lh_search.addWidget(self.l_search)
+
+ self.cb_search_type = QComboBox(self.w_central)
+ self.cb_search_type.setObjectName("cb_search_type")
+ # if QT_CONFIG(tooltip)
+ self.cb_search_type.setToolTip("")
+ # endif // QT_CONFIG(tooltip)
+ # if QT_CONFIG(statustip)
+ self.cb_search_type.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.cb_search_type.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ # if QT_CONFIG(accessibility)
+ self.cb_search_type.setAccessibleName("")
+ # endif // QT_CONFIG(accessibility)
+ # if QT_CONFIG(accessibility)
+ self.cb_search_type.setAccessibleDescription("")
+ # endif // QT_CONFIG(accessibility)
+ self.cb_search_type.setCurrentText("")
+ self.cb_search_type.setPlaceholderText("")
+
+ self.lh_search.addWidget(self.cb_search_type)
+
+ self.b_search = QPushButton(self.w_central)
+ self.b_search.setObjectName("b_search")
+ # if QT_CONFIG(statustip)
+ self.b_search.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.b_search.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ # if QT_CONFIG(accessibility)
+ self.b_search.setAccessibleName("")
+ # endif // QT_CONFIG(accessibility)
+ # if QT_CONFIG(accessibility)
+ self.b_search.setAccessibleDescription("")
+ # endif // QT_CONFIG(accessibility)
+ self.b_search.setText("Search")
+ # if QT_CONFIG(shortcut)
+ self.b_search.setShortcut("")
+ # endif // QT_CONFIG(shortcut)
+
+ self.lh_search.addWidget(self.b_search)
+
+ self.lv_search_result.addLayout(self.lh_search)
+
+ self.tr_results = QTreeWidget(self.w_central)
+ self.tr_results.setObjectName("tr_results")
+ self.tr_results.setEditTriggers(QAbstractItemView.NoEditTriggers)
+ self.tr_results.setProperty("showDropIndicator", False)
+ self.tr_results.setDragDropOverwriteMode(False)
+ self.tr_results.setAlternatingRowColors(False)
+ self.tr_results.setSelectionMode(QAbstractItemView.ExtendedSelection)
+ self.tr_results.setIndentation(10)
+ self.tr_results.setSortingEnabled(True)
+ self.tr_results.header().setProperty("showSortIndicator", True)
+ self.tr_results.header().setStretchLastSection(False)
+
+ self.lv_search_result.addWidget(self.tr_results)
+
+ self.lh_download = QHBoxLayout()
+ self.lh_download.setObjectName("lh_download")
+ self.l_quality_audio = QLabel(self.w_central)
+ self.l_quality_audio.setObjectName("l_quality_audio")
+ # if QT_CONFIG(tooltip)
+ self.l_quality_audio.setToolTip("")
+ # endif // QT_CONFIG(tooltip)
+ # if QT_CONFIG(statustip)
+ self.l_quality_audio.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.l_quality_audio.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ # if QT_CONFIG(accessibility)
+ self.l_quality_audio.setAccessibleName("")
+ # endif // QT_CONFIG(accessibility)
+ # if QT_CONFIG(accessibility)
+ self.l_quality_audio.setAccessibleDescription("")
+ # endif // QT_CONFIG(accessibility)
+ self.l_quality_audio.setText("Audio")
+ self.l_quality_audio.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter)
+
+ self.lh_download.addWidget(self.l_quality_audio)
+
+ self.cb_quality_audio = QComboBox(self.w_central)
+ self.cb_quality_audio.setObjectName("cb_quality_audio")
+ # if QT_CONFIG(tooltip)
+ self.cb_quality_audio.setToolTip("")
+ # endif // QT_CONFIG(tooltip)
+ # if QT_CONFIG(statustip)
+ self.cb_quality_audio.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.cb_quality_audio.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ # if QT_CONFIG(accessibility)
+ self.cb_quality_audio.setAccessibleName("")
+ # endif // QT_CONFIG(accessibility)
+ # if QT_CONFIG(accessibility)
+ self.cb_quality_audio.setAccessibleDescription("")
+ # endif // QT_CONFIG(accessibility)
+ self.cb_quality_audio.setCurrentText("")
+ self.cb_quality_audio.setPlaceholderText("")
+ self.cb_quality_audio.setFrame(True)
+
+ self.lh_download.addWidget(self.cb_quality_audio)
+
+ self.l_quality_video = QLabel(self.w_central)
+ self.l_quality_video.setObjectName("l_quality_video")
+ # if QT_CONFIG(tooltip)
+ self.l_quality_video.setToolTip("")
+ # endif // QT_CONFIG(tooltip)
+ # if QT_CONFIG(statustip)
+ self.l_quality_video.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.l_quality_video.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ # if QT_CONFIG(accessibility)
+ self.l_quality_video.setAccessibleName("")
+ # endif // QT_CONFIG(accessibility)
+ # if QT_CONFIG(accessibility)
+ self.l_quality_video.setAccessibleDescription("")
+ # endif // QT_CONFIG(accessibility)
+ self.l_quality_video.setText("Video")
+ self.l_quality_video.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter)
+
+ self.lh_download.addWidget(self.l_quality_video)
+
+ self.cb_quality_video = QComboBox(self.w_central)
+ self.cb_quality_video.setObjectName("cb_quality_video")
+ # if QT_CONFIG(tooltip)
+ self.cb_quality_video.setToolTip("")
+ # endif // QT_CONFIG(tooltip)
+ # if QT_CONFIG(statustip)
+ self.cb_quality_video.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.cb_quality_video.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ # if QT_CONFIG(accessibility)
+ self.cb_quality_video.setAccessibleName("")
+ # endif // QT_CONFIG(accessibility)
+ # if QT_CONFIG(accessibility)
+ self.cb_quality_video.setAccessibleDescription("")
+ # endif // QT_CONFIG(accessibility)
+ self.cb_quality_video.setCurrentText("")
+ self.cb_quality_video.setPlaceholderText("")
+
+ self.lh_download.addWidget(self.cb_quality_video)
+
+ self.b_download = QPushButton(self.w_central)
+ self.b_download.setObjectName("b_download")
+ # if QT_CONFIG(tooltip)
+ self.b_download.setToolTip("")
+ # endif // QT_CONFIG(tooltip)
+ # if QT_CONFIG(statustip)
+ self.b_download.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.b_download.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ # if QT_CONFIG(accessibility)
+ self.b_download.setAccessibleName("")
+ # endif // QT_CONFIG(accessibility)
+ # if QT_CONFIG(accessibility)
+ self.b_download.setAccessibleDescription("")
+ # endif // QT_CONFIG(accessibility)
+ self.b_download.setText("Download")
+ # if QT_CONFIG(shortcut)
+ self.b_download.setShortcut("")
+ # endif // QT_CONFIG(shortcut)
+
+ self.lh_download.addWidget(self.b_download)
+
+ self.lh_download.setStretch(0, 5)
+ self.lh_download.setStretch(2, 5)
+ self.lh_download.setStretch(4, 15)
+
+ self.lv_search_result.addLayout(self.lh_download)
+
+ self.te_debug = QPlainTextEdit(self.w_central)
+ self.te_debug.setObjectName("te_debug")
+ self.te_debug.setEnabled(True)
+ sizePolicy1 = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum)
+ sizePolicy1.setHorizontalStretch(0)
+ sizePolicy1.setVerticalStretch(0)
+ sizePolicy1.setHeightForWidth(self.te_debug.sizePolicy().hasHeightForWidth())
+ self.te_debug.setSizePolicy(sizePolicy1)
+ self.te_debug.setMaximumSize(QSize(16777215, 16777215))
+ self.te_debug.setAcceptDrops(False)
+ # if QT_CONFIG(tooltip)
+ self.te_debug.setToolTip("")
+ # endif // QT_CONFIG(tooltip)
+ # if QT_CONFIG(statustip)
+ self.te_debug.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.te_debug.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ # if QT_CONFIG(accessibility)
+ self.te_debug.setAccessibleName("")
+ # endif // QT_CONFIG(accessibility)
+ # if QT_CONFIG(accessibility)
+ self.te_debug.setAccessibleDescription("")
+ # endif // QT_CONFIG(accessibility)
+ self.te_debug.setUndoRedoEnabled(False)
+ self.te_debug.setReadOnly(True)
+
+ self.lv_search_result.addWidget(self.te_debug)
+
+ self.lh_main.addLayout(self.lv_search_result)
+
+ self.lh_main.setStretch(0, 40)
+ self.lh_main.setStretch(1, 60)
+
+ self.horizontalLayout.addLayout(self.lh_main)
+
+ MainWindow.setCentralWidget(self.w_central)
+ self.menubar = QMenuBar(MainWindow)
+ self.menubar.setObjectName("menubar")
+ self.menubar.setGeometry(QRect(0, 0, 900, 24))
+ # if QT_CONFIG(tooltip)
+ self.menubar.setToolTip("")
+ # endif // QT_CONFIG(tooltip)
+ # if QT_CONFIG(statustip)
+ self.menubar.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.menubar.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ # if QT_CONFIG(accessibility)
+ self.menubar.setAccessibleName("")
+ # endif // QT_CONFIG(accessibility)
+ # if QT_CONFIG(accessibility)
+ self.menubar.setAccessibleDescription("")
+ # endif // QT_CONFIG(accessibility)
+ self.m_file = QMenu(self.menubar)
+ self.m_file.setObjectName("m_file")
+ # if QT_CONFIG(tooltip)
+ self.m_file.setToolTip("")
+ # endif // QT_CONFIG(tooltip)
+ # if QT_CONFIG(statustip)
+ self.m_file.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.m_file.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ # if QT_CONFIG(accessibility)
+ self.m_file.setAccessibleName("")
+ # endif // QT_CONFIG(accessibility)
+ # if QT_CONFIG(accessibility)
+ self.m_file.setAccessibleDescription("")
+ # endif // QT_CONFIG(accessibility)
+ MainWindow.setMenuBar(self.menubar)
+ self.statusbar = QStatusBar(MainWindow)
+ self.statusbar.setObjectName("statusbar")
+ # if QT_CONFIG(tooltip)
+ self.statusbar.setToolTip("")
+ # endif // QT_CONFIG(tooltip)
+ # if QT_CONFIG(statustip)
+ self.statusbar.setStatusTip("")
+ # endif // QT_CONFIG(statustip)
+ # if QT_CONFIG(whatsthis)
+ self.statusbar.setWhatsThis("")
+ # endif // QT_CONFIG(whatsthis)
+ # if QT_CONFIG(accessibility)
+ self.statusbar.setAccessibleName("")
+ # endif // QT_CONFIG(accessibility)
+ # if QT_CONFIG(accessibility)
+ self.statusbar.setAccessibleDescription("")
+ # endif // QT_CONFIG(accessibility)
+ self.statusbar.setLayoutDirection(Qt.LeftToRight)
+ MainWindow.setStatusBar(self.statusbar)
+
+ self.menubar.addAction(self.m_file.menuAction())
+ self.m_file.addAction(self.a_options)
+
+ self.retranslateUi(MainWindow)
+
+ QMetaObject.connectSlotsByName(MainWindow)
+
+ # setupUi
+
+ def retranslateUi(self, MainWindow):
+ MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", "MainWindow", None))
+ ___qtreewidgetitem = self.tr_lists_user.headerItem()
+ ___qtreewidgetitem.setText(2, QCoreApplication.translate("MainWindow", "obj", None))
+
+ __sortingEnabled = self.tr_lists_user.isSortingEnabled()
+ self.tr_lists_user.setSortingEnabled(False)
+ ___qtreewidgetitem1 = self.tr_lists_user.topLevelItem(0)
+ ___qtreewidgetitem1.setText(0, QCoreApplication.translate("MainWindow", "Playlists", None))
+ ___qtreewidgetitem2 = self.tr_lists_user.topLevelItem(1)
+ ___qtreewidgetitem2.setText(0, QCoreApplication.translate("MainWindow", "Mixes", None))
+ ___qtreewidgetitem3 = self.tr_lists_user.topLevelItem(2)
+ ___qtreewidgetitem3.setText(0, QCoreApplication.translate("MainWindow", "Favorites", None))
+ self.tr_lists_user.setSortingEnabled(__sortingEnabled)
+
+ ___qtreewidgetitem4 = self.tr_results.headerItem()
+ ___qtreewidgetitem4.setText(5, QCoreApplication.translate("MainWindow", "obj", None))
+ ___qtreewidgetitem4.setText(4, QCoreApplication.translate("MainWindow", "Duration", None))
+ ___qtreewidgetitem4.setText(3, QCoreApplication.translate("MainWindow", "Album", None))
+ ___qtreewidgetitem4.setText(2, QCoreApplication.translate("MainWindow", "Title", None))
+ ___qtreewidgetitem4.setText(1, QCoreApplication.translate("MainWindow", "Artist", None))
+ ___qtreewidgetitem4.setText(0, QCoreApplication.translate("MainWindow", "#", None))
+ self.te_debug.setPlaceholderText(QCoreApplication.translate("MainWindow", "Logs...", None))
+ self.m_file.setTitle(QCoreApplication.translate("MainWindow", "File", None))
+
+ # retranslateUi
diff --git a/tidal_dl_ng/ui/main.ui b/tidal_dl_ng/ui/main.ui
new file mode 100644
index 0000000..e4584d1
--- /dev/null
+++ b/tidal_dl_ng/ui/main.ui
@@ -0,0 +1,590 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MainWindow</class>
+ <widget class="QMainWindow" name="MainWindow">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>900</width>
+ <height>700</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>MainWindow</string>
+ </property>
+ <widget class="QWidget" name="w_central">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Expanding">
+ <horstretch>100</horstretch>
+ <verstretch>100</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleName">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleDescription">
+ <string notr="true"/>
+ </property>
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <item>
+ <layout class="QHBoxLayout" name="lh_main" stretch="40,60">
+ <property name="sizeConstraint">
+ <enum>QLayout::SetNoConstraint</enum>
+ </property>
+ <item>
+ <widget class="QTreeWidget" name="tr_lists_user">
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleName">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleDescription">
+ <string notr="true"/>
+ </property>
+ <property name="editTriggers">
+ <set>QAbstractItemView::NoEditTriggers</set>
+ </property>
+ <property name="showDropIndicator" stdset="0">
+ <bool>false</bool>
+ </property>
+ <property name="indentation">
+ <number>10</number>
+ </property>
+ <property name="uniformRowHeights">
+ <bool>true</bool>
+ </property>
+ <property name="sortingEnabled">
+ <bool>true</bool>
+ </property>
+ <attribute name="headerCascadingSectionResizes">
+ <bool>true</bool>
+ </attribute>
+ <attribute name="headerHighlightSections">
+ <bool>true</bool>
+ </attribute>
+ <attribute name="headerShowSortIndicator" stdset="0">
+ <bool>true</bool>
+ </attribute>
+ <column>
+ <property name="text">
+ <string notr="true">Playlist</string>
+ </property>
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ </column>
+ <column>
+ <property name="text">
+ <string notr="true">Info</string>
+ </property>
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ </column>
+ <column>
+ <property name="text">
+ <string>obj</string>
+ </property>
+ </column>
+ <item>
+ <property name="text">
+ <string>Playlists</string>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="flags">
+ <set>ItemIsEnabled</set>
+ </property>
+ </item>
+ <item>
+ <property name="text">
+ <string>Mixes</string>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="flags">
+ <set>ItemIsEnabled</set>
+ </property>
+ </item>
+ <item>
+ <property name="text">
+ <string>Favorites</string>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="flags">
+ <set>ItemIsEnabled</set>
+ </property>
+ </item>
+ </widget>
+ </item>
+ <item>
+ <layout class="QVBoxLayout" name="lv_search_result" stretch="0,0,0,0">
+ <property name="spacing">
+ <number>-1</number>
+ </property>
+ <item>
+ <layout class="QHBoxLayout" name="lh_search">
+ <item>
+ <widget class="QLineEdit" name="l_search">
+ <property name="acceptDrops">
+ <bool>false</bool>
+ </property>
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleName">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleDescription">
+ <string notr="true"/>
+ </property>
+ <property name="locale">
+ <locale language="English" country="UnitedStates"/>
+ </property>
+ <property name="text">
+ <string notr="true"/>
+ </property>
+ <property name="placeholderText">
+ <string notr="true">Type and press ENTER to search...</string>
+ </property>
+ <property name="clearButtonEnabled">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QComboBox" name="cb_search_type">
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleName">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleDescription">
+ <string notr="true"/>
+ </property>
+ <property name="currentText">
+ <string notr="true"/>
+ </property>
+ <property name="placeholderText">
+ <string notr="true"/>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="b_search">
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleName">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleDescription">
+ <string notr="true"/>
+ </property>
+ <property name="text">
+ <string notr="true">Search</string>
+ </property>
+ <property name="shortcut">
+ <string notr="true"/>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QTreeWidget" name="tr_results">
+ <property name="editTriggers">
+ <set>QAbstractItemView::NoEditTriggers</set>
+ </property>
+ <property name="showDropIndicator" stdset="0">
+ <bool>false</bool>
+ </property>
+ <property name="dragDropOverwriteMode">
+ <bool>false</bool>
+ </property>
+ <property name="alternatingRowColors">
+ <bool>false</bool>
+ </property>
+ <property name="selectionMode">
+ <enum>QAbstractItemView::ExtendedSelection</enum>
+ </property>
+ <property name="indentation">
+ <number>10</number>
+ </property>
+ <property name="sortingEnabled">
+ <bool>true</bool>
+ </property>
+ <attribute name="headerShowSortIndicator" stdset="0">
+ <bool>true</bool>
+ </attribute>
+ <attribute name="headerStretchLastSection">
+ <bool>false</bool>
+ </attribute>
+ <column>
+ <property name="text">
+ <string>#</string>
+ </property>
+ </column>
+ <column>
+ <property name="text">
+ <string>Artist</string>
+ </property>
+ </column>
+ <column>
+ <property name="text">
+ <string>Title</string>
+ </property>
+ </column>
+ <column>
+ <property name="text">
+ <string>Album</string>
+ </property>
+ </column>
+ <column>
+ <property name="text">
+ <string>Duration</string>
+ </property>
+ </column>
+ <column>
+ <property name="text">
+ <string>obj</string>
+ </property>
+ </column>
+ </widget>
+ </item>
+ <item>
+ <layout class="QHBoxLayout" name="lh_download" stretch="5,0,5,0,15">
+ <item>
+ <widget class="QLabel" name="l_quality_audio">
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleName">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleDescription">
+ <string notr="true"/>
+ </property>
+ <property name="text">
+ <string notr="true">Audio</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QComboBox" name="cb_quality_audio">
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleName">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleDescription">
+ <string notr="true"/>
+ </property>
+ <property name="currentText">
+ <string notr="true"/>
+ </property>
+ <property name="placeholderText">
+ <string notr="true"/>
+ </property>
+ <property name="frame">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="l_quality_video">
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleName">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleDescription">
+ <string notr="true"/>
+ </property>
+ <property name="text">
+ <string notr="true">Video</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QComboBox" name="cb_quality_video">
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleName">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleDescription">
+ <string notr="true"/>
+ </property>
+ <property name="currentText">
+ <string notr="true"/>
+ </property>
+ <property name="placeholderText">
+ <string notr="true"/>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="b_download">
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleName">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleDescription">
+ <string notr="true"/>
+ </property>
+ <property name="text">
+ <string notr="true">Download</string>
+ </property>
+ <property name="shortcut">
+ <string notr="true"/>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QPlainTextEdit" name="te_debug">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Maximum">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="maximumSize">
+ <size>
+ <width>16777215</width>
+ <height>16777215</height>
+ </size>
+ </property>
+ <property name="acceptDrops">
+ <bool>false</bool>
+ </property>
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleName">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleDescription">
+ <string notr="true"/>
+ </property>
+ <property name="undoRedoEnabled">
+ <bool>false</bool>
+ </property>
+ <property name="readOnly">
+ <bool>true</bool>
+ </property>
+ <property name="placeholderText">
+ <string>Logs...</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QMenuBar" name="menubar">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>900</width>
+ <height>24</height>
+ </rect>
+ </property>
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleName">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleDescription">
+ <string notr="true"/>
+ </property>
+ <widget class="QMenu" name="m_file">
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleName">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleDescription">
+ <string notr="true"/>
+ </property>
+ <property name="title">
+ <string>File</string>
+ </property>
+ <addaction name="a_options"/>
+ </widget>
+ <addaction name="m_file"/>
+ </widget>
+ <widget class="QStatusBar" name="statusbar">
+ <property name="toolTip">
+ <string notr="true"/>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleName">
+ <string notr="true"/>
+ </property>
+ <property name="accessibleDescription">
+ <string notr="true"/>
+ </property>
+ <property name="layoutDirection">
+ <enum>Qt::LeftToRight</enum>
+ </property>
+ </widget>
+ <action name="a_options">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string notr="true">Options</string>
+ </property>
+ <property name="iconText">
+ <string notr="true">Options</string>
+ </property>
+ <property name="toolTip">
+ <string notr="true">Options</string>
+ </property>
+ <property name="statusTip">
+ <string notr="true"/>
+ </property>
+ <property name="whatsThis">
+ <string notr="true"/>
+ </property>
+ </action>
+ </widget>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/tidal_dl_ng/ui/spinner.py b/tidal_dl_ng/ui/spinner.py
new file mode 100644
index 0000000..124b7f4
--- /dev/null
+++ b/tidal_dl_ng/ui/spinner.py
@@ -0,0 +1,221 @@
+"""
+The MIT License (MIT)
+
+Copyright (c) 2012-2014 Alexander Turkin
+Copyright (c) 2014 William Hallatt
+Copyright (c) 2015 Jacob Dawid
+Copyright (c) 2016 Luca Weiss
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+"""
+
+import math
+
+from PySide6.QtCore import QRect, Qt, QTimer
+from PySide6.QtGui import QColor, QPainter
+from PySide6.QtWidgets import QWidget
+
+
+# Taken from https://github.com/COOLMSF/QtWaitingSpinnerForPyQt6 and adapted for PySide6.
+class QtWaitingSpinner(QWidget):
+ def __init__(
+ self, parent, centerOnParent=True, disableParentWhenSpinning=False, modality=Qt.WindowModality.NonModal
+ ):
+ super().__init__(parent)
+
+ self._centerOnParent = centerOnParent
+ self._disableParentWhenSpinning = disableParentWhenSpinning
+
+ # WAS IN initialize()
+ self._color = QColor(Qt.GlobalColor.black)
+ self._roundness = 100.0
+ self._minimumTrailOpacity = 3.14159265358979323846
+ self._trailFadePercentage = 80.0
+ self._revolutionsPerSecond = 1.57079632679489661923
+ self._numberOfLines = 20
+ self._lineLength = 10
+ self._lineWidth = 2
+ self._innerRadius = 10
+ self._currentCounter = 0
+ self._isSpinning = False
+
+ self._timer = QTimer(self)
+ self._timer.timeout.connect(self.rotate)
+ self.updateSize()
+ self.updateTimer()
+ self.hide()
+ # END initialize()
+
+ self.setWindowModality(modality)
+ self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
+
+ def paintEvent(self, QPaintEvent):
+ self.updatePosition()
+ painter = QPainter(self)
+ painter.fillRect(self.rect(), Qt.GlobalColor.transparent)
+ # Can't found in Qt6
+ # painter.setRenderHint(QPainter.Antialiasing, True)
+
+ if self._currentCounter >= self._numberOfLines:
+ self._currentCounter = 0
+
+ painter.setPen(Qt.PenStyle.NoPen)
+ for i in range(0, self._numberOfLines):
+ painter.save()
+ painter.translate(self._innerRadius + self._lineLength, self._innerRadius + self._lineLength)
+ rotateAngle = float(360 * i) / float(self._numberOfLines)
+ painter.rotate(rotateAngle)
+ painter.translate(self._innerRadius, 0)
+ distance = self.lineCountDistanceFromPrimary(i, self._currentCounter, self._numberOfLines)
+ color = self.currentLineColor(
+ distance, self._numberOfLines, self._trailFadePercentage, self._minimumTrailOpacity, self._color
+ )
+ painter.setBrush(color)
+ rect = QRect(0, int(-self._lineWidth / 2), int(self._lineLength), int(self._lineWidth))
+ painter.drawRoundedRect(rect, self._roundness, self._roundness, Qt.SizeMode.RelativeSize)
+ painter.restore()
+
+ def start(self):
+ self.updatePosition()
+ self._isSpinning = True
+ self.show()
+
+ if self.parentWidget and self._disableParentWhenSpinning:
+ self.parentWidget().setEnabled(False)
+
+ if not self._timer.isActive():
+ self._timer.start()
+ self._currentCounter = 0
+
+ def stop(self):
+ self._isSpinning = False
+ self.hide()
+
+ if self.parentWidget() and self._disableParentWhenSpinning:
+ self.parentWidget().setEnabled(True)
+
+ if self._timer.isActive():
+ self._timer.stop()
+ self._currentCounter = 0
+
+ def setNumberOfLines(self, lines):
+ self._numberOfLines = lines
+ self._currentCounter = 0
+ self.updateTimer()
+
+ def setLineLength(self, length):
+ self._lineLength = length
+ self.updateSize()
+
+ def setLineWidth(self, width):
+ self._lineWidth = width
+ self.updateSize()
+
+ def setInnerRadius(self, radius):
+ self._innerRadius = radius
+ self.updateSize()
+
+ def color(self):
+ return self._color
+
+ def roundness(self):
+ return self._roundness
+
+ def minimumTrailOpacity(self):
+ return self._minimumTrailOpacity
+
+ def trailFadePercentage(self):
+ return self._trailFadePercentage
+
+ def revolutionsPersSecond(self):
+ return self._revolutionsPerSecond
+
+ def numberOfLines(self):
+ return self._numberOfLines
+
+ def lineLength(self):
+ return self._lineLength
+
+ def lineWidth(self):
+ return self._lineWidth
+
+ def innerRadius(self):
+ return self._innerRadius
+
+ def isSpinning(self):
+ return self._isSpinning
+
+ def setRoundness(self, roundness):
+ self._roundness = max(0.0, min(100.0, roundness))
+
+ def setColor(self, color=Qt.GlobalColor.black):
+ self._color = QColor(color)
+
+ def setRevolutionsPerSecond(self, revolutionsPerSecond):
+ self._revolutionsPerSecond = revolutionsPerSecond
+ self.updateTimer()
+
+ def setTrailFadePercentage(self, trail):
+ self._trailFadePercentage = trail
+
+ def setMinimumTrailOpacity(self, minimumTrailOpacity):
+ self._minimumTrailOpacity = minimumTrailOpacity
+
+ def rotate(self):
+ self._currentCounter += 1
+ if self._currentCounter >= self._numberOfLines:
+ self._currentCounter = 0
+ self.update()
+
+ def updateSize(self):
+ size = int((self._innerRadius + self._lineLength) * 2)
+ self.setFixedSize(size, size)
+
+ def updateTimer(self):
+ self._timer.setInterval(int(1000 / (self._numberOfLines * self._revolutionsPerSecond)))
+
+ def updatePosition(self):
+ if self.parentWidget() and self._centerOnParent:
+ self.move(
+ int(self.parentWidget().width() / 2 - self.width() / 2),
+ int(self.parentWidget().height() / 2 - self.height() / 2),
+ )
+
+ def lineCountDistanceFromPrimary(self, current, primary, totalNrOfLines):
+ distance = primary - current
+ if distance < 0:
+ distance += totalNrOfLines
+ return distance
+
+ def currentLineColor(self, countDistance, totalNrOfLines, trailFadePerc, minOpacity, colorinput):
+ color = QColor(colorinput)
+ if countDistance == 0:
+ return color
+ minAlphaF = minOpacity / 100.0
+ distanceThreshold = int(math.ceil((totalNrOfLines - 1) * trailFadePerc / 100.0))
+ if countDistance > distanceThreshold:
+ color.setAlphaF(minAlphaF)
+ else:
+ alphaDiff = color.alphaF() - minAlphaF
+ gradient = alphaDiff / float(distanceThreshold + 1)
+ resultAlpha = color.alphaF() - gradient * countDistance
+ # If alpha is out of bounds, clip it.
+ resultAlpha = min(1.0, max(0.0, resultAlpha))
+ color.setAlphaF(resultAlpha)
+ return color
diff --git a/tidal_dl_ng/worker.py b/tidal_dl_ng/worker.py
new file mode 100644
index 0000000..84d5ac3
--- /dev/null
+++ b/tidal_dl_ng/worker.py
@@ -0,0 +1,31 @@
+from PySide6 import QtCore
+
+
+# Taken from https://www.pythonguis.com/tutorials/multithreading-pyside6-applications-qthreadpool/
+class Worker(QtCore.QRunnable):
+ """
+ Worker thread
+
+ Inherits from QRunnable to handler worker thread setup, signals and wrap-up.
+
+ :param callback: The function callback to run on this worker thread. Supplied args and
+ kwargs will be passed through to the runner.
+ :type callback: function
+ :param args: Arguments to pass to the callback function
+ :param kwargs: Keywords to pass to the callback function
+
+ """
+
+ def __init__(self, fn, *args, **kwargs):
+ super().__init__()
+ # Store constructor arguments (re-used for processing)
+ self.fn = fn
+ self.args = args
+ self.kwargs = kwargs
+
+ @QtCore.Slot() # QtCore.Slot
+ def run(self):
+ """
+ Initialise the runner function with passed args, kwargs.
+ """
+ self.fn(*self.args, **self.kwargs)
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..01da58f
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,17 @@
+[tox]
+skipsdist = true
+envlist = py310, py311, py312
+
+[gh-actions]
+python =
+ 3.10: py310
+ 3.11: py311
+ 3.12: py312
+
+[testenv]
+passenv = PYTHON_VERSION
+allowlist_externals = poetry
+commands =
+ poetry install -v
+ pytest --doctest-modules tests --cov --cov-config=pyproject.toml --cov-report=xml
+ mypy