From b2d05fa051cfe003a5b2846ac7c827f0fac68b0a Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 17 Dec 2025 20:03:46 +0100 Subject: [PATCH] Prepare V0.2 --- librespot/audio/__init__.py | 268 ++++++++++++++---------------------- zotify/track.py | 3 +- 2 files changed, 106 insertions(+), 165 deletions(-) diff --git a/librespot/audio/__init__.py b/librespot/audio/__init__.py index 623a88a..e8199e2 100644 --- a/librespot/audio/__init__.py +++ b/librespot/audio/__init__.py @@ -7,17 +7,7 @@ from librespot.cache import CacheManager from librespot.crypto import Packet from librespot.metadata import EpisodeId, PlayableId, TrackId from librespot.proto import Metadata_pb2 as Metadata, StorageResolve_pb2 as StorageResolve -from librespot.structure import ( - AudioDecrypt, - AudioQualityPicker, - Closeable, - FeederException, - GeneralAudioStream, - GeneralWritableStream, - HaltListener, - NoopAudioDecrypt, - PacketsReceiver, -) +from librespot.structure import GeneralAudioStream, AudioDecrypt, AudioQualityPicker, Closeable, FeederException, GeneralAudioStream, GeneralWritableStream, HaltListener, NoopAudioDecrypt, PacketsReceiver from pathlib import Path import concurrent.futures import io @@ -37,36 +27,41 @@ if typing.TYPE_CHECKING: from librespot.core import Session """ -PATCH : SpotiClub Audio Key Fetching +PATCH : SpotiClub Audio Key Fetching (v0.2.0) Fetches the audio decryption key from the SpotiClub Audio Key API instead of Spotify directly. -This is a workaround for Spotify's tightened restrictions on Audio Key access (only Premium Tier now). +This is a workaround for Spotify's tightened restrictions on Audio Key access (they allow only Premium Tier now). -There are 3 importants parameters to provide, and one is already filled in: +If you are using our fork, there is no reason for you to complete this section, as upon first run, Zotify will ask you for the logins and save them for future use. +But if needed somehow or by using this single patch file, there are 3 importants parameters to provide, and one is already filled in: - server_url: The URL of the SpotiClub Audio Key API endpoint. You should not need to change this, except if a dev instructs you to do so. - spoticlub_user : Your SpotiClub FTP username. You can get this by using our Padoru Asssistant once. - spoticlub_password : Your SpotiClub FTP password, also obtainable via the Padoru Assistant. -Alternatively, you can use the file `spoticlub_credentials.json` in the Zotify project root with the following structure: - - { - "spoticlub_user": "spoticlub-...", - "spoticlub_password": "..." - } +Using the fork's assistant is the recommended way to get register your credentials, as overwriting this file during the beta phase will need you to put them here over and over again. """ ##### WRITE YOUR LOGINS DOWN HERE ##### ####################################### server_url = "http://api.spoticlub.zip:4277/get_audio_key" -spoticlub_user = "" -spoticlub_password = "" +spoticlub_user = "anonymous" +spoticlub_password = "IfWeFeelLikeEnablingThis" ######################################## ##### END OF USER INPUT AREA ########### -SPOTICLUB_DISABLE = os.getenv("SPOTICLUB_DISABLE", "").lower() in {"1", "true", "yes"} -SPOTICLUB_FALLBACK_TO_SPOTIFY = os.getenv("SPOTICLUB_FALLBACK_SPOTIFY", "1").lower() not in { - "0", - "false", - "no", -} +### SPOTICLUB CLIENT SERIAL TRACKING (DO NOT EDIT) ### +spoticlub_client_serial: typing.Optional[str] = None +spoticlub_loaded_logged: bool = False +######################################## + +class LoadedStream(GeneralAudioStream): + def __init__(self, data: bytes): + super().__init__() + self._buffer = io.BytesIO(data) + + def read(self, n: int = -1) -> bytes: + return self._buffer.read(n) + + def close(self) -> None: + self._buffer.close() class AbsChunkedInputStream(io.BytesIO, HaltListener): chunk_exception = None @@ -275,6 +270,7 @@ class AudioKeyManager(PacketsReceiver, Closeable): __seq_holder_lock = threading.Condition() __session: Session __zero_short = b"\x00\x00" + _spoticlub_current_country: typing.Optional[str] = None def __init__(self, session: Session): self.__session = session @@ -298,88 +294,47 @@ class AudioKeyManager(PacketsReceiver, Closeable): "Couldn't handle packet, cmd: {}, length: {}".format( packet.cmd, len(packet.payload))) - def get_audio_key( - self, - gid: bytes, - file_id: bytes, - retry: bool = True, - ) -> bytes: - """Retrieve an audio key via SpotiClub API or fall back to Spotify.""" - - if self._spoticlub_available(): + def get_audio_key(self, + gid: bytes, + file_id: bytes, + retry: bool = True) -> bytes: + global spoticlub_user, spoticlub_password, spoticlub_client_serial, spoticlub_loaded_logged + if not spoticlub_user or not spoticlub_password or spoticlub_user == "anonymous": try: - return self._get_audio_key_via_spoticlub(gid, file_id, retry) - except Exception as exc: # noqa: BLE001 - self.logger.warning( - "SpotiClub API failed for gid=%s file_id=%s: %s", - util.bytes_to_hex(gid), - util.bytes_to_hex(file_id), - exc, - ) - if not SPOTICLUB_FALLBACK_TO_SPOTIFY: - raise - self.logger.info("Falling back to Spotify audio-key pipeline") + # To verify : Do all forks look for the same path ? + cfg_path = Path.home() / "AppData\\Roaming\\Zotify\\spoticlub_credentials.json" + if cfg_path.is_file(): + print(f"\n[SpotiClub API] Loading credentials...") + with open(cfg_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + spoticlub_user = cfg.get("spoticlub_user") + spoticlub_password = cfg.get("spoticlub_password") + else: + print(f"[SpotiClub API] Credentials file NOT found at: {cfg_path}. We will proceed with hardcoded credentials if any...\n") + except Exception as exc: + print(f"[SpotiClub API] Error while loading credentials file: {exc}\n") - return self._get_audio_key_via_spotify(gid, file_id, retry) - - def _spoticlub_available(self) -> bool: - if SPOTICLUB_DISABLE or not server_url: - return False - return self._ensure_spoticlub_credentials() - - def _ensure_spoticlub_credentials(self) -> bool: - global spoticlub_user, spoticlub_password - if spoticlub_user and spoticlub_password: - return True - - try: + if not spoticlub_user or not spoticlub_password or not server_url: cfg_path = Path.home() / "AppData\\Roaming\\Zotify\\spoticlub_credentials.json" - print(f"\n[SpotiClub API] Looking for credentials file at: {cfg_path}") - if cfg_path.is_file(): - print("[SpotiClub API] Found credentials file") - with open(cfg_path, "r", encoding="utf-8") as f: - cfg = json.load(f) - spoticlub_user = spoticlub_user or cfg.get("spoticlub_user") - spoticlub_password = spoticlub_password or cfg.get("spoticlub_password") - else: - print( - "[SpotiClub API] Credentials file NOT found at: %s. Will proceed with hardcoded credentials if any.\n" - % cfg_path - ) - except Exception as exc: # noqa: BLE001 - print(f"[SpotiClub API] Error while loading credentials file: {exc}\n") - - has_credentials = bool(spoticlub_user and spoticlub_password) - if not has_credentials: msg = ( - "Missing SpotiClub credentials: please set spoticlub_user & spoticlub_password inside the top of the __init__.py file," - " or use the spoticlub_credentials.json in your Zotify config folder [C:\\Users\\USERNAME\\AppData\\Roaming\\Zotify\\]." + "Missing SpotiClub credentials: please set the appropriates values inside your spoticlub_credentials.json," + f"located in the Zotify config folder [{cfg_path}] (Or delete it and restart Zotify to be prompted for credentials)." ) - if SPOTICLUB_FALLBACK_TO_SPOTIFY: - self.logger.warning("%s Falling back to Spotify audio keys.", msg) - return False - print(f"[SpotiClub API][FATAL] {msg}") + print(f"[SpotiClub API][ERROR]\n{msg}") raise SystemExit(1) - return True - def _get_audio_key_via_spoticlub(self, gid: bytes, file_id: bytes, retry: bool) -> bytes: - global spoticlub_user, spoticlub_password + if not spoticlub_loaded_logged: + spoticlub_loaded_logged = True + print(f"\n[SpotiClub API] Plugin Loaded! Welcome {spoticlub_user}\n") payload = { "gid": util.bytes_to_hex(gid), "file_id": util.bytes_to_hex(file_id), - "ftpUser": spoticlub_user, + "user": spoticlub_user, "password": spoticlub_password, } - - if not server_url: - msg = ( - "The SpotiClub server_url is not set! It should be defined at the top of __init__.py or via SPOTICLUB_SERVER_URL." - ) - print(f"[SpotiClub API][FATAL] {msg}") - raise SystemExit(1) - - print(f"[SpotiClub API] Loaded credentials - Welcome {spoticlub_user}\n") + if spoticlub_client_serial: + payload["client_serial"] = spoticlub_client_serial tries = 0 last_err: typing.Optional[Exception] = None @@ -387,82 +342,70 @@ class AudioKeyManager(PacketsReceiver, Closeable): while True: tries += 1 try: - resp = requests.post( - server_url, - json=payload, - timeout=AudioKeyManager.audio_key_request_timeout, - ) - if resp.status_code != 200: - raise RuntimeError( - f"[SpotiClub API] Unexpected response {resp.status_code}: {resp.text}" + resp = requests.post(server_url, json=payload, timeout=AudioKeyManager.audio_key_request_timeout) + + # If another client instance is already active for this + # SpotiClub user, the server will reply with HTTP 423 and + # instruct this client to wait before retrying. + if resp.status_code == 423: + try: + data = resp.json() + except Exception: # noqa: BLE001 + data = {} + retry_after = data.get("retry_after", 60) + if not isinstance(retry_after, (int, float)): + retry_after = 10 + print( + f"[SpotiClub API] Another client is already using this account. Waiting {int(retry_after)}s before retrying..." ) + self.logger.info( + "[SpotiClub API] Queued client for user %s; waiting %ds before retry", + spoticlub_user, + int(retry_after), + ) + time.sleep(float(retry_after)) + # Do NOT count this as a failure towards the max retries. + continue + + # Explicit handling for bad logins so we don't just retry. + if resp.status_code == 401: + print( + "[SpotiClub API][BAD_LOGIN] It seems your credentials aren't recognized by the API. Please ensure you have entered them correctly, or contact a DEV if you are absolutely certain of their validity." + ) + raise SystemExit(1) + + if resp.status_code != 200: + raise RuntimeError(f"[SpotiClub API] Sorry, the API returned the unexpected code {resp.status_code}: {resp.text}") + data = resp.json() key_hex = data.get("key") if not isinstance(key_hex, str): - raise RuntimeError("[SpotiClub API] Response missing 'key'") + raise RuntimeError("[SpotiClub API] Sorry, API response missing 'key'") + + country = data.get("country") + if isinstance(country, str): + if AudioKeyManager._spoticlub_current_country != country: + AudioKeyManager._spoticlub_current_country = country + print(f"[SpotiClub API] Received {country} as the download country\n\n") + + new_serial = data.get("client_serial") + if isinstance(new_serial, str) and new_serial: + spoticlub_client_serial = new_serial + key_bytes = util.hex_to_bytes(key_hex) if len(key_bytes) != 16: - raise RuntimeError("[SpotiClub API] Audio key must be 16 bytes long") + raise RuntimeError("[SpotiClub API] Woops, received Audio Key must be 16 bytes long") return key_bytes except Exception as exc: # noqa: BLE001 last_err = exc - self.logger.warning( - "[SpotiClub API] Retrying... (try %d/%d): %s", - tries, - 3, - exc, - ) + self.logger.warning("[SpotiClub API] Retrying the contact... (try %d): %s", tries, exc) if not retry or tries >= 3: break time.sleep(5) raise RuntimeError( "Failed fetching Audio Key from API for gid: {}, fileId: {} (last error: {})".format( - util.bytes_to_hex(gid), util.bytes_to_hex(file_id), last_err - ) - ) - - def _get_audio_key_via_spotify(self, gid: bytes, file_id: bytes, retry: bool) -> bytes: - callback = AudioKeyManager.SyncCallback(self) - seq = self._next_sequence() - self.__callbacks[seq] = callback - try: - self._send_audio_key_request(seq, gid, file_id) - key = callback.wait_response() - finally: - self.__callbacks.pop(seq, None) - - if key is None: - if retry: - self.logger.warning( - "Spotify audio key request returned no data for gid=%s file_id=%s; retrying once", - util.bytes_to_hex(gid), - util.bytes_to_hex(file_id), - ) - time.sleep(0.5) - return self._get_audio_key_via_spotify(gid, file_id, False) - raise RuntimeError( - "Failed retrieving audio key from Spotify for gid: {}, fileId: {}".format( - util.bytes_to_hex(gid), util.bytes_to_hex(file_id) - ) - ) - return key - - def _send_audio_key_request(self, seq: int, gid: bytes, file_id: bytes) -> None: - buffer = io.BytesIO() - buffer.write(struct.pack(">i", seq)) - buffer.write(gid) - buffer.write(self.__zero_short) - buffer.write(file_id) - buffer.write(self.__zero_short) - self.__session.send(Packet.Type.aes_key, buffer.getvalue()) - - def _next_sequence(self) -> int: - with self.__seq_holder_lock: - self.__seq_holder = (self.__seq_holder + 1) & 0x7FFFFFFF - if self.__seq_holder == 0: - self.__seq_holder = 1 - return self.__seq_holder + util.bytes_to_hex(gid), util.bytes_to_hex(file_id), last_err)) class Callback: @@ -492,14 +435,11 @@ class AudioKeyManager(PacketsReceiver, Closeable): self.__reference.put(None) self.__reference_lock.notify_all() - def wait_response(self) -> typing.Optional[bytes]: + def wait_response(self) -> bytes: with self.__reference_lock: self.__reference_lock.wait( AudioKeyManager.audio_key_request_timeout) - try: - return self.__reference.get(block=False) - except queue.Empty: - return None + return self.__reference.get(block=False) class CdnFeedHelper: @@ -956,7 +896,7 @@ class PlayableContentFeeder: def load_episode(self, episode_id: EpisodeId, audio_quality_picker: AudioQualityPicker, preload: bool, - halt_listener: HaltListener) -> "LoadedStream": + halt_listener: HaltListener) -> LoadedStream: episode = self.__session.api().get_metadata_4_episode(episode_id) if episode.external_url: return CdnFeedHelper.load_episode_external(self.__session, episode, diff --git a/zotify/track.py b/zotify/track.py index 609a0f0..085b957 100644 --- a/zotify/track.py +++ b/zotify/track.py @@ -440,7 +440,8 @@ def convert_audio_format(filename) -> None: if file_codec != 'copy': bitrate = Zotify.CONFIG.get_transcode_bitrate() bitrates = { - 'auto': '320k' if Zotify.check_premium() else '160k', + #SpotiClub API permit the use of '320k' for free users, so we map 'auto' to that value. + 'auto': '320k', 'normal': '96k', 'high': '160k', 'very_high': '320k'