mirror of
https://github.com/badaix/snapcast.git
synced 2025-05-31 18:06:15 +02:00
Reformat code
This commit is contained in:
parent
48b2840365
commit
baf09b340e
6 changed files with 436 additions and 307 deletions
|
@ -4,61 +4,72 @@ import telnetlib
|
|||
import json
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
print("usage: control.py <SERVER HOST> [setVolume|setName]")
|
||||
sys.exit(0)
|
||||
print("usage: control.py <SERVER HOST> [setVolume|setName]")
|
||||
sys.exit(0)
|
||||
|
||||
telnet = telnetlib.Telnet(sys.argv[1], 1705)
|
||||
requestId = 1
|
||||
|
||||
def doRequest( j, requestId ):
|
||||
print("send: " + j)
|
||||
telnet.write(j + "\r\n")
|
||||
while (True):
|
||||
response = telnet.read_until("\r\n", 2)
|
||||
jResponse = json.loads(response)
|
||||
if 'id' in jResponse:
|
||||
if jResponse['id'] == requestId:
|
||||
print("recv: " + response)
|
||||
return jResponse;
|
||||
|
||||
def doRequest(j, requestId):
|
||||
print("send: " + j)
|
||||
telnet.write(j + "\r\n")
|
||||
while (True):
|
||||
response = telnet.read_until("\r\n", 2)
|
||||
jResponse = json.loads(response)
|
||||
if 'id' in jResponse:
|
||||
if jResponse['id'] == requestId:
|
||||
print("recv: " + response)
|
||||
return jResponse
|
||||
|
||||
|
||||
def setVolume(client, volume):
|
||||
global requestId
|
||||
doRequest(json.dumps({'jsonrpc': '2.0', 'method': 'Client.SetVolume', 'params': {'client': client, 'volume': volume}, 'id': requestId}), requestId)
|
||||
requestId = requestId + 1
|
||||
global requestId
|
||||
doRequest(json.dumps({'jsonrpc': '2.0', 'method': 'Client.SetVolume', 'params': {
|
||||
'client': client, 'volume': volume}, 'id': requestId}), requestId)
|
||||
requestId = requestId + 1
|
||||
|
||||
|
||||
def setName(client, name):
|
||||
global requestId
|
||||
doRequest(json.dumps({'jsonrpc': '2.0', 'method': 'Client.SetName', 'params': {'client': client, 'name': name}, 'id': requestId}), requestId)
|
||||
requestId = requestId + 1
|
||||
global requestId
|
||||
doRequest(json.dumps({'jsonrpc': '2.0', 'method': 'Client.SetName', 'params': {
|
||||
'client': client, 'name': name}, 'id': requestId}), requestId)
|
||||
requestId = requestId + 1
|
||||
|
||||
|
||||
if sys.argv[2] == "setVolume":
|
||||
if len(sys.argv) < 5:
|
||||
print("usage: control.py <SERVER HOST> setVolume <HOSTNAME> [+/-]<VOLUME>")
|
||||
exit(0)
|
||||
volstr = sys.argv[4]
|
||||
j = doRequest(json.dumps({'jsonrpc': '2.0', 'method': 'Server.GetStatus', 'id': 1}), 1)
|
||||
for client in j["result"]["clients"]:
|
||||
if(sys.argv[3] == client['host']['name'] or sys.argv[3] == 'all'):
|
||||
if(volstr[0] == '+'):
|
||||
volume = int(client['config']['volume']['percent']) + int(volstr[1:])
|
||||
elif(volstr[0] == '-'):
|
||||
volume = int(client['config']['volume']['percent']) - int(volstr[1:])
|
||||
else:
|
||||
volume = int(volstr)
|
||||
setVolume(client['host']['mac'], volume)
|
||||
if len(sys.argv) < 5:
|
||||
print(
|
||||
"usage: control.py <SERVER HOST> setVolume <HOSTNAME> [+/-]<VOLUME>")
|
||||
exit(0)
|
||||
volstr = sys.argv[4]
|
||||
j = doRequest(json.dumps(
|
||||
{'jsonrpc': '2.0', 'method': 'Server.GetStatus', 'id': 1}), 1)
|
||||
for client in j["result"]["clients"]:
|
||||
if (sys.argv[3] == client['host']['name'] or sys.argv[3] == 'all'):
|
||||
if (volstr[0] == '+'):
|
||||
volume = int(client['config']['volume']
|
||||
['percent']) + int(volstr[1:])
|
||||
elif (volstr[0] == '-'):
|
||||
volume = int(client['config']['volume']
|
||||
['percent']) - int(volstr[1:])
|
||||
else:
|
||||
volume = int(volstr)
|
||||
setVolume(client['host']['mac'], volume)
|
||||
|
||||
elif sys.argv[2] == "setName":
|
||||
if len(sys.argv) < 5:
|
||||
print("usage: control.py <SERVER HOST> setName <MAC> <NAME>")
|
||||
exit(0)
|
||||
setName(sys.argv[3], sys.argv[4])
|
||||
if len(sys.argv) < 5:
|
||||
print("usage: control.py <SERVER HOST> setName <MAC> <NAME>")
|
||||
exit(0)
|
||||
setName(sys.argv[3], sys.argv[4])
|
||||
|
||||
else:
|
||||
print("unknown command \"" + sys.argv[2] + "\"")
|
||||
print("unknown command \"" + sys.argv[2] + "\"")
|
||||
|
||||
j = doRequest(json.dumps({'jsonrpc': '2.0', 'method': 'Server.GetStatus', 'id': 1}), 1)
|
||||
j = doRequest(json.dumps(
|
||||
{'jsonrpc': '2.0', 'method': 'Server.GetStatus', 'id': 1}), 1)
|
||||
for client in j["result"]["clients"]:
|
||||
print("MAC: " + client['host']['mac'] + ", connect: " + str(client['connected']) + ", volume: " + str(client['config']['volume']['percent']) + ", name: " + client['host']['name'] + ", host: " + client['host']['ip'])
|
||||
print("MAC: " + client['host']['mac'] + ", connect: " + str(client['connected']) + ", volume: " + str(
|
||||
client['config']['volume']['percent']) + ", name: " + client['host']['name'] + ", host: " + client['host']['ip'])
|
||||
|
||||
telnet.close
|
||||
|
||||
|
|
|
@ -6,32 +6,38 @@ import json
|
|||
telnet = telnetlib.Telnet(sys.argv[1], 1705)
|
||||
requestId = 1
|
||||
|
||||
def doRequest( j, requestId ):
|
||||
print("send: " + j)
|
||||
telnet.write(j + "\r\n")
|
||||
while (True):
|
||||
response = telnet.read_until("\r\n", 2)
|
||||
jResponse = json.loads(response)
|
||||
if 'id' in jResponse:
|
||||
if jResponse['id'] == requestId:
|
||||
# print("recv: " + response)
|
||||
return jResponse;
|
||||
|
||||
def doRequest(j, requestId):
|
||||
print("send: " + j)
|
||||
telnet.write(j + "\r\n")
|
||||
while (True):
|
||||
response = telnet.read_until("\r\n", 2)
|
||||
jResponse = json.loads(response)
|
||||
if 'id' in jResponse:
|
||||
if jResponse['id'] == requestId:
|
||||
# print("recv: " + response)
|
||||
return jResponse
|
||||
|
||||
|
||||
def setVolume(client, volume):
|
||||
global requestId
|
||||
doRequest(json.dumps({'jsonrpc': '2.0', 'method': 'Client.SetVolume', 'params': {'id': client, 'volume': {'muted': False, 'percent': volume}}, 'id': requestId}), requestId)
|
||||
requestId = requestId + 1
|
||||
global requestId
|
||||
doRequest(json.dumps({'jsonrpc': '2.0', 'method': 'Client.SetVolume', 'params': {
|
||||
'id': client, 'volume': {'muted': False, 'percent': volume}}, 'id': requestId}), requestId)
|
||||
requestId = requestId + 1
|
||||
|
||||
|
||||
volume = int(sys.argv[2])
|
||||
j = doRequest(json.dumps({'jsonrpc': '2.0', 'method': 'Server.GetStatus', 'id': 1}), 1)
|
||||
j = doRequest(json.dumps(
|
||||
{'jsonrpc': '2.0', 'method': 'Server.GetStatus', 'id': 1}), 1)
|
||||
for group in j["result"]["server"]["groups"]:
|
||||
for client in group["clients"]:
|
||||
setVolume(client['id'], volume)
|
||||
setVolume(client['id'], volume)
|
||||
|
||||
j = doRequest(json.dumps({'jsonrpc': '2.0', 'method': 'Server.GetStatus', 'id': 1}), 1)
|
||||
j = doRequest(json.dumps(
|
||||
{'jsonrpc': '2.0', 'method': 'Server.GetStatus', 'id': 1}), 1)
|
||||
for group in j["result"]["server"]["groups"]:
|
||||
for client in group["clients"]:
|
||||
print("MAC: " + client['host']['mac'] + ", name: " + client['config']['name'] + ", conntect: " + str(client['connected']) + ", volume: " + str(client['config']['volume']['percent']))
|
||||
print("MAC: " + client['host']['mac'] + ", name: " + client['config']['name'] + ", conntect: " + str(
|
||||
client['connected']) + ", volume: " + str(client['config']['volume']['percent']))
|
||||
|
||||
telnet.close
|
||||
|
||||
|
|
|
@ -6,36 +6,36 @@ import threading
|
|||
import time
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: testClient.py <SERVER HOST>")
|
||||
sys.exit(0)
|
||||
print("usage: testClient.py <SERVER HOST>")
|
||||
sys.exit(0)
|
||||
|
||||
telnet = telnetlib.Telnet(sys.argv[1], 1705)
|
||||
|
||||
|
||||
class ReaderThread(threading.Thread):
|
||||
def __init__(self, tn, stop_event):
|
||||
super(ReaderThread, self).__init__()
|
||||
self.tn = tn
|
||||
self.stop_event = stop_event
|
||||
def __init__(self, tn, stop_event):
|
||||
super(ReaderThread, self).__init__()
|
||||
self.tn = tn
|
||||
self.stop_event = stop_event
|
||||
|
||||
def run(self):
|
||||
while (not self.stop_event.is_set()):
|
||||
response = self.tn.read_until("\r\n", 2)
|
||||
if response:
|
||||
print("received: " + response)
|
||||
jresponse = json.loads(response)
|
||||
print(json.dumps(jresponse, indent=2))
|
||||
print("\r\n")
|
||||
def run(self):
|
||||
while (not self.stop_event.is_set()):
|
||||
response = self.tn.read_until("\r\n", 2)
|
||||
if response:
|
||||
print("received: " + response)
|
||||
jresponse = json.loads(response)
|
||||
print(json.dumps(jresponse, indent=2))
|
||||
print("\r\n")
|
||||
|
||||
|
||||
def doRequest( str ):
|
||||
print("send: " + str)
|
||||
telnet.write(str)
|
||||
time.sleep(1)
|
||||
return;
|
||||
def doRequest(str):
|
||||
print("send: " + str)
|
||||
telnet.write(str)
|
||||
time.sleep(1)
|
||||
return
|
||||
|
||||
|
||||
t_stop= threading.Event()
|
||||
t_stop = threading.Event()
|
||||
t = ReaderThread(telnet, t_stop)
|
||||
t.start()
|
||||
|
||||
|
@ -58,7 +58,7 @@ doRequest("{\"jsonrpc\": \"2.0\", \"method\": \"Client.SetStream\", \"params\":
|
|||
time.sleep(5)
|
||||
|
||||
|
||||
#doRequest("{\"jsonrpc\": \"2.0\", \"method\": \"Server.GetStatus\", \"params\": {\"client\": \"80:1f:02:ed:fd:e0\"}, \"id\": 2}\r\n")
|
||||
# doRequest("{\"jsonrpc\": \"2.0\", \"method\": \"Server.GetStatus\", \"params\": {\"client\": \"80:1f:02:ed:fd:e0\"}, \"id\": 2}\r\n")
|
||||
'''
|
||||
doRequest("{\"jsonrpc\": \"2.0\", \"method\": \"Client.SetVolume\", \"params\": {\"client\": \"80:1f:02:ed:fd:e0\", \"volume\": 10}, \"id\": 3}\r\n")
|
||||
doRequest("{\"jsonrpc\": \"2.0\", \"method\": \"Client.SetVolume\", \"params\": {\"client\": \"80:1f:02:ed:fd:e0\", \"volume\": 30}, \"id\": 4}\r\n")
|
||||
|
@ -83,6 +83,6 @@ doRequest("{\"jsonrpc\": \"2.0\", \"method\": \"Client.SetVolume\", \"params\":
|
|||
'''
|
||||
s = raw_input("")
|
||||
print(s)
|
||||
t_stop.set();
|
||||
t_stop.set()
|
||||
t.join()
|
||||
telnet.close
|
||||
|
|
|
@ -28,10 +28,13 @@ if __name__ == "__main__":
|
|||
with open(sys.argv[1], 'r') as file:
|
||||
data = file.read()
|
||||
|
||||
data = re.sub('^\s*# Snapcast changelog *\n*', '', data, flags=re.MULTILINE)
|
||||
data = re.sub('^\s*### ([a-zA-Z]+) *\n', r'\n * \1\n', data, flags=re.MULTILINE)
|
||||
data = re.sub('^\s*## Version\s+(\S*) *\n', r'snapcast (\1-1) unstable; urgency=medium\n', data, flags=re.MULTILINE)
|
||||
data = re.sub('^\s*# Snapcast changelog *\n*',
|
||||
'', data, flags=re.MULTILINE)
|
||||
data = re.sub('^\s*### ([a-zA-Z]+) *\n',
|
||||
r'\n * \1\n', data, flags=re.MULTILINE)
|
||||
data = re.sub('^\s*## Version\s+(\S*) *\n',
|
||||
r'snapcast (\1-1) unstable; urgency=medium\n', data, flags=re.MULTILINE)
|
||||
data = re.sub('^\s*-\s*(.*) *\n', r' -\1\n', data, flags=re.MULTILINE)
|
||||
data = re.sub('^_(.*)_ *\n', r' -- \1\n\n', data, flags=re.MULTILINE)
|
||||
|
||||
print(data)
|
||||
print(data)
|
||||
|
|
|
@ -37,33 +37,33 @@ __git_version__ = "@gitversion@"
|
|||
identity = "Snapcast"
|
||||
|
||||
params = {
|
||||
'progname': sys.argv[0],
|
||||
"progname": sys.argv[0],
|
||||
# Connection
|
||||
'mopidy-host': None,
|
||||
'mopidy-port': None,
|
||||
'snapcast-host': None,
|
||||
'snapcast-port': None,
|
||||
'stream': None,
|
||||
"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',
|
||||
"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.write("\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
class MopidyControl(object):
|
||||
""" Mopidy websocket remote control """
|
||||
"""Mopidy websocket remote control"""
|
||||
|
||||
def __init__(self, params):
|
||||
self._params = params
|
||||
|
@ -74,17 +74,20 @@ class MopidyControl(object):
|
|||
self._mopidy_request_map = {}
|
||||
self._seek_offset = 0.0
|
||||
|
||||
wsversion = websocket.__version__.split('.')
|
||||
wsversion = websocket.__version__.split(".")
|
||||
if int(wsversion[0]) == 0 and int(wsversion[1]) < 58:
|
||||
logger.error(
|
||||
f"websocket-client version 0.58.0 or higher required, installed: {websocket.__version__}, exiting.")
|
||||
f"websocket-client version 0.58.0 or higher required, installed: {websocket.__version__}, exiting."
|
||||
)
|
||||
exit()
|
||||
|
||||
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 = websocket.WebSocketApp(
|
||||
f"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=())
|
||||
|
@ -105,70 +108,73 @@ class MopidyControl(object):
|
|||
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 = jmsg[track_uri][0]["uri"]
|
||||
if url.find("://") == -1:
|
||||
url = str(
|
||||
f"http://{self._params['mopidy-host']}:{self._params['mopidy-port']}{url}")
|
||||
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:
|
||||
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)
|
||||
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})
|
||||
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']
|
||||
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:
|
||||
|
@ -183,85 +189,97 @@ class MopidyControl(object):
|
|||
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':
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
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 metadata is None and "url" in metadata:
|
||||
url = self.extractImageUrl(metadata["url"], result)
|
||||
if not url is None:
|
||||
self._metadata['artUrl'] = url
|
||||
self._metadata["artUrl"] = url
|
||||
|
||||
if repeat and single:
|
||||
properties['loopStatus'] = 'track'
|
||||
properties["loopStatus"] = "track"
|
||||
elif repeat:
|
||||
properties['loopStatus'] = 'playlist'
|
||||
properties["loopStatus"] = "playlist"
|
||||
else:
|
||||
properties['loopStatus'] = 'none'
|
||||
properties["loopStatus"] = "none"
|
||||
|
||||
properties['canGoNext'] = True
|
||||
properties['canGoPrevious'] = True
|
||||
properties['canPlay'] = True
|
||||
properties['canPause'] = True
|
||||
properties['canSeek'] = 'duration' in self._metadata
|
||||
properties['canControl'] = True
|
||||
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
|
||||
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']]}))
|
||||
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))
|
||||
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}')
|
||||
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})
|
||||
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})
|
||||
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.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}')
|
||||
logger.debug(f"Snapcast RPC websocket message received: {message}")
|
||||
jmsg = json.loads(message)
|
||||
|
||||
# Batch request
|
||||
|
@ -270,52 +288,82 @@ class MopidyControl(object):
|
|||
req_res = []
|
||||
callback = None
|
||||
for msg in jmsg:
|
||||
id = msg['id']
|
||||
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']))
|
||||
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']
|
||||
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'])
|
||||
callback(jmsg["result"])
|
||||
|
||||
# Notification
|
||||
else:
|
||||
if 'event' in jmsg:
|
||||
event = jmsg['event']
|
||||
if "event" in jmsg:
|
||||
event = jmsg["event"]
|
||||
logger.info(f"Event: {event}")
|
||||
if event == 'track_playback_started':
|
||||
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']]}))
|
||||
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']:
|
||||
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"]:
|
||||
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']:
|
||||
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)
|
||||
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")
|
||||
|
@ -332,7 +380,7 @@ class MopidyControl(object):
|
|||
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}')
|
||||
logger.debug(f"send_request: {j}")
|
||||
result = self._req_id
|
||||
self._mopidy_request_map[result] = (str(method), callback)
|
||||
self._req_id += 1
|
||||
|
@ -345,15 +393,13 @@ class MopidyControl(object):
|
|||
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}
|
||||
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._mopidy_request_map[self._req_id] = (method, callback)
|
||||
self._req_id += 1
|
||||
logger.debug(f'send_batch_request: {batch}')
|
||||
logger.debug(f"send_batch_request: {batch}")
|
||||
self.websocket.send(json.dumps(batch))
|
||||
return result
|
||||
|
||||
|
@ -366,90 +412,138 @@ class MopidyControl(object):
|
|||
try:
|
||||
id = None
|
||||
request = json.loads(cmd)
|
||||
id = request['id']
|
||||
[interface, cmd] = request['method'].rsplit('.', 1)
|
||||
if interface == 'Plugin.Stream.Player':
|
||||
if cmd == 'Control':
|
||||
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', {})
|
||||
command = request["params"]["command"]
|
||||
params = request["params"].get("params", {})
|
||||
logger.debug(
|
||||
f'Control command: {command}, params: {params}')
|
||||
if command == 'next':
|
||||
f"Control command: {command}, params: {params}")
|
||||
if command == "next":
|
||||
self.send_request("core.playback.next")
|
||||
elif command == 'previous':
|
||||
elif command == "previous":
|
||||
self.send_request("core.playback.previous")
|
||||
elif command == 'play':
|
||||
elif command == "play":
|
||||
self.send_request("core.playback.play")
|
||||
elif command == 'pause':
|
||||
elif command == "pause":
|
||||
self.send_request("core.playback.pause")
|
||||
elif command == 'playPause':
|
||||
if self._properties['playbackStatus'] == 'playing':
|
||||
elif command == "playPause":
|
||||
if self._properties["playbackStatus"] == "playing":
|
||||
self.send_request("core.playback.pause")
|
||||
else:
|
||||
self.send_request("core.playback.play")
|
||||
elif command == 'stop':
|
||||
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'])
|
||||
elif command == "setPosition":
|
||||
position = float(params["position"])
|
||||
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']
|
||||
"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})
|
||||
"core.tracklist.set_single", {"value": False}
|
||||
)
|
||||
self.send_request(
|
||||
"core.tracklist.set_repeat", {"value": True})
|
||||
"core.tracklist.set_repeat", {"value": True}
|
||||
)
|
||||
elif value == "track":
|
||||
self.send_request(
|
||||
"core.tracklist.set_single", {"value": True})
|
||||
"core.tracklist.set_single", {"value": True}
|
||||
)
|
||||
self.send_request(
|
||||
"core.tracklist.set_repeat", {"value": True})
|
||||
"core.tracklist.set_repeat", {"value": True}
|
||||
)
|
||||
elif value == "none":
|
||||
self.send_request(
|
||||
"core.tracklist.set_single", {"value": False})
|
||||
"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))
|
||||
"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})
|
||||
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})
|
||||
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})
|
||||
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})
|
||||
send(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"error": {"code": -32700, "message": "Parse error", "data": str(e)},
|
||||
"id": id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def usage(params):
|
||||
print("""\
|
||||
print(
|
||||
"""\
|
||||
Usage: %(progname)s [OPTION]...
|
||||
|
||||
--mopidy-host=ADDR Set the mopidy server address
|
||||
|
@ -462,19 +556,33 @@ Usage: %(progname)s [OPTION]...
|
|||
-d, --debug Run in debug mode
|
||||
-v, --version meta_mopidy version
|
||||
|
||||
Report bugs to https://github.com/badaix/snapcast/issues""" % params)
|
||||
Report bugs to https://github.com/badaix/snapcast/issues"""
|
||||
% params
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
|
||||
log_format_stderr = '%(asctime)s %(module)s %(levelname)s: %(message)s'
|
||||
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'])
|
||||
(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
|
||||
|
@ -483,23 +591,23 @@ if __name__ == '__main__':
|
|||
usage(params)
|
||||
sys.exit(2)
|
||||
|
||||
for (opt, arg) in opts:
|
||||
if opt in ['-h', '--help']:
|
||||
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']:
|
||||
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']:
|
||||
elif opt in ["-v", "--version"]:
|
||||
v = __version__
|
||||
if __git_version__:
|
||||
v = __git_version__
|
||||
|
@ -510,7 +618,7 @@ if __name__ == '__main__':
|
|||
usage(params)
|
||||
sys.exit()
|
||||
|
||||
logger = logging.getLogger('meta_mopidy')
|
||||
logger = logging.getLogger("meta_mopidy")
|
||||
logger.propagate = False
|
||||
logger.setLevel(log_level)
|
||||
|
||||
|
@ -520,11 +628,11 @@ if __name__ == '__main__':
|
|||
|
||||
logger.addHandler(log_handler)
|
||||
|
||||
for p in ['mopidy-host', 'mopidy-port', 'snapcast-host', 'snapcast-port', 'stream']:
|
||||
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}')
|
||||
logger.debug(f"Parameters: {params}")
|
||||
|
||||
mopidy_ctrl = MopidyControl(params)
|
||||
|
||||
|
|
|
@ -449,7 +449,8 @@ class MPDWrapper(object):
|
|||
|
||||
def io_callback(self, fd, event):
|
||||
try:
|
||||
logger.error(f'IO event "{event}" on fd "{fd}" (type: "{type(fd)}"')
|
||||
logger.error(
|
||||
f'IO event "{event}" on fd "{fd}" (type: "{type(fd)}"')
|
||||
if event & GLib.IO_HUP:
|
||||
logger.debug("IO_HUP")
|
||||
return True
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue