Files
NtR-soudcloud-fetcher/docs/plans/2026-03-12-live-announce-dashboard-impl.md
cottongin e5c06a2f67 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 07:08:02 -04:00

1262 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 56.
**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("<!--ERROR-->", '<p class="error">Invalid username or password</p>')
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("<!--ERROR-->", '<p class="error">Invalid username or password</p>')
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
<!DOCTYPE html>
<html><head><title>Login</title></head>
<body>
<!--ERROR-->
<form method="post" action="/login">
<label>Username <input name="username"></label>
<label>Password <input name="password" type="password"></label>
<button type="submit">Login</button>
</form>
</body></html>
```
Create `src/ntr_fetcher/static/dashboard.html`:
```html
<!DOCTYPE html>
<html><head><title>Dashboard</title></head>
<body><h1>NtR Playlist Dashboard</h1></body></html>
```
**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:
- `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">`
- `<html data-theme="dark">`
- Centered card with "NtR Playlist" heading, username/password fields, submit button
- `<!--ERROR-->` placeholder for error message injection
- Minimal custom CSS for the error message color
```html
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NtR Login</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
main { max-width: 400px; width: 100%; }
.error { color: var(--pico-color-red-500, #e53935); margin-bottom: 1rem; }
</style>
</head>
<body>
<main>
<article>
<header><h2>NtR Playlist</h2></header>
<!--ERROR-->
<form method="post" action="/login">
<label>Username
<input name="username" autocomplete="username" required>
</label>
<label>Password
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button type="submit">Log in</button>
</form>
</article>
</main>
</body>
</html>
```
**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: `<details>` 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 `<meta>` 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 `<script>` block as a JS variable. **Alternative (safer):** add a small `/dashboard/ws-token` endpoint that returns the admin token if the user has a valid session. This avoids putting the token in the HTML source.
Actually, re-reading the design: the WS endpoint authenticates via the admin token. The dashboard needs to know this token to connect. Two options:
- The server injects it into the HTML as a template variable
- A small API endpoint returns it to authenticated sessions
Use the template-injection approach since we're already reading the HTML file and can do a simple string replacement. The token is only visible to authenticated users (the `/dashboard` route requires a session).
```html
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NtR Playlist Dashboard</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<style>
.status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; }
.status-dot.connected { background: #4caf50; }
.status-dot.disconnected { background: #888; }
header nav { display: flex; align-items: center; justify-content: space-between; }
.announce-btn { padding: 4px 12px; font-size: 0.85rem; }
.announce-btn.success { background: #4caf50; }
.announce-btn.warning { background: #ff9800; }
table { width: 100%; }
.toast { position: fixed; bottom: 20px; right: 20px; padding: 12px 20px; border-radius: 8px;
background: #333; color: #fff; display: none; z-index: 100; }
.toast.error { background: #e53935; }
</style>
</head>
<body>
<main class="container">
<nav>
<div>
<span class="status-dot disconnected" id="status-dot"></span>
<strong>NtR Playlist Dashboard</strong>
<small id="sub-count">(connecting...)</small>
</div>
<a href="/logout">Logout</a>
</nav>
<section id="current-show"><h3>Loading current show...</h3></section>
<details id="prev-show-details">
<summary><h3 style="display:inline">Previous Show</h3></summary>
<section id="prev-show"><p>Loading...</p></section>
</details>
<div class="toast" id="toast"></div>
</main>
<script>
const WS_TOKEN = "{{WS_TOKEN}}";
// JS implementation: fetch playlists, render tables, handle announce clicks, WS status
// (full JS provided below)
</script>
</body>
</html>
```
The full JS in the `<script>` block handles:
- `loadCurrentShow()` — fetches `/playlist`, renders track table with announce buttons
- `loadPreviousShow()` — fetches `/shows?limit=2`, if 2 results fetches `/shows/{prev_id}`, renders table
- `announce(showId, position, btn)` — POSTs to `/admin/announce`, handles button state
- `connectWS()` — connects to `ws://{host}/ws/announce`, sends subscribe, listens for status updates, auto-reconnects
- `showToast(msg, isError)` — brief toast notification
Update `dashboard.py` to inject the token:
```python
@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()
html = html.replace("{{WS_TOKEN}}", admin_token)
return HTMLResponse(html)
```
**Step 2: Run tests**
Run: `pytest tests/ -v`
Expected: ALL PASS
**Step 3: Commit**
```bash
git add src/ntr_fetcher/static/dashboard.html src/ntr_fetcher/dashboard.py
git commit -m "feat: styled dashboard with playlist tables and announce buttons"
```
---
## Task 10: Limnoria plugin — WS client + announce config
**Files:**
- Modify: `plugins/limnoria/NtrPlaylist/plugin.py`
- Modify: `plugins/limnoria/NtrPlaylist/config.py`
No automated test (Limnoria's test framework is separate and requires a running bot instance). Manual testing instructions provided.
**Step 1: Add config values**
In `plugins/limnoria/NtrPlaylist/config.py`, add two new registry values:
```python
conf.registerGlobalValue(
NtrPlaylist,
"wsUrl",
registry.String(
"ws://127.0.0.1:8000/ws/announce",
"""WebSocket URL for receiving announce commands from the dashboard.""",
),
)
conf.registerGlobalValue(
NtrPlaylist,
"announceChannel",
registry.String(
"#sewerchat",
"""IRC channel to send announce messages to.""",
),
)
```
**Step 2: Add WS client thread to plugin**
In `plugins/limnoria/NtrPlaylist/plugin.py`:
1. Add `import json, threading, time` to existing imports.
2. Add a `_ws_listener` method to the `NtrPlaylist` class that runs in a daemon thread.
3. Start the thread in `__init__` and stop it in `die()`.
```python
def __init__(self, irc):
super().__init__(irc)
self._irc = irc
self._ws_stop = threading.Event()
self._ws_thread = threading.Thread(target=self._ws_listener, daemon=True)
self._ws_thread.start()
def die(self):
self._ws_stop.set()
super().die()
def _ws_listener(self):
import websocket # websocket-client library
from supybot import ircmsgs
backoff = 5
max_backoff = 60
while not self._ws_stop.is_set():
ws_url = self.registryValue("wsUrl")
token = self.registryValue("adminToken")
channel = self.registryValue("announceChannel")
if not ws_url or not token:
LOGGER.warning("wsUrl or adminToken not configured, WS listener sleeping")
self._ws_stop.wait(30)
continue
try:
ws = websocket.WebSocket()
ws.connect(ws_url, timeout=10)
ws.send(json.dumps({"type": "subscribe", "token": token}))
LOGGER.info("Connected to announce WebSocket at %s", ws_url)
backoff = 5
while not self._ws_stop.is_set():
ws.settimeout(5)
try:
raw = ws.recv()
if not raw:
break
data = json.loads(raw)
if data.get("type") == "announce" and "message" in data:
msg = ircmsgs.privmsg(channel, data["message"])
self._irc.queueMsg(msg)
LOGGER.info("Announced to %s: %s", channel, data["message"])
except websocket.WebSocketTimeoutException:
continue
except websocket.WebSocketConnectionClosedException:
break
except Exception:
LOGGER.exception("WS listener error")
finally:
try:
ws.close()
except Exception:
pass
if not self._ws_stop.is_set():
LOGGER.info("Reconnecting in %ds", backoff)
self._ws_stop.wait(backoff)
backoff = min(backoff * 2, max_backoff)
```
**Step 3: Commit**
```bash
git add plugins/limnoria/NtrPlaylist/plugin.py plugins/limnoria/NtrPlaylist/config.py
git commit -m "feat: add WS announce listener to Limnoria plugin"
```
---
## Task 11: Sopel plugin — WS client + announce config
**Files:**
- Modify: `plugins/sopel/ntr_playlist.py`
Feature-parity with the Limnoria plugin.
**Step 1: Add config values**
Add to `NtrPlaylistSection`:
```python
ws_url = types.ValidatedAttribute("ws_url", default="ws://127.0.0.1:8000/ws/announce")
announce_channel = types.ValidatedAttribute("announce_channel", default="#sewerchat")
```
**Step 2: Add WS client thread**
Add a `_ws_listener` function and start/stop lifecycle via `setup` and `shutdown` hooks:
```python
_ws_stop = None
_ws_thread = None
def setup(bot):
global _ws_stop, _ws_thread
bot.settings.define_section("ntr_playlist", NtrPlaylistSection)
_ws_stop = threading.Event()
_ws_thread = threading.Thread(target=_ws_listener, args=(bot,), daemon=True)
_ws_thread.start()
def shutdown(bot):
global _ws_stop
if _ws_stop:
_ws_stop.set()
def _ws_listener(bot):
import websocket
backoff = 5
max_backoff = 60
while not _ws_stop.is_set():
ws_url = bot.settings.ntr_playlist.ws_url
token = bot.settings.ntr_playlist.admin_token
channel = bot.settings.ntr_playlist.announce_channel
if not ws_url or not token:
LOGGER.warning("ws_url or admin_token not configured, WS listener sleeping")
_ws_stop.wait(30)
continue
try:
ws = websocket.WebSocket()
ws.connect(ws_url, timeout=10)
ws.send(json.dumps({"type": "subscribe", "token": token}))
LOGGER.info("Connected to announce WebSocket at %s", ws_url)
backoff = 5
while not _ws_stop.is_set():
ws.settimeout(5)
try:
raw = ws.recv()
if not raw:
break
data = json.loads(raw)
if data.get("type") == "announce" and "message" in data:
bot.say(data["message"], channel)
LOGGER.info("Announced to %s: %s", channel, data["message"])
except websocket.WebSocketTimeoutException:
continue
except websocket.WebSocketConnectionClosedException:
break
except Exception:
LOGGER.exception("WS listener error")
finally:
try:
ws.close()
except Exception:
pass
if not _ws_stop.is_set():
LOGGER.info("Reconnecting in %ds", backoff)
_ws_stop.wait(backoff)
backoff = min(backoff * 2, max_backoff)
```
Add `import threading` to the module imports.
**Step 3: Commit**
```bash
git add plugins/sopel/ntr_playlist.py
git commit -m "feat: add WS announce listener to Sopel plugin"
```
---
## Task 12: Update docs
**Files:**
- Modify: `docs/api.md`
- Modify: `README.md`
**Step 1: Update `docs/api.md`**
Add new sections for:
- `GET /login`, `POST /login`, `GET /logout`, `GET /dashboard`
- `POST /admin/announce` — request/response shapes
- `WS /ws/announce` — protocol description (subscribe message, announce message, status message)
- Dashboard configuration table
**Step 2: Update `README.md`**
Add a "Dashboard" section with:
- The three env vars needed
- Brief description of the feature
- New endpoint table rows
**Step 3: Commit**
```bash
git add docs/api.md README.md
git commit -m "docs: add dashboard and announce API documentation"
```
---
## Task 13: Run full test suite and lint
**Step 1: Run all tests**
Run: `pytest tests/ -v`
Expected: ALL PASS
**Step 2: Run linter**
Run: `ruff check src/ tests/`
Expected: no errors (fix any that appear)
**Step 3: Final commit (if any lint fixes)**
```bash
git add -A
git commit -m "chore: lint fixes"
```