├── .circleci └── config.yml ├── .gitignore ├── .mailmap ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── mopidy_gmusic ├── __init__.py ├── backend.py ├── commands.py ├── ext.conf ├── library.py ├── playback.py ├── playlists.py ├── repeating_timer.py ├── scrobbler_frontend.py ├── session.py └── translator.py ├── pyproject.toml ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── test_extension.py ├── test_library.py ├── test_playback.py ├── test_playlist.py ├── test_repeating_timer.py ├── test_scrobbler_frontend.py └── test_session.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | codecov: codecov/codecov@1.0.5 5 | 6 | workflows: 7 | version: 2 8 | test: 9 | jobs: 10 | - py38 11 | - py37 12 | - black 13 | - check-manifest 14 | - flake8 15 | 16 | jobs: 17 | py38: &test-template 18 | docker: 19 | - image: mopidy/ci-python:3.8 20 | steps: 21 | - checkout 22 | - restore_cache: 23 | name: Restoring tox cache 24 | key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} 25 | - run: 26 | name: Run tests 27 | command: | 28 | tox -e $CIRCLE_JOB -- \ 29 | --junit-xml=test-results/pytest/results.xml \ 30 | --cov-report=xml 31 | - save_cache: 32 | name: Saving tox cache 33 | key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} 34 | paths: 35 | - ./.tox 36 | - ~/.cache/pip 37 | - codecov/upload: 38 | file: coverage.xml 39 | - store_test_results: 40 | path: test-results 41 | 42 | py37: 43 | <<: *test-template 44 | docker: 45 | - image: mopidy/ci-python:3.7 46 | 47 | black: *test-template 48 | 49 | check-manifest: *test-template 50 | 51 | flake8: *test-template 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /.coverage 3 | /.mypy_cache/ 4 | /.pytest_cache/ 5 | /.tox/ 6 | /*.egg-info 7 | /build/ 8 | /dist/ 9 | /MANIFEST 10 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Alexandre Barreira 2 | Hans Elias J. 3 | Kaleb Elwert q 4 | Ronald Hecht 5 | Ronald Hecht 6 | Shae Erisson 7 | Stein Magnus Jodal 8 | Yannik Enss 9 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 2 | ********* 3 | Changelog 4 | ********* 5 | 6 | 7 | v4.0.1 (2020-12-07) 8 | =================== 9 | 10 | - Warn about Google Play Music service no longer being operational. 11 | - Fix track filtering (PR: #232) 12 | - Don't use inconsistent `albumId` or `artistId` (PR: #233) 13 | 14 | 15 | v4.0.0 (2019-12-22) 16 | =================== 17 | 18 | - Depend on final release of Mopidy 3.0.0. 19 | 20 | 21 | v4.0.0a1 (2019-11-25) 22 | ===================== 23 | 24 | - Require Mopidy >= 3.0.0a5. (PR: #227) 25 | - Require Python >= 3.7. (Fixes: #226, PR: #227) 26 | - Require gmusicapi >= 12.1. 27 | - Switch from username/password to OAuth flow. 28 | - Change name of the "Promoted" playlist to "Top". 29 | - Report the bitrate of tracks based on the bitrate config. (PR: #111) 30 | - Update project setup. (PR: #227) 31 | 32 | 33 | v3.0.0 (2018-06-27) 34 | =================== 35 | 36 | - Add Top Tracks to Artists. 37 | - Work around broken track IDs returned by Google. 38 | - Require Device ID to be set in the config. 39 | 40 | 41 | v2.0.0 (2016-11-2) 42 | =================== 43 | 44 | - Require gmusicapi >= 10.1. 45 | - Make search work for gmusicapi >= 10.0. (Fixes: #116, PR: #117) 46 | - Enable search for accounts without All Access. (PR: #117) 47 | - Require cachetools. (PR: #119) 48 | - Caching should be more consistent. (Fixes: #63, PR: #122) 49 | - Autodetect All Access if not specified in config. (PR: #123) 50 | - General refactoring. (PR: #120, #121) 51 | - Much faster playlist loading. (PR: #130) 52 | - Library browse rewrite. (PR: #131) 53 | - Add IFL playlist and improve radio caching. (PR: #135) 54 | 55 | 56 | v1.0.0 (2015-10-23) 57 | =================== 58 | 59 | - Require Mopidy >= 1.0. 60 | - Require gmusicapi >= 6.0. 61 | - Update to work with new playback API in Mopidy 1.0. (PR: #75) 62 | - Update to work with new search API in Mopidy 1.0. 63 | - Fix crash when tracks lack album or artist information. (Fixes: #74, PR: #24, 64 | also thanks to PRs #27, #64) 65 | - Log error on login failure instead of swallowing the error. (PR: #36) 66 | - Add support for All Access search and lookup (PR: #34) 67 | - Add dynamic playlist based on top rated tracks. 68 | - Add support for radio stations in browser and/or as playlists. 69 | - Add support for browsing artists and albums in the cached library. 70 | - Add cover art to ``Album.images`` model field. 71 | - Add background refreshing of library and playlists. (Fixes: #21) 72 | - Fix authentication issues. (Fixes: #82, #87) 73 | - Add LRU cache for All Access albums and tracks. 74 | - Increment Google's play count if 50% or 240s of the track has been played. 75 | (PR: #51, and later changes) 76 | - Let gmusicapi use the device's MAC address as device ID by default. 77 | - Fix increasing of play counts in Google Play Music. (Fixes: #96) 78 | - Fix scrobbling of tracks to Last.fm through Mopidy-Scrobbler. (Fixes: #60) 79 | - Fix unhandled crashes on network connectivity issues. (Fixes: #85) 80 | - Add ``gmusic/bitrate`` config to select streaming bitrate. 81 | 82 | 83 | v0.3.0 (2014-01-28) 84 | =================== 85 | 86 | - Issue #19: Public playlist support 87 | - Issue #16: All playlist files are playable now 88 | - Require Mopidy >= 0.18. 89 | 90 | 91 | v0.2.2 (2013-11-11) 92 | =================== 93 | 94 | - Issue #17: Fixed a bug regarding various artist albums 95 | (compilations) 96 | - Issue #18: Fixed Google Music API playlist call for version 3.0.0 97 | - Issue #16 (partial): All Access tracks in playlists are playable now 98 | 99 | 100 | v0.2.1 (2013-10-11) 101 | =================== 102 | 103 | - Issue #15: Fixed a bug regarding the translation of Google album 104 | artists to Mopidy album artists 105 | 106 | 107 | v0.2 (2013-10-11) 108 | ================= 109 | 110 | - Issue #12: Now able to play music from Google All Access 111 | - Issue #9: Switched to the Mobileclient API of Google Music API 112 | - Issue #4: Generate Album and Artist Search Results 113 | 114 | 115 | v0.1.1 (2013-09-23) 116 | =================== 117 | 118 | - Issue #11: Browsing the library fixed by implementing find_exact() 119 | 120 | 121 | v0.1 (2013-09-16) 122 | ================= 123 | 124 | - Initial release 125 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.py 2 | include *.rst 3 | include .mailmap 4 | include LICENSE 5 | include MANIFEST.in 6 | include pyproject.toml 7 | include tox.ini 8 | 9 | recursive-include .circleci * 10 | recursive-include .github * 11 | 12 | include mopidy_*/ext.conf 13 | 14 | recursive-include tests *.py 15 | recursive-include tests/data * 16 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ******* 2 | WARNING 3 | ******* 4 | 5 | As of December 2020, **the Google Play Music service is no longer operational**. 6 | Thus, the maintenance of this extension has been stopped. 7 | The ``mopidy-gmusic`` package has been removed from Debian/Ubuntu, 8 | and the Git repo is put into archive mode. 9 | 10 | ---- 11 | 12 | ************* 13 | Mopidy-GMusic 14 | ************* 15 | 16 | .. image:: https://img.shields.io/pypi/v/Mopidy-GMusic 17 | :target: https://pypi.org/project/Mopidy-GMusic/ 18 | :alt: Latest PyPI version 19 | 20 | .. image:: https://img.shields.io/circleci/build/gh/mopidy/mopidy-gmusic 21 | :target: https://circleci.com/gh/mopidy/mopidy-gmusic 22 | :alt: CircleCI build status 23 | 24 | .. image:: https://img.shields.io/codecov/c/gh/mopidy/mopidy-gmusic 25 | :target: https://codecov.io/gh/mopidy/mopidy-gmusic 26 | :alt: Test coverage 27 | 28 | `Mopidy `_ extension for playing music from 29 | `Google Play Music `_. 30 | 31 | 32 | Dependencies 33 | ============ 34 | 35 | You must have a Google account, and either: 36 | 37 | - have some music uploaded to your Google Play Music library, or 38 | - have a paid subscription for Google Play Music. 39 | 40 | 41 | Installation 42 | ============ 43 | 44 | Install by running:: 45 | 46 | sudo python3 -m pip install Mopidy-GMusic 47 | 48 | See https://mopidy.com/ext/gmusic/ for alternative installation methods 49 | 50 | 51 | Configuration 52 | ============= 53 | 54 | Run ``mopidy gmusic login`` to obtain a refresh token, and then include it in 55 | your config file:: 56 | 57 | [gmusic] 58 | refresh_token = 59 | 60 | Google Play Music now requires all clients to provide a device ID. In the past, 61 | Mopidy-GMusic generated one automatically from your MAC address, but Google 62 | seems to have changed their API in a way that prevents this from working. 63 | Therefore you will need to configure one manually. 64 | 65 | If no device ID is configured, Mopidy-GMusic will output a list of registered 66 | devices and their IDs. You can either use one of those IDs in your config file, 67 | or use the special value ``mac`` if you want gmusicapi to use the old method of 68 | generating an ID from your MAC address:: 69 | 70 | [gmusic] 71 | deviceid = 0123456789abcdef 72 | # or 73 | deviceid = mac 74 | 75 | By default, All Access will be enabled automatically if you subscribe. You may 76 | force enable or disable it by using the ``all_access`` option:: 77 | 78 | [gmusic] 79 | all_access = true 80 | 81 | By default, the bitrate is set to 160 kbps. You can change this to either 128 82 | or 320 kbps by setting:: 83 | 84 | [gmusic] 85 | bitrate = 320 86 | 87 | All Access radios are available as browsable content or playlist. The following 88 | are the default config values:: 89 | 90 | [gmusic] 91 | # Show radio stations in content browser 92 | radio_stations_in_browse = true 93 | # Show radio stations as playlists 94 | radio_stations_as_playlists = false 95 | # Limit the number of radio stations, unlimited if unset 96 | radio_stations_count = 97 | # Limit the number or tracks for each radio station 98 | radio_tracks_count = 25 99 | 100 | The library and playlists are automatically refresh at regular intervals. 101 | Refreshing can be CPU intensive on very low-powered machines, e.g. Raspberry Pi 102 | Zero. The refresh intervals can be configured:: 103 | 104 | [gmusic] 105 | # How often to refresh the library, in minutes 106 | refresh_library = 1440 107 | # How often to refresh playlists, in minutes 108 | refresh_playlists = 60 109 | 110 | Usage 111 | ===== 112 | 113 | The extension is enabled by default if all dependencies are 114 | available. You can simply browse through your library and search for 115 | tracks, albums, and artists. Google Play Music playlists are imported 116 | as well. You can even add songs from your All Access subscription to 117 | your library. Mopidy will able to play them. 118 | 119 | 120 | Project resources 121 | ================= 122 | 123 | - `Source code `_ 124 | - `Issue tracker `_ 125 | - `Changelog `_ 126 | 127 | 128 | Credits 129 | ======= 130 | 131 | - Original author: `Ronald Hecht `_ 132 | - Current maintainer: `Kaleb Elwert `_ 133 | - `Contributors `_ 134 | -------------------------------------------------------------------------------- /mopidy_gmusic/__init__.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import pkg_resources 4 | 5 | from mopidy import config, ext 6 | 7 | __version__ = pkg_resources.get_distribution("Mopidy-GMusic").version 8 | 9 | 10 | class Extension(ext.Extension): 11 | 12 | dist_name = "Mopidy-GMusic" 13 | ext_name = "gmusic" 14 | version = __version__ 15 | 16 | def get_default_config(self): 17 | return config.read(pathlib.Path(__file__).parent / "ext.conf") 18 | 19 | def get_config_schema(self): 20 | schema = super().get_config_schema() 21 | 22 | schema["username"] = config.Deprecated() 23 | schema["password"] = config.Deprecated() 24 | 25 | schema["refresh_token"] = config.Secret(optional=True) 26 | 27 | schema["bitrate"] = config.Integer(choices=(128, 160, 320)) 28 | 29 | schema["deviceid"] = config.String(optional=True) 30 | 31 | schema["all_access"] = config.Boolean(optional=True) 32 | 33 | schema["refresh_library"] = config.Integer(minimum=-1, optional=True) 34 | schema["refresh_playlists"] = config.Integer(minimum=-1, optional=True) 35 | 36 | schema["radio_stations_in_browse"] = config.Boolean(optional=True) 37 | schema["radio_stations_as_playlists"] = config.Boolean(optional=True) 38 | schema["radio_stations_count"] = config.Integer( 39 | minimum=1, optional=True 40 | ) 41 | schema["radio_tracks_count"] = config.Integer(minimum=1, optional=True) 42 | 43 | schema["top_tracks_count"] = config.Integer(minimum=1, optional=True) 44 | 45 | return schema 46 | 47 | def setup(self, registry): 48 | from .backend import GMusicBackend 49 | from .scrobbler_frontend import GMusicScrobblerFrontend 50 | 51 | registry.add("backend", GMusicBackend) 52 | registry.add("frontend", GMusicScrobblerFrontend) 53 | 54 | def get_command(self): 55 | from .commands import GMusicCommand 56 | 57 | return GMusicCommand() 58 | -------------------------------------------------------------------------------- /mopidy_gmusic/backend.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from threading import Lock 4 | 5 | import pykka 6 | 7 | from mopidy import backend 8 | 9 | from .library import GMusicLibraryProvider 10 | from .playback import GMusicPlaybackProvider 11 | from .playlists import GMusicPlaylistsProvider 12 | from .repeating_timer import RepeatingTimer 13 | from .scrobbler_frontend import GMusicScrobblerListener 14 | from .session import GMusicSession 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class GMusicBackend( 20 | pykka.ThreadingActor, backend.Backend, GMusicScrobblerListener 21 | ): 22 | def __init__(self, config, audio): 23 | super().__init__() 24 | 25 | self.config = config 26 | 27 | self._refresh_library_rate = config["gmusic"]["refresh_library"] * 60.0 28 | self._refresh_playlists_rate = ( 29 | config["gmusic"]["refresh_playlists"] * 60.0 30 | ) 31 | self._refresh_library_timer = None 32 | self._refresh_playlists_timer = None 33 | self._refresh_lock = Lock() 34 | self._playlist_lock = Lock() 35 | self._refresh_last = 0 36 | # do not run playlist refresh around library refresh 37 | self._refresh_threshold = self._refresh_playlists_rate * 0.3 38 | 39 | self.library = GMusicLibraryProvider(backend=self) 40 | self.playback = GMusicPlaybackProvider(audio=audio, backend=self) 41 | self.playlists = GMusicPlaylistsProvider(backend=self) 42 | self.session = GMusicSession(all_access=config["gmusic"]["all_access"]) 43 | 44 | self.uri_schemes = ["gmusic"] 45 | 46 | def on_start(self): 47 | self.session.login( 48 | self.config["gmusic"]["refresh_token"], 49 | self.config["gmusic"]["deviceid"], 50 | ) 51 | 52 | # wait a few seconds to let mopidy settle 53 | # then refresh google music content asynchronously 54 | self._refresh_library_timer = RepeatingTimer( 55 | self._refresh_library, self._refresh_library_rate 56 | ) 57 | self._refresh_library_timer.start() 58 | # schedule playlist refresh as desired 59 | if self._refresh_playlists_rate > 0: 60 | self._refresh_playlists_timer = RepeatingTimer( 61 | self._refresh_playlists, self._refresh_playlists_rate 62 | ) 63 | self._refresh_playlists_timer.start() 64 | 65 | def on_stop(self): 66 | if self._refresh_library_timer: 67 | self._refresh_library_timer.cancel() 68 | self._refresh_library_timer = None 69 | if self._refresh_playlists_timer: 70 | self._refresh_playlists_timer.cancel() 71 | self._refresh_playlists_timer = None 72 | self.session.logout() 73 | 74 | def increment_song_playcount(self, track_id): 75 | # Called through GMusicScrobblerListener 76 | self.session.increment_song_playcount(track_id) 77 | 78 | def _refresh_library(self): 79 | with self._refresh_lock: 80 | t0 = round(time.time()) 81 | logger.debug("Refreshing library") 82 | self.library.refresh() 83 | t = round(time.time()) - t0 84 | logger.debug(f"Refreshed library in {t:.1f}s") 85 | 86 | def _refresh_playlists(self): 87 | with self._playlist_lock: 88 | t0 = round(time.time()) 89 | logger.debug("Refreshing playlists") 90 | self.playlists.refresh() 91 | t = round(time.time()) - t0 92 | logger.debug(f"Refreshed playlists in {t:.1f}s") 93 | -------------------------------------------------------------------------------- /mopidy_gmusic/commands.py: -------------------------------------------------------------------------------- 1 | import gmusicapi 2 | from mopidy import commands 3 | from oauth2client.client import OAuth2WebServerFlow 4 | 5 | 6 | class GMusicCommand(commands.Command): 7 | def __init__(self): 8 | super().__init__() 9 | self.add_child("login", LoginCommand()) 10 | 11 | 12 | class LoginCommand(commands.Command): 13 | def run(self, args, config): 14 | oauth_info = gmusicapi.Mobileclient._session_class.oauth 15 | flow = OAuth2WebServerFlow(**oauth_info._asdict()) 16 | print() 17 | print( 18 | "Go to the following URL to get an initial auth code, " 19 | "then provide it below:" 20 | ) 21 | print(flow.step1_get_authorize_url()) 22 | print() 23 | initial_code = input("code: ") 24 | credentials = flow.step2_exchange(initial_code) 25 | refresh_token = credentials.refresh_token 26 | print("\nPlease update your config to include the following:") 27 | print() 28 | print("[gmusic]") 29 | print("refresh_token =", refresh_token) 30 | print() 31 | -------------------------------------------------------------------------------- /mopidy_gmusic/ext.conf: -------------------------------------------------------------------------------- 1 | [gmusic] 2 | enabled = true 3 | refresh_token = 4 | bitrate = 160 5 | deviceid = 6 | all_access = 7 | refresh_library = 1440 8 | refresh_playlists = 60 9 | radio_stations_in_browse = true 10 | radio_stations_as_playlists = false 11 | radio_stations_count = 12 | radio_tracks_count = 25 13 | top_tracks_count = 20 14 | -------------------------------------------------------------------------------- /mopidy_gmusic/library.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import reduce 3 | 4 | from cachetools import LRUCache 5 | from mopidy import backend 6 | from mopidy.models import Album, Artist, Ref, SearchResult, Track 7 | from mopidy_gmusic.translator import ( 8 | album_to_ref, 9 | artist_to_ref, 10 | create_id, 11 | track_to_ref, 12 | ) 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class GMusicLibraryProvider(backend.LibraryProvider): 18 | root_directory = Ref.directory( 19 | uri="gmusic:directory", name="Google Play Music" 20 | ) 21 | 22 | def __init__(self, *args, **kwargs): 23 | super().__init__(*args, **kwargs) 24 | 25 | # tracks, albums, and artists here refer to what is explicitly 26 | # in our library. 27 | self.tracks = {} 28 | self.albums = {} 29 | self.artists = {} 30 | 31 | # aa_* caches are *only* used for temporary objects. Library 32 | # objects will never make it here. 33 | self.aa_artists = LRUCache(1024) 34 | self.aa_tracks = LRUCache(1024) 35 | self.aa_albums = LRUCache(1024) 36 | 37 | self._radio_stations_in_browse = self.backend.config["gmusic"][ 38 | "radio_stations_in_browse" 39 | ] 40 | self._radio_stations_count = self.backend.config["gmusic"][ 41 | "radio_stations_count" 42 | ] 43 | self._radio_tracks_count = self.backend.config["gmusic"][ 44 | "radio_tracks_count" 45 | ] 46 | 47 | self._top_tracks_count = self.backend.config["gmusic"][ 48 | "top_tracks_count" 49 | ] 50 | 51 | # Setup the root of library browsing. 52 | self._root = [ 53 | Ref.directory(uri="gmusic:album", name="Albums"), 54 | Ref.directory(uri="gmusic:artist", name="Artists"), 55 | Ref.directory(uri="gmusic:track", name="Tracks"), 56 | ] 57 | 58 | if self._radio_stations_in_browse: 59 | self._root.append(Ref.directory(uri="gmusic:radio", name="Radios")) 60 | 61 | @property 62 | def all_access(self): 63 | return self.backend.session.all_access 64 | 65 | def _browse_tracks(self): 66 | tracks = list(self.tracks.values()) 67 | tracks.sort(key=lambda ref: ref.name) 68 | refs = [] 69 | for track in tracks: 70 | refs.append(track_to_ref(track)) 71 | return refs 72 | 73 | def _browse_albums(self): 74 | refs = [] 75 | for album in self.albums.values(): 76 | refs.append(album_to_ref(album)) 77 | refs.sort(key=lambda ref: ref.name) 78 | return refs 79 | 80 | def _browse_album(self, uri): 81 | refs = [] 82 | for track in self._lookup_album(uri): 83 | refs.append(track_to_ref(track, True)) 84 | return refs 85 | 86 | def _browse_artists(self): 87 | refs = [] 88 | for artist in self.artists.values(): 89 | refs.append(artist_to_ref(artist)) 90 | refs.sort(key=lambda ref: ref.name) 91 | return refs 92 | 93 | def _browse_artist(self, uri): 94 | refs = [] 95 | for album in self._get_artist_albums(uri): 96 | refs.append(album_to_ref(album)) 97 | refs.sort(key=lambda ref: ref.name) 98 | if len(refs) > 0: 99 | refs.insert(0, Ref.directory(uri=uri + ":all", name="All Tracks")) 100 | is_all_access = uri.startswith("gmusic:artist:A") 101 | if is_all_access: 102 | refs.insert( 103 | 1, Ref.directory(uri=uri + ":top", name="Top Tracks") 104 | ) 105 | return refs 106 | else: 107 | # Show all tracks if no album is available 108 | return self._browse_artist_all_tracks(uri) 109 | 110 | def _browse_artist_all_tracks(self, uri): 111 | artist_uri = ":".join(uri.split(":")[:3]) 112 | refs = [] 113 | tracks = self._lookup_artist(artist_uri, True) 114 | for track in tracks: 115 | refs.append(track_to_ref(track)) 116 | return refs 117 | 118 | def _browse_artist_top_tracks(self, uri): 119 | artist_uri = ":".join(uri.split(":")[:3]) 120 | refs = [] 121 | tracks = self._get_artist_top_tracks(artist_uri) 122 | for track in tracks: 123 | refs.append(track_to_ref(track)) 124 | return refs 125 | 126 | def _browse_radio_stations(self, uri): 127 | stations = self.backend.session.get_radio_stations( 128 | self._radio_stations_count 129 | ) 130 | # create Ref objects 131 | refs = [] 132 | for station in stations: 133 | refs.append( 134 | Ref.directory( 135 | uri="gmusic:radio:" + station["id"], name=station["name"] 136 | ) 137 | ) 138 | return refs 139 | 140 | def _browse_radio_station(self, uri): 141 | station_id = uri.split(":")[2] 142 | tracks = self.backend.session.get_station_tracks( 143 | station_id, self._radio_tracks_count 144 | ) 145 | 146 | # create Ref objects 147 | refs = [] 148 | for track in tracks: 149 | mopidy_track = self._to_mopidy_track(track) 150 | self.aa_tracks[mopidy_track.uri] = mopidy_track 151 | refs.append(track_to_ref(mopidy_track)) 152 | return refs 153 | 154 | def browse(self, uri): 155 | logger.debug("browse: %s", str(uri)) 156 | if not uri: 157 | return [] 158 | if uri == self.root_directory.uri: 159 | return self._root 160 | 161 | parts = uri.split(":") 162 | 163 | # tracks 164 | if uri == "gmusic:track": 165 | return self._browse_tracks() 166 | 167 | # albums 168 | if uri == "gmusic:album": 169 | return self._browse_albums() 170 | 171 | # a single album 172 | # uri == 'gmusic:album:album_id' 173 | if len(parts) == 3 and parts[1] == "album": 174 | return self._browse_album(uri) 175 | 176 | # artists 177 | if uri == "gmusic:artist": 178 | return self._browse_artists() 179 | 180 | # a single artist 181 | # uri == 'gmusic:artist:artist_id' 182 | if len(parts) == 3 and parts[1] == "artist": 183 | return self._browse_artist(uri) 184 | 185 | # all tracks of a single artist 186 | # uri == 'gmusic:artist:artist_id:all' 187 | if len(parts) == 4 and parts[1] == "artist" and parts[3] == "all": 188 | return self._browse_artist_all_tracks(uri) 189 | 190 | # top tracks of a single artist 191 | # uri == 'gmusic:artist:artist_id:top' 192 | if len(parts) == 4 and parts[1] == "artist" and parts[3] == "top": 193 | return self._browse_artist_top_tracks(uri) 194 | 195 | # all radio stations 196 | if uri == "gmusic:radio": 197 | return self._browse_radio_stations(uri) 198 | 199 | # a single radio station 200 | # uri == 'gmusic:radio:station_id' 201 | if len(parts) == 3 and parts[1] == "radio": 202 | return self._browse_radio_station(uri) 203 | 204 | logger.debug("Unknown uri for browse request: %s", uri) 205 | 206 | return [] 207 | 208 | def lookup(self, uri): 209 | if uri.startswith("gmusic:track:"): 210 | return self._lookup_track(uri) 211 | elif uri.startswith("gmusic:album:"): 212 | return self._lookup_album(uri) 213 | elif uri.startswith("gmusic:artist:"): 214 | return self._lookup_artist(uri) 215 | else: 216 | return [] 217 | 218 | def _lookup_track(self, uri): 219 | is_all_access = uri.startswith("gmusic:track:T") 220 | 221 | try: 222 | return [self.tracks[uri]] 223 | except KeyError: 224 | logger.debug(f"Track {uri!r} is not a library track") 225 | pass 226 | 227 | if is_all_access and self.all_access: 228 | track = self.aa_tracks.get(uri) 229 | if track: 230 | return [track] 231 | track = self.backend.session.get_track_info(uri.split(":")[2]) 232 | if track is None: 233 | logger.warning(f"Could not find track {uri!r}") 234 | return [] 235 | if "artistId" not in track: 236 | logger.warning(f"Failed to lookup {uri!r}") 237 | return [] 238 | mopidy_track = self._to_mopidy_track(track) 239 | self.aa_tracks[mopidy_track.uri] = mopidy_track 240 | return [mopidy_track] 241 | else: 242 | return [] 243 | 244 | def _lookup_album(self, uri): 245 | is_all_access = uri.startswith("gmusic:album:B") 246 | if self.all_access and is_all_access: 247 | tracks = self.aa_albums.get(uri) 248 | if tracks: 249 | return tracks 250 | album = self.backend.session.get_album_info( 251 | uri.split(":")[2], include_tracks=True 252 | ) 253 | if album and album.get("tracks"): 254 | tracks = [ 255 | self._to_mopidy_track(track) for track in album["tracks"] 256 | ] 257 | for track in tracks: 258 | self.aa_tracks[track.uri] = track 259 | tracks = sorted(tracks, key=lambda t: (t.disc_no, t.track_no)) 260 | self.aa_albums[uri] = tracks 261 | return tracks 262 | 263 | logger.warning(f"Failed to lookup all access album {uri!r}") 264 | 265 | # Even if the album has an all access ID, we need to look it 266 | # up here (as a fallback) because purchased tracks can have a 267 | # store ID, but only show up in your library. 268 | try: 269 | album = self.albums[uri] 270 | except KeyError: 271 | logger.debug(f"Failed to lookup {uri!r}") 272 | return [] 273 | 274 | tracks = self._find_exact( 275 | dict( 276 | album=album.name, 277 | artist=[artist.name for artist in album.artists], 278 | date=album.date, 279 | ) 280 | ).tracks 281 | return sorted(tracks, key=lambda t: (t.disc_no, t.track_no)) 282 | 283 | def _get_artist_top_tracks(self, uri): 284 | is_all_access = uri.startswith("gmusic:artist:A") 285 | artist_id = uri.split(":")[2] 286 | 287 | if not is_all_access: 288 | logger.debug("Top Tracks not available for non-all-access artists") 289 | return [] 290 | 291 | artist_info = self.backend.session.get_artist_info( 292 | artist_id, 293 | include_albums=False, 294 | max_top_tracks=self._top_tracks_count, 295 | max_rel_artist=0, 296 | ) 297 | top_tracks = [] 298 | 299 | for track_dict in artist_info["topTracks"]: 300 | top_tracks.append(self._to_mopidy_track(track_dict)) 301 | 302 | return top_tracks 303 | 304 | def _get_artist_albums(self, uri): 305 | is_all_access = uri.startswith("gmusic:artist:A") 306 | 307 | artist_id = uri.split(":")[2] 308 | if is_all_access: 309 | # all access 310 | artist_infos = self.backend.session.get_artist_info( 311 | artist_id, max_top_tracks=0, max_rel_artist=0 312 | ) 313 | if artist_infos is None or "albums" not in artist_infos: 314 | return [] 315 | albums = [] 316 | for album in artist_infos["albums"]: 317 | albums.append( 318 | self._aa_search_album_to_mopidy_album({"album": album}) 319 | ) 320 | return albums 321 | elif self.all_access and artist_id in self.aa_artists: 322 | albums = self._get_artist_albums( 323 | "gmusic:artist:%s" % self.aa_artists[artist_id] 324 | ) 325 | if len(albums) > 0: 326 | return albums 327 | # else fall back to non aa albums 328 | if uri in self.artists: 329 | artist = self.artists[uri] 330 | return [ 331 | album 332 | for album in self.albums.values() 333 | if artist in album.artists 334 | ] 335 | else: 336 | logger.debug(f"No albums available for artist {uri!r}") 337 | return [] 338 | 339 | def _lookup_artist(self, uri, exact_match=False): 340 | def sorter(track): 341 | return ( 342 | track.album.date, 343 | track.album.name, 344 | track.disc_no, 345 | track.track_no, 346 | ) 347 | 348 | if self.all_access: 349 | try: 350 | all_access_id = self.aa_artists[uri.split(":")[2]] 351 | artist_infos = self.backend.session.get_artist_info( 352 | all_access_id, max_top_tracks=0, max_rel_artist=0 353 | ) 354 | if not artist_infos or not artist_infos["albums"]: 355 | logger.warning(f"Failed to lookup {artist_infos}!r") 356 | tracks = [ 357 | self._lookup_album("gmusic:album:" + album["albumId"]) 358 | for album in artist_infos["albums"] 359 | ] 360 | tracks = reduce(lambda a, b: (a + b), tracks) 361 | return sorted(tracks, key=sorter) 362 | except KeyError: 363 | pass 364 | try: 365 | artist = self.artists[uri] 366 | except KeyError: 367 | logger.debug(f"Failed to lookup {uri!r}") 368 | return [] 369 | 370 | tracks = self._find_exact(dict(artist=artist.name)).tracks 371 | if exact_match: 372 | tracks = filter(lambda t: artist in t.artists, tracks) 373 | return sorted(tracks, key=sorter) 374 | 375 | def refresh(self, uri=None): 376 | logger.info("Refreshing library") 377 | 378 | self.tracks = {} 379 | self.albums = {} 380 | self.artists = {} 381 | 382 | album_tracks = {} 383 | for track in self.backend.session.get_all_songs(): 384 | mopidy_track = self._to_mopidy_track(track) 385 | 386 | self.tracks[mopidy_track.uri] = mopidy_track 387 | self.albums[mopidy_track.album.uri] = mopidy_track.album 388 | 389 | # We don't care about the order because we're just using 390 | # this as a temporary variable to grab the proper album 391 | # artist out of the album. 392 | if mopidy_track.album.uri not in album_tracks: 393 | album_tracks[mopidy_track.album.uri] = [] 394 | 395 | album_tracks[mopidy_track.album.uri].append(mopidy_track) 396 | 397 | # Yes, this is awful. No, I don't have a better solution. Yes, 398 | # I'm annoyed at Google for not providing album artist IDs. 399 | for album in self.albums.values(): 400 | artist_found = False 401 | for album_artist in album.artists: 402 | for track in album_tracks[album.uri]: 403 | for artist in track.artists: 404 | if album_artist.name == artist.name: 405 | artist_found = True 406 | self.artists[artist.uri] = artist 407 | 408 | if not artist_found: 409 | for artist in album.artists: 410 | self.artists[artist.uri] = artist 411 | 412 | logger.info( 413 | "Loaded " 414 | f"{len(self.artists)} artists, " 415 | f"{len(self.albums)} albums, " 416 | f"{len(self.tracks)} tracks from Google Play Music" 417 | ) 418 | 419 | def search(self, query=None, uris=None, exact=False): 420 | if exact: 421 | return self._find_exact(query=query, uris=uris) 422 | 423 | lib_tracks, lib_artists, lib_albums = self._search_library(query, uris) 424 | 425 | if query: 426 | aa_tracks, aa_artists, aa_albums = self._search(query, uris) 427 | for aa_artist in aa_artists: 428 | lib_artists.add(aa_artist) 429 | 430 | for aa_album in aa_albums: 431 | lib_albums.add(aa_album) 432 | 433 | lib_tracks = set(lib_tracks) 434 | 435 | for aa_track in aa_tracks: 436 | lib_tracks.add(aa_track) 437 | 438 | return SearchResult( 439 | uri="gmusic:search", 440 | tracks=lib_tracks, 441 | artists=lib_artists, 442 | albums=lib_albums, 443 | ) 444 | 445 | def _find_exact(self, query=None, uris=None): 446 | # Find exact can only be done on gmusic library, 447 | # since one can't filter all access searches 448 | lib_tracks, lib_artists, lib_albums = self._search_library(query, uris) 449 | 450 | return SearchResult( 451 | uri="gmusic:search", 452 | tracks=lib_tracks, 453 | artists=lib_artists, 454 | albums=lib_albums, 455 | ) 456 | 457 | def _search(self, query=None, uris=None): 458 | for (field, values) in query.items(): 459 | if not hasattr(values, "__iter__"): 460 | values = [values] 461 | 462 | # Since gmusic does not support search filters, just search for the 463 | # first 'searchable' filter 464 | if field in ["track_name", "album", "artist", "albumartist", "any"]: 465 | logger.info(f"Searching Google Play Music for: {values[0]}") 466 | res = self.backend.session.search(values[0], max_results=50) 467 | if res is None: 468 | return [], [], [] 469 | 470 | albums = [ 471 | self._aa_search_album_to_mopidy_album(album_res) 472 | for album_res in res["album_hits"] 473 | ] 474 | artists = [ 475 | self._aa_search_artist_to_mopidy_artist(artist_res) 476 | for artist_res in res["artist_hits"] 477 | ] 478 | tracks = [ 479 | self._aa_search_track_to_mopidy_track(track_res) 480 | for track_res in res["song_hits"] 481 | ] 482 | 483 | return tracks, artists, albums 484 | 485 | return [], [], [] 486 | 487 | def _search_library(self, query=None, uris=None): 488 | if query is None: 489 | query = {} 490 | self._validate_query(query) 491 | result_tracks = self.tracks.values() 492 | 493 | for (field, values) in query.items(): 494 | if not isinstance(values, list): 495 | values = [values] 496 | # FIXME this is bound to be slow for large libraries 497 | for value in values: 498 | if field == "track_no": 499 | q = self._convert_to_int(value) 500 | else: 501 | q = value.strip().lower() 502 | 503 | def uri_filter(track): 504 | return q in track.uri.lower() 505 | 506 | def track_name_filter(track): 507 | return q in track.name.lower() 508 | 509 | def album_filter(track): 510 | return q in getattr(track, "album", Album()).name.lower() 511 | 512 | def artist_filter(track): 513 | return any( 514 | q in a.name.lower() for a in track.artists 515 | ) or albumartist_filter(track) 516 | 517 | def albumartist_filter(track): 518 | album_artists = getattr(track, "album", Album()).artists 519 | return any(q in a.name.lower() for a in album_artists) 520 | 521 | def track_no_filter(track): 522 | return track.track_no == q 523 | 524 | def date_filter(track): 525 | return track.date and track.date.startswith(q) 526 | 527 | def any_filter(track): 528 | return any( 529 | [ 530 | uri_filter(track), 531 | track_name_filter(track), 532 | album_filter(track), 533 | artist_filter(track), 534 | albumartist_filter(track), 535 | date_filter(track), 536 | ] 537 | ) 538 | 539 | if field == "uri": 540 | result_tracks = list(filter(uri_filter, result_tracks)) 541 | elif field == "track_name": 542 | result_tracks = list( 543 | filter(track_name_filter, result_tracks) 544 | ) 545 | elif field == "album": 546 | result_tracks = list(filter(album_filter, result_tracks)) 547 | elif field == "artist": 548 | result_tracks = list(filter(artist_filter, result_tracks)) 549 | elif field == "albumartist": 550 | result_tracks = list( 551 | filter(albumartist_filter, result_tracks) 552 | ) 553 | elif field == "track_no": 554 | result_tracks = list(filter(track_no_filter, result_tracks)) 555 | elif field == "date": 556 | result_tracks = list(filter(date_filter, result_tracks)) 557 | elif field == "any": 558 | result_tracks = list(filter(any_filter, result_tracks)) 559 | else: 560 | raise LookupError("Invalid lookup field: %s" % field) 561 | 562 | result_artists = set() 563 | result_albums = set() 564 | for track in result_tracks: 565 | result_artists |= track.artists 566 | result_albums.add(track.album) 567 | 568 | return result_tracks, result_artists, result_albums 569 | 570 | def _validate_query(self, query): 571 | for (_, values) in query.items(): 572 | if not values: 573 | raise LookupError("Missing query") 574 | for value in values: 575 | if not value: 576 | raise LookupError("Missing query") 577 | 578 | def _to_mopidy_track(self, song): 579 | track_id = song.get("id", song.get("nid")) 580 | if track_id is None: 581 | raise ValueError 582 | if track_id[0] != "T" and "-" not in track_id: 583 | track_id = "T" + track_id 584 | return Track( 585 | uri="gmusic:track:" + track_id, 586 | name=song["title"], 587 | artists=[self._to_mopidy_artist(song)], 588 | album=self._to_mopidy_album(song), 589 | track_no=song.get("trackNumber", 1), 590 | disc_no=song.get("discNumber", 1), 591 | date=str(song.get("year", 0)), 592 | length=int(song["durationMillis"]), 593 | bitrate=self.backend.config["gmusic"]["bitrate"], 594 | ) 595 | 596 | def _to_mopidy_album(self, song): 597 | name = song.get("album", "") 598 | artist = self._to_mopidy_album_artist(song) 599 | date = str(song.get("year", 0)) 600 | 601 | album_id = create_id(f"{artist.name}|{name}|{date}") 602 | 603 | uri = "gmusic:album:" + album_id 604 | return Album( 605 | uri=uri, 606 | name=name, 607 | artists=[artist], 608 | num_tracks=song.get("totalTrackCount"), 609 | num_discs=song.get("totalDiscCount"), 610 | date=date, 611 | ) 612 | 613 | def _to_mopidy_artist(self, song): 614 | name = song.get("artist", "") 615 | artist_id = create_id(name) 616 | uri = "gmusic:artist:" + artist_id 617 | return Artist(uri=uri, name=name) 618 | 619 | def _to_mopidy_album_artist(self, song): 620 | name = song.get("albumArtist", "") 621 | if name.strip() == "": 622 | name = song.get("artist", "") 623 | uri = "gmusic:artist:" + create_id(name) 624 | return Artist(uri=uri, name=name) 625 | 626 | def _aa_search_track_to_mopidy_track(self, search_track): 627 | track = search_track["track"] 628 | 629 | aa_artist_id = create_id(track["artist"]) 630 | if "artistId" in track: 631 | aa_artist_id = track["artistId"][0] 632 | else: 633 | logger.warning("No artistId for Track %r", track) 634 | 635 | artist = Artist( 636 | uri="gmusic:artist:" + aa_artist_id, name=track["artist"] 637 | ) 638 | 639 | album = Album( 640 | uri="gmusic:album:" + track["albumId"], 641 | name=track["album"], 642 | artists=[artist], 643 | date=str(track.get("year", 0)), 644 | ) 645 | 646 | return Track( 647 | uri="gmusic:track:" + track["storeId"], 648 | name=track["title"], 649 | artists=[artist], 650 | album=album, 651 | track_no=track.get("trackNumber", 1), 652 | disc_no=track.get("discNumber", 1), 653 | date=str(track.get("year", 0)), 654 | length=int(track["durationMillis"]), 655 | bitrate=self.backend.config["gmusic"]["bitrate"], 656 | ) 657 | 658 | def _aa_search_artist_to_mopidy_artist(self, search_artist): 659 | artist = search_artist["artist"] 660 | uri = "gmusic:artist:" + artist["artistId"] 661 | return Artist(uri=uri, name=artist["name"]) 662 | 663 | def _aa_search_album_to_mopidy_album(self, search_album): 664 | album = search_album["album"] 665 | uri = "gmusic:album:" + album["albumId"] 666 | name = album["name"] 667 | artist = self._aa_search_artist_album_to_mopidy_artist_album(album) 668 | date = str(album.get("year", 0)) 669 | return Album(uri=uri, name=name, artists=[artist], date=date) 670 | 671 | def _aa_search_artist_album_to_mopidy_artist_album(self, album): 672 | name = album.get("albumArtist", "") 673 | if name.strip() == "": 674 | name = album.get("artist", "") 675 | uri = "gmusic:artist:" + create_id(name) 676 | return Artist(uri=uri, name=name) 677 | 678 | def _convert_to_int(self, string): 679 | try: 680 | return int(string) 681 | except ValueError: 682 | return object() 683 | -------------------------------------------------------------------------------- /mopidy_gmusic/playback.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from mopidy import backend 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | BITRATES = { 9 | 128: "low", 10 | 160: "med", 11 | 320: "hi", 12 | } 13 | 14 | 15 | class GMusicPlaybackProvider(backend.PlaybackProvider): 16 | def translate_uri(self, uri): 17 | track_id = uri.rsplit(":")[-1] 18 | 19 | quality = BITRATES[self.backend.config["gmusic"]["bitrate"]] 20 | stream_uri = self.backend.session.get_stream_url( 21 | track_id, quality=quality 22 | ) 23 | 24 | logger.debug("Translated: %s -> %s", uri, stream_uri) 25 | return stream_uri 26 | -------------------------------------------------------------------------------- /mopidy_gmusic/playlists.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import operator 3 | 4 | from mopidy import backend 5 | from mopidy.models import Playlist, Ref 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class GMusicPlaylistsProvider(backend.PlaylistsProvider): 11 | def __init__(self, *args, **kwargs): 12 | super().__init__(*args, **kwargs) 13 | self._radio_stations_as_playlists = self.backend.config["gmusic"][ 14 | "radio_stations_as_playlists" 15 | ] 16 | self._radio_stations_count = self.backend.config["gmusic"][ 17 | "radio_stations_count" 18 | ] 19 | self._radio_tracks_count = self.backend.config["gmusic"][ 20 | "radio_tracks_count" 21 | ] 22 | self._playlists = {} 23 | 24 | def as_list(self): 25 | refs = [ 26 | Ref.playlist(uri=pl.uri, name=pl.name) 27 | for pl in self._playlists.values() 28 | ] 29 | return sorted(refs, key=operator.attrgetter("name")) 30 | 31 | def get_items(self, uri): 32 | playlist = self._playlists.get(uri) 33 | if playlist is None: 34 | return None 35 | return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] 36 | 37 | def lookup(self, uri): 38 | return self._playlists.get(uri) 39 | 40 | def refresh(self): 41 | playlists = {} 42 | 43 | # We need to grab all the songs for later. All access metadata 44 | # will be included with the playlist entry, but uploaded music 45 | # will not. 46 | library_tracks = {} 47 | for track in self.backend.session.get_all_songs(): 48 | mopidy_track = self.backend.library._to_mopidy_track(track) 49 | library_tracks[track["id"]] = mopidy_track 50 | 51 | # add thumbs up playlist 52 | tracks = [] 53 | for track in self.backend.session.get_top_songs(): 54 | tracks.append(self.backend.library._to_mopidy_track(track)) 55 | 56 | if len(tracks) > 0: 57 | uri = "gmusic:playlist:top" 58 | playlists[uri] = Playlist(uri=uri, name="Top", tracks=tracks) 59 | 60 | # load user playlists 61 | for playlist in self.backend.session.get_all_user_playlist_contents(): 62 | tracks = [] 63 | for entry in playlist["tracks"]: 64 | if entry["deleted"]: 65 | continue 66 | 67 | if entry["source"] == "1": 68 | tracks.append(library_tracks[entry["trackId"]]) 69 | else: 70 | entry["track"]["id"] = entry["trackId"] 71 | tracks.append( 72 | self.backend.library._to_mopidy_track(entry["track"]) 73 | ) 74 | 75 | uri = "gmusic:playlist:" + playlist["id"] 76 | playlists[uri] = Playlist( 77 | uri=uri, name=playlist["name"], tracks=tracks 78 | ) 79 | 80 | # load shared playlists 81 | for playlist in self.backend.session.get_all_playlists(): 82 | if playlist.get("type") == "SHARED": 83 | tracks = [] 84 | tracklist = self.backend.session.get_shared_playlist_contents( 85 | playlist["shareToken"] 86 | ) 87 | for entry in tracklist: 88 | if entry["source"] == "1": 89 | tracks.append(library_tracks[entry["trackId"]]) 90 | else: 91 | entry["track"]["id"] = entry["trackId"] 92 | tracks.append( 93 | self.backend.library._to_mopidy_track( 94 | entry["track"] 95 | ) 96 | ) 97 | 98 | uri = "gmusic:playlist:" + playlist["id"] 99 | playlists[uri] = Playlist( 100 | uri=uri, name=playlist["name"], tracks=tracks 101 | ) 102 | 103 | num_playlists = len(playlists) 104 | logger.info("Loaded %d playlists from Google Play Music", num_playlists) 105 | 106 | # load radios as playlists 107 | if self._radio_stations_as_playlists: 108 | logger.debug("Loading radio stations") 109 | stations = self.backend.session.get_radio_stations( 110 | self._radio_stations_count 111 | ) 112 | for station in stations: 113 | tracks = [] 114 | tracklist = self.backend.session.get_station_tracks( 115 | station["id"], self._radio_tracks_count 116 | ) 117 | for track in tracklist: 118 | tracks.append(self.backend.library._to_mopidy_track(track)) 119 | uri = "gmusic:playlist:" + station["id"] 120 | playlists[uri] = Playlist( 121 | uri=uri, name=station["name"], tracks=tracks 122 | ) 123 | 124 | num_radios = len(playlists) - num_playlists 125 | logger.info( 126 | f"Loaded {num_radios} radio stations from Google Play Music" 127 | ) 128 | 129 | self._playlists = playlists 130 | backend.BackendListener.send("playlists_loaded") 131 | 132 | def create(self, name): 133 | raise NotImplementedError 134 | 135 | def delete(self, uri): 136 | raise NotImplementedError 137 | 138 | def save(self, playlist): 139 | raise NotImplementedError 140 | -------------------------------------------------------------------------------- /mopidy_gmusic/repeating_timer.py: -------------------------------------------------------------------------------- 1 | from threading import Event, Thread 2 | 3 | 4 | class RepeatingTimer(Thread): 5 | def __init__(self, method, interval=0): 6 | Thread.__init__(self) 7 | self._stop_event = Event() 8 | self._interval = interval 9 | self._method = method 10 | 11 | def run(self): 12 | self._method() 13 | while self._interval > 0 and not self._stop_event.wait(self._interval): 14 | # wait for interval 15 | # call method over and over again 16 | self._method() 17 | 18 | def cancel(self): 19 | self._stop_event.set() 20 | -------------------------------------------------------------------------------- /mopidy_gmusic/scrobbler_frontend.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pykka 4 | 5 | from mopidy import core, listener 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class GMusicScrobblerFrontend(pykka.ThreadingActor, core.CoreListener): 11 | def __init__(self, config, core): 12 | super().__init__() 13 | 14 | def track_playback_ended(self, tl_track, time_position): 15 | track = tl_track.track 16 | 17 | duration = track.length and track.length // 1000 or 0 18 | time_position = time_position // 1000 19 | 20 | if time_position < duration // 2 and time_position < 240: 21 | logger.debug( 22 | "Track not played long enough too scrobble. (50% or 240s)" 23 | ) 24 | return 25 | 26 | track_id = track.uri.rsplit(":")[-1] 27 | logger.debug("Increasing play count: %s", track_id) 28 | listener.send( 29 | GMusicScrobblerListener, 30 | "increment_song_playcount", 31 | track_id=track_id, 32 | ) 33 | 34 | 35 | class GMusicScrobblerListener(listener.Listener): 36 | def increment_song_playcount(self, track_id): 37 | pass 38 | -------------------------------------------------------------------------------- /mopidy_gmusic/session.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import logging 3 | 4 | import requests 5 | 6 | import gmusicapi 7 | from gmusicapi.exceptions import CallFailure, NotLoggedIn 8 | from gmusicapi.session import credentials_from_refresh_token 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def endpoint(default=None, require_all_access=False): 14 | default = default() if callable(default) else default 15 | 16 | def outer_wrapper(func): 17 | @functools.wraps(func) 18 | def inner_wrapper(self, *args, **kwargs): 19 | if require_all_access and not self.all_access: 20 | logger.warning( 21 | "Google Play Music All Access is required for %s()", 22 | func.__name__, 23 | ) 24 | return default 25 | 26 | if not self.api.is_authenticated(): 27 | return default 28 | 29 | try: 30 | return func(self, *args, **kwargs) 31 | except gmusicapi.CallFailure: 32 | logger.exception("Call to Google Play Music failed") 33 | return default 34 | except requests.exceptions.RequestException: 35 | logger.exception("HTTP request to Google Play Music failed") 36 | return default 37 | 38 | return inner_wrapper 39 | 40 | return outer_wrapper 41 | 42 | 43 | class GMusicSession: 44 | def __init__(self, all_access, api=None): 45 | self._all_access = all_access 46 | if api is None: 47 | self.api = gmusicapi.Mobileclient() 48 | self.api._authtype = "oauth" 49 | else: 50 | self.api = api 51 | 52 | def login(self, refresh_token, device_id): 53 | if self.api.is_authenticated(): 54 | self.api.logout() 55 | 56 | if device_id is None or device_id == "mac": 57 | device_id = gmusicapi.Mobileclient.FROM_MAC_ADDRESS 58 | 59 | oauth_info = gmusicapi.Mobileclient._session_class.oauth 60 | 61 | if not refresh_token: 62 | logger.error( 63 | "No refresh_token in gmusic config. Please run " 64 | + "`mopidy gmusic login`." 65 | ) 66 | return False 67 | 68 | authenticated = self.api.oauth_login( 69 | device_id, 70 | oauth_credentials=credentials_from_refresh_token( 71 | refresh_token, oauth_info 72 | ), 73 | ) 74 | 75 | if authenticated: 76 | logger.info("Logged in to Google Play Music") 77 | else: 78 | logger.error("Failed to login to Google Play Music") 79 | 80 | return authenticated 81 | 82 | @property 83 | def all_access(self): 84 | if self._all_access is None: 85 | try: 86 | return self.api.is_subscribed 87 | except NotLoggedIn: 88 | return False 89 | 90 | return self._all_access 91 | 92 | @endpoint(default=None) 93 | def logout(self): 94 | return self.api.logout() 95 | 96 | @endpoint(default=list) 97 | def get_all_songs(self): 98 | return self.api.get_all_songs() 99 | 100 | @endpoint(default=None) 101 | def get_stream_url(self, song_id, quality="hi"): 102 | try: 103 | return self.api.get_stream_url(song_id, quality=quality) 104 | except CallFailure: 105 | logger.warn("Failed to get stream url for %s.", song_id) 106 | logger.warn("Please ensure your deviceid is set correctly.") 107 | raise 108 | 109 | @endpoint(default=list) 110 | def get_all_playlists(self): 111 | return self.api.get_all_playlists() 112 | 113 | @endpoint(default=list) 114 | def get_all_user_playlist_contents(self): 115 | return self.api.get_all_user_playlist_contents() 116 | 117 | @endpoint(default=list) 118 | def get_shared_playlist_contents(self, share_token): 119 | return self.api.get_shared_playlist_contents(share_token) 120 | 121 | @endpoint(default=list) 122 | def get_top_songs(self): 123 | return self.api.get_top_songs() 124 | 125 | @endpoint(default=None, require_all_access=True) 126 | def get_track_info(self, store_track_id): 127 | return self.api.get_track_info(store_track_id) 128 | 129 | @endpoint(default=None, require_all_access=True) 130 | def get_album_info(self, album_id, include_tracks=True): 131 | return self.api.get_album_info(album_id, include_tracks=include_tracks) 132 | 133 | @endpoint(default=None, require_all_access=True) 134 | def get_artist_info( 135 | self, artist_id, include_albums=True, max_top_tracks=5, max_rel_artist=5 136 | ): 137 | return self.api.get_artist_info( 138 | artist_id, 139 | include_albums=include_albums, 140 | max_top_tracks=max_top_tracks, 141 | max_rel_artist=max_rel_artist, 142 | ) 143 | 144 | @endpoint(default=None, require_all_access=False) 145 | def search(self, query, max_results=50): 146 | return self.api.search(query, max_results=max_results) 147 | 148 | @endpoint(default=list) 149 | def get_all_stations(self): 150 | return self.api.get_all_stations() 151 | 152 | def get_radio_stations(self, num_stations=None): 153 | stations = self.get_all_stations() 154 | 155 | # Last played radio first 156 | stations.reverse() 157 | 158 | # Add IFL radio on top 159 | stations.insert(0, {"id": "IFL", "name": "I'm Feeling Lucky"}) 160 | 161 | if num_stations is not None and num_stations > 0: 162 | # Limit radio stations 163 | stations = stations[:num_stations] 164 | 165 | return stations 166 | 167 | @endpoint(default=list, require_all_access=True) 168 | def get_station_tracks(self, station_id, num_tracks=25): 169 | return self.api.get_station_tracks(station_id, num_tracks=num_tracks) 170 | 171 | @endpoint(default=None) 172 | def increment_song_playcount(self, song_id, plays=1, playtime=None): 173 | return self.api.increment_song_playcount( 174 | song_id, plays=plays, playtime=playtime 175 | ) 176 | -------------------------------------------------------------------------------- /mopidy_gmusic/translator.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | from mopidy.models import Ref 4 | 5 | 6 | def album_to_ref(album): 7 | """Convert a mopidy album to a mopidy ref.""" 8 | name = "" 9 | for artist in album.artists: 10 | if len(name) > 0: 11 | name += ", " 12 | name += artist.name 13 | if (len(name)) > 0: 14 | name += " - " 15 | if album.name: 16 | name += album.name 17 | else: 18 | name += "Unknown Album" 19 | return Ref.directory(uri=album.uri, name=name) 20 | 21 | 22 | def artist_to_ref(artist): 23 | """Convert a mopidy artist to a mopidy ref.""" 24 | if artist.name: 25 | name = artist.name 26 | else: 27 | name = "Unknown artist" 28 | return Ref.directory(uri=artist.uri, name=name) 29 | 30 | 31 | def track_to_ref(track, with_track_no=False): 32 | """Convert a mopidy track to a mopidy ref.""" 33 | if with_track_no and track.track_no > 0: 34 | name = "%d - " % track.track_no 35 | else: 36 | name = "" 37 | for artist in track.artists: 38 | if len(name) > 0: 39 | name += ", " 40 | name += artist.name 41 | if (len(name)) > 0: 42 | name += " - " 43 | name += track.name 44 | return Ref.track(uri=track.uri, name=name) 45 | 46 | 47 | def get_images(song): 48 | if "albumArtRef" in song: 49 | return [ 50 | art_ref["url"] 51 | for art_ref in song["albumArtRef"] 52 | if "url" in art_ref 53 | ] 54 | 55 | return [] 56 | 57 | 58 | def create_id(u): 59 | return hashlib.md5(u.encode("utf-8")).hexdigest() 60 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 30.3.0", "wheel"] 3 | 4 | 5 | [tool.black] 6 | target-version = ["py37", "py38"] 7 | line-length = 80 8 | 9 | 10 | [tool.isort] 11 | multi_line_output = 3 12 | include_trailing_comma = true 13 | force_grid_wrap = 0 14 | use_parentheses = true 15 | line_length = 88 16 | known_tests = "tests" 17 | sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER" 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = Mopidy-GMusic 3 | version = 4.0.1 4 | url = https://github.com/mopidy/mopidy-gmusic 5 | author = Ronald Hecht 6 | author_email = ronald.hecht@gmx.de 7 | license = Apache License, Version 2.0 8 | license_file = LICENSE 9 | description = Mopidy extension for playing music from Google Play Music 10 | long_description = file: README.rst 11 | classifiers = 12 | Environment :: No Input/Output (Daemon) 13 | Intended Audience :: End Users/Desktop 14 | License :: OSI Approved :: Apache Software License 15 | Operating System :: OS Independent 16 | Programming Language :: Python :: 3 17 | Programming Language :: Python :: 3.7 18 | Programming Language :: Python :: 3.8 19 | Topic :: Multimedia :: Sound/Audio :: Players 20 | 21 | 22 | [options] 23 | zip_safe = False 24 | include_package_data = True 25 | packages = find: 26 | python_requires = >= 3.7 27 | install_requires = 28 | Mopidy >= 3.0.0 29 | Pykka >= 2.0.1 30 | setuptools 31 | gmusicapi >= 12.1 32 | requests >= 2.0 33 | cachetools >= 1.0 34 | 35 | 36 | [options.extras_require] 37 | lint = 38 | black 39 | check-manifest 40 | flake8 41 | flake8-bugbear 42 | flake8-import-order 43 | isort[pyproject] 44 | release = 45 | twine 46 | wheel 47 | test = 48 | pytest 49 | pytest-cov 50 | dev = 51 | %(lint)s 52 | %(release)s 53 | %(test)s 54 | 55 | 56 | [options.packages.find] 57 | exclude = 58 | tests 59 | tests.* 60 | 61 | 62 | [options.entry_points] 63 | mopidy.ext = 64 | gmusic = mopidy_gmusic:Extension 65 | 66 | 67 | [flake8] 68 | application-import-names = mopidy_gmusic, tests 69 | max-line-length = 80 70 | exclude = .git, .tox, build 71 | select = 72 | # Regular flake8 rules 73 | C, E, F, W 74 | # flake8-bugbear rules 75 | B 76 | # B950: line too long (soft speed limit) 77 | B950 78 | # pep8-naming rules 79 | N 80 | ignore = 81 | # E203: whitespace before ':' (not PEP8 compliant) 82 | E203 83 | # E501: line too long (replaced by B950) 84 | E501 85 | # W503: line break before binary operator (not PEP8 compliant) 86 | W503 87 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mopidy/mopidy-gmusic/7d3df06e0d3be21a8581e26c2144c44db62084dd/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | 4 | from mopidy_gmusic import Extension 5 | from mopidy_gmusic import backend as backend_lib 6 | from mopidy_gmusic import scrobbler_frontend 7 | 8 | 9 | class ExtensionTest(unittest.TestCase): 10 | @staticmethod 11 | def get_config(): 12 | config = {} 13 | config["username"] = "testuser@gmail.com" 14 | config["password"] = "secret_password" 15 | config["refresh_token"] = "0987654321" 16 | config["deviceid"] = "1234567890" 17 | config["all_access"] = False 18 | config["refresh_library"] = 1440 19 | config["refresh_playlists"] = 60 20 | config["radio_stations_in_browse"] = True 21 | config["radio_stations_as_playlists"] = False 22 | config["radio_stations_count"] = 0 23 | config["radio_tracks_count"] = 25 24 | config["top_tracks_count"] = 20 25 | return {"gmusic": config} 26 | 27 | def test_get_default_config(self): 28 | ext = Extension() 29 | 30 | config = ext.get_default_config() 31 | 32 | assert "[gmusic]" in config 33 | assert "enabled = true" in config 34 | assert "all_access =" in config 35 | assert "radio_stations_in_browse = true" in config 36 | assert "radio_stations_count =" in config 37 | assert "radio_tracks_count = 25" in config 38 | 39 | def test_get_config_schema(self): 40 | ext = Extension() 41 | 42 | schema = ext.get_config_schema() 43 | 44 | assert "username" in schema 45 | assert "password" in schema 46 | assert "deviceid" in schema 47 | assert "refresh_library" in schema 48 | assert "refresh_playlists" in schema 49 | assert "all_access" in schema 50 | assert "radio_stations_in_browse" in schema 51 | assert "radio_stations_as_playlists" in schema 52 | assert "radio_stations_count" in schema 53 | assert "radio_tracks_count" in schema 54 | 55 | def test_get_backend_classes(self): 56 | registry = mock.Mock() 57 | 58 | ext = Extension() 59 | ext.setup(registry) 60 | 61 | assert ( 62 | mock.call("backend", backend_lib.GMusicBackend) 63 | in registry.add.mock_calls 64 | ) 65 | assert ( 66 | mock.call("frontend", scrobbler_frontend.GMusicScrobblerFrontend) 67 | in registry.add.mock_calls 68 | ) 69 | 70 | def test_init_backend(self): 71 | backend = backend_lib.GMusicBackend(ExtensionTest.get_config(), None) 72 | assert backend is not None 73 | backend.on_start() 74 | backend.on_stop() 75 | -------------------------------------------------------------------------------- /tests/test_library.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from mopidy.models import Ref 4 | from mopidy_gmusic import backend as backend_lib 5 | 6 | from tests.test_extension import ExtensionTest 7 | 8 | 9 | class LibraryTest(unittest.TestCase): 10 | def setUp(self): 11 | config = ExtensionTest.get_config() 12 | self.backend = backend_lib.GMusicBackend(config=config, audio=None) 13 | 14 | def test_browse_radio_deactivated(self): 15 | config = ExtensionTest.get_config() 16 | config["gmusic"]["radio_stations_in_browse"] = False 17 | self.backend = backend_lib.GMusicBackend(config=config, audio=None) 18 | 19 | refs = self.backend.library.browse("gmusic:directory") 20 | for ref in refs: 21 | assert ref.uri != "gmusic:radio" 22 | 23 | def test_browse_none(self): 24 | refs = self.backend.library.browse(None) 25 | assert refs == [] 26 | 27 | def test_browse_invalid(self): 28 | refs = self.backend.library.browse("gmusic:invalid_uri") 29 | assert refs == [] 30 | 31 | def test_browse_root(self): 32 | refs = self.backend.library.browse("gmusic:directory") 33 | found = False 34 | for ref in refs: 35 | if ref.uri == "gmusic:album": 36 | found = True 37 | break 38 | assert found, "ref 'gmusic:album' not found" 39 | found = False 40 | for ref in refs: 41 | if ref.uri == "gmusic:artist": 42 | found = True 43 | break 44 | assert found, "ref 'gmusic:artist' not found" 45 | found = False 46 | for ref in refs: 47 | if ref.uri == "gmusic:track": 48 | found = True 49 | break 50 | assert found, "ref 'gmusic:track' not found" 51 | found = False 52 | for ref in refs: 53 | if ref.uri == "gmusic:radio": 54 | found = True 55 | break 56 | assert found, "ref 'gmusic:radio' not found" 57 | 58 | def test_browse_tracks(self): 59 | refs = self.backend.library.browse("gmusic:track") 60 | assert refs is not None 61 | 62 | def test_browse_artist(self): 63 | refs = self.backend.library.browse("gmusic:artist") 64 | assert refs is not None 65 | 66 | def test_browse_artist_id_invalid(self): 67 | refs = self.backend.library.browse("gmusic:artist:artist_id") 68 | assert refs is not None 69 | assert refs == [] 70 | 71 | def test_browse_album(self): 72 | refs = self.backend.library.browse("gmusic:album") 73 | assert refs is not None 74 | 75 | def test_browse_album_id_invalid(self): 76 | refs = self.backend.library.browse("gmusic:album:album_id") 77 | assert refs is not None 78 | assert refs == [] 79 | 80 | def test_browse_radio(self): 81 | refs = self.backend.library.browse("gmusic:radio") 82 | # tests should be unable to fetch stations :( 83 | assert refs is not None 84 | assert refs == [ 85 | Ref.directory(uri="gmusic:radio:IFL", name="I'm Feeling Lucky") 86 | ] 87 | 88 | def test_browse_station(self): 89 | refs = self.backend.library.browse("gmusic:radio:invalid_stations_id") 90 | # tests should be unable to fetch stations :( 91 | assert refs == [] 92 | 93 | def test_lookup_invalid(self): 94 | refs = self.backend.library.lookup("gmusic:invalid_uri") 95 | # tests should be unable to fetch any content :( 96 | assert refs == [] 97 | 98 | def test_lookup_invalid_album(self): 99 | refs = self.backend.library.lookup("gmusic:album:invalid_uri") 100 | # tests should be unable to fetch any content :( 101 | assert refs == [] 102 | 103 | def test_lookup_invalid_artist(self): 104 | refs = self.backend.library.lookup("gmusic:artis:invalid_uri") 105 | # tests should be unable to fetch any content :( 106 | assert refs == [] 107 | 108 | def test_lookup_invalid_track(self): 109 | refs = self.backend.library.lookup("gmusic:track:invalid_uri") 110 | # tests should be unable to fetch any content :( 111 | assert refs == [] 112 | 113 | def test_search(self): 114 | refs = self.backend.library.search({"artist": ["abba"]}) 115 | assert refs is not None 116 | -------------------------------------------------------------------------------- /tests/test_playback.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from mopidy_gmusic import playback 6 | 7 | 8 | @pytest.fixture 9 | def backend(): 10 | backend_mock = mock.Mock() 11 | backend_mock.config = {"gmusic": {"bitrate": 160}} 12 | return backend_mock 13 | 14 | 15 | @pytest.fixture 16 | def provider(backend): 17 | return playback.GMusicPlaybackProvider(audio=None, backend=backend) 18 | 19 | 20 | def test_translate_invalid_uri(backend, provider): 21 | backend.session.get_stream_url.return_value = None 22 | 23 | assert provider.translate_uri("gmusic:track:invalid_uri") is None 24 | 25 | 26 | def test_change_track_valid(backend, provider): 27 | stream_url = "http://stream.example.com/foo.mp3" 28 | backend.session.get_stream_url.return_value = stream_url 29 | 30 | assert provider.translate_uri("gmusic:track:valid_uri") == stream_url 31 | backend.session.get_stream_url.assert_called_once_with( 32 | "valid_uri", quality="med" 33 | ) 34 | 35 | 36 | def test_changed_bitrate(backend, provider): 37 | stream_url = "http://stream.example.com/foo.mp3" 38 | backend.session.get_stream_url.return_value = stream_url 39 | backend.config["gmusic"]["bitrate"] = 320 40 | 41 | assert provider.translate_uri("gmusic:track:valid_uri") == stream_url 42 | backend.session.get_stream_url.assert_called_once_with( 43 | "valid_uri", quality="hi" 44 | ) 45 | -------------------------------------------------------------------------------- /tests/test_playlist.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | 4 | from mopidy.models import Playlist, Ref, Track 5 | from mopidy_gmusic.playlists import GMusicPlaylistsProvider 6 | 7 | from tests.test_extension import ExtensionTest 8 | 9 | 10 | class PlaylistsTest(unittest.TestCase): 11 | def setUp(self): 12 | backend = mock.Mock() 13 | backend.config = ExtensionTest.get_config() 14 | self.provider = GMusicPlaylistsProvider(backend) 15 | self.provider._playlists = { 16 | "gmusic:playlist:foo": Playlist( 17 | uri="gmusic:playlist:foo", 18 | name="foo", 19 | tracks=[Track(uri="gmusic:track:test_track", name="test")], 20 | ), 21 | "gmusic:playlist:boo": Playlist( 22 | uri="gmusic:playlist:boo", name="boo", tracks=[] 23 | ), 24 | } 25 | 26 | def test_as_list(self): 27 | result = self.provider.as_list() 28 | 29 | assert len(result) == 2 30 | assert result[0] == Ref.playlist(uri="gmusic:playlist:boo", name="boo") 31 | assert result[1] == Ref.playlist(uri="gmusic:playlist:foo", name="foo") 32 | 33 | def test_get_items(self): 34 | result = self.provider.get_items("gmusic:playlist:foo") 35 | 36 | assert len(result) == 1 37 | assert result[0] == Ref.track( 38 | uri="gmusic:track:test_track", name="test" 39 | ) 40 | 41 | def test_get_items_for_unknown_playlist(self): 42 | result = self.provider.get_items("gmusic:playlist:bar") 43 | 44 | assert result is None 45 | 46 | def test_create(self): 47 | with self.assertRaises(NotImplementedError): 48 | self.provider.create("foo") 49 | 50 | def test_delete(self): 51 | with self.assertRaises(NotImplementedError): 52 | self.provider.delete("gmusic:playlist:foo") 53 | 54 | def test_save(self): 55 | with self.assertRaises(NotImplementedError): 56 | self.provider.save(Playlist()) 57 | 58 | def test_lookup_valid(self): 59 | result = self.provider.lookup("gmusic:playlist:foo") 60 | 61 | assert result is not None 62 | 63 | def test_lookup_invalid(self): 64 | result = self.provider.lookup("gmusic:playlist:bar") 65 | 66 | assert result is None 67 | -------------------------------------------------------------------------------- /tests/test_repeating_timer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | from threading import Event 4 | 5 | from mopidy_gmusic.repeating_timer import RepeatingTimer 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class ExtensionTest(unittest.TestCase): 11 | def setUp(self): 12 | self._event = Event() 13 | 14 | def _run_by_timer(self): 15 | self._event.set() 16 | logger.debug("Tick.") 17 | 18 | def test_init(self): 19 | timer = RepeatingTimer(self._run_by_timer, 0.5) 20 | timer.start() 21 | assert self._event.wait(1), "timer was not running" 22 | self._event.clear() 23 | assert self._event.wait(1), "timer was not running" 24 | timer.cancel() 25 | 26 | def test_stop(self): 27 | timer = RepeatingTimer(self._run_by_timer, 10) 28 | timer.start() 29 | assert timer.is_alive(), "timer is not running" 30 | timer.cancel() 31 | timer.join(1) 32 | assert not timer.is_alive(), "timer is still alive" 33 | -------------------------------------------------------------------------------- /tests/test_scrobbler_frontend.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from mopidy import models 6 | from mopidy_gmusic import scrobbler_frontend 7 | 8 | 9 | @pytest.yield_fixture 10 | def send_mock(): 11 | patcher = mock.patch.object(scrobbler_frontend.listener, "send") 12 | yield patcher.start() 13 | patcher.stop() 14 | 15 | 16 | @pytest.fixture 17 | def frontend(send_mock): 18 | return scrobbler_frontend.GMusicScrobblerFrontend(config={}, core=None) 19 | 20 | 21 | def test_aborts_if_less_than_half_is_played(frontend, send_mock): 22 | track = models.Track(uri="gmusic:track:foo", length=60000) 23 | tl_track = models.TlTrack(tlid=17, track=track) 24 | 25 | frontend.track_playback_ended(tl_track, 20000) 26 | 27 | assert send_mock.call_count == 0 28 | 29 | 30 | def test_scrobbles_if_more_than_half_is_played(frontend, send_mock): 31 | track = models.Track(uri="gmusic:track:foo", length=60000) 32 | tl_track = models.TlTrack(tlid=17, track=track) 33 | 34 | frontend.track_playback_ended(tl_track, 40000) 35 | 36 | send_mock.assert_called_once_with( 37 | mock.ANY, "increment_song_playcount", track_id="foo" 38 | ) 39 | -------------------------------------------------------------------------------- /tests/test_session.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | import requests 5 | 6 | import gmusicapi 7 | from mopidy_gmusic import session as session_lib 8 | 9 | 10 | @pytest.fixture 11 | def offline_session(): 12 | api_mock = mock.Mock(spec=gmusicapi.Mobileclient) 13 | api_mock.is_authenticated.return_value = False 14 | return session_lib.GMusicSession(all_access=True, api=api_mock) 15 | 16 | 17 | @pytest.fixture 18 | def online_session(): 19 | api_mock = mock.Mock(spec=gmusicapi.Mobileclient) 20 | api_mock.is_authenticated.return_value = True 21 | return session_lib.GMusicSession(all_access=True, api=api_mock) 22 | 23 | 24 | # TODO login 25 | 26 | 27 | class TestLogout: 28 | def test_when_offline(self, offline_session): 29 | assert offline_session.logout() is None 30 | 31 | assert offline_session.api.logout.call_count == 0 32 | 33 | def test_when_online(self, online_session): 34 | online_session.api.logout.return_value = mock.sentinel.rv 35 | 36 | assert online_session.logout() is mock.sentinel.rv 37 | 38 | online_session.api.logout.assert_called_once_with() 39 | 40 | def test_when_call_failure(self, online_session, caplog): 41 | online_session.api.logout.side_effect = gmusicapi.CallFailure( 42 | "foo", "bar" 43 | ) 44 | 45 | assert online_session.logout() is None 46 | assert "Call to Google Play Music failed" in caplog.text 47 | 48 | def test_when_connection_error(self, online_session, caplog): 49 | online_session.api.logout.side_effect = ( 50 | requests.exceptions.ConnectionError 51 | ) 52 | 53 | assert online_session.logout() is None 54 | assert "HTTP request to Google Play Music failed" in caplog.text 55 | 56 | 57 | class TestGetAllSongs: 58 | def test_when_offline(self, offline_session): 59 | assert offline_session.get_all_songs() == [] 60 | 61 | def test_when_online(self, online_session): 62 | online_session.api.get_all_songs.return_value = mock.sentinel.rv 63 | 64 | assert online_session.get_all_songs() is mock.sentinel.rv 65 | 66 | online_session.api.get_all_songs.assert_called_once_with() 67 | 68 | 69 | class TestGetStreamUrl: 70 | def test_when_offline(self, offline_session): 71 | assert offline_session.get_stream_url("abc") is None 72 | 73 | def test_when_online(self, online_session): 74 | online_session.api.get_stream_url.return_value = mock.sentinel.rv 75 | 76 | assert online_session.get_stream_url("abc") is mock.sentinel.rv 77 | 78 | online_session.api.get_stream_url.assert_called_once_with( 79 | "abc", quality="hi" 80 | ) 81 | 82 | 83 | class TestGetAllPlaylists: 84 | def test_when_offline(self, offline_session): 85 | assert offline_session.get_all_playlists() == [] 86 | 87 | def test_when_online(self, online_session): 88 | online_session.api.get_all_playlists.return_value = mock.sentinel.rv 89 | 90 | assert online_session.get_all_playlists() is mock.sentinel.rv 91 | 92 | online_session.api.get_all_playlists.assert_called_once_with() 93 | 94 | 95 | class TestGetAllUserPlaylistContents: 96 | def test_when_offline(self, offline_session): 97 | assert offline_session.get_all_user_playlist_contents() == [] 98 | 99 | def test_when_online(self, online_session): 100 | online_session.api.get_all_user_playlist_contents.return_value = ( 101 | mock.sentinel.rv 102 | ) 103 | 104 | assert ( 105 | online_session.get_all_user_playlist_contents() is mock.sentinel.rv 106 | ) 107 | 108 | ( 109 | online_session.api.get_all_user_playlist_contents.assert_called_once_with() 110 | ) 111 | 112 | 113 | class TestGetSharedPlaylistContents: 114 | def test_when_offline(self, offline_session): 115 | assert offline_session.get_shared_playlist_contents("token") == [] 116 | 117 | def test_when_online(self, online_session): 118 | online_session.api.get_shared_playlist_contents.return_value = ( 119 | mock.sentinel.rv 120 | ) 121 | 122 | assert ( 123 | online_session.get_shared_playlist_contents("token") 124 | is mock.sentinel.rv 125 | ) 126 | 127 | ( 128 | online_session.api.get_shared_playlist_contents.assert_called_once_with( 129 | "token" 130 | ) 131 | ) 132 | 133 | 134 | class TestGetTopSongs: 135 | def test_when_offline(self, offline_session): 136 | assert offline_session.get_top_songs() == [] 137 | 138 | def test_when_online(self, online_session): 139 | online_session.api.get_top_songs.return_value = mock.sentinel.rv 140 | 141 | assert online_session.get_top_songs() is mock.sentinel.rv 142 | 143 | online_session.api.get_top_songs.assert_called_once_with() 144 | 145 | 146 | class TestGetTrackInfo: 147 | def test_when_offline(self, offline_session): 148 | assert offline_session.get_track_info("id") is None 149 | 150 | def test_when_online(self, online_session): 151 | online_session.api.get_track_info.return_value = mock.sentinel.rv 152 | 153 | assert online_session.get_track_info("id") is mock.sentinel.rv 154 | 155 | online_session.api.get_track_info.assert_called_once_with("id") 156 | 157 | def test_without_all_access(self, online_session, caplog): 158 | online_session._all_access = False 159 | 160 | assert online_session.get_track_info("id") is None 161 | assert ( 162 | "Google Play Music All Access is required for get_track_info()" 163 | in caplog.text 164 | ) 165 | 166 | 167 | class TestGetAlbumInfo: 168 | def test_when_offline(self, offline_session): 169 | assert offline_session.get_album_info("id") is None 170 | 171 | def test_when_online(self, online_session): 172 | online_session.api.get_album_info.return_value = mock.sentinel.rv 173 | 174 | result = online_session.get_album_info("id", include_tracks=False) 175 | 176 | assert result is mock.sentinel.rv 177 | online_session.api.get_album_info.assert_called_once_with( 178 | "id", include_tracks=False 179 | ) 180 | 181 | def test_without_all_access(self, online_session, caplog): 182 | online_session._all_access = False 183 | 184 | assert online_session.get_album_info("id") is None 185 | assert ( 186 | "Google Play Music All Access is required for get_album_info()" 187 | in caplog.text 188 | ) 189 | 190 | 191 | class TestGetArtistInfo: 192 | def test_when_offline(self, offline_session): 193 | assert offline_session.get_artist_info("id") is None 194 | 195 | def test_when_online(self, online_session): 196 | online_session.api.get_artist_info.return_value = mock.sentinel.rv 197 | 198 | result = online_session.get_artist_info( 199 | "id", include_albums=False, max_rel_artist=3, max_top_tracks=4 200 | ) 201 | 202 | assert result is mock.sentinel.rv 203 | online_session.api.get_artist_info.assert_called_once_with( 204 | "id", include_albums=False, max_rel_artist=3, max_top_tracks=4 205 | ) 206 | 207 | def test_without_all_access(self, online_session, caplog): 208 | online_session._all_access = False 209 | 210 | assert online_session.get_artist_info("id") is None 211 | assert ( 212 | "Google Play Music All Access is required for get_artist_info()" 213 | in caplog.text 214 | ) 215 | 216 | 217 | class TestSearchAllAccess: 218 | def test_when_offline(self, offline_session): 219 | assert offline_session.search("abba") is None 220 | 221 | def test_when_online(self, online_session): 222 | online_session.api.search.return_value = mock.sentinel.rv 223 | 224 | result = online_session.search("abba", max_results=10) 225 | 226 | assert result is mock.sentinel.rv 227 | online_session.api.search.assert_called_once_with( 228 | "abba", max_results=10 229 | ) 230 | 231 | def test_without_all_access(self, online_session, caplog): 232 | online_session._all_access = False 233 | 234 | online_session.api.search.return_value = mock.sentinel.rv 235 | 236 | assert online_session.search("abba") is mock.sentinel.rv 237 | assert "Google Play Music All Access is required for" not in caplog.text 238 | 239 | 240 | class TestGetAllStations: 241 | def test_when_offline(self, offline_session): 242 | assert offline_session.get_all_stations() == [ 243 | {"id": "IFL", "name": "I'm Feeling Lucky"} 244 | ] 245 | 246 | def test_when_online(self, online_session): 247 | online_session.api.get_all_stations.return_value = mock.sentinel.rv 248 | 249 | assert online_session.get_all_stations() is mock.sentinel.rv 250 | 251 | online_session.api.get_all_stations.assert_called_once_with() 252 | 253 | 254 | class TestGetStationTracks: 255 | def test_when_offline(self, offline_session): 256 | assert offline_session.get_station_tracks("IFL") == [] 257 | 258 | def test_when_online(self, online_session): 259 | online_session.api.get_station_tracks.return_value = mock.sentinel.rv 260 | 261 | result = online_session.get_station_tracks("IFL", num_tracks=5) 262 | 263 | assert result is mock.sentinel.rv 264 | online_session.api.get_station_tracks.assert_called_once_with( 265 | "IFL", num_tracks=5 266 | ) 267 | 268 | def test_without_all_access(self, online_session, caplog): 269 | online_session._all_access = False 270 | 271 | assert online_session.get_station_tracks("IFL") == [] 272 | assert ( 273 | "Google Play Music All Access is required for get_station_tracks()" 274 | in caplog.text 275 | ) 276 | 277 | 278 | class TestIncrementSongPlayCount: 279 | def test_when_offline(self, offline_session): 280 | assert offline_session.increment_song_playcount("foo") is None 281 | 282 | def test_when_online(self, online_session): 283 | online_session.api.increment_song_playcount.return_value = ( 284 | mock.sentinel.rv 285 | ) 286 | 287 | result = online_session.increment_song_playcount( 288 | "foo", plays=2, playtime=1000000000 289 | ) 290 | 291 | assert result is mock.sentinel.rv 292 | online_session.api.increment_song_playcount.assert_called_once_with( 293 | "foo", plays=2, playtime=1000000000 294 | ) 295 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37, py38, black, check-manifest, flake8 3 | 4 | [testenv] 5 | sitepackages = true 6 | deps = .[test] 7 | commands = 8 | python -m pytest \ 9 | --basetemp={envtmpdir} \ 10 | --cov=mopidy_gmusic --cov-report=term-missing \ 11 | {posargs} 12 | 13 | [testenv:black] 14 | deps = .[lint] 15 | commands = python -m black --check . 16 | 17 | [testenv:check-manifest] 18 | deps = .[lint] 19 | commands = python -m check_manifest 20 | 21 | [testenv:flake8] 22 | deps = .[lint] 23 | commands = python -m flake8 --show-source --statistics 24 | --------------------------------------------------------------------------------