diff --git a/README.md b/README.md index 3de0ce2..da834f4 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Everything 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 either account (the reverse of connecting to it from the Spotify app) — labeled with the familiar names you gave them, e.g. Fifou's or Clarita's. Optional, see [Spotify](#configure-spotify-optional) below +- **Disconnect Spotify** again by tapping the bordered button — the account playing there: it pauses that account and signs the speaker out of it, so the music stops and the room belongs to nobody until someone claims it +- **Hand a room over** by tapping the other account: a speaker belongs to one Spotify account at a time, so the panel signs it in to the other one over the LAN — the same thing picking the room in the Spotify app does — and resumes there ## The kit it assumes @@ -108,7 +110,7 @@ Spotify dropped native, browsable HEOS integration years ago — it is Connect-o 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**. +2. While the app is in development mode, Spotify only serves accounts you have listed: 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 @@ -146,7 +148,8 @@ Used by the interface: | `GET /api/avr/inputs` | your renamed sources, over HEOS | | `POST /api/avr/input` | `{"code": "inputs/aux_in_1"}` | | `GET /api/spotify/devices?account=account1` | every Spotify Connect receiver that account currently sees (needs [Spotify](#configure-spotify-optional) configured) | -| `POST /api/spotify/resume` | `{"target": "lego_room", "account": "account1"}` — transfers that account's current playback there and resumes it | +| `POST /api/spotify/resume` | `{"target": "lego_room", "account": "account1"}` — transfers that account's current playback there and resumes it, signing the speaker in to that account first if another one has it | +| `POST /api/spotify/disconnect` | `{"target": "lego_room", "account": "account1"}` — pauses that account and signs the speaker out of it over the LAN, which stops the music too | ## Demo/Tests diff --git a/app.py b/app.py index d1c85a7..119af1d 100644 --- a/app.py +++ b/app.py @@ -15,15 +15,18 @@ friends) are still here so existing Shortcuts and scripts keep working. import argparse import os +import time from functools import wraps from flask import Flask, jsonify, render_template, request, url_for from werkzeug.middleware.proxy_fix import ProxyFix import config +import spotify_zc from controller import Controller, TargetError from heos import HeosError -from spotify import SpotifyClient, SpotifyError +from spotify import SpotifyClient, SpotifyDeviceUnavailable, SpotifyError +from spotify_zc import ZeroconfError from zidoo import ZidooError app = Flask(__name__) @@ -81,7 +84,7 @@ def handle_errors(view): return view(*args, **kwargs) except (TargetError, ValueError) as exc: return jsonify({"error": str(exc)}), 400 - except (HeosError, SpotifyError, ZidooError) as exc: + except (HeosError, SpotifyError, ZeroconfError, ZidooError) as exc: return jsonify({"error": str(exc)}), 502 return wrapped @@ -140,6 +143,165 @@ def _spotify_playing_on() -> dict: return playing_on +def _spotify_account_seeing(device_name: str, besides: str) -> str: + """Which OTHER configured account can currently see that Connect + receiver, if any. A speaker is logged into one Spotify account at a + time and Spotify shows it to that account alone, so when a room goes + missing this -- not the room's power or its spotify_name -- is very + often the whole answer.""" + for key, client in spotify.items(): + if key == besides: + continue + try: + if any(d.get("name") == device_name for d in client.devices()): + return key + except (SpotifyError, OSError): + continue # an account we cannot ask is not an account holding it + return None + + +def _spotify_pause_on(key: str, account: str) -> bool: + """Pause an account, but only if this room is where it is playing -- an + account that has already moved on is playing somewhere else now, and + stopping it there is nobody's intention. Best effort: an account + Spotify will not answer for is no reason to leave the room alone.""" + client = spotify.get(account) + if client is None: + return False + try: + player = client.playback() + if (player.get("device") or {}).get("name") == _spotify_name(key) and player.get("is_playing"): + client.pause() + return True + except (SpotifyError, OSError): + pass + return False + + +# How long to let a speaker finish changing source before stopping it. +RELEASE_TRIES = 6 +RELEASE_WAIT = 0.5 + + +def _is_demo() -> bool: + """Demo mode's rooms are invented, but the real speakers are very + likely on the same LAN -- and signing one in or out would physically + change it. So the zeroconf side sits out a demo.""" + return bool(getattr(controller, "demo", False)) + + +def _find_speaker(device_name: str): + """A room's own LAN endpoint, or None when it cannot be reached.""" + try: + return spotify_zc.find(device_name) + except (ZeroconfError, OSError): + return None + + +def _spotify_release(key: str, account: str = None) -> dict: + """Take a room off Spotify: pause the account playing there, then sign + the speaker out over the LAN. Reports what it managed: {"paused", + "signed_out"}. + + Signing out is the whole disconnect -- the speaker stops within a + second and drops out of the account's device list -- and it leaves the + room belonging to nobody, which costs nothing now that either button + can claim it back with a sign-in (see _spotify_take_over). Pausing + first is manners: it leaves that account where it was rather than cut + off mid-song. + + The HEOS fallback below is for a speaker that does not answer on the + LAN. It can end the stream but not the sign-in, and it cannot do it the + obvious way either: player/set_play_state with stop is accepted for a + Connect stream and then quietly leaves the player paused, session + intact. Putting the speaker on one of its own inputs does end the + stream, and stopping *that* is honoured, so the room is left idle + rather than sitting on a live input. + """ + released = {"paused": _spotify_pause_on(key, account), "signed_out": False} + speaker = None if _is_demo() else _find_speaker(_spotify_name(key)) + if speaker is not None: + speaker.reset_users() + released["signed_out"] = True + return released + _release_over_heos(key) + return released + + +def _release_over_heos(key: str): + """Stop a room's Spotify stream without the speaker's help -- see + _spotify_release for why it goes the long way round.""" + inputs = controller.heos_inputs(key) + if not inputs: + raise ValueError( + f"'{config.TARGETS[key]['label']}' did not answer on the network, and HEOS lists " + "no input on it to fall back to -- so there is no way from here to take this room " + "off Spotify.") + # An AUX jack with nothing in it is the quietest thing a speaker can be + # switched to, and the stop below cuts even that short. + quiet = next((i for i in inputs if "aux" in i["input_id"]), inputs[0]) + controller.play_heos_input(key, quiet["input_id"]) + # A speaker takes a moment to change source, and a stop that arrives + # while it is still on the Spotify stream is exactly the one HEOS turns + # into a pause -- so wait for the input to be what is playing. + for _ in range(RELEASE_TRIES): + time.sleep(RELEASE_WAIT) + if not controller.on_spotify(key): + break + controller.toggle_play(key, "stop") + + +# Signing a speaker in is a LAN round trip, but Spotify's cloud hearing +# about it is not: the room only turns up in the account's device list a +# moment later, and a resume before then looks exactly like a failure. +HANDOVER_TRIES = 8 +HANDOVER_WAIT = 1.2 + + +def _spotify_take_over(device_name: str, client: SpotifyClient) -> str: + """Sign a room's speaker in to this account over the LAN, the way the + Spotify app does -- see spotify_zc. Returns None when it worked, or + what went wrong, since that is what the caller has to tell the user. + + This is the only thing that moves a speaker between accounts: Spotify's + cloud will not, and until the speaker itself is signed in, the Web API + does not admit the room exists.""" + try: + # Checked before knocking, not after: a speaker takes the sign-in, + # drops whoever was on it, and only then finds out Spotify will not + # have the token -- which leaves the room signed in to nobody. A + # login too old to carry the scope must not cost you the room. + if not client.has_scope("streaming"): + return ("that account's Spotify login has no 'streaming' scope, which is the one a " + "speaker asks for -- re-run tools/spotify_auth.py for it (see the README's " + "Spotify section), put the new refresh token in .env and restart the panel") + if _is_demo(): + return "the panel is in demo mode, which leaves real speakers alone" + speaker = _find_speaker(device_name) + if speaker is None: + return f"no speaker calling itself '{device_name}' answered on the network" + speaker.add_user(client.me()["id"], client.access_token()) + except (ZeroconfError, SpotifyError, OSError) as exc: + return str(exc) + return None + + +def _resume_once_signed_in(client: SpotifyClient, device_name: str) -> dict: + """Resume as soon as the room turns up in this account's device list, + rather than once, at the one moment it is least likely to be there.""" + for attempt in range(HANDOVER_TRIES): + if attempt: + time.sleep(HANDOVER_WAIT) + try: + return client.resume(device_name) + except SpotifyDeviceUnavailable: + continue + raise SpotifyError( + f"'{device_name}' took the sign-in, but Spotify never offered the room to this account " + "-- which means the speaker could not log in with the token it was given, and is now " + "signed in to nobody. Pick the room once in that account's Spotify app to put it back.") + + 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 @@ -357,10 +519,44 @@ def api_spotify_resume(): data = _payload() client = _spotify_from(data) key = _target_from(data) - device = client.resume(_spotify_name(key)) + try: + device = client.resume(_spotify_name(key)) + except SpotifyDeviceUnavailable as exc: + # A room this account cannot see is nearly always one another + # account is signed in to -- which is worth naming if the hand-over + # below cannot go through. + holder = _spotify_account_seeing(exc.device_name, besides=data["account"]) + # Leave whoever is there paused where they were, rather than cut + # off: signing the speaker in to someone else ends their stream. + if holder: + _spotify_pause_on(key, holder) + trouble = _spotify_take_over(exc.device_name, client) + if trouble: + whose = (f" It is signed in to {config.SPOTIFY_ACCOUNTS[holder]['label']}'s Spotify," + " and Spotify's own API will not move a speaker between accounts." if holder else "") + raise SpotifyError( + f"Could not sign '{exc.device_name}' in to " + f"{config.SPOTIFY_ACCOUNTS[data['account']]['label']}'s Spotify: {trouble}.{whose} " + "Picking the room once in that account's Spotify app does the same thing by " + "hand.") from exc + device = _resume_once_signed_in(client, exc.device_name) return jsonify({"target": key, "account": data["account"], "device": device.get("name")}) +@app.post("/api/spotify/disconnect") +@handle_errors +def api_spotify_disconnect(): + """Let go of a room: pause what the account is playing there, then sign + the speaker out of it, which stops the music and leaves the room free + for either account's button to claim -- see _spotify_release.""" + data = _payload() + _spotify_from(data) # the account has to be one of ours to let go of + key = _target_from(data) + released = _spotify_release(key, data["account"]) + return jsonify({"target": key, "account": data["account"], + "device": _spotify_name(key), **released}) + + # --- The original bridge's API, unchanged ----------------------------- @app.get("/targets") @handle_errors diff --git a/demo.py b/demo.py index ae64ca2..454b83c 100644 --- a/demo.py +++ b/demo.py @@ -42,6 +42,11 @@ class _FakeAvr: class DemoController: + # app.py checks this before anything that would touch real hardware + # over the network -- the speakers this stands in for may be switched + # on in the next room. + demo = True + def __init__(self, cfg): self.cfg = cfg self.avr = _FakeAvr() @@ -150,8 +155,15 @@ class DemoController: return [{"name": s["name"], "input_id": s["code"]} for s in self.avr.inputs()] def play_heos_input(self, key, input_id, source_key=None): + # A room put on one of its own inputs is off Spotify, which is how + # the panel lets go of a room -- see app.py's _spotify_release. + if key in self._spotify: + self._spotify[key] = False return None + def on_spotify(self, key): + return self._spotify.get(key, False) + # -- the AVR: app.py calls these directly, same as the real Controller def avr_inputs(self): return self.avr.inputs() diff --git a/spotify.py b/spotify.py index abc94b9..e96a885 100644 --- a/spotify.py +++ b/spotify.py @@ -25,6 +25,18 @@ class SpotifyError(RuntimeError): """Spotify answered, but said no (or never answered at all).""" +class SpotifyDeviceUnavailable(SpotifyError): + """The room is not in this account's device list. Usually that is not a + fault at all: a Connect receiver is signed in to ONE Spotify account at + a time, and Spotify hides it from every other account until that one + lets go -- which only a caller that knows the other accounts can say, + hence the device name travelling with the error.""" + + def __init__(self, message, device_name): + super().__init__(message) + self.device_name = device_name + + class SpotifyClient: """One access token, refreshed on demand, guarded by a lock the same way HeosClient guards its socket.""" @@ -42,6 +54,8 @@ class SpotifyClient: self.api_url = api_url or self.API_URL self._access_token = None self._expires_at = 0 + self._scopes = set() + self._me = None self._lock = threading.Lock() # -- auth ------------------------------------------------------------ @@ -70,6 +84,10 @@ class SpotifyClient: raise SpotifyError(f"Could not reach Spotify: {exc.reason}") from exc self._access_token = payload["access_token"] self._expires_at = time.time() + payload.get("expires_in", 3600) + # The only place Spotify ever says what this login is allowed to do: + # scopes are fixed when the refresh token is minted, so a feature + # needing one can ask instead of finding out the hard way. + self._scopes = set((payload.get("scope") or "").split()) return self._access_token def _token(self) -> str: @@ -119,6 +137,26 @@ class SpotifyClient: whether or not anything is currently playing.""" return self._call("GET", "/v1/me/player/devices").get("devices", []) + def me(self) -> dict: + """Who this refresh token belongs to. Its "id" is the canonical + username a speaker wants when signing the account in over the LAN + -- see spotify_zc.Speaker.add_user. Asked once: a refresh token + belongs to the one account for as long as it is a refresh token.""" + if self._me is None: + self._me = self._call("GET", "/v1/me") + return self._me + + def has_scope(self, scope: str) -> bool: + """Whether this login carries a scope. Getting a token is what + makes Spotify tell us, so make sure we have one.""" + self._token() + return scope in self._scopes + + def access_token(self) -> str: + """The current access token, for the one caller that has to hand it + somewhere other than our own requests: a speaker's zeroconf login.""" + return self._token() + def playback(self) -> dict: """The account's current playback -- which device, and whether it is playing -- or {} when the account has no playback session at all.""" @@ -131,15 +169,23 @@ class SpotifyClient: 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?") + names = [d.get("name", "?") for d in devices] + raise SpotifyDeviceUnavailable( + f"No Spotify Connect device named '{device_name}' is visible to this " + f"account right now (Spotify sees: {', '.join(names) or 'none'}) -- is " + "the room powered on, is it already connected to another Spotify " + "account, or does its spotify_name in config.json need setting?", + device_name) device = matches[0] self._call("PUT", "/v1/me/player", body={"device_ids": [device["id"]], "play": True}) return device + def pause(self): + """Pause whatever this account is playing, on whichever device is + playing it. Spotify refuses this with a 403 when nothing is, so ask + playback() first rather than pausing on spec.""" + self._call("PUT", "/v1/me/player/pause") + def seek(self, position_ms: int): """Jump to position_ms in whatever the account is playing, on whichever device is playing it.""" @@ -147,9 +193,21 @@ class SpotifyClient: def _error_detail(exc: urllib.error.HTTPError) -> str: + """Whatever Spotify actually said, since a bare "Forbidden" sends you + nowhere. Most refusals come back as JSON, but some answer with a plain + sentence instead -- notably the 403 for an account the developer app + has not listed under User Management -- so fall through to the raw body + before giving up and quoting the status line.""" try: with exc: - payload = json.loads(exc.read()) - return payload.get("error_description") or payload.get("error", {}).get("message") or exc.reason + raw = exc.read() + except (ValueError, OSError): + raw = b"" + try: + payload = json.loads(raw) + detail = payload.get("error_description") or payload.get("error", {}).get("message") except (ValueError, AttributeError, KeyError): - return exc.reason + detail = None + # A body that is neither JSON nor a short sentence is some proxy's error + # page, not Spotify's -- keep enough of it to recognise, not all of it. + return detail or raw.decode(errors="replace").strip()[:200] or exc.reason diff --git a/spotify_zc.py b/spotify_zc.py new file mode 100644 index 0000000..b5c2f9a --- /dev/null +++ b/spotify_zc.py @@ -0,0 +1,344 @@ +"""Spotify Connect over the LAN -- the channel the phone app uses, and the +only one that can move a speaker from one Spotify account to another. + +The Web API (spotify.py) only ever sees a speaker already signed in to the +account doing the asking: to every other account Spotify answers "Device not +found", even handed the right device id, and nothing in that API signs a +speaker in or out. The speakers themselves advertise `_spotify-connect._tcp` +on the LAN, with a small HTTP endpoint -- getInfo, addUser, resetUsers -- and +that endpoint is what your phone talks to when you pick a room in the +Spotify app. Same speaker, same device id; a different door. + +These Denons report `tokenType: accesstoken`, and what that means in +practice was worth measuring rather than assuming, because the older +`authBlob` protocol these devices descend from wraps its payload in a +Diffie-Hellman exchange and AES. This firmware does not: it takes the blob +literally. A correctly sealed blob is accepted with a cheerful 101 and then +never signs in; the token sent as plain text signs in immediately. The +clientKey parameter is required -- without it the POST is a 400 -- and is +then completely ignored: 96 random bytes work as well as a real public key. + +So the token crosses the LAN in the clear, which is the protocol's doing +rather than a shortcut here, and there is no crypto in this file at all. + +None of this is Spotify-documented -- it is the eSDK zeroconf protocol as +these speakers actually implement it. +""" + +import base64 +import json +import os +import socket +import struct +import time +import urllib.error +import urllib.parse +import urllib.request + +SERVICE = b"_spotify-connect._tcp.local" +MDNS_GROUP = "224.0.0.251" +MDNS_PORT = 5353 + +# The length of the client key the device insists on -- see _client_key. +KEY_BYTES = 96 + +# How long to listen for announcements. Every speaker here answers within a +# quarter of a second, and find() stops at the first match anyway, so this +# is only the patience for a room that does not answer -- and mDNS +# responders ignore a question they just answered, which is the usual +# reason for that, so the second look matters more than a long first one. +DISCOVERY_WINDOW = 1.5 +RETRY_AFTER = 1.5 + + +class ZeroconfError(RuntimeError): + """The speaker answered, but not with what we asked for.""" + + +# --- finding the speakers ---------------------------------------------- +def _local_address() -> str: + """The address mDNS should go out of. Connecting a UDP socket sends + nothing; it just makes the kernel pick the route and tell us which + interface it chose.""" + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + probe.connect((MDNS_GROUP, MDNS_PORT)) + return probe.getsockname()[0] + finally: + probe.close() + + +def _encode_name(name: bytes) -> bytes: + return b"".join(bytes([len(part)]) + part for part in name.split(b".")) + b"\x00" + + +def _read_name(data: bytes, offset: int): + """A DNS name, following the compression pointers that make one + impossible to read with a plain slice.""" + parts = [] + while True: + length = data[offset] + if length & 0xC0 == 0xC0: + pointer = struct.unpack("!H", data[offset:offset + 2])[0] & 0x3FFF + parts.append(_read_name(data, pointer)[0]) + return b".".join(parts), offset + 2 + offset += 1 + if not length: + return b".".join(parts), offset + parts.append(data[offset:offset + length]) + offset += length + + +def _mdns_socket(interface: str): + """Joined to the group on 5353 where answers are broadcast. Something + else (avahi) may already hold that port, so fall back to an ephemeral + one and ask for unicast answers instead.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except (AttributeError, OSError): + pass + unicast = False + try: + sock.bind(("", MDNS_PORT)) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, + socket.inet_aton(MDNS_GROUP) + socket.inet_aton(interface)) + except OSError: + sock.bind((interface, 0)) + unicast = True + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(interface)) + sock.settimeout(0.4) + return sock, unicast + + +def discover(timeout: float = DISCOVERY_WINDOW, interface: str = None): + """Every Spotify Connect receiver announcing itself on the LAN, as + {"host", "port", "path"} -- yielded as each one answers rather than + collected up and handed back at the end, because they all reply within + a quarter of a second and a caller looking for one room should not have + to sit out the rest of the window. + + A speaker that re-announces on a different port -- which is exactly + what it does after signing in or out -- comes round again as a second + entry rather than being swallowed as a duplicate of itself. + + The name in the announcement is the HEOS one and the path comes from + its TXT record, so what a room is actually called to Spotify is + getInfo's business, not this function's.""" + interface = interface or _local_address() + sock, unicast = _mdns_socket(interface) + try: + query = struct.pack("!6H", 0, 0, 1, 0, 0, 0) + _encode_name(SERVICE) + query += struct.pack("!2H", 12, 0x8001 if unicast else 1) + sock.sendto(query, (MDNS_GROUP, MDNS_PORT)) + + speakers, announced = {}, set() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + data, sender = sock.recvfrom(9000) + except socket.timeout: + continue + except OSError: + break + entry = _parse_answer(data) + if not entry: + continue + # SRV (the port) and TXT (the path) arrive in the same packet, + # so one is worth acting on as soon as it has a port at all. + speaker = speakers.setdefault(sender[0], {"host": sender[0], "path": "/zc"}) + speaker.update(entry) + if "port" in speaker and (sender[0], speaker["port"]) not in announced: + announced.add((sender[0], speaker["port"])) + yield dict(speaker) + finally: + sock.close() + + +def _parse_answer(data: bytes) -> dict: + """The port and TXT path out of one mDNS answer, if it is about us.""" + try: + counts = struct.unpack("!6H", data[:12]) + offset = 12 + for _ in range(counts[2]): + _, offset = _read_name(data, offset) + offset += 4 + entry = {} + for _ in range(sum(counts[3:])): + name, offset = _read_name(data, offset) + rtype, _cls, _ttl, length = struct.unpack("!HHIH", data[offset:offset + 10]) + offset += 10 + body, offset = data[offset:offset + length], offset + length + if SERVICE not in name.lower(): + continue + if rtype == 33: # SRV: where to knock + entry["port"] = struct.unpack("!H", body[4:6])[0] + elif rtype == 16: # TXT: CPath=/zc + for field in _txt_fields(body): + if field.startswith(b"CPath="): + entry["path"] = field[6:].decode(errors="replace") + return entry + except (struct.error, IndexError): + return {} # not ours to read + + +def _txt_fields(body: bytes) -> list: + fields, offset = [], 0 + while offset < len(body): + length = body[offset] + fields.append(body[offset + 1:offset + 1 + length]) + offset += 1 + length + return fields + + +# --- talking to one speaker --------------------------------------------- +class Speaker: + """One Connect receiver's zeroconf endpoint.""" + + def __init__(self, host: str, port: int, path: str = "/zc", timeout: float = 6.0): + self.host = host + self.port = port + self.path = path + self.timeout = timeout + + def __repr__(self): + return f"Speaker({self.host}:{self.port}{self.path})" + + @property + def url(self) -> str: + return f"http://{self.host}:{self.port}{self.path}" + + def _call(self, params: dict, post: bool = False) -> dict: + body = urllib.parse.urlencode(params).encode() + try: + if post: + request = urllib.request.Request( + self.url, data=body, method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}) + else: + request = urllib.request.Request(f"{self.url}?{body.decode()}") + with urllib.request.urlopen(request, timeout=self.timeout) as response: + raw = response.read() + except (urllib.error.URLError, OSError) as exc: + raise ZeroconfError(f"{self.host} did not answer: {exc}") from exc + try: + return json.loads(raw) + except ValueError as exc: + raise ZeroconfError( + f"{self.host} answered something that is not JSON: " + f"{raw.decode(errors='replace')[:120]}") from exc + + def info(self) -> dict: + """What the speaker says it is: remoteName (the name Spotify shows, + which is what config.json's spotify_name has to match), deviceID + (the same id the Web API uses), publicKey, and the tokenType that + says how add_user has to talk to it.""" + return self._call({"action": "getInfo"}) + + def add_user(self, username: str, access_token: str, device_name: str = "Heos panel") -> dict: + """Sign this speaker in to an account, the way the phone does. + + One POST: who, and a token to be them with. See the module + docstring for why the token is not encrypted -- this firmware would + not decrypt it if it were.""" + details = self.info() + if details.get("tokenType") != "accesstoken": + raise ZeroconfError( + f"'{details.get('remoteName', self.host)}' wants a {details.get('tokenType')} " + "login, not an access token -- that is the older blob protocol, which needs " + "credentials a Web API token cannot produce.") + answer = self._call({ + "action": "addUser", + "userName": username, + "blob": access_token, + "clientKey": self._client_key(), + "tokenType": "accesstoken", + "deviceName": device_name, + "version": details.get("version", "2.10.0"), + "loginId": "", + }, post=True) + # 101 is the speaker saying it took the request, not that the + # sign-in worked: it answers before it has talked to Spotify at + # all. Whether it worked is the caller's device list, a moment later. + if answer.get("status") != 101: + raise ZeroconfError( + f"'{details.get('remoteName', self.host)}' refused the login: " + f"{answer.get('statusString') or answer}") + return answer + + def reset_users(self) -> dict: + """Sign the speaker out of whatever account it is on. Leaves it + signed in to nobody -- so nobody's Web API can see it either, which + is why add_user, not this, is what hands a room over. + + The receiver restarts afterwards and comes back on a *different* + port, so a Speaker held across this call is pointing at nothing: + find() it again rather than reusing this one.""" + answer = self._call({"action": "resetUsers"}, post=True) + forget(self) + return answer + + @staticmethod + def _client_key() -> str: + """The device refuses a POST without one (HTTP 400) and signs in + perfectly well with random bytes, so that is what this is: the + shape of a public key, for a key exchange this firmware never + performs.""" + return base64.b64encode(os.urandom(KEY_BYTES)).decode() + + +# Speakers found before, by the name Spotify knows them by. Worth keeping: +# an mDNS responder ignores a question it has just answered, so asking the +# network on every button press is slower *and* less reliable than knocking +# on the door that worked last time. +_known = {} + + +def _answers_to(speaker: Speaker, name: str, timeout: float = 1.0) -> bool: + """Whether that endpoint is still this room. Its own short timeout: a + speaker that has been switched off does not refuse the connection, it + says nothing at all, and the point of this check is to be quick.""" + probe = Speaker(speaker.host, speaker.port, speaker.path, timeout=timeout) + try: + return probe.info().get("remoteName") == name + except ZeroconfError: + return False + + +def forget(speaker: Speaker): + """Drop any remembered endpoint pointing where this one does -- for + after something that restarts the receiver.""" + for name, known in list(_known.items()): + if (known.host, known.port) == (speaker.host, speaker.port): + del _known[name] + + +def find(name: str, timeout: float = DISCOVERY_WINDOW, interface: str = None, + tries: int = 2) -> Speaker: + """The speaker Spotify calls `name`, or None. + + Tried in the cheap order: the endpoint that worked last time, confirmed + with one getInfo (some 20ms), and only then the network. Discovery + answers with HEOS names, which are not always the Spotify ones -- the + Living Room pair announces itself as "Denon Home 200 L" and calls + itself "Living Room" to Spotify -- so every candidate gets asked who it + is. And it goes round twice, because a responder that has just answered + somebody ignores the next question for about a second, which looks + exactly like a room that is not there.""" + remembered = _known.get(name) + if remembered is not None and _answers_to(remembered, name): + return remembered + for attempt in range(tries): + if attempt: + time.sleep(RETRY_AFTER) + for candidate in discover(timeout=timeout, interface=interface): + speaker = Speaker(candidate["host"], candidate["port"], candidate.get("path", "/zc")) + try: + if speaker.info().get("remoteName") == name: + _known[name] = speaker + return speaker + except ZeroconfError: + continue # an endpoint that has moved on since it announced + _known.pop(name, None) + return None diff --git a/static/app.js b/static/app.js index 17f158e..9155322 100644 --- a/static/app.js +++ b/static/app.js @@ -204,8 +204,14 @@ function paintRoom(room) { // them, because the row also holds the host toggle, which stays put and // merely disables. els('.spotify', room.spotify).forEach((button) => { + const holding = button.dataset.account === room.spotifyAccount; button.hidden = !room.available || room.grouped; - button.setAttribute('aria-pressed', String(button.dataset.account === room.spotifyAccount)); + button.setAttribute('aria-pressed', String(holding)); + // Bordered, the button is the way back out of the room rather than into + // it, so its label has to say that and not "Resume". + button.setAttribute( + 'aria-label', + `${holding ? 'Disconnect' : 'Resume'} ${button.querySelector('span').textContent}'s Spotify`); }); room.toggle.disabled = !room.available; @@ -592,7 +598,14 @@ function applyJoined(joined) { receiver to resume that account, instead of always connecting to it from the Spotify app -------------------------------------------------- */ els('.spotify').forEach((button) => { - button.addEventListener('click', () => resumeSpotify(button)); + button.addEventListener('click', () => { + // The bordered button is the account already playing here, and resuming + // it again would do nothing, so that tap is the opposite request: let + // the room go, so another account can have it. + const room = rooms[button.dataset.target]; + if (room && room.spotifyAccount === button.dataset.account) disconnectSpotify(button); + else resumeSpotify(button); + }); }); async function resumeSpotify(button) { @@ -615,6 +628,35 @@ async function resumeSpotify(button) { } } +async function disconnectSpotify(button) { + const { target, account } = button.dataset; + const who = button.querySelector('span').textContent; + const room = rooms[target]; + button.disabled = true; + try { + const data = await api('/api/spotify/disconnect', { target, account }); + // The speaker not answering on the LAN is not a failure -- the room + // still stops -- but it does leave the account signed in to it, and + // that is the difference between "let go of" and "stopped". + toast(data.signed_out + ? `${data.device} let go of ${who}'s Spotify` + : `Stopped ${who}'s Spotify on ${data.device} — the speaker did not answer, so it stays signed in`, + 'ok'); + // The room stops with it, so drop the border, the transport row and the + // song now rather than leaving a card that still claims to be playing; + // the refresh below confirms it. + room.spotifyAccount = null; + room.playState = 'stop'; + room.track = null; + paintRoom(room); + 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); diff --git a/tests/fake_spotify.py b/tests/fake_spotify.py index c7d7fde..cb4971f 100644 --- a/tests/fake_spotify.py +++ b/tests/fake_spotify.py @@ -1,6 +1,6 @@ """Stand-in Spotify Web API: just enough of /api/token, .../player/devices, -.../player and .../player/seek to test spotify.py against, the same way fakes.py stands in -for real HEOS hardware.""" +.../player, .../player/pause and .../player/seek to test spotify.py against, the same way +fakes.py stands in for real HEOS hardware.""" import json import threading @@ -23,8 +23,11 @@ class FakeSpotify(threading.Thread): self.tokens_issued = 0 self.transfers = [] # every PUT /v1/me/player body self.seeks = [] # every PUT /v1/me/player/seek's position_ms + self.pauses = 0 # how many PUT /v1/me/player/pause it took self.reject_refresh = False # simulate a revoked refresh token + self.scope = "user-read-playback-state user-modify-playback-state" self.player = None # GET /v1/me/player's body; None is no session (204) + self.forbid = None # a plain-text 403 body every API call answers with fake = self @@ -39,6 +42,19 @@ class FakeSpotify(threading.Thread): if payload is not None: self.wfile.write(json.dumps(payload).encode()) + def _forbidden(self): + """The real one refuses an account the developer app has not + listed with a bare sentence, not the usual JSON error object.""" + if fake.forbid is None: + return False + body = fake.forbid.encode() + self.send_response(403) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return True + def _authorized(self): header = self.headers.get("Authorization", "") return header == f"Bearer {fake.valid_token}" and fake.valid_token is not None @@ -51,11 +67,14 @@ class FakeSpotify(threading.Thread): return fake.tokens_issued += 1 fake.valid_token = f"token-{fake.tokens_issued}" - self._send(200, {"access_token": fake.valid_token, "expires_in": 3600}) + self._send(200, {"access_token": fake.valid_token, "expires_in": 3600, + "scope": fake.scope}) return self._send(404, {"error": {"message": "not found"}}) def do_PUT(self): + if self._forbidden(): + return if self.path == "/v1/me/player": if not self._authorized(): self._send(401, {"error": {"message": "The access token expired"}}) @@ -65,6 +84,16 @@ class FakeSpotify(threading.Thread): self.send_response(204) self.end_headers() return + if self.path == "/v1/me/player/pause": + if not self._authorized(): + self._send(401, {"error": {"message": "The access token expired"}}) + return + fake.pauses += 1 + if fake.player is not None: + fake.player["is_playing"] = False + self.send_response(204) + self.end_headers() + return if self.path.startswith("/v1/me/player/seek?"): if not self._authorized(): self._send(401, {"error": {"message": "The access token expired"}}) @@ -83,6 +112,8 @@ class FakeSpotify(threading.Thread): self._send(404, {"error": {"message": "not found"}}) def do_GET(self): + if self._forbidden(): + return if self.path == "/v1/me/player/devices": if not self._authorized(): self._send(401, {"error": {"message": "The access token expired"}}) diff --git a/tests/test_spotify.py b/tests/test_spotify.py index 64372a1..127bd74 100644 --- a/tests/test_spotify.py +++ b/tests/test_spotify.py @@ -9,7 +9,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from spotify import SpotifyClient, SpotifyError # noqa: E402 +from spotify import SpotifyClient, SpotifyDeviceUnavailable, SpotifyError # noqa: E402 from tests.fake_spotify import FakeSpotify # noqa: E402 @@ -33,10 +33,33 @@ class SpotifyTest(unittest.TestCase): 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): + with self.assertRaises(SpotifyDeviceUnavailable): self.client.resume("Kitchen") self.assertEqual(self.fake.transfers, []) + def test_an_invisible_device_reports_what_it_looked_for(self): + # A room another Spotify account is signed in to is simply absent + # here, and app.py needs the name back to go and ask who has it. + with self.assertRaises(SpotifyDeviceUnavailable) as caught: + self.client.resume("Living Room") + self.assertEqual(caught.exception.device_name, "Living Room") + + def test_it_knows_what_its_login_is_allowed_to_do(self): + # Signing a speaker in needs the "streaming" scope, and finding that + # out afterwards costs the room -- see app.py's _spotify_take_over. + self.fake.scope = "user-read-playback-state streaming" + self.assertTrue(self.client.has_scope("streaming")) + self.assertFalse(self.client.has_scope("user-modify-playback-state")) + + def test_pause_stops_what_the_account_is_playing(self): + # What handing a room back starts with: the account keeps its place + # in the queue, so its own app -- or this panel's button -- can pick + # the stream up again somewhere else. + self.fake.player = {"device": {"id": "dev-1", "name": "Lego Room"}, "is_playing": True} + self.client.pause() + self.assertEqual(self.fake.pauses, 1) + self.assertFalse(self.client.playback()["is_playing"]) + def test_seek_asks_for_the_position(self): self.client.seek(90000) self.assertEqual(self.fake.seeks, [90000]) @@ -60,6 +83,15 @@ class SpotifyTest(unittest.TestCase): self.assertEqual(names, ["Lego Room", "Home Cinema"]) self.assertEqual(self.fake.tokens_issued, 2) + def test_a_plain_text_refusal_is_quoted_rather_than_flattened(self): + # The 403 for an account missing from the developer app's User + # Management is Spotify's only hint that that is what is wrong, + # and it arrives as prose rather than as a JSON error object. + self.fake.forbid = "The user is not registered for this application." + with self.assertRaises(SpotifyError) as caught: + self.client.devices() + self.assertIn("not registered for this application", str(caught.exception)) + def test_a_revoked_refresh_token_raises_a_clear_error(self): self.fake.reject_refresh = True with self.assertRaises(SpotifyError): diff --git a/tests/test_spotify_zc.py b/tests/test_spotify_zc.py new file mode 100644 index 0000000..c8e7eb0 --- /dev/null +++ b/tests/test_spotify_zc.py @@ -0,0 +1,141 @@ +"""The LAN half of Spotify Connect, against a stand-in for one speaker's +/zc endpoint. What the real ones do with these calls was measured rather +than assumed -- spotify_zc's module docstring says what and why. + + python3 -m unittest discover -s tests -t . +""" + +import base64 +import json +import sys +import threading +import unittest +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from spotify_zc import KEY_BYTES, Speaker, ZeroconfError # noqa: E402 + + +class FakeSpeaker(threading.Thread): + """A speaker's /zc endpoint, as much of one as this needs -- including + its habit of answering 101 to a sign-in it has not tried yet.""" + + def __init__(self, token_type="accesstoken"): + super().__init__(daemon=True) + self.token_type = token_type + self.refuse = False + self.logins = [] + self.resets = 0 + fake = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def _send(self, payload): + body = json.dumps(payload).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if parse_qs(urlparse(self.path).query).get("action") == ["getInfo"]: + self._send(fake.getinfo()) + else: + self._send({"status": 301, "statusString": "ERROR-UNKNOWN"}) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + form = {k: v[0] for k, v in parse_qs(self.rfile.read(length).decode()).items()} + if form.get("action") == "addUser": + fake.logins.append(form) + if fake.refuse: + self._send({"status": 203, "statusString": "ERROR-INVALID-ARGUMENTS"}) + else: + self._send({"status": 101, "statusString": "OK", "spotifyError": 0}) + elif form.get("action") == "resetUsers": + fake.resets += 1 + self._send({"status": 101, "statusString": "OK"}) + else: + self._send({"status": 301, "statusString": "ERROR-UNKNOWN"}) + + self.server = HTTPServer(("127.0.0.1", 0), Handler) + self.port = self.server.server_port + self.start() + + def getinfo(self): + return { + "status": 101, "statusString": "OK", "remoteName": "Living Room", + "deviceID": "8f190c21", "version": "2.10.0", "tokenType": self.token_type, + } + + def run(self): + self.server.serve_forever(poll_interval=0.05) + + def stop(self): + self.server.shutdown() + self.server.server_close() + + +class SpeakerTest(unittest.TestCase): + def setUp(self): + self.fake = FakeSpeaker() + self.addCleanup(self.fake.stop) + self.speaker = Speaker("127.0.0.1", self.fake.port, "/zc") + + def test_info_reads_the_speaker_back(self): + self.assertEqual(self.speaker.info()["remoteName"], "Living Room") + + def test_add_user_hands_over_the_token(self): + self.speaker.add_user("fifou", "BQD-token") + (login,) = self.fake.logins + self.assertEqual(login["userName"], "fifou") + self.assertEqual(login["tokenType"], "accesstoken") + # Plain, because that is the only shape these speakers act on: a + # sealed blob is taken and then never signed in with. + self.assertEqual(login["blob"], "BQD-token") + + def test_add_user_sends_the_client_key_the_device_demands(self): + # Required -- a POST without one is a 400 -- and then never read, + # so all that matters is that something of the size is there. + self.speaker.add_user("fifou", "BQD-token") + self.assertEqual(len(base64.b64decode(self.fake.logins[0]["clientKey"])), KEY_BYTES) + + def test_every_sign_in_brings_its_own_client_key(self): + self.speaker.add_user("fifou", "BQD-token") + self.speaker.add_user("fifou", "BQD-token") + first, second = (login["clientKey"] for login in self.fake.logins) + self.assertNotEqual(first, second) + + def test_a_refused_sign_in_is_reported(self): + self.fake.refuse = True + with self.assertRaises(ZeroconfError) as caught: + self.speaker.add_user("fifou", "BQD-token") + self.assertIn("refused the login", str(caught.exception)) + + def test_reset_users_signs_it_out(self): + self.speaker.reset_users() + self.assertEqual(self.fake.resets, 1) + + def test_an_older_speaker_says_so_rather_than_failing_obscurely(self): + # A device on the original blob protocol wants credentials a Web API + # token cannot produce, so there is no point sending it one. + self.fake.token_type = "authBlob" + with self.assertRaises(ZeroconfError) as caught: + self.speaker.add_user("fifou", "BQD-token") + self.assertIn("older blob protocol", str(caught.exception)) + self.assertEqual(self.fake.logins, []) + + def test_a_speaker_that_is_not_there_is_an_error_not_a_hang(self): + self.fake.stop() + with self.assertRaises(ZeroconfError): + self.speaker.info() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/spotify_auth.py b/tools/spotify_auth.py index f35044b..a2adb9d 100644 --- a/tools/spotify_auth.py +++ b/tools/spotify_auth.py @@ -46,7 +46,12 @@ 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" +# "streaming" is not for streaming anything here: it is the scope the +# speakers themselves ask for (getInfo says so) when this panel signs one +# in to an account over the LAN -- see spotify_zc.py. Without it a token +# can still see and steer a room the account already holds, but cannot +# take a room that another account is signed in to. +SCOPES = "user-read-playback-state user-modify-playback-state streaming" def get_code(client_id: str, state: str) -> str: