Mopiqtt 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mopiqtt/__init__.py ADDED
@@ -0,0 +1,39 @@
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__ = "2.0.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)
mopiqtt/ext.conf ADDED
@@ -0,0 +1,7 @@
1
+ [mopiqtt]
2
+ enabled = true
3
+ host = 127.0.0.1
4
+ port = 1883
5
+ topic = mopidy
6
+ username =
7
+ password =
mopiqtt/frontend.py ADDED
@@ -0,0 +1,415 @@
1
+ from builtins import str
2
+ from importlib import import_module
3
+ import json
4
+ import logging
5
+
6
+ import pykka
7
+ from mopidy.core import CoreListener
8
+ from mopidy.models import SearchResult
9
+
10
+ from .mqtt import Comms
11
+ from .utils import describe_track, describe_stream, get_track_artwork
12
+
13
+
14
+ def _load_playback_state():
15
+ for module_name in ("mopidy.types", "mopidy.audio"):
16
+ try:
17
+ module = import_module(module_name)
18
+ return getattr(module, "PlaybackState")
19
+ except (AttributeError, ImportError):
20
+ continue
21
+
22
+ raise ImportError("Cannot import PlaybackState from Mopidy")
23
+
24
+
25
+ PlaybackState = _load_playback_state()
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ VOLUME_MAX = 100
31
+ VOLUME_MIN = 0
32
+
33
+
34
+ class MopiqttFrontend(pykka.ThreadingActor, CoreListener):
35
+ def __init__(self, config, core):
36
+ """
37
+ config (dict): The entire Mopidy configuration.
38
+ core (ActorProxy): Core actor for Mopidy Core API.
39
+ """
40
+ super(MopiqttFrontend, self).__init__()
41
+ self.core = core
42
+ self.mqtt = Comms(frontend=self, **config["mopiqtt"])
43
+ self.defaultImage = (
44
+ "https://upload.wikimedia.org/wikipedia/commons/1/14/No_Image_Available.jpg"
45
+ )
46
+
47
+ def on_start(self):
48
+ """
49
+ Hook for doing any setup that should be done *after* the actor is
50
+ started, but *before* it starts processing messages.
51
+ """
52
+ log.debug("Starting MQTT frontend: %s", self)
53
+ self.mqtt.start()
54
+
55
+ def on_stop(self):
56
+ """
57
+ Hook for doing any cleanup that should be done *after* the actor has
58
+ processed the last message, and *before* the actor stops.
59
+ """
60
+ log.debug("Stopping MQTT frontend: %s", self)
61
+ self.mqtt.stop()
62
+
63
+ def on_failure(self, exception_type, exception_value, traceback):
64
+ """
65
+ Hook for doing any cleanup *after* an unhandled exception is raised,
66
+ and *before* the actor stops.
67
+ """
68
+ log.error("MQTT frontend failed: %s", exception_value)
69
+
70
+ @property
71
+ def volume(self):
72
+ return self.core.mixer.get_volume().get()
73
+
74
+ @volume.setter
75
+ def volume(self, value):
76
+ # Normalize.
77
+ value = min(value, VOLUME_MAX)
78
+ value = max(value, VOLUME_MIN)
79
+ self.core.mixer.set_volume(value).get()
80
+
81
+ @property
82
+ def current_state(self):
83
+ return self.core.playback.get_state().get()
84
+
85
+ def tracklist_changed(self):
86
+ # reports any change to the current tracklist
87
+ # and triggers trklist
88
+ log.debug("MQTT tracklist changed")
89
+ #
90
+ # get list of all tracks in the queue
91
+ #
92
+ tk_list = self.core.tracklist.get_tracks().get()
93
+ tracks = []
94
+ item = {}
95
+ for a in tk_list:
96
+ name = a.name or ""
97
+ if a.artists:
98
+ artist = next(iter(a.artists)).name or ""
99
+ item_name = "{} - {}".format(artist, name) if artist else name
100
+ else:
101
+ item_name = name
102
+ item = {"name": item_name, "uri": a.uri}
103
+ tracks.append(item)
104
+ self.mqtt.publish("trklist", json.dumps(tracks))
105
+ log.debug("Generated tracklist list")
106
+
107
+ def playback_state_changed(self, old_state, new_state):
108
+ """
109
+ old_state (mopidy.core.PlaybackState) - the state before the change.
110
+ new_state (mopidy.core.PlaybackState) - the state after the change.
111
+ """
112
+ log.debug("MQTT playback state changed: %s", new_state)
113
+ self.mqtt.publish("plstate", new_state)
114
+
115
+ def track_playback_started(self, tl_track):
116
+ """
117
+ tl_track (mopidy.models.TlTrack) - the track that just started playing.
118
+ """
119
+ log.debug("MQTT track started: %s", tl_track.track)
120
+ self.mqtt.publish("trk", describe_track(tl_track.track))
121
+
122
+ # get track's uri
123
+ self.mqtt.publish("trk_uri", tl_track.track.uri)
124
+
125
+ # get track's artwork (if any)
126
+ self.mqtt.publish("artw", get_track_artwork(self, tl_track.track))
127
+
128
+ # get track playing indexes
129
+ curr = self.core.tracklist.index().get()
130
+ last = self.core.tracklist.get_length().get()
131
+ pl_index = {}
132
+ pl_index["current"] = curr + 1 if curr is not None else None
133
+ pl_index["last"] = last
134
+ pl_index = json.dumps(pl_index)
135
+ self.mqtt.publish("trk-index", pl_index)
136
+
137
+ def track_playback_ended(self, tl_track, time_position):
138
+ """
139
+ tl_track (mopidy.models.TlTrack) - the track that was played before
140
+ playback stopped.
141
+ time_position (int) - the time position in milliseconds.
142
+ """
143
+ log.debug("MQTT track ended: %s", tl_track.track.name)
144
+ self.mqtt.publish("trk", "")
145
+
146
+ def volume_changed(self, volume):
147
+ """
148
+ volume (int) - the new volume in the range [0..100].
149
+ """
150
+ log.debug("MQTT volume changed: %s", volume)
151
+ self.mqtt.publish("vol", str(volume))
152
+
153
+ def stream_title_changed(self, title):
154
+ """
155
+ title (string) - the new stream title.
156
+ """
157
+ log.debug("MQTT title changed: %s", title)
158
+ self.mqtt.publish("trk", describe_stream(title))
159
+
160
+ def playlists_loaded(self):
161
+ log.debug("Playlists loaded event")
162
+ self.mqtt.publish("refreshed", "")
163
+
164
+ def on_action_plb(self, value):
165
+ """Playback control."""
166
+ if value == "play":
167
+ return self.core.playback.play().get()
168
+ if value == "stop":
169
+ return self.core.playback.stop().get()
170
+ if value == "pause":
171
+ return self.core.playback.pause().get()
172
+ if value == "resume":
173
+ return self.core.playback.resume().get()
174
+
175
+ if value == "toggle":
176
+ if self.current_state == PlaybackState.PLAYING:
177
+ return self.core.playback.pause().get()
178
+ if self.current_state == PlaybackState.PAUSED:
179
+ return self.core.playback.resume().get()
180
+ if self.current_state == PlaybackState.STOPPED:
181
+ return self.core.playback.play().get()
182
+
183
+ if value == "prev":
184
+ return self.core.playback.previous().get()
185
+ if value == "next":
186
+ return self.core.playback.next().get()
187
+
188
+ log.warning("Unknown playback control action: %s", value)
189
+
190
+ def on_action_vol(self, value):
191
+ """Volume control."""
192
+ if not value or len(value) < 2:
193
+ return log.warning("Invalid volume control parameter: %s", value)
194
+
195
+ operator = value[0]
196
+ try:
197
+ amount = int(value[1:])
198
+ except ValueError:
199
+ return log.warning("Invalid volume setting value: %s", value[1:])
200
+
201
+ # Exact volume.
202
+ if operator == "=":
203
+ self.volume = amount
204
+ return
205
+ # Volume down.
206
+ if operator == "-":
207
+ self.volume -= amount
208
+ return
209
+ # Volume up.
210
+ if operator == "+":
211
+ self.volume += amount
212
+ return
213
+
214
+ log.warning("Unknown volume control operator: %s", operator)
215
+
216
+ def on_action_add(self, value):
217
+ """Append URI to queue (tracklist)."""
218
+ if not value:
219
+ return log.warning("Cannot add empty track to queue")
220
+
221
+ track = []
222
+ track.append(value)
223
+ self.core.tracklist.add(uris=track).get()
224
+ log.debug("Added track: %s", value)
225
+
226
+ def _resolve_tracks(self, uris):
227
+ if not uris:
228
+ return None
229
+
230
+ # Mopidy 3 and 4 implement tracklist.add(uris=...) using this same
231
+ # lookup and flattening each URI in order. Iterating the original URI
232
+ # list here also preserves duplicate playlist entries.
233
+ lookup = self.core.library.lookup(uris=uris).get()
234
+ if not lookup:
235
+ return None
236
+
237
+ tracks = []
238
+ for uri in uris:
239
+ uri_tracks = lookup.get(uri)
240
+ if not uri_tracks:
241
+ return None
242
+ tracks.extend(uri_tracks)
243
+
244
+ return tracks
245
+
246
+ def _restore_tracklist(self, tracks):
247
+ try:
248
+ self.core.tracklist.clear().get()
249
+ if tracks:
250
+ self.core.tracklist.add(tracks=tracks).get()
251
+ except Exception:
252
+ log.exception("Failed to restore previous tracklist")
253
+ return False
254
+
255
+ return True
256
+
257
+ def _replace_tracklist(self, tracks, shuffle=False):
258
+ try:
259
+ previous_version = self.core.tracklist.get_version().get()
260
+ previous_tracks = self.core.tracklist.get_tracks().get()
261
+ if self.core.tracklist.get_version().get() != previous_version:
262
+ log.warning("Tracklist changed while preparing replacement")
263
+ return False
264
+ except Exception:
265
+ log.exception("Cannot snapshot the current tracklist")
266
+ return False
267
+
268
+ try:
269
+ self.core.tracklist.clear().get()
270
+ self.core.tracklist.add(tracks=tracks).get()
271
+ if shuffle:
272
+ self.core.tracklist.shuffle().get()
273
+ self.core.playback.play().get()
274
+ except Exception:
275
+ log.exception("Failed to replace tracklist; restoring previous queue")
276
+ self._restore_tracklist(previous_tracks)
277
+ return False
278
+
279
+ return True
280
+
281
+ def on_action_pstream(self, value):
282
+ """Load and start a radio stream or a single track (tracklist)."""
283
+ if not value:
284
+ return log.warning("Cannot load empty track to queue")
285
+
286
+ try:
287
+ tracks = self._resolve_tracks([value])
288
+ except Exception:
289
+ log.exception("Failed to validate stream: %s", value)
290
+ return
291
+
292
+ if not tracks:
293
+ log.info("Invalid stream: %s", value)
294
+ return
295
+
296
+ if self._replace_tracklist(tracks):
297
+ log.debug("Started track: %s", value)
298
+
299
+ def _get_playlist_tracks(self, uri):
300
+ items = self.core.playlists.get_items(uri).get()
301
+ if not items:
302
+ return None
303
+
304
+ uris = []
305
+ for item in items:
306
+ item_uri = getattr(item, "uri", None)
307
+ if not item_uri:
308
+ return None
309
+ uris.append(item_uri)
310
+
311
+ return self._resolve_tracks(uris)
312
+
313
+ def on_action_pload(self, value):
314
+ """Replace current queue with playlist from URI."""
315
+ if not value:
316
+ return log.warning("Cannot load unnamed playlist")
317
+
318
+ try:
319
+ tracks = self._get_playlist_tracks(value)
320
+ except Exception:
321
+ log.exception("Failed to validate playlist: %s", value)
322
+ return
323
+
324
+ if not tracks:
325
+ log.info("Invalid playlist: %s", value)
326
+ return
327
+
328
+ if self._replace_tracklist(tracks):
329
+ log.debug("Started Playlist: %s", value)
330
+
331
+ def on_action_ploadshfl(self, value):
332
+ # Replace current queue with shuffled playlist from URI.
333
+ if not value:
334
+ return log.warning("Cannot load unnamed playlist")
335
+
336
+ try:
337
+ tracks = self._get_playlist_tracks(value)
338
+ except Exception:
339
+ log.exception("Failed to validate playlist: %s", value)
340
+ return
341
+
342
+ if not tracks:
343
+ log.info("Invalid playlist: %s", value)
344
+ return
345
+
346
+ if self._replace_tracklist(tracks, shuffle=True):
347
+ log.debug("Started shuffled Playlist: %s", value)
348
+
349
+ def on_action_clr(self, value):
350
+ """Clear the queue (tracklist)."""
351
+ return self.core.tracklist.clear().get()
352
+
353
+ def on_action_plist(self, value):
354
+ # Request a list of all playlist
355
+ plist = self.core.playlists.as_list()
356
+ playlists = []
357
+ item = {}
358
+ for a in plist.get():
359
+ item = {"name": a.name, "uri": a.uri}
360
+ playlists.append(item)
361
+ self.mqtt.publish("plists", json.dumps(playlists))
362
+ log.debug("Generated playlist list")
363
+
364
+ def on_action_plrefresh(self, value):
365
+ # refresh a single playlist or all
366
+ # value = uri_scheme, if value=None, all playlists are refreshed
367
+ if value:
368
+ self.core.playlists.refresh(uri_scheme=value).get()
369
+ log.debug("Refreshed playlists with uri_scheme: %s", value)
370
+ else:
371
+ self.core.playlists.refresh().get()
372
+ log.debug("Refreshed all playlists")
373
+
374
+ def on_action_chgtrk(self, value):
375
+ # change current playing track in the queue
376
+ if not value:
377
+ return log.info("chgtrk: Cannot change track to empty uri")
378
+
379
+ flt = self.core.tracklist.filter(criteria={"uri": [value]}).get()
380
+ if not flt:
381
+ return log.info("chgtrk: Invalid track")
382
+ tl_track = flt[0]
383
+ self.core.playback.play(tlid=tl_track.tlid).get()
384
+ log.debug("Changed track to tlid: %s", tl_track.tlid)
385
+
386
+ def on_action_queryschemes(self, value):
387
+ # request uri_schemes handled by search
388
+ schemes = self.core.get_uri_schemes().get()
389
+ log.debug("Uri_schemes handled by search: %s", schemes)
390
+ self.mqtt.publish("uri_schemes", json.dumps(schemes))
391
+
392
+ def on_action_search(self, value):
393
+ if not value:
394
+ return log.info("search: Cannot search empty strings")
395
+ value = json.loads(value)
396
+ lookup_str = value["search"]
397
+ lookup_uris = value["uri_schemes"]
398
+ query = {"any": lookup_str}
399
+ results = self.core.library.search(query=query, uris=lookup_uris).get()
400
+ if isinstance(results, SearchResult):
401
+ search_result = results
402
+ else:
403
+ search_result = next(iter(results or ()), None)
404
+
405
+ tracks = getattr(search_result, "tracks", ()) or ()
406
+ found = len(tracks)
407
+ item = {}
408
+ final_list = []
409
+ for k in tracks:
410
+ item = {"name": k.name, "uri": k.uri}
411
+ final_list.append(item)
412
+ log.debug(
413
+ "Search for %s in %s ended. Found %d tracks", lookup_str, lookup_uris, found
414
+ )
415
+ self.mqtt.publish("search_results", json.dumps(final_list))
mopiqtt/mqtt.py ADDED
@@ -0,0 +1,119 @@
1
+ import logging
2
+ import random
3
+
4
+ from paho.mqtt import client as mqtt
5
+ from paho.mqtt.enums import CallbackAPIVersion
6
+
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+
11
+ HANDLER_PREFIX = "on_action_"
12
+
13
+
14
+ class Comms:
15
+ def __init__(
16
+ self,
17
+ frontend,
18
+ host="localhost",
19
+ port=1883,
20
+ topic="mopidy",
21
+ username=None,
22
+ password=None,
23
+ **kwargs
24
+ ):
25
+ """
26
+ Configure MQTT communication client.
27
+ frontend (MopiqttFrontend): Instance of extension's frontend.
28
+ """
29
+ self.frontend = frontend
30
+ self.host = host
31
+ self.port = port
32
+ self.topic = topic
33
+ self.user = username
34
+ self.password = password
35
+
36
+ self.client = mqtt.Client(
37
+ callback_api_version=CallbackAPIVersion.VERSION2,
38
+ client_id="mopidy-{}".format(random.randint(1000, 9999)),
39
+ )
40
+ self.client.on_connect = self._on_connect
41
+ self.client.on_message = self._on_message
42
+
43
+ def start(self):
44
+ """
45
+ Attempt connection to MQTT broker and initialise network loop.
46
+ """
47
+ if self.user and self.password:
48
+ self.client.username_pw_set(username=self.user, password=self.password)
49
+
50
+ self.client.connect_async(host=self.host, port=self.port)
51
+ log.debug("Connecting to MQTT broker at %s:%s", self.host, self.port)
52
+ self.client.loop_start()
53
+ log.debug("Started MQTT communication loop.")
54
+
55
+ def stop(self):
56
+ """
57
+ Clean up and disconnect from MQTT broker.
58
+ """
59
+ self.client.disconnect()
60
+ log.debug("Disconnected from MQTT broker")
61
+
62
+ def _on_connect(self, client, userdata, flags, rc, properties):
63
+ if rc != 0:
64
+ log.error("Failed to connect to MQTT broker, result: %s", rc)
65
+ return
66
+
67
+ log.info("Successfully connected to MQTT broker, result :%s", rc)
68
+
69
+ for name in dir(self.frontend):
70
+ if not name.startswith(HANDLER_PREFIX):
71
+ continue
72
+
73
+ suffix = name[len(HANDLER_PREFIX) :]
74
+ full_topic = "{}/cmnd/{}".format(self.topic, suffix)
75
+ result, _ = self.client.subscribe(full_topic)
76
+
77
+ if result == mqtt.MQTT_ERR_SUCCESS:
78
+ log.debug("Subscribed to MQTT topic: %s", full_topic)
79
+ else:
80
+ log.warning(
81
+ "Failed to subscribe to MQTT topic: %s, result: %s",
82
+ full_topic,
83
+ result,
84
+ )
85
+
86
+ def _on_message(self, client, userdata, message):
87
+ topic = "<unknown>"
88
+ payload = None
89
+
90
+ try:
91
+ topic = message.topic
92
+ action = topic.split("/")[-1]
93
+
94
+ handler = getattr(self.frontend, HANDLER_PREFIX + action, None)
95
+ if not handler:
96
+ log.warning("Cannot handle MQTT messages on topic: %s", topic)
97
+ return
98
+
99
+ payload = message.payload.decode("utf8")
100
+ log.debug(
101
+ "Passing payload: %s to MQTT handler: %s",
102
+ message.payload,
103
+ handler.__name__,
104
+ )
105
+ handler(value=payload)
106
+ except Exception:
107
+ if payload is None:
108
+ payload = getattr(message, "payload", None)
109
+ log.exception(
110
+ "Failed to process MQTT message: topic=%s payload=%r",
111
+ topic,
112
+ payload,
113
+ )
114
+
115
+ def publish(self, subtopic, value):
116
+ full_topic = "{}/stat/{}".format(self.topic, subtopic)
117
+
118
+ log.debug("Publishing: %s to MQTT topic: %s", value, full_topic)
119
+ return self.client.publish(topic=full_topic, payload=value)
mopiqtt/utils.py ADDED
@@ -0,0 +1,74 @@
1
+ import logging
2
+
3
+
4
+ log = logging.getLogger(__name__)
5
+
6
+
7
+ UNKNOWN = ""
8
+
9
+
10
+ def describe_track(track):
11
+ """
12
+ Prepare a short human-readable Track description.
13
+
14
+ track (mopidy.models.Track): Track to source song data from.
15
+ """
16
+ title = track.name or UNKNOWN
17
+
18
+ # Simple/regular case: normal song (e.g. from Spotify).
19
+ if track.artists:
20
+ artist = next(iter(track.artists)).name
21
+ elif track.album and track.album.artists: # Album-only artist case.
22
+ artist = next(iter(track.album.artists)).name
23
+ else:
24
+ artist = UNKNOWN
25
+
26
+ if track.album and track.album.name:
27
+ album = track.album.name
28
+ else:
29
+ album = UNKNOWN
30
+
31
+ return ";".join([title, artist, album])
32
+
33
+
34
+ def describe_stream(raw_title):
35
+ """
36
+ Attempt to parse given stream title in very rudimentary way.
37
+ """
38
+ title = UNKNOWN
39
+ artist = UNKNOWN
40
+ album = UNKNOWN
41
+
42
+ # Very common separator.
43
+ if "-" in raw_title:
44
+ parts = raw_title.split("-")
45
+ artist = parts[0].strip()
46
+ title = parts[1].strip()
47
+ else:
48
+ # Just assume we only have track title.
49
+ title = raw_title
50
+
51
+ return ";".join([title, artist, album])
52
+
53
+
54
+ def get_track_artwork(self, track):
55
+ track_uri = getattr(track, "uri", None)
56
+ if not track_uri:
57
+ return self.defaultImage
58
+
59
+ try:
60
+ artwork = self.core.library.get_images([track_uri]).get()
61
+ images = (artwork or {}).get(track_uri, ())
62
+ if not images:
63
+ return self.defaultImage
64
+
65
+ image_uri = getattr(images[0], "uri", None)
66
+ if not image_uri:
67
+ return self.defaultImage
68
+ if image_uri == "/local" or image_uri.startswith("/local/"):
69
+ return self.defaultImage
70
+
71
+ return image_uri
72
+ except Exception:
73
+ log.exception("Failed to get artwork for track URI: %s", track_uri)
74
+ return self.defaultImage
@@ -0,0 +1,189 @@
1
+ Metadata-Version: 2.4
2
+ Name: Mopiqtt
3
+ Version: 2.0.0
4
+ Summary: Control mopidy music server through MQTT broker
5
+ Home-page: https://github.com/fmarzocca/mopiqtt
6
+ Author: Fabio Marzocca
7
+ Author-email: marzoccafabio@gmail.com
8
+ License: Apache License, Version 2.0
9
+ Classifier: Development Status :: 5 - Production/Stable
10
+ Classifier: Intended Audience :: End Users/Desktop
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Players
19
+ Classifier: Environment :: No Input/Output (Daemon)
20
+ Requires-Python: >=3.13
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: Mopidy<5,>=3.4
24
+ Requires-Dist: paho-mqtt>=2.0
25
+ Requires-Dist: Pykka>=2.0
26
+ Dynamic: author
27
+ Dynamic: author-email
28
+ Dynamic: classifier
29
+ Dynamic: description
30
+ Dynamic: description-content-type
31
+ Dynamic: home-page
32
+ Dynamic: license
33
+ Dynamic: license-file
34
+ Dynamic: requires-dist
35
+ Dynamic: requires-python
36
+ Dynamic: summary
37
+
38
+ Based on [mopidy-mqtt](https://github.com/odiroot/mopidy-mqtt)
39
+
40
+ # Mopiqtt
41
+ [![CI](https://github.com/fmarzocca/Mopiqtt/actions/workflows/ci.yml/badge.svg?branch=Development)](https://github.com/fmarzocca/Mopiqtt/actions/workflows/ci.yml)
42
+
43
+ MQTT interface for Mopidy music server. Allows easy integration with Node Red or any MQTT client.
44
+ 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.
45
+
46
+ # Requirements
47
+
48
+ * Python 3.13 or later
49
+ * Mopidy 3.4.x or Mopidy 4.0.x
50
+ * An MQTT broker
51
+
52
+ Mopidy 4 is recommended for new installations. Mopidy 3.4 compatibility is
53
+ maintained throughout the Mopiqtt 2.x series.
54
+
55
+ # Installation
56
+
57
+ Using pip:
58
+ ```
59
+ [sudo] python3 -m pip install Mopiqtt
60
+ ```
61
+ (If you are running Mopidy as a service, use `sudo`)
62
+
63
+ # Configuration
64
+
65
+ Add the following section to your mopidy's configuration file: `/etc/mopidy/mopidy.conf`
66
+
67
+
68
+ ```
69
+ [mopiqtt]
70
+ host = <mqtt broker address>
71
+ port = 1883
72
+ topic = mopidy
73
+ username =
74
+ password =
75
+ ```
76
+
77
+ *Note*: Remember to also supply `username` and `password` options if your
78
+ MQTT broker requires authentication. If not, just leave blank the two values.
79
+
80
+ *Note*: Restart Mopidy with `sudo service mopidy restart`
81
+
82
+ To check Mopidy log run `sudo tail -f /var/log/mopidy/mopidy.log`
83
+
84
+ ### Mopidy 3 compatibility
85
+
86
+ Mopidy 3 imports `pkg_resources` at runtime. If you use Mopidy 3, keep
87
+ `setuptools` below version 82, since version 82 removed `pkg_resources`.
88
+ Mopidy 4 does not require this constraint.
89
+
90
+ Mopidy 3 compatibility is maintained in the 2.x series and may be removed in a
91
+ future major release. New installations should use Mopidy 4.
92
+
93
+ # Features
94
+
95
+ * Sends information about Mopidy state on any change
96
+ - Playback status
97
+ - Volume
98
+ - Track description
99
+ - Playlists list
100
+ - Artwork
101
+ - Track index
102
+ * Reacts to control commands
103
+ - Playback control
104
+ - Tracklist control
105
+ - Volume control
106
+ - Load & play a playlist (straight or shuffle)
107
+ - Request playlists list
108
+ - Refresh playlists
109
+ - Get tracklist
110
+ - Search for artist/album/track name
111
+
112
+
113
+ # MQTT protocol
114
+
115
+ ## Topics
116
+
117
+ Default top level topic: `mopidy`.
118
+
119
+ Control topic: `mopidy/cmnd`.
120
+
121
+ Information topic `mopidy/stat`.
122
+
123
+ ## Messages to subscribe to (mopidy/stat/`<msg>`)
124
+
125
+ | | Subtopic | Values |
126
+ |:-------------:|:---------:|:-----------------------------------------:|
127
+ | Playback State| `/plstate` | `paused` / `stopped` / `playing` |
128
+ | Volume | `/vol` | `<level(int)>` |
129
+ | Current track | `/trk` | `<title(str)>;<artist(str)>;<album(str)>` or ` ` |
130
+ | List of playlists | `/plists` | `<array of playlists name:uri>` |
131
+ | Track Artwork (*)| `/artw` | `<url of image to download>` |
132
+ | Track uri (*)| `/trk_uri` | `<track uri (string)>` |
133
+ | Playing track index (*)| `/trk-index` | ` {current: x, last: y}` |
134
+ | Playlists have been refreshed | `/refreshed` | ` ` |
135
+ | List of tracks in the queue(**) | `/trklist` | `<array of tracks name:uri>` |
136
+ | List of URI schemes Mopidy can handle in search(***) | `/uri_schemes` | `<array of schemes (str)>` |
137
+ | Search results | `/search_results` | `<array of objects {name: .. , uri:...}>` |
138
+
139
+ `(*)` Published after any track started playback. Local artwork is NOT supported
140
+ `(**)` Published after any tracklist change
141
+ `(***)`Published after a request `queryschemes`
142
+
143
+ ## Messages to publish to (mopidy/cmnd/`<msg>`)
144
+
145
+ | | Subtopic | Parameters |
146
+ |:----------------:|:--------:|:-----------------------------------------------------------------:|
147
+ | Playback control | `/plb` | `play` / `stop` / `pause` / `resume` / `toggle` / `prev` / `next` |
148
+ | Volume control | `/vol` | `=<int>` or `-<int>` or `+<int>` |
149
+ | Clear queue | `/clr` | ` ` |
150
+ | Add to queue | `/add` | `<uri(str)>` |
151
+ | Load and play playlist (straight) | `/pload` | `<uri(str)>` |
152
+ | Load and play playlist (shuffle) | `/ploadshfl` | `<uri(str)>` |
153
+ | Request list of playlists| `/plist` | ` ` |
154
+ | Load and play a radio stream (or a single track) | `/pstream`| `<uri(str)>` |
155
+ | Refresh one or all playlists(*)| `/plrefresh` | `<uri_scheme>` or `None` |
156
+ | Change current playing track(**)| `/chgtrk` | `<uri(str)>` |
157
+ | Query URI schemes Mopidy can handle in search | `/queryschemes` | ` ` |
158
+ | Search for any string (artist, track, album) | `/search` | `<JSON {"search": [list of strings], "uri_schemes": [list of schemes]}>` |
159
+
160
+
161
+ `(*)` 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.
162
+ `(**)` Note that the track must already be in the tracklist.
163
+
164
+ Playlist and stream load commands validate every URI before changing the queue. If
165
+ loading fails after the replacement starts, Mopiqtt attempts to restore the previous
166
+ queue using Mopidy's public tracklist operations. This rollback is best-effort and
167
+ only covers queue contents; it does not restore the current track, TLID, playback
168
+ position, or playback state.
169
+
170
+
171
+ # Contribute
172
+
173
+ You can contribute to Mopiqtt by:
174
+
175
+ [![paypal](https://img.shields.io/badge/donate-paypal-blue.svg?style=flat-square)](https://www.paypal.com/donate/?hosted_button_id=NQHVVDCNK3UDL)
176
+
177
+ # Credits
178
+ - Current maintainer: [fmarzocca](https://github.com/fmarzocca)
179
+
180
+ Based on previous works of:
181
+ - [odiroot](https://github.com/odiroot)
182
+ - [magcode](https://github.com/magcode>)
183
+
184
+ # Project resources
185
+ [![Downloads](https://pepy.tech/badge/mopiqtt)](https://pepy.tech/project/mopiqtt)
186
+
187
+ - [Source code](<https://github.com/fmarzocca/mopiqtt>)
188
+ - [Issue tracker](<https://github.com/fmarzocca/mopiqtt/issues>)
189
+ - [Changelog](<https://github.com/fmarzocca/mopiqtt/blob/main/CHANGELOG.md>)
@@ -0,0 +1,11 @@
1
+ mopiqtt/__init__.py,sha256=1nU0N2oz04WA6BJa_-iIUC8t84MiT9PFRDILEsJKZ9I,963
2
+ mopiqtt/ext.conf,sha256=sX7ejcS6-Whl5RSpoRprEZstRQC7Q5vcPExUfFSo87c,98
3
+ mopiqtt/frontend.py,sha256=0mPcDB4gIJ6SO0GGaeMmovSXoEp_OWE7mSo1jRzpjaI,14002
4
+ mopiqtt/mqtt.py,sha256=nhx_BedXpez6p70bgQrXWWrillOHL_MIAKSO-iCllGQ,3671
5
+ mopiqtt/utils.py,sha256=I4VDMKH_ddmrrG3aR-0Bl8cYapnj0aibBrWqv_TOV6o,1880
6
+ mopiqtt-2.0.0.dist-info/licenses/LICENSE,sha256=UOZ1F5fFDe3XXvG4oNnkL1-Ecun7zpHzRxjp-XsMeAo,11324
7
+ mopiqtt-2.0.0.dist-info/METADATA,sha256=R2TutOYng9sKD5zU9I0Q-SyrGKcyNW1R4VZa2kfzUpE,7641
8
+ mopiqtt-2.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
9
+ mopiqtt-2.0.0.dist-info/entry_points.txt,sha256=-BJFFSMBNL7I7NYM4-TWX3_PGuzfJE3AVR_xAv-E1-Q,41
10
+ mopiqtt-2.0.0.dist-info/top_level.txt,sha256=JOydIglHbWPv_XZnlDM8eKwqPDRaKKqg6A9-OxYVeH8,8
11
+ mopiqtt-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [mopidy.ext]
2
+ mopiqtt = mopiqtt:Extension
@@ -0,0 +1,201 @@
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.
@@ -0,0 +1 @@
1
+ mopiqtt