mirror of
https://github.com/badaix/snapcast
synced 2025-10-16 07:03:54 +02:00
357 lines
12 KiB
Python
Executable file
357 lines
12 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
#
|
|
# This file is part of snapcast
|
|
# Copyright (C) 2014-2025 Johannes Pohl
|
|
# Copyright (C) 2025 lucianm
|
|
#
|
|
# 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>=0.58
|
|
# - requests
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
import requests
|
|
import websocket
|
|
|
|
__version__ = "@version@"
|
|
__git_version__ = "@gitversion@"
|
|
|
|
identity = "Snapcast"
|
|
|
|
defaults = {
|
|
"librespot_host": "127.0.0.1",
|
|
"librespot_port": 24879,
|
|
"stream": "SpotCast",
|
|
"snapcast_host": "localhost",
|
|
"snapcast_port": 1780,
|
|
}
|
|
|
|
log_level = logging.INFO
|
|
logger = logging.getLogger("meta_go-librespot")
|
|
logger.propagate = False
|
|
log_handler = logging.StreamHandler()
|
|
log_handler.setFormatter(logging.Formatter(
|
|
"%(asctime)s %(levelname)s: %(message)s"))
|
|
logger.addHandler(log_handler)
|
|
logger.setLevel(log_level)
|
|
|
|
|
|
def send(msg):
|
|
sys.stdout.write(json.dumps(msg) + "\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
class LibrespotControl:
|
|
def __init__(self, params):
|
|
self._params = params
|
|
|
|
self._properties = {
|
|
"playbackStatus": "stopped",
|
|
"shuffle": False,
|
|
"loopStatus": "none",
|
|
"volume": 100,
|
|
"mute": False,
|
|
"position": 0.0,
|
|
"canGoNext": True,
|
|
"canGoPrevious": True,
|
|
"canPlay": True,
|
|
"canPause": True,
|
|
"canSeek": True,
|
|
"canControl": True,
|
|
"metadata": {
|
|
"title": "",
|
|
"artist": [],
|
|
"album": "",
|
|
"trackId": "",
|
|
"artUrl": "",
|
|
"trackNumber": 0,
|
|
"discNumber": 0,
|
|
"duration": 0.0,
|
|
"contentCreated": "",
|
|
},
|
|
}
|
|
self._metadata = self._properties["metadata"].copy()
|
|
|
|
self._lock = threading.Lock()
|
|
self._stop_event = threading.Event()
|
|
self.BASE_URL = (
|
|
f"http://{self._params.librespot_host}:{self._params.librespot_port}"
|
|
)
|
|
|
|
wsversion = websocket.__version__.split(".")
|
|
|
|
if int(wsversion[0]) == 0 and int(wsversion[1]) < 58:
|
|
logger.error(
|
|
"websocket-client version 0.58.0 or higher required, installed: %s, exiting.",
|
|
websocket.__version__,
|
|
)
|
|
exit()
|
|
|
|
ws_url = (
|
|
f"ws://{self._params.librespot_host}:{self._params.librespot_port}/events"
|
|
)
|
|
logger.info("Connecting to librespot WebSocket at %s", ws_url)
|
|
|
|
self.websocket = websocket.WebSocketApp(
|
|
url=ws_url,
|
|
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 not self._stop_event.is_set():
|
|
try:
|
|
self.websocket.run_forever()
|
|
except Exception as e:
|
|
logger.info(f"Exception: {str(e)}")
|
|
self.websocket.close()
|
|
|
|
if not self._stop_event.is_set():
|
|
logger.info("Disconnected. Retrying in 2 seconds...")
|
|
time.sleep(2)
|
|
|
|
def on_ws_message(self, ws, message):
|
|
logger.debug("Snapcast RPC websocket message received: %s", message)
|
|
|
|
try:
|
|
jmsg = json.loads(message)
|
|
event = jmsg["type"]
|
|
data = jmsg["data"]
|
|
except json.JSONDecodeError as e:
|
|
logger.error(
|
|
"Invalid JSON from WebSocket: %s, content: %s", e, message)
|
|
return
|
|
|
|
logger.info(f"Event: {type}, msg: {data}")
|
|
sendupdate = True
|
|
|
|
if event == "not_playing":
|
|
self._properties["playbackStatus"] = "stopped"
|
|
elif event == "paused":
|
|
self._properties["playbackStatus"] = "paused"
|
|
elif event == "playing":
|
|
self._properties["playbackStatus"] = "playing"
|
|
elif event == "volume":
|
|
self._properties["volume"] = int(
|
|
data["value"] / data["max"] * 100.0)
|
|
elif event == "seek":
|
|
self._properties["position"] = float(data["position"]) / 1000.0
|
|
elif event == "metadata":
|
|
with self._lock:
|
|
self._metadata.update(
|
|
{
|
|
"trackId": data.get("uri", ""),
|
|
"title": data.get("name", ""),
|
|
"artist": data.get("artist_names", []),
|
|
"album": data.get("album_name", ""),
|
|
"artUrl": data.get("album_cover_url", ""),
|
|
"duration": data.get("duration", 0) / 1000.0,
|
|
"contentCreated": data.get("release_date", ""),
|
|
"trackNumber": data.get("track_number", 0),
|
|
"discNumber": data.get("disc_number", 0),
|
|
}
|
|
)
|
|
self._properties["metadata"] = self._metadata
|
|
self._properties["position"] = data.get("position", 0) / 1000.0
|
|
|
|
# elif event == 'active':
|
|
# elif event == 'inactive':
|
|
# elif event == 'playbackHaltStateChanged':
|
|
# elif event == 'sessionCleared':
|
|
# elif event == 'sessionChanged':
|
|
# elif event == 'inactiveSession':
|
|
# elif event == 'connectionDropped':
|
|
# elif event == 'connectionEstablished':
|
|
# elif event == 'panic':
|
|
else:
|
|
sendupdate = False
|
|
|
|
if sendupdate:
|
|
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 (%s)", str(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, close_status_code, close_msg):
|
|
logger.info("Snapcast RPC websocket closed")
|
|
|
|
def stop(self):
|
|
self._stop_event.set()
|
|
self.websocket.keep_running = False
|
|
logger.info("Waiting for websocket thread to exit")
|
|
# self.websocket_thread.join()
|
|
|
|
def post_json(self, path: str, payload=None, timeout=2.0):
|
|
if payload is None:
|
|
payload = {}
|
|
|
|
url = f"{self.BASE_URL}/{path}"
|
|
|
|
try:
|
|
r = requests.post(url, json=payload, timeout=timeout)
|
|
logger.debug(
|
|
f"POST {url} -> {r.status_code} {r.reason} {r.text[:256]!r}")
|
|
return r
|
|
except requests.RequestException as e:
|
|
logger.debug(f"POST {url} failed: {e}")
|
|
return None
|
|
|
|
def control(self, cmd):
|
|
if not cmd:
|
|
return
|
|
try:
|
|
req = json.loads(cmd)
|
|
method = req.get("method", "")
|
|
id_ = req.get("id")
|
|
|
|
if method.endswith(".Control"):
|
|
action = req["params"].get("command", "")
|
|
logger.debug(f"Control command: {action}")
|
|
|
|
if action == "play":
|
|
self.post_json("player/resume")
|
|
elif action == "pause":
|
|
self.post_json("player/pause")
|
|
elif action == "playPause":
|
|
status = ctrl._properties["playbackStatus"]
|
|
self.post_json(
|
|
"player/pause" if status == "playing" else "player/resume"
|
|
)
|
|
|
|
elif action == "previous":
|
|
# prev always worked via POST without body
|
|
r = self.post_json("player/prev")
|
|
if not r or r.status_code // 100 != 2:
|
|
self.get_simple("player/prev")
|
|
|
|
elif action == "next":
|
|
# send an explicit empty JSON body; fallback to GET if needed
|
|
r = self.post_json("player/next", payload={})
|
|
if not r or r.status_code // 100 != 2:
|
|
self.get_simple("player/next")
|
|
|
|
elif action == "setPosition":
|
|
pos = float(req["params"].get(
|
|
"params", {}).get("position", 0))
|
|
self.post_json(
|
|
"player/seek", {"position": int(pos * 1000)})
|
|
|
|
elif action == "seek":
|
|
pos = float(req["params"].get(
|
|
"params", {}).get("offset", 0))
|
|
self.post_json(
|
|
"player/seek", {"position": int(pos * 1000),
|
|
"relative": True}
|
|
)
|
|
|
|
# ack
|
|
if id_ is not None:
|
|
send({"jsonrpc": "2.0", "result": "ok", "id": id_})
|
|
|
|
elif method.endswith(".GetProperties"):
|
|
send({"jsonrpc": "2.0", "id": id_, "result": ctrl._properties})
|
|
|
|
elif method.endswith(".SetProperty"):
|
|
# We keep Spotify volume fixed; ignore for now.
|
|
if id_ is not None:
|
|
send({"jsonrpc": "2.0", "id": id_, "result": "ok"})
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error processing command: {e}")
|
|
|
|
|
|
def parse_args():
|
|
"""Configures and parses command-line arguments."""
|
|
parser = argparse.ArgumentParser(
|
|
description="""Connects go-librespot to Snapcast for metadata display and control.
|
|
\nReport bugs to https://github.com/badaix/snapcast/issues
|
|
""",
|
|
formatter_class=argparse.RawTextHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"--librespot-host",
|
|
type=str,
|
|
default=defaults["librespot_host"],
|
|
help="The hostname or IP address of the librespot instance.",
|
|
)
|
|
parser.add_argument(
|
|
"--librespot-port",
|
|
type=int,
|
|
default=defaults["librespot_port"],
|
|
help="The port of the librespot web API.",
|
|
)
|
|
parser.add_argument(
|
|
"--stream",
|
|
type=str,
|
|
default=defaults["stream"],
|
|
help="The name of the Snapcast stream to control.",
|
|
)
|
|
parser.add_argument(
|
|
"--snapcast-host",
|
|
type=str,
|
|
default=defaults["snapcast_host"],
|
|
help="The hostname or IP address of the Snapserver.",
|
|
)
|
|
parser.add_argument(
|
|
"--snapcast-port",
|
|
type=int,
|
|
default=defaults["snapcast_port"],
|
|
help="The JSON-RPC port of the Snapserver.",
|
|
)
|
|
parser.add_argument(
|
|
"-d", "--debug", action="store_true", help="Enable debug logging."
|
|
)
|
|
parser.add_argument(
|
|
"-v", "--version", action="version", version=f"%(prog)s {__version__}"
|
|
)
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
args = parse_args()
|
|
|
|
ctrl = LibrespotControl(args)
|
|
|
|
try:
|
|
for line in sys.stdin:
|
|
ctrl.control(line)
|
|
except KeyboardInterrupt:
|
|
ctrl.stop()
|