Compare commits

...

22 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
unknown
a1ca15f109 SpotiClub Patch v0.2.0
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-17 22:37:14 +01:00
unknown
1480047ecb SpotiClub Patch v0.2.0
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-17 22:28:02 +01:00
3 changed files with 560 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.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,21 +24,23 @@ 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).
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. 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 or by using this single patch file, there are 3 importants parameters to provide, and one is already filled in: 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. - 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_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. - 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 ##### ##### WRITE YOUR LOGINS DOWN HERE #####
####################################### #######################################
@@ -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:
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, gid: bytes,
file_id: bytes, file_id: bytes,
retry: bool = True) -> 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,29 +530,27 @@ 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):
if AudioKeyManager._spoticlub_current_country != country: if AudioKeyManager._spoticlub_current_country != country:
AudioKeyManager._spoticlub_current_country = country AudioKeyManager._spoticlub_current_country = country
print(f"[SpotiClub API] Received {country} as the download country\n\n") print(f"\n\n[SpotiClub API] Received {country} as the download country\n\n")
new_serial = data.get("client_serial") new_serial = data.get("client_serial")
if isinstance(new_serial, str) and new_serial: if isinstance(new_serial, str) and new_serial:
@@ -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

@@ -33,6 +33,7 @@ from Cryptodome.Signature import PKCS1_v1_5
from librespot import util from librespot import util
from librespot import Version from librespot import Version
from librespot.oauth import OAuth
from librespot.audio import AudioKeyManager from librespot.audio import AudioKeyManager
from librespot.audio import CdnManager from librespot.audio import CdnManager
from librespot.audio import PlayableContentFeeder from librespot.audio import PlayableContentFeeder
@@ -599,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."
) )
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() 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:
@@ -1279,7 +1297,24 @@ class Session(Closeable, MessageListener, SubListener):
self.__inner.device_id)) self.__inner.device_id))
def connect(self) -> None: def connect(self) -> None:
"""Connect to the Spotify Server""" """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() acc = Session.Accumulator()
# Send ClientHello # Send ClientHello
nonce = Random.get_random_bytes(0x10) nonce = Random.get_random_bytes(0x10)
@@ -1291,7 +1326,10 @@ class Session(Closeable, MessageListener, SubListener):
], ],
login_crypto_hello=Keyexchange.LoginCryptoHelloUnion( login_crypto_hello=Keyexchange.LoginCryptoHelloUnion(
diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanHello( diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanHello(
gc=self.__keys.public_key_bytes(), server_keys_known=1), ), gc=self.__keys.public_key_bytes(),
server_keys_known=1,
),
),
padding=b"\x1e", padding=b"\x1e",
) )
client_hello_bytes = client_hello_proto.SerializeToString() client_hello_bytes = client_hello_proto.SerializeToString()
@@ -1306,20 +1344,29 @@ class Session(Closeable, MessageListener, SubListener):
ap_response_message_length = self.connection.read_int() ap_response_message_length = self.connection.read_int()
acc.write_int(ap_response_message_length) acc.write_int(ap_response_message_length)
ap_response_message_bytes = self.connection.read( ap_response_message_bytes = self.connection.read(
ap_response_message_length - 4) ap_response_message_length - 4
)
acc.write(ap_response_message_bytes) acc.write(ap_response_message_bytes)
ap_response_message_proto = Keyexchange.APResponseMessage() ap_response_message_proto = Keyexchange.APResponseMessage()
ap_response_message_proto.ParseFromString(ap_response_message_bytes) ap_response_message_proto.ParseFromString(
ap_response_message_bytes
)
shared_key = util.int_to_bytes( shared_key = util.int_to_bytes(
self.__keys.compute_shared_key( self.__keys.compute_shared_key(
ap_response_message_proto.challenge.login_crypto_challenge. ap_response_message_proto.challenge.login_crypto_challenge.
diffie_hellman.gs)) diffie_hellman.gs
)
)
# Check gs_signature # Check gs_signature
rsa = RSA.construct((int.from_bytes(self.__server_key, "big"), 65537)) rsa = RSA.construct(
(int.from_bytes(self.__server_key, "big"), 65537)
)
pkcs1_v1_5 = PKCS1_v1_5.new(rsa) pkcs1_v1_5 = PKCS1_v1_5.new(rsa)
sha1 = SHA1.new() sha1 = SHA1.new()
sha1.update(ap_response_message_proto.challenge.login_crypto_challenge. sha1.update(
diffie_hellman.gs) ap_response_message_proto.challenge.login_crypto_challenge.
diffie_hellman.gs
)
if not pkcs1_v1_5.verify( if not pkcs1_v1_5.verify(
sha1, sha1,
ap_response_message_proto.challenge.login_crypto_challenge. ap_response_message_proto.challenge.login_crypto_challenge.
@@ -1337,16 +1384,23 @@ class Session(Closeable, MessageListener, SubListener):
mac = HMAC.new(buffer.read(20), digestmod=SHA1) mac = HMAC.new(buffer.read(20), digestmod=SHA1)
mac.update(acc.read()) mac.update(acc.read())
challenge = mac.digest() challenge = mac.digest()
client_response_plaintext_proto = Keyexchange.ClientResponsePlaintext( client_response_plaintext_proto = (
Keyexchange.ClientResponsePlaintext(
crypto_response=Keyexchange.CryptoResponseUnion(), crypto_response=Keyexchange.CryptoResponseUnion(),
login_crypto_response=Keyexchange.LoginCryptoResponseUnion( login_crypto_response=Keyexchange.LoginCryptoResponseUnion(
diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanResponse( diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanResponse(
hmac=challenge)), hmac=challenge
)
),
pow_response=Keyexchange.PoWResponseUnion(), pow_response=Keyexchange.PoWResponseUnion(),
) )
)
client_response_plaintext_bytes = ( client_response_plaintext_bytes = (
client_response_plaintext_proto.SerializeToString()) client_response_plaintext_proto.SerializeToString()
self.connection.write_int(4 + len(client_response_plaintext_bytes)) )
self.connection.write_int(
4 + len(client_response_plaintext_bytes)
)
self.connection.write(client_response_plaintext_bytes) self.connection.write(client_response_plaintext_bytes)
self.connection.flush() self.connection.flush()
try: try:
@@ -1354,7 +1408,8 @@ class Session(Closeable, MessageListener, SubListener):
scrap = self.connection.read(4) scrap = self.connection.read(4)
if len(scrap) == 4: if len(scrap) == 4:
payload = self.connection.read( payload = self.connection.read(
struct.unpack(">i", scrap)[0] - 4) struct.unpack(">i", scrap)[0] - 4
)
failed = Keyexchange.APResponseMessage() failed = Keyexchange.APResponseMessage()
failed.ParseFromString(payload) failed.ParseFromString(payload)
raise RuntimeError(failed) raise RuntimeError(failed)
@@ -1362,11 +1417,64 @@ class Session(Closeable, MessageListener, SubListener):
pass pass
finally: finally:
self.connection.set_timeout(0) self.connection.set_timeout(0)
buffer.seek(20) buffer.seek(20)
with self.__auth_lock: with self.__auth_lock:
self.cipher_pair = CipherPair(buffer.read(32), buffer.read(32)) self.cipher_pair = CipherPair(
buffer.read(32), buffer.read(32)
)
self.__auth_lock_bool = True self.__auth_lock_bool = True
self.logger.info("Connection successfully!") 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."
)
self.logger.error("%s Last error: %s", friendly_message, last_exc)
raise SystemExit(1)
def content_feeder(self) -> PlayableContentFeeder: def content_feeder(self) -> PlayableContentFeeder:
""" """ """ """
@@ -1662,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))
@@ -2182,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).
@@ -2229,7 +2340,15 @@ class Session(Closeable, MessageListener, SubListener):
:returns: Bytes data from socket :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: def read_int(self) -> int:
"""Read integer from socket """Read integer from socket
@@ -2238,7 +2357,12 @@ class Session(Closeable, MessageListener, SubListener):
:returns: integer from socket :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: def read_short(self) -> int:
"""Read short integer from socket """Read short integer from socket
@@ -2247,7 +2371,12 @@ class Session(Closeable, MessageListener, SubListener):
:returns: short integer from socket :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: def set_timeout(self, seconds: float) -> None:
"""Set socket's timeout """Set socket's timeout

145
librespot/oauth.py Normal file
View File

@@ -0,0 +1,145 @@
import base64
import logging
import random
import urllib
from hashlib import sha256
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse
from librespot.proto import Authentication_pb2 as Authentication
import requests
class OAuth:
logger = logging.getLogger("Librespot:OAuth")
__spotify_auth = "https://accounts.spotify.com/authorize?response_type=code&client_id=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=S256&scope=%s"
__scopes = ["app-remote-control", "playlist-modify", "playlist-modify-private", "playlist-modify-public", "playlist-read", "playlist-read-collaborative", "playlist-read-private", "streaming", "ugc-image-upload", "user-follow-modify", "user-follow-read", "user-library-modify", "user-library-read", "user-modify", "user-modify-playback-state", "user-modify-private", "user-personalized", "user-read-birthdate", "user-read-currently-playing", "user-read-email", "user-read-play-history", "user-read-playback-position", "user-read-playback-state", "user-read-private", "user-read-recently-played", "user-top-read"]
__spotify_token = "https://accounts.spotify.com/api/token"
__spotify_token_data = {"grant_type": "authorization_code", "client_id": "", "redirect_uri": "", "code": "", "code_verifier": ""}
__client_id = ""
__redirect_url = ""
__code_verifier = ""
__code = ""
__token = ""
__server = None
__oauth_url_callback = None
__success_page_content = None
def __init__(self, client_id, redirect_url, oauth_url_callback):
self.__client_id = client_id
self.__redirect_url = redirect_url
self.__oauth_url_callback = oauth_url_callback
def set_success_page_content(self, content):
self.__success_page_content = content
return self
def __generate_generate_code_verifier(self):
possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
verifier = ""
for i in range(128):
verifier += possible[random.randint(0, len(possible) - 1)]
return verifier
def __generate_code_challenge(self, code_verifier):
digest = sha256(code_verifier.encode('utf-8')).digest()
return base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')
def get_auth_url(self):
self.__code_verifier = self.__generate_generate_code_verifier()
auth_url = self.__spotify_auth % (self.__client_id, self.__redirect_url, self.__generate_code_challenge(self.__code_verifier), "+".join(self.__scopes))
if self.__oauth_url_callback:
self.__oauth_url_callback(auth_url)
return auth_url
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:
raise RuntimeError("You need to provide a code before!")
request_data = self.__spotify_token_data
request_data["client_id"] = self.__client_id
request_data["redirect_uri"] = self.__redirect_url
request_data["code"] = self.__code
request_data["code_verifier"] = self.__code_verifier
request = requests.post(
self.__spotify_token,
data=request_data,
)
if request.status_code != 200:
raise RuntimeError("Received status code %d: %s" % (request.status_code, request.reason))
self.__token = request.json()["access_token"]
def get_credentials(self):
if not self.__token:
raise RuntimeError("You need to request a token bore!")
return Authentication.LoginCredentials(
typ=Authentication.AuthenticationType.AUTHENTICATION_SPOTIFY_TOKEN,
auth_data=self.__token.encode("utf-8")
)
class CallbackServer(HTTPServer):
callback_path = None
def __init__(self, server_address, RequestHandlerClass, callback_path, set_code, success_page_content):
self.callback_path = callback_path
self.set_code = set_code
self.success_page_content = success_page_content
super().__init__(server_address, RequestHandlerClass)
class CallbackRequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
if(self.path.startswith(self.server.callback_path)):
query = urllib.parse.parse_qs(urlparse(self.path).query)
if not query.__contains__("code"):
self.send_response(400)
self.send_header('Content-type', 'text/html')
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 "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
# Suppress logging
def log_message(self, format, *args) -> None:
return
def __start_server(self):
try:
self.__server.handle_request()
except KeyboardInterrupt:
return
if not self.__code:
self.__start_server()
def run_callback_server(self):
url = urlparse(self.__redirect_url)
self.__server = self.CallbackServer(
(url.hostname, url.port),
self.CallbackRequestHandler,
url.path,
self.set_code,
self.__success_page_content,
)
logging.info("OAuth: Waiting for callback on %s", url.hostname + ":" + str(url.port))
self.__start_server()
def flow(self):
logging.info("OAuth: Visit in your browser and log in: %s ", self.get_auth_url())
self.run_callback_server()
self.request_token()
return self.get_credentials()
def __close(self):
if self.__server:
self.__server.shutdown()