This commit is contained in:
+66
-8
@@ -25,6 +25,18 @@ class SpotifyError(RuntimeError):
|
||||
"""Spotify answered, but said no (or never answered at all)."""
|
||||
|
||||
|
||||
class SpotifyDeviceUnavailable(SpotifyError):
|
||||
"""The room is not in this account's device list. Usually that is not a
|
||||
fault at all: a Connect receiver is signed in to ONE Spotify account at
|
||||
a time, and Spotify hides it from every other account until that one
|
||||
lets go -- which only a caller that knows the other accounts can say,
|
||||
hence the device name travelling with the error."""
|
||||
|
||||
def __init__(self, message, device_name):
|
||||
super().__init__(message)
|
||||
self.device_name = device_name
|
||||
|
||||
|
||||
class SpotifyClient:
|
||||
"""One access token, refreshed on demand, guarded by a lock the same
|
||||
way HeosClient guards its socket."""
|
||||
@@ -42,6 +54,8 @@ class SpotifyClient:
|
||||
self.api_url = api_url or self.API_URL
|
||||
self._access_token = None
|
||||
self._expires_at = 0
|
||||
self._scopes = set()
|
||||
self._me = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# -- auth ------------------------------------------------------------
|
||||
@@ -70,6 +84,10 @@ class SpotifyClient:
|
||||
raise SpotifyError(f"Could not reach Spotify: {exc.reason}") from exc
|
||||
self._access_token = payload["access_token"]
|
||||
self._expires_at = time.time() + payload.get("expires_in", 3600)
|
||||
# The only place Spotify ever says what this login is allowed to do:
|
||||
# scopes are fixed when the refresh token is minted, so a feature
|
||||
# needing one can ask instead of finding out the hard way.
|
||||
self._scopes = set((payload.get("scope") or "").split())
|
||||
return self._access_token
|
||||
|
||||
def _token(self) -> str:
|
||||
@@ -119,6 +137,26 @@ class SpotifyClient:
|
||||
whether or not anything is currently playing."""
|
||||
return self._call("GET", "/v1/me/player/devices").get("devices", [])
|
||||
|
||||
def me(self) -> dict:
|
||||
"""Who this refresh token belongs to. Its "id" is the canonical
|
||||
username a speaker wants when signing the account in over the LAN
|
||||
-- see spotify_zc.Speaker.add_user. Asked once: a refresh token
|
||||
belongs to the one account for as long as it is a refresh token."""
|
||||
if self._me is None:
|
||||
self._me = self._call("GET", "/v1/me")
|
||||
return self._me
|
||||
|
||||
def has_scope(self, scope: str) -> bool:
|
||||
"""Whether this login carries a scope. Getting a token is what
|
||||
makes Spotify tell us, so make sure we have one."""
|
||||
self._token()
|
||||
return scope in self._scopes
|
||||
|
||||
def access_token(self) -> str:
|
||||
"""The current access token, for the one caller that has to hand it
|
||||
somewhere other than our own requests: a speaker's zeroconf login."""
|
||||
return self._token()
|
||||
|
||||
def playback(self) -> dict:
|
||||
"""The account's current playback -- which device, and whether it is
|
||||
playing -- or {} when the account has no playback session at all."""
|
||||
@@ -131,15 +169,23 @@ class SpotifyClient:
|
||||
devices = self.devices()
|
||||
matches = [d for d in devices if d.get("name") == device_name]
|
||||
if not matches:
|
||||
visible = ", ".join(d.get("name", "?") for d in devices) or "none"
|
||||
raise SpotifyError(
|
||||
f"No Spotify Connect device named '{device_name}' is visible right now "
|
||||
f"(Spotify sees: {visible}) -- is the room powered on, or does its "
|
||||
"spotify_name in config.py need setting?")
|
||||
names = [d.get("name", "?") for d in devices]
|
||||
raise SpotifyDeviceUnavailable(
|
||||
f"No Spotify Connect device named '{device_name}' is visible to this "
|
||||
f"account right now (Spotify sees: {', '.join(names) or 'none'}) -- is "
|
||||
"the room powered on, is it already connected to another Spotify "
|
||||
"account, or does its spotify_name in config.json need setting?",
|
||||
device_name)
|
||||
device = matches[0]
|
||||
self._call("PUT", "/v1/me/player", body={"device_ids": [device["id"]], "play": True})
|
||||
return device
|
||||
|
||||
def pause(self):
|
||||
"""Pause whatever this account is playing, on whichever device is
|
||||
playing it. Spotify refuses this with a 403 when nothing is, so ask
|
||||
playback() first rather than pausing on spec."""
|
||||
self._call("PUT", "/v1/me/player/pause")
|
||||
|
||||
def seek(self, position_ms: int):
|
||||
"""Jump to position_ms in whatever the account is playing, on
|
||||
whichever device is playing it."""
|
||||
@@ -147,9 +193,21 @@ class SpotifyClient:
|
||||
|
||||
|
||||
def _error_detail(exc: urllib.error.HTTPError) -> str:
|
||||
"""Whatever Spotify actually said, since a bare "Forbidden" sends you
|
||||
nowhere. Most refusals come back as JSON, but some answer with a plain
|
||||
sentence instead -- notably the 403 for an account the developer app
|
||||
has not listed under User Management -- so fall through to the raw body
|
||||
before giving up and quoting the status line."""
|
||||
try:
|
||||
with exc:
|
||||
payload = json.loads(exc.read())
|
||||
return payload.get("error_description") or payload.get("error", {}).get("message") or exc.reason
|
||||
raw = exc.read()
|
||||
except (ValueError, OSError):
|
||||
raw = b""
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
detail = payload.get("error_description") or payload.get("error", {}).get("message")
|
||||
except (ValueError, AttributeError, KeyError):
|
||||
return exc.reason
|
||||
detail = None
|
||||
# A body that is neither JSON nor a short sentence is some proxy's error
|
||||
# page, not Spotify's -- keep enough of it to recognise, not all of it.
|
||||
return detail or raw.decode(errors="replace").strip()[:200] or exc.reason
|
||||
|
||||
Reference in New Issue
Block a user