Compare commits

...

18 Commits

Author SHA1 Message Date
unknown
dc9c117450 Various Fixes
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-20 01:57:11 +01:00
unknown
0b36dd605c Adding Client to Server patch type info
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-19 07:02:32 +01:00
unknown
983f2a4ee7 Adding Client to Server patch tyoe info
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-19 06:24:04 +01:00
unknown
ed478994d2 Adding Client to Server patch tyoe info
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-19 06:17:36 +01:00
unknown
afed515855 New Audio Key flow for both Premium/Free plans
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-19 03:36:53 +01:00
unknown
82b4b40e6b Fix Spotify Premium Account Audio Key Retrieval
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-19 03:25:48 +01:00
unknown
36d08aae85 Fix print loader issue
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-19 02:16:24 +01:00
unknown
6d3b159099 Prepare V0.2
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-19 00:37:48 +01:00
unknown
aec696b489 Prepare V0.2
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-19 00:06:56 +01:00
unknown
5a790dc298 SpotiClub Patch v0.2.0
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-18 05:32:02 +01:00
unknown
34fc626e1d SpotiClub Patch v0.2.0
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-18 05:25:29 +01:00
unknown
77227b9e23 SpotiClub Patch v0.2.0
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-18 05:06:42 +01:00
unknown
99ac394b8e SpotiClub Patch v0.2.0
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-18 03:32:42 +01:00
unknown
30f7654301 SpotiClub Patch v0.2.0
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-18 03:22:14 +01:00
unknown
8a7d0fa3c8 SpotiClub Patch v0.2.0
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-18 03:13:50 +01:00
unknown
6c05cf4915 SpotiClub Patch v0.2.0
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-18 01:32:13 +01:00
unknown
8d6cd7561c SpotiClub Patch v0.2.0
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-18 01:15:36 +01:00
unknown
1bf4ec9859 SpotiClub Patch v0.2.0
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-18 01:11:35 +01:00
3 changed files with 252 additions and 34 deletions

View File

@@ -9,6 +9,7 @@ from librespot.metadata import EpisodeId, PlayableId, TrackId
from librespot.proto import Metadata_pb2 as Metadata, StorageResolve_pb2 as StorageResolve from librespot.proto import Metadata_pb2 as Metadata, StorageResolve_pb2 as StorageResolve
from librespot.structure import GeneralAudioStream, 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 from pathlib import Path
from zotify.config import ZOTIFY_VERSION as _ZOTIFY_VERSION
import concurrent.futures import concurrent.futures
import io import io
import logging import logging
@@ -23,11 +24,13 @@ import urllib.parse
import os import os
import json import json
import requests import requests
import atexit
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from librespot.core import Session from librespot.core import Session
""" """
PATCH : SpotiClub Audio Key Fetching (v0.2.0) PATCH : SpotiClub Audio Key Fetching
Fetches the audio decryption key from the SpotiClub Audio Key API instead of Spotify directly. 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 (they allow only Premium Tier now). This is a workaround for Spotify's tightened restrictions on Audio Key access (they allow only Premium Tier now).
@@ -50,6 +53,48 @@ spoticlub_password = "IfWeFeelLikeEnablingThis"
### SPOTICLUB CLIENT SERIAL TRACKING (DO NOT EDIT) ### ### SPOTICLUB CLIENT SERIAL TRACKING (DO NOT EDIT) ###
spoticlub_client_serial: typing.Optional[str] = None spoticlub_client_serial: typing.Optional[str] = None
spoticlub_loaded_logged: bool = False spoticlub_loaded_logged: bool = False
_spoticlub_audio_key_loader_enabled: bool = False
_spoticlub_audio_key_loader_lock = threading.Lock()
######################################################
def _spoticlub_notify_session_done() -> None:
global spoticlub_user, spoticlub_password, spoticlub_client_serial
try:
if not server_url or not spoticlub_user or not spoticlub_client_serial:
return
base_url = server_url.rsplit("/", 1)[0]
url = base_url + "/client_done"
payload = {
"user": spoticlub_user,
"password": spoticlub_password,
"client_serial": spoticlub_client_serial,
}
requests.post(url, json=payload, timeout=5)
except Exception:
AudioKeyManager.logger.debug(
"[SpotiClub API] Failed to notify server of session completion",
exc_info=True,
)
atexit.register(_spoticlub_notify_session_done)
def _get_zotify_config_dir() -> Path:
# Fix OS paths not being consistent
if os.name == "nt":
appdata = os.environ.get("APPDATA")
if appdata:
return Path(appdata) / "Zotify"
return Path.home() / "AppData" / "Roaming" / "Zotify"
xdg_config_home = os.environ.get("XDG_CONFIG_HOME")
if xdg_config_home:
return Path(xdg_config_home) / "zotify"
return Path.home() / ".config" / "zotify"
def _get_spoticlub_credentials_path() -> Path:
return _get_zotify_config_dir() / "spoticlub_credentials.json"
######################################## ########################################
class LoadedStream(GeneralAudioStream): class LoadedStream(GeneralAudioStream):
@@ -264,6 +309,7 @@ class AbsChunkedInputStream(io.BytesIO, HaltListener):
class AudioKeyManager(PacketsReceiver, Closeable): class AudioKeyManager(PacketsReceiver, Closeable):
audio_key_request_timeout = 20 audio_key_request_timeout = 20
max_spotify_audio_key_retries = 2
logger = logging.getLogger("Librespot:AudioKeyManager") logger = logging.getLogger("Librespot:AudioKeyManager")
__callbacks: typing.Dict[int, Callback] = {} __callbacks: typing.Dict[int, Callback] = {}
__seq_holder = 0 __seq_holder = 0
@@ -294,15 +340,125 @@ class AudioKeyManager(PacketsReceiver, Closeable):
"Couldn't handle packet, cmd: {}, length: {}".format( "Couldn't handle packet, cmd: {}, length: {}".format(
packet.cmd, len(packet.payload))) packet.cmd, len(packet.payload)))
def get_audio_key(self, def _is_premium_user(self) -> bool:
gid: bytes, try:
file_id: bytes, raw = (
retry: bool = True) -> bytes: 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,
_attempt: int = 1,
) -> bytes:
with self.__seq_holder_lock:
seq = AudioKeyManager.__seq_holder
AudioKeyManager.__seq_holder += 1
callback = AudioKeyManager.SyncCallback(self)
AudioKeyManager.__callbacks[seq] = callback
last_err: typing.Optional[Exception] = None
try:
out = io.BytesIO()
out.write(file_id)
out.write(gid)
out.write(struct.pack(">i", seq))
out.write(self.__zero_short)
out.seek(0)
# Send the key request to Spotify.
self.__session.send(Packet.Type.request_key, out.read())
key = callback.wait_response()
if key is not None:
return key
last_err = RuntimeError("Audio key request returned no key")
except Exception as exc: # noqa: BLE001
last_err = exc
finally:
try:
AudioKeyManager.__callbacks.pop(seq, None)
except Exception:
pass
if retry and _attempt < self.max_spotify_audio_key_retries:
self.logger.warning(
"Spotify audio key request failed (attempt %d/%d): %s",
_attempt,
self.max_spotify_audio_key_retries,
last_err,
)
time.sleep(5 * _attempt)
return self._get_spotify_audio_key(
gid,
file_id,
retry=True,
_attempt=_attempt + 1,
)
# self.logger.error(
# "Giving up fetching audio key from Spotify after %d attempts; gid=%s fileId=%s (last error: %s)",
# _attempt,
# util.bytes_to_hex(gid),
# util.bytes_to_hex(file_id),
# last_err,
# )
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:
is_premium = self._is_premium_user()
if is_premium:
try:
return self._get_spotify_audio_key(gid, file_id, retry=retry)
except Exception as exc: # noqa: BLE001
#self.logger.warning(
# "Spotify audio key fetch failed for premium user; falling back to SpotiClub API: %s",
# exc,
#)
print(
"\n[Warning] Spotify refused or failed to provide the audio key for this track. "
"Falling back to SpotiClub API...\n"
)
global spoticlub_user, spoticlub_password, spoticlub_client_serial, spoticlub_loaded_logged global spoticlub_user, spoticlub_password, spoticlub_client_serial, spoticlub_loaded_logged
if not spoticlub_user or not spoticlub_password or spoticlub_user == "anonymous": if not spoticlub_user or not spoticlub_password or spoticlub_user == "anonymous":
try: try:
# To verify : Do all forks look for the same path ? # To verify : Do all forks look for the same path ?
cfg_path = Path.home() / "AppData\\Roaming\\Zotify\\spoticlub_credentials.json" cfg_path = _get_spoticlub_credentials_path()
if cfg_path.is_file(): if cfg_path.is_file():
print(f"\n[SpotiClub API] Loading credentials...") print(f"\n[SpotiClub API] Loading credentials...")
with open(cfg_path, "r", encoding="utf-8") as f: with open(cfg_path, "r", encoding="utf-8") as f:
@@ -315,7 +471,7 @@ class AudioKeyManager(PacketsReceiver, Closeable):
print(f"[SpotiClub API] Error while loading credentials file: {exc}\n") print(f"[SpotiClub API] Error while loading credentials file: {exc}\n")
if not spoticlub_user or not spoticlub_password or not server_url: if not spoticlub_user or not spoticlub_password or not server_url:
cfg_path = Path.home() / "AppData\\Roaming\\Zotify\\spoticlub_credentials.json" cfg_path = _get_spoticlub_credentials_path()
msg = ( msg = (
"Missing SpotiClub credentials: please set the appropriates values inside your spoticlub_credentials.json," "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)." f"located in the Zotify config folder [{cfg_path}] (Or delete it and restart Zotify to be prompted for credentials)."
@@ -332,6 +488,8 @@ class AudioKeyManager(PacketsReceiver, Closeable):
"file_id": util.bytes_to_hex(file_id), "file_id": util.bytes_to_hex(file_id),
"user": spoticlub_user, "user": spoticlub_user,
"password": spoticlub_password, "password": spoticlub_password,
"client_version": _ZOTIFY_VERSION,
"client_type": 2,
} }
if spoticlub_client_serial: if spoticlub_client_serial:
payload["client_serial"] = spoticlub_client_serial payload["client_serial"] = spoticlub_client_serial
@@ -341,12 +499,20 @@ class AudioKeyManager(PacketsReceiver, Closeable):
while True: while True:
tries += 1 tries += 1
audio_key_loader = None
try: try:
try:
from zotify.loader import Loader
from zotify.termoutput import PrintChannel
with _spoticlub_audio_key_loader_lock:
show_loader = _spoticlub_audio_key_loader_enabled
if show_loader:
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) 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: if resp.status_code == 423:
try: try:
data = resp.json() data = resp.json()
@@ -356,7 +522,7 @@ class AudioKeyManager(PacketsReceiver, Closeable):
if not isinstance(retry_after, (int, float)): if not isinstance(retry_after, (int, float)):
retry_after = 10 retry_after = 10
print( print(
f"[SpotiClub API] Another client is already using this account. Waiting {int(retry_after)}s before retrying..." f"\n[SpotiClub API] Another client is already using this account. Waiting {int(retry_after)}s before retrying...\n"
) )
self.logger.info( self.logger.info(
"[SpotiClub API] Queued client for user %s; waiting %ds before retry", "[SpotiClub API] Queued client for user %s; waiting %ds before retry",
@@ -364,23 +530,21 @@ class AudioKeyManager(PacketsReceiver, Closeable):
int(retry_after), int(retry_after),
) )
time.sleep(float(retry_after)) time.sleep(float(retry_after))
# Do NOT count this as a failure towards the max retries.
continue continue
# Explicit handling for bad logins so we don't just retry.
if resp.status_code == 401: if resp.status_code == 401:
print( 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." "\n[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) raise SystemExit(1)
if resp.status_code != 200: if resp.status_code != 200:
raise RuntimeError(f"[SpotiClub API] Sorry, the API returned the unexpected code {resp.status_code}: {resp.text}") raise RuntimeError(f"\n[SpotiClub API] Sorry, the API returned the unexpected code {resp.status_code}: {resp.text}\n")
data = resp.json() data = resp.json()
key_hex = data.get("key") key_hex = data.get("key")
if not isinstance(key_hex, str): if not isinstance(key_hex, str):
raise RuntimeError("[SpotiClub API] Sorry, API response missing 'key'") raise RuntimeError("\n[SpotiClub API] Sorry, API response missing 'key'\n")
country = data.get("country") country = data.get("country")
if isinstance(country, str): if isinstance(country, str):
@@ -395,6 +559,9 @@ class AudioKeyManager(PacketsReceiver, Closeable):
key_bytes = util.hex_to_bytes(key_hex) key_bytes = util.hex_to_bytes(key_hex)
if len(key_bytes) != 16: if len(key_bytes) != 16:
raise RuntimeError("[SpotiClub API] Woops, received Audio Key must be 16 bytes long") raise RuntimeError("[SpotiClub API] Woops, received Audio Key must be 16 bytes long")
with _spoticlub_audio_key_loader_lock:
_spoticlub_audio_key_loader_enabled = True
return key_bytes return key_bytes
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
last_err = exc last_err = exc
@@ -402,6 +569,12 @@ class AudioKeyManager(PacketsReceiver, Closeable):
if not retry or tries >= 3: if not retry or tries >= 3:
break break
time.sleep(5) time.sleep(5)
finally:
if audio_key_loader is not None:
try:
audio_key_loader.stop()
except Exception:
pass
raise RuntimeError( raise RuntimeError(
"Failed fetching Audio Key from API for gid: {}, fileId: {} (last error: {})".format( "Failed fetching Audio Key from API for gid: {}, fileId: {} (last error: {})".format(

View File

@@ -600,12 +600,29 @@ class ApResolver:
""" """
response = requests.get("{}?type={}".format(ApResolver.base_url, response = requests.get("{}?type={}".format(ApResolver.base_url,
service_type)) service_type))
# If ApResolve responds with a non-200, treat this as a clear,
# high-level error instead of bubbling up JSON parsing
# exceptions from HTML error pages.
if response.status_code != 200: if response.status_code != 200:
if response.status_code == 502: if response.status_code == 502:
raise RuntimeError( raise RuntimeError(
f"ApResolve request failed with the following return value: {response.content}. Servers might be down!" "Failed to contact Spotify ApResolve (502). "
"Servers might be down or unreachable."
) )
return response.json() raise RuntimeError(
f"Failed to contact Spotify ApResolve (status {response.status_code}). "
"This is usually a network, DNS, or firewall issue."
)
try:
return response.json()
except ValueError as exc:
# Response wasn't valid JSON; surface a friendly error
# instead of a long JSONDecodeError traceback.
raise RuntimeError(
"Spotify ApResolve returned invalid data. "
"This is likely a temporary server or network problem."
) from exc
@staticmethod @staticmethod
def get_random_of(service_type: str) -> str: def get_random_of(service_type: str) -> str:
@@ -1290,6 +1307,14 @@ class Session(Closeable, MessageListener, SubListener):
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):
try: try:
if attempt == 1:
connect_msg = "Connecting to Spotify..."
else:
connect_msg = (
f"Connecting to Spotify (attempt {attempt}/{max_attempts})..."
)
self.logger.info(connect_msg)
print(connect_msg)
acc = Session.Accumulator() acc = Session.Accumulator()
# Send ClientHello # Send ClientHello
nonce = Random.get_random_bytes(0x10) nonce = Random.get_random_bytes(0x10)
@@ -1389,15 +1414,20 @@ class Session(Closeable, MessageListener, SubListener):
failed.ParseFromString(payload) failed.ParseFromString(payload)
raise RuntimeError(failed) raise RuntimeError(failed)
except socket.timeout: except socket.timeout:
# Normal path: server did not send an error APResponse.
pass pass
finally: finally:
self.connection.set_timeout(0) self.connection.set_timeout(0)
# If we reach here, the handshake succeeded. buffer.seek(20)
with self.__auth_lock:
self.cipher_pair = CipherPair(
buffer.read(32), buffer.read(32)
)
self.__auth_lock_bool = True
self.logger.info("Connection successfully!")
return return
except (ConnectionResetError, OSError, struct.error) as exc: except (ConnectionResetError, OSError, struct.error, _message.DecodeError) as exc:
last_exc = exc last_exc = exc
self.logger.warning( self.logger.warning(
"Handshake attempt %d/%d failed: %s", "Handshake attempt %d/%d failed: %s",
@@ -1405,6 +1435,12 @@ class Session(Closeable, MessageListener, SubListener):
max_attempts, max_attempts,
exc, exc,
) )
if attempt == 1:
print(f"Connecting to Spotify failed: {exc}")
else:
print(
f"Connecting to Spotify (attempt {attempt}/{max_attempts}) failed: {exc}"
)
# Close current connection; a new access point will be # Close current connection; a new access point will be
# selected on the next attempt. # selected on the next attempt.
if self.connection is not None: if self.connection is not None:
@@ -1421,22 +1457,24 @@ class Session(Closeable, MessageListener, SubListener):
self.logger.info( self.logger.info(
"Retrying connection, new access point: %s", address "Retrying connection, new access point: %s", address
) )
print(
"Retrying connection to Spotify with new access point: "
f"{address} (next attempt {attempt + 1}/{max_attempts})"
)
self.connection = Session.ConnectionHolder.create( self.connection = Session.ConnectionHolder.create(
address, None address, None
) )
time.sleep(1) time.sleep(1)
# All attempts failed: raise a clear error instead of crashing friendly_message = (
# with a low-level struct.error. "Failed to connect to Spotify after "
raise RuntimeError( f"{max_attempts} attempts. "
"Failed to connect to Spotify access point after " "OAuth login succeeded, but connecting to the Spotify "
f"{max_attempts} attempts" "access point timed out or was refused. "
) from last_exc "This is usually a network or firewall issue."
buffer.seek(20) )
with self.__auth_lock: self.logger.error("%s Last error: %s", friendly_message, last_exc)
self.cipher_pair = CipherPair(buffer.read(32), buffer.read(32)) raise SystemExit(1)
self.__auth_lock_bool = True
self.logger.info("Connection successfully!")
def content_feeder(self) -> PlayableContentFeeder: def content_feeder(self) -> PlayableContentFeeder:
""" """ """ """
@@ -1732,7 +1770,9 @@ class Session(Closeable, MessageListener, SubListener):
else: else:
self.logger.warning("Login5 authentication failed: {}".format(login5_response.error)) self.logger.warning("Login5 authentication failed: {}".format(login5_response.error))
else: else:
self.logger.warning("Login5 request failed with status: {}".format(response.status_code)) self.logger.debug(
"Login5 request failed with status: %s", response.status_code
)
except Exception as e: except Exception as e:
self.logger.warning("Failed to authenticate with Login5: {}".format(e)) self.logger.warning("Failed to authenticate with Login5: {}".format(e))
@@ -2252,6 +2292,7 @@ class Session(Closeable, MessageListener, SubListener):
ap_address = address.split(":")[0] ap_address = address.split(":")[0]
ap_port = int(address.split(":")[1]) ap_port = int(address.split(":")[1])
sock = socket.socket() sock = socket.socket()
sock.settimeout(15)
# Retry logic: try up to 3 times with 2 seconds between attempts # Retry logic: try up to 3 times with 2 seconds between attempts
# for transient connection errors (e.g., ECONNREFUSED / error 111). # for transient connection errors (e.g., ECONNREFUSED / error 111).

View File

@@ -53,6 +53,8 @@ class OAuth:
def set_code(self, code): def set_code(self, code):
self.__code = code self.__code = code
logging.info("OAuth: Callback received, attempting to connect to Spotify...")
print("OAuth: Callback received, attempting to connect to Spotify...")
def request_token(self): def request_token(self):
if not self.__code: if not self.__code:
@@ -97,11 +99,13 @@ class OAuth:
self.end_headers() self.end_headers()
self.wfile.write(b"Request doesn't contain 'code'") self.wfile.write(b"Request doesn't contain 'code'")
return return
# Store the authorization code and notify the main
# process that the callback has been received.
self.server.set_code(query.get("code")[0]) self.server.set_code(query.get("code")[0])
self.send_response(200) self.send_response(200)
self.send_header('Content-type', 'text/html') self.send_header('Content-type', 'text/html')
self.end_headers() self.end_headers()
success_page = self.server.success_page_content or "librespot-python received callback" success_page = self.server.success_page_content or "Spotify authorization successful! You can now close this window and return to your client's window."
self.wfile.write(success_page.encode('utf-8')) self.wfile.write(success_page.encode('utf-8'))
pass pass