Compare commits
27 Commits
cb3ae403cf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b353f606e5
|
||
|
|
8d9d565c04
|
||
|
|
0f99e7914b
|
||
|
|
911dd3d5dd
|
||
|
|
f244749293
|
||
|
|
f6840a777c
|
||
|
|
d6d5ac10e6
|
||
|
|
658c0d4a15 | ||
|
|
e31a9503db
|
||
|
|
a7849e6cd9
|
||
|
|
92136f0508
|
||
|
|
e90f44439b
|
||
|
|
788225b3b6
|
||
|
|
7ed7ace578
|
||
|
|
e5c06a2f67
|
||
|
|
47a78b09a7
|
||
|
|
9664b8225d
|
||
|
|
ae66242935
|
||
|
|
359a11dd4a
|
||
|
|
03ce201a47
|
||
|
|
05bcf184ac
|
||
|
|
b63c851d14
|
||
|
|
5c227766f1
|
||
|
|
6dd7aee2f2
|
||
|
|
2a00cc263f
|
||
|
|
1d08580a45
|
||
|
|
b529edecc3
|
27
.gitignore
vendored
27
.gitignore
vendored
@@ -1,5 +1,28 @@
|
|||||||
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.db
|
|
||||||
.env
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.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
21
LICENSE
Normal 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.
|
||||||
77
README.md
77
README.md
@@ -1,3 +1,6 @@
|
|||||||
|
> [!IMPORTANT]
|
||||||
|
> This project was developed entirely with AI coding assistance (Claude Opus 4.6 via Cursor IDE) and has not undergone rigorous review. It is provided as-is and may require adjustments for other environments.
|
||||||
|
|
||||||
# NtR SoundCloud Fetcher
|
# NtR SoundCloud Fetcher
|
||||||
|
|
||||||
Fetches SoundCloud likes from NicktheRat's profile, builds weekly playlists
|
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` | GET | -- | Current week's playlist |
|
||||||
| `/playlist/{position}` | GET | -- | Single track by position (1-indexed) |
|
| `/playlist/{position}` | GET | -- | Single track by position (1-indexed) |
|
||||||
| `/shows` | GET | -- | List all shows (paginated) |
|
| `/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/refresh` | POST | Bearer | Trigger immediate SoundCloud fetch |
|
||||||
| `/admin/tracks` | POST | Bearer | Add track to current show |
|
| `/admin/tracks` | POST | Bearer | Add track to current show |
|
||||||
| `/admin/tracks/{track_id}` | DELETE | Bearer | Remove track from 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/tracks/{track_id}/position` | PUT | Bearer | Move track to new position |
|
||||||
|
| `/admin/announce` | POST | Bearer/Session | Announce track to IRC |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -58,6 +66,73 @@ Environment variables (prefix `NTR_`):
|
|||||||
| `NTR_SHOW_DAY` | `2` | Day of week for show (0=Mon, 2=Wed) |
|
| `NTR_SHOW_DAY` | `2` | Day of week for show (0=Mon, 2=Wed) |
|
||||||
| `NTR_SHOW_HOUR` | `22` | Hour (Eastern Time) when the show starts |
|
| `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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
165
docs/api.md
165
docs/api.md
@@ -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}`
|
### `GET /shows/{show_id}`
|
||||||
|
|
||||||
Returns a specific show with its full track listing.
|
Returns a specific show by internal database ID.
|
||||||
|
|
||||||
**Path Parameters**
|
**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
|
## Admin Endpoints
|
||||||
|
|
||||||
All admin endpoints require a bearer token via the `Authorization` header:
|
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
|
## Track Object
|
||||||
|
|
||||||
Returned inside playlist and show detail responses.
|
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 |
|
| EST (Nov -- Mar) | Wed 22:00 | Thu 03:00 |
|
||||||
| EDT (Mar -- Nov) | Wed 22:00 | Thu 02: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.
|
||||||
|
|||||||
157
docs/plans/2026-03-12-irc-plugins-design.md
Normal file
157
docs/plans/2026-03-12-irc-plugins-design.md
Normal 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.
|
||||||
758
docs/plans/2026-03-12-irc-plugins-implementation.md
Normal file
758
docs/plans/2026-03-12-irc-plugins-implementation.md
Normal 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"
|
||||||
|
```
|
||||||
168
docs/plans/2026-03-12-live-announce-dashboard-design.md
Normal file
168
docs/plans/2026-03-12-live-announce-dashboard-design.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Live Announce Dashboard — Design Document
|
||||||
|
|
||||||
|
> **Date**: 2026-03-12
|
||||||
|
> **Status**: Approved
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
A web dashboard that lets Nick announce tracks to IRC in real-time during his live show. He sees the current and previous week's playlists, clicks "Announce" next to a track, and the IRC bot immediately emits the announcement to `#sewerchat` (configurable). No one in chat needs to request it — the host pushes announcements on his own schedule.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Communication: WebSocket hub
|
||||||
|
|
||||||
|
The API gains a WebSocket endpoint at `/ws/announce`. IRC bot plugins (Sopel and Limnoria) connect as subscribers. When Nick clicks "Announce" on the dashboard, the web page POSTs to `/admin/announce`, the API formats the message and broadcasts it to all connected WS subscribers, and each bot emits it to the configured channel.
|
||||||
|
|
||||||
|
```
|
||||||
|
Nick's browser API server IRC bot plugin
|
||||||
|
| | |
|
||||||
|
| POST /admin/announce | |
|
||||||
|
| {show_id, position} | |
|
||||||
|
|--------------------------->| |
|
||||||
|
| | WS: {"type":"announce", |
|
||||||
|
| | "message":"Now Playing:|
|
||||||
|
| | Song #3: ..."} |
|
||||||
|
| |------------------------->|
|
||||||
|
| | |
|
||||||
|
| 200 {"status":"announced"}| bot.say(message)
|
||||||
|
|<---------------------------| -> #sewerchat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why POST instead of WS for sending
|
||||||
|
|
||||||
|
The dashboard authenticates via session cookie. A plain POST with the existing session is simpler than mixing cookie auth with a WebSocket send channel. HTTP round-trip is <50ms, broadcast is instant.
|
||||||
|
|
||||||
|
### Reconnection
|
||||||
|
|
||||||
|
Bot plugins auto-reconnect with exponential backoff (5s, 10s, 20s, capped at 60s). Announcements during disconnection are lost. Nick can see bot connection status on the dashboard and re-announce if needed.
|
||||||
|
|
||||||
|
## Authentication & Sessions
|
||||||
|
|
||||||
|
### Login flow
|
||||||
|
|
||||||
|
1. Nick visits `/dashboard` — no valid session cookie → redirect to `/login`.
|
||||||
|
2. `/login` serves a styled HTML login form.
|
||||||
|
3. Form POSTs to `/login` with username + password.
|
||||||
|
4. Server validates against `NTR_WEB_USER` / `NTR_WEB_PASSWORD` env vars. Success → signed session cookie (`ntr_session`) + redirect to `/dashboard`. Failure → re-render login with error.
|
||||||
|
5. Cookie signed with `NTR_SECRET_KEY`. HTTPOnly, SameSite=Lax.
|
||||||
|
6. `/logout` clears the cookie, redirects to `/login`.
|
||||||
|
|
||||||
|
### Session expiry
|
||||||
|
|
||||||
|
24 hours.
|
||||||
|
|
||||||
|
### New config values
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `NTR_WEB_USER` | *(required if dashboard enabled)* | Dashboard login username |
|
||||||
|
| `NTR_WEB_PASSWORD` | *(required if dashboard enabled)* | Dashboard login password |
|
||||||
|
| `NTR_SECRET_KEY` | *(required if dashboard enabled)* | Cookie signing key |
|
||||||
|
|
||||||
|
### Dashboard-optional
|
||||||
|
|
||||||
|
If these three env vars are absent, the dashboard routes are not mounted. The API works exactly as before.
|
||||||
|
|
||||||
|
## Web Dashboard UI
|
||||||
|
|
||||||
|
Single page at `/dashboard`, no client-side routing.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
- **Header**: "NtR Playlist Dashboard" + connection status indicator (green dot = bots connected, gray = none) + logout link.
|
||||||
|
- **Current Show**: Episode number, date range, track count. Track table with columns: `#`, `Title`, `Artist`, `Link`, `Announce` button. Button flashes checkmark on success, shows error toast on failure.
|
||||||
|
- **Previous Show**: Same layout, collapsed by default (click to expand). Same announce functionality.
|
||||||
|
|
||||||
|
### Connection status
|
||||||
|
|
||||||
|
The dashboard opens a read-only WebSocket to `/ws/announce` (authenticated via token query param from the session). The API broadcasts `{"type": "status", "subscribers": N}` on connect/disconnect events. Zero subscribers → buttons show "No bots connected" warning.
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
Pico CSS via CDN. Dark theme. Minimal custom CSS for button states and status indicator. Responsive for phone use.
|
||||||
|
|
||||||
|
### No JS framework
|
||||||
|
|
||||||
|
Vanilla `fetch()` for announce POST, vanilla `WebSocket` for status. Total JS under 100 lines.
|
||||||
|
|
||||||
|
## Announcement Format
|
||||||
|
|
||||||
|
```
|
||||||
|
Now Playing: Song #3: Running Through My Mind by Purrple Panther - https://soundcloud.com/...
|
||||||
|
```
|
||||||
|
|
||||||
|
Format logic lives in the API (`/admin/announce` endpoint), not in the bot plugins.
|
||||||
|
|
||||||
|
## Bot Plugin Changes
|
||||||
|
|
||||||
|
Both Sopel and Limnoria plugins gain identical behavior, maintaining 1:1 feature parity.
|
||||||
|
|
||||||
|
### New config values
|
||||||
|
|
||||||
|
| Limnoria (camelCase) | Sopel (snake_case) | Default | Description |
|
||||||
|
|----------------------|--------------------|---------|-------------|
|
||||||
|
| `wsUrl` | `ws_url` | `ws://127.0.0.1:8000/ws/announce` | WebSocket endpoint |
|
||||||
|
| `announceChannel` | `announce_channel` | `#sewerchat` | Channel for announcements |
|
||||||
|
|
||||||
|
### WebSocket client lifecycle
|
||||||
|
|
||||||
|
1. On plugin load, spawn a background daemon thread connecting to the WS endpoint.
|
||||||
|
2. Send `{"type": "subscribe", "token": "<admin_token>"}` on connect.
|
||||||
|
3. On `{"type": "announce", "message": "..."}`, send the message to the configured channel via `irc.queueMsg` (Limnoria) / `bot.say` (Sopel).
|
||||||
|
4. On disconnect, reconnect with exponential backoff (5s → 60s cap).
|
||||||
|
5. On plugin unload, close the connection cleanly.
|
||||||
|
|
||||||
|
### Threading
|
||||||
|
|
||||||
|
WS client runs in a dedicated daemon thread using the `websocket-client` library (synchronous, blocking). `bot.say()` and `irc.queueMsg()` are thread-safe in both frameworks.
|
||||||
|
|
||||||
|
### New dependency
|
||||||
|
|
||||||
|
`websocket-client` — listed in each plugin's docs, not in the main `pyproject.toml` (plugins deploy independently).
|
||||||
|
|
||||||
|
### No changes to existing commands
|
||||||
|
|
||||||
|
`!N`, `!playlist`, `!song`, `!lastshow`, `!status`, `!refresh` all work as before.
|
||||||
|
|
||||||
|
## API Endpoints (New)
|
||||||
|
|
||||||
|
| Method | Path | Auth | Description |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| `GET` | `/login` | none | Login page |
|
||||||
|
| `POST` | `/login` | none | Validate credentials, set session |
|
||||||
|
| `GET` | `/logout` | session | Clear session, redirect |
|
||||||
|
| `GET` | `/dashboard` | session | Dashboard page |
|
||||||
|
| `POST` | `/admin/announce` | bearer OR session | Broadcast track announcement |
|
||||||
|
| `WS` | `/ws/announce` | token in first message | Bot subscriber + dashboard status |
|
||||||
|
|
||||||
|
### `POST /admin/announce`
|
||||||
|
|
||||||
|
Request: `{"show_id": 10, "position": 3}`
|
||||||
|
|
||||||
|
Response: `{"status": "announced", "message": "Now Playing: Song #3: ..."}`
|
||||||
|
|
||||||
|
The API looks up the track, formats the message, and broadcasts to all WS subscribers.
|
||||||
|
|
||||||
|
## New Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/ntr_fetcher/dashboard.py` | FastAPI router: login, logout, dashboard, announce |
|
||||||
|
| `src/ntr_fetcher/websocket.py` | WebSocket manager: subscriber tracking, broadcast |
|
||||||
|
| `src/ntr_fetcher/static/dashboard.html` | Dashboard page (HTML + inline JS + CSS) |
|
||||||
|
| `src/ntr_fetcher/static/login.html` | Login page |
|
||||||
|
|
||||||
|
## Modified Files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/ntr_fetcher/config.py` | Add `web_user`, `web_password`, `secret_key` (optional) |
|
||||||
|
| `src/ntr_fetcher/api.py` | Mount dashboard router + WS if dashboard config present |
|
||||||
|
| `src/ntr_fetcher/main.py` | Pass new config to `create_app` |
|
||||||
|
| `plugins/limnoria/NtrPlaylist/plugin.py` | WS client thread + announce channel |
|
||||||
|
| `plugins/limnoria/NtrPlaylist/config.py` | `wsUrl`, `announceChannel` registry values |
|
||||||
|
| `plugins/sopel/ntr_playlist.py` | WS client thread + announce channel |
|
||||||
|
|
||||||
|
## Unchanged Files
|
||||||
|
|
||||||
|
`db.py`, `models.py`, `soundcloud.py`, `poller.py`, `week.py` — no modifications.
|
||||||
1261
docs/plans/2026-03-12-live-announce-dashboard-impl.md
Normal file
1261
docs/plans/2026-03-12-live-announce-dashboard-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
10
plugins/limnoria/NtrPlaylist/__init__.py
Normal file
10
plugins/limnoria/NtrPlaylist/__init__.py
Normal 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"
|
||||||
72
plugins/limnoria/NtrPlaylist/config.py
Normal file
72
plugins/limnoria/NtrPlaylist/config.py
Normal 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.""",
|
||||||
|
),
|
||||||
|
)
|
||||||
385
plugins/limnoria/NtrPlaylist/plugin.py
Normal file
385
plugins/limnoria/NtrPlaylist/plugin.py
Normal 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
|
||||||
5
plugins/limnoria/NtrPlaylist/test.py
Normal file
5
plugins/limnoria/NtrPlaylist/test.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from supybot.test import PluginTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class NtrPlaylistTestCase(PluginTestCase):
|
||||||
|
plugins = ("NtrPlaylist",)
|
||||||
372
plugins/sopel/ntr_playlist.py
Normal file
372
plugins/sopel/ntr_playlist.py
Normal 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")
|
||||||
@@ -29,6 +29,9 @@ def create_app(
|
|||||||
admin_token: str,
|
admin_token: str,
|
||||||
show_day: int = 2,
|
show_day: int = 2,
|
||||||
show_hour: int = 22,
|
show_hour: int = 22,
|
||||||
|
web_user: str | None = None,
|
||||||
|
web_password: str | None = None,
|
||||||
|
secret_key: str | None = None,
|
||||||
) -> FastAPI:
|
) -> FastAPI:
|
||||||
app = FastAPI(title="NtR SoundCloud Fetcher")
|
app = FastAPI(title="NtR SoundCloud Fetcher")
|
||||||
|
|
||||||
@@ -89,6 +92,20 @@ def create_app(
|
|||||||
for s in shows
|
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}")
|
@app.get("/shows/{show_id}")
|
||||||
def show_detail(show_id: int):
|
def show_detail(show_id: int):
|
||||||
shows = db.list_shows(limit=1000, offset=0)
|
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)
|
db.move_show_track(show.id, track_id, body.position)
|
||||||
return {"status": "moved"}
|
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
|
return app
|
||||||
|
|||||||
@@ -12,3 +12,11 @@ class Settings(BaseSettings):
|
|||||||
soundcloud_user: str = "nicktherat"
|
soundcloud_user: str = "nicktherat"
|
||||||
show_day: int = 2
|
show_day: int = 2
|
||||||
show_hour: int = 22
|
show_hour: int = 22
|
||||||
|
|
||||||
|
web_user: str | None = None
|
||||||
|
web_password: str | None = None
|
||||||
|
secret_key: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dashboard_enabled(self) -> bool:
|
||||||
|
return all([self.web_user, self.web_password, self.secret_key])
|
||||||
|
|||||||
153
src/ntr_fetcher/dashboard.py
Normal file
153
src/ntr_fetcher/dashboard.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, HTTPException, Form, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ntr_fetcher.db import Database
|
||||||
|
from ntr_fetcher.websocket import AnnounceManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
SESSION_MAX_AGE = 86400
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(data: str, secret: str) -> str:
|
||||||
|
sig = hmac.new(secret.encode(), data.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return f"{data}.{sig}"
|
||||||
|
|
||||||
|
|
||||||
|
def _unsign(signed: str, secret: str, max_age: int = SESSION_MAX_AGE) -> str | None:
|
||||||
|
if "." not in signed:
|
||||||
|
return None
|
||||||
|
data, sig = signed.rsplit(".", 1)
|
||||||
|
expected = hmac.new(secret.encode(), data.encode(), hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(sig, expected):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(data)
|
||||||
|
if time.time() - payload.get("t", 0) > max_age:
|
||||||
|
return None
|
||||||
|
return payload.get("user")
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AnnounceRequest(BaseModel):
|
||||||
|
show_id: int
|
||||||
|
position: int
|
||||||
|
|
||||||
|
|
||||||
|
def create_dashboard_router(
|
||||||
|
db: Database,
|
||||||
|
manager: AnnounceManager,
|
||||||
|
admin_token: str,
|
||||||
|
web_user: str,
|
||||||
|
web_password: str,
|
||||||
|
secret_key: str,
|
||||||
|
show_day: int = 2,
|
||||||
|
show_hour: int = 22,
|
||||||
|
) -> APIRouter:
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
def _get_session_user(request: Request) -> str | None:
|
||||||
|
cookie = request.cookies.get("ntr_session")
|
||||||
|
if not cookie:
|
||||||
|
return None
|
||||||
|
return _unsign(cookie, secret_key)
|
||||||
|
|
||||||
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
|
def login_page():
|
||||||
|
html = (STATIC_DIR / "login.html").read_text()
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def login_submit(username: str = Form(...), password: str = Form(...)):
|
||||||
|
if username == web_user and password == web_password:
|
||||||
|
payload = json.dumps({"user": username, "t": int(time.time())})
|
||||||
|
cookie_value = _sign(payload, secret_key)
|
||||||
|
response = RedirectResponse(url="/dashboard", status_code=303)
|
||||||
|
response.set_cookie(
|
||||||
|
"ntr_session",
|
||||||
|
cookie_value,
|
||||||
|
max_age=SESSION_MAX_AGE,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
html = (STATIC_DIR / "login.html").read_text()
|
||||||
|
html = html.replace("<!--ERROR-->", '<p class="error">Invalid username or password</p>')
|
||||||
|
return HTMLResponse(html, status_code=200)
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
def logout():
|
||||||
|
response = RedirectResponse(url="/login", status_code=303)
|
||||||
|
response.delete_cookie("ntr_session")
|
||||||
|
return response
|
||||||
|
|
||||||
|
@router.get("/dashboard", response_class=HTMLResponse)
|
||||||
|
def dashboard(request: Request):
|
||||||
|
user = _get_session_user(request)
|
||||||
|
if user is None:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
html = (STATIC_DIR / "dashboard.html").read_text()
|
||||||
|
html = html.replace("{{WS_TOKEN}}", admin_token)
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
@router.post("/admin/announce")
|
||||||
|
async def announce(body: AnnounceRequest, request: Request):
|
||||||
|
user = _get_session_user(request)
|
||||||
|
if user is None:
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if not auth_header.startswith("Bearer ") or auth_header.removeprefix("Bearer ") != admin_token:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
track = db.get_show_track_by_position(body.show_id, body.position)
|
||||||
|
if track is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"No track at position {body.position}")
|
||||||
|
|
||||||
|
message = (
|
||||||
|
f"Now Playing: Song #{track['position']}: "
|
||||||
|
f"{track['title']} by {track['artist']} - {track['permalink_url']}"
|
||||||
|
)
|
||||||
|
await manager.broadcast({"type": "announce", "message": message})
|
||||||
|
return {"status": "announced", "message": message}
|
||||||
|
|
||||||
|
@router.websocket("/ws/announce")
|
||||||
|
async def ws_announce(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
auth_msg = await websocket.receive_json()
|
||||||
|
if auth_msg.get("type") != "subscribe" or auth_msg.get("token") != admin_token:
|
||||||
|
await websocket.close(code=4001, reason="Invalid token")
|
||||||
|
return
|
||||||
|
|
||||||
|
role = auth_msg.get("role", "bot")
|
||||||
|
client_id = auth_msg.get("client_id", "")
|
||||||
|
remote_addr = ""
|
||||||
|
if websocket.client:
|
||||||
|
remote_addr = websocket.client.host or ""
|
||||||
|
|
||||||
|
manager.add_client(websocket, role=role, client_id=client_id, remote_addr=remote_addr)
|
||||||
|
await manager.broadcast_status()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
manager.remove_client(websocket)
|
||||||
|
try:
|
||||||
|
await manager.broadcast_status()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return router
|
||||||
@@ -239,6 +239,24 @@ class Database:
|
|||||||
for row in rows
|
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:
|
def get_latest_episode_number(self) -> int | None:
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ def run() -> None:
|
|||||||
admin_token=settings.admin_token,
|
admin_token=settings.admin_token,
|
||||||
show_day=settings.show_day,
|
show_day=settings.show_day,
|
||||||
show_hour=settings.show_hour,
|
show_hour=settings.show_hour,
|
||||||
|
web_user=settings.web_user,
|
||||||
|
web_password=settings.web_password,
|
||||||
|
secret_key=settings.secret_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
322
src/ntr_fetcher/static/dashboard.html
Normal file
322
src/ntr_fetcher/static/dashboard.html
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>NtR Playlist Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<style>
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.status-dot.connected { background: #4caf50; }
|
||||||
|
.status-dot.disconnected { background: #666; }
|
||||||
|
nav { display: flex; align-items: center; justify-content: space-between; padding: 1rem 0; }
|
||||||
|
nav .left { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.btn-sm { padding: 4px 14px; font-size: 0.85rem; cursor: pointer; }
|
||||||
|
.announce-btn.success { background: #4caf50; border-color: #4caf50; pointer-events: none; }
|
||||||
|
.copy-btn.copied { background: #666; border-color: #666; pointer-events: none; }
|
||||||
|
.announce-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
table { width: 100%; }
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 20px; right: 20px; padding: 12px 20px;
|
||||||
|
border-radius: 8px; background: #333; color: #fff;
|
||||||
|
display: none; z-index: 100; max-width: 350px;
|
||||||
|
}
|
||||||
|
.toast.show { display: block; }
|
||||||
|
.toast.error { background: #e53935; }
|
||||||
|
.track-num { width: 40px; text-align: center; }
|
||||||
|
.client-tag {
|
||||||
|
display: inline-block; padding: 2px 8px; margin: 2px 4px;
|
||||||
|
border-radius: 4px; background: #2a2a2a; font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
#client-detail { display: none; margin-top: 4px; }
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-bottom: 2px solid #333;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.tab-bar::-webkit-scrollbar { height: 4px; }
|
||||||
|
.tab-bar::-webkit-scrollbar-thumb { background: #555; border-radius: 2px; }
|
||||||
|
.tab {
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border-top: none;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
.tab:hover { color: #ddd; }
|
||||||
|
.tab.active {
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-color: #4caf50;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-group { display: flex; gap: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<nav>
|
||||||
|
<div class="left">
|
||||||
|
<span class="status-dot disconnected" id="status-dot"></span>
|
||||||
|
<strong>NtR Playlist Dashboard</strong>
|
||||||
|
<small id="sub-count">(connecting...)</small>
|
||||||
|
<div id="client-detail"></div>
|
||||||
|
</div>
|
||||||
|
<a href="/logout" role="button" class="outline secondary">Logout</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="tab-bar" id="tab-bar">
|
||||||
|
<span class="tab active">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="show-content">
|
||||||
|
<p>Loading shows...</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const WS_TOKEN = "{{WS_TOKEN}}";
|
||||||
|
let subscriberCount = 0;
|
||||||
|
let allShows = [];
|
||||||
|
let showCache = {};
|
||||||
|
let activeShowId = null;
|
||||||
|
|
||||||
|
function showToast(msg, isError) {
|
||||||
|
const t = document.getElementById("toast");
|
||||||
|
t.textContent = msg;
|
||||||
|
t.className = "toast show" + (isError ? " error" : "");
|
||||||
|
setTimeout(() => { t.className = "toast"; }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = s || "";
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrackTable(tracks, showId) {
|
||||||
|
if (!tracks || tracks.length === 0) return "<p>No tracks yet.</p>";
|
||||||
|
let html = '<table role="grid"><thead><tr>';
|
||||||
|
html += '<th class="track-num">#</th><th>Title</th><th>Artist</th><th></th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
for (const t of tracks) {
|
||||||
|
const disabled = subscriberCount === 0 ? 'disabled title="No bots connected"' : "";
|
||||||
|
const copyText = `${t.title} by ${t.artist} - ${t.permalink_url}`;
|
||||||
|
html += `<tr>
|
||||||
|
<td class="track-num">${t.position}</td>
|
||||||
|
<td>${esc(t.title)}</td>
|
||||||
|
<td>${esc(t.artist)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn-sm copy-btn outline"
|
||||||
|
onclick="copyTrack(this, '${esc(copyText).replace(/'/g, "\\'")}')">Copy</button>
|
||||||
|
<button class="btn-sm announce-btn" ${disabled}
|
||||||
|
onclick="announce(${showId}, ${t.position}, this)">Announce</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllShows() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/shows?limit=200");
|
||||||
|
if (!resp.ok) throw new Error("Failed to load shows");
|
||||||
|
allShows = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
allShows = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/playlist");
|
||||||
|
if (!resp.ok) throw new Error("Failed to load current playlist");
|
||||||
|
const current = await resp.json();
|
||||||
|
showCache[current.show_id] = current;
|
||||||
|
|
||||||
|
const exists = allShows.find(s => s.id === current.show_id);
|
||||||
|
if (!exists) {
|
||||||
|
allShows.unshift({
|
||||||
|
id: current.show_id,
|
||||||
|
episode_number: current.episode_number,
|
||||||
|
week_start: current.week_start,
|
||||||
|
week_end: current.week_end,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTabs(current.show_id);
|
||||||
|
renderShow(current);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("show-content").innerHTML = "<p>Failed to load shows.</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTabs(activeId) {
|
||||||
|
activeShowId = activeId;
|
||||||
|
const bar = document.getElementById("tab-bar");
|
||||||
|
bar.innerHTML = "";
|
||||||
|
for (const s of allShows) {
|
||||||
|
const tab = document.createElement("button");
|
||||||
|
tab.className = "tab" + (s.id === activeId ? " active" : "");
|
||||||
|
const label = s.episode_number ? `Ep ${s.episode_number}` : `Show ${s.id}`;
|
||||||
|
tab.textContent = label;
|
||||||
|
tab.onclick = () => selectShow(s.id);
|
||||||
|
bar.appendChild(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectShow(showId) {
|
||||||
|
activeShowId = showId;
|
||||||
|
document.querySelectorAll(".tab").forEach((tab, i) => {
|
||||||
|
tab.classList.toggle("active", allShows[i].id === showId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (showCache[showId]) {
|
||||||
|
renderShow(showCache[showId]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("show-content").innerHTML = "<p>Loading...</p>";
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/shows/${showId}`);
|
||||||
|
if (!resp.ok) throw new Error("Failed to load show");
|
||||||
|
const data = await resp.json();
|
||||||
|
showCache[showId] = data;
|
||||||
|
if (activeShowId === showId) renderShow(data);
|
||||||
|
} catch (e) {
|
||||||
|
if (activeShowId === showId) {
|
||||||
|
document.getElementById("show-content").innerHTML = "<p>Failed to load show.</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShow(data) {
|
||||||
|
const el = document.getElementById("show-content");
|
||||||
|
const ep = data.episode_number ? `Episode ${data.episode_number}` : "Show";
|
||||||
|
el.innerHTML = `<h3>${esc(ep)} <small>(${data.tracks.length} tracks)</small></h3>`
|
||||||
|
+ renderTrackTable(data.tracks, data.show_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyTrack(btn, text) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
btn.textContent = "\u2713";
|
||||||
|
btn.classList.add("copied");
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = "Copy";
|
||||||
|
btn.classList.remove("copied");
|
||||||
|
}, 1500);
|
||||||
|
}).catch(() => {
|
||||||
|
showToast("Copy failed", true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function announce(showId, position, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "...";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/admin/announce", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({show_id: showId, position: position}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || "Announce failed");
|
||||||
|
}
|
||||||
|
btn.textContent = "\u2713";
|
||||||
|
btn.classList.add("success");
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = "Announce";
|
||||||
|
btn.classList.remove("success");
|
||||||
|
btn.disabled = false;
|
||||||
|
}, 2000);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, true);
|
||||||
|
btn.textContent = "Announce";
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(count, clients) {
|
||||||
|
subscriberCount = count;
|
||||||
|
const dot = document.getElementById("status-dot");
|
||||||
|
const sub = document.getElementById("sub-count");
|
||||||
|
const detail = document.getElementById("client-detail");
|
||||||
|
if (count > 0) {
|
||||||
|
dot.className = "status-dot connected";
|
||||||
|
sub.textContent = `(${count} bot${count > 1 ? "s" : ""} connected)`;
|
||||||
|
if (clients && clients.length > 0) {
|
||||||
|
detail.innerHTML = clients.map(c => {
|
||||||
|
const name = esc(c.client_id || "unknown");
|
||||||
|
const addr = esc(c.remote_addr || "?");
|
||||||
|
return `<span class="client-tag">${name} (${addr})</span>`;
|
||||||
|
}).join(" ");
|
||||||
|
detail.style.display = "block";
|
||||||
|
} else {
|
||||||
|
detail.style.display = "none";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dot.className = "status-dot disconnected";
|
||||||
|
sub.textContent = "(no bots connected)";
|
||||||
|
detail.style.display = "none";
|
||||||
|
}
|
||||||
|
document.querySelectorAll(".announce-btn").forEach(btn => {
|
||||||
|
if (count === 0) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.title = "No bots connected";
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.title = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let wsBackoff = 1000;
|
||||||
|
function connectWS() {
|
||||||
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const ws = new WebSocket(`${proto}//${location.host}/ws/announce`);
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({type: "subscribe", token: WS_TOKEN, role: "viewer"}));
|
||||||
|
wsBackoff = 1000;
|
||||||
|
};
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === "status") {
|
||||||
|
updateStatus(data.subscribers, data.clients || []);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
updateStatus(0, []);
|
||||||
|
document.getElementById("sub-count").textContent = "(reconnecting...)";
|
||||||
|
setTimeout(connectWS, wsBackoff);
|
||||||
|
wsBackoff = Math.min(wsBackoff * 2, 60000);
|
||||||
|
};
|
||||||
|
ws.onerror = () => ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAllShows();
|
||||||
|
connectWS();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
src/ntr_fetcher/static/login.html
Normal file
31
src/ntr_fetcher/static/login.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>NtR Login</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<style>
|
||||||
|
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||||
|
main { max-width: 400px; width: 100%; }
|
||||||
|
.error { color: var(--pico-color-red-500, #e53935); margin-bottom: 1rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<article>
|
||||||
|
<header><h2>NtR Playlist</h2></header>
|
||||||
|
<!--ERROR-->
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label>Username
|
||||||
|
<input name="username" autocomplete="username" required>
|
||||||
|
</label>
|
||||||
|
<label>Password
|
||||||
|
<input name="password" type="password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Log in</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
74
src/ntr_fetcher/websocket.py
Normal file
74
src/ntr_fetcher/websocket.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Client:
|
||||||
|
websocket: object
|
||||||
|
role: str
|
||||||
|
client_id: str = ""
|
||||||
|
remote_addr: str = ""
|
||||||
|
connected_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
class AnnounceManager:
|
||||||
|
def __init__(self):
|
||||||
|
self._clients: list[Client] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bot_count(self) -> int:
|
||||||
|
return sum(1 for c in self._clients if c.role == "bot")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bot_clients(self) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"client_id": c.client_id,
|
||||||
|
"remote_addr": c.remote_addr,
|
||||||
|
"connected_at": c.connected_at,
|
||||||
|
}
|
||||||
|
for c in self._clients
|
||||||
|
if c.role == "bot"
|
||||||
|
]
|
||||||
|
|
||||||
|
def add_client(self, websocket, role: str, client_id: str = "", remote_addr: str = "") -> None:
|
||||||
|
self._clients.append(Client(
|
||||||
|
websocket=websocket,
|
||||||
|
role=role,
|
||||||
|
client_id=client_id,
|
||||||
|
remote_addr=remote_addr,
|
||||||
|
))
|
||||||
|
logger.info(
|
||||||
|
"Client connected: role=%s client_id=%s addr=%s (%d bots, %d total)",
|
||||||
|
role, client_id, remote_addr, self.bot_count, len(self._clients),
|
||||||
|
)
|
||||||
|
|
||||||
|
def remove_client(self, websocket) -> None:
|
||||||
|
removed = [c for c in self._clients if c.websocket is websocket]
|
||||||
|
self._clients = [c for c in self._clients if c.websocket is not websocket]
|
||||||
|
for c in removed:
|
||||||
|
logger.info(
|
||||||
|
"Client disconnected: role=%s client_id=%s (%d bots, %d total)",
|
||||||
|
c.role, c.client_id, self.bot_count, len(self._clients),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def broadcast(self, message: dict) -> None:
|
||||||
|
dead = []
|
||||||
|
for client in self._clients:
|
||||||
|
try:
|
||||||
|
await client.websocket.send_json(message)
|
||||||
|
except Exception:
|
||||||
|
dead.append(client.websocket)
|
||||||
|
logger.warning("Removing dead client: %s", client.client_id or client.role)
|
||||||
|
for ws in dead:
|
||||||
|
self.remove_client(ws)
|
||||||
|
|
||||||
|
async def broadcast_status(self) -> None:
|
||||||
|
await self.broadcast({
|
||||||
|
"type": "status",
|
||||||
|
"subscribers": self.bot_count,
|
||||||
|
"clients": self.bot_clients,
|
||||||
|
})
|
||||||
@@ -122,3 +122,33 @@ def test_admin_remove_track(client, db):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
tracks = db.get_show_tracks(show.id)
|
tracks = db.get_show_tracks(show.id)
|
||||||
assert len(tracks) == 1
|
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
|
||||||
|
|||||||
@@ -28,3 +28,27 @@ def test_settings_admin_token_required():
|
|||||||
import pytest
|
import pytest
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
Settings()
|
Settings()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_config_absent(monkeypatch):
|
||||||
|
monkeypatch.setenv("NTR_ADMIN_TOKEN", "tok")
|
||||||
|
monkeypatch.delenv("NTR_WEB_USER", raising=False)
|
||||||
|
monkeypatch.delenv("NTR_WEB_PASSWORD", raising=False)
|
||||||
|
monkeypatch.delenv("NTR_SECRET_KEY", raising=False)
|
||||||
|
s = Settings()
|
||||||
|
assert s.web_user is None
|
||||||
|
assert s.web_password is None
|
||||||
|
assert s.secret_key is None
|
||||||
|
assert s.dashboard_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_config_present(monkeypatch):
|
||||||
|
monkeypatch.setenv("NTR_ADMIN_TOKEN", "tok")
|
||||||
|
monkeypatch.setenv("NTR_WEB_USER", "nick")
|
||||||
|
monkeypatch.setenv("NTR_WEB_PASSWORD", "secret")
|
||||||
|
monkeypatch.setenv("NTR_SECRET_KEY", "signme")
|
||||||
|
s = Settings()
|
||||||
|
assert s.web_user == "nick"
|
||||||
|
assert s.web_password == "secret"
|
||||||
|
assert s.secret_key == "signme"
|
||||||
|
assert s.dashboard_enabled is True
|
||||||
|
|||||||
189
tests/test_dashboard.py
Normal file
189
tests/test_dashboard.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from ntr_fetcher.dashboard import create_dashboard_router
|
||||||
|
from ntr_fetcher.db import Database
|
||||||
|
from ntr_fetcher.models import Track
|
||||||
|
from ntr_fetcher.websocket import AnnounceManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db(tmp_path):
|
||||||
|
database = Database(str(tmp_path / "test.db"))
|
||||||
|
database.initialize()
|
||||||
|
return database
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager():
|
||||||
|
return AnnounceManager()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(db, manager):
|
||||||
|
a = FastAPI()
|
||||||
|
router = create_dashboard_router(
|
||||||
|
db=db,
|
||||||
|
manager=manager,
|
||||||
|
admin_token="test-token",
|
||||||
|
web_user="nick",
|
||||||
|
web_password="secret",
|
||||||
|
secret_key="test-secret-key",
|
||||||
|
show_day=2,
|
||||||
|
show_hour=22,
|
||||||
|
)
|
||||||
|
a.include_router(router)
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_show(db):
|
||||||
|
week_start = datetime(2026, 3, 12, 2, 0, 0, tzinfo=timezone.utc)
|
||||||
|
week_end = datetime(2026, 3, 19, 2, 0, 0, tzinfo=timezone.utc)
|
||||||
|
show = db.get_or_create_show(week_start, week_end)
|
||||||
|
t1 = Track(1, "Song A", "Artist A", "https://soundcloud.com/a/1", None, 180000, "cc-by",
|
||||||
|
datetime(2026, 3, 14, 1, 0, 0, tzinfo=timezone.utc), "{}")
|
||||||
|
db.upsert_track(t1)
|
||||||
|
db.set_show_tracks(show.id, [t1.id])
|
||||||
|
return show
|
||||||
|
|
||||||
|
|
||||||
|
# --- Session auth tests ---
|
||||||
|
|
||||||
|
def test_dashboard_redirects_without_session(client):
|
||||||
|
resp = client.get("/dashboard", follow_redirects=False)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert "/login" in resp.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_page_renders(client):
|
||||||
|
resp = client.get("/login")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "login" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_with_valid_credentials(client):
|
||||||
|
resp = client.post(
|
||||||
|
"/login",
|
||||||
|
data={"username": "nick", "password": "secret"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert "/dashboard" in resp.headers["location"]
|
||||||
|
assert "ntr_session" in resp.cookies
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_with_invalid_credentials(client):
|
||||||
|
resp = client.post(
|
||||||
|
"/login",
|
||||||
|
data={"username": "nick", "password": "wrong"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "invalid" in resp.text.lower() or "incorrect" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_accessible_with_session(client):
|
||||||
|
client.post(
|
||||||
|
"/login",
|
||||||
|
data={"username": "nick", "password": "secret"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
resp = client.get("/dashboard")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "dashboard" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_clears_session(client):
|
||||||
|
client.post(
|
||||||
|
"/login",
|
||||||
|
data={"username": "nick", "password": "secret"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
resp = client.get("/logout", follow_redirects=False)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert "/login" in resp.headers["location"]
|
||||||
|
|
||||||
|
resp2 = client.get("/dashboard", follow_redirects=False)
|
||||||
|
assert resp2.status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
# --- Announce endpoint tests ---
|
||||||
|
|
||||||
|
def test_announce_with_session(client, db):
|
||||||
|
_seed_show(db)
|
||||||
|
client.post(
|
||||||
|
"/login",
|
||||||
|
data={"username": "nick", "password": "secret"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
resp = client.post("/admin/announce", json={"show_id": 1, "position": 1})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "announced"
|
||||||
|
assert "Now Playing:" in data["message"]
|
||||||
|
assert "Song A" in data["message"]
|
||||||
|
assert "Artist A" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_announce_with_bearer(client, db):
|
||||||
|
_seed_show(db)
|
||||||
|
resp = client.post(
|
||||||
|
"/admin/announce",
|
||||||
|
json={"show_id": 1, "position": 1},
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Now Playing:" in resp.json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_announce_without_auth(client, db):
|
||||||
|
_seed_show(db)
|
||||||
|
resp = client.post("/admin/announce", json={"show_id": 1, "position": 1})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_announce_invalid_position(client, db):
|
||||||
|
_seed_show(db)
|
||||||
|
resp = client.post(
|
||||||
|
"/admin/announce",
|
||||||
|
json={"show_id": 1, "position": 99},
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# --- WebSocket tests ---
|
||||||
|
|
||||||
|
def test_ws_subscribe_bot_with_valid_token(app):
|
||||||
|
with TestClient(app) as c:
|
||||||
|
with c.websocket_connect("/ws/announce") as ws:
|
||||||
|
ws.send_json({"type": "subscribe", "token": "test-token", "role": "bot", "client_id": "test-bot"})
|
||||||
|
data = ws.receive_json()
|
||||||
|
assert data["type"] == "status"
|
||||||
|
assert data["subscribers"] == 1
|
||||||
|
assert data["clients"][0]["client_id"] == "test-bot"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_subscribe_viewer_not_counted(app):
|
||||||
|
with TestClient(app) as c:
|
||||||
|
with c.websocket_connect("/ws/announce") as ws:
|
||||||
|
ws.send_json({"type": "subscribe", "token": "test-token", "role": "viewer"})
|
||||||
|
data = ws.receive_json()
|
||||||
|
assert data["type"] == "status"
|
||||||
|
assert data["subscribers"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_subscribe_with_invalid_token(app):
|
||||||
|
with TestClient(app) as c:
|
||||||
|
with c.websocket_connect("/ws/announce") as ws:
|
||||||
|
ws.send_json({"type": "subscribe", "token": "wrong"})
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
ws.receive_json()
|
||||||
@@ -272,6 +272,20 @@ def test_update_show_episode_number(db):
|
|||||||
assert show2.episode_number == 521
|
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):
|
def test_has_track_in_show(db):
|
||||||
week_start = datetime(2026, 3, 13, 2, 0, 0, tzinfo=timezone.utc)
|
week_start = datetime(2026, 3, 13, 2, 0, 0, tzinfo=timezone.utc)
|
||||||
week_end = datetime(2026, 3, 20, 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
57
tests/test_format_dt.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Tests for the format_dt helper used by both IRC plugins.
|
||||||
|
|
||||||
|
The function is duplicated in both plugins (Limnoria and Sopel) since they
|
||||||
|
can't share imports. This tests the logic independently of either IRC framework.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|
||||||
|
def format_dt(iso_string: str | None, tz_name: str = "America/New_York") -> str:
|
||||||
|
if not iso_string:
|
||||||
|
return "never"
|
||||||
|
dt = datetime.fromisoformat(iso_string)
|
||||||
|
local = dt.astimezone(ZoneInfo(tz_name))
|
||||||
|
return local.strftime("%a %b %-d, %-I:%M %p %Z")
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_dt_est():
|
||||||
|
result = format_dt("2026-01-15T03:00:00+00:00")
|
||||||
|
assert result == "Wed Jan 14, 10:00 PM EST"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_dt_edt():
|
||||||
|
result = format_dt("2026-03-12T02:00:00+00:00")
|
||||||
|
assert result == "Wed Mar 11, 10:00 PM EDT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_dt_none():
|
||||||
|
assert format_dt(None) == "never"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_dt_empty_string():
|
||||||
|
assert format_dt("") == "never"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_dt_custom_timezone():
|
||||||
|
result = format_dt("2026-03-12T02:00:00+00:00", "America/Chicago")
|
||||||
|
assert "CDT" in result or "CST" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_dt_no_seconds_or_microseconds():
|
||||||
|
result = format_dt("2026-03-12T02:30:45.123456+00:00")
|
||||||
|
assert ":45" not in result
|
||||||
|
assert ".123456" not in result
|
||||||
|
assert "10:30 PM" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_dt_single_digit_day():
|
||||||
|
result = format_dt("2026-03-05T03:00:00+00:00")
|
||||||
|
assert "Wed Mar 4," in result
|
||||||
|
assert " 04," not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_dt_single_digit_hour():
|
||||||
|
result = format_dt("2026-01-15T06:00:00+00:00")
|
||||||
|
assert "1:00 AM" in result
|
||||||
|
assert "01:00" not in result
|
||||||
265
tests/test_plugin_helpers.py
Normal file
265
tests/test_plugin_helpers.py
Normal 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
100
tests/test_websocket.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import pytest
|
||||||
|
from ntr_fetcher.websocket import AnnounceManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager():
|
||||||
|
return AnnounceManager()
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_bots_initially(manager):
|
||||||
|
assert manager.bot_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bot_subscribe_and_broadcast(manager):
|
||||||
|
received = []
|
||||||
|
|
||||||
|
class FakeWS:
|
||||||
|
async def send_json(self, data):
|
||||||
|
received.append(data)
|
||||||
|
|
||||||
|
ws = FakeWS()
|
||||||
|
manager.add_client(ws, role="bot", client_id="test-bot", remote_addr="127.0.0.1")
|
||||||
|
assert manager.bot_count == 1
|
||||||
|
|
||||||
|
await manager.broadcast({"type": "announce", "message": "Now Playing: Song #1"})
|
||||||
|
assert len(received) == 1
|
||||||
|
assert received[0]["message"] == "Now Playing: Song #1"
|
||||||
|
|
||||||
|
manager.remove_client(ws)
|
||||||
|
assert manager.bot_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_viewer_not_counted_as_bot(manager):
|
||||||
|
received = []
|
||||||
|
|
||||||
|
class FakeWS:
|
||||||
|
async def send_json(self, data):
|
||||||
|
received.append(data)
|
||||||
|
|
||||||
|
ws = FakeWS()
|
||||||
|
manager.add_client(ws, role="viewer")
|
||||||
|
assert manager.bot_count == 0
|
||||||
|
|
||||||
|
await manager.broadcast({"type": "announce", "message": "test"})
|
||||||
|
assert len(received) == 1
|
||||||
|
|
||||||
|
manager.remove_client(ws)
|
||||||
|
assert manager.bot_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broadcast_skips_dead_connections(manager):
|
||||||
|
class DeadWS:
|
||||||
|
async def send_json(self, data):
|
||||||
|
raise Exception("connection closed")
|
||||||
|
|
||||||
|
ws = DeadWS()
|
||||||
|
manager.add_client(ws, role="bot", client_id="dead-bot")
|
||||||
|
assert manager.bot_count == 1
|
||||||
|
|
||||||
|
await manager.broadcast({"type": "announce", "message": "test"})
|
||||||
|
assert manager.bot_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bot_clients_returns_metadata(manager):
|
||||||
|
class FakeWS:
|
||||||
|
async def send_json(self, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
ws = FakeWS()
|
||||||
|
manager.add_client(ws, role="bot", client_id="limnoria-prod", remote_addr="10.0.0.5")
|
||||||
|
clients = manager.bot_clients
|
||||||
|
assert len(clients) == 1
|
||||||
|
assert clients[0]["client_id"] == "limnoria-prod"
|
||||||
|
assert clients[0]["remote_addr"] == "10.0.0.5"
|
||||||
|
assert "connected_at" in clients[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_broadcast_includes_clients(manager):
|
||||||
|
received = []
|
||||||
|
|
||||||
|
class FakeWS:
|
||||||
|
async def send_json(self, data):
|
||||||
|
received.append(data)
|
||||||
|
|
||||||
|
bot = FakeWS()
|
||||||
|
viewer = FakeWS()
|
||||||
|
manager.add_client(bot, role="bot", client_id="my-bot", remote_addr="1.2.3.4")
|
||||||
|
manager.add_client(viewer, role="viewer")
|
||||||
|
|
||||||
|
await manager.broadcast_status()
|
||||||
|
for msg in received:
|
||||||
|
assert msg["type"] == "status"
|
||||||
|
assert msg["subscribers"] == 1
|
||||||
|
assert len(msg["clients"]) == 1
|
||||||
|
assert msg["clients"][0]["client_id"] == "my-bot"
|
||||||
Reference in New Issue
Block a user