1# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*-
2# Copyright (C) 2010 Kevin Mehall <km@kevinmehall.net>
3# Copyright (C) 2012 Christopher Eby <kreed@kreed.org>
4# This program is free software: you can redistribute it and/or modify it
5# under the terms of the GNU General Public License version 3, as published
6# by the Free Software Foundation.
7#
8# This program is distributed in the hope that it will be useful, but
9# WITHOUT ANY WARRANTY; without even the implied warranties of
10# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
11# PURPOSE.  See the GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License along
14# with this program.  If not, see <http://www.gnu.org/licenses/>.
15
16"""Pandora JSON v5 API
17
18See http://6xq.net/playground/pandora-apidoc/json/ for API documentation.
19"""
20
21from .blowfish import Blowfish
22# from Crypto.Cipher import Blowfish
23from xml.dom import minidom
24import re
25import json
26import logging
27import time
28import urllib.request, urllib.parse, urllib.error
29import codecs
30import ssl
31import os
32from enum import IntEnum
33from socket import error as SocketError
34
35from . import data
36
37HTTP_TIMEOUT = 30
38USER_AGENT = 'pithos'
39
40RATE_BAN = 'ban'
41RATE_LOVE = 'love'
42RATE_NONE = None
43
44class ApiError(IntEnum):
45    INTERNAL_ERROR = 0
46    MAINTENANCE_MODE = 1
47    URL_PARAM_MISSING_METHOD = 2
48    URL_PARAM_MISSING_AUTH_TOKEN = 3
49    URL_PARAM_MISSING_PARTNER_ID = 4
50    URL_PARAM_MISSING_USER_ID = 5
51    SECURE_PROTOCOL_REQUIRED = 6
52    CERTIFICATE_REQUIRED = 7
53    PARAMETER_TYPE_MISMATCH = 8
54    PARAMETER_MISSING = 9
55    PARAMETER_VALUE_INVALID = 10
56    API_VERSION_NOT_SUPPORTED = 11
57    COUNTRY_NOT_SUPPORTED = 12
58    INSUFFICIENT_CONNECTIVITY = 13
59    UNKNOWN_METHOD_NAME = 14
60    WRONG_PROTOCOL = 15
61    READ_ONLY_MODE = 1000
62    INVALID_AUTH_TOKEN = 1001
63    INVALID_LOGIN = 1002
64    LISTENER_NOT_AUTHORIZED = 1003
65    USER_NOT_AUTHORIZED = 1004
66    MAX_STATIONS_REACHED = 1005
67    STATION_DOES_NOT_EXIST = 1006
68    COMPLIMENTARY_PERIOD_ALREADY_IN_USE = 1007
69    CALL_NOT_ALLOWED = 1008
70    DEVICE_NOT_FOUND = 1009
71    PARTNER_NOT_AUTHORIZED = 1010
72    INVALID_USERNAME = 1011
73    INVALID_PASSWORD = 1012
74    USERNAME_ALREADY_EXISTS = 1013
75    DEVICE_ALREADY_ASSOCIATED_TO_ACCOUNT = 1014
76    UPGRADE_DEVICE_MODEL_INVALID = 1015
77    EXPLICIT_PIN_INCORRECT = 1018
78    EXPLICIT_PIN_MALFORMED = 1020
79    DEVICE_MODEL_INVALID = 1023
80    ZIP_CODE_INVALID = 1024
81    BIRTH_YEAR_INVALID = 1025
82    BIRTH_YEAR_TOO_YOUNG = 1026
83    # FIXME: They can't both be 1027?
84    # INVALID_COUNTRY_CODE = 1027
85    # INVALID_GENDER = 1027
86    DEVICE_DISABLED = 1034
87    DAILY_TRIAL_LIMIT_REACHED = 1035
88    INVALID_SPONSOR = 1036
89    USER_ALREADY_USED_TRIAL = 1037
90    PLAYLIST_EXCEEDED = 1039
91    # Catch all for undocumented error codes
92    UNKNOWN_ERROR = 100000
93
94    @property
95    def title(self):
96        # Turns RANDOM_ERROR into Pandora Error: Random Error
97        return 'Pandora Error: {}'.format(self.name.replace('_', ' ').title())
98
99    @property
100    def sub_message(self):
101        value = self.value
102        if value == 1:
103            return 'Pandora is performing maintenance.\nTry again later.'
104        elif value == 12:
105            return ('Pandora is not available in your country.\n'
106                    'If you wish to use Pandora you must configure your system or Pithos proxy accordingly.')
107        elif value == 13:
108            return ('Out of sync. Correct your system\'s clock.\n'
109                    'If the problem persists it may indicate a Pandora API change.\nA Pithos update may be required.')
110        if value == 1000:
111            return 'Pandora is in read-only mode.\nTry again later.'
112        elif value == 1002:
113            return 'Invalid username or password.'
114        elif value == 1003:
115            return 'A Pandora One account is required to access this feature.\nUncheck "Pandora One" in Settings.'
116        elif value == 1005:
117            return ('You have reached the maximum number of stations.\n'
118                    'To add a new station you must first delete an existing station.')
119        elif value == 1010:
120            return 'Invalid Pandora partner keys.\nA Pithos update may be required.'
121        elif value == 1023:
122            return 'Invalid Pandora device model.\nA Pithos update may be required.'
123        elif value == 1039:
124            return 'You have requested too many playlists.\nTry again later.'
125        else:
126            return None
127
128PLAYLIST_VALIDITY_TIME = 60*60
129
130NAME_COMPARE_REGEX = re.compile(r'[^A-Za-z0-9]')
131
132class PandoraError(IOError):
133    def __init__(self, message, status=None, submsg=None):
134        self.status = status
135        self.message = message
136        self.submsg = submsg
137
138class PandoraAuthTokenInvalid(PandoraError): pass
139class PandoraNetError(PandoraError): pass
140class PandoraAPIVersionError(PandoraError): pass
141class PandoraTimeout(PandoraNetError): pass
142
143def pad(s, l):
144    return s + b'\0' * (l - len(s))
145
146class Pandora:
147    """Access the Pandora API
148
149    To use the Pandora class, make sure to call :py:meth:`set_audio_quality`
150    and :py:meth:`connect` methods.
151
152    Get information from Pandora using:
153
154    - :py:meth:`get_stations` which populates the :py:attr:`stations` attribute
155    - :py:meth:`search` to find songs to add to stations or create a new station with
156    - :py:meth:`json_call` call into the JSON API directly
157    """
158    def __init__(self):
159        self.opener = self.build_opener()
160        self.connected = False
161        self.isSubscriber = False
162
163    def pandora_encrypt(self, s):
164        return b''.join([codecs.encode(self.blowfish_encode.encrypt(pad(s[i:i+8], 8)), 'hex_codec') for i in range(0, len(s), 8)])
165
166    def pandora_decrypt(self, s):
167        return b''.join([self.blowfish_decode.decrypt(pad(codecs.decode(s[i:i+16], 'hex_codec'), 8)) for i in range(0, len(s), 16)]).rstrip(b'\x08')
168
169    def json_call(self, method, args=None, https=False, blowfish=True):
170        if not args:
171            args = {}
172        url_arg_strings = []
173        if self.partnerId:
174            url_arg_strings.append('partner_id=%s'%self.partnerId)
175        if self.userId:
176            url_arg_strings.append('user_id=%s'%self.userId)
177        if self.userAuthToken:
178            url_arg_strings.append('auth_token=%s'%urllib.parse.quote_plus(self.userAuthToken))
179        elif self.partnerAuthToken:
180            url_arg_strings.append('auth_token=%s'%urllib.parse.quote_plus(self.partnerAuthToken))
181
182        url_arg_strings.append('method=%s'%method)
183        protocol = 'https' if https else 'http'
184        url = protocol + self.rpcUrl + '&'.join(url_arg_strings)
185
186        if self.time_offset:
187            args['syncTime'] = int(time.time()+self.time_offset)
188        if self.userAuthToken:
189            args['userAuthToken'] = self.userAuthToken
190        elif self.partnerAuthToken:
191            args['partnerAuthToken'] = self.partnerAuthToken
192        data = json.dumps(args).encode('utf-8')
193
194        logging.debug(url)
195        logging.debug(data)
196
197        if blowfish:
198            data = self.pandora_encrypt(data)
199
200        try:
201            req = urllib.request.Request(url, data, {'User-agent': USER_AGENT, 'Content-type': 'text/plain'})
202            with self.opener.open(req, timeout=HTTP_TIMEOUT) as response:
203                text = response.read().decode('utf-8')
204        except urllib.error.HTTPError as e:
205            logging.error("HTTP error: %s", e)
206            raise PandoraNetError(str(e))
207        except urllib.error.URLError as e:
208            logging.error("Network error: %s", e)
209            if e.reason.strerror == 'timed out':
210                raise PandoraTimeout("Network error", submsg="Timeout")
211            else:
212                raise PandoraNetError("Network error", submsg=e.reason.strerror)
213        except SocketError as e:
214            try:
215                error_string = os.strerror(e.errno)
216            except (TypeError, ValueError):
217                error_string = "Unknown Error"
218            logging.error("Network Socket Error: %s", error_string)
219            raise PandoraNetError("Network Socket Error", submsg=error_string)
220
221        logging.debug(text)
222
223        tree = json.loads(text)
224        if tree['stat'] == 'fail':
225            code = tree['code']
226            msg = tree['message']
227
228            try:
229                error_enum = ApiError(code)
230            except ValueError:
231                error_enum = ApiError.UNKNOWN_ERROR
232
233            logging.error('fault code: {} {} message: {}'.format(code, error_enum.name, msg))
234
235            if error_enum is ApiError.INVALID_AUTH_TOKEN:
236                raise PandoraAuthTokenInvalid(msg)
237            elif error_enum is ApiError.API_VERSION_NOT_SUPPORTED:
238                raise PandoraAPIVersionError(msg)
239            elif error_enum is ApiError.UNKNOWN_ERROR:
240                submsg = 'Undocumented Error Code: {}\n{}'.format(code, msg)
241                raise PandoraError(error_enum.title, code, submsg)
242            else:
243                submsg = error_enum.sub_message or 'Error Code: {}\n{}'.format(code, msg)
244                raise PandoraError(error_enum.title, code, submsg)
245
246        if 'result' in tree:
247            return tree['result']
248
249    def set_audio_quality(self, fmt):
250        """Set the desired audio quality
251
252        Used by the :py:attr:`Song.audioUrl` property.
253
254        :param fmt: An audio quality format from :py:data:`pithos.pandora.data.valid_audio_formats`
255        """
256        self.audio_quality = fmt
257
258    @staticmethod
259    def build_opener(*handlers):
260        """Creates a new opener
261
262        Wrapper around urllib.request.build_opener() that adds
263        a custom ssl.SSLContext for use with internal-tuner.pandora.com
264        """
265        ctx = ssl.create_default_context()
266        ctx.load_verify_locations(cadata=data.internal_cert)
267        https = urllib.request.HTTPSHandler(context=ctx)
268        return urllib.request.build_opener(https, *handlers)
269
270    def set_url_opener(self, opener):
271        self.opener = opener
272
273    def connect(self, client, user, password):
274        """Connect to the Pandora API and log the user in
275
276        :param client:   The client ID from :py:data:`pithos.pandora.data.client_keys`
277        :param user:     The user's login email
278        :param password: The user's login password
279        """
280        self.connected = False
281        self.partnerId = self.userId = self.partnerAuthToken = None
282        self.userAuthToken = self.time_offset = None
283
284        self.rpcUrl = client['rpcUrl']
285        self.blowfish_encode = Blowfish(client['encryptKey'].encode('utf-8'))
286        self.blowfish_decode = Blowfish(client['decryptKey'].encode('utf-8'))
287
288        partner = self.json_call('auth.partnerLogin', {
289            'deviceModel': client['deviceModel'],
290            'username': client['username'], # partner username
291            'password': client['password'], # partner password
292            'version': client['version']
293            },https=True, blowfish=False)
294
295        self.partnerId = partner['partnerId']
296        self.partnerAuthToken = partner['partnerAuthToken']
297
298        pandora_time = int(self.pandora_decrypt(partner['syncTime'].encode('utf-8'))[4:14])
299        self.time_offset = pandora_time - time.time()
300        logging.info("Time offset is %s", self.time_offset)
301        auth_args = {'username': user, 'password': password, 'loginType': 'user', 'returnIsSubscriber': True}
302        user = self.json_call('auth.userLogin', auth_args, https=True)
303        self.userId = user['userId']
304        self.userAuthToken = user['userAuthToken']
305
306        self.connected = True
307        self.isSubscriber = user['isSubscriber']
308
309    @property
310    def explicit_content_filter_state(self):
311        """The User must already be authenticated before this is called.
312           returns the state of Explicit Content Filter and if the Explicit Content Filter is PIN protected
313        """
314        get_filter_state = self.json_call('user.getSettings', https=True)
315        filter_state = get_filter_state['isExplicitContentFilterEnabled']
316        pin_protected = get_filter_state['isExplicitContentFilterPINProtected']
317        logging.info('Explicit Content Filter state: %s' %filter_state)
318        logging.info('PIN protected: %s' %pin_protected)
319        return filter_state, pin_protected
320
321    def set_explicit_content_filter(self, state):
322        """The User must already be authenticated before this is called.
323           Does not take effect until the next playlist.
324           Valid desired states are True to enable and False to disable the Explicit Content Filter.
325        """
326        self.json_call('user.setExplicitContentFilter', {'isExplicitContentFilterEnabled': state})
327        logging.info('Explicit Content Filter set to: %s' %(state))
328
329    def get_stations(self, *ignore):
330        stations = self.json_call(
331            'user.getStationList',
332            {'returnAllStations': True}
333        )['stations']
334        self.quickMixStationIds = None
335        self.stations = [Station(self, i) for i in stations]
336
337        if self.quickMixStationIds:
338            for i in self.stations:
339                if i.id in self.quickMixStationIds:
340                    i.useQuickMix = True
341
342        return self.stations
343
344    def save_quick_mix(self):
345        stationIds = []
346        for i in self.stations:
347            if i.useQuickMix:
348                stationIds.append(i.id)
349        self.json_call('user.setQuickMix', {'quickMixStationIds': stationIds})
350
351    def search(self, query):
352        results = self.json_call(
353            'music.search',
354            {'includeGenreStations': True, 'includeNearMatches': True, 'searchText': query},
355        )
356
357        l = [SearchResult('artist', i) for i in results['artists'] if i['score'] >= 80]
358        l += [SearchResult('song', i) for i in results['songs'] if i['score'] >= 80]
359        l += [SearchResult('genre', i) for i in results['genreStations']]
360        l.sort(key=lambda i: i.score, reverse=True)
361
362        return l
363
364    def add_station_by_music_id(self, musicid):
365        d = self.json_call('station.createStation', {'musicToken': musicid})
366        station = Station(self, d)
367        if not self.get_station_by_id(station.id):
368            self.stations.append(station)
369        return station
370
371    def add_station_by_track_token(self, trackToken, musicType):
372        d = self.json_call('station.createStation', {'trackToken': trackToken, 'musicType': musicType})
373        station = Station(self, d)
374        if not self.get_station_by_id(station.id):
375            self.stations.append(station)
376        return station
377
378    def delete_station(self, station):
379        if self.get_station_by_id(station.id):
380            logging.info("pandora: Deleting Station")
381            self.json_call('station.deleteStation', {'stationToken': station.idToken})
382            self.stations.remove(station)
383
384    def get_station_by_id(self, id):
385        for i in self.stations:
386            if i.id == id:
387                return i
388
389    def add_feedback(self, trackToken, rating):
390        logging.info("pandora: addFeedback")
391        rating_bool = True if rating == RATE_LOVE else False
392        feedback = self.json_call('station.addFeedback', {'trackToken': trackToken, 'isPositive': rating_bool})
393        return feedback['feedbackId']
394
395    def delete_feedback(self, stationToken, feedbackId):
396        self.json_call('station.deleteFeedback', {'feedbackId': feedbackId, 'stationToken': stationToken})
397
398class Station:
399    def __init__(self, pandora, d):
400        self.pandora = pandora
401
402        self.id = d['stationId']
403        self.idToken = d['stationToken']
404        self.isCreator = not d['isShared']
405        self.isQuickMix = d['isQuickMix']
406        self.isThumbprint = d.get('isThumbprint', False)
407        self.name = d['stationName']
408        self.useQuickMix = False
409
410        if self.isQuickMix:
411            self.pandora.quickMixStationIds = d.get('quickMixStationIds', [])
412
413    def transformIfShared(self):
414        if not self.isCreator:
415            logging.info("pandora: transforming station")
416            self.pandora.json_call('station.transformSharedStation', {'stationToken': self.idToken})
417            self.isCreator = True
418
419    def get_playlist(self):
420        logging.info("pandora: Get Playlist")
421        # Set the playlist time to the time we requested a playlist.
422        # It is better that a playlist be considered invalid a fraction
423        # of a sec early than be considered valid any longer than it actually is.
424        playlist_time = time.time()
425        playlist = self.pandora.json_call('station.getPlaylist', {
426                        'stationToken': self.idToken,
427                        'includeTrackLength': True,
428                        'additionalAudioUrl': 'HTTP_32_AACPLUS,HTTP_128_MP3',
429                    }, https=True)['items']
430
431        return [Song(self.pandora, i, playlist_time) for i in playlist if 'songName' in i]
432
433    @property
434    def info_url(self):
435        return 'http://www.pandora.com/stations/'+self.idToken
436
437    def rename(self, new_name):
438        if new_name != self.name:
439            self.transformIfShared()
440            logging.info("pandora: Renaming station")
441            self.pandora.json_call('station.renameStation', {'stationToken': self.idToken, 'stationName': new_name})
442            self.name = new_name
443
444    def delete(self):
445        self.pandora.delete_station(self)
446
447    def __repr__(self):
448        return '<{}.{} {} "{}">'.format(
449            __name__,
450            __class__.__name__,
451            self.id,
452            self.name,
453        )
454
455class Song:
456    def __init__(self, pandora, d, playlist_time):
457        self.pandora = pandora
458        self.playlist_time = playlist_time
459        self.is_ad = None  # None = we haven't checked, otherwise True/False
460        self.tired = False
461        self.message = ''
462        self.duration = None
463        self.position = None
464        self.bitrate = None
465        self.start_time = None
466        self.finished = False
467        self.feedbackId = None
468        self.bitrate = None
469        self.artUrl = None
470        self.album = d['albumName']
471        self.artist = d['artistName']
472        self.trackToken = d['trackToken']
473        self.rating = RATE_LOVE if d['songRating'] == 1 else RATE_NONE # banned songs won't play, so we don't care about them
474        self.stationId = d['stationId']
475        self.songName = d['songName']
476        self.songDetailURL = d['songDetailUrl']
477        self.songExplorerUrl = d['songExplorerUrl']
478        self.artRadio = d['albumArtUrl']
479        self.trackLength = d['trackLength']
480        self.trackGain = float(d.get('trackGain', '0.0'))
481        self.audioUrlMap = d['audioUrlMap']
482
483        # Optionally we requested more URLs
484        if len(d.get('additionalAudioUrl', [])) == 2:
485            if int(self.audioUrlMap['highQuality']['bitrate']) < 128:
486                # We can use the higher quality mp3 stream for non-one users
487                self.audioUrlMap['mediumQuality'] = self.audioUrlMap['highQuality']
488                self.audioUrlMap['highQuality'] = {
489                    'encoding': 'mp3',
490                    'bitrate': '128',
491                    'audioUrl': d['additionalAudioUrl'][1],
492                }
493            else:
494                # And we can offer a lower bandwidth option for one users
495                self.audioUrlMap['lowQuality'] = {
496                    'encoding': 'aacplus',
497                    'bitrate': '32',
498                    'audioUrl': d['additionalAudioUrl'][0],
499                }
500
501        # the actual name of the track, minus any special characters (except dashes) is stored
502        # as the last part of the songExplorerUrl, before the args.
503        explorer_name = self.songExplorerUrl.split('?')[0].split('/')[-1]
504        clean_expl_name = NAME_COMPARE_REGEX.sub('', explorer_name).lower()
505        clean_name = NAME_COMPARE_REGEX.sub('', self.songName).lower()
506
507        if clean_name == clean_expl_name:
508            self.title = self.songName
509        else:
510            try:
511                with urllib.request.urlopen(self.songExplorerUrl) as x, minidom.parseString(x.read()) as dom:
512                    attr_value = dom.getElementsByTagName('songExplorer')[0].attributes['songTitle'].value
513
514                # Pandora stores their titles for film scores and the like as 'Score name: song name'
515                self.title = attr_value.replace('{0}: '.format(self.songName), '', 1)
516            except:
517                self.title = self.songName
518
519    @property
520    def audioUrl(self):
521        quality = self.pandora.audio_quality
522        try:
523            q = self.audioUrlMap[quality]
524            self.bitrate = q['bitrate']
525            logging.info("Using audio quality %s: %s %s", quality, q['bitrate'], q['encoding'])
526            return q['audioUrl']
527        except KeyError:
528            logging.warning("Unable to use audio format %s. Using %s",
529                           quality, list(self.audioUrlMap.keys())[0])
530            self.bitrate = list(self.audioUrlMap.values())[0]['bitrate']
531            return list(self.audioUrlMap.values())[0]['audioUrl']
532
533    @property
534    def station(self):
535        return self.pandora.get_station_by_id(self.stationId)
536
537    def get_duration_sec(self):
538        if self.duration is not None:
539            return self.duration // 1000000000
540        else:
541            return self.trackLength
542
543    def get_position_sec(self):
544        if self.position is not None:
545            return self.position // 1000000000
546        else:
547            return 0
548
549    def rate(self, rating):
550        if self.rating != rating:
551            self.station.transformIfShared()
552            if rating == RATE_NONE:
553                if not self.feedbackId:
554                    # We need a feedbackId, get one by re-rating the song. We
555                    # could also get one by calling station.getStation, but
556                    # that requires transferring a lot of data (all feedback,
557                    # seeds, etc for the station).
558                    opposite = RATE_BAN if self.rating == RATE_LOVE else RATE_LOVE
559                    self.feedbackId = self.pandora.add_feedback(self.trackToken, opposite)
560                self.pandora.delete_feedback(self.station.idToken, self.feedbackId)
561            else:
562                self.feedbackId = self.pandora.add_feedback(self.trackToken, rating)
563            self.rating = rating
564
565    def set_tired(self):
566        if not self.tired:
567            self.pandora.json_call('user.sleepSong', {'trackToken': self.trackToken})
568            self.tired = True
569
570    def bookmark(self):
571        self.pandora.json_call('bookmark.addSongBookmark', {'trackToken': self.trackToken})
572
573    def bookmark_artist(self):
574        self.pandora.json_call('bookmark.addArtistBookmark', {'trackToken': self.trackToken})
575
576    @property
577    def rating_str(self):
578        return self.rating
579
580    def is_still_valid(self):
581        # Playlists are valid for 1 hour. A song is considered valid if there is enough time
582        # to play the remaining duration of the song before the playlist expires.
583        return ((time.time() + (self.get_duration_sec() - self.get_position_sec())) - self.playlist_time) < PLAYLIST_VALIDITY_TIME
584
585    def __repr__(self):
586        return '<{}.{} {} "{}" by "{}" from "{}">'.format(
587            __name__,
588            __class__.__name__,
589            self.trackToken,
590            self.title,
591            self.artist,
592            self.album,
593        )
594
595
596class SearchResult:
597    def __init__(self, resultType, d):
598        self.resultType = resultType
599        self.score = d['score']
600        self.musicId = d['musicToken']
601
602        if resultType == 'song':
603            self.title = d['songName']
604            self.artist = d['artistName']
605        elif resultType == 'artist':
606            self.name = d['artistName']
607        elif resultType == 'genre':
608            self.stationName = d['stationName']
609
610