Compare commits
17 Commits
ae66242935
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b353f606e5
|
||
|
|
8d9d565c04
|
||
|
|
0f99e7914b
|
||
|
|
911dd3d5dd
|
||
|
|
f244749293
|
||
|
|
f6840a777c
|
||
|
|
d6d5ac10e6
|
||
|
|
658c0d4a15 | ||
|
|
e31a9503db
|
||
|
|
a7849e6cd9
|
||
|
|
92136f0508
|
||
|
|
e90f44439b
|
||
|
|
788225b3b6
|
||
|
|
7ed7ace578
|
||
|
|
e5c06a2f67
|
||
|
|
47a78b09a7
|
||
|
|
9664b8225d
|
74
README.md
74
README.md
@@ -1,3 +1,6 @@
|
||||
> [!IMPORTANT]
|
||||
> This project was developed entirely with AI coding assistance (Claude Opus 4.6 via Cursor IDE) and has not undergone rigorous review. It is provided as-is and may require adjustments for other environments.
|
||||
|
||||
# NtR SoundCloud Fetcher
|
||||
|
||||
Fetches SoundCloud likes from NicktheRat's profile, builds weekly playlists
|
||||
@@ -39,10 +42,14 @@ Full documentation: [`docs/api.md`](docs/api.md)
|
||||
| `/shows` | GET | -- | List all shows (paginated) |
|
||||
| `/shows/by-episode/{episode_number}` | GET | -- | Look up show by episode number |
|
||||
| `/shows/{show_id}` | GET | -- | Specific show by internal ID |
|
||||
| `/login` | GET/POST | -- | Login page |
|
||||
| `/logout` | GET | Session | Clear session |
|
||||
| `/dashboard` | GET | Session | Live playlist dashboard |
|
||||
| `/admin/refresh` | POST | Bearer | Trigger immediate SoundCloud fetch |
|
||||
| `/admin/tracks` | POST | Bearer | Add track to current show |
|
||||
| `/admin/tracks/{track_id}` | DELETE | Bearer | Remove track from current show |
|
||||
| `/admin/tracks/{track_id}/position` | PUT | Bearer | Move track to new position |
|
||||
| `/admin/announce` | POST | Bearer/Session | Announce track to IRC |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -59,6 +66,73 @@ Environment variables (prefix `NTR_`):
|
||||
| `NTR_SHOW_DAY` | `2` | Day of week for show (0=Mon, 2=Wed) |
|
||||
| `NTR_SHOW_HOUR` | `22` | Hour (Eastern Time) when the show starts |
|
||||
|
||||
## Dashboard
|
||||
|
||||
An optional web dashboard for announcing tracks to IRC during live shows.
|
||||
Nick opens the dashboard, sees the current and previous week's playlist, and
|
||||
clicks "Announce" next to a track to push it to the configured IRC channel
|
||||
via connected bot plugins.
|
||||
|
||||
Enable by setting these environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `NTR_WEB_USER` | *(required)* | Dashboard login username |
|
||||
| `NTR_WEB_PASSWORD` | *(required)* | Dashboard login password |
|
||||
| `NTR_SECRET_KEY` | *(required)* | Secret key for cookie signing |
|
||||
|
||||
## IRC Commands
|
||||
|
||||
Both Sopel and Limnoria plugins expose the same commands:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!1`, `!2`, ... `!N` | Track by position in the current week's playlist |
|
||||
| `!song <episode> <position>` | Track from a specific episode's playlist |
|
||||
| `!playlist [episode]` | Current week's playlist, or a specific episode |
|
||||
| `!lastshow <position>` | Track from the previous week's show |
|
||||
| `!status` | API health, poller status, and track count |
|
||||
| `!refresh` | Trigger a manual SoundCloud fetch (admin only) |
|
||||
|
||||
## IRC Plugin Setup
|
||||
|
||||
Both plugins require the `websocket-client` package for live announcements:
|
||||
|
||||
```bash
|
||||
pip install websocket-client
|
||||
```
|
||||
|
||||
### Sopel
|
||||
|
||||
Copy `plugins/sopel/ntr_playlist.py` into your Sopel plugins directory
|
||||
(typically `~/.sopel/plugins/`). Add a `[ntr_playlist]` section to your
|
||||
Sopel config:
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `api_base_url` | `http://127.0.0.1:8000` | NtR API base URL |
|
||||
| `admin_token` | *(empty)* | Bearer token for admin commands |
|
||||
| `admin_nicks` | *(empty)* | Comma-separated list of admin nicknames |
|
||||
| `display_timezone` | `America/New_York` | Timezone for displayed timestamps |
|
||||
| `ws_url` | `ws://127.0.0.1:8000/ws/announce` | WebSocket endpoint for announcements |
|
||||
| `announce_channel` | `#sewerchat` | Channel to send announcements to |
|
||||
| `client_id` | `sopel` | Identifier for this bot in status messages |
|
||||
|
||||
### Limnoria
|
||||
|
||||
Copy the `plugins/limnoria/NtrPlaylist/` directory into your Limnoria bot's
|
||||
plugins folder. Configure via the Limnoria registry:
|
||||
|
||||
| Registry Value | Default | Description |
|
||||
|----------------|---------|-------------|
|
||||
| `apiBaseUrl` | `http://127.0.0.1:8000` | NtR API base URL |
|
||||
| `adminToken` | *(empty)* | Bearer token for admin commands |
|
||||
| `adminNicks` | *(empty)* | Space-separated list of admin nicknames |
|
||||
| `displayTimezone` | `America/New_York` | Timezone for displayed timestamps |
|
||||
| `wsUrl` | `ws://127.0.0.1:8000/ws/announce` | WebSocket endpoint for announcements |
|
||||
| `announceChannel` | `#sewerchat` | Channel to send announcements to |
|
||||
| `clientId` | `limnoria` | Identifier for this bot in status messages |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
||||
133
docs/api.md
133
docs/api.md
@@ -177,6 +177,35 @@ Returns a specific show by internal database ID.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Endpoints
|
||||
|
||||
These routes are only available when the dashboard is enabled (all three `NTR_WEB_*` env vars set).
|
||||
|
||||
### `GET /login`
|
||||
|
||||
Serves the login page.
|
||||
|
||||
### `POST /login`
|
||||
|
||||
Authenticates with username and password. Sets a session cookie on success, redirects to `/dashboard`.
|
||||
|
||||
**Form Data**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `username` | string | Dashboard username |
|
||||
| `password` | string | Dashboard password |
|
||||
|
||||
### `GET /logout`
|
||||
|
||||
Clears the session cookie and redirects to `/login`.
|
||||
|
||||
### `GET /dashboard`
|
||||
|
||||
Serves the playlist dashboard page. Requires a valid session cookie (redirects to `/login` if absent).
|
||||
|
||||
---
|
||||
|
||||
## Admin Endpoints
|
||||
|
||||
All admin endpoints require a bearer token via the `Authorization` header:
|
||||
@@ -308,6 +337,37 @@ Move a track to a new position within the current week's show.
|
||||
|
||||
---
|
||||
|
||||
### `POST /admin/announce`
|
||||
|
||||
Broadcasts a track announcement to all connected WebSocket subscribers (IRC bots). The announcement is formatted as: `Now Playing: Song #N: Title by Artist - URL`.
|
||||
|
||||
Accepts either a bearer token OR a valid session cookie.
|
||||
|
||||
**Request Body**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `show_id` | integer | yes | Show database ID |
|
||||
| `position` | integer | yes | Track position in the show |
|
||||
|
||||
**Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "announced",
|
||||
"message": "Now Playing: Song #1: Night Drive by SomeArtist - https://soundcloud.com/..."
|
||||
}
|
||||
```
|
||||
|
||||
**Errors**
|
||||
|
||||
| Status | Detail |
|
||||
|--------|--------|
|
||||
| 401 | `"Unauthorized"` |
|
||||
| 404 | `"No track at position {n}"` |
|
||||
|
||||
---
|
||||
|
||||
## Track Object
|
||||
|
||||
Returned inside playlist and show detail responses.
|
||||
@@ -338,3 +398,76 @@ All timestamps in API responses are UTC. The boundary shifts by 1 hour across DS
|
||||
|--------|---------|--------------|
|
||||
| EST (Nov -- Mar) | Wed 22:00 | Thu 03:00 |
|
||||
| EDT (Mar -- Nov) | Wed 22:00 | Thu 02:00 |
|
||||
|
||||
---
|
||||
|
||||
## WebSocket
|
||||
|
||||
### `WS /ws/announce`
|
||||
|
||||
WebSocket endpoint for receiving announce broadcasts. Used by IRC bot plugins.
|
||||
|
||||
**Authentication**
|
||||
|
||||
After connecting, send a subscribe message:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "subscribe",
|
||||
"token": "<NTR_ADMIN_TOKEN>",
|
||||
"role": "bot",
|
||||
"client_id": "limnoria"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `type` | string | yes | Must be `"subscribe"` |
|
||||
| `token` | string | yes | `NTR_ADMIN_TOKEN` value |
|
||||
| `role` | string | no | Client role, defaults to `"bot"` |
|
||||
| `client_id` | string | no | Identifier for this client (e.g. `"sopel"`, `"limnoria"`) |
|
||||
|
||||
Invalid token closes the connection with code `4001`.
|
||||
|
||||
**Messages from server**
|
||||
|
||||
Announce broadcast:
|
||||
|
||||
```json
|
||||
{"type": "announce", "message": "Now Playing: Song #1: Title by Artist - URL"}
|
||||
```
|
||||
|
||||
Status update (sent on subscriber connect/disconnect):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "status",
|
||||
"subscribers": 2,
|
||||
"clients": [
|
||||
{
|
||||
"client_id": "limnoria",
|
||||
"remote_addr": "1.2.3.4",
|
||||
"connected_at": "2026-03-12T02:00:00+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `subscribers` | integer | Number of connected bot clients |
|
||||
| `clients` | array | List of connected bot clients with their `client_id`, `remote_addr`, and `connected_at` timestamp |
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Configuration
|
||||
|
||||
The web dashboard is optional. Enable it by setting all three environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `NTR_WEB_USER` | *(required)* | Dashboard login username |
|
||||
| `NTR_WEB_PASSWORD` | *(required)* | Dashboard login password |
|
||||
| `NTR_SECRET_KEY` | *(required)* | Secret key for session cookie signing |
|
||||
|
||||
If any of these are absent, dashboard routes are not mounted and the API works exactly as before.
|
||||
|
||||
168
docs/plans/2026-03-12-live-announce-dashboard-design.md
Normal file
168
docs/plans/2026-03-12-live-announce-dashboard-design.md
Normal file
@@ -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": "<admin_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.
|
||||
1261
docs/plans/2026-03-12-live-announce-dashboard-impl.md
Normal file
1261
docs/plans/2026-03-12-live-announce-dashboard-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,3 +34,39 @@ conf.registerGlobalValue(
|
||||
"""IRC nicknames allowed to run admin commands (space-separated).""",
|
||||
),
|
||||
)
|
||||
|
||||
conf.registerGlobalValue(
|
||||
NtrPlaylist,
|
||||
"displayTimezone",
|
||||
registry.String(
|
||||
"America/New_York",
|
||||
"""IANA timezone for displaying dates in IRC (e.g. America/New_York, America/Chicago).""",
|
||||
),
|
||||
)
|
||||
|
||||
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.""",
|
||||
),
|
||||
)
|
||||
|
||||
conf.registerGlobalValue(
|
||||
NtrPlaylist,
|
||||
"clientId",
|
||||
registry.String(
|
||||
"limnoria",
|
||||
"""Identifier for this bot when connecting to the announce WebSocket.""",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,9 +5,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from supybot import callbacks
|
||||
from supybot.commands import optional, wrap
|
||||
@@ -73,6 +76,14 @@ def _api_post(base_url: str, path: str, token: str, body: dict | None = None) ->
|
||||
# --- Formatting --------------------------------------------------------------
|
||||
|
||||
|
||||
def format_dt(iso_string: str | None, tz_name: str = "America/New_York") -> str:
|
||||
if not iso_string:
|
||||
return "never"
|
||||
dt = datetime.fromisoformat(iso_string)
|
||||
local = dt.astimezone(ZoneInfo(tz_name))
|
||||
return local.strftime("%a %b %-d, %-I:%M %p %Z")
|
||||
|
||||
|
||||
_MAX_IRC_LINE = 430
|
||||
|
||||
|
||||
@@ -111,6 +122,99 @@ _NUMBER_RE = re.compile(r"^!(\d+)$")
|
||||
class NtrPlaylist(callbacks.Plugin):
|
||||
"""Query the NtR SoundCloud Fetcher API from IRC."""
|
||||
|
||||
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
|
||||
from supybot import ircmsgs
|
||||
|
||||
backoff = 5
|
||||
max_backoff = 60
|
||||
LOGGER.info("WS listener thread started")
|
||||
|
||||
while not self._ws_stop.is_set():
|
||||
ws_url = self.registryValue("wsUrl")
|
||||
if ws_url.startswith("https://"):
|
||||
ws_url = "wss://" + ws_url[8:]
|
||||
elif ws_url.startswith("http://"):
|
||||
ws_url = "ws://" + ws_url[7:]
|
||||
token = self.registryValue("adminToken")
|
||||
channel = self.registryValue("announceChannel")
|
||||
client_id = self.registryValue("clientId") or "limnoria"
|
||||
|
||||
if not ws_url or not token:
|
||||
LOGGER.warning("wsUrl or adminToken not configured, WS listener sleeping")
|
||||
self._ws_stop.wait(30)
|
||||
continue
|
||||
|
||||
LOGGER.info("Connecting to %s as client_id=%s", ws_url, client_id)
|
||||
ws = None
|
||||
try:
|
||||
ws = websocket.WebSocket()
|
||||
ws.connect(ws_url, timeout=10)
|
||||
LOGGER.info("WebSocket TCP connection established")
|
||||
subscribe_msg = {
|
||||
"type": "subscribe",
|
||||
"token": token,
|
||||
"role": "bot",
|
||||
"client_id": client_id,
|
||||
}
|
||||
ws.send(json.dumps(subscribe_msg))
|
||||
LOGGER.info("Sent subscribe message (role=bot, client_id=%s)", client_id)
|
||||
backoff = 5
|
||||
|
||||
while not self._ws_stop.is_set():
|
||||
ws.settimeout(5)
|
||||
try:
|
||||
raw = ws.recv()
|
||||
if not raw:
|
||||
LOGGER.warning("Received empty message, connection closing")
|
||||
break
|
||||
data = json.loads(raw)
|
||||
LOGGER.debug("Received WS message: type=%s", data.get("type"))
|
||||
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"])
|
||||
elif data.get("type") == "status":
|
||||
LOGGER.info(
|
||||
"Status update: %d bot(s) connected, clients=%s",
|
||||
data.get("subscribers", 0),
|
||||
[c.get("client_id") for c in data.get("clients", [])],
|
||||
)
|
||||
except websocket.WebSocketTimeoutException:
|
||||
continue
|
||||
except websocket.WebSocketConnectionClosedException:
|
||||
LOGGER.warning("WebSocket connection closed by server")
|
||||
break
|
||||
except ConnectionRefusedError:
|
||||
LOGGER.warning("Connection refused at %s", ws_url)
|
||||
except TimeoutError:
|
||||
LOGGER.warning("Connection timed out to %s", ws_url)
|
||||
except Exception:
|
||||
LOGGER.exception("WS listener error")
|
||||
finally:
|
||||
if ws:
|
||||
try:
|
||||
ws.close()
|
||||
LOGGER.debug("WebSocket closed cleanly")
|
||||
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)
|
||||
|
||||
def _is_admin(self, nick: str) -> bool:
|
||||
admin_nicks = self.registryValue("adminNicks")
|
||||
if not admin_nicks:
|
||||
@@ -120,7 +224,6 @@ class NtrPlaylist(callbacks.Plugin):
|
||||
def doPrivmsg(self, irc, msg):
|
||||
channel = msg.args[0] if msg.args else None
|
||||
if not channel or not irc.isChannel(channel):
|
||||
super().doPrivmsg(irc, msg)
|
||||
return
|
||||
text = msg.args[1] if len(msg.args) > 1 else ""
|
||||
match = _NUMBER_RE.match(text)
|
||||
@@ -133,7 +236,6 @@ class NtrPlaylist(callbacks.Plugin):
|
||||
except ApiError as exc:
|
||||
LOGGER.warning("API error for !%s: %s", position, exc)
|
||||
irc.reply(exc.detail)
|
||||
super().doPrivmsg(irc, msg)
|
||||
|
||||
@wrap([optional("text")])
|
||||
def song(self, irc, msg, args, text):
|
||||
@@ -195,6 +297,45 @@ class NtrPlaylist(callbacks.Plugin):
|
||||
return
|
||||
irc.reply(format_playlist(data))
|
||||
|
||||
@wrap([optional("text")])
|
||||
def lastshow(self, irc, msg, args, text):
|
||||
"""<position>
|
||||
|
||||
Returns a track from last week's show by position number.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
irc.reply("Usage: !lastshow <position>")
|
||||
return
|
||||
try:
|
||||
position = int(text.strip())
|
||||
except ValueError:
|
||||
irc.reply("Usage: !lastshow <position>")
|
||||
return
|
||||
base_url = self.registryValue("apiBaseUrl")
|
||||
try:
|
||||
shows = _api_get(base_url, "/shows?limit=2")
|
||||
except ApiError as exc:
|
||||
LOGGER.warning("API error for lastshow: %s", exc)
|
||||
irc.reply(exc.detail)
|
||||
return
|
||||
if len(shows) < 2:
|
||||
irc.reply("No previous show found")
|
||||
return
|
||||
prev_show_id = shows[1]["id"]
|
||||
try:
|
||||
data = _api_get(base_url, f"/shows/{prev_show_id}")
|
||||
except ApiError as exc:
|
||||
LOGGER.warning("API error for lastshow show %s: %s", prev_show_id, exc)
|
||||
irc.reply(exc.detail)
|
||||
return
|
||||
tracks = data.get("tracks", [])
|
||||
track = next((t for t in tracks if t.get("position") == position), None)
|
||||
if not track:
|
||||
episode = data.get("episode_number", "?")
|
||||
irc.reply(f"No track at position {position} in episode {episode}")
|
||||
return
|
||||
irc.reply(format_track(track))
|
||||
|
||||
@wrap
|
||||
def status(self, irc, msg, args):
|
||||
"""takes no arguments
|
||||
@@ -208,9 +349,10 @@ class NtrPlaylist(callbacks.Plugin):
|
||||
LOGGER.warning("API error for status: %s", exc)
|
||||
irc.reply(exc.detail)
|
||||
return
|
||||
tz = self.registryValue("displayTimezone")
|
||||
status = data.get("status", "unknown")
|
||||
poller = "alive" if data.get("poller_alive") else "dead"
|
||||
last_fetch = data.get("last_fetch") or "never"
|
||||
last_fetch = format_dt(data.get("last_fetch"), tz)
|
||||
count = data.get("current_week_track_count", 0)
|
||||
irc.reply(
|
||||
f"Status: {status.upper()} | Poller: {poller} | Last fetch: {last_fetch} | Tracks this week: {count}"
|
||||
|
||||
@@ -5,8 +5,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sopel import plugin
|
||||
from sopel.config import types
|
||||
@@ -18,10 +21,109 @@ class NtrPlaylistSection(types.StaticSection):
|
||||
api_base_url = types.ValidatedAttribute("api_base_url", default="http://127.0.0.1:8000")
|
||||
admin_token = types.ValidatedAttribute("admin_token", default="")
|
||||
admin_nicks = types.ListAttribute("admin_nicks")
|
||||
display_timezone = types.ValidatedAttribute("display_timezone", default="America/New_York")
|
||||
ws_url = types.ValidatedAttribute("ws_url", default="ws://127.0.0.1:8000/ws/announce")
|
||||
announce_channel = types.ValidatedAttribute("announce_channel", default="#sewerchat")
|
||||
client_id = types.ValidatedAttribute("client_id", default="sopel")
|
||||
|
||||
|
||||
_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
|
||||
LOGGER.info("WS listener thread started")
|
||||
|
||||
while not _ws_stop.is_set():
|
||||
ws_url = bot.settings.ntr_playlist.ws_url
|
||||
if ws_url.startswith("https://"):
|
||||
ws_url = "wss://" + ws_url[8:]
|
||||
elif ws_url.startswith("http://"):
|
||||
ws_url = "ws://" + ws_url[7:]
|
||||
token = bot.settings.ntr_playlist.admin_token
|
||||
channel = bot.settings.ntr_playlist.announce_channel
|
||||
client_id = bot.settings.ntr_playlist.client_id or "sopel"
|
||||
|
||||
if not ws_url or not token:
|
||||
LOGGER.warning("ws_url or admin_token not configured, WS listener sleeping")
|
||||
_ws_stop.wait(30)
|
||||
continue
|
||||
|
||||
LOGGER.info("Connecting to %s as client_id=%s", ws_url, client_id)
|
||||
ws = None
|
||||
try:
|
||||
ws = websocket.WebSocket()
|
||||
ws.connect(ws_url, timeout=10)
|
||||
LOGGER.info("WebSocket TCP connection established")
|
||||
subscribe_msg = {
|
||||
"type": "subscribe",
|
||||
"token": token,
|
||||
"role": "bot",
|
||||
"client_id": client_id,
|
||||
}
|
||||
ws.send(json.dumps(subscribe_msg))
|
||||
LOGGER.info("Sent subscribe message (role=bot, client_id=%s)", client_id)
|
||||
backoff = 5
|
||||
|
||||
while not _ws_stop.is_set():
|
||||
ws.settimeout(5)
|
||||
try:
|
||||
raw = ws.recv()
|
||||
if not raw:
|
||||
LOGGER.warning("Received empty message, connection closing")
|
||||
break
|
||||
data = json.loads(raw)
|
||||
LOGGER.debug("Received WS message: type=%s", data.get("type"))
|
||||
if data.get("type") == "announce" and "message" in data:
|
||||
bot.say(data["message"], channel)
|
||||
LOGGER.info("Announced to %s: %s", channel, data["message"])
|
||||
elif data.get("type") == "status":
|
||||
LOGGER.info(
|
||||
"Status update: %d bot(s) connected, clients=%s",
|
||||
data.get("subscribers", 0),
|
||||
[c.get("client_id") for c in data.get("clients", [])],
|
||||
)
|
||||
except websocket.WebSocketTimeoutException:
|
||||
continue
|
||||
except websocket.WebSocketConnectionClosedException:
|
||||
LOGGER.warning("WebSocket connection closed by server")
|
||||
break
|
||||
except ConnectionRefusedError:
|
||||
LOGGER.warning("Connection refused at %s", ws_url)
|
||||
except TimeoutError:
|
||||
LOGGER.warning("Connection timed out to %s", ws_url)
|
||||
except Exception:
|
||||
LOGGER.exception("WS listener error")
|
||||
finally:
|
||||
if ws:
|
||||
try:
|
||||
ws.close()
|
||||
LOGGER.debug("WebSocket closed cleanly")
|
||||
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)
|
||||
|
||||
|
||||
def configure(config):
|
||||
@@ -83,6 +185,14 @@ def _api_post(base_url: str, path: str, token: str, body: dict | None = None) ->
|
||||
raise ApiError(0, "Cannot reach API") from e
|
||||
|
||||
|
||||
def format_dt(iso_string: str | None, tz_name: str = "America/New_York") -> str:
|
||||
if not iso_string:
|
||||
return "never"
|
||||
dt = datetime.fromisoformat(iso_string)
|
||||
local = dt.astimezone(ZoneInfo(tz_name))
|
||||
return local.strftime("%a %b %-d, %-I:%M %p %Z")
|
||||
|
||||
|
||||
def format_track(track: dict) -> str:
|
||||
pos = track.get("position", 0)
|
||||
title = track.get("title", "")
|
||||
@@ -188,6 +298,43 @@ def ntr_playlist(bot, trigger):
|
||||
bot.say(format_playlist(data))
|
||||
|
||||
|
||||
@plugin.command("lastshow")
|
||||
def ntr_lastshow(bot, trigger):
|
||||
raw = trigger.group(2)
|
||||
if not raw or not raw.strip():
|
||||
bot.say("Usage: !lastshow <position>")
|
||||
return
|
||||
try:
|
||||
position = int(raw.strip())
|
||||
except ValueError:
|
||||
bot.say("Usage: !lastshow <position>")
|
||||
return
|
||||
base_url = bot.settings.ntr_playlist.api_base_url
|
||||
try:
|
||||
shows = _api_get(base_url, "/shows?limit=2")
|
||||
except ApiError as e:
|
||||
LOGGER.warning("API error for !lastshow: %s", e)
|
||||
bot.say(e.detail)
|
||||
return
|
||||
if len(shows) < 2:
|
||||
bot.say("No previous show found")
|
||||
return
|
||||
prev_show_id = shows[1]["id"]
|
||||
try:
|
||||
data = _api_get(base_url, f"/shows/{prev_show_id}")
|
||||
except ApiError as e:
|
||||
LOGGER.warning("API error for !lastshow show %s: %s", prev_show_id, e)
|
||||
bot.say(e.detail)
|
||||
return
|
||||
tracks = data.get("tracks", [])
|
||||
track = next((t for t in tracks if t.get("position") == position), None)
|
||||
if not track:
|
||||
episode = data.get("episode_number", "?")
|
||||
bot.say(f"No track at position {position} in episode {episode}")
|
||||
return
|
||||
bot.say(format_track(track))
|
||||
|
||||
|
||||
@plugin.command("status")
|
||||
def ntr_status(bot, trigger):
|
||||
base_url = bot.settings.ntr_playlist.api_base_url
|
||||
@@ -197,9 +344,10 @@ def ntr_status(bot, trigger):
|
||||
LOGGER.warning("API error for !status: %s", e)
|
||||
bot.say(e.detail)
|
||||
return
|
||||
tz = bot.settings.ntr_playlist.display_timezone
|
||||
status = data.get("status", "unknown")
|
||||
poller = "alive" if data.get("poller_alive") else "dead"
|
||||
last_fetch = data.get("last_fetch") or "never"
|
||||
last_fetch = format_dt(data.get("last_fetch"), tz)
|
||||
count = data.get("current_week_track_count", 0)
|
||||
bot.say(f"Status: {status.upper()} | Poller: {poller} | Last fetch: {last_fetch} | Tracks this week: {count}")
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ def create_app(
|
||||
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")
|
||||
|
||||
@@ -147,4 +150,20 @@ def create_app(
|
||||
db.move_show_track(show.id, track_id, body.position)
|
||||
return {"status": "moved"}
|
||||
|
||||
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
|
||||
|
||||
@@ -12,3 +12,11 @@ class Settings(BaseSettings):
|
||||
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])
|
||||
|
||||
153
src/ntr_fetcher/dashboard.py
Normal file
153
src/ntr_fetcher/dashboard.py
Normal file
@@ -0,0 +1,153 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Form, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ntr_fetcher.db import Database
|
||||
from ntr_fetcher.websocket import AnnounceManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
SESSION_MAX_AGE = 86400
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class AnnounceRequest(BaseModel):
|
||||
show_id: int
|
||||
position: int
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_page():
|
||||
html = (STATIC_DIR / "login.html").read_text()
|
||||
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()
|
||||
html = html.replace("{{WS_TOKEN}}", admin_token)
|
||||
return HTMLResponse(html)
|
||||
|
||||
@router.post("/admin/announce")
|
||||
async def announce(body: AnnounceRequest, request: Request):
|
||||
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}
|
||||
|
||||
@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
|
||||
|
||||
role = auth_msg.get("role", "bot")
|
||||
client_id = auth_msg.get("client_id", "")
|
||||
remote_addr = ""
|
||||
if websocket.client:
|
||||
remote_addr = websocket.client.host or ""
|
||||
|
||||
manager.add_client(websocket, role=role, client_id=client_id, remote_addr=remote_addr)
|
||||
await manager.broadcast_status()
|
||||
try:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
manager.remove_client(websocket)
|
||||
try:
|
||||
await manager.broadcast_status()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return router
|
||||
@@ -80,6 +80,9 @@ def run() -> None:
|
||||
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,
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
|
||||
322
src/ntr_fetcher/static/dashboard.html
Normal file
322
src/ntr_fetcher/static/dashboard.html
Normal file
@@ -0,0 +1,322 @@
|
||||
<!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;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.status-dot.connected { background: #4caf50; }
|
||||
.status-dot.disconnected { background: #666; }
|
||||
nav { display: flex; align-items: center; justify-content: space-between; padding: 1rem 0; }
|
||||
nav .left { display: flex; align-items: center; gap: 8px; }
|
||||
.btn-sm { padding: 4px 14px; font-size: 0.85rem; cursor: pointer; }
|
||||
.announce-btn.success { background: #4caf50; border-color: #4caf50; pointer-events: none; }
|
||||
.copy-btn.copied { background: #666; border-color: #666; pointer-events: none; }
|
||||
.announce-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
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; max-width: 350px;
|
||||
}
|
||||
.toast.show { display: block; }
|
||||
.toast.error { background: #e53935; }
|
||||
.track-num { width: 40px; text-align: center; }
|
||||
.client-tag {
|
||||
display: inline-block; padding: 2px 8px; margin: 2px 4px;
|
||||
border-radius: 4px; background: #2a2a2a; font-size: 0.8rem;
|
||||
}
|
||||
#client-detail { display: none; margin-top: 4px; }
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
border-bottom: 2px solid #333;
|
||||
margin-bottom: 1rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.tab-bar::-webkit-scrollbar { height: 4px; }
|
||||
.tab-bar::-webkit-scrollbar-thumb { background: #555; border-radius: 2px; }
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: #999;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
.tab:hover { color: #ddd; }
|
||||
.tab.active {
|
||||
color: #fff;
|
||||
border-bottom-color: #4caf50;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-group { display: flex; gap: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<nav>
|
||||
<div class="left">
|
||||
<span class="status-dot disconnected" id="status-dot"></span>
|
||||
<strong>NtR Playlist Dashboard</strong>
|
||||
<small id="sub-count">(connecting...)</small>
|
||||
<div id="client-detail"></div>
|
||||
</div>
|
||||
<a href="/logout" role="button" class="outline secondary">Logout</a>
|
||||
</nav>
|
||||
|
||||
<div class="tab-bar" id="tab-bar">
|
||||
<span class="tab active">Loading...</span>
|
||||
</div>
|
||||
|
||||
<section id="show-content">
|
||||
<p>Loading shows...</p>
|
||||
</section>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const WS_TOKEN = "{{WS_TOKEN}}";
|
||||
let subscriberCount = 0;
|
||||
let allShows = [];
|
||||
let showCache = {};
|
||||
let activeShowId = null;
|
||||
|
||||
function showToast(msg, isError) {
|
||||
const t = document.getElementById("toast");
|
||||
t.textContent = msg;
|
||||
t.className = "toast show" + (isError ? " error" : "");
|
||||
setTimeout(() => { t.className = "toast"; }, 3000);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s || "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function renderTrackTable(tracks, showId) {
|
||||
if (!tracks || tracks.length === 0) return "<p>No tracks yet.</p>";
|
||||
let html = '<table role="grid"><thead><tr>';
|
||||
html += '<th class="track-num">#</th><th>Title</th><th>Artist</th><th></th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
for (const t of tracks) {
|
||||
const disabled = subscriberCount === 0 ? 'disabled title="No bots connected"' : "";
|
||||
const copyText = `${t.title} by ${t.artist} - ${t.permalink_url}`;
|
||||
html += `<tr>
|
||||
<td class="track-num">${t.position}</td>
|
||||
<td>${esc(t.title)}</td>
|
||||
<td>${esc(t.artist)}</td>
|
||||
<td>
|
||||
<div class="btn-group">
|
||||
<button class="btn-sm copy-btn outline"
|
||||
onclick="copyTrack(this, '${esc(copyText).replace(/'/g, "\\'")}')">Copy</button>
|
||||
<button class="btn-sm announce-btn" ${disabled}
|
||||
onclick="announce(${showId}, ${t.position}, this)">Announce</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
async function loadAllShows() {
|
||||
try {
|
||||
const resp = await fetch("/shows?limit=200");
|
||||
if (!resp.ok) throw new Error("Failed to load shows");
|
||||
allShows = await resp.json();
|
||||
} catch (e) {
|
||||
allShows = [];
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch("/playlist");
|
||||
if (!resp.ok) throw new Error("Failed to load current playlist");
|
||||
const current = await resp.json();
|
||||
showCache[current.show_id] = current;
|
||||
|
||||
const exists = allShows.find(s => s.id === current.show_id);
|
||||
if (!exists) {
|
||||
allShows.unshift({
|
||||
id: current.show_id,
|
||||
episode_number: current.episode_number,
|
||||
week_start: current.week_start,
|
||||
week_end: current.week_end,
|
||||
});
|
||||
}
|
||||
|
||||
renderTabs(current.show_id);
|
||||
renderShow(current);
|
||||
} catch (e) {
|
||||
document.getElementById("show-content").innerHTML = "<p>Failed to load shows.</p>";
|
||||
}
|
||||
}
|
||||
|
||||
function renderTabs(activeId) {
|
||||
activeShowId = activeId;
|
||||
const bar = document.getElementById("tab-bar");
|
||||
bar.innerHTML = "";
|
||||
for (const s of allShows) {
|
||||
const tab = document.createElement("button");
|
||||
tab.className = "tab" + (s.id === activeId ? " active" : "");
|
||||
const label = s.episode_number ? `Ep ${s.episode_number}` : `Show ${s.id}`;
|
||||
tab.textContent = label;
|
||||
tab.onclick = () => selectShow(s.id);
|
||||
bar.appendChild(tab);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectShow(showId) {
|
||||
activeShowId = showId;
|
||||
document.querySelectorAll(".tab").forEach((tab, i) => {
|
||||
tab.classList.toggle("active", allShows[i].id === showId);
|
||||
});
|
||||
|
||||
if (showCache[showId]) {
|
||||
renderShow(showCache[showId]);
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById("show-content").innerHTML = "<p>Loading...</p>";
|
||||
try {
|
||||
const resp = await fetch(`/shows/${showId}`);
|
||||
if (!resp.ok) throw new Error("Failed to load show");
|
||||
const data = await resp.json();
|
||||
showCache[showId] = data;
|
||||
if (activeShowId === showId) renderShow(data);
|
||||
} catch (e) {
|
||||
if (activeShowId === showId) {
|
||||
document.getElementById("show-content").innerHTML = "<p>Failed to load show.</p>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderShow(data) {
|
||||
const el = document.getElementById("show-content");
|
||||
const ep = data.episode_number ? `Episode ${data.episode_number}` : "Show";
|
||||
el.innerHTML = `<h3>${esc(ep)} <small>(${data.tracks.length} tracks)</small></h3>`
|
||||
+ renderTrackTable(data.tracks, data.show_id);
|
||||
}
|
||||
|
||||
function copyTrack(btn, text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
btn.textContent = "\u2713";
|
||||
btn.classList.add("copied");
|
||||
setTimeout(() => {
|
||||
btn.textContent = "Copy";
|
||||
btn.classList.remove("copied");
|
||||
}, 1500);
|
||||
}).catch(() => {
|
||||
showToast("Copy failed", true);
|
||||
});
|
||||
}
|
||||
|
||||
async function announce(showId, position, btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "...";
|
||||
try {
|
||||
const resp = await fetch("/admin/announce", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({show_id: showId, position: position}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw new Error(data.detail || "Announce failed");
|
||||
}
|
||||
btn.textContent = "\u2713";
|
||||
btn.classList.add("success");
|
||||
setTimeout(() => {
|
||||
btn.textContent = "Announce";
|
||||
btn.classList.remove("success");
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
showToast(e.message, true);
|
||||
btn.textContent = "Announce";
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(count, clients) {
|
||||
subscriberCount = count;
|
||||
const dot = document.getElementById("status-dot");
|
||||
const sub = document.getElementById("sub-count");
|
||||
const detail = document.getElementById("client-detail");
|
||||
if (count > 0) {
|
||||
dot.className = "status-dot connected";
|
||||
sub.textContent = `(${count} bot${count > 1 ? "s" : ""} connected)`;
|
||||
if (clients && clients.length > 0) {
|
||||
detail.innerHTML = clients.map(c => {
|
||||
const name = esc(c.client_id || "unknown");
|
||||
const addr = esc(c.remote_addr || "?");
|
||||
return `<span class="client-tag">${name} (${addr})</span>`;
|
||||
}).join(" ");
|
||||
detail.style.display = "block";
|
||||
} else {
|
||||
detail.style.display = "none";
|
||||
}
|
||||
} else {
|
||||
dot.className = "status-dot disconnected";
|
||||
sub.textContent = "(no bots connected)";
|
||||
detail.style.display = "none";
|
||||
}
|
||||
document.querySelectorAll(".announce-btn").forEach(btn => {
|
||||
if (count === 0) {
|
||||
btn.disabled = true;
|
||||
btn.title = "No bots connected";
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.title = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let wsBackoff = 1000;
|
||||
function connectWS() {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws/announce`);
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({type: "subscribe", token: WS_TOKEN, role: "viewer"}));
|
||||
wsBackoff = 1000;
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === "status") {
|
||||
updateStatus(data.subscribers, data.clients || []);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
updateStatus(0, []);
|
||||
document.getElementById("sub-count").textContent = "(reconnecting...)";
|
||||
setTimeout(connectWS, wsBackoff);
|
||||
wsBackoff = Math.min(wsBackoff * 2, 60000);
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
|
||||
loadAllShows();
|
||||
connectWS();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
31
src/ntr_fetcher/static/login.html
Normal file
31
src/ntr_fetcher/static/login.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!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>
|
||||
74
src/ntr_fetcher/websocket.py
Normal file
74
src/ntr_fetcher/websocket.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Client:
|
||||
websocket: object
|
||||
role: str
|
||||
client_id: str = ""
|
||||
remote_addr: str = ""
|
||||
connected_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
|
||||
|
||||
class AnnounceManager:
|
||||
def __init__(self):
|
||||
self._clients: list[Client] = []
|
||||
|
||||
@property
|
||||
def bot_count(self) -> int:
|
||||
return sum(1 for c in self._clients if c.role == "bot")
|
||||
|
||||
@property
|
||||
def bot_clients(self) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"client_id": c.client_id,
|
||||
"remote_addr": c.remote_addr,
|
||||
"connected_at": c.connected_at,
|
||||
}
|
||||
for c in self._clients
|
||||
if c.role == "bot"
|
||||
]
|
||||
|
||||
def add_client(self, websocket, role: str, client_id: str = "", remote_addr: str = "") -> None:
|
||||
self._clients.append(Client(
|
||||
websocket=websocket,
|
||||
role=role,
|
||||
client_id=client_id,
|
||||
remote_addr=remote_addr,
|
||||
))
|
||||
logger.info(
|
||||
"Client connected: role=%s client_id=%s addr=%s (%d bots, %d total)",
|
||||
role, client_id, remote_addr, self.bot_count, len(self._clients),
|
||||
)
|
||||
|
||||
def remove_client(self, websocket) -> None:
|
||||
removed = [c for c in self._clients if c.websocket is websocket]
|
||||
self._clients = [c for c in self._clients if c.websocket is not websocket]
|
||||
for c in removed:
|
||||
logger.info(
|
||||
"Client disconnected: role=%s client_id=%s (%d bots, %d total)",
|
||||
c.role, c.client_id, self.bot_count, len(self._clients),
|
||||
)
|
||||
|
||||
async def broadcast(self, message: dict) -> None:
|
||||
dead = []
|
||||
for client in self._clients:
|
||||
try:
|
||||
await client.websocket.send_json(message)
|
||||
except Exception:
|
||||
dead.append(client.websocket)
|
||||
logger.warning("Removing dead client: %s", client.client_id or client.role)
|
||||
for ws in dead:
|
||||
self.remove_client(ws)
|
||||
|
||||
async def broadcast_status(self) -> None:
|
||||
await self.broadcast({
|
||||
"type": "status",
|
||||
"subscribers": self.bot_count,
|
||||
"clients": self.bot_clients,
|
||||
})
|
||||
@@ -142,3 +142,13 @@ def test_show_by_episode(client, db):
|
||||
def test_show_by_episode_not_found(client):
|
||||
resp = client.get("/shows/by-episode/999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -28,3 +28,27 @@ def test_settings_admin_token_required():
|
||||
import pytest
|
||||
with pytest.raises(Exception):
|
||||
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
|
||||
|
||||
189
tests/test_dashboard.py
Normal file
189
tests/test_dashboard.py
Normal file
@@ -0,0 +1,189 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
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.models import Track
|
||||
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 _seed_show(db):
|
||||
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
|
||||
|
||||
|
||||
# --- Session auth tests ---
|
||||
|
||||
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
|
||||
|
||||
|
||||
# --- Announce endpoint tests ---
|
||||
|
||||
def test_announce_with_session(client, db):
|
||||
_seed_show(db)
|
||||
client.post(
|
||||
"/login",
|
||||
data={"username": "nick", "password": "secret"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
def test_announce_invalid_position(client, db):
|
||||
_seed_show(db)
|
||||
resp = client.post(
|
||||
"/admin/announce",
|
||||
json={"show_id": 1, "position": 99},
|
||||
headers={"Authorization": "Bearer test-token"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- WebSocket tests ---
|
||||
|
||||
def test_ws_subscribe_bot_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", "role": "bot", "client_id": "test-bot"})
|
||||
data = ws.receive_json()
|
||||
assert data["type"] == "status"
|
||||
assert data["subscribers"] == 1
|
||||
assert data["clients"][0]["client_id"] == "test-bot"
|
||||
|
||||
|
||||
def test_ws_subscribe_viewer_not_counted(app):
|
||||
with TestClient(app) as c:
|
||||
with c.websocket_connect("/ws/announce") as ws:
|
||||
ws.send_json({"type": "subscribe", "token": "test-token", "role": "viewer"})
|
||||
data = ws.receive_json()
|
||||
assert data["type"] == "status"
|
||||
assert data["subscribers"] == 0
|
||||
|
||||
|
||||
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"})
|
||||
with pytest.raises(Exception):
|
||||
ws.receive_json()
|
||||
57
tests/test_format_dt.py
Normal file
57
tests/test_format_dt.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Tests for the format_dt helper used by both IRC plugins.
|
||||
|
||||
The function is duplicated in both plugins (Limnoria and Sopel) since they
|
||||
can't share imports. This tests the logic independently of either IRC framework.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
def format_dt(iso_string: str | None, tz_name: str = "America/New_York") -> str:
|
||||
if not iso_string:
|
||||
return "never"
|
||||
dt = datetime.fromisoformat(iso_string)
|
||||
local = dt.astimezone(ZoneInfo(tz_name))
|
||||
return local.strftime("%a %b %-d, %-I:%M %p %Z")
|
||||
|
||||
|
||||
def test_format_dt_est():
|
||||
result = format_dt("2026-01-15T03:00:00+00:00")
|
||||
assert result == "Wed Jan 14, 10:00 PM EST"
|
||||
|
||||
|
||||
def test_format_dt_edt():
|
||||
result = format_dt("2026-03-12T02:00:00+00:00")
|
||||
assert result == "Wed Mar 11, 10:00 PM EDT"
|
||||
|
||||
|
||||
def test_format_dt_none():
|
||||
assert format_dt(None) == "never"
|
||||
|
||||
|
||||
def test_format_dt_empty_string():
|
||||
assert format_dt("") == "never"
|
||||
|
||||
|
||||
def test_format_dt_custom_timezone():
|
||||
result = format_dt("2026-03-12T02:00:00+00:00", "America/Chicago")
|
||||
assert "CDT" in result or "CST" in result
|
||||
|
||||
|
||||
def test_format_dt_no_seconds_or_microseconds():
|
||||
result = format_dt("2026-03-12T02:30:45.123456+00:00")
|
||||
assert ":45" not in result
|
||||
assert ".123456" not in result
|
||||
assert "10:30 PM" in result
|
||||
|
||||
|
||||
def test_format_dt_single_digit_day():
|
||||
result = format_dt("2026-03-05T03:00:00+00:00")
|
||||
assert "Wed Mar 4," in result
|
||||
assert " 04," not in result
|
||||
|
||||
|
||||
def test_format_dt_single_digit_hour():
|
||||
result = format_dt("2026-01-15T06:00:00+00:00")
|
||||
assert "1:00 AM" in result
|
||||
assert "01:00" not in result
|
||||
100
tests/test_websocket.py
Normal file
100
tests/test_websocket.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import pytest
|
||||
from ntr_fetcher.websocket import AnnounceManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager():
|
||||
return AnnounceManager()
|
||||
|
||||
|
||||
def test_no_bots_initially(manager):
|
||||
assert manager.bot_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_subscribe_and_broadcast(manager):
|
||||
received = []
|
||||
|
||||
class FakeWS:
|
||||
async def send_json(self, data):
|
||||
received.append(data)
|
||||
|
||||
ws = FakeWS()
|
||||
manager.add_client(ws, role="bot", client_id="test-bot", remote_addr="127.0.0.1")
|
||||
assert manager.bot_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_client(ws)
|
||||
assert manager.bot_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_viewer_not_counted_as_bot(manager):
|
||||
received = []
|
||||
|
||||
class FakeWS:
|
||||
async def send_json(self, data):
|
||||
received.append(data)
|
||||
|
||||
ws = FakeWS()
|
||||
manager.add_client(ws, role="viewer")
|
||||
assert manager.bot_count == 0
|
||||
|
||||
await manager.broadcast({"type": "announce", "message": "test"})
|
||||
assert len(received) == 1
|
||||
|
||||
manager.remove_client(ws)
|
||||
assert manager.bot_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_client(ws, role="bot", client_id="dead-bot")
|
||||
assert manager.bot_count == 1
|
||||
|
||||
await manager.broadcast({"type": "announce", "message": "test"})
|
||||
assert manager.bot_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_clients_returns_metadata(manager):
|
||||
class FakeWS:
|
||||
async def send_json(self, data):
|
||||
pass
|
||||
|
||||
ws = FakeWS()
|
||||
manager.add_client(ws, role="bot", client_id="limnoria-prod", remote_addr="10.0.0.5")
|
||||
clients = manager.bot_clients
|
||||
assert len(clients) == 1
|
||||
assert clients[0]["client_id"] == "limnoria-prod"
|
||||
assert clients[0]["remote_addr"] == "10.0.0.5"
|
||||
assert "connected_at" in clients[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_broadcast_includes_clients(manager):
|
||||
received = []
|
||||
|
||||
class FakeWS:
|
||||
async def send_json(self, data):
|
||||
received.append(data)
|
||||
|
||||
bot = FakeWS()
|
||||
viewer = FakeWS()
|
||||
manager.add_client(bot, role="bot", client_id="my-bot", remote_addr="1.2.3.4")
|
||||
manager.add_client(viewer, role="viewer")
|
||||
|
||||
await manager.broadcast_status()
|
||||
for msg in received:
|
||||
assert msg["type"] == "status"
|
||||
assert msg["subscribers"] == 1
|
||||
assert len(msg["clients"]) == 1
|
||||
assert msg["clients"][0]["client_id"] == "my-bot"
|
||||
Reference in New Issue
Block a user