This commit is contained in:
@@ -317,10 +317,83 @@ 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 _is_premium_user(self) -> bool:
|
||||||
|
"""Best-effort check whether the current Spotify account is Premium.
|
||||||
|
|
||||||
|
We rely on Session user attributes populated from the AP product_info packet.
|
||||||
|
Historically, the attribute named "type" contains values like "premium", "free",
|
||||||
|
"open", etc.
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
"""Request the audio key directly from Spotify (Premium accounts)."""
|
||||||
|
tries = 0
|
||||||
|
last_err: typing.Optional[Exception] = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
tries += 1
|
||||||
|
callback = AudioKeyManager.SyncCallback(self)
|
||||||
|
|
||||||
|
with self.__seq_holder_lock:
|
||||||
|
AudioKeyManager.__seq_holder += 1
|
||||||
|
seq = AudioKeyManager.__seq_holder
|
||||||
|
AudioKeyManager.__callbacks[seq] = callback
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = struct.pack(">i", seq) + gid + file_id
|
||||||
|
self.__session.send(Packet.Type.request_key, payload)
|
||||||
|
|
||||||
|
key = callback.wait_response()
|
||||||
|
if key is None:
|
||||||
|
raise RuntimeError("Audio key request failed")
|
||||||
|
return key
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
last_err = exc
|
||||||
|
self.logger.warning("Spotify audio key request failed (try %d): %s", tries, exc)
|
||||||
|
if not retry or tries >= 3:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
AudioKeyManager.__callbacks.pop(seq, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
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,
|
def get_audio_key(self,
|
||||||
gid: bytes,
|
gid: bytes,
|
||||||
file_id: bytes,
|
file_id: bytes,
|
||||||
retry: bool = True) -> bytes:
|
retry: bool = True) -> bytes:
|
||||||
|
# If the user is Premium, Spotify will return audio keys directly.
|
||||||
|
# In that case, do not use the SpotiClub API.
|
||||||
|
if self._is_premium_user():
|
||||||
|
return self._get_spotify_audio_key(gid, file_id, retry=retry)
|
||||||
|
|
||||||
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:
|
||||||
@@ -364,7 +437,15 @@ 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
|
||||||
|
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
|
# If another client instance is already active for this
|
||||||
@@ -423,6 +504,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(
|
||||||
|
|||||||
@@ -1414,14 +1414,10 @@ class Session(Closeable, MessageListener, SubListener):
|
|||||||
failed.ParseFromString(payload)
|
failed.ParseFromString(payload)
|
||||||
raise RuntimeError(failed)
|
raise RuntimeError(failed)
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
# Normal path: server did not send an error APResponse.
|
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
self.connection.set_timeout(0)
|
self.connection.set_timeout(0)
|
||||||
|
|
||||||
# If we reach here, the handshake succeeded; derive
|
|
||||||
# the Shannon cipher keys and mark the session as
|
|
||||||
# connected.
|
|
||||||
buffer.seek(20)
|
buffer.seek(20)
|
||||||
with self.__auth_lock:
|
with self.__auth_lock:
|
||||||
self.cipher_pair = CipherPair(
|
self.cipher_pair = CipherPair(
|
||||||
@@ -1470,10 +1466,6 @@ class Session(Closeable, MessageListener, SubListener):
|
|||||||
)
|
)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
# All attempts failed: log a clear, user-friendly error and
|
|
||||||
# terminate the process without a traceback. This prevents
|
|
||||||
# callers (like Zotify) from incorrectly retrying OAuth when
|
|
||||||
# the failure is purely network-related.
|
|
||||||
friendly_message = (
|
friendly_message = (
|
||||||
"Failed to connect to Spotify after "
|
"Failed to connect to Spotify after "
|
||||||
f"{max_attempts} attempts. "
|
f"{max_attempts} attempts. "
|
||||||
@@ -1482,8 +1474,6 @@ class Session(Closeable, MessageListener, SubListener):
|
|||||||
"This is usually a network or firewall issue."
|
"This is usually a network or firewall issue."
|
||||||
)
|
)
|
||||||
self.logger.error("%s Last error: %s", friendly_message, last_exc)
|
self.logger.error("%s Last error: %s", friendly_message, last_exc)
|
||||||
# Exit with a non-zero status but no stack trace; the
|
|
||||||
# logger message above provides the user-facing explanation.
|
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
def content_feeder(self) -> PlayableContentFeeder:
|
def content_feeder(self) -> PlayableContentFeeder:
|
||||||
|
|||||||
Reference in New Issue
Block a user