├── .github └── workflows │ ├── integration.yml │ └── test.yml ├── .gitignore ├── .isort.cfg ├── CHANGELOG.md ├── DEVELOPMENT.md ├── LICENSE ├── README.md ├── docs └── docs0.png ├── integration_tests ├── config │ ├── basic.conf │ ├── hack.conf │ ├── lazy.conf │ └── lazy_hack.conf ├── conftest.py ├── test_config.py └── test_login_hack.py ├── makefile ├── mopidy_tidal ├── __init__.py ├── backend.py ├── context.py ├── ext.conf ├── full_models_mappers.py ├── helpers.py ├── library.py ├── login_hack.py ├── lru_cache.py ├── playback.py ├── playlists.py ├── ref_models_mappers.py ├── search.py ├── utils.py ├── web_auth_server.py └── workers.py ├── poetry.lock ├── pyproject.toml ├── test_requirements.txt └── tests ├── __init__.py ├── conftest.py ├── test_backend.py ├── test_cache_search_key.py ├── test_context.py ├── test_extension.py ├── test_full_model_mappers.py ├── test_helpers.py ├── test_image_getter.py ├── test_library.py ├── test_login_hack.py ├── test_lru_cache.py ├── test_playback.py ├── test_playlist.py ├── test_playlist_cache.py ├── test_ref_models_mappers.py ├── test_search.py ├── test_search_cache.py └── test_utils.py /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: Integration Test 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * 0" # weekly 6 | push: 7 | pull_request: 8 | types: [opened, synchronize] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: [3.9, "3.10", 3.11] 17 | mopidy-version: [3.3, 3.4, "git"] 18 | # Run all the matrix jobs, even if one fails. 19 | fail-fast: false 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Install Mopidy system-wide 24 | run: sudo apt update && sudo apt install mopidy libgirepository1.0-dev mpc 25 | - name: Install Poetry 26 | uses: abatilo/actions-poetry@v2 27 | - name: Set up Python 28 | uses: actions/setup-python@v4 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | cache: 'poetry' 32 | - name: Correct mopidy version 33 | run: | 34 | if [ ${{ matrix.mopidy-version }} != "git" ]; then 35 | poetry add mopidy@${{ matrix.mopidy-version }} 36 | else 37 | poetry add "git+https://github.com/mopidy/mopidy.git" 38 | fi 39 | poetry show 40 | - name: Install mopidy-tidal 41 | run: | 42 | rm poetry.lock 43 | poetry install --with complete 44 | - name: Integration test 45 | run: make integration-test 46 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | types: [opened, synchronize] 7 | workflow_dispatch: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: [3.9, "3.10", 3.11] 15 | # Run all the matrix jobs, even if one fails. 16 | fail-fast: false 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Install Poetry 21 | uses: abatilo/actions-poetry@v2 22 | - name: Set up Python 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | cache: 'poetry' 27 | - name: Install mopidy-tidal 28 | run: | 29 | poetry install 30 | - name: Lint 31 | run: | 32 | make lint 33 | - name: Test 34 | run: | 35 | make test 36 | - name: Upload coverage 37 | if: ${{ matrix.python-version == '3.10' }} 38 | uses: codecov/codecov-action@v3 39 | with: 40 | token: ${{ secrets.CODECOV_TOKEN }} 41 | fail_ci_if_error: true 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | .idea/ 165 | 166 | ### Python Patch ### 167 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 168 | poetry.toml 169 | 170 | # ruff 171 | .ruff_cache/ 172 | 173 | # LSP config files 174 | pyrightconfig.json 175 | 176 | # End of https://www.toptal.com/developers/gitignore/api/python 177 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | known_first_party=mopidy_tidal -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | #### v0.3.9 4 | 5 | - Bugfix: Handle get_urls() returning list in tidalapi v0.8.1 6 | 7 | #### v0.3.8 8 | 9 | - tidalapi version bump to v0.8.1 10 | - Bugfix: Reverted auto-login only when AUTO enabled. 11 | 12 | #### v0.3.7 13 | 14 | - tidalapi version bump to v0.8.0 15 | - Tests: Fixed unit tests 16 | 17 | #### v0.3.6 18 | 19 | - Bugfix: Fix missing images on tracks 20 | - Readme: PKCE logon details added 21 | - Print Mopidy-Tidal, Python-Tidal version info to log 22 | 23 | #### v0.3.5 24 | 25 | - Fix HI_RES_LOSSLESS playback with PKCE authentication 26 | - Added support for two stage PKCE authentication using HTTP web server 27 | - Add new categories (HiRes, Mixes & Radio, My Mixes) 28 | - Add helper functions (create_category_directories) for navigating sub-categories 29 | - Switch to using Stream MPEG-DASH MPD manifest directly for playback instead of direct URL 30 | - Refactor/cleanup backend, move load/save session to tidalapi. 31 | - Handle missing objects (ObjectNotFound) gracefully 32 | - Handle HTTP 429 (TooManyRequests) gracefully 33 | - Add auth_mode, login_server_port config params 34 | - Add HI_RES (MQA), add auth_method, login_server_port, AUTO login_method as alias 35 | - Fix missing pictures on some playlist types 36 | - Skip video_mixes when generating mix playlists 37 | - Rewrite test suite 38 | 39 | (Major thanks to [2e0byo](https://github.com/2e0byo) for test suite 40 | improvements, [quodrum-glas](https://github.com/quodrum-glas) for inspiration to use HTTP Server for PKCE 41 | authentication) 42 | 43 | #### v0.3.4 44 | 45 | - Added support for navigating For You, Explore pages. 46 | 47 | #### v0.3.3 48 | 49 | - Added HI_RES_LOSSLESS quality (Requires HiFi+ subscription) 50 | 51 | #### v0.3.2 52 | 53 | - Implemented a configurable `playlist_cache_refresh_secs` 54 | - Replace colons in cache filenames with hyphens to add FAT32/NTFS compatibility 55 | 56 | (Thanks [BlackLight](https://github.com/BlackLight) for the above PRs) 57 | 58 | #### v0.3.1 59 | 60 | - Added support for tidalapi 0.7.x. Tidalapi >=0.7.x is now required. 61 | - Added support for Moods, Mixes, track/album release date. 62 | - Speed, cache improvements and Iris bugfixes. 63 | - Overhauled Test suite 64 | - Support for playlist editing 65 | 66 | (Major thanks [BlackLight](https://github.com/BlackLight) and [2e0byo](https://github.com/2e0byo) for the above 67 | improvements and all testers involved) 68 | 69 | #### v0.2.8 70 | 71 | - Major caching improvements to avoid slow intialization at startup. Code cleanup, bugfixes and refactoring ( 72 | Thanks [BlackLight](https://github.com/BlackLight), [fmarzocca](https://github.com/fmarzocca)) 73 | - Reduced default album art, author and track image size. 74 | - Improved Iris integration 75 | 76 | #### v0.2.7 77 | 78 | - Use path extension for Tidal OAuth cache file 79 | - Improved error handling for missing images, unplayable albums 80 | - Improved playlist browsing 81 | 82 | #### v0.2.6 83 | 84 | - Improved reliability of OAuth cache file generation. 85 | - Added optional client_id & client_secret to [tidal] in mopidy config (thanks Glog78) 86 | - Removed username/pass, as it is not needed by OAuth (thanks tbjep) 87 | 88 | #### v0.2.5 89 | 90 | - Reload existing OAuth session on Mopidy restart 91 | - Added OAuth login support from tidalapi (thanks to greggilbert) 92 | 93 | #### v0.2.4 94 | 95 | - Added track caching (thanks to MrSurly and kingosticks) 96 | 97 | #### v0.2.3 98 | 99 | - fixed python 3 compatibility issues 100 | - Change dependency tidalapi4mopidy back to tidalapi (thanks to stevedenman) 101 | 102 | #### v0.2.2 103 | 104 | - added support browsing of favourite tracks, moods, genres and playlists (thanks to SERVCUBED) 105 | 106 | #### v0.2.1 107 | 108 | - implemented get_images method 109 | - updated tidal's api key 110 | 111 | #### v0.2.0 112 | 113 | - playlist support (read-only) 114 | - implemented artists lookup 115 | - high and low quality streams should now work correctly 116 | - cache search results (to be improved in next releases) 117 | 118 | #### v0.1.0 119 | 120 | - Initial release. 121 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Development guidelines 2 | Please refer to this guide for development guidelines and instructions. 3 | 4 | ### Installation 5 | 6 | - [Install poetry](https://python-poetry.org/docs/#installation) 7 | - run `poetry install` to install all dependencies, including for development 8 | - run `poetry shell` to activate the virtual environment 9 | 10 | ## Test Suite 11 | Mopidy-Tidal has a test suite which currently has 100% coverage. Ideally 12 | contributions would come with tests to keep this coverage up, but we can help in 13 | writing them if need be. 14 | 15 | Install using poetry, and then run: 16 | 17 | ```bash 18 | pytest tests/ -k "not gt_3_10" --cov=mopidy_tidal --cov-report=html 19 | --cov-report=term-missing --cov-branch 20 | ``` 21 | 22 | substituting the correct python version (e.g. `-k "not gt_3.8"`). This is 23 | unlikely to be necessary beyond 3.9 as the python language has become very 24 | standard. It's only really needed to exclude a few tests which check that 25 | dict-like objects behave the way modern dicts do, with `|`. 26 | 27 | If you are on *nix, you can simply run: 28 | 29 | ```bash 30 | make test 31 | ``` 32 | 33 | Currently the code is not very heavily documented. The easiest way to see how 34 | something is supposed to work is probably to have a look at the tests. 35 | 36 | 37 | ### Code Style 38 | Code should be formatted with `isort` and `black`: 39 | 40 | ```bash 41 | isort --profile=black mopidy_tidal tests 42 | black mopidy_tidal tests 43 | ``` 44 | 45 | if you are on *nix you can run: 46 | 47 | ```bash 48 | make format 49 | ``` 50 | 51 | The CI workflow will fail on linting as well as test failures. 52 | 53 | ### Installing a development version system-wide 54 | 55 | ```bash 56 | rm -rf dist 57 | poetry build 58 | pip install dist/*.whl 59 | ``` 60 | 61 | This installs the built package, without any of the development dependencies. 62 | If you are on *nix you can just run: 63 | 64 | ```bash 65 | make install 66 | ``` 67 | 68 | ### Running mopidy against development code 69 | 70 | Mopidy can be run against a local (development) version of Mopidy-Tidal. There 71 | are two ways to do this: using the system mopidy installation to provide audio 72 | support, or installing `PyGObject` inside the virtualenv. The former is 73 | recommended by Mopidy; the latter is used in our integration tests for two reasons: 74 | 75 | - at the time of writing, poetry system-site-packages support is broken 76 | - when integration testing, the version of PyGObject should be pinned (reproducible builds) 77 | 78 | To install a completely isolated mopidy inside the virtualenv with `PyGObject`, 79 | `mopidy-local` and `mopidy-iris` run 80 | 81 | ```bash 82 | poetry install --with complete 83 | ``` 84 | 85 | This will compile a shim for gobject. On any system other than a Raspberry 86 | pi this will not take more than a minute, and is a once off. 87 | 88 | Alternatively you can use a virtualenv which can see system-site-packages (which 89 | still needs mopidy installed locally, as plugins use pip to register 90 | themselves). Until [#6035](https://github.com/python-poetry/poetry/issues/6035) 91 | is resolved this requires a hack: 92 | 93 | ```bash 94 | python -m venv .venv --system-site-packages 95 | source ".venv/bin/activate" #or activate.csh or activate.fish or activate.ps1 as required 96 | poetry install 97 | ``` 98 | 99 | *nix users can just run 100 | 101 | ```bash 102 | make system-venv 103 | source ".venv/bin/activate" #or activate.csh or activate.fish 104 | ``` 105 | (Make runs in a subshell and cannot modify the parent shell, so there's no way 106 | by design of entering the venv permanently from within the makefile.) 107 | 108 | 109 | In either case, run `mopidy` inside the virtualenv to launch mopidy with your 110 | development version of Mopidy-Tidal. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mopidy-Tidal 2 | 3 | [![Latest PyPI version](https://img.shields.io/pypi/v/Mopidy-Tidal.svg?style=flat)](https://github.com/tehkillerbee/mopidy-tidal) 4 | [![Number of PyPI downloads](https://img.shields.io/pypi/dm/Mopidy-Tidal.svg?style=flat)](https://github.com/tehkillerbee/mopidy-tidal) 5 | [![codecov](https://codecov.io/gh/tehkillerbee/mopidy-tidal/branch/master/graph/badge.svg?token=cTJDQ646wy)](https://codecov.io/gh/tehkillerbee/mopidy-tidal) 6 | 7 | Mopidy Extension for Tidal music service integration. 8 | 9 | ### Changelog 10 | 11 | Find the latest changelog [here](CHANGELOG.md) 12 | 13 | ### Contributions 14 | 15 | - Current maintainer: [tehkillerbee](https://github.com/tehkillerbee) 16 | - Original author: [mones88](https://github.com/mones88) 17 | - [Contributors](https://github.com/tehkillerbee/mopidy-tidal/graphs/contributors) 18 | 19 | Questions related to Mopidy-Tidal, feature suggestions, bug reports and Pull Requests are very welcome. 20 | 21 | If you are experiencing playback issues unrelated to this plugin, please report this to the Mopidy-Tidal issue tracker 22 | and/or check [Python-Tidal/Tidalapi repository](https://github.com/tamland/python-tidal) for relevant issues. 23 | 24 | ### Development guidelines 25 | 26 | Please refer to [this document](DEVELOPMENT.md) to get you started. 27 | 28 | ### Getting started 29 | 30 | First install and configure Mopidy as per the instructions 31 | listed [here](https://docs.mopidy.com/en/latest/installation/). It is encouraged to install Mopidy as a systemd service, 32 | as per the instructions listed [here](https://docs.mopidy.com/en/latest/running/service/). 33 | 34 | After installing Mopidy, you can now proceed installing the plugins that you require, including Mopidy-Tidal. : 35 | 36 | ``` 37 | sudo pip3 install Mopidy-Tidal 38 | ``` 39 | 40 | Poetry can also be used to install mopidy-tidal and its dependencies. 41 | 42 | ``` 43 | cd 44 | poetry install 45 | ``` 46 | 47 | ##### Note: Make sure to install the Mopidy-Tidal plugin in the same python venv used by your Mopidy installation. Otherwise, the plugin will NOT be detected. 48 | 49 | ### Install from latest sources 50 | 51 | In case you are upgrading your Mopidy-Tidal installation from the latest git sources, make sure to do a force upgrade 52 | from the source root (remove both mopidy-tidal and python-tidal), followed by a (service) restart. 53 | 54 | ``` 55 | cd 56 | sudo pip3 uninstall mopidy-tidal 57 | sudo pip3 uninstall tidalapi 58 | sudo pip3 install . 59 | sudo systemctl restart mopidy 60 | ``` 61 | 62 | ## Dependencies 63 | 64 | ### Python 65 | 66 | Released versions of Mopidy-Tidal have the same requirement as the Mopidy 67 | version they depend on. Development code may depend on unreleased features. 68 | At the time of writing we require python >= 3.9 in anticipation of mopidy 3.5.0. 69 | 70 | ### Python-Tidal 71 | 72 | Mopidy-Tidal requires the Python-Tidal API (tidalapi) to function. This is usually installed automatically when 73 | installing Mopidy-Tidal. 74 | In some cases, Python-Tidal stops working due to Tidal changing their API keys. 75 | 76 | When this happens, it will usually be necessary to upgrade the Python-Tidal API plugin manually 77 | 78 | ``` 79 | sudo pip3 install --upgrade tidalapi 80 | ``` 81 | 82 | After upgrading Python-Tidal/tidalapi, it will often be necessary to delete the existing json file and restart mopidy. 83 | The file is usually stored in `/var/lib/mopidy/tidal/tidal-.json`, depending on your system configuration. 84 | 85 | ### GStreamer 86 | 87 | When using High and Low quality, be sure to install gstreamer bad-plugins, e.g.: 88 | 89 | ``` 90 | sudo apt-get install gstreamer1.0-plugins-bad 91 | ``` 92 | 93 | This is mandatory to be able to play m4a streams and for playback of MPEG-DASH streams. Otherwise, you will likely get 94 | an error: 95 | 96 | ``` 97 | WARNING [MainThread] mopidy.audio.actor Could not find a application/x-hls decoder to handle media. 98 | WARNING [MainThread] mopidy.audio.gst GStreamer warning: No decoder available for type 'application/x-hls'. 99 | ERROR [MainThread] mopidy.audio.gst GStreamer error: Your GStreamer installation is missing a plug-in. 100 | ``` 101 | 102 | ## Configuration 103 | 104 | Before starting Mopidy, you must add configuration for Mopidy-Tidal to your Mopidy configuration file, if it is not 105 | already present. 106 | Run `sudo mopidyctl config` to see the current effective config used by Mopidy 107 | 108 | The configuration is usually stored in `/etc/mopidy/mopidy.conf`, depending on your system configuration. Add the 109 | configuration listed below in the respective configuration file and set the relevant fields. 110 | 111 | Restart the Mopidy service after adding/changing the Tidal configuration 112 | `sudo systemctl restart mopidy` 113 | 114 | ### Plugin configuration 115 | 116 | The configuration is usually stored in `/etc/mopidy/mopidy.conf`, depending on your system configuration. Add the 117 | configuration listed below in the respective configuration file and set the relevant fields. 118 | 119 | ``` 120 | [tidal] 121 | enabled = true 122 | quality = LOSSLESS 123 | #playlist_cache_refresh_secs = 0 124 | #lazy = true 125 | #login_method = AUTO 126 | #auth_method = OAUTH 127 | #login_server_port = 8989 128 | #client_id = 129 | #client_secret = 130 | ``` 131 | 132 | ### Plugin parameters 133 | 134 | * **quality:** Set to one of the following quality types: `HI_RES_LOSSLESS`, `LOSSLESS`, `HIGH` or `LOW`. All quality 135 | levels are available with the standard (paid) subscription. 136 | * `HI_RES_LOSSLESS` provides HiRes lossless FLAC if available for the selected media. 137 | * `LOSSLESS` provides HiFi lossless FLAC if available. 138 | * `HIGH`, `LOW` provides M4A in either 320kbps or 96kbps bitrates. 139 | * **auth_method (Optional):**: Select the authentication mode to use. 140 | * `OAUTH` used as default and currently allows playback in all available qualities, including `HI_RES_LOSSLESS`. 141 | * `PKCE` is optional, and allows `HI_RES_LOSSLESS`, `LOSSLESS` playback. This method uses the HTTP server for 142 | completing the second authentication step. 143 | * **login_web_port (Optional):**: Port to use for the authentication HTTP Server. Default port: `8989`, i.e. web server 144 | will be available on `:8989` eg. `localhost:8989`. 145 | * **playlist_cache_refresh_secs (Optional):** Tells if (and how often) playlist 146 | content should be refreshed upon lookup. 147 | * `0` (default): The default value (`0`) means that playlists won't be refreshed after the 148 | extension has started, unless they are explicitly modified from mopidy. 149 | * `>0`: A non-zero value expresses for how long (in seconds) a cached playlist is 150 | considered valid. For example, a value of `300` means that the cached snapshot 151 | of a playlist will be used if a new `lookup` occurs within 5 minutes from the 152 | previous one, but the playlist will be re-loaded via API if a lookup request 153 | occurs later. 154 | 155 | The preferred setting for this value is a trade-off between UI responsiveness 156 | and responsiveness to changes. If you perform a lot of playlist changes from 157 | other clients and you want your playlists to be instantly updated on mopidy, 158 | then you may choose a low value for this setting, albeit this will result in 159 | longer waits when you look up a playlist, since it will be fetched from 160 | upstream most of the times. If instead you don't perform many playlist 161 | modifications, then you may choose a value for this setting within the range of 162 | hours - or days, or even leave it to zero so playlists will only be refreshed 163 | when mopidy restarts. This means that it will take longer for external changes 164 | to be reflected in the loaded playlists, but the UI will be more responsive 165 | when playlists are looked up. A value of zero makes the behaviour of 166 | `mopidy-tidal` quite akin to the current behaviour of `mopidy-spotify`. 167 | * **lazy (Optional):**: Whether to connect lazily, i.e. when required, rather than 168 | at startup. 169 | * `false` (default): Lazy mode is off by default for backwards compatibility and to make the first login easier ( 170 | since mopidy will not block in lazy mode until you try to access Tidal). 171 | * `true`: Mopidy-Tidal will only try to connect when something 172 | tries to access a resource provided by Tidal. 173 | 174 | Since failed connections due to 175 | network errors do not overwrite cached credentials (see below) and Mopidy 176 | handles exceptions in plugins gracefully, lazy mode allows Mopidy to continue to 177 | run even with intermittent or non-existent network access (although you will 178 | obviously be unable to play any streamed music if you cannot access the 179 | network). When the network comes back Mopidy will be able to play tidal content 180 | again. This may be desirable on mobile internet connections, or when a server 181 | is used with multiple backends and a failure with Tidal should not prevent 182 | other services from running. 183 | * **login_method (Optional):**: This setting configures the auth login process. 184 | * `BLOCK` (default): The user is REQUIRED to complete the OAuth login flow, otherwise mopidy will hang. 185 | * `AUTO`/`HACK`: Mopidy will start as usual but the user will be prompted to complete the auth login flow by 186 | visiting a link. The link is provided as a dummy track and as a log message. 187 | * **client_id, _secret (Optional):**: Tidal API `client_id`, `client_secret` can be overridden by the user if necessary. 188 | 189 | ## Login 190 | 191 | Before TIDAL can be accessed from Mopidy, it is necessary to login, using either the OAuth or PKCE flow described below. 192 | 193 | Both OAuth and PKCE flow require visiting an URL to complete the login process. The URL can be found either: 194 | 195 | * In the Mopidy logs, as listed below 196 | 197 | ``` 198 | journalctl -u mopidy | tail -10 199 | ... 200 | Visit link.tidal.com/AAAAA to log in, the code will expire in 300 seconds. 201 | ``` 202 | 203 | * Displayed in the Mopidy web client as a "dummy" track when the `login_method` is set to `AUTO` 204 | * By playing the "dummy" track, a QR code will be displayed and the URL will be read aloud. 205 | * Displayed as a link when accessing the auth. webserver `localhost:` when PKCE authentication is used. 206 | 207 | ### General login tips 208 | 209 | * When the `login_method` is set to BLOCK, all login processes are **blocking** actions, so Mopidy + Web interface will 210 | stop loading until you approve the application. 211 | * When using the `lazy` mode, the login process will not be started until browsing the TIDAL related directories. 212 | * Session is reloaded automatically when Mopidy is restarted. It will be necessary to perform these steps again if the 213 | json file is moved/deleted. 214 | 215 | ### OAuth Flow 216 | 217 | When using OAuth authentication mode, you will be prompted to visit an URL to login. 218 | This URL will be displayed in the Mopidy logs and/or in the Mopidy-Web client as a dummy track. 219 | 220 | When prompted, visit the URL to complete the OAuth login flow. No extra steps are required. 221 | 222 | ### PKCE Flow 223 | 224 | For `HI_RES` and `HI_RES_LOSSLESS` playback, the PKCE authentication method is required. 225 | This PKCE flow also requires visiting an URL but requires an extra step to return the Tidal response URL to Python-Tidal 226 | 227 | 1. Visit the URL listed in the logs or in the Mopidy client. Usually, this should be `:`, eg. 228 | localhost:8989. When running a headless server, make sure to use the correct IP. 229 | 2. You will be greeted with a link to the TIDAL login page and a form where you can paste the response URL: 230 | ![web_auth](docs/docs0.png) 231 | 3. Click the link and visit the TIDAL URL and login using your normal credentials. 232 | 4. Copy the complete URL of the page you were redirected to. This webpage normally lists "Oops" or something similar; 233 | this is normal. 234 | 5. Paste this URL into the web authentication page and click "Submit". You can now close the web page. 235 | 6. Refresh your Mopidy frontend. You should now be able to browse as usual. -------------------------------------------------------------------------------- /docs/docs0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tehkillerbee/mopidy-tidal/abead0f347b79f94a3615b2a8d63aba62f98980b/docs/docs0.png -------------------------------------------------------------------------------- /integration_tests/config/basic.conf: -------------------------------------------------------------------------------- 1 | [tidal] 2 | quality=LOSSLESS -------------------------------------------------------------------------------- /integration_tests/config/hack.conf: -------------------------------------------------------------------------------- 1 | [tidal] 2 | quality = LOSSLESS 3 | lazy = false 4 | login_method = HACK -------------------------------------------------------------------------------- /integration_tests/config/lazy.conf: -------------------------------------------------------------------------------- 1 | [tidal] 2 | quality=LOSSLESS 3 | lazy=true -------------------------------------------------------------------------------- /integration_tests/config/lazy_hack.conf: -------------------------------------------------------------------------------- 1 | [tidal] 2 | quality = LOSSLESS 3 | lazy = true 4 | login_method = HACK -------------------------------------------------------------------------------- /integration_tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from contextlib import contextmanager 3 | from pathlib import Path 4 | 5 | import pexpect 6 | import pytest 7 | 8 | 9 | class AssertiveChild: 10 | def __init__(self, child): 11 | self.child = child 12 | 13 | def expect(self, msg, timeout=3): 14 | try: 15 | self.child.expect(msg, timeout=timeout) 16 | except pexpect.TIMEOUT as e: 17 | raise AssertionError(f"Did not recieve '{msg}' within {timeout} S.") from e 18 | 19 | 20 | @pytest.fixture 21 | def spawn(): 22 | @contextmanager 23 | def _spawn(*args, **kwargs): 24 | kwargs["encoding"] = kwargs.get("encoding", "utf8") 25 | child = pexpect.spawn(*args, **kwargs) 26 | child.logfile = sys.stdout 27 | yield AssertiveChild(child) 28 | assert child.terminate(force=True), "Failed to kill process" 29 | 30 | return _spawn 31 | 32 | 33 | @pytest.fixture 34 | def config_dir(): 35 | return Path(__file__).parent / "config" 36 | 37 | 38 | # import pytest 39 | 40 | # @pytest.fixture 41 | # def mopidy() 42 | -------------------------------------------------------------------------------- /integration_tests/test_config.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def test_basic_config_loads_tidal_generates_auth_url(spawn, config_dir): 5 | config = config_dir / "basic.conf" 6 | with spawn(f"mopidy --config {config.resolve()}") as child: 7 | child.expect("Connecting to TIDAL... Quality = LOSSLESS") 8 | child.expect("Visit https://link.tidal.com/.* to log in") 9 | 10 | 11 | def test_lazy_config_no_connect_to_tidal(spawn, config_dir): 12 | config = config_dir / "lazy.conf" 13 | with spawn(f"mopidy --config {config.resolve()}") as child: 14 | child.expect("Connecting to TIDAL... Quality = LOSSLESS") 15 | with pytest.raises(AssertionError): 16 | child.expect("Visit https://link.tidal.com/.* to log in") 17 | 18 | 19 | def test_lazy_config_generates_auth_url_on_access(spawn, config_dir): 20 | config = config_dir / "lazy.conf" 21 | with spawn(f"mopidy --config {config.resolve()}") as child: 22 | child.expect("Connecting to TIDAL... Quality = LOSSLESS") 23 | with pytest.raises(AssertionError): 24 | child.expect("Visit https://link.tidal.com/.* to log in") 25 | with spawn("mpc list artist"): 26 | child.expect("Visit https://link.tidal.com/.* to log in") 27 | -------------------------------------------------------------------------------- /integration_tests/test_login_hack.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.parametrize( 5 | "type", 6 | ( 7 | "artist", 8 | "album", 9 | "Artist", 10 | "Album", 11 | "AlbumArtist", 12 | "Title", 13 | "Genre", 14 | "Date", 15 | "Composer", 16 | "Performer", 17 | "Comment", 18 | ), 19 | ) 20 | def test_link_on_mpc_list_with_hack_login(type, spawn, config_dir): 21 | config = config_dir / "lazy_hack.conf" 22 | with spawn(f"mopidy --config {config.resolve()}") as child: 23 | child.expect("Connecting to TIDAL... Quality = LOSSLESS") 24 | child.expect("Starting GLib mainloop") 25 | with spawn(f"mpc list {type}") as mpc: 26 | mpc.expect("Please visit .*link.tidal.com/.* to log in") 27 | 28 | 29 | def test_user_warned_if_lazy_set_implicitly(spawn, config_dir): 30 | config = config_dir / "hack.conf" 31 | with spawn(f"mopidy --config {config.resolve()}") as child: 32 | child.expect("Connecting to TIDAL... Quality = LOSSLESS") 33 | child.expect("HACK login implies lazy connection") 34 | child.expect("Starting GLib mainloop") 35 | with spawn(f"mpc list artist") as mpc: 36 | mpc.expect("Please visit .*link.tidal.com/.* to log in") 37 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: lint test install format all system-venv integration-test 2 | POETRY ?= poetry run 3 | 4 | help: 5 | @printf "Chose one of install, format, lint or test.\n" 6 | 7 | install: 8 | rm -rf dist 9 | poetry build 10 | pip install dist/*.whl 11 | 12 | format: 13 | ${POETRY} isort --profile=black mopidy_tidal tests 14 | ${POETRY} black mopidy_tidal tests 15 | 16 | lint: 17 | ${POETRY} isort --check --profile=black mopidy_tidal tests 18 | ${POETRY} black --check mopidy_tidal tests 19 | 20 | system-venv: 21 | python -m venv .venv --system-site-packages 22 | bash -c "source .venv/bin/activate && poetry install" 23 | @printf "You now need to activate the venv by sourcing the right file, e.g. source .venv/bin/activate\n" 24 | 25 | 26 | test: 27 | ${POETRY} pytest tests/ \ 28 | -k "not gt_$$(python3 --version | sed 's/Python \([0-9]\).\([0-9]*\)\..*/\1_\2/')" \ 29 | --cov=mopidy_tidal --cov-report=html --cov-report=xml --cov-report=term-missing --cov-branch 30 | 31 | integration-test: 32 | ${POETRY} pytest integration_tests/ 33 | -------------------------------------------------------------------------------- /mopidy_tidal/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | import os 5 | import sys 6 | 7 | from mopidy import config, ext 8 | 9 | __version__ = "0.3.9" 10 | 11 | # TODO: If you need to log, use loggers named after the current Python module 12 | logger = logging.getLogger(__name__) 13 | 14 | file_dir = os.path.dirname(__file__) 15 | sys.path.append(file_dir) 16 | 17 | 18 | class Extension(ext.Extension): 19 | dist_name = "Mopidy-Tidal" 20 | ext_name = "tidal" 21 | version = __version__ 22 | 23 | def get_default_config(self): 24 | conf_file = os.path.join(os.path.dirname(__file__), "ext.conf") 25 | return config.read(conf_file) 26 | 27 | def get_config_schema(self): 28 | schema = super().get_config_schema() 29 | schema["quality"] = config.String( 30 | choices=["HI_RES_LOSSLESS", "LOSSLESS", "HIGH", "LOW"] 31 | ) 32 | schema["client_id"] = config.String(optional=True) 33 | schema["client_secret"] = config.String(optional=True) 34 | schema["playlist_cache_refresh_secs"] = config.Integer(optional=True) 35 | schema["lazy"] = config.Boolean(optional=True) 36 | schema["login_method"] = config.String(choices=["BLOCK", "HACK", "AUTO"]) 37 | schema["auth_method"] = config.String(optional=True, choices=["OAUTH", "PKCE"]) 38 | schema["login_server_port"] = config.Integer( 39 | optional=True, choices=range(8000, 9000) 40 | ) 41 | return schema 42 | 43 | def setup(self, registry): 44 | from .backend import TidalBackend 45 | 46 | registry.add("backend", TidalBackend) 47 | -------------------------------------------------------------------------------- /mopidy_tidal/backend.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | import time 5 | from concurrent.futures import Future 6 | from pathlib import Path 7 | from typing import Optional, Union 8 | 9 | from mopidy import backend 10 | from pykka import ThreadingActor 11 | from tidalapi import Config, Session 12 | from tidalapi import __version__ as tidalapi_ver 13 | 14 | from mopidy_tidal import Extension 15 | from mopidy_tidal import __version__ as mopidy_tidal_ver 16 | from mopidy_tidal import context, library, playback, playlists 17 | from mopidy_tidal.web_auth_server import WebAuthServer 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class TidalBackend(ThreadingActor, backend.Backend): 23 | def __init__(self, config, audio): 24 | super().__init__() 25 | # Mopidy cfg 26 | self._config = config 27 | context.set_config(self._config) 28 | self._tidal_config = config[Extension.ext_name] 29 | 30 | # Backend 31 | self.playback = playback.TidalPlaybackProvider(audio=audio, backend=self) 32 | self.library = library.TidalLibraryProvider(backend=self) 33 | self.playlists = playlists.TidalPlaylistsProvider(backend=self) 34 | 35 | # Session parameters 36 | self._active_session: Optional[Session] = None 37 | self._logged_in: bool = False 38 | self.uri_schemes: tuple[str] = ("tidal",) 39 | self._login_future: Optional[Future] = None 40 | self._login_url: Optional[str] = None 41 | self.data_dir: Path = Path(Extension.get_data_dir(self._config)) 42 | self.session_file_path: Path = Path("") 43 | self.web_auth_server: WebAuthServer = WebAuthServer() 44 | 45 | # Config parameters 46 | # Lazy: Connect lazily, i.e. login only when user starts browsing TIDAL directories 47 | self.lazy_connect: bool = False 48 | # Login Method: 49 | # BLOCK: Immediately prompt user for login (This will block mopidy startup!) 50 | # HACK/AUTO: Display dummy track with login URL. When clicked, QR code and TTS is generated 51 | self.login_method: str = "BLOCK" 52 | # pkce_enabled: If true, TIDAL session will use PKCE auth. Otherwise OAuth2 is used 53 | self.auth_method: str = "OAUTH" 54 | self.pkce_enabled: bool = False 55 | # login_server_port: Port to use for login HTTP server, eg. :. Default :8989 56 | self.login_server_port: int = 8989 57 | 58 | @property 59 | def session(self): 60 | if not self.logged_in: 61 | self._login() 62 | return self._active_session 63 | 64 | @property 65 | def logged_in(self): 66 | if not self._logged_in: 67 | if self._active_session.load_session_from_file(self.session_file_path): 68 | logger.info("Loaded TIDAL session from file %s", self.session_file_path) 69 | self._logged_in = self.session_valid 70 | return self._logged_in 71 | 72 | @property 73 | def session_valid(self): 74 | # Returns true when session is logged in and valid 75 | return self._active_session.check_login() 76 | 77 | def on_start(self): 78 | logger.info("Mopidy-Tidal version: v%s", mopidy_tidal_ver) 79 | logger.info("Python-Tidal version: v%s", tidalapi_ver) 80 | quality = self._tidal_config["quality"] 81 | client_id = self._tidal_config["client_id"] 82 | client_secret = self._tidal_config["client_secret"] 83 | self.auth_method = self._tidal_config["auth_method"] 84 | if self.auth_method == "PKCE": 85 | self.pkce_enabled = True 86 | self.login_server_port = self._tidal_config["login_server_port"] 87 | logger.info("PKCE login web server port: %s", self.login_server_port) 88 | self.login_method = self._tidal_config["login_method"] 89 | if self.login_method == "AUTO": 90 | # Add AUTO as alias to HACK login method 91 | self.login_method = "HACK" 92 | self.lazy_connect = self._tidal_config["lazy"] 93 | logger.info("Quality: %s", quality) 94 | logger.info("Authentication: %s", "PKCE" if self.pkce_enabled else "OAuth") 95 | config = Config(quality=quality) 96 | 97 | # Set the session filename, depending on the type of session 98 | if self.pkce_enabled: 99 | self.session_file_path = Path(self.data_dir, "tidal-pkce.json") 100 | else: 101 | self.session_file_path = Path(self.data_dir, "tidal-oauth.json") 102 | 103 | if (self.login_method == "HACK") and not self._tidal_config["lazy"]: 104 | logger.warning("AUTO login implies lazy connection, setting lazy=True.") 105 | self.lazy_connect = True 106 | logger.info( 107 | "Login method: %s", "BLOCK" if self.pkce_enabled == "BLOCK" else "AUTO" 108 | ) 109 | 110 | if client_id and client_secret: 111 | logger.info("Using client id & client secret from config") 112 | config.client_id = client_id 113 | config.api_token = client_id 114 | config.client_secret = client_secret 115 | elif (client_id and not client_secret) or (client_secret and not client_id): 116 | logger.warning("Always provide both client_id and client_secret") 117 | logger.info("Using default client id & client secret from python-tidal") 118 | else: 119 | logger.info("Using default client id & client secret from python-tidal") 120 | 121 | self._active_session = Session(config) 122 | if not self.lazy_connect: 123 | self._login() 124 | 125 | def _login(self): 126 | """Load session at startup or create a new session""" 127 | if self._active_session.load_session_from_file(self.session_file_path): 128 | logger.info( 129 | "Loaded existing TIDAL session from file %s...", self.session_file_path 130 | ) 131 | if not self.session_valid: 132 | if not self.login_server_port: 133 | # A. Default login, user must find login URL in Mopidy log 134 | logger.info("Creating new session (OAuth)...") 135 | self._active_session.login_oauth_simple(fn_print=logger.info) 136 | else: 137 | # B. Interactive login, user must perform login using web auth 138 | logger.info( 139 | "Creating new session (%s)...", 140 | "PKCE" if self.pkce_enabled else "OAuth", 141 | ) 142 | if self.pkce_enabled: 143 | # PKCE Login 144 | login_url = self._active_session.pkce_login_url() 145 | logger.info( 146 | "Please visit 'http://localhost:%s' to authenticate", 147 | self.login_server_port, 148 | ) 149 | # Enable web server for interactive login + callback on form Submit 150 | self.web_auth_server.set_callback(self._web_auth_callback) 151 | self.web_auth_server.start_oauth_daemon( 152 | login_url, self.login_server_port, self.pkce_enabled 153 | ) 154 | else: 155 | # OAuth login 156 | login_url = self.login_url 157 | logger.info( 158 | "Please visit 'http://localhost:%s' or '%s' to authenticate", 159 | self.login_server_port, 160 | login_url, 161 | ) 162 | # Enable web server for interactive login (no callback) 163 | self.web_auth_server.start_oauth_daemon( 164 | login_url, self.login_server_port, self.pkce_enabled 165 | ) 166 | 167 | # Wait for user to complete interactive login sequence 168 | max_time = time.time() + 300 169 | while time.time() < max_time: 170 | if self._logged_in: 171 | if not self.pkce_enabled: 172 | self._complete_login() 173 | return 174 | logger.info( 175 | "Time left to complete authentication: %s sec", 176 | int(max_time - time.time()), 177 | ) 178 | time.sleep(5) 179 | raise TimeoutError("You took too long to log in") 180 | 181 | def _web_auth_callback(self, url_redirect: str): 182 | """Callback triggered on web auth completion 183 | :param url_redirect: URL of the 'Ooops' page, where the user was redirected to after login. 184 | :type url_redirect: str 185 | """ 186 | if self.pkce_enabled: 187 | try: 188 | # Query for auth tokens 189 | json: dict[str, Union[str, int]] = ( 190 | self._active_session.pkce_get_auth_token(url_redirect) 191 | ) 192 | # Parse and set tokens. 193 | self._active_session.process_auth_token(json, is_pkce_token=True) 194 | self._logged_in = True 195 | except: 196 | raise ValueError("Response code is required for PKCE login!") 197 | # Store session after auth completion 198 | self._complete_login() 199 | 200 | def _complete_login(self): 201 | """Perform final steps of login sequence; save session to file""" 202 | if self.session_valid: 203 | # Only store current session if valid 204 | logger.info("TIDAL Login OK") 205 | self._active_session.save_session_to_file(self.session_file_path) 206 | self._logged_in = True 207 | else: 208 | logger.error("TIDAL Login Failed") 209 | raise ConnectionError("Failed to log in.") 210 | 211 | @property 212 | def logging_in(self) -> bool: 213 | """Are we currently waiting for user confirmation to log in?""" 214 | return bool(self._login_future and self._login_future.running()) 215 | 216 | @property 217 | def login_url(self) -> Optional[str]: 218 | """Start a new login sequence (if not active) and get the latest login URL""" 219 | if not self.pkce_enabled: 220 | if not self._logged_in and not self.logging_in: 221 | login_url, self._login_future = self._active_session.login_oauth() 222 | self._login_future.add_done_callback(lambda *_: self._complete_login()) 223 | self._login_url = login_url.verification_uri_complete 224 | return f"https://{self._login_url}" if self._login_url else None 225 | else: 226 | if not self._logged_in and not self.web_auth_server.is_daemon_running: 227 | login_url = self._active_session.pkce_login_url() 228 | self._login_url = "http://localhost:{}".format(self.login_server_port) 229 | # Enable web server for interactive login + callback on form Submit 230 | self.web_auth_server.set_callback(self._web_auth_callback) 231 | self.web_auth_server.start_oauth_daemon( 232 | login_url, self.login_server_port, self.pkce_enabled 233 | ) 234 | return f"{self._login_url}" if self._login_url else None 235 | -------------------------------------------------------------------------------- /mopidy_tidal/context.py: -------------------------------------------------------------------------------- 1 | _ctx = { 2 | "config": None, 3 | } 4 | 5 | 6 | def set_config(cfg): 7 | _ctx["config"] = cfg 8 | 9 | 10 | def get_config(): 11 | if not _ctx["config"]: 12 | raise ValueError("Extension configuration not set.") 13 | return _ctx["config"] 14 | -------------------------------------------------------------------------------- /mopidy_tidal/ext.conf: -------------------------------------------------------------------------------- 1 | [tidal] 2 | enabled = true 3 | quality = LOSSLESS 4 | auth_method = OAUTH 5 | login_server_port = 8989 6 | lazy = false 7 | login_method= AUTO 8 | playlist_cache_refresh_secs = 0 9 | client_id = 10 | client_secret = -------------------------------------------------------------------------------- /mopidy_tidal/full_models_mappers.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | 5 | from mopidy.models import Album, Artist, Playlist, Track 6 | 7 | from mopidy_tidal.helpers import to_timestamp 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def _get_release_date(obj): 13 | d = None 14 | for attr in ("release_date", "tidal_release_date"): 15 | d = getattr(obj, attr, None) 16 | if d: 17 | break 18 | 19 | if d: 20 | return str(d.year) 21 | 22 | 23 | def create_mopidy_artists(tidal_artists): 24 | return [create_mopidy_artist(a) for a in tidal_artists] 25 | 26 | 27 | def create_mopidy_artist(tidal_artist): 28 | if tidal_artist is None: 29 | return None 30 | 31 | return Artist(uri="tidal:artist:" + str(tidal_artist.id), name=tidal_artist.name) 32 | 33 | 34 | def create_mopidy_albums(tidal_albums): 35 | return [create_mopidy_album(a, None) for a in tidal_albums] 36 | 37 | 38 | def create_mopidy_album(tidal_album, artist): 39 | if artist is None: 40 | artist = create_mopidy_artist(tidal_album.artist) 41 | 42 | return Album( 43 | uri="tidal:album:" + str(tidal_album.id), 44 | name=tidal_album.name, 45 | artists=[artist], 46 | date=_get_release_date(tidal_album), 47 | ) 48 | 49 | 50 | def create_mopidy_tracks(tidal_tracks): 51 | return [create_mopidy_track(None, None, t) for t in tidal_tracks] 52 | 53 | 54 | def create_mopidy_track(artist, album, tidal_track): 55 | uri = "tidal:track:{0}:{1}:{2}".format( 56 | tidal_track.artist.id, tidal_track.album.id, tidal_track.id 57 | ) 58 | if artist is None: 59 | artist = create_mopidy_artist(tidal_track.artist) 60 | if album is None: 61 | album = create_mopidy_album(tidal_track.album, artist) 62 | 63 | track_len = tidal_track.duration * 1000 64 | return Track( 65 | uri=uri, 66 | name=tidal_track.full_name, 67 | track_no=tidal_track.track_num, 68 | artists=[artist], 69 | album=album, 70 | length=track_len, 71 | date=_get_release_date(tidal_track), 72 | # Different attribute name for disc_num on tidalapi >= 0.7.0 73 | disc_no=getattr(tidal_track, "disc_num", getattr(tidal_track, "volume_num")), 74 | ) 75 | 76 | 77 | def create_mopidy_playlist(tidal_playlist, tidal_tracks): 78 | return Playlist( 79 | uri=f"tidal:playlist:{tidal_playlist.id}", 80 | name=tidal_playlist.name, 81 | tracks=tidal_tracks, 82 | last_modified=to_timestamp(tidal_playlist.last_updated), 83 | ) 84 | 85 | 86 | def create_mopidy_mix_playlist(tidal_mix): 87 | return Playlist( 88 | uri=f"tidal:mix:{tidal_mix.id}", 89 | name=f"{tidal_mix.title} ({tidal_mix.sub_title})", 90 | tracks=create_mopidy_tracks(tidal_mix.items()), 91 | ) 92 | -------------------------------------------------------------------------------- /mopidy_tidal/helpers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | 4 | def to_timestamp(dt): 5 | if not dt: 6 | return 0 7 | if isinstance(dt, str): 8 | dt = datetime.datetime.fromisoformat(dt) 9 | if isinstance(dt, datetime.datetime): 10 | dt = dt.timestamp() 11 | return int(dt) 12 | -------------------------------------------------------------------------------- /mopidy_tidal/library.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | from concurrent.futures import ThreadPoolExecutor 5 | from contextlib import suppress 6 | from typing import TYPE_CHECKING, List, Optional, Tuple 7 | 8 | from mopidy import backend, models 9 | from mopidy.models import Image, Ref, SearchResult, Track 10 | from requests.exceptions import HTTPError 11 | from tidalapi.exceptions import ObjectNotFound, TooManyRequests 12 | 13 | from mopidy_tidal import full_models_mappers, ref_models_mappers 14 | from mopidy_tidal.login_hack import login_hack 15 | from mopidy_tidal.lru_cache import LruCache 16 | from mopidy_tidal.playlists import PlaylistMetadataCache 17 | from mopidy_tidal.utils import apply_watermark 18 | from mopidy_tidal.workers import get_items 19 | 20 | if TYPE_CHECKING: # pragma: no cover 21 | from mopidy_tidal.backend import TidalBackend 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class ImagesGetter: 27 | def __init__(self, session): 28 | self._session = session 29 | self._image_cache = LruCache(directory="image") 30 | 31 | @staticmethod 32 | def _log_image_not_found(obj): 33 | logger.debug( 34 | 'No images available for %s "%s"', 35 | type(obj).__name__, 36 | getattr(obj, "name", getattr(obj, "title", getattr(obj, "id"))), 37 | ) 38 | 39 | @classmethod 40 | def _get_image_uri(cls, obj): 41 | method = None 42 | 43 | if hasattr(obj, "image"): 44 | if hasattr(obj, "picture") and getattr(obj, "picture", None) is not None: 45 | method = obj.image 46 | elif ( 47 | hasattr(obj, "square_picture") 48 | and getattr(obj, "square_picture", None) is not None 49 | ): 50 | method = obj.image 51 | elif hasattr(obj, "cover") and getattr(obj, "cover", None) is not None: 52 | method = obj.image 53 | elif hasattr(obj, "images") and getattr(obj, "images", None) is not None: 54 | # Mix types contain images type with three small/medium/large image sizes 55 | method = obj.image 56 | else: 57 | # Handle artists/albums/playlists/mixes with missing images 58 | cls._log_image_not_found(obj) 59 | return 60 | else: 61 | cls._log_image_not_found(obj) 62 | return 63 | 64 | dimensions = (750, 640, 480) 65 | for dim in dimensions: 66 | args = (dim,) 67 | try: 68 | return method(*args) 69 | except ValueError: 70 | pass 71 | 72 | cls._log_image_not_found(obj) 73 | 74 | def _get_api_getter(self, item_type: str): 75 | return getattr(self._session, item_type, None) 76 | 77 | def _get_images(self, uri) -> List[Image]: 78 | assert uri.startswith("tidal:"), f"Invalid TIDAL URI: {uri}" 79 | 80 | parts = uri.split(":") 81 | item_type = parts[1] 82 | if item_type == "track": 83 | # For tracks, retrieve the artwork of the associated album 84 | item_type = "album" 85 | item_id = parts[3] 86 | uri = ":".join([parts[0], "album", parts[3]]) 87 | elif item_type == "album": 88 | item_id = parts[2] 89 | elif item_type == "playlist": 90 | item_id = parts[2] 91 | elif item_type == "artist": 92 | item_id = parts[2] 93 | elif item_type == "mix": 94 | item_id = parts[2] 95 | else: 96 | # uri has no image associated to it (eg. tidal:mood tidal:genres etc.) 97 | return [] 98 | 99 | if uri in self._image_cache: 100 | # Cache hit 101 | logger.debug("Cache hit for {}".format(uri)) 102 | return self._image_cache[uri] 103 | 104 | logger.debug("Retrieving %r from the API", uri) 105 | getter = self._get_api_getter(item_type) 106 | if not getter: 107 | logger.warning("The API item type %s has no session getters", item_type) 108 | return [] 109 | 110 | item = getter(item_id) 111 | if not item: 112 | logger.debug("%r is not available on the backend", uri) 113 | return [] 114 | 115 | img_uri = self._get_image_uri(item) 116 | if not img_uri: 117 | logger.debug("%r has no associated images", uri) 118 | return [] 119 | 120 | logger.debug("Image URL for %r: %r", uri, img_uri) 121 | return [Image(uri=img_uri, width=320, height=320)] 122 | 123 | def __call__(self, uri: str) -> Tuple[str, List[Image]]: 124 | parts = uri.split(":") 125 | item_type = parts[1] 126 | if item_type not in ["artist", "album", "playlist", "mix", "track"]: 127 | logger.debug("URI %s type has no image getters", uri) 128 | return uri, [] 129 | try: 130 | return uri, self._get_images(uri) 131 | except (AssertionError, AttributeError, ObjectNotFound) as err: 132 | logger.error("%s when processing URI %r: %s", type(err), uri, err) 133 | return uri, [] 134 | except (HTTPError, TooManyRequests) as err: 135 | logger.error("%s when processing URI %r: %s", type(err), uri, err) 136 | return uri, [] 137 | 138 | def cache_update(self, images): 139 | self._image_cache.update(images) 140 | 141 | 142 | class TidalLibraryProvider(backend.LibraryProvider): 143 | root_directory = models.Ref.directory(uri="tidal:directory", name="Tidal") 144 | backend: "TidalBackend" 145 | 146 | def __init__(self, *args, **kwargs): 147 | super().__init__(*args, **kwargs) 148 | self._artist_cache = LruCache() 149 | self._album_cache = LruCache() 150 | self._track_cache = LruCache() 151 | self._playlist_cache = PlaylistMetadataCache() 152 | 153 | @property 154 | def _session(self): 155 | return self.backend.session 156 | 157 | @login_hack(passthrough=True) 158 | def get_distinct(self, field, query=None) -> set[str]: 159 | from mopidy_tidal.search import tidal_search 160 | 161 | logger.debug("Browsing distinct %s with query %r", field, query) 162 | session = self._session 163 | 164 | if not query: # library root 165 | if field in {"artist", "albumartist"}: 166 | return { 167 | apply_watermark(a.name) for a in session.user.favorites.artists() 168 | } 169 | elif field == "album": 170 | return { 171 | apply_watermark(a.name) for a in session.user.favorites.albums() 172 | } 173 | elif field in {"track", "track_name"}: 174 | return { 175 | apply_watermark(t.name) for t in session.user.favorites.tracks() 176 | } 177 | else: 178 | if field == "artist": 179 | return { 180 | apply_watermark(a.name) for a in session.user.favorites.artists() 181 | } 182 | elif field in {"album", "albumartist"}: 183 | artists, _, _ = tidal_search(session, query=query, exact=True) 184 | if len(artists) > 0: 185 | artist = artists[0] 186 | artist_id = artist.uri.split(":")[2] 187 | return { 188 | apply_watermark(a.name) 189 | for a in self._get_artist_albums(session, artist_id) 190 | } 191 | elif field in {"track", "track_name"}: 192 | return { 193 | apply_watermark(t.name) for t in session.user.favorites.tracks() 194 | } 195 | pass 196 | 197 | return set() 198 | 199 | @login_hack 200 | def browse(self, uri) -> list[Ref]: 201 | logger.info("Browsing uri %s", uri) 202 | if not uri or not uri.startswith("tidal:"): 203 | return [] 204 | 205 | session = self._session 206 | 207 | # summaries 208 | 209 | if uri == self.root_directory.uri: 210 | return ref_models_mappers.create_root() 211 | 212 | elif uri == "tidal:my_artists": 213 | return ref_models_mappers.create_artists( 214 | get_items(session.user.favorites.artists) 215 | ) 216 | elif uri == "tidal:my_albums": 217 | return ref_models_mappers.create_albums( 218 | get_items(session.user.favorites.albums) 219 | ) 220 | elif uri == "tidal:my_playlists": 221 | return self.backend.playlists.as_list() 222 | elif uri == "tidal:my_mixes": 223 | return ref_models_mappers.create_mixes(session.user.favorites.mixes()) 224 | elif uri == "tidal:my_tracks": 225 | return ref_models_mappers.create_tracks( 226 | get_items(session.user.favorites.tracks) 227 | ) 228 | elif uri == "tidal:home": 229 | return ref_models_mappers.create_category_directories(uri, session.home()) 230 | elif uri == "tidal:for_you": 231 | return ref_models_mappers.create_category_directories( 232 | uri, session.for_you() 233 | ) 234 | elif uri == "tidal:explore": 235 | return ref_models_mappers.create_category_directories( 236 | uri, session.explore() 237 | ) 238 | elif uri == "tidal:hires": 239 | return ref_models_mappers.create_category_directories( 240 | uri, session.hires_page() 241 | ) 242 | elif uri == "tidal:moods": 243 | return ref_models_mappers.create_moods(session.moods()) 244 | elif uri == "tidal:mixes": 245 | return ref_models_mappers.create_mixes([m for m in session.mixes()]) 246 | elif uri == "tidal:genres": 247 | return ref_models_mappers.create_genres(session.genre.get_genres()) 248 | 249 | # Category nested on a page (eg. page(For You).category[0..n]) 250 | # These have 3-part uris 251 | with suppress(ValueError): 252 | _, page_id, type, category_id = uri.split(":") 253 | category = session.page.get(f"pages/{page_id}").categories[int(category_id)] 254 | return ref_models_mappers.create_mixed_directory(category.items) 255 | 256 | # details with 2-part uris 257 | try: 258 | _, type, id = uri.split(":") 259 | 260 | if type == "album": 261 | return ref_models_mappers.create_tracks( 262 | self._get_album_tracks(session, id) 263 | ) 264 | 265 | elif type == "artist": 266 | top_10_tracks = ref_models_mappers.create_tracks( 267 | self._get_artist_top_tracks(session, id)[:10] 268 | ) 269 | 270 | albums = ref_models_mappers.create_albums( 271 | self._get_artist_albums(session, id) 272 | ) 273 | 274 | return albums + top_10_tracks 275 | 276 | elif type == "playlist": 277 | return ref_models_mappers.create_tracks( 278 | self._get_playlist_tracks(session, id) 279 | ) 280 | 281 | elif type == "mood": 282 | return ref_models_mappers.create_playlists( 283 | self._get_mood_items(session, id) 284 | ) 285 | 286 | elif type == "genre": 287 | return ref_models_mappers.create_playlists( 288 | self._get_genre_items(session, id) 289 | ) 290 | 291 | elif type == "mix": 292 | return ref_models_mappers.create_tracks( 293 | self._get_mix_tracks(session, id) 294 | ) 295 | 296 | elif type == "page": 297 | return ref_models_mappers.create_mixed_directory(session.page.get(id)) 298 | else: 299 | return [] 300 | 301 | except ValueError: 302 | logger.exception("Unable to parse uri '%s' for browse.", uri) 303 | return [] 304 | except HTTPError: 305 | logger.exception("Unable to retrieve object from uri '%s'", uri) 306 | return [] 307 | 308 | @login_hack 309 | def search(self, query=None, uris=None, exact=False) -> Optional[SearchResult]: 310 | from mopidy_tidal.search import tidal_search 311 | 312 | try: 313 | artists, albums, tracks = tidal_search( 314 | self._session, query=query, exact=exact 315 | ) 316 | return SearchResult(artists=artists, albums=albums, tracks=tracks) 317 | except Exception as ex: 318 | logger.info("EX") 319 | logger.info("%r", ex) 320 | 321 | @login_hack 322 | def get_images(self, uris) -> dict[str, list[Image]]: 323 | logger.info("Searching Tidal for images for %r" % uris) 324 | images_getter = ImagesGetter(self._session) 325 | 326 | with ThreadPoolExecutor(4, thread_name_prefix="mopidy-tidal-images-") as pool: 327 | pool_res = pool.map(images_getter, uris) 328 | 329 | images = {uri: item_images for uri, item_images in pool_res if item_images} 330 | images_getter.cache_update(images) 331 | return images 332 | 333 | @login_hack 334 | def lookup(self, uris=None) -> list[Track]: 335 | if isinstance(uris, str): 336 | uris = [uris] 337 | if not hasattr(uris, "__iter__"): 338 | uris = [uris] 339 | 340 | tracks = [] 341 | cache_updates = {} 342 | 343 | for uri in uris or []: 344 | data = [] 345 | try: 346 | parts = uri.split(":") 347 | item_type = parts[1] 348 | cache_name = f"_{parts[1]}_cache" 349 | cache_miss = True 350 | 351 | try: 352 | data = getattr(self, cache_name)[uri] 353 | cache_miss = not bool(data) 354 | except (AttributeError, KeyError): 355 | pass 356 | 357 | if cache_miss: 358 | try: 359 | lookup = getattr(self, f"_lookup_{parts[1]}") 360 | except AttributeError: 361 | continue 362 | 363 | data = cache_data = lookup(self._session, parts) 364 | cache_updates[cache_name] = cache_updates.get(cache_name, {}) 365 | if item_type == "playlist": 366 | # Playlists should be persisted on the cache as objects, 367 | # not as lists of tracks. Therefore, _lookup_playlist 368 | # returns a tuple that we need to unpack 369 | data, cache_data = data 370 | 371 | cache_updates[cache_name][uri] = cache_data 372 | 373 | if item_type == "playlist" and not cache_miss: 374 | tracks += data.tracks 375 | else: 376 | tracks += data if hasattr(data, "__iter__") else [data] 377 | except HTTPError as err: 378 | logger.error("%s when processing URI %r: %s", type(err), uri, err) 379 | 380 | for cache_name, new_data in cache_updates.items(): 381 | getattr(self, cache_name).update(new_data) 382 | 383 | self._track_cache.update({track.uri: track for track in tracks}) 384 | logger.info("Returning %d tracks", len(tracks)) 385 | return tracks 386 | 387 | @classmethod 388 | def _get_playlist_tracks(cls, session, playlist_id): 389 | try: 390 | pl = session.playlist(playlist_id) 391 | except ObjectNotFound: 392 | logger.debug("No such playlist: %s", playlist_id) 393 | return [] 394 | getter_args = tuple() 395 | return get_items(pl.tracks, *getter_args) 396 | 397 | @staticmethod 398 | def _get_genre_items(session, genre_id): 399 | from tidalapi.playlist import Playlist 400 | 401 | filtered_genres = [g for g in session.genre.get_genres() if genre_id == g.path] 402 | if filtered_genres: 403 | return filtered_genres[0].items(Playlist) 404 | return [] 405 | 406 | @staticmethod 407 | def _get_mood_items(session, mood_id): 408 | filtered_moods = [ 409 | m for m in session.moods() if mood_id == m.api_path.split("/")[-1] 410 | ] 411 | 412 | if filtered_moods: 413 | mood = filtered_moods[0].get() 414 | return [p for p in mood] 415 | return [] 416 | 417 | @staticmethod 418 | def _get_mix_tracks(session, mix_id): 419 | try: 420 | mix = session.mix(mix_id) 421 | except ObjectNotFound: 422 | logger.debug("No such mix: %s", mix_id) 423 | return [] 424 | return mix.items() 425 | 426 | def _lookup_playlist(self, session, parts): 427 | playlist_id = parts[2] 428 | tidal_playlist = session.playlist(playlist_id) 429 | tidal_tracks = self._get_playlist_tracks(session, playlist_id) 430 | pl_tracks = full_models_mappers.create_mopidy_tracks(tidal_tracks) 431 | pl = full_models_mappers.create_mopidy_playlist(tidal_playlist, pl_tracks) 432 | # We need both the list of tracks and the mapped playlist object for 433 | # caching purposes 434 | return pl_tracks, pl 435 | 436 | @staticmethod 437 | def _get_artist_albums(session, artist_id): 438 | try: 439 | artist = session.artist(artist_id) 440 | except ObjectNotFound: 441 | logger.debug("No such artist: %s", artist_id) 442 | return [] 443 | return artist.get_albums() 444 | 445 | @staticmethod 446 | def _get_album_tracks(session, album_id): 447 | try: 448 | album = session.album(album_id) 449 | except ObjectNotFound: 450 | logger.debug("No such album: %s", album_id) 451 | return [] 452 | return album.tracks() 453 | 454 | def _lookup_track(self, session, parts): 455 | if len(parts) == 3: # Track in format `tidal:track:` 456 | track_id = parts[2] 457 | track = session.track(track_id) 458 | album_id = str(track.album.id) 459 | else: # Track in format `tidal:track:::` 460 | album_id = parts[3] 461 | track_id = parts[4] 462 | tracks = self._get_album_tracks(session, album_id) 463 | # If album is unavailable, no tracks will be returned 464 | if tracks: 465 | # We get a spurious coverage error since the next expression should never raise StopIteration 466 | track = next(t for t in tracks if t.id == int(track_id)) # pragma: no cover 467 | artist = full_models_mappers.create_mopidy_artist(track.artist) 468 | album = full_models_mappers.create_mopidy_album(track.album, artist) 469 | return [full_models_mappers.create_mopidy_track(artist, album, track)] 470 | else: 471 | return [] 472 | 473 | def _lookup_album(self, session, parts): 474 | album_id = parts[2] 475 | tracks = self._get_album_tracks(session, album_id) 476 | 477 | return full_models_mappers.create_mopidy_tracks(tracks) 478 | 479 | @staticmethod 480 | def _get_artist_top_tracks(session, artist_id): 481 | return session.artist(artist_id).get_top_tracks() 482 | 483 | def _lookup_artist(self, session, parts): 484 | artist_id = parts[2] 485 | tracks = self._get_artist_top_tracks(session, artist_id) 486 | return full_models_mappers.create_mopidy_tracks(tracks) 487 | -------------------------------------------------------------------------------- /mopidy_tidal/login_hack.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from abc import ABC 3 | from contextlib import suppress 4 | from functools import reduce, wraps 5 | from itertools import chain 6 | from logging import getLogger 7 | from pathlib import Path 8 | from types import FunctionType 9 | from typing import TYPE_CHECKING, Optional, Union, get_args, get_origin 10 | from urllib.parse import urlencode 11 | 12 | from mopidy.models import Album, Artist, Image, Playlist, Ref, SearchResult, Track 13 | from requests import get 14 | 15 | if TYPE_CHECKING: # pragma: no cover 16 | from backend import TidalBackend 17 | 18 | __all__ = ["login_hack", "speak_login_hack"] 19 | 20 | NoneType = type(None) 21 | 22 | UNION_TYPES = {Union} 23 | 24 | try: # pragma: no cover 25 | from types import UnionType 26 | 27 | UNION_TYPES |= {UnionType} 28 | except ImportError: # pragma: no cover 29 | pass 30 | 31 | logger = getLogger(__name__) 32 | 33 | 34 | def extract_types(possibly_union_type) -> list: 35 | """Extract the real type from an optional type.""" 36 | if get_origin(possibly_union_type) in UNION_TYPES: 37 | return nonnull_types(possibly_union_type) 38 | return [possibly_union_type] 39 | 40 | 41 | def nonnull_types(t): 42 | return [x for x in get_args(t) if x is not NoneType] 43 | 44 | 45 | def interesting_types(t) -> set: 46 | """Find all the interesting types in a type, as a flat list.""" 47 | if base_type := get_origin(t): 48 | if base_type is list: 49 | return set(chain.from_iterable(interesting_types(x) for x in get_args(t))) 50 | if base_type is dict: 51 | return interesting_types(get_args(t)[1]) 52 | return {t} 53 | 54 | 55 | class Builder(ABC): 56 | mapping: dict 57 | 58 | def build(self, t): 59 | if supertype := get_origin(t): 60 | subtypes = nonnull_types(t) 61 | if supertype is list: 62 | return [self.build(subtypes[0])] 63 | elif supertype is set: 64 | return {self.build(subtypes[0])} 65 | else: 66 | assert supertype is dict 67 | k, v = subtypes 68 | return {self.build(k): self.build(v)} 69 | else: 70 | return self.mapping[t]() 71 | 72 | 73 | class ObjectBuilder(Builder): 74 | width = height = 150 75 | 76 | def __init__(self, *_, schema: str, uri: str, url: str, msg: str, **kwargs): 77 | self.schema = schema 78 | self.uri = uri 79 | self.url = url 80 | self.msg = msg 81 | self.mapping = { 82 | str: self._login_uri, 83 | Playlist: lambda: Playlist( 84 | uri="tidal:playlist:login", 85 | name=self.msg, 86 | tracks=[self.build(Track)], 87 | ), 88 | Ref: lambda: Ref( 89 | name=self.msg, 90 | type=self.ref_type(), 91 | uri=self.uri, 92 | ), 93 | Ref.playlist: lambda: Ref.playlist( 94 | name=self.msg, 95 | uri="tidal:playlist:login", 96 | ), 97 | Track: lambda: Track(uri="tidal:track:login", name=self.msg), 98 | Artist: lambda: Artist(uri="tidal:artist:login", name=self.msg), 99 | Album: lambda: Album(uri="tidal:album:login", name=self.msg), 100 | Image: lambda: Image( 101 | uri=self._image_url(), width=self.width, height=self.height 102 | ), 103 | SearchResult: lambda: SearchResult( 104 | artists=[self.build(Artist)], 105 | albums=[self.build(Album)], 106 | tracks=[self.build(Track)], 107 | ), 108 | } 109 | 110 | def _image_url(self) -> str: 111 | """Link to a qr code encoding the login url.""" 112 | return "https://api.qrserver.com/v1/create-qr-code/?" + urlencode( 113 | dict(size=f"{self.width}x{self.height}", data=self.url) 114 | ) 115 | 116 | def ref_type(self): 117 | return "directory" if self.schema.endswith("s") else self.schema 118 | 119 | def _login_uri(self) -> str: 120 | return self.uri if self.uri else f"tidal:{self.schema}:login" 121 | 122 | 123 | class PassthroughBuilder(Builder): 124 | def __init__(self, *_, by_type: dict): 125 | self.mapping = by_type 126 | 127 | 128 | def doublewrap(fn): 129 | """Double decorate to allow version with/without args. 130 | 131 | See https://stackoverflow.com/a/14412901/15452601 132 | """ 133 | 134 | @wraps(fn) 135 | def wrapper(*args, **kwargs): 136 | without_args = ( 137 | len(args) == 1 and not kwargs and isinstance(args[0], FunctionType) 138 | ) 139 | if without_args: 140 | return fn(args[0]) 141 | else: 142 | # pass args/kwargs through to decorated fn 143 | return lambda f: fn(f, *args, **kwargs) 144 | 145 | return wrapper 146 | 147 | 148 | def find_uri(args_mapping, kwargs): 149 | uri = None 150 | for k in ("uri", "uris"): 151 | for mapping in (args_mapping, kwargs): 152 | if v := mapping.get(k): 153 | uri = v 154 | if isinstance(uri, list): 155 | uri = uri[0] 156 | 157 | return uri 158 | 159 | 160 | @doublewrap 161 | def login_hack(fn, type=None, passthrough=False): 162 | manual_return_type = type 163 | 164 | @wraps(fn) 165 | def wrapper(obj, *args, **kwargs): 166 | backend: "TidalBackend" = obj.backend 167 | if not backend.logged_in and backend.login_method == "HACK": 168 | schema = "" 169 | uri = "" 170 | expected_return_type = NoneType 171 | spec = inspect.getfullargspec(fn) 172 | # we only need the first vararg anyhow, but this solution is not general 173 | spec_args = spec.args + [spec.varargs] 174 | args_mapping = { 175 | k: next(iter(args[i : i + 1]), None) 176 | for i, k in enumerate(spec_args[1:]) 177 | } 178 | if "uri" in repr(spec): 179 | uri = find_uri(args_mapping, kwargs) 180 | if uri: 181 | _, schema, *_ = uri.split(":") 182 | elif "field" in spec.args: 183 | schema = kwargs.get("field", args_mapping["field"]) 184 | 185 | if schema: 186 | type_mapping = { 187 | "artists": Artist, 188 | "albums": Album, 189 | "playlists": Playlist, 190 | "tracks": Track, 191 | "moods": Ref, 192 | "mixes": Ref, 193 | "genres": Ref, 194 | } 195 | plural = ( 196 | schema 197 | if schema.endswith("s") 198 | else schema + ("es" if schema.endswith("x") else "s") 199 | ).replace("my_", "") 200 | expected_return_type = type_mapping.get(plural, NoneType) 201 | 202 | if manual_return_type: 203 | return_type = manual_return_type 204 | else: 205 | # Assume all declared return types have the same structure 206 | declared_return_types = extract_types(fn.__annotations__["return"]) 207 | possible_return_types = reduce( 208 | lambda a, b: a | interesting_types(b), 209 | [set(), *declared_return_types], 210 | ) 211 | expected_type_possible = ( 212 | expected_return_type is not NoneType 213 | and interesting_types(expected_return_type).issubset( 214 | possible_return_types 215 | ) 216 | ) 217 | return_type = ( 218 | match_structure(declared_return_types[0], expected_return_type) 219 | if expected_type_possible 220 | else declared_return_types[0] 221 | ) 222 | 223 | url = backend.login_url 224 | msg = f"Please visit {url} to log in." 225 | 226 | logger.info("Not logged in. " + msg) 227 | if passthrough: 228 | return PassthroughBuilder(by_type={str: lambda: msg}).build(return_type) 229 | else: 230 | return ObjectBuilder(schema=schema, uri=uri, url=url, msg=msg).build( 231 | return_type 232 | ) 233 | elif backend.login_method == "HACK": 234 | audio_helper = LoginAudioHelper(backend) 235 | audio_helper.remove() 236 | 237 | return fn(obj, *args, **kwargs) 238 | 239 | return wrapper 240 | 241 | 242 | def match_structure(target_type, inner_type): 243 | """Return a type struture equivalent to the target but with the right inner type.""" 244 | base_type = get_origin(target_type) 245 | if base_type is dict: 246 | return base_type[get_args(target_type)[0], inner_type] 247 | if base_type is list: 248 | return base_type[inner_type] 249 | else: 250 | return inner_type 251 | 252 | 253 | voice_rss_api_key = "eb909fe9f2ce403bb7209de172d096f1" 254 | 255 | 256 | def speech_url(msg: str) -> str: 257 | return "https://api.voicerss.org?" + urlencode( 258 | dict( 259 | key=voice_rss_api_key, 260 | hl="en-gb", 261 | c="OGG", 262 | f="16khz_16_bit_stereo", 263 | src=msg, 264 | ) 265 | ) 266 | 267 | 268 | class LoginAudioHelper: 269 | def __init__(self, backend: "TidalBackend"): 270 | self.backend = backend 271 | self.outdir = backend.data_dir / "login_audio" 272 | url = self.backend.login_url 273 | self._audiof = None 274 | if url: 275 | *_, code = url.split("/") 276 | self._audiof = self.outdir / f"{code}.ogg" 277 | 278 | def remove(self): 279 | if self.outdir.exists(): 280 | for x in self.outdir.glob("*.ogg"): 281 | x.unlink() 282 | 283 | def download(self, url: str) -> Optional[str]: 284 | self.outdir.mkdir(parents=True, exist_ok=True) 285 | r = get(url) 286 | try: 287 | r.raise_for_status() 288 | except Exception: 289 | return None 290 | assert self._audiof 291 | with self._audiof.open("wb") as f: 292 | f.write(r.content) 293 | return self._audiof.as_uri() 294 | 295 | 296 | def speak_login_hack(fn): 297 | @wraps(fn) 298 | def wrapper(obj, *args, **kwargs): 299 | backend: "TidalBackend" = obj.backend 300 | if not backend.logged_in and backend.login_method == "HACK": 301 | audio_helper = LoginAudioHelper(backend) 302 | url = backend.login_url 303 | msg = f"Please visit {url}, log in, and add this device. Then come back and refresh to remove this message." 304 | return audio_helper.download(speech_url(msg)) 305 | else: 306 | return fn(obj, *args, **kwargs) 307 | 308 | return wrapper 309 | -------------------------------------------------------------------------------- /mopidy_tidal/lru_cache.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | import pickle 5 | from collections import OrderedDict 6 | from pathlib import Path 7 | from typing import Optional 8 | 9 | from mopidy_tidal import Extension, context 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def id_to_cachef(id: str) -> Path: 15 | return Path(id.replace(":", "-") + ".cache") 16 | 17 | 18 | class LruCache(OrderedDict): 19 | def __init__(self, max_size: Optional[int] = 1024, persist=True, directory=""): 20 | """ 21 | :param max_size: Max size of the cache in memory. Set 0 or None for no 22 | limit (default: 1024) 23 | :param persist: Whether the cache should be persisted to disk 24 | (default: True) 25 | :param directory: If `persist=True`, store the cached entries in this 26 | subfolder of the cache directory (default: '') 27 | """ 28 | super().__init__(self) 29 | if max_size: 30 | assert max_size > 0, f"Invalid cache size: {max_size}" 31 | 32 | self._max_size = max_size or 0 33 | self._cache_dir = Path(Extension.get_cache_dir(context.get_config()), directory) 34 | self._persist = persist 35 | if persist: 36 | self._cache_dir.mkdir(parents=True, exist_ok=True) 37 | 38 | self._check_limit() 39 | 40 | @property 41 | def max_size(self): 42 | return self._max_size 43 | 44 | @property 45 | def persist(self): 46 | return self._persist 47 | 48 | def cache_file(self, key: str, cache_dir: Optional[Path] = None) -> Path: 49 | parts = key.split(":") 50 | assert len(parts) > 2, f"Invalid TIDAL ID: {key}" 51 | _, obj_type, id, *_ = parts 52 | if not cache_dir: 53 | cache_dir = Path(obj_type) 54 | 55 | cache_dir = self._cache_dir / cache_dir / id[:2] 56 | cache_dir.mkdir(parents=True, exist_ok=True) 57 | cache_file = cache_dir / id_to_cachef(key) 58 | legacy_cache_file = cache_dir / f"{key}.cache" 59 | if legacy_cache_file.is_file(): 60 | return legacy_cache_file 61 | 62 | return cache_file 63 | 64 | def _get_from_storage(self, key): 65 | cache_file = self.cache_file(key) 66 | err = KeyError(key) 67 | if not cache_file.is_file(): 68 | # Cache miss on the filesystem 69 | raise err 70 | 71 | # Cache hit on the filesystem 72 | with open(cache_file, "rb") as f: 73 | try: 74 | value = pickle.load(f) 75 | except Exception as e: 76 | # If the cache entry on the filesystem is corrupt, reset it 77 | logger.warning( 78 | "Could not deserialize cache file %s: " "refreshing the entry: %s", 79 | cache_file, 80 | e, 81 | ) 82 | self._reset_stored_entry(key) 83 | raise err 84 | 85 | # Store the filesystem item in memory 86 | if value is not None: 87 | self.__setitem__(key, value, _sync_to_fs=False) 88 | logger.debug(f"Filesystem cache hit for {key}") 89 | return value 90 | 91 | def __getitem__(self, key, *_, **__): 92 | try: 93 | # Cache hit in memory 94 | return super().__getitem__(key) 95 | except KeyError as e: 96 | if not self.persist: 97 | # No persisted storage -> cache miss 98 | raise e 99 | 100 | # Check on the persisted cache 101 | return self._get_from_storage(key) 102 | 103 | def __setitem__(self, key, value, _sync_to_fs=True, *_, **__): 104 | if super().__contains__(key): 105 | del self[key] 106 | 107 | super().__setitem__(key, value) 108 | if self.persist and _sync_to_fs: 109 | cache_file = self.cache_file(key) 110 | with open(cache_file, "wb") as f: 111 | pickle.dump(value, f) 112 | 113 | self._check_limit() 114 | 115 | def __contains__(self, key): 116 | return self.get(key) is not None 117 | 118 | def _reset_stored_entry(self, key): 119 | cache_file = self.cache_file(key) 120 | if cache_file.is_file(): 121 | cache_file.unlink() 122 | 123 | def get(self, key, default=None, *args, **kwargs): 124 | try: 125 | return self.__getitem__(key, *args, **kwargs) 126 | except KeyError: 127 | return default 128 | 129 | def prune(self, *keys): 130 | """ 131 | Delete the specified keys both from memory and disk. 132 | """ 133 | for key in keys: 134 | logger.debug("Pruning key %r from cache %s", key, self.__class__.__name__) 135 | 136 | self._reset_stored_entry(key) 137 | self.pop(key, None) 138 | 139 | def prune_all(self): 140 | """ 141 | Prune all the keys in the cache. 142 | """ 143 | self.prune(*[*self.keys()]) 144 | 145 | def update(self, *args, **kwargs): 146 | super().update(*args, **kwargs) 147 | self._check_limit() 148 | 149 | def _check_limit(self): 150 | if self.max_size: 151 | # delete oldest entries 152 | while len(self) > self.max_size: 153 | self.popitem(last=False) 154 | 155 | 156 | class SearchCache(LruCache): 157 | def __init__(self, search_function): 158 | super().__init__(persist=False) 159 | self._search_function = search_function 160 | 161 | def __call__(self, *args, **kwargs): 162 | key = str(SearchKey(**kwargs)) 163 | cached_result = self.get(key) 164 | logger.info( 165 | "Search cache miss" if cached_result is None else "Search cache hit" 166 | ) 167 | if cached_result is None: 168 | cached_result = self._search_function(*args, **kwargs) 169 | self[key] = cached_result 170 | 171 | return cached_result 172 | 173 | 174 | class SearchKey(object): 175 | def __init__(self, **kwargs): 176 | fixed_query = self.fix_query(kwargs["query"]) 177 | self._query = tuple(sorted(fixed_query.items())) 178 | self._exact = kwargs["exact"] 179 | self._hash = None 180 | 181 | def __hash__(self): 182 | if self._hash is None: 183 | self._hash = hash(self._exact) 184 | self._hash ^= hash(repr(self._query)) 185 | 186 | return self._hash 187 | 188 | def __str__(self): 189 | return f"tidal:search:{self.__hash__()}" 190 | 191 | def __eq__(self, other): 192 | if not isinstance(other, SearchKey): 193 | return False 194 | 195 | return self._exact == other._exact and self._query == other._query 196 | 197 | @staticmethod 198 | def fix_query(query): 199 | """ 200 | Removes some query parameters that otherwise will lead to a cache miss. 201 | Eg: 'track_no' since we can't query TIDAL for a specific album's track. 202 | :param query: query dictionary 203 | :return: sanitized query dictionary 204 | """ 205 | query.pop("track_no", None) 206 | return query 207 | -------------------------------------------------------------------------------- /mopidy_tidal/playback.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from login_hack import speak_login_hack 6 | 7 | if TYPE_CHECKING: # pragma: no cover 8 | from mopidy_tidal.backend import TidalBackend 9 | 10 | import logging 11 | from pathlib import Path 12 | 13 | from mopidy import backend 14 | from tidalapi import Quality 15 | from tidalapi.media import ManifestMimeType 16 | 17 | from . import Extension, context 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class TidalPlaybackProvider(backend.PlaybackProvider): 23 | backend: "TidalBackend" 24 | 25 | @speak_login_hack 26 | def translate_uri(self, uri): 27 | logger.info("TIDAL uri: %s", uri) 28 | parts = uri.split(":") 29 | track_id = int(parts[4]) 30 | session = self.backend.session 31 | if session.config.quality == Quality.hi_res_lossless: 32 | if "HIRES_LOSSLESS" in session.track(track_id).media_metadata_tags: 33 | logger.info("Playback quality: %s", session.config.quality) 34 | else: 35 | logger.info( 36 | "No HIRES_LOSSLESS available for this track; Using playback quality: %s", 37 | "LOSSLESS", 38 | ) 39 | 40 | stream = session.track(track_id).get_stream() 41 | manifest = stream.get_stream_manifest() 42 | logger.info("MimeType:{}".format(stream.manifest_mime_type)) 43 | logger.info( 44 | "Starting playback of track:{}, (quality:{}, codec:{}, {}bit/{}Hz)".format( 45 | track_id, 46 | stream.audio_quality, 47 | manifest.get_codecs(), 48 | stream.bit_depth, 49 | stream.sample_rate, 50 | ) 51 | ) 52 | 53 | if stream.manifest_mime_type == ManifestMimeType.MPD: 54 | data = stream.get_manifest_data() 55 | if data: 56 | mpd_path = Path( 57 | Extension.get_cache_dir(context.get_config()), "manifest.mpd" 58 | ) 59 | with open(mpd_path, "w") as file: 60 | file.write(data) 61 | 62 | return "file://{}".format(mpd_path) 63 | else: 64 | raise AttributeError("No MPD manifest available!") 65 | elif stream.manifest_mime_type == ManifestMimeType.BTS: 66 | urls = manifest.get_urls() 67 | if isinstance(urls, list): 68 | return urls[0] 69 | else: 70 | return urls 71 | -------------------------------------------------------------------------------- /mopidy_tidal/playlists.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import difflib 4 | import logging 5 | import operator 6 | from concurrent.futures import ThreadPoolExecutor 7 | from pathlib import Path 8 | from threading import Event, Timer 9 | from typing import TYPE_CHECKING, Collection, List, Optional, Tuple, Union 10 | 11 | from mopidy import backend 12 | from mopidy.models import Playlist as MopidyPlaylist 13 | from mopidy.models import Ref, Track 14 | from requests import HTTPError 15 | from tidalapi.playlist import Playlist as TidalPlaylist 16 | 17 | from mopidy_tidal import full_models_mappers 18 | from mopidy_tidal.full_models_mappers import create_mopidy_playlist 19 | from mopidy_tidal.helpers import to_timestamp 20 | from mopidy_tidal.login_hack import login_hack 21 | from mopidy_tidal.lru_cache import LruCache 22 | from mopidy_tidal.utils import mock_track 23 | from mopidy_tidal.workers import get_items 24 | 25 | if TYPE_CHECKING: # pragma: no cover 26 | from mopidy_tidal.backend import TidalBackend 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | class PlaylistCache(LruCache): 32 | def __getitem__( 33 | self, key: Union[str, TidalPlaylist], *args, **kwargs 34 | ) -> MopidyPlaylist: 35 | uri = key.id if isinstance(key, TidalPlaylist) else key 36 | assert uri 37 | uri = f"tidal:playlist:{uri}" if not uri.startswith("tidal:playlist:") else uri 38 | 39 | playlist = super().__getitem__(uri, *args, **kwargs) 40 | if ( 41 | playlist 42 | and isinstance(key, TidalPlaylist) 43 | and to_timestamp(key.last_updated) > to_timestamp(playlist.last_modified) 44 | ): 45 | # The playlist has been updated since last time: 46 | # we should refresh the associated cache entry 47 | logger.info('The playlist "%s" has been updated: refresh forced', key.name) 48 | 49 | raise KeyError(uri) 50 | 51 | return playlist 52 | 53 | 54 | class PlaylistMetadataCache(PlaylistCache): 55 | def cache_file(self, key: str) -> Path: 56 | return super().cache_file(key, Path("playlist_metadata")) 57 | 58 | 59 | class TidalPlaylistsProvider(backend.PlaylistsProvider): 60 | backend: "TidalBackend" 61 | 62 | def __init__(self, *args, **kwargs): 63 | super().__init__(*args, **kwargs) 64 | self._playlists_metadata = PlaylistMetadataCache() 65 | self._playlists = PlaylistCache() 66 | self._current_tidal_playlists = [] 67 | self._playlists_loaded_event = Event() 68 | 69 | def _calculate_added_and_removed_playlist_ids( 70 | self, 71 | ) -> Tuple[Collection[str], Collection[str]]: 72 | logger.info("Calculating playlist updates..") 73 | session = self.backend.session 74 | updated_playlists = [] 75 | 76 | with ThreadPoolExecutor( 77 | 2, thread_name_prefix="mopidy-tidal-playlists-refresh-" 78 | ) as pool: 79 | pool_res = pool.map( 80 | lambda func: ( 81 | get_items(func) 82 | if func == session.user.favorites.playlists 83 | else func() 84 | ), 85 | [ 86 | session.user.favorites.playlists, 87 | session.user.playlists, 88 | ], 89 | ) 90 | 91 | for playlists in pool_res: 92 | updated_playlists += playlists 93 | 94 | self._current_tidal_playlists = updated_playlists 95 | updated_ids = set(pl.id for pl in updated_playlists) 96 | if not self._playlists_metadata: 97 | return updated_ids, set() 98 | 99 | current_ids = set(uri.split(":")[-1] for uri in self._playlists_metadata.keys()) 100 | added_ids = updated_ids.difference(current_ids) 101 | removed_ids = current_ids.difference(updated_ids) 102 | self._playlists_metadata.prune( 103 | *[ 104 | uri 105 | for uri in self._playlists_metadata.keys() 106 | if uri.split(":")[-1] in removed_ids 107 | ] 108 | ) 109 | 110 | return added_ids, removed_ids 111 | 112 | def _has_changes(self, playlist: MopidyPlaylist): 113 | upstream_playlist = self.backend.session.playlist(playlist.uri.split(":")[-1]) 114 | if not upstream_playlist: 115 | return True 116 | 117 | upstream_last_updated_at = to_timestamp( 118 | getattr(upstream_playlist, "last_updated", None) 119 | ) 120 | local_last_updated_at = to_timestamp(playlist.last_modified) 121 | 122 | if not upstream_last_updated_at: 123 | logger.warning( 124 | "You are using a version of python-tidal that does not " 125 | "support last_updated on playlist objects" 126 | ) 127 | return True 128 | 129 | if upstream_last_updated_at > local_last_updated_at: 130 | logger.info( 131 | 'The playlist "%s" has been updated: refresh forced', playlist.name 132 | ) 133 | return True 134 | 135 | return False 136 | 137 | @login_hack(list[Ref.playlist]) 138 | def as_list(self) -> list[Ref]: 139 | if not self._playlists_loaded_event.is_set(): 140 | added_ids, _ = self._calculate_added_and_removed_playlist_ids() 141 | if added_ids: 142 | self.refresh(include_items=False) 143 | 144 | logger.debug("Listing TIDAL playlists..") 145 | refs = [ 146 | Ref.playlist(uri=pl.uri, name=pl.name) 147 | for pl in self._playlists_metadata.values() 148 | ] 149 | 150 | return sorted(refs, key=operator.attrgetter("name")) 151 | 152 | def _lookup_mix(self, uri): 153 | mix_id = uri.split(":")[-1] 154 | session = self.backend.session 155 | return session.mix(mix_id) 156 | 157 | def _get_or_refresh_playlist(self, uri) -> Optional[MopidyPlaylist]: 158 | parts = uri.split(":") 159 | if parts[1] == "mix": 160 | mix = self._lookup_mix(uri) 161 | return full_models_mappers.create_mopidy_mix_playlist(mix) 162 | 163 | playlist = self._playlists.get(uri) 164 | if (playlist is None) or (playlist and self._has_changes(playlist)): 165 | self.refresh(uri, include_items=True) 166 | return self._playlists.get(uri) 167 | 168 | def create(self, name): 169 | tidal_playlist = self.backend.session.user.create_playlist(name, "") 170 | pl = create_mopidy_playlist(tidal_playlist, []) 171 | 172 | self._current_tidal_playlists.append(tidal_playlist) 173 | self.refresh(pl.uri) 174 | return pl 175 | 176 | def delete(self, uri): 177 | playlist_id = uri.split(":")[-1] 178 | session = self.backend.session 179 | 180 | try: 181 | session.request.request( 182 | "DELETE", 183 | "playlists/{playlist_id}".format( 184 | playlist_id=playlist_id, 185 | ), 186 | ) 187 | except HTTPError as e: 188 | # If we got a 401, it's likely that the user is following 189 | # this playlist but they don't have permissions for removing 190 | # it. If that's the case, remove the playlist from the 191 | # favourites instead of deleting it. 192 | if e.response.status_code == 401 and uri in { 193 | f"tidal:playlist:{pl.id}" for pl in session.user.favorites.playlists() 194 | }: 195 | session.user.favorites.remove_playlist(playlist_id) 196 | else: 197 | raise e 198 | 199 | self._playlists_metadata.prune(uri) 200 | self._playlists.prune(uri) 201 | 202 | @login_hack 203 | def lookup(self, uri) -> Optional[MopidyPlaylist]: 204 | return self._get_or_refresh_playlist(uri) 205 | 206 | @login_hack 207 | def refresh(self, *uris, include_items: bool = True) -> dict[str, MopidyPlaylist]: 208 | if uris: 209 | logger.info("Looking up playlists: %r", uris) 210 | else: 211 | logger.info("Refreshing TIDAL playlists..") 212 | 213 | session = self.backend.session 214 | plists = self._current_tidal_playlists 215 | mapped_playlists = {} 216 | playlist_cache = self._playlists if include_items else self._playlists_metadata 217 | 218 | for pl in plists: 219 | uri = "tidal:playlist:" + pl.id 220 | # Skip or cache hit case 221 | if (uris and uri not in uris) or pl in playlist_cache: 222 | continue 223 | 224 | # Cache miss case 225 | if include_items: 226 | pl_tracks = self._retrieve_api_tracks(session, pl) 227 | tracks = full_models_mappers.create_mopidy_tracks(pl_tracks) 228 | else: 229 | # Create as many mock tracks as the number of items in the playlist. 230 | # Playlist metadata is concerned only with the number of tracks, not 231 | # the actual list. 232 | tracks = [mock_track] * pl.num_tracks 233 | 234 | mapped_playlists[uri] = MopidyPlaylist( 235 | uri=uri, 236 | name=pl.name, 237 | tracks=tracks, 238 | last_modified=to_timestamp(pl.last_updated), 239 | ) 240 | 241 | # When we trigger a playlists_loaded event the backend may call as_list 242 | # again. Set an event in playlist_cache_refresh_secs seconds to ensure 243 | # that we don't perform another playlist sync. 244 | self._playlists_loaded_event.set() 245 | playlist_cache_refresh_secs = self.backend._config["tidal"].get( 246 | "playlist_cache_refresh_secs" 247 | ) 248 | 249 | if playlist_cache_refresh_secs: 250 | Timer( 251 | playlist_cache_refresh_secs, 252 | lambda: self._playlists_loaded_event.clear(), 253 | ).start() 254 | 255 | # Update the right playlist cache and send the playlists_loaded event. 256 | playlist_cache.update(mapped_playlists) 257 | backend.BackendListener.send("playlists_loaded") 258 | logger.info("TIDAL playlists refreshed") 259 | 260 | @login_hack 261 | def get_items(self, uri) -> Optional[List[Ref]]: 262 | playlist = self._get_or_refresh_playlist(uri) 263 | if not playlist: 264 | return 265 | 266 | return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] 267 | 268 | def _retrieve_api_tracks(self, session, playlist): 269 | getter_args = tuple() 270 | return get_items(playlist.tracks, *getter_args) 271 | 272 | def save(self, playlist): 273 | old_playlist = self._get_or_refresh_playlist(playlist.uri) 274 | session = self.backend.session 275 | playlist_id = playlist.uri.split(":")[-1] 276 | assert old_playlist, f"No such playlist: {playlist.uri}" 277 | assert session, "No active session" 278 | upstream_playlist = session.playlist(playlist_id) 279 | 280 | # Playlist rename case 281 | if old_playlist.name != playlist.name: 282 | upstream_playlist.edit(title=playlist.name) 283 | 284 | additions = [] 285 | removals = [] 286 | remove_offset = 0 287 | diff_lines = difflib.ndiff( 288 | [t.uri for t in old_playlist.tracks], [t.uri for t in playlist.tracks] 289 | ) 290 | 291 | for diff_line in diff_lines: 292 | if diff_line.startswith("+ "): 293 | additions.append(diff_line[2:].split(":")[-1]) 294 | else: 295 | if diff_line.startswith("- "): 296 | removals.append(remove_offset) 297 | remove_offset += 1 298 | 299 | # Process removals in descending order so we don't have to recalculate 300 | # the offsets while we remove tracks 301 | if removals: 302 | logger.info( 303 | 'Removing %d tracks from the playlist "%s"', 304 | len(removals), 305 | playlist.name, 306 | ) 307 | 308 | removals.reverse() 309 | for idx in removals: 310 | upstream_playlist.remove_by_index(idx) 311 | 312 | # tidalapi currently only supports appending tracks to the end of the 313 | # playlist 314 | if additions: 315 | logger.info( 316 | 'Adding %d tracks to the playlist "%s"', len(additions), playlist.name 317 | ) 318 | 319 | upstream_playlist.add(additions) 320 | 321 | # remove all defunct tracks from cache 322 | self._calculate_added_and_removed_playlist_ids() 323 | # force update the whole playlist so all state is good 324 | self.refresh(playlist.uri) 325 | -------------------------------------------------------------------------------- /mopidy_tidal/ref_models_mappers.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | 5 | from mopidy.models import Ref 6 | from tidalapi import Album, Artist, Mix, Playlist, Track 7 | from tidalapi.mix import MixType 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def create_root(): 13 | return [ 14 | Ref.directory(uri="tidal:home", name="Home"), 15 | Ref.directory(uri="tidal:for_you", name="For You"), 16 | Ref.directory(uri="tidal:explore", name="Explore"), 17 | Ref.directory(uri="tidal:hires", name="HiRes"), 18 | Ref.directory(uri="tidal:genres", name="Genres"), 19 | Ref.directory(uri="tidal:moods", name="Moods"), 20 | Ref.directory(uri="tidal:mixes", name="My Mixes"), 21 | Ref.directory(uri="tidal:my_artists", name="My Artists"), 22 | Ref.directory(uri="tidal:my_albums", name="My Albums"), 23 | Ref.directory(uri="tidal:my_playlists", name="My Playlists"), 24 | Ref.directory(uri="tidal:my_tracks", name="My Tracks"), 25 | Ref.directory(uri="tidal:my_mixes", name="Mixes & Radio"), 26 | ] 27 | 28 | 29 | def create_artists(tidal_artists): 30 | return [create_artist(a) for a in tidal_artists] 31 | 32 | 33 | def create_artist(tidal_artist): 34 | return Ref.artist( 35 | uri="tidal:artist:" + str(tidal_artist.id), name=tidal_artist.name 36 | ) 37 | 38 | 39 | def create_playlists(tidal_playlists): 40 | return [create_playlist(p) for p in tidal_playlists] 41 | 42 | 43 | def create_playlist(tidal_playlist): 44 | return Ref.playlist( 45 | uri="tidal:playlist:" + str(tidal_playlist.id), name=tidal_playlist.name 46 | ) 47 | 48 | 49 | def create_moods(tidal_moods): 50 | return [create_mood(m) for m in tidal_moods] 51 | 52 | 53 | def create_mood(tidal_mood): 54 | mood_id = tidal_mood.api_path.split("/")[-1] 55 | return Ref.directory(uri="tidal:mood:" + mood_id, name=tidal_mood.title) 56 | 57 | 58 | def create_genres(tidal_genres): 59 | return [create_genre(m) for m in tidal_genres] 60 | 61 | 62 | def create_genre(tidal_genre): 63 | genre_id = tidal_genre.path 64 | return Ref.directory(uri="tidal:genre:" + genre_id, name=tidal_genre.name) 65 | 66 | 67 | def create_category_directories(uri, tidal_page): 68 | res = [] 69 | for idx, category in enumerate(tidal_page.categories): 70 | if category.title == "": 71 | res.extend( 72 | [create_mixed_entry(item) for idx, item in enumerate(category.items)] 73 | ) 74 | else: 75 | res.append(create_category_directory(uri, idx, category.title)) 76 | 77 | # Remove None/Unsupported entries 78 | res_filtered = [i for i in res if i is not None] 79 | return res_filtered 80 | 81 | 82 | def create_category_directory(uri, idx, name): 83 | return Ref.directory(uri="{}:category:{}".format(uri, idx), name=name) 84 | 85 | 86 | def create_mixed_directory(tidal_mixed): 87 | res = [create_mixed_entry(m) for m in tidal_mixed] 88 | # Remove None/Unsupported entries 89 | res_filtered = [i for i in res if i is not None] 90 | return res_filtered 91 | 92 | 93 | def create_mixed_entry(tidal_mixed): 94 | if isinstance(tidal_mixed, Mix): 95 | return Ref.playlist( 96 | uri="tidal:mix:" + tidal_mixed.id, 97 | name=f"{tidal_mixed.title} ({tidal_mixed.sub_title})", 98 | ) 99 | elif isinstance(tidal_mixed, Album): 100 | return Ref.album( 101 | uri="tidal:album:" + str(tidal_mixed.id), 102 | name=f"{tidal_mixed.name} ({tidal_mixed.artist.name})", 103 | ) 104 | elif isinstance(tidal_mixed, Playlist): 105 | return Ref.playlist( 106 | uri="tidal:playlist:" + str(tidal_mixed.id), 107 | name=f"{tidal_mixed.name}", 108 | ) 109 | elif isinstance(tidal_mixed, Track): 110 | return create_track(tidal_mixed) 111 | elif isinstance(tidal_mixed, Artist): 112 | return create_artist(tidal_mixed) 113 | else: 114 | if hasattr(tidal_mixed, "api_path"): 115 | # Objects containing api_path are usually pages and must be processed further 116 | return Ref.directory( 117 | uri="tidal:page:" + tidal_mixed.api_path, name=tidal_mixed.title 118 | ) 119 | elif hasattr(tidal_mixed, "artifact_id"): 120 | # Objects containing artifact_id can be viewed directly 121 | explore_id = tidal_mixed.artifact_id 122 | name = f"{tidal_mixed.short_header} ({tidal_mixed.short_sub_header})" 123 | if tidal_mixed.type == "PLAYLIST": 124 | return Ref.playlist( 125 | uri="tidal:playlist:" + explore_id, 126 | name=name, 127 | ) 128 | else: 129 | # Unsupported type (eg. interview, exturl) 130 | return None 131 | else: 132 | # Unsupported type (eg. Video) 133 | return None 134 | 135 | 136 | def create_mixes(tidal_mixes): 137 | res = [create_mix(m) for m in tidal_mixes] 138 | # Remove None/Unsupported entries 139 | res_filtered = [i for i in res if i is not None] 140 | return res_filtered 141 | 142 | 143 | def create_mix(tidal_mix): 144 | if tidal_mix.mix_type is MixType.video_daily: 145 | # Skip video mixes (not supported) 146 | return None 147 | else: 148 | return Ref.playlist( 149 | uri="tidal:mix:" + tidal_mix.id, 150 | name=f"{tidal_mix.title} ({tidal_mix.sub_title})", 151 | ) 152 | 153 | 154 | def create_albums(tidal_albums): 155 | return [create_album(a) for a in tidal_albums] 156 | 157 | 158 | def create_album(tidal_album): 159 | return Ref.album(uri="tidal:album:" + str(tidal_album.id), name=tidal_album.name) 160 | 161 | 162 | def create_tracks(tidal_tracks): 163 | return [create_track(t) for t in tidal_tracks] 164 | 165 | 166 | def create_track(tidal_track): 167 | uri = "tidal:track:{0}:{1}:{2}".format( 168 | tidal_track.artist.id, tidal_track.album.id, tidal_track.id 169 | ) 170 | return Ref.track(uri=uri, name=tidal_track.name) 171 | -------------------------------------------------------------------------------- /mopidy_tidal/search.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import logging 4 | from collections import OrderedDict 5 | from concurrent.futures import ThreadPoolExecutor 6 | from dataclasses import dataclass 7 | from enum import IntEnum 8 | from typing import ( 9 | Callable, 10 | Collection, 11 | Iterable, 12 | List, 13 | Mapping, 14 | Sequence, 15 | Tuple, 16 | Type, 17 | Union, 18 | ) 19 | 20 | from lru_cache import SearchCache 21 | from tidalapi.album import Album 22 | from tidalapi.artist import Artist 23 | from tidalapi.media import Track 24 | 25 | from mopidy_tidal.full_models_mappers import ( 26 | create_mopidy_albums, 27 | create_mopidy_artists, 28 | create_mopidy_tracks, 29 | ) 30 | from mopidy_tidal.utils import remove_watermark 31 | 32 | logger = logging.getLogger(__name__) 33 | 34 | 35 | class SearchField(IntEnum): 36 | ANY = 0 37 | ARTIST = 1 38 | ALBUMARTIST = 2 39 | ALBUM = 3 40 | TITLE = 4 41 | 42 | 43 | @dataclass 44 | class SearchFieldMeta: 45 | field: SearchField 46 | request_field: str 47 | results_fields: Sequence[str] 48 | model_classes: Collection[Type[Union[Artist, Album, Track]]] 49 | mappers: Sequence[Callable[[Collection], Sequence]] 50 | 51 | 52 | fields_meta = { 53 | meta.field: meta 54 | for meta in [ 55 | SearchFieldMeta( 56 | SearchField.ANY, 57 | request_field="any", 58 | results_fields=("artists", "albums", "tracks"), 59 | model_classes=(Artist, Album, Track), 60 | mappers=(create_mopidy_artists, create_mopidy_albums, create_mopidy_tracks), 61 | ), 62 | SearchFieldMeta( 63 | SearchField.ARTIST, 64 | request_field="artist", 65 | results_fields=("artists",), 66 | model_classes=(Artist,), 67 | mappers=(create_mopidy_artists,), 68 | ), 69 | SearchFieldMeta( 70 | SearchField.ALBUMARTIST, 71 | request_field="albumartist", 72 | results_fields=("artists",), 73 | model_classes=(Artist,), 74 | mappers=(create_mopidy_artists,), 75 | ), 76 | SearchFieldMeta( 77 | SearchField.ALBUM, 78 | request_field="album", 79 | results_fields=("albums",), 80 | model_classes=(Album,), 81 | mappers=(create_mopidy_albums,), 82 | ), 83 | SearchFieldMeta( 84 | SearchField.TITLE, 85 | request_field="track_name", 86 | results_fields=("tracks",), 87 | model_classes=(Track,), 88 | mappers=(create_mopidy_tracks,), 89 | ), 90 | ] 91 | } 92 | 93 | 94 | def _get_flattened_query_and_field_meta( 95 | query: Mapping[str, str] 96 | ) -> Tuple[str, SearchFieldMeta]: 97 | q = " ".join( 98 | query[field] 99 | for field in ("any", "artist", "album", "track_name") 100 | if query.get(field) 101 | ) 102 | 103 | fields_by_request_field = { 104 | field_meta.request_field: field_meta for field_meta in fields_meta.values() 105 | } 106 | 107 | matched_field_meta = fields_by_request_field["any"] 108 | for attr in ("track_name", "album", "artist", "albumartist"): 109 | field_meta = fields_by_request_field.get(attr) 110 | if field_meta and query.get(attr): 111 | matched_field_meta = field_meta 112 | break 113 | 114 | return q, matched_field_meta 115 | 116 | 117 | def _get_exact_result( 118 | query: Mapping, 119 | results: Tuple[Iterable[Artist], Iterable[Album], Iterable[Track]], 120 | field_meta: SearchFieldMeta, 121 | ) -> Tuple[List[Artist], List[Album], List[Track]]: 122 | query_value = query[field_meta.request_field] 123 | filtered_results = [], [], [] 124 | 125 | for i, attr in enumerate( 126 | (SearchField.TITLE, SearchField.ALBUM, SearchField.ARTIST) 127 | ): 128 | if attr == field_meta.field: 129 | item = next( 130 | ( 131 | res 132 | # TODO: why not results[-i-1]? 133 | for res in results[len(results) - i - 1] 134 | if res.name and res.name.lower() == query_value.lower() 135 | ), 136 | None, 137 | ) 138 | 139 | if item: 140 | filtered_results[len(results) - i - 1].append(item) 141 | break 142 | 143 | return filtered_results 144 | 145 | 146 | def _expand_artist_top_tracks(artist: Artist) -> List[Track]: 147 | return artist.get_top_tracks(limit=25) 148 | 149 | 150 | def _expand_album_tracks(album: Album) -> List[Track]: 151 | return album.tracks() 152 | 153 | 154 | def _expand_results_tracks( 155 | results: Tuple[List[Artist], List[Album], List[Track]], 156 | ) -> Tuple[List[Artist], List[Album], List[Track]]: 157 | results_ = list(results) 158 | artists = results_[0] 159 | albums = results_[1] 160 | 161 | with ThreadPoolExecutor(4, thread_name_prefix="mopidy-tidal-search-") as pool: 162 | pool_res = pool.map(_expand_artist_top_tracks, artists) 163 | for tracks in pool_res: 164 | results_[2].extend(tracks) 165 | 166 | pool_res = pool.map(_expand_album_tracks, albums) 167 | for tracks in pool_res: 168 | results_[2].extend(tracks) 169 | 170 | # Remove any duplicate tracks from results 171 | tracks_by_id = OrderedDict({track.id: track for track in results_[2]}) 172 | results_[2] = list(tracks_by_id.values()) 173 | return tuple(results_) 174 | 175 | 176 | @SearchCache 177 | def tidal_search(session, query, exact=False): 178 | logger.info("Searching Tidal for: %r", query) 179 | query = query.copy() 180 | 181 | for field, value in query.items(): 182 | if hasattr(value, "__iter__") and not isinstance(value, (str, bytes)): 183 | value = value[0] 184 | query[field] = remove_watermark(value) 185 | 186 | query_string, field_meta = _get_flattened_query_and_field_meta(query) 187 | 188 | results = [[], [], []] # artists, albums, tracks 189 | api_results = session.search(query_string, models=field_meta.model_classes) 190 | 191 | for i, field_type in enumerate( 192 | (SearchField.ARTIST, SearchField.ALBUM, SearchField.TITLE) 193 | ): 194 | meta = fields_meta[field_type] 195 | results_field = meta.results_fields[0] 196 | mapper = meta.mappers[0] 197 | 198 | if not (results_field in api_results and results_field in meta.results_fields): 199 | continue 200 | 201 | results[i] = api_results[results_field] 202 | 203 | if exact: 204 | results = list(_get_exact_result(query, tuple(results), field_meta)) 205 | 206 | _expand_results_tracks(results) 207 | for i, field_type in enumerate( 208 | (SearchField.ARTIST, SearchField.ALBUM, SearchField.TITLE) 209 | ): 210 | meta = fields_meta[field_type] 211 | mapper = meta.mappers[0] 212 | results[i] = mapper(results[i]) 213 | 214 | return tuple(results) 215 | -------------------------------------------------------------------------------- /mopidy_tidal/utils.py: -------------------------------------------------------------------------------- 1 | from mopidy.models import Track 2 | 3 | watermark = " [TIDAL]" 4 | mock_track = Track(uri="tidal:track:0:0:0", artists=[], name=None) 5 | 6 | 7 | def apply_watermark(val): 8 | return val + watermark 9 | 10 | 11 | def remove_watermark(watermarked_val): 12 | if watermarked_val is None: 13 | return None 14 | 15 | if watermarked_val.endswith(watermark): 16 | watermarked_val = watermarked_val[: -len(watermark)] 17 | 18 | return watermarked_val 19 | -------------------------------------------------------------------------------- /mopidy_tidal/web_auth_server.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from functools import partial 3 | from http.server import BaseHTTPRequestHandler, HTTPServer 4 | from string import whitespace 5 | from typing import Callable, Optional 6 | from urllib.parse import unquote 7 | 8 | HTML_BODY = """ 9 | 10 | 11 | TIDAL Web Auth 12 | 13 | 14 | 15 |

KEEP THIS TAB OPEN

16 | Click here to be forwarded to TIDAL Login page 17 | {interactive} 18 | 19 | 20 | 21 | """.format 22 | 23 | INTERACTIVE_HTML_BODY = """ 24 |

...then, after login, copy the complete URL of the page you were redirected to.

25 |

Probably a "Oops / Not found" page, nevertheless we need the whole URL as is.

26 |
27 | 28 | 29 | 30 |
31 | """ 32 | 33 | 34 | class WebAuthServer: 35 | def __init__(self): 36 | self.handler: Optional[partial] = None 37 | self.callback: Optional[Callable] = None 38 | self.response_code: str = "" 39 | self.daemon_started: bool = False 40 | 41 | def start_oauth_daemon(self, login_url: str, port: int, pkce_enabled: bool): 42 | if self.daemon_started: 43 | return 44 | 45 | self.handler = partial( 46 | HTTPHandler, login_url, self.set_response_code, pkce_enabled 47 | ) 48 | 49 | daemon = threading.Thread( 50 | name="TidalOAuthLogin", 51 | target=HTTPServer(("", port), self.handler).serve_forever, 52 | ) 53 | daemon.daemon = ( 54 | True # Set as a daemon so it will be killed once the main thread is dead. 55 | ) 56 | daemon.start() 57 | self.daemon_started = True 58 | 59 | def set_callback(self, callback: Callable[[str], None]): 60 | self.callback = callback 61 | 62 | def set_response_code(self, response_code: str): 63 | self.response_code = response_code 64 | if self.callback: 65 | self.callback(response_code) 66 | 67 | @property 68 | def is_daemon_running(self): 69 | return self.daemon_started 70 | 71 | @property 72 | def get_response_code(self): 73 | if self.response_code == "": 74 | return None 75 | else: 76 | return self.response_code 77 | 78 | 79 | class HTTPHandler(BaseHTTPRequestHandler, object): 80 | def __init__( 81 | self, 82 | login_url: str, 83 | callback: Callable[[str], None], 84 | pkce_enabled: bool, 85 | *args, 86 | **kwargs 87 | ): 88 | self.login_url = login_url 89 | self.callback_fn: Callable = callback 90 | self.pkce_enabled = pkce_enabled 91 | super().__init__(*args, **kwargs) 92 | 93 | def do_GET(self): 94 | self.send_response(200) 95 | self.send_header("Content-type", "text/html") 96 | self.end_headers() 97 | interactive = INTERACTIVE_HTML_BODY if self.pkce_enabled else "" 98 | self.wfile.write( 99 | HTML_BODY(authurl=self.login_url, interactive=interactive).encode() 100 | ) 101 | 102 | def do_POST(self): 103 | content_length = int(self.headers.get("Content-Length"), 0) 104 | body = self.rfile.read(content_length).decode() 105 | try: 106 | form = {k: v for k, v in (p.split("=", 1) for p in body.split("&"))} 107 | code_url = unquote(form["code"].strip(whitespace)) 108 | except: 109 | self.send_response(400) 110 | self.end_headers() 111 | self.wfile.write(b"Malformed request") 112 | raise 113 | else: 114 | self.callback_fn(code_url) 115 | self.send_response(200) 116 | self.end_headers() 117 | self.wfile.write(b"Success!\nEnjoy your music!") 118 | -------------------------------------------------------------------------------- /mopidy_tidal/workers.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import ThreadPoolExecutor 2 | from typing import Callable 3 | 4 | 5 | def func_wrapper(args): 6 | (f, offset, *args) = args 7 | items = f(*args) 8 | return list((i + offset, item) for i, item in enumerate(items)) 9 | 10 | 11 | def get_items( 12 | func: Callable, 13 | *args, 14 | parse: Callable = lambda _: _, 15 | chunk_size: int = 100, 16 | processes: int = 5, 17 | ): 18 | """ 19 | This function performs pagination on a function that supports 20 | `limit`/`offset` parameters and it runs API requests in parallel to speed 21 | things up. 22 | """ 23 | items = [] 24 | offsets = [-chunk_size] 25 | remaining = chunk_size * processes 26 | 27 | with ThreadPoolExecutor( 28 | processes, thread_name_prefix=f"mopidy-tidal-{func.__name__}-" 29 | ) as pool: 30 | while remaining == chunk_size * processes: 31 | offsets = [offsets[-1] + chunk_size * (i + 1) for i in range(processes)] 32 | 33 | pool_results = pool.map( 34 | func_wrapper, 35 | [ 36 | ( 37 | func, 38 | offset, 39 | *args, 40 | chunk_size, # limit 41 | offset, # offset 42 | ) 43 | for offset in offsets 44 | ], 45 | ) 46 | 47 | new_items = [] 48 | for results in pool_results: 49 | new_items.extend(results) 50 | 51 | remaining = len(new_items) 52 | items.extend(new_items) 53 | 54 | items = [_ for _ in items if _] 55 | sorted_items = list( 56 | map(lambda item: item[1], sorted(items, key=lambda item: item[0])) 57 | ) 58 | 59 | return list(map(parse, sorted_items)) 60 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "mopidy-tidal" 3 | version = "0.3.9" 4 | description = "Mopidy Extension for Tidal music service integration." 5 | authors = ["Johannes Linde "] 6 | license = "Apache License 2.0" 7 | readme = "README.md" 8 | packages = [{ include = "mopidy_tidal" }] 9 | repository = "https://github.com/tehkillerbee/mopidy-tidal" 10 | classifiers = [ 11 | "Environment :: No Input/Output (Daemon)", 12 | "Intended Audience :: End Users/Desktop", 13 | "License :: OSI Approved :: Apache Software License", 14 | "Operating System :: OS Independent", 15 | "Programming Language :: Python :: 3", 16 | "Topic :: Multimedia :: Sound/Audio :: Players", 17 | ] 18 | 19 | [tool.poetry.dependencies] 20 | python = "^3.9" 21 | Mopidy = "^3.0" 22 | tidalapi = "^0.8.1" 23 | 24 | [tool.poetry.group.dev.dependencies] 25 | pytest = "^7.3.1" 26 | pytest-mock = "^3.10.0" 27 | pytest-sugar = "^0.9.7" 28 | black = ">=23.3,<25.0" 29 | isort = "^5.12.0" 30 | pytest-cov = "^4.0.0" 31 | pexpect = "^4.8.0" 32 | pytest-diff = "^0.1.14" 33 | 34 | [tool.poetry.group.complete] 35 | optional = true 36 | 37 | [tool.poetry.group.complete.dependencies] 38 | pygobject = "^3.44.1" 39 | mopidy-local = "^3.2.1" 40 | mopidy-iris = "^3.66.1" 41 | mopidy-mpd = "^3.3" 42 | 43 | [build-system] 44 | requires = ["poetry-core"] 45 | build-backend = "poetry.core.masonry.api" 46 | 47 | [tool.poetry.plugins."mopidy.ext"] 48 | tidal = "mopidy_tidal:Extension" 49 | 50 | 51 | [tool.pytest.ini_options] 52 | markers = [ 53 | "gt_3_10: Mark a test as requiring python > 3.10.", 54 | "poor_test: Mark a test in need of improvement", 55 | "insufficiently_decoupled: Mark a test as insufficiently decoupled from implementation", 56 | ] 57 | filterwarnings = [ 58 | "error::DeprecationWarning:mopidy[.*]", 59 | "error::PendingDeprecationWarning:mopidy[.*]", 60 | ] -------------------------------------------------------------------------------- /test_requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | pytest-mock 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tehkillerbee/mopidy-tidal/abead0f347b79f94a3615b2a8d63aba62f98980b/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from concurrent.futures import Future 3 | from typing import Optional 4 | from unittest.mock import Mock 5 | 6 | import pytest 7 | from tidalapi import Genre, Session 8 | from tidalapi.album import Album 9 | from tidalapi.artist import Artist 10 | from tidalapi.media import Track 11 | from tidalapi.mix import Mix 12 | from tidalapi.page import Page, PageCategory 13 | from tidalapi.playlist import UserPlaylist 14 | from tidalapi.session import LinkLogin 15 | 16 | from mopidy_tidal import context 17 | from mopidy_tidal.backend import TidalBackend 18 | from mopidy_tidal.context import set_config 19 | 20 | 21 | def _make_mock(mock: Optional[Mock] = None, **kwargs) -> Mock: 22 | """Make a mock with the desired properties. 23 | 24 | This exists to work around name collisions in `Mock(**kwargs)`, which 25 | prevents settings some values, such as `name`. If desired a configured 26 | mock can be passed in, in which case this is simply a wrapper around 27 | setting attributes. 28 | 29 | >>> from unittest.mock import Mock 30 | >>> # shadowed: sets the *mock name*, not the attribute 31 | >>> assert Mock(name="foo").name != "foo" 32 | >>> assert make_mock(name="foo").name == "foo" 33 | """ 34 | mock = mock or Mock() 35 | for k, v in kwargs.items(): 36 | setattr(mock, k, v) 37 | 38 | return mock 39 | 40 | 41 | make_mock = pytest.fixture(lambda: _make_mock) 42 | 43 | 44 | @pytest.fixture(autouse=True) 45 | def config(tmp_path): 46 | """Set up config. 47 | 48 | This fixture sets up config in context and removes it after the test. It 49 | yields the config dictionary, so if you edit the dictionary you are editing 50 | the config. 51 | """ 52 | 53 | cfg = { 54 | "core": { 55 | "cache_dir": str(tmp_path), 56 | "data_dir": str(tmp_path), 57 | }, 58 | "tidal": { 59 | "client_id": "client_id", 60 | "client_secret": "client_secret", 61 | "quality": "LOSSLESS", 62 | "lazy": False, 63 | "login_method": "BLOCK", 64 | "auth_method": "OAUTH2", 65 | "login_server_port": 8989, 66 | }, 67 | } 68 | context.set_config(cfg) 69 | yield cfg 70 | context.set_config(None) 71 | 72 | 73 | @pytest.fixture 74 | def tidal_search(mocker): 75 | """Provide an uncached tidal_search. 76 | 77 | Tidal search is cached with a decorator, so we have to mock before we 78 | import anything. No test should import anything from 79 | `mopidy_tidal.search`. Instead use this fixture.""" 80 | # import lru_cache so we can mock the right name in sys.modules without it 81 | # being overriden. 82 | from mopidy_tidal import lru_cache # noqa 83 | 84 | # remove caching, since the cache is created only at import so otherwise we 85 | # can't remove it 86 | mocker.patch("lru_cache.SearchCache", lambda x: x) 87 | from mopidy_tidal.search import tidal_search 88 | 89 | yield tidal_search 90 | 91 | 92 | def counter(msg: str): 93 | """A counter for providing sequential names.""" 94 | x = 0 95 | while True: 96 | yield msg.format(x) 97 | x += 1 98 | 99 | 100 | # Giving each mock a unique name really helps when inspecting funny behaviour. 101 | track_counter = counter("Mock Track #{}") 102 | album_counter = counter("Mock Album #{}") 103 | artist_counter = counter("Mock Artist #{}") 104 | page_counter = counter("Mock Page #{}") 105 | mix_counter = counter("Mock Mix #{}") 106 | genre_counter = counter("Mock Genre #{}") 107 | 108 | 109 | def _make_tidal_track( 110 | id: int, 111 | artist: Artist, 112 | album: Album, 113 | name: Optional[str] = None, 114 | duration: Optional[int] = None, 115 | ): 116 | return _make_mock( 117 | mock=Mock(spec=Track, name=next(track_counter)), 118 | id=id, 119 | name=name or f"Track-{id}", 120 | full_name=name or f"Track-{id}", 121 | artist=artist, 122 | album=album, 123 | uri=f"tidal:track:{artist.id}:{album.id}:{id}", 124 | duration=duration or (100 + id), 125 | track_num=id, 126 | disc_num=id, 127 | ) 128 | 129 | 130 | def _make_tidal_artist(*, name: str, id: int, top_tracks: Optional[list[Track]] = None): 131 | return _make_mock( 132 | mock=Mock(spec=Artist, name=next(artist_counter)), 133 | **{ 134 | "id": id, 135 | "get_top_tracks.return_value": top_tracks, 136 | "name": name, 137 | }, 138 | ) 139 | 140 | 141 | def _make_tidal_album( 142 | *, 143 | name: str, 144 | id: int, 145 | tracks: Optional[list[dict]] = None, 146 | artist: Optional[Artist] = None, 147 | **kwargs, 148 | ): 149 | album = _make_mock( 150 | mock=Mock(spec=Album, name=next(album_counter)), 151 | name=name, 152 | id=id, 153 | artist=artist or _make_tidal_artist(name="Album Artist", id=id + 1234), 154 | **kwargs, 155 | ) 156 | tracks = [_make_tidal_track(**spec, album=album) for spec in (tracks or [])] 157 | album.tracks.return_value = tracks 158 | return album 159 | 160 | 161 | def _make_tidal_page(*, title: str, categories: list[PageCategory], api_path: str): 162 | return Mock(spec=Page, title=title, categories=categories, api_path=api_path) 163 | 164 | 165 | def _make_tidal_mix(*, title: str, sub_title: str, id: int): 166 | return Mock( 167 | spec=Mix, title=title, sub_title=sub_title, name=next(mix_counter), id=str(id) 168 | ) 169 | 170 | 171 | def _make_tidal_genre(*, name: str, path: str): 172 | return _make_mock( 173 | mock=Mock(spec=Genre, name=next(genre_counter), path=path), name=name 174 | ) 175 | 176 | 177 | @pytest.fixture() 178 | def make_tidal_genre(): 179 | return _make_tidal_genre 180 | 181 | 182 | @pytest.fixture() 183 | def make_tidal_artist(): 184 | return _make_tidal_artist 185 | 186 | 187 | @pytest.fixture() 188 | def make_tidal_album(): 189 | return _make_tidal_album 190 | 191 | 192 | @pytest.fixture() 193 | def make_tidal_track(): 194 | return _make_tidal_track 195 | 196 | 197 | @pytest.fixture() 198 | def make_tidal_page(): 199 | return _make_tidal_page 200 | 201 | 202 | @pytest.fixture() 203 | def make_tidal_mix(): 204 | return _make_tidal_mix 205 | 206 | 207 | @pytest.fixture() 208 | def tidal_artists(mocker): 209 | """A list of tidal artists.""" 210 | artists = [mocker.Mock(spec=Artist, name=f"Artist-{i}") for i in range(2)] 211 | album = mocker.Mock(spec=Album) 212 | album.name = "demo album" 213 | album.id = 7 214 | for i, artist in enumerate(artists): 215 | artist.id = i 216 | artist.name = f"Artist-{i}" 217 | artist.get_top_tracks.return_value = [ 218 | _make_tidal_track((i + 1) * 100, artist, album) 219 | ] 220 | return artists 221 | 222 | 223 | @pytest.fixture() 224 | def tidal_albums(mocker): 225 | """A list of tidal albums.""" 226 | albums = [mocker.Mock(spec=Album, name=f"Album-{i}") for i in range(2)] 227 | artist = mocker.Mock(spec=Artist, name="Album Artist") 228 | artist.name = "Album Artist" 229 | artist.id = 1234 230 | for i, album in enumerate(albums): 231 | album.id = i 232 | album.name = f"Album-{i}" 233 | album.artist = artist 234 | album.tracks.return_value = [_make_tidal_track(i, artist, album)] 235 | return albums 236 | 237 | 238 | @pytest.fixture 239 | def tidal_tracks(tidal_artists, tidal_albums): 240 | """A list of tidal tracks.""" 241 | return [ 242 | _make_tidal_track(i, artist, album) 243 | for i, (artist, album) in enumerate(zip(tidal_artists, tidal_albums)) 244 | ] 245 | 246 | 247 | def make_playlist(playlist_id, tracks): 248 | return _make_mock( 249 | mock=Mock(spec=UserPlaylist, session=Mock()), 250 | name=f"Playlist-{playlist_id}", 251 | id=str(playlist_id), 252 | uri=f"tidal:playlist:{playlist_id}", 253 | tracks=tracks, 254 | num_tracks=len(tracks), 255 | last_updated=10, 256 | ) 257 | 258 | 259 | @pytest.fixture 260 | def tidal_playlists(tidal_tracks): 261 | return [ 262 | make_playlist(101, tidal_tracks[:2]), 263 | make_playlist(222, tidal_tracks[1:]), 264 | ] 265 | 266 | 267 | def compare_track(tidal, mopidy): 268 | assert tidal.uri == mopidy.uri 269 | assert tidal.full_name == mopidy.name 270 | assert tidal.duration * 1000 == mopidy.length 271 | assert tidal.disc_num == mopidy.disc_no 272 | assert tidal.track_num == mopidy.track_no 273 | compare_artist(tidal.artist, list(mopidy.artists)[0]) 274 | compare_album(tidal.album, mopidy.album) 275 | 276 | 277 | def compare_artist(tidal, mopidy): 278 | assert tidal.name == mopidy.name 279 | assert f"tidal:artist:{tidal.id}" == mopidy.uri 280 | 281 | 282 | def compare_album(tidal, mopidy): 283 | assert tidal.name == mopidy.name 284 | assert f"tidal:album:{tidal.id}" == mopidy.uri 285 | 286 | 287 | def compare_playlist(tidal, mopidy): 288 | assert tidal.uri == mopidy.uri 289 | assert tidal.name == mopidy.name 290 | _compare(tidal.tracks, mopidy.tracks, "track") 291 | 292 | 293 | _compare_map = { 294 | "artist": compare_artist, 295 | "album": compare_album, 296 | "track": compare_track, 297 | "playlist": compare_playlist, 298 | } 299 | 300 | 301 | def _compare(tidal: list, mopidy: list, type: str): 302 | """Compare artists, tracks or albums. 303 | 304 | Args: 305 | tidal: The tidal tracks. 306 | mopidy: The mopidy tracks. 307 | type: The type of comparison: one of "artist", "album" or "track". 308 | """ 309 | assert len(tidal) == len(mopidy) 310 | for t, m in zip(tidal, mopidy): 311 | _compare_map[type](t, m) 312 | 313 | 314 | compare = pytest.fixture(lambda: _compare) 315 | 316 | 317 | @pytest.fixture 318 | def get_backend(mocker): 319 | def _get_backend(config=mocker.MagicMock(), audio=mocker.Mock()): 320 | backend = TidalBackend(config, audio) 321 | session_factory = mocker.Mock() 322 | # session = mocker.Mock() 323 | session = mocker.Mock(spec=SessionForTest) 324 | session.token_type = "token_type" 325 | session.session_id = "session_id" 326 | session.access_token = "access_token" 327 | session.refresh_token = "refresh_token" 328 | session.is_pkce = False 329 | session_factory.return_value = session 330 | mocker.patch("mopidy_tidal.backend.Session", session_factory) 331 | 332 | # Mock web_auth 333 | backend.web_auth_server.start_oauth_daemon = mocker.Mock() 334 | 335 | # Mock login_oauth 336 | url = mocker.Mock(spec=LinkLogin, verification_uri_complete="link.tidal/URI") 337 | future = mocker.Mock(spec=Future) 338 | session.login_oauth.return_value = (url, future) 339 | 340 | def save_session_dummy(file_path): 341 | data = { 342 | "token_type": {"data": session.token_type}, 343 | "session_id": {"data": session.session_id}, 344 | "access_token": {"data": session.access_token}, 345 | "refresh_token": {"data": session.refresh_token}, 346 | "is_pkce": {"data": session.is_pkce}, 347 | # "expiry_time": {"data": self.expiry_time}, 348 | } 349 | with file_path.open("w") as outfile: 350 | json.dump(data, outfile) 351 | 352 | # Saving a session will create a dummy file containing the expected data 353 | session.save_session_to_file.side_effect = save_session_dummy 354 | 355 | # Always start in logged-out state 356 | session.check_login.return_value = False 357 | 358 | return backend, config, audio, session_factory, session 359 | 360 | yield _get_backend 361 | set_config(None) 362 | 363 | 364 | class SessionForTest(Session): 365 | """Session has an attribute genre which is set in __init__ doesn't exist on 366 | the class. Thus mock gets the spec wrong, i.e. forbids access to genre. 367 | This is a bug in Session, but until it's fixed we mock it here. 368 | 369 | See https://docs.python.org/3/library/unittest.mock.html#auto-speccing 370 | 371 | Tracked at https://github.com/tamland/python-tidal/issues/192 372 | """ 373 | 374 | genre = None 375 | 376 | 377 | @pytest.fixture 378 | def session(mocker): 379 | return mocker.Mock(spec=SessionForTest) 380 | 381 | 382 | @pytest.fixture 383 | def backend(mocker, session): 384 | return mocker.Mock(session=session) 385 | -------------------------------------------------------------------------------- /tests/test_backend.py: -------------------------------------------------------------------------------- 1 | from json import dump, dumps, load, loads 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from mopidy_tidal.library import TidalLibraryProvider 7 | from mopidy_tidal.playback import TidalPlaybackProvider 8 | from mopidy_tidal.playlists import TidalPlaylistsProvider 9 | 10 | 11 | def test_backend_composed_of_correct_parts(get_backend): 12 | backend, *_ = get_backend() 13 | 14 | assert isinstance(backend.playback, TidalPlaybackProvider) 15 | assert isinstance(backend.library, TidalLibraryProvider) 16 | assert isinstance(backend.playlists, TidalPlaylistsProvider) 17 | 18 | 19 | def test_backend_begins_in_correct_state(get_backend): 20 | """This test tests private attributes and is thus *BAD*. But we can keep 21 | it till it breaks.""" 22 | backend, config, *_ = get_backend() 23 | 24 | assert backend.uri_schemes == ("tidal",) 25 | assert not backend._active_session 26 | assert backend._config is config 27 | 28 | 29 | def test_initial_login_caches_credentials(get_backend, config): 30 | backend, _, _, _, session = get_backend(config=config) 31 | 32 | def login_success(*_, **__): 33 | session.check_login.return_value = True 34 | backend._logged_in = True # Set to true to skip login timeout immediately 35 | 36 | # Starting the mock web server will trigger login instantly 37 | backend.web_auth_server.start_oauth_daemon.side_effect = login_success 38 | 39 | backend._active_session = session 40 | authf = Path(config["core"]["data_dir"], "tidal/tidal-oauth.json") 41 | assert not authf.exists() 42 | 43 | # backend._login() 44 | backend.on_start() 45 | 46 | # First attempt to (mock) load session from file (fails) 47 | session.load_session_from_file.assert_called_once() 48 | # Followed by OAuth (mock) daemon starting 49 | backend.web_auth_server.start_oauth_daemon.assert_called_once() 50 | # After a succesful (mock) oauth, the session file should be created 51 | session.save_session_to_file.assert_called_once() 52 | 53 | # Check if dummy file was created with the expected contents 54 | with authf.open() as f: 55 | data = load(f) 56 | assert data == { 57 | "token_type": dict(data="token_type"), 58 | "session_id": dict(data="session_id"), 59 | "access_token": dict(data="access_token"), 60 | "refresh_token": dict(data="refresh_token"), 61 | "is_pkce": dict(data=False), 62 | } 63 | 64 | 65 | def test_login_after_failed_cached_credentials_overwrites_cached_credentials( 66 | get_backend, config 67 | ): 68 | backend, _, _, _, session = get_backend(config=config) 69 | 70 | def login_success(*_, **__): 71 | session.check_login.return_value = True 72 | backend._logged_in = True # Set to true to skip login timeout immediately 73 | 74 | # Starting the mock web server will trigger login instantly 75 | backend.web_auth_server.start_oauth_daemon.side_effect = login_success 76 | 77 | backend._active_session = session 78 | authf = Path(config["core"]["data_dir"], "tidal/tidal-oauth.json") 79 | cached_data = { 80 | "token_type": dict(data="token_type2"), 81 | "session_id": dict(data="session_id2"), 82 | "access_token": dict(data="access_token2"), 83 | "refresh_token": dict(data="refresh_token2"), 84 | "is_pkce": dict(data=False), 85 | } 86 | authf.write_text(dumps(cached_data)) 87 | 88 | backend.on_start() 89 | 90 | with authf.open() as f: 91 | data = load(f) 92 | assert data == { 93 | "token_type": dict(data="token_type"), 94 | "session_id": dict(data="session_id"), 95 | "access_token": dict(data="access_token"), 96 | "refresh_token": dict(data="refresh_token"), 97 | "is_pkce": dict(data=False), 98 | } 99 | 100 | # After a succesful (mock) oauth, the session file should be created (overwriting original file) 101 | session.save_session_to_file.assert_called_once() 102 | 103 | 104 | def test_failed_login_does_not_overwrite_cached_credentials( 105 | get_backend, mocker, config, tmp_path 106 | ): 107 | backend, _, _, _, session = get_backend(config=config) 108 | 109 | # trigger failed login by setting check_login false even though oauth was completed 110 | def login_failed(*_, **__): 111 | # session.check_login.return_value = False 112 | backend._logged_in = True # Set to true to skip login timeout immediately 113 | 114 | # Starting the mock web server will trigger login instantly (but login will fail) 115 | backend.web_auth_server.start_oauth_daemon.side_effect = login_failed 116 | 117 | backend._active_session = session 118 | authf = Path(config["core"]["data_dir"], "tidal/tidal-oauth.json") 119 | cached_data = { 120 | "token_type": dict(data="token_type2"), 121 | "session_id": dict(data="session_id2"), 122 | "access_token": dict(data="access_token2"), 123 | "refresh_token": dict(data="refresh_token2"), 124 | } 125 | authf.write_text(dumps(cached_data)) 126 | 127 | with pytest.raises(ConnectionError): 128 | backend.on_start() 129 | 130 | data = loads(authf.read_text()) 131 | assert data == cached_data 132 | 133 | # First attempt to (mock) load session from file (fails) 134 | session.load_session_from_file.assert_called_once() 135 | # Followed by OAuth (mock) daemon starting 136 | backend.web_auth_server.start_oauth_daemon.assert_called_once() 137 | # Login failed, no session file created 138 | session.save_session_to_file.assert_not_called() 139 | 140 | 141 | def test_failed_overall_login_throws_error(get_backend, tmp_path, mocker, config): 142 | backend, _, _, _, session = get_backend(config=config) 143 | 144 | # trigger failed login by setting check_login false even though oauth was completed 145 | def login_failed(*_, **__): 146 | # session.check_login.return_value = False 147 | backend._logged_in = True # Set to true to skip login timeout immediately 148 | 149 | # Starting the mock web server will trigger login instantly (but login will fail) 150 | backend.web_auth_server.start_oauth_daemon.side_effect = login_failed 151 | 152 | backend._active_session = session 153 | authf = tmp_path / "auth.json" 154 | 155 | with pytest.raises(ConnectionError): 156 | backend.on_start() 157 | 158 | assert not authf.exists() 159 | 160 | 161 | def test_logs_in(get_backend, mocker, config): 162 | backend, _, _, session_factory, session = get_backend(config=config) 163 | backend._active_session = session 164 | 165 | def login_success(*_, **__): 166 | session.check_login.return_value = True 167 | backend._logged_in = True # Set to true to skip login timeout immediately 168 | 169 | # Starting the mock web server will trigger login instantly 170 | backend.web_auth_server.start_oauth_daemon.side_effect = login_success 171 | 172 | backend.on_start() 173 | 174 | session_factory.assert_called_once() 175 | config_obj = session_factory.mock_calls[0].args[0] 176 | assert config_obj.quality == config["tidal"]["quality"] 177 | assert config_obj.client_id == config["tidal"]["client_id"] 178 | assert config_obj.client_secret == config["tidal"]["client_secret"] 179 | 180 | 181 | def test_accessing_session_triggers_lazy_login(get_backend, mocker, config): 182 | config["tidal"]["lazy"] = True 183 | backend, _, _, session_factory, session = get_backend(config=config) 184 | 185 | def login_success(*_, **__): 186 | session.check_login.return_value = True 187 | backend._logged_in = True # Set to true to skip login timeout immediately 188 | 189 | # Loading session from file will result in successful login 190 | session.load_session_from_file.side_effect = login_success 191 | 192 | backend.on_start() 193 | 194 | session.load_session_from_file.assert_not_called() 195 | assert not backend._active_session.check_login() 196 | assert backend.session # accessing session will trigger login 197 | assert backend.session.check_login() 198 | session_factory.assert_called_once() 199 | session.load_session_from_file.assert_called_once() 200 | config_obj = session_factory.mock_calls[0].args[0] 201 | assert config_obj.quality == config["tidal"]["quality"] 202 | assert config_obj.client_id == config["tidal"]["client_id"] 203 | assert config_obj.client_secret == config["tidal"]["client_secret"] 204 | 205 | 206 | def test_logs_in_only_client_secret(get_backend, mocker, config): 207 | config["tidal"]["client_id"] = "" 208 | backend, _, _, session_factory, session = get_backend(config=config) 209 | 210 | def login_success(*_, **__): 211 | session.check_login.return_value = True 212 | backend._logged_in = True # Set to true to skip login timeout immediately 213 | 214 | # Loading session from file will result in successful login 215 | session.load_session_from_file.side_effect = login_success 216 | 217 | backend.on_start() 218 | 219 | session.load_session_from_file.assert_called_once() 220 | session_factory.assert_called_once() 221 | config_obj = session_factory.mock_calls[0].args[0] 222 | assert config_obj.quality == config["tidal"]["quality"] 223 | assert config_obj.client_id and config_obj.client_id != config["tidal"]["client_id"] 224 | assert ( 225 | config_obj.client_secret 226 | and config_obj.client_secret != config["tidal"]["client_secret"] 227 | ) 228 | 229 | 230 | def test_logs_in_default_id_secret(get_backend, mocker, config): 231 | config["tidal"]["client_id"] = "" 232 | config["tidal"]["client_secret"] = "" 233 | backend, _, _, session_factory, session = get_backend(config=config) 234 | 235 | def login_success(*_, **__): 236 | session.check_login.return_value = True 237 | backend._logged_in = True # Set to true to skip login timeout immediately 238 | 239 | # Loading session from file will result in successful login 240 | session.load_session_from_file.side_effect = login_success 241 | 242 | backend.on_start() 243 | 244 | session.load_session_from_file.assert_called_once() 245 | session_factory.assert_called_once() 246 | config_obj = session_factory.mock_calls[0].args[0] 247 | assert config_obj.quality == config["tidal"]["quality"] 248 | assert config_obj.client_id and config_obj.client_id != config["tidal"]["client_id"] 249 | assert ( 250 | config_obj.client_secret 251 | and config_obj.client_secret != config["tidal"]["client_secret"] 252 | ) 253 | 254 | 255 | def test_loads_session(get_backend, mocker, config): 256 | backend, config, _, session_factory, session = get_backend(config=config) 257 | 258 | authf = Path(config["core"]["data_dir"], "tidal") / "tidal-oauth.json" 259 | data = { 260 | "token_type": dict(data="token_type"), 261 | "session_id": dict(data="session_id"), 262 | "access_token": dict(data="access_token"), 263 | "refresh_token": dict(data="refresh_token"), 264 | } 265 | with authf.open("w") as f: 266 | dump(data, f) 267 | session.check_login.return_value = True 268 | 269 | backend.on_start() 270 | 271 | # Loading session successfully should not trigger oauth_daemon 272 | backend.web_auth_server.start_oauth_daemon.assert_not_called() 273 | session.load_session_from_file.assert_called_once() 274 | session_factory.assert_called_once() 275 | -------------------------------------------------------------------------------- /tests/test_cache_search_key.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from mopidy_tidal.lru_cache import SearchKey 4 | 5 | 6 | def test_hashes_of_equal_objects_are_equal(): 7 | params = dict(exact=True, query=dict(artist="Arty", album="Alby")) 8 | assert SearchKey(**params) == SearchKey(**params) 9 | 10 | assert hash(SearchKey(**params)) == hash(SearchKey(**params)) 11 | 12 | 13 | def test_hashes_of_different_objects_are_different(): 14 | key_1 = SearchKey(exact=True, query=dict(artist="Arty", album="Alby")) 15 | key_2 = SearchKey(exact=False, query=dict(artist="Arty", album="Alby")) 16 | key_3 = SearchKey(exact=True, query=dict(artist="Arty", album="Albion")) 17 | 18 | assert hash(key_1) != hash(key_2) != hash(key_3) 19 | 20 | 21 | def test_as_str_constructs_uri_from_hash(): 22 | key = SearchKey(exact=True, query=dict(artist="Arty", album="Alby")) 23 | 24 | assert str(key) == f"tidal:search:{hash(key)}" 25 | -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | """Test context, which is used to manage config.""" 2 | 3 | import pytest 4 | 5 | from mopidy_tidal import context 6 | 7 | 8 | @pytest.fixture(autouse=True) 9 | def config(): 10 | """Override fixture which sets up config: here we want to do it manually.""" 11 | 12 | 13 | def test_get_config_raises_until_set(): 14 | config = {"k": "v"} 15 | 16 | with pytest.raises(ValueError, match="Extension configuration not set."): 17 | context.get_config() 18 | 19 | context.set_config(config) 20 | 21 | assert context.get_config() == config 22 | -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import pytest 4 | 5 | from mopidy_tidal import Extension 6 | from mopidy_tidal.backend import TidalBackend 7 | 8 | 9 | def test_sanity_check_default_resembles_conf_file(): 10 | ext = Extension() 11 | 12 | config = ext.get_default_config() 13 | 14 | assert "[tidal]" in config 15 | assert "enabled = true" in config 16 | 17 | 18 | def test_config_schema_has_correct_keys(): 19 | """This is mostly a sanity check in case we forget to add a key.""" 20 | ext = Extension() 21 | 22 | schema = ext.get_config_schema() 23 | 24 | assert set(schema.keys()) == { 25 | "enabled", 26 | "quality", 27 | "client_id", 28 | "client_secret", 29 | "playlist_cache_refresh_secs", 30 | "lazy", 31 | "login_method", 32 | "login_server_port", 33 | "auth_method", 34 | } 35 | 36 | 37 | def test_extension_setup_registers_tidal_backend(mocker): 38 | ext = Extension() 39 | registry = mocker.Mock() 40 | 41 | ext.setup(registry) 42 | 43 | registry.add.assert_called_once() 44 | plugin_type, obj = registry.add.mock_calls[0].args 45 | assert plugin_type == "backend" 46 | assert issubclass(obj, TidalBackend) 47 | -------------------------------------------------------------------------------- /tests/test_full_model_mappers.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import mopidy.models as mopidy_models 4 | 5 | from mopidy_tidal.full_models_mappers import create_mopidy_album, create_mopidy_artist 6 | 7 | 8 | class TestCreateMopidyArtist: 9 | def test_returns_none_if_tidal_artist_none(self): 10 | assert create_mopidy_artist(tidal_artist=None) is None 11 | 12 | def test_returns_artist_with_uri_and_name(self, make_tidal_artist): 13 | artist = make_tidal_artist(name="Arty", id=17) 14 | 15 | mopidy_artist = create_mopidy_artist(artist) 16 | 17 | assert mopidy_artist == mopidy_models.Artist(uri="tidal:artist:17", name="Arty") 18 | 19 | 20 | class TestCreateMopidyAlbum: 21 | def test_returns_album_with_uri_name_and_artist( 22 | self, make_tidal_album, make_tidal_artist 23 | ): 24 | album = make_tidal_album(name="Alby", id=156) 25 | mopidy_artist = create_mopidy_artist(make_tidal_artist(name="Arty", id=12)) 26 | 27 | mopidy_album = create_mopidy_album(album, mopidy_artist) 28 | 29 | assert mopidy_album 30 | assert mopidy_album.uri == "tidal:album:156" 31 | assert mopidy_album.artists == { 32 | mopidy_models.Artist(name="Arty", uri="tidal:artist:12") 33 | } 34 | assert mopidy_album.name == "Alby" 35 | 36 | def test_date_prefers_release_date(self, make_tidal_album): 37 | album = make_tidal_album( 38 | name="Albion", 39 | id=156, 40 | release_date=datetime(1995, 6, 7), 41 | tidal_release_date=datetime(1997, 4, 5), 42 | ) 43 | 44 | mopidy_album = create_mopidy_album(album, None) 45 | 46 | assert mopidy_album 47 | assert mopidy_album.date == "1995" 48 | 49 | def test_date_falls_back_on_tidal_release_date(self, make_tidal_album): 50 | album = make_tidal_album( 51 | name="Albion", 52 | id=156, 53 | release_date=None, 54 | tidal_release_date=datetime(1997, 4, 5), 55 | ) 56 | 57 | mopidy_album = create_mopidy_album(album, None) 58 | 59 | assert mopidy_album 60 | assert mopidy_album.date == "1997" 61 | 62 | def test_null_when_unknown(self, make_tidal_album): 63 | album = make_tidal_album( 64 | name="Albion", 65 | id=156, 66 | release_date=None, 67 | tidal_release_date=None, 68 | ) 69 | 70 | mopidy_album = create_mopidy_album(album, None) 71 | 72 | assert mopidy_album 73 | assert mopidy_album.date is None 74 | 75 | def test_uses_artist_album_if_no_artist_provided( 76 | self, make_tidal_album, make_tidal_artist 77 | ): 78 | album = make_tidal_album( 79 | name="Alby", id=156, artist=make_tidal_artist(name="Arty", id=12) 80 | ) 81 | 82 | mopidy_album = create_mopidy_album(album, None) 83 | 84 | assert mopidy_album 85 | assert mopidy_album.artists == { 86 | mopidy_models.Artist(name="Arty", uri="tidal:artist:12") 87 | } 88 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | import pytest 4 | 5 | from mopidy_tidal.helpers import to_timestamp 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "dt, res", 10 | [ 11 | (None, 0), 12 | ("2022-08-06 12:38:40+00:00", 1659789520), 13 | (datetime(2022, 1, 5, tzinfo=timezone.utc), 1641340800), 14 | (12, 12), 15 | ], 16 | ) 17 | def test_to_timestamp_converts_correctly(dt, res): 18 | assert to_timestamp(dt) == res 19 | -------------------------------------------------------------------------------- /tests/test_image_getter.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tidalapi import Artist 3 | 4 | from mopidy_tidal.library import HTTPError, Image, ImagesGetter 5 | 6 | 7 | @pytest.fixture 8 | def images_getter(mocker, config): 9 | session = mocker.Mock() 10 | getter = ImagesGetter(session) 11 | return getter, session 12 | 13 | 14 | @pytest.mark.parametrize("dimensions", (750, 640, 480)) 15 | def test_get_album_image(images_getter, mocker, dimensions): 16 | ig, session = images_getter 17 | uri = "tidal:album:1-1-1" 18 | get_album = mocker.Mock() 19 | 20 | get_uri_args = None 21 | 22 | def get_uri(dim, *args): 23 | nonlocal get_uri_args 24 | get_uri_args = [dim] + list(args) 25 | if dim != dimensions: 26 | raise ValueError() 27 | return uri 28 | 29 | get_album.image = get_uri 30 | session.album.return_value = get_album 31 | assert ig(uri) == ( 32 | uri, 33 | [ 34 | Image(height=320, uri=uri, width=320) 35 | ], # Why can we just set the dimensions like that? 36 | ) 37 | assert get_uri_args == [dimensions] 38 | 39 | 40 | def test_get_album_no_image(images_getter, mocker): 41 | ig, session = images_getter 42 | uri = "tidal:album:1-1-1" 43 | get_album = mocker.Mock() 44 | 45 | def get_uri(*_): 46 | raise ValueError() 47 | 48 | get_album.image = get_uri 49 | session.album.return_value = get_album 50 | assert ig(uri) == (uri, []) 51 | 52 | 53 | def test_get_album_no_getter_methods(images_getter, mocker): 54 | ig, session = images_getter 55 | uri = "tidal:album:1-1-1" 56 | get_album = mocker.Mock(spec={"id", "__name__"}, name="get_album", id="1") 57 | session.album.return_value = get_album 58 | assert ig(uri) == (uri, []) 59 | 60 | 61 | def test_get_track_image(images_getter, mocker): 62 | ig, session = images_getter 63 | uri = "tidal:track:0-0-0:1-1-1:2-2-2" 64 | get_album = mocker.Mock() 65 | get_album.image.return_value = "tidal:album:1-1-1" 66 | session.album.return_value = get_album 67 | assert ig(uri) == ( 68 | uri, 69 | [ 70 | Image(height=320, uri="tidal:album:1-1-1", width=320) 71 | ], # Why can we just set the dimensions like that? 72 | ) 73 | session.album.assert_called_once_with("1-1-1") 74 | 75 | 76 | def test_get_artist_image(images_getter, mocker): 77 | ig, session = images_getter 78 | uri = "tidal:artist:2-2-2" 79 | get_artist = mocker.Mock() 80 | get_artist.image.return_value = uri 81 | session.artist.return_value = get_artist 82 | assert ig(uri) == ( 83 | uri, 84 | [ 85 | Image(height=320, uri=uri, width=320) 86 | ], # Why can we just set the dimensions like that? 87 | ) 88 | 89 | 90 | def test_get_artist_no_image_no_picture(images_getter, mocker): 91 | # Handle artists with missing images 92 | ig, session = images_getter 93 | uri = "tidal:artist:2-2-2" 94 | get_artist = mocker.Mock(spec=Artist) 95 | # All image related entries should be None to trigger missing images 96 | get_artist.picture = None 97 | get_artist.image = None 98 | get_artist.square_picture = None 99 | session.artist.return_value = get_artist 100 | assert ig(uri) == (uri, []) 101 | 102 | 103 | def test_get_artist_no_image(images_getter, mocker): 104 | ig, session = images_getter 105 | uri = "tidal:artist:2-2-2" 106 | session.artist.return_value = None 107 | assert ig(uri) == (uri, []) 108 | 109 | 110 | def test_nonsuch_type(images_getter, mocker): 111 | ig, session = images_getter 112 | uri = "tidal:stuffinmycupboard:2-2-2" 113 | session.mock_add_spec([]) 114 | assert ig(uri) == (uri, []) 115 | 116 | 117 | @pytest.mark.parametrize("error", {HTTPError, AttributeError}) 118 | def test_get_artist_error(images_getter, mocker, error): 119 | ig, session = images_getter 120 | uri = "tidal:artist:2-2-2" 121 | get_artist = mocker.Mock() 122 | get_artist.image.side_effect = error() 123 | session.artist.return_value = get_artist 124 | assert ig(uri) == (uri, []) 125 | 126 | 127 | def test_image_getter_cache(images_getter, mocker): 128 | ig, session = images_getter 129 | uri = "tidal:track:0-0-0:1-1-1:2-2-2" 130 | get_album = mocker.Mock() 131 | get_album.image.return_value = "tidal:album:1-1-1" 132 | session.album.return_value = get_album 133 | resp = ig(uri) 134 | assert resp == ( 135 | uri, 136 | [ 137 | Image(height=320, uri="tidal:album:1-1-1", width=320) 138 | ], # Why can we just set the dimensions like that? 139 | ) 140 | ig.cache_update({"tidal:album:1-1-1": resp[1]}) 141 | assert ig(uri) == resp 142 | 143 | session.album.assert_called_once_with("1-1-1") 144 | -------------------------------------------------------------------------------- /tests/test_login_hack.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import Future 2 | 3 | import pytest 4 | from mopidy.models import Album, Artist, Image, Playlist, Ref, SearchResult, Track 5 | from tidalapi.session import LinkLogin 6 | 7 | from mopidy_tidal.library import TidalLibraryProvider 8 | from mopidy_tidal.playback import TidalPlaybackProvider 9 | from mopidy_tidal.playlists import TidalPlaylistsProvider 10 | 11 | 12 | @pytest.fixture 13 | def library_provider(get_backend, config, mocker): 14 | config["tidal"]["login_method"] = "HACK" 15 | config["tidal"]["lazy"] = True 16 | backend, *_, session = get_backend(config=config) 17 | url = mocker.Mock(spec=LinkLogin, verification_uri_complete="link.tidal/URI") 18 | future = mocker.Mock(spec=Future) 19 | future.running.return_value = True 20 | session.login_oauth.return_value = (url, future) 21 | backend.on_start() 22 | 23 | # Always start in logged-out state 24 | session.check_login.return_value = False 25 | 26 | backend._active_session = session 27 | 28 | return backend, TidalLibraryProvider(backend=backend) 29 | 30 | 31 | class TestLibraryProviderMethods: 32 | @pytest.mark.parametrize( 33 | "type, uri", 34 | [ 35 | ["directory", "tidal:my_albums"], 36 | ["directory", "tidal:my_artists"], 37 | ["directory", "tidal:my_playlists"], 38 | ["directory", "tidal:my_tracks"], 39 | ["directory", "tidal:moods"], 40 | ["directory", "tidal:mixes"], 41 | ["directory", "tidal:genres"], 42 | ["album", "tidal:album:id"], 43 | ["artist", "tidal:artist:id"], 44 | ["playlist", "tidal:playlist:id"], 45 | ["mood", "tidal:mood:id"], 46 | ["genre", "tidal:genre:id"], 47 | ["mix", "tidal:mix:id"], 48 | ], 49 | ) 50 | def test_browse_triggers_login(self, type, uri, library_provider): 51 | backend, lp = library_provider 52 | assert not backend.logged_in 53 | assert not backend.logging_in 54 | 55 | browse_results = lp.browse(uri) 56 | 57 | assert not backend._logged_in 58 | assert backend.logging_in 59 | assert len(browse_results) == 1 60 | browse_result = browse_results[0] 61 | assert isinstance(browse_result, Ref) 62 | assert browse_result.type == type 63 | assert "link.tidal/URI" in browse_result.name 64 | assert browse_result.uri == uri 65 | 66 | def test_get_image_triggers_login(self, library_provider): 67 | backend, lp = library_provider 68 | assert not backend.logged_in 69 | assert not backend.logging_in 70 | 71 | images = lp.get_images(["tidal:playlist:uri"]) 72 | 73 | assert not backend.logged_in 74 | assert backend.logging_in 75 | assert images == { 76 | "tidal:playlist:uri": [ 77 | Image( 78 | height=150, 79 | uri="https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=https%3A%2F%2Flink.tidal%2FURI", 80 | width=150, 81 | ) 82 | ] 83 | } 84 | 85 | def test_library_lookup_triggers_login(self, library_provider): 86 | backend, lp = library_provider 87 | assert not backend.logged_in 88 | assert not backend.logging_in 89 | 90 | tracks = lp.lookup(["tidal:track:uri"]) 91 | 92 | assert not backend.logged_in 93 | assert backend.logging_in 94 | assert tracks == [ 95 | Track( 96 | name="Please visit https://link.tidal/URI to log in.", 97 | uri="tidal:track:login", 98 | ) 99 | ] 100 | assert True 101 | 102 | def test_search_triggers_login(self, library_provider): 103 | backend, lp = library_provider 104 | assert not backend.logged_in 105 | assert not backend.logging_in 106 | 107 | result = lp.search() 108 | 109 | assert not backend.logged_in 110 | assert backend.logging_in 111 | assert result == SearchResult( 112 | albums=[ 113 | Album( 114 | name="Please visit https://link.tidal/URI to log in.", 115 | uri="tidal:album:login", 116 | ) 117 | ], 118 | artists=[ 119 | Artist( 120 | name="Please visit https://link.tidal/URI to log in.", 121 | uri="tidal:artist:login", 122 | ) 123 | ], 124 | tracks=[ 125 | Track( 126 | name="Please visit https://link.tidal/URI to log in.", 127 | uri="tidal:track:login", 128 | ) 129 | ], 130 | ) 131 | 132 | @pytest.mark.parametrize("field", ("artist", "album", "track")) 133 | def test_get_distinct_triggers_login(self, field, library_provider): 134 | backend, lp = library_provider 135 | assert not backend.logged_in 136 | assert not backend.logging_in 137 | 138 | result = lp.get_distinct(field) 139 | 140 | assert not backend.logged_in 141 | assert backend.logging_in 142 | assert result == {"Please visit https://link.tidal/URI to log in."} 143 | 144 | 145 | @pytest.fixture 146 | def playlist_provider(get_backend, config, mocker): 147 | config["tidal"]["login_method"] = "HACK" 148 | config["tidal"]["lazy"] = True 149 | backend, *_, session = get_backend(config=config) 150 | session.check_login.return_value = False 151 | url = mocker.Mock(spec=LinkLogin, verification_uri_complete="link.tidal/URI") 152 | future = mocker.Mock(spec=Future) 153 | future.running.return_value = True 154 | session.login_oauth.return_value = (url, future) 155 | backend.on_start() 156 | return backend, TidalPlaylistsProvider(backend=backend) 157 | 158 | 159 | class TestPlaylistMethods: 160 | def test_playlist_lookup_triggers_login(self, playlist_provider): 161 | backend, pp = playlist_provider 162 | assert not backend.logged_in 163 | assert not backend.logging_in 164 | 165 | result = pp.lookup("tidal:playlist:uri") 166 | 167 | assert not backend.logged_in 168 | assert backend.logging_in 169 | assert result == Playlist( 170 | name="Please visit https://link.tidal/URI to log in.", 171 | tracks=[ 172 | Track( 173 | name="Please visit https://link.tidal/URI to log in.", 174 | uri="tidal:track:login", 175 | ) 176 | ], 177 | uri="tidal:playlist:login", 178 | ) 179 | 180 | def test_playlist_refresh_triggers_login(self, playlist_provider): 181 | backend, pp = playlist_provider 182 | assert not backend.logged_in 183 | assert not backend.logging_in 184 | 185 | result = pp.refresh("tidal:playlist:uri") 186 | 187 | assert not backend.logged_in 188 | assert backend.logging_in 189 | assert result == { 190 | "tidal:playlist:uri": Playlist( 191 | name="Please visit https://link.tidal/URI to log in.", 192 | tracks=[ 193 | Track( 194 | name="Please visit https://link.tidal/URI to log in.", 195 | uri="tidal:track:login", 196 | ) 197 | ], 198 | uri="tidal:playlist:login", 199 | ) 200 | } 201 | 202 | def test_playlist_as_list_triggers_login(self, playlist_provider): 203 | backend, pp = playlist_provider 204 | assert not backend.logged_in 205 | assert not backend.logging_in 206 | 207 | result = pp.as_list() 208 | 209 | assert not backend.logged_in 210 | assert backend.logging_in 211 | assert result == [ 212 | Ref( 213 | name="Please visit https://link.tidal/URI to log in.", 214 | type="playlist", 215 | uri="tidal:playlist:login", 216 | ) 217 | ] 218 | 219 | def test_continues_unfazed_when_already_logged_in( 220 | self, playlist_provider, mocker, tidal_playlists 221 | ): 222 | backend, pp = playlist_provider 223 | backend._logged_in = True 224 | audiof = backend.data_dir / "login_audio/URI.ogg" 225 | session = mocker.Mock(**{"user.favorites.playlists": tidal_playlists[:1]}) 226 | backend._active_session = session 227 | mocker.patch("mopidy_tidal.playlists.get_items", lambda x: x) 228 | backend.session.user.playlists.return_value = tidal_playlists[1:] 229 | 230 | assert backend.logged_in 231 | assert not backend.logging_in 232 | 233 | assert pp.as_list() == [ 234 | Ref(name="Playlist-101", type="playlist", uri="tidal:playlist:101"), 235 | Ref(name="Playlist-222", type="playlist", uri="tidal:playlist:222"), 236 | ] 237 | assert not audiof.exists() 238 | 239 | 240 | @pytest.fixture 241 | def playback_provider(get_backend, config, mocker): 242 | config["tidal"]["login_method"] = "HACK" 243 | config["tidal"]["lazy"] = True 244 | backend, *_, session = get_backend(config=config) 245 | session.check_login.return_value = False 246 | url = mocker.Mock(spec=LinkLogin, verification_uri_complete="link.tidal/URI") 247 | future = mocker.Mock(spec=Future) 248 | future.running.return_value = True 249 | session.login_oauth.return_value = (url, future) 250 | backend.on_start() 251 | 252 | return backend, TidalPlaybackProvider(audio=mocker.Mock(), backend=backend) 253 | 254 | 255 | class TestPlaybackMethods: 256 | def test_audio_downloaded(self, playback_provider, mocker): 257 | backend, pp = playback_provider 258 | get = mocker.Mock(**{"return_value.content": b"mock audio"}) 259 | mocker.patch("login_hack.get", get) 260 | audiof = backend.data_dir / "login_audio/URI.ogg" 261 | assert not audiof.exists() 262 | assert not backend.logged_in 263 | assert not backend.logging_in 264 | 265 | result = pp.translate_uri("tidal:track:1:2:3") 266 | 267 | assert not backend.logged_in 268 | assert backend.logging_in 269 | assert result == audiof.as_uri() 270 | get.assert_called_once() 271 | assert audiof.read_bytes() == b"mock audio" 272 | 273 | def test_failed_audio_download_returns_None(self, playback_provider, mocker): 274 | backend, pp = playback_provider 275 | get = mocker.Mock(**{"return_value.raise_for_status": Exception()}) 276 | mocker.patch("login_hack.get", get) 277 | audiof = backend.data_dir / "login_audio/URI.ogg" 278 | assert not audiof.exists() 279 | assert not backend.logged_in 280 | assert not backend.logging_in 281 | 282 | result = pp.translate_uri("tidal:track:1:2:3") 283 | 284 | assert not backend.logged_in 285 | assert backend.logging_in 286 | assert result is None 287 | get.assert_called_once() 288 | assert not audiof.exists() 289 | 290 | # @pytest.disable() 291 | def test_downloaded_audio_removed_on_next_access( 292 | self, playback_provider, mocker, tidal_playlists 293 | ): 294 | backend, pp = playback_provider 295 | get = mocker.Mock(**{"return_value.content": b"mock audio"}) 296 | mocker.patch("login_hack.get", get) 297 | audiof = backend.data_dir / "login_audio/URI.ogg" 298 | 299 | result = pp.translate_uri("tidal:track:1:2:3") 300 | 301 | assert not backend.logged_in 302 | assert backend.logging_in 303 | assert result == audiof.as_uri() 304 | get.assert_called_once() 305 | assert audiof.read_bytes() == b"mock audio" 306 | backend._login_future.running.return_value = False 307 | assert not backend.logging_in 308 | 309 | session = mocker.Mock(**{"user.favorites.playlists": tidal_playlists[:1]}) 310 | backend._active_session = session 311 | mocker.patch("mopidy_tidal.playlists.get_items", lambda x: x) 312 | backend.session.user.playlists.return_value = tidal_playlists[1:] 313 | 314 | # User has now logged in. We should now be able to access playlists and audiof should be removed 315 | backend._logged_in = True 316 | tpp = TidalPlaylistsProvider(backend=backend) 317 | assert tpp.as_list() == [ 318 | Ref(name="Playlist-101", type="playlist", uri="tidal:playlist:101"), 319 | Ref(name="Playlist-222", type="playlist", uri="tidal:playlist:222"), 320 | ] 321 | assert not audiof.exists() 322 | 323 | 324 | class TestConfig: 325 | def test_login_hack_implies_lazy_connect_even_if_set_to_false( 326 | self, config, get_backend 327 | ): 328 | config["tidal"]["login_method"] = "HACK" 329 | config["tidal"]["lazy"] = False 330 | backend, *_ = get_backend(config=config) 331 | assert not backend.lazy_connect 332 | 333 | backend.on_start() 334 | 335 | assert backend.lazy_connect 336 | -------------------------------------------------------------------------------- /tests/test_lru_cache.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | from mopidy_tidal.lru_cache import LruCache, SearchCache 8 | 9 | 10 | @pytest.fixture 11 | def lru_disk_cache() -> LruCache: 12 | return LruCache(max_size=8, persist=True, directory="cache") 13 | 14 | 15 | @pytest.fixture 16 | def lru_ram_cache() -> LruCache: 17 | return LruCache(max_size=8, persist=False) 18 | 19 | 20 | @pytest.fixture(params=[True, False]) 21 | def lru_cache(request) -> LruCache: 22 | return LruCache(max_size=8, persist=request.param, directory="cache") 23 | 24 | 25 | def test_config_stored_on_cache(): 26 | l = LruCache(max_size=1678, persist=True, directory="cache") 27 | 28 | assert l.max_size == 1678 29 | assert l.persist 30 | 31 | 32 | class TestDiskPersistence: 33 | def test_raises_keyerror_if_file_corrupted(self): 34 | cache = LruCache(max_size=8, persist=True, directory="cache") 35 | cache.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) 36 | cache.cache_file("tidal:uri:val").write_text("hahaha") 37 | del cache 38 | 39 | new_cache = LruCache(max_size=8, persist=True, directory="cache") 40 | assert new_cache["tidal:uri:otherval"] == 17 41 | with pytest.raises(KeyError): 42 | new_cache["tidal:uri:val"] 43 | 44 | def test_raises_keyerror_if_file_deleted(self): 45 | cache = LruCache(max_size=8, persist=True, directory="cache") 46 | cache.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) 47 | cache.cache_file("tidal:uri:val").unlink() 48 | del cache 49 | 50 | new_cache = LruCache(max_size=8, persist=True, directory="cache") 51 | assert new_cache["tidal:uri:otherval"] == 17 52 | with pytest.raises(KeyError): 53 | new_cache["tidal:uri:val"] 54 | 55 | def test_prune_removes_files(self): 56 | cache = LruCache(max_size=8, persist=True, directory="cache") 57 | cache.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) 58 | assert cache.cache_file("tidal:uri:otherval").exists() 59 | assert cache.cache_file("tidal:uri:val").exists() 60 | 61 | cache.prune("tidal:uri:otherval") 62 | cache.prune("tidal:uri:val") 63 | 64 | assert not cache.cache_file("tidal:uri:otherval").exists() 65 | assert not cache.cache_file("tidal:uri:val").exists() 66 | 67 | def test_prune_ignores_already_deleted_files(self): 68 | cache = LruCache(max_size=8, persist=True, directory="cache") 69 | cache.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) 70 | cache.cache_file("tidal:uri:val").unlink() 71 | del cache 72 | 73 | new_cache = LruCache(max_size=8, persist=True, directory="cache") 74 | new_cache.prune("tidal:uri:otherval") 75 | new_cache.prune("tidal:uri:val") 76 | 77 | def test_migrates_old_filename_if_present(self, lru_disk_cache): 78 | uri = "tidal:uri:val" 79 | value = "hi" 80 | lru_disk_cache[uri] = value 81 | assert lru_disk_cache[uri] == value 82 | 83 | # The cache filename should be dash-separated 84 | filename = lru_disk_cache.cache_file(uri) 85 | assert filename.name == "-".join(uri.split(":")) + ".cache" 86 | 87 | # Rename the cache filename to match the old file format 88 | new_filename = os.path.join(os.path.dirname(filename), f"{uri}.cache") 89 | shutil.move(filename, new_filename) 90 | 91 | # Remove the in-memory cache element in order to force a filesystem reload 92 | lru_disk_cache.pop(uri) 93 | cached_value = lru_disk_cache.get(uri) 94 | assert cached_value == value 95 | 96 | # The cache filename should be column-separated 97 | filename = lru_disk_cache.cache_file(uri) 98 | assert filename.name == f"{uri}.cache" 99 | 100 | def test_values_persisted_between_caches(self): 101 | cache = LruCache(max_size=8, persist=True, directory="cache") 102 | cache.update( 103 | {"tidal:uri:val": "hi", "tidal:uri:otherval": 17, "tidal:uri:none": None} 104 | ) 105 | del cache 106 | 107 | new_cache = LruCache(max_size=8, persist=True, directory="cache") 108 | 109 | assert new_cache["tidal:uri:val"] == "hi" 110 | assert new_cache["tidal:uri:otherval"] == 17 111 | assert new_cache["tidal:uri:none"] == None 112 | 113 | 114 | def test_raises_key_error_if_target_missing(lru_cache): 115 | with pytest.raises(KeyError): 116 | lru_cache["tidal:uri:nonsuch"] 117 | 118 | 119 | def test_simple_objects_persisted_in_cache(lru_cache): 120 | lru_cache["tidal:uri:val"] = "hi" 121 | lru_cache["tidal:uri:none"] = None 122 | 123 | assert lru_cache["tidal:uri:val"] == "hi" == lru_cache.get("tidal:uri:val") 124 | assert lru_cache["tidal:uri:none"] is None 125 | assert len(lru_cache) == 2 126 | 127 | 128 | def test_complex_objects_persisted_in_cache(lru_cache): 129 | lru_cache["tidal:uri:otherval"] = {"complex": "object", "with": [0, 1]} 130 | 131 | assert ( 132 | lru_cache["tidal:uri:otherval"] 133 | == {"complex": "object", "with": [0, 1]} 134 | == lru_cache.get("tidal:uri:otherval") 135 | ) 136 | assert len(lru_cache) == 1 137 | 138 | 139 | def test_update_adds_or_replaces(lru_cache): 140 | lru_cache.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) 141 | 142 | assert lru_cache["tidal:uri:val"] == "hi" 143 | assert lru_cache["tidal:uri:otherval"] == 17 144 | assert "tidal:uri:val" in lru_cache 145 | assert "tidal:uri:nonesuch" not in lru_cache 146 | 147 | 148 | def test_dict_style_update_behaves_like_update(lru_cache): 149 | lru_cache |= {"tidal:uri:val": "hi", "tidal:uri:otherval": 17} 150 | 151 | assert lru_cache["tidal:uri:val"] == "hi" 152 | assert lru_cache["tidal:uri:otherval"] == 17 153 | 154 | 155 | def test_get_returns_default_if_supplied_and_no_match(lru_cache): 156 | uniq = object() 157 | 158 | assert lru_cache.get("tidal:uri:nonsuch", default=uniq) is uniq 159 | 160 | 161 | def test_prune_removes_from_cache(lru_cache): 162 | lru_cache.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) 163 | assert "tidal:uri:val" in lru_cache 164 | 165 | lru_cache.prune("tidal:uri:val") 166 | 167 | assert "tidal:uri:val" not in lru_cache 168 | assert "tidal:uri:otherval" in lru_cache 169 | 170 | 171 | def test_prune_all_empties_cache(lru_cache): 172 | lru_cache.update({"tidal:uri:val": "hi", "tidal:uri:otherval": 17}) 173 | assert len(lru_cache) == 2 174 | 175 | lru_cache.prune_all() 176 | 177 | assert len(lru_cache) == 0 178 | assert "tidal:uri:val" not in lru_cache 179 | assert "tidal:uri:otherval" not in lru_cache 180 | 181 | 182 | def test_compares_equal_to_dict(lru_cache): 183 | data = {"tidal:uri:val": "hi", "tidal:uri:otherval": 17} 184 | lru_cache.update(data) 185 | 186 | assert lru_cache == data 187 | 188 | 189 | @pytest.mark.parametrize("persist", (True, False)) 190 | def test_maintains_size_by_excluding_values(persist: bool): 191 | cache = LruCache(max_size=8, persist=persist) 192 | cache.update({f"tidal:uri:{val}": val for val in range(8)}) 193 | assert len(cache) == 8 194 | 195 | cache["tidal:uri:8"] = 8 196 | 197 | assert len(cache) == 8 198 | 199 | 200 | def test_excludes_least_recently_inserted_value_when_no_accesses_made(): 201 | cache = LruCache(max_size=8, persist=False) 202 | 203 | cache.update({f"tidal:uri:{val}": val for val in range(9)}) 204 | 205 | assert len(cache) == 8 206 | assert "tidal:uri:8" in cache 207 | assert "tidal:uri:0" not in cache 208 | 209 | 210 | @pytest.mark.xfail(reason="Disk cache grows indefinitely") 211 | def test_removes_least_recently_inserted_value_from_disk_when_cache_overflows(): 212 | cache = LruCache(max_size=8, persist=True) 213 | 214 | cache.update({f"tidal:uri:{val}": val for val in range(9)}) 215 | 216 | assert len(cache) == 8 217 | assert "tidal:uri:8" in cache 218 | assert "tidal:uri:0" not in cache 219 | 220 | 221 | @pytest.mark.xfail(reason="Cache ignores usage") 222 | def test_excludes_least_recently_accessed_value(): 223 | cache = LruCache(max_size=8, persist=False) 224 | 225 | cache.update({f"tidal:uri:{val}": val for val in range(8)}) 226 | cache.get("tidal:uri:0") 227 | cache["tidal:uri:8"] = 8 228 | 229 | assert len(cache) == 8 230 | assert "tidal:uri:8" in cache 231 | assert "tidal:uri:0" in cache 232 | assert "tidal:uri:1" not in cache 233 | 234 | 235 | def test_cache_grows_indefinitely_if_max_size_zero(): 236 | cache = LruCache(max_size=0, persist=False) 237 | 238 | cache.update({f"tidal:uri:{val}": val for val in range(2**12)}) 239 | 240 | assert len(cache) == 2**12 241 | -------------------------------------------------------------------------------- /tests/test_playback.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from mopidy_tidal.playback import TidalPlaybackProvider 4 | 5 | 6 | @pytest.mark.xfail(reason="Requires mock tidal object") 7 | def test_playback(mocker): 8 | uniq = object() 9 | session = mocker.Mock(spec=["track"]) 10 | session.mock_add_spec(["track"]) 11 | track = mocker.Mock() 12 | track.get_url.return_value = uniq 13 | session.track.return_value = track 14 | backend = mocker.Mock(session=session) 15 | audio = mocker.Mock() 16 | tpp = TidalPlaybackProvider(audio, backend) 17 | assert tpp.translate_uri("tidal:track:1:2:3") is uniq 18 | session.track.assert_called_once_with(3) 19 | track.get_url.assert_called_once() 20 | -------------------------------------------------------------------------------- /tests/test_playlist.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from time import sleep 3 | 4 | import pytest 5 | from mopidy.models import Track 6 | from requests import HTTPError 7 | 8 | from mopidy_tidal.playlists import ( 9 | MopidyPlaylist, 10 | PlaylistCache, 11 | Ref, 12 | TidalPlaylist, 13 | TidalPlaylistsProvider, 14 | ) 15 | 16 | 17 | @pytest.fixture 18 | def tpp(mocker): 19 | mocker.patch("mopidy_tidal.playlists.Timer") 20 | backend = mocker.Mock() 21 | backend._config = {"tidal": {"playlist_cache_refresh_secs": 0}} 22 | 23 | tpp = TidalPlaylistsProvider(backend) 24 | tpp._playlists = PlaylistCache(persist=False) 25 | yield tpp, backend 26 | 27 | 28 | def test_create(tpp, mocker): 29 | tpp, backend = tpp 30 | playlist = mocker.Mock(last_updated=9, id="17") 31 | playlist.tracks.__name__ = "tracks" 32 | playlist.tracks.return_value = [] 33 | playlist.name = "playlist name" 34 | backend.session.user.create_playlist.return_value = playlist 35 | p = tpp.create("playlist") 36 | assert p == MopidyPlaylist( 37 | last_modified=9, name="playlist name", uri="tidal:playlist:17" 38 | ) 39 | backend.session.user.create_playlist.assert_called_once_with("playlist", "") 40 | 41 | 42 | def test_delete(tpp): 43 | tpp, backend = tpp 44 | tpp.delete("tidal:playlist:19") 45 | backend.session.request.request.assert_called_once_with("DELETE", "playlists/19") 46 | 47 | 48 | def test_delete_http_404(tpp, mocker): 49 | tpp, backend = tpp 50 | response = mocker.Mock(status_code=404) 51 | error = HTTPError() 52 | error.response = response 53 | backend.session.request.request.side_effect = error 54 | with pytest.raises(HTTPError) as e: 55 | tpp.delete("tidal:playlist:19") 56 | assert e.response == response 57 | backend.session.request.request.assert_called_once_with("DELETE", "playlists/19") 58 | 59 | 60 | def test_delete_http_401_in_favourites(tpp, mocker): 61 | """ 62 | Test removing from favourites. 63 | 64 | We should just remove the playlist from user favourites if its present but 65 | we get a 401 for deleting it. 66 | """ 67 | tpp, backend = tpp 68 | session = backend.session 69 | response = mocker.Mock(status_code=401) 70 | error = HTTPError() 71 | error.response = response 72 | session.request.request.side_effect = error 73 | pl = mocker.Mock() 74 | pl.id = 21 75 | session.user.favorites.playlists.return_value = [pl] 76 | tpp.delete("tidal:playlist:21") 77 | session.user.favorites.remove_playlist.assert_called_once_with("21") 78 | session.request.request.assert_called_once_with("DELETE", "playlists/21") 79 | 80 | 81 | def test_delete_http_401_not_in_favourites(tpp, mocker): 82 | """ 83 | Test removing from favourites. 84 | 85 | We should just remove the playlist from user favourites if its present but 86 | we get a 401 for deleting it. 87 | """ 88 | tpp, backend = tpp 89 | session = backend.session 90 | response = mocker.Mock(status_code=401) 91 | error = HTTPError() 92 | error.response = response 93 | session.request.request.side_effect = error 94 | pl = mocker.Mock() 95 | pl.id = 21 96 | session.user.favorites.playlists.return_value = [pl] 97 | with pytest.raises(HTTPError) as e: 98 | tpp.delete("tidal:playlist:678") 99 | assert e.response == response 100 | session.request.request.assert_called_once_with("DELETE", "playlists/678") 101 | 102 | 103 | def test_save_no_changes(tpp, mocker, tidal_playlists): 104 | tpp, backend = tpp 105 | session = backend.session 106 | tidal_pl = tidal_playlists[0] 107 | uri = tidal_pl.uri 108 | mopidy_pl = mocker.Mock( 109 | uri=uri, 110 | last_modified=10, 111 | tracks=tidal_pl.tracks, 112 | ) 113 | mopidy_pl.name = tidal_pl.name 114 | session.playlist.return_value = tidal_pl 115 | session.user.favorites.playlists.__name__ = "pl" 116 | session.user.favorites.playlists.return_value = [tidal_pl] 117 | session.user.playlists.return_value = [] 118 | tpp._playlists[uri] = mopidy_pl 119 | tpp.save(mopidy_pl) 120 | assert tpp._playlists[uri] == mopidy_pl 121 | 122 | 123 | def test_save_change_name(tpp, mocker, tidal_playlists): 124 | tpp, backend = tpp 125 | session = backend.session 126 | tidal_pl = tidal_playlists[0] 127 | uri = tidal_pl.uri 128 | mopidy_pl = mocker.Mock( 129 | uri=uri, 130 | last_modified=10, 131 | tracks=tidal_pl.tracks, 132 | ) 133 | mopidy_pl.name = tidal_pl.name 134 | session.playlist.return_value = tidal_pl 135 | session.user.favorites.playlists.__name__ = "pl" 136 | session.user.favorites.playlists.return_value = [tidal_pl] 137 | session.user.playlists.return_value = [] 138 | tpp._playlists[uri] = mopidy_pl 139 | pl = deepcopy(mopidy_pl) 140 | pl.name += "NEW" 141 | tpp.save(pl) 142 | session.playlist.assert_called_with("101") 143 | session.playlist().edit.assert_called_once_with(title=pl.name) 144 | 145 | 146 | def test_save_remove(tpp, mocker, tidal_playlists): 147 | tpp, backend = tpp 148 | session = backend.session 149 | tidal_pl = tidal_playlists[0] 150 | uri = tidal_pl.uri 151 | mopidy_pl = mocker.Mock( 152 | uri=uri, 153 | last_modified=10, 154 | tracks=tidal_pl.tracks, 155 | ) 156 | mopidy_pl.name = tidal_pl.name 157 | session.playlist.return_value = tidal_pl 158 | session.user.favorites.playlists.__name__ = "pl" 159 | session.user.favorites.playlists.return_value = [tidal_pl] 160 | session.user.playlists.return_value = [] 161 | tpp._playlists[uri] = mopidy_pl 162 | pl = deepcopy(mopidy_pl) 163 | pl.tracks = pl.tracks[:1] 164 | tpp.save(pl) 165 | session.playlist.assert_called_with("101") 166 | session.playlist().remove_by_index.assert_called_once_with(1) 167 | 168 | 169 | def test_save_add(tpp, mocker, tidal_playlists, tidal_tracks): 170 | tpp, backend = tpp 171 | session = backend.session 172 | tidal_pl = tidal_playlists[0] 173 | uri = tidal_pl.uri 174 | mopidy_pl = mocker.Mock( 175 | uri=uri, 176 | last_modified=10, 177 | tracks=tidal_pl.tracks, 178 | ) 179 | mopidy_pl.name = tidal_pl.name 180 | session.playlist.return_value = tidal_pl 181 | session.user.favorites.playlists.__name__ = "pl" 182 | session.user.favorites.playlists.return_value = [tidal_pl] 183 | session.user.playlists.return_value = [] 184 | tpp._playlists[uri] = mopidy_pl 185 | pl = deepcopy(mopidy_pl) 186 | pl.tracks += tidal_tracks[-2:-1] 187 | tpp.save(pl) 188 | session.playlist.assert_called_with("101") 189 | session.playlist().add.assert_called_once_with(["0"]) 190 | 191 | 192 | def test_lookup_unmodified_cached(tpp, mocker): 193 | tpp, backend = tpp 194 | remote_playlist = mocker.Mock(last_updated=9) 195 | backend.session.playlist.return_value = remote_playlist 196 | playlist = mocker.MagicMock(last_modified=9) 197 | tpp._playlists["tidal:playlist:0:1:2"] = playlist 198 | assert tpp.lookup("tidal:playlist:0:1:2") is playlist 199 | 200 | 201 | def test_refresh_metadata(tpp, mocker, tidal_playlists): 202 | listener = mocker.Mock() 203 | mocker.patch("mopidy_tidal.playlists.backend.BackendListener", listener) 204 | tpp, backend = tpp 205 | tpp._current_tidal_playlists = tidal_playlists 206 | assert not len(tpp._playlists_metadata) 207 | tpp.refresh(include_items=False) 208 | 209 | listener.send.assert_called_once_with("playlists_loaded") 210 | 211 | tracks = [Track(uri="tidal:track:0:0:0")] * 2 212 | assert dict(tpp._playlists_metadata) == { 213 | "tidal:playlist:101": MopidyPlaylist( 214 | last_modified=10, 215 | name="Playlist-101", 216 | uri="tidal:playlist:101", 217 | tracks=tracks, 218 | ), 219 | "tidal:playlist:222": MopidyPlaylist( 220 | last_modified=10, 221 | name="Playlist-222", 222 | uri="tidal:playlist:222", 223 | tracks=tracks[:1], 224 | ), 225 | } 226 | 227 | 228 | def api_test(tpp, mocker, api_method, tp): 229 | listener = mocker.Mock() 230 | mocker.patch("mopidy_tidal.playlists.backend.BackendListener", listener) 231 | tpp._current_tidal_playlists = [tp] 232 | tracks = [mocker.Mock() for _ in range(2)] 233 | for i, track in enumerate(tracks): 234 | track.id = i 235 | track.uri = f"tidal:track:{i}:{i}:{i}" 236 | track.name = f"Track-{i}" 237 | track.full_name = f"{track.name} (version)" 238 | track.artist.name = "artist_name" 239 | track.artist.id = i 240 | track.album.name = "album_name" 241 | track.album.id = i 242 | track.duration = 100 + i 243 | track.track_num = i 244 | track.disc_num = i 245 | api_method.return_value = tracks 246 | api_method.__name__ = "get_playlist_tracks" 247 | 248 | tpp.refresh(include_items=True) 249 | listener.send.assert_called_once_with("playlists_loaded") 250 | assert len(tpp._playlists) == 1 251 | playlist = tpp._playlists["tidal:playlist:1-1-1"] 252 | assert isinstance(playlist, MopidyPlaylist) 253 | assert playlist.last_modified == 10 254 | assert playlist.name == "Playlist-1" 255 | assert playlist.uri == "tidal:playlist:1-1-1" 256 | assert len(playlist.tracks) == 2 * len(api_method.mock_calls) 257 | attr_map = {"disc_num": "disc_no", "full_name": "name"} 258 | assert all( 259 | getattr(orig_tr, k) == getattr(tr, attr_map.get(k, k)) 260 | for orig_tr, tr in zip(tracks, playlist.tracks) 261 | for k in {"uri", "disc_num", "full_name"} 262 | ) 263 | 264 | 265 | def test_refresh(tpp, mocker): 266 | tpp, backend = tpp 267 | session = backend.session 268 | session.mock_add_spec([]) 269 | tp = mocker.Mock(spec=TidalPlaylist, session=mocker.Mock, playlist_id="1-1-1") 270 | tp.id = tp.playlist_id 271 | tp.name = "Playlist-1" 272 | tp.last_updated = 10 273 | tp.tracks = mocker.Mock() 274 | api_method = tp.tracks 275 | api_test(tpp, mocker, api_method, tp) 276 | 277 | 278 | def test_as_list(tpp, mocker, tidal_playlists): 279 | tpp, backend = tpp 280 | mocker.patch("mopidy_tidal.playlists.get_items", lambda x: x) 281 | backend.session.configure_mock(**{"user.favorites.playlists": tidal_playlists[:1]}) 282 | backend.session.user.playlists.return_value = tidal_playlists[1:] 283 | assert tpp.as_list() == [ 284 | Ref(name="Playlist-101", type="playlist", uri="tidal:playlist:101"), 285 | Ref(name="Playlist-222", type="playlist", uri="tidal:playlist:222"), 286 | ] 287 | 288 | 289 | def test_prevent_duplicate_playlist_sync(tpp, mocker, tidal_playlists): 290 | tpp, backend = tpp 291 | mocker.patch("mopidy_tidal.playlists.get_items", lambda x: x) 292 | backend.session.configure_mock(**{"user.favorites.playlists": tidal_playlists[:1]}) 293 | backend.session.user.playlists.return_value = tidal_playlists[1:] 294 | tpp.as_list() 295 | p = mocker.Mock(spec=TidalPlaylist, session=mocker.Mock, playlist_id="2-2-2") 296 | backend.session.user.playlists.return_value.append(p) 297 | assert tpp.as_list() == [ 298 | Ref(name="Playlist-101", type="playlist", uri="tidal:playlist:101"), 299 | Ref(name="Playlist-222", type="playlist", uri="tidal:playlist:222"), 300 | ] 301 | 302 | 303 | def test_playlist_sync_downtime(mocker, tidal_playlists, config): 304 | backend = mocker.Mock() 305 | tpp = TidalPlaylistsProvider(backend) 306 | tpp._playlists = PlaylistCache(persist=False) 307 | mocker.patch("mopidy_tidal.playlists.get_items", lambda x: x) 308 | backend._config = {"tidal": {"playlist_cache_refresh_secs": 0.1}} 309 | 310 | backend.session.configure_mock(**{"user.favorites.playlists": tidal_playlists[:1]}) 311 | backend.session.user.playlists.return_value = tidal_playlists[1:] 312 | tpp.as_list() 313 | p = mocker.Mock(spec=TidalPlaylist, session=mocker.Mock, playlist_id="2") 314 | p.id = p.playlist_id 315 | p.num_tracks = 2 316 | p.name = "Playlist-2" 317 | p.last_updated = 10 318 | backend.session.user.playlists.return_value.append(p) 319 | assert tpp.as_list() == [ 320 | Ref(name="Playlist-101", type="playlist", uri="tidal:playlist:101"), 321 | Ref(name="Playlist-222", type="playlist", uri="tidal:playlist:222"), 322 | ] 323 | sleep(0.1) 324 | assert tpp.as_list() == [ 325 | Ref(name="Playlist-101", type="playlist", uri="tidal:playlist:101"), 326 | Ref(name="Playlist-2", type="playlist", uri="tidal:playlist:2"), 327 | Ref(name="Playlist-222", type="playlist", uri="tidal:playlist:222"), 328 | ] 329 | 330 | 331 | def test_update_changes(tpp, mocker, tidal_playlists): 332 | tpp, backend = tpp 333 | tpp._playlists_metadata.update( 334 | { 335 | "tidal:playlist:101": MopidyPlaylist( 336 | last_modified=10, name="Playlist-101", uri="tidal:playlist:101" 337 | ), 338 | "tidal:playlist:222": MopidyPlaylist( 339 | last_modified=9, name="Playlist-222", uri="tidal:playlist:222" 340 | ), 341 | } 342 | ) 343 | 344 | mocker.patch("mopidy_tidal.playlists.get_items", lambda x: x) 345 | backend.session.configure_mock(**{"user.favorites.playlists": tidal_playlists[:1]}) 346 | backend.session.user.playlists.return_value = tidal_playlists[1:] 347 | assert tpp.as_list() == [ 348 | Ref(name="Playlist-101", type="playlist", uri="tidal:playlist:101"), 349 | Ref(name="Playlist-222", type="playlist", uri="tidal:playlist:222"), 350 | ] 351 | 352 | 353 | def test_update_no_changes(tpp, mocker, tidal_playlists): 354 | tpp, backend = tpp 355 | tpp._playlists_metadata.update( 356 | { 357 | "tidal:playlist:101": MopidyPlaylist( 358 | last_modified=10, name="Playlist-101", uri="tidal:playlist:101" 359 | ), 360 | "tidal:playlist:222": MopidyPlaylist( 361 | last_modified=10, name="Playlist-222", uri="tidal:playlist:222" 362 | ), 363 | } 364 | ) 365 | 366 | mocker.patch("mopidy_tidal.playlists.get_items", lambda x: x) 367 | backend.session.configure_mock(**{"user.favorites.playlists": tidal_playlists[:1]}) 368 | backend.session.user.playlists.return_value = tidal_playlists[1:] 369 | assert tpp.as_list() == [ 370 | Ref(name="Playlist-101", type="playlist", uri="tidal:playlist:101"), 371 | Ref(name="Playlist-222", type="playlist", uri="tidal:playlist:222"), 372 | ] 373 | 374 | 375 | def test_lookup_modified_cached(tpp, mocker): 376 | tpp, backend = tpp 377 | remote_playlist = mocker.Mock(last_updated=10) 378 | backend.session.playlist.return_value = remote_playlist 379 | playlist = mocker.MagicMock(last_modified=9) 380 | tpp._playlists["tidal:playlist:0:1:2"] = playlist 381 | assert tpp.lookup("tidal:playlist:0:1:2") is playlist 382 | 383 | 384 | def test_get_items_none(tpp): 385 | tpp, backend = tpp 386 | assert not tpp.get_items("tidal:playlist:0-1-2") 387 | 388 | 389 | def test_get_items_none_upstream(tpp, mocker): 390 | tpp, backend = tpp 391 | backend.session.playlist.return_value = None 392 | tracks = [mocker.Mock() for _ in range(2)] 393 | for i, track in enumerate(tracks): 394 | track.uri = f"tidal:track:{i}:{i}:{i}" 395 | track.name = f"Track-{i}" 396 | 397 | playlist = mocker.MagicMock(last_modified=9, tracks=tracks) 398 | tpp._playlists["tidal:playlist:0-1-2"] = playlist 399 | assert tpp.get_items("tidal:playlist:0-1-2") == [ 400 | Ref(name="Track-0", type="track", uri="tidal:track:0:0:0"), 401 | Ref(name="Track-1", type="track", uri="tidal:track:1:1:1"), 402 | ] 403 | 404 | 405 | def test_get_items_playlists(tpp, mocker): 406 | tpp, backend = tpp 407 | backend.session.playlist.return_value = mocker.Mock(last_updated=9) 408 | tracks = [mocker.Mock() for _ in range(2)] 409 | for i, track in enumerate(tracks): 410 | track.uri = f"tidal:track:{i}:{i}:{i}" 411 | track.name = f"Track-{i}" 412 | 413 | playlist = mocker.MagicMock(last_modified=9, tracks=tracks) 414 | tpp._playlists["tidal:playlist:0-1-2"] = playlist 415 | assert tpp.get_items("tidal:playlist:0-1-2") == [ 416 | Ref(name="Track-0", type="track", uri="tidal:track:0:0:0"), 417 | Ref(name="Track-1", type="track", uri="tidal:track:1:1:1"), 418 | ] 419 | 420 | 421 | def test_get_items_playlists_no_updated(tpp, mocker): 422 | tpp, backend = tpp 423 | backend.session.playlist.return_value = mocker.Mock(spec={}) 424 | tracks = [mocker.Mock() for _ in range(2)] 425 | for i, track in enumerate(tracks): 426 | track.uri = f"tidal:track:{i}:{i}:{i}" 427 | track.name = f"Track-{i}" 428 | 429 | playlist = mocker.MagicMock(last_modified=9, tracks=tracks) 430 | tpp._playlists["tidal:playlist:0-1-2"] = playlist 431 | assert tpp.get_items("tidal:playlist:0-1-2") == [ 432 | Ref(name="Track-0", type="track", uri="tidal:track:0:0:0"), 433 | Ref(name="Track-1", type="track", uri="tidal:track:1:1:1"), 434 | ] 435 | 436 | 437 | def test_get_items_mix(tpp, mocker): 438 | tpp, backend = tpp 439 | tracks = [mocker.Mock() for _ in range(2)] 440 | for i, track in enumerate(tracks): 441 | track.id = i 442 | track.uri = f"tidal:track:{i}:{i}:{i}" 443 | track.name = f"Track-{i}" 444 | track.full_name = f"{track.name} (version)" 445 | track.artist.name = "artist_name" 446 | track.artist.id = i 447 | track.album.name = "album_name" 448 | track.album.id = i 449 | track.duration = 100 450 | track.track_num = i 451 | track.disc_num = i 452 | tidal_playlist = mocker.Mock(last_updated=9) 453 | tidal_playlist.items.return_value = tracks 454 | backend.session.mix.return_value = tidal_playlist 455 | 456 | playlist = mocker.MagicMock(last_modified=9, tracks=tracks) 457 | tpp._playlists["tidal:mix:0-1-2"] = playlist 458 | assert tpp.get_items("tidal:mix:0-1-2") == [ 459 | Ref(name="Track-0 (version)", type="track", uri="tidal:track:0:0:0"), 460 | Ref(name="Track-1 (version)", type="track", uri="tidal:track:1:1:1"), 461 | ] 462 | -------------------------------------------------------------------------------- /tests/test_playlist_cache.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from mopidy_tidal.playlists import PlaylistCache, PlaylistMetadataCache, TidalPlaylist 6 | 7 | 8 | def test_metadata_cache(): 9 | cache = PlaylistMetadataCache(directory="cache") 10 | uniq = object() 11 | outf = cache.cache_file("tidal:playlist:00-1-2") 12 | assert not outf.exists() 13 | cache["tidal:playlist:00-1-2"] = uniq 14 | assert outf.exists() 15 | assert cache["tidal:playlist:00-1-2"] is uniq 16 | 17 | 18 | def test_cached_as_str(): 19 | cache = PlaylistCache(persist=False) 20 | uniq = object() 21 | cache["tidal:playlist:0-1-2"] = uniq 22 | assert cache["tidal:playlist:0-1-2"] is uniq 23 | assert cache["0-1-2"] is uniq 24 | 25 | 26 | def test_not_updated(mocker): 27 | cache = PlaylistCache(persist=False) 28 | session = mocker.Mock() 29 | key = mocker.Mock(spec=TidalPlaylist, session=session, playlist_id="0-1-2") 30 | key.id = "0-1-2" 31 | key.last_updated = 10 32 | 33 | playlist = mocker.Mock(last_modified=10) 34 | playlist.last_modified = 10 35 | cache["tidal:playlist:0-1-2"] = playlist 36 | assert cache[key] is playlist 37 | 38 | 39 | def test_updated(mocker): 40 | cache = PlaylistCache(persist=False) 41 | session = mocker.Mock() 42 | resp = mocker.Mock(headers={"etag": None}) 43 | session.request.request.return_value = resp 44 | key = mocker.Mock(spec=TidalPlaylist, session=session, playlist_id="0-1-2") 45 | key.id = "0-1-2" 46 | key.last_updated = 10 47 | 48 | playlist = mocker.Mock(last_modified=9) 49 | cache["tidal:playlist:0-1-2"] = playlist 50 | with pytest.raises(KeyError): 51 | cache[key] 52 | -------------------------------------------------------------------------------- /tests/test_ref_models_mappers.py: -------------------------------------------------------------------------------- 1 | from mopidy_tidal import ref_models_mappers as rmm 2 | 3 | 4 | def test_root_contains_entries_for_eachfield(): 5 | root = rmm.create_root() 6 | 7 | uri_map = { 8 | "tidal:genres": "Genres", 9 | "tidal:moods": "Moods", 10 | "tidal:mixes": "My Mixes", 11 | "tidal:my_artists": "My Artists", 12 | "tidal:my_albums": "My Albums", 13 | "tidal:my_playlists": "My Playlists", 14 | "tidal:my_tracks": "My Tracks", 15 | } 16 | for uri, name in uri_map.items(): 17 | ref = next(x for x in root if x.uri == uri) 18 | assert ref.name == name 19 | -------------------------------------------------------------------------------- /tests/test_search.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from tidalapi.album import Album 3 | from tidalapi.artist import Artist 4 | from tidalapi.media import Track 5 | 6 | test_queries = [ 7 | ( # Any 8 | dict( 9 | any=["nonsuch"], 10 | ), 11 | dict(tracks=0, artists=0, albums=0), 12 | "nonsuch", 13 | (Artist, Album, Track), 14 | ), 15 | ( # Album 16 | dict( 17 | album=["Album-1"], 18 | ), 19 | dict(tracks=0, artists=0, albums=None), 20 | "Album-1", 21 | (Album,), 22 | ), 23 | ( # Artist 24 | dict( 25 | artist=["Artist-1"], 26 | ), 27 | dict(tracks=0, artists=None, albums=0), 28 | "Artist-1", 29 | (Artist,), 30 | ), 31 | ( # No results 32 | dict( 33 | artist=["Artist-1"], 34 | album=["Album-1"], 35 | track_name=["Track-1"], 36 | any=["any1"], 37 | ), 38 | dict(tracks=0, artists=0, albums=0), 39 | "any1 Artist-1 Album-1 Track-1", 40 | (Track,), 41 | ), 42 | ( # Tracks 43 | dict( 44 | artist="Artist-1", 45 | album=["Album-1"], 46 | track_name=["Track-1"], 47 | any=["any1"], 48 | ), 49 | dict(tracks=None, artists=0, albums=0), 50 | "any1 Artist-1 Album-1 Track-1", 51 | (Track,), 52 | ), 53 | ] 54 | 55 | 56 | @pytest.mark.parametrize("query, results, query_str, models", test_queries) 57 | def test_search_inexact( 58 | mocker, 59 | tidal_search, 60 | query, 61 | results, 62 | query_str, 63 | models, 64 | tidal_tracks, 65 | tidal_artists, 66 | tidal_albums, 67 | compare, 68 | ): 69 | # generate the right query. We use list slicing since we the fixture isn't 70 | # available in the parametrizing code. 71 | _l = locals() 72 | results = {k: _l[f"tidal_{k}"][:v] for k, v in results.items()} 73 | session = mocker.Mock() 74 | session.search.return_value = results 75 | artists, albums, tracks = tidal_search(session, query=query, exact=False) 76 | # NOTE: There is no need to copy the extra artist/album tracks into 77 | # results["tracks"]: the call to tidal_search() will actually do that for 78 | # us. 79 | compare(results["tracks"], tracks, "track") 80 | compare(results["artists"], artists, "artist") 81 | compare(results["albums"], albums, "album") 82 | session.search.assert_called_once_with(query_str, models=models) 83 | 84 | 85 | @pytest.mark.parametrize("query, results, query_str, models", test_queries) 86 | def test_search_exact( 87 | mocker, 88 | tidal_search, 89 | query, 90 | results, 91 | query_str, 92 | models, 93 | tidal_tracks, 94 | tidal_artists, 95 | tidal_albums, 96 | compare, 97 | ): 98 | # generate the right query. We use list slicing since we the fixture isn't 99 | # available in the parametrizing code. 100 | _l = locals() 101 | results = {k: _l[f"tidal_{k}"][:v] for k, v in results.items()} 102 | session = mocker.Mock() 103 | session.search.return_value = results 104 | artists, albums, tracks = tidal_search(session, query=query, exact=True) 105 | 106 | if "track_name" in query: 107 | results["tracks"] = [ 108 | t for t in results["tracks"] if t.name == query["track_name"][0] 109 | ] 110 | if "album" in query: 111 | results["albums"] = [ 112 | a for a in results["albums"] if a.name == query["album"][0] 113 | ] 114 | for album in results["albums"]: 115 | results["tracks"] += album.tracks() 116 | if "artist" in query: 117 | results["artists"] = [ 118 | a for a in results["artists"] if a.name == query["artist"][0] 119 | ] 120 | for artist in results["artists"]: 121 | results["tracks"] += artist.get_top_tracks() 122 | compare(results["tracks"], tracks, "track") 123 | compare(results["artists"], artists, "artist") 124 | compare(results["albums"], albums, "album") 125 | session.search.assert_called_once_with(query_str, models=models) 126 | 127 | 128 | @pytest.mark.xfail(reason="Unknown") 129 | def test_malformed_api_response(mocker, tidal_search, tidal_tracks, compare): 130 | session = mocker.Mock() 131 | session.search.return_value = { 132 | # missing albums and artists 133 | "tracks": tidal_tracks, 134 | # new category 135 | "nonsuch": tidal_tracks, 136 | } 137 | query = dict( 138 | artist=["Artist-1"], 139 | album=["Album-1"], 140 | track_name=["Track-1"], 141 | any=["any1"], 142 | ) 143 | artists, albums, tracks = tidal_search(session, query=query, exact=False) 144 | assert not artists 145 | assert not albums 146 | compare(tidal_tracks, tracks, "track") 147 | -------------------------------------------------------------------------------- /tests/test_search_cache.py: -------------------------------------------------------------------------------- 1 | from mopidy_tidal.lru_cache import SearchCache, SearchKey 2 | 3 | 4 | def test_search_cache_returns_cached_value_if_present(mocker): 5 | search_function = mocker.Mock() 6 | cache = SearchCache(search_function) 7 | query = {"exact": True, "query": {"artist": "TestArtist", "album": "TestAlbum"}} 8 | search_key = SearchKey(**query) 9 | cache[str(search_key)] = mocker.sentinel.results 10 | 11 | results = cache("arg", **query) 12 | 13 | assert results is mocker.sentinel.results 14 | search_function.assert_not_called() 15 | 16 | 17 | def test_search_defers_to_search_function_if_not_present_and_stores(mocker): 18 | search_function = mocker.Mock(return_value=mocker.sentinel.results) 19 | cache = SearchCache(search_function) 20 | query = {"exact": True, "query": {"artist": "TestArtist", "album": "TestAlbum"}} 21 | search_key = SearchKey(**query) 22 | assert str(search_key) not in cache 23 | 24 | results = cache("arg", **query) 25 | 26 | assert results is mocker.sentinel.results 27 | assert str(search_key) in cache 28 | search_function.assert_called_once_with("arg", **query) 29 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from mopidy_tidal.utils import apply_watermark, remove_watermark 2 | 3 | 4 | def test_apply_watermark_adds_tidal(): 5 | assert apply_watermark("track") == "track [TIDAL]" 6 | 7 | 8 | def test_remove_watermark_removes_tidal_if_present(): 9 | assert remove_watermark(None) is None 10 | assert remove_watermark("track [TIDAL]") == "track" 11 | --------------------------------------------------------------------------------