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