Fix account swaping
Deploy HEOS panel / deploy (push) Successful in 26s

This commit is contained in:
2026-09-18 02:05:48 +02:00
parent 053be20b9c
commit 5ca504c680
10 changed files with 885 additions and 21 deletions
+66 -8
View File
@@ -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