#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # This file is part of snapcast # Copyright (C) 2014-2022 Johannes Pohl # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # Dependencies: # - websocket-client from time import sleep import websocket import logging import threading import json import sys import getopt import logging __version__ = "@version@" __git_version__ = "@gitversion@" identity = "Snapcast" params = { 'progname': sys.argv[0], # Connection 'mopidy-host': None, 'mopidy-port': None, 'snapcast-host': None, 'snapcast-port': None, 'stream': None, } defaults = { # Connection 'mopidy-host': 'localhost', 'mopidy-port': 6680, 'snapcast-host': 'localhost', 'snapcast-port': 1780, 'stream': 'default', } def send(json_msg): sys.stdout.write(json.dumps(json_msg)) sys.stdout.write('\n') sys.stdout.flush() class MopidyControl(object): """ Mopidy websocket remote control """ def __init__(self, params): self._params = params self._metadata = {} self._properties = {} self._req_id = 0 self._mopidy_request_map = {} self._seek_offset = 0.0 self.websocket = websocket.WebSocketApp("ws://" + self._params['mopidy-host'] + ":" + str(self._params['mopidy-port']) + "/mopidy/ws", on_message=self.on_ws_message, on_error=self.on_ws_error, on_open=self.on_ws_open, on_close=self.on_ws_close) self.websocket_thread = threading.Thread( target=self.websocket_loop, args=()) self.websocket_thread.name = "MopidyControl" self.websocket_thread.start() def websocket_loop(self): logger.info("Started MopidyControl loop") while True: self.websocket.run_forever() sleep(1) logger.info("Ending MopidyControl loop") def extractImageUrl(self, track_uri, jmsg): url = None if jmsg and track_uri in jmsg and jmsg[track_uri]: url = jmsg[track_uri][0]['uri'] if url.find('://') == -1: url = str( f"http://{self._params['mopidy-host']}:{self._params['mopidy-port']}{url}") logger.debug(f"Image: {url}") return url def onGetImageResponse(self, result): if 'metadata' in self._properties: # => {'id': 25, 'jsonrpc': '2.0', 'method': 'core.library.get_images', 'params': {'uris': ['local:track:A/ABBA/ABBA%20-%20Voyage%20%282021%29/10%20-%20Ode%20to%20Freedom.ogg']}} # <= {"jsonrpc": "2.0", "id": 25, "result": {"local:track:A/ABBA/ABBA%20-%20Voyage%20%282021%29/10%20-%20Ode%20to%20Freedom.ogg": [{"__model__": "Image", "uri": "/local/f0b20b441175563334f6ad75e76426b7-500x500.jpeg", "width": 500, "height": 500}]}} meta = self._properties['metadata'] if 'url' in meta: url = self.extractImageUrl(meta['url'], result) if not url is None: self._properties['metadata']['artUrl'] = url logger.info(f'New properties: {self._properties}') return send({"jsonrpc": "2.0", "method": "Plugin.Stream.Player.Properties", "params": self._properties}) def getMetaData(self, track): metadata = {} if track is None: return metadata if 'uri' in track: metadata['url'] = track['uri'] if 'name' in track: metadata['title'] = track['name'] if 'artists' in track: for artist in track['artists']: if 'musicbrainz_id' in artist: metadata['musicbrainzArtistId'] = artist['musicbrainz_id'] if 'name' in artist: if not 'artist' in metadata: metadata['artist'] = [] metadata['artist'].append( artist['name']) if 'sortname' in artist: if not 'sortname' in metadata: metadata['artistsort'] = [] metadata['artistsort'].append( artist['sortname']) if 'album' in track: album = track['album'] if 'musicbrainz_id' in album: self._metadata['musicbrainzAlbumId'] = album['musicbrainz_id'] if 'name' in album: self._metadata['album'] = album['name'] if 'genre' in track: metadata['genre'] = [track['genre']] if 'track_no' in track: metadata['trackNumber'] = track['track_no'] metadata['trackId'] = str(track['track_no']) if 'disc_no' in track: metadata['discNumber'] = track['disc_no'] if 'date' in track: metadata['contentCreated'] = track['date'] if 'length' in track: metadata['duration'] = float( track['length']) / 1000 if 'comment' in track: metadata['comment'] = [track['comment']] if 'musicbrainz_id' in track: metadata['musicbrainzTrackId'] = track['musicbrainz_id'] # Not supported: # if 'composers' in result: # if 'performers' in result: # if 'bitrate' in result: # if 'last_modified' in result: return metadata def getProperties(self, req_res): properties = {} repeat = False single = False for rr in req_res: request = rr[0] result = rr[1] logger.debug( f'getProperties request: {request}, result: {result}') if request == 'core.playback.get_stream_title': if not result is None and not self._metadata is None: self._metadata['title'] = result elif request == 'core.playback.get_state': properties['playbackStatus'] = str(result) elif request == 'core.tracklist.get_repeat': repeat = result elif request == 'core.tracklist.get_single': single = result elif request == 'core.tracklist.get_random': properties['shuffle'] = result elif request == 'core.mixer.get_volume': properties['volume'] = result elif request == 'core.mixer.get_mute': properties['mute'] = result elif request == 'core.playback.get_time_position': properties['position'] = float(result) / 1000 elif request == 'core.playback.get_current_track': self._metadata = self.getMetaData(result) elif request == 'core.library.get_images': metadata = self._metadata if not metadata is None and 'url' in metadata: url = self.extractImageUrl(metadata['url'], result) if not url is None: self._metadata['artUrl'] = url if repeat and single: properties['loopStatus'] = 'track' elif repeat: properties['loopStatus'] = 'playlist' else: properties['loopStatus'] = 'none' properties['canGoNext'] = True properties['canGoPrevious'] = True properties['canPlay'] = True properties['canPause'] = True properties['canSeek'] = 'duration' in self._metadata properties['canControl'] = True if self._metadata: properties['metadata'] = self._metadata return properties def onGetTrackResponse(self, req_id, track): self._metadata = self.getMetaData(track) batch_req = [("core.playback.get_stream_title", None), ("core.playback.get_state", None), ("core.tracklist.get_repeat", None), ("core.tracklist.get_single", None), ("core.tracklist.get_random", None), ("core.mixer.get_volume", None), ("core.playback.get_time_position", None)] if 'url' in self._metadata: batch_req.append(('core.library.get_images', { 'uris': [self._metadata['url']]})) self.send_batch_request( batch_req, lambda req_res: self.onSnapcastPropertiesResponse(req_id, req_res)) def onSnapcastPropertiesResponse(self, req_id, req_res): logger.debug(f'onSnapcastPropertiesRequest id: {req_id}') self._properties = self.getProperties(req_res) logger.info(f'New properties: {self._properties}') send({"jsonrpc": "2.0", "id": req_id, "result": self._properties}) def onPropertiesResponse(self, req_res): self._properties = self.getProperties(req_res) logger.info(f'New properties: {self._properties}') send({"jsonrpc": "2.0", "method": "Plugin.Stream.Player.Properties", "params": self._properties}) def onGetTimePositionResponse(self, result): if self._seek_offset != 0: pos = int(int(result) + self._seek_offset * 1000) logger.debug(f"Seeking to: {pos}") self.send_request("core.playback.seek", { "time_position": pos}) self._seek_offset = 0.0 def on_ws_message(self, ws, message): # TODO: error handling logger.debug(f'Snapcast RPC websocket message received: {message}') jmsg = json.loads(message) # Batch request if isinstance(jmsg, list): self._properties = {} req_res = [] callback = None for msg in jmsg: id = msg['id'] if id in self._mopidy_request_map: request = self._mopidy_request_map[id] del self._mopidy_request_map[id] req_res.append((request[0], msg['result'])) if not request[1] is None: callback = request[1] if not callback is None: callback(req_res) # Request elif 'id' in jmsg: id = jmsg['id'] if id in self._mopidy_request_map: request = self._mopidy_request_map[id] del self._mopidy_request_map[id] logger.debug(f'Received response to request "{request[0]}"') callback = request[1] if not callback is None: callback(jmsg['result']) # Notification else: if 'event' in jmsg: event = jmsg['event'] logger.info(f"Event: {event}") if event == 'track_playback_started': self._metadata = self.getMetaData( jmsg['tl_track']['track']) logger.debug(f'Meta: {self._metadata}') batch_req = [("core.playback.get_stream_title", None), ("core.playback.get_state", None), ("core.tracklist.get_repeat", None), ("core.tracklist.get_single", None), ("core.tracklist.get_random", None), ("core.mixer.get_volume", None), ("core.mixer.get_mute", None), ("core.playback.get_time_position", None)] if 'url' in self._metadata: batch_req.append(('core.library.get_images', { 'uris': [self._metadata['url']]})) self.send_batch_request( batch_req, self.onPropertiesResponse) elif event in ['tracklist_changed', 'track_playback_ended']: logger.debug("Nothing to do") elif event == 'playback_state_changed' and jmsg["old_state"] == jmsg["new_state"]: logger.debug("Nothing to do") elif event == 'volume_changed' and 'volume' in self._properties and jmsg['volume'] == self._properties['volume']: logger.debug("Nothing to do") else: self.send_batch_request([("core.playback.get_stream_title", None), ("core.playback.get_state", None), ("core.tracklist.get_repeat", None), ("core.tracklist.get_single", None), ("core.tracklist.get_random", None), ("core.mixer.get_volume", None), ("core.mixer.get_mute", None), ("core.playback.get_time_position", None)], self.onPropertiesResponse) def on_ws_error(self, ws, error): logger.error("Snapcast RPC websocket error") logger.error(error) def on_ws_open(self, ws): logger.info("Snapcast RPC websocket opened") send({"jsonrpc": "2.0", "method": "Plugin.Stream.Ready"}) def on_ws_close(self, ws): logger.info("Snapcast RPC websocket closed") def send_request(self, method, params=None, callback=None): j = {"id": self._req_id, "jsonrpc": "2.0", "method": str(method)} if not params is None: j["params"] = params logger.debug(f'send_request: {j}') result = self._req_id self._mopidy_request_map[result] = (str(method), callback) self._req_id += 1 self.websocket.send(json.dumps(j)) return result def send_batch_request(self, methods_params, callback=None): batch = [] result = self._req_id for method_param in methods_params: method = str(method_param[0]) params = method_param[1] j = {"id": self._req_id, "jsonrpc": "2.0", "method": method} if not params is None: j["params"] = params batch.append(j) self._mopidy_request_map[self._req_id] = ( method, callback) self._req_id += 1 logger.debug(f'send_batch_request: {batch}') self.websocket.send(json.dumps(batch)) return result def stop(self): self.websocket.keep_running = False logger.info("Waiting for websocket thread to exit") # self.websocket_thread.join() def control(self, cmd): try: id = None request = json.loads(cmd) id = request['id'] [interface, cmd] = request['method'].rsplit('.', 1) if interface == 'Plugin.Stream.Player': if cmd == 'Control': success = True command = request['params']['command'] params = request['params'].get('params', {}) logger.debug( f'Control command: {command}, params: {params}') if command == 'next': self.send_request("core.playback.next") elif command == 'previous': self.send_request("core.playback.previous") elif command == 'play': self.send_request("core.playback.play") elif command == 'pause': self.send_request("core.playback.pause") elif command == 'playPause': if self._properties['playbackStatus'] == 'playing': self.send_request("core.playback.pause") else: self.send_request("core.playback.play") elif command == 'stop': self.send_request("core.playback.stop") elif command == 'setPosition': position = float(params['position']) self.send_request("core.playback.seek", { "time_position": int(position * 1000)}) elif command == 'seek': self._seek_offset = float(params['offset']) self.send_request( "core.playback.get_time_position", None, self.onGetTimePositionResponse) elif cmd == 'SetProperty': property = request['params'] logger.debug(f'SetProperty: {property}') if 'shuffle' in property: self.send_request("core.tracklist.set_random", { "value": property['shuffle']}) if 'loopStatus' in property: value = property['loopStatus'] if value == "playlist": self.send_request( "core.tracklist.set_single", {"value": False}) self.send_request( "core.tracklist.set_repeat", {"value": True}) elif value == "track": self.send_request( "core.tracklist.set_single", {"value": True}) self.send_request( "core.tracklist.set_repeat", {"value": True}) elif value == "none": self.send_request( "core.tracklist.set_single", {"value": False}) self.send_request( "core.tracklist.set_repeat", {"value": False}) if 'volume' in property: self.send_request("core.mixer.set_volume", { "volume": int(property['volume'])}) if 'mute' in property: self.send_request("core.mixer.set_mute", { "mute": property['mute']}) elif cmd == 'GetProperties': self.send_request("core.playback.get_current_track", None, lambda track: self.onGetTrackResponse(id, track)) return elif cmd == 'GetMetadata': send({"jsonrpc": "2.0", "method": "Plugin.Stream.Log", "params": { "severity": "Info", "message": "Logmessage"}}) return send({"jsonrpc": "2.0", "error": {"code": -32601, "message": "TODO: GetMetadata not yet implemented"}, "id": id}) else: return send({"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": id}) else: return send({"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": id}) send({"jsonrpc": "2.0", "result": "ok", "id": id}) except Exception as e: send({"jsonrpc": "2.0", "error": { "code": -32700, "message": "Parse error", "data": str(e)}, "id": id}) def usage(params): print("""\ Usage: %(progname)s [OPTION]... --mopidy-host=ADDR Set the mopidy server address --mopidy-port=PORT Set the mopidy server port --snapcast-host=ADDR Set the snapcast server address --snapcast-port=PORT Set the snapcast server port --stream=ID Set the stream id -h, --help Show this help message -d, --debug Run in debug mode -v, --version meta_mopidy version Report bugs to https://github.com/badaix/snapcast/issues""" % params) if __name__ == '__main__': log_format_stderr = '%(asctime)s %(module)s %(levelname)s: %(message)s' log_level = logging.INFO # Parse command line try: (opts, args) = getopt.getopt(sys.argv[1:], 'hdv', ['help', 'mopidy-host=', 'mopidy-port=', 'snapcast-host=', 'snapcast-port=', 'stream=', 'debug', 'version']) except getopt.GetoptError as ex: (msg, opt) = ex.args print("%s: %s" % (sys.argv[0], msg), file=sys.stderr) print(file=sys.stderr) usage(params) sys.exit(2) for (opt, arg) in opts: if opt in ['-h', '--help']: usage(params) sys.exit() elif opt in ['--mopidy-host']: params['mopidy-host'] = arg elif opt in ['--mopidy-port']: params['mopidy-port'] = int(arg) elif opt in ['--snapcast-host']: params['snapcast-host'] = arg elif opt in ['--snapcast-port']: params['snapcast-port'] = int(arg) elif opt in ['--stream']: params['stream'] = arg elif opt in ['-d', '--debug']: log_level = logging.DEBUG elif opt in ['-v', '--version']: v = __version__ if __git_version__: v = __git_version__ print("meta_mopidy version %s" % v) sys.exit() if len(args) > 2: usage(params) sys.exit() logger = logging.getLogger('meta_mopidy') logger.propagate = False logger.setLevel(log_level) # Log to stderr log_handler = logging.StreamHandler() log_handler.setFormatter(logging.Formatter(log_format_stderr)) logger.addHandler(log_handler) for p in ['mopidy-host', 'mopidy-port', 'snapcast-host', 'snapcast-port', 'stream']: if not params[p]: params[p] = defaults[p] logger.debug(f'Parameters: {params}') mopidy_ctrl = MopidyControl(params) for line in sys.stdin: mopidy_ctrl.control(line)