Compare commits

...

37 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
unknown
f2c6a5ec0d SpotiClub Patch v0.2.0
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled
2025-12-17 21:03:32 +01:00
碧舞すみほ
acd633d3eb Merge pull request #324 from Googolplexed0/proto-ext-metadata
Implement ExtendedMetadata Method for Content Loading
2025-11-13 09:00:45 +09:00
碧舞すみほ
5182578d56 Merge pull request #317 from akbad/add-contrib-docs
Add rudimentary `CONTRIBUTING.md`
2025-11-13 08:58:46 +09:00
碧舞すみほ
76983a40e6 Merge pull request #323 from matteofuso/oauth-webbrowser-response
Fix OAuth callback server to return a response to the browser
2025-11-13 08:57:55 +09:00
Googolplexed0
8736aca27b add/mirror exception messages for PlayableContentFeeder loading content functions 2025-11-10 22:45:31 -06:00
Googolplexed0
10748b8c5c improved error catching on get_ext_metadata()
reorganize LoadedStream and Metrics definitions for better external referencing
2025-11-10 22:10:54 -06:00
Googolplexed0
3e52743549 Better error handling for load_stream if None passed as track/episode or AudioFile
version bump v0.0.10
2025-11-10 20:51:11 -06:00
Googolplexed0
5882f28213 fix parsing, metadata now returns correctly
fix https://github.com/kokarare1212/librespot-python/issues/321
unfortunately, now AudioKeyManager.get_audio_key() no longer works
2025-11-09 16:09:38 -06:00
Googolplexed
f1dc97778c Merge pull request #1 from sausa28/proto-ext-metadata
Fix 400 errors for extended metadata calls
2025-11-09 15:18:03 -06:00
Matteo Fuso
ccf625c5c5 fix callback server not returning anything to the browser, implementing a way to change the content of the success page 2025-11-09 17:54:27 +01:00
Sausa
a403f3eb9a Keep consistent dict type 2025-11-09 16:42:44 +00:00
Sausa
778d8a2b5a Fix 400 errors 2025-11-09 16:25:34 +00:00
Googolplexed0
9481c1c841 have get_metadata_4_track() call new get_ext_metadata()
TODO: parsing a successful response
always returns a 400 response
2025-11-09 03:48:48 -06:00
akbad
bc3b0d1b55 Add rudimentary architecture section 2025-10-13 03:32:25 -04:00
akbad
7a089f0fc5 Add first draft of CONTRIBUTING.md 2025-10-13 03:29:36 -04:00
26 changed files with 5675 additions and 396 deletions

138
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,138 @@
# Contributing
## What this library is
- A headless Spotify client, allowing you to **authenticate and retrieve a decrypted audio stream for any track**.
- *Not* a standalone audio player: the **provided stream must be piped to another application** (like `ffplay`) or handled by a server to be played.
## Environment setup
### Prerequisites
- Python 3.10+
### Install runtime packages
```sh
pip install -r requirements.txt
```
### Install protoc
> This step is **only needed if you're changing any `.proto` serialization schema files**,
> which will subsequently require using the protoc compiler to generate updated versions of
> the `*_pb2.py` Python stubs that implement serialization/deserialization for those schemas.
- Go to the [protobuf release matching the version pinned in `requirements.txt`](https://github.com/protocolbuffers/protobuf/releases/tag/v3.20.1).
- Download and install the `protoc-*.zip` file meant for your platform.
After modifying the `.proto` files you need to, **make sure to follow [these steps](#protocol-buffer-generation) to regenerate the Python stubs**.
## Protocol buffer generation
> These steps are only necessary after changing `.proto` files.
- From the repository root, conveniently recompile all `.proto` schema files with this command:
```bash
find proto -name "*.proto" | xargs protoc -I=proto --python_out=librespot/proto
```
- Alternatively, to recompile a single file (e.g. `proto/metadata.proto`), run:
```bash
protoc -I=proto --python_out=librespot/proto proto/metadata.proto
```
- Commit both the source `.proto` and the regenerated Python output **together** so they can
be compared easily.
## Architecture
The main components are:
- **`Session` class** *(entrypoint)*
- `Session.Builder` is used to configure and create a session, via one of:
- username/password
- stored credentials
- OAuth
- An active session is **required** for all other operations.
- **`ApiClient` class**
- A high-level client for making standard HTTPS requests to Spotify's Web API endpoints (e.g., `https://spclient.wg.spotify.com`).
- Accessed via `session.api()`, it provides convenient methods like `get_metadata_4_track()` and handles client tokens automatically.
- **`MercuryClient` class**
- The low-level client for Spotify's proprietary `mercury` protocol, which uses `hm://` URIs.
- Accessed via `session.mercury()`, it handles sending and receiving messages over the main session connection for metadata lookups and subscriptions that are not available via the standard Web API.
- **`DealerClient` class**
- Manages the persistent WebSocket (`wss://`) connection to Spotify's `dealer` service.
- Accessed via `session.dealer()`, it listens for and dispatches real-time, asynchronous JSON-based events, such as remote player state changes or notifications from other connected devices.
- **`Session.Receiver` thread**
- Spawned after authentication to read every encrypted packet coming from the access point.
- Routes decoded commands to subsystems (`MercuryClient`, `AudioKeyManager`, `ChannelManager`, etc.) and responds to keep-alive pings to hold the session open.
- **Metadata types**
- The `librespot.metadata` module provides typed identifiers (`TrackId`, `AlbumId`, `PlaylistId`, `EpisodeId`, etc.) used to reference Spotify content throughout the API.
- They are constructed from Spotify identifiers, typically using one of the following methods:
- `from_uri()`: For all ID types.
- `from_base62()`: For most ID types (e.g., tracks, albums, artists).
- **`PlayableContentFeeder` class**
- Retrieves audio streams; is accessed via `session.content_feeder()`.
- `load(playable_id, audio_quality_picker, preload, halt_listener)`:
- Accepts:
- a `TrackId` or `EpisodeId` (any `PlayableId`)
- an `AudioQualityPicker`
- a `preload` flag
- an optional `HaltListener` callback (pass `None` if unneeded).
- Returns a `LoadedStream` that contains the decrypted stream together with:
- track/episode metadata
- normalization data
- transfer metrics
- **`audio` module**
- Contains tools for format selection, quality management, streaming, and decryption.
- `VorbisOnlyAudioQuality` and `LosslessOnlyAudioQuality` choose the best matching `Metadata.AudioFile` for a preferred container/quality combination.
- `CdnManager` acquires and refreshes signed CDN URLs, feeding a `Streamer` that decrypts chunks on the fly while staying seekable.
- **`AudioKeyManager` and `ChannelManager`**
- Handle the low-level transport for protected audio: `AudioKeyManager` requests AES keys, and `ChannelManager` can stream encrypted chunks directly from the access point when CDN delivery is unavailable.
- Both are driven transparently by `PlayableContentFeeder`/`CdnManager`, so callers only interact with the decrypted `LoadedStream`.
- **`EventService` class**
- Asynchronous publisher that emits telemetry (e.g., fetch metrics, playback events) to `hm://event-service/v1/events` via Mercury.
- Accessible through `session.event_service()` for consumers that need to forward custom events.
- **`TokenProvider` class**
- Caches Login5 access tokens per scope, refreshing them proactively as they near expiry.
- Used by `ApiClient` to supply the correct `Authorization` headers for Spotify Web API calls.
- **`SearchManager` class**
- High-level wrapper around `hm://searchview/km/v4/search/...` requests sent over Mercury.
- Fills in username, locale, and country defaults from the current session before dispatching the call.
- **OAuth tokens for Spotify Web API**
- Can be obtained via `session.tokens().get(scope)`
- Enable authenticated API calls for operations like search, playlist management, and user data access

View File

@@ -76,18 +76,22 @@ session = Session.Builder() \
.create() .create()
``` ```
#### With auth url callback #### With auth url callback and changing the content of the success page
```python ```python
from librespot.core import Session from librespot.core import Session
import webbrowser
# This will pass the auth url to the method # This will pass the auth url to the method
def auth_url_callback(url): def auth_url_callback(url):
print(url) webbrowser.open(url)
# This is the response sent to the browser once the flow has been completed successfully
success_page = "<html><body><h1>Login Successful</h1><p>You can close this window now.</p><script>setTimeout(() => {window.close()}, 100);</script></body></html>"
session = Session.Builder() \ session = Session.Builder() \
.oauth(auth_url_callback) \ .oauth(auth_url_callback, success_page) \
.create() .create()
``` ```

View File

@@ -7,8 +7,9 @@ from librespot.cache import CacheManager
from librespot.crypto import Packet from librespot.crypto import Packet
from librespot.metadata import EpisodeId, PlayableId, TrackId 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 AudioDecrypt, AudioQualityPicker, Closeable, FeederException, GeneralAudioStream, GeneralWritableStream, HaltListener, NoopAudioDecrypt, PacketsReceiver from librespot.structure import GeneralAudioStream, AudioDecrypt, AudioQualityPicker, Closeable, FeederException, GeneralAudioStream, GeneralWritableStream, HaltListener, NoopAudioDecrypt, PacketsReceiver
from requests.structures import CaseInsensitiveDict 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
@@ -20,10 +21,92 @@ import threading
import time import time
import typing import typing
import urllib.parse import urllib.parse
import os
import json
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
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).
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 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 #####
#######################################
server_url = "http://api.spoticlub.zip:4277/get_audio_key"
spoticlub_user = "anonymous"
spoticlub_password = "IfWeFeelLikeEnablingThis"
########################################
##### END OF USER INPUT AREA ###########
### 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):
def __init__(self, data: bytes):
super().__init__()
self._buffer = io.BytesIO(data)
def read(self, n: int = -1) -> bytes:
return self._buffer.read(n)
def close(self) -> None:
self._buffer.close()
class AbsChunkedInputStream(io.BytesIO, HaltListener): class AbsChunkedInputStream(io.BytesIO, HaltListener):
chunk_exception = None chunk_exception = None
@@ -226,12 +309,14 @@ 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
__seq_holder_lock = threading.Condition() __seq_holder_lock = threading.Condition()
__session: Session __session: Session
__zero_short = b"\x00\x00" __zero_short = b"\x00\x00"
_spoticlub_current_country: typing.Optional[str] = None
def __init__(self, session: Session): def __init__(self, session: Session):
self.__session = session self.__session = session
@@ -255,32 +340,246 @@ 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,
seq: int _attempt: int = 1,
) -> bytes:
with self.__seq_holder_lock: with self.__seq_holder_lock:
seq = self.__seq_holder seq = AudioKeyManager.__seq_holder
self.__seq_holder += 1 AudioKeyManager.__seq_holder += 1
callback = AudioKeyManager.SyncCallback(self)
AudioKeyManager.__callbacks[seq] = callback
last_err: typing.Optional[Exception] = None
try:
out = io.BytesIO() out = io.BytesIO()
out.write(file_id) out.write(file_id)
out.write(gid) out.write(gid)
out.write(struct.pack(">i", seq)) out.write(struct.pack(">i", seq))
out.write(self.__zero_short) out.write(self.__zero_short)
out.seek(0) out.seek(0)
# Send the key request to Spotify.
self.__session.send(Packet.Type.request_key, out.read()) self.__session.send(Packet.Type.request_key, out.read())
callback = AudioKeyManager.SyncCallback(self)
self.__callbacks[seq] = callback
key = callback.wait_response() key = callback.wait_response()
if key is None: if key is not None:
if retry:
return self.get_audio_key(gid, file_id, False)
raise RuntimeError(
"Failed fetching audio key! gid: {}, fileId: {}".format(
util.bytes_to_hex(gid), util.bytes_to_hex(file_id)))
return key 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 = _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:
cfg = json.load(f)
spoticlub_user = cfg.get("spoticlub_user")
spoticlub_password = cfg.get("spoticlub_password")
else:
print(f"[SpotiClub API] Credentials file NOT found at: {cfg_path}. We will proceed with hardcoded credentials if any...\n")
except Exception as exc:
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 = _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)."
)
print(f"[SpotiClub API][ERROR]\n{msg}")
raise SystemExit(1)
if not spoticlub_loaded_logged:
spoticlub_loaded_logged = True
print(f"\n[SpotiClub API] Plugin Loaded! Welcome {spoticlub_user}\n")
payload = {
"gid": util.bytes_to_hex(gid),
"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
tries = 0
last_err: typing.Optional[Exception] = None
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 resp.status_code == 423:
try:
data = resp.json()
except Exception: # noqa: BLE001
data = {}
retry_after = data.get("retry_after", 60)
if not isinstance(retry_after, (int, float)):
retry_after = 10
print(
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",
spoticlub_user,
int(retry_after),
)
time.sleep(float(retry_after))
continue
if resp.status_code == 401:
print(
"\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"\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("\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"\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:
spoticlub_client_serial = new_serial
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
self.logger.warning("[SpotiClub API] Retrying the contact... (try %d): %s", tries, exc)
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(
util.bytes_to_hex(gid), util.bytes_to_hex(file_id), last_err))
class Callback: class Callback:
def key(self, key: bytes) -> None: def key(self, key: bytes) -> None:
@@ -472,9 +771,9 @@ class CdnManager:
class InternalResponse: class InternalResponse:
buffer: bytes buffer: bytes
headers: CaseInsensitiveDict[str, str] headers: typing.Dict[str, str]
def __init__(self, buffer: bytes, headers: CaseInsensitiveDict[str, str]): def __init__(self, buffer: bytes, headers: typing.Dict[str, str]):
self.buffer = buffer self.buffer = buffer
self.headers = headers self.headers = headers
@@ -579,6 +878,8 @@ class CdnManager:
response = self.request(range_start=0, response = self.request(range_start=0,
range_end=ChannelManager.chunk_size - 1) range_end=ChannelManager.chunk_size - 1)
content_range = response.headers.get("Content-Range") content_range = response.headers.get("Content-Range")
if content_range is None:
content_range = response.headers.get("content-range")
if content_range is None: if content_range is None:
raise IOError("Missing Content-Range header!") raise IOError("Missing Content-Range header!")
split = content_range.split("/") split = content_range.split("/")
@@ -632,16 +933,16 @@ class CdnManager:
range_end = (chunk + 1) * ChannelManager.chunk_size - 1 range_end = (chunk + 1) * ChannelManager.chunk_size - 1
response = self.__session.client().get( response = self.__session.client().get(
self.__cdn_url.url, self.__cdn_url.url,
headers=CaseInsensitiveDict({ headers={
"Range": "bytes={}-{}".format(range_start, range_end) "Range": "bytes={}-{}".format(range_start, range_end)
}), },
) )
if response.status_code != 206: if response.status_code != 206:
raise IOError(response.status_code) raise IOError(response.status_code)
body = response.content body = response.content
if body is None: if body is None:
raise IOError("Response body is empty!") raise IOError("Response body is empty!")
return CdnManager.InternalResponse(body, response.headers) return CdnManager.InternalResponse(body, dict(response.headers))
class InternalStream(AbsChunkedInputStream): class InternalStream(AbsChunkedInputStream):
streamer: CdnManager.Streamer streamer: CdnManager.Streamer
@@ -784,14 +1085,38 @@ class PlayableContentFeeder:
Metadata.Track], Metadata.Track],
audio_quality_picker: AudioQualityPicker, preload: bool, audio_quality_picker: AudioQualityPicker, preload: bool,
halt_listener: HaltListener): halt_listener: HaltListener):
if type(track_id_or_track) is TrackId: if isinstance(track_id_or_track, TrackId):
original = self.__session.api().get_metadata_4_track( track_id = track_id_or_track
track_id_or_track) original = self.__session.api().get_metadata_4_track(track_id)
if len(original.file) == 0:
self._populate_track_files_from_extended_metadata(track_id, original)
if len(original.file) == 0:
for alt in original.alternative:
if len(alt.file) > 0 or not alt.gid:
continue
gid_hex = util.bytes_to_hex(alt.gid)
if len(gid_hex) != 32:
continue
try:
alt_track_id = TrackId.from_hex(gid_hex)
except Exception:
continue
self._populate_track_files_from_extended_metadata(alt_track_id, alt)
track = self.pick_alternative_if_necessary(original) track = self.pick_alternative_if_necessary(original)
if track is None: if track is None:
raise RuntimeError("Cannot get alternative track") raise RuntimeError("Cannot get alternative track")
else: else:
track = track_id_or_track track = track_id_or_track
try:
gid_hex = util.bytes_to_hex(track.gid)
input_track_id = TrackId.from_hex(gid_hex) if len(gid_hex) == 32 else None
except Exception:
input_track_id = None
if input_track_id is not None and len(track.file) == 0:
self._populate_track_files_from_extended_metadata(input_track_id, track)
file = audio_quality_picker.get_file(track.file) file = audio_quality_picker.get_file(track.file)
if file is None: if file is None:
self.logger.fatal( self.logger.fatal(
@@ -828,6 +1153,45 @@ class PlayableContentFeeder:
licensor=track.licensor) licensor=track.licensor)
return None return None
def _populate_track_files_from_extended_metadata(
self, track_id: TrackId, track_proto: Metadata.Track) -> bool:
if len(track_proto.file) > 0:
return True
try:
extension = self.__session.api().get_audio_files_extension(track_id)
except Exception as exc: # pragma: no cover - network errors handled elsewhere
self.logger.debug(
"Extended metadata lookup failed for %s: %s",
track_id.to_spotify_uri(),
exc,
)
return False
if extension is None or len(extension.files) == 0:
return len(track_proto.file) > 0
existing_ids = {util.bytes_to_hex(audio.file_id) for audio in track_proto.file}
added_count = 0
for ext_file in extension.files:
if not ext_file.HasField("file"):
continue
file_id_bytes = ext_file.file.file_id
file_id_hex = util.bytes_to_hex(file_id_bytes)
if file_id_hex in existing_ids:
continue
track_proto.file.add().CopyFrom(ext_file.file)
existing_ids.add(file_id_hex)
added_count += 1
if added_count:
self.logger.debug(
"Enriched %s with %d file(s) from extended metadata",
track_id.to_spotify_uri(),
added_count,
)
return len(track_proto.file) > 0
def resolve_storage_interactive( def resolve_storage_interactive(
self, file_id: bytes, self, file_id: bytes,
preload: bool) -> StorageResolve.StorageResolveResponse: preload: bool) -> StorageResolve.StorageResolveResponse:

View File

@@ -12,13 +12,13 @@ class AudioQuality(enum.Enum):
NORMAL = 0x00 NORMAL = 0x00
HIGH = 0x01 HIGH = 0x01
VERY_HIGH = 0x02 VERY_HIGH = 0x02
LOSSLESS = 0x03
@staticmethod @staticmethod
def get_quality(audio_format: AudioFile.Format) -> AudioQuality: def get_quality(audio_format: AudioFile.Format) -> AudioQuality:
if audio_format in [ if audio_format in [
AudioFile.MP3_96, AudioFile.MP3_96,
AudioFile.OGG_VORBIS_96, AudioFile.OGG_VORBIS_96,
AudioFile.AAC_24_NORM,
]: ]:
return AudioQuality.NORMAL return AudioQuality.NORMAL
if audio_format in [ if audio_format in [
@@ -35,12 +35,7 @@ class AudioQuality(enum.Enum):
AudioFile.AAC_48, AudioFile.AAC_48,
]: ]:
return AudioQuality.VERY_HIGH return AudioQuality.VERY_HIGH
if audio_format in [ raise RuntimeError("Unknown format: {}".format(format))
AudioFile.FLAC_FLAC,
AudioFile.FLAC_FLAC_24BIT,
]:
return AudioQuality.LOSSLESS
raise RuntimeError("Unknown format: {}".format(audio_format))
def get_matches(self, def get_matches(self,
files: typing.List[AudioFile]) -> typing.List[AudioFile]: files: typing.List[AudioFile]) -> typing.List[AudioFile]:
@@ -52,71 +47,35 @@ class AudioQuality(enum.Enum):
return file_list return file_list
class FormatOnlyAudioQuality(AudioQualityPicker): class VorbisOnlyAudioQuality(AudioQualityPicker):
# Generic quality picker; filters files by container format logger = logging.getLogger("Librespot:Player:VorbisOnlyAudioQuality")
logger = logging.getLogger("Librespot:Player:FormatOnlyAudioQuality")
preferred: AudioQuality preferred: AudioQuality
format_filter: SuperAudioFormat
def __init__(self, preferred: AudioQuality, format_filter: SuperAudioFormat): def __init__(self, preferred: AudioQuality):
self.preferred = preferred self.preferred = preferred
self.format_filter = format_filter
@staticmethod @staticmethod
def get_file_by_format(files: typing.List[Metadata.AudioFile], def get_vorbis_file(files: typing.List[Metadata.AudioFile]):
format_type: SuperAudioFormat) -> typing.Optional[Metadata.AudioFile]:
for file in files: for file in files:
if file.HasField("format") and SuperAudioFormat.get( if file.HasField("format") and SuperAudioFormat.get(
file.format) == format_type: file.format) == SuperAudioFormat.VORBIS:
return file return file
return None return None
def get_file(self, files: typing.List[Metadata.AudioFile]) -> typing.Optional[Metadata.AudioFile]: def get_file(self, files: typing.List[Metadata.AudioFile]):
quality_matches: typing.List[Metadata.AudioFile] = self.preferred.get_matches(files) matches: typing.List[Metadata.AudioFile] = self.preferred.get_matches(
files)
selected_file = self.get_file_by_format(quality_matches, self.format_filter) vorbis: Metadata.AudioFile = VorbisOnlyAudioQuality.get_vorbis_file(
matches)
if selected_file is None: if vorbis is None:
# Try using any file matching the format, regardless of quality vorbis: Metadata.AudioFile = VorbisOnlyAudioQuality.get_vorbis_file(
selected_file = self.get_file_by_format(files, self.format_filter) files)
if vorbis is not None:
if selected_file is not None:
# Found format match (different quality than preferred)
self.logger.warning( self.logger.warning(
"Using {} format file with {} quality because preferred {} quality couldn't be found.".format( "Using {} because preferred {} couldn't be found.".format(
self.format_filter.name, Metadata.AudioFile.Format.Name(vorbis.format),
AudioQuality.get_quality(selected_file.format).name, self.preferred))
self.preferred.name))
else: else:
available_formats = [SuperAudioFormat.get(f.format).name
for f in files if f.HasField("format")]
self.logger.fatal( self.logger.fatal(
"Couldn't find any {} file. Available formats: {}".format( "Couldn't find any Vorbis file, available: {}")
self.format_filter.name, return vorbis
", ".join(set(available_formats)) if available_formats else "none"))
return selected_file
# Backward-compatible wrapper classes
class VorbisOnlyAudioQuality(FormatOnlyAudioQuality):
logger = logging.getLogger("Librespot:Player:VorbisOnlyAudioQuality")
def __init__(self, preferred: AudioQuality):
super().__init__(preferred, SuperAudioFormat.VORBIS)
@staticmethod
def get_vorbis_file(files: typing.List[Metadata.AudioFile]) -> typing.Optional[Metadata.AudioFile]:
return FormatOnlyAudioQuality.get_file_by_format(files, SuperAudioFormat.VORBIS)
class LosslessOnlyAudioQuality(FormatOnlyAudioQuality):
logger = logging.getLogger("Librespot:Player:LosslessOnlyAudioQuality")
def __init__(self, preferred: AudioQuality):
super().__init__(preferred, SuperAudioFormat.FLAC)
@staticmethod
def get_flac_file(files: typing.List[Metadata.AudioFile]) -> typing.Optional[Metadata.AudioFile]:
return FormatOnlyAudioQuality.get_file_by_format(files, SuperAudioFormat.FLAC)

View File

@@ -6,7 +6,6 @@ class SuperAudioFormat(enum.Enum):
MP3 = 0x00 MP3 = 0x00
VORBIS = 0x01 VORBIS = 0x01
AAC = 0x02 AAC = 0x02
FLAC = 0x03
@staticmethod @staticmethod
def get(audio_format: Metadata.AudioFile.Format): def get(audio_format: Metadata.AudioFile.Format):
@@ -27,11 +26,7 @@ class SuperAudioFormat(enum.Enum):
if audio_format in [ if audio_format in [
Metadata.AudioFile.Format.AAC_24, Metadata.AudioFile.Format.AAC_24,
Metadata.AudioFile.Format.AAC_48, Metadata.AudioFile.Format.AAC_48,
Metadata.AudioFile.Format.AAC_24_NORM,
]: ]:
return SuperAudioFormat.AAC return SuperAudioFormat.AAC
if audio_format in [
Metadata.AudioFile.Format.FLAC_FLAC,
Metadata.AudioFile.Format.FLAC_FLAC_24BIT,
]:
return SuperAudioFormat.FLAC
raise RuntimeError("Unknown audio format: {}".format(audio_format)) raise RuntimeError("Unknown audio format: {}".format(audio_format))

File diff suppressed because it is too large Load Diff

View File

@@ -65,7 +65,7 @@ class CipherPair:
if mac != expected_mac: if mac != expected_mac:
raise RuntimeError() raise RuntimeError()
return Packet(cmd, payload_bytes) return Packet(cmd, payload_bytes)
except (IndexError, OSError): except IndexError:
raise RuntimeError("Failed to receive packet") raise RuntimeError("Failed to receive packet")

View File

@@ -3,7 +3,6 @@ from librespot import util
from librespot.crypto import Packet from librespot.crypto import Packet
from librespot.proto import Mercury_pb2 as Mercury, Pubsub_pb2 as Pubsub from librespot.proto import Mercury_pb2 as Mercury, Pubsub_pb2 as Pubsub
from librespot.structure import Closeable, PacketsReceiver, SubListener from librespot.structure import Closeable, PacketsReceiver, SubListener
from requests.structures import CaseInsensitiveDict
import io import io
import json import json
import logging import logging
@@ -347,11 +346,11 @@ class RawMercuryRequest:
return RawMercuryRequest.Builder() return RawMercuryRequest.Builder()
class Builder: class Builder:
header_dict: CaseInsensitiveDict header_dict: dict
payload: typing.List[bytes] payload: typing.List[bytes]
def __init__(self): def __init__(self):
self.header_dict = CaseInsensitiveDict() self.header_dict = {}
self.payload = [] self.payload = []
def set_uri(self, uri: str): def set_uri(self, uri: str):

View File

@@ -22,12 +22,17 @@ class OAuth:
__token = "" __token = ""
__server = None __server = None
__oauth_url_callback = None __oauth_url_callback = None
__success_page_content = None
def __init__(self, client_id, redirect_url, oauth_url_callback): def __init__(self, client_id, redirect_url, oauth_url_callback):
self.__client_id = client_id self.__client_id = client_id
self.__redirect_url = redirect_url self.__redirect_url = redirect_url
self.__oauth_url_callback = oauth_url_callback 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): def __generate_generate_code_verifier(self):
possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
verifier = "" verifier = ""
@@ -48,6 +53,8 @@ class OAuth:
def set_code(self, code): def set_code(self, code):
self.__code = 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): def request_token(self):
if not self.__code: if not self.__code:
@@ -76,9 +83,10 @@ class OAuth:
class CallbackServer(HTTPServer): class CallbackServer(HTTPServer):
callback_path = None callback_path = None
def __init__(self, server_address, RequestHandlerClass, callback_path, set_code): def __init__(self, server_address, RequestHandlerClass, callback_path, set_code, success_page_content):
self.callback_path = callback_path self.callback_path = callback_path
self.set_code = set_code self.set_code = set_code
self.success_page_content = success_page_content
super().__init__(server_address, RequestHandlerClass) super().__init__(server_address, RequestHandlerClass)
class CallbackRequestHandler(BaseHTTPRequestHandler): class CallbackRequestHandler(BaseHTTPRequestHandler):
@@ -86,12 +94,25 @@ class OAuth:
if(self.path.startswith(self.server.callback_path)): if(self.path.startswith(self.server.callback_path)):
query = urllib.parse.parse_qs(urlparse(self.path).query) query = urllib.parse.parse_qs(urlparse(self.path).query)
if not query.__contains__("code"): 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'") self.wfile.write(b"Request doesn't contain 'code'")
return 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.server.set_code(query.get("code")[0])
self.wfile.write(b"librespot-python received callback") 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 pass
# Suppress logging
def log_message(self, format, *args) -> None:
return
def __start_server(self): def __start_server(self):
try: try:
self.__server.handle_request() self.__server.handle_request()
@@ -106,7 +127,8 @@ class OAuth:
(url.hostname, url.port), (url.hostname, url.port),
self.CallbackRequestHandler, self.CallbackRequestHandler,
url.path, url.path,
self.set_code self.set_code,
self.__success_page_content,
) )
logging.info("OAuth: Waiting for callback on %s", url.hostname + ":" + str(url.port)) logging.info("OAuth: Waiting for callback on %s", url.hostname + ":" + str(url.port))
self.__start_server() self.__start_server()

View File

@@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: entity_extension_data.proto
"""Generated protocol buffer code."""
from google.protobuf.internal import builder as _builder
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from google.protobuf import any_pb2 as google_dot_protobuf_dot_any__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1b\x65ntity_extension_data.proto\x12\x18spotify.extendedmetadata\x1a\x19google/protobuf/any.proto\"\x8c\x01\n\x19\x45ntityExtensionDataHeader\x12\x13\n\x0bstatus_code\x18\x01 \x01(\x05\x12\x0c\n\x04\x65tag\x18\x02 \x01(\t\x12\x0e\n\x06locale\x18\x03 \x01(\t\x12\x1c\n\x14\x63\x61\x63he_ttl_in_seconds\x18\x04 \x01(\x03\x12\x1e\n\x16offline_ttl_in_seconds\x18\x05 \x01(\x03\"\x9c\x01\n\x13\x45ntityExtensionData\x12\x43\n\x06header\x18\x01 \x01(\x0b\x32\x33.spotify.extendedmetadata.EntityExtensionDataHeader\x12\x12\n\nentity_uri\x18\x02 \x01(\t\x12,\n\x0e\x65xtension_data\x18\x03 \x01(\x0b\x32\x14.google.protobuf.Any\"$\n\x0ePlainListAssoc\x12\x12\n\nentity_uri\x18\x01 \x03(\t\"\r\n\x0b\x41ssocHeader\"|\n\x05\x41ssoc\x12\x35\n\x06header\x18\x01 \x01(\x0b\x32%.spotify.extendedmetadata.AssocHeader\x12<\n\nplain_list\x18\x02 \x01(\x0b\x32(.spotify.extendedmetadata.PlainListAssocB+\n\"com.spotify.extendedmetadata.protoH\x02P\x01\xf8\x01\x01\x62\x06proto3')
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'EntityExtensionData_pb2', globals())
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\"com.spotify.extendedmetadata.protoH\002P\001\370\001\001'
_ENTITYEXTENSIONDATAHEADER._serialized_start=85
_ENTITYEXTENSIONDATAHEADER._serialized_end=225
_ENTITYEXTENSIONDATA._serialized_start=228
_ENTITYEXTENSIONDATA._serialized_end=384
_PLAINLISTASSOC._serialized_start=386
_PLAINLISTASSOC._serialized_end=422
_ASSOCHEADER._serialized_start=424
_ASSOCHEADER._serialized_end=437
_ASSOC._serialized_start=439
_ASSOC._serialized_end=563
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: extended_metadata.proto
"""Generated protocol buffer code."""
from google.protobuf.internal import builder as _builder
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
import librespot.proto.ExtensionKind_pb2 as extension__kind__pb2
import librespot.proto.EntityExtensionData_pb2 as entity__extension__data__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17\x65xtended_metadata.proto\x12\x18spotify.extendedmetadata\x1a\x14\x65xtension_kind.proto\x1a\x1b\x65ntity_extension_data.proto\"_\n\x0e\x45xtensionQuery\x12?\n\x0e\x65xtension_kind\x18\x01 \x01(\x0e\x32\'.spotify.extendedmetadata.ExtensionKind\x12\x0c\n\x04\x65tag\x18\x02 \x01(\t\"\\\n\rEntityRequest\x12\x12\n\nentity_uri\x18\x01 \x01(\t\x12\x37\n\x05query\x18\x02 \x03(\x0b\x32(.spotify.extendedmetadata.ExtensionQuery\"Q\n\x1a\x42\x61tchedEntityRequestHeader\x12\x0f\n\x07\x63ountry\x18\x01 \x01(\t\x12\x11\n\tcatalogue\x18\x02 \x01(\t\x12\x0f\n\x07task_id\x18\x03 \x01(\x0c\"\x9d\x01\n\x14\x42\x61tchedEntityRequest\x12\x44\n\x06header\x18\x01 \x01(\x0b\x32\x34.spotify.extendedmetadata.BatchedEntityRequestHeader\x12?\n\x0e\x65ntity_request\x18\x02 \x03(\x0b\x32\'.spotify.extendedmetadata.EntityRequest\"\xbe\x01\n\x1e\x45ntityExtensionDataArrayHeader\x12\x1d\n\x15provider_error_status\x18\x01 \x01(\x05\x12\x1c\n\x14\x63\x61\x63he_ttl_in_seconds\x18\x02 \x01(\x03\x12\x1e\n\x16offline_ttl_in_seconds\x18\x03 \x01(\x03\x12?\n\x0e\x65xtension_type\x18\x04 \x01(\x0e\x32\'.spotify.extendedmetadata.ExtensionType\"\xec\x01\n\x18\x45ntityExtensionDataArray\x12H\n\x06header\x18\x01 \x01(\x0b\x32\x38.spotify.extendedmetadata.EntityExtensionDataArrayHeader\x12?\n\x0e\x65xtension_kind\x18\x02 \x01(\x0e\x32\'.spotify.extendedmetadata.ExtensionKind\x12\x45\n\x0e\x65xtension_data\x18\x03 \x03(\x0b\x32-.spotify.extendedmetadata.EntityExtensionData\" \n\x1e\x42\x61tchedExtensionResponseHeader\"\xb3\x01\n\x18\x42\x61tchedExtensionResponse\x12H\n\x06header\x18\x01 \x01(\x0b\x32\x38.spotify.extendedmetadata.BatchedExtensionResponseHeader\x12M\n\x11\x65xtended_metadata\x18\x02 \x03(\x0b\x32\x32.spotify.extendedmetadata.EntityExtensionDataArray*4\n\rExtensionType\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x0b\n\x07GENERIC\x10\x01\x12\t\n\x05\x41SSOC\x10\x02\x42+\n\"com.spotify.extendedmetadata.protoH\x02P\x01\xf8\x01\x01\x62\x06proto3')
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ExtendedMetadata_pb2', globals())
if _descriptor._USE_C_DESCRIPTORS == False:
DESCRIPTOR._options = None
DESCRIPTOR._serialized_options = b'\n\"com.spotify.extendedmetadata.protoH\002P\001\370\001\001'
_EXTENSIONTYPE._serialized_start=1186
_EXTENSIONTYPE._serialized_end=1238
_EXTENSIONQUERY._serialized_start=104
_EXTENSIONQUERY._serialized_end=199
_ENTITYREQUEST._serialized_start=201
_ENTITYREQUEST._serialized_end=293
_BATCHEDENTITYREQUESTHEADER._serialized_start=295
_BATCHEDENTITYREQUESTHEADER._serialized_end=376
_BATCHEDENTITYREQUEST._serialized_start=379
_BATCHEDENTITYREQUEST._serialized_end=536
_ENTITYEXTENSIONDATAARRAYHEADER._serialized_start=539
_ENTITYEXTENSIONDATAARRAYHEADER._serialized_end=729
_ENTITYEXTENSIONDATAARRAY._serialized_start=732
_ENTITYEXTENSIONDATAARRAY._serialized_end=968
_BATCHEDEXTENSIONRESPONSEHEADER._serialized_start=970
_BATCHEDEXTENSIONRESPONSEHEADER._serialized_end=1002
_BATCHEDEXTENSIONRESPONSE._serialized_start=1005
_BATCHEDEXTENSIONRESPONSE._serialized_end=1184
# @@protoc_insertion_point(module_scope)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,12 +7,20 @@ from google.protobuf import message as _message
from google.protobuf import reflection as _reflection from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import enum_type_wrapper from google.protobuf.internal import enum_type_wrapper
from . import ClientInfo_pb2 as spotify_dot_login5_dot_v3_dot_client__info__pb2 from librespot.proto.spotify.login5.v3 import \
from . import UserInfo_pb2 as spotify_dot_login5_dot_v3_dot_user__info__pb2 ClientInfo_pb2 as spotify_dot_login5_dot_v3_dot_client__info__pb2
from ..v3.challenges import Code_pb2 as spotify_dot_login5_dot_v3_dot_challenges_dot_code__pb2 from librespot.proto.spotify.login5.v3 import \
from ..v3.challenges import Hashcash_pb2 as spotify_dot_login5_dot_v3_dot_challenges_dot_hashcash__pb2 UserInfo_pb2 as spotify_dot_login5_dot_v3_dot_user__info__pb2
from ..v3.credentials import Credentials_pb2 as spotify_dot_login5_dot_v3_dot_credentials_dot_credentials__pb2 from librespot.proto.spotify.login5.v3.challenges import \
from ..v3.identifiers import Identifiers_pb2 as spotify_dot_login5_dot_v3_dot_identifiers_dot_identifiers__pb2 Code_pb2 as spotify_dot_login5_dot_v3_dot_challenges_dot_code__pb2
from librespot.proto.spotify.login5.v3.challenges import \
Hashcash_pb2 as spotify_dot_login5_dot_v3_dot_challenges_dot_hashcash__pb2
from librespot.proto.spotify.login5.v3.credentials import \
Credentials_pb2 as \
spotify_dot_login5_dot_v3_dot_credentials_dot_credentials__pb2
from librespot.proto.spotify.login5.v3.identifiers import \
Identifiers as \
spotify_dot_login5_dot_v3_dot_identifiers_dot_identifiers__pb2
# @@protoc_insertion_point(imports) # @@protoc_insertion_point(imports)

View File

@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# source: spotify/login5/v3/identifiers/identifiers.proto
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
from google.protobuf import reflection as _reflection
from google.protobuf import symbol_database as _symbol_database
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor.FileDescriptor(
name="spotify/login5/v3/identifiers/identifiers.proto",
package="spotify.login5.v3.identifiers",
syntax="proto3",
serialized_options=b"\n\024com.spotify.login5v3",
create_key=_descriptor._internal_create_key,
serialized_pb=
b'\n/spotify/login5/v3/identifiers/identifiers.proto\x12\x1dspotify.login5.v3.identifiers"U\n\x0bPhoneNumber\x12\x0e\n\x06number\x18\x01 \x01(\t\x12\x18\n\x10iso_country_code\x18\x02 \x01(\t\x12\x1c\n\x14\x63ountry_calling_code\x18\x03 \x01(\tB\x16\n\x14\x63om.spotify.login5v3b\x06proto3',
)
_PHONENUMBER = _descriptor.Descriptor(
name="PhoneNumber",
full_name="spotify.login5.v3.identifiers.PhoneNumber",
filename=None,
file=DESCRIPTOR,
containing_type=None,
create_key=_descriptor._internal_create_key,
fields=[
_descriptor.FieldDescriptor(
name="number",
full_name="spotify.login5.v3.identifiers.PhoneNumber.number",
index=0,
number=1,
type=9,
cpp_type=9,
label=1,
has_default_value=False,
default_value=b"".decode("utf-8"),
message_type=None,
enum_type=None,
containing_type=None,
is_extension=False,
extension_scope=None,
serialized_options=None,
file=DESCRIPTOR,
create_key=_descriptor._internal_create_key,
),
_descriptor.FieldDescriptor(
name="iso_country_code",
full_name=
"spotify.login5.v3.identifiers.PhoneNumber.iso_country_code",
index=1,
number=2,
type=9,
cpp_type=9,
label=1,
has_default_value=False,
default_value=b"".decode("utf-8"),
message_type=None,
enum_type=None,
containing_type=None,
is_extension=False,
extension_scope=None,
serialized_options=None,
file=DESCRIPTOR,
create_key=_descriptor._internal_create_key,
),
_descriptor.FieldDescriptor(
name="country_calling_code",
full_name=
"spotify.login5.v3.identifiers.PhoneNumber.country_calling_code",
index=2,
number=3,
type=9,
cpp_type=9,
label=1,
has_default_value=False,
default_value=b"".decode("utf-8"),
message_type=None,
enum_type=None,
containing_type=None,
is_extension=False,
extension_scope=None,
serialized_options=None,
file=DESCRIPTOR,
create_key=_descriptor._internal_create_key,
),
],
extensions=[],
nested_types=[],
enum_types=[],
serialized_options=None,
is_extendable=False,
syntax="proto3",
extension_ranges=[],
oneofs=[],
serialized_start=82,
serialized_end=167,
)
DESCRIPTOR.message_types_by_name["PhoneNumber"] = _PHONENUMBER
_sym_db.RegisterFileDescriptor(DESCRIPTOR)
PhoneNumber = _reflection.GeneratedProtocolMessageType(
"PhoneNumber",
(_message.Message, ),
{
"DESCRIPTOR": _PHONENUMBER,
"__module__": "spotify.login5.v3.identifiers.identifiers_pb2"
# @@protoc_insertion_point(class_scope:spotify.login5.v3.identifiers.PhoneNumber)
},
)
_sym_db.RegisterMessage(PhoneNumber)
DESCRIPTOR._options = None
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1 @@
"""Protobuf helpers for Spotify extended metadata."""

View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: audio_files_extension.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from librespot.proto import Metadata_pb2 as metadata__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1baudio_files_extension.proto\x12#spotify.extendedmetadata.audiofiles\x1a\x0emetadata.proto"@\n\x13NormalizationParams\x12\x13\n\x0bloudness_db\x18\x01 \x01(\x02\x12\x14\n\x0ctrue_peak_db\x18\x02 \x01(\x02"i\n\x11ExtendedAudioFile\x12/\n\x04file\x18\x01 \x01(\x0b2!.spotify.metadata.proto.AudioFile\x12\x17\n\x0faverage_bitrate\x18\x04 \x01(\x05J\x04\x08\x02\x10\x03J\x04\x08\x03\x10\x04"\xc1\x02\n\x1bAudioFilesExtensionResponse\x12E\n\x05files\x18\x01 \x03(\x0b26.spotify.extendedmetadata.audiofiles.ExtendedAudioFile\x12c\n!default_file_normalization_params\x18\x02 \x01(\x0b28.spotify.extendedmetadata.audiofiles.NormalizationParams\x12d\n"default_album_normalization_params\x18\x03 \x01(\x0b28.spotify.extendedmetadata.audiofiles.NormalizationParams\x12\x10\n\x08audio_id\x18\x04 \x01(\x0cB \n\x1ccom.spotify.audiophile.protoH\x02b\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'audio_files_extension_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
_globals['DESCRIPTOR']._loaded_options = None
_globals['DESCRIPTOR']._serialized_options = b'\n\034com.spotify.audiophile.protoH\002'
_globals['_NORMALIZATIONPARAMS']._serialized_start=84
_globals['_NORMALIZATIONPARAMS']._serialized_end=148
_globals['_EXTENDEDAUDIOFILE']._serialized_start=150
_globals['_EXTENDEDAUDIOFILE']._serialized_end=249
_globals['_AUDIOFILESEXTENSIONRESPONSE']._serialized_start=252
_globals['_AUDIOFILESEXTENSIONRESPONSE']._serialized_end=573
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: entity_extension_data.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from google.protobuf import any_pb2 as google_dot_protobuf_dot_any__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1b\x65ntity_extension_data.proto\x12\x18spotify.extendedmetadata\x1a\x19google/protobuf/any.proto\"\x8c\x01\n\x19\x45ntityExtensionDataHeader\x12\x13\n\x0bstatus_code\x18\x01 \x01(\x05\x12\x0c\n\x04\x65tag\x18\x02 \x01(\t\x12\x0e\n\x06locale\x18\x03 \x01(\t\x12\x1c\n\x14\x63\x61\x63he_ttl_in_seconds\x18\x04 \x01(\x03\x12\x1e\n\x16offline_ttl_in_seconds\x18\x05 \x01(\x03\"\x9c\x01\n\x13\x45ntityExtensionData\x12\x43\n\x06header\x18\x01 \x01(\x0b\x32\x33.spotify.extendedmetadata.EntityExtensionDataHeader\x12\x12\n\nentity_uri\x18\x02 \x01(\t\x12,\n\x0e\x65xtension_data\x18\x03 \x01(\x0b\x32\x14.google.protobuf.Any\"$\n\x0ePlainListAssoc\x12\x12\n\nentity_uri\x18\x01 \x03(\t\"\r\n\x0b\x41ssocHeader\"|\n\x05\x41ssoc\x12\x35\n\x06header\x18\x01 \x01(\x0b\x32%.spotify.extendedmetadata.AssocHeader\x12<\n\nplain_list\x18\x02 \x01(\x0b\x32(.spotify.extendedmetadata.PlainListAssocB+\n\"com.spotify.extendedmetadata.protoH\x02P\x01\xf8\x01\x01\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'entity_extension_data_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
_globals['DESCRIPTOR']._loaded_options = None
_globals['DESCRIPTOR']._serialized_options = b'\n\"com.spotify.extendedmetadata.protoH\002P\001\370\001\001'
_globals['_ENTITYEXTENSIONDATAHEADER']._serialized_start=85
_globals['_ENTITYEXTENSIONDATAHEADER']._serialized_end=225
_globals['_ENTITYEXTENSIONDATA']._serialized_start=228
_globals['_ENTITYEXTENSIONDATA']._serialized_end=384
_globals['_PLAINLISTASSOC']._serialized_start=386
_globals['_PLAINLISTASSOC']._serialized_end=422
_globals['_ASSOCHEADER']._serialized_start=424
_globals['_ASSOCHEADER']._serialized_end=437
_globals['_ASSOC']._serialized_start=439
_globals['_ASSOC']._serialized_end=563
# @@protoc_insertion_point(module_scope)

View File

@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: extended_metadata.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from . import extension_kind_pb2 as extension__kind__pb2
from . import entity_extension_data_pb2 as entity__extension__data__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17\x65xtended_metadata.proto\x12\x18spotify.extendedmetadata\x1a\x14\x65xtension_kind.proto\x1a\x1b\x65ntity_extension_data.proto\"_\n\x0e\x45xtensionQuery\x12?\n\x0e\x65xtension_kind\x18\x01 \x01(\x0e\x32\'.spotify.extendedmetadata.ExtensionKind\x12\x0c\n\x04\x65tag\x18\x02 \x01(\t\"\\\n\rEntityRequest\x12\x12\n\nentity_uri\x18\x01 \x01(\t\x12\x37\n\x05query\x18\x02 \x03(\x0b\x32(.spotify.extendedmetadata.ExtensionQuery\"Q\n\x1a\x42\x61tchedEntityRequestHeader\x12\x0f\n\x07\x63ountry\x18\x01 \x01(\t\x12\x11\n\tcatalogue\x18\x02 \x01(\t\x12\x0f\n\x07task_id\x18\x03 \x01(\x0c\"\x9d\x01\n\x14\x42\x61tchedEntityRequest\x12\x44\n\x06header\x18\x01 \x01(\x0b\x32\x34.spotify.extendedmetadata.BatchedEntityRequestHeader\x12?\n\x0e\x65ntity_request\x18\x02 \x03(\x0b\x32\'.spotify.extendedmetadata.EntityRequest\"\xbe\x01\n\x1e\x45ntityExtensionDataArrayHeader\x12\x1d\n\x15provider_error_status\x18\x01 \x01(\x05\x12\x1c\n\x14\x63\x61\x63he_ttl_in_seconds\x18\x02 \x01(\x03\x12\x1e\n\x16offline_ttl_in_seconds\x18\x03 \x01(\x03\x12?\n\x0e\x65xtension_type\x18\x04 \x01(\x0e\x32\'.spotify.extendedmetadata.ExtensionType\"\xec\x01\n\x18\x45ntityExtensionDataArray\x12H\n\x06header\x18\x01 \x01(\x0b\x32\x38.spotify.extendedmetadata.EntityExtensionDataArrayHeader\x12?\n\x0e\x65xtension_kind\x18\x02 \x01(\x0e\x32\'.spotify.extendedmetadata.ExtensionKind\x12\x45\n\x0e\x65xtension_data\x18\x03 \x03(\x0b\x32-.spotify.extendedmetadata.EntityExtensionData\" \n\x1e\x42\x61tchedExtensionResponseHeader\"\xb3\x01\n\x18\x42\x61tchedExtensionResponse\x12H\n\x06header\x18\x01 \x01(\x0b\x32\x38.spotify.extendedmetadata.BatchedExtensionResponseHeader\x12M\n\x11\x65xtended_metadata\x18\x02 \x03(\x0b\x32\x32.spotify.extendedmetadata.EntityExtensionDataArray*4\n\rExtensionType\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x0b\n\x07GENERIC\x10\x01\x12\t\n\x05\x41SSOC\x10\x02\x42+\n\"com.spotify.extendedmetadata.protoH\x02P\x01\xf8\x01\x01\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'extended_metadata_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
_globals['DESCRIPTOR']._loaded_options = None
_globals['DESCRIPTOR']._serialized_options = b'\n\"com.spotify.extendedmetadata.protoH\002P\001\370\001\001'
_globals['_EXTENSIONTYPE']._serialized_start=1186
_globals['_EXTENSIONTYPE']._serialized_end=1238
_globals['_EXTENSIONQUERY']._serialized_start=104
_globals['_EXTENSIONQUERY']._serialized_end=199
_globals['_ENTITYREQUEST']._serialized_start=201
_globals['_ENTITYREQUEST']._serialized_end=293
_globals['_BATCHEDENTITYREQUESTHEADER']._serialized_start=295
_globals['_BATCHEDENTITYREQUESTHEADER']._serialized_end=376
_globals['_BATCHEDENTITYREQUEST']._serialized_start=379
_globals['_BATCHEDENTITYREQUEST']._serialized_end=536
_globals['_ENTITYEXTENSIONDATAARRAYHEADER']._serialized_start=539
_globals['_ENTITYEXTENSIONDATAARRAYHEADER']._serialized_end=729
_globals['_ENTITYEXTENSIONDATAARRAY']._serialized_start=732
_globals['_ENTITYEXTENSIONDATAARRAY']._serialized_end=968
_globals['_BATCHEDEXTENSIONRESPONSEHEADER']._serialized_start=970
_globals['_BATCHEDEXTENSIONRESPONSEHEADER']._serialized_end=1002
_globals['_BATCHEDEXTENSIONRESPONSE']._serialized_start=1005
_globals['_BATCHEDEXTENSIONRESPONSE']._serialized_end=1184
# @@protoc_insertion_point(module_scope)

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,6 @@ if typing.TYPE_CHECKING:
from librespot.crypto import Packet from librespot.crypto import Packet
from librespot.mercury import MercuryClient from librespot.mercury import MercuryClient
from librespot.proto import Metadata_pb2 as Metadata from librespot.proto import Metadata_pb2 as Metadata
from requests.structures import CaseInsensitiveDict
class AudioDecrypt: class AudioDecrypt:
@@ -62,7 +61,7 @@ class HaltListener:
class MessageListener: class MessageListener:
def on_message(self, uri: str, headers: CaseInsensitiveDict[str, str], def on_message(self, uri: str, headers: typing.Dict[str, str],
payload: bytes): payload: bytes):
raise NotImplementedError raise NotImplementedError

View File

@@ -7,7 +7,6 @@ from librespot.core import Session
from librespot.crypto import DiffieHellman from librespot.crypto import DiffieHellman
from librespot.proto import Connect_pb2 as Connect from librespot.proto import Connect_pb2 as Connect
from librespot.structure import Closeable, Runnable, SessionListener from librespot.structure import Closeable, Runnable, SessionListener
from requests.structures import CaseInsensitiveDict
import base64 import base64
import concurrent.futures import concurrent.futures
import copy import copy
@@ -276,7 +275,7 @@ class ZeroconfServer(Closeable):
method = request_line[0].decode() method = request_line[0].decode()
path = request_line[1].decode() path = request_line[1].decode()
http_version = request_line[2].decode() http_version = request_line[2].decode()
headers = CaseInsensitiveDict() headers = {}
while True: while True:
header = request.readline().strip() header = request.readline().strip()
if not header: if not header:

View File

@@ -0,0 +1,38 @@
// Extracted from: Spotify 1.2.52.442 (windows)
syntax = "proto3";
package spotify.extendedmetadata;
import "google/protobuf/any.proto";
option cc_enable_arenas = true;
option java_multiple_files = true;
option optimize_for = CODE_SIZE;
option java_package = "com.spotify.extendedmetadata.proto";
message EntityExtensionDataHeader {
int32 status_code = 1;
string etag = 2;
string locale = 3;
int64 cache_ttl_in_seconds = 4;
int64 offline_ttl_in_seconds = 5;
}
message EntityExtensionData {
EntityExtensionDataHeader header = 1;
string entity_uri = 2;
google.protobuf.Any extension_data = 3;
}
message PlainListAssoc {
repeated string entity_uri = 1;
}
message AssocHeader {
}
message Assoc {
AssocHeader header = 1;
PlainListAssoc plain_list = 2;
}

View File

@@ -0,0 +1,59 @@
syntax = "proto3";
package spotify.extendedmetadata;
import "extension_kind.proto";
import "entity_extension_data.proto";
option cc_enable_arenas = true;
option java_multiple_files = true;
option optimize_for = CODE_SIZE;
option java_package = "com.spotify.extendedmetadata.proto";
message ExtensionQuery {
ExtensionKind extension_kind = 1;
string etag = 2;
}
message EntityRequest {
string entity_uri = 1;
repeated ExtensionQuery query = 2;
}
message BatchedEntityRequestHeader {
string country = 1;
string catalogue = 2;
bytes task_id = 3;
}
message BatchedEntityRequest {
BatchedEntityRequestHeader header = 1;
repeated EntityRequest entity_request = 2;
}
message EntityExtensionDataArrayHeader {
int32 provider_error_status = 1;
int64 cache_ttl_in_seconds = 2;
int64 offline_ttl_in_seconds = 3;
ExtensionType extension_type = 4;
}
message EntityExtensionDataArray {
EntityExtensionDataArrayHeader header = 1;
ExtensionKind extension_kind = 2;
repeated EntityExtensionData extension_data = 3;
}
message BatchedExtensionResponseHeader {
}
message BatchedExtensionResponse {
BatchedExtensionResponseHeader header = 1;
repeated EntityExtensionDataArray extended_metadata = 2;
}
enum ExtensionType {
UNKNOWN = 0;
GENERIC = 1;
ASSOC = 2;
}

209
proto/extension_kind.proto Normal file
View File

@@ -0,0 +1,209 @@
// Extracted from: Spotify 1.2.52.442 (windows)
syntax = "proto3";
package spotify.extendedmetadata;
option objc_class_prefix = "SPTExtendedMetadata";
option cc_enable_arenas = true;
option java_multiple_files = true;
option optimize_for = CODE_SIZE;
option java_package = "com.spotify.extendedmetadata.proto";
enum ExtensionKind {
UNKNOWN_EXTENSION = 0;
CANVAZ = 1;
STORYLINES = 2;
PODCAST_TOPICS = 3;
PODCAST_SEGMENTS = 4;
AUDIO_FILES = 5;
TRACK_DESCRIPTOR = 6;
PODCAST_COUNTER = 7;
ARTIST_V4 = 8;
ALBUM_V4 = 9;
TRACK_V4 = 10;
SHOW_V4 = 11;
EPISODE_V4 = 12;
PODCAST_HTML_DESCRIPTION = 13;
PODCAST_QUOTES = 14;
USER_PROFILE = 15;
CANVAS_V1 = 16;
SHOW_V4_BASE = 17;
SHOW_V4_EPISODES_ASSOC = 18;
TRACK_DESCRIPTOR_SIGNATURES = 19;
PODCAST_AD_SEGMENTS = 20;
EPISODE_TRANSCRIPTS = 21;
PODCAST_SUBSCRIPTIONS = 22;
EXTRACTED_COLOR = 23;
PODCAST_VIRALITY = 24;
IMAGE_SPARKLES_HACK = 25;
PODCAST_POPULARITY_HACK = 26;
AUTOMIX_MODE = 27;
CUEPOINTS = 28;
PODCAST_POLL = 29;
EPISODE_ACCESS = 30;
SHOW_ACCESS = 31;
PODCAST_QNA = 32;
CLIPS = 33;
SHOW_V5 = 34;
EPISODE_V5 = 35;
PODCAST_CTA_CARDS = 36;
PODCAST_RATING = 37;
DISPLAY_SEGMENTS = 38;
GREENROOM = 39;
USER_CREATED = 40;
SHOW_DESCRIPTION = 41;
SHOW_HTML_DESCRIPTION = 42;
SHOW_PLAYABILITY = 43;
EPISODE_DESCRIPTION = 44;
EPISODE_HTML_DESCRIPTION = 45;
EPISODE_PLAYABILITY = 46;
SHOW_EPISODES_ASSOC = 47;
CLIENT_CONFIG = 48;
PLAYLISTABILITY = 49;
AUDIOBOOK_V5 = 50;
CHAPTER_V5 = 51;
AUDIOBOOK_SPECIFICS = 52;
EPISODE_RANKING = 53;
HTML_DESCRIPTION = 54;
CREATOR_CHANNEL = 55;
AUDIOBOOK_PROVIDERS = 56;
PLAY_TRAIT = 57;
CONTENT_WARNING = 58;
IMAGE_CUE = 59;
STREAM_COUNT = 60;
AUDIO_ATTRIBUTES = 61;
NAVIGABLE_TRAIT = 62;
NEXT_BEST_EPISODE = 63;
AUDIOBOOK_PRICE = 64;
EXPRESSIVE_PLAYLISTS = 65;
DYNAMIC_SHOW_EPISODE = 66;
LIVE = 67;
SKIP_PLAYED = 68;
AD_BREAK_FREE_PODCASTS = 69;
ASSOCIATIONS = 70;
PLAYLIST_EVALUATION = 71;
CACHE_INVALIDATIONS = 72;
LIVESTREAM_ENTITY = 73;
SINGLE_TAP_REACTIONS = 74;
USER_COMMENTS = 75;
CLIENT_RESTRICTIONS = 76;
PODCAST_GUEST = 77;
PLAYABILITY = 78;
COVER_IMAGE = 79;
SHARE_TRAIT = 80;
INSTANCE_SHARING = 81;
ARTIST_TOUR = 82;
AUDIOBOOK_GENRE = 83;
CONCEPT = 84;
ORIGINAL_VIDEO = 85;
SMART_SHUFFLE = 86;
LIVE_EVENTS = 87;
AUDIOBOOK_RELATIONS = 88;
HOME_POC_BASECARD = 89;
AUDIOBOOK_SUPPLEMENTS = 90;
PAID_PODCAST_BANNER = 91;
FEWER_ADS = 92;
WATCH_FEED_SHOW_EXPLORER = 93;
TRACK_EXTRA_DESCRIPTORS = 94;
TRACK_EXTRA_AUDIO_ATTRIBUTES = 95;
TRACK_EXTENDED_CREDITS = 96;
SIMPLE_TRAIT = 97;
AUDIO_ASSOCIATIONS = 98;
VIDEO_ASSOCIATIONS = 99;
PLAYLIST_TUNER = 100;
ARTIST_VIDEOS_ENTRYPOINT = 101;
ALBUM_PRERELEASE = 102;
CONTENT_ALTERNATIVES = 103;
SNAPSHOT_SHARING = 105;
DISPLAY_SEGMENTS_COUNT = 106;
PODCAST_FEATURED_EPISODE = 107;
PODCAST_SPONSORED_CONTENT = 108;
PODCAST_EPISODE_TOPICS_LLM = 109;
PODCAST_EPISODE_TOPICS_KG = 110;
EPISODE_RANKING_POPULARITY = 111;
MERCH = 112;
COMPANION_CONTENT = 113;
WATCH_FEED_ENTITY_EXPLORER = 114;
ANCHOR_CARD_TRAIT = 115;
AUDIO_PREVIEW_PLAYBACK_TRAIT = 116;
VIDEO_PREVIEW_STILL_TRAIT = 117;
PREVIEW_CARD_TRAIT = 118;
SHORTCUTS_CARD_TRAIT = 119;
VIDEO_PREVIEW_PLAYBACK_TRAIT = 120;
COURSE_SPECIFICS = 121;
CONCERT = 122;
CONCERT_LOCATION = 123;
CONCERT_MARKETING = 124;
CONCERT_PERFORMERS = 125;
TRACK_PAIR_TRANSITION = 126;
CONTENT_TYPE_TRAIT = 127;
NAME_TRAIT = 128;
ARTWORK_TRAIT = 129;
RELEASE_DATE_TRAIT = 130;
CREDITS_TRAIT = 131;
RELEASE_URI_TRAIT = 132;
ENTITY_CAPPING = 133;
LESSON_SPECIFICS = 134;
CONCERT_OFFERS = 135;
TRANSITION_MAPS = 136;
ARTIST_HAS_CONCERTS = 137;
PRERELEASE = 138;
PLAYLIST_ATTRIBUTES_V2 = 139;
LIST_ATTRIBUTES_V2 = 140;
LIST_METADATA = 141;
LIST_TUNER_AUDIO_ANALYSIS = 142;
LIST_TUNER_CUEPOINTS = 143;
CONTENT_RATING_TRAIT = 144;
COPYRIGHT_TRAIT = 145;
SUPPORTED_BADGES = 146;
BADGES = 147;
PREVIEW_TRAIT = 148;
ROOTLISTABILITY_TRAIT = 149;
LOCAL_CONCERTS = 150;
RECOMMENDED_PLAYLISTS = 151;
POPULAR_RELEASES = 152;
RELATED_RELEASES = 153;
SHARE_RESTRICTIONS = 154;
CONCERT_OFFER = 155;
CONCERT_OFFER_PROVIDER = 156;
ENTITY_BOOKMARKS = 157;
PRIVACY_TRAIT = 158;
DUPLICATE_ITEMS_TRAIT = 159;
REORDERING_TRAIT = 160;
PODCAST_RESUMPTION_SEGMENTS = 161;
ARTIST_EXPRESSION_VIDEO = 162;
PRERELEASE_VIDEO = 163;
GATED_ENTITY_RELATIONS = 164;
RELATED_CREATORS_SECTION = 165;
CREATORS_APPEARS_ON_SECTION = 166;
PROMO_V1_TRAIT = 167;
SPEECHLESS_SHARE_CARD = 168;
TOP_PLAYABLES_SECTION = 169;
AUTO_LENS = 170;
PROMO_V3_TRAIT = 171;
TRACK_CONTENT_FILTER = 172;
HIGHLIGHTABILITY = 173;
LINK_CARD_WITH_IMAGE_TRAIT = 174;
TRACK_CLOUD_SECTION = 175;
EPISODE_TOPICS = 176;
VIDEO_THUMBNAIL = 177;
IDENTITY_TRAIT = 178;
VISUAL_IDENTITY_TRAIT = 179;
CONTENT_TYPE_V2_TRAIT = 180;
PREVIEW_PLAYBACK_TRAIT = 181;
CONSUMPTION_EXPERIENCE_TRAIT = 182;
PUBLISHING_METADATA_TRAIT = 183;
DETAILED_EVALUATION_TRAIT = 184;
ON_PLATFORM_REPUTATION_TRAIT = 185;
CREDITS_V2_TRAIT = 186;
HIGHLIGHT_PLAYABILITY_TRAIT = 187;
SHOW_EPISODE_LIST = 188;
AVAILABLE_RELEASES = 189;
PLAYLIST_DESCRIPTORS = 190;
LINK_CARD_WITH_ANIMATIONS_TRAIT = 191;
RECAP = 192;
AUDIOBOOK_COMPANION_CONTENT = 193;
THREE_OH_THREE_PLAY_TRAIT = 194;
ARTIST_WRAPPED_2024_VIDEO = 195;
}

View File

@@ -1,7 +1,7 @@
import setuptools import setuptools
setuptools.setup(name="librespot", setuptools.setup(name="librespot",
version="0.0.9", version="0.0.10",
description="Open Source Spotify Client", description="Open Source Spotify Client",
long_description=open("README.md").read(), long_description=open("README.md").read(),
long_description_content_type="text/markdown", long_description_content_type="text/markdown",