SpotiClub Patch v0.2.0
Some checks failed
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
unknown
2025-12-17 21:03:32 +01:00
parent acd633d3eb
commit f2c6a5ec0d
18 changed files with 4786 additions and 515 deletions

View File

@@ -6,7 +6,7 @@ import platform
class Version:
version_name = "0.0.10"
version_name = "0.0.9"
@staticmethod
def platform() -> Platform:

View File

@@ -7,8 +7,8 @@ from librespot.cache import CacheManager
from librespot.crypto import Packet
from librespot.metadata import EpisodeId, PlayableId, TrackId
from librespot.proto import Metadata_pb2 as Metadata, StorageResolve_pb2 as StorageResolve
from librespot.structure import AudioDecrypt, AudioQualityPicker, Closeable, FeederException, GeneralAudioStream, GeneralWritableStream, HaltListener, NoopAudioDecrypt, PacketsReceiver
from requests.structures import CaseInsensitiveDict
from librespot.structure import GeneralAudioStream, AudioDecrypt, AudioQualityPicker, Closeable, FeederException, GeneralAudioStream, GeneralWritableStream, HaltListener, NoopAudioDecrypt, PacketsReceiver
from pathlib import Path
import concurrent.futures
import io
import logging
@@ -20,10 +20,48 @@ import threading
import time
import typing
import urllib.parse
import os
import json
import requests
if typing.TYPE_CHECKING:
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):
chunk_exception = None
@@ -232,6 +270,7 @@ class AudioKeyManager(PacketsReceiver, Closeable):
__seq_holder_lock = threading.Condition()
__session: Session
__zero_short = b"\x00\x00"
_spoticlub_current_country: typing.Optional[str] = None
def __init__(self, session: Session):
self.__session = session
@@ -259,27 +298,114 @@ class AudioKeyManager(PacketsReceiver, Closeable):
gid: bytes,
file_id: bytes,
retry: bool = True) -> bytes:
seq: int
with self.__seq_holder_lock:
seq = self.__seq_holder
self.__seq_holder += 1
out = io.BytesIO()
out.write(file_id)
out.write(gid)
out.write(struct.pack(">i", seq))
out.write(self.__zero_short)
out.seek(0)
self.__session.send(Packet.Type.request_key, out.read())
callback = AudioKeyManager.SyncCallback(self)
self.__callbacks[seq] = callback
key = callback.wait_response()
if key is None:
if retry:
return self.get_audio_key(gid, file_id, False)
raise RuntimeError(
"Failed fetching audio key! gid: {}, fileId: {}".format(
util.bytes_to_hex(gid), util.bytes_to_hex(file_id)))
return key
global spoticlub_user, spoticlub_password, spoticlub_client_serial, spoticlub_loaded_logged
if not spoticlub_user or not spoticlub_password or spoticlub_user == "anonymous":
try:
# To verify : Do all forks look for the same path ?
cfg_path = Path.home() / "AppData\\Roaming\\Zotify\\spoticlub_credentials.json"
if cfg_path.is_file():
print(f"\n[SpotiClub API] Loading credentials...")
with open(cfg_path, "r", encoding="utf-8") as f:
cfg = json.load(f)
spoticlub_user = cfg.get("spoticlub_user")
spoticlub_password = cfg.get("spoticlub_password")
else:
print(f"[SpotiClub API] Credentials file NOT found at: {cfg_path}. We will proceed with hardcoded credentials if any...\n")
except Exception as exc:
print(f"[SpotiClub API] Error while loading credentials file: {exc}\n")
if not spoticlub_user or not spoticlub_password or not server_url:
cfg_path = Path.home() / "AppData\\Roaming\\Zotify\\spoticlub_credentials.json"
msg = (
"Missing SpotiClub credentials: please set the appropriates values inside your spoticlub_credentials.json,"
f"located in the Zotify config folder [{cfg_path}] (Or delete it and restart Zotify to be prompted for credentials)."
)
print(f"[SpotiClub API][ERROR]\n{msg}")
raise SystemExit(1)
if not spoticlub_loaded_logged:
spoticlub_loaded_logged = True
print(f"\n[SpotiClub API] Plugin Loaded! Welcome {spoticlub_user}\n")
payload = {
"gid": util.bytes_to_hex(gid),
"file_id": util.bytes_to_hex(file_id),
"user": spoticlub_user,
"password": spoticlub_password,
}
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:
@@ -331,7 +457,7 @@ class CdnFeedHelper:
session: Session, track: Metadata.Track, file: Metadata.AudioFile,
resp_or_url: typing.Union[StorageResolve.StorageResolveResponse,
str], preload: bool,
halt_listener: HaltListener) -> LoadedStream:
halt_listener: HaltListener) -> PlayableContentFeeder.LoadedStream:
if type(resp_or_url) is str:
url = resp_or_url
else:
@@ -345,17 +471,18 @@ class CdnFeedHelper:
normalization_data = NormalizationData.read(input_stream)
if input_stream.skip(0xA7) != 0xA7:
raise IOError("Couldn't skip 0xa7 bytes!")
return LoadedStream(
return PlayableContentFeeder.LoadedStream(
track,
streamer,
normalization_data,
file.file_id, preload, audio_key_time
PlayableContentFeeder.Metrics(file.file_id, preload,
-1 if preload else audio_key_time),
)
@staticmethod
def load_episode_external(
session: Session, episode: Metadata.Episode,
halt_listener: HaltListener) -> LoadedStream:
halt_listener: HaltListener) -> PlayableContentFeeder.LoadedStream:
resp = session.client().head(episode.external_url)
if resp.status_code != 200:
@@ -367,11 +494,11 @@ class CdnFeedHelper:
streamer = session.cdn().stream_external_episode(
episode, url, halt_listener)
return LoadedStream(
return PlayableContentFeeder.LoadedStream(
episode,
streamer,
None,
None, False, -1
PlayableContentFeeder.Metrics(None, False, -1),
)
@staticmethod
@@ -382,7 +509,7 @@ class CdnFeedHelper:
resp_or_url: typing.Union[StorageResolve.StorageResolveResponse, str],
preload: bool,
halt_listener: HaltListener,
) -> LoadedStream:
) -> PlayableContentFeeder.LoadedStream:
if type(resp_or_url) is str:
url = resp_or_url
else:
@@ -396,11 +523,12 @@ class CdnFeedHelper:
normalization_data = NormalizationData.read(input_stream)
if input_stream.skip(0xA7) != 0xA7:
raise IOError("Couldn't skip 0xa7 bytes!")
return LoadedStream(
return PlayableContentFeeder.LoadedStream(
episode,
streamer,
normalization_data,
file.file_id, preload, audio_key_time
PlayableContentFeeder.Metrics(file.file_id, preload,
-1 if preload else audio_key_time),
)
@@ -470,9 +598,9 @@ class CdnManager:
class InternalResponse:
buffer: bytes
headers: CaseInsensitiveDict[str, str]
headers: typing.Dict[str, str]
def __init__(self, buffer: bytes, headers: CaseInsensitiveDict[str, str]):
def __init__(self, buffer: bytes, headers: typing.Dict[str, str]):
self.buffer = buffer
self.headers = headers
@@ -577,6 +705,8 @@ class CdnManager:
response = self.request(range_start=0,
range_end=ChannelManager.chunk_size - 1)
content_range = response.headers.get("Content-Range")
if content_range is None:
content_range = response.headers.get("content-range")
if content_range is None:
raise IOError("Missing Content-Range header!")
split = content_range.split("/")
@@ -630,16 +760,16 @@ class CdnManager:
range_end = (chunk + 1) * ChannelManager.chunk_size - 1
response = self.__session.client().get(
self.__cdn_url.url,
headers=CaseInsensitiveDict({
headers={
"Range": "bytes={}-{}".format(range_start, range_end)
}),
},
)
if response.status_code != 206:
raise IOError(response.status_code)
body = response.content
if body is None:
raise IOError("Response body is empty!")
return CdnManager.InternalResponse(body, response.headers)
return CdnManager.InternalResponse(body, dict(response.headers))
class InternalStream(AbsChunkedInputStream):
streamer: CdnManager.Streamer
@@ -746,9 +876,7 @@ class PlayableContentFeeder:
episode: Metadata.Episode, preload: bool,
halt_lister: HaltListener):
if track is None and episode is None:
raise RuntimeError("No content passed!")
elif file is None:
raise RuntimeError("Content has no audio file!")
raise RuntimeError()
response = self.resolve_storage_interactive(file.file_id, preload)
if response.result == StorageResolve.StorageResolveResponse.Result.CDN:
if track is not None:
@@ -778,27 +906,50 @@ class PlayableContentFeeder:
self.logger.fatal(
"Couldn't find any suitable audio file, available: {}".format(
episode.audio))
raise FeederException("Cannot find suitable audio file")
return self.load_stream(file, None, episode, preload, halt_listener)
def load_track(self, track_id_or_track: typing.Union[TrackId,
Metadata.Track],
audio_quality_picker: AudioQualityPicker, preload: bool,
halt_listener: HaltListener):
if type(track_id_or_track) is TrackId:
original = self.__session.api().get_metadata_4_track(
track_id_or_track)
if isinstance(track_id_or_track, TrackId):
track_id = track_id_or_track
original = self.__session.api().get_metadata_4_track(track_id)
if len(original.file) == 0:
self._populate_track_files_from_extended_metadata(track_id, original)
if len(original.file) == 0:
for alt in original.alternative:
if len(alt.file) > 0 or not alt.gid:
continue
gid_hex = util.bytes_to_hex(alt.gid)
if len(gid_hex) != 32:
continue
try:
alt_track_id = TrackId.from_hex(gid_hex)
except Exception:
continue
self._populate_track_files_from_extended_metadata(alt_track_id, alt)
track = self.pick_alternative_if_necessary(original)
if track is None:
raise RuntimeError("Cannot get alternative track")
else:
track = track_id_or_track
try:
gid_hex = util.bytes_to_hex(track.gid)
input_track_id = TrackId.from_hex(gid_hex) if len(gid_hex) == 32 else None
except Exception:
input_track_id = None
if input_track_id is not None and len(track.file) == 0:
self._populate_track_files_from_extended_metadata(input_track_id, track)
file = audio_quality_picker.get_file(track.file)
if file is None:
self.logger.fatal(
"Couldn't find any suitable audio file, available: {}".format(
track.file))
raise FeederException("Cannot find suitable audio file")
raise FeederException()
return self.load_stream(file, track, None, preload, halt_listener)
def pick_alternative_if_necessary(
@@ -829,6 +980,45 @@ class PlayableContentFeeder:
licensor=track.licensor)
return None
def _populate_track_files_from_extended_metadata(
self, track_id: TrackId, track_proto: Metadata.Track) -> bool:
if len(track_proto.file) > 0:
return True
try:
extension = self.__session.api().get_audio_files_extension(track_id)
except Exception as exc: # pragma: no cover - network errors handled elsewhere
self.logger.debug(
"Extended metadata lookup failed for %s: %s",
track_id.to_spotify_uri(),
exc,
)
return False
if extension is None or len(extension.files) == 0:
return len(track_proto.file) > 0
existing_ids = {util.bytes_to_hex(audio.file_id) for audio in track_proto.file}
added_count = 0
for ext_file in extension.files:
if not ext_file.HasField("file"):
continue
file_id_bytes = ext_file.file.file_id
file_id_hex = util.bytes_to_hex(file_id_bytes)
if file_id_hex in existing_ids:
continue
track_proto.file.add().CopyFrom(ext_file.file)
existing_ids.add(file_id_hex)
added_count += 1
if added_count:
self.logger.debug(
"Enriched %s with %d file(s) from extended metadata",
track_id.to_spotify_uri(),
added_count,
)
return len(track_proto.file) > 0
def resolve_storage_interactive(
self, file_id: bytes,
preload: bool) -> StorageResolve.StorageResolveResponse:
@@ -849,13 +1039,29 @@ class PlayableContentFeeder:
storage_resolve_response.ParseFromString(body)
return storage_resolve_response
class LoadedStream:
episode: Metadata.Episode
track: Metadata.Track
input_stream: GeneralAudioStream
normalization_data: NormalizationData
metrics: PlayableContentFeeder.Metrics
class LoadedStream:
episode: Metadata.Episode
track: Metadata.Track
input_stream: GeneralAudioStream
normalization_data: NormalizationData
metrics: Metrics
def __init__(self, track_or_episode: typing.Union[Metadata.Track,
Metadata.Episode],
input_stream: GeneralAudioStream,
normalization_data: typing.Union[NormalizationData, None],
metrics: PlayableContentFeeder.Metrics):
if type(track_or_episode) is Metadata.Track:
self.track = track_or_episode
self.episode = None
elif type(track_or_episode) is Metadata.Episode:
self.track = None
self.episode = track_or_episode
else:
raise TypeError()
self.input_stream = input_stream
self.normalization_data = normalization_data
self.metrics = metrics
class Metrics:
file_id: str
@@ -863,27 +1069,13 @@ class LoadedStream:
audio_key_time: int
def __init__(self, file_id: typing.Union[bytes, None],
preloaded_audio_key: bool, audio_key_time: int):
preloaded_audio_key: bool, audio_key_time: int):
self.file_id = None if file_id is None else util.bytes_to_hex(
file_id)
self.preloaded_audio_key = preloaded_audio_key
self.audio_key_time = -1 if preloaded_audio_key else audio_key_time
def __init__(self, track_or_episode: typing.Union[Metadata.Track, Metadata.Episode],
input_stream: GeneralAudioStream,
normalization_data: typing.Union[NormalizationData, None],
file_id: str, preloaded_audio_key: bool, audio_key_time: int):
if type(track_or_episode) is Metadata.Track:
self.track = track_or_episode
self.episode = None
elif type(track_or_episode) is Metadata.Episode:
self.track = None
self.episode = track_or_episode
else:
raise TypeError()
self.input_stream = input_stream
self.normalization_data = normalization_data
self.metrics = self.Metrics(file_id, preloaded_audio_key, audio_key_time)
self.audio_key_time = audio_key_time
if preloaded_audio_key and audio_key_time != -1:
raise RuntimeError()
class StreamId:

View File

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

View File

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

View File

@@ -17,10 +17,12 @@ import threading
import time
import typing
import urllib.parse
from collections.abc import Iterable
import defusedxml.ElementTree
import requests
import websocket
from google.protobuf import message as _message
from Cryptodome import Random
from Cryptodome.Cipher import AES
from Cryptodome.Hash import HMAC
@@ -28,7 +30,6 @@ from Cryptodome.Hash import SHA1
from Cryptodome.Protocol.KDF import PBKDF2
from Cryptodome.PublicKey import RSA
from Cryptodome.Signature import PKCS1_v1_5
from requests.structures import CaseInsensitiveDict
from librespot import util
from librespot import Version
@@ -49,7 +50,6 @@ from librespot.metadata import EpisodeId
from librespot.metadata import PlaylistId
from librespot.metadata import ShowId
from librespot.metadata import TrackId
from librespot.oauth import OAuth
from librespot.proto import Authentication_pb2 as Authentication
from librespot.proto import ClientToken_pb2 as ClientToken
from librespot.proto import Connect_pb2 as Connect
@@ -57,11 +57,21 @@ from librespot.proto import Connectivity_pb2 as Connectivity
from librespot.proto import Keyexchange_pb2 as Keyexchange
from librespot.proto import Metadata_pb2 as Metadata
from librespot.proto import Playlist4External_pb2 as Playlist4External
from librespot.proto.ExtendedMetadata_pb2 import EntityRequest, BatchedEntityRequest, ExtensionQuery, BatchedExtensionResponse
from librespot.proto.ExtensionKind_pb2 import ExtensionKind
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.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 MessageListener
from librespot.structure import RequestListener
@@ -83,7 +93,7 @@ class ApiClient(Closeable):
self,
method: str,
suffix: str,
headers: typing.Union[None, CaseInsensitiveDict[str, str]],
headers: typing.Union[None, typing.Dict[str, str]],
body: typing.Union[None, bytes],
url: typing.Union[None, str],
) -> requests.PreparedRequest:
@@ -92,7 +102,7 @@ class ApiClient(Closeable):
:param method: str:
:param suffix: str:
:param headers: typing.Union[None:
:param CaseInsensitiveDict[str:
:param typing.Dict[str:
:param str]]:
:param body: typing.Union[None:
:param bytes]:
@@ -106,26 +116,32 @@ class ApiClient(Closeable):
self.logger.debug("Updated client token: {}".format(
self.__client_token_str))
if url is None:
url = self.__base_url + suffix
else:
url = url + suffix
merged_headers: dict[str, str] = {}
if headers is not None:
merged_headers.update(headers)
if headers is None:
headers = CaseInsensitiveDict()
headers["Authorization"] = "Bearer {}".format(
self.__session.tokens().get("playlist-read"))
headers["client-token"] = self.__client_token_str
if "Authorization" not in merged_headers:
merged_headers["Authorization"] = "Bearer {}".format(
self.__session.tokens().get("playlist-read"))
request = requests.Request(method, url, headers=headers, data=body)
if "client-token" not in merged_headers:
merged_headers["client-token"] = self.__client_token_str
return request.prepare()
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(
self,
method: str,
suffix: str,
headers: typing.Union[None, CaseInsensitiveDict[str, str]],
headers: typing.Union[None, typing.Dict[str, str]],
body: typing.Union[None, bytes],
) -> requests.Response:
"""
@@ -133,7 +149,7 @@ class ApiClient(Closeable):
:param method: str:
:param suffix: str:
:param headers: typing.Union[None:
:param CaseInsensitiveDict[str:
:param typing.Dict[str:
:param str]]:
:param body: typing.Union[None:
:param bytes]:
@@ -148,20 +164,18 @@ class ApiClient(Closeable):
method: str,
url: str,
suffix: str,
headers: typing.Union[None, CaseInsensitiveDict[str, str]],
headers: typing.Union[None, typing.Dict[str, str]],
body: typing.Union[None, bytes],
) -> requests.Response:
"""
:param method: str:
:param url: str:
:param suffix: str:
:param headers: typing.Union[None:
:param CaseInsensitiveDict[str:
:param typing.Dict[str:
:param str]]:
:param body: typing.Union[None:
:param bytes]:
"""
response = self.__session.client().send(
self.build_request(method, suffix, headers, body, url))
@@ -192,36 +206,22 @@ class ApiClient(Closeable):
self.logger.warning("PUT state returned {}. headers: {}".format(
response.status_code, response.headers))
def get_ext_metadata(self, extension_kind: ExtensionKind, uri: str):
headers = CaseInsensitiveDict({"content-type": "application/x-protobuf"})
req = EntityRequest(entity_uri=uri, query=[ExtensionQuery(extension_kind=extension_kind),])
response = self.send("POST", "/extended-metadata/v0/extended-metadata",
headers, BatchedEntityRequest(entity_request=[req,]).SerializeToString())
ApiClient.StatusCodeException.check_status(response)
body = response.content
if body is None:
raise ConnectionError("Extended Metadata request failed: No response body")
proto = BatchedExtensionResponse()
proto.ParseFromString(body)
entityextd = proto.extended_metadata.pop().extension_data.pop()
if entityextd.header.status_code != 200:
raise ConnectionError("Extended Metadata request failed: Status code {}".format(entityextd.header.status_code))
mdb: bytes = entityextd.extension_data.value
return mdb
def get_metadata_4_track(self, track: TrackId) -> Metadata.Track:
"""
:param track: TrackId:
"""
mdb = self.get_ext_metadata(ExtensionKind.TRACK_V4, track.to_spotify_uri())
md = Metadata.Track()
md.ParseFromString(mdb)
return md
response = self.sendToUrl("GET", "https://spclient.wg.spotify.com",
"/metadata/4/track/{}".format(track.hex_id()),
None, None)
ApiClient.StatusCodeException.check_status(response)
body = response.content
if body is None:
raise RuntimeError()
proto = Metadata.Track()
proto.ParseFromString(body)
return proto
def get_metadata_4_episode(self, episode: EpisodeId) -> Metadata.Episode:
"""
@@ -229,10 +229,16 @@ class ApiClient(Closeable):
:param episode: EpisodeId:
"""
mdb = self.get_ext_metadata(ExtensionKind.EPISODE_V4, episode.to_spotify_uri())
md = Metadata.Episode()
md.ParseFromString(mdb)
return md
response = self.sendToUrl("GET", "https://spclient.wg.spotify.com",
"/metadata/4/episode/{}".format(episode.hex_id()),
None, None)
ApiClient.StatusCodeException.check_status(response)
body = response.content
if body is None:
raise IOError()
proto = Metadata.Episode()
proto.ParseFromString(body)
return proto
def get_metadata_4_album(self, album: AlbumId) -> Metadata.Album:
"""
@@ -240,10 +246,17 @@ class ApiClient(Closeable):
:param album: AlbumId:
"""
mdb = self.get_ext_metadata(ExtensionKind.ALBUM_V4, album.to_spotify_uri())
md = Metadata.Album()
md.ParseFromString(mdb)
return md
response = self.sendToUrl("GET", "https://spclient.wg.spotify.com",
"/metadata/4/album/{}".format(album.hex_id()),
None, None)
ApiClient.StatusCodeException.check_status(response)
body = response.content
if body is None:
raise IOError()
proto = Metadata.Album()
proto.ParseFromString(body)
return proto
def get_metadata_4_artist(self, artist: ArtistId) -> Metadata.Artist:
"""
@@ -251,10 +264,16 @@ class ApiClient(Closeable):
:param artist: ArtistId:
"""
mdb = self.get_ext_metadata(ExtensionKind.ARTIST_V4, artist.to_spotify_uri())
md = Metadata.Artist()
md.ParseFromString(mdb)
return md
response = self.sendToUrl("GET", "https://spclient.wg.spotify.com",
"/metadata/4/artist/{}".format(artist.hex_id()),
None, None)
ApiClient.StatusCodeException.check_status(response)
body = response.content
if body is None:
raise IOError()
proto = Metadata.Artist()
proto.ParseFromString(body)
return proto
def get_metadata_4_show(self, show: ShowId) -> Metadata.Show:
"""
@@ -262,10 +281,16 @@ class ApiClient(Closeable):
:param show: ShowId:
"""
mdb = self.get_ext_metadata(ExtensionKind.SHOW_V4, show.to_spotify_uri())
md = Metadata.Show()
md.ParseFromString(mdb)
return md
response = self.send("GET",
"/metadata/4/show/{}".format(show.hex_id()), None,
None)
ApiClient.StatusCodeException.check_status(response)
body = response.content
if body is None:
raise IOError()
proto = Metadata.Show()
proto.ParseFromString(body)
return proto
def get_playlist(self,
_id: PlaylistId) -> Playlist4External.SelectedListContent:
@@ -285,6 +310,216 @@ class ApiClient(Closeable):
proto.ParseFromString(body)
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):
"""
@@ -319,10 +554,10 @@ class ApiClient(Closeable):
resp = requests.post(
"https://clienttoken.spotify.com/v1/clienttoken",
proto_req.SerializeToString(),
headers=CaseInsensitiveDict({
headers={
"Accept": "application/x-protobuf",
"Content-Encoding": "",
}),
},
)
ApiClient.StatusCodeException.check_status(resp)
@@ -596,10 +831,10 @@ class DealerClient(Closeable):
return
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")
if headers is None:
return CaseInsensitiveDict()
return {}
return headers
class ConnectionHolder(Closeable):
@@ -923,6 +1158,8 @@ class Session(Closeable, MessageListener, SubListener):
__stored_str: str = ""
__token_provider: typing.Union[TokenProvider, None]
__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:
self.__client = Session.create_client(inner.conf)
@@ -962,6 +1199,7 @@ class Session(Closeable, MessageListener, SubListener):
"""
self.__authenticate_partial(credential, False)
self.__authenticate_login5(credential)
with self.__auth_lock:
self.__mercury_client = MercuryClient(self)
self.__token_provider = TokenProvider(self)
@@ -1204,12 +1442,12 @@ class Session(Closeable, MessageListener, SubListener):
raise RuntimeError("Session isn't authenticated!")
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):
"""
:param uri: str:
:param headers: CaseInsensitiveDict[str:
:param headers: typing.Dict[str:
:param str]:
:param payload: bytes:
@@ -1379,6 +1617,64 @@ class Session(Closeable, MessageListener, SubListener):
def __send_unchecked(self, cmd: bytes, payload: bytes) -> None:
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:
if self.__closing and self.connection is None:
self.logger.debug("Connection was broken while closing.")
@@ -1610,7 +1906,6 @@ class Session(Closeable, MessageListener, SubListener):
pass
else:
try:
# Try Python librespot format first
self.login_credentials = Authentication.LoginCredentials(
typ=Authentication.AuthenticationType.Value(
obj["type"]),
@@ -1618,27 +1913,7 @@ class Session(Closeable, MessageListener, SubListener):
auth_data=base64.b64decode(obj["credentials"]),
)
except KeyError:
# Try Rust librespot format (auth_type as int, auth_data instead of credentials)
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, success_page_content = None) -> 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).set_success_page_content(success_page_content).flow()
pass
return self
def user_pass(self, username: str, password: str) -> Session.Builder:
@@ -1907,7 +2182,23 @@ class Session(Closeable, MessageListener, SubListener):
ap_address = address.split(":")[0]
ap_port = int(address.split(":")[1])
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)
def close(self) -> None:
@@ -1916,12 +2207,20 @@ class Session(Closeable, MessageListener, SubListener):
def flush(self) -> None:
"""Flush data to socket"""
try:
self.__buffer.seek(0)
self.__socket.send(self.__buffer.read())
self.__buffer = io.BytesIO()
except BrokenPipeError:
pass
attempts = 0
while True:
try:
self.__buffer.seek(0)
self.__socket.send(self.__buffer.read())
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:
"""Read data from socket
@@ -2023,12 +2322,41 @@ class Session(Closeable, MessageListener, SubListener):
self.__thread.start()
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
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:
"""Receive Packet thread function"""
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:
packet: Packet
cmd: bytes
@@ -2042,10 +2370,70 @@ class Session(Closeable, MessageListener, SubListener):
format(util.bytes_to_hex(packet.cmd),
packet.payload))
continue
except (RuntimeError, ConnectionResetError) as ex:
if self.__running:
except (RuntimeError, ConnectionResetError, OSError) as ex:
# 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(
"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()
break
if not self.__running:
@@ -2261,7 +2649,7 @@ class TokenProvider:
__tokens: typing.List[StoredToken] = []
def __init__(self, session: Session):
self.__session = session
self._session = session
def find_token_with_all_scopes(
self, scopes: typing.List[str]) -> typing.Union[StoredToken, None]:
@@ -2293,59 +2681,57 @@ class TokenProvider:
if len(scopes) == 0:
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)
if token is not None:
if token.expired():
self.__tokens.remove(token)
self.logger.debug("Login5 token expired, need to re-authenticate")
else:
return token
self.logger.debug(
"Token expired or not suitable, requesting again. scopes: {}, old_token: {}"
.format(scopes, token))
token = self.login5(scopes)
if token is not None:
try:
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.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]:
"""Submit Login5 request for a fresh access token"""
class Login5StoredToken:
"""StoredToken-compatible wrapper for Login5 access tokens"""
access_token: str
scopes: typing.List[str]
if self.__session.ap_welcome():
login5_request = Login5.LoginRequest()
login5_request.client_info.client_id = MercuryRequests.keymaster_client_id
login5_request.client_info.device_id = self.__session.device_id()
def __init__(self, access_token: str, scopes: typing.List[str]):
self.access_token = access_token
self.scopes = scopes
stored_cred = Login5Credentials.StoredCredential()
stored_cred.username = self.__session.username()
stored_cred.data = self.__session.ap_welcome().reusable_auth_credentials
login5_request.stored_credential.CopyFrom(stored_cred)
def expired(self) -> bool:
"""Login5 tokens are managed by Session, so delegate expiry check"""
return False # Session handles expiry
response = requests.post(
"https://login5.spotify.com/v3/login",
data=login5_request.SerializeToString(),
headers=CaseInsensitiveDict({
"Content-Type": "application/x-protobuf",
"Accept": "application/x-protobuf"
}))
def has_scope(self, scope: str) -> bool:
"""Login5 tokens are general-purpose, assume they have all scopes"""
return True
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")
def has_scopes(self, sc: typing.List[str]) -> bool:
"""Login5 tokens are general-purpose, assume they have all scopes"""
return True
class StoredToken:
""" """

View File

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

View File

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

View File

@@ -1,141 +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
__success_page_content = None
def __init__(self, client_id, redirect_url, oauth_url_callback):
self.__client_id = client_id
self.__redirect_url = redirect_url
self.__oauth_url_callback = oauth_url_callback
def set_success_page_content(self, content):
self.__success_page_content = content
return self
def __generate_generate_code_verifier(self):
possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
verifier = ""
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, success_page_content):
self.callback_path = callback_path
self.set_code = set_code
self.success_page_content = success_page_content
super().__init__(server_address, RequestHandlerClass)
class CallbackRequestHandler(BaseHTTPRequestHandler):
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.send_response(400)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(b"Request doesn't contain 'code'")
return
self.server.set_code(query.get("code")[0])
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
success_page = self.server.success_page_content or "librespot-python received callback"
self.wfile.write(success_page.encode('utf-8'))
pass
# Suppress logging
def log_message(self, format, *args) -> None:
return
def __start_server(self):
try:
self.__server.handle_request()
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,
self.__success_page_content,
)
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()

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 symbol_database as _symbol_database
from google.protobuf.internal import enum_type_wrapper
from . import ClientInfo_pb2 as spotify_dot_login5_dot_v3_dot_client__info__pb2
from . import UserInfo_pb2 as spotify_dot_login5_dot_v3_dot_user__info__pb2
from ..v3.challenges import Code_pb2 as spotify_dot_login5_dot_v3_dot_challenges_dot_code__pb2
from ..v3.challenges import Hashcash_pb2 as spotify_dot_login5_dot_v3_dot_challenges_dot_hashcash__pb2
from ..v3.credentials import Credentials_pb2 as spotify_dot_login5_dot_v3_dot_credentials_dot_credentials__pb2
from ..v3.identifiers import Identifiers_pb2 as spotify_dot_login5_dot_v3_dot_identifiers_dot_identifiers__pb2
from librespot.proto.spotify.login5.v3 import \
ClientInfo_pb2 as spotify_dot_login5_dot_v3_dot_client__info__pb2
from librespot.proto.spotify.login5.v3 import \
UserInfo_pb2 as spotify_dot_login5_dot_v3_dot_user__info__pb2
from librespot.proto.spotify.login5.v3.challenges import \
Code_pb2 as spotify_dot_login5_dot_v3_dot_challenges_dot_code__pb2
from librespot.proto.spotify.login5.v3.challenges import \
Hashcash_pb2 as spotify_dot_login5_dot_v3_dot_challenges_dot_hashcash__pb2
from librespot.proto.spotify.login5.v3.credentials import \
Credentials_pb2 as \
spotify_dot_login5_dot_v3_dot_credentials_dot_credentials__pb2
from librespot.proto.spotify.login5.v3.identifiers import \
Identifiers as \
spotify_dot_login5_dot_v3_dot_identifiers_dot_identifiers__pb2
# @@protoc_insertion_point(imports)

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.mercury import MercuryClient
from librespot.proto import Metadata_pb2 as Metadata
from requests.structures import CaseInsensitiveDict
class AudioDecrypt:
@@ -62,7 +61,7 @@ class HaltListener:
class MessageListener:
def on_message(self, uri: str, headers: CaseInsensitiveDict[str, str],
def on_message(self, uri: str, headers: typing.Dict[str, str],
payload: bytes):
raise NotImplementedError

View File

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