From 16415f86596d9dc2a5c313011e23dbcea4ca7580 Mon Sep 17 00:00:00 2001 From: James Barnsley Date: Sat, 18 Feb 2017 12:45:48 +1300 Subject: [PATCH 01/11] Overhaul of websocket messages --- mopidy_iris/__init__.py | 40 ++++- mopidy_iris/core.py | 200 +++++++++++++++++++++++++ mopidy_iris/frontend.py | 318 ++-------------------------------------- mopidy_iris/handlers.py | 127 ++++++++++++++++ mopidy_iris/mem.py | 5 +- 5 files changed, 374 insertions(+), 316 deletions(-) create mode 100755 mopidy_iris/core.py create mode 100755 mopidy_iris/handlers.py diff --git a/mopidy_iris/__init__.py b/mopidy_iris/__init__.py index 1cd5a531..fb279009 100755 --- a/mopidy_iris/__init__.py +++ b/mopidy_iris/__init__.py @@ -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' + }), + ] \ No newline at end of file diff --git a/mopidy_iris/core.py b/mopidy_iris/core.py new file mode 100755 index 00000000..634d5d09 --- /dev/null +++ b/mopidy_iris/core.py @@ -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 + } + diff --git a/mopidy_iris/frontend.py b/mopidy_iris/frontend.py index 682142eb..abea147d 100755 --- a/mopidy_iris/frontend.py +++ b/mopidy_iris/frontend.py @@ -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' + }) \ No newline at end of file diff --git a/mopidy_iris/handlers.py b/mopidy_iris/handlers.py new file mode 100755 index 00000000..5f141fe7 --- /dev/null +++ b/mopidy_iris/handlers.py @@ -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 + diff --git a/mopidy_iris/mem.py b/mopidy_iris/mem.py index 0dbd2b36..e75471ef 100755 --- a/mopidy_iris/mem.py +++ b/mopidy_iris/mem.py @@ -1,2 +1,3 @@ -queuemanager = None -localfiles = None \ No newline at end of file + +iris = None + From b1af53fed931983b7c315393e1e8b8c73808e9bb Mon Sep 17 00:00:00 2001 From: James Barnsley Date: Sat, 18 Feb 2017 22:04:05 +1300 Subject: [PATCH 02/11] Hooking websocket into react --- mopidy_iris/__init__.py | 3 +- mopidy_iris/core.py | 217 +++++++++++-- mopidy_iris/frontend.py | 15 +- mopidy_iris/handlers.py | 22 +- mopidy_iris/http.py | 53 ---- mopidy_iris/websocket.py | 359 ---------------------- src/js/components/PusherConnectionList.js | 8 +- src/js/services/pusher/middleware.js | 256 ++++++++------- src/js/services/pusher/reducer.js | 12 +- src/js/services/spotify/actions.js | 9 +- src/js/services/spotify/middleware.js | 4 +- src/js/services/ui/middleware.js | 4 +- src/js/services/ui/reducer.js | 9 +- 13 files changed, 363 insertions(+), 608 deletions(-) delete mode 100755 mopidy_iris/http.py delete mode 100755 mopidy_iris/websocket.py diff --git a/mopidy_iris/__init__.py b/mopidy_iris/__init__.py index fb279009..9ea2b496 100755 --- a/mopidy_iris/__init__.py +++ b/mopidy_iris/__init__.py @@ -8,8 +8,7 @@ import handlers from mopidy import config, ext from frontend import IrisFrontend -from http import HttpHandler -from websocket import WebsocketHandler +from handlers import WebsocketHandler, HttpHandler from core import IrisCore logger = logging.getLogger(__name__) diff --git a/mopidy_iris/core.py b/mopidy_iris/core.py index 634d5d09..75b0a8b4 100755 --- a/mopidy_iris/core.py +++ b/mopidy_iris/core.py @@ -74,12 +74,7 @@ class IrisCore(object): # 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) ) @@ -90,8 +85,22 @@ class IrisCore(object): return {} ## - # Add a new connection + # Connections + # + # 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 ## + + def get_connections(self, data): + connections = [] + for connection in self.connections.itervalues(): + connections.append(connection['client']) + + return { + 'connections': connections + } + def add_connection(self, connection_id, connection, client): new_connection = { 'client': client, @@ -100,29 +109,48 @@ class IrisCore(object): self.connections[connection_id] = new_connection self.broadcast({ - 'action': 'client_connected', + 'type': 'client_connected', 'client': client }) - ## - # Add a new connection - ## def remove_connection(self, connection_id): if connection_id in self.connections: try: + client = self.connections[connection_id]['client'] del self.connections[connection_id] - self.broadcast(self.get_connections()) + self.broadcast({ + 'type': 'client_disconnected', + 'client': client + }) except: - print 'Failed to close connection to '+ connection_id - + logger.error('Failed to close connection to '+ connection_id) + + def set_username(self, data): + connection_id = data['connection_id'] + if connection_id in self.connections: + self.connections[connection_id]['client']['username'] = data['username'] self.broadcast({ - 'action': 'client_disconnected', - 'client': client + 'type': 'connection_updated', + 'connection': self.connections[connection_id]['client'] }) - + return {} + + else: + error = 'Connection "'+data['connection_id']+'" not found' + logger.error(error) + return { + 'error': error + } + + ## + # System controls + # + # Faciitates upgrades and configuration fetching + ## + def get_config(self, data): config = { "spotify_username": self.config['spotify']['username'], @@ -133,7 +161,6 @@ class IrisCore(object): 'config': config } - def get_version(self, data): url = 'https://pypi.python.org/pypi/Mopidy-Iris/json' @@ -161,22 +188,47 @@ class IrisCore(object): } } - def get_connections(self, data): - connections = [] - for connection in self.connections.itervalues(): - connections.append(connection['client']) + def perform_upgrade( self ): + try: + subprocess.check_call(["pip", "install", "--upgrade", "Mopidy-Iris"]) + return True + except subprocess.CalledProcessError: + return False - return { - 'connections': connections - } + def restart( self ): + os.execl(sys.executable, *([sys.executable]+sys.argv)) + + + ## + # Spotify Radio + # + # 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 + ## def get_radio(self, data): return { 'radio': self.radio } - def stop_radio(self, data): + def start_radio(self, data): + self.radio = data + self.radio['enabled'] = 1; + + self.core.tracklist.clear() + self.core.tracklist.set_consume( True ) + self.load_more_tracks() + self.core.playback.play() + + self.broadcast({ + 'type': 'radio_started', + 'radio': self.radio + }) + + return self.get_radio({}) + def stop_radio(self, data): self.radio = { "enabled": 0, "seed_artists": [], @@ -187,14 +239,125 @@ class IrisCore(object): self.core.playback.stop() self.broadcast({ - 'action': 'radio_stopped', + 'type': 'radio_stopped', 'radio': self.radio }) return {} + + 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.broadcast({ + 'error': 'Could not get radio tracks: 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: + logger.error('IrisFrontend: Failed to fetch Spotify recommendations') + self.broadcast({ + 'error': 'Failed to fetch radio recommendations' + }) + + + ## + # Additional queue metadata + # + # This maps tltracks with extra info for display in Iris, including + # added_by and from_uri. + ## + def get_queue_metadata(self, data): return { 'queue_metadata': self.queue_metadata } + def add_queue_metadata(self, data): + + for tlid in data['tlids']: + item = { + 'tlid': tlid, + 'added_from': data['added_from'], + 'added_by': data['added_by'] + } + self.queue_metadata['tlid_'+str(tlid)] = item + + self.broadcast({ + 'type': 'queue_metadata_changed', + 'queue_metadata': self.queue_metadata + }) + + return {} + + def clean_queue_metadata( self ): + cleaned_queue_metadata = {} + + 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)] + + self.queue_metadata = cleaned_queue_metadata + + self.broadcast({ + 'type': 'queue_metadata_changed', + 'queue_metadata': self.queue_metadata + }) + + return {} + + + ## + # Spotify authentication + # + # 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 get_spotify_token(self, data): + return { + 'spotify_token': self.spotify_token + } + + def refresh_spotify_token(self, data): + + 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 + + self.broadcast({ + 'type': 'spotify_token_changed', + 'spotify_token': self.spotify_token + }) + + return self.get_spotify_token({}) + except urllib2.HTTPError as e: + return e diff --git a/mopidy_iris/frontend.py b/mopidy_iris/frontend.py index abea147d..1d35f2e0 100755 --- a/mopidy_iris/frontend.py +++ b/mopidy_iris/frontend.py @@ -4,6 +4,10 @@ from mopidy.core import CoreListener import mem import pykka +import logging + +# import logger +logger = logging.getLogger(__name__) class IrisFrontend(pykka.ThreadingActor, CoreListener): @@ -13,10 +17,11 @@ class IrisFrontend(pykka.ThreadingActor, CoreListener): mem.iris.config = config def on_start(self): - print '--- Starting IrisFrontend' + logger.info('Starting Iris '+mem.iris.version) - def track_playback_started(self, tl_track): - mem.iris.broadcast({ - 'action': 'started_playback' - }) + def track_playback_ended( self, tl_track, time_position ): + mem.iris.check_for_radio_update() + + def tracklist_changed( self ): + mem.iris.clean_queue_metadata() \ No newline at end of file diff --git a/mopidy_iris/handlers.py b/mopidy_iris/handlers.py index 5f141fe7..9c330ebe 100755 --- a/mopidy_iris/handlers.py +++ b/mopidy_iris/handlers.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import tornado.ioloop, tornado.web, tornado.websocket, tornado.template -import random, string, logging, uuid, subprocess, pykka +import random, string, logging, uuid, subprocess, pykka, ast from datetime import datetime from tornado.escape import json_encode, json_decode import logging, json, urllib, urllib2 @@ -62,6 +62,7 @@ class WebsocketHandler(tornado.websocket.WebSocketHandler): def on_message(self, message): + message = json_decode(message) if 'data' in message: @@ -69,6 +70,8 @@ class WebsocketHandler(tornado.websocket.WebSocketHandler): else: data = {} + data['connection_id'] = self.connection_id + if 'request_id' in message: request_id = message['request_id'] else: @@ -82,8 +85,9 @@ class WebsocketHandler(tornado.websocket.WebSocketHandler): # 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) + if response: + response['request_id'] = request_id + mem.iris.send_message(self.connection_id, response) else: response = { 'error': 'Method "'+message['method']+'" does not exist', @@ -117,11 +121,13 @@ class HttpHandler(tornado.web.RequestHandler): def get(self, slug=None): - if( slug == 'refresh_spotify_token' ): - self.write( mem.iriscore.refresh_spotify_token() ) - return + # make sure the method exists + if hasattr(mem.iris, slug): + # make the call, and return it's response + self.write(getattr(mem.iris, slug)({})) else: - self.write('Invalid request') - return + self.write({ + 'error': 'Method "'+slug+'" does not exist' + }) diff --git a/mopidy_iris/http.py b/mopidy_iris/http.py deleted file mode 100755 index b6e57888..00000000 --- a/mopidy_iris/http.py +++ /dev/null @@ -1,53 +0,0 @@ - -from __future__ import unicode_literals - -import logging, json, urllib, urllib2 -import tornado.web -from spotipy import Spotify - -# import logger -logger = logging.getLogger(__name__) - -class HttpHandler(tornado.web.RequestHandler): - - def set_default_headers(self): - self.set_header("Access-Control-Allow-Origin", "*") - - def initialize(self, frontend): - self.frontend = frontend - - def get(self, slug=None): - - if( slug == 'refresh_spotify_token' ): - self.write( self.refresh_spotify_token() ) - return - - else: - self.write('Invalid request') - return - - - ## - # Get a new spotify authentication token - # - # 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 = json.loads(response) - return response - except urllib2.HTTPError as e: - return e - diff --git a/mopidy_iris/websocket.py b/mopidy_iris/websocket.py deleted file mode 100755 index 5b6470f1..00000000 --- a/mopidy_iris/websocket.py +++ /dev/null @@ -1,359 +0,0 @@ - -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 - -logger = logging.getLogger(__name__) - -# generate random string -def generateGuid(length): - return ''.join(random.choice(string.lowercase) for i in range(length)) - -# digest a protocol header into it's id/name parts -def digest_protocol( 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] - connectionid = protocol[1] - username = protocol[2] - generated = False - - # invalid, so just create a default connection, and auto-generate an ID - except: - clientid = generateGuid(12) - connectionid = generateGuid(12) - username = 'Anonymous' - generated = True - - # construct our protocol object, and return - return {"clientid": clientid, "connectionid": connectionid, "username": username, "generated": generated} - - -## -# Websocket server -# -# This is the actual websocket thread that accepts, digests and emits messages. -# TODO: Figure out how to merge this into the main Mopidy websocket to avoid needing two websocket servers -## -class WebsocketHandler(tornado.websocket.WebSocketHandler): - - - # initiate (not the actual object __init__, but run shortly after) - def initialize(self, frontend): - - # add this websocket instance to our Frontend - frontend.websocket = self - self.frontend = frontend - - - # when a new connection is opened - def open(self): - - # decode our connection protocol value (which is a payload of id/name from javascript) - protocolElements = digest_protocol(self.request.headers.get('Sec-Websocket-Protocol', [])) - - connectionid = protocolElements['connectionid'] - clientid = protocolElements['clientid'] - self.connectionid = connectionid - 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, - 'connectionid': connectionid, - 'username': username, - 'ip': self.request.remote_ip, - 'created': created - } - self.frontend.connections[connectionid] = { - 'client': client, - 'connection': self - } - - logger.info( 'Pusher connection established: '+ connectionid +' ('+ clientid +'/'+ username +')' ) - - # broadcast to all connections that a new user has connected - self.broadcast( 'new_connection', client ) - - - 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 = 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'] - - - # server received a message - def on_message(self, message): - messageJson = json_decode(message) - - # construct the origin client info - messageJson['origin'] = { - 'connectionid' : self.connectionid, - 'clientid': self.frontend.connections[self.connectionid]['client']['clientid'], - 'ip': self.request.remote_ip, - 'username': self.frontend.connections[self.connectionid]['client']['username'] - } - - logger.debug('Pusher message received: '+message) - - # broadcast message to other connections (except for self) - if messageJson['action'] == 'broadcast': - - # respond to request with status update - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'status': 'Ok' } - ) - - for connection in self.frontend.connections.itervalues(): - if connection['client']['connectionid'] != self.connectionid: - connection['connection'].write_message(messageJson) - - # send authroization details - elif messageJson['action'] == 'send_authorization': - - # make sure we actually have a connection matching the provided connectionid - if messageJson['recipient_connectionid'] in self.frontend.connections: - - # send payload to recipient - authorization_message = { - 'type': 'broadcast', - 'action': 'received_authorization', - 'authorization': messageJson['authorization'], - 'me': messageJson['me'], - 'origin': messageJson['origin'] - } - self.frontend.connections[messageJson['recipient_connectionid']]['connection'].write_message(authorization_message) - - # respond to request with status update - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'status': 'Ok' } - ) - else: - # respond to request with status update - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'error': 'Could not send to that connection, does not exist' } - ) - - # fetch our pusher connections - elif messageJson['action'] == 'get_config': - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'config': self.frontend.get_config() } - ) - - # fetch our pusher connections - elif messageJson['action'] == 'get_connections': - - connectionsDetailsList = [] - for connection in self.frontend.connections.itervalues(): - connectionsDetailsList.append(connection['client']) - - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'connections': connectionsDetailsList } - ) - - # add some queue metadata - elif messageJson['action'] == 'add_queue_metadata': - queue_metadata = self.frontend.add_queue_metadata( - messageJson['tlids'], - messageJson['added_from'], - self.frontend.connections[self.connectionid]['client']['username'] - ) - - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'queue_metadata': queue_metadata } - ) - - # get our queue metadata (added_by, from, etc) - elif messageJson['action'] == 'get_queue_metadata': - - connectionsDetailsList = [] - for connection in self.frontend.connections.itervalues(): - connectionsDetailsList.append(connection['client']) - - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'queue_metadata': self.frontend.get_queue_metadata() } - ) - - # change connection's client username - elif messageJson['action'] == 'set_username': - - # username is the only value we allow clients to change - self.frontend.connections[messageJson['origin']['connectionid']]['client']['username'] = messageJson['username'] - - # respond to request - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'username': messageJson['username'] } - ) - - # notify all clients of this change - self.broadcast( 'connection_updated', { 'connection': self.frontend.connections[messageJson['origin']['connectionid']]['client'] }) - - # start radio - elif messageJson['action'] == 'start_radio': - - # pull out just the radio data (we don't want all the request_id guff) - radio = { - 'enabled': 1, - 'seed_artists': messageJson['seed_artists'], - 'seed_genres': messageJson['seed_genres'], - 'seed_tracks': messageJson['seed_tracks'] - } - radio = self.frontend.start_radio( radio ) - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'radio': radio } - ) - - # stop radio - elif messageJson['action'] == 'stop_radio': - radio = self.frontend.stop_radio() - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'radio': self.frontend.radio } - ) - - # fetch our current radio state - elif messageJson['action'] == 'get_radio': - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'radio': self.frontend.radio } - ) - - # get system version and check for upgrade - elif messageJson['action'] == 'get_version': - version = self.frontend.get_version() - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'version': version } - ) - - # perform upgrade - elif messageJson['action'] == 'upgrade': - version = self.frontend.get_version() - upgrade_successful = self.frontend.perform_upgrade() - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'upgrade_successful': upgrade_successful, 'version': version } - ) - - # restart mopidy - elif messageJson['action'] == 'restart': - self.frontend.restart() - - # not an action we recognise! - else: - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'error': 'Unhandled action' } - ) - - logger.debug( 'Pusher: Unhandled message received from '+ self.connectionid ) - - - # connection closed - def on_close(self): - if self.connectionid in self.frontend.connections: - - clientRemoved = self.frontend.connections[self.connectionid]['client'] - logger.debug( 'Spotmop Pusher connection to '+ self.connectionid +' closed' ) - - # now actually remove it - try: - del self.frontend.connections[self.connectionid] - except: - logger.info( 'Failed to close connection to '+ self.connectionid ) - - self.broadcast( 'client_disconnected', clientRemoved ) - - ## - # Send a message to an individual connection - # - # @param recipient_connection_ids = array - # @param action = string (action method of this message) - # @param request_id = string (used for callbacks) - # @param data = array (any data required to include in our message) - ## - def send_message( self, recipient_connection_id, action, request_id, data ): - message = { - 'action': action, - 'request_id': request_id, - 'data': data - } - self.frontend.connections[recipient_connection_id]['connection'].write_message( json_encode(message) ) - - ## - # Broadcast a message to all recipients - # - # @param action = string - # @param data = array (the body of our message to send) - ## - def broadcast( self, type, data ): - for connection in self.frontend.connections.itervalues(): - message = { - 'action': 'broadcast', - 'type': type, - 'data': data - } - connection['connection'].write_message( json_encode(message) ) - - - - \ No newline at end of file diff --git a/src/js/components/PusherConnectionList.js b/src/js/components/PusherConnectionList.js index 163c31b2..1957ef56 100755 --- a/src/js/components/PusherConnectionList.js +++ b/src/js/components/PusherConnectionList.js @@ -35,13 +35,13 @@ class PusherConnectionList extends React.Component{ { this.props.connections.map( (connection, index) => { var is_me = false; - if( connection.connectionid == this.props.connectionid ) is_me = true; + if( connection.connection_id == this.props.connection_id ) is_me = true; return ( -
+
{ connection.username } { is_me ? (you) : null }
{ connection.ip } - ({ connection.connectionid }) + ({ connection.connection_id })
); @@ -55,7 +55,7 @@ class PusherConnectionList extends React.Component{ const mapStateToProps = (state, ownProps) => { return { connected: state.pusher.connected, - connectionid: state.pusher.connectionid, + connection_id: state.pusher.connection_id, connections: state.pusher.connections } } diff --git a/src/js/services/pusher/middleware.js b/src/js/services/pusher/middleware.js index f45d99b8..54a99aa3 100755 --- a/src/js/services/pusher/middleware.js +++ b/src/js/services/pusher/middleware.js @@ -22,60 +22,46 @@ const PusherMiddleware = (function(){ // handle all manner of socket messages const handleMessage = (ws, store, message) => { + console.log(message) + // response to a request [we] made + if (typeof(message.request_id) !== 'undefined' && message.request_id){ + if (typeof( deferredRequests[ message.request_id ]) !== 'undefined' ){ + deferredRequests[ message.request_id ].resolve( message ) + } else { + console.error('Pusher: Response with no matching request', message); + } - //console.log('handleMessage', message) - - switch (message.action){ - case 'response': - if (typeof( deferredRequests[ message.request_id ]) !== 'undefined' ){ - deferredRequests[ message.request_id ].resolve( message ) - } else { - console.error('Pusher: Response with no matching request', message); - } - break - - case 'broadcast': - if (message.type ){ - var type = message.type.toUpperCase() - } else if (message.data.type ){ - var type = message.data.type.toUpperCase() - } else { - var type = 'UNRECOGNISED_BROADCAST' - } - store.dispatch({ type: type, data: message.data }) - break + // general message + // this can be client-client, server-client or a broadcast to many clients + } else { + message.type = message.type.toUpperCase() + store.dispatch(message) } } - const request = (data) => { + const request = (method, data = {}) => { return new Promise( (resolve, reject) => { - - // send the payload - data.request_id = helpers.generateGuid() - socket.send( JSON.stringify(data) ) + var request_id = helpers.generateGuid() + var message = { + method: method, + data: data, + request_id: request_id + } + socket.send( JSON.stringify(message) ) // add query to our deferred responses - deferredRequests[ data.request_id ] = { + deferredRequests[request_id] = { resolve: resolve, reject: reject } }) } - - /** - * Middleware - * - * This behaves like an action interceptor. We listen for specific actions - * and handle special functionality. If the action is not in our switch, then - * it just proceeds to the next middleware, or default functionality - **/ return store => next => action => { - switch(action.type) { case 'PUSHER_INSTRUCT': - request( action.data ) + request( action ) .then( response => { store.dispatch({ type: 'PUSHER_INSTRUCT', data: response.data }) @@ -91,7 +77,7 @@ const PusherMiddleware = (function(){ var state = store.getState(); var connection = { clientid: helpers.generateGuid(), - connectionid: helpers.generateGuid(), + connection_id: helpers.generateGuid(), username: 'Anonymous' } if( state.pusher.username ) connection.username = state.pusher.username; @@ -99,7 +85,7 @@ const PusherMiddleware = (function(){ socket = new WebSocket( 'ws://'+state.mopidy.host+':'+state.mopidy.port+'/iris/ws', - [ connection.clientid, connection.connectionid, connection.username ] + [ connection.clientid, connection.connection_id, connection.username ] ); socket.onopen = () => { @@ -115,44 +101,46 @@ const PusherMiddleware = (function(){ break; case 'PUSHER_CONNECTED': - request({ action: 'get_config' }) + request('get_config') .then( response => { - if (response.data.error){ - console.error(response.data.error) + if (response.error){ + console.error(response.error) return false } - store.dispatch({ type: 'CONFIG', config: response.data.config }) - if (response.data.config.spotify_username){ - store.dispatch(spotifyActions.getUser('spotify:user:'+response.data.config.spotify_username)) + response.type = 'CONFIG' + store.dispatch(response) + if (response.config.spotify_username){ + store.dispatch(spotifyActions.getUser('spotify:user:'+response.config.spotify_username)) } var spotify = store.getState().spotify if (!spotify.country || !spotify.locale){ - store.dispatch({ type: 'SPOTIFY_SET_CONFIG', config: response.data.config }) + store.dispatch({ type: 'SPOTIFY_SET_CONFIG', config: response.config }) } } ) - request({ action: 'get_version' }) + request('get_version') .then( response => { - if (response.data.error){ - console.error(response.data.error) + if (response.error){ + console.error(response.error) return false } - - store.dispatch({ type: 'VERSION', data: response.data }) + response.type = 'VERSION' + store.dispatch(response) } ) - request({ action: 'get_radio' }) + request('get_radio') .then( response => { - if (response.data.error){ - console.error(response.data.error) + if (response.error){ + console.error(response.error) return false } - store.dispatch({ type: 'RADIO', data: response.data }) + response.type = 'RADIO' + store.dispatch(response) } ) @@ -162,98 +150,110 @@ const PusherMiddleware = (function(){ break; case 'ERROR': - store.dispatch( uiActions.createNotification(action.data.source+': '+action.data.message,'bad') ) + store.dispatch( uiActions.createNotification(action.source+': '+action.message,'bad') ) break; case 'PUSHER_GET_QUEUE_METADATA': - request({ action: 'get_queue_metadata'}) + request('get_queue_metadata') .then( response => { - store.dispatch({ type: 'QUEUE_METADATA', data: response.data }) + response.type = 'QUEUE_METADATA' + store.dispatch(response) } ) break; case 'PUSHER_ADD_QUEUE_METADATA': - request({ action: 'add_queue_metadata', tlids: action.tlids, added_from: action.from_uri }) + request('add_queue_metadata', { + tlids: action.tlids, + added_from: action.from_uri, + added_by: store.getState().pusher.username + }) break; case 'START_UPGRADE': - request({ action: 'upgrade' }) - .then( - response => { - if (response.data.error){ - console.error(response.data.error) - return false - } - - if (response.data.upgrade_successful){ - store.dispatch( uiActions.createNotification('Upgrade complete') ) - }else{ - store.dispatch( uiActions.createNotification('Upgrade failed, please upgrade manually','bad') ) - } - store.dispatch({ type: 'VERSION', data: response.data }) + request('upgrade') + .then( + response => { + if (response.error){ + console.error(response.error) + return false } - ) + + if (response.upgrade_successful){ + store.dispatch( uiActions.createNotification('Upgrade complete') ) + }else{ + store.dispatch( uiActions.createNotification('Upgrade failed, please upgrade manually','bad') ) + } + + response.type = 'VERSION' + store.dispatch(response) + } + ) return next(action); break; case 'PUSHER_SET_USERNAME': - request({ action: 'set_username', username: action.username }) - .then( - response => { - if (response.data.error){ - console.error(response.data.error) - return false - } - - store.dispatch({ type: 'PUSHER_USERNAME', data: { username: response.data.username }}) + request('set_username', { + username: action.username + }) + .then( + response => { + console.log(response) + if (response.error){ + console.error(response.error) + return false } - ) + + //response.type = 'PUSHER_USERNAME' + //store.dispatch(response) + } + ) return next(action); break; case 'GET_CONNECTIONS': case 'NEW_CONNECTION': - request({ action: 'get_connections' }) - .then( - response => { - if (response.data.error){ - console.error(response.data.error) - return false - } - - store.dispatch({ type: 'CONNECTIONS', data: response.data }) + request('get_connections') + .then( + response => { + if (response.error){ + console.error(response.error) + return false } - ) + + response.type = 'CONNECTIONS' + store.dispatch(response) + } + ) return next(action); break case 'PUSHER_DEBUG': - request( action.data ) - .then( - response => { - if (response.data.error){ - console.error(response.data.error) - return false - } - - store.dispatch({ type: 'DEBUG', response: response.data }) + request( action ) + .then( + response => { + if (response.error){ + console.error(response.error) + return false } - ) + + response.type = 'DEBUG' + store.dispatch(response) + } + ) break; case 'PUSHER_SEND_AUTHORIZATION': - request({ - action: 'send_authorization', - recipient_connectionid: action.recipient_connectionid, + request('send_authorization', { + recipient_connection_id: action.recipient_connection_id, authorization: action.authorization, me: action.me }) .then( response => { - if (response.data.error){ - console.error(response.data.error) + if (response.error){ + console.error(response.error) return false } @@ -263,29 +263,25 @@ const PusherMiddleware = (function(){ break; case 'PUSHER_SEND_AUTHORIZATION': - if( window.confirm('Spotify authorization for user '+action.data.me.id+' received. Do you want to import?') ){ + if( window.confirm('Spotify authorization for user '+action.me.id+' received. Do you want to import?') ){ // remove any existing authentication store.dispatch({ type: 'SPOTIFY_AUTHORIZATION_REVOKED' }) // import our new authentication - store.dispatch({ type: 'SPOTIFY_ME_LOADED', data: action.data.me }) - store.dispatch({ type: 'SPOTIFY_AUTHORIZATION_GRANTED', data: action.data.authorization }) + store.dispatch({ type: 'SPOTIFY_ME_LOADED', data: action.me }) + store.dispatch({ type: 'SPOTIFY_AUTHORIZATION_GRANTED', data: action.authorization }) }else{ console.log('Authorization ignored') } break case 'START_RADIO': - - request({ - action: 'broadcast', - data: { - type: 'browser_notification', - title: 'Radio started', - body: store.getState().pusher.username +' started radio mode', - icon: '' - } + request('broadcast', { + type: 'browser_notification', + title: 'Radio started', + body: store.getState().pusher.username +' started radio mode', + icon: '' }) .then( response => { @@ -299,7 +295,7 @@ const PusherMiddleware = (function(){ ) var data = { - action: 'start_radio', + method: 'start_radio', seed_artists: [], seed_genres: [], seed_tracks: [] @@ -323,15 +319,11 @@ const PusherMiddleware = (function(){ break case 'STOP_RADIO': - - request({ - action: 'broadcast', - data: { - type: 'browser_notification', - title: 'Radio stopped', - body: store.getState().pusher.username +' stopped radio mode', - icon: '' - } + request('broadcast', { + type: 'browser_notification', + title: 'Radio stopped', + body: store.getState().pusher.username +' stopped radio mode', + icon: '' }) .then( response => { @@ -345,7 +337,7 @@ const PusherMiddleware = (function(){ ) var data = { - action: 'stop_radio', + method: 'stop_radio', seed_artists: [], seed_genres: [], seed_tracks: [] diff --git a/src/js/services/pusher/reducer.js b/src/js/services/pusher/reducer.js index 8335e1f1..b1081253 100755 --- a/src/js/services/pusher/reducer.js +++ b/src/js/services/pusher/reducer.js @@ -10,7 +10,7 @@ export default function reducer(pusher = {}, action){ return Object.assign({}, pusher, { connected: true, connecting: false, - connectionid: action.connection.connectionid, + connection_id: action.connection.connection_id, username: action.connection.username }); @@ -21,25 +21,25 @@ export default function reducer(pusher = {}, action){ return Object.assign({}, pusher, { port: action.port }); case 'PUSHER_USERNAME': - return Object.assign({}, pusher, { username: action.data.username }); + return Object.assign({}, pusher, { username: action.username }); case 'CONNECTIONS': - return Object.assign({}, pusher, { connections: action.data.connections }); + return Object.assign({}, pusher, { connections: action.connections }); case 'CONNECTION_UPDATED': function byID(connection){ - return connection.connectionid == action.data.connection.connectionid; + return connection.connection_id == action.connection.connection_id; } var connection = pusher.connections.find(byID); var index = pusher.connections.indexOf(connection); var connections = Object.assign([], pusher.connections); - connections[index] = action.data.connection; + connections[index] = action.connection; return Object.assign({}, pusher, { connections: connections }); case 'VERSION': return Object.assign({}, pusher, { - version: action.data.version, + version: action.version, upgrading: false }); diff --git a/src/js/services/spotify/actions.js b/src/js/services/spotify/actions.js index 0219257b..4b342d7f 100755 --- a/src/js/services/spotify/actions.js +++ b/src/js/services/spotify/actions.js @@ -119,14 +119,15 @@ function refreshToken( dispatch, getState ){ }) .then( response => { - response.token_expiry = new Date().getTime() + ( response.expires_in * 1000 ); - response.source = 'mopidy'; + var token = response.spotify_token + token.token_expiry = new Date().getTime() + ( token.expires_in * 1000 ); + token.source = 'mopidy'; dispatch({ type: 'SPOTIFY_TOKEN_REFRESHED', provider: 'mopidy-spotify', - data: response + data: token }); - resolve(response); + resolve(token); }, error => { dispatch({ type: 'SPOTIFY_DISCONNECTED' }) diff --git a/src/js/services/spotify/middleware.js b/src/js/services/spotify/middleware.js index c7de90fa..fbbdb705 100755 --- a/src/js/services/spotify/middleware.js +++ b/src/js/services/spotify/middleware.js @@ -92,8 +92,8 @@ const SpotifyMiddleware = (function(){ next(action) // only resolve if radio is enabled - if( action.data.radio.enabled ){ - store.dispatch(spotifyActions.resolveRadioSeeds(action.data.radio)) + if( action.radio.enabled ){ + store.dispatch(spotifyActions.resolveRadioSeeds(action.radio)) } break diff --git a/src/js/services/ui/middleware.js b/src/js/services/ui/middleware.js index 3d3a99f5..6f6c9d2a 100755 --- a/src/js/services/ui/middleware.js +++ b/src/js/services/ui/middleware.js @@ -210,8 +210,8 @@ const UIMiddleware = (function(){ break case 'VERSION': - if( action.data.version.upgrade_available ) - store.dispatch( uiActions.createNotification( 'Version '+action.data.version.latest+' is available. See settings to upgrade.' ) ) + if( action.version.upgrade_available ) + store.dispatch( uiActions.createNotification( 'Version '+action.version.latest+' is available. See settings to upgrade.' ) ) next( action ) break diff --git a/src/js/services/ui/reducer.js b/src/js/services/ui/reducer.js index 901662b5..52fa489b 100755 --- a/src/js/services/ui/reducer.js +++ b/src/js/services/ui/reducer.js @@ -132,23 +132,24 @@ export default function reducer(ui = {}, action){ }); case 'QUEUE_METADATA': + case 'QUEUE_METADATA_CHANGED': var tracklist = Object.assign([], ui.current_tracklist) for( var i = 0; i < tracklist.length; i++ ){ // load our metadata (if we have any for that tlid) - if (typeof(action.data.queue_metadata['tlid_'+tracklist[i].tlid]) !== 'undefined'){ + if (typeof(action.queue_metadata['tlid_'+tracklist[i].tlid]) !== 'undefined'){ tracklist[i] = Object.assign( {}, tracklist[i], - action.data.queue_metadata['tlid_'+tracklist[i].tlid], + action.queue_metadata['tlid_'+tracklist[i].tlid], ) } } - return Object.assign({}, ui, { current_tracklist: tracklist, queue_metadata: action.data.queue_metadata }); + return Object.assign({}, ui, { current_tracklist: tracklist, queue_metadata: action.queue_metadata }); case 'RADIO': case 'START_RADIO': - return Object.assign({}, ui, { seeds_resolved: false }, { radio: action.data.radio }) + return Object.assign({}, ui, { seeds_resolved: false }, { radio: action.radio }) case 'RADIO_SEEDS_RESOLVED': var radio = Object.assign({}, ui.radio, { resolved_seeds: action.resolved_seeds }) From 252d031238c88240385b88854d35024460bbc49a Mon Sep 17 00:00:00 2001 From: James Barnsley Date: Sat, 18 Feb 2017 22:25:26 +1300 Subject: [PATCH 03/11] Tidying connections; Move into indexed connections --- src/js/services/pusher/middleware.js | 2 +- src/js/services/pusher/reducer.js | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/js/services/pusher/middleware.js b/src/js/services/pusher/middleware.js index 54a99aa3..5043376a 100755 --- a/src/js/services/pusher/middleware.js +++ b/src/js/services/pusher/middleware.js @@ -89,7 +89,7 @@ const PusherMiddleware = (function(){ ); socket.onopen = () => { - store.dispatch({ type: 'PUSHER_CONNECTED', connection: connection }); + store.dispatch({ type: 'PUSHER_CONNECTED', connection_id: connection.connection_id }); store.dispatch({ type: 'PUSHER_SET_USERNAME', username: connection.username }); }; diff --git a/src/js/services/pusher/reducer.js b/src/js/services/pusher/reducer.js index b1081253..49750fc1 100755 --- a/src/js/services/pusher/reducer.js +++ b/src/js/services/pusher/reducer.js @@ -7,11 +7,8 @@ export default function reducer(pusher = {}, action){ return Object.assign({}, pusher, { connected: false, connecting: true }); case 'PUSHER_CONNECTED': - return Object.assign({}, pusher, { - connected: true, - connecting: false, - connection_id: action.connection.connection_id, - username: action.connection.username + return Object.assign({}, pusher, { + connection_id: action.connection_id }); case 'PUSHER_DISCONNECTED': From 40b83037b3f2715096436f38bc7da5dba63414fa Mon Sep 17 00:00:00 2001 From: James Barnsley Date: Sun, 19 Feb 2017 21:27:43 +1300 Subject: [PATCH 04/11] Renaming of pusher events --- mopidy_iris/core.py | 21 ++++++++++--- src/js/bootstrap.js | 2 +- src/js/components/PusherConnectionList.js | 12 ++++++-- src/js/services/localstorage/middleware.js | 7 ++--- src/js/services/pusher/actions.js | 2 +- src/js/services/pusher/middleware.js | 28 +++++------------ src/js/services/pusher/reducer.js | 36 +++++++++++++--------- src/js/services/ui/middleware.js | 2 +- src/js/views/Settings.js | 2 +- 9 files changed, 61 insertions(+), 51 deletions(-) diff --git a/mopidy_iris/core.py b/mopidy_iris/core.py index 75b0a8b4..14e27eaf 100755 --- a/mopidy_iris/core.py +++ b/mopidy_iris/core.py @@ -108,9 +108,17 @@ class IrisCore(object): } self.connections[connection_id] = new_connection + self.send_message( + connection_id, { + 'type': 'connected', + 'connection_id': connection_id, + 'username': client['username'] + } + ) + self.broadcast({ - 'type': 'client_connected', - 'client': client + 'type': 'connection_added', + 'connection': client }) def remove_connection(self, connection_id): @@ -119,8 +127,8 @@ class IrisCore(object): client = self.connections[connection_id]['client'] del self.connections[connection_id] self.broadcast({ - 'type': 'client_disconnected', - 'client': client + 'type': 'connection_removed', + 'connection': client }) except: logger.error('Failed to close connection to '+ connection_id) @@ -133,7 +141,10 @@ class IrisCore(object): 'type': 'connection_updated', 'connection': self.connections[connection_id]['client'] }) - return {} + return { + 'type': 'username_changed', + 'username': data['username'] + } else: error = 'Connection "'+data['connection_id']+'" not found' diff --git a/src/js/bootstrap.js b/src/js/bootstrap.js index b174b7df..365064c6 100755 --- a/src/js/bootstrap.js +++ b/src/js/bootstrap.js @@ -37,7 +37,7 @@ var initialState = { connected: false, port: 6681, username: 'Anonymous', - connections: [], + connections: {}, version: { current: '0.0.0' } diff --git a/src/js/components/PusherConnectionList.js b/src/js/components/PusherConnectionList.js index 1957ef56..2477a920 100755 --- a/src/js/components/PusherConnectionList.js +++ b/src/js/components/PusherConnectionList.js @@ -28,12 +28,20 @@ class PusherConnectionList extends React.Component{ render(){ if( !this.props.connected ) return
Not connected
- if( typeof(this.props.connections) == 'undefined' || this.props.connections.length <= 0 ) return
No connections
; + + var connections = [] + for (var connection_id in this.props.connections){ + if (this.props.connections.hasOwnProperty(connection_id)) { + connections.push(this.props.connections[connection_id]) + } + } + + if (connections.length <= 0) return
No connections
; return (
{ - this.props.connections.map( (connection, index) => { + connections.map( (connection, index) => { var is_me = false; if( connection.connection_id == this.props.connection_id ) is_me = true; return ( diff --git a/src/js/services/localstorage/middleware.js b/src/js/services/localstorage/middleware.js index 1b7b6925..e7b12904 100755 --- a/src/js/services/localstorage/middleware.js +++ b/src/js/services/localstorage/middleware.js @@ -23,8 +23,7 @@ const localstorageMiddleware = (function(){ if( !pusher ) pusher = {}; Object.assign( pusher,{ - username: action.connection.username, - connectionid: action.connection.connectionid + connection_id: action.connection_id } ); localStorage.setItem('pusher', JSON.stringify(pusher)); @@ -37,9 +36,9 @@ const localstorageMiddleware = (function(){ localStorage.setItem('pusher', JSON.stringify(pusher)); break; - case 'PUSHER_USERNAME': + case 'PUSHER_USERNAME_CHANGED': var stored_pusher = JSON.parse( localStorage.getItem('pusher') ) - var pusher = Object.assign({}, stored_pusher, { username: action.data.username }) + var pusher = Object.assign({}, stored_pusher, { username: action.username }) localStorage.setItem('pusher', JSON.stringify(pusher)) break; diff --git a/src/js/services/pusher/actions.js b/src/js/services/pusher/actions.js index 8c4440e1..fa1a6a66 100755 --- a/src/js/services/pusher/actions.js +++ b/src/js/services/pusher/actions.js @@ -37,7 +37,7 @@ export function startUpgrade(){ export function getConnections(){ return { - type: 'GET_CONNECTIONS' + type: 'PUSHER_GET_CONNECTIONS' } } diff --git a/src/js/services/pusher/middleware.js b/src/js/services/pusher/middleware.js index 5043376a..961b15d4 100755 --- a/src/js/services/pusher/middleware.js +++ b/src/js/services/pusher/middleware.js @@ -22,7 +22,7 @@ const PusherMiddleware = (function(){ // handle all manner of socket messages const handleMessage = (ws, store, message) => { - console.log(message) + // response to a request [we] made if (typeof(message.request_id) !== 'undefined' && message.request_id){ if (typeof( deferredRequests[ message.request_id ]) !== 'undefined' ){ @@ -34,7 +34,7 @@ const PusherMiddleware = (function(){ // general message // this can be client-client, server-client or a broadcast to many clients } else { - message.type = message.type.toUpperCase() + message.type = 'PUSHER_'+message.type.toUpperCase() store.dispatch(message) } } @@ -88,11 +88,6 @@ const PusherMiddleware = (function(){ [ connection.clientid, connection.connection_id, connection.username ] ); - socket.onopen = () => { - store.dispatch({ type: 'PUSHER_CONNECTED', connection_id: connection.connection_id }); - store.dispatch({ type: 'PUSHER_SET_USERNAME', username: connection.username }); - }; - socket.onmessage = (message) => { var message = JSON.parse(message.data); handleMessage( socket, store, message ) @@ -149,10 +144,6 @@ const PusherMiddleware = (function(){ return next(action); break; - case 'ERROR': - store.dispatch( uiActions.createNotification(action.source+': '+action.message,'bad') ) - break; - case 'PUSHER_GET_QUEUE_METADATA': request('get_queue_metadata') .then( @@ -171,7 +162,7 @@ const PusherMiddleware = (function(){ }) break; - case 'START_UPGRADE': + case 'PUSHER_START_UPGRADE': request('upgrade') .then( response => { @@ -199,21 +190,18 @@ const PusherMiddleware = (function(){ }) .then( response => { - console.log(response) if (response.error){ console.error(response.error) return false } - - //response.type = 'PUSHER_USERNAME' - //store.dispatch(response) + response.type = 'PUSHER_USERNAME_CHANGED' + store.dispatch(response) } ) return next(action); break; - case 'GET_CONNECTIONS': - case 'NEW_CONNECTION': + case 'PUSHER_GET_CONNECTIONS': request('get_connections') .then( response => { @@ -221,8 +209,7 @@ const PusherMiddleware = (function(){ console.error(response.error) return false } - - response.type = 'CONNECTIONS' + response.type = 'PUSHER_CONNECTIONS' store.dispatch(response) } ) @@ -237,7 +224,6 @@ const PusherMiddleware = (function(){ console.error(response.error) return false } - response.type = 'DEBUG' store.dispatch(response) } diff --git a/src/js/services/pusher/reducer.js b/src/js/services/pusher/reducer.js index 49750fc1..8650691f 100755 --- a/src/js/services/pusher/reducer.js +++ b/src/js/services/pusher/reducer.js @@ -8,7 +8,9 @@ export default function reducer(pusher = {}, action){ case 'PUSHER_CONNECTED': return Object.assign({}, pusher, { - connection_id: action.connection_id + connected: true, + connection_id: action.connection_id, + username: action.username }); case 'PUSHER_DISCONNECTED': @@ -17,30 +19,34 @@ export default function reducer(pusher = {}, action){ case 'PUSHER_SET_PORT': return Object.assign({}, pusher, { port: action.port }); - case 'PUSHER_USERNAME': + case 'PUSHER_USERNAME_CHANGED': return Object.assign({}, pusher, { username: action.username }); - case 'CONNECTIONS': - return Object.assign({}, pusher, { connections: action.connections }); - - case 'CONNECTION_UPDATED': - function byID(connection){ - return connection.connection_id == action.connection.connection_id; + case 'PUSHER_CONNECTIONS': + var connections = {} + for (var i = 0; i < action.connections.length; i++){ + connections[action.connections[i].connection_id] = action.connections[i] } - var connection = pusher.connections.find(byID); - var index = pusher.connections.indexOf(connection); - var connections = Object.assign([], pusher.connections); - connections[index] = action.connection; - return Object.assign({}, pusher, { connections: connections }); - case 'VERSION': + case 'PUSHER_CONNECTION_ADDED': + case 'PUSHER_CONNECTION_UPDATED': + var connections = Object.assign({}, pusher.connections) + connections[action.connection.connection_id] = action.connection + return Object.assign({}, pusher, { connections: connections }); + + case 'PUSHER_CONNECTION_REMOVED': + var connections = Object.assign({}, pusher.connections) + delete connections[action.connection.connection_id] + return Object.assign({}, pusher, { connections: connections }); + + case 'PUSHER_VERSION': return Object.assign({}, pusher, { version: action.version, upgrading: false }); - case 'START_UPGRADE': + case 'PUSHER_START_UPGRADE': return Object.assign({}, pusher, { upgrading: true }); default: diff --git a/src/js/services/ui/middleware.js b/src/js/services/ui/middleware.js index 6f6c9d2a..ac130b1d 100755 --- a/src/js/services/ui/middleware.js +++ b/src/js/services/ui/middleware.js @@ -21,7 +21,7 @@ const UIMiddleware = (function(){ break case 'PUSHER_CONNECTED': - ReactGA.event({ category: 'Pusher', action: 'Connected', label: action.connection.username }) + ReactGA.event({ category: 'Pusher', action: 'Connected', label: action.connection_id+'/'+action.username }) next(action) break diff --git a/src/js/views/Settings.js b/src/js/views/Settings.js index e9425453..d81e804c 100755 --- a/src/js/views/Settings.js +++ b/src/js/views/Settings.js @@ -181,7 +181,7 @@ class Settings extends React.Component{
- +
From 60290db45295d99608900873072bd96445780980 Mon Sep 17 00:00:00 2001 From: James Barnsley Date: Tue, 21 Feb 2017 05:46:28 +1300 Subject: [PATCH 05/11] PUSHER_ prepends all related actions; Radio updater --- mopidy_iris/core.py | 62 +++++++++++++++++------ mopidy_iris/handlers.py | 2 +- src/js/services/pusher/actions.js | 8 +-- src/js/services/pusher/middleware.js | 72 ++++++++------------------- src/js/services/spotify/middleware.js | 3 +- src/js/services/ui/actions.js | 7 +++ src/js/services/ui/reducer.js | 10 ++-- src/js/views/Debug.js | 17 +------ src/js/views/Settings.js | 48 +++++++++++------- 9 files changed, 120 insertions(+), 109 deletions(-) diff --git a/mopidy_iris/core.py b/mopidy_iris/core.py index 14e27eaf..d83e166c 100755 --- a/mopidy_iris/core.py +++ b/mopidy_iris/core.py @@ -82,7 +82,10 @@ class IrisCore(object): def broadcast(self, data): for connection in self.connections.itervalues(): connection['connection'].write_message( json_encode(data) ) - return {} + return { + 'status': 'ok', + 'message': 'Broadcast to '+str(len(self.connections))+' connections' + } ## # Connections @@ -227,17 +230,28 @@ class IrisCore(object): self.radio = data self.radio['enabled'] = 1; - self.core.tracklist.clear() - self.core.tracklist.set_consume( True ) - self.load_more_tracks() - self.core.playback.play() - - self.broadcast({ - 'type': 'radio_started', - 'radio': self.radio - }) - - return self.get_radio({}) + uris = self.load_more_tracks() + + # no uris means we can't play radio + if not uris: + return { + 'error': 'No recommendations found' + } + + # if we got recommendations + else: + self.core.tracklist.clear() + self.core.tracklist.set_consume( True ) + self.core.tracklist.add( uris = uris ) + self.core.playback.play() + + self.broadcast({ + 'type': 'radio_started', + 'radio': self.radio + }) + + return self.get_radio({}) + def stop_radio(self, data): self.radio = { @@ -280,12 +294,28 @@ class IrisCore(object): for track in response['tracks']: uris.append( track['uri'] ) - self.core.tracklist.add( uris = uris ) + return uris except: logger.error('IrisFrontend: Failed to fetch Spotify recommendations') - self.broadcast({ - 'error': 'Failed to fetch radio recommendations' - }) + return False + + + def check_for_radio_update( self ): + tracklistLength = self.core.tracklist.length.get() + if( tracklistLength <= 5 and self.radio['enabled'] == 1 ): + + uris = self.load_more_tracks() + + if not uris: + self.broadcast({ + 'error': 'Could not fetch tracklist length', + 'source': 'check_for_radio_update' + }) + logger.warning('IrisFrontend: Could not fetch tracklist length') + + else: + self.core.tracklist.add( uris = uris ) + ## diff --git a/mopidy_iris/handlers.py b/mopidy_iris/handlers.py index 9c330ebe..9f4a84cf 100755 --- a/mopidy_iris/handlers.py +++ b/mopidy_iris/handlers.py @@ -62,7 +62,7 @@ class WebsocketHandler(tornado.websocket.WebSocketHandler): def on_message(self, message): - + message = json_decode(message) if 'data' in message: diff --git a/src/js/services/pusher/actions.js b/src/js/services/pusher/actions.js index fa1a6a66..c24e1aa4 100755 --- a/src/js/services/pusher/actions.js +++ b/src/js/services/pusher/actions.js @@ -59,21 +59,21 @@ export function sendAuthorization( recipient_connectionid, authorization, me ){ export function startRadio( uris ){ return { - type: 'START_RADIO', + type: 'PUSHER_START_RADIO', uris: uris } } export function stopRadio(){ return { - type: 'STOP_RADIO' + type: 'PUSHER_STOP_RADIO' } } -export function debug( data = null ){ +export function debug( message = null ){ return { type: 'PUSHER_DEBUG', - data: data + message: message } } diff --git a/src/js/services/pusher/middleware.js b/src/js/services/pusher/middleware.js index 961b15d4..5dbf5a68 100755 --- a/src/js/services/pusher/middleware.js +++ b/src/js/services/pusher/middleware.js @@ -104,7 +104,7 @@ const PusherMiddleware = (function(){ return false } - response.type = 'CONFIG' + response.type = 'PUSHER_CONFIG' store.dispatch(response) if (response.config.spotify_username){ store.dispatch(spotifyActions.getUser('spotify:user:'+response.config.spotify_username)) @@ -122,7 +122,7 @@ const PusherMiddleware = (function(){ console.error(response.error) return false } - response.type = 'VERSION' + response.type = 'PUSHER_VERSION' store.dispatch(response) } ) @@ -134,7 +134,7 @@ const PusherMiddleware = (function(){ return false } - response.type = 'RADIO' + response.type = 'PUSHER_RADIO' store.dispatch(response) } ) @@ -177,7 +177,7 @@ const PusherMiddleware = (function(){ store.dispatch( uiActions.createNotification('Upgrade failed, please upgrade manually','bad') ) } - response.type = 'VERSION' + response.type = 'PUSHER_VERSION' store.dispatch(response) } ) @@ -217,15 +217,10 @@ const PusherMiddleware = (function(){ break case 'PUSHER_DEBUG': - request( action ) + request( action.message.method, action.message.data ) .then( - response => { - if (response.error){ - console.error(response.error) - return false - } - response.type = 'DEBUG' - store.dispatch(response) + response => { + store.dispatch({type: 'DEBUG', response: response}) } ) break; @@ -262,26 +257,10 @@ const PusherMiddleware = (function(){ } break - case 'START_RADIO': - request('broadcast', { - type: 'browser_notification', - title: 'Radio started', - body: store.getState().pusher.username +' started radio mode', - icon: '' - }) - .then( - response => { - if (response.data.error){ - console.error(response.data.error) - return false - } - - store.dispatch(uiActions.createNotification('Starting radio...')) - } - ) + case 'PUSHER_START_RADIO': + store.dispatch(uiActions.createNotification('Starting radio...')) var data = { - method: 'start_radio', seed_artists: [], seed_genres: [], seed_tracks: [] @@ -301,34 +280,27 @@ const PusherMiddleware = (function(){ } } - request( data ) + request( 'start_radio', data ) + .then(response => { + console.log(response) + }) break - case 'STOP_RADIO': - request('broadcast', { - type: 'browser_notification', - title: 'Radio stopped', - body: store.getState().pusher.username +' stopped radio mode', - icon: '' - }) - .then( - response => { - if (response.data.error){ - console.error(response.data.error) - return false - } - - store.dispatch(uiActions.createNotification('Stopping radio')) - } - ) + case 'PUSHER_STOP_RADIO': + store.dispatch(uiActions.createNotification('Stopping radio')) var data = { - method: 'stop_radio', seed_artists: [], seed_genres: [], seed_tracks: [] } - request( data ) + + // we don't need to wait for request, as change will be broadcast + request( 'stop_radio', data ) + break + + case 'PUSHER_BROWSER_NOTIFICATION': + store.dispatch(uiActions.createBrowserNotification(action)) break // This action is irrelevant to us, pass it on to the next middleware diff --git a/src/js/services/spotify/middleware.js b/src/js/services/spotify/middleware.js index fbbdb705..dee78940 100755 --- a/src/js/services/spotify/middleware.js +++ b/src/js/services/spotify/middleware.js @@ -87,7 +87,8 @@ const SpotifyMiddleware = (function(){ break // when radio returns - case 'RADIO': + case 'PUSHER_RADIO_STARTED': + case 'PUSHER_RADIO_CHANGED': next(action) diff --git a/src/js/services/ui/actions.js b/src/js/services/ui/actions.js index 2a947d43..3672f38f 100755 --- a/src/js/services/ui/actions.js +++ b/src/js/services/ui/actions.js @@ -215,6 +215,13 @@ export function closeModal(){ * Subtle info/tooltip messages **/ +export function createBrowserNotification( data ){ + return { + type: 'BROWSER_NOTIFICATION', + data: data + } +} + export function createNotification( content, type = 'default' ){ return { type: 'CREATE_NOTIFICATION', diff --git a/src/js/services/ui/reducer.js b/src/js/services/ui/reducer.js index 52fa489b..f03de435 100755 --- a/src/js/services/ui/reducer.js +++ b/src/js/services/ui/reducer.js @@ -13,7 +13,7 @@ export default function reducer(ui = {}, action){ case 'UI_SET': return Object.assign({}, ui, action.data) - case 'CONFIG': + case 'PUSHER_CONFIG': return Object.assign({}, ui, { config: action.config }) case 'TOGGLE_SIDEBAR': @@ -147,8 +147,10 @@ export default function reducer(ui = {}, action){ } return Object.assign({}, ui, { current_tracklist: tracklist, queue_metadata: action.queue_metadata }); - case 'RADIO': - case 'START_RADIO': + case 'PUSHER_RADIO': + case 'PUSHER_RADIO_STARTED': + case 'PUSHER_RADIO_CHANGED': + case 'PUSHER_RADIO_STOPPED': return Object.assign({}, ui, { seeds_resolved: false }, { radio: action.radio }) case 'RADIO_SEEDS_RESOLVED': @@ -371,7 +373,7 @@ export default function reducer(ui = {}, action){ case 'USER_PLAYLISTS_LOADED': var users = Object.assign([], ui.users) var playlists_uris = [] - if (users[action.key].playlists_uris) playlists_uris = users[action.key].playlists_uris + if (users[action.key] && users[action.key].playlists_uris) playlists_uris = users[action.key].playlists_uris var artist = Object.assign( {}, diff --git a/src/js/views/Debug.js b/src/js/views/Debug.js index e6497062..628b38a4 100755 --- a/src/js/views/Debug.js +++ b/src/js/views/Debug.js @@ -25,22 +25,7 @@ class Debug extends React.Component{ this.state = { mopidy_call: 'playlists.asList', mopidy_data: '{}', - pusher_data: '{}' - } - } - - componentDidMount(){ - if( this.props.connectionid ){ - var data = { - action: "broadcast", - data: { - type: 'browser_notification', - title: "Title", - body: "Test notification", - icon: "http://lorempixel.com/100/100/nature/" - } - } - this.setState({ pusher_data: JSON.stringify(data) }) + pusher_data: '{"method":"broadcast","data":{"type":"browser_notification","title":"Testing","body":"This is my message"}}' } } diff --git a/src/js/views/Settings.js b/src/js/views/Settings.js index d81e804c..bdac2956 100755 --- a/src/js/views/Settings.js +++ b/src/js/views/Settings.js @@ -56,28 +56,28 @@ class Settings extends React.Component{ } renderConnectionStatus(service){ - if( this.props[service].connected ){ + if( this.props[service.toLowerCase()].connected ){ return ( - +   - Connected + {service} ) - }else if( this.props[service].connecting ){ + }else if( this.props[service.toLowerCase()].connecting ){ return ( - +   - Connecting + {service} ) }else{ return ( - +   - Not connected + {service} ) } @@ -127,6 +127,19 @@ class Settings extends React.Component{ ) } + renderApplyButton(){ + if (this.props.mopidy.host == this.state.mopidy_host && this.props.mopidy.port == this.state.mopidy_port) return null + + return ( +
+
+
+ +
+
+ ) + } + render(){ var actions = ( @@ -142,12 +155,16 @@ class Settings extends React.Component{
-

Mopidy

+

System

this.setMopidyConfig(e)}>
Status
- { this.renderConnectionStatus('mopidy') } +
+ { this.renderConnectionStatus('Mopidy') } +    + { this.renderConnectionStatus('Pusher') } +
@@ -177,20 +194,17 @@ class Settings extends React.Component{ onChange={ e => this.setState({ mopidy_port: e.target.value })} value={ this.state.mopidy_port } />
-
-
-
-
- -
+ {this.renderApplyButton()}

Spotify

Status
- { this.renderConnectionStatus('spotify') } +
+ { this.renderConnectionStatus('Spotify') } +
From ba5ef316f4ca71de17040a717f5f63caaa11bd00 Mon Sep 17 00:00:00 2001 From: James Barnsley Date: Tue, 21 Feb 2017 08:35:24 +1300 Subject: [PATCH 06/11] Nicer status --- src/assets/icons/connection.svg | 10 +++++ src/assets/icons/server.svg | 13 ++++++ src/assets/icons/websocket.svg | 30 +++++++++++++ src/js/services/pusher/reducer.js | 1 + src/js/views/Settings.js | 71 +++++++++++++++++++++---------- src/scss/global/_core.scss | 1 + src/scss/global/_variables.scss | 1 + src/scss/views/_settings.scss | 23 ++++++++++ 8 files changed, 127 insertions(+), 23 deletions(-) create mode 100755 src/assets/icons/connection.svg create mode 100755 src/assets/icons/server.svg create mode 100755 src/assets/icons/websocket.svg diff --git a/src/assets/icons/connection.svg b/src/assets/icons/connection.svg new file mode 100755 index 00000000..5b253cdd --- /dev/null +++ b/src/assets/icons/connection.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/src/assets/icons/server.svg b/src/assets/icons/server.svg new file mode 100755 index 00000000..4bb5597b --- /dev/null +++ b/src/assets/icons/server.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/src/assets/icons/websocket.svg b/src/assets/icons/websocket.svg new file mode 100755 index 00000000..1c0f1871 --- /dev/null +++ b/src/assets/icons/websocket.svg @@ -0,0 +1,30 @@ + + + + + + diff --git a/src/js/services/pusher/reducer.js b/src/js/services/pusher/reducer.js index 8650691f..b4c6a160 100755 --- a/src/js/services/pusher/reducer.js +++ b/src/js/services/pusher/reducer.js @@ -9,6 +9,7 @@ export default function reducer(pusher = {}, action){ case 'PUSHER_CONNECTED': return Object.assign({}, pusher, { connected: true, + connecting: false, connection_id: action.connection_id, username: action.username }); diff --git a/src/js/views/Settings.js b/src/js/views/Settings.js index bdac2956..2f1df936 100755 --- a/src/js/views/Settings.js +++ b/src/js/views/Settings.js @@ -11,6 +11,7 @@ import PusherConnectionList from '../components/PusherConnectionList' import URISchemesList from '../components/URISchemesList' import VersionManager from '../components/VersionManager' import Header from '../components/Header' +import Icon from '../components/Icon' import Thumbnail from '../components/Thumbnail' import * as uiActions from '../services/ui/actions' @@ -56,7 +57,15 @@ class Settings extends React.Component{ } renderConnectionStatus(service){ - if( this.props[service.toLowerCase()].connected ){ + if (service == 'Spotify' && !this.props[service.toLowerCase()].authorized){ + return ( + + +   + Not connected + + ) + } else if (this.props[service.toLowerCase()].connected){ return ( @@ -64,20 +73,20 @@ class Settings extends React.Component{ {service} ) - }else if( this.props[service.toLowerCase()].connecting ){ + } else if (this.props[service.toLowerCase()].connecting){ return (   - {service} + Connecting ) - }else{ + } else { return (   - {service} + Not connected ) } @@ -140,6 +149,18 @@ class Settings extends React.Component{ ) } + serviceStatus(service){ + if (this.props[service].connecting){ + return Connecting + } else if (service == 'spotify' && !this.props[service].authorized){ + return Not authorized + } else if (this.props[service].connected){ + return Connected + } else { + return Disconnected + } + } + render(){ var actions = ( @@ -155,18 +176,30 @@ class Settings extends React.Component{
+
+ +
+ +

Mopidy

+ {this.serviceStatus('mopidy')} +
+ +
+ +

Pusher

+ {this.serviceStatus('pusher')} +
+ +
+ +

Spotify

+ {this.serviceStatus('spotify')} +
+ +
+

System

this.setMopidyConfig(e)}> -
-
Status
-
-
- { this.renderConnectionStatus('Mopidy') } -    - { this.renderConnectionStatus('Pusher') } -
-
-
Username
@@ -199,14 +232,6 @@ class Settings extends React.Component{

Spotify

-
-
Status
-
-
- { this.renderConnectionStatus('Spotify') } -
-
-
Country
diff --git a/src/scss/global/_core.scss b/src/scss/global/_core.scss index 742dceae..99565f16 100755 --- a/src/scss/global/_core.scss +++ b/src/scss/global/_core.scss @@ -167,6 +167,7 @@ h4 { .grey-text { color: $mid_grey !important; } .red-text { color: $red !important; } .green-text { color: $green !important; } +.orange-text { color: $orange !important; } .dark-text { color: $dark_grey !important; } .centred-text { text-align: center; } diff --git a/src/scss/global/_variables.scss b/src/scss/global/_variables.scss index 2cbb55d4..1ebbc277 100755 --- a/src/scss/global/_variables.scss +++ b/src/scss/global/_variables.scss @@ -11,6 +11,7 @@ $red: #cf2d2d; $green: #47af2a; $blue: #32b5f2; $yellow: #FFF39C; +$orange: #f16f19; $bp_wide: 1000px; $bp_medium: 800px; diff --git a/src/scss/views/_settings.scss b/src/scss/views/_settings.scss index 3d2751b0..0aa8ff9b 100755 --- a/src/scss/views/_settings.scss +++ b/src/scss/views/_settings.scss @@ -3,6 +3,29 @@ section { padding: 40px; } + + .status { + .item { + position: relative; + display: inline-block; + padding: 0 0 0 45px; + margin-right: 20px; + min-width: 80px; + + .icon { + width: 35px; + height: 35px; + position: absolute; + top: 0; + left: 0; + } + + h4 { + padding: 0; + margin: 0; + } + } + } .field.current-user { .text { From 148fe39cd48937772b3fb84614cdb19bc0571a11 Mon Sep 17 00:00:00 2001 From: James Barnsley Date: Tue, 21 Feb 2017 08:58:49 +1300 Subject: [PATCH 07/11] Standardized status value on all pusher responses; deliver_message --- mopidy_iris/core.py | 50 ++++++++++++++----- .../Modal/SendAuthorizationModal.js | 19 ++++--- src/js/services/pusher/actions.js | 11 ++-- src/js/services/pusher/middleware.js | 27 ++++++---- 4 files changed, 76 insertions(+), 31 deletions(-) diff --git a/mopidy_iris/core.py b/mopidy_iris/core.py index d83e166c..15e3cdd3 100755 --- a/mopidy_iris/core.py +++ b/mopidy_iris/core.py @@ -29,10 +29,6 @@ class IrisCore(object): } - def on_start(self): - logger.info('--- Starting Iris core '+self.version) - - ## # Generate a random string # @@ -83,7 +79,7 @@ class IrisCore(object): for connection in self.connections.itervalues(): connection['connection'].write_message( json_encode(data) ) return { - 'status': 'ok', + 'status': 1, 'message': 'Broadcast to '+str(len(self.connections))+' connections' } @@ -101,6 +97,7 @@ class IrisCore(object): connections.append(connection['client']) return { + 'status': 1, 'connections': connections } @@ -145,7 +142,7 @@ class IrisCore(object): 'connection': self.connections[connection_id]['client'] }) return { - 'type': 'username_changed', + 'status': 1, 'username': data['username'] } @@ -153,8 +150,27 @@ class IrisCore(object): error = 'Connection "'+data['connection_id']+'" not found' logger.error(error) return { - 'error': error - } + 'status': 0, + 'message': error + } + + def deliver_message(self, data): + to = data['to'] + if to in self.connections: + + self.send_message(to, data['message']) + + return { + 'status': 1 + } + + else: + error = 'Connection "'+data['connection_id']+'" not found' + logger.error(error) + return { + 'status': 0, + 'message': error + } @@ -194,6 +210,7 @@ class IrisCore(object): upgrade_available = False return { + 'status': 1, 'version': { 'current': self.version, 'latest': latest_version, @@ -223,6 +240,7 @@ class IrisCore(object): def get_radio(self, data): return { + 'status': 1, 'radio': self.radio } @@ -235,7 +253,8 @@ class IrisCore(object): # no uris means we can't play radio if not uris: return { - 'error': 'No recommendations found' + 'status': 0, + 'message': 'No recommendations found' } # if we got recommendations @@ -268,7 +287,9 @@ class IrisCore(object): 'radio': self.radio }) - return {} + return { + 'status': 1 + } def load_more_tracks( self ): @@ -327,6 +348,7 @@ class IrisCore(object): def get_queue_metadata(self, data): return { + 'status': 1, 'queue_metadata': self.queue_metadata } @@ -345,7 +367,9 @@ class IrisCore(object): 'queue_metadata': self.queue_metadata }) - return {} + return { + 'status': 1 + } def clean_queue_metadata( self ): cleaned_queue_metadata = {} @@ -363,7 +387,9 @@ class IrisCore(object): 'queue_metadata': self.queue_metadata }) - return {} + return { + 'status': 1 + } ## diff --git a/src/js/components/Modal/SendAuthorizationModal.js b/src/js/components/Modal/SendAuthorizationModal.js index 7950f3ad..0031f864 100755 --- a/src/js/components/Modal/SendAuthorizationModal.js +++ b/src/js/components/Modal/SendAuthorizationModal.js @@ -15,27 +15,34 @@ class SendAuthorizationModal extends React.Component{ super(props) } - handleClick(e, connectionid){ + handleClick(e, connection_id){ e.preventDefault() - this.props.pusherActions.sendAuthorization( connectionid, this.props.authorization, this.props.me ) + this.props.pusherActions.sendAuthorization( connection_id, this.props.authorization, this.props.me ) this.props.uiActions.closeModal() return false; } render(){ + var connections = [] + for (var connection_id in this.props.connections){ + if (this.props.connections.hasOwnProperty(connection_id)) { + connections.push(this.props.connections[connection_id]) + } + } + return (

Share Spotify authentication

Send your authentication tokens to another client. When the recipient client imports this, their Iris will have full access to your Spotify account ({this.props.me.id}).

{ - this.props.connections.map( (connection, index) => { + connections.map( (connection, index) => { // don't list OUR connection - if (connection.connectionid == this.props.connectionid) return null + if (connection.connection_id == this.props.connection_id) return null return ( -
this.handleClick(e, connection.connectionid) }> +
this.handleClick(e, connection.connection_id) }> { connection.username }   ({ connection.ip }) @@ -53,7 +60,7 @@ const mapStateToProps = (state, ownProps) => { return { me: state.spotify.me, authorization: state.spotify.authorization, - connectionid: state.pusher.connectionid, + connection_id: state.pusher.connection_id, connections: state.pusher.connections } } diff --git a/src/js/services/pusher/actions.js b/src/js/services/pusher/actions.js index c24e1aa4..eb5c78df 100755 --- a/src/js/services/pusher/actions.js +++ b/src/js/services/pusher/actions.js @@ -50,10 +50,13 @@ export function instruct( data = null ){ export function sendAuthorization( recipient_connectionid, authorization, me ){ return { - type: 'PUSHER_SEND_AUTHORIZATION', - recipient_connectionid: recipient_connectionid, - authorization: authorization, - me: me + type: 'PUSHER_DELIVER_MESSAGE', + to: recipient_connectionid, + message: { + type: 'spotify_authorization', + authorization: authorization, + me: me + } } } diff --git a/src/js/services/pusher/middleware.js b/src/js/services/pusher/middleware.js index 5dbf5a68..0cc5f6a0 100755 --- a/src/js/services/pusher/middleware.js +++ b/src/js/services/pusher/middleware.js @@ -60,15 +60,6 @@ const PusherMiddleware = (function(){ return store => next => action => { switch(action.type) { - case 'PUSHER_INSTRUCT': - request( action ) - .then( - response => { - store.dispatch({ type: 'PUSHER_INSTRUCT', data: response.data }) - } - ) - break - case 'PUSHER_CONNECT': if(socket != null) socket.close(); @@ -144,6 +135,24 @@ const PusherMiddleware = (function(){ return next(action); break; + case 'PUSHER_INSTRUCT': + request( action ) + .then( + response => { + store.dispatch({ type: 'PUSHER_INSTRUCT', data: response.data }) + } + ) + break + + case 'PUSHER_DELIVER_MESSAGE': + request('deliver_message', action) + .then( + response => { + store.dispatch( uiActions.createNotification(response.message) ) + } + ) + break + case 'PUSHER_GET_QUEUE_METADATA': request('get_queue_metadata') .then( From f4fcd2521d4492332587ce889e2b4b63a900bbc4 Mon Sep 17 00:00:00 2001 From: James Barnsley Date: Tue, 21 Feb 2017 19:36:17 +1300 Subject: [PATCH 08/11] Spotify without display_name --- src/js/views/Settings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/views/Settings.js b/src/js/views/Settings.js index 2f1df936..e3771adf 100755 --- a/src/js/views/Settings.js +++ b/src/js/views/Settings.js @@ -108,7 +108,7 @@ class Settings extends React.Component{ - {user.display_name ? user.display_name : user.username} + {user.display_name ? user.display_name : user.id} {!this.props.spotify.authorized ?  (limited access) : null} From da5aa367b18f15d7a965ce0af857e4f1c0cf2c45 Mon Sep 17 00:00:00 2001 From: James Barnsley Date: Tue, 21 Feb 2017 19:54:00 +1300 Subject: [PATCH 09/11] Version 2.13.0; Send authorization --- mopidy_iris/__init__.py | 2 +- src/js/services/pusher/middleware.js | 22 ++-------------------- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/mopidy_iris/__init__.py b/mopidy_iris/__init__.py index 9ea2b496..1ea46856 100755 --- a/mopidy_iris/__init__.py +++ b/mopidy_iris/__init__.py @@ -12,7 +12,7 @@ from handlers import WebsocketHandler, HttpHandler from core import IrisCore logger = logging.getLogger(__name__) -__version__ = '2.12.1' +__version__ = '2.13.0' ## # Core extension class diff --git a/src/js/services/pusher/middleware.js b/src/js/services/pusher/middleware.js index 0cc5f6a0..097118f6 100755 --- a/src/js/services/pusher/middleware.js +++ b/src/js/services/pusher/middleware.js @@ -148,7 +148,7 @@ const PusherMiddleware = (function(){ request('deliver_message', action) .then( response => { - store.dispatch( uiActions.createNotification(response.message) ) + store.dispatch( uiActions.createNotification('Message delivered') ) } ) break @@ -234,25 +234,7 @@ const PusherMiddleware = (function(){ ) break; - case 'PUSHER_SEND_AUTHORIZATION': - request('send_authorization', { - recipient_connection_id: action.recipient_connection_id, - authorization: action.authorization, - me: action.me - }) - .then( - response => { - if (response.error){ - console.error(response.error) - return false - } - - store.dispatch( uiActions.createNotification('Authorization sent') ) - } - ) - break; - - case 'PUSHER_SEND_AUTHORIZATION': + case 'PUSHER_SPOTIFY_AUTHORIZATION': if( window.confirm('Spotify authorization for user '+action.me.id+' received. Do you want to import?') ){ // remove any existing authentication From 141bc95feb0a96cff0685b3b5522f5e6b6d55d5a Mon Sep 17 00:00:00 2001 From: James Barnsley Date: Tue, 21 Feb 2017 20:04:45 +1300 Subject: [PATCH 10/11] Pusher try reconnecting --- src/js/services/pusher/middleware.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/js/services/pusher/middleware.js b/src/js/services/pusher/middleware.js index 097118f6..636bb971 100755 --- a/src/js/services/pusher/middleware.js +++ b/src/js/services/pusher/middleware.js @@ -84,6 +84,16 @@ const PusherMiddleware = (function(){ handleMessage( socket, store, message ) }; + socket.onclose = () => { + store.dispatch({ + type: 'PUSHER_DISCONNECTED' + }) + + setTimeout(() => { + store.dispatch(pusherActions.connect()) + }, 5000); + }; + break; case 'PUSHER_CONNECTED': From a9645366181d3ecf63c82cde422e56745db05684 Mon Sep 17 00:00:00 2001 From: James Barnsley Date: Tue, 21 Feb 2017 21:56:40 +1300 Subject: [PATCH 11/11] Responsive header icons --- src/js/services/pusher/middleware.js | 1 + src/scss/components/_header.scss | 29 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/js/services/pusher/middleware.js b/src/js/services/pusher/middleware.js index 636bb971..c9a9aceb 100755 --- a/src/js/services/pusher/middleware.js +++ b/src/js/services/pusher/middleware.js @@ -89,6 +89,7 @@ const PusherMiddleware = (function(){ type: 'PUSHER_DISCONNECTED' }) + // attempt to reconnect ever 5 seconds setTimeout(() => { store.dispatch(pusherActions.connect()) }, 5000); diff --git a/src/scss/components/_header.scss b/src/scss/components/_header.scss index 9be01dba..00513eee 100755 --- a/src/scss/components/_header.scss +++ b/src/scss/components/_header.scss @@ -45,5 +45,34 @@ main { } } } + + @include responsive( $bp_medium ){ + .dropdown-field { + .label { + padding-left: 16px; + padding-right: 16px; + + .text { + display: none; + } + .fa { + line-height: 24px; + } + } + } + .actions { + button { + font-size: 1px; + color: transparent; + vertical-align: top; + + .fa { + font-size: 14px; + color: #000000; + vertical-align: middle; + } + } + } + } } } \ No newline at end of file