Files
Iris/mopidy_iris/core.py

1264 lines
41 KiB
Python
Raw Permalink Normal View History

2020-02-03 16:48:39 +13:00
import random
import string
import logging
import json
import pykka
import urllib
import os
import sys
2017-02-18 12:45:48 +13:00
import tornado.web
import tornado.ioloop
import time
import packaging
2018-10-12 16:10:29 +13:00
import pickle
2020-02-03 16:48:39 +13:00
from tornado.escape import json_encode
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
2017-02-18 12:45:48 +13:00
2020-01-06 00:26:55 +00:00
from . import Extension
2018-11-02 16:10:38 +13:00
from .system import IrisSystemThread
# Check for Mopidy v4+ compatibility.
# We try to import the old serialization class.
# If it fails, we assume we're running on Mopidy v4+.
try:
from mopidy.models.serialize import ModelJSONEncoder
MOPIDY_V4 = False
except ImportError:
ModelJSONEncoder = None
MOPIDY_V4 = True
2020-02-03 16:14:04 +13:00
if sys.platform == "win32":
2017-09-25 14:10:46 -04:00
import ctypes
2017-02-18 12:45:48 +13:00
# import logger
logger = logging.getLogger(__name__)
2020-02-03 16:14:04 +13:00
class IrisCore(pykka.ThreadingActor):
version = ""
2017-02-18 12:45:48 +13:00
spotify_token = False
queue_metadata = {}
connections = {}
2018-10-12 16:10:29 +13:00
commands = {}
2017-04-18 08:52:28 +12:00
initial_consume = False
2017-02-18 12:45:48 +13:00
radio = {
"enabled": 0,
"seed_artists": [],
"seed_genres": [],
"seed_tracks": [],
2020-02-03 16:14:04 +13:00
"results": [],
2017-02-18 12:45:48 +13:00
}
data = {}
ioloop = None
@classmethod
async def do_fetch(cls, client, request):
# This wrapper function exists to ease mocking.
return await client.fetch(request)
def setup(self, config, core):
self.config = config
self.core = core
2018-10-12 16:10:29 +13:00
##
# Mopidy server is starting
2018-10-12 16:10:29 +13:00
##
def start(self):
2020-02-03 16:14:04 +13:00
logger.info("Starting Iris " + Extension.version)
2020-08-21 22:38:39 +12:00
self.data["commands"] = self.load_from_file("commands")
2020-08-22 17:27:55 +12:00
self.data["pinned"] = self.load_from_file("pinned")
2022-03-28 21:18:26 +13:00
self.data["shared_config"] = self.load_from_file("shared_config")
##
# Mopidy is shutting down
##
def stop(self):
2020-02-03 16:14:04 +13:00
logger.info("Stopping Iris")
2018-11-02 16:10:38 +13:00
##
# Load a dict from disk
2018-11-02 16:10:38 +13:00
#
# @param name String
# @return Dict
2018-11-02 16:10:38 +13:00
##
def load_from_file(self, name):
file_path = Extension.get_data_dir(self.config) / ("%s.pkl" % name)
2018-10-12 16:10:29 +13:00
try:
2020-02-03 16:14:04 +13:00
with file_path.open("rb") as f:
content = pickle.load(f)
f.close()
return content
except BaseException: # noqa: B036
2020-08-23 13:13:17 +12:00
if name == "pinned":
return []
else:
return {}
2018-10-12 16:10:29 +13:00
##
# Save dict object to disk
2018-10-12 16:10:29 +13:00
#
# @param dict Dict
2018-10-12 16:10:29 +13:00
# @param name String
# @return void
2018-10-12 16:10:29 +13:00
##
def save_to_file(self, dict, name):
file_path = Extension.get_data_dir(self.config) / ("%s.pkl" % name)
2018-10-12 16:10:29 +13:00
try:
2020-02-03 16:14:04 +13:00
with file_path.open("wb") as f:
pickle.dump(dict, f, pickle.HIGHEST_PROTOCOL)
pickle.close()
except BaseException: # noqa: B036
return False
2018-10-12 16:10:29 +13:00
2017-02-18 12:45:48 +13:00
##
# Generate a random string
#
# Used for connection_ids where none is provided by client
# @return string
##
2018-03-21 21:40:33 +13:00
def generateGuid(self):
2020-02-06 10:12:28 +13:00
return "".join(
random.choices(string.ascii_uppercase + string.digits, k=12)
)
2018-08-15 16:52:24 +12:00
2017-02-18 12:45:48 +13:00
##
# Digest a protocol header into it's id/name parts
#
# @return dict
##
def digest_protocol(self, protocol):
2018-08-15 16:52:24 +12:00
2017-02-18 12:45:48 +13:00
# if we're a string, split into list
2020-02-03 16:48:39 +13:00
# this handles the different ways we get this passed
# (select_subprotocols gives string, headers.get gives list)
if isinstance(protocol, str):
2018-08-15 16:52:24 +12:00
2020-02-03 16:48:39 +13:00
# make sure we strip any spaces (IE gives "element,element", proper
# browsers give "element, element")
2020-02-03 16:14:04 +13:00
protocol = [i.strip() for i in protocol.split(",")]
2018-08-15 16:52:24 +12:00
2017-02-18 12:45:48 +13:00
# if we've been given a valid array
try:
client_id = protocol[0]
2017-02-18 12:45:48 +13:00
connection_id = protocol[1]
username = protocol[2]
generated = False
2018-08-15 16:52:24 +12:00
2017-02-18 12:45:48 +13:00
# invalid, so just create a default connection, and auto-generate an ID
except BaseException: # noqa: B036
client_id = self.generateGuid()
connection_id = self.generateGuid()
2020-02-03 16:14:04 +13:00
username = "Anonymous"
2017-02-18 12:45:48 +13:00
generated = True
2018-08-15 16:52:24 +12:00
2017-02-18 12:45:48 +13:00
# construct our protocol object, and return
return {
"client_id": client_id,
"connection_id": connection_id,
"username": username,
2020-02-03 16:14:04 +13:00
"generated": generated,
}
2017-02-18 12:45:48 +13:00
2018-08-15 16:52:24 +12:00
def send_message(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", None)
data = kwargs.get("data", None)
logger.debug(data)
# Catch invalid recipient
2020-02-03 16:14:04 +13:00
if data["recipient"] not in self.connections:
error = 'Connection "' + data["recipient"] + '" not found'
logger.error(error)
2020-02-03 16:14:04 +13:00
error = {"message": error}
if callback:
callback(False, error)
else:
return error
# Sending of an error
2020-02-03 16:14:04 +13:00
if "error" in data:
message = {"jsonrpc": "2.0", "error": data["error"]}
# Sending of a regular message
else:
message = {
2020-02-03 16:14:04 +13:00
"jsonrpc": "2.0",
"method": data["method"] if "method" in data else None,
}
2020-02-03 16:14:04 +13:00
if "id" in data:
message["id"] = data["id"]
if "params" in data:
message["params"] = data["params"]
if "result" in data:
message["result"] = data["result"]
# Dispatch the message
try:
2020-02-03 16:14:04 +13:00
self.connections[data["recipient"]]["connection"].write_message(
json_encode(message)
)
2020-02-03 16:14:04 +13:00
response = {"message": "Sent message to " + data["recipient"]}
if callback:
callback(response)
else:
2018-08-15 16:52:24 +12:00
return response
except BaseException: # noqa: B036
2020-02-03 16:14:04 +13:00
error = "Failed to send message to " + data["recipient"]
logger.error(error)
2020-02-03 16:14:04 +13:00
error = {"message": error}
if callback:
callback(False, error)
else:
return error
def broadcast(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", None)
data = kwargs.get("data", None)
logger.debug(data)
2020-02-03 16:14:04 +13:00
if "error" in data:
message = {"jsonrpc": "2.0", "error": data["error"]}
else:
message = {
2020-02-03 16:14:04 +13:00
"jsonrpc": "2.0",
"method": data["method"] if "method" in data else None,
"params": data["params"] if "params" in data else None,
}
2017-02-18 12:45:48 +13:00
for connection in self.connections.values():
send_to_this_connection = True
# Don't send the broadcast to the origin, naturally
2020-02-03 16:14:04 +13:00
if "connection_id" in data:
if connection["connection_id"] == data["connection_id"]:
send_to_this_connection = False
if send_to_this_connection:
2020-02-03 16:14:04 +13:00
connection["connection"].write_message(json_encode(message))
2020-02-06 10:12:28 +13:00
response = {
"message": "Broadcast to "
+ str(len(self.connections))
+ " connections"
}
2020-02-03 16:14:04 +13:00
if callback:
callback(response)
else:
2018-08-15 16:52:24 +12:00
return response
2017-02-18 12:45:48 +13:00
##
2017-02-18 22:04:05 +13:00
# Connections
#
2020-02-03 16:48:39 +13:00
# Contains all our connections and client details. This requires
# updates when new clients connect, and old ones disconnect. These
# events are broadcast to all current connections
2017-02-18 12:45:48 +13:00
##
2017-02-18 22:04:05 +13:00
2018-08-15 16:52:24 +12:00
def get_connections(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", None)
2017-02-18 22:04:05 +13:00
connections = []
for connection in self.connections.values():
2020-02-03 16:14:04 +13:00
connections.append(connection["client"])
2018-08-15 16:52:24 +12:00
2020-02-03 16:14:04 +13:00
response = {"connections": connections}
if callback:
callback(response)
else:
2018-08-15 16:52:24 +12:00
return response
2017-02-18 22:04:05 +13:00
def add_connection(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
connection = kwargs.get("connection", None)
client = kwargs.get("client", None)
logger.debug("Connection added")
logger.debug(connection)
2020-02-03 16:14:04 +13:00
self.connections[client["connection_id"]] = {
"client": client,
"connection_id": client["connection_id"],
"connection": connection,
2017-02-18 12:45:48 +13:00
}
2020-02-03 16:14:04 +13:00
self.broadcast(
2020-02-03 16:48:39 +13:00
data={
"method": "connection_added",
2020-02-06 10:12:28 +13:00
"params": {"connection": client},
}
2020-02-03 16:14:04 +13:00
)
2018-08-15 16:52:24 +12:00
def update_connection(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", None)
data = kwargs.get("data", {})
connection_id = data["connection_id"]
if connection_id in self.connections:
2020-02-03 16:48:39 +13:00
username = data["username"]
client_id = data["client_id"]
self.connections[connection_id]["client"]["username"] = username
self.connections[connection_id]["client"]["client_id"] = client_id
2020-02-03 16:14:04 +13:00
self.broadcast(
data={
"method": "connection_changed",
2020-02-03 16:48:39 +13:00
"params": {
2020-02-06 10:12:28 +13:00
"connection": self.connections[connection_id]["client"]
},
}
)
response = {"connection": self.connections[connection_id]["client"]}
2020-02-03 16:14:04 +13:00
if callback:
callback(response)
else:
return response
else:
2020-02-03 16:14:04 +13:00
error = 'Connection "' + data["connection_id"] + '" not found'
logger.error(error)
2020-02-03 16:14:04 +13:00
error = {"message": error}
if callback:
callback(False, error)
else:
return error
2017-02-18 12:45:48 +13:00
def remove_connection(self, connection_id):
if connection_id in self.connections:
try:
2020-02-03 16:14:04 +13:00
client = self.connections[connection_id]["client"]
2017-02-18 12:45:48 +13:00
del self.connections[connection_id]
2020-02-03 16:14:04 +13:00
self.broadcast(
data={
"method": "connection_removed",
"params": {"connection": client},
}
2020-02-03 16:14:04 +13:00
)
except BaseException: # noqa: B036
2020-02-03 16:14:04 +13:00
logger.error("Failed to close connection to " + connection_id)
2017-02-18 22:04:05 +13:00
def set_username(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", None)
data = kwargs.get("data", {})
connection_id = data["connection_id"]
2017-02-18 22:04:05 +13:00
if connection_id in self.connections:
2020-02-03 16:48:39 +13:00
username = data["username"]
self.connections[connection_id]["client"]["username"] = username
2020-02-03 16:14:04 +13:00
self.broadcast(
data={
"method": "connection_changed",
2020-02-03 16:48:39 +13:00
"params": {
2020-02-06 10:12:28 +13:00
"connection": self.connections[connection_id]["client"]
},
}
)
2020-02-03 16:48:39 +13:00
response = {
"connection_id": connection_id,
2020-02-06 10:12:28 +13:00
"username": data["username"],
}
2020-02-03 16:14:04 +13:00
if callback:
callback(response)
else:
2018-08-15 16:52:24 +12:00
return response
2017-02-18 22:04:05 +13:00
else:
2020-02-03 16:14:04 +13:00
error = 'Connection "' + data["connection_id"] + '" not found'
2017-02-18 22:04:05 +13:00
logger.error(error)
2017-10-20 21:49:41 +13:00
2020-02-03 16:14:04 +13:00
error = {"message": error}
if callback:
2017-10-20 21:49:41 +13:00
callback(False, error)
else:
return error
2018-08-15 16:52:24 +12:00
2017-02-18 22:04:05 +13:00
##
# System controls
#
# Faciitates upgrades and configuration fetching
2018-08-15 16:52:24 +12:00
##
2017-02-18 12:45:48 +13:00
def get_config(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
2017-03-31 08:46:44 +13:00
# handle config setups where there is no username/password
2020-02-03 16:48:39 +13:00
# Iris won't work properly anyway, but at least we won't get server
# errors
2020-02-03 16:14:04 +13:00
if "spotify" in self.config and "username" in self.config["spotify"]:
spotify_username = self.config["spotify"]["username"]
2018-08-15 16:52:24 +12:00
else:
spotify_username = False
response = {
2020-02-03 16:14:04 +13:00
"config": {
2018-04-16 09:01:42 +12:00
"is_root": self.is_root(),
"spotify_username": spotify_username,
2020-02-03 16:14:04 +13:00
"country": self.config["iris"]["country"],
"locale": self.config["iris"]["locale"],
"snapcast_enabled": self.config["iris"]["snapcast_enabled"],
"snapcast_host": self.config["iris"]["snapcast_host"],
"snapcast_port": self.config["iris"]["snapcast_port"],
2021-10-27 21:28:38 +13:00
"snapcast_ssl": self.config["iris"]["snapcast_ssl"],
"snapcast_stream": self.config["iris"]["snapcast_stream"],
2020-02-03 16:14:04 +13:00
"spotify_authorization_url": self.config["iris"][
"spotify_authorization_url"
],
"lastfm_authorization_url": self.config["iris"][
"lastfm_authorization_url"
],
"genius_authorization_url": self.config["iris"][
"genius_authorization_url"
],
2022-03-28 21:18:26 +13:00
"shared_config": self.data["shared_config"],
}
2017-02-18 12:45:48 +13:00
}
2020-02-03 16:14:04 +13:00
if callback:
callback(response)
else:
return response
async def get_version(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
url = "https://pypi.python.org/pypi/Mopidy-Iris/json"
http_client = AsyncHTTPClient()
2018-08-15 16:52:24 +12:00
2017-02-18 12:45:48 +13:00
try:
http_response = await http_client.fetch(url)
response_body = json.loads(http_response.body)
2020-02-03 16:14:04 +13:00
latest_version = response_body["info"]["version"]
current_version = Extension.version
2018-08-15 16:52:24 +12:00
2017-02-18 12:45:48 +13:00
# compare our versions, and convert result to boolean
upgrade_available = packaging.version.parse(
latest_version
) > packaging.version.parse(current_version)
2020-02-03 16:14:04 +13:00
upgrade_available = upgrade_available == 1
2017-02-18 12:45:48 +13:00
2020-02-03 16:48:39 +13:00
except (urllib.request.HTTPError, urllib.request.URLError):
2020-02-03 16:14:04 +13:00
latest_version = "0.0.0"
2017-02-18 12:45:48 +13:00
upgrade_available = False
2018-08-15 16:52:24 +12:00
response = {
2020-02-03 16:14:04 +13:00
"version": {
"current": current_version,
"latest": latest_version,
"is_root": self.is_root(),
"upgrade_available": upgrade_available,
2017-02-18 12:45:48 +13:00
}
}
2020-02-03 16:14:04 +13:00
if callback:
callback(response)
else:
return response
2018-11-02 16:10:38 +13:00
##
# Restart Mopidy
# This requires sudo access to system.sh
##
def restart(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
ioloop = kwargs.get("ioloop", False)
2017-02-18 12:45:48 +13:00
# Trigger the action
2020-02-03 16:14:04 +13:00
IrisSystemThread("restart", ioloop, self.restart_callback).start()
2020-02-03 16:14:04 +13:00
self.broadcast(data={"method": "restart_started"})
2020-02-03 16:14:04 +13:00
response = {"message": "Restart started"}
if callback:
callback(response)
else:
return response
def restart_callback(self, response, error, update):
if error:
2020-02-03 16:14:04 +13:00
self.broadcast(data={"method": "restart_error", "params": error})
elif update:
2020-02-06 10:12:28 +13:00
self.broadcast(data={"method": "restart_updated", "params": update})
2018-11-02 16:10:38 +13:00
else:
2020-02-03 16:48:39 +13:00
self.broadcast(
2020-02-06 10:12:28 +13:00
data={"method": "restart_finished", "params": response}
)
2018-11-02 16:10:38 +13:00
##
# Run an upgrade of Iris
##
def upgrade(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
ioloop = kwargs.get("ioloop", False)
2020-02-03 16:14:04 +13:00
self.broadcast(data={"method": "upgrade_started"})
# Trigger the action
2020-02-03 16:14:04 +13:00
IrisSystemThread("upgrade", ioloop, self.upgrade_callback).start()
2020-02-03 16:14:04 +13:00
response = {"message": "Upgrade started"}
2020-02-03 16:14:04 +13:00
if callback:
callback(response)
else:
return response
def upgrade_callback(self, response, error, update):
if error:
2020-02-03 16:14:04 +13:00
self.broadcast(data={"method": "upgrade_error", "params": error})
elif update:
2020-02-06 10:12:28 +13:00
self.broadcast(data={"method": "upgrade_updated", "params": update})
2018-11-02 16:10:38 +13:00
else:
2020-02-03 16:48:39 +13:00
self.broadcast(
2020-02-06 10:12:28 +13:00
data={"method": "upgrade_finished", "params": response}
)
self.restart()
2018-08-15 16:52:24 +12:00
2018-11-02 16:10:38 +13:00
##
# Run a mopidy local scan
# Essetially an alias to "mopidyctl local scan"
##
def local_scan(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
ioloop = kwargs.get("ioloop", False)
# Trigger the action
2020-02-06 10:12:28 +13:00
IrisSystemThread("local_scan", ioloop, self.local_scan_callback).start()
2020-02-03 16:14:04 +13:00
self.broadcast(data={"method": "local_scan_started"})
2020-02-03 16:14:04 +13:00
response = {"message": "Local scan started"}
if callback:
callback(response)
else:
return response
2017-02-18 22:04:05 +13:00
def local_scan_callback(self, response, error, update):
if error:
2020-02-06 10:12:28 +13:00
self.broadcast(data={"method": "local_scan_error", "params": error})
elif update:
2020-02-03 16:48:39 +13:00
self.broadcast(
2020-02-06 10:12:28 +13:00
data={"method": "local_scan_updated", "params": update}
)
2018-11-02 16:10:38 +13:00
else:
2020-02-03 16:48:39 +13:00
self.broadcast(
2020-02-06 10:12:28 +13:00
data={"method": "local_scan_finished", "params": response}
)
2017-02-18 22:04:05 +13:00
##
# Spotify Radio
#
2020-02-03 16:48:39 +13:00
# Accepts seed URIs and creates radio-like experience. When our
# tracklist is nearly empty, we fetch more recommendations. This
# can result in duplicates. We keep the recommendations limit low
# to avoid timeouts and slow UI
2017-02-18 22:04:05 +13:00
##
2017-02-18 12:45:48 +13:00
def get_radio(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
2020-02-03 16:14:04 +13:00
response = {"radio": self.radio}
if callback:
callback(response)
else:
return response
2017-02-18 12:45:48 +13:00
async def change_radio(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
data = kwargs.get("data", {})
# We're starting a new radio (or forced restart)
2020-02-03 16:14:04 +13:00
if data["reset"] or not self.radio["enabled"]:
2017-04-18 08:52:28 +12:00
starting = True
self.initial_consume = self.core.tracklist.get_consume().get()
else:
starting = False
2018-08-15 16:52:24 +12:00
2017-04-18 08:52:28 +12:00
# fetch more tracks from Mopidy-Spotify
self.radio = {
2020-02-03 16:14:04 +13:00
"seed_artists": data["seed_artists"],
"seed_genres": data["seed_genres"],
"seed_tracks": data["seed_tracks"],
"enabled": 1,
"results": [],
}
uris = await self.load_more_tracks()
2017-04-18 04:44:39 +12:00
2017-04-18 08:52:28 +12:00
# make sure we got recommendations
if uris:
if starting:
self.core.tracklist.clear()
self.core.tracklist.set_consume(True)
# We only want to play the first batch
2020-02-03 16:14:04 +13:00
added = self.core.tracklist.add(uris=uris[0:3])
2020-02-03 16:14:04 +13:00
if not added.get():
logger.error("No recommendations added to queue")
2020-02-03 16:14:04 +13:00
self.radio["enabled"] = 0
error = {
2020-02-03 16:14:04 +13:00
"message": "No recommendations added to queue",
"radio": self.radio,
}
2020-02-03 16:14:04 +13:00
if callback:
callback(False, error)
else:
return error
# Save results (minus first batch) for later use
2020-02-03 16:14:04 +13:00
self.radio["results"] = uris[3:]
2017-04-18 08:52:28 +12:00
self.add_radio_metadata(added)
2019-09-26 10:56:51 +12:00
if starting:
self.core.playback.play()
2020-02-03 16:14:04 +13:00
self.broadcast(
2020-02-03 16:48:39 +13:00
data={
"method": "radio_started",
2020-02-06 10:12:28 +13:00
"params": {"radio": self.radio},
}
2020-02-03 16:14:04 +13:00
)
else:
2020-02-03 16:14:04 +13:00
self.broadcast(
2020-02-03 16:48:39 +13:00
data={
"method": "radio_changed",
2020-02-06 10:12:28 +13:00
"params": {"radio": self.radio},
}
2020-02-03 16:14:04 +13:00
)
self.get_radio(callback=callback)
return
2018-08-15 16:52:24 +12:00
# Failed fetching/adding tracks, so no-go
else:
logger.error("No recommendations returned by Spotify")
2020-02-03 16:14:04 +13:00
self.radio["enabled"] = 0
error = {
2020-02-03 16:14:04 +13:00
"code": 32500,
"message": "Could not start radio",
"data": {"radio": self.radio},
}
2020-02-03 16:14:04 +13:00
if callback:
callback(False, error)
else:
return error
def stop_radio(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
2017-04-18 08:52:28 +12:00
2017-02-18 12:45:48 +13:00
self.radio = {
"enabled": 0,
"seed_artists": [],
"seed_genres": [],
"seed_tracks": [],
2020-02-03 16:14:04 +13:00
"results": [],
2017-02-18 12:45:48 +13:00
}
2017-04-18 08:52:28 +12:00
# restore initial consume state
self.core.tracklist.set_consume(self.initial_consume)
2018-08-15 16:52:24 +12:00
self.core.playback.stop()
2017-02-18 12:45:48 +13:00
2020-02-03 16:14:04 +13:00
self.broadcast(
data={"method": "radio_stopped", "params": {"radio": self.radio}}
)
2018-08-15 16:52:24 +12:00
2020-02-03 16:14:04 +13:00
response = {"message": "Stopped radio"}
if callback:
callback(response)
else:
return response
2017-02-18 12:45:48 +13:00
async def load_more_tracks(self, *args, **kwargs):
logger.info("Loading more radio tracks from Spotify")
2017-02-18 22:04:05 +13:00
try:
await self.get_spotify_token()
spotify_token = self.spotify_token
2020-02-03 16:14:04 +13:00
access_token = spotify_token["access_token"]
except BaseException: # noqa: B036
2020-02-03 16:14:04 +13:00
error = "IrisFrontend: access_token missing or invalid"
2017-09-22 16:15:50 +12:00
logger.error(error)
return False
2018-08-15 16:52:24 +12:00
2020-02-03 16:14:04 +13:00
url = "https://api.spotify.com/v1/recommendations/"
url = (
url
+ "?seed_artists="
2020-02-06 10:12:28 +13:00
+ (",".join(self.radio["seed_artists"])).replace(
"spotify:artist:", ""
)
2020-02-03 16:14:04 +13:00
)
url = (
url
+ "&seed_genres="
2020-02-06 10:12:28 +13:00
+ (",".join(self.radio["seed_genres"])).replace(
"spotify:genre:", ""
)
2020-02-03 16:14:04 +13:00
)
url = (
url
+ "&seed_tracks="
2020-02-06 10:12:28 +13:00
+ (",".join(self.radio["seed_tracks"])).replace(
"spotify:track:", ""
)
2020-02-03 16:14:04 +13:00
)
url = url + "&limit=50"
http_client = AsyncHTTPClient()
2020-01-22 16:19:57 +13:00
2017-02-18 22:04:05 +13:00
try:
http_response = await http_client.fetch(
2020-02-06 10:12:28 +13:00
url, "POST", headers={"Authorization": "Bearer " + access_token}
)
response_body = json.loads(http_response.body)
2018-08-15 16:52:24 +12:00
2017-02-18 22:04:05 +13:00
uris = []
2020-02-03 16:14:04 +13:00
for track in response_body["tracks"]:
uris.append(track["uri"])
return uris
except (urllib.error.HTTPError, urllib.error.URLError) as e:
error = json.loads(e.read())
error_response = {
2020-02-03 16:14:04 +13:00
"message": "Could not fetch Spotify recommendations: "
+ error["error_description"]
}
logger.error(
2020-02-06 10:12:28 +13:00
"Could not fetch Spotify recommendations: "
+ error["error_description"]
2020-02-03 16:14:04 +13:00
)
logger.debug(error_response)
return False
async def check_for_radio_update(self):
2019-07-16 10:10:03 -06:00
tracklistLength = self.core.tracklist.get_length().get()
2020-02-03 16:14:04 +13:00
if tracklistLength < 3 and self.radio["enabled"] == 1:
2018-08-15 16:52:24 +12:00
# Grab our loaded tracks
2020-02-03 16:14:04 +13:00
uris = self.radio["results"]
2020-02-03 16:48:39 +13:00
# We've run out of pre-fetched tracks, so we need to get more
# recommendations
2020-02-03 16:14:04 +13:00
if len(uris) < 3:
uris = await self.load_more_tracks()
# Remove the next batch, and update our results
2020-02-03 16:14:04 +13:00
self.radio["results"] = uris[3:]
# Only add the next set of uris
uris = uris[0:3]
2020-02-03 16:14:04 +13:00
added = self.core.tracklist.add(uris=uris)
2018-08-15 16:52:24 +12:00
self.add_radio_metadata(added)
def add_radio_metadata(self, added):
seeds = []
2020-02-03 16:14:04 +13:00
if len(self.radio["seed_artists"]) > 0:
seeds = seeds + self.radio["seed_artists"]
2020-02-03 16:14:04 +13:00
if len(self.radio["seed_tracks"]) > 0:
seeds = seeds + self.radio["seed_tracks"]
2020-02-03 16:14:04 +13:00
if len(self.radio["seed_genres"]) > 0:
seeds = seeds + self.radio["seed_genres"]
2020-02-03 16:14:04 +13:00
metadata = {
"tlids": [],
"added_by": "Radio",
"added_from": {
"name": "Radio",
"type": "radio",
"seeds": seeds
}
2020-02-03 16:14:04 +13:00
}
for added_tltrack in added.get():
2020-02-03 16:14:04 +13:00
metadata["tlids"].append(added_tltrack.tlid)
self.add_queue_metadata(data=metadata)
2017-02-18 22:04:05 +13:00
##
# Additional queue metadata
#
# This maps tltracks with extra info for display in Iris, including
# added_by and from_uri.
##
def get_queue_metadata(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
2020-02-03 16:14:04 +13:00
response = {"queue_metadata": self.queue_metadata}
if callback:
callback(response)
else:
return response
2017-02-18 12:45:48 +13:00
def add_queue_metadata(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
data = kwargs.get("data", {})
2017-02-18 22:04:05 +13:00
2020-02-03 16:14:04 +13:00
for tlid in data["tlids"]:
2017-02-18 22:04:05 +13:00
item = {
2020-02-03 16:14:04 +13:00
"tlid": tlid,
2020-02-06 10:12:28 +13:00
"added_from": data["added_from"]
if "added_from" in data
else None,
"added_by": data["added_by"] if "added_by" in data else None,
2017-02-18 22:04:05 +13:00
}
2020-02-03 16:14:04 +13:00
self.queue_metadata["tlid_" + str(tlid)] = item
2017-02-18 22:04:05 +13:00
2020-02-03 16:14:04 +13:00
self.broadcast(
data={
"method": "queue_metadata_changed",
"params": {"queue_metadata": self.queue_metadata},
}
2020-02-03 16:14:04 +13:00
)
2018-08-15 16:52:24 +12:00
2020-02-03 16:14:04 +13:00
response = {"message": "Added queue metadata"}
if callback:
callback(response)
else:
return response
2017-02-18 22:04:05 +13:00
def clean_queue_metadata(self, *args, **kwargs):
2017-02-18 22:04:05 +13:00
cleaned_queue_metadata = {}
for tltrack in self.core.tracklist.get_tl_tracks().get():
2020-02-03 16:48:39 +13:00
# if we have metadata for this track, push it through to cleaned
# dictionary
2020-02-03 16:14:04 +13:00
if "tlid_" + str(tltrack.tlid) in self.queue_metadata:
cleaned_queue_metadata[
"tlid_" + str(tltrack.tlid)
] = self.queue_metadata["tlid_" + str(tltrack.tlid)]
2017-02-18 22:04:05 +13:00
self.queue_metadata = cleaned_queue_metadata
2018-10-12 16:10:29 +13:00
##
# Server-side data assets
2018-10-12 16:10:29 +13:00
#
2020-08-23 13:13:17 +12:00
# These functions are used internally to store data locally for all users to access
2018-10-12 16:10:29 +13:00
##
def get_data(self, name, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
2018-10-12 16:10:29 +13:00
response = {name: self.data[name]}
2020-02-03 16:14:04 +13:00
if callback:
2018-10-12 16:10:29 +13:00
callback(response)
else:
return response
def set_data(self, name, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
data = kwargs.get("data", {})
2018-10-12 16:10:29 +13:00
# Update our temporary variable
self.data[name] = data[name]
2018-10-12 16:10:29 +13:00
# Save the new commands to file storage
self.save_to_file(self.data[name], name)
2018-10-12 16:10:29 +13:00
2020-02-03 16:14:04 +13:00
self.broadcast(
2020-02-03 16:48:39 +13:00
data={
"method": f"{name}_changed",
"params": {name: self.data[name]},
2020-02-06 10:12:28 +13:00
}
2020-02-03 16:14:04 +13:00
)
2018-10-12 16:10:29 +13:00
response = {"message": f"Saved {name}"}
2020-02-03 16:14:04 +13:00
if callback:
2018-10-12 16:10:29 +13:00
callback(response)
else:
return response
##
# Pinned assets
##
def get_pinned(self, *args, **kwargs):
2020-08-21 22:38:39 +12:00
return self.get_data("pinned", *args, **kwargs)
def set_pinned(self, *args, **kwargs):
2020-08-21 22:38:39 +12:00
return self.set_data("pinned", *args, **kwargs)
2022-03-28 21:18:26 +13:00
##
# Portable configuration template for other users to import
##
def get_shared_config(self, *args, **kwargs):
return self.get_data("shared_config", *args, **kwargs)
2022-03-28 21:18:26 +13:00
def set_shared_config(self, *args, **kwargs):
return self.set_data("shared_config", *args, **kwargs)
##
# Commands
##
def get_commands(self, *args, **kwargs):
2020-08-21 22:38:39 +12:00
return self.get_data("commands", *args, **kwargs)
def set_commands(self, *args, **kwargs):
2020-08-21 22:38:39 +12:00
return self.set_data("commands", *args, **kwargs)
async def run_command(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
data = kwargs.get("data", {})
error = False
2020-08-21 22:38:39 +12:00
if str(data["id"]) not in self.data["commands"]:
error = {
2020-02-03 16:14:04 +13:00
"message": "Command failed",
2020-02-06 10:12:28 +13:00
"description": "Could not find command by ID "
+ '"'
+ str(data["id"])
+ '"',
}
else:
2020-08-21 22:38:39 +12:00
command = self.data["commands"][str(data["id"])]
if "method" not in command:
error = {
2020-02-03 16:14:04 +13:00
"message": "Command failed",
"description": 'Missing required property "method"',
}
if "url" not in command:
error = {
2020-02-03 16:14:04 +13:00
"message": "Command failed",
"description": 'Missing required property "url"',
}
2020-02-03 16:14:04 +13:00
logger.debug("Running command " + str(command))
if error:
2020-02-03 16:14:04 +13:00
if callback:
callback(False, error)
return
else:
return error
# Build headers dict if additional headers are given
headers = None
2020-02-03 16:14:04 +13:00
if "additional_headers" in command:
d = command["additional_headers"].split("\n")
lines = list(filter(lambda x: x.find(":") > 0, d))
fields = [
2020-02-06 10:12:28 +13:00
(x.split(":", 1)[0].strip().lower(), x.split(":", 1)[1].strip())
2020-02-03 16:14:04 +13:00
for x in lines
]
headers = dict(fields)
2020-02-03 16:14:04 +13:00
if command["method"] == "POST":
if (
"content-type" in headers
and headers["content-type"].lower() != "application/json"
):
post_data = command["post_data"]
else:
2020-02-03 16:14:04 +13:00
post_data = json.dumps(command["post_data"])
request = HTTPRequest(
command["url"],
connect_timeout=5,
method="POST",
body=post_data,
validate_cert=False,
headers=headers,
)
else:
2020-02-03 16:14:04 +13:00
request = HTTPRequest(
2020-02-03 16:48:39 +13:00
command["url"],
connect_timeout=5,
validate_cert=False,
2020-02-06 10:12:28 +13:00
headers=headers,
)
# Make the request, and handle any request errors
try:
http_client = AsyncHTTPClient()
command_response = await http_client.fetch(request)
except Exception as e: # noqa: B036
2020-02-03 16:14:04 +13:00
error = {"message": "Command failed", "description": str(e)}
if callback:
callback(False, error)
return
else:
return error
# Attempt to parse body as JSON
try:
command_response_body = json.loads(command_response.body)
except BaseException: # noqa: B036
# Perhaps it requires unicode encoding?
try:
2020-02-03 16:48:39 +13:00
command_response_body = tornado.escape.to_unicode(
2020-02-06 10:12:28 +13:00
command_response.body
)
except BaseException: # noqa: B036
command_response_body = ""
# Finally, return the result
2020-02-06 10:12:28 +13:00
response = {"message": "Command run", "response": command_response_body}
2020-02-03 16:14:04 +13:00
if callback:
callback(response)
return
else:
return response
2018-10-12 16:10:29 +13:00
2017-02-18 22:04:05 +13:00
##
# Spotify authentication
#
2020-02-03 16:48:39 +13:00
# Uses the Client Credentials Flow, so is invisible to the user.
# We need this token for any backend spotify requests (we don't tap in
# to Mopidy-Spotify, yet). Also used for passing token to frontend for
# javascript requests without use of the Authorization Code Flow.
2017-02-18 22:04:05 +13:00
##
async def get_spotify_token(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
# Expired, so go get a new one
2020-02-03 16:48:39 +13:00
if (
2020-02-06 10:12:28 +13:00
not self.spotify_token
or self.spotify_token["expires_at"] <= time.time()
2020-02-03 16:48:39 +13:00
):
await self.refresh_spotify_token()
2020-02-03 16:14:04 +13:00
response = {"spotify_token": self.spotify_token}
2017-02-18 22:04:05 +13:00
2020-02-03 16:14:04 +13:00
if callback:
callback(response)
else:
return response
async def refresh_spotify_token(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", None)
2018-08-15 16:52:24 +12:00
try:
# Use client_id and client_secret from config
# This was introduced in Mopidy-Spotify 3.1.0
url = "https://auth.mopidy.com/spotify/token"
data = {
"client_id": self.config["spotify"]["client_id"],
"client_secret": self.config["spotify"]["client_secret"],
"grant_type": "client_credentials",
}
except BaseException: # noqa: B036
error = {
"message": "Could not refresh Spotify token: invalid configuration"
}
if callback:
callback(False, error)
else:
return error
2017-02-18 22:04:05 +13:00
try:
http_client = tornado.httpclient.AsyncHTTPClient()
2020-02-03 16:14:04 +13:00
request = tornado.httpclient.HTTPRequest(
url, method="POST", body=urllib.parse.urlencode(data)
)
response = await self.do_fetch(http_client, request)
2017-02-18 22:04:05 +13:00
token = json.loads(response.body)
2020-02-03 16:14:04 +13:00
token["expires_at"] = time.time() + token["expires_in"]
self.spotify_token = token
2020-02-03 16:14:04 +13:00
self.broadcast(
data={
"method": "spotify_token_changed",
"params": {"spotify_token": self.spotify_token},
}
2020-02-03 16:14:04 +13:00
)
2017-02-18 22:04:05 +13:00
2020-02-03 16:14:04 +13:00
response = {"spotify_token": token}
if callback:
callback(response)
else:
2017-10-20 21:49:41 +13:00
return response
except (urllib.error.HTTPError, urllib.error.URLError) as e:
error = json.loads(e.read())
2020-02-03 16:14:04 +13:00
error = {
"message": "Could not refresh Spotify token: "
2020-02-06 10:12:28 +13:00
+ error["error_description"]
}
2020-02-03 16:14:04 +13:00
if callback:
2017-10-20 21:49:41 +13:00
callback(False, error)
else:
2017-10-20 21:49:41 +13:00
return error
2018-04-16 09:01:42 +12:00
##
# Detect if we're running as root
##
2018-08-15 16:52:24 +12:00
def is_root(self):
2020-02-03 16:14:04 +13:00
if sys.platform == "win32":
2018-04-16 09:01:42 +12:00
return ctypes.windll.shell32.IsUserAnAdmin() != 0
else:
return os.geteuid() == 0
2018-08-20 08:48:46 +12:00
##
# Spotify authentication
#
2020-02-03 16:48:39 +13:00
# Uses the Client Credentials Flow, so is invisible to the user.
# We need this token for any backend spotify requests (we don't tap in
# to Mopidy-Spotify, yet). Also used for passing token to frontend for
# javascript requests without use of the Authorization Code Flow.
2018-08-20 08:48:46 +12:00
##
2020-01-22 14:26:15 +13:00
async def get_lyrics(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
request = kwargs.get("request", False)
2018-08-20 08:48:46 +12:00
error = False
url = ""
2018-08-20 08:48:46 +12:00
try:
2020-02-03 16:14:04 +13:00
path = request.get_argument("path")
url = "https://genius.com" + path
except Exception as e: # noqa: B036
logger.error(e)
2020-02-03 16:14:04 +13:00
error = {"message": "Path not valid", "description": str(e)}
try:
2020-02-03 16:14:04 +13:00
connection_id = request.get_argument("connection_id")
2018-08-21 21:08:11 +12:00
if connection_id not in self.connections:
error = {
2020-02-03 16:14:04 +13:00
"message": "Unauthorized request",
2020-02-06 10:12:28 +13:00
"description": "Connection "
+ connection_id
+ " not connected",
}
except Exception as e: # noqa: B036
logger.error(e)
error = {
2020-02-03 16:14:04 +13:00
"message": "Unauthorized request",
"description": "connection_id missing",
}
if error:
2020-01-22 14:26:15 +13:00
return error
2020-01-22 16:19:57 +13:00
2020-01-22 14:26:15 +13:00
try:
http_client = AsyncHTTPClient()
http_response = await http_client.fetch(url)
2020-02-03 16:48:39 +13:00
callback(
2020-02-06 10:12:28 +13:00
http_response.body.decode("utf-8", errors="replace"), False
)
2018-08-20 08:48:46 +12:00
2020-01-22 14:26:15 +13:00
except (urllib.error.HTTPError, urllib.error.URLError) as e:
error = json.loads(e.read())
2020-02-03 16:14:04 +13:00
error = {
"message": "Could not fetch Genius lyrics: "
2020-02-03 16:14:04 +13:00
+ error["error_description"]
}
logger.error(
"Could not fetch Genius lyrics: " + error["error_description"]
2020-02-03 16:14:04 +13:00
)
2020-01-22 14:26:15 +13:00
logger.debug(error)
return error
2018-08-20 08:48:46 +12:00
##
# Send our current track data to the configured Snapcast server's stream
# Uses snapcast server and stream details as defined in configuration
##
async def update_snapcast_meta(self, *args, **kwargs):
2021-10-25 20:58:32 +13:00
callback = kwargs.get("callback", False)
track = self.core.playback.get_current_track().get()
meta = {}
2021-10-25 20:58:32 +13:00
if track:
# Convert the Track to JSON,
# We use different serialization methods depending on the Mopidy version
if MOPIDY_V4:
# Mopidy v4+ uses Pydantic models with .model_dump()
track_data = track.model_dump()
images_result = self.core.library.get_images([track_data["uri"]]).get()
if images_result and track_data["uri"] in images_result:
images = images_result[track_data["uri"]]
# Images in Mopidy v4 are a list of Image objects
meta["images"] = [img.model_dump() for img in images]
else:
# Older versions use the legacy ModelJSONEncoder:
# Convert the Track to JSON, but to make it a response-ready JSON we need to load it.
# Required because ModelJSONEncoder produces single-quote JSON, and we need standard
# double-quoted json.
track_data = json.loads(json.dumps(track, cls=ModelJSONEncoder))
images_result = self.core.library.get_images([track_data["uri"]]).get()
if images_result and track_data["uri"] in images_result:
images = images_result[track_data["uri"]]
meta["images"] = json.loads(json.dumps(images, cls=ModelJSONEncoder))
meta["name"] = track_data["name"]
meta["uri"] = track_data["uri"]
meta["artists"] = track_data["artists"]
meta["album"] = track_data["album"]
url = "http"
if self.config["iris"]["snapcast_ssl"]:
url += "s"
url += "://" + self.config["iris"]["snapcast_host"]
url += ":" + self.config["iris"]["snapcast_port"]
url += "/jsonrpc"
logger.info("Updating Snapcast stream metadata: " + url)
data = {
"id": 1,
"jsonrpc": "2.0",
"method": "Stream.SetMeta",
"params": {
"id": self.config["iris"]["snapcast_stream"],
2021-10-25 20:58:32 +13:00
"meta": meta,
},
}
try:
2021-11-21 11:32:00 +13:00
http_client = AsyncHTTPClient(validate_cert=self.config["iris"]["verify_certificates"])
2021-11-21 11:51:14 +13:00
response = await http_client.fetch(url, method="POST", body=json.dumps(data))
except (urllib.error.HTTPError, urllib.error.URLError) as e:
error = json.loads(e.read())
2021-10-25 21:11:19 +13:00
logger.error("Could not update Snapcast meta")
2021-10-25 20:58:32 +13:00
response = {
"message": "Could not update Snapcast meta",
error: error,
}
except Exception as e: # noqa: B036
logger.error(e)
2021-10-25 20:58:32 +13:00
response = {"message": "Could not update Snapcast meta"}
if callback:
callback(response)
else:
return response
##
# Simple test method to debug access to system tasks
##
def test(self, *args, **kwargs):
2020-02-03 16:14:04 +13:00
callback = kwargs.get("callback", False)
ioloop = kwargs.get("ioloop", False)
2017-10-20 21:49:41 +13:00
2020-02-03 16:14:04 +13:00
self.broadcast(data={"method": "test_started"})
2020-02-03 16:14:04 +13:00
response = {"message": "Running test... please wait"}
2020-01-03 06:01:36 +13:00
2020-02-03 16:14:04 +13:00
if callback:
2020-01-03 06:01:36 +13:00
callback(response)
else:
return response
2018-11-02 16:10:38 +13:00
2020-02-03 16:14:04 +13:00
IrisSystemThread("test", ioloop, self.test_callback).run()
def test_callback(self, response, error, update):
if error:
2020-02-03 16:14:04 +13:00
self.broadcast(data={"method": "test_error", "params": error})
elif error:
2020-02-03 16:14:04 +13:00
self.broadcast(data={"method": "test_updated", "params": update})
2018-11-02 16:10:38 +13:00
else:
2020-02-06 10:12:28 +13:00
self.broadcast(data={"method": "test_finished", "params": response})