Compare commits
18 Commits
8ea905e65f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc9c117450 | ||
|
|
0b36dd605c | ||
|
|
983f2a4ee7 | ||
|
|
ed478994d2 | ||
|
|
afed515855 | ||
|
|
82b4b40e6b | ||
|
|
36d08aae85 | ||
|
|
6d3b159099 | ||
|
|
aec696b489 | ||
|
|
5a790dc298 | ||
|
|
34fc626e1d | ||
|
|
77227b9e23 | ||
|
|
99ac394b8e | ||
|
|
30f7654301 | ||
|
|
8a7d0fa3c8 | ||
|
|
6c05cf4915 | ||
|
|
8d6cd7561c | ||
|
|
1bf4ec9859 |
@@ -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,11 +24,13 @@ 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).
|
||||
|
||||
@@ -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,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."
|
||||
)
|
||||
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:
|
||||
@@ -1290,6 +1307,14 @@ class Session(Closeable, MessageListener, SubListener):
|
||||
|
||||
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)
|
||||
@@ -1389,15 +1414,20 @@ class Session(Closeable, MessageListener, SubListener):
|
||||
failed.ParseFromString(payload)
|
||||
raise RuntimeError(failed)
|
||||
except socket.timeout:
|
||||
# Normal path: server did not send an error APResponse.
|
||||
pass
|
||||
finally:
|
||||
self.connection.set_timeout(0)
|
||||
|
||||
# If we reach here, the handshake succeeded.
|
||||
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) as exc:
|
||||
except (ConnectionResetError, OSError, struct.error, _message.DecodeError) as exc:
|
||||
last_exc = exc
|
||||
self.logger.warning(
|
||||
"Handshake attempt %d/%d failed: %s",
|
||||
@@ -1405,6 +1435,12 @@ class Session(Closeable, MessageListener, SubListener):
|
||||
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:
|
||||
@@ -1421,22 +1457,24 @@ class Session(Closeable, MessageListener, SubListener):
|
||||
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)
|
||||
|
||||
# All attempts failed: raise a clear error instead of crashing
|
||||
# with a low-level struct.error.
|
||||
raise RuntimeError(
|
||||
"Failed to connect to Spotify access point after "
|
||||
f"{max_attempts} attempts"
|
||||
) from last_exc
|
||||
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!")
|
||||
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:
|
||||
""" """
|
||||
@@ -1732,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))
|
||||
|
||||
@@ -2252,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).
|
||||
|
||||
@@ -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