Add spotify integration
Deploy HEOS panel / deploy (push) Successful in 25s

This commit is contained in:
2026-09-15 22:07:43 +02:00
parent 6a0f1fa5e8
commit 7c19ff9096
17 changed files with 810 additions and 44 deletions
+1
View File
@@ -0,0 +1 @@
.env
+4 -2
View File
@@ -80,8 +80,9 @@ jobs:
- name: Run tests
run: .venv-ci/bin/python -m unittest discover -s tests -t . --verbose
# .venv and members.json are excluded, so the runtime and the stereo
# pair's learned membership survive --delete untouched.
# .venv, members.json and .env are excluded, so the runtime, the
# stereo pair's learned membership, and Spotify's credentials survive
# --delete untouched.
- name: Deploy to production
run: |
rsync -azc --no-times --delete \
@@ -90,6 +91,7 @@ jobs:
--exclude "/.venv/" \
--exclude "/.venv-ci/" \
--exclude "/members.json" \
--exclude "/.env" \
--exclude "__pycache__/" \
./ "$DEPLOY_PATH/"
+72 -7
View File
@@ -9,7 +9,9 @@ Everything it does fits on one screen:
- **Volume** up/down for the Home 400 and the Living Room pair. A tap lands
on the next multiple of `VOLUME_STEP` — from 23 it goes to 25, not 28 —
so the levels stay round. Hold to keep moving.
- **Play or pause** either room. A room that is grouped shares the AVR's
- **Play or pause** either room, or skip a track, while it is playing
Spotify — the buttons only show up then, since an AVR input has nothing
to pause or skip. A room that is grouped shares the AVR's
transport, so pausing it pauses the group — HEOS's doing, not the panel's:
a group has one thing playing, by definition
- **Group** either room with the AVR — the AVR is always the host, so its
@@ -18,6 +20,9 @@ Everything it does fits on one screen:
- **Ungroup** either room again, or all of them at once
- **Change the AVR's input**, listed under the names you gave them, minus
the sources you deleted in the AVR's setup menu
- **Resume Spotify** on a room from Fifou's or Clarita's account, one
button each — the reverse of connecting to it from the Spotify app.
Optional; see [Spotify](#spotify) below
## The kit it assumes
@@ -78,6 +83,62 @@ by their HEOS input id (`GET /api/avr/inputs` shows the exact strings, e.g.
`inputs/aux_in_1`), and sets their order; leave it empty to list everything
HEOS reports for it.
## Spotify
Spotify dropped native, browsable HEOS integration years ago — it is
Connect-only on HEOS now, and Connect only works phone → speaker: the
Spotify app pushes playback to a room, and there is nothing in HEOS that
asks for the reverse. So "Resume Spotify" doesn't go through HEOS at all;
it calls Spotify's own Web API to transfer the account's current playback
onto the room's Spotify Connect receiver, which the receiver already
advertises on the LAN whether or not anything is playing. This needs
Spotify Premium and a one-time login, since Spotify has no way to grant
that without a human approving it once.
1. Create an app at the
[Spotify Developer dashboard](https://developer.spotify.com/dashboard)
(any name), and add this Redirect URI in its settings:
`http://127.0.0.1:8899/callback`. Spotify allows plain `http` for a
`127.0.0.1` redirect specifically, which is why the login below needs no
HTTPS setup.
2. While the app is in development mode, Spotify only lets accounts you
have listed log in to it: add both accounts' email addresses under the
app's **User Management**.
3. Run the one-time login once per account, from a machine with a browser
(your laptop is fine — it doesn't have to be the Pi):
```bash
python3 tools/spotify_auth.py --client-id <id> --client-secret <secret> --account fifou
python3 tools/spotify_auth.py --client-id <id> --client-secret <secret> --account clarita
```
Log in as that account each time — use a private window for the second
run, or Spotify just approves whichever account the browser is already
logged in as. Each run prints `SPOTIFY_CLIENT_ID` and
`SPOTIFY_CLIENT_SECRET` (the same both times) plus that account's own
`SPOTIFY_FIFOU_REFRESH_TOKEN` or `SPOTIFY_CLARITA_REFRESH_TOKEN`.
4. Put those four lines in a `.env` file in this directory (already
git-ignored, and excluded from the deploy rsync the same way
`members.json` is — see "What survives a deploy" below). `config.py`
reads it directly, so no shell-sourcing step or restart-in-the-right-
terminal gotcha — just `python3 app.py` as normal. A real exported
environment variable (or systemd's `EnvironmentFile`, in the service)
still overrides the file rather than the other way round.
`config.py` never sees the credentials themselves — it only reads them out
of the environment. Each speaker's card then gets one Spotify button per
account in `SPOTIFY_ACCOUNTS` that has a refresh token; an account without
one simply gets no button. The AVR's card has none. Whichever account is
playing on a room right now gets a border around its button — the panel
asks Spotify, but only while HEOS says a room is on Spotify. Each room resumes on the Spotify Connect device named
`spotify_name` in its `TARGETS` entry, falling back to `heos_name` if that
key is missing (they usually match). `GET /api/spotify/devices?account=fifou`
lists what Spotify actually calls each device, if a room's button ever says
one isn't visible.
A room's play/pause and next buttons only appear while HEOS reports it is
playing (or paused on) Spotify. To see what HEOS reports for a room, use
`GET /raw/player/get_now_playing_media?pid=<pid>` with a pid from
`/api/targets`: Spotify shows up as `"sid": 4`.
## Add it to the iOS home screen
Open the page in Safari → Share → **Add to Home Screen**. It then launches
@@ -159,11 +220,11 @@ else or runs as someone else. Re-run the workflow and it goes green.
### What survives a deploy
The rsync excludes `.venv` and `members.json`, so the runtime and the stereo
pair's learned membership are left alone by `--delete`. Everything else in
the deploy path is made to match the repo, `config.py` included: your device
names live in git, so change them there and push rather than editing the
deployed copy.
The rsync excludes `.venv`, `members.json` and `.env`, so the runtime, the
stereo pair's learned membership, and Spotify's credentials are left alone by
`--delete`. Everything else in the deploy path is made to match the repo,
`config.py` included: your device names live in git, so change them there and
push rather than editing the deployed copy.
## Behind a reverse proxy, at /heos
@@ -277,6 +338,8 @@ Used by the interface:
| `POST /api/group/none` | every room back on its own |
| `GET /api/avr/inputs` | your renamed sources, over HEOS |
| `POST /api/avr/input` | `{"code": "inputs/aux_in_1"}` |
| `GET /api/spotify/devices?account=fifou` | every Spotify Connect receiver that account currently sees (needs [Spotify](#spotify) configured) |
| `POST /api/spotify/resume` | `{"target": "home400", "account": "fifou"}` — transfers that account's current playback there and resumes it |
`POST /volume/up` and `/volume/down` take one snapped tap by default; pass
`?step=3` and they move that many raw points instead, as they always did.
@@ -306,8 +369,10 @@ python3 tools/make_icons.py # re-render the icons from static/lo
app.py Flask: the UI, the API, and the old bridge's routes
controller.py what a room is, what grouping means, volume, the AVR's inputs
heos.py HEOS CLI client (persistent socket, reconnects itself)
spotify.py Spotify Web API client, for the "Resume Spotify" button
config.py your devices and preferences
demo.py fake speakers for --demo
templates/ static/ the interface
tests/ a fake HEOS server, and tests against it
tests/ fake HEOS and Spotify servers, and tests against them
tools/spotify_auth.py one-time Spotify login, prints the refresh token
```
+81 -4
View File
@@ -23,6 +23,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
import config
from controller import Controller, TargetError
from heos import HeosError
from spotify import SpotifyClient, SpotifyError
app = Flask(__name__)
@@ -33,6 +34,18 @@ app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
controller: Controller = None
spotify: dict = {} # account key -> SpotifyClient, configured accounts only
def _build_spotify() -> dict:
if not (config.SPOTIFY_CLIENT_ID and config.SPOTIFY_CLIENT_SECRET):
return {}
return {
key: SpotifyClient(config.SPOTIFY_CLIENT_ID, config.SPOTIFY_CLIENT_SECRET, account["refresh_token"])
for key, account in config.SPOTIFY_ACCOUNTS.items()
if account.get("refresh_token")
}
if __name__ != "__main__":
# Imported by a WSGI server rather than run as a script, so main()'s
@@ -43,6 +56,7 @@ if __name__ != "__main__":
controller = DemoController(config)
else:
controller = Controller(config)
spotify = _build_spotify()
def handle_errors(view):
@@ -53,7 +67,7 @@ def handle_errors(view):
return view(*args, **kwargs)
except (TargetError, ValueError) as exc:
return jsonify({"error": str(exc)}), 400
except HeosError as exc:
except (HeosError, SpotifyError) as exc:
return jsonify({"error": str(exc)}), 502
return wrapped
@@ -79,15 +93,54 @@ def _target_from(data: dict, field: str = "target") -> str:
return key
def _spotify_from(data: dict) -> SpotifyClient:
if not spotify:
raise ValueError("Spotify isn't configured -- see the README's Spotify section")
key = data.get("account")
if not key:
raise ValueError(f"Missing 'account'. Spotify accounts: {', '.join(spotify)}")
if key not in spotify:
raise ValueError(f"Unknown Spotify account '{key}'. Spotify accounts: {', '.join(spotify)}")
return spotify[key]
def _spotify_name(key: str) -> str:
target = config.TARGETS[key]
return target.get("spotify_name", target["heos_name"])
def _mark_spotify_accounts(rooms: list):
"""Tag each room HEOS says is on Spotify with the account playing it, so
its card can pick out that account's button. Spotify is only asked when
some room is on Spotify at all, and an account it refuses (a revoked
token, say) just matches nothing instead of failing the whole poll."""
on_spotify = [room for room in rooms if room.get("spotify")]
if not (spotify and on_spotify):
return
playing_on = {} # device name -> account key
for account, client in spotify.items():
try:
player = client.playback()
except (SpotifyError, OSError):
continue
device = (player.get("device") or {}).get("name")
# Should two accounts both claim a device, the one actually playing wins.
if device and (device not in playing_on or player.get("is_playing")):
playing_on[device] = account
for room in on_spotify:
room["spotify_account"] = playing_on.get(_spotify_name(room["key"]))
# --- The UI -----------------------------------------------------------
@app.route("/")
def index():
return render_template(
"index.html",
app_name=config.APP_NAME,
host=config.TARGETS[config.HOST_KEY],
host={"key": config.HOST_KEY, **config.TARGETS[config.HOST_KEY]},
rooms=[{"key": key, **config.TARGETS[key]} for key in config.ROOM_KEYS],
step=config.VOLUME_STEP,
spotify_accounts=[{"key": key, "label": config.SPOTIFY_ACCOUNTS[key]["label"]} for key in spotify],
)
@@ -104,7 +157,9 @@ def manifest():
@app.get("/api/state")
@handle_errors
def api_state():
return jsonify(controller.state())
data = controller.state()
_mark_spotify_accounts(data["rooms"])
return jsonify(data)
@app.get("/api/targets")
@@ -213,6 +268,27 @@ def api_avr_set_input():
return jsonify(controller.avr_select_input(code))
@app.get("/api/spotify/devices")
@handle_errors
def api_spotify_devices():
"""Diagnostic: every Spotify Connect receiver one account currently sees
(?account=fifou) -- use this to fill in a target's spotify_name if it
differs from heos_name."""
return jsonify(_spotify_from(request.args).devices())
@app.post("/api/spotify/resume")
@handle_errors
def api_spotify_resume():
"""Ask a room's own Spotify Connect receiver to resume one account's
playback -- the reverse of connecting to it from the Spotify app."""
data = _payload()
client = _spotify_from(data)
key = _target_from(data)
device = client.resume(_spotify_name(key))
return jsonify({"target": key, "account": data["account"], "device": device.get("name")})
# --- The original bridge's API, unchanged -----------------------------
@app.get("/targets")
@handle_errors
@@ -355,7 +431,7 @@ def legacy_avr_input():
def main():
global controller
global controller, spotify
parser = argparse.ArgumentParser(description="HEOS panel")
parser.add_argument("--port", type=int, default=config.WEB_PORT)
parser.add_argument("--host", default="0.0.0.0",
@@ -370,6 +446,7 @@ def main():
controller = DemoController(config)
else:
controller = Controller(config)
spotify = _build_spotify()
app.run(host=args.host, port=args.port, threaded=True)
+49 -7
View File
@@ -5,6 +5,28 @@ first time you run this, so the names below match exactly what HEOS
reports for your own devices.
"""
import os
from pathlib import Path
def _load_dotenv(path: Path):
"""A `source .env` a shell might forget to `export` is a whole class of
bug this sidesteps: read the file directly, rather than trusting
whatever the calling shell's environment happens to contain. Existing
environment variables still win, so a real export (or systemd's
EnvironmentFile) overrides the file rather than the other way round."""
if not path.exists():
return
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())
_load_dotenv(Path(__file__).resolve().parent / ".env")
# --- Network ----------------------------------------------------------
# Any ONE HEOS device's IP is enough: HEOS is a distributed system, so
# whichever unit you connect to can see and control every player and
@@ -18,13 +40,16 @@ WEB_PORT = 5443
# --- Rooms ------------------------------------------------------------
# key -> how to find it on the network, and how to label it in the UI.
#
# heos_name : the EXACT name HEOS reports for that player or group.
# players : only for a target that is a HEOS *group* (a stereo pair
# or an In-Room Group). List its member players, leader
# first. Leave it out and the panel learns the members the
# first time it sees the group un-merged, then remembers
# them in members.json -- which is what lets it rebuild the
# pair after you unmerge it from the AVR.
# heos_name : the EXACT name HEOS reports for that player or group.
# players : only for a target that is a HEOS *group* (a stereo pair
# or an In-Room Group). List its member players, leader
# first. Leave it out and the panel learns the members the
# first time it sees the group un-merged, then remembers
# them in members.json -- which is what lets it rebuild the
# pair after you unmerge it from the AVR.
# spotify_name : only needed if a room's Spotify Connect name differs
# from heos_name -- GET /api/spotify/devices?account=fifou shows what
# Spotify actually calls it. Defaults to heos_name.
TARGETS = {
"avr": {
"label": "Home Cinema",
@@ -37,6 +62,7 @@ TARGETS = {
"living_room_group": {
"label": "Living Room",
"heos_name": "Denon Home 200 L", # 2x Denon Home 200, In-Room Group
"spotify_name": "Living Room", # what Spotify Connect calls the pair
# "players": ["Denon Home 200 L", "Denon Home 200 R"],
},
}
@@ -61,3 +87,19 @@ AVR_INPUT_CODES = []
# Shown as the app's name on the iOS home screen.
APP_NAME = "Heos"
# --- Spotify (optional) -------------------------------------------------
# One Spotify button per account on each speaker's card (not the AVR's),
# resuming that account's playback there -- see the README's Spotify section and
# tools/spotify_auth.py. Credentials come from .env, never from here, so
# they stay out of git. One developer app serves every account; only the
# refresh token differs, since that is what each account's login produces.
SPOTIFY_CLIENT_ID = os.environ.get("SPOTIFY_CLIENT_ID")
SPOTIFY_CLIENT_SECRET = os.environ.get("SPOTIFY_CLIENT_SECRET")
# key -> button label and that account's refresh token, in button order.
# An account without a token gets no button.
SPOTIFY_ACCOUNTS = {
"fifou": {"label": "Fifou", "refresh_token": os.environ.get("SPOTIFY_FIFOU_REFRESH_TOKEN")},
"clarita": {"label": "Clarita", "refresh_token": os.environ.get("SPOTIFY_CLARITA_REFRESH_TOKEN")},
}
+22 -1
View File
@@ -52,6 +52,9 @@ class TargetError(ValueError):
class Controller:
# HEOS's source id for Spotify, in get_now_playing_media's payload.
SPOTIFY_SID = "4"
def __init__(self, cfg):
self.cfg = cfg
self.heos = HeosClient(cfg.HEOS_HOST, cfg.HEOS_PORT)
@@ -375,6 +378,21 @@ class Controller:
reply = self.heos.command("player/get_play_state", pid=self.playback_pid(key))
return parse_message(reply["heos"]["message"]).get("state", "stop")
def on_spotify(self, key: str) -> bool:
"""Whether a room's now-playing is a Spotify stream, playing or
paused -- the only thing its play/pause and next buttons make sense
for. An idle player can refuse the query outright, which is "not
Spotify" too."""
with self._lock:
self._fresh()
try:
reply = self.heos.command("player/get_now_playing_media", pid=self.playback_pid(key))
except HeosError:
return False
payload = reply.get("payload") or {}
return (str(payload.get("sid")) == self.SPOTIFY_SID
or str(payload.get("mid", "")).startswith("spotify:"))
def toggle_play(self, key: str, state: str = None) -> str:
"""Start or stop a room. With no state, flips whatever it is doing
now -- read from the speakers rather than trusted from the phone,
@@ -487,12 +505,14 @@ class Controller:
"grouped": key in joined,
"volume": None,
"play_state": None,
"spotify": False,
"error": None,
}
try:
scope, obj_id = self._volume_handles(key)[0]
room["volume"] = self._read_volume(scope, obj_id)
room["play_state"] = self.get_play_state(key)
room["spotify"] = self.on_spotify(key)
except (TargetError, HeosError, KeyError) as exc:
room["available"] = False
room["error"] = str(exc)
@@ -502,7 +522,8 @@ class Controller:
snapshot["errors"].append(str(exc))
snapshot["rooms"] = [
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": False,
"grouped": False, "volume": None, "play_state": None, "error": str(exc)}
"grouped": False, "volume": None, "play_state": None, "spotify": False,
"error": str(exc)}
for key in self.cfg.ROOM_KEYS
]
+3 -1
View File
@@ -51,6 +51,8 @@ class DemoController:
self._volume = {key: 22 + 7 * i for i, key in enumerate(cfg.TARGETS)}
self._play = {key: "play" for key in cfg.TARGETS}
self._joined = set()
# One room on Spotify and one not, so both kinds of card show up.
self._spotify = {key: i == 0 for i, key in enumerate(cfg.ROOM_KEYS)}
# -- what the UI uses ---------------------------------------------
def state(self):
@@ -59,7 +61,7 @@ class DemoController:
"rooms": [
{"key": key, "label": self.cfg.TARGETS[key]["label"], "available": True,
"grouped": key in self._joined, "volume": self._volume[key],
"play_state": self._play[key], "error": None}
"play_state": self._play[key], "spotify": self._spotify[key], "error": None}
for key in self.cfg.ROOM_KEYS
],
"avr": {"connected": True, "inputs": self.avr.inputs(), "input": self.avr.current_input()},
+5
View File
@@ -22,6 +22,11 @@ Type=simple
User=franzz
Group=www-data
WorkingDirectory=/var/www/html/heos
# Optional (the leading "-" means systemd won't refuse to start without
# it): Spotify's credentials, as plain KEY=VALUE lines -- see the
# README's Spotify section. Not in the repo on purpose, so a deploy's
# rsync --delete is told to leave it alone too.
EnvironmentFile=-/var/www/html/heos/.env
ExecStart=/var/www/html/heos/.venv/bin/gunicorn --worker-class gthread --workers 1 --threads 8 --bind 0.0.0.0:5443 app:app
# Bind 127.0.0.1:5443 above to allow only the reverse proxy in.
#
+145
View File
@@ -0,0 +1,145 @@
"""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
+52 -6
View File
@@ -42,8 +42,10 @@ async function api(path, body) {
}
let toastTimer;
function toast(message) {
/* Red by default, since most toasts report trouble; 'ok' for a confirmation. */
function toast(message, kind = 'error') {
ui.toast.textContent = message;
ui.toast.classList.toggle('ok', kind === 'ok');
ui.toast.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { ui.toast.hidden = true; }, 4000);
@@ -62,9 +64,12 @@ els('.room').forEach((node) => {
toggleLabel: el('[data-role="group-label"]', node),
play: el('[data-role="play"]', node),
next: el('[data-role="next"]', node),
spotify: el('.spotify-row', node), // absent with no Spotify account set up
steps: els('.step', node),
volume: null,
playState: null,
onSpotify: false,
spotifyAccount: null, // the account playing here, whose button gets a border
grouped: false,
available: false,
taps: 0, // button presses not yet sent
@@ -95,12 +100,24 @@ function paintRoom(room) {
room.play.disabled = !room.available || room.playState === null;
room.play.setAttribute(
'aria-label', `${room.node.querySelector('h2').textContent}: ${playing ? 'pause' : 'play'}`);
// Grouped, transport belongs to the AVR's card, not this one -- pressing
// either here would still work (it shares the group's transport) but
// only invites confusion about which card is actually in charge of it.
room.play.hidden = room.grouped;
room.next.hidden = !playing || room.grouped;
// Play/pause and next only mean something for a Spotify stream: an AVR
// input has no queue to pause or skip, and starting Spotify is what the
// account buttons are for. Grouped, transport belongs to the AVR's card,
// not this one -- pressing either here would still work (it shares the
// group's transport) but only invites confusion about which card is
// actually in charge of it.
const transport = room.onSpotify && !room.grouped;
room.play.hidden = !transport;
room.next.hidden = !transport || !playing;
room.next.disabled = !room.available;
// Resuming stays on offer while grouped: only the speakers have Spotify
// buttons, so hiding them here would leave none at all.
if (room.spotify) {
room.spotify.hidden = !room.available;
els('.spotify', room.spotify).forEach((button) => {
button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount));
});
}
room.toggle.disabled = !room.available;
room.toggle.classList.toggle('busy', room.busy);
@@ -265,6 +282,33 @@ ui.splitAll.addEventListener('click', async () => {
}
});
/* --- Spotify: one button per account, asking the card's own Connect
receiver to resume that account, instead of always connecting to it
from the Spotify app -------------------------------------------------- */
els('.spotify').forEach((button) => {
button.addEventListener('click', () => resumeSpotify(button));
});
async function resumeSpotify(button) {
const { target, account } = button.dataset;
const who = button.querySelector('span').textContent;
button.disabled = true;
try {
const data = await api('/api/spotify/resume', { target, account });
toast(`Resuming ${who}'s Spotify on ${data.device}`, 'ok');
const room = rooms[target];
room.spotifyAccount = account; // show it straight away; the refresh below confirms it
paintRoom(room);
// HEOS takes a moment to notice the new stream; look again once it
// has, so play/pause turns up without waiting for the next poll.
setTimeout(refresh, 1500);
} catch (error) {
toast(error.message);
} finally {
button.disabled = false;
}
}
/* --- the input picker -------------------------------------------------- */
ui.inputButton.addEventListener('click', openSheet);
el('[data-role="scrim"]').addEventListener('click', closeSheet);
@@ -317,6 +361,8 @@ function render(state) {
// Do not stomp on a volume the user is in the middle of changing.
if (!room.taps && !room.inflight) room.volume = incoming.volume;
if (!room.playBusy) room.playState = incoming.play_state;
room.onSpotify = Boolean(incoming.spotify);
room.spotifyAccount = incoming.spotify_account || null;
paintRoom(room);
});
paintGrouping();
+21 -7
View File
@@ -45,7 +45,8 @@ button {
touch-action: manipulation;
}
svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
/* Every icon is a Font Awesome glyph: a solid shape, filled, never stroked. */
svg { width: 22px; height: 22px; fill: currentColor; }
.app {
max-width: 520px;
@@ -109,9 +110,9 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
width: 78px; height: 62px;
border-radius: 18px;
background: var(--raised);
font-size: 30px; font-weight: 500; line-height: 1;
display: grid; place-items: center;
}
.step svg { width: 20px; height: 20px; }
.step:active { background: var(--accent); transform: scale(.96); }
.step:disabled { opacity: .4; }
@@ -132,11 +133,6 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
.transport:active { background: var(--accent); transform: scale(.97); }
.transport:disabled { opacity: .5; }
.transport svg { width: 19px; height: 19px; }
/* A play triangle wants filling; the pause bars are drawn with the stroke
everything else in here uses. */
.transport .icon-play { fill: currentColor; stroke: none; }
.transport .icon-pause { stroke-width: 2.4; }
.transport[data-role="next"] svg { fill: currentColor; stroke: none; }
/* Which icon shows is a class on the button, not `hidden` on the svg:
`hidden` is an HTMLElement property and SVGElement does not inherit it,
@@ -160,6 +156,22 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
.toggle:disabled { opacity: .5; }
.toggle.busy { opacity: .6; }
/* --- Spotify: one resume button per account ----------------------------- */
.spotify-row { display: flex; gap: 10px; }
.spotify {
flex: 1; min-width: 0;
height: 50px; border-radius: 16px;
background: var(--raised);
display: flex; align-items: center; justify-content: center; gap: 9px;
font-size: 15px; font-weight: 550;
}
.spotify svg { flex: 0 0 auto; color: #fff; }
.spotify span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spotify:active { transform: scale(.985); }
.spotify:disabled { opacity: .5; }
/* The account playing on this room right now. */
.spotify[aria-pressed="true"] { border: 2px solid var(--ink); }
/* --- source card ------------------------------------------------------ */
.source-button {
display: flex; align-items: center; gap: 12px;
@@ -192,6 +204,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
background: none; border: 1px solid var(--edge); color: var(--muted);
}
.joined .toggle .dot { display: none; }
.joined .spotify { height: 40px; font-size: 13px; font-weight: 500; }
.joined .toggle:active { background: var(--raised); color: var(--ink); }
/* --- misc ------------------------------------------------------------- */
@@ -242,6 +255,7 @@ svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width:
background: #2b1f24; border: 1px solid #52303a; color: #ffd9d4;
font-size: 14px; box-shadow: 0 10px 30px rgba(0, 0, 0, .45);
}
.toast.ok { background: #172a21; border-color: #2a5540; color: #c9f7df; }
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
+29 -8
View File
@@ -18,12 +18,27 @@
<link rel="stylesheet" href="{{ url_for('static', filename='panel.css') }}">
</head>
<body>
<!-- Icons: Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com - License CC BY 4.0 -->
{% macro spotify_row(target, accounts) -%}
{% if accounts %}
<div class="spotify-row" hidden>
{% for account in accounts %}
<button class="spotify" data-target="{{ target }}" data-account="{{ account.key }}"
aria-label="Resume {{ account.label }}'s Spotify">
<svg viewBox="0 0 496 512" aria-hidden="true"><path d="M248 8C111.1 8 0 119.1 0 256s111.1 248 248 248 248-111.1 248-248S384.9 8 248 8zm100.7 364.9c-4.2 0-6.8-1.3-10.7-3.6-62.4-37.6-135-39.2-206.7-24.5-3.9 1-9 2.6-11.9 2.6-9.7 0-15.8-7.7-15.8-15.8 0-10.3 6.1-15.2 13.6-16.8 81.9-18.1 165.6-16.5 237 26.2 6.1 3.9 9.7 7.4 9.7 16.5s-7.1 15.4-15.2 15.4zm26.9-65.6c-5.2 0-8.7-2.3-12.3-4.2-62.5-37-155.7-51.9-238.6-29.4-4.8 1.3-7.4 2.6-11.9 2.6-10.7 0-19.4-8.7-19.4-19.4s5.2-17.8 15.5-20.7c27.8-7.8 56.2-13.6 97.8-13.6 64.9 0 127.6 16.1 177 45.5 8.1 4.8 11.3 11 11.3 19.7-.1 10.8-8.5 19.5-19.4 19.5zm31-76.2c-5.2 0-8.4-1.3-12.9-3.9-71.2-42.5-198.5-52.7-280.9-29.7-3.6 1-8.1 2.6-12.9 2.6-13.2 0-23.3-10.3-23.3-23.6 0-13.6 8.4-21.3 17.4-23.9 35.2-10.3 74.6-15.2 117.5-15.2 73 0 149.5 15.2 205.4 47.8 7.8 4.5 12.9 10.7 12.9 22.6 0 13.6-11 23.3-23.2 23.3z"/></svg>
<span>{{ account.label }}</span>
</button>
{% endfor %}
</div>
{% endif %}
{%- endmacro %}
<div class="app">
<header class="top">
<h1><img class="logo" src="{{ url_for('static', filename='logo.svg') }}" alt="{{ app_name }}"></h1>
<button class="icon-button" data-role="refresh" aria-label="Refresh">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.34-5.66M20 4v5h-5"/></svg>
<svg viewBox="0 0 512 512" aria-hidden="true"><path d="M105.1 202.6c7.7-21.8 20.2-42.3 37.8-59.8c62.5-62.5 163.8-62.5 226.3 0L386.3 160 352 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l111.5 0c0 0 0 0 0 0l.4 0c17.7 0 32-14.3 32-32l0-112c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 35.2L414.4 97.6c-87.5-87.5-229.3-87.5-316.8 0C73.2 122 55.6 150.7 44.8 181.4c-5.9 16.7 2.9 34.9 19.5 40.8s34.9-2.9 40.8-19.5zM39 289.3c-5 1.5-9.8 4.2-13.7 8.2c-4 4-6.7 8.8-8.1 14c-.3 1.2-.6 2.5-.8 3.8c-.3 1.7-.4 3.4-.4 5.1L16 432c0 17.7 14.3 32 32 32s32-14.3 32-32l0-35.1 17.6 17.5c0 0 0 0 0 0c87.5 87.4 229.3 87.4 316.7 0c24.4-24.4 42.1-53.1 52.9-83.8c5.9-16.7-2.9-34.9-19.5-40.8s-34.9 2.9-40.8 19.5c-7.7 21.8-20.2 42.3-37.8 59.8c-62.5 62.5-163.8 62.5-226.3 0l-.1-.1L125.6 352l34.4 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L48.4 288c-1.6 0-3.2 .1-4.8 .3s-3.1 .5-4.6 1z"/></svg>
</button>
</header>
@@ -35,7 +50,7 @@
<button class="source-button" data-role="input-button">
<span class="eyebrow">Input</span>
<span class="value" data-role="input-name"></span>
<svg class="chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
<svg class="chevron" viewBox="0 0 320 512" aria-hidden="true"><path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z"/></svg>
</button>
<div class="joined" data-role="joined" hidden>
@@ -53,19 +68,23 @@
</div>
<div class="volume">
<button class="step" data-delta="-1" aria-label="{{ room.label }}: volume down">&minus;</button>
<button class="step" data-delta="-1" aria-label="{{ room.label }}: volume down">
<svg viewBox="0 0 448 512" aria-hidden="true"><path d="M432 256c0 17.7-14.3 32-32 32L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l352 0c17.7 0 32 14.3 32 32z"/></svg>
</button>
<div class="meter"><i data-role="bar"></i></div>
<button class="step" data-delta="1" aria-label="{{ room.label }}: volume up">+</button>
<button class="step" data-delta="1" aria-label="{{ room.label }}: volume up">
<svg viewBox="0 0 448 512" aria-hidden="true"><path d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"/></svg>
</button>
</div>
<div class="actions">
<button class="transport" data-role="play" aria-label="{{ room.label }}: play">
<svg class="icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5l11 7-11 7z"/></svg>
<svg class="icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5v14M15 5v14"/></svg>
<button class="transport" data-role="play" aria-label="{{ room.label }}: play" hidden>
<svg class="icon-play" viewBox="0 0 384 512" aria-hidden="true"><path d="M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"/></svg>
<svg class="icon-pause" viewBox="0 0 320 512" aria-hidden="true"><path d="M48 64C21.5 64 0 85.5 0 112L0 400c0 26.5 21.5 48 48 48l32 0c26.5 0 48-21.5 48-48l0-288c0-26.5-21.5-48-48-48L48 64zm192 0c-26.5 0-48 21.5-48 48l0 288c0 26.5 21.5 48 48 48l32 0c26.5 0 48-21.5 48-48l0-288c0-26.5-21.5-48-48-48l-32 0z"/></svg>
</button>
<button class="transport" data-role="next" aria-label="{{ room.label }}: next track" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
<svg viewBox="0 0 320 512" aria-hidden="true"><path d="M52.5 440.6c-9.5 7.9-22.8 9.7-34.1 4.4S0 428.4 0 416L0 96C0 83.6 7.2 72.3 18.4 67s24.5-3.6 34.1 4.4l192 160L256 241l0-145c0-17.7 14.3-32 32-32s32 14.3 32 32l0 320c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-145-11.5 9.6-192 160z"/></svg>
</button>
<button class="toggle" data-role="group" aria-pressed="false">
@@ -73,6 +92,8 @@
<span data-role="group-label">Separate</span>
</button>
</div>
{{ spotify_row(room.key, spotify_accounts) }}
</section>
</div>
{% endfor %}
+100
View File
@@ -0,0 +1,100 @@
"""Stand-in Spotify Web API: just enough of /api/token, .../player/devices
and .../player to test spotify.py against, the same way fakes.py stands in
for real HEOS hardware."""
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
class FakeSpotify(threading.Thread):
"""Issues a fresh access token per refresh, tracks which one is
currently valid, and remembers every playback transfer it was asked
to make."""
def __init__(self, devices=None):
super().__init__(daemon=True)
self.devices_list = devices if devices is not None else [
{"id": "dev-1", "name": "Lego Room", "type": "Speaker"},
{"id": "dev-2", "name": "Home Cinema", "type": "AVR"},
]
self.valid_token = None
self.tokens_issued = 0
self.transfers = [] # every PUT /v1/me/player body
self.reject_refresh = False # simulate a revoked refresh token
self.player = None # GET /v1/me/player's body; None is no session (204)
fake = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def _send(self, status, payload=None):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
if payload is not None:
self.wfile.write(json.dumps(payload).encode())
def _authorized(self):
header = self.headers.get("Authorization", "")
return header == f"Bearer {fake.valid_token}" and fake.valid_token is not None
def do_POST(self):
if self.path == "/api/token":
if fake.reject_refresh:
self._send(400, {"error": "invalid_grant",
"error_description": "refresh token revoked"})
return
fake.tokens_issued += 1
fake.valid_token = f"token-{fake.tokens_issued}"
self._send(200, {"access_token": fake.valid_token, "expires_in": 3600})
return
self._send(404, {"error": {"message": "not found"}})
def do_PUT(self):
if self.path == "/v1/me/player":
if not self._authorized():
self._send(401, {"error": {"message": "The access token expired"}})
return
length = int(self.headers.get("Content-Length", 0))
fake.transfers.append(json.loads(self.rfile.read(length) or b"{}"))
self.send_response(204)
self.end_headers()
return
self._send(404, {"error": {"message": "not found"}})
def do_GET(self):
if self.path == "/v1/me/player/devices":
if not self._authorized():
self._send(401, {"error": {"message": "The access token expired"}})
return
self._send(200, {"devices": fake.devices_list})
return
if self.path == "/v1/me/player":
if not self._authorized():
self._send(401, {"error": {"message": "The access token expired"}})
return
if fake.player is None:
self.send_response(204)
self.end_headers()
else:
self._send(200, fake.player)
return
self._send(404, {"error": {"message": "not found"}})
self.server = HTTPServer(("127.0.0.1", 0), Handler)
self.port = self.server.server_port
self.start()
def run(self):
self.server.serve_forever(poll_interval=0.05)
def stop(self):
self.server.shutdown()
self.server.server_close()
@property
def base_url(self):
return f"http://127.0.0.1:{self.port}"
+5 -1
View File
@@ -31,6 +31,7 @@ class FakeHeos(threading.Thread):
self.play_states = {pid: "play" for pid in self.NAMES}
self.group_volumes = {3: 25}
self.now_playing_mid = {1: "inputs/mediaplayer"} # pid -> what get_now_playing_media reports
self.now_playing_sid = {} # pid -> its source id; 4 is Spotify
self.commands = [] # everything we were asked to do
self.server = socket.socket()
self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -120,7 +121,10 @@ class FakeHeos(threading.Thread):
pid = int(args["pid"])
mid = self.now_playing_mid.get(pid, "")
name = next((s["name"] for s in self.AVR_INPUTS if s["mid"] == mid), mid)
return self._ok(path, payload={"mid": mid, "station": name} if mid else {})
payload = {"mid": mid, "station": name} if mid else {}
if pid in self.now_playing_sid:
payload["sid"] = self.now_playing_sid[pid]
return self._ok(path, payload=payload)
if path == "browse/browse":
sid = int(args["sid"])
+12
View File
@@ -174,6 +174,18 @@ class PanelTest(unittest.TestCase):
self.panel.toggle_play("living_room_group", "pause")
self.assertEqual(self.heos.play_states[3], "pause")
def test_state_says_which_rooms_are_playing_spotify(self):
"""The cards only offer play/pause for a Spotify stream, so the
snapshot has to say which rooms have one."""
self.heos.now_playing_mid[HOME400_PID] = "spotify:track:4uLU6hMCjMI75M1A2tKUQC"
self.heos.now_playing_sid[HOME400_PID] = 4
state = self.panel.state()
self.assertEqual([r["spotify"] for r in state["rooms"]], [True, False])
def test_an_avr_input_is_not_spotify(self):
self.heos.now_playing_sid[AVR_PID] = 1027
self.assertFalse(self.panel.on_spotify("avr"))
# -- the AVR, entirely over HEOS --------------------------------------
def test_avr_inputs_carry_your_renamed_labels(self):
"""HEOS reports the AVR's own renamed sources itself (browse/browse
+66
View File
@@ -0,0 +1,66 @@
"""Run against the fake Spotify Web API in fake_spotify.py:
python3 -m unittest discover -s tests -t .
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from spotify import SpotifyClient, SpotifyError # noqa: E402
from tests.fake_spotify import FakeSpotify # noqa: E402
class SpotifyTest(unittest.TestCase):
def setUp(self):
self.fake = FakeSpotify()
self.addCleanup(self.fake.stop)
self.client = SpotifyClient(
"client-id", "client-secret", "refresh-token",
accounts_url=self.fake.base_url, api_url=self.fake.base_url,
)
def test_devices_lists_what_spotify_reports(self):
names = [d["name"] for d in self.client.devices()]
self.assertEqual(names, ["Lego Room", "Home Cinema"])
self.assertEqual(self.fake.tokens_issued, 1) # one refresh for the whole call
def test_resume_transfers_playback_to_the_matched_device(self):
device = self.client.resume("Lego Room")
self.assertEqual(device["id"], "dev-1")
self.assertEqual(self.fake.transfers, [{"device_ids": ["dev-1"], "play": True}])
def test_resume_raises_when_no_device_has_that_name(self):
with self.assertRaises(SpotifyError):
self.client.resume("Kitchen")
self.assertEqual(self.fake.transfers, [])
def test_playback_reports_the_device_and_whether_it_plays(self):
self.fake.player = {"device": {"id": "dev-1", "name": "Lego Room"}, "is_playing": True}
player = self.client.playback()
self.assertEqual(player["device"]["name"], "Lego Room")
self.assertTrue(player["is_playing"])
def test_playback_is_empty_without_a_session(self):
self.assertEqual(self.client.playback(), {})
def test_a_rejected_access_token_is_refreshed_and_retried_once(self):
self.client.devices() # get a real token first
self.assertEqual(self.fake.tokens_issued, 1)
self.client._access_token = "stale-but-not-yet-expired"
# _expires_at is untouched, so only the 401 -- not the pre-call
# expiry check -- can be what forces this to work.
names = [d["name"] for d in self.client.devices()]
self.assertEqual(names, ["Lego Room", "Home Cinema"])
self.assertEqual(self.fake.tokens_issued, 2)
def test_a_revoked_refresh_token_raises_a_clear_error(self):
self.fake.reject_refresh = True
with self.assertRaises(SpotifyError):
self.client.devices()
if __name__ == "__main__":
unittest.main()
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""One-time Spotify login per account, to get the refresh token config.py
needs for that account's Spotify button on the panel.
python3 tools/spotify_auth.py --client-id ... --client-secret ... --account fifou
Run this somewhere you can actually reach a browser to approve the
login -- your laptop, or a WSL shell if Windows can reach it (WSL2
forwards localhost both ways, so a browser on the Windows side works
fine too). It listens on 127.0.0.1 for the one redirect Spotify sends
back, so nothing here ever sees your Spotify password, only the
short-lived code Spotify hands back afterwards.
In a plain shell with no desktop session wired up, this can't open a
browser for you automatically -- it tries, and that attempt can print
its own "Operation not supported" message when it fails. That's the
browser launcher failing, not this script; the URL it prints above that
still works, copied into any browser by hand.
Before running it:
1. Create an app at https://developer.spotify.com/dashboard (any name).
2. In its settings, add this exact Redirect URI:
http://127.0.0.1:8899/callback
Spotify allows plain http for a 127.0.0.1 redirect specifically --
nowhere else -- which is why this doesn't need HTTPS to work.
3. Copy its Client ID and Client Secret and pass them here.
It prints SPOTIFY_<ACCOUNT>_REFRESH_TOKEN for the account you logged in
as -- put that, plus the client id and secret, in .env (the README's
Spotify section has the details). Run it again with the other --account,
logged in as that account, for its own token. None of these belong in
config.py itself or in git.
"""
import argparse
import base64
import json
import secrets
import sys
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
REDIRECT_PORT = 8899
REDIRECT_URI = f"http://127.0.0.1:{REDIRECT_PORT}/callback"
SCOPES = "user-read-playback-state user-modify-playback-state"
def get_code(client_id: str, state: str) -> str:
"""Open Spotify's login page and block until its redirect lands."""
result = {}
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass # the printed instructions are enough noise already
def do_GET(self):
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
result["code"] = query.get("code", [None])[0]
result["state"] = query.get("state", [None])[0]
result["error"] = query.get("error", [None])[0]
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
body = "You can close this tab and go back to the terminal." \
if result["code"] else f"Spotify said: {result['error']}"
self.wfile.write(body.encode())
server = HTTPServer(("127.0.0.1", REDIRECT_PORT), Handler)
authorize_url = "https://accounts.spotify.com/authorize?" + urllib.parse.urlencode({
"client_id": client_id,
"response_type": "code",
"redirect_uri": REDIRECT_URI,
"scope": SCOPES,
"state": state,
})
print(f"Open this URL and log in to Spotify:\n\n{authorize_url}\n")
try:
# Best-effort only: in a plain WSL shell (no desktop session wired
# up) this can fail with its own "Operation not supported" message
# printed straight to the terminal -- that's the browser launcher
# complaining, not this script; ignore it and open the URL above
# by hand (from Windows too -- WSL2 forwards localhost both ways,
# so the redirect below still reaches this script).
webbrowser.open(authorize_url)
except Exception:
pass
print("Waiting for Spotify to redirect back here once you approve it...")
server.handle_request() # one request is all this ever needs
server.server_close()
if result.get("error"):
sys.exit(f"Spotify refused: {result['error']}")
if result.get("state") != state:
sys.exit("state mismatch -- got a callback that wasn't for this run, aborting")
return result["code"]
def exchange(client_id: str, client_secret: str, code: str) -> dict:
credentials = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
body = urllib.parse.urlencode({
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
}).encode()
request = urllib.request.Request(
"https://accounts.spotify.com/api/token",
data=body,
headers={
"Authorization": f"Basic {credentials}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
try:
with urllib.request.urlopen(request) as response:
return json.loads(response.read())
except urllib.error.HTTPError as exc:
sys.exit(f"Spotify rejected the code exchange: {exc.read().decode()}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--client-id", required=True)
parser.add_argument("--client-secret", required=True)
parser.add_argument("--account", required=True,
help="the SPOTIFY_ACCOUNTS key this login is for, e.g. fifou or clarita")
args = parser.parse_args()
state = secrets.token_urlsafe(16)
code = get_code(args.client_id, state)
tokens = exchange(args.client_id, args.client_secret, code)
print("\nPut these in .env -- the client id and secret are the same for every account:\n")
print(f"SPOTIFY_CLIENT_ID={args.client_id}")
print(f"SPOTIFY_CLIENT_SECRET={args.client_secret}")
print(f"SPOTIFY_{args.account.upper()}_REFRESH_TOKEN={tokens['refresh_token']}")
if __name__ == "__main__":
main()