146 lines
6.6 KiB
Python
146 lines
6.6 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 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._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)
|
|
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()
|
|
return json.loads(raw) if raw else {}
|
|
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 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:
|
|
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?")
|
|
device = matches[0]
|
|
self._call("PUT", "/v1/me/player", body={"device_ids": [device["id"]], "play": True})
|
|
return device
|
|
|
|
|
|
def _error_detail(exc: urllib.error.HTTPError) -> str:
|
|
try:
|
|
with exc:
|
|
payload = json.loads(exc.read())
|
|
return payload.get("error_description") or payload.get("error", {}).get("message") or exc.reason
|
|
except (ValueError, AttributeError, KeyError):
|
|
return exc.reason
|