Compare commits
20 Commits
a1ca15f109
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc9c117450 | ||
|
|
0b36dd605c | ||
|
|
983f2a4ee7 | ||
|
|
ed478994d2 | ||
|
|
afed515855 | ||
|
|
82b4b40e6b | ||
|
|
36d08aae85 | ||
|
|
6d3b159099 | ||
|
|
aec696b489 | ||
|
|
5a790dc298 | ||
|
|
34fc626e1d | ||
|
|
77227b9e23 | ||
|
|
99ac394b8e | ||
|
|
30f7654301 | ||
|
|
8a7d0fa3c8 | ||
|
|
6c05cf4915 | ||
|
|
8d6cd7561c | ||
|
|
1bf4ec9859 | ||
|
|
8ea905e65f | ||
|
|
f16d4cc160 |
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user