From e5c06a2f672f28f8b587e124a6fff88e625b6f66 Mon Sep 17 00:00:00 2001 From: cottongin Date: Thu, 12 Mar 2026 07:08:02 -0400 Subject: [PATCH] docs: add live announce dashboard implementation plan 13-task TDD plan covering WebSocket manager, dashboard auth, announce endpoint, styled UI, and bot plugin WS clients for both Sopel and Limnoria. Made-with: Cursor --- ...2026-03-12-live-announce-dashboard-impl.md | 1261 +++++++++++++++++ 1 file changed, 1261 insertions(+) create mode 100644 docs/plans/2026-03-12-live-announce-dashboard-impl.md diff --git a/docs/plans/2026-03-12-live-announce-dashboard-impl.md b/docs/plans/2026-03-12-live-announce-dashboard-impl.md new file mode 100644 index 0000000..2528579 --- /dev/null +++ b/docs/plans/2026-03-12-live-announce-dashboard-impl.md @@ -0,0 +1,1261 @@ +# Live Announce Dashboard Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a web dashboard that lets Nick announce tracks to IRC in real-time during live shows via WebSocket push, with both Sopel and Limnoria plugins receiving announcements. + +**Architecture:** FastAPI serves a login page + dashboard page as static HTML. A WebSocket endpoint at `/ws/announce` acts as a broadcast hub. The dashboard POSTs to `/admin/announce`, which formats the message and pushes it to all connected WS subscriber bots. Bot plugins run a background thread with a `websocket-client` connection that listens for announcements and emits them to a configured IRC channel. + +**Tech Stack:** FastAPI (WS support built-in), `itsdangerous` (cookie signing), `websocket-client` (bot plugins), Pico CSS (CDN), vanilla JS. + +**Design doc:** `docs/plans/2026-03-12-live-announce-dashboard-design.md` + +--- + +## Task 1: Add optional dashboard config fields + +**Files:** +- Modify: `src/ntr_fetcher/config.py` +- Test: `tests/test_config.py` (create) + +**Step 1: Write the failing test** + +```python +# tests/test_config.py +import os +from ntr_fetcher.config import Settings + + +def test_dashboard_config_absent(monkeypatch): + monkeypatch.setenv("NTR_ADMIN_TOKEN", "tok") + monkeypatch.delenv("NTR_WEB_USER", raising=False) + monkeypatch.delenv("NTR_WEB_PASSWORD", raising=False) + monkeypatch.delenv("NTR_SECRET_KEY", raising=False) + s = Settings() + assert s.web_user is None + assert s.web_password is None + assert s.secret_key is None + assert s.dashboard_enabled is False + + +def test_dashboard_config_present(monkeypatch): + monkeypatch.setenv("NTR_ADMIN_TOKEN", "tok") + monkeypatch.setenv("NTR_WEB_USER", "nick") + monkeypatch.setenv("NTR_WEB_PASSWORD", "secret") + monkeypatch.setenv("NTR_SECRET_KEY", "signme") + s = Settings() + assert s.web_user == "nick" + assert s.web_password == "secret" + assert s.secret_key == "signme" + assert s.dashboard_enabled is True +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_config.py -v` +Expected: FAIL — `Settings` has no `web_user` / `dashboard_enabled` attributes. + +**Step 3: Write minimal implementation** + +In `src/ntr_fetcher/config.py`, add three optional fields and a computed property: + +```python +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + model_config = {"env_prefix": "NTR_"} + + port: int = 8000 + host: str = "127.0.0.1" + db_path: str = "./ntr_fetcher.db" + poll_interval_seconds: int = 3600 + admin_token: str + soundcloud_user: str = "nicktherat" + show_day: int = 2 + show_hour: int = 22 + + web_user: str | None = None + web_password: str | None = None + secret_key: str | None = None + + @property + def dashboard_enabled(self) -> bool: + return all([self.web_user, self.web_password, self.secret_key]) +``` + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_config.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/ntr_fetcher/config.py tests/test_config.py +git commit -m "feat: add optional dashboard config fields" +``` + +--- + +## Task 2: Add `itsdangerous` dependency + +**Files:** +- Modify: `pyproject.toml` + +**Step 1: Add dependency** + +Add `"itsdangerous"` to the `dependencies` list in `pyproject.toml`: + +```toml +dependencies = [ + "fastapi", + "uvicorn[standard]", + "httpx", + "pydantic-settings", + "itsdangerous", +] +``` + +**Step 2: Install** + +Run: `pip install -e ".[dev]"` +Expected: installs `itsdangerous` + +**Step 3: Commit** + +```bash +git add pyproject.toml +git commit -m "chore: add itsdangerous dependency for session cookies" +``` + +--- + +## Task 3: WebSocket manager + +**Files:** +- Create: `src/ntr_fetcher/websocket.py` +- Test: `tests/test_websocket.py` (create) + +**Step 1: Write the failing test** + +```python +# tests/test_websocket.py +import asyncio +import pytest +from ntr_fetcher.websocket import AnnounceManager + + +@pytest.fixture +def manager(): + return AnnounceManager() + + +def test_no_subscribers_initially(manager): + assert manager.subscriber_count == 0 + + +@pytest.mark.asyncio +async def test_subscribe_and_broadcast(manager): + received = [] + + async def fake_send(msg): + received.append(msg) + + class FakeWS: + async def send_json(self, data): + received.append(data) + + ws = FakeWS() + manager.add_subscriber(ws) + assert manager.subscriber_count == 1 + + await manager.broadcast({"type": "announce", "message": "Now Playing: Song #1"}) + assert len(received) == 1 + assert received[0]["message"] == "Now Playing: Song #1" + + manager.remove_subscriber(ws) + assert manager.subscriber_count == 0 + + +@pytest.mark.asyncio +async def test_broadcast_skips_dead_connections(manager): + class DeadWS: + async def send_json(self, data): + raise Exception("connection closed") + + ws = DeadWS() + manager.add_subscriber(ws) + assert manager.subscriber_count == 1 + + await manager.broadcast({"type": "announce", "message": "test"}) + assert manager.subscriber_count == 0 +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_websocket.py -v` +Expected: FAIL — `ntr_fetcher.websocket` does not exist. + +**Step 3: Write minimal implementation** + +```python +# src/ntr_fetcher/websocket.py +import logging + +logger = logging.getLogger(__name__) + + +class AnnounceManager: + def __init__(self): + self._subscribers: list = [] + + @property + def subscriber_count(self) -> int: + return len(self._subscribers) + + def add_subscriber(self, websocket) -> None: + self._subscribers.append(websocket) + logger.info("Subscriber connected (%d total)", self.subscriber_count) + + def remove_subscriber(self, websocket) -> None: + self._subscribers = [ws for ws in self._subscribers if ws is not websocket] + logger.info("Subscriber disconnected (%d total)", self.subscriber_count) + + async def broadcast(self, message: dict) -> None: + dead = [] + for ws in self._subscribers: + try: + await ws.send_json(message) + except Exception: + dead.append(ws) + logger.warning("Removing dead subscriber") + for ws in dead: + self.remove_subscriber(ws) + + async def broadcast_status(self) -> None: + await self.broadcast({ + "type": "status", + "subscribers": self.subscriber_count, + }) +``` + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_websocket.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/ntr_fetcher/websocket.py tests/test_websocket.py +git commit -m "feat: add WebSocket announce manager" +``` + +--- + +## Task 4: Dashboard router — session auth helpers + +**Files:** +- Create: `src/ntr_fetcher/dashboard.py` +- Test: `tests/test_dashboard.py` (create) + +This task covers session cookie creation/validation and the login/logout flow. The announce endpoint and WS handler come in Tasks 5–6. + +**Step 1: Write the failing test** + +```python +# tests/test_dashboard.py +from datetime import datetime, timezone +from unittest.mock import MagicMock, AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from ntr_fetcher.dashboard import create_dashboard_router +from ntr_fetcher.db import Database +from ntr_fetcher.websocket import AnnounceManager + + +@pytest.fixture +def db(tmp_path): + database = Database(str(tmp_path / "test.db")) + database.initialize() + return database + + +@pytest.fixture +def manager(): + return AnnounceManager() + + +@pytest.fixture +def app(db, manager): + a = FastAPI() + router = create_dashboard_router( + db=db, + manager=manager, + admin_token="test-token", + web_user="nick", + web_password="secret", + secret_key="test-secret-key", + show_day=2, + show_hour=22, + ) + a.include_router(router) + return a + + +@pytest.fixture +def client(app): + return TestClient(app) + + +def test_dashboard_redirects_without_session(client): + resp = client.get("/dashboard", follow_redirects=False) + assert resp.status_code == 303 + assert "/login" in resp.headers["location"] + + +def test_login_page_renders(client): + resp = client.get("/login") + assert resp.status_code == 200 + assert "login" in resp.text.lower() + + +def test_login_with_valid_credentials(client): + resp = client.post( + "/login", + data={"username": "nick", "password": "secret"}, + follow_redirects=False, + ) + assert resp.status_code == 303 + assert "/dashboard" in resp.headers["location"] + assert "ntr_session" in resp.cookies + + +def test_login_with_invalid_credentials(client): + resp = client.post( + "/login", + data={"username": "nick", "password": "wrong"}, + follow_redirects=False, + ) + assert resp.status_code == 200 + assert "invalid" in resp.text.lower() or "incorrect" in resp.text.lower() + + +def test_dashboard_accessible_with_session(client): + client.post( + "/login", + data={"username": "nick", "password": "secret"}, + follow_redirects=False, + ) + resp = client.get("/dashboard") + assert resp.status_code == 200 + assert "dashboard" in resp.text.lower() + + +def test_logout_clears_session(client): + client.post( + "/login", + data={"username": "nick", "password": "secret"}, + follow_redirects=False, + ) + resp = client.get("/logout", follow_redirects=False) + assert resp.status_code == 303 + assert "/login" in resp.headers["location"] + + resp2 = client.get("/dashboard", follow_redirects=False) + assert resp2.status_code == 303 +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_dashboard.py -v` +Expected: FAIL — `ntr_fetcher.dashboard` does not exist. + +**Step 3: Write minimal implementation** + +```python +# src/ntr_fetcher/dashboard.py +import hashlib +import hmac +import json +import logging +import time +from pathlib import Path + +from fastapi import APIRouter, Request, Response, HTTPException, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse + +from ntr_fetcher.db import Database +from ntr_fetcher.websocket import AnnounceManager +from ntr_fetcher.week import get_show_week + +logger = logging.getLogger(__name__) + +STATIC_DIR = Path(__file__).parent / "static" +SESSION_MAX_AGE = 86400 # 24 hours + + +def _sign(data: str, secret: str) -> str: + sig = hmac.new(secret.encode(), data.encode(), hashlib.sha256).hexdigest() + return f"{data}.{sig}" + + +def _unsign(signed: str, secret: str, max_age: int = SESSION_MAX_AGE) -> str | None: + if "." not in signed: + return None + data, sig = signed.rsplit(".", 1) + expected = hmac.new(secret.encode(), data.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(sig, expected): + return None + try: + payload = json.loads(data) + if time.time() - payload.get("t", 0) > max_age: + return None + return payload.get("user") + except (json.JSONDecodeError, KeyError): + return None + + +def create_dashboard_router( + db: Database, + manager: AnnounceManager, + admin_token: str, + web_user: str, + web_password: str, + secret_key: str, + show_day: int = 2, + show_hour: int = 22, +) -> APIRouter: + router = APIRouter() + + def _get_session_user(request: Request) -> str | None: + cookie = request.cookies.get("ntr_session") + if not cookie: + return None + return _unsign(cookie, secret_key) + + def _require_session(request: Request) -> str: + user = _get_session_user(request) + if user is None: + raise HTTPException(status_code=303, headers={"Location": "/login"}) + return user + + @router.get("/login", response_class=HTMLResponse) + def login_page(request: Request, error: str = ""): + html = (STATIC_DIR / "login.html").read_text() + if error: + html = html.replace("", '

Invalid username or password

') + return HTMLResponse(html) + + @router.post("/login") + def login_submit( + username: str = Form(...), + password: str = Form(...), + ): + if username == web_user and password == web_password: + payload = json.dumps({"user": username, "t": int(time.time())}) + cookie_value = _sign(payload, secret_key) + response = RedirectResponse(url="/dashboard", status_code=303) + response.set_cookie( + "ntr_session", + cookie_value, + max_age=SESSION_MAX_AGE, + httponly=True, + samesite="lax", + ) + return response + html = (STATIC_DIR / "login.html").read_text() + html = html.replace("", '

Invalid username or password

') + return HTMLResponse(html, status_code=200) + + @router.get("/logout") + def logout(): + response = RedirectResponse(url="/login", status_code=303) + response.delete_cookie("ntr_session") + return response + + @router.get("/dashboard", response_class=HTMLResponse) + def dashboard(request: Request): + user = _get_session_user(request) + if user is None: + return RedirectResponse(url="/login", status_code=303) + html = (STATIC_DIR / "dashboard.html").read_text() + return HTMLResponse(html) + + return router +``` + +**Step 4: Create minimal static HTML stubs** + +Create `src/ntr_fetcher/static/login.html`: + +```html + +Login + + +
+ + + +
+ +``` + +Create `src/ntr_fetcher/static/dashboard.html`: + +```html + +Dashboard +

NtR Playlist Dashboard

+``` + +**Step 5: Run test to verify it passes** + +Run: `pytest tests/test_dashboard.py -v` +Expected: PASS + +**Step 6: Commit** + +```bash +git add src/ntr_fetcher/dashboard.py src/ntr_fetcher/static/ tests/test_dashboard.py +git commit -m "feat: add dashboard router with session auth" +``` + +--- + +## Task 5: Announce endpoint + +**Files:** +- Modify: `src/ntr_fetcher/dashboard.py` +- Test: `tests/test_dashboard.py` (append) + +**Step 1: Write the failing test** + +Append to `tests/test_dashboard.py`: + +```python +from ntr_fetcher.models import Track + + +def _seed_show(db): + from datetime import datetime, timezone + week_start = datetime(2026, 3, 12, 2, 0, 0, tzinfo=timezone.utc) + week_end = datetime(2026, 3, 19, 2, 0, 0, tzinfo=timezone.utc) + show = db.get_or_create_show(week_start, week_end) + t1 = Track(1, "Song A", "Artist A", "https://soundcloud.com/a/1", None, 180000, "cc-by", + datetime(2026, 3, 14, 1, 0, 0, tzinfo=timezone.utc), "{}") + db.upsert_track(t1) + db.set_show_tracks(show.id, [t1.id]) + return show + + +def test_announce_with_session(client, db): + _seed_show(db) + client.post( + "/login", + data={"username": "nick", "password": "secret"}, + follow_redirects=False, + ) + from unittest.mock import patch + with patch("ntr_fetcher.dashboard.datetime") as mock_dt: + mock_dt.now.return_value = datetime(2026, 3, 14, 12, 0, 0, tzinfo=timezone.utc) + resp = client.post("/admin/announce", json={"show_id": 1, "position": 1}) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "announced" + assert "Now Playing:" in data["message"] + assert "Song A" in data["message"] + assert "Artist A" in data["message"] + + +def test_announce_with_bearer(client, db): + _seed_show(db) + from unittest.mock import patch + with patch("ntr_fetcher.dashboard.datetime") as mock_dt: + mock_dt.now.return_value = datetime(2026, 3, 14, 12, 0, 0, tzinfo=timezone.utc) + resp = client.post( + "/admin/announce", + json={"show_id": 1, "position": 1}, + headers={"Authorization": "Bearer test-token"}, + ) + assert resp.status_code == 200 + assert "Now Playing:" in resp.json()["message"] + + +def test_announce_without_auth(client, db): + _seed_show(db) + resp = client.post("/admin/announce", json={"show_id": 1, "position": 1}) + assert resp.status_code == 401 +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_dashboard.py::test_announce_with_session -v` +Expected: FAIL — no `/admin/announce` route. + +**Step 3: Write minimal implementation** + +Add to `src/ntr_fetcher/dashboard.py`, inside `create_dashboard_router`, before `return router`: + +```python +from datetime import datetime, timezone +from pydantic import BaseModel + +class AnnounceRequest(BaseModel): + show_id: int + position: int + +@router.post("/admin/announce") +async def announce(body: AnnounceRequest, request: Request): + # Accept session cookie OR bearer token + user = _get_session_user(request) + if user is None: + auth_header = request.headers.get("authorization", "") + if not auth_header.startswith("Bearer ") or auth_header.removeprefix("Bearer ") != admin_token: + raise HTTPException(status_code=401, detail="Unauthorized") + + track = db.get_show_track_by_position(body.show_id, body.position) + if track is None: + raise HTTPException(status_code=404, detail=f"No track at position {body.position}") + + message = ( + f"Now Playing: Song #{track['position']}: " + f"{track['title']} by {track['artist']} - {track['permalink_url']}" + ) + await manager.broadcast({"type": "announce", "message": message}) + return {"status": "announced", "message": message} +``` + +Note: the `AnnounceRequest` model and `datetime` import should go at module level, not inside the function. Place the `AnnounceRequest` class near the top of the file. The `datetime` import is already present from the session code. + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_dashboard.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/ntr_fetcher/dashboard.py tests/test_dashboard.py +git commit -m "feat: add announce endpoint with dual auth" +``` + +--- + +## Task 6: WebSocket handler on the API + +**Files:** +- Modify: `src/ntr_fetcher/dashboard.py` +- Test: `tests/test_dashboard.py` (append) + +**Step 1: Write the failing test** + +Append to `tests/test_dashboard.py`: + +```python +def test_ws_subscribe_with_valid_token(app): + with TestClient(app) as c: + with c.websocket_connect("/ws/announce") as ws: + ws.send_json({"type": "subscribe", "token": "test-token"}) + data = ws.receive_json() + assert data["type"] == "status" + + +def test_ws_subscribe_with_invalid_token(app): + with TestClient(app) as c: + with c.websocket_connect("/ws/announce") as ws: + ws.send_json({"type": "subscribe", "token": "wrong"}) + # Server should close the connection + import websockets + with pytest.raises(Exception): + ws.receive_json() +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_dashboard.py::test_ws_subscribe_with_valid_token -v` +Expected: FAIL — no `/ws/announce` route. + +**Step 3: Write minimal implementation** + +Add to `src/ntr_fetcher/dashboard.py`, inside `create_dashboard_router`: + +```python +from fastapi import WebSocket, WebSocketDisconnect + +@router.websocket("/ws/announce") +async def ws_announce(websocket: WebSocket): + await websocket.accept() + try: + auth_msg = await websocket.receive_json() + if auth_msg.get("type") != "subscribe" or auth_msg.get("token") != admin_token: + await websocket.close(code=4001, reason="Invalid token") + return + + manager.add_subscriber(websocket) + await manager.broadcast_status() + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + pass + except WebSocketDisconnect: + pass + finally: + manager.remove_subscriber(websocket) + try: + await manager.broadcast_status() + except Exception: + pass +``` + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_dashboard.py -v` +Expected: PASS (the invalid-token test may need adjustment — FastAPI test client may raise `WebSocketDisconnect` or similar; adjust assertion as needed during implementation). + +**Step 5: Commit** + +```bash +git add src/ntr_fetcher/dashboard.py tests/test_dashboard.py +git commit -m "feat: add WebSocket handler for announce subscribers" +``` + +--- + +## Task 7: Wire dashboard into the app + +**Files:** +- Modify: `src/ntr_fetcher/api.py` +- Modify: `src/ntr_fetcher/main.py` +- Test: `tests/test_api.py` (add one test to verify existing behavior unbroken) + +**Step 1: Write the failing test** + +Add to `tests/test_api.py`: + +```python +def test_no_dashboard_routes_without_config(client): + resp = client.get("/dashboard", follow_redirects=False) + assert resp.status_code == 404 + + +def test_no_login_route_without_config(client): + resp = client.get("/login") + assert resp.status_code == 404 +``` + +**Step 2: Run test to verify it fails (or passes if routes aren't mounted yet)** + +Run: `pytest tests/test_api.py::test_no_dashboard_routes_without_config -v` +Expected: PASS (routes don't exist yet — this is a guard test). + +**Step 3: Modify `create_app` signature and mounting** + +In `src/ntr_fetcher/api.py`, update `create_app` to accept optional dashboard params and conditionally mount: + +```python +def create_app( + db: Database, + poller, + admin_token: str, + show_day: int = 2, + show_hour: int = 22, + web_user: str | None = None, + web_password: str | None = None, + secret_key: str | None = None, +) -> FastAPI: + app = FastAPI(title="NtR SoundCloud Fetcher") + + # ... existing routes unchanged ... + + if all([web_user, web_password, secret_key]): + from ntr_fetcher.dashboard import create_dashboard_router + from ntr_fetcher.websocket import AnnounceManager + manager = AnnounceManager() + dashboard_router = create_dashboard_router( + db=db, + manager=manager, + admin_token=admin_token, + web_user=web_user, + web_password=web_password, + secret_key=secret_key, + show_day=show_day, + show_hour=show_hour, + ) + app.include_router(dashboard_router) + + return app +``` + +In `src/ntr_fetcher/main.py`, pass the new params: + +```python +app = create_app( + db=db, + poller=poller, + admin_token=settings.admin_token, + show_day=settings.show_day, + show_hour=settings.show_hour, + web_user=settings.web_user, + web_password=settings.web_password, + secret_key=settings.secret_key, +) +``` + +**Step 4: Run full test suite** + +Run: `pytest tests/ -v` +Expected: ALL PASS — existing tests unbroken, dashboard routes absent without config. + +**Step 5: Commit** + +```bash +git add src/ntr_fetcher/api.py src/ntr_fetcher/main.py tests/test_api.py +git commit -m "feat: conditionally mount dashboard when config present" +``` + +--- + +## Task 8: Styled login page + +**Files:** +- Modify: `src/ntr_fetcher/static/login.html` + +No test needed — this is a visual/HTML-only change. The functional login tests from Task 4 already cover the form behavior. + +**Step 1: Write the styled login page** + +Replace `src/ntr_fetcher/static/login.html` with a Pico CSS dark-themed login form. Key elements: + +- `` +- `` +- Centered card with "NtR Playlist" heading, username/password fields, submit button +- `` placeholder for error message injection +- Minimal custom CSS for the error message color + +```html + + + + + + NtR Login + + + + +
+
+

NtR Playlist

+ +
+ + + +
+
+
+ + +``` + +**Step 2: Run existing tests to verify nothing broke** + +Run: `pytest tests/test_dashboard.py -v` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/ntr_fetcher/static/login.html +git commit -m "feat: styled login page with Pico CSS dark theme" +``` + +--- + +## Task 9: Styled dashboard page + +**Files:** +- Modify: `src/ntr_fetcher/static/dashboard.html` + +This is the main dashboard with playlist tables and announce buttons. The page uses vanilla JS to: +1. Fetch playlists from `/playlist` and `/shows?limit=2` + `/shows/{id}` on load +2. Open a read-only WebSocket for connection status +3. POST to `/admin/announce` when an "Announce" button is clicked + +**Step 1: Write the dashboard HTML** + +Replace `src/ntr_fetcher/static/dashboard.html`. Key elements: + +- Pico CSS dark theme +- Header with title, subscriber status dot, logout link +- "Current Show" section: episode heading, track table, announce buttons +- "Previous Show" section: `
` element (collapsed by default), same table layout +- JS: `fetch('/playlist')` for current show data, `fetch('/shows?limit=2')` then `fetch('/shows/{id}')` for previous show +- JS: `new WebSocket(...)` connecting to `/ws/announce` with admin token from a `` tag or fetched from a session-protected endpoint. Simpler approach: the dashboard HTML is served by the router, which can inject the admin token into a ` + + +``` + +The full JS in the `