diff --git a/server/etc/plug-ins/meta_librespot-java.py b/server/etc/plug-ins/meta_librespot-java.py new file mode 100755 index 00000000..47e214cf --- /dev/null +++ b/server/etc/plug-ins/meta_librespot-java.py @@ -0,0 +1,381 @@ +#!/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 +# - requests + +from time import sleep +import websocket +import logging +import threading +import json +import sys +import getopt +import logging +import requests + +__version__ = "@version@" +__git_version__ = "@gitversion@" + + +identity = "Snapcast" + +params = { + 'progname': sys.argv[0], + # Connection + 'librespot-host': None, + 'librespot-port': None, + 'snapcast-host': None, + 'snapcast-port': None, + 'stream': None, +} + +defaults = { + # Connection + 'librespot-host': 'localhost', + 'librespot-port': 24879, + '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 LibrespotControl(object): + """ Librespot websocket remote control """ + + def __init__(self, params): + self._params = params + + self._metadata = {} + self._properties = {} + self._properties['playbackStatus'] = 'playing' + self._properties['loopStatus'] = 'none' + self._properties['shuffle'] = False + self._properties['volume'] = 100 + self._properties['mute'] = False + self._properties['rate'] = 1.0 + self._properties['position'] = 0 + + self._properties['canGoNext'] = True + self._properties['canGoPrevious'] = True + self._properties['canPlay'] = True + self._properties['canPause'] = True + self._properties['canSeek'] = True + self._properties['canControl'] = True + + self._req_id = 0 + self._librespot_request_map = {} + self._seek_offset = 0.0 + + self.websocket = websocket.WebSocketApp("ws://" + self._params['librespot-host'] + ":" + str(self._params['librespot-port']) + "/events", + 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 = "LibrespotControl" + self.websocket_thread.start() + + def websocket_loop(self): + logger.info("Started LibrespotControl loop") + while True: + self.websocket.run_forever() + sleep(1) + logger.info("Ending LibrespotControl loop") + + def getMetaData(self, data): + data = json.loads(data) + logger.debug(f'getMetaData: {data}') + metadata = {} + if data is None: + return metadata + if 'trackTime' in data: + self._properties['position'] = max( + 0.0, float(data['trackTime']) / 1000.0) + if 'current' in data: + metadata['url'] = data['current'] + if 'track' in data: + track = data['track'] + if 'name' in track: + metadata['title'] = track['name'] + if 'duration' in track: + metadata['duration'] = float( + track['duration']) / 1000 + if 'artist' in track: + metadata['artist'] = [] + artists = track['artist'] + for artist in artists: + metadata['artist'].append( + artist['name']) + + if 'album' in track: + album = track['album'] + if 'name' in album: + metadata['album'] = album['name'] + if 'date' in album: + date = album['date'] + metadata['contentCreated'] = f"{date['year']:04d}-{date['month']:02d}-{date['day']:02d}" + if 'coverGroup' in album and 'image' in album['coverGroup']: + images = album['coverGroup']['image'] + for image in images: + if 'size' in image and image['size'] == 'DEFAULT': + metadata['artUrl'] = f"http://i.scdn.co/image/{str(image['fileId']).lower()}" + + if 'discNumber' in track: + metadata['discNumber'] = track['discNumber'] + if 'number' in track: + metadata['trackNumber'] = track['number'] + metadata['trackId'] = track['gid'] + + return metadata + + 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): + return + + # Request + elif 'id' in jmsg: + return + + # Notification + else: + if 'event' in jmsg: + event = jmsg['event'] + logger.info(f"Event: {event}, msg: {jmsg}") + if event == 'metadataAvailable': + # 'trackChanged': + res = self.send_request('player/current') + if res.status_code / 100 == 2: + self._metadata = self.getMetaData(res.text) + self._properties['metadata'] = self._metadata + send({"jsonrpc": "2.0", "method": "Plugin.Stream.Player.Properties", + "params": self._properties}) + + 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): + request = f"http://{self._params['librespot-host']}:{self._params['librespot-port']}/{method}" + logger.debug(f"Sending request: {request}") + r = requests.post(request, data=params) + logger.info( + f"Status: {r.status_code}, reason: {r.reason}, text: {r.text}") + return r + + 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': + command = request['params']['command'] + params = request['params'].get('params', {}) + logger.debug( + f'Control command: {command}, params: {params}') + if command == 'next': + self.send_request("player/next") + elif command == 'previous': + self.send_request("player/prev") + elif command == 'play': + self.send_request("player/resume") + self._properties['playbackStatus'] = 'playing' + elif command == 'pause': + self.send_request("player/pause") + self._properties['playbackStatus'] = 'paused' + elif command == 'playPause': + self.send_request("player/play-pause") + if self._properties['playbackStatus'] == 'playing': + self._properties['playbackStatus'] = 'paused' + else: + self._properties['playbackStatus'] = 'playing' + elif command == 'stop': + self.send_request("player/pause") + self._properties['playbackStatus'] = 'paused' + elif command == 'setPosition': + position = float(params['position']) + self._properties['position'] = position + self.send_request( + "player/seek", {"pos": int(position * 1000)}) + elif command == 'seek': + self._seek_offset = float(params['offset']) + logger.debug("todo: not implemented") + # 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._properties['shuffle'] = not self._properties['shuffle'] + self.send_request("player/shuffle") + if 'loopStatus' in property: + value = property['loopStatus'] + self._properties['loopStatus'] = value + if value == "playlist": + self.send_request( + "player/repeat", {"val": "context"}) + elif value == "track": + self.send_request( + "player/repeat", {"val": "track"}) + elif value == "none": + self.send_request("player/repeat", {"val": "none"}) + if 'volume' in property: + self.send_request( + "player/set-volume", {"volume": int(float(property['volume']) / 100.0 * 65536.0)}) + if 'mute' in property: + logger.debug("todo: not implemented") + # self._properties['mute'] = False + # self.send_request("core.mixer.set_mute", { + # "mute": property['mute']}) + elif cmd == 'GetProperties': + res = self.send_request('player/current') + # if res.status_code / 100 == 2: + self._metadata = self.getMetaData(res.text) + self._properties['metadata'] = self._metadata + send({"jsonrpc": "2.0", "id": id, + "result": self._properties}) + 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]... + + --librespot-host=ADDR Set the librespot server address + --librespot-port=PORT Set the librespot 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_librespot 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', 'librespot-host=', 'librespot-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 ['--librespot-host']: + params['librespot-host'] = arg + elif opt in ['--librespot-port']: + params['librespot-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_librespot version %s" % v) + sys.exit() + + if len(args) > 2: + usage(params) + sys.exit() + + logger = logging.getLogger('meta_librespot') + 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 ['librespot-host', 'librespot-port', 'snapcast-host', 'snapcast-port', 'stream']: + if not params[p]: + params[p] = defaults[p] + + logger.debug(f'Parameters: {params}') + + librespot_ctrl = LibrespotControl(params) + + for line in sys.stdin: + librespot_ctrl.control(line)