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