diff --git a/.claudeignore b/.claudeignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.claudeignore @@ -0,0 +1 @@ +.env diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c9b506a..73289ef 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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/" diff --git a/README.md b/README.md index be7ed30..af1fc17 100644 --- a/README.md +++ b/README.md @@ -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 --client-secret --account fifou + python3 tools/spotify_auth.py --client-id --client-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=` 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 ``` diff --git a/app.py b/app.py index 2db8fbc..cc9ebd2 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/config.py b/config.py index 0c71043..dee3329 100644 --- a/config.py +++ b/config.py @@ -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")}, +} diff --git a/controller.py b/controller.py index 8e0a786..b427e4a 100644 --- a/controller.py +++ b/controller.py @@ -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 ] diff --git a/demo.py b/demo.py index 37b0e93..2b06fe6 100644 --- a/demo.py +++ b/demo.py @@ -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()}, diff --git a/deploy/heos-panel.service b/deploy/heos-panel.service index ceb433b..83d49e5 100644 --- a/deploy/heos-panel.service +++ b/deploy/heos-panel.service @@ -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. # diff --git a/spotify.py b/spotify.py new file mode 100644 index 0000000..8a372be --- /dev/null +++ b/spotify.py @@ -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 diff --git a/static/app.js b/static/app.js index 419eead..e8b00a6 100644 --- a/static/app.js +++ b/static/app.js @@ -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(); diff --git a/static/panel.css b/static/panel.css index 1cf7fa7..ae2ec57 100644 --- a/static/panel.css +++ b/static/panel.css @@ -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; } diff --git a/templates/index.html b/templates/index.html index e3a604f..cfedc16 100644 --- a/templates/index.html +++ b/templates/index.html @@ -18,12 +18,27 @@ + + +{% macro spotify_row(target, accounts) -%} +{% if accounts %} + +{% endif %} +{%- endmacro %}

@@ -35,7 +50,7 @@
- +
- +
-
+ + {{ spotify_row(room.key, spotify_accounts) }}
{% endfor %} diff --git a/tests/fake_spotify.py b/tests/fake_spotify.py new file mode 100644 index 0000000..f2af572 --- /dev/null +++ b/tests/fake_spotify.py @@ -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}" diff --git a/tests/fakes.py b/tests/fakes.py index a9678dd..984eddc 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -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"]) diff --git a/tests/test_panel.py b/tests/test_panel.py index 603959b..c2a4c2a 100644 --- a/tests/test_panel.py +++ b/tests/test_panel.py @@ -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 diff --git a/tests/test_spotify.py b/tests/test_spotify.py new file mode 100644 index 0000000..8a5ba2f --- /dev/null +++ b/tests/test_spotify.py @@ -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() diff --git a/tools/spotify_auth.py b/tools/spotify_auth.py new file mode 100644 index 0000000..9d4aab8 --- /dev/null +++ b/tools/spotify_auth.py @@ -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__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()