Skip to content

Commit ffbed1a

Browse files
authored
Merge pull request #146 from jaedb/develop
2.10.0
2 parents 3540593 + ab3b5b7 commit ffbed1a

108 files changed

Lines changed: 3688 additions & 3767 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

mopidy_spotmop/__init__.py

Lines changed: 19 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,23 @@
11
from __future__ import unicode_literals
22

3-
import logging
4-
import os
3+
import logging, os, json
54
import tornado.web
65
import tornado.websocket
7-
import json
8-
9-
from services.upgrade import upgrade
10-
from services.pusher import pusher
11-
from services.auth import auth
12-
from services.queuer import queuer
136
from mopidy import config, ext
14-
15-
__version__ = '2.9.2'
16-
__ext_name__ = 'spotmop'
17-
__verbosemode__ = False
7+
from frontend import SpotmopFrontend
188

199
logger = logging.getLogger(__name__)
10+
__version__ = '2.10.0'
11+
12+
##
13+
# Core extension class
14+
#
15+
# Loads config and gets the party started. Initiates any additional frontends, etc.
16+
##
17+
class SpotmopExtension( ext.Extension ):
2018

21-
class SpotmopExtension(ext.Extension):
2219
dist_name = 'Mopidy-Spotmop'
23-
ext_name = __ext_name__
20+
ext_name = 'spotmop'
2421
version = __version__
2522

2623
def get_default_config(self):
@@ -34,59 +31,26 @@ def get_config_schema(self):
3431
return schema
3532

3633
def setup(self, registry):
37-
34+
3835
# Add web extension
3936
registry.add('http:app', {
4037
'name': self.ext_name,
41-
'factory': spotmop_client_factory
38+
'factory': factory
4239
})
43-
44-
logger.info('Starting Spotmop web client '+ self.version)
45-
46-
class ArtworkHandler(tornado.web.RequestHandler):
47-
def get(self, file):
48-
self.write("You requested the file " + file)
4940

50-
def spotmop_client_factory(config, core):
41+
# add our frontend
42+
registry.add('frontend', SpotmopFrontend)
43+
44+
def factory(config, core):
5145

52-
# TODO create minified version of the project for production (or use Bower or Grunt for building??)
53-
environment = 'dev' if config.get(__ext_name__)['debug'] is True else 'prod'
54-
spotmoppath = os.path.join( os.path.dirname(__file__), 'static')
55-
56-
# PUSHER: TODO: need to fire this up from within the PusherHandler class... somehow
57-
pusherport = str(config['spotmop']['pusherport'])
58-
application = tornado.web.Application([
59-
('/pusher', pusher.PusherHandler, {
60-
'version': __version__
61-
}),
62-
])
63-
application.listen(pusherport)
64-
65-
logger.info( 'Pusher server running on []:'+ str(pusherport) )
46+
path = os.path.join( os.path.dirname(__file__), 'static')
6647

6748
return [
68-
(r'/upgrade', upgrade.UpgradeRequestHandler, {
69-
'core': core,
70-
'config': config,
71-
'version': __version__
72-
}),
73-
(r'/pusher/([^/]+)', pusher.PusherRequestHandler, {
74-
'core': core,
75-
'config': config
76-
}),
77-
(r'/auth', auth.AuthRequestHandler, {
78-
'core': core,
79-
'config': config
80-
}),
81-
(r'/queuer/([^/]*)', queuer.QueuerRequestHandler, {
82-
'core': core,
83-
'config': config
84-
}),
8549
(r"/images/(.*)", tornado.web.StaticFileHandler, {
8650
"path": config['local-images']['image_dir']
8751
}),
8852
(r'/(.*)', tornado.web.StaticFileHandler, {
89-
"path": spotmoppath,
53+
"path": path,
9054
"default_filename": "index.html"
9155
}),
9256
]

mopidy_spotmop/frontend.py

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
from __future__ import unicode_literals
2+
3+
import logging, json, pykka, pylast, pusher, urllib, urllib2, os, sys, mopidy_spotmop, subprocess
4+
import tornado.web
5+
import tornado.websocket
6+
import tornado.ioloop
7+
from mopidy import config, ext
8+
from mopidy.core import CoreListener
9+
from pkg_resources import parse_version
10+
from spotipy import Spotify
11+
12+
# import logger
13+
logger = logging.getLogger(__name__)
14+
15+
16+
###
17+
# Spotmop supporting frontend
18+
#
19+
# This provides a wrapping thread for the Pusher websocket, as well as the radio infrastructure
20+
##
21+
class SpotmopFrontend(pykka.ThreadingActor, CoreListener):
22+
23+
def __init__(self, config, core):
24+
global spotmop
25+
super(SpotmopFrontend, self).__init__()
26+
self.config = config
27+
self.core = core
28+
self.version = mopidy_spotmop.__version__
29+
self.is_root = ( os.geteuid() == 0 )
30+
self.spotify_token = False
31+
self.radio = {
32+
"enabled": 0,
33+
"seed_artists": [],
34+
"seed_genres": [],
35+
"seed_tracks": []
36+
}
37+
38+
def on_start(self):
39+
40+
logger.info('Starting Spotmop '+self.version)
41+
42+
# try and start a pusher server
43+
port = str(self.config['spotmop']['pusherport'])
44+
try:
45+
self.pusher = tornado.web.Application([( '/pusher', pusher.PusherWebsocketHandler, { 'frontend': self } )])
46+
self.pusher.listen(port)
47+
logger.info('Pusher server running at [0.0.0.0]:'+port)
48+
49+
except( pylast.NetworkError, pylast.MalformedResponseError, pylast.WSError ) as e:
50+
logger.error('Error starting Pusher: %s', e)
51+
self.stop()
52+
53+
# get a fresh spotify authentication token and store for future use
54+
self.refresh_spotify_token()
55+
56+
##
57+
# Listen for core events, and update our frontend as required
58+
##
59+
def track_playback_ended( self, tl_track, time_position ):
60+
self.check_for_radio_update()
61+
62+
63+
##
64+
# See if we need to perform updates to our radio
65+
#
66+
# We see if we've got one or two tracks left, if so, go get some more
67+
##
68+
def check_for_radio_update( self ):
69+
try:
70+
tracklistLength = self.core.tracklist.length.get()
71+
if( tracklistLength <= 5 and self.radio['enabled'] == 1 ):
72+
self.load_more_tracks()
73+
74+
except RuntimeError:
75+
logger.warning('RadioHandler: Could not fetch tracklist length')
76+
pass
77+
78+
79+
##
80+
# Load some more radio tracks
81+
#
82+
# We need to build a Spotify authentication token first, and then fetch recommendations
83+
##
84+
def load_more_tracks( self ):
85+
86+
# this is crude, but it means we don't need to handle expired tokens
87+
# TODO: address this when it's clear what Jodal and the team want to do with Pyspotify
88+
self.refresh_spotify_token()
89+
90+
try:
91+
token = self.spotify_token
92+
token = token['access_token']
93+
except:
94+
logger.error('SpotmopFrontend: access_token missing or invalid')
95+
96+
try:
97+
spotify = Spotify( auth = token )
98+
response = spotify.recommendations(seed_artists = self.radio['seed_artists'], seed_genres = self.radio['seed_genres'], seed_tracks = self.radio['seed_tracks'], limit = 5)
99+
100+
uris = []
101+
for track in response['tracks']:
102+
uris.append( track['uri'] )
103+
104+
self.core.tracklist.add( uris = uris )
105+
except:
106+
logger.error('SpotmopFrontend: Failed to fetch recommendations from Spotify')
107+
108+
109+
##
110+
# Start radio
111+
#
112+
# Take the provided radio details, and start a new radio process
113+
##
114+
def start_radio( self, new_state ):
115+
116+
# TODO: validate payload has the required seed values
117+
118+
# set our new radio state
119+
self.radio = new_state
120+
self.radio['enabled'] = 1;
121+
122+
# clear all tracks
123+
self.core.tracklist.clear()
124+
125+
# explicitly set consume, to ensure we don't end up with a huge tracklist (and it's how a radio should 'feel')
126+
self.core.tracklist.set_consume( True )
127+
128+
# load me some tracks, and start playing!
129+
self.load_more_tracks()
130+
self.core.playback.play()
131+
132+
# notify clients
133+
pusher.broadcast( 'radio_started', { 'radio': self.radio })
134+
135+
# return new radio state to initial call
136+
return self.radio
137+
138+
##
139+
# Stop radio
140+
##
141+
def stop_radio( self ):
142+
143+
# reset radio
144+
self.radio = {
145+
"enabled": 0,
146+
"seed_artists": [],
147+
"seed_genres": [],
148+
"seed_tracks": []
149+
}
150+
151+
# stop track playback
152+
self.core.playback.stop()
153+
154+
# notify clients
155+
pusher.broadcast( 'radio_stopped', { 'radio': self.radio })
156+
157+
# return new radio state to initial call
158+
return self.radio
159+
160+
161+
##
162+
# Get a new spotify authentication token
163+
#
164+
# Uses the Client Credentials Flow, so is invisible to the user. We need this token for
165+
# any backend spotify requests (we don't tap in to Mopidy-Spotify, yet). Also used for
166+
# passing token to frontend for javascript requests without use of the Authorization Code Flow.
167+
##
168+
def refresh_spotify_token( self ):
169+
170+
url = 'https://accounts.spotify.com/api/token'
171+
authorization = 'YTg3ZmI0ZGJlZDMwNDc1YjhjZWMzODUyM2RmZjUzZTI6ZDdjODlkMDc1M2VmNDA2OGJiYTE2NzhjNmNmMjZlZDY='
172+
173+
headers = {'Authorization' : 'Basic ' + authorization}
174+
data = {'grant_type': 'client_credentials'}
175+
data_encoded = urllib.urlencode( data )
176+
req = urllib2.Request(url, data_encoded, headers)
177+
178+
try:
179+
response = urllib2.urlopen(req, timeout=30).read()
180+
response_dict = json.loads(response)
181+
self.spotify_token = response_dict
182+
return response_dict
183+
except urllib2.HTTPError as e:
184+
return e
185+
186+
187+
# get our spotify token
188+
def get_spotify_token( self ):
189+
return self.spotify_token
190+
191+
192+
##
193+
# Get Spotmop version, and check for updates
194+
#
195+
# We compare our version with the latest available on PyPi
196+
##
197+
def get_version( self ):
198+
199+
url = 'https://pypi.python.org/pypi/Mopidy-Spotmop/json'
200+
req = urllib2.Request(url)
201+
202+
try:
203+
response = urllib2.urlopen(req, timeout=30).read()
204+
response = json.loads(response)
205+
latest_version = response['info']['version']
206+
except urllib2.HTTPError as e:
207+
latest_version = False
208+
209+
# compare our versions, and convert result to boolean
210+
upgrade_available = cmp( parse_version( latest_version ), parse_version( self.version ) )
211+
upgrade_available = ( upgrade_available == 1 )
212+
213+
# prepare our response
214+
data = {
215+
'current': self.version,
216+
'latest': latest_version,
217+
'is_root': self.is_root,
218+
'upgrade_available': upgrade_available
219+
}
220+
return data
221+
222+
223+
##
224+
# Upgrade Spotmop module
225+
#
226+
# Upgrade myself to the latest version available on PyPi
227+
##
228+
def perform_upgrade( self ):
229+
try:
230+
subprocess.check_call(["pip", "install", "--upgrade", "Mopidy-Spotmop"])
231+
return True
232+
except subprocess.CalledProcessError:
233+
return False
234+
235+
##
236+
# Restart Mopidy
237+
#
238+
# This is untested and may require installation of an upstart script to properly restart
239+
##
240+
def restart( self ):
241+
os.execl(sys.executable, *([sys.executable]+sys.argv))
242+
243+
244+

0 commit comments

Comments
 (0)