214 lines
9.7 KiB
Python
214 lines
9.7 KiB
Python
"""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
|