This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+66
-8
@@ -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
|
||||
|
||||
+344
@@ -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
|
||||
+44
-2
@@ -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);
|
||||
|
||||
+34
-3
@@ -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"}})
|
||||
|
||||
+34
-2
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user