Compare commits

...

15 Commits

Author SHA1 Message Date
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 5255 additions and 423 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,8 @@ 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
import concurrent.futures import concurrent.futures
import io import io
import logging import logging
@@ -20,10 +20,48 @@ import threading
import time import time
import typing import typing
import urllib.parse import urllib.parse
import os
import json
import requests
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from librespot.core import Session from librespot.core import Session
"""
PATCH : SpotiClub Audio Key Fetching (v0.2.0)
Fetches the audio decryption key from the SpotiClub Audio Key API instead of Spotify directly.
This is a workaround for Spotify's tightened restrictions on Audio Key access (they allow only Premium Tier now).
If you are using our fork, there is no reason for you to complete this section, as upon first run, Zotify will ask you for the logins and save them for future use.
But if needed somehow or by using this single patch file, there are 3 importants parameters to provide, and one is already filled in:
- server_url: The URL of the SpotiClub Audio Key API endpoint. You should not need to change this, except if a dev instructs you to do so.
- spoticlub_user : Your SpotiClub FTP username. You can get this by using our Padoru Asssistant once.
- spoticlub_password : Your SpotiClub FTP password, also obtainable via the Padoru Assistant.
Using the fork's assistant is the recommended way to get register your credentials, as overwriting this file during the beta phase will need you to put them here over and over again.
"""
##### 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
########################################
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
@@ -232,6 +270,7 @@ class AudioKeyManager(PacketsReceiver, Closeable):
__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
@@ -259,27 +298,114 @@ class AudioKeyManager(PacketsReceiver, Closeable):
gid: bytes, gid: bytes,
file_id: bytes, file_id: bytes,
retry: bool = True) -> bytes: retry: bool = True) -> bytes:
seq: int global spoticlub_user, spoticlub_password, spoticlub_client_serial, spoticlub_loaded_logged
with self.__seq_holder_lock: if not spoticlub_user or not spoticlub_password or spoticlub_user == "anonymous":
seq = self.__seq_holder try:
self.__seq_holder += 1 # To verify : Do all forks look for the same path ?
out = io.BytesIO() cfg_path = Path.home() / "AppData\\Roaming\\Zotify\\spoticlub_credentials.json"
out.write(file_id) if cfg_path.is_file():
out.write(gid) print(f"\n[SpotiClub API] Loading credentials...")
out.write(struct.pack(">i", seq)) with open(cfg_path, "r", encoding="utf-8") as f:
out.write(self.__zero_short) cfg = json.load(f)
out.seek(0) spoticlub_user = cfg.get("spoticlub_user")
self.__session.send(Packet.Type.request_key, out.read()) spoticlub_password = cfg.get("spoticlub_password")
callback = AudioKeyManager.SyncCallback(self) else:
self.__callbacks[seq] = callback print(f"[SpotiClub API] Credentials file NOT found at: {cfg_path}. We will proceed with hardcoded credentials if any...\n")
key = callback.wait_response() except Exception as exc:
if key is None: print(f"[SpotiClub API] Error while loading credentials file: {exc}\n")
if retry:
return self.get_audio_key(gid, file_id, False) if not spoticlub_user or not spoticlub_password or not server_url:
raise RuntimeError( cfg_path = Path.home() / "AppData\\Roaming\\Zotify\\spoticlub_credentials.json"
"Failed fetching audio key! gid: {}, fileId: {}".format( msg = (
util.bytes_to_hex(gid), util.bytes_to_hex(file_id))) "Missing SpotiClub credentials: please set the appropriates values inside your spoticlub_credentials.json,"
return key 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,
}
if spoticlub_client_serial:
payload["client_serial"] = spoticlub_client_serial
tries = 0
last_err: typing.Optional[Exception] = None
while True:
tries += 1
try:
resp = requests.post(server_url, json=payload, timeout=AudioKeyManager.audio_key_request_timeout)
# If another client instance is already active for this
# SpotiClub user, the server will reply with HTTP 423 and
# instruct this client to wait before retrying.
if resp.status_code == 423:
try:
data = resp.json()
except Exception: # noqa: BLE001
data = {}
retry_after = data.get("retry_after", 60)
if not isinstance(retry_after, (int, float)):
retry_after = 10
print(
f"[SpotiClub API] Another client is already using this account. Waiting {int(retry_after)}s before retrying..."
)
self.logger.info(
"[SpotiClub API] Queued client for user %s; waiting %ds before retry",
spoticlub_user,
int(retry_after),
)
time.sleep(float(retry_after))
# Do NOT count this as a failure towards the max retries.
continue
# Explicit handling for bad logins so we don't just retry.
if resp.status_code == 401:
print(
"[SpotiClub API][BAD_LOGIN] It seems your credentials aren't recognized by the API. Please ensure you have entered them correctly, or contact a DEV if you are absolutely certain of their validity."
)
raise SystemExit(1)
if resp.status_code != 200:
raise RuntimeError(f"[SpotiClub API] Sorry, the API returned the unexpected code {resp.status_code}: {resp.text}")
data = resp.json()
key_hex = data.get("key")
if not isinstance(key_hex, str):
raise RuntimeError("[SpotiClub API] Sorry, API response missing 'key'")
country = data.get("country")
if isinstance(country, str):
if AudioKeyManager._spoticlub_current_country != country:
AudioKeyManager._spoticlub_current_country = country
print(f"[SpotiClub API] Received {country} as the download country\n\n")
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")
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)
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:
@@ -472,9 +598,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 +705,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 +760,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 +912,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 +980,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))

View File

@@ -17,10 +17,12 @@ import threading
import time import time
import typing import typing
import urllib.parse import urllib.parse
from collections.abc import Iterable
import defusedxml.ElementTree import defusedxml.ElementTree
import requests import requests
import websocket import websocket
from google.protobuf import message as _message
from Cryptodome import Random from Cryptodome import Random
from Cryptodome.Cipher import AES from Cryptodome.Cipher import AES
from Cryptodome.Hash import HMAC from Cryptodome.Hash import HMAC
@@ -28,7 +30,6 @@ from Cryptodome.Hash import SHA1
from Cryptodome.Protocol.KDF import PBKDF2 from Cryptodome.Protocol.KDF import PBKDF2
from Cryptodome.PublicKey import RSA from Cryptodome.PublicKey import RSA
from Cryptodome.Signature import PKCS1_v1_5 from Cryptodome.Signature import PKCS1_v1_5
from requests.structures import CaseInsensitiveDict
from librespot import util from librespot import util
from librespot import Version from librespot import Version
@@ -49,7 +50,6 @@ from librespot.metadata import EpisodeId
from librespot.metadata import PlaylistId from librespot.metadata import PlaylistId
from librespot.metadata import ShowId from librespot.metadata import ShowId
from librespot.metadata import TrackId from librespot.metadata import TrackId
from librespot.oauth import OAuth
from librespot.proto import Authentication_pb2 as Authentication from librespot.proto import Authentication_pb2 as Authentication
from librespot.proto import ClientToken_pb2 as ClientToken from librespot.proto import ClientToken_pb2 as ClientToken
from librespot.proto import Connect_pb2 as Connect from librespot.proto import Connect_pb2 as Connect
@@ -57,9 +57,21 @@ from librespot.proto import Connectivity_pb2 as Connectivity
from librespot.proto import Keyexchange_pb2 as Keyexchange from librespot.proto import Keyexchange_pb2 as Keyexchange
from librespot.proto import Metadata_pb2 as Metadata from librespot.proto import Metadata_pb2 as Metadata
from librespot.proto import Playlist4External_pb2 as Playlist4External from librespot.proto import Playlist4External_pb2 as Playlist4External
from librespot.proto_ext import audio_files_extension_pb2
from librespot.proto_ext import extended_metadata_pb2
from librespot.proto_ext import extension_kind_pb2
try:
from librespot.proto.spotify.login5.v3 import Login5_pb2 as Login5
from librespot.proto.spotify.login5.v3 import ClientInfo_pb2 as Login5ClientInfo
from librespot.proto.spotify.login5.v3.credentials import Credentials_pb2 as Login5Credentials
LOGIN5_AVAILABLE = True
except ImportError as e:
# Login5 protobuf files not available, will use fallback
LOGIN5_AVAILABLE = False
Login5 = None
Login5ClientInfo = None
Login5Credentials = None
from librespot.proto.ExplicitContentPubsub_pb2 import UserAttributesUpdate from librespot.proto.ExplicitContentPubsub_pb2 import UserAttributesUpdate
from librespot.proto.spotify.login5.v3 import Login5_pb2 as Login5
from librespot.proto.spotify.login5.v3.credentials import Credentials_pb2 as Login5Credentials
from librespot.structure import Closeable from librespot.structure import Closeable
from librespot.structure import MessageListener from librespot.structure import MessageListener
from librespot.structure import RequestListener from librespot.structure import RequestListener
@@ -81,7 +93,7 @@ class ApiClient(Closeable):
self, self,
method: str, method: str,
suffix: str, suffix: str,
headers: typing.Union[None, CaseInsensitiveDict[str, str]], headers: typing.Union[None, typing.Dict[str, str]],
body: typing.Union[None, bytes], body: typing.Union[None, bytes],
url: typing.Union[None, str], url: typing.Union[None, str],
) -> requests.PreparedRequest: ) -> requests.PreparedRequest:
@@ -90,7 +102,7 @@ class ApiClient(Closeable):
:param method: str: :param method: str:
:param suffix: str: :param suffix: str:
:param headers: typing.Union[None: :param headers: typing.Union[None:
:param CaseInsensitiveDict[str: :param typing.Dict[str:
:param str]]: :param str]]:
:param body: typing.Union[None: :param body: typing.Union[None:
:param bytes]: :param bytes]:
@@ -104,26 +116,32 @@ class ApiClient(Closeable):
self.logger.debug("Updated client token: {}".format( self.logger.debug("Updated client token: {}".format(
self.__client_token_str)) self.__client_token_str))
request = requests.PreparedRequest() merged_headers: dict[str, str] = {}
request.method = method
request.data = body
request.headers = CaseInsensitiveDict()
if headers is not None: if headers is not None:
request.headers = headers merged_headers.update(headers)
request.headers["Authorization"] = "Bearer {}".format(
self.__session.tokens().get("playlist-read")) if "Authorization" not in merged_headers:
request.headers["client-token"] = self.__client_token_str merged_headers["Authorization"] = "Bearer {}".format(
if url is None: self.__session.tokens().get("playlist-read"))
request.url = self.__base_url + suffix
else: if "client-token" not in merged_headers:
request.url = url + suffix merged_headers["client-token"] = self.__client_token_str
return request
full_url = (self.__base_url if url is None else url) + suffix
request = requests.Request(
method=method,
url=full_url,
headers=merged_headers,
data=body,
)
session = self.__session.client()
return session.prepare_request(request)
def send( def send(
self, self,
method: str, method: str,
suffix: str, suffix: str,
headers: typing.Union[None, CaseInsensitiveDict[str, str]], headers: typing.Union[None, typing.Dict[str, str]],
body: typing.Union[None, bytes], body: typing.Union[None, bytes],
) -> requests.Response: ) -> requests.Response:
""" """
@@ -131,7 +149,7 @@ class ApiClient(Closeable):
:param method: str: :param method: str:
:param suffix: str: :param suffix: str:
:param headers: typing.Union[None: :param headers: typing.Union[None:
:param CaseInsensitiveDict[str: :param typing.Dict[str:
:param str]]: :param str]]:
:param body: typing.Union[None: :param body: typing.Union[None:
:param bytes]: :param bytes]:
@@ -146,20 +164,18 @@ class ApiClient(Closeable):
method: str, method: str,
url: str, url: str,
suffix: str, suffix: str,
headers: typing.Union[None, CaseInsensitiveDict[str, str]], headers: typing.Union[None, typing.Dict[str, str]],
body: typing.Union[None, bytes], body: typing.Union[None, bytes],
) -> requests.Response: ) -> requests.Response:
""" """
:param method: str: :param method: str:
:param url: str: :param url: str:
:param suffix: str: :param suffix: str:
:param headers: typing.Union[None: :param headers: typing.Union[None:
:param CaseInsensitiveDict[str: :param typing.Dict[str:
:param str]]: :param str]]:
:param body: typing.Union[None: :param body: typing.Union[None:
:param bytes]: :param bytes]:
""" """
response = self.__session.client().send( response = self.__session.client().send(
self.build_request(method, suffix, headers, body, url)) self.build_request(method, suffix, headers, body, url))
@@ -265,7 +281,7 @@ class ApiClient(Closeable):
:param show: ShowId: :param show: ShowId:
""" """
response = self.sendToUrl("GET", "https://spclient.wg.spotify.com", response = self.send("GET",
"/metadata/4/show/{}".format(show.hex_id()), None, "/metadata/4/show/{}".format(show.hex_id()), None,
None) None)
ApiClient.StatusCodeException.check_status(response) ApiClient.StatusCodeException.check_status(response)
@@ -294,6 +310,216 @@ class ApiClient(Closeable):
proto.ParseFromString(body) proto.ParseFromString(body)
return proto return proto
def get_audio_files_extension(
self, track: TrackId
) -> typing.Optional[audio_files_extension_pb2.AudioFilesExtensionResponse]:
"""Fetch audio file metadata via extended metadata for a given track."""
spotify_uri = track.to_spotify_uri()
request = extended_metadata_pb2.BatchedEntityRequest()
header = request.header
def _resolve_country_code() -> typing.Optional[str]:
code = getattr(self.__session, "_Session__country_code", None)
if code:
code = str(code).strip().upper()
if len(code) == 2 and code.isalpha():
return code
return None
country_code = _resolve_country_code()
if country_code:
header.country = country_code
try:
catalogue = self.__session.ap_welcome().catalogue
except AttributeError:
catalogue = None
except Exception: # pragma: no cover - defensive guard if ap_welcome raises
catalogue = None
if catalogue:
header.catalogue = catalogue
entity_request = request.entity_request.add()
entity_request.entity_uri = spotify_uri
query = entity_request.query.add()
query.extension_kind = extension_kind_pb2.ExtensionKind.AUDIO_FILES
request_bytes = request.SerializeToString()
def _decode_audio_files_extension(
payload: typing.Optional[typing.Union[bytes, bytearray, typing.Iterable[bytes]]]
) -> typing.Optional[audio_files_extension_pb2.AudioFilesExtensionResponse]:
if not payload:
return None
if isinstance(payload, (bytes, bytearray)):
payload_bytes = bytes(payload)
elif isinstance(payload, Iterable): # Mercury responses sometimes return payload parts
payload_bytes = b"".join(typing.cast(typing.Iterable[bytes], payload))
else:
payload_bytes = bytes(payload)
batch_response = extended_metadata_pb2.BatchedExtensionResponse()
try:
batch_response.ParseFromString(payload_bytes)
except _message.DecodeError:
self.logger.debug(
"Failed to parse extended metadata payload for %s",
spotify_uri,
exc_info=True,
)
return None
for extension_array in batch_response.extended_metadata:
if extension_array.extension_kind != extension_kind_pb2.ExtensionKind.AUDIO_FILES:
continue
for entity in extension_array.extension_data:
if entity.entity_uri and entity.entity_uri != spotify_uri:
continue
if not entity.HasField("extension_data"):
continue
audio_response = audio_files_extension_pb2.AudioFilesExtensionResponse()
try:
entity.extension_data.Unpack(audio_response)
except (ValueError, _message.DecodeError):
try:
audio_response.ParseFromString(entity.extension_data.value)
except _message.DecodeError:
self.logger.debug(
"Failed to unpack audio files extension for %s",
spotify_uri,
exc_info=True,
)
continue
return audio_response
return None
# Prefer the HTTPS extended metadata endpoint; fall back to Mercury if necessary.
login5_token = None
try:
login5_token = self.__session.get_login5_token()
except Exception: # pragma: no cover - defensive guard if session raises unexpectedly
login5_token = None
bearer_token = login5_token
if not bearer_token:
try:
bearer_token = self.__session.tokens().get("playlist-read")
except Exception:
bearer_token = None
headers = {
"Content-Type": "application/x-protobuf",
"Accept": "application/x-protobuf",
"Content-Length": str(len(request_bytes)),
}
if bearer_token:
headers["Authorization"] = f"Bearer {bearer_token}"
preferred_locale = getattr(self.__session, "preferred_locale", None)
if callable(preferred_locale): # Session.preferred_locale() is a method
try:
locale_value = preferred_locale()
except Exception:
locale_value = None
else:
locale_value = preferred_locale
if isinstance(locale_value, str) and locale_value:
headers.setdefault("Accept-Language", locale_value)
query_params: dict[str, str] = {"product": "0"}
if country_code:
query_params["country"] = country_code
query_params["salt"] = str(random.randint(0, 0xFFFFFFFF))
suffix = "/extended-metadata/v0/extended-metadata?" + urllib.parse.urlencode(query_params)
def _http_post(url_override: typing.Optional[str]) -> typing.Optional[requests.Response]:
target_headers = headers.copy()
for attempt in range(2):
try:
if url_override is None:
response = self.send("POST", suffix, target_headers, request_bytes)
else:
response = self.sendToUrl("POST", url_override, suffix, target_headers, request_bytes)
except Exception as exc: # pragma: no cover - network errors handled gracefully
self.logger.debug(
"Extended metadata HTTP request failed for %s via %s: %s",
spotify_uri,
url_override or "AP host",
exc,
)
return None
if response is not None and response.status_code in (401, 403):
self.logger.debug(
"Extended metadata HTTP returned %s for %s; refreshing client token",
response.status_code,
spotify_uri,
)
self.__client_token_str = None
target_headers.pop("client-token", None)
continue
return response
return None
http_response = _http_post(None)
if http_response is None or http_response.status_code != 200:
http_response = _http_post("https://spclient.wg.spotify.com")
if http_response is not None:
if http_response.status_code == 200:
http_payload: typing.Optional[bytes]
if isinstance(http_response.content, (bytes, bytearray)):
http_payload = bytes(http_response.content)
else:
http_payload = None
http_extension = _decode_audio_files_extension(http_payload)
if http_extension is not None:
return http_extension
else:
self.logger.debug(
"Extended metadata HTTP returned status %s for %s",
http_response.status_code,
spotify_uri,
)
mercury_request = (
RawMercuryRequest.new_builder()
.set_uri("hm://extendedmetadata/v1/entity")
.set_method("POST")
.set_content_type("application/x-protobuf")
.add_payload_part(request_bytes)
.build()
)
try:
response = self.__session.mercury().send_sync(mercury_request)
except Exception as exc: # pragma: no cover - network errors handled gracefully
self.logger.debug(
"Extended metadata request failed for %s: %s",
spotify_uri,
exc,
)
return None
if response.status_code != 200 or not response.payload:
self.logger.debug(
"Extended metadata returned status %s for %s",
response.status_code,
spotify_uri,
)
return None
return _decode_audio_files_extension(response.payload)
def set_client_token(self, client_token): def set_client_token(self, client_token):
""" """
@@ -328,10 +554,10 @@ class ApiClient(Closeable):
resp = requests.post( resp = requests.post(
"https://clienttoken.spotify.com/v1/clienttoken", "https://clienttoken.spotify.com/v1/clienttoken",
proto_req.SerializeToString(), proto_req.SerializeToString(),
headers=CaseInsensitiveDict({ headers={
"Accept": "application/x-protobuf", "Accept": "application/x-protobuf",
"Content-Encoding": "", "Content-Encoding": "",
}), },
) )
ApiClient.StatusCodeException.check_status(resp) ApiClient.StatusCodeException.check_status(resp)
@@ -605,10 +831,10 @@ class DealerClient(Closeable):
return return
self.__message_listeners_lock.wait() self.__message_listeners_lock.wait()
def __get_headers(self, obj: typing.Any) -> CaseInsensitiveDict[str, str]: def __get_headers(self, obj: typing.Any) -> dict[str, str]:
headers = obj.get("headers") headers = obj.get("headers")
if headers is None: if headers is None:
return CaseInsensitiveDict() return {}
return headers return headers
class ConnectionHolder(Closeable): class ConnectionHolder(Closeable):
@@ -932,6 +1158,8 @@ class Session(Closeable, MessageListener, SubListener):
__stored_str: str = "" __stored_str: str = ""
__token_provider: typing.Union[TokenProvider, None] __token_provider: typing.Union[TokenProvider, None]
__user_attributes = {} __user_attributes = {}
__login5_access_token: typing.Union[str, None] = None
__login5_token_expiry: typing.Union[int, None] = None
def __init__(self, inner: Inner, address: str) -> None: def __init__(self, inner: Inner, address: str) -> None:
self.__client = Session.create_client(inner.conf) self.__client = Session.create_client(inner.conf)
@@ -971,6 +1199,7 @@ class Session(Closeable, MessageListener, SubListener):
""" """
self.__authenticate_partial(credential, False) self.__authenticate_partial(credential, False)
self.__authenticate_login5(credential)
with self.__auth_lock: with self.__auth_lock:
self.__mercury_client = MercuryClient(self) self.__mercury_client = MercuryClient(self)
self.__token_provider = TokenProvider(self) self.__token_provider = TokenProvider(self)
@@ -1213,12 +1442,12 @@ class Session(Closeable, MessageListener, SubListener):
raise RuntimeError("Session isn't authenticated!") raise RuntimeError("Session isn't authenticated!")
return self.__mercury_client return self.__mercury_client
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):
""" """
:param uri: str: :param uri: str:
:param headers: CaseInsensitiveDict[str: :param headers: typing.Dict[str:
:param str]: :param str]:
:param payload: bytes: :param payload: bytes:
@@ -1388,6 +1617,64 @@ class Session(Closeable, MessageListener, SubListener):
def __send_unchecked(self, cmd: bytes, payload: bytes) -> None: def __send_unchecked(self, cmd: bytes, payload: bytes) -> None:
self.cipher_pair.send_encoded(self.connection, cmd, payload) self.cipher_pair.send_encoded(self.connection, cmd, payload)
def __authenticate_login5(self, credential: Authentication.LoginCredentials) -> None:
"""Authenticate using Login5 to get access token"""
if not LOGIN5_AVAILABLE:
self.logger.warning("Login5 protobuf files not available, skipping Login5 authentication")
return
try:
# Build Login5 request
login5_request = Login5.LoginRequest()
# Set client info
login5_request.client_info.client_id = "65b708073fc0480ea92a077233ca87bd"
login5_request.client_info.device_id = self.__inner.device_id
# Set stored credential from APWelcome
if hasattr(self, '_Session__ap_welcome') and self.__ap_welcome:
stored_cred = Login5Credentials.StoredCredential()
stored_cred.username = self.__ap_welcome.canonical_username
stored_cred.data = self.__ap_welcome.reusable_auth_credentials
login5_request.stored_credential.CopyFrom(stored_cred)
# Send Login5 request
login5_url = "https://login5.spotify.com/v3/login"
headers = {
"Content-Type": "application/x-protobuf",
"Accept": "application/x-protobuf"
}
response = requests.post(
login5_url,
data=login5_request.SerializeToString(),
headers=headers
)
if response.status_code == 200:
login5_response = Login5.LoginResponse()
login5_response.ParseFromString(response.content)
if login5_response.HasField('ok'):
self.__login5_access_token = login5_response.ok.access_token
self.__login5_token_expiry = int(time.time()) + login5_response.ok.access_token_expires_in
self.logger.info("Login5 authentication successful, got access token")
else:
self.logger.warning("Login5 authentication failed: {}".format(login5_response.error))
else:
self.logger.warning("Login5 request failed with status: {}".format(response.status_code))
except Exception as e:
self.logger.warning("Failed to authenticate with Login5: {}".format(e))
def get_login5_token(self) -> typing.Union[str, None]:
"""Get the Login5 access token if available and not expired"""
if self.__login5_access_token and self.__login5_token_expiry:
if int(time.time()) < self.__login5_token_expiry - 60: # 60 second buffer
return self.__login5_access_token
else:
self.logger.debug("Login5 token expired, need to re-authenticate")
return None
def __wait_auth_lock(self) -> None: def __wait_auth_lock(self) -> None:
if self.__closing and self.connection is None: if self.__closing and self.connection is None:
self.logger.debug("Connection was broken while closing.") self.logger.debug("Connection was broken while closing.")
@@ -1619,7 +1906,6 @@ class Session(Closeable, MessageListener, SubListener):
pass pass
else: else:
try: try:
# Try Python librespot format first
self.login_credentials = Authentication.LoginCredentials( self.login_credentials = Authentication.LoginCredentials(
typ=Authentication.AuthenticationType.Value( typ=Authentication.AuthenticationType.Value(
obj["type"]), obj["type"]),
@@ -1627,27 +1913,7 @@ class Session(Closeable, MessageListener, SubListener):
auth_data=base64.b64decode(obj["credentials"]), auth_data=base64.b64decode(obj["credentials"]),
) )
except KeyError: except KeyError:
# Try Rust librespot format (auth_type as int, auth_data instead of credentials) pass
try:
self.login_credentials = Authentication.LoginCredentials(
typ=obj["auth_type"],
username=obj["username"],
auth_data=base64.b64decode(obj["auth_data"]),
)
except KeyError:
pass
return self
def oauth(self, oauth_url_callback) -> Session.Builder:
"""
Login via OAuth
You can supply an oauth_url_callback method that takes a string and returns the OAuth URL.
When oauth_url_callback is None, this will only log the auth url to the console.
"""
if os.path.isfile(self.conf.stored_credentials_file):
return self.stored_file(None)
self.login_credentials = OAuth(MercuryRequests.keymaster_client_id, "http://127.0.0.1:5588/login", oauth_url_callback).flow()
return self return self
def user_pass(self, username: str, password: str) -> Session.Builder: def user_pass(self, username: str, password: str) -> Session.Builder:
@@ -1916,7 +2182,23 @@ class Session(Closeable, MessageListener, SubListener):
ap_address = address.split(":")[0] ap_address = address.split(":")[0]
ap_port = int(address.split(":")[1]) ap_port = int(address.split(":")[1])
sock = socket.socket() sock = socket.socket()
sock.connect((ap_address, ap_port))
# Retry logic: try up to 3 times with 2 seconds between attempts
# for transient connection errors (e.g., ECONNREFUSED / error 111).
attempts = 0
last_err: typing.Optional[Exception] = None
while attempts < 3:
attempts += 1
try:
sock.connect((ap_address, ap_port))
break
except OSError as exc:
last_err = exc
# Connection refused / temporary failure
if attempts >= 3:
raise
time.sleep(2)
return Session.ConnectionHolder(sock) return Session.ConnectionHolder(sock)
def close(self) -> None: def close(self) -> None:
@@ -1925,12 +2207,20 @@ class Session(Closeable, MessageListener, SubListener):
def flush(self) -> None: def flush(self) -> None:
"""Flush data to socket""" """Flush data to socket"""
try: attempts = 0
self.__buffer.seek(0) while True:
self.__socket.send(self.__buffer.read()) try:
self.__buffer = io.BytesIO() self.__buffer.seek(0)
except BrokenPipeError: self.__socket.send(self.__buffer.read())
pass self.__buffer = io.BytesIO()
break
except ConnectionResetError as exc:
attempts += 1
if attempts >= 3:
raise
time.sleep(1)
except BrokenPipeError:
break
def read(self, length: int) -> bytes: def read(self, length: int) -> bytes:
"""Read data from socket """Read data from socket
@@ -2032,12 +2322,41 @@ class Session(Closeable, MessageListener, SubListener):
self.__thread.start() self.__thread.start()
def stop(self) -> None: def stop(self) -> None:
""" """ """Signal the receiver thread to stop and wait for it.
This ensures that the background thread exits cleanly before
the underlying socket/connection is closed, avoiding
"Bad file descriptor" errors from pending recv() calls.
"""
self.__running = False self.__running = False
try:
# Joining from within the same thread would deadlock, so
# guard against that.
if threading.current_thread() is not self.__thread:
self.__thread.join(timeout=1)
except Exception:
# Shutdown should be best-effort; if join fails, we
# still proceed with closing the session.
self.__session.logger.debug(
"Receiver.stop: failed to join receiver thread", exc_info=True
)
def run(self) -> None: def run(self) -> None:
"""Receive Packet thread function""" """Receive Packet thread function"""
self.__session.logger.info("Session.Receiver started") self.__session.logger.info("Session.Receiver started")
# If the session has been explicitly closed elsewhere, do not
# start the receive loop at all; this prevents infinite
# reconnect cycles when the caller is done.
if not self.__running:
return
# Track how many times in a row we have seen a connection
# reset (Errno 104). After a small number of consecutive
# occurrences, stop trying to reconnect to avoid an
# infinite loop when the remote side keeps closing.
consecutive_resets = 0
max_consecutive_resets = 3
while self.__running: while self.__running:
packet: Packet packet: Packet
cmd: bytes cmd: bytes
@@ -2051,10 +2370,70 @@ class Session(Closeable, MessageListener, SubListener):
format(util.bytes_to_hex(packet.cmd), format(util.bytes_to_hex(packet.cmd),
packet.payload)) packet.payload))
continue continue
except (RuntimeError, ConnectionResetError) as ex: except (RuntimeError, ConnectionResetError, OSError) as ex:
if self.__running: # If we've been asked to stop, just exit quietly without
# trying to reconnect. This avoids the situation where the
# session keeps reconnecting in a loop after the work is
# finished and the caller expects shutdown.
if not self.__running:
# When the underlying socket is closed as part of a
# normal shutdown, recv() may raise "Bad file
# descriptor" (errno 9). This is expected and
# harmless, so we skip logging it to avoid noisy
# messages after a clean Session.close().
if not (
isinstance(ex, OSError)
and getattr(ex, "errno", None) == 9
):
self.__session.logger.info(
"Receiver stopping after connection error: %s", ex
)
break
# Detect repeated "connection reset by peer" errors.
is_reset = isinstance(ex, ConnectionResetError) or (
isinstance(ex, OSError)
and getattr(ex, "errno", None) == 104
)
# If the underlying socket is already closed (e.g.
# Bad file descriptor), just stop quietly; this can
# happen when Session.close() has torn down the
# connection while the receiver was blocked in recv().
if isinstance(ex, OSError) and getattr(ex, "errno", None) == 9:
#self.__session.logger.info(
# "Receiver stopping after socket close (errno 9)"
#)
self.__running = False
break
if is_reset:
consecutive_resets += 1
self.__session.logger.fatal( self.__session.logger.fatal(
"Failed reading packet! {}".format(ex)) "Failed reading packet (reset #%d)! %s",
consecutive_resets,
ex,
)
if consecutive_resets >= max_consecutive_resets:
self.__session.logger.error(
"Too many consecutive connection resets (%d). "
"Stopping receiver without further reconnect attempts.",
consecutive_resets,
)
# Mark as not running so the outer loop and
# any future reconnect logic will see that the
# session should shut down.
self.__running = False
break
else:
consecutive_resets = 0
self.__session.logger.fatal(
"Failed reading packet! %s", ex
)
# For both reset and non-reset errors (unless we've
# exceeded the reset threshold), attempt a single
# reconnect.
if self.__running:
self.__session.reconnect() self.__session.reconnect()
break break
if not self.__running: if not self.__running:
@@ -2270,7 +2649,7 @@ class TokenProvider:
__tokens: typing.List[StoredToken] = [] __tokens: typing.List[StoredToken] = []
def __init__(self, session: Session): def __init__(self, session: Session):
self.__session = session self._session = session
def find_token_with_all_scopes( def find_token_with_all_scopes(
self, scopes: typing.List[str]) -> typing.Union[StoredToken, None]: self, scopes: typing.List[str]) -> typing.Union[StoredToken, None]:
@@ -2301,60 +2680,58 @@ class TokenProvider:
scopes = list(scopes) scopes = list(scopes)
if len(scopes) == 0: if len(scopes) == 0:
raise RuntimeError("The token doesn't have any scope") raise RuntimeError("The token doesn't have any scope")
login5_token = self._session.get_login5_token()
if login5_token:
# Create a StoredToken-compatible object using Login5 token
login5_stored_token = TokenProvider.Login5StoredToken(login5_token, scopes)
self.logger.debug("Using Login5 access token for scopes: {}".format(scopes))
return login5_stored_token
token = self.find_token_with_all_scopes(scopes) token = self.find_token_with_all_scopes(scopes)
if token is not None: if token is not None:
if token.expired(): if token.expired():
self.__tokens.remove(token) self.__tokens.remove(token)
self.logger.debug("Login5 token expired, need to re-authenticate")
else: else:
return token return token
self.logger.debug(
"Token expired or not suitable, requesting again. scopes: {}, old_token: {}"
.format(scopes, token))
token = self.login5(scopes) try:
if token is not None: response = self._session.mercury().send_sync_json(
MercuryRequests.request_token(self._session.device_id(),
",".join(scopes)))
token = TokenProvider.StoredToken(response)
self.logger.debug(
"Updated token successfully! scopes: {}, new_token: {}".format(
scopes, token))
self.__tokens.append(token) self.__tokens.append(token)
self.logger.debug("Using Login5 access token for scopes: {}".format(scopes)) return token
return token except Exception as e:
self.logger.warning("Failed to get token from keymaster endpoint: {}".format(e))
raise RuntimeError("Unable to obtain access token")
def login5(self, scopes: typing.List[str]) -> typing.Union[StoredToken, None]: class Login5StoredToken:
"""Submit Login5 request for a fresh access token""" """StoredToken-compatible wrapper for Login5 access tokens"""
access_token: str
scopes: typing.List[str]
if self.__session.ap_welcome(): def __init__(self, access_token: str, scopes: typing.List[str]):
login5_request = Login5.LoginRequest() self.access_token = access_token
login5_request.client_info.client_id = MercuryRequests.keymaster_client_id self.scopes = scopes
login5_request.client_info.device_id = self.__session.device_id()
def expired(self) -> bool:
stored_cred = Login5Credentials.StoredCredential() """Login5 tokens are managed by Session, so delegate expiry check"""
stored_cred.username = self.__session.username() return False # Session handles expiry
stored_cred.data = self.__session.ap_welcome().reusable_auth_credentials
login5_request.stored_credential.CopyFrom(stored_cred) def has_scope(self, scope: str) -> bool:
"""Login5 tokens are general-purpose, assume they have all scopes"""
response = requests.post( return True
"https://login5.spotify.com/v3/login",
data=login5_request.SerializeToString(), def has_scopes(self, sc: typing.List[str]) -> bool:
headers=CaseInsensitiveDict({ """Login5 tokens are general-purpose, assume they have all scopes"""
"Content-Type": "application/x-protobuf", return True
"Accept": "application/x-protobuf"
}))
if response.status_code == 200:
login5_response = Login5.LoginResponse()
login5_response.ParseFromString(response.content)
if login5_response.HasField('ok'):
self.logger.info("Login5 authentication successful, got access token".format(login5_response.ok.access_token))
token = TokenProvider.StoredToken({
"expiresIn": login5_response.ok.access_token_expires_in, # approximately one hour
"accessToken": login5_response.ok.access_token,
"scope": scopes
})
return token
else:
self.logger.warning("Login5 authentication failed: {}".format(login5_response.error))
else:
self.logger.warning("Login5 request failed with status: {}".format(response.status_code))
else:
self.logger.error("Login5 authentication failed: No APWelcome found")
class StoredToken: class StoredToken:
""" """ """ """

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

@@ -1,123 +0,0 @@
import base64
import logging
import random
import urllib
from hashlib import sha256
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse
from librespot.proto import Authentication_pb2 as Authentication
import requests
class OAuth:
logger = logging.getLogger("Librespot:OAuth")
__spotify_auth = "https://accounts.spotify.com/authorize?response_type=code&client_id=%s&redirect_uri=%s&code_challenge=%s&code_challenge_method=S256&scope=%s"
__scopes = ["app-remote-control", "playlist-modify", "playlist-modify-private", "playlist-modify-public", "playlist-read", "playlist-read-collaborative", "playlist-read-private", "streaming", "ugc-image-upload", "user-follow-modify", "user-follow-read", "user-library-modify", "user-library-read", "user-modify", "user-modify-playback-state", "user-modify-private", "user-personalized", "user-read-birthdate", "user-read-currently-playing", "user-read-email", "user-read-play-history", "user-read-playback-position", "user-read-playback-state", "user-read-private", "user-read-recently-played", "user-top-read"]
__spotify_token = "https://accounts.spotify.com/api/token"
__spotify_token_data = {"grant_type": "authorization_code", "client_id": "", "redirect_uri": "", "code": "", "code_verifier": ""}
__client_id = ""
__redirect_url = ""
__code_verifier = ""
__code = ""
__token = ""
__server = None
__oauth_url_callback = None
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 __generate_generate_code_verifier(self):
possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
verifier = ""
for i in range(128):
verifier += possible[random.randint(0, len(possible) - 1)]
return verifier
def __generate_code_challenge(self, code_verifier):
digest = sha256(code_verifier.encode('utf-8')).digest()
return base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')
def get_auth_url(self):
self.__code_verifier = self.__generate_generate_code_verifier()
auth_url = self.__spotify_auth % (self.__client_id, self.__redirect_url, self.__generate_code_challenge(self.__code_verifier), "+".join(self.__scopes))
if self.__oauth_url_callback:
self.__oauth_url_callback(auth_url)
return auth_url
def set_code(self, code):
self.__code = code
def request_token(self):
if not self.__code:
raise RuntimeError("You need to provide a code before!")
request_data = self.__spotify_token_data
request_data["client_id"] = self.__client_id
request_data["redirect_uri"] = self.__redirect_url
request_data["code"] = self.__code
request_data["code_verifier"] = self.__code_verifier
request = requests.post(
self.__spotify_token,
data=request_data,
)
if request.status_code != 200:
raise RuntimeError("Received status code %d: %s" % (request.status_code, request.reason))
self.__token = request.json()["access_token"]
def get_credentials(self):
if not self.__token:
raise RuntimeError("You need to request a token bore!")
return Authentication.LoginCredentials(
typ=Authentication.AuthenticationType.AUTHENTICATION_SPOTIFY_TOKEN,
auth_data=self.__token.encode("utf-8")
)
class CallbackServer(HTTPServer):
callback_path = None
def __init__(self, server_address, RequestHandlerClass, callback_path, set_code):
self.callback_path = callback_path
self.set_code = set_code
super().__init__(server_address, RequestHandlerClass)
class CallbackRequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
if(self.path.startswith(self.server.callback_path)):
query = urllib.parse.parse_qs(urlparse(self.path).query)
if not query.__contains__("code"):
self.wfile.write(b"Request doesn't contain 'code'")
return
self.server.set_code(query.get("code")[0])
self.wfile.write(b"librespot-python received callback")
pass
def __start_server(self):
try:
self.__server.handle_request()
except KeyboardInterrupt:
return
if not self.__code:
self.__start_server()
def run_callback_server(self):
url = urlparse(self.__redirect_url)
self.__server = self.CallbackServer(
(url.hostname, url.port),
self.CallbackRequestHandler,
url.path,
self.set_code
)
logging.info("OAuth: Waiting for callback on %s", url.hostname + ":" + str(url.port))
self.__start_server()
def flow(self):
logging.info("OAuth: Visit in your browser and log in: %s ", self.get_auth_url())
self.run_callback_server()
self.request_token()
return self.get_credentials()
def __close(self):
if self.__server:
self.__server.shutdown()

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",