Compare commits

...

27 Commits

Author SHA1 Message Date
cottongin
b353f606e5 docs: fix AI disclaimer in README 2026-03-12 08:30:16 -04:00
cottongin
8d9d565c04 docs: add AI disclaimer to README 2026-03-12 08:28:54 -04:00
cottongin
0f99e7914b docs: add IRC commands/plugin setup to README, update WebSocket docs
README was missing IRC command reference, plugin installation/config
guidance, and referenced nonexistent plugin READMEs. The WebSocket
docs in api.md were stale — subscribe message now documents role and
client_id fields, status message now includes the clients array.

Made-with: Cursor
2026-03-12 08:26:16 -04:00
cottongin
911dd3d5dd feat: tabbed show interface and copy-to-clipboard button
Replace the link column with a Copy button that copies
"Title by Artist - URL" to clipboard. Replace the current/previous
show layout with a horizontal scrollable tab bar showing all shows
from the database, most recent first. Tabs lazy-load and cache
show data on click.

Made-with: Cursor
2026-03-12 08:15:22 -04:00
cottongin
f244749293 fix: auto-normalize http(s) URLs to ws(s) in bot plugins
Users behind reverse proxies naturally configure https:// URLs.
The websocket-client library requires ws:// or wss:// schemes.
Both plugins now auto-convert before connecting.

Made-with: Cursor
2026-03-12 08:01:11 -04:00
cottongin
f6840a777c chore: add verbose WS logging to both bot plugins
Log each phase: thread start, TCP connect, subscribe sent, message
received (type), status updates with client list, connection closed
reasons, connection refused/timeout as distinct warnings.

Made-with: Cursor
2026-03-12 07:55:04 -04:00
cottongin
d6d5ac10e6 fix: separate bot vs viewer WebSocket connections, add client identification
The dashboard's own WS connection was being counted as a bot subscriber,
causing "1 bot connected" with no bots actually present. Now WS clients
send a role ("bot" or "viewer") in the subscribe message. Only bots count
toward the subscriber total. Bot plugins also send a configurable client_id
so the dashboard shows which specific bots are connected.

Made-with: Cursor
2026-03-12 07:51:55 -04:00
cottongin
658c0d4a15 docs: add dashboard and announce API documentation
Made-with: Cursor
2026-03-12 07:24:42 -04:00
cottongin
e31a9503db Add WebSocket announce listener to both IRC bot plugins
- Limnoria: add wsUrl, announceChannel config; __init__, die, _ws_listener
- Sopel: add ws_url, announce_channel config; setup/shutdown, _ws_listener
- Feature parity: subscribe to WS, receive announce msgs, send to IRC channel
- Deferred websocket-client import to avoid load failure if not installed

Made-with: Cursor
2026-03-12 07:22:49 -04:00
cottongin
a7849e6cd9 feat: styled login and dashboard pages with Pico CSS dark theme
Made-with: Cursor
2026-03-12 07:21:39 -04:00
cottongin
92136f0508 Wire dashboard into app
- Add web_user, web_password, secret_key as optional params to create_app
- Conditionally mount dashboard router when all three are set
- Pass settings from main.py to create_app
- Add tests for no dashboard/login routes when config absent

Made-with: Cursor
2026-03-12 07:19:01 -04:00
cottongin
e90f44439b Add dashboard router with session auth, announce endpoint, and WebSocket handler
- Login/logout/dashboard with HMAC-signed session cookies
- POST /admin/announce with session or bearer auth
- WS /ws/announce for subscribe/broadcast
- Static stubs: login.html, dashboard.html

Made-with: Cursor
2026-03-12 07:17:20 -04:00
cottongin
788225b3b6 feat: add WebSocket announce manager
Made-with: Cursor
2026-03-12 07:14:04 -04:00
cottongin
7ed7ace578 feat: add optional dashboard config fields
Made-with: Cursor
2026-03-12 07:12:10 -04:00
cottongin
e5c06a2f67 docs: add live announce dashboard implementation plan
13-task TDD plan covering WebSocket manager, dashboard auth, announce
endpoint, styled UI, and bot plugin WS clients for both Sopel and Limnoria.

Made-with: Cursor
2026-03-12 07:08:02 -04:00
cottongin
47a78b09a7 docs: add live announce dashboard design document
Approved design for a web dashboard that lets the host announce tracks
to IRC in real-time during live shows via WebSocket push.

Made-with: Cursor
2026-03-12 07:04:53 -04:00
cottongin
9664b8225d feat: add human-readable datetime formatting and !lastshow command
IRC plugins now format datetimes as "Wed Mar 11, 10:00 PM EDT" instead
of raw ISO 8601. Configurable timezone defaults to America/New_York.

Adds !lastshow N command to both Limnoria and Sopel plugins, returning
track N from the previous week's show via existing API endpoints.

Made-with: Cursor
2026-03-12 05:31:50 -04:00
cottongin
ae66242935 chore: add MIT license
Made-with: Cursor
2026-03-12 03:51:01 -04:00
cottongin
359a11dd4a chore: exclude chat-summaries from version control
Add chat-summaries/ to .gitignore and remove tracked files from index.

Made-with: Cursor
2026-03-12 03:48:21 -04:00
cottongin
03ce201a47 docs: add chat summary for IRC bot plugins implementation
Made-with: Cursor
2026-03-12 03:42:27 -04:00
cottongin
05bcf184ac fix: playlist truncation overflow and align logging across plugins
- Fix format_playlist truncation: +4 → +5 to account for ", ..." suffix
- Add API error logging to all Sopel command handlers (matching Limnoria)
- Add long-track truncation test case

Made-with: Cursor
2026-03-12 03:24:29 -04:00
cottongin
b63c851d14 docs: add IRC bot plugins design and implementation plan
Made-with: Cursor
2026-03-12 03:20:45 -04:00
cottongin
5c227766f1 test: add tests for IRC plugin formatting and API helpers
Made-with: Cursor
2026-03-12 03:20:05 -04:00
cottongin
6dd7aee2f2 feat(limnoria): add NtrPlaylist IRC plugin
Made-with: Cursor
2026-03-12 03:18:04 -04:00
cottongin
2a00cc263f feat(sopel): add NtR playlist IRC plugin
Made-with: Cursor
2026-03-12 03:11:04 -04:00
cottongin
1d08580a45 feat: add GET /shows/by-episode/{episode_number} endpoint
Allows looking up shows by episode number instead of internal DB ID,
enabling IRC bot commands like !playlist 530 to resolve directly.

Made-with: Cursor
2026-03-12 02:33:42 -04:00
cottongin
b529edecc3 chore: update .gitignore for SQLite WAL, venv, and tool caches
Made-with: Cursor
2026-03-12 02:10:55 -04:00
30 changed files with 4795 additions and 73 deletions

27
.gitignore vendored
View File

@@ -1,5 +1,28 @@
# macOS
.DS_Store
*.db
.env
# Python
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
.venv/
# Tools
.ruff_cache/
.pytest_cache/
.mypy_cache/
# SQLite runtime
*.db
*.db-shm
*.db-wal
# Environment / secrets
.env
.env.*
# AI session artifacts
chat-summaries/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cottongin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -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
@@ -37,11 +40,16 @@ Full documentation: [`docs/api.md`](docs/api.md)
| `/playlist` | GET | -- | Current week's playlist |
| `/playlist/{position}` | GET | -- | Single track by position (1-indexed) |
| `/shows` | GET | -- | List all shows (paginated) |
| `/shows/{show_id}` | GET | -- | Specific show with tracks |
| `/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
@@ -58,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

View File

@@ -1,39 +0,0 @@
# NtR SoundCloud Fetcher — Full Implementation
## Task Description
Designed and implemented a Python service that polls NicktheRat's SoundCloud likes, builds weekly playlists aligned to the Wednesday 22:00 ET show schedule, and serves them via a JSON API for an IRC bot.
## Changes Made
### Design Phase
- Brainstormed requirements through 6 clarifying questions
- Evaluated 3 architectural approaches, selected single-process daemon
- Produced design doc covering architecture, data model, API, poller logic
- Produced 13-task TDD implementation plan
### Implementation (42 tests, all passing, lint clean)
| Module | File | Purpose |
|--------|------|---------|
| Config | `src/ntr_fetcher/config.py` | Pydantic settings with `NTR_` env prefix |
| Week | `src/ntr_fetcher/week.py` | DST-aware Wednesday 22:00 ET boundary computation |
| Models | `src/ntr_fetcher/models.py` | Track, Show, ShowTrack dataclasses |
| Database | `src/ntr_fetcher/db.py` | SQLite schema, CRUD, track sync with unlike removal |
| SoundCloud | `src/ntr_fetcher/soundcloud.py` | client_id extraction, user resolution, likes fetching |
| Poller | `src/ntr_fetcher/poller.py` | Hourly polling with supervised restart |
| API | `src/ntr_fetcher/api.py` | FastAPI routes for playlist, shows, admin, health |
| Main | `src/ntr_fetcher/main.py` | Entry point wiring everything together |
### Key Design Decisions
- Tracks removed when Nick unlikes them (positions re-compact)
- Cursor-seeking for efficient SoundCloud API pagination
- Automatic client_id rotation on 401
- Supervisor restarts poller on failure without affecting API
## Follow-up Items
- **Incremental fetching**: Currently fetches full week every poll; could optimize to stop at known tracks
- **Retry/backoff for non-401 errors**: 429, 5xx, timeouts not yet handled with retries
- **`full` parameter**: Accepted but currently equivalent to normal poll (no incremental to differentiate from)
- **`soundcloud_url` in admin add track**: Removed from API; only `track_id` supported

View File

@@ -1,30 +0,0 @@
# Historical Backfill (--init) Feature
## Task
Add CLI-based historical show backfill with episode numbering throughout the system.
## Changes Made
### New file
- `src/ntr_fetcher/backfill.py` — Computes show weeks from an anchor episode/date, batch-fetches all likes from SoundCloud, partitions them into weekly buckets, and populates the DB.
### Modified files
- `src/ntr_fetcher/models.py` — Added `episode_number: int | None` to `Show` dataclass.
- `src/ntr_fetcher/db.py` — Added `episode_number` column to schema, ALTER TABLE migration for existing DBs, updated `get_or_create_show` to accept/store episode numbers, added `get_latest_episode_number()` and `update_show_episode_number()`, changed `list_shows` ordering to `week_start DESC`.
- `src/ntr_fetcher/main.py` — Added `argparse` with `--init`, `--show`, `--aired` flags. `--init` runs backfill then exits; default starts the server as before.
- `src/ntr_fetcher/poller.py` — Auto-assigns episode number (latest + 1) when creating a new show if historical data exists.
- `src/ntr_fetcher/api.py` — Added `episode_number` to `/playlist`, `/shows`, `/shows/{show_id}` responses.
### New/updated tests
- `tests/test_backfill.py` — Week computation, batch partitioning, empty data, idempotency.
- `tests/test_db.py` — Episode number creation, update, and `get_latest_episode_number`.
- `tests/test_poller.py` — Auto-numbering when history exists, skips when no history, skips when already assigned.
- `tests/test_api.py``episode_number` present in show responses.
## Results
- 58 tests passing (up from 42), ruff clean.
## Usage
```
NTR_ADMIN_TOKEN=token ntr-fetcher --init --show 521 --aired 2026-01-07
```

View File

@@ -117,9 +117,39 @@ Lists all shows, ordered by week start date (newest first).
---
### `GET /shows/by-episode/{episode_number}`
Look up a show by its episode number. This is the recommended endpoint for IRC bot integrations (e.g. `!playlist 530`).
**Path Parameters**
| Parameter | Type | Description |
|-----------|------|-------------|
| `episode_number` | integer | The episode number (e.g. 530) |
**Response**
```json
{
"show_id": 10,
"episode_number": 530,
"week_start": "2026-03-05T02:00:00+00:00",
"week_end": "2026-03-12T02:00:00+00:00",
"tracks": [...]
}
```
**Errors**
| Status | Detail |
|--------|--------|
| 404 | `"No show with episode number {n}"` |
---
### `GET /shows/{show_id}`
Returns a specific show with its full track listing.
Returns a specific show by internal database ID.
**Path Parameters**
@@ -147,6 +177,35 @@ Returns a specific show with its full track listing.
---
## 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:
@@ -278,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.
@@ -308,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.

View File

@@ -0,0 +1,157 @@
# NtR IRC Bot Plugins — Design Document
> **Date**: 2026-03-12
> **Status**: Approved
## Purpose
Two functionally-identical IRC bot plugins (Sopel and Limnoria) that query the NtR SoundCloud Fetcher API to serve playlist and track information in IRC channels.
## Approach
Fully independent plugins with no shared code. Each plugin is a self-contained unit you drop into the bot's plugin directory. Both follow identical internal structure and function naming so a diff between them shows only framework-specific glue.
## Directory Layout
```
plugins/
├── sopel/
│ └── ntr_playlist.py # Single-file Sopel plugin
└── limnoria/
└── NtrPlaylist/ # Limnoria package plugin
├── __init__.py # Plugin metadata
├── config.py # Registry values
├── plugin.py # Command handlers
└── test.py # Test stub
```
Sopel: copy `ntr_playlist.py` into `~/.sopel/plugins/`.
Limnoria: copy `NtrPlaylist/` into `limnoria-bot/plugins/`.
## Commands
| Trigger | API Call | Auth | Output |
|---------|----------|------|--------|
| `!1`, `!2`, ... `!N` | `GET /playlist/{n}` | None | Single track |
| `!song <episode> <position>` | `GET /shows/by-episode/{episode}` → filter by position | None | Single track |
| `!refresh` | `POST /admin/refresh` (bearer token) | Admin nicks | Refresh result |
| `!status` | `GET /health` | None | Full health info |
| `!playlist` | `GET /playlist` | None | Comma-separated track list |
| `!playlist <episode>` | `GET /shows/by-episode/{episode}` | None | Comma-separated track list |
### Number commands (`!1`, `!2`, etc.)
Neither framework natively registers bare-digit commands. Both plugins use a regex/rule pattern to match messages starting with `!` followed by one or more digits and nothing else.
- Sopel: `@plugin.rule(r'^!(\d+)$')`
- Limnoria: `doPrivmsg` override checking against `r'^!(\d+)$'`
### Output Formats
**Single track** (`!N`, `!song`):
```
Song #3: Night Drive by SomeArtist - https://soundcloud.com/someartist/night-drive
```
**Playlist** (`!playlist`):
```
Episode 530 (9 tracks): Night Drive by SomeArtist, Running Through My Mind by Purrple Panther, ...
```
**Status** (`!status`):
```
Status: OK | Poller: alive | Last fetch: 2026-03-12T02:00:00+00:00 | Tracks this week: 9
```
**Refresh** (`!refresh`):
```
Refreshed — 9 tracks
```
**Errors** (pass through API detail):
```
No track at position 15
```
## Configuration
Both plugins expose the same three settings:
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `api_base_url` | string | `http://127.0.0.1:8000` | NtR API base URL (no trailing slash) |
| `admin_token` | string | *(empty)* | Bearer token for `POST /admin/refresh` |
| `admin_nicks` | list of strings | *(empty)* | IRC nicknames allowed to run `!refresh` |
### Sopel (INI)
```ini
[ntr_playlist]
api_base_url = http://127.0.0.1:8000
admin_token = secret-token-here
admin_nicks =
NicktheRat
SomeOtherAdmin
```
Defined via `StaticSection` with `ValidatedAttribute` and `ListAttribute`.
### Limnoria (registry)
```
config plugins.NtrPlaylist.apiBaseUrl http://127.0.0.1:8000
config plugins.NtrPlaylist.adminToken secret-token-here
config plugins.NtrPlaylist.adminNicks NicktheRat SomeOtherAdmin
```
`adminToken` marked `private=True`. `adminNicks` uses `SpaceSeparatedListOfStrings`.
### Admin Check
Compare the triggering user's IRC nickname against `admin_nicks`, case-insensitive. Framework-level admin/owner systems are not used. This keeps admin management identical and portable between plugins.
## HTTP Client
Both plugins use `urllib.request` from stdlib — no external dependencies beyond the bot framework.
### Internal Helpers
**`_api_get(base_url, path)`** → `dict`
- `GET {base_url}{path}` with `Accept: application/json`
- 10-second timeout
- On success: parse and return JSON
- On HTTP error: parse JSON error body, raise `ApiError(status_code, detail)`
- On connection failure: raise `ApiError(0, "Cannot reach API")`
**`_api_post(base_url, path, token, body=None)`** → `dict`
- Same as above but `POST` with `Authorization: Bearer {token}` and `Content-Type: application/json`
- Used only by `!refresh`
### Error Handling
```
try:
data = _api_get(base_url, f"/playlist/{position}")
reply with formatted track
except ApiError as e:
reply with e.detail
```
No retries at the plugin level. The API is local; IRC users expect immediate responses. If the API is down, the user sees "Cannot reach API".
No async concerns — both frameworks run command handlers synchronously. A blocking 10-second timeout is acceptable.
## Design Decisions
| Decision | Rationale |
|----------|-----------|
| Independent plugins, no shared code | Each plugin is one self-contained directory. No deployment headaches with shared imports. |
| Regex for `!N` commands | Frameworks don't support bare-digit command names. Regex catch-all is the cleanest solution. |
| Plugin-config admin nicks only | Portable between frameworks. No coupling to Sopel's `core.admins` or Limnoria's capability system. |
| No caching | API is local, responses are fast. Simplifies code and guarantees fresh data. |
| stdlib `urllib.request` | Zero external dependencies. Both frameworks already have Python 3.11+. |
| `!song` fetches full show, filters client-side | API has no single-track-by-episode endpoint. The response is small enough that client-side filtering is fine. |
## Dependencies
None beyond the bot framework itself and Python 3.11+ stdlib.

View File

@@ -0,0 +1,758 @@
# IRC Bot Plugins Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build two functionally-identical IRC bot plugins (Sopel and Limnoria) that query the NtR SoundCloud Fetcher API.
**Architecture:** Fully independent plugins, no shared code. Each plugin has its own API client (`urllib.request`), formatting functions, and admin-nick check. Sopel is a single-file plugin; Limnoria is a four-file package. Both produce identical IRC output.
**Tech Stack:** Python 3.11+ stdlib only (`urllib.request`, `json`, `re`). Sopel 7.0+ API. Limnoria 2025.07.18+ API. pytest for helper tests.
**Design doc:** `docs/plans/2026-03-12-irc-plugins-design.md`
**API reference:** `docs/api.md`
---
### Task 1: Sopel Plugin — Complete Implementation
**Files:**
- Create: `plugins/sopel/ntr_playlist.py`
**Step 1: Create the plugin file**
Create `plugins/sopel/ntr_playlist.py` with the full contents below. This is a single-file Sopel plugin covering config, API helpers, formatting, admin check, and all five commands.
```python
"""NtR Playlist — Sopel plugin for NicktheRat SoundCloud playlists."""
import json
import urllib.error
import urllib.request
from sopel import plugin
from sopel.config import types
# --- Configuration -----------------------------------------------------------
class NtrPlaylistSection(types.StaticSection):
api_base_url = types.ValidatedAttribute(
"api_base_url", str, default="http://127.0.0.1:8000",
)
admin_token = types.ValidatedAttribute("admin_token", str, default="")
admin_nicks = types.ListAttribute("admin_nicks")
def setup(bot):
bot.settings.define_section("ntr_playlist", NtrPlaylistSection)
def configure(config):
config.define_section("ntr_playlist", NtrPlaylistSection, validate=False)
# --- API helpers -------------------------------------------------------------
class ApiError(Exception):
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
super().__init__(detail)
def _api_get(base_url: str, path: str) -> dict:
req = urllib.request.Request(
f"{base_url}{path}",
headers={"Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
try:
body = json.loads(exc.read().decode())
detail = body.get("detail", str(exc))
except Exception:
detail = str(exc)
raise ApiError(exc.code, detail) from exc
except urllib.error.URLError as exc:
raise ApiError(0, "Cannot reach API") from exc
def _api_post(base_url: str, path: str, token: str, body: dict | None = None) -> dict:
req = urllib.request.Request(
f"{base_url}{path}",
data=json.dumps(body).encode() if body else None,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
try:
body = json.loads(exc.read().decode())
detail = body.get("detail", str(exc))
except Exception:
detail = str(exc)
raise ApiError(exc.code, detail) from exc
except urllib.error.URLError as exc:
raise ApiError(0, "Cannot reach API") from exc
# --- Formatting --------------------------------------------------------------
def format_track(track: dict) -> str:
return (
f"Song #{track['position']}: {track['title']} "
f"by {track['artist']} - {track['permalink_url']}"
)
def format_playlist(data: dict) -> str:
ep = data.get("episode_number", "?")
tracks = data.get("tracks", [])
items = ", ".join(f"{t['title']} by {t['artist']}" for t in tracks)
return f"Episode {ep} ({len(tracks)} tracks): {items}"
# --- Admin check -------------------------------------------------------------
def _is_admin(bot, nick: str) -> bool:
nicks = bot.settings.ntr_playlist.admin_nicks or []
return nick.lower() in [n.lower() for n in nicks]
# --- Commands ----------------------------------------------------------------
@plugin.rule(r"^!(\d+)$")
def track_by_number(bot, trigger):
"""Fetch a track by position number (!1, !2, etc.)."""
position = trigger.group(1)
base_url = bot.settings.ntr_playlist.api_base_url
try:
data = _api_get(base_url, f"/playlist/{position}")
bot.say(format_track(data))
except ApiError as exc:
bot.say(exc.detail)
@plugin.command("song")
def song(bot, trigger):
"""Fetch a track from a specific episode: !song <episode> <position>."""
raw = trigger.group(2)
if not raw:
bot.say("Usage: !song <episode> <position>")
return
parts = raw.strip().split()
if len(parts) != 2:
bot.say("Usage: !song <episode> <position>")
return
try:
episode, position = int(parts[0]), int(parts[1])
except ValueError:
bot.say("Usage: !song <episode> <position>")
return
base_url = bot.settings.ntr_playlist.api_base_url
try:
data = _api_get(base_url, f"/shows/by-episode/{episode}")
track = next(
(t for t in data.get("tracks", []) if t["position"] == position),
None,
)
if track is None:
bot.say(f"No track at position {position} in episode {episode}")
return
bot.say(format_track(track))
except ApiError as exc:
bot.say(exc.detail)
@plugin.command("playlist")
def playlist_cmd(bot, trigger):
"""Current playlist, or a specific episode: !playlist [episode]."""
raw = trigger.group(2)
base_url = bot.settings.ntr_playlist.api_base_url
try:
if raw and raw.strip():
episode = int(raw.strip())
data = _api_get(base_url, f"/shows/by-episode/{episode}")
else:
data = _api_get(base_url, "/playlist")
bot.say(format_playlist(data))
except ValueError:
bot.say("Usage: !playlist [episode]")
except ApiError as exc:
bot.say(exc.detail)
@plugin.command("status")
def status_cmd(bot, trigger):
"""Show API health status."""
base_url = bot.settings.ntr_playlist.api_base_url
try:
data = _api_get(base_url, "/health")
poller = "alive" if data.get("poller_alive") else "dead"
last_fetch = data.get("last_fetch") or "never"
count = data.get("current_week_track_count", 0)
bot.say(
f"Status: {data['status'].upper()} | Poller: {poller} "
f"| Last fetch: {last_fetch} | Tracks this week: {count}"
)
except ApiError as exc:
bot.say(exc.detail)
@plugin.command("refresh")
def refresh_cmd(bot, trigger):
"""Manually refresh the playlist (admin only)."""
if not _is_admin(bot, trigger.nick):
bot.say("You don't have permission to use this command.")
return
base_url = bot.settings.ntr_playlist.api_base_url
token = bot.settings.ntr_playlist.admin_token
if not token:
bot.say("Admin token not configured.")
return
try:
data = _api_post(base_url, "/admin/refresh", token)
count = data.get("track_count", "?")
bot.say(f"Refreshed \u2014 {count} tracks")
except ApiError as exc:
bot.say(f"Refresh failed: {exc.detail}")
```
**Step 2: Verify syntax**
Run: `python -c "import ast; ast.parse(open('plugins/sopel/ntr_playlist.py').read()); print('OK')"`
Expected: `OK`
**Step 3: Commit**
```bash
git add plugins/sopel/ntr_playlist.py
git commit -m "feat(sopel): add NtR playlist IRC plugin"
```
---
### Task 2: Limnoria Plugin — Scaffold
**Files:**
- Create: `plugins/limnoria/NtrPlaylist/__init__.py`
- Create: `plugins/limnoria/NtrPlaylist/config.py`
- Create: `plugins/limnoria/NtrPlaylist/test.py`
**Step 1: Create `__init__.py`**
```python
"""NtR Playlist — Limnoria plugin for NicktheRat SoundCloud playlists."""
import importlib
from . import config, plugin
importlib.reload(config)
importlib.reload(plugin)
Class = plugin.Class
configure = config.configure
__version__ = "0.1.0"
__author__ = "NtR SoundCloud Fetcher"
```
**Step 2: Create `config.py`**
```python
from supybot import conf, registry
def configure(advanced):
conf.registerPlugin("NtrPlaylist", True)
NtrPlaylist = conf.registerPlugin("NtrPlaylist")
conf.registerGlobalValue(
NtrPlaylist,
"apiBaseUrl",
registry.String(
"http://127.0.0.1:8000",
"""Base URL for the NtR SoundCloud Fetcher API (no trailing slash).""",
),
)
conf.registerGlobalValue(
NtrPlaylist,
"adminToken",
registry.String(
"",
"""Bearer token for admin API endpoints.""",
private=True,
),
)
conf.registerGlobalValue(
NtrPlaylist,
"adminNicks",
registry.SpaceSeparatedListOfStrings(
[],
"""IRC nicknames allowed to run admin commands (space-separated).""",
),
)
```
**Step 3: Create `test.py`**
```python
from supybot.test import PluginTestCase
class NtrPlaylistTestCase(PluginTestCase):
plugins = ("NtrPlaylist",)
```
**Step 4: Verify syntax on all three files**
Run: `for f in plugins/limnoria/NtrPlaylist/__init__.py plugins/limnoria/NtrPlaylist/config.py plugins/limnoria/NtrPlaylist/test.py; do python -c "import ast; ast.parse(open('$f').read()); print('$f OK')"; done`
Expected: all three print OK
**Step 5: Commit**
```bash
git add plugins/limnoria/NtrPlaylist/
git commit -m "feat(limnoria): scaffold NtrPlaylist plugin package"
```
---
### Task 3: Limnoria Plugin — Commands
**Files:**
- Create: `plugins/limnoria/NtrPlaylist/plugin.py`
**Step 1: Create `plugin.py`**
This file contains the API helpers, formatting functions, and all command handlers — functionally identical to the Sopel plugin.
```python
"""NtR Playlist — Limnoria command handlers."""
import json
import re
import urllib.error
import urllib.request
from supybot import callbacks
from supybot.commands import optional, wrap
# --- API helpers -------------------------------------------------------------
class ApiError(Exception):
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
super().__init__(detail)
def _api_get(base_url: str, path: str) -> dict:
req = urllib.request.Request(
f"{base_url}{path}",
headers={"Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
try:
body = json.loads(exc.read().decode())
detail = body.get("detail", str(exc))
except Exception:
detail = str(exc)
raise ApiError(exc.code, detail) from exc
except urllib.error.URLError as exc:
raise ApiError(0, "Cannot reach API") from exc
def _api_post(base_url: str, path: str, token: str, body: dict | None = None) -> dict:
req = urllib.request.Request(
f"{base_url}{path}",
data=json.dumps(body).encode() if body else None,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
try:
body = json.loads(exc.read().decode())
detail = body.get("detail", str(exc))
except Exception:
detail = str(exc)
raise ApiError(exc.code, detail) from exc
except urllib.error.URLError as exc:
raise ApiError(0, "Cannot reach API") from exc
# --- Formatting --------------------------------------------------------------
def format_track(track: dict) -> str:
return (
f"Song #{track['position']}: {track['title']} "
f"by {track['artist']} - {track['permalink_url']}"
)
def format_playlist(data: dict) -> str:
ep = data.get("episode_number", "?")
tracks = data.get("tracks", [])
items = ", ".join(f"{t['title']} by {t['artist']}" for t in tracks)
return f"Episode {ep} ({len(tracks)} tracks): {items}"
# --- Plugin ------------------------------------------------------------------
_NUMBER_RE = re.compile(r"^!(\d+)$")
class NtrPlaylist(callbacks.Plugin):
"""Query the NtR SoundCloud Fetcher API from IRC."""
def _is_admin(self, nick: str) -> bool:
nicks = self.registryValue("adminNicks")
return nick.lower() in [n.lower() for n in nicks]
def doPrivmsg(self, irc, msg):
channel = msg.args[0] if msg.args else None
if not channel or not irc.isChannel(channel):
return
text = msg.args[1] if len(msg.args) > 1 else ""
match = _NUMBER_RE.match(text)
if not match:
return
position = match.group(1)
base_url = self.registryValue("apiBaseUrl")
try:
data = _api_get(base_url, f"/playlist/{position}")
irc.reply(format_track(data))
except ApiError as exc:
irc.reply(exc.detail)
@wrap(["int", "int"])
def song(self, irc, msg, args, episode, position):
"""<episode> <position>
Returns a track from a specific episode's playlist.
"""
base_url = self.registryValue("apiBaseUrl")
try:
data = _api_get(base_url, f"/shows/by-episode/{episode}")
track = next(
(t for t in data.get("tracks", []) if t["position"] == position),
None,
)
if track is None:
irc.reply(f"No track at position {position} in episode {episode}")
return
irc.reply(format_track(track))
except ApiError as exc:
irc.reply(exc.detail)
@wrap([optional("int")])
def playlist(self, irc, msg, args, episode):
"""[<episode>]
Returns the playlist for the current show, or a specific episode.
"""
base_url = self.registryValue("apiBaseUrl")
try:
if episode is not None:
data = _api_get(base_url, f"/shows/by-episode/{episode}")
else:
data = _api_get(base_url, "/playlist")
irc.reply(format_playlist(data))
except ApiError as exc:
irc.reply(exc.detail)
@wrap
def status(self, irc, msg, args):
"""takes no arguments
Returns the current API status.
"""
base_url = self.registryValue("apiBaseUrl")
try:
data = _api_get(base_url, "/health")
poller = "alive" if data.get("poller_alive") else "dead"
last_fetch = data.get("last_fetch") or "never"
count = data.get("current_week_track_count", 0)
irc.reply(
f"Status: {data['status'].upper()} | Poller: {poller} "
f"| Last fetch: {last_fetch} | Tracks this week: {count}"
)
except ApiError as exc:
irc.reply(exc.detail)
@wrap
def refresh(self, irc, msg, args):
"""takes no arguments
Triggers a manual playlist refresh. Admin only.
"""
if not self._is_admin(msg.nick):
irc.reply("You don't have permission to use this command.")
return
base_url = self.registryValue("apiBaseUrl")
token = self.registryValue("adminToken")
if not token:
irc.reply("Admin token not configured.")
return
try:
data = _api_post(base_url, "/admin/refresh", token)
count = data.get("track_count", "?")
irc.reply(f"Refreshed \u2014 {count} tracks")
except ApiError as exc:
irc.reply(f"Refresh failed: {exc.detail}")
Class = NtrPlaylist
```
**Step 2: Verify syntax**
Run: `python -c "import ast; ast.parse(open('plugins/limnoria/NtrPlaylist/plugin.py').read()); print('OK')"`
Expected: `OK`
**Step 3: Commit**
```bash
git add plugins/limnoria/NtrPlaylist/plugin.py
git commit -m "feat(limnoria): add NtrPlaylist command handlers"
```
---
### Task 4: Tests for Formatting and Admin Logic
Both plugins have identical formatting functions. We test the logic once using the Sopel plugin's module (it's a plain Python file with no import-time side effects from Sopel beyond the decorators, which we can handle).
Since importing the Sopel plugin requires `sopel` to be installed (due to top-level imports), we write standalone tests that duplicate the pure functions. This avoids adding `sopel` or `limnoria` as test dependencies for the main project.
**Files:**
- Create: `tests/test_plugin_helpers.py`
**Step 1: Write the tests**
```python
"""Tests for IRC plugin helper functions (formatting, admin check, API errors).
These test the pure logic shared across both Sopel and Limnoria plugins.
The functions are duplicated here to avoid importing framework-dependent modules.
"""
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
import pytest
# --- Duplicated pure functions under test ------------------------------------
# Kept in sync with plugins/sopel/ntr_playlist.py and
# plugins/limnoria/NtrPlaylist/plugin.py
import urllib.error
import urllib.request
class ApiError(Exception):
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
super().__init__(detail)
def _api_get(base_url: str, path: str) -> dict:
req = urllib.request.Request(
f"{base_url}{path}",
headers={"Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
try:
body = json.loads(exc.read().decode())
detail = body.get("detail", str(exc))
except Exception:
detail = str(exc)
raise ApiError(exc.code, detail) from exc
except urllib.error.URLError as exc:
raise ApiError(0, "Cannot reach API") from exc
def format_track(track: dict) -> str:
return (
f"Song #{track['position']}: {track['title']} "
f"by {track['artist']} - {track['permalink_url']}"
)
def format_playlist(data: dict) -> str:
ep = data.get("episode_number", "?")
tracks = data.get("tracks", [])
items = ", ".join(f"{t['title']} by {t['artist']}" for t in tracks)
return f"Episode {ep} ({len(tracks)} tracks): {items}"
# --- format_track tests -----------------------------------------------------
def test_format_track_basic():
track = {
"position": 3,
"title": "Night Drive",
"artist": "SomeArtist",
"permalink_url": "https://soundcloud.com/someartist/night-drive",
}
assert format_track(track) == (
"Song #3: Night Drive by SomeArtist "
"- https://soundcloud.com/someartist/night-drive"
)
def test_format_track_position_1():
track = {
"position": 1,
"title": "A",
"artist": "B",
"permalink_url": "https://example.com",
}
result = format_track(track)
assert result.startswith("Song #1:")
# --- format_playlist tests --------------------------------------------------
def test_format_playlist_with_tracks():
data = {
"episode_number": 530,
"tracks": [
{"title": "Night Drive", "artist": "SomeArtist"},
{"title": "Running", "artist": "Purrple Panther"},
],
}
result = format_playlist(data)
assert result == (
"Episode 530 (2 tracks): "
"Night Drive by SomeArtist, Running by Purrple Panther"
)
def test_format_playlist_empty():
data = {"episode_number": 530, "tracks": []}
assert format_playlist(data) == "Episode 530 (0 tracks): "
def test_format_playlist_missing_episode():
data = {"tracks": [{"title": "A", "artist": "B"}]}
result = format_playlist(data)
assert result.startswith("Episode ?")
# --- _api_get tests (with a real HTTP server) --------------------------------
class _Handler(BaseHTTPRequestHandler):
response_code = 200
response_body = "{}"
def do_GET(self):
self.send_response(self.response_code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(self.response_body.encode())
def log_message(self, *_args):
pass # silence logs
@pytest.fixture()
def mock_server():
server = HTTPServer(("127.0.0.1", 0), _Handler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
host, port = server.server_address
yield f"http://{host}:{port}", server
server.shutdown()
def test_api_get_success(mock_server):
base_url, srv = mock_server
_Handler.response_code = 200
_Handler.response_body = json.dumps({"title": "ok"})
result = _api_get(base_url, "/test")
assert result == {"title": "ok"}
def test_api_get_404(mock_server):
base_url, srv = mock_server
_Handler.response_code = 404
_Handler.response_body = json.dumps({"detail": "Not found"})
with pytest.raises(ApiError) as exc_info:
_api_get(base_url, "/missing")
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Not found"
def test_api_get_unreachable():
with pytest.raises(ApiError) as exc_info:
_api_get("http://127.0.0.1:1", "/nope")
assert exc_info.value.status_code == 0
assert exc_info.value.detail == "Cannot reach API"
```
**Step 2: Run the tests**
Run: `pytest tests/test_plugin_helpers.py -v`
Expected: all tests pass
**Step 3: Commit**
```bash
git add tests/test_plugin_helpers.py
git commit -m "test: add tests for IRC plugin formatting and API helpers"
```
---
### Task 5: Final Verification and Docs
**Step 1: Run full test suite**
Run: `pytest -v`
Expected: all tests pass (existing + new)
**Step 2: Run ruff on plugin files**
Run: `ruff check plugins/ tests/test_plugin_helpers.py`
Expected: no errors (or only pre-existing ones outside these files)
**Step 3: Commit any fixes from linting**
If ruff found issues, fix and commit:
```bash
git add -u
git commit -m "style: fix lint issues in IRC plugins"
```

View 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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
from . import config
from . import plugin
from importlib import reload
reload(config)
reload(plugin)
Class = plugin.Class
configure = config.configure
__version__ = "0.1.0"

View File

@@ -0,0 +1,72 @@
from supybot import conf, registry
def configure(advanced):
conf.registerPlugin("NtrPlaylist", True)
NtrPlaylist = conf.registerPlugin("NtrPlaylist")
conf.registerGlobalValue(
NtrPlaylist,
"apiBaseUrl",
registry.String(
"http://127.0.0.1:8000",
"""Base URL for the NtR SoundCloud Fetcher API (no trailing slash).""",
),
)
conf.registerGlobalValue(
NtrPlaylist,
"adminToken",
registry.String(
"",
"""Bearer token for admin API endpoints.""",
private=True,
),
)
conf.registerGlobalValue(
NtrPlaylist,
"adminNicks",
registry.SpaceSeparatedListOfStrings(
[],
"""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.""",
),
)

View File

@@ -0,0 +1,385 @@
"""
NtR Playlist — Limnoria plugin for NtR SoundCloud Fetcher API.
"""
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
LOGGER = logging.getLogger(__name__)
# --- API helpers -------------------------------------------------------------
class ApiError(Exception):
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
super().__init__(f"{status_code}: {detail}")
def _api_get(base_url: str, path: str) -> dict:
url = f"{base_url.rstrip('/')}{path}"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode()
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise ApiError(resp.status, "Invalid API response") from exc
except urllib.error.HTTPError as e:
try:
detail = json.loads(e.read().decode()).get("detail", str(e))
except (json.JSONDecodeError, ValueError):
detail = str(e)
raise ApiError(e.code, detail) from e
except urllib.error.URLError as e:
raise ApiError(0, "Cannot reach API") from e
def _api_post(base_url: str, path: str, token: str, body: dict | None = None) -> dict:
url = f"{base_url.rstrip('/')}{path}"
encoded = None
headers = {"Accept": "application/json", "Authorization": f"Bearer {token}"}
if body is not None:
encoded = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=encoded, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode()
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise ApiError(resp.status, "Invalid API response") from exc
except urllib.error.HTTPError as e:
try:
detail = json.loads(e.read().decode()).get("detail", str(e))
except (json.JSONDecodeError, ValueError):
detail = str(e)
raise ApiError(e.code, detail) from e
except urllib.error.URLError as e:
raise ApiError(0, "Cannot reach API") from e
# --- 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
def format_track(track: dict) -> str:
pos = track.get("position", 0)
title = track.get("title", "")
artist = track.get("artist", "")
url = track.get("permalink_url", "")
return f"Song #{pos}: {title} by {artist} - {url}"
def format_playlist(data: dict) -> str:
episode = data.get("episode_number", "?")
tracks = data.get("tracks", [])
count = len(tracks)
prefix = f"Episode {episode} ({count} tracks): "
parts: list[str] = []
length = len(prefix)
for t in tracks:
entry = f"{t.get('title', '')} by {t.get('artist', '')}"
sep = ", " if parts else ""
if length + len(sep) + len(entry) + 5 > _MAX_IRC_LINE: # +5 for ", ..."
parts.append("...")
break
parts.append(entry)
length += len(sep) + len(entry)
return prefix + ", ".join(parts)
# --- Plugin ------------------------------------------------------------------
_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:
return False
return nick.lower() in [n.lower() for n in admin_nicks]
def doPrivmsg(self, irc, msg):
channel = msg.args[0] if msg.args else None
if not channel or not irc.isChannel(channel):
return
text = msg.args[1] if len(msg.args) > 1 else ""
match = _NUMBER_RE.match(text)
if match:
position = match.group(1)
base_url = self.registryValue("apiBaseUrl")
try:
data = _api_get(base_url, f"/playlist/{position}")
irc.reply(format_track(data))
except ApiError as exc:
LOGGER.warning("API error for !%s: %s", position, exc)
irc.reply(exc.detail)
@wrap([optional("text")])
def song(self, irc, msg, args, text):
"""<episode> <position>
Returns a track from a specific episode's playlist.
"""
if not text:
irc.reply("Usage: !song <episode> <position>")
return
parts = text.strip().split()
if len(parts) < 2:
irc.reply("Usage: !song <episode> <position>")
return
try:
episode, position = int(parts[0]), int(parts[1])
except ValueError:
irc.reply("Usage: !song <episode> <position>")
return
base_url = self.registryValue("apiBaseUrl")
try:
data = _api_get(base_url, f"/shows/by-episode/{episode}")
except ApiError as exc:
LOGGER.warning("API error for !song %s %s: %s", episode, position, 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:
irc.reply(f"No track at position {position} in episode {episode}")
return
irc.reply(format_track(track))
@wrap([optional("text")])
def playlist(self, irc, msg, args, text):
"""[<episode>]
Returns the playlist for the current show, or a specific episode.
"""
base_url = self.registryValue("apiBaseUrl")
if text and text.strip():
try:
episode = int(text.strip())
except ValueError:
irc.reply("Usage: !playlist [episode]")
return
try:
data = _api_get(base_url, f"/shows/by-episode/{episode}")
except ApiError as exc:
LOGGER.warning("API error for playlist: %s", exc)
irc.reply(exc.detail)
return
else:
try:
data = _api_get(base_url, "/playlist")
except ApiError as exc:
LOGGER.warning("API error for playlist: %s", exc)
irc.reply(exc.detail)
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
Returns the current API status.
"""
base_url = self.registryValue("apiBaseUrl")
try:
data = _api_get(base_url, "/health")
except ApiError as exc:
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 = 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}"
)
@wrap
def refresh(self, irc, msg, args):
"""takes no arguments
Triggers a manual playlist refresh. Admin only.
"""
if not self._is_admin(msg.nick):
irc.reply("Access denied")
return
token = self.registryValue("adminToken")
if not token:
irc.reply("Admin token not configured")
return
base_url = self.registryValue("apiBaseUrl")
try:
data = _api_post(base_url, "/admin/refresh", token)
except ApiError as exc:
LOGGER.warning("API error for refresh: %s", exc)
irc.reply(f"Refresh failed: {exc.detail}")
return
count = data.get("track_count", 0)
irc.reply(f"Refreshed — {count} tracks")
Class = NtrPlaylist

View File

@@ -0,0 +1,5 @@
from supybot.test import PluginTestCase
class NtrPlaylistTestCase(PluginTestCase):
plugins = ("NtrPlaylist",)

View File

@@ -0,0 +1,372 @@
"""
ntr_playlist.py - Sopel plugin for NtR SoundCloud Fetcher API
"""
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
LOGGER = logging.getLogger(__name__)
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):
config.define_section("ntr_playlist", NtrPlaylistSection)
config.ntr_playlist.configure_setting("api_base_url", "API base URL:")
config.ntr_playlist.configure_setting("admin_token", "Admin token (optional):")
config.ntr_playlist.configure_setting("admin_nicks", "Admin nicks (comma-separated):")
class ApiError(Exception):
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
super().__init__(f"{status_code}: {detail}")
def _api_get(base_url: str, path: str) -> dict:
url = f"{base_url.rstrip('/')}{path}"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode()
try:
return json.loads(raw)
except (json.JSONDecodeError, ValueError) as exc:
raise ApiError(resp.status, "Invalid API response") from exc
except urllib.error.HTTPError as e:
try:
detail = json.loads(e.read().decode()).get("detail", str(e))
except (json.JSONDecodeError, ValueError):
detail = str(e)
raise ApiError(e.code, detail) from e
except urllib.error.URLError as e:
raise ApiError(0, "Cannot reach API") from e
def _api_post(base_url: str, path: str, token: str, body: dict | None = None) -> dict:
url = f"{base_url.rstrip('/')}{path}"
encoded = None
headers = {"Accept": "application/json", "Authorization": f"Bearer {token}"}
if body is not None:
encoded = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=encoded, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode()
try:
return json.loads(raw)
except (json.JSONDecodeError, ValueError) as exc:
raise ApiError(resp.status, "Invalid API response") from exc
except urllib.error.HTTPError as e:
try:
detail = json.loads(e.read().decode()).get("detail", str(e))
except (json.JSONDecodeError, ValueError):
detail = str(e)
raise ApiError(e.code, detail) from e
except urllib.error.URLError as e:
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", "")
artist = track.get("artist", "")
url = track.get("permalink_url", "")
return f"Song #{pos}: {title} by {artist} - {url}"
_MAX_IRC_LINE = 430
def format_playlist(data: dict) -> str:
episode = data.get("episode_number", "?")
tracks = data.get("tracks", [])
count = len(tracks)
prefix = f"Episode {episode} ({count} tracks): "
parts: list[str] = []
length = len(prefix)
for t in tracks:
entry = f"{t.get('title', '')} by {t.get('artist', '')}"
sep = ", " if parts else ""
if length + len(sep) + len(entry) + 5 > _MAX_IRC_LINE: # +5 for ", ..."
parts.append("...")
break
parts.append(entry)
length += len(sep) + len(entry)
return prefix + ", ".join(parts)
def _is_admin(bot, nick: str) -> bool:
admin_nicks = bot.settings.ntr_playlist.admin_nicks
if not admin_nicks:
return False
return nick.lower() in [n.lower() for n in admin_nicks]
@plugin.rule(r"^!(\d+)$")
def ntr_playlist_position(bot, trigger):
base_url = bot.settings.ntr_playlist.api_base_url
position = trigger.group(1)
try:
data = _api_get(base_url, f"/playlist/{position}")
bot.say(format_track(data))
except ApiError as e:
LOGGER.warning("API error for !%s: %s", position, e)
bot.say(e.detail)
@plugin.command("song")
def ntr_song(bot, trigger):
raw = trigger.group(2)
if not raw or not raw.strip():
bot.say("Usage: !song <episode> <position>")
return
parts = raw.strip().split()
if len(parts) < 2:
bot.say("Usage: !song <episode> <position>")
return
try:
episode = int(parts[0])
position = int(parts[1])
except ValueError:
bot.say("Usage: !song <episode> <position>")
return
base_url = bot.settings.ntr_playlist.api_base_url
try:
data = _api_get(base_url, f"/shows/by-episode/{episode}")
except ApiError as e:
LOGGER.warning("API error for !song %s %s: %s", episode, position, 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:
bot.say(f"No track at position {position} in episode {episode}")
return
bot.say(format_track(track))
@plugin.command("playlist")
def ntr_playlist(bot, trigger):
raw = trigger.group(2)
base_url = bot.settings.ntr_playlist.api_base_url
if raw and raw.strip():
try:
episode = int(raw.strip())
except ValueError:
bot.say("Usage: !playlist [episode]")
return
try:
data = _api_get(base_url, f"/shows/by-episode/{episode}")
except ApiError as e:
LOGGER.warning("API error for !playlist: %s", e)
bot.say(e.detail)
return
else:
try:
data = _api_get(base_url, "/playlist")
except ApiError as e:
LOGGER.warning("API error for !playlist: %s", e)
bot.say(e.detail)
return
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
try:
data = _api_get(base_url, "/health")
except ApiError as e:
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 = 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}")
@plugin.command("refresh")
def ntr_refresh(bot, trigger):
if not _is_admin(bot, trigger.nick):
bot.say("Access denied")
return
token = bot.settings.ntr_playlist.admin_token
if not token:
bot.say("Admin token not configured")
return
base_url = bot.settings.ntr_playlist.api_base_url
try:
data = _api_post(base_url, "/admin/refresh", token)
except ApiError as e:
LOGGER.warning("API error for !refresh: %s", e)
bot.say(f"Refresh failed: {e.detail}")
return
count = data.get("track_count", 0)
bot.say(f"Refreshed — {count} tracks")

View File

@@ -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")
@@ -89,6 +92,20 @@ def create_app(
for s in shows
]
@app.get("/shows/by-episode/{episode_number}")
def show_by_episode(episode_number: int):
show = db.get_show_by_episode_number(episode_number)
if show is None:
raise HTTPException(status_code=404, detail=f"No show with episode number {episode_number}")
tracks = db.get_show_tracks(show.id)
return {
"show_id": show.id,
"episode_number": show.episode_number,
"week_start": show.week_start.isoformat(),
"week_end": show.week_end.isoformat(),
"tracks": tracks,
}
@app.get("/shows/{show_id}")
def show_detail(show_id: int):
shows = db.list_shows(limit=1000, offset=0)
@@ -133,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

View File

@@ -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])

View 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

View File

@@ -239,6 +239,24 @@ class Database:
for row in rows
]
def get_show_by_episode_number(self, episode_number: int) -> Show | None:
conn = self._connect()
row = conn.execute(
"SELECT id, week_start, week_end, created_at, episode_number "
"FROM shows WHERE episode_number = ? LIMIT 1",
(episode_number,),
).fetchone()
conn.close()
if row is None:
return None
return Show(
id=row["id"],
week_start=datetime.fromisoformat(row["week_start"]),
week_end=datetime.fromisoformat(row["week_end"]),
created_at=datetime.fromisoformat(row["created_at"]),
episode_number=row["episode_number"],
)
def get_latest_episode_number(self) -> int | None:
conn = self._connect()
row = conn.execute(

View File

@@ -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")

View 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>

View 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>

View 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,
})

View File

@@ -122,3 +122,33 @@ def test_admin_remove_track(client, db):
assert resp.status_code == 200
tracks = db.get_show_tracks(show.id)
assert len(tracks) == 1
def test_show_by_episode(client, 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, episode_number=530)
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])
resp = client.get("/shows/by-episode/530")
assert resp.status_code == 200
data = resp.json()
assert data["episode_number"] == 530
assert len(data["tracks"]) == 1
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

View File

@@ -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
View 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()

View File

@@ -272,6 +272,20 @@ def test_update_show_episode_number(db):
assert show2.episode_number == 521
def test_get_show_by_episode_number(db):
week_start = datetime(2026, 1, 8, 3, 0, 0, tzinfo=timezone.utc)
week_end = datetime(2026, 1, 15, 3, 0, 0, tzinfo=timezone.utc)
show = db.get_or_create_show(week_start, week_end, episode_number=521)
result = db.get_show_by_episode_number(521)
assert result is not None
assert result.id == show.id
assert result.episode_number == 521
def test_get_show_by_episode_number_missing(db):
assert db.get_show_by_episode_number(999) is None
def test_has_track_in_show(db):
week_start = datetime(2026, 3, 13, 2, 0, 0, tzinfo=timezone.utc)
week_end = datetime(2026, 3, 20, 2, 0, 0, tzinfo=timezone.utc)

57
tests/test_format_dt.py Normal file
View 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

View File

@@ -0,0 +1,265 @@
"""Tests for IRC plugin helper functions.
The pure logic (formatting, API client, admin check) is duplicated across both
plugins. We duplicate the functions here to test without framework dependencies.
"""
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from threading import Thread
import urllib.error
import urllib.request
import pytest
# ---------------------------------------------------------------------------
# Duplicated pure functions under test
# Keep in sync with plugins/sopel/ntr_playlist.py and
# plugins/limnoria/NtrPlaylist/plugin.py
# ---------------------------------------------------------------------------
class ApiError(Exception):
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
super().__init__(f"{status_code}: {detail}")
def _api_get(base_url: str, path: str) -> dict:
url = f"{base_url.rstrip("/")}{path}"
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode()
try:
return json.loads(raw)
except (json.JSONDecodeError, ValueError) as exc:
raise ApiError(resp.status, "Invalid API response") from exc
except urllib.error.HTTPError as e:
try:
detail = json.loads(e.read().decode()).get("detail", str(e))
except (json.JSONDecodeError, ValueError):
detail = str(e)
raise ApiError(e.code, detail) from e
except urllib.error.URLError as e:
raise ApiError(0, "Cannot reach API") from e
def _api_post(base_url: str, path: str, token: str, body: dict | None = None) -> dict:
url = f"{base_url.rstrip("/")}{path}"
encoded = None
headers = {"Accept": "application/json", "Authorization": f"Bearer {token}"}
if body is not None:
encoded = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=encoded, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read().decode()
try:
return json.loads(raw)
except (json.JSONDecodeError, ValueError) as exc:
raise ApiError(resp.status, "Invalid API response") from exc
except urllib.error.HTTPError as e:
try:
detail = json.loads(e.read().decode()).get("detail", str(e))
except (json.JSONDecodeError, ValueError):
detail = str(e)
raise ApiError(e.code, detail) from e
except urllib.error.URLError as e:
raise ApiError(0, "Cannot reach API") from e
_MAX_IRC_LINE = 430
def format_track(track: dict) -> str:
pos = track.get("position", 0)
title = track.get("title", "")
artist = track.get("artist", "")
url = track.get("permalink_url", "")
return f"Song #{pos}: {title} by {artist} - {url}"
def format_playlist(data: dict) -> str:
episode = data.get("episode_number", "?")
tracks = data.get("tracks", [])
count = len(tracks)
prefix = f"Episode {episode} ({count} tracks): "
parts: list[str] = []
length = len(prefix)
for t in tracks:
entry = f"{t.get('title', '')} by {t.get('artist', '')}"
sep = ", " if parts else ""
if length + len(sep) + len(entry) + 5 > _MAX_IRC_LINE: # +5 for ", ..."
parts.append("...")
break
parts.append(entry)
length += len(sep) + len(entry)
return prefix + ", ".join(parts)
# ---------------------------------------------------------------------------
# Test HTTP server
# ---------------------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler):
response_code = 200
response_body = "{}"
last_headers: dict = {}
def do_GET(self):
self._respond()
def do_POST(self):
_Handler.last_headers = dict(self.headers)
# Consume request body so client connection stays valid
content_length = int(self.headers.get("Content-Length", 0))
if content_length:
self.rfile.read(content_length)
self._respond()
def _respond(self):
self.send_response(self.response_code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(self.response_body.encode())
def log_message(self, *_args):
pass
@pytest.fixture()
def mock_server():
server = HTTPServer(("127.0.0.1", 0), _Handler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
host, port = server.server_address
yield f"http://{host}:{port}", server
server.shutdown()
# ---------------------------------------------------------------------------
# format_track
# ---------------------------------------------------------------------------
class TestFormatTrack:
def test_basic(self):
track = {
"position": 3,
"title": "Night Drive",
"artist": "SomeArtist",
"permalink_url": "https://soundcloud.com/someartist/night-drive",
}
assert format_track(track) == (
"Song #3: Night Drive by SomeArtist"
" - https://soundcloud.com/someartist/night-drive"
)
def test_position_1(self):
track = {
"position": 1,
"title": "A",
"artist": "B",
"permalink_url": "https://example.com",
}
assert format_track(track).startswith("Song #1:")
def test_missing_fields_uses_defaults(self):
result = format_track({})
assert result == "Song #0: by - "
# ---------------------------------------------------------------------------
# format_playlist
# ---------------------------------------------------------------------------
class TestFormatPlaylist:
def test_with_tracks(self):
data = {
"episode_number": 530,
"tracks": [
{"title": "Night Drive", "artist": "SomeArtist"},
{"title": "Running", "artist": "Purrple Panther"},
],
}
assert format_playlist(data) == (
"Episode 530 (2 tracks): "
"Night Drive by SomeArtist, Running by Purrple Panther"
)
def test_empty_tracks(self):
data = {"episode_number": 530, "tracks": []}
assert format_playlist(data) == "Episode 530 (0 tracks): "
def test_missing_episode(self):
data = {"tracks": [{"title": "A", "artist": "B"}]}
assert format_playlist(data).startswith("Episode ?")
def test_truncation_many_tracks(self):
tracks = [
{"title": f"Track{i:03d} With A Longer Name", "artist": f"Artist{i:03d}"}
for i in range(50)
]
data = {"episode_number": 999, "tracks": tracks}
result = format_playlist(data)
assert len(result) <= _MAX_IRC_LINE
assert result.endswith("...")
def test_truncation_long_single_track(self):
tracks = [
{"title": "A" * 200, "artist": "B" * 200},
{"title": "Second", "artist": "Track"},
]
data = {"episode_number": 1, "tracks": tracks}
result = format_playlist(data)
assert len(result) <= _MAX_IRC_LINE
# ---------------------------------------------------------------------------
# _api_get
# ---------------------------------------------------------------------------
class TestApiGet:
def test_success(self, mock_server):
base_url, _ = mock_server
_Handler.response_code = 200
_Handler.response_body = json.dumps({"title": "ok"})
assert _api_get(base_url, "/test") == {"title": "ok"}
def test_404_with_detail(self, mock_server):
base_url, _ = mock_server
_Handler.response_code = 404
_Handler.response_body = json.dumps({"detail": "Not found"})
with pytest.raises(ApiError) as exc_info:
_api_get(base_url, "/missing")
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Not found"
def test_unreachable(self):
with pytest.raises(ApiError) as exc_info:
_api_get("http://127.0.0.1:1", "/nope")
assert exc_info.value.status_code == 0
assert exc_info.value.detail == "Cannot reach API"
# ---------------------------------------------------------------------------
# _api_post
# ---------------------------------------------------------------------------
class TestApiPost:
def test_success_with_token(self, mock_server):
base_url, _ = mock_server
_Handler.response_code = 200
_Handler.response_body = json.dumps({"status": "refreshed"})
result = _api_post(base_url, "/admin/refresh", "my-token")
assert result == {"status": "refreshed"}
assert _Handler.last_headers.get("Authorization") == "Bearer my-token"
def test_post_with_body(self, mock_server):
base_url, _ = mock_server
_Handler.response_code = 200
_Handler.response_body = json.dumps({"ok": True})
result = _api_post(base_url, "/endpoint", "tok", body={"full": True})
assert result == {"ok": True}

100
tests/test_websocket.py Normal file
View 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"