Overhaul of websocket messages

This commit is contained in:
James Barnsley
2017-02-18 12:45:48 +13:00
parent 639bcbcbce
commit 16415f8659
5 changed files with 374 additions and 316 deletions

View File

@ -4,10 +4,13 @@ from __future__ import unicode_literals
import logging, os, json
import tornado.web
import tornado.websocket
import handlers
from mopidy import config, ext
from frontend import IrisFrontend, make_iris_factory
from frontend import IrisFrontend
from http import HttpHandler
from websocket import WebsocketHandler
from core import IrisCore
logger = logging.getLogger(__name__)
__version__ = '2.12.1'
@ -40,8 +43,35 @@ class Extension( ext.Extension ):
# Add web extension
registry.add('http:app', {
'name': self.ext_name,
'factory': make_iris_factory(
registry['http:app'],
registry['http:static']
)
'factory': iris_factory
})
# create our core instance
mem.iris = IrisCore()
mem.iris.version = self.version
# Add our frontend
registry.add('frontend', IrisFrontend)
def iris_factory(config, core):
path = os.path.join( os.path.dirname(__file__), 'static')
return [
(r"/images/(.*)", tornado.web.StaticFileHandler, {
'path': config['local-images']['image_dir']
}),
(r'/http/([^/]*)', handlers.HttpHandler, {
'core': core,
'config': config
}),
(r'/ws/?', handlers.WebsocketHandler, {
'core': core,
'config': config
}),
(r'/(.*)', tornado.web.StaticFileHandler, {
'path': path,
'default_filename': 'index.html'
}),
]

200
mopidy_iris/core.py Executable file
View File

@ -0,0 +1,200 @@
from __future__ import unicode_literals
import random, string, logging, json, pykka, pylast, urllib, urllib2, os, sys, mopidy_iris, subprocess
import tornado.web
import tornado.websocket
import tornado.ioloop
from mopidy import config, ext
from mopidy.core import CoreListener
from pkg_resources import parse_version
from tornado.escape import json_encode, json_decode
from spotipy import Spotify
# import logger
logger = logging.getLogger(__name__)
class IrisCore(object):
version = 0
is_root = ( os.geteuid() == 0 )
spotify_token = False
queue_metadata = {}
connections = {}
radio = {
"enabled": 0,
"seed_artists": [],
"seed_genres": [],
"seed_tracks": []
}
def on_start(self):
logger.info('--- Starting Iris core '+self.version)
##
# Generate a random string
#
# Used for connection_ids where none is provided by client
# @return string
##
def generateGuid(self, length):
return ''.join(random.choice(string.lowercase) for i in range(length))
##
# Digest a protocol header into it's id/name parts
#
# @return dict
##
def digest_protocol(self, protocol):
# if we're a string, split into list
# this handles the different ways we get this passed (select_subprotocols gives string, headers.get gives list)
if isinstance(protocol, basestring):
# make sure we strip any spaces (IE gives "element,element", proper browsers give "element, element")
protocol = [i.strip() for i in protocol.split(',')]
# if we've been given a valid array
try:
clientid = protocol[0]
connection_id = protocol[1]
username = protocol[2]
generated = False
# invalid, so just create a default connection, and auto-generate an ID
except:
clientid = self.generateGuid(12)
connection_id = self.generateGuid(12)
username = 'Anonymous'
generated = True
# construct our protocol object, and return
return {"clientid": clientid, "connection_id": connection_id, "username": username, "generated": generated}
##
# Send a message to an individual connection
#
# @param to = recipient's connection_id
# @param data = array (any data required to include in our message)
##
def send_message(self, to, data):
self.connections[to]['connection'].write_message( json_encode(data) )
def broadcast(self, data):
for connection in self.connections.itervalues():
connection['connection'].write_message( json_encode(data) )
return {}
##
# Add a new connection
##
def add_connection(self, connection_id, connection, client):
new_connection = {
'client': client,
'connection': connection
}
self.connections[connection_id] = new_connection
self.broadcast({
'action': 'client_connected',
'client': client
})
##
# Add a new connection
##
def remove_connection(self, connection_id):
if connection_id in self.connections:
try:
del self.connections[connection_id]
self.broadcast(self.get_connections())
except:
print 'Failed to close connection to '+ connection_id
self.broadcast({
'action': 'client_disconnected',
'client': client
})
def get_config(self, data):
config = {
"spotify_username": self.config['spotify']['username'],
"country": self.config['iris']['country'],
"locale": self.config['iris']['locale']
}
return {
'config': config
}
def get_version(self, data):
url = 'https://pypi.python.org/pypi/Mopidy-Iris/json'
req = urllib2.Request(url)
try:
response = urllib2.urlopen(req, timeout=30).read()
response = json.loads(response)
latest_version = response['info']['version']
# compare our versions, and convert result to boolean
upgrade_available = cmp( parse_version( latest_version ), parse_version( self.version ) )
upgrade_available = ( upgrade_available == 1 )
except urllib2.HTTPError as e:
latest_version = '0.0.0'
upgrade_available = False
return {
'version': {
'current': self.version,
'latest': latest_version,
'is_root': self.is_root,
'upgrade_available': upgrade_available
}
}
def get_connections(self, data):
connections = []
for connection in self.connections.itervalues():
connections.append(connection['client'])
return {
'connections': connections
}
def get_radio(self, data):
return {
'radio': self.radio
}
def stop_radio(self, data):
self.radio = {
"enabled": 0,
"seed_artists": [],
"seed_genres": [],
"seed_tracks": []
}
self.core.playback.stop()
self.broadcast({
'action': 'radio_stopped',
'radio': self.radio
})
return {}
def get_queue_metadata(self, data):
return {
'queue_metadata': self.queue_metadata
}

View File

@ -1,322 +1,22 @@
from __future__ import unicode_literals
import logging, json, pykka, pylast, urllib, urllib2, os, sys, mopidy_iris, subprocess
import tornado.web
import tornado.websocket
import tornado.ioloop
from mopidy import config, ext
from mopidy.core import CoreListener
from pkg_resources import parse_version
from spotipy import Spotify
from websocket import WebsocketHandler
from http import HttpHandler
import mem
import pykka
# import logger
logger = logging.getLogger(__name__)
###
# Create our factory
#
# This hooks all our components into the Mopidy registry. Called from __init__.py
##
def make_iris_factory(apps, statics):
def iris_factory(config, core):
path = os.path.join( os.path.dirname(__file__), 'static')
frontend = IrisFrontend(config, core)
return [
(r"/images/(.*)", tornado.web.StaticFileHandler, {
"path": config['local-images']['image_dir']
}),
(r'/http/([^/]*)', HttpHandler, {
"frontend": frontend
}),
(r'/ws/?', WebsocketHandler, {
"frontend": frontend
}),
(r'/(.*)', tornado.web.StaticFileHandler, {
"path": path,
"default_filename": "index.html"
}),
]
return iris_factory
###
# Spotmop supporting frontend
#
# This provides a wrapping thread for the Pusher websocket, as well as the radio infrastructure
##
class IrisFrontend(pykka.ThreadingActor, CoreListener):
def __init__(self, config, core):
super(IrisFrontend, self).__init__()
self.config = config
self.core = core
self.version = mopidy_iris.__version__
self.is_root = ( os.geteuid() == 0 )
self.spotify_token = False
self.queue_metadata = {}
self.connections = {}
self.radio = {
"enabled": 0,
"seed_artists": [],
"seed_genres": [],
"seed_tracks": []
}
mem.iris.core = core
mem.iris.config = config
def on_start(self):
logger.info('Starting Iris '+self.version)
print '--- Starting IrisFrontend'
##
# Get a new spotify authentication token for server-side use
#
# 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.
##
def refresh_spotify_token( self ):
url = 'https://accounts.spotify.com/api/token'
authorization = 'YTg3ZmI0ZGJlZDMwNDc1YjhjZWMzODUyM2RmZjUzZTI6ZDdjODlkMDc1M2VmNDA2OGJiYTE2NzhjNmNmMjZlZDY='
headers = {'Authorization' : 'Basic ' + authorization}
data = {'grant_type': 'client_credentials'}
data_encoded = urllib.urlencode( data )
req = urllib2.Request(url, data_encoded, headers)
try:
response = urllib2.urlopen(req, timeout=30).read()
response_dict = json.loads(response)
self.spotify_token = response_dict
return response_dict
except urllib2.HTTPError as e:
return e
##
# Listen for core events, and update our frontend as required
##
def track_playback_ended( self, tl_track, time_position ):
self.check_for_radio_update()
def tracklist_changed( self ):
self.clean_queue_metadata()
##
# See if we need to perform updates to our radio
#
# We see if we've got one or two tracks left, if so, go get some more
##
def check_for_radio_update( self ):
try:
tracklistLength = self.core.tracklist.length.get()
if( tracklistLength <= 5 and self.radio['enabled'] == 1 ):
self.load_more_tracks()
except RuntimeError:
self.websocket.broadcast('error', {'source': 'check_for_radio_update', 'message': 'Could not fetch tracklist length'})
logger.warning('IrisFrontend: Could not fetch tracklist length')
pass
##
# Load some more radio tracks
#
# We need to build a Spotify authentication token first, and then fetch recommendations
##
def load_more_tracks( self ):
# this is crude, but it means we don't need to handle expired tokens
# TODO: address this when it's clear what Jodal and the team want to do with Pyspotify
self.refresh_spotify_token()
try:
token = self.spotify_token
token = token['access_token']
except:
logger.error('IrisFrontend: access_token missing or invalid')
self.websocket.broadcast('error', {'source': 'load_more_tracks', 'message': 'access_token missing or invalid'})
try:
spotify = Spotify( auth = token )
response = spotify.recommendations(seed_artists = self.radio['seed_artists'], seed_genres = self.radio['seed_genres'], seed_tracks = self.radio['seed_tracks'], limit = 5)
uris = []
for track in response['tracks']:
uris.append( track['uri'] )
self.core.tracklist.add( uris = uris )
except:
self.websocket.broadcast('error', {'source': 'load_more_tracks', 'message': 'Failed to fetch Spotify recommendations'})
logger.error('IrisFrontend: Failed to fetch Spotify recommendations')
##
# Start radio
#
# Take the provided radio details, and start a new radio process
##
def start_radio( self, new_state ):
# set our new radio state
self.radio = new_state
self.radio['enabled'] = 1;
# clear all tracks
self.core.tracklist.clear()
# explicitly set consume, to ensure we don't end up with a huge tracklist (and it's how a radio should 'feel')
self.core.tracklist.set_consume( True )
# load me some tracks, and start playing!
self.load_more_tracks()
self.core.playback.play()
# notify clients
self.websocket.broadcast('radio', { 'radio': self.radio })
# return new radio state to initial call
return self.radio
##
# Stop radio
##
def stop_radio( self ):
# reset radio
self.radio = {
"enabled": 0,
"seed_artists": [],
"seed_genres": [],
"seed_tracks": []
}
# stop track playback
self.core.playback.stop()
# notify clients
self.websocket.broadcast( 'radio', { 'radio': self.radio })
# return new radio state to initial call
return self.radio
# get our spotify token
def get_spotify_token( self ):
return self.spotify_token
##
# Queue metadata
##
def get_queue_metadata( self ):
return self.queue_metadata
def add_queue_metadata( self, tlids, added_from, added_by ):
for tlid in tlids:
item = {
'tlid': tlid,
'added_from': added_from,
'added_by': added_by
}
self.queue_metadata['tlid_'+str(tlid)] = item
# broadcast to all clients
self.websocket.broadcast('queue_metadata', {'queue_metadata': self.queue_metadata})
return self.queue_metadata
# fetch our tracklist, and remove any metadata for tlids that don't exist anymore
def clean_queue_metadata( self ):
cleaned_queue_metadata = {}
# get and loop all tltracks
for tltrack in self.core.tracklist.get_tl_tracks().get():
# if we have metadata for this track, push it through to cleaned dictionary
if 'tlid_'+str(tltrack.tlid) in self.queue_metadata:
cleaned_queue_metadata['tlid_'+str(tltrack.tlid)] = self.queue_metadata['tlid_'+str(tltrack.tlid)]
# update our cleaned store
self.queue_metadata = cleaned_queue_metadata
# broadcast to all clients
self.websocket.broadcast('queue_metadata', {'queue_metadata': self.queue_metadata})
##
# System configuration
#
# This enables Iris to respect system config
##
def get_config( self ):
all_config = self.config
config = {
"spotify_username": all_config['spotify']['username'],
"country": all_config['iris']['country'],
"locale": all_config['iris']['locale']
}
return config
##
# Get Spotmop version, and check for updates
#
# We compare our version with the latest available on PyPi
##
def get_version( self ):
url = 'https://pypi.python.org/pypi/Mopidy-Iris/json'
req = urllib2.Request(url)
try:
response = urllib2.urlopen(req, timeout=30).read()
response = json.loads(response)
latest_version = response['info']['version']
# compare our versions, and convert result to boolean
upgrade_available = cmp( parse_version( latest_version ), parse_version( self.version ) )
upgrade_available = ( upgrade_available == 1 )
except urllib2.HTTPError as e:
latest_version = '0.0.0'
upgrade_available = False
# prepare our response
data = {
'current': self.version,
'latest': latest_version,
'is_root': self.is_root,
'upgrade_available': upgrade_available
}
return data
##
# Upgrade Spotmop module
#
# Upgrade myself to the latest version available on PyPi
##
def perform_upgrade( self ):
try:
subprocess.check_call(["pip", "install", "--upgrade", "Mopidy-Iris"])
return True
except subprocess.CalledProcessError:
return False
##
# Restart Mopidy
#
# This is untested and may require installation of an upstart script to properly restart
##
def restart( self ):
os.execl(sys.executable, *([sys.executable]+sys.argv))
def track_playback_started(self, tl_track):
mem.iris.broadcast({
'action': 'started_playback'
})

127
mopidy_iris/handlers.py Executable file
View File

@ -0,0 +1,127 @@
from __future__ import unicode_literals
import tornado.ioloop, tornado.web, tornado.websocket, tornado.template
import random, string, logging, uuid, subprocess, pykka
from datetime import datetime
from tornado.escape import json_encode, json_decode
import logging, json, urllib, urllib2
import tornado.web
from spotipy import Spotify
import mem
logger = logging.getLogger(__name__)
class WebsocketHandler(tornado.websocket.WebSocketHandler):
# initiate (not the actual object __init__, but run shortly after)
def initialize(self, core, config):
self.core = core
self.config = config
def check_origin(self, origin):
return True
def select_subprotocol(self, subprotocols):
# select one of our subprotocol elements and return it. This confirms the connection has been accepted.
protocols = mem.iris.digest_protocol( subprotocols )
# if we've auto-generated some ids, the provided subprotocols was a string, so just return it right back
# this allows a connection to be completed
if protocols['generated']:
return subprotocols[0]
# otherwise, just return one of the supplied subprotocols
else:
return protocols['clientid']
def open(self):
# decode our connection protocol value (which is a payload of id/name from javascript)
protocolElements = mem.iris.digest_protocol(self.request.headers.get('Sec-Websocket-Protocol', []))
connection_id = protocolElements['connection_id']
clientid = protocolElements['clientid']
self.connection_id = connection_id
username = protocolElements['username']
created = datetime.strftime(datetime.now(), '%Y-%m-%d %H:%M:%S')
# construct our client object, and add to our list of connections
client = {
'clientid': clientid,
'connection_id': connection_id,
'username': username,
'ip': self.request.remote_ip,
'created': created
}
# add to connections
mem.iris.add_connection(connection_id, self, client)
def on_message(self, message):
message = json_decode(message)
if 'data' in message:
data = message['data']
else:
data = {}
if 'request_id' in message:
request_id = message['request_id']
else:
request_id = False
# call the method, as specified in payload
if 'method' in message:
# make sure the method exists
if hasattr(mem.iris, message['method']):
# make the call, and return it's response
response = getattr(mem.iris, message['method'])(data)
response['request_id'] = request_id
mem.iris.send_message(self.connection_id, response)
else:
response = {
'error': 'Method "'+message['method']+'" does not exist',
'request_id': request_id
}
mem.iris.send_message(self.connection_id, response)
else:
response = {
'error': 'Method key missing from request',
'request_id': request_id
}
mem.iris.send_message(self.connection_id, response)
def on_close(self):
mem.iris.remove_connection(self.connection_id)
class HttpHandler(tornado.web.RequestHandler):
def set_default_headers(self):
self.set_header("Access-Control-Allow-Origin", "*")
def initialize(self, core, config):
self.core = core
self.config = config
def get(self, slug=None):
if( slug == 'refresh_spotify_token' ):
self.write( mem.iriscore.refresh_spotify_token() )
return
else:
self.write('Invalid request')
return

View File

@ -1,2 +1,3 @@
queuemanager = None
localfiles = None
iris = None