From 5ddb3426dffc1511c1b7accf28c3ba70fd23714b Mon Sep 17 00:00:00 2001 From: Tzur Soffer Date: Thu, 4 Jun 2026 16:46:34 -0700 Subject: [PATCH 1/8] Work outside main thread --- spotapi/websocket.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/spotapi/websocket.py b/spotapi/websocket.py index 698f7b1..2cbba86 100644 --- a/spotapi/websocket.py +++ b/spotapi/websocket.py @@ -64,7 +64,13 @@ def __init__(self, login: Login) -> None: self.keep_alive_thread.start() atexit.register(self.ws.close) - signal.signal(signal.SIGINT, self.handle_interrupt) + + atexit.register(self.ws.close) + + try: + signal.signal(signal.SIGINT, self.handle_interrupt) + except ValueError: #< Not running in the main thread + pass def register_device(self) -> None: url = f"https://gue1-spclient.spotify.com/track-playback/v1/devices" From 61d8814ac800de6743d09bbd7edb51a5d6dbcbfb Mon Sep 17 00:00:00 2001 From: Tzur Soffer Date: Thu, 4 Jun 2026 16:54:15 -0700 Subject: [PATCH 2/8] Double atexit --- spotapi/websocket.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/spotapi/websocket.py b/spotapi/websocket.py index 2cbba86..dc4d2af 100644 --- a/spotapi/websocket.py +++ b/spotapi/websocket.py @@ -65,8 +65,6 @@ def __init__(self, login: Login) -> None: atexit.register(self.ws.close) - atexit.register(self.ws.close) - try: signal.signal(signal.SIGINT, self.handle_interrupt) except ValueError: #< Not running in the main thread From ba490f511ff2473c1448b76c9a3b3168cc918870 Mon Sep 17 00:00:00 2001 From: Tzur Soffer Date: Thu, 4 Jun 2026 17:56:45 -0700 Subject: [PATCH 3/8] Fixed timeouts by adding reconnect --- spotapi/websocket.py | 88 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 15 deletions(-) diff --git a/spotapi/websocket.py b/spotapi/websocket.py index dc4d2af..6ebdb5a 100644 --- a/spotapi/websocket.py +++ b/spotapi/websocket.py @@ -50,24 +50,63 @@ def __init__(self, login: Login) -> None: self.device_id = random_hex_string(32) + self.rlock = threading.Lock() + self.ws_dump = None + + self._create_websocket() + + self.keep_alive_thread = threading.Thread( + target=self.keep_alive, + daemon=True, + ) + self.keep_alive_thread.start() + + atexit.register(self.ws.close) + + try: + signal.signal(signal.SIGINT, self.handle_interrupt) + except ValueError: #< Not running in the main thread + pass + + def _create_websocket(self) -> None: uri = f"wss://dealer.spotify.com/?access_token={self.base.access_token}" + self.ws = connect( uri, - user_agent_header="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + user_agent_header=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), ) - self.rlock = threading.Lock() - self.ws_dump: Dict[Any, Any] | None = None self.connection_id = self.get_init_packet() - self.keep_alive_thread = threading.Thread(target=self.keep_alive, daemon=True) - self.keep_alive_thread.start() + def reconnect(self) -> None: + with self.rlock: + try: + self.ws.close() + except Exception: + pass - atexit.register(self.ws.close) + self.base.get_session() + self.base.get_client_token() + + self._create_websocket() + + if ( + not hasattr(self, "keep_alive_thread") + or not self.keep_alive_thread.is_alive() + ): + self.keep_alive_thread = threading.Thread( + target=self.keep_alive, + daemon=True, + ) + self.keep_alive_thread.start() try: - signal.signal(signal.SIGINT, self.handle_interrupt) - except ValueError: #< Not running in the main thread + self.register_device() + except Exception: pass def register_device(self) -> None: @@ -142,16 +181,35 @@ def keep_alive(self) -> None: while True: try: time.sleep(60) + with self.rlock: self.ws.send('{"type":"ping"}') - except (ConnectionError, KeyboardInterrupt): - break - def get_packet(self) -> Dict[Any, Any]: - with self.rlock: - ws_dump = dict(json.loads(self.ws.recv())) - self.ws_dump = ws_dump - return self.ws_dump + except Exception as e: + print("Websocket disconnected, reconnecting...") + + try: + self.reconnect() + except Exception as reconnectError: + print(f"Reconnect failed: {reconnectError}") + return + + def get_packet(self): + while True: + try: + with self.rlock: + ws_dump = dict(json.loads(self.ws.recv())) + + self.ws_dump = ws_dump + return ws_dump + + except Exception: + try: + self.reconnect() + except Exception as e: + print("Failed to reconnect!") + + time.sleep(1) def get_init_packet(self) -> str: """Gets the Spotify Connection ID in the init packet""" From 41dc1ed4131e562e26180cdec010af30c85c66f7 Mon Sep 17 00:00:00 2001 From: Tzur Soffer Date: Sun, 7 Jun 2026 11:15:35 -0700 Subject: [PATCH 4/8] add supervisor thread --- spotapi/status.py | 13 +++++++--- spotapi/websocket.py | 56 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/spotapi/status.py b/spotapi/status.py index e678da7..ebb2a5c 100644 --- a/spotapi/status.py +++ b/spotapi/status.py @@ -3,7 +3,7 @@ from spotapi.login import Login from spotapi.types.annotations import enforce from spotapi.types.data import PlayerState, Devices, Track -from spotapi.websocket import WebsocketStreamer +from spotapi.websocket import WebsocketStreamer, WebSocketError from typing import Dict, Any, Callable, List, ParamSpec, TypeVar __all__ = [ @@ -47,8 +47,15 @@ def __init__(self, login: Login, s_device_id: str | None = None) -> None: def renew_state(self) -> None: self._device_dump = self.connect_device() - self._state = self._device_dump["player_state"] - self._devices = self._device_dump["devices"] + + if type(self._device_dump) != dict: + raise WebSocketError("Invalid device dump received", error=str(self._device_dump)) + + self._state = self._device_dump.get("player_state") + if self._state == None: + raise WebSocketError("Could not obtain 'player_state' from connect_device response", error=str(self._device_dump)) + + self._devices = self._device_dump.get("devices", {}) @functools.cached_property def saved_state(self) -> PlayerState: diff --git a/spotapi/websocket.py b/spotapi/websocket.py index 6ebdb5a..a5e1c2c 100644 --- a/spotapi/websocket.py +++ b/spotapi/websocket.py @@ -36,6 +36,7 @@ class WebsocketStreamer: "ws_dump", "connection_id", "keep_alive_thread", + "supervisor_thread", ) def __init__(self, login: Login) -> None: @@ -52,6 +53,7 @@ def __init__(self, login: Login) -> None: self.rlock = threading.Lock() self.ws_dump = None + self.ws = None self._create_websocket() @@ -61,7 +63,23 @@ def __init__(self, login: Login) -> None: ) self.keep_alive_thread.start() - atexit.register(self.ws.close) + def _cleanup(): + try: + if self.ws != None: + try: + self.ws.close() + except Exception: + pass + except Exception: + pass + + atexit.register(_cleanup) + + self.supervisor_thread = threading.Thread( + target=self._supervise, + daemon=True, + ) + self.supervisor_thread.start() try: signal.signal(signal.SIGINT, self.handle_interrupt) @@ -104,6 +122,10 @@ def reconnect(self) -> None: ) self.keep_alive_thread.start() + if not hasattr(self, "supervisor_thread") or not self.supervisor_thread.is_alive(): + self.supervisor_thread = threading.Thread(target=self._supervise, daemon=True) + self.supervisor_thread.start() + try: self.register_device() except Exception: @@ -227,3 +249,35 @@ def handle_interrupt(self, signum: int, frame: Any) -> None: """Handle interrupt signal (Ctrl+C)""" self.ws.close() exit(0) + + def _supervise(self) -> None: + """Monitor websocket and threads, attempt reconnects when down.""" + backoff = 1 + while True: + try: + need_reconnect = False + + if self.ws == None: + need_reconnect = True + else: + try: + closed_attr = self.ws.closed + if closed_attr is True: + need_reconnect = True + except Exception: + need_reconnect = True + + if self.keep_alive_thread.is_alive() == False: + need_reconnect = True + + if need_reconnect: + try: + self.reconnect() + backoff = 1 + except Exception: + time.sleep(backoff) + backoff = min(backoff * 2, 60) + + time.sleep(5) + except Exception: + time.sleep(5) From e20f170bc79c7f9b6165c9ebd119349cee2f8b9f Mon Sep 17 00:00:00 2001 From: Tzur Soffer Date: Tue, 9 Jun 2026 11:41:20 -0700 Subject: [PATCH 5/8] Remove redundant code and add better logging so i can find root cause of websocket error --- spotapi/websocket.py | 49 +++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/spotapi/websocket.py b/spotapi/websocket.py index a5e1c2c..5c8658a 100644 --- a/spotapi/websocket.py +++ b/spotapi/websocket.py @@ -104,7 +104,7 @@ def reconnect(self) -> None: with self.rlock: try: self.ws.close() - except Exception: + except: pass self.base.get_session() @@ -112,24 +112,7 @@ def reconnect(self) -> None: self._create_websocket() - if ( - not hasattr(self, "keep_alive_thread") - or not self.keep_alive_thread.is_alive() - ): - self.keep_alive_thread = threading.Thread( - target=self.keep_alive, - daemon=True, - ) - self.keep_alive_thread.start() - - if not hasattr(self, "supervisor_thread") or not self.supervisor_thread.is_alive(): - self.supervisor_thread = threading.Thread(target=self._supervise, daemon=True) - self.supervisor_thread.start() - - try: self.register_device() - except Exception: - pass def register_device(self) -> None: url = f"https://gue1-spclient.spotify.com/track-playback/v1/devices" @@ -172,7 +155,19 @@ def register_device(self) -> None: resp = self.client.post(url, json=payload, authenticate=True) if resp.fail: - raise WebSocketError("Could not register device", error=resp.error.string) + try: + print("\nREGISTER DEVICE FAILED") + print(f"device_id = {self.device_id}") + print(f"connection_id = {self.connection_id}") + print(f"error = {resp.error.string}") + print(f"response = {resp.response}") + except Exception: + pass + + raise WebSocketError( + "Could not register device", + error=resp.error.string + ) def connect_device(self) -> Dict[str, Any]: url = f"https://gue1-spclient.spotify.com/connect-state/v1/devices/hobs_{self.device_id}" @@ -195,7 +190,19 @@ def connect_device(self) -> Dict[str, Any]: resp = self.client.put(url, json=payload, authenticate=True, headers=headers) if resp.fail: - raise WebSocketError("Could not connect device", error=resp.error.string) + try: + print("\nCONNECT DEVICE FAILED") + print(f"device_id = {self.device_id}") + print(f"connection_id = {self.connection_id}") + print(f"error = {resp.error.string}") + print(f"response = {resp.response}") + except Exception: + pass + + raise WebSocketError( + "Could not connect device", + error=resp.error.string + ) return resp.response @@ -214,7 +221,7 @@ def keep_alive(self) -> None: self.reconnect() except Exception as reconnectError: print(f"Reconnect failed: {reconnectError}") - return + time.sleep(5) def get_packet(self): while True: From bdd65b9425cb90a9c9def2db7f7be19dd31d01ec Mon Sep 17 00:00:00 2001 From: Tzur Soffer Date: Wed, 10 Jun 2026 10:15:38 -0700 Subject: [PATCH 6/8] Improved reconnect and added disconnect. Also added better logging --- spotapi/websocket.py | 62 +++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/spotapi/websocket.py b/spotapi/websocket.py index 5c8658a..7adc9e6 100644 --- a/spotapi/websocket.py +++ b/spotapi/websocket.py @@ -5,6 +5,7 @@ import json import time import signal +import traceback from typing import Any, Dict from spotapi.login import Login from spotapi.client import BaseClient @@ -42,20 +43,15 @@ class WebsocketStreamer: def __init__(self, login: Login) -> None: if not login.logged_in: raise ValueError("Must be logged in") - - self.base = BaseClient(login.client) - self.client = self.base.client - - self.base.get_session() - self.base.get_client_token() + + self.login = login self.device_id = random_hex_string(32) self.rlock = threading.Lock() self.ws_dump = None self.ws = None - - self._create_websocket() + self.connect() self.keep_alive_thread = threading.Thread( target=self.keep_alive, @@ -63,17 +59,6 @@ def __init__(self, login: Login) -> None: ) self.keep_alive_thread.start() - def _cleanup(): - try: - if self.ws != None: - try: - self.ws.close() - except Exception: - pass - except Exception: - pass - - atexit.register(_cleanup) self.supervisor_thread = threading.Thread( target=self._supervise, @@ -85,6 +70,11 @@ def _cleanup(): signal.signal(signal.SIGINT, self.handle_interrupt) except ValueError: #< Not running in the main thread pass + + def _cleanup(): + print("Websockets closing due to program ending") + self.disconnect() + atexit.register(_cleanup) def _create_websocket(self) -> None: uri = f"wss://dealer.spotify.com/?access_token={self.base.access_token}" @@ -99,13 +89,11 @@ def _create_websocket(self) -> None: ) self.connection_id = self.get_init_packet() - - def reconnect(self) -> None: + + def connect(self): with self.rlock: - try: - self.ws.close() - except: - pass + self.base = BaseClient(self.login.client) + self.client = self.base.client self.base.get_session() self.base.get_client_token() @@ -114,6 +102,20 @@ def reconnect(self) -> None: self.register_device() + def disconnect(self): + with self.rlock: + if self.ws == None: + return + try: + self.ws.close() + self.ws = None + except Exception as e: + print(f"Failed to close websocket: {e}") + + def reconnect(self) -> None: + self.disconnect() + self.connect() + def register_device(self) -> None: url = f"https://gue1-spclient.spotify.com/track-playback/v1/devices" payload = { @@ -254,7 +256,8 @@ def get_init_packet(self) -> str: def handle_interrupt(self, signum: int, frame: Any) -> None: """Handle interrupt signal (Ctrl+C)""" - self.ws.close() + print("Ctrl+C detected, exiting spotapi") + self.disconnect() exit(0) def _supervise(self) -> None: @@ -274,14 +277,13 @@ def _supervise(self) -> None: except Exception: need_reconnect = True - if self.keep_alive_thread.is_alive() == False: - need_reconnect = True - if need_reconnect: try: self.reconnect() backoff = 1 - except Exception: + except Exception as e: + print(f"Spotapi failed to reconnect: {e}") + traceback.print_exc() time.sleep(backoff) backoff = min(backoff * 2, 60) From e0bd15379a0801898d8d3e6ec6068479b8d2cc3d Mon Sep 17 00:00:00 2001 From: Tzur Soffer Date: Wed, 10 Jun 2026 10:59:25 -0700 Subject: [PATCH 7/8] Fixed double rlock issue --- spotapi/websocket.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spotapi/websocket.py b/spotapi/websocket.py index 7adc9e6..53ece99 100644 --- a/spotapi/websocket.py +++ b/spotapi/websocket.py @@ -228,8 +228,7 @@ def keep_alive(self) -> None: def get_packet(self): while True: try: - with self.rlock: - ws_dump = dict(json.loads(self.ws.recv())) + ws_dump = dict(json.loads(self.ws.recv())) self.ws_dump = ws_dump return ws_dump From bc09bc6224fb49f8c5251efec10d43370f1a1182 Mon Sep 17 00:00:00 2001 From: Tzur Soffer Date: Wed, 10 Jun 2026 11:53:03 -0700 Subject: [PATCH 8/8] Bugfix with rlock --- spotapi/websocket.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/spotapi/websocket.py b/spotapi/websocket.py index 53ece99..547b41d 100644 --- a/spotapi/websocket.py +++ b/spotapi/websocket.py @@ -228,12 +228,14 @@ def keep_alive(self) -> None: def get_packet(self): while True: try: - ws_dump = dict(json.loads(self.ws.recv())) + with self.rlock: + ws_dump = dict(json.loads(self.ws.recv())) self.ws_dump = ws_dump return ws_dump - except Exception: + except Exception as e: + print(e) try: self.reconnect() except Exception as e: @@ -243,15 +245,15 @@ def get_packet(self): def get_init_packet(self) -> str: """Gets the Spotify Connection ID in the init packet""" - packet = self.get_packet() + self.ws_dump = dict(json.loads(self.ws.recv())) if ( - packet.get("headers") is None - or dict(packet["headers"]).get("Spotify-Connection-Id") is None + self.ws_dump.get("headers") is None + or dict(self.ws_dump["headers"]).get("Spotify-Connection-Id") is None ): raise ValueError("Invalid init packet") - return packet["headers"]["Spotify-Connection-Id"] + return self.ws_dump["headers"]["Spotify-Connection-Id"] def handle_interrupt(self, signum: int, frame: Any) -> None: """Handle interrupt signal (Ctrl+C)"""