├── .gitattributes ├── .github └── FUNDING.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── NodeRed examples ├── README.md └── images │ ├── dashboard2.png │ ├── dashboard3.png │ ├── few_nodes.png │ ├── playback.png │ └── playlists.png ├── README.md ├── mopiqtt ├── __init__.py ├── ext.conf ├── frontend.py ├── mqtt.py └── utils.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py └── test_smoke.py └── tox.ini /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # Would you like to sponsor my work? 2 | 3 | # Developing open source projects takes a lot of time and effort. Any level of one-off or recuring sponsorship 4 | # helps focus my energies on improving the projects. If you would like to contribute, please use one of these links. 5 | 6 | custom: ['https://paypal.me/FabioMarzocca'] 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | *.egg-info 3 | build/ 4 | mynotes.txt 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## 1.2.0 3 | * Added track uri message 4 | 5 | ## 1.1.0 6 | * New paho-mqqt requirement (v.>=2.0) 7 | 8 | ## 1.0.11 9 | * Fixes [#4](https://github.com/fmarzocca/Mopiqtt/issues/4) 10 | 11 | ## 1.0.10 12 | * Fixed default artwork image 13 | 14 | ## 1.0.9 15 | * Bugfix on mqtt username (Credit: @hirschharald) Closes [#2](https://github.com/fmarzocca/Mopiqtt/issues/2) 16 | 17 | ## 1.0.8 18 | * Local artwork is not supported 19 | 20 | ## 1.0.7 21 | * Added `mopidy/cmnd/queryschemes` to request a list of uri-schemes Mopidy can handle in searches 22 | * Added `mopidy/stat/uri_schemes` to get a list of uri-schemes Mopidy can handle in searches 23 | * Added `mopidy/cmnd/search` to search libraries for any string (artist, album, track) 24 | * Added Added `mopidy/stat/search_results` to get results of search command 25 | 26 | ## 1.0.6 27 | * Fixed bug on `mopidy/cmnd/plrefresh` 28 | * Class name renaming 29 | 30 | ## 1.0.5 31 | * Improved error catching 32 | * Added `mopidy/stat/trklist` message showing the list of tracks in the queue 33 | * Added `mopidy/cmnd/chgtrk` to change current playing track in tracklist 34 | 35 | ## 1.0.4 36 | * Fixed bug on `mopidy/cmnd/add` 37 | * Added `mopidy/cmnd/pstream` to load and play a radio stream (or any single track) 38 | * Added `mopidy/stat/refreshed` event when playlists have been refreshed 39 | * Added `mopidy/stat/plrefresh` to refresh one or all playlists 40 | 41 | ## 1.0.3 42 | * Better playlist list formatting **Breaking change:** Now the list is an array of objects 43 | * Introducing position of current playing track 44 | 45 | ## 1.0.2 46 | * Added `mopidy/cmnd/ploadshf` to load and play shuffled playlists 47 | 48 | ## 1.0.1 49 | * First release 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt 2 | include *.md 3 | include LICENSE 4 | include MANIFEST.in 5 | include mopiqtt/ext.conf 6 | include tox.ini 7 | recursive-include tests *.py 8 | -------------------------------------------------------------------------------- /NodeRed examples/README.md: -------------------------------------------------------------------------------- 1 | 2 | # Examples of use Mopiqtt in Node Red 3 | This package is mainly useful to Node Red users, who can embed in their flows a full control over Mopidy by simple mqtt-in or mqtt-out nodes. Of course, it can be used by any other MQTT client too. 4 | 5 | Few example nodes in Node-Red (self explaining): 6 | 7 | 8 | 9 | 10 | 11 | ![Sample image 3](https://github.com/fmarzocca/Mopiqtt/blob/Development/NodeRed%20examples/images/playlists.png) 12 | 13 | A dashboard example: 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /NodeRed examples/images/dashboard2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fmarzocca/Mopiqtt/dc96fdf50e5e015ffe6d5bd38def809fec0fa517/NodeRed examples/images/dashboard2.png -------------------------------------------------------------------------------- /NodeRed examples/images/dashboard3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fmarzocca/Mopiqtt/dc96fdf50e5e015ffe6d5bd38def809fec0fa517/NodeRed examples/images/dashboard3.png -------------------------------------------------------------------------------- /NodeRed examples/images/few_nodes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fmarzocca/Mopiqtt/dc96fdf50e5e015ffe6d5bd38def809fec0fa517/NodeRed examples/images/few_nodes.png -------------------------------------------------------------------------------- /NodeRed examples/images/playback.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fmarzocca/Mopiqtt/dc96fdf50e5e015ffe6d5bd38def809fec0fa517/NodeRed examples/images/playback.png -------------------------------------------------------------------------------- /NodeRed examples/images/playlists.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fmarzocca/Mopiqtt/dc96fdf50e5e015ffe6d5bd38def809fec0fa517/NodeRed examples/images/playlists.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Based on [mopidy-mqtt](https://github.com/odiroot/mopidy-mqtt) 2 | 3 | # Mopiqtt 4 | MQTT interface for Mopidy music server. Allows easy integration with Node Red or any MQTT client. 5 | This package is mainly useful to Node Red users, who can embed in their flows a full control over Mopidy by simple mqtt-in or mqtt-out nodes. See [Node Red examples](https://github.com/fmarzocca/Mopiqtt/tree/Development/NodeRed%20examples). Of course, it can be used by any other MQTT client too. 6 | 7 | 8 | # Installation 9 | 10 | Using pip: 11 | ``` 12 | [sudo] python3 -m pip install Mopiqtt 13 | ``` 14 | (If you are running Mopidy as a service, use `sudo`) 15 | 16 | # Configuration 17 | 18 | Add the following section to your mopidy's configuration file: `/etc/mopidy/mopidy.conf` 19 | 20 | 21 | ``` 22 | [mopiqtt] 23 | host = 24 | port = 1883 25 | topic = mopidy 26 | username = 27 | password = 28 | ``` 29 | 30 | *Note*: Remember to also supply `username` and `password` options if your 31 | MQTT broker requires authentication. If not, just leave blank the two values. 32 | 33 | *Note*: Restart Mopidy with `sudo service mopidy restart` 34 | 35 | To check Mopidy log run `sudo tail -f /var/log/mopidy/mopidy.log` 36 | 37 | # Features 38 | 39 | * Sends information about Mopidy state on any change 40 | - Playback status 41 | - Volume 42 | - Track description 43 | - Playlists list 44 | - Artwork 45 | - Track index 46 | * Reacts to control commands 47 | - Playback control 48 | - Tracklist control 49 | - Volume control 50 | - Load & play a playlist (straight or shuffle) 51 | - Request playlists list 52 | - Refresh playlists 53 | - Get tracklist 54 | - Search for artist/album/track name 55 | 56 | 57 | # MQTT protocol 58 | 59 | ## Topics 60 | 61 | Default top level topic: `mopidy`. 62 | 63 | Control topic: `mopidy/cmnd`. 64 | 65 | Information topic `mopidy/stat`. 66 | 67 | ## Messages to subscribe to (mopidy/stat/``) 68 | 69 | | | Subtopic | Values | 70 | |:-------------:|:---------:|:-----------------------------------------:| 71 | | Playback State| `/plstate` | `paused` / `stop` / `playing` | 72 | | Volume | `/vol` | `` | 73 | | Current track | `/trk` | ` - - ` or ` ` | 74 | | List of playlists | `/plists` | `` | 75 | | Track Artwork (*)| `/artw` | `` | 76 | | Track uri (*)| `/trk_uri` | `` | 77 | | Playing track index (*)| `/trk-index` | ` {current: x, last: y}` | 78 | | Playlists have been refreshed | `/refreshed` | ` ` | 79 | | List of tracks in the queue(**) | `/trklist` | `` | 80 | | List of URI schemes Mopidy can handle in search(***) | `/uri_schemes` | `` | 81 | | Search results | `/search_results` | `` | 82 | 83 | `(*)` Published after any track started playback. Local artwork is NOT supported 84 | `(**)` Published after any tracklist change 85 | `(***)`Published after a request `queryschemes` 86 | 87 | ## Messages to publish to (mopidy/cmnd/``) 88 | 89 | | | Subtopic | Parameters | 90 | |:----------------:|:--------:|:-----------------------------------------------------------------:| 91 | | Playback control | `/plb` | `play` / `stop` / `pause` / `resume` / `toggle` / `prev` / `next` | 92 | | Volume control | `/vol` | `=` or `-` or `+` | 93 | | Clear queue | `/clr` | ` ` | 94 | | Add to queue | `/add` | `` | 95 | | Load and play playlist (straight) | `/pload` | `` | 96 | | Load and play playlist (shuffle) | `/ploadshfl` | `` | 97 | | Request list of playlists| `/plist` | ` ` | 98 | | Load and play a radio stream (or a single track) | `/pstream`| `` | 99 | | Refresh one or all playlists(*)| `/plrefresh` | `` or `None` | 100 | | Change current playing track(**)| `/chgtrk` | `` | 101 | | Query URI schemes Mopidy can handle in search | `/queryschemes` | ` ` | 102 | | Search for any string (artist, track, album) | `/search` | `` | 103 | 104 | 105 | `(*)` If `uri_scheme` is None, all backends are asked to refresh. If `uri_scheme` is an URI scheme handled by a backend, only that backend is asked to refresh. 106 | `(**)` Note that the track must already be in the tracklist. 107 | 108 | 109 | # Contribute 110 | 111 | You can contribute to Mopiqtt by: 112 | 113 | [![paypal](https://img.shields.io/badge/donate-paypal-blue.svg?style=flat-square)](https://www.paypal.com/donate/?hosted_button_id=NQHVVDCNK3UDL) 114 | 115 | # Credits 116 | - Current maintainer: [fmarzocca](https://github.com/fmarzocca) 117 | 118 | Based on previous works of: 119 | - [odiroot](https://github.com/odiroot) 120 | - [magcode](https://github.com/magcode>) 121 | 122 | # Project resources 123 | [![Downloads](https://pepy.tech/badge/mopiqtt)](https://pepy.tech/project/mopiqtt) 124 | 125 | - [Source code]() 126 | - [Issue tracker]() 127 | - [Changelog]() 128 | -------------------------------------------------------------------------------- /mopiqtt/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from __future__ import absolute_import 4 | import logging 5 | import os 6 | 7 | from mopidy import config, ext 8 | 9 | 10 | __version__ = "1.2.0" 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class Extension(ext.Extension): 15 | 16 | dist_name = "Mopiqtt" 17 | ext_name = "mopiqtt" 18 | version = __version__ 19 | 20 | def get_default_config(self): 21 | conf_file = os.path.join(os.path.dirname(__file__), "ext.conf") 22 | return config.read(conf_file) 23 | 24 | def get_config_schema(self): 25 | schema = super(Extension, self).get_config_schema() 26 | 27 | schema["host"] = config.Hostname() 28 | schema["port"] = config.Port(optional=True) 29 | schema["topic"] = config.String() 30 | 31 | schema["username"] = config.String(optional=True) 32 | schema["password"] = config.Secret(optional=True) 33 | 34 | return schema 35 | 36 | def setup(self, registry): 37 | from mopiqtt.frontend import MopiqttFrontend 38 | 39 | registry.add("frontend", MopiqttFrontend) 40 | -------------------------------------------------------------------------------- /mopiqtt/ext.conf: -------------------------------------------------------------------------------- 1 | [mopiqtt] 2 | enabled = true 3 | host = 127.0.0.1 4 | port = 1883 5 | topic = mopidy 6 | username = 7 | password = 8 | -------------------------------------------------------------------------------- /mopiqtt/frontend.py: -------------------------------------------------------------------------------- 1 | from builtins import str 2 | import logging 3 | 4 | import pykka 5 | from mopidy.core import CoreListener 6 | from mopidy.audio import PlaybackState 7 | from mopidy.models import SearchResult, Track, Artist, Album 8 | 9 | from .mqtt import Comms 10 | from .utils import describe_track, describe_stream, get_track_artwork 11 | 12 | import json 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | 17 | VOLUME_MAX = 100 18 | VOLUME_MIN = 0 19 | 20 | 21 | class MopiqttFrontend(pykka.ThreadingActor, CoreListener): 22 | def __init__(self, config, core): 23 | """ 24 | config (dict): The entire Mopidy configuration. 25 | core (ActorProxy): Core actor for Mopidy Core API. 26 | """ 27 | super(MopiqttFrontend, self).__init__() 28 | self.core = core 29 | self.mqtt = Comms(frontend=self, **config["mopiqtt"]) 30 | self.defaultImage = ( 31 | "https://upload.wikimedia.org/wikipedia/commons/1/14/No_Image_Available.jpg" 32 | ) 33 | 34 | def on_start(self): 35 | """ 36 | Hook for doing any setup that should be done *after* the actor is 37 | started, but *before* it starts processing messages. 38 | """ 39 | log.debug("Starting MQTT frontend: %s", self) 40 | self.mqtt.start() 41 | 42 | def on_stop(self): 43 | """ 44 | Hook for doing any cleanup that should be done *after* the actor has 45 | processed the last message, and *before* the actor stops. 46 | """ 47 | log.debug("Stopping MQTT frontend: %s", self) 48 | self.mqtt.stop() 49 | 50 | def on_failure(self, exception_type, exception_value, traceback): 51 | """ 52 | Hook for doing any cleanup *after* an unhandled exception is raised, 53 | and *before* the actor stops. 54 | """ 55 | log.error("MQTT frontend failed: %s", exception_value) 56 | 57 | @property 58 | def volume(self): 59 | return self.core.mixer.get_volume().get() 60 | 61 | @volume.setter 62 | def volume(self, value): 63 | # Normalize. 64 | value = min(value, VOLUME_MAX) 65 | value = max(value, VOLUME_MIN) 66 | self.core.mixer.set_volume(value) 67 | 68 | @property 69 | def current_state(self): 70 | return self.core.playback.get_state().get() 71 | 72 | def tracklist_changed(self): 73 | # reports any change to the current tracklist 74 | # and triggers trklist 75 | log.debug("MQTT tracklist changed") 76 | # 77 | # get list of all tracks in the queue 78 | # 79 | tk_list = self.core.tracklist.get_tracks().get() 80 | tracks = [] 81 | item = {} 82 | for a in tk_list: 83 | if a.artists: 84 | artist = next(iter(a.artists)).name 85 | item = {"name": artist + " - " + a.name, "uri": a.uri} 86 | else: 87 | item = {"name": a.name, "uri": a.uri} 88 | tracks.append(item) 89 | self.mqtt.publish("trklist", json.dumps(tracks)) 90 | log.debug("Generated tracklist list") 91 | 92 | def playback_state_changed(self, old_state, new_state): 93 | """ 94 | old_state (mopidy.core.PlaybackState) - the state before the change. 95 | new_state (mopidy.core.PlaybackState) - the state after the change. 96 | """ 97 | log.debug("MQTT playback state changed: %s", new_state) 98 | self.mqtt.publish("plstate", new_state) 99 | 100 | def track_playback_started(self, tl_track): 101 | """ 102 | tl_track (mopidy.models.TlTrack) - the track that just started playing. 103 | """ 104 | log.debug("MQTT track started: %s", tl_track.track) 105 | self.mqtt.publish("trk", describe_track(tl_track.track)) 106 | 107 | # get track's uri 108 | self.mqtt.publish("trk_uri", tl_track.track.uri) 109 | 110 | # get track's artwork (if any) 111 | self.mqtt.publish("artw", get_track_artwork(self, tl_track.track)) 112 | 113 | # get track playing indexes 114 | curr = self.core.tracklist.index().get() 115 | last = self.core.tracklist.get_length().get() 116 | pl_index = {} 117 | pl_index["current"] = curr + 1 118 | pl_index["last"] = last 119 | pl_index = json.dumps(pl_index) 120 | self.mqtt.publish("trk-index", pl_index) 121 | 122 | def track_playback_ended(self, tl_track, time_position): 123 | """ 124 | tl_track (mopidy.models.TlTrack) - the track that was played before 125 | playback stopped. 126 | time_position (int) - the time position in milliseconds. 127 | """ 128 | log.debug("MQTT track ended: %s", tl_track.track.name) 129 | self.mqtt.publish("trk", "") 130 | 131 | def volume_changed(self, volume): 132 | """ 133 | volume (int) - the new volume in the range [0..100]. 134 | """ 135 | log.debug("MQTT volume changed: %s", volume) 136 | self.mqtt.publish("vol", str(volume)) 137 | 138 | def stream_title_changed(self, title): 139 | """ 140 | title (string) - the new stream title. 141 | """ 142 | log.debug("MQTT title changed: %s", title) 143 | self.mqtt.publish("trk", describe_stream(title)) 144 | 145 | def playlists_loaded(self): 146 | log.debug("Playlists loaded event") 147 | self.mqtt.publish("refreshed", "") 148 | 149 | def on_action_plb(self, value): 150 | """Playback control.""" 151 | if value == "play": 152 | return self.core.playback.play() 153 | if value == "stop": 154 | return self.core.playback.stop() 155 | if value == "pause": 156 | return self.core.playback.pause() 157 | if value == "resume": 158 | return self.core.playback.resume() 159 | 160 | if value == "toggle": 161 | if self.current_state == PlaybackState.PLAYING: 162 | return self.core.playback.pause() 163 | if self.current_state == PlaybackState.PAUSED: 164 | return self.core.playback.resume() 165 | if self.current_state == PlaybackState.STOPPED: 166 | return self.core.playback.play() 167 | 168 | if value == "prev": 169 | return self.core.playback.previous() 170 | if value == "next": 171 | return self.core.playback.next() 172 | 173 | log.warn("Unknown playback control action: %s", value) 174 | 175 | def on_action_vol(self, value): 176 | """Volume control.""" 177 | if not value or len(value) < 2: 178 | return log.warn("Invalid volume control parameter: %s", value) 179 | 180 | operator = value[0] 181 | try: 182 | amount = int(value[1:]) 183 | except ValueError: 184 | return log.warn("Invalid volume setting value: %s", value[1:]) 185 | 186 | # Exact volume. 187 | if operator == "=": 188 | self.volume = amount 189 | return 190 | # Volume down. 191 | if operator == "-": 192 | self.volume -= amount 193 | return 194 | # Volume up. 195 | if operator == "+": 196 | self.volume += amount 197 | return 198 | 199 | log.warn("Unknown volume control operator: %s", operator) 200 | 201 | def on_action_add(self, value): 202 | """Append URI to queue (tracklist).""" 203 | if not value: 204 | return log.warn("Cannot add empty track to queue") 205 | 206 | track = [] 207 | track.append(value) 208 | self.core.tracklist.add(uris=track) 209 | log.debug("Added track: %s", value) 210 | 211 | def on_action_pstream(self, value): 212 | """Load and start a radio stream or a single track (tracklist).""" 213 | if not value: 214 | return log.warn("Cannot load empty track to queue") 215 | 216 | track = [] 217 | track.append(value) 218 | self.core.tracklist.clear() 219 | self.core.tracklist.add(uris=track) 220 | self.core.playback.play() 221 | log.debug("Started track: %s", value) 222 | 223 | def on_action_pload(self, value): 224 | """Replace current queue with playlist from URI.""" 225 | if not value: 226 | return log.warn("Cannot load unnamed playlist") 227 | 228 | self.core.tracklist.clear() 229 | # Read playlist (e.g. Spotify, Tidal, streams) 230 | items = self.core.playlists.get_items(value) 231 | tracks = [] 232 | try: 233 | for a in items.get(): 234 | tracks.append(a.uri) 235 | except ValueError: 236 | return log.info("Invalid playlist: %s", value) 237 | self.core.tracklist.add(uris=tracks) 238 | self.core.playback.play() 239 | log.debug("Started Playlist: %s", value) 240 | 241 | def on_action_ploadshfl(self, value): 242 | # Replace current queue with shuffled playlist from URI. 243 | if not value: 244 | return log.warn("Cannot load unnamed playlist") 245 | 246 | self.core.tracklist.clear() 247 | # Read playlist (e.g. Spotify, Tidal, streams) 248 | items = self.core.playlists.get_items(value) 249 | tracks = [] 250 | try: 251 | for a in items.get(): 252 | tracks.append(a.uri) 253 | except ValueError: 254 | return log.info("Invalid playlist: %s", value) 255 | self.core.tracklist.add(uris=tracks) 256 | self.core.tracklist.shuffle() 257 | self.core.playback.play() 258 | log.debug("Started shuffled Playlist: %s", value) 259 | 260 | def on_action_clr(self, value): 261 | """Clear the queue (tracklist).""" 262 | return self.core.tracklist.clear() 263 | 264 | def on_action_plist(self, value): 265 | # Request a list of all playlist 266 | plist = self.core.playlists.as_list() 267 | playlists = [] 268 | item = {} 269 | for a in plist.get(): 270 | item = {"name": a.name, "uri": a.uri} 271 | playlists.append(item) 272 | self.mqtt.publish("plists", json.dumps(playlists)) 273 | log.debug("Generated playlist list") 274 | 275 | def on_action_plrefresh(self, value): 276 | # refresh a single playlist or all 277 | # value = uri_scheme, if value=None, all playlists are refreshed 278 | if value: 279 | self.core.playlists.refresh(uri_scheme=value) 280 | log.debug("Refreshed playlists with uri_scheme: %s", value) 281 | else: 282 | self.core.playlists.refresh() 283 | log.debug("Refreshed all playlists") 284 | 285 | def on_action_chgtrk(self, value): 286 | # change current playing track in the queue 287 | if not value: 288 | return log.info("chgtrk: Cannot change track to empty uri") 289 | 290 | flt = self.core.tracklist.filter(criteria={"uri": [value]}).get() 291 | if not flt: 292 | return log.info("chgtrk: Invalid track") 293 | (tlid, trk) = flt[0] 294 | self.core.playback.play(tlid=tlid) 295 | log.debug("Changed track to tlid: %s", tlid) 296 | 297 | def on_action_queryschemes(self, value): 298 | # request uri_schemes handled by search 299 | schemes = self.core.get_uri_schemes().get() 300 | log.debug("Uri_schemes handled by search: %s", schemes) 301 | self.mqtt.publish("uri_schemes", json.dumps(schemes)) 302 | 303 | def on_action_search(self, value): 304 | if not value: 305 | return log.info("search: Cannot search empty strings") 306 | value = json.loads(value) 307 | lookup_str = value["search"] 308 | lookup_uris = value["uri_schemes"] 309 | query = {"any": lookup_str} 310 | ret: SearchResult 311 | ret = self.core.library.search(query=query, uris=lookup_uris).get() 312 | found = len(ret[0].tracks) 313 | tracks = ret[0].tracks 314 | item = {} 315 | final_list = [] 316 | for k in tracks: 317 | item = {"name": k.name, "uri": k.uri} 318 | final_list.append(item) 319 | log.debug( 320 | "Search for %s in %s ended. Found %d tracks", lookup_str, lookup_uris, found 321 | ) 322 | self.mqtt.publish("search_results", json.dumps(final_list)) 323 | -------------------------------------------------------------------------------- /mopiqtt/mqtt.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | 4 | from paho.mqtt import client as mqtt 5 | 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | HANDLER_PREFIX = "on_action_" 11 | 12 | 13 | class Comms: 14 | def __init__( 15 | self, 16 | frontend, 17 | host="localhost", 18 | port=1883, 19 | topic="mopidy", 20 | username=None, 21 | password=None, 22 | **kwargs 23 | ): 24 | """ 25 | Configure MQTT communication client. 26 | frontend (MopiqttFrontend): Instance of extension's frontend. 27 | """ 28 | self.frontend = frontend 29 | self.host = host 30 | self.port = port 31 | self.topic = topic 32 | self.user = username 33 | self.password = password 34 | 35 | self.client = mqtt.Client( 36 | mqtt.CallbackAPIVersion.VERSION2, 37 | client_id="mopidy-{}".format(random.randint(1000, 9999)), 38 | ) 39 | self.client.on_connect = self._on_connect 40 | self.client.on_message = self._on_message 41 | 42 | def start(self): 43 | """ 44 | Attempt connection to MQTT broker and initialise network loop. 45 | """ 46 | if self.user and self.password: 47 | self.client.username_pw_set(username=self.user, password=self.password) 48 | 49 | self.client.connect_async(host=self.host, port=self.port) 50 | log.debug("Connecting to MQTT broker at %s:%s", self.host, self.port) 51 | self.client.loop_start() 52 | log.debug("Started MQTT communication loop.") 53 | 54 | def stop(self): 55 | """ 56 | Clean up and disconnect from MQTT broker. 57 | """ 58 | self.client.disconnect() 59 | log.debug("Disconnected from MQTT broker") 60 | 61 | def _on_connect(self, client, userdata, flags, rc, properties): 62 | log.info("Successfully connected to MQTT broker, result :%s", rc) 63 | 64 | for name in dir(self.frontend): 65 | if not name.startswith(HANDLER_PREFIX): 66 | continue 67 | 68 | suffix = name[len(HANDLER_PREFIX) :] 69 | full_topic = "{}/cmnd/{}".format(self.topic, suffix) 70 | result, _ = self.client.subscribe(full_topic) 71 | 72 | if result == mqtt.MQTT_ERR_SUCCESS: 73 | log.debug("Subscribed to MQTT topic: %s", full_topic) 74 | else: 75 | log.warn( 76 | "Failed to subscribe to MQTT topic: %s, result: %s", 77 | full_topic, 78 | result, 79 | ) 80 | 81 | def _on_message(self, client, userdata, message): 82 | topic = message.topic.split("/")[-1] 83 | 84 | handler = getattr(self.frontend, HANDLER_PREFIX + topic, None) 85 | if not handler: 86 | log.warn("Cannot handle MQTT messages on topic: %s", message.topic) 87 | return 88 | 89 | log.debug( 90 | "Passing payload: %s to MQTT handler: %s", message.payload, handler.__name__ 91 | ) 92 | handler(value=message.payload.decode("utf8")) 93 | 94 | def publish(self, subtopic, value): 95 | full_topic = "{}/stat/{}".format(self.topic, subtopic) 96 | 97 | log.debug("Publishing: %s to MQTT topic: %s", value, full_topic) 98 | return self.client.publish(topic=full_topic, payload=value) 99 | -------------------------------------------------------------------------------- /mopiqtt/utils.py: -------------------------------------------------------------------------------- 1 | UNKNOWN = "" 2 | 3 | 4 | def describe_track(track): 5 | """ 6 | Prepare a short human-readable Track description. 7 | 8 | track (mopidy.models.Track): Track to source song data from. 9 | """ 10 | title = track.name or UNKNOWN 11 | 12 | # Simple/regular case: normal song (e.g. from Spotify). 13 | if track.artists: 14 | artist = next(iter(track.artists)).name 15 | elif track.album and track.album.artists: # Album-only artist case. 16 | artist = next(iter(track.album.artists)).name 17 | else: 18 | artist = UNKNOWN 19 | 20 | if track.album and track.album.name: 21 | album = track.album.name 22 | else: 23 | album = UNKNOWN 24 | 25 | return ";".join([title, artist, album]) 26 | 27 | 28 | def describe_stream(raw_title): 29 | """ 30 | Attempt to parse given stream title in very rudimentary way. 31 | """ 32 | title = UNKNOWN 33 | artist = UNKNOWN 34 | album = UNKNOWN 35 | 36 | # Very common separator. 37 | if "-" in raw_title: 38 | parts = raw_title.split("-") 39 | artist = parts[0].strip() 40 | title = parts[1].strip() 41 | else: 42 | # Just assume we only have track title. 43 | title = raw_title 44 | 45 | return ";".join([title, artist, album]) 46 | 47 | 48 | def get_track_artwork(self, track): 49 | imageUri = self.core.library.get_images([track.uri]).get()[track.uri] 50 | if imageUri: 51 | if imageUri[0].uri.startswith("/local"): 52 | return self.defaultImage 53 | else: 54 | return imageUri[0].uri 55 | 56 | return image 57 | else: 58 | return self.defaultImage 59 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | application-import-names = mopiqtt,tests 3 | exclude = .git,.tox 4 | 5 | [wheel] 6 | universal = 1 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import re 4 | from setuptools import find_packages 5 | from setuptools import setup 6 | import pkg_resources 7 | 8 | 9 | def get_version(filename): 10 | content = open(filename).read() 11 | metadata = dict(re.findall("__([a-z]+)__ = \"([\d.]+)\"", content)) 12 | return metadata["version"] 13 | 14 | 15 | setup( 16 | name="Mopiqtt", 17 | version=get_version("mopiqtt/__init__.py"), 18 | license="Apache License, Version 2.0", 19 | description="Control mopidy music server through MQTT broker", 20 | long_description=open("README.md").read(), 21 | long_description_content_type="text/markdown", 22 | author="Fabio Marzocca", 23 | author_email="marzoccafabio@gmail.com", 24 | url="https://github.com/fmarzocca/mopiqtt", 25 | packages=find_packages(exclude=["tests", "tests.*"]), 26 | include_package_data=True, 27 | zip_safe=False, 28 | install_requires=[ 29 | "Mopidy >= 3.0", 30 | "paho-mqtt >= 2.0", 31 | "Pykka >= 2.0", 32 | "setuptools", 33 | ], 34 | entry_points={ 35 | "mopidy.ext": [ 36 | "mopiqtt = mopiqtt:Extension", 37 | ], 38 | }, 39 | classifiers=[ 40 | "Development Status :: 5 - Production/Stable", 41 | "Intended Audience :: End Users/Desktop", 42 | "License :: OSI Approved :: Apache Software License", 43 | "Operating System :: OS Independent", 44 | "Programming Language :: Python", 45 | "Programming Language :: Python :: 3", 46 | "Programming Language :: Python :: 3.7", 47 | "Topic :: Multimedia :: Sound/Audio :: Players", 48 | "Environment :: No Input/Output (Daemon)", 49 | ], 50 | ) 51 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fmarzocca/Mopiqtt/dc96fdf50e5e015ffe6d5bd38def809fec0fa517/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import pytest 3 | from mopidy.core import Core 4 | 5 | 6 | @pytest.fixture 7 | def config(): 8 | return { 9 | 'core': {}, 10 | 'mqtt': { 11 | 'host': 'localhost', 12 | 'port': 1883, 13 | 'topic': 'mopidy', 14 | }, 15 | } 16 | 17 | 18 | @pytest.fixture 19 | def core(config): 20 | actor = Core.start( 21 | config=config, backends=[]).proxy() 22 | 23 | yield actor 24 | actor.stop() 25 | -------------------------------------------------------------------------------- /tests/test_smoke.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | 4 | from __future__ import absolute_import 5 | from mopiqtt import frontend, Extension 6 | 7 | 8 | def test_extension(): 9 | ext = Extension() 10 | 11 | schema = ext.get_config_schema() 12 | assert schema 13 | 14 | config = ext.get_default_config() 15 | assert '[mqtt]' in config 16 | 17 | 18 | def test_smoke(config, core): 19 | frontend.MopiqttFrontend(config, core) 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37, flake8 3 | 4 | [testenv] 5 | sitepackages = true 6 | 7 | deps = 8 | https://github.com/mopidy/mopidy/archive/develop.zip 9 | pytest 10 | 11 | commands = 12 | py.test --basetemp={envtmpdir} {posargs} 13 | 14 | 15 | [testenv:flake8] 16 | skip_install = true 17 | deps = 18 | flake8 19 | 20 | commands = flake8 mopiqtt setup.py tests/ 21 | 22 | --------------------------------------------------------------------------------