├── requirements.txt ├── examples.py ├── README.md └── experimental.py /requirements.txt: -------------------------------------------------------------------------------- 1 | browser_cookie3 2 | websockets 3 | requests 4 | -------------------------------------------------------------------------------- /examples.py: -------------------------------------------------------------------------------- 1 | from experimental import SpotifyPlayer 2 | import logging 3 | 4 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)-8s - %(name)-14s - %(message)s') 5 | 6 | # edge is preferred (and is now default) because reading cookies in chrome > 104.0.5112.102 needs elevation 7 | # https://github.com/borisbabic/browser_cookie3/issues/180 8 | spotifyplayer = SpotifyPlayer(browser='edge') 9 | spotifyplayer.command(spotifyplayer.pause) 10 | 11 | spotifyplayer.force_disconnect = True 12 | spotifyplayer.disconnect() 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spotify-free-api-player 2 | This allows you to modify the user's playback state through the spotify API, without needing premium. 3 | 4 | # Prerequisites 5 | You must have logged in once to open.spotify.com through Microsoft Edge (can change browser, see `examples.py`). 6 | 7 | # How it works: 8 | This program checks for the local cookies for open.spotify.com and other related domains for Microsoft Edge. These cookies contain information that are used to login to Spotify, without needing to reenter credentials. This program generates a access token with these cookies to the url `https://open.spotify.com/get_access_token?reason=transport&productType=web_player` with a GET request, and uses that access token to open a websocket connection to `wss://guc3-dealer.spotify.com/?access_token={access_token}`. This websocket connection then recieves information about its Spotify connection ID, which we can then use to create a fake device through the POST request to `https://guc-spclient.spotify.com/track-playback/v1/devices`. After that, we register notifications to recieve events about the queue through the put requests `https://api.spotify.com/v1/me/notifications/user?connection_id={connection_id}` and `https://guc-spclient.spotify.com/connect-state/v1/devices/hobs_{device_id}`.This device is capable of sending requests to play to the user's main device, like the app, through the POST request to `https://guc-spclient.spotify.com/connect-state/v1/player/command/from/{fake_device_id}/to/{playback_device_id}`. 9 | 10 | # Known issues: 11 | If the user isn't playing any tracks, a non 200 response code will be returned upon trying to send a request to modify the user's playback. 12 | Possible fix: Create another fake device with the right cookies using selenium, and send the playback commands to that "device". 13 | 14 | # Other: 15 | This project woudn't be possible without the Chrome Dev Console, and the npm package sactivity. 16 | This is also still in development. 17 | -------------------------------------------------------------------------------- /experimental.py: -------------------------------------------------------------------------------- 1 | import browser_cookie3 2 | import requests 3 | import websockets 4 | import asyncio 5 | import json 6 | import logging 7 | import string 8 | import random 9 | import time 10 | import typing 11 | 12 | from threading import Thread 13 | from requests.exceptions import RequestException 14 | 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class SpotifyPlayer: 20 | """ 21 | This class provides an endpoint to access the Spotify API used by "open.spotify.com" to gain access to features 22 | such as managing playback across devices, to mimic premium API features. 23 | """ 24 | 25 | pause = {'command': {'endpoint': 'pause'}} 26 | resume = {'command': {'endpoint': 'resume'}} 27 | skip = {'command': {'endpoint': 'skip_next'}} 28 | previous = {'command': {'endpoint': 'skip_prev'}} 29 | repeating_context = {'command': {'repeating_context': True, 'repeating_track': False, 'endpoint': 'set_options'}} 30 | repeating_track = {'command': {'repeating_context': True, 'repeating_track': True, 'endpoint': 'set_options'}} 31 | no_repeat = {'command': {'repeating_context': False, 'repeating_track': False, 'endpoint': 'set_options'}} 32 | shuffle = {'command': {'value': True, 'endpoint': 'set_shuffling_context'}} 33 | stop_shuffle = {'command': {'value': False, 'endpoint': 'set_shuffling_context'}} 34 | 35 | @staticmethod 36 | def volume(volume): 37 | return {'volume': volume * 65535 / 100, 'url': 'https://guc-spclient.spotify.com/connect-state/' 38 | 'v1/connect/volume/from/player/to/device', 39 | 'request_type': 'PUT'} 40 | 41 | @staticmethod 42 | def seek_to(ms): 43 | return {'command': {'value': ms, 'endpoint': 'seek_to'}} 44 | 45 | @staticmethod 46 | def add_to_queue(track_id): 47 | return {'command': {'track': {'uri': f'spotify:track:{track_id}', 'metadata': {'is_queued': True}, 48 | 'provider': 'queue'}, 'endpoint': 'add_to_queue'}} 49 | 50 | @staticmethod 51 | def play(track_id): 52 | return {"command": {"context": {"uri": f"spotify:track:{track_id}", 53 | "url": f"context://spotify:track:{track_id}", 54 | "metadata": {}}, "play_origin": 55 | {"feature_identifier": "harmony", "feature_version": "4.11.0-af0ef98"}, "options": 56 | {"license": "on-demand", "skip_to": {"track_index": 0}, "player_options_override": {}}, 57 | "endpoint": "play"}} 58 | 59 | def remove_from_queue(self, track_id): 60 | matches = ([index for index, track in enumerate(self.queue) if track_id in track['uri'] 61 | or 'spotify:ad:' in track['uri']]) 62 | [self.queue.pop(index) for index in matches] 63 | return {'command': {'next_tracks': self.queue, 'queue_revision': self.queue_revision, 'endpoint': 'set_queue'}} 64 | 65 | def clear_queue(self): 66 | matches = ([track for track in self.queue if 'queue' != track['provider']]) 67 | return {'command': {'next_tracks': matches, 'queue_revision': self.queue_revision, 'endpoint': 'set_queue'}} 68 | 69 | def queue_playlist(self, playlist_id): 70 | url = f'https://api.spotify.com/v1/playlists/{playlist_id}/tracks' 71 | headers = {'Authorization': f'Bearer {self.access_token}'} 72 | response = self._session.get(url, headers=headers) 73 | ids = [item['track']['id'] for item in response.json()['items']] 74 | queue = [{'uri': f'spotify:track:{track_id}', 'metadata': {'is_queued': True}, 'provider': 'queue'} 75 | for track_id in ids] 76 | if self.shuffling: 77 | random.shuffle(queue) 78 | queuequeue = [track for track in self.queue if track['provider'] != 'context'] 79 | queue = queuequeue + queue 80 | if ids: 81 | return {'command': {'next_tracks': queue, 'queue_revision': self.queue_revision, 'endpoint': 'set_queue'}} 82 | 83 | def play_playlist(self, playlist_id, skip_to=0): 84 | url = f'https://api.spotify.com/v1/playlists/{playlist_id}/tracks' 85 | headers = {'Authorization': f'Bearer {self.access_token}'} 86 | response = self._session.get(url, headers=headers) 87 | ids = [item['track']['id'] for item in response.json()['items']] 88 | queue = [{'uri': f'spotify:track:{track_id}', 'metadata': {'is_queued': True}, 'provider': 'queue'} 89 | for track_id in ids] 90 | if self.shuffling: 91 | random.shuffle(queue) 92 | queue = queue + self.queue 93 | if ids: 94 | return [{'command': {'next_tracks': queue[1:], 'queue_revision': self.queue_revision, 95 | 'endpoint': 'set_queue'}}, 96 | {"command": {"context": {"uri": f"{queue[0]['uri']}", 97 | "url": f"context://{queue[0]['uri']}", 98 | "metadata": {}}, "play_origin": 99 | {"feature_identifier": "harmony", "feature_version": "4.11.0-af0ef98"}, "options": 100 | {"license": "on-demand", "skip_to": {"track_index": skip_to}, 101 | "player_options_override": {}}, 102 | "endpoint": "play"}}] 103 | 104 | def queue_from_uris(self, uris): 105 | queue = [{'uri': uri, 'metadata': {'is_queued': True}, 'provider': 'queue'} 106 | for uri in uris] 107 | queuequeue = [track for track in self.queue if track['provider'] != 'context'] 108 | queue = queuequeue + queue 109 | return {'command': {'next_tracks': queue, 'queue_revision': self.queue_revision, 110 | 'endpoint': 'set_queue'}} 111 | 112 | def play_from_uris(self, uris): 113 | queue = [{'uri': uri, 'metadata': {'is_queued': True}, 'provider': 'queue'} 114 | for uri in uris] 115 | queue = queue + self.queue 116 | return [{'command': {'next_tracks': queue[1:], 'queue_revision': self.queue_revision, 117 | 'endpoint': 'set_queue'}}, 118 | {"command": {"context": {"uri": queue[0]['uri'], 119 | "url": f'context://{queue[0]["uri"]}', 120 | "metadata": {}}, "play_origin": 121 | {"feature_identifier": "harmony", "feature_version": "4.11.0-af0ef98"}, "options": 122 | {"license": "on-demand", "skip_to": {"track_index": 0}, "player_options_override": {}}, 123 | "endpoint": "play"}}] 124 | 125 | def play_from_context(self, context_uri, skip_to=0): 126 | oldqueue = [track for track in self.queue if track['provider'] == 'queue'] 127 | oldqueue = [{'uri': track['uri'], 'metadata': {'is_queued': True}, 'provider': 'queue'} 128 | for track in oldqueue] 129 | self.command(self.clear_queue()) 130 | self.command({"command": {"context": {"uri": f"{context_uri}", 131 | "url": f"context://{context_uri}", 132 | "metadata": {}}, "play_origin": 133 | {"feature_identifier": "harmony", "feature_version": "4.11.0-af0ef98"}, "options": 134 | {"license": "on-demand", "skip_to": {"track_index": skip_to}, 135 | "player_options_override": {}}, 136 | "endpoint": "play"}}) 137 | time.sleep(0.75) 138 | context_songs = [track for track in self.queue if track['provider'] == 'context'] 139 | context_songs = [track for track in context_songs if track['metadata']['iteration'] == '0'] 140 | context_songs = [{'uri': track['uri'], 'metadata': {'is_queued': True}, 'provider': 'queue'} 141 | for track in context_songs] 142 | queue = context_songs + oldqueue 143 | return {'command': {'next_tracks': queue, 'queue_revision': self.queue_revision, 144 | 'endpoint': 'set_queue'}} 145 | 146 | def queue_from_context(self, context_uri, skip_to=0): 147 | oldqueue = [track for track in self.queue if track['provider'] == 'queue'] 148 | oldqueue = [{'uri': track['uri'], 'metadata': {'is_queued': True}, 'provider': 'queue'} 149 | for track in oldqueue] 150 | self.command(self.clear_queue()) 151 | self.command({"command": {"context": {"uri": f"{context_uri}", 152 | "url": f"context://{context_uri}", 153 | "metadata": {}}, "play_origin": 154 | {"feature_identifier": "harmony", "feature_version": "4.11.0-af0ef98"}, "options": 155 | {"license": "on-demand", "skip_to": {"track_index": skip_to}, 156 | "player_options_override": {}}, 157 | "endpoint": "play"}}) 158 | time.sleep(0.75) 159 | context_songs = [track for track in self.queue if track['provider'] == 'context'] 160 | context_songs = [track for track in context_songs if track['metadata']['iteration'] == '0'] 161 | context_songs = [{'uri': track['uri'], 'metadata': {'is_queued': True}, 'provider': 'queue'} 162 | for track in context_songs] 163 | queue = context_songs + oldqueue 164 | return {'command': {'next_tracks': queue, 'queue_revision': self.queue_revision, 165 | 'endpoint': 'set_queue'}} 166 | 167 | def __init__(self, event_reciever: typing.List[typing.Callable] = None, cookie_str: str = None, 168 | cookie_path: str = None, browser: str = 'edge'): 169 | """ 170 | :param event_reciever: A list of callables to be called upon a change in the playback state 171 | :param cookie_str: The actual sp_t cookie string, if available, to be used for authentication 172 | :param cookie_path: The path to a file which contains the sp_t cookie string 173 | :param browser: The name of the browser which was used to log in to open.spotify.com, to obtain cookies from 174 | This can be one of: 175 | chrome 176 | firefox 177 | librewolf 178 | opera 179 | opera_gx 180 | edge 181 | chromium 182 | brave 183 | vivaldi 184 | safari 185 | """ 186 | self.isinitialized = False 187 | if event_reciever is None: 188 | event_reciever = [lambda: None] 189 | if cookie_str: 190 | self.isinitialized = True 191 | if cookie_path: 192 | self.isinitialized = True 193 | self.cookie_path = cookie_path 194 | self.cookie_str = cookie_str 195 | if not self.isinitialized: 196 | self.cj = getattr(browser_cookie3, browser)() 197 | # noinspection PyProtectedMember 198 | _ = self.cj._cookies['.spotify.com']['/']['sp_t'] 199 | self.isinitialized = True 200 | self._default_headers = {'sec-fetch-dest': 'empty', 201 | 'sec-fetch-mode': 'cors', 202 | 'sec-fetch-site': 'same-origin', 203 | 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' 204 | '(KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36'} 205 | 206 | self._session = requests.Session() 207 | self.event_reciever = event_reciever 208 | self.shuffling = False 209 | self.looping = False 210 | self.playing = False 211 | self.force_disconnect = False 212 | self.devices = [] 213 | self.active_device_id = '' 214 | self.current_volume = 65535 215 | self._last_timestamp = 0 216 | self._last_position = 0 217 | self.last_command = None 218 | self.time_executed = 0 219 | self.diff = 0 220 | self.ws = None 221 | self.disconnected = False 222 | self.player_state = {} 223 | self.attempt_reconnect_time = 0 224 | if self.isinitialized: 225 | self.isinitialized = False 226 | self._authorize() 227 | 228 | def _authorize(self): 229 | self.isinitialized = False 230 | access_token_response = False 231 | attempts = 0 232 | while not access_token_response: 233 | try: 234 | access_token_response = self.get_access_token() 235 | except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError) as exc: 236 | attempts += 1 237 | if attempts == 3: 238 | raise exc 239 | time.sleep(1) 240 | self.access_token = access_token_response['accessToken'] 241 | self.access_token_expire = access_token_response['accessTokenExpirationTimestampMs'] / 1000 242 | 243 | guc_url = f'wss://guc3-dealer.spotify.com/?access_token={self.access_token}' 244 | guc_headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)' 245 | ' Chrome/87.0.4280.66 Safari/537.36'} 246 | 247 | self.connection_id = None 248 | self.queue_revision = None 249 | 250 | async def websocket(): 251 | async with websockets.connect(guc_url, extra_headers=guc_headers) as ws: 252 | self.ws = ws 253 | self.websocket_task_event_loop = asyncio.get_event_loop() 254 | while True: 255 | try: 256 | recv = await ws.recv() 257 | load = json.loads(recv) 258 | if load.get('headers'): 259 | if load['headers'].get('Spotify-Connection-Id'): 260 | self.connection_id = load['headers']['Spotify-Connection-Id'] 261 | if load.get('payloads'): 262 | try: 263 | if load['payloads'][0].get('cluster'): 264 | try: 265 | self.queue = load['payloads'][0]['cluster']['player_state']['next_tracks'] 266 | except KeyError: 267 | pass 268 | try: 269 | update_reason = load['payloads'][0]['update_reason'] 270 | if 'DEVICE' in update_reason: 271 | self.devices = load['payloads'][0]['cluster']['devices'] 272 | except KeyError: 273 | pass 274 | self.queue_revision = (load['payloads'][0]['cluster']['player_state'] 275 | ['queue_revision']) 276 | self.player_state = load['payloads'][0]['cluster']['player_state'] 277 | options = load['payloads'][0]['cluster']['player_state']['options'] 278 | try: 279 | active_device = load['payloads'][0]['cluster']['active_device_id'] 280 | if 'volume' in load['payloads'][0]['cluster']['devices'][active_device]: 281 | self.current_volume = (load['payloads'][0]['cluster']['devices'] 282 | [active_device]['volume']) 283 | else: 284 | self.current_volume = 0 285 | self.active_device_id = active_device 286 | except KeyError: 287 | self.active_device_id = '' 288 | self.playing = not load['payloads'][0]['cluster']['player_state']['is_paused'] 289 | self.shuffling = options['shuffling_context'] 290 | try: 291 | self._last_timestamp = int(load['payloads'][0]['cluster']['player_state'] 292 | ['timestamp']) 293 | timediff = int(load['payloads'][0]['cluster']['server_timestamp_ms']) 294 | self._timestamp_diff = time.time() - timediff / 1000 295 | except KeyError: 296 | self._last_timestamp = 0 297 | self._timestamp_diff = 0 298 | position_ms = int(load['payloads'][0]['cluster']['player_state'] 299 | ['position_as_of_timestamp']) 300 | if position_ms != self._last_position: 301 | self._last_position = position_ms 302 | if options['repeating_track']: 303 | self.looping = 'track' 304 | elif options['repeating_context']: 305 | self.looping = 'context' 306 | else: 307 | self.looping = 'off' 308 | for ev in self.event_reciever: 309 | try: 310 | ev() 311 | except Exception as e: 312 | logger.error('An exception occured while executing an event listener: ', 313 | exc_info=e) 314 | except AttributeError: 315 | pass 316 | except websockets.ConnectionClosed: 317 | self._cancel_tasks() 318 | return 319 | 320 | async def wrap_ws(): 321 | try: 322 | await websocket() 323 | except asyncio.CancelledError: 324 | return 325 | 326 | async def ping_loop(): 327 | try: 328 | while True: 329 | if self.isinitialized and self.ws: 330 | await self.ws.send('{"type": "ping"}') 331 | self.sleep_task_event_loop = asyncio.get_event_loop() 332 | await asyncio.sleep(30) 333 | else: 334 | await asyncio.sleep(1) # don't lag the gui thread 335 | except asyncio.CancelledError: 336 | return 337 | 338 | async def schedule_refresh(): 339 | try: 340 | await asyncio.sleep(self.access_token_expire - time.time()) 341 | self.refresh() 342 | return 343 | except asyncio.CancelledError: 344 | return 345 | 346 | async def run_until_complete(): 347 | self.tasks.append(asyncio.create_task(wrap_ws())) 348 | self.tasks.append(asyncio.create_task(ping_loop())) 349 | self.tasks.append(asyncio.create_task(schedule_refresh())) 350 | await asyncio.gather(*self.tasks, return_exceptions=True) 351 | 352 | if not self.force_disconnect and not self.disconnected: 353 | event_recievers = self.event_reciever.copy() 354 | self.event_reciever.clear() 355 | self.ws = None 356 | self.isinitialized = False 357 | self.disconnected = True 358 | logger.error('The SpotifyPlayer was disconnected') 359 | for ev in event_recievers: 360 | try: 361 | ev() 362 | except Exception as e: 363 | logger.error('An exception occured while executing an event listener: ', exc_info=e) 364 | while not self.isinitialized: 365 | try: 366 | self._authorize() 367 | logger.info('The SpotifyPlayer reconnected successfully') 368 | self.disconnected = False 369 | for event in event_recievers: 370 | try: 371 | self.add_event_reciever(event) 372 | except (RuntimeError, Exception): 373 | pass 374 | time.sleep(2) 375 | for ev in self.event_reciever: 376 | try: 377 | ev() 378 | except Exception as e: 379 | logger.error('An exception occured while executing an event listener: ', 380 | exc_info=e) 381 | return 382 | except Exception as e: 383 | self.active_device_id = '' 384 | self.current_volume = 65535 385 | self._last_timestamp = 0 386 | self._last_position = 0 387 | self.last_command = None 388 | self.time_executed = 0 389 | self.diff = 0 390 | self._cancel_tasks() 391 | logger.error('An error occured while the SpotifyPlayer was reconnecting, ' 392 | 'retrying in 30 seconds: ', exc_info=e) 393 | self.attempt_reconnect_time = time.time() + 30 394 | while time.time() < self.attempt_reconnect_time: 395 | await asyncio.sleep(1) 396 | else: 397 | logger.info(f'Closing SpotifyPlayer task queue with id {self.device_id}') 398 | 399 | self.sleep_task_event_loop = None 400 | self.websocket_task_event_loop = None 401 | self.tasks = [] 402 | policy = asyncio.get_event_loop_policy() # workaround to some dumb bug 403 | policy._loop_factory = asyncio.SelectorEventLoop # why does this work 404 | Thread(target=asyncio.run, args=(run_until_complete(),)).start() 405 | 406 | device_url = 'https://guc-spclient.spotify.com/track-playback/v1/devices' 407 | self.device_id = ''.join(random.choices(string.ascii_letters, k=40)) 408 | start = time.time() 409 | while True: 410 | if self.connection_id: 411 | device_data = {"device": {"brand": "spotify", "capabilities": 412 | {"change_volume": True, "enable_play_token": True, 413 | "supports_file_media_type": True, 414 | "play_token_lost_behavior": "pause", 415 | "disable_connect": True, "audio_podcasts": True, 416 | "video_playback": True, 417 | "manifest_formats": ["file_urls_mp3", 418 | "manifest_ids_video", 419 | "file_urls_external", 420 | "file_ids_mp4", 421 | "file_ids_mp4_dual"]}, 422 | "device_id": self.device_id, "device_type": "computer", 423 | "metadata": {}, "model": "web_player", "name": "Spotify Player", 424 | "platform_identifier": "web_player windows 10;chrome 87.0.4280.66;desktop"}, 425 | "connection_id": self.connection_id, "client_version": 426 | "harmony:4.11.0-af0ef98", 427 | "volume": 65535} 428 | break 429 | else: 430 | if time.time() - start > 10: 431 | raise TimeoutError 432 | time.sleep(0.5) 433 | 434 | device_headers = self._default_headers.copy() 435 | device_headers.update({'authorization': f'Bearer {self.access_token}'}) 436 | 437 | try: 438 | response = self._session.post(device_url, headers=device_headers, data=json.dumps(device_data)) 439 | except requests.exceptions.ConnectionError: 440 | time.sleep(1) 441 | response = self._session.post(device_url, headers=device_headers, data=json.dumps(device_data)) 442 | 443 | if response.status_code == 200: 444 | logger.info(f'Successfully created Spotify device with id {self.device_id}.') 445 | 446 | notifications_url = f'https://api.spotify.com/v1/me/notifications/user?connection_id={self.connection_id}' 447 | notifications_headers = self._default_headers.copy() 448 | notifications_headers.update({'Authorization': f'Bearer {self.access_token}'}) 449 | try: 450 | self._session.put(notifications_url, headers=notifications_headers) 451 | except requests.exceptions.ConnectionError: 452 | time.sleep(1) 453 | self._session.put(notifications_url, headers=notifications_headers) 454 | 455 | hobs_url = f'https://guc-spclient.spotify.com/connect-state/v1/devices/hobs_{self.device_id}' 456 | hobs_headers = self._default_headers.copy() 457 | hobs_headers.update({'authorization': f'Bearer {self.access_token}'}) 458 | hobs_headers.update({'x-spotify-connection-id': self.connection_id}) 459 | hobs_data = {"member_type": "CONNECT_STATE", "device": {"device_info": 460 | {"capabilities": {"can_be_player": False, 461 | "hidden": True}}}} 462 | try: 463 | response = self._session.put(hobs_url, headers=hobs_headers, data=json.dumps(hobs_data)) 464 | except requests.exceptions.ConnectionError: 465 | time.sleep(1) 466 | response = self._session.put(hobs_url, headers=hobs_headers, data=json.dumps(hobs_data)) 467 | 468 | try: 469 | self.queue = response.json()['player_state']['next_tracks'] 470 | except KeyError: 471 | self.queue = [] 472 | response_load = response.json() 473 | try: 474 | response_options = response_load['player_state']['options'] 475 | self.active_device_id = response_load['active_device_id'] 476 | self.devices = response_load['devices'] 477 | self.current_volume = response_load['devices'][self.active_device_id]['volume'] 478 | self.queue_revision = response_load['player_state']['queue_revision'] 479 | self.shuffling = response_options['shuffling_context'] 480 | self.playing = not response_load['player_state']['is_paused'] 481 | self._last_position = int(response_load['player_state']['position_as_of_timestamp']) 482 | self._last_timestamp = int(response_load['player_state']['timestamp']) 483 | diff = int(response_load['server_timestamp_ms']) 484 | self._timestamp_diff = time.time() - diff / 1000 485 | if response_options['repeating_track']: 486 | self.looping = 'track' 487 | elif response_options['repeating_context']: 488 | self.looping = 'context' 489 | else: 490 | self.looping = 'off' 491 | except KeyError: 492 | pass 493 | self.isinitialized = True 494 | 495 | def get_position(self): 496 | if not self.playing: 497 | return self._last_position / 1000 498 | last_diff = self.diff 499 | self.diff = time.time() - self._timestamp_diff - self._last_timestamp / 1000 - 1 500 | self.diff = 0 if self.diff < 0 else self.diff 501 | if self.diff < 0: 502 | self._last_position = self._last_position + last_diff * 1000 503 | return self._last_position / 1000 504 | return self._last_position / 1000 + self.diff 505 | 506 | def transfer(self, device_id): 507 | if self.access_token_expire < time.time(): 508 | self._authorize() 509 | while not self.isinitialized: 510 | pass 511 | transfer_url = f'https://guc-spclient.spotify.com/connect-state/v1/connect/transfer/from/' \ 512 | f'{self.device_id}/to/{device_id}' 513 | transfer_headers = self._default_headers.copy() 514 | transfer_headers.update({'authorization': f'Bearer {self.access_token}'}) 515 | transfer_data = {'transfer_options': {'restore_paused': 'restore'}} 516 | response = self._session.post(transfer_url, headers=transfer_headers, data=json.dumps(transfer_data)) 517 | return response 518 | 519 | def add_event_reciever(self, event_reciever: typing.Callable): 520 | self.event_reciever.append(event_reciever) 521 | 522 | def remove_event_reciever(self, event_reciever: typing.Callable): 523 | if event_reciever in self.event_reciever: 524 | self.event_reciever.pop(self.event_reciever.index(event_reciever)) 525 | else: 526 | raise TypeError('The specified event reciever was not in the list of event recievers.') 527 | 528 | def create_api_request(self, path, request_type='GET'): 529 | if request_type.upper() in ['GET', 'PUT', 'DELETE', 'POST', 'PATCH', 'HEAD']: 530 | try: 531 | req = getattr(self._session, request_type.lower())('https://api.spotify.com/v1' + path, 532 | headers={'Authorization': f'Bearer' 533 | f' {self.access_token}'}) 534 | if req.status_code == 401: 535 | self._cancel_tasks() 536 | while not self.isinitialized: 537 | time.sleep(0.1) 538 | req = getattr(self._session, request_type.lower())('https://api.spotify.com/v1' + path, 539 | headers={'Authorization': f'Bearer' 540 | f' {self.access_token}'}) 541 | return req 542 | except RequestException: 543 | return getattr(self._session, request_type.lower())('https://api.spotify.com/v1' + path, 544 | headers={'Authorization': f'Bearer' 545 | f' {self.access_token}'}) 546 | 547 | def _cancel_tasks(self): 548 | if self.websocket_task_event_loop and self.ws: 549 | self.websocket_task_event_loop.create_task(self.ws.close()) 550 | [task.cancel() for task in self.tasks] 551 | 552 | def disconnect(self): 553 | if self.websocket_task_event_loop and self.ws: 554 | self.websocket_task_event_loop.create_task(self.ws.close()) 555 | [task.cancel() for task in self.tasks] 556 | self.force_disconnect = True 557 | 558 | def get_access_token(self): 559 | access_token_headers = self._default_headers.copy() 560 | access_token_headers.update({'spotify-app-version': '1.1.48.530.g38509c6c', 561 | 'referer': 'https://accounts.spotify.com'}) 562 | access_token_url = 'https://open.spotify.com/get_access_token?reason=transport&productType=web_player' 563 | if self.cookie_path: 564 | with open(self.cookie_path, 'r') as f: 565 | self.cookie_str = f.read() 566 | if self.cookie_str: 567 | access_token_headers.update({'cookie': self.cookie_str}) 568 | try: 569 | response = self._session.get(access_token_url, headers=access_token_headers) 570 | except requests.exceptions.ConnectionError: 571 | time.sleep(2) 572 | response = self._session.get(access_token_url, headers=access_token_headers) 573 | else: 574 | try: 575 | response = self._session.get(access_token_url, headers=access_token_headers, cookies=self.cj) 576 | except requests.exceptions.ConnectionError: 577 | time.sleep(2) 578 | response = self._session.get(access_token_url, headers=access_token_headers, cookies=self.cj) 579 | return response.json() 580 | 581 | def refresh(self, retries=0): 582 | try: 583 | self._cancel_tasks() 584 | time.sleep(2) 585 | while not self.isinitialized: 586 | time.sleep(0.1) 587 | time.sleep(1) 588 | self.disconnected = False 589 | except Exception as exc: 590 | if retries > 1: 591 | logger.error('The maximum number of retries was exceeded while attempting to refresh the access token:', 592 | exc_info=exc) 593 | return 594 | logger.error('An unexpected error occured while refreshing the access token, retrying: ', exc_info=exc) 595 | self.refresh(retries + 1) 596 | 597 | def command(self, command_dict): 598 | try: 599 | self._command(command_dict) 600 | except requests.exceptions.ConnectionError: 601 | time.sleep(1) 602 | self._command(command_dict) 603 | 604 | def _command(self, command_dict, retries=0): 605 | if retries > 1: 606 | raise RecursionError('Max amount of retries reached (2)') 607 | if self.access_token_expire < time.time(): 608 | self.refresh() 609 | headers = {'Authorization': f'Bearer {self.access_token}'} 610 | start = time.time() 611 | while not self.isinitialized: 612 | time.sleep(0.25) 613 | if time.time() - start > 10: 614 | raise TimeoutError('SpotifyPlayer took too long to reconnect while attempting command') 615 | if self.active_device_id: 616 | currently_playing_device = self.active_device_id 617 | else: 618 | currently_playing_device = self._session.get('https://api.spotify.com/v1/me/player', 619 | headers=headers) 620 | try: 621 | currently_playing_device = currently_playing_device.json()['device']['id'] 622 | except json.decoder.JSONDecodeError or KeyError: 623 | currently_playing_device = self._session.get('https://api.spotify.com/v1/me/player/devices', 624 | headers=headers).json()['devices'][0]['id'] 625 | self.transfer(currently_playing_device) 626 | time.sleep(1) 627 | currently_playing_device = self.active_device_id 628 | except requests.exceptions.RequestException: 629 | self._cancel_tasks() 630 | player_url = f'https://guc-spclient.spotify.com/connect-state/v1/player/command/from/{self.device_id}' \ 631 | f'/to/{currently_playing_device}' 632 | if isinstance(command_dict, list): 633 | for command in command_dict: 634 | player_data = command 635 | player_headers = self._default_headers.copy() 636 | player_headers.update({'authorization': f'Bearer {self.access_token}'}) 637 | response = self._session.post(player_url, headers=headers, data=json.dumps(player_data)) 638 | if response.status_code != 200: 639 | try: 640 | raise RequestException(f'Command failed: {response.json()}') # keep exception trace 641 | except RequestException: 642 | if response.json().get('error_description') == 'queue_revision_mismatch': 643 | if player_data.get('command'): 644 | player_data['command']['queue_revision'] = self.queue_revision 645 | logger.error(f'Command failed, attempting retry {retries}/1') 646 | self._command(player_data, retries + 1) 647 | else: 648 | logger.debug(f'Command executed successfully. {player_data}') 649 | else: 650 | if 'url' in command_dict: 651 | player_url = command_dict['url'].replace('player', self.device_id).replace('device', 652 | currently_playing_device) 653 | command_dict.pop('url') 654 | player_data = command_dict 655 | player_headers = self._default_headers.copy() 656 | player_headers.update({'authorization': f'Bearer {self.access_token}'}) 657 | if 'request_type' in player_data: 658 | if player_data['request_type'] == 'PUT': 659 | player_data.pop('request_type') 660 | response = self._session.put(player_url, headers=headers, data=json.dumps(player_data)) 661 | if response.status_code != 200: 662 | try: 663 | try: 664 | raise RequestException(f'Command failed: {response.json()}') # keep exception trace 665 | except RequestException: 666 | if response.json().get('error_description') == 'queue_revision_mismatch': 667 | if command_dict.get('command'): 668 | command_dict['command']['queue_revision'] = self.queue_revision 669 | logger.error(f'Command failed, attempting retry {retries}/1') 670 | self._command(command_dict, retries + 1) 671 | except json.decoder.JSONDecodeError: 672 | raise RequestException(f'Command failed.') 673 | else: 674 | logger.debug(f'Command executed successfully. {player_data}') 675 | self.time_executed = time.time() 676 | self.last_command = player_data 677 | else: 678 | response = self._session.post(player_url, headers=headers, data=json.dumps(player_data)) 679 | if response.status_code != 200: 680 | try: 681 | response.json() 682 | try: 683 | raise RequestException(f'Command failed: {response.json()}') # keep exception trace 684 | except RequestException: 685 | logger.error(f'Command failed, attempting retry {retries}/1') 686 | time.sleep(1) 687 | if response.json().get('error_description') == 'queue_revision_mismatch': 688 | if command_dict.get('command'): 689 | command_dict['command']['queue_revision'] = self.queue_revision 690 | self._command(command_dict, retries + 1) 691 | except json.decoder.JSONDecodeError: 692 | raise RequestException(f'Command failed.') 693 | else: 694 | logger.debug(f'Command executed successfully. {player_data}') 695 | self.time_executed = time.time() 696 | self.last_command = player_data 697 | time.sleep(0.5) 698 | --------------------------------------------------------------------------------