This commit is contained in:
@@ -6,7 +6,7 @@ import platform
|
|||||||
|
|
||||||
|
|
||||||
class Version:
|
class Version:
|
||||||
version_name = "0.0.10"
|
version_name = "0.0.9"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def platform() -> Platform:
|
def platform() -> Platform:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
@@ -331,7 +457,7 @@ class CdnFeedHelper:
|
|||||||
session: Session, track: Metadata.Track, file: Metadata.AudioFile,
|
session: Session, track: Metadata.Track, file: Metadata.AudioFile,
|
||||||
resp_or_url: typing.Union[StorageResolve.StorageResolveResponse,
|
resp_or_url: typing.Union[StorageResolve.StorageResolveResponse,
|
||||||
str], preload: bool,
|
str], preload: bool,
|
||||||
halt_listener: HaltListener) -> LoadedStream:
|
halt_listener: HaltListener) -> PlayableContentFeeder.LoadedStream:
|
||||||
if type(resp_or_url) is str:
|
if type(resp_or_url) is str:
|
||||||
url = resp_or_url
|
url = resp_or_url
|
||||||
else:
|
else:
|
||||||
@@ -345,17 +471,18 @@ class CdnFeedHelper:
|
|||||||
normalization_data = NormalizationData.read(input_stream)
|
normalization_data = NormalizationData.read(input_stream)
|
||||||
if input_stream.skip(0xA7) != 0xA7:
|
if input_stream.skip(0xA7) != 0xA7:
|
||||||
raise IOError("Couldn't skip 0xa7 bytes!")
|
raise IOError("Couldn't skip 0xa7 bytes!")
|
||||||
return LoadedStream(
|
return PlayableContentFeeder.LoadedStream(
|
||||||
track,
|
track,
|
||||||
streamer,
|
streamer,
|
||||||
normalization_data,
|
normalization_data,
|
||||||
file.file_id, preload, audio_key_time
|
PlayableContentFeeder.Metrics(file.file_id, preload,
|
||||||
|
-1 if preload else audio_key_time),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load_episode_external(
|
def load_episode_external(
|
||||||
session: Session, episode: Metadata.Episode,
|
session: Session, episode: Metadata.Episode,
|
||||||
halt_listener: HaltListener) -> LoadedStream:
|
halt_listener: HaltListener) -> PlayableContentFeeder.LoadedStream:
|
||||||
resp = session.client().head(episode.external_url)
|
resp = session.client().head(episode.external_url)
|
||||||
|
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
@@ -367,11 +494,11 @@ class CdnFeedHelper:
|
|||||||
|
|
||||||
streamer = session.cdn().stream_external_episode(
|
streamer = session.cdn().stream_external_episode(
|
||||||
episode, url, halt_listener)
|
episode, url, halt_listener)
|
||||||
return LoadedStream(
|
return PlayableContentFeeder.LoadedStream(
|
||||||
episode,
|
episode,
|
||||||
streamer,
|
streamer,
|
||||||
None,
|
None,
|
||||||
None, False, -1
|
PlayableContentFeeder.Metrics(None, False, -1),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -382,7 +509,7 @@ class CdnFeedHelper:
|
|||||||
resp_or_url: typing.Union[StorageResolve.StorageResolveResponse, str],
|
resp_or_url: typing.Union[StorageResolve.StorageResolveResponse, str],
|
||||||
preload: bool,
|
preload: bool,
|
||||||
halt_listener: HaltListener,
|
halt_listener: HaltListener,
|
||||||
) -> LoadedStream:
|
) -> PlayableContentFeeder.LoadedStream:
|
||||||
if type(resp_or_url) is str:
|
if type(resp_or_url) is str:
|
||||||
url = resp_or_url
|
url = resp_or_url
|
||||||
else:
|
else:
|
||||||
@@ -396,11 +523,12 @@ class CdnFeedHelper:
|
|||||||
normalization_data = NormalizationData.read(input_stream)
|
normalization_data = NormalizationData.read(input_stream)
|
||||||
if input_stream.skip(0xA7) != 0xA7:
|
if input_stream.skip(0xA7) != 0xA7:
|
||||||
raise IOError("Couldn't skip 0xa7 bytes!")
|
raise IOError("Couldn't skip 0xa7 bytes!")
|
||||||
return LoadedStream(
|
return PlayableContentFeeder.LoadedStream(
|
||||||
episode,
|
episode,
|
||||||
streamer,
|
streamer,
|
||||||
normalization_data,
|
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:
|
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
|
||||||
|
|
||||||
@@ -577,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("/")
|
||||||
@@ -630,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
|
||||||
@@ -746,9 +876,7 @@ class PlayableContentFeeder:
|
|||||||
episode: Metadata.Episode, preload: bool,
|
episode: Metadata.Episode, preload: bool,
|
||||||
halt_lister: HaltListener):
|
halt_lister: HaltListener):
|
||||||
if track is None and episode is None:
|
if track is None and episode is None:
|
||||||
raise RuntimeError("No content passed!")
|
raise RuntimeError()
|
||||||
elif file is None:
|
|
||||||
raise RuntimeError("Content has no audio file!")
|
|
||||||
response = self.resolve_storage_interactive(file.file_id, preload)
|
response = self.resolve_storage_interactive(file.file_id, preload)
|
||||||
if response.result == StorageResolve.StorageResolveResponse.Result.CDN:
|
if response.result == StorageResolve.StorageResolveResponse.Result.CDN:
|
||||||
if track is not None:
|
if track is not None:
|
||||||
@@ -778,27 +906,50 @@ class PlayableContentFeeder:
|
|||||||
self.logger.fatal(
|
self.logger.fatal(
|
||||||
"Couldn't find any suitable audio file, available: {}".format(
|
"Couldn't find any suitable audio file, available: {}".format(
|
||||||
episode.audio))
|
episode.audio))
|
||||||
raise FeederException("Cannot find suitable audio file")
|
|
||||||
return self.load_stream(file, None, episode, preload, halt_listener)
|
return self.load_stream(file, None, episode, preload, halt_listener)
|
||||||
|
|
||||||
def load_track(self, track_id_or_track: typing.Union[TrackId,
|
def load_track(self, track_id_or_track: typing.Union[TrackId,
|
||||||
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(
|
||||||
"Couldn't find any suitable audio file, available: {}".format(
|
"Couldn't find any suitable audio file, available: {}".format(
|
||||||
track.file))
|
track.file))
|
||||||
raise FeederException("Cannot find suitable audio file")
|
raise FeederException()
|
||||||
return self.load_stream(file, track, None, preload, halt_listener)
|
return self.load_stream(file, track, None, preload, halt_listener)
|
||||||
|
|
||||||
def pick_alternative_if_necessary(
|
def pick_alternative_if_necessary(
|
||||||
@@ -829,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:
|
||||||
@@ -849,13 +1039,29 @@ class PlayableContentFeeder:
|
|||||||
storage_resolve_response.ParseFromString(body)
|
storage_resolve_response.ParseFromString(body)
|
||||||
return storage_resolve_response
|
return storage_resolve_response
|
||||||
|
|
||||||
|
class LoadedStream:
|
||||||
|
episode: Metadata.Episode
|
||||||
|
track: Metadata.Track
|
||||||
|
input_stream: GeneralAudioStream
|
||||||
|
normalization_data: NormalizationData
|
||||||
|
metrics: PlayableContentFeeder.Metrics
|
||||||
|
|
||||||
class LoadedStream:
|
def __init__(self, track_or_episode: typing.Union[Metadata.Track,
|
||||||
episode: Metadata.Episode
|
Metadata.Episode],
|
||||||
track: Metadata.Track
|
input_stream: GeneralAudioStream,
|
||||||
input_stream: GeneralAudioStream
|
normalization_data: typing.Union[NormalizationData, None],
|
||||||
normalization_data: NormalizationData
|
metrics: PlayableContentFeeder.Metrics):
|
||||||
metrics: 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:
|
class Metrics:
|
||||||
file_id: str
|
file_id: str
|
||||||
@@ -863,27 +1069,13 @@ class LoadedStream:
|
|||||||
audio_key_time: int
|
audio_key_time: int
|
||||||
|
|
||||||
def __init__(self, file_id: typing.Union[bytes, None],
|
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(
|
self.file_id = None if file_id is None else util.bytes_to_hex(
|
||||||
file_id)
|
file_id)
|
||||||
self.preloaded_audio_key = preloaded_audio_key
|
self.preloaded_audio_key = preloaded_audio_key
|
||||||
self.audio_key_time = -1 if preloaded_audio_key else audio_key_time
|
self.audio_key_time = audio_key_time
|
||||||
|
if preloaded_audio_key and audio_key_time != -1:
|
||||||
def __init__(self, track_or_episode: typing.Union[Metadata.Track, Metadata.Episode],
|
raise RuntimeError()
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class StreamId:
|
class StreamId:
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,11 +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.ExtendedMetadata_pb2 import EntityRequest, BatchedEntityRequest, ExtensionQuery, BatchedExtensionResponse
|
from librespot.proto_ext import audio_files_extension_pb2
|
||||||
from librespot.proto.ExtensionKind_pb2 import ExtensionKind
|
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
|
||||||
@@ -83,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:
|
||||||
@@ -92,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]:
|
||||||
@@ -106,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))
|
||||||
|
|
||||||
if url is None:
|
merged_headers: dict[str, str] = {}
|
||||||
url = self.__base_url + suffix
|
if headers is not None:
|
||||||
else:
|
merged_headers.update(headers)
|
||||||
url = url + suffix
|
|
||||||
|
|
||||||
if headers is None:
|
if "Authorization" not in merged_headers:
|
||||||
headers = CaseInsensitiveDict()
|
merged_headers["Authorization"] = "Bearer {}".format(
|
||||||
headers["Authorization"] = "Bearer {}".format(
|
self.__session.tokens().get("playlist-read"))
|
||||||
self.__session.tokens().get("playlist-read"))
|
|
||||||
headers["client-token"] = self.__client_token_str
|
|
||||||
|
|
||||||
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(
|
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:
|
||||||
"""
|
"""
|
||||||
@@ -133,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]:
|
||||||
@@ -148,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))
|
||||||
@@ -192,36 +206,22 @@ class ApiClient(Closeable):
|
|||||||
self.logger.warning("PUT state returned {}. headers: {}".format(
|
self.logger.warning("PUT state returned {}. headers: {}".format(
|
||||||
response.status_code, response.headers))
|
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:
|
def get_metadata_4_track(self, track: TrackId) -> Metadata.Track:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
:param track: TrackId:
|
:param track: TrackId:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
mdb = self.get_ext_metadata(ExtensionKind.TRACK_V4, track.to_spotify_uri())
|
response = self.sendToUrl("GET", "https://spclient.wg.spotify.com",
|
||||||
md = Metadata.Track()
|
"/metadata/4/track/{}".format(track.hex_id()),
|
||||||
md.ParseFromString(mdb)
|
None, None)
|
||||||
return md
|
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:
|
def get_metadata_4_episode(self, episode: EpisodeId) -> Metadata.Episode:
|
||||||
"""
|
"""
|
||||||
@@ -229,10 +229,16 @@ class ApiClient(Closeable):
|
|||||||
:param episode: EpisodeId:
|
:param episode: EpisodeId:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
mdb = self.get_ext_metadata(ExtensionKind.EPISODE_V4, episode.to_spotify_uri())
|
response = self.sendToUrl("GET", "https://spclient.wg.spotify.com",
|
||||||
md = Metadata.Episode()
|
"/metadata/4/episode/{}".format(episode.hex_id()),
|
||||||
md.ParseFromString(mdb)
|
None, None)
|
||||||
return md
|
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:
|
def get_metadata_4_album(self, album: AlbumId) -> Metadata.Album:
|
||||||
"""
|
"""
|
||||||
@@ -240,10 +246,17 @@ class ApiClient(Closeable):
|
|||||||
:param album: AlbumId:
|
:param album: AlbumId:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
mdb = self.get_ext_metadata(ExtensionKind.ALBUM_V4, album.to_spotify_uri())
|
response = self.sendToUrl("GET", "https://spclient.wg.spotify.com",
|
||||||
md = Metadata.Album()
|
"/metadata/4/album/{}".format(album.hex_id()),
|
||||||
md.ParseFromString(mdb)
|
None, None)
|
||||||
return md
|
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:
|
def get_metadata_4_artist(self, artist: ArtistId) -> Metadata.Artist:
|
||||||
"""
|
"""
|
||||||
@@ -251,10 +264,16 @@ class ApiClient(Closeable):
|
|||||||
:param artist: ArtistId:
|
:param artist: ArtistId:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
mdb = self.get_ext_metadata(ExtensionKind.ARTIST_V4, artist.to_spotify_uri())
|
response = self.sendToUrl("GET", "https://spclient.wg.spotify.com",
|
||||||
md = Metadata.Artist()
|
"/metadata/4/artist/{}".format(artist.hex_id()),
|
||||||
md.ParseFromString(mdb)
|
None, None)
|
||||||
return md
|
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:
|
def get_metadata_4_show(self, show: ShowId) -> Metadata.Show:
|
||||||
"""
|
"""
|
||||||
@@ -262,10 +281,16 @@ class ApiClient(Closeable):
|
|||||||
:param show: ShowId:
|
:param show: ShowId:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
mdb = self.get_ext_metadata(ExtensionKind.SHOW_V4, show.to_spotify_uri())
|
response = self.send("GET",
|
||||||
md = Metadata.Show()
|
"/metadata/4/show/{}".format(show.hex_id()), None,
|
||||||
md.ParseFromString(mdb)
|
None)
|
||||||
return md
|
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,
|
def get_playlist(self,
|
||||||
_id: PlaylistId) -> Playlist4External.SelectedListContent:
|
_id: PlaylistId) -> Playlist4External.SelectedListContent:
|
||||||
@@ -285,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):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -319,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)
|
||||||
@@ -596,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):
|
||||||
@@ -923,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)
|
||||||
@@ -962,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)
|
||||||
@@ -1204,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:
|
||||||
|
|
||||||
@@ -1379,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.")
|
||||||
@@ -1610,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"]),
|
||||||
@@ -1618,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, 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()
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def user_pass(self, username: str, password: str) -> Session.Builder:
|
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_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:
|
||||||
@@ -1916,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
|
||||||
@@ -2023,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
|
||||||
@@ -2042,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:
|
||||||
@@ -2261,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]:
|
||||||
@@ -2293,59 +2681,57 @@ class TokenProvider:
|
|||||||
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()
|
|
||||||
|
|
||||||
stored_cred = Login5Credentials.StoredCredential()
|
def expired(self) -> bool:
|
||||||
stored_cred.username = self.__session.username()
|
"""Login5 tokens are managed by Session, so delegate expiry check"""
|
||||||
stored_cred.data = self.__session.ap_welcome().reusable_auth_credentials
|
return False # Session handles expiry
|
||||||
login5_request.stored_credential.CopyFrom(stored_cred)
|
|
||||||
|
|
||||||
response = requests.post(
|
def has_scope(self, scope: str) -> bool:
|
||||||
"https://login5.spotify.com/v3/login",
|
"""Login5 tokens are general-purpose, assume they have all scopes"""
|
||||||
data=login5_request.SerializeToString(),
|
return True
|
||||||
headers=CaseInsensitiveDict({
|
|
||||||
"Content-Type": "application/x-protobuf",
|
|
||||||
"Accept": "application/x-protobuf"
|
|
||||||
}))
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
def has_scopes(self, sc: typing.List[str]) -> bool:
|
||||||
login5_response = Login5.LoginResponse()
|
"""Login5 tokens are general-purpose, assume they have all scopes"""
|
||||||
login5_response.ParseFromString(response.content)
|
return True
|
||||||
|
|
||||||
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:
|
||||||
""" """
|
""" """
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
119
librespot/proto/spotify/login5/v3/identifiers/Identifiers.py
Normal file
119
librespot/proto/spotify/login5/v3/identifiers/Identifiers.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||||
|
# source: spotify/login5/v3/identifiers/identifiers.proto
|
||||||
|
"""Generated protocol buffer code."""
|
||||||
|
from google.protobuf import descriptor as _descriptor
|
||||||
|
from google.protobuf import message as _message
|
||||||
|
from google.protobuf import reflection as _reflection
|
||||||
|
from google.protobuf import symbol_database as _symbol_database
|
||||||
|
|
||||||
|
# @@protoc_insertion_point(imports)
|
||||||
|
|
||||||
|
_sym_db = _symbol_database.Default()
|
||||||
|
|
||||||
|
DESCRIPTOR = _descriptor.FileDescriptor(
|
||||||
|
name="spotify/login5/v3/identifiers/identifiers.proto",
|
||||||
|
package="spotify.login5.v3.identifiers",
|
||||||
|
syntax="proto3",
|
||||||
|
serialized_options=b"\n\024com.spotify.login5v3",
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
serialized_pb=
|
||||||
|
b'\n/spotify/login5/v3/identifiers/identifiers.proto\x12\x1dspotify.login5.v3.identifiers"U\n\x0bPhoneNumber\x12\x0e\n\x06number\x18\x01 \x01(\t\x12\x18\n\x10iso_country_code\x18\x02 \x01(\t\x12\x1c\n\x14\x63ountry_calling_code\x18\x03 \x01(\tB\x16\n\x14\x63om.spotify.login5v3b\x06proto3',
|
||||||
|
)
|
||||||
|
|
||||||
|
_PHONENUMBER = _descriptor.Descriptor(
|
||||||
|
name="PhoneNumber",
|
||||||
|
full_name="spotify.login5.v3.identifiers.PhoneNumber",
|
||||||
|
filename=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
containing_type=None,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
fields=[
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name="number",
|
||||||
|
full_name="spotify.login5.v3.identifiers.PhoneNumber.number",
|
||||||
|
index=0,
|
||||||
|
number=1,
|
||||||
|
type=9,
|
||||||
|
cpp_type=9,
|
||||||
|
label=1,
|
||||||
|
has_default_value=False,
|
||||||
|
default_value=b"".decode("utf-8"),
|
||||||
|
message_type=None,
|
||||||
|
enum_type=None,
|
||||||
|
containing_type=None,
|
||||||
|
is_extension=False,
|
||||||
|
extension_scope=None,
|
||||||
|
serialized_options=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name="iso_country_code",
|
||||||
|
full_name=
|
||||||
|
"spotify.login5.v3.identifiers.PhoneNumber.iso_country_code",
|
||||||
|
index=1,
|
||||||
|
number=2,
|
||||||
|
type=9,
|
||||||
|
cpp_type=9,
|
||||||
|
label=1,
|
||||||
|
has_default_value=False,
|
||||||
|
default_value=b"".decode("utf-8"),
|
||||||
|
message_type=None,
|
||||||
|
enum_type=None,
|
||||||
|
containing_type=None,
|
||||||
|
is_extension=False,
|
||||||
|
extension_scope=None,
|
||||||
|
serialized_options=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
),
|
||||||
|
_descriptor.FieldDescriptor(
|
||||||
|
name="country_calling_code",
|
||||||
|
full_name=
|
||||||
|
"spotify.login5.v3.identifiers.PhoneNumber.country_calling_code",
|
||||||
|
index=2,
|
||||||
|
number=3,
|
||||||
|
type=9,
|
||||||
|
cpp_type=9,
|
||||||
|
label=1,
|
||||||
|
has_default_value=False,
|
||||||
|
default_value=b"".decode("utf-8"),
|
||||||
|
message_type=None,
|
||||||
|
enum_type=None,
|
||||||
|
containing_type=None,
|
||||||
|
is_extension=False,
|
||||||
|
extension_scope=None,
|
||||||
|
serialized_options=None,
|
||||||
|
file=DESCRIPTOR,
|
||||||
|
create_key=_descriptor._internal_create_key,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
extensions=[],
|
||||||
|
nested_types=[],
|
||||||
|
enum_types=[],
|
||||||
|
serialized_options=None,
|
||||||
|
is_extendable=False,
|
||||||
|
syntax="proto3",
|
||||||
|
extension_ranges=[],
|
||||||
|
oneofs=[],
|
||||||
|
serialized_start=82,
|
||||||
|
serialized_end=167,
|
||||||
|
)
|
||||||
|
|
||||||
|
DESCRIPTOR.message_types_by_name["PhoneNumber"] = _PHONENUMBER
|
||||||
|
_sym_db.RegisterFileDescriptor(DESCRIPTOR)
|
||||||
|
|
||||||
|
PhoneNumber = _reflection.GeneratedProtocolMessageType(
|
||||||
|
"PhoneNumber",
|
||||||
|
(_message.Message, ),
|
||||||
|
{
|
||||||
|
"DESCRIPTOR": _PHONENUMBER,
|
||||||
|
"__module__": "spotify.login5.v3.identifiers.identifiers_pb2"
|
||||||
|
# @@protoc_insertion_point(class_scope:spotify.login5.v3.identifiers.PhoneNumber)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_sym_db.RegisterMessage(PhoneNumber)
|
||||||
|
|
||||||
|
DESCRIPTOR._options = None
|
||||||
|
# @@protoc_insertion_point(module_scope)
|
||||||
1
librespot/proto_ext/__init__.py
Normal file
1
librespot/proto_ext/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Protobuf helpers for Spotify extended metadata."""
|
||||||
33
librespot/proto_ext/audio_files_extension_pb2.py
Normal file
33
librespot/proto_ext/audio_files_extension_pb2.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||||
|
# NO CHECKED-IN PROTOBUF GENCODE
|
||||||
|
# source: audio_files_extension.proto
|
||||||
|
# Protobuf Python Version: 6.31.1
|
||||||
|
"""Generated protocol buffer code."""
|
||||||
|
from google.protobuf import descriptor as _descriptor
|
||||||
|
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||||
|
from google.protobuf import symbol_database as _symbol_database
|
||||||
|
from google.protobuf.internal import builder as _builder
|
||||||
|
# @@protoc_insertion_point(imports)
|
||||||
|
|
||||||
|
_sym_db = _symbol_database.Default()
|
||||||
|
|
||||||
|
|
||||||
|
from librespot.proto import Metadata_pb2 as metadata__pb2
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1baudio_files_extension.proto\x12#spotify.extendedmetadata.audiofiles\x1a\x0emetadata.proto"@\n\x13NormalizationParams\x12\x13\n\x0bloudness_db\x18\x01 \x01(\x02\x12\x14\n\x0ctrue_peak_db\x18\x02 \x01(\x02"i\n\x11ExtendedAudioFile\x12/\n\x04file\x18\x01 \x01(\x0b2!.spotify.metadata.proto.AudioFile\x12\x17\n\x0faverage_bitrate\x18\x04 \x01(\x05J\x04\x08\x02\x10\x03J\x04\x08\x03\x10\x04"\xc1\x02\n\x1bAudioFilesExtensionResponse\x12E\n\x05files\x18\x01 \x03(\x0b26.spotify.extendedmetadata.audiofiles.ExtendedAudioFile\x12c\n!default_file_normalization_params\x18\x02 \x01(\x0b28.spotify.extendedmetadata.audiofiles.NormalizationParams\x12d\n"default_album_normalization_params\x18\x03 \x01(\x0b28.spotify.extendedmetadata.audiofiles.NormalizationParams\x12\x10\n\x08audio_id\x18\x04 \x01(\x0cB \n\x1ccom.spotify.audiophile.protoH\x02b\x06proto3')
|
||||||
|
|
||||||
|
_globals = globals()
|
||||||
|
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||||
|
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'audio_files_extension_pb2', _globals)
|
||||||
|
if not _descriptor._USE_C_DESCRIPTORS:
|
||||||
|
_globals['DESCRIPTOR']._loaded_options = None
|
||||||
|
_globals['DESCRIPTOR']._serialized_options = b'\n\034com.spotify.audiophile.protoH\002'
|
||||||
|
_globals['_NORMALIZATIONPARAMS']._serialized_start=84
|
||||||
|
_globals['_NORMALIZATIONPARAMS']._serialized_end=148
|
||||||
|
_globals['_EXTENDEDAUDIOFILE']._serialized_start=150
|
||||||
|
_globals['_EXTENDEDAUDIOFILE']._serialized_end=249
|
||||||
|
_globals['_AUDIOFILESEXTENSIONRESPONSE']._serialized_start=252
|
||||||
|
_globals['_AUDIOFILESEXTENSIONRESPONSE']._serialized_end=573
|
||||||
|
# @@protoc_insertion_point(module_scope)
|
||||||
37
librespot/proto_ext/entity_extension_data_pb2.py
Normal file
37
librespot/proto_ext/entity_extension_data_pb2.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||||
|
# NO CHECKED-IN PROTOBUF GENCODE
|
||||||
|
# source: entity_extension_data.proto
|
||||||
|
# Protobuf Python Version: 6.31.1
|
||||||
|
"""Generated protocol buffer code."""
|
||||||
|
from google.protobuf import descriptor as _descriptor
|
||||||
|
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||||
|
from google.protobuf import symbol_database as _symbol_database
|
||||||
|
from google.protobuf.internal import builder as _builder
|
||||||
|
# @@protoc_insertion_point(imports)
|
||||||
|
|
||||||
|
_sym_db = _symbol_database.Default()
|
||||||
|
|
||||||
|
|
||||||
|
from google.protobuf import any_pb2 as google_dot_protobuf_dot_any__pb2
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1b\x65ntity_extension_data.proto\x12\x18spotify.extendedmetadata\x1a\x19google/protobuf/any.proto\"\x8c\x01\n\x19\x45ntityExtensionDataHeader\x12\x13\n\x0bstatus_code\x18\x01 \x01(\x05\x12\x0c\n\x04\x65tag\x18\x02 \x01(\t\x12\x0e\n\x06locale\x18\x03 \x01(\t\x12\x1c\n\x14\x63\x61\x63he_ttl_in_seconds\x18\x04 \x01(\x03\x12\x1e\n\x16offline_ttl_in_seconds\x18\x05 \x01(\x03\"\x9c\x01\n\x13\x45ntityExtensionData\x12\x43\n\x06header\x18\x01 \x01(\x0b\x32\x33.spotify.extendedmetadata.EntityExtensionDataHeader\x12\x12\n\nentity_uri\x18\x02 \x01(\t\x12,\n\x0e\x65xtension_data\x18\x03 \x01(\x0b\x32\x14.google.protobuf.Any\"$\n\x0ePlainListAssoc\x12\x12\n\nentity_uri\x18\x01 \x03(\t\"\r\n\x0b\x41ssocHeader\"|\n\x05\x41ssoc\x12\x35\n\x06header\x18\x01 \x01(\x0b\x32%.spotify.extendedmetadata.AssocHeader\x12<\n\nplain_list\x18\x02 \x01(\x0b\x32(.spotify.extendedmetadata.PlainListAssocB+\n\"com.spotify.extendedmetadata.protoH\x02P\x01\xf8\x01\x01\x62\x06proto3')
|
||||||
|
|
||||||
|
_globals = globals()
|
||||||
|
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||||
|
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'entity_extension_data_pb2', _globals)
|
||||||
|
if not _descriptor._USE_C_DESCRIPTORS:
|
||||||
|
_globals['DESCRIPTOR']._loaded_options = None
|
||||||
|
_globals['DESCRIPTOR']._serialized_options = b'\n\"com.spotify.extendedmetadata.protoH\002P\001\370\001\001'
|
||||||
|
_globals['_ENTITYEXTENSIONDATAHEADER']._serialized_start=85
|
||||||
|
_globals['_ENTITYEXTENSIONDATAHEADER']._serialized_end=225
|
||||||
|
_globals['_ENTITYEXTENSIONDATA']._serialized_start=228
|
||||||
|
_globals['_ENTITYEXTENSIONDATA']._serialized_end=384
|
||||||
|
_globals['_PLAINLISTASSOC']._serialized_start=386
|
||||||
|
_globals['_PLAINLISTASSOC']._serialized_end=422
|
||||||
|
_globals['_ASSOCHEADER']._serialized_start=424
|
||||||
|
_globals['_ASSOCHEADER']._serialized_end=437
|
||||||
|
_globals['_ASSOC']._serialized_start=439
|
||||||
|
_globals['_ASSOC']._serialized_end=563
|
||||||
|
# @@protoc_insertion_point(module_scope)
|
||||||
46
librespot/proto_ext/extended_metadata_pb2.py
Normal file
46
librespot/proto_ext/extended_metadata_pb2.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||||
|
# NO CHECKED-IN PROTOBUF GENCODE
|
||||||
|
# source: extended_metadata.proto
|
||||||
|
# Protobuf Python Version: 6.31.1
|
||||||
|
"""Generated protocol buffer code."""
|
||||||
|
from google.protobuf import descriptor as _descriptor
|
||||||
|
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||||
|
from google.protobuf import symbol_database as _symbol_database
|
||||||
|
from google.protobuf.internal import builder as _builder
|
||||||
|
# @@protoc_insertion_point(imports)
|
||||||
|
|
||||||
|
_sym_db = _symbol_database.Default()
|
||||||
|
|
||||||
|
|
||||||
|
from . import extension_kind_pb2 as extension__kind__pb2
|
||||||
|
from . import entity_extension_data_pb2 as entity__extension__data__pb2
|
||||||
|
|
||||||
|
|
||||||
|
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17\x65xtended_metadata.proto\x12\x18spotify.extendedmetadata\x1a\x14\x65xtension_kind.proto\x1a\x1b\x65ntity_extension_data.proto\"_\n\x0e\x45xtensionQuery\x12?\n\x0e\x65xtension_kind\x18\x01 \x01(\x0e\x32\'.spotify.extendedmetadata.ExtensionKind\x12\x0c\n\x04\x65tag\x18\x02 \x01(\t\"\\\n\rEntityRequest\x12\x12\n\nentity_uri\x18\x01 \x01(\t\x12\x37\n\x05query\x18\x02 \x03(\x0b\x32(.spotify.extendedmetadata.ExtensionQuery\"Q\n\x1a\x42\x61tchedEntityRequestHeader\x12\x0f\n\x07\x63ountry\x18\x01 \x01(\t\x12\x11\n\tcatalogue\x18\x02 \x01(\t\x12\x0f\n\x07task_id\x18\x03 \x01(\x0c\"\x9d\x01\n\x14\x42\x61tchedEntityRequest\x12\x44\n\x06header\x18\x01 \x01(\x0b\x32\x34.spotify.extendedmetadata.BatchedEntityRequestHeader\x12?\n\x0e\x65ntity_request\x18\x02 \x03(\x0b\x32\'.spotify.extendedmetadata.EntityRequest\"\xbe\x01\n\x1e\x45ntityExtensionDataArrayHeader\x12\x1d\n\x15provider_error_status\x18\x01 \x01(\x05\x12\x1c\n\x14\x63\x61\x63he_ttl_in_seconds\x18\x02 \x01(\x03\x12\x1e\n\x16offline_ttl_in_seconds\x18\x03 \x01(\x03\x12?\n\x0e\x65xtension_type\x18\x04 \x01(\x0e\x32\'.spotify.extendedmetadata.ExtensionType\"\xec\x01\n\x18\x45ntityExtensionDataArray\x12H\n\x06header\x18\x01 \x01(\x0b\x32\x38.spotify.extendedmetadata.EntityExtensionDataArrayHeader\x12?\n\x0e\x65xtension_kind\x18\x02 \x01(\x0e\x32\'.spotify.extendedmetadata.ExtensionKind\x12\x45\n\x0e\x65xtension_data\x18\x03 \x03(\x0b\x32-.spotify.extendedmetadata.EntityExtensionData\" \n\x1e\x42\x61tchedExtensionResponseHeader\"\xb3\x01\n\x18\x42\x61tchedExtensionResponse\x12H\n\x06header\x18\x01 \x01(\x0b\x32\x38.spotify.extendedmetadata.BatchedExtensionResponseHeader\x12M\n\x11\x65xtended_metadata\x18\x02 \x03(\x0b\x32\x32.spotify.extendedmetadata.EntityExtensionDataArray*4\n\rExtensionType\x12\x0b\n\x07UNKNOWN\x10\x00\x12\x0b\n\x07GENERIC\x10\x01\x12\t\n\x05\x41SSOC\x10\x02\x42+\n\"com.spotify.extendedmetadata.protoH\x02P\x01\xf8\x01\x01\x62\x06proto3')
|
||||||
|
|
||||||
|
_globals = globals()
|
||||||
|
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||||
|
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'extended_metadata_pb2', _globals)
|
||||||
|
if not _descriptor._USE_C_DESCRIPTORS:
|
||||||
|
_globals['DESCRIPTOR']._loaded_options = None
|
||||||
|
_globals['DESCRIPTOR']._serialized_options = b'\n\"com.spotify.extendedmetadata.protoH\002P\001\370\001\001'
|
||||||
|
_globals['_EXTENSIONTYPE']._serialized_start=1186
|
||||||
|
_globals['_EXTENSIONTYPE']._serialized_end=1238
|
||||||
|
_globals['_EXTENSIONQUERY']._serialized_start=104
|
||||||
|
_globals['_EXTENSIONQUERY']._serialized_end=199
|
||||||
|
_globals['_ENTITYREQUEST']._serialized_start=201
|
||||||
|
_globals['_ENTITYREQUEST']._serialized_end=293
|
||||||
|
_globals['_BATCHEDENTITYREQUESTHEADER']._serialized_start=295
|
||||||
|
_globals['_BATCHEDENTITYREQUESTHEADER']._serialized_end=376
|
||||||
|
_globals['_BATCHEDENTITYREQUEST']._serialized_start=379
|
||||||
|
_globals['_BATCHEDENTITYREQUEST']._serialized_end=536
|
||||||
|
_globals['_ENTITYEXTENSIONDATAARRAYHEADER']._serialized_start=539
|
||||||
|
_globals['_ENTITYEXTENSIONDATAARRAYHEADER']._serialized_end=729
|
||||||
|
_globals['_ENTITYEXTENSIONDATAARRAY']._serialized_start=732
|
||||||
|
_globals['_ENTITYEXTENSIONDATAARRAY']._serialized_end=968
|
||||||
|
_globals['_BATCHEDEXTENSIONRESPONSEHEADER']._serialized_start=970
|
||||||
|
_globals['_BATCHEDEXTENSIONRESPONSEHEADER']._serialized_end=1002
|
||||||
|
_globals['_BATCHEDEXTENSIONRESPONSE']._serialized_start=1005
|
||||||
|
_globals['_BATCHEDEXTENSIONRESPONSE']._serialized_end=1184
|
||||||
|
# @@protoc_insertion_point(module_scope)
|
||||||
29
librespot/proto_ext/extension_kind_pb2.py
Normal file
29
librespot/proto_ext/extension_kind_pb2.py
Normal file
File diff suppressed because one or more lines are too long
@@ -8,7 +8,6 @@ if typing.TYPE_CHECKING:
|
|||||||
from librespot.crypto import Packet
|
from librespot.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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user