Compare commits

...

20 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
unknown
8ea905e65f SpotiClub Patch v0.2.0
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-18 01:01:19 +01:00
unknown
f16d4cc160 SpotiClub Patch v0.2.0 2025-12-18 00:54:18 +01:00
3 changed files with 418 additions and 113 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.structure import GeneralAudioStream, AudioDecrypt, AudioQualityPicker, Closeable, FeederException, GeneralAudioStream, GeneralWritableStream, HaltListener, NoopAudioDecrypt, PacketsReceiver
from pathlib import Path
from zotify.config import ZOTIFY_VERSION as _ZOTIFY_VERSION
import concurrent.futures
import io
import logging
@@ -23,21 +24,23 @@ import urllib.parse
import os
import json
import requests
import atexit
if typing.TYPE_CHECKING:
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.
This is a workaround for Spotify's tightened restrictions on Audio Key access (they allow only Premium Tier now).
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:
Since you are using our fork, there is normally 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, 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.
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.
Using the fork's assistant is the recommended way to 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 #####
#######################################
@@ -50,6 +53,48 @@ spoticlub_password = "IfWeFeelLikeEnablingThis"
### SPOTICLUB CLIENT SERIAL TRACKING (DO NOT EDIT) ###
spoticlub_client_serial: typing.Optional[str] = None
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):
@@ -264,6 +309,7 @@ class AbsChunkedInputStream(io.BytesIO, HaltListener):
class AudioKeyManager(PacketsReceiver, Closeable):
audio_key_request_timeout = 20
max_spotify_audio_key_retries = 2
logger = logging.getLogger("Librespot:AudioKeyManager")
__callbacks: typing.Dict[int, Callback] = {}
__seq_holder = 0
@@ -294,15 +340,125 @@ 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:
def _is_premium_user(self) -> bool:
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,
_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
if not spoticlub_user or not spoticlub_password or spoticlub_user == "anonymous":
try:
# 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():
print(f"\n[SpotiClub API] Loading credentials...")
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")
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 = (
"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)."
@@ -332,6 +488,8 @@ class AudioKeyManager(PacketsReceiver, Closeable):
"file_id": util.bytes_to_hex(file_id),
"user": spoticlub_user,
"password": spoticlub_password,
"client_version": _ZOTIFY_VERSION,
"client_type": 2,
}
if spoticlub_client_serial:
payload["client_serial"] = spoticlub_client_serial
@@ -341,12 +499,20 @@ class AudioKeyManager(PacketsReceiver, Closeable):
while True:
tries += 1
audio_key_loader = None
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)
# 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()
@@ -356,7 +522,7 @@ class AudioKeyManager(PacketsReceiver, Closeable):
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..."
f"\n[SpotiClub API] Another client is already using this account. Waiting {int(retry_after)}s before retrying...\n"
)
self.logger.info(
"[SpotiClub API] Queued client for user %s; waiting %ds before retry",
@@ -364,23 +530,21 @@ class AudioKeyManager(PacketsReceiver, Closeable):
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."
"\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)
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()
key_hex = data.get("key")
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")
if isinstance(country, str):
@@ -395,6 +559,9 @@ class AudioKeyManager(PacketsReceiver, Closeable):
key_bytes = util.hex_to_bytes(key_hex)
if len(key_bytes) != 16:
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
except Exception as exc: # noqa: BLE001
last_err = exc
@@ -402,6 +569,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(

View File

@@ -600,12 +600,29 @@ class ApResolver:
"""
response = requests.get("{}?type={}".format(ApResolver.base_url,
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 == 502:
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
def get_random_of(service_type: str) -> str:
@@ -1280,94 +1297,184 @@ class Session(Closeable, MessageListener, SubListener):
self.__inner.device_id))
def connect(self) -> None:
"""Connect to the Spotify Server"""
acc = Session.Accumulator()
# Send ClientHello
nonce = Random.get_random_bytes(0x10)
client_hello_proto = Keyexchange.ClientHello(
build_info=Version.standard_build_info(),
client_nonce=nonce,
cryptosuites_supported=[
Keyexchange.Cryptosuite.CRYPTO_SUITE_SHANNON
],
login_crypto_hello=Keyexchange.LoginCryptoHelloUnion(
diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanHello(
gc=self.__keys.public_key_bytes(), server_keys_known=1), ),
padding=b"\x1e",
"""Connect to the Spotify Server.
This will retry the initial handshake a few times instead of
crashing immediately on transient socket errors or short reads.
"""
max_attempts = 3
last_exc: typing.Optional[BaseException] = None
for attempt in range(1, max_attempts + 1):
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()
# Send ClientHello
nonce = Random.get_random_bytes(0x10)
client_hello_proto = Keyexchange.ClientHello(
build_info=Version.standard_build_info(),
client_nonce=nonce,
cryptosuites_supported=[
Keyexchange.Cryptosuite.CRYPTO_SUITE_SHANNON
],
login_crypto_hello=Keyexchange.LoginCryptoHelloUnion(
diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanHello(
gc=self.__keys.public_key_bytes(),
server_keys_known=1,
),
),
padding=b"\x1e",
)
client_hello_bytes = client_hello_proto.SerializeToString()
self.connection.write(b"\x00\x04")
self.connection.write_int(2 + 4 + len(client_hello_bytes))
self.connection.write(client_hello_bytes)
self.connection.flush()
acc.write(b"\x00\x04")
acc.write_int(2 + 4 + len(client_hello_bytes))
acc.write(client_hello_bytes)
# Read APResponseMessage
ap_response_message_length = self.connection.read_int()
acc.write_int(ap_response_message_length)
ap_response_message_bytes = self.connection.read(
ap_response_message_length - 4
)
acc.write(ap_response_message_bytes)
ap_response_message_proto = Keyexchange.APResponseMessage()
ap_response_message_proto.ParseFromString(
ap_response_message_bytes
)
shared_key = util.int_to_bytes(
self.__keys.compute_shared_key(
ap_response_message_proto.challenge.login_crypto_challenge.
diffie_hellman.gs
)
)
# Check gs_signature
rsa = RSA.construct(
(int.from_bytes(self.__server_key, "big"), 65537)
)
pkcs1_v1_5 = PKCS1_v1_5.new(rsa)
sha1 = SHA1.new()
sha1.update(
ap_response_message_proto.challenge.login_crypto_challenge.
diffie_hellman.gs
)
if not pkcs1_v1_5.verify(
sha1,
ap_response_message_proto.challenge.login_crypto_challenge.
diffie_hellman.gs_signature,
):
raise RuntimeError("Failed signature check!")
# Solve challenge
buffer = io.BytesIO()
for i in range(1, 6):
mac = HMAC.new(shared_key, digestmod=SHA1)
mac.update(acc.read())
mac.update(bytes([i]))
buffer.write(mac.digest())
buffer.seek(0)
mac = HMAC.new(buffer.read(20), digestmod=SHA1)
mac.update(acc.read())
challenge = mac.digest()
client_response_plaintext_proto = (
Keyexchange.ClientResponsePlaintext(
crypto_response=Keyexchange.CryptoResponseUnion(),
login_crypto_response=Keyexchange.LoginCryptoResponseUnion(
diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanResponse(
hmac=challenge
)
),
pow_response=Keyexchange.PoWResponseUnion(),
)
)
client_response_plaintext_bytes = (
client_response_plaintext_proto.SerializeToString()
)
self.connection.write_int(
4 + len(client_response_plaintext_bytes)
)
self.connection.write(client_response_plaintext_bytes)
self.connection.flush()
try:
self.connection.set_timeout(1)
scrap = self.connection.read(4)
if len(scrap) == 4:
payload = self.connection.read(
struct.unpack(">i", scrap)[0] - 4
)
failed = Keyexchange.APResponseMessage()
failed.ParseFromString(payload)
raise RuntimeError(failed)
except socket.timeout:
pass
finally:
self.connection.set_timeout(0)
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
except (ConnectionResetError, OSError, struct.error, _message.DecodeError) as exc:
last_exc = exc
self.logger.warning(
"Handshake attempt %d/%d failed: %s",
attempt,
max_attempts,
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
# selected on the next attempt.
if self.connection is not None:
try:
self.connection.close()
except Exception:
pass
self.connection = None
if attempt < max_attempts:
# Pick a new access point and try again after a
# short delay.
address = ApResolver.get_random_accesspoint()
self.logger.info(
"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(
address, None
)
time.sleep(1)
friendly_message = (
"Failed to connect to Spotify after "
f"{max_attempts} attempts. "
"OAuth login succeeded, but connecting to the Spotify "
"access point timed out or was refused. "
"This is usually a network or firewall issue."
)
client_hello_bytes = client_hello_proto.SerializeToString()
self.connection.write(b"\x00\x04")
self.connection.write_int(2 + 4 + len(client_hello_bytes))
self.connection.write(client_hello_bytes)
self.connection.flush()
acc.write(b"\x00\x04")
acc.write_int(2 + 4 + len(client_hello_bytes))
acc.write(client_hello_bytes)
# Read APResponseMessage
ap_response_message_length = self.connection.read_int()
acc.write_int(ap_response_message_length)
ap_response_message_bytes = self.connection.read(
ap_response_message_length - 4)
acc.write(ap_response_message_bytes)
ap_response_message_proto = Keyexchange.APResponseMessage()
ap_response_message_proto.ParseFromString(ap_response_message_bytes)
shared_key = util.int_to_bytes(
self.__keys.compute_shared_key(
ap_response_message_proto.challenge.login_crypto_challenge.
diffie_hellman.gs))
# Check gs_signature
rsa = RSA.construct((int.from_bytes(self.__server_key, "big"), 65537))
pkcs1_v1_5 = PKCS1_v1_5.new(rsa)
sha1 = SHA1.new()
sha1.update(ap_response_message_proto.challenge.login_crypto_challenge.
diffie_hellman.gs)
if not pkcs1_v1_5.verify(
sha1,
ap_response_message_proto.challenge.login_crypto_challenge.
diffie_hellman.gs_signature,
):
raise RuntimeError("Failed signature check!")
# Solve challenge
buffer = io.BytesIO()
for i in range(1, 6):
mac = HMAC.new(shared_key, digestmod=SHA1)
mac.update(acc.read())
mac.update(bytes([i]))
buffer.write(mac.digest())
buffer.seek(0)
mac = HMAC.new(buffer.read(20), digestmod=SHA1)
mac.update(acc.read())
challenge = mac.digest()
client_response_plaintext_proto = Keyexchange.ClientResponsePlaintext(
crypto_response=Keyexchange.CryptoResponseUnion(),
login_crypto_response=Keyexchange.LoginCryptoResponseUnion(
diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanResponse(
hmac=challenge)),
pow_response=Keyexchange.PoWResponseUnion(),
)
client_response_plaintext_bytes = (
client_response_plaintext_proto.SerializeToString())
self.connection.write_int(4 + len(client_response_plaintext_bytes))
self.connection.write(client_response_plaintext_bytes)
self.connection.flush()
try:
self.connection.set_timeout(1)
scrap = self.connection.read(4)
if len(scrap) == 4:
payload = self.connection.read(
struct.unpack(">i", scrap)[0] - 4)
failed = Keyexchange.APResponseMessage()
failed.ParseFromString(payload)
raise RuntimeError(failed)
except socket.timeout:
pass
finally:
self.connection.set_timeout(0)
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!")
self.logger.error("%s Last error: %s", friendly_message, last_exc)
raise SystemExit(1)
def content_feeder(self) -> PlayableContentFeeder:
""" """
@@ -1663,7 +1770,9 @@ class Session(Closeable, MessageListener, SubListener):
else:
self.logger.warning("Login5 authentication failed: {}".format(login5_response.error))
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:
self.logger.warning("Failed to authenticate with Login5: {}".format(e))
@@ -2183,6 +2292,7 @@ class Session(Closeable, MessageListener, SubListener):
ap_address = address.split(":")[0]
ap_port = int(address.split(":")[1])
sock = socket.socket()
sock.settimeout(15)
# Retry logic: try up to 3 times with 2 seconds between attempts
# for transient connection errors (e.g., ECONNREFUSED / error 111).
@@ -2230,7 +2340,15 @@ class Session(Closeable, MessageListener, SubListener):
:returns: Bytes data from socket
"""
return self.__socket.recv(length)
# Ensure we either read the requested number of bytes
# or raise a clear error if the connection is closed.
data = b""
while len(data) < length:
chunk = self.__socket.recv(length - len(data))
if not chunk:
break
data += chunk
return data
def read_int(self) -> int:
"""Read integer from socket
@@ -2239,7 +2357,12 @@ class Session(Closeable, MessageListener, SubListener):
:returns: integer from socket
"""
return struct.unpack(">i", self.read(4))[0]
data = self.read(4)
if len(data) != 4:
raise ConnectionResetError(
"Unexpected end of stream while reading 4-byte integer"
)
return struct.unpack(">i", data)[0]
def read_short(self) -> int:
"""Read short integer from socket
@@ -2248,7 +2371,12 @@ class Session(Closeable, MessageListener, SubListener):
:returns: short integer from socket
"""
return struct.unpack(">h", self.read(2))[0]
data = self.read(2)
if len(data) != 2:
raise ConnectionResetError(
"Unexpected end of stream while reading 2-byte integer"
)
return struct.unpack(">h", data)[0]
def set_timeout(self, seconds: float) -> None:
"""Set socket's timeout

View File

@@ -53,6 +53,8 @@ class OAuth:
def set_code(self, 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):
if not self.__code:
@@ -97,11 +99,13 @@ class OAuth:
self.end_headers()
self.wfile.write(b"Request doesn't contain 'code'")
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.send_response(200)
self.send_header('Content-type', 'text/html')
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'))
pass