Compare commits
22 Commits
f2c6a5ec0d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc9c117450 | ||
|
|
0b36dd605c | ||
|
|
983f2a4ee7 | ||
|
|
ed478994d2 | ||
|
|
afed515855 | ||
|
|
82b4b40e6b | ||
|
|
36d08aae85 | ||
|
|
6d3b159099 | ||
|
|
aec696b489 | ||
|
|
5a790dc298 | ||
|
|
34fc626e1d | ||
|
|
77227b9e23 | ||
|
|
99ac394b8e | ||
|
|
30f7654301 | ||
|
|
8a7d0fa3c8 | ||
|
|
6c05cf4915 | ||
|
|
8d6cd7561c | ||
|
|
1bf4ec9859 | ||
|
|
8ea905e65f | ||
|
|
f16d4cc160 | ||
|
|
a1ca15f109 | ||
|
|
1480047ecb |
@@ -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,
|
||||
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) -> 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,29 +530,27 @@ 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):
|
||||
if 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")
|
||||
if isinstance(new_serial, str) and new_serial:
|
||||
@@ -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(
|
||||
|
||||
@@ -33,6 +33,7 @@ from Cryptodome.Signature import PKCS1_v1_5
|
||||
|
||||
from librespot import util
|
||||
from librespot import Version
|
||||
from librespot.oauth import OAuth
|
||||
from librespot.audio import AudioKeyManager
|
||||
from librespot.audio import CdnManager
|
||||
from librespot.audio import PlayableContentFeeder
|
||||
@@ -599,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."
|
||||
)
|
||||
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:
|
||||
@@ -1279,7 +1297,24 @@ class Session(Closeable, MessageListener, SubListener):
|
||||
self.__inner.device_id))
|
||||
|
||||
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()
|
||||
# Send ClientHello
|
||||
nonce = Random.get_random_bytes(0x10)
|
||||
@@ -1291,7 +1326,10 @@ class Session(Closeable, MessageListener, SubListener):
|
||||
],
|
||||
login_crypto_hello=Keyexchange.LoginCryptoHelloUnion(
|
||||
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",
|
||||
)
|
||||
client_hello_bytes = client_hello_proto.SerializeToString()
|
||||
@@ -1306,20 +1344,29 @@ class Session(Closeable, MessageListener, SubListener):
|
||||
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)
|
||||
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)
|
||||
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))
|
||||
diffie_hellman.gs
|
||||
)
|
||||
)
|
||||
# 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)
|
||||
sha1 = SHA1.new()
|
||||
sha1.update(ap_response_message_proto.challenge.login_crypto_challenge.
|
||||
diffie_hellman.gs)
|
||||
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.
|
||||
@@ -1337,16 +1384,23 @@ class Session(Closeable, MessageListener, SubListener):
|
||||
mac = HMAC.new(buffer.read(20), digestmod=SHA1)
|
||||
mac.update(acc.read())
|
||||
challenge = mac.digest()
|
||||
client_response_plaintext_proto = Keyexchange.ClientResponsePlaintext(
|
||||
client_response_plaintext_proto = (
|
||||
Keyexchange.ClientResponsePlaintext(
|
||||
crypto_response=Keyexchange.CryptoResponseUnion(),
|
||||
login_crypto_response=Keyexchange.LoginCryptoResponseUnion(
|
||||
diffie_hellman=Keyexchange.LoginCryptoDiffieHellmanResponse(
|
||||
hmac=challenge)),
|
||||
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))
|
||||
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:
|
||||
@@ -1354,7 +1408,8 @@ class Session(Closeable, MessageListener, SubListener):
|
||||
scrap = self.connection.read(4)
|
||||
if len(scrap) == 4:
|
||||
payload = self.connection.read(
|
||||
struct.unpack(">i", scrap)[0] - 4)
|
||||
struct.unpack(">i", scrap)[0] - 4
|
||||
)
|
||||
failed = Keyexchange.APResponseMessage()
|
||||
failed.ParseFromString(payload)
|
||||
raise RuntimeError(failed)
|
||||
@@ -1362,11 +1417,64 @@ class Session(Closeable, MessageListener, SubListener):
|
||||
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.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."
|
||||
)
|
||||
self.logger.error("%s Last error: %s", friendly_message, last_exc)
|
||||
raise SystemExit(1)
|
||||
|
||||
def content_feeder(self) -> PlayableContentFeeder:
|
||||
""" """
|
||||
@@ -1662,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))
|
||||
|
||||
@@ -2182,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).
|
||||
@@ -2229,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
|
||||
@@ -2238,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
|
||||
@@ -2247,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
|
||||
|
||||
145
librespot/oauth.py
Normal file
145
librespot/oauth.py
Normal 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()
|
||||
|
||||
Reference in New Issue
Block a user