1262 lines
37 KiB
Markdown
1262 lines
37 KiB
Markdown
|
|
# 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("<!--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"
|
|||
|
|
```
|