6 SCROBBLER_USERNAME_DEFAULT
=''
7 SCROBBLER_PASSWORD_DEFAULT
=''
9 # TODO cached failed submissions
11 class pluginScrobbler(Plugin
):
15 def __init__(self
, winMain
):
16 Plugin
.__init
__(self
, winMain
, 'Scrobbler')
17 self
.addMontyListener('onSongChange', self
.onSongChange
)
18 self
.addMontyListener('onTimeChange', self
.onTimeChange
)
27 self
.normal("logging in %s"%(self
._username
()))
29 login(self
._username
(), self
._password
())
32 self
.normal("failed to login: "+str(e
))
34 self
.debug("no username provided, not logging in")
37 return self
.getSetting('username')
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:
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 ...")
55 self
.important("failed to submit song1 - %s"%(e))
56 self
.extended("Logging in ...")
62 self
.important("failed to submit song2 - %s"%(e))
66 self
.important("failed to submit the song!")
68 def onSongChange(self
, params
):
69 if self
.loggedIn
==False:
71 self
.time
=int(time
.mktime(datetime
.utcnow().timetuple()))
73 song
=monty
.getCurrentSong()
74 # for loop: in case of SessionError, we need to retry once ...
77 self
.extended("submitting now playing")
78 now_playing(song
.getArtist(), song
.getTitle(), song
.getAlbum(), "", song
.getTrack())
79 self
.debug("submitted")
82 self
.important("failed to submit playing song - %s"%(e))
84 except SessionError
, e
:
85 self
.normal("session error")
87 def _getSettings(self
):
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
):
95 return "Submits tracks to last.fm"
101 # http://exhuma.wicked.lu/projects/python/scrobbler/ !"
104 A pure-python library to assist sending data to AudioScrobbler (the LastFM
107 import urllib
, urllib2
108 from time
import mktime
109 from datetime
import datetime
, timedelta
116 LAST_HS
= None # Last handshake time
117 HS_DELAY
= 0 # wait this many seconds until next handshake
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"
125 class AuthError(Exception):
126 "Raised on authencitation errors"
128 class PostError(Exception):
129 "Raised if something goes wrong when posting data to AS"
131 class SessionError(Exception):
132 "Raised when problems with the session exist"
134 class ProtocolError(Exception):
135 "Raised on general Protocol errors"
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()
161 'p' : PROTOCOL_VERSION
,
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'):
186 raise BackendError("Authencitation with AS failed. Reason: %s" %
189 elif lines
[0] == 'OK':
190 # wooooooohooooooo. We made it!
191 SESSION_ID
= lines
[1]
200 def handle_hard_error():
201 "Handles hard errors."
202 global SESSION_ID
, HARD_FAILS
, HS_DELAY
206 elif HS_DELAY
< 120*60:
208 if HS_DELAY
> 120*60:
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"
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)")
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'),
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":
257 elif result
.strip() == "BADSESSION" :
258 raise SessionError('Invalid session')
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
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,
286 @param artist: Artist name
287 @param track: Track name
288 @param time: Time the track *started* playing in the UTC timezone (see
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.
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,
302 @param rating: The rating of the song. One of:
303 'L': Love (on any mode if the user has manually loved the
305 'B': Ban (only if source=L)
306 'S': Skip (only if source=L)
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
)
330 { 'a': unicode(artist
).encode('utf-8'),
331 't': unicode(track
).encode('utf-8'),
336 'b': unicode(album
).encode('utf-8'),
342 if autoflush
or len(SUBMIT_CACHE
) >= MAX_CACHE
:
346 "Sends the cached songs to AS."
351 for i
, item
in enumerate(SUBMIT_CACHE
):
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')
366 elif lines
[0] == "BADSESSION" :
367 raise SessionError('Invalid session')
368 elif lines
[0].startswith('FAILED'):
370 raise BackendError("Authencitation with AS failed. Reason: %s" %
378 if __name__
== "__main__":
379 login( 'user', 'password' )
389 'Beginning of the End',