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