Start playing on add to playlist.
[nephilim.git] / plugins / Scrobbler.py
blobced39648acce4da0a6ff725f24e42324bcd8c822
1 import time
2 import datetime
4 from clPlugin import *
6 SCROBBLER_USERNAME_DEFAULT=''
7 SCROBBLER_PASSWORD_DEFAULT=''
9 # TODO cached failed submissions
11 class pluginScrobbler(Plugin):
12 submitted=False
13 time=None
14 loggedIn=False
15 def __init__(self, winMain):
16 Plugin.__init__(self, winMain, 'Scrobbler')
17 self.addMontyListener('onSongChange', self.onSongChange)
18 self.addMontyListener('onTimeChange', self.onTimeChange)
20 def _load(self):
21 self._login()
23 def _login(self):
24 self.submitting=False
25 self.loggedIn=False
26 if self._username():
27 self.normal("logging in %s"%(self._username()))
28 try:
29 login(self._username(), self._password())
30 self.loggedIn=True
31 except Exception, e:
32 self.normal("failed to login: "+str(e))
33 else:
34 self.debug("no username provided, not logging in")
36 def _username(self):
37 return self.getSetting('username')
38 def _password(self):
39 return self.getSetting('password')
40 def onTimeChange(self, params):
41 if self.submitted==False and self.loggedIn:
42 song=monty.getCurrentSong()
43 if song.getTag('time')>30:
44 if int(params['newTime'])>int(song.getTag('time'))/2 \
45 or int(params['newTime'])>240:
46 if not self.time:
47 self.onSongChange(None)
48 self.normal("submitting song")
49 submit(song.getArtist(), song.getTitle(), self.time, 'P', '', song.getTag('time'), song.getAlbum(), song.getTrack(), False)
50 self.debug("flushing ...")
51 try:
52 flush()
53 self.submitted=True
54 except Exception, e:
55 self.important("failed to submit song1 - %s"%(e))
56 self.extended("Logging in ...")
57 self._login()
58 try:
59 flush()
60 self.submitted=True
61 except Exception, e:
62 self.important("failed to submit song2 - %s"%(e))
63 if self.submitted:
64 self.debug("flushed")
65 else:
66 self.important("failed to submit the song!")
68 def onSongChange(self, params):
69 if self.loggedIn==False:
70 return
71 self.time=int(time.mktime(datetime.utcnow().timetuple()))
72 self.submitted=False
73 song=monty.getCurrentSong()
74 # for loop: in case of SessionError, we need to retry once ...
75 for i in [0, 1]:
76 try:
77 self.extended("submitting now playing")
78 now_playing(song.getArtist(), song.getTitle(), song.getAlbum(), "", song.getTrack())
79 self.debug("submitted")
80 break
81 except AuthError, e:
82 self.important("failed to submit playing song - %s"%(e))
83 break
84 except SessionError, e:
85 self.normal("session error")
86 self._login()
87 def _getSettings(self):
88 return [
89 ['username', 'Username', 'Username to submit to last.fm.', QtGui.QLineEdit(self._username())],
90 ['password', 'Password', 'Password to user to submit. Note that the password is stored *unencrypted* to file.', QtGui.QLineEdit(self._password())],
92 def afterSaveSettings(self):
93 self._login()
94 def getInfo(self):
95 return "Submits tracks to last.fm"
100 # Big thank you,
101 # http://exhuma.wicked.lu/projects/python/scrobbler/ !"
104 A pure-python library to assist sending data to AudioScrobbler (the LastFM
105 backend)
107 import urllib, urllib2
108 from time import mktime
109 from datetime import datetime, timedelta
110 from md5 import md5
112 SESSION_ID = None
113 POST_URL = None
114 NOW_URL = None
115 HARD_FAILS = 0
116 LAST_HS = None # Last handshake time
117 HS_DELAY = 0 # wait this many seconds until next handshake
118 SUBMIT_CACHE = []
119 MAX_CACHE = 5 # keep only this many songs in the cache
120 PROTOCOL_VERSION = '1.2'
122 class BackendError(Exception):
123 "Raised if the AS backend does something funny"
124 pass
125 class AuthError(Exception):
126 "Raised on authencitation errors"
127 pass
128 class PostError(Exception):
129 "Raised if something goes wrong when posting data to AS"
130 pass
131 class SessionError(Exception):
132 "Raised when problems with the session exist"
133 pass
134 class ProtocolError(Exception):
135 "Raised on general Protocol errors"
136 pass
138 def login( user, password, client=('tst', '1.0') ):
139 """Authencitate with AS (The Handshake)
141 @param user: The username
142 @param password: The password
143 @param client: Client information (see http://www.audioscrobbler.net/development/protocol/ for more info)
144 @type client: Tuple: (client-id, client-version)"""
145 global LAST_HS, SESSION_ID, POST_URL, NOW_URL, HARD_FAILS, HS_DELAY, PROTOCOL_VERSION
147 if LAST_HS is not None:
148 next_allowed_hs = LAST_HS + timedelta(seconds=HS_DELAY)
149 if datetime.now() < next_allowed_hs:
150 delta = next_allowed_hs - datetime.now()
151 raise ProtocolError("""Please wait another %d seconds until next handshake (login) attempt.""" % delta.seconds)
153 LAST_HS = datetime.now()
155 tstamp = int(mktime(datetime.now().timetuple()))
156 url = "http://post.audioscrobbler.com/"
157 pwhash = md5(password).hexdigest()
158 token = md5( "%s%d" % (pwhash, int(tstamp))).hexdigest()
159 values = {
160 'hs': 'true',
161 'p' : PROTOCOL_VERSION,
162 'c': client[0],
163 'v': client[1],
164 'u': user,
165 't': tstamp,
166 'a': token
168 data = urllib.urlencode(values)
169 req = urllib2.Request("%s?%s" % (url, data) )
170 response = urllib2.urlopen(req)
171 result = response.read()
172 lines = result.split('\n')
175 if lines[0] == 'BADAUTH':
176 raise AuthError('Bad username/password')
178 elif lines[0] == 'BANNED':
179 raise Exception('''This client-version was banned by Audioscrobbler. Please contact the author of this module!''')
181 elif lines[0] == 'BADTIME':
182 raise ValueError('''Your system time is out of sync with Audioscrobbler.Consider using an NTP-client to keep you system time in sync.''')
184 elif lines[0].startswith('FAILED'):
185 handle_hard_error()
186 raise BackendError("Authencitation with AS failed. Reason: %s" %
187 lines[0])
189 elif lines[0] == 'OK':
190 # wooooooohooooooo. We made it!
191 SESSION_ID = lines[1]
192 NOW_URL = lines[2]
193 POST_URL = lines[3]
194 HARD_FAILS = 0
196 else:
197 # some hard error
198 handle_hard_error()
200 def handle_hard_error():
201 "Handles hard errors."
202 global SESSION_ID, HARD_FAILS, HS_DELAY
204 if HS_DELAY == 0:
205 HS_DELAY = 60
206 elif HS_DELAY < 120*60:
207 HS_DELAY *= 2
208 if HS_DELAY > 120*60:
209 HS_DELAY = 120*60
211 HARD_FAILS += 1
212 if HARD_FAILS == 3:
213 SESSION_ID = None
215 def now_playing( artist, track, album="", length="", trackno="", mbid="" ):
216 """Tells audioscrobbler what is currently running in your player. This won't
217 affect the user-profile on last.fm. To do submissions, use the "submit"
218 method
220 @param artist: The artist name
221 @param track: The track name
222 @param album: The album name
223 @param length: The song length in seconds
224 @param trackno: The track number
225 @param mbid: The MusicBrainz Track ID
226 @return: True on success, False on failure"""
228 global SESSION_ID, NOW_URL
230 if SESSION_ID is None:
231 raise AuthError("Please 'login()' first. (No session available)")
233 if POST_URL is None:
234 raise PostError("Unable to post data. Post URL was empty!")
236 if length != "" and type(length) != type(1):
237 raise TypeError("length should be of type int")
239 if trackno != "" and type(trackno) != type(1):
240 raise TypeError("trackno should be of type int")
242 values = {'s': SESSION_ID,
243 'a': unicode(artist).encode('utf-8'),
244 't': unicode(track).encode('utf-8'),
245 'b': unicode(album).encode('utf-8'),
246 'l': length,
247 'n': trackno,
248 'm': mbid }
250 data = urllib.urlencode(values)
251 req = urllib2.Request(NOW_URL, data)
252 response = urllib2.urlopen(req)
253 result = response.read()
255 if result.strip() == "OK":
256 return True
257 elif result.strip() == "BADSESSION" :
258 raise SessionError('Invalid session')
259 else:
260 return False
262 def submit(artist, track, time, source='P', rating="", length="", album="",
263 trackno="", mbid="", autoflush=False):
264 """Append a song to the submission cache. Use 'flush()' to send the cache to
265 AS. You can also set "autoflush" to True.
267 From the Audioscrobbler protocol docs:
268 ---------------------------------------------------------------------------
270 The client should monitor the user's interaction with the music playing
271 service to whatever extent the service allows. In order to qualify for
272 submission all of the following criteria must be met:
274 1. The track must be submitted once it has finished playing. Whether it has
275 finished playing naturally or has been manually stopped by the user is
276 irrelevant.
277 2. The track must have been played for a duration of at least 240 seconds or
278 half the track's total length, whichever comes first. Skipping or pausing
279 the track is irrelevant as long as the appropriate amount has been played.
280 3. The total playback time for the track must be more than 30 seconds. Do
281 not submit tracks shorter than this.
282 4. Unless the client has been specially configured, it should not attempt to
283 interpret filename information to obtain metadata instead of tags (ID3,
284 etc).
286 @param artist: Artist name
287 @param track: Track name
288 @param time: Time the track *started* playing in the UTC timezone (see
289 datetime.utcnow()).
291 Example: int(time.mktime(datetime.utcnow()))
292 @param source: Source of the track. One of:
293 'P': Chosen by the user
294 'R': Non-personalised broadcast (e.g. Shoutcast, BBC Radio 1)
295 'E': Personalised recommendation except Last.fm (e.g.
296 Pandora, Launchcast)
297 'L': Last.fm (any mode). In this case, the 5-digit Last.fm
298 recommendation key must be appended to this source ID to
299 prove the validity of the submission (for example,
300 "L1b48a").
301 'U': Source unknown
302 @param rating: The rating of the song. One of:
303 'L': Love (on any mode if the user has manually loved the
304 track)
305 'B': Ban (only if source=L)
306 'S': Skip (only if source=L)
307 '': Not applicable
308 @param length: The song length in seconds
309 @param album: The album name
310 @param trackno:The track number
311 @param mbid: MusicBrainz Track ID
312 @param autoflush: Automatically flush the cache to AS?
315 global SUBMIT_CACHE, MAX_CACHE
317 source = source.upper()
318 rating = rating.upper()
320 if source == 'L' and (rating == 'B' or rating == 'S'):
321 raise ProtocolError("""You can only use rating 'B' or 'S' on source 'L'.See the docs!""")
323 if source == 'P' and length == '':
324 raise ProtocolError("""Song length must be specified when using 'P' as source!""")
326 if type(time) != type(1):
327 raise ValueError("""The time parameter must be of type int (unix timestamp). Instead it was %s""" % time)
329 SUBMIT_CACHE.append(
330 { 'a': unicode(artist).encode('utf-8'),
331 't': unicode(track).encode('utf-8'),
332 'i': time,
333 'o': source,
334 'r': rating,
335 'l': length,
336 'b': unicode(album).encode('utf-8'),
337 'n': trackno,
338 'm': mbid
342 if autoflush or len(SUBMIT_CACHE) >= MAX_CACHE:
343 flush()
345 def flush():
346 "Sends the cached songs to AS."
347 global SUBMIT_CACHE
349 values = {}
351 for i, item in enumerate(SUBMIT_CACHE):
352 for key in item:
353 values[key + "[%d]" % i] = item[key]
355 values['s'] = SESSION_ID
357 data = urllib.urlencode(values)
358 req = urllib2.Request(POST_URL, data)
359 response = urllib2.urlopen(req)
360 result = response.read()
361 lines = result.split('\n')
363 if lines[0] == "OK":
364 SUBMIT_CACHE = []
365 return True
366 elif lines[0] == "BADSESSION" :
367 raise SessionError('Invalid session')
368 elif lines[0].startswith('FAILED'):
369 handle_hard_error()
370 raise BackendError("Authencitation with AS failed. Reason: %s" %
371 lines[0])
372 else:
373 # some hard error
374 handle_hard_error()
375 return False
378 if __name__ == "__main__":
379 login( 'user', 'password' )
380 submit(
381 'De/Vision',
382 'Scars',
383 1192374052,
384 source='P',
385 length=3*60+44
387 submit(
388 'Spineshank',
389 'Beginning of the End',
390 1192374052+(5*60),
391 source='P',
392 length=3*60+32
394 submit(
395 'Dry Cell',
396 'Body Crumbles',
397 1192374052+(10*60),
398 source='P',
399 length=3*60+3
401 print flush()