"""Spotify Web API client -- just enough to ask a room's own Spotify Connect receiver to resume whatever the account was last playing. This is the opposite direction from how Spotify Connect normally works: instead of the phone pushing playback to a speaker, `resume()` calls the Web API's "Transfer Playback" endpoint to pull it there. It needs a Spotify Developer app and a one-time login -- see tools/spotify_auth.py and the README's Spotify section -- because Spotify has no way to grant that without a human authorizing it once. Credentials are never hardcoded here: the client takes them as arguments and config.py reads them from the environment, so nothing secret ends up committed alongside the rest of the config. """ import base64 import json import threading import time import urllib.error import urllib.request 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.""" ACCOUNTS_URL = "https://accounts.spotify.com" API_URL = "https://api.spotify.com" def __init__(self, client_id, client_secret, refresh_token, timeout=8.0, accounts_url=None, api_url=None): self.client_id = client_id self.client_secret = client_secret self.refresh_token = refresh_token self.timeout = timeout self.accounts_url = accounts_url or self.ACCOUNTS_URL 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 ------------------------------------------------------------ def _refresh_token(self) -> str: """Exchange the long-lived refresh token for a fresh access token. Spotify's access tokens last about an hour; refresh a bit early rather than racing the clock on every call.""" credentials = base64.b64encode( f"{self.client_id}:{self.client_secret}".encode()).decode() body = f"grant_type=refresh_token&refresh_token={self.refresh_token}".encode() request = urllib.request.Request( f"{self.accounts_url}/api/token", data=body, method="POST", headers={ "Authorization": f"Basic {credentials}", "Content-Type": "application/x-www-form-urlencoded", }, ) try: with urllib.request.urlopen(request, timeout=self.timeout) as response: payload = json.loads(response.read()) except urllib.error.HTTPError as exc: raise SpotifyError(f"Spotify login refresh failed: {_error_detail(exc)}") from exc except urllib.error.URLError as exc: 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: with self._lock: if self._access_token and time.time() < self._expires_at - 30: return self._access_token return self._refresh_token() # -- transport --------------------------------------------------------- def _call(self, method: str, path: str, body: dict = None, retrying: bool = False): headers = {"Authorization": f"Bearer {self._token()}"} data = None if body is not None: data = json.dumps(body).encode() headers["Content-Type"] = "application/json" request = urllib.request.Request( f"{self.api_url}{path}", data=data, method=method, headers=headers) try: with urllib.request.urlopen(request, timeout=self.timeout) as response: raw = response.read() # A player command (seek, say) can answer 200 with a body that is # not JSON at all -- it worked, there is just nothing to read. try: return json.loads(raw) if raw else {} except ValueError: return {} except urllib.error.HTTPError as exc: if exc.code == 401 and not retrying: # The access token can go stale between calls even inside # its nominal lifetime -- one retry after a forced refresh # covers that without hiding a genuinely bad refresh token. exc.close() with self._lock: self._access_token = None return self._call(method, path, body, retrying=True) if exc.code == 404 and method == "PUT" and path == "/v1/me/player": raise SpotifyError("Nothing is queued to resume on that Spotify account") from exc raise SpotifyError(f"Spotify said no: {_error_detail(exc)}") from exc except urllib.error.URLError as exc: raise SpotifyError(f"Could not reach Spotify: {exc.reason}") from exc # -- the calls this panel needs ----------------------------------------- def devices(self) -> list: """Every Spotify Connect receiver visible to this account right now -- including a HEOS room nobody has ever connected to from the Spotify app, since Connect devices announce themselves on the LAN 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.""" return self._call("GET", "/v1/me/player") def resume(self, device_name: str) -> dict: """Transfer the account's current (usually paused) playback to the named device and resume it -- the reverse of tapping the device in the Spotify app's Connect picker.""" devices = self.devices() matches = [d for d in devices if d.get("name") == device_name] if not matches: 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.""" self._call("PUT", f"/v1/me/player/seek?position_ms={int(position_ms)}") 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: 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): 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