snapcast/server/etc/plug-ins/meta_mopidy.py
2022-07-19 08:04:38 +02:00

522 lines
22 KiB
Python
Executable file

#!/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 <http://www.gnu.org/licenses/>.
# 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)