Compare commits
37 Commits
8760279c64
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc9c117450 | ||
|
|
0b36dd605c | ||
|
|
983f2a4ee7 | ||
|
|
ed478994d2 | ||
|
|
afed515855 | ||
|
|
82b4b40e6b | ||
|
|
36d08aae85 | ||
|
|
6d3b159099 | ||
|
|
aec696b489 | ||
|
|
5a790dc298 | ||
|
|
34fc626e1d | ||
|
|
77227b9e23 | ||
|
|
99ac394b8e | ||
|
|
30f7654301 | ||
|
|
8a7d0fa3c8 | ||
|
|
6c05cf4915 | ||
|
|
8d6cd7561c | ||
|
|
1bf4ec9859 | ||
|
|
8ea905e65f | ||
|
|
f16d4cc160 | ||
|
|
a1ca15f109 | ||
|
|
1480047ecb | ||
|
|
f2c6a5ec0d | ||
|
|
acd633d3eb | ||
|
|
5182578d56 | ||
|
|
76983a40e6 | ||
|
|
8736aca27b | ||
|
|
10748b8c5c | ||
|
|
3e52743549 | ||
|
|
5882f28213 | ||
|
|
f1dc97778c | ||
|
|
ccf625c5c5 | ||
|
|
a403f3eb9a | ||
|
|
778d8a2b5a | ||
|
|
9481c1c841 | ||
|
|
bc3b0d1b55 | ||
|
|
7a089f0fc5 |
138
CONTRIBUTING.md
Normal file
138
CONTRIBUTING.md
Normal 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
|
||||
10
README.md
10
README.md
@@ -76,18 +76,22 @@ session = Session.Builder() \
|
||||
.create()
|
||||
```
|
||||
|
||||
#### With auth url callback
|
||||
#### With auth url callback and changing the content of the success page
|
||||
|
||||
```python
|
||||
from librespot.core import Session
|
||||
import webbrowser
|
||||
|
||||
# This will pass the auth url to the method
|
||||
|
||||
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() \
|
||||
.oauth(auth_url_callback) \
|
||||
.oauth(auth_url_callback, success_page) \
|
||||
.create()
|
||||
```
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ from librespot.cache import CacheManager
|
||||
from librespot.crypto import Packet
|
||||
from librespot.metadata import EpisodeId, PlayableId, TrackId
|
||||
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 requests.structures import CaseInsensitiveDict
|
||||
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
|
||||
@@ -20,10 +21,92 @@ import threading
|
||||
import time
|
||||
import typing
|
||||
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
|
||||
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):
|
||||
chunk_exception = None
|
||||
@@ -226,12 +309,14 @@ 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
|
||||
__seq_holder_lock = threading.Condition()
|
||||
__session: Session
|
||||
__zero_short = b"\x00\x00"
|
||||
_spoticlub_current_country: typing.Optional[str] = None
|
||||
|
||||
def __init__(self, session: Session):
|
||||
self.__session = session
|
||||
@@ -255,32 +340,246 @@ 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:
|
||||
seq: int
|
||||
retry: bool = True,
|
||||
_attempt: int = 1,
|
||||
) -> bytes:
|
||||
with self.__seq_holder_lock:
|
||||
seq = self.__seq_holder
|
||||
self.__seq_holder += 1
|
||||
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())
|
||||
callback = AudioKeyManager.SyncCallback(self)
|
||||
self.__callbacks[seq] = callback
|
||||
|
||||
key = callback.wait_response()
|
||||
if key is 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)))
|
||||
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 = _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:
|
||||
|
||||
def key(self, key: bytes) -> None:
|
||||
@@ -472,9 +771,9 @@ class CdnManager:
|
||||
|
||||
class InternalResponse:
|
||||
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.headers = headers
|
||||
|
||||
@@ -579,6 +878,8 @@ class CdnManager:
|
||||
response = self.request(range_start=0,
|
||||
range_end=ChannelManager.chunk_size - 1)
|
||||
content_range = response.headers.get("Content-Range")
|
||||
if content_range is None:
|
||||
content_range = response.headers.get("content-range")
|
||||
if content_range is None:
|
||||
raise IOError("Missing Content-Range header!")
|
||||
split = content_range.split("/")
|
||||
@@ -632,16 +933,16 @@ class CdnManager:
|
||||
range_end = (chunk + 1) * ChannelManager.chunk_size - 1
|
||||
response = self.__session.client().get(
|
||||
self.__cdn_url.url,
|
||||
headers=CaseInsensitiveDict({
|
||||
headers={
|
||||
"Range": "bytes={}-{}".format(range_start, range_end)
|
||||
}),
|
||||
},
|
||||
)
|
||||
if response.status_code != 206:
|
||||
raise IOError(response.status_code)
|
||||
body = response.content
|
||||
if body is None:
|
||||
raise IOError("Response body is empty!")
|
||||
return CdnManager.InternalResponse(body, response.headers)
|
||||
return CdnManager.InternalResponse(body, dict(response.headers))
|
||||
|
||||
class InternalStream(AbsChunkedInputStream):
|
||||
streamer: CdnManager.Streamer
|
||||
@@ -784,14 +1085,38 @@ class PlayableContentFeeder:
|
||||
Metadata.Track],
|
||||
audio_quality_picker: AudioQualityPicker, preload: bool,
|
||||
halt_listener: HaltListener):
|
||||
if type(track_id_or_track) is TrackId:
|
||||
original = self.__session.api().get_metadata_4_track(
|
||||
track_id_or_track)
|
||||
if isinstance(track_id_or_track, TrackId):
|
||||
track_id = 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)
|
||||
if track is None:
|
||||
raise RuntimeError("Cannot get alternative track")
|
||||
else:
|
||||
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)
|
||||
if file is None:
|
||||
self.logger.fatal(
|
||||
@@ -828,6 +1153,45 @@ class PlayableContentFeeder:
|
||||
licensor=track.licensor)
|
||||
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(
|
||||
self, file_id: bytes,
|
||||
preload: bool) -> StorageResolve.StorageResolveResponse:
|
||||
|
||||
@@ -12,13 +12,13 @@ class AudioQuality(enum.Enum):
|
||||
NORMAL = 0x00
|
||||
HIGH = 0x01
|
||||
VERY_HIGH = 0x02
|
||||
LOSSLESS = 0x03
|
||||
|
||||
@staticmethod
|
||||
def get_quality(audio_format: AudioFile.Format) -> AudioQuality:
|
||||
if audio_format in [
|
||||
AudioFile.MP3_96,
|
||||
AudioFile.OGG_VORBIS_96,
|
||||
AudioFile.AAC_24_NORM,
|
||||
]:
|
||||
return AudioQuality.NORMAL
|
||||
if audio_format in [
|
||||
@@ -35,12 +35,7 @@ class AudioQuality(enum.Enum):
|
||||
AudioFile.AAC_48,
|
||||
]:
|
||||
return AudioQuality.VERY_HIGH
|
||||
if audio_format in [
|
||||
AudioFile.FLAC_FLAC,
|
||||
AudioFile.FLAC_FLAC_24BIT,
|
||||
]:
|
||||
return AudioQuality.LOSSLESS
|
||||
raise RuntimeError("Unknown format: {}".format(audio_format))
|
||||
raise RuntimeError("Unknown format: {}".format(format))
|
||||
|
||||
def get_matches(self,
|
||||
files: typing.List[AudioFile]) -> typing.List[AudioFile]:
|
||||
@@ -52,71 +47,35 @@ class AudioQuality(enum.Enum):
|
||||
return file_list
|
||||
|
||||
|
||||
class FormatOnlyAudioQuality(AudioQualityPicker):
|
||||
# Generic quality picker; filters files by container format
|
||||
|
||||
logger = logging.getLogger("Librespot:Player:FormatOnlyAudioQuality")
|
||||
class VorbisOnlyAudioQuality(AudioQualityPicker):
|
||||
logger = logging.getLogger("Librespot:Player:VorbisOnlyAudioQuality")
|
||||
preferred: AudioQuality
|
||||
format_filter: SuperAudioFormat
|
||||
|
||||
def __init__(self, preferred: AudioQuality, format_filter: SuperAudioFormat):
|
||||
def __init__(self, preferred: AudioQuality):
|
||||
self.preferred = preferred
|
||||
self.format_filter = format_filter
|
||||
|
||||
@staticmethod
|
||||
def get_file_by_format(files: typing.List[Metadata.AudioFile],
|
||||
format_type: SuperAudioFormat) -> typing.Optional[Metadata.AudioFile]:
|
||||
def get_vorbis_file(files: typing.List[Metadata.AudioFile]):
|
||||
for file in files:
|
||||
if file.HasField("format") and SuperAudioFormat.get(
|
||||
file.format) == format_type:
|
||||
file.format) == SuperAudioFormat.VORBIS:
|
||||
return file
|
||||
return None
|
||||
|
||||
def get_file(self, files: typing.List[Metadata.AudioFile]) -> typing.Optional[Metadata.AudioFile]:
|
||||
quality_matches: typing.List[Metadata.AudioFile] = self.preferred.get_matches(files)
|
||||
|
||||
selected_file = self.get_file_by_format(quality_matches, self.format_filter)
|
||||
|
||||
if selected_file is None:
|
||||
# Try using any file matching the format, regardless of quality
|
||||
selected_file = self.get_file_by_format(files, self.format_filter)
|
||||
|
||||
if selected_file is not None:
|
||||
# Found format match (different quality than preferred)
|
||||
def get_file(self, files: typing.List[Metadata.AudioFile]):
|
||||
matches: typing.List[Metadata.AudioFile] = self.preferred.get_matches(
|
||||
files)
|
||||
vorbis: Metadata.AudioFile = VorbisOnlyAudioQuality.get_vorbis_file(
|
||||
matches)
|
||||
if vorbis is None:
|
||||
vorbis: Metadata.AudioFile = VorbisOnlyAudioQuality.get_vorbis_file(
|
||||
files)
|
||||
if vorbis is not None:
|
||||
self.logger.warning(
|
||||
"Using {} format file with {} quality because preferred {} quality couldn't be found.".format(
|
||||
self.format_filter.name,
|
||||
AudioQuality.get_quality(selected_file.format).name,
|
||||
self.preferred.name))
|
||||
"Using {} because preferred {} couldn't be found.".format(
|
||||
Metadata.AudioFile.Format.Name(vorbis.format),
|
||||
self.preferred))
|
||||
else:
|
||||
available_formats = [SuperAudioFormat.get(f.format).name
|
||||
for f in files if f.HasField("format")]
|
||||
self.logger.fatal(
|
||||
"Couldn't find any {} file. Available formats: {}".format(
|
||||
self.format_filter.name,
|
||||
", ".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)
|
||||
"Couldn't find any Vorbis file, available: {}")
|
||||
return vorbis
|
||||
|
||||
@@ -6,7 +6,6 @@ class SuperAudioFormat(enum.Enum):
|
||||
MP3 = 0x00
|
||||
VORBIS = 0x01
|
||||
AAC = 0x02
|
||||
FLAC = 0x03
|
||||
|
||||
@staticmethod
|
||||
def get(audio_format: Metadata.AudioFile.Format):
|
||||
@@ -27,11 +26,7 @@ class SuperAudioFormat(enum.Enum):
|
||||
if audio_format in [
|
||||
Metadata.AudioFile.Format.AAC_24,
|
||||
Metadata.AudioFile.Format.AAC_48,
|
||||
Metadata.AudioFile.Format.AAC_24_NORM,
|
||||
]:
|
||||
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))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -65,7 +65,7 @@ class CipherPair:
|
||||
if mac != expected_mac:
|
||||
raise RuntimeError()
|
||||
return Packet(cmd, payload_bytes)
|
||||
except (IndexError, OSError):
|
||||
except IndexError:
|
||||
raise RuntimeError("Failed to receive packet")
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ from librespot import util
|
||||
from librespot.crypto import Packet
|
||||
from librespot.proto import Mercury_pb2 as Mercury, Pubsub_pb2 as Pubsub
|
||||
from librespot.structure import Closeable, PacketsReceiver, SubListener
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
@@ -347,11 +346,11 @@ class RawMercuryRequest:
|
||||
return RawMercuryRequest.Builder()
|
||||
|
||||
class Builder:
|
||||
header_dict: CaseInsensitiveDict
|
||||
header_dict: dict
|
||||
payload: typing.List[bytes]
|
||||
|
||||
def __init__(self):
|
||||
self.header_dict = CaseInsensitiveDict()
|
||||
self.header_dict = {}
|
||||
self.payload = []
|
||||
|
||||
def set_uri(self, uri: str):
|
||||
|
||||
@@ -22,12 +22,17 @@ class OAuth:
|
||||
__token = ""
|
||||
__server = None
|
||||
__oauth_url_callback = None
|
||||
__success_page_content = None
|
||||
|
||||
def __init__(self, client_id, redirect_url, oauth_url_callback):
|
||||
self.__client_id = client_id
|
||||
self.__redirect_url = redirect_url
|
||||
self.__oauth_url_callback = oauth_url_callback
|
||||
|
||||
def set_success_page_content(self, content):
|
||||
self.__success_page_content = content
|
||||
return self
|
||||
|
||||
def __generate_generate_code_verifier(self):
|
||||
possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
verifier = ""
|
||||
@@ -48,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:
|
||||
@@ -76,9 +83,10 @@ class OAuth:
|
||||
class CallbackServer(HTTPServer):
|
||||
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.set_code = set_code
|
||||
self.success_page_content = success_page_content
|
||||
super().__init__(server_address, RequestHandlerClass)
|
||||
|
||||
class CallbackRequestHandler(BaseHTTPRequestHandler):
|
||||
@@ -86,12 +94,25 @@ class OAuth:
|
||||
if(self.path.startswith(self.server.callback_path)):
|
||||
query = urllib.parse.parse_qs(urlparse(self.path).query)
|
||||
if not query.__contains__("code"):
|
||||
self.send_response(400)
|
||||
self.send_header('Content-type', 'text/html')
|
||||
self.end_headers()
|
||||
self.wfile.write(b"Request doesn't contain 'code'")
|
||||
return
|
||||
# Store the authorization code and notify the main
|
||||
# process that the callback has been received.
|
||||
self.server.set_code(query.get("code")[0])
|
||||
self.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
|
||||
|
||||
# Suppress logging
|
||||
def log_message(self, format, *args) -> None:
|
||||
return
|
||||
|
||||
def __start_server(self):
|
||||
try:
|
||||
self.__server.handle_request()
|
||||
@@ -106,7 +127,8 @@ class OAuth:
|
||||
(url.hostname, url.port),
|
||||
self.CallbackRequestHandler,
|
||||
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))
|
||||
self.__start_server()
|
||||
|
||||
35
librespot/proto/EntityExtensionData_pb2.py
Normal file
35
librespot/proto/EntityExtensionData_pb2.py
Normal 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)
|
||||
44
librespot/proto/ExtendedMetadata_pb2.py
Normal file
44
librespot/proto/ExtendedMetadata_pb2.py
Normal 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)
|
||||
26
librespot/proto/ExtensionKind_pb2.py
Normal file
26
librespot/proto/ExtensionKind_pb2.py
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -7,12 +7,20 @@ from google.protobuf import message as _message
|
||||
from google.protobuf import reflection as _reflection
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import enum_type_wrapper
|
||||
from . import ClientInfo_pb2 as spotify_dot_login5_dot_v3_dot_client__info__pb2
|
||||
from . import UserInfo_pb2 as spotify_dot_login5_dot_v3_dot_user__info__pb2
|
||||
from ..v3.challenges import Code_pb2 as spotify_dot_login5_dot_v3_dot_challenges_dot_code__pb2
|
||||
from ..v3.challenges import Hashcash_pb2 as spotify_dot_login5_dot_v3_dot_challenges_dot_hashcash__pb2
|
||||
from ..v3.credentials import Credentials_pb2 as spotify_dot_login5_dot_v3_dot_credentials_dot_credentials__pb2
|
||||
from ..v3.identifiers import Identifiers_pb2 as spotify_dot_login5_dot_v3_dot_identifiers_dot_identifiers__pb2
|
||||
from librespot.proto.spotify.login5.v3 import \
|
||||
ClientInfo_pb2 as spotify_dot_login5_dot_v3_dot_client__info__pb2
|
||||
from librespot.proto.spotify.login5.v3 import \
|
||||
UserInfo_pb2 as spotify_dot_login5_dot_v3_dot_user__info__pb2
|
||||
from librespot.proto.spotify.login5.v3.challenges import \
|
||||
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)
|
||||
|
||||
|
||||
119
librespot/proto/spotify/login5/v3/identifiers/Identifiers.py
Normal file
119
librespot/proto/spotify/login5/v3/identifiers/Identifiers.py
Normal 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)
|
||||
1
librespot/proto_ext/__init__.py
Normal file
1
librespot/proto_ext/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Protobuf helpers for Spotify extended metadata."""
|
||||
33
librespot/proto_ext/audio_files_extension_pb2.py
Normal file
33
librespot/proto_ext/audio_files_extension_pb2.py
Normal 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)
|
||||
37
librespot/proto_ext/entity_extension_data_pb2.py
Normal file
37
librespot/proto_ext/entity_extension_data_pb2.py
Normal 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)
|
||||
46
librespot/proto_ext/extended_metadata_pb2.py
Normal file
46
librespot/proto_ext/extended_metadata_pb2.py
Normal 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)
|
||||
29
librespot/proto_ext/extension_kind_pb2.py
Normal file
29
librespot/proto_ext/extension_kind_pb2.py
Normal file
File diff suppressed because one or more lines are too long
@@ -8,7 +8,6 @@ if typing.TYPE_CHECKING:
|
||||
from librespot.crypto import Packet
|
||||
from librespot.mercury import MercuryClient
|
||||
from librespot.proto import Metadata_pb2 as Metadata
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
|
||||
|
||||
class AudioDecrypt:
|
||||
@@ -62,7 +61,7 @@ class HaltListener:
|
||||
|
||||
|
||||
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):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from librespot.core import Session
|
||||
from librespot.crypto import DiffieHellman
|
||||
from librespot.proto import Connect_pb2 as Connect
|
||||
from librespot.structure import Closeable, Runnable, SessionListener
|
||||
from requests.structures import CaseInsensitiveDict
|
||||
import base64
|
||||
import concurrent.futures
|
||||
import copy
|
||||
@@ -276,7 +275,7 @@ class ZeroconfServer(Closeable):
|
||||
method = request_line[0].decode()
|
||||
path = request_line[1].decode()
|
||||
http_version = request_line[2].decode()
|
||||
headers = CaseInsensitiveDict()
|
||||
headers = {}
|
||||
while True:
|
||||
header = request.readline().strip()
|
||||
if not header:
|
||||
|
||||
38
proto/entity_extension_data.proto
Normal file
38
proto/entity_extension_data.proto
Normal 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;
|
||||
}
|
||||
59
proto/extended_metadata.proto
Normal file
59
proto/extended_metadata.proto
Normal 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
209
proto/extension_kind.proto
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user