diff --git a/docs/plans/2026-03-12-live-announce-dashboard-design.md b/docs/plans/2026-03-12-live-announce-dashboard-design.md new file mode 100644 index 0000000..4e0e7f5 --- /dev/null +++ b/docs/plans/2026-03-12-live-announce-dashboard-design.md @@ -0,0 +1,168 @@ +# Live Announce Dashboard — Design Document + +> **Date**: 2026-03-12 +> **Status**: Approved + +## Purpose + +A web dashboard that lets Nick announce tracks to IRC in real-time during his live show. He sees the current and previous week's playlists, clicks "Announce" next to a track, and the IRC bot immediately emits the announcement to `#sewerchat` (configurable). No one in chat needs to request it — the host pushes announcements on his own schedule. + +## Architecture + +### Communication: WebSocket hub + +The API gains a WebSocket endpoint at `/ws/announce`. IRC bot plugins (Sopel and Limnoria) connect as subscribers. When Nick clicks "Announce" on the dashboard, the web page POSTs to `/admin/announce`, the API formats the message and broadcasts it to all connected WS subscribers, and each bot emits it to the configured channel. + +``` +Nick's browser API server IRC bot plugin + | | | + | POST /admin/announce | | + | {show_id, position} | | + |--------------------------->| | + | | WS: {"type":"announce", | + | | "message":"Now Playing:| + | | Song #3: ..."} | + | |------------------------->| + | | | + | 200 {"status":"announced"}| bot.say(message) + |<---------------------------| -> #sewerchat +``` + +### Why POST instead of WS for sending + +The dashboard authenticates via session cookie. A plain POST with the existing session is simpler than mixing cookie auth with a WebSocket send channel. HTTP round-trip is <50ms, broadcast is instant. + +### Reconnection + +Bot plugins auto-reconnect with exponential backoff (5s, 10s, 20s, capped at 60s). Announcements during disconnection are lost. Nick can see bot connection status on the dashboard and re-announce if needed. + +## Authentication & Sessions + +### Login flow + +1. Nick visits `/dashboard` — no valid session cookie → redirect to `/login`. +2. `/login` serves a styled HTML login form. +3. Form POSTs to `/login` with username + password. +4. Server validates against `NTR_WEB_USER` / `NTR_WEB_PASSWORD` env vars. Success → signed session cookie (`ntr_session`) + redirect to `/dashboard`. Failure → re-render login with error. +5. Cookie signed with `NTR_SECRET_KEY`. HTTPOnly, SameSite=Lax. +6. `/logout` clears the cookie, redirects to `/login`. + +### Session expiry + +24 hours. + +### New config values + +| Variable | Default | Description | +|----------|---------|-------------| +| `NTR_WEB_USER` | *(required if dashboard enabled)* | Dashboard login username | +| `NTR_WEB_PASSWORD` | *(required if dashboard enabled)* | Dashboard login password | +| `NTR_SECRET_KEY` | *(required if dashboard enabled)* | Cookie signing key | + +### Dashboard-optional + +If these three env vars are absent, the dashboard routes are not mounted. The API works exactly as before. + +## Web Dashboard UI + +Single page at `/dashboard`, no client-side routing. + +### Layout + +- **Header**: "NtR Playlist Dashboard" + connection status indicator (green dot = bots connected, gray = none) + logout link. +- **Current Show**: Episode number, date range, track count. Track table with columns: `#`, `Title`, `Artist`, `Link`, `Announce` button. Button flashes checkmark on success, shows error toast on failure. +- **Previous Show**: Same layout, collapsed by default (click to expand). Same announce functionality. + +### Connection status + +The dashboard opens a read-only WebSocket to `/ws/announce` (authenticated via token query param from the session). The API broadcasts `{"type": "status", "subscribers": N}` on connect/disconnect events. Zero subscribers → buttons show "No bots connected" warning. + +### Styling + +Pico CSS via CDN. Dark theme. Minimal custom CSS for button states and status indicator. Responsive for phone use. + +### No JS framework + +Vanilla `fetch()` for announce POST, vanilla `WebSocket` for status. Total JS under 100 lines. + +## Announcement Format + +``` +Now Playing: Song #3: Running Through My Mind by Purrple Panther - https://soundcloud.com/... +``` + +Format logic lives in the API (`/admin/announce` endpoint), not in the bot plugins. + +## Bot Plugin Changes + +Both Sopel and Limnoria plugins gain identical behavior, maintaining 1:1 feature parity. + +### New config values + +| Limnoria (camelCase) | Sopel (snake_case) | Default | Description | +|----------------------|--------------------|---------|-------------| +| `wsUrl` | `ws_url` | `ws://127.0.0.1:8000/ws/announce` | WebSocket endpoint | +| `announceChannel` | `announce_channel` | `#sewerchat` | Channel for announcements | + +### WebSocket client lifecycle + +1. On plugin load, spawn a background daemon thread connecting to the WS endpoint. +2. Send `{"type": "subscribe", "token": ""}` on connect. +3. On `{"type": "announce", "message": "..."}`, send the message to the configured channel via `irc.queueMsg` (Limnoria) / `bot.say` (Sopel). +4. On disconnect, reconnect with exponential backoff (5s → 60s cap). +5. On plugin unload, close the connection cleanly. + +### Threading + +WS client runs in a dedicated daemon thread using the `websocket-client` library (synchronous, blocking). `bot.say()` and `irc.queueMsg()` are thread-safe in both frameworks. + +### New dependency + +`websocket-client` — listed in each plugin's docs, not in the main `pyproject.toml` (plugins deploy independently). + +### No changes to existing commands + +`!N`, `!playlist`, `!song`, `!lastshow`, `!status`, `!refresh` all work as before. + +## API Endpoints (New) + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| `GET` | `/login` | none | Login page | +| `POST` | `/login` | none | Validate credentials, set session | +| `GET` | `/logout` | session | Clear session, redirect | +| `GET` | `/dashboard` | session | Dashboard page | +| `POST` | `/admin/announce` | bearer OR session | Broadcast track announcement | +| `WS` | `/ws/announce` | token in first message | Bot subscriber + dashboard status | + +### `POST /admin/announce` + +Request: `{"show_id": 10, "position": 3}` + +Response: `{"status": "announced", "message": "Now Playing: Song #3: ..."}` + +The API looks up the track, formats the message, and broadcasts to all WS subscribers. + +## New Files + +| File | Purpose | +|------|---------| +| `src/ntr_fetcher/dashboard.py` | FastAPI router: login, logout, dashboard, announce | +| `src/ntr_fetcher/websocket.py` | WebSocket manager: subscriber tracking, broadcast | +| `src/ntr_fetcher/static/dashboard.html` | Dashboard page (HTML + inline JS + CSS) | +| `src/ntr_fetcher/static/login.html` | Login page | + +## Modified Files + +| File | Change | +|------|--------| +| `src/ntr_fetcher/config.py` | Add `web_user`, `web_password`, `secret_key` (optional) | +| `src/ntr_fetcher/api.py` | Mount dashboard router + WS if dashboard config present | +| `src/ntr_fetcher/main.py` | Pass new config to `create_app` | +| `plugins/limnoria/NtrPlaylist/plugin.py` | WS client thread + announce channel | +| `plugins/limnoria/NtrPlaylist/config.py` | `wsUrl`, `announceChannel` registry values | +| `plugins/sopel/ntr_playlist.py` | WS client thread + announce channel | + +## Unchanged Files + +`db.py`, `models.py`, `soundcloud.py`, `poller.py`, `week.py` — no modifications.