1""" 2A pure-python library to assist sending data to AudioScrobbler (the Last.fm 3backend) 4""" 5import urllib.parse 6import urllib.request 7import logging 8from time import mktime 9from datetime import datetime, timedelta 10from hashlib import md5 11 12logger = logging.getLogger(__name__) 13 14SESSION_ID = None 15INITIAL_URL = None 16POST_URL = None 17POST_URL = None 18NOW_URL = None 19HARD_FAILS = 0 20LAST_HS = None # Last handshake time 21HS_DELAY = 0 # wait this many seconds until next handshake 22SUBMIT_CACHE = [] 23MAX_CACHE = 5 # keep only this many songs in the cache 24MAX_SUBMIT = 10 # submit at most this many tracks at one time 25PROTOCOL_VERSION = '1.2' 26__LOGIN = {} # data required to login 27 28USER_AGENT_HEADERS = None 29 30 31class BackendError(Exception): 32 "Raised if the AS backend does something funny" 33 pass 34 35 36class AuthError(Exception): 37 "Raised on authencitation errors" 38 pass 39 40 41class PostError(Exception): 42 "Raised if something goes wrong when posting data to AS" 43 pass 44 45 46class SessionError(Exception): 47 "Raised when problems with the session exist" 48 pass 49 50 51class ProtocolError(Exception): 52 "Raised on general Protocol errors" 53 pass 54 55 56def set_user_agent(s): 57 global USER_AGENT_HEADERS 58 USER_AGENT_HEADERS = {'User-Agent': s} 59 60 61def login(user, password, hashpw=False, client=('exa', '0.3.0'), post_url=None): 62 """Authencitate with AS (The Handshake) 63 64 @param user: The username 65 @param password: md5-hash of the user-password 66 @param hashpw: If True, then the md5-hash of the password is performed 67 internally. If set to False (the default), then the module 68 assumes the passed value is already a valid md5-hash of the 69 password. 70 @param client: Client information (see https://www.last.fm/api for more info) 71 @type client: Tuple: (client-id, client-version)""" 72 global LAST_HS, SESSION_ID, POST_URL, NOW_URL, HARD_FAILS, HS_DELAY, PROTOCOL_VERSION, INITIAL_URL, __LOGIN 73 74 __LOGIN['u'] = user 75 __LOGIN['c'] = client 76 77 if LAST_HS is not None: 78 next_allowed_hs = LAST_HS + timedelta(seconds=HS_DELAY) 79 if datetime.now() < next_allowed_hs: 80 delta = next_allowed_hs - datetime.now() 81 raise ProtocolError( 82 """Please wait another %d seconds until next handshake 83(login) attempt.""" 84 % delta.seconds 85 ) 86 87 LAST_HS = datetime.now() 88 89 tstamp = int(mktime(datetime.now().timetuple())) 90 # Store and keep first passed URL for future login retries 91 INITIAL_URL = INITIAL_URL or post_url 92 # Use passed or previously stored URL 93 url = post_url or POST_URL 94 95 if hashpw is True: 96 __LOGIN['p'] = md5(password.encode('utf-8')).hexdigest() 97 else: 98 __LOGIN['p'] = password 99 100 token = "%s%d" % (__LOGIN['p'], int(tstamp)) 101 token = md5(token.encode('utf-8')).hexdigest() 102 values = { 103 'hs': 'true', 104 'p': PROTOCOL_VERSION, 105 'c': client[0], 106 'v': client[1], 107 'u': user, 108 't': tstamp, 109 'a': token, 110 } 111 data = urllib.parse.urlencode(values) 112 req = urllib.request.Request("%s?%s" % (url, data), None, USER_AGENT_HEADERS) 113 response = urllib.request.urlopen(req) 114 result = response.read().decode('utf-8') 115 lines = result.split('\n') 116 117 if lines[0] == 'BADAUTH': 118 raise AuthError('Bad username/password') 119 120 elif lines[0] == 'BANNED': 121 raise Exception( 122 '''This client-version was banned by Audioscrobbler. Please 123contact the author of this module!''' 124 ) 125 126 elif lines[0] == 'BADTIME': 127 raise ValueError( 128 '''Your system time is out of sync with Audioscrobbler. 129Consider using an NTP-client to keep you system time in sync.''' 130 ) 131 132 elif lines[0].startswith('FAILED'): 133 handle_hard_error() 134 raise BackendError("Authencitation with AS failed. Reason: %s" % lines[0]) 135 136 elif lines[0] == 'OK': 137 # wooooooohooooooo. We made it! 138 SESSION_ID = lines[1] 139 NOW_URL = lines[2] 140 POST_URL = lines[3] 141 HARD_FAILS = 0 142 logger.info("Logged in successfully to AudioScrobbler (%s)", url) 143 144 else: 145 # some hard error 146 handle_hard_error() 147 148 149def handle_hard_error(): 150 "Handles hard errors." 151 global SESSION_ID, HARD_FAILS, HS_DELAY 152 153 if HS_DELAY == 0: 154 HS_DELAY = 60 155 elif HS_DELAY < 120 * 60: 156 HS_DELAY *= 2 157 if HS_DELAY > 120 * 60: 158 HS_DELAY = 120 * 60 159 160 HARD_FAILS += 1 161 if HARD_FAILS == 3: 162 SESSION_ID = None 163 164 165def now_playing( 166 artist, track, album="", length="", trackno="", mbid="", inner_call=False 167): 168 """Tells audioscrobbler what is currently running in your player. This won't 169 affect the user-profile on last.fm. To do submissions, use the "submit" 170 method 171 172 @param artist: The artist name 173 @param track: The track name 174 @param album: The album name 175 @param length: The song length in seconds 176 @param trackno: The track number 177 @param mbid: The MusicBrainz Track ID 178 @return: True on success, False on failure""" 179 180 global SESSION_ID, NOW_URL, INITIAL_URL 181 182 if SESSION_ID is None: 183 raise AuthError("Please 'login()' first. (No session available)") 184 185 if POST_URL is None: 186 raise PostError("Unable to post data. Post URL was empty!") 187 188 if length != "" and not isinstance(length, type(1)): 189 raise TypeError("length should be of type int") 190 191 if trackno != "" and not isinstance(trackno, type(1)): 192 raise TypeError("trackno should be of type int") 193 194 # Quote from AS Protocol 1.1, Submitting Songs: 195 # Note that all the post variables noted here MUST be 196 # supplied for each entry, even if they are blank. 197 track = track or '' 198 artist = artist or '' 199 album = album or '' 200 201 values = { 202 's': SESSION_ID, 203 'a': artist, 204 't': track, 205 'b': album, 206 'l': length, 207 'n': trackno, 208 'm': mbid, 209 } 210 data = urllib.parse.urlencode(values) 211 212 req = urllib.request.Request(NOW_URL, data.encode('utf-8'), USER_AGENT_HEADERS) 213 response = urllib.request.urlopen(req) 214 result = response.read().decode('utf-8') 215 216 if result.strip() == "OK": 217 logger.info("Submitted \"Now Playing\" successfully to AudioScrobbler") 218 return True 219 elif result.strip() == "BADSESSION": 220 if inner_call is False: 221 login(__LOGIN['u'], __LOGIN['p'], client=__LOGIN['c'], post_url=INITIAL_URL) 222 now_playing(artist, track, album, length, trackno, mbid, inner_call=True) 223 else: 224 raise SessionError('Invalid session') 225 else: 226 logger.warning("Error submitting \"Now Playing\"") 227 228 return False 229 230 231def submit( 232 artist, 233 track, 234 time=0, 235 source='P', 236 rating="", 237 length="", 238 album="", 239 trackno="", 240 mbid="", 241 autoflush=False, 242): 243 """Append a song to the submission cache. Use 'flush()' to send the cache to 244 AS. You can also set "autoflush" to True. 245 246 From the Audioscrobbler protocol docs: 247 --------------------------------------------------------------------------- 248 249 The client should monitor the user's interaction with the music playing 250 service to whatever extent the service allows. In order to qualify for 251 submission all of the following criteria must be met: 252 253 1. The track must be submitted once it has finished playing. Whether it has 254 finished playing naturally or has been manually stopped by the user is 255 irrelevant. 256 2. The track must have been played for a duration of at least 240 seconds or 257 half the track's total length, whichever comes first. Skipping or pausing 258 the track is irrelevant as long as the appropriate amount has been played. 259 3. The total playback time for the track must be more than 30 seconds. Do 260 not submit tracks shorter than this. 261 4. Unless the client has been specially configured, it should not attempt to 262 interpret filename information to obtain metadata instead of tags (ID3, 263 etc). 264 265 @param artist: Artist name 266 @param track: Track name 267 @param time: Time the track *started* playing in the UTC timezone (see 268 datetime.utcnow()). 269 270 Example: int(time.mktime(datetime.utcnow())) 271 @param source: Source of the track. One of: 272 'P': Chosen by the user 273 'R': Non-personalised broadcast (e.g. Shoutcast, BBC Radio 1) 274 'E': Personalised recommendation except Last.fm (e.g. 275 Pandora, Launchcast) 276 'L': Last.fm (any mode). In this case, the 5-digit Last.fm 277 recommendation key must be appended to this source ID to 278 prove the validity of the submission (for example, 279 "L1b48a"). 280 'U': Source unknown 281 @param rating: The rating of the song. One of: 282 'L': Love (on any mode if the user has manually loved the 283 track) 284 'B': Ban (only if source=L) 285 'S': Skip (only if source=L) 286 '': Not applicable 287 @param length: The song length in seconds 288 @param album: The album name 289 @param trackno:The track number 290 @param mbid: MusicBrainz Track ID 291 @param autoflush: Automatically flush the cache to AS? 292 @return: True on success, False if something went wrong 293 """ 294 if None in (artist, track): 295 raise Exception 296 297 if not artist.strip() or not track.strip(): 298 raise Exception 299 300 global SUBMIT_CACHE, MAX_CACHE 301 302 source = source.upper() 303 rating = rating.upper() 304 305 if source == 'L' and (rating == 'B' or rating == 'S'): 306 raise ProtocolError( 307 """You can only use rating 'B' or 'S' on source 'L'. 308 See the docs!""" 309 ) 310 311 if source == 'P' and length == '': 312 raise ProtocolError( 313 """Song length must be specified when using 'P' as 314 source!""" 315 ) 316 317 if not isinstance(time, type(1)): 318 raise ValueError( 319 """The time parameter must be of type int (unix 320 timestamp). Instead it was %s""" 321 % time 322 ) 323 324 album = album or '' 325 326 SUBMIT_CACHE.append( 327 { 328 'a': artist, 329 't': track, 330 'i': time, 331 'o': source, 332 'r': rating, 333 'l': length, 334 'b': album, 335 'n': trackno, 336 'm': mbid, 337 } 338 ) 339 340 if autoflush or len(SUBMIT_CACHE) >= MAX_CACHE: 341 return flush() 342 else: 343 return True 344 345 346def flush(inner_call=False): 347 """Sends the cached songs to AS. 348 349 @param inner_call: Internally used variable. Don't touch!""" 350 global SUBMIT_CACHE, __LOGIN, MAX_SUBMIT, POST_URL, INITIAL_URL 351 352 if POST_URL is None: 353 raise ProtocolError( 354 '''Cannot submit without having a valid post-URL. Did 355you login?''' 356 ) 357 358 values = {} 359 360 for i, item in enumerate(SUBMIT_CACHE[:MAX_SUBMIT]): 361 for key in item: 362 values[key + "[%d]" % i] = item[key] 363 364 values['s'] = SESSION_ID 365 366 data = urllib.parse.urlencode(values) 367 req = urllib.request.Request(POST_URL, data.encode('utf-8'), USER_AGENT_HEADERS) 368 response = urllib.request.urlopen(req) 369 result = response.read().decode('utf-8') 370 lines = result.split('\n') 371 372 if lines[0] == "OK": 373 SUBMIT_CACHE = SUBMIT_CACHE[MAX_SUBMIT:] 374 logger.info("AudioScrobbler OK: %s", data) 375 return True 376 elif lines[0] == "BADSESSION": 377 if inner_call is False: 378 login(__LOGIN['u'], __LOGIN['p'], client=__LOGIN['c'], post_url=INITIAL_URL) 379 flush(inner_call=True) 380 else: 381 raise Warning("Infinite loop prevented") 382 elif lines[0].startswith('FAILED'): 383 handle_hard_error() 384 raise BackendError("Submission to AS failed. Reason: %s" % lines[0]) 385 else: 386 # some hard error 387 handle_hard_error() 388 return False 389 390 391if __name__ == "__main__": 392 login('user', 'password') 393 submit('De/Vision', 'Scars', 1192374052, source='P', length=3 * 60 + 44) 394 submit( 395 'Spineshank', 396 'Beginning of the End', 397 1192374052 + (5 * 60), 398 source='P', 399 length=3 * 60 + 32, 400 ) 401 submit( 402 'Dry Cell', 403 'Body Crumbles', 404 1192374052 + (10 * 60), 405 source='P', 406 length=3 * 60 + 3, 407 ) 408 print(flush()) 409