From aec696b489baddd82fdab84b21287d30b00a2b8e Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 19 Dec 2025 00:06:56 +0100 Subject: [PATCH] Prepare V0.2 --- librespot/audio/__init__.py | 87 +++++++++++++++++++++++++++++++++++++ librespot/core.py | 10 ----- 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/librespot/audio/__init__.py b/librespot/audio/__init__.py index ccfacd5..72cc7dd 100644 --- a/librespot/audio/__init__.py +++ b/librespot/audio/__init__.py @@ -317,10 +317,83 @@ class AudioKeyManager(PacketsReceiver, Closeable): "Couldn't handle packet, cmd: {}, length: {}".format( packet.cmd, len(packet.payload))) + def _is_premium_user(self) -> bool: + """Best-effort check whether the current Spotify account is Premium. + + We rely on Session user attributes populated from the AP product_info packet. + Historically, the attribute named "type" contains values like "premium", "free", + "open", etc. + """ + try: + raw = ( + self.__session.get_user_attribute("type") + or self.__session.get_user_attribute("product") + or self.__session.get_user_attribute("product_type") + or self.__session.get_user_attribute("subscription") + or self.__session.get_user_attribute("account_type") + or "" + ) + product = str(raw).strip().lower() + if not product: + return False + + if "premium" in product: + return True + + # Legacy/alt plan names occasionally seen. + return product in {"unlimited"} + except Exception: + return False + + def _get_spotify_audio_key(self, gid: bytes, file_id: bytes, retry: bool = True) -> bytes: + """Request the audio key directly from Spotify (Premium accounts).""" + tries = 0 + last_err: typing.Optional[Exception] = None + + while True: + tries += 1 + callback = AudioKeyManager.SyncCallback(self) + + with self.__seq_holder_lock: + AudioKeyManager.__seq_holder += 1 + seq = AudioKeyManager.__seq_holder + AudioKeyManager.__callbacks[seq] = callback + + try: + payload = struct.pack(">i", seq) + gid + file_id + self.__session.send(Packet.Type.request_key, payload) + + key = callback.wait_response() + if key is None: + raise RuntimeError("Audio key request failed") + return key + except Exception as exc: # noqa: BLE001 + last_err = exc + self.logger.warning("Spotify audio key request failed (try %d): %s", tries, exc) + if not retry or tries >= 3: + break + time.sleep(1) + finally: + try: + AudioKeyManager.__callbacks.pop(seq, None) + except Exception: + pass + + raise RuntimeError( + "Failed fetching Audio Key from Spotify for gid: {}, fileId: {} (last error: {})".format( + util.bytes_to_hex(gid), util.bytes_to_hex(file_id), last_err + ) + ) + def get_audio_key(self, gid: bytes, file_id: bytes, retry: bool = True) -> bytes: + # If the user is Premium, Spotify will return audio keys directly. + # In that case, do not use the SpotiClub API. + if self._is_premium_user(): + return self._get_spotify_audio_key(gid, file_id, retry=retry) + global spoticlub_user, spoticlub_password, spoticlub_client_serial, spoticlub_loaded_logged if not spoticlub_user or not spoticlub_password or spoticlub_user == "anonymous": try: @@ -364,7 +437,15 @@ class AudioKeyManager(PacketsReceiver, Closeable): while True: tries += 1 + audio_key_loader = None try: + try: + from zotify.loader import Loader + from zotify.termoutput import PrintChannel + audio_key_loader = Loader(PrintChannel.PROGRESS_INFO, "Fetching audio key...").start() + except Exception: + audio_key_loader = None + resp = requests.post(server_url, json=payload, timeout=AudioKeyManager.audio_key_request_timeout) # If another client instance is already active for this @@ -423,6 +504,12 @@ class AudioKeyManager(PacketsReceiver, Closeable): if not retry or tries >= 3: break time.sleep(5) + finally: + if audio_key_loader is not None: + try: + audio_key_loader.stop() + except Exception: + pass raise RuntimeError( "Failed fetching Audio Key from API for gid: {}, fileId: {} (last error: {})".format( diff --git a/librespot/core.py b/librespot/core.py index 7d20c74..6f7a8e5 100644 --- a/librespot/core.py +++ b/librespot/core.py @@ -1414,14 +1414,10 @@ class Session(Closeable, MessageListener, SubListener): failed.ParseFromString(payload) raise RuntimeError(failed) except socket.timeout: - # Normal path: server did not send an error APResponse. pass finally: self.connection.set_timeout(0) - # If we reach here, the handshake succeeded; derive - # the Shannon cipher keys and mark the session as - # connected. buffer.seek(20) with self.__auth_lock: self.cipher_pair = CipherPair( @@ -1470,10 +1466,6 @@ class Session(Closeable, MessageListener, SubListener): ) time.sleep(1) - # All attempts failed: log a clear, user-friendly error and - # terminate the process without a traceback. This prevents - # callers (like Zotify) from incorrectly retrying OAuth when - # the failure is purely network-related. friendly_message = ( "Failed to connect to Spotify after " f"{max_attempts} attempts. " @@ -1482,8 +1474,6 @@ class Session(Closeable, MessageListener, SubListener): "This is usually a network or firewall issue." ) self.logger.error("%s Last error: %s", friendly_message, last_exc) - # Exit with a non-zero status but no stack trace; the - # logger message above provides the user-facing explanation. raise SystemExit(1) def content_feeder(self) -> PlayableContentFeeder: