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