Compare commits
4 Commits
a5f77187b3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d014a14cbe
|
||
|
|
4dd3f43ae3
|
||
|
|
425a7047c3
|
||
|
|
11f13c86b5
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,3 +27,4 @@ build/
|
|||||||
|
|
||||||
# AI session artifacts
|
# AI session artifacts
|
||||||
chat-summaries/
|
chat-summaries/
|
||||||
|
.superpowers/
|
||||||
|
|||||||
54
README.md
54
README.md
@@ -11,7 +11,7 @@ API.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
export NTR_ADMIN_TOKEN="your-secret-here"
|
cp .env.example .env # edit with your settings
|
||||||
ntr-fetcher
|
ntr-fetcher
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -36,12 +36,16 @@ Full documentation: [`docs/api.md`](docs/api.md)
|
|||||||
|
|
||||||
| Endpoint | Method | Auth | Description |
|
| Endpoint | Method | Auth | Description |
|
||||||
|----------|--------|------|-------------|
|
|----------|--------|------|-------------|
|
||||||
|
| `/` | GET | -- | Public index page |
|
||||||
| `/health` | GET | -- | Service health check |
|
| `/health` | GET | -- | Service health check |
|
||||||
| `/playlist` | GET | -- | Current week's playlist |
|
| `/public/playlist` | GET | -- | Current week's playlist (unannounced tracks censored) |
|
||||||
| `/playlist/{position}` | GET | -- | Single track by position (1-indexed) |
|
| `/public/shows` | GET | -- | List all shows (paginated) |
|
||||||
| `/shows` | GET | -- | List all shows (paginated) |
|
| `/public/shows/{show_id}` | GET | -- | Past show with full tracklist |
|
||||||
| `/shows/by-episode/{episode_number}` | GET | -- | Look up show by episode number |
|
| `/playlist` | GET | Bearer | Current week's full playlist |
|
||||||
| `/shows/{show_id}` | GET | -- | Specific show by internal ID |
|
| `/playlist/{position}` | GET | Bearer | Single track by position (1-indexed) |
|
||||||
|
| `/shows` | GET | Bearer | List all shows (paginated) |
|
||||||
|
| `/shows/by-episode/{episode_number}` | GET | Bearer | Look up show by episode number |
|
||||||
|
| `/shows/{show_id}` | GET | Bearer | Specific show by internal ID |
|
||||||
| `/login` | GET/POST | -- | Login page |
|
| `/login` | GET/POST | -- | Login page |
|
||||||
| `/logout` | GET | Session | Clear session |
|
| `/logout` | GET | Session | Clear session |
|
||||||
| `/dashboard` | GET | Session | Live playlist dashboard |
|
| `/dashboard` | GET | Session | Live playlist dashboard |
|
||||||
@@ -50,10 +54,16 @@ Full documentation: [`docs/api.md`](docs/api.md)
|
|||||||
| `/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 |
|
| `/admin/announce` | POST | Bearer/Session | Announce track to IRC |
|
||||||
|
| `/admin/announced` | POST | Bearer/Session | Toggle track announced state |
|
||||||
|
| `/admin/ping` | POST | Bearer/Session | Send IRC message via connected bots |
|
||||||
|
| `/ws/announce` | WS | Bearer | Bot WebSocket for announcements |
|
||||||
|
| `/ws/public` | WS | -- | Public WebSocket for live track reveals |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Environment variables (prefix `NTR_`):
|
All settings are read from environment variables (prefix `NTR_`). A `.env`
|
||||||
|
file in the project root is loaded automatically. See
|
||||||
|
[`.env.example`](.env.example) for a template with all available variables.
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
@@ -65,13 +75,35 @@ Environment variables (prefix `NTR_`):
|
|||||||
| `NTR_SOUNDCLOUD_USER` | `nicktherat` | SoundCloud username to track |
|
| `NTR_SOUNDCLOUD_USER` | `nicktherat` | SoundCloud username to track |
|
||||||
| `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 |
|
||||||
|
| `NTR_SHOW_ROTATION_DELAY_HOURS` | `0` | Hours to wait after the show boundary before rotating to the next episode (recommended `2` for live recording window) |
|
||||||
|
| `NTR_PING_TARGET` | *(empty)* | Default nick/target for the dashboard ping feature |
|
||||||
|
| `NTR_PING_MESSAGE` | *(empty)* | Default message for the dashboard ping feature |
|
||||||
|
|
||||||
|
## Public Index Page
|
||||||
|
|
||||||
|
The root URL (`/`) serves a public-facing page showing the current show's
|
||||||
|
playlist. Unannounced tracks are hidden behind silhouette cards until the
|
||||||
|
admin announces them from the dashboard. Reveals happen in real time over
|
||||||
|
WebSocket (`/ws/public`), so listeners following along see tracks appear
|
||||||
|
as they are played.
|
||||||
|
|
||||||
|
Past episodes are browsable from the same page with their full tracklists
|
||||||
|
visible.
|
||||||
|
|
||||||
## Dashboard
|
## Dashboard
|
||||||
|
|
||||||
An optional web dashboard for announcing tracks to IRC during live shows.
|
An optional web dashboard for managing tracks during live shows. Features:
|
||||||
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
|
- **Tabbed show interface** -- switch between the current and previous week's
|
||||||
via connected bot plugins.
|
playlists in one view.
|
||||||
|
- **Announce button** -- push a track to IRC via connected bot plugins. Also
|
||||||
|
reveals the track on the public index page in real time.
|
||||||
|
- **Announced checkbox** -- persistent per-track flag, auto-checked on
|
||||||
|
announce. Can be toggled manually.
|
||||||
|
- **Copy to clipboard** -- one-click copy of a formatted track line.
|
||||||
|
- **Send IRC Message** -- ping a nick or channel with a custom message through
|
||||||
|
all connected bots. Default target and message are configurable via
|
||||||
|
`NTR_PING_TARGET` / `NTR_PING_MESSAGE`.
|
||||||
|
|
||||||
Enable by setting these environment variables:
|
Enable by setting these environment variables:
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,12 @@ class ApiError(Exception):
|
|||||||
super().__init__(f"{status_code}: {detail}")
|
super().__init__(f"{status_code}: {detail}")
|
||||||
|
|
||||||
|
|
||||||
def _api_get(base_url: str, path: str) -> dict:
|
def _api_get(base_url: str, path: str, token: str = "") -> dict:
|
||||||
url = f"{base_url.rstrip('/')}{path}"
|
url = f"{base_url.rstrip('/')}{path}"
|
||||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
headers = {"Accept": "application/json"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
req = urllib.request.Request(url, headers=headers)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
raw = resp.read().decode()
|
raw = resp.read().decode()
|
||||||
@@ -234,8 +237,9 @@ class NtrPlaylist(callbacks.Plugin):
|
|||||||
if match:
|
if match:
|
||||||
position = match.group(1)
|
position = match.group(1)
|
||||||
base_url = self.registryValue("apiBaseUrl")
|
base_url = self.registryValue("apiBaseUrl")
|
||||||
|
token = self.registryValue("adminToken")
|
||||||
try:
|
try:
|
||||||
data = _api_get(base_url, f"/playlist/{position}")
|
data = _api_get(base_url, f"/playlist/{position}", token)
|
||||||
irc.reply(format_track(data))
|
irc.reply(format_track(data))
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
LOGGER.warning("API error for !%s: %s", position, exc)
|
LOGGER.warning("API error for !%s: %s", position, exc)
|
||||||
@@ -260,8 +264,9 @@ class NtrPlaylist(callbacks.Plugin):
|
|||||||
irc.reply("Usage: !song <episode> <position>")
|
irc.reply("Usage: !song <episode> <position>")
|
||||||
return
|
return
|
||||||
base_url = self.registryValue("apiBaseUrl")
|
base_url = self.registryValue("apiBaseUrl")
|
||||||
|
token = self.registryValue("adminToken")
|
||||||
try:
|
try:
|
||||||
data = _api_get(base_url, f"/shows/by-episode/{episode}")
|
data = _api_get(base_url, f"/shows/by-episode/{episode}", token)
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
LOGGER.warning("API error for !song %s %s: %s", episode, position, exc)
|
LOGGER.warning("API error for !song %s %s: %s", episode, position, exc)
|
||||||
irc.reply(exc.detail)
|
irc.reply(exc.detail)
|
||||||
@@ -280,6 +285,7 @@ class NtrPlaylist(callbacks.Plugin):
|
|||||||
Returns the playlist for the current show, or a specific episode.
|
Returns the playlist for the current show, or a specific episode.
|
||||||
"""
|
"""
|
||||||
base_url = self.registryValue("apiBaseUrl")
|
base_url = self.registryValue("apiBaseUrl")
|
||||||
|
token = self.registryValue("adminToken")
|
||||||
if text and text.strip():
|
if text and text.strip():
|
||||||
try:
|
try:
|
||||||
episode = int(text.strip())
|
episode = int(text.strip())
|
||||||
@@ -287,14 +293,14 @@ class NtrPlaylist(callbacks.Plugin):
|
|||||||
irc.reply("Usage: !playlist [episode]")
|
irc.reply("Usage: !playlist [episode]")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
data = _api_get(base_url, f"/shows/by-episode/{episode}")
|
data = _api_get(base_url, f"/shows/by-episode/{episode}", token)
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
LOGGER.warning("API error for playlist: %s", exc)
|
LOGGER.warning("API error for playlist: %s", exc)
|
||||||
irc.reply(exc.detail)
|
irc.reply(exc.detail)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
data = _api_get(base_url, "/playlist")
|
data = _api_get(base_url, "/playlist", token)
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
LOGGER.warning("API error for playlist: %s", exc)
|
LOGGER.warning("API error for playlist: %s", exc)
|
||||||
irc.reply(exc.detail)
|
irc.reply(exc.detail)
|
||||||
@@ -316,8 +322,9 @@ class NtrPlaylist(callbacks.Plugin):
|
|||||||
irc.reply("Usage: !lastshow <position>")
|
irc.reply("Usage: !lastshow <position>")
|
||||||
return
|
return
|
||||||
base_url = self.registryValue("apiBaseUrl")
|
base_url = self.registryValue("apiBaseUrl")
|
||||||
|
token = self.registryValue("adminToken")
|
||||||
try:
|
try:
|
||||||
shows = _api_get(base_url, "/shows?limit=2")
|
shows = _api_get(base_url, "/shows?limit=2", token)
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
LOGGER.warning("API error for lastshow: %s", exc)
|
LOGGER.warning("API error for lastshow: %s", exc)
|
||||||
irc.reply(exc.detail)
|
irc.reply(exc.detail)
|
||||||
@@ -327,7 +334,7 @@ class NtrPlaylist(callbacks.Plugin):
|
|||||||
return
|
return
|
||||||
prev_show_id = shows[1]["id"]
|
prev_show_id = shows[1]["id"]
|
||||||
try:
|
try:
|
||||||
data = _api_get(base_url, f"/shows/{prev_show_id}")
|
data = _api_get(base_url, f"/shows/{prev_show_id}", token)
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
LOGGER.warning("API error for lastshow show %s: %s", prev_show_id, exc)
|
LOGGER.warning("API error for lastshow show %s: %s", prev_show_id, exc)
|
||||||
irc.reply(exc.detail)
|
irc.reply(exc.detail)
|
||||||
|
|||||||
@@ -143,9 +143,12 @@ class ApiError(Exception):
|
|||||||
super().__init__(f"{status_code}: {detail}")
|
super().__init__(f"{status_code}: {detail}")
|
||||||
|
|
||||||
|
|
||||||
def _api_get(base_url: str, path: str) -> dict:
|
def _api_get(base_url: str, path: str, token: str = "") -> dict:
|
||||||
url = f"{base_url.rstrip('/')}{path}"
|
url = f"{base_url.rstrip('/')}{path}"
|
||||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
headers = {"Accept": "application/json"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
req = urllib.request.Request(url, headers=headers)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
raw = resp.read().decode()
|
raw = resp.read().decode()
|
||||||
@@ -235,9 +238,10 @@ def _is_admin(bot, nick: str) -> bool:
|
|||||||
@plugin.rule(r"^!(\d+)$")
|
@plugin.rule(r"^!(\d+)$")
|
||||||
def ntr_playlist_position(bot, trigger):
|
def ntr_playlist_position(bot, trigger):
|
||||||
base_url = bot.settings.ntr_playlist.api_base_url
|
base_url = bot.settings.ntr_playlist.api_base_url
|
||||||
|
token = bot.settings.ntr_playlist.admin_token
|
||||||
position = trigger.group(1)
|
position = trigger.group(1)
|
||||||
try:
|
try:
|
||||||
data = _api_get(base_url, f"/playlist/{position}")
|
data = _api_get(base_url, f"/playlist/{position}", token)
|
||||||
bot.say(format_track(data))
|
bot.say(format_track(data))
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
LOGGER.warning("API error for !%s: %s", position, e)
|
LOGGER.warning("API error for !%s: %s", position, e)
|
||||||
@@ -261,8 +265,9 @@ def ntr_song(bot, trigger):
|
|||||||
bot.say("Usage: !song <episode> <position>")
|
bot.say("Usage: !song <episode> <position>")
|
||||||
return
|
return
|
||||||
base_url = bot.settings.ntr_playlist.api_base_url
|
base_url = bot.settings.ntr_playlist.api_base_url
|
||||||
|
token = bot.settings.ntr_playlist.admin_token
|
||||||
try:
|
try:
|
||||||
data = _api_get(base_url, f"/shows/by-episode/{episode}")
|
data = _api_get(base_url, f"/shows/by-episode/{episode}", token)
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
LOGGER.warning("API error for !song %s %s: %s", episode, position, e)
|
LOGGER.warning("API error for !song %s %s: %s", episode, position, e)
|
||||||
bot.say(e.detail)
|
bot.say(e.detail)
|
||||||
@@ -279,6 +284,7 @@ def ntr_song(bot, trigger):
|
|||||||
def ntr_playlist(bot, trigger):
|
def ntr_playlist(bot, trigger):
|
||||||
raw = trigger.group(2)
|
raw = trigger.group(2)
|
||||||
base_url = bot.settings.ntr_playlist.api_base_url
|
base_url = bot.settings.ntr_playlist.api_base_url
|
||||||
|
token = bot.settings.ntr_playlist.admin_token
|
||||||
if raw and raw.strip():
|
if raw and raw.strip():
|
||||||
try:
|
try:
|
||||||
episode = int(raw.strip())
|
episode = int(raw.strip())
|
||||||
@@ -286,14 +292,14 @@ def ntr_playlist(bot, trigger):
|
|||||||
bot.say("Usage: !playlist [episode]")
|
bot.say("Usage: !playlist [episode]")
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
data = _api_get(base_url, f"/shows/by-episode/{episode}")
|
data = _api_get(base_url, f"/shows/by-episode/{episode}", token)
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
LOGGER.warning("API error for !playlist: %s", e)
|
LOGGER.warning("API error for !playlist: %s", e)
|
||||||
bot.say(e.detail)
|
bot.say(e.detail)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
data = _api_get(base_url, "/playlist")
|
data = _api_get(base_url, "/playlist", token)
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
LOGGER.warning("API error for !playlist: %s", e)
|
LOGGER.warning("API error for !playlist: %s", e)
|
||||||
bot.say(e.detail)
|
bot.say(e.detail)
|
||||||
@@ -313,8 +319,9 @@ def ntr_lastshow(bot, trigger):
|
|||||||
bot.say("Usage: !lastshow <position>")
|
bot.say("Usage: !lastshow <position>")
|
||||||
return
|
return
|
||||||
base_url = bot.settings.ntr_playlist.api_base_url
|
base_url = bot.settings.ntr_playlist.api_base_url
|
||||||
|
token = bot.settings.ntr_playlist.admin_token
|
||||||
try:
|
try:
|
||||||
shows = _api_get(base_url, "/shows?limit=2")
|
shows = _api_get(base_url, "/shows?limit=2", token)
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
LOGGER.warning("API error for !lastshow: %s", e)
|
LOGGER.warning("API error for !lastshow: %s", e)
|
||||||
bot.say(e.detail)
|
bot.say(e.detail)
|
||||||
@@ -324,7 +331,7 @@ def ntr_lastshow(bot, trigger):
|
|||||||
return
|
return
|
||||||
prev_show_id = shows[1]["id"]
|
prev_show_id = shows[1]["id"]
|
||||||
try:
|
try:
|
||||||
data = _api_get(base_url, f"/shows/{prev_show_id}")
|
data = _api_get(base_url, f"/shows/{prev_show_id}", token)
|
||||||
except ApiError as e:
|
except ApiError as e:
|
||||||
LOGGER.warning("API error for !lastshow show %s: %s", prev_show_id, e)
|
LOGGER.warning("API error for !lastshow show %s: %s", prev_show_id, e)
|
||||||
bot.say(e.detail)
|
bot.say(e.detail)
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Depends, Header
|
from fastapi import FastAPI, HTTPException, Depends, Header
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ntr_fetcher.db import Database
|
from ntr_fetcher.db import Database
|
||||||
from ntr_fetcher.week import get_current_show_week
|
from ntr_fetcher.week import get_current_show_week
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +69,7 @@ def create_app(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/playlist")
|
@app.get("/playlist")
|
||||||
def playlist():
|
def playlist(_=Depends(_require_admin)):
|
||||||
show = _current_show()
|
show = _current_show()
|
||||||
tracks = db.get_show_tracks(show.id)
|
tracks = db.get_show_tracks(show.id)
|
||||||
return {
|
return {
|
||||||
@@ -77,7 +81,7 @@ def create_app(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/playlist/{position}")
|
@app.get("/playlist/{position}")
|
||||||
def playlist_track(position: int):
|
def playlist_track(position: int, _=Depends(_require_admin)):
|
||||||
show = _current_show()
|
show = _current_show()
|
||||||
track = db.get_show_track_by_position(show.id, position)
|
track = db.get_show_track_by_position(show.id, position)
|
||||||
if track is None:
|
if track is None:
|
||||||
@@ -85,7 +89,7 @@ def create_app(
|
|||||||
return track
|
return track
|
||||||
|
|
||||||
@app.get("/shows")
|
@app.get("/shows")
|
||||||
def list_shows(limit: int = 20, offset: int = 0):
|
def list_shows(limit: int = 20, offset: int = 0, _=Depends(_require_admin)):
|
||||||
shows = db.list_shows(limit=limit, offset=offset)
|
shows = db.list_shows(limit=limit, offset=offset)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -99,7 +103,7 @@ def create_app(
|
|||||||
]
|
]
|
||||||
|
|
||||||
@app.get("/shows/by-episode/{episode_number}")
|
@app.get("/shows/by-episode/{episode_number}")
|
||||||
def show_by_episode(episode_number: int):
|
def show_by_episode(episode_number: int, _=Depends(_require_admin)):
|
||||||
show = db.get_show_by_episode_number(episode_number)
|
show = db.get_show_by_episode_number(episode_number)
|
||||||
if show is None:
|
if show is None:
|
||||||
raise HTTPException(status_code=404, detail=f"No show with episode number {episode_number}")
|
raise HTTPException(status_code=404, detail=f"No show with episode number {episode_number}")
|
||||||
@@ -113,7 +117,7 @@ def create_app(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/shows/{show_id}")
|
@app.get("/shows/{show_id}")
|
||||||
def show_detail(show_id: int):
|
def show_detail(show_id: int, _=Depends(_require_admin)):
|
||||||
shows = db.list_shows(limit=1000, offset=0)
|
shows = db.list_shows(limit=1000, offset=0)
|
||||||
show = next((s for s in shows if s.id == show_id), None)
|
show = next((s for s in shows if s.id == show_id), None)
|
||||||
if show is None:
|
if show is None:
|
||||||
@@ -127,6 +131,70 @@ def create_app(
|
|||||||
"tracks": tracks,
|
"tracks": tracks,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Public endpoints (no auth) ---
|
||||||
|
|
||||||
|
_PUBLIC_TRACK_FIELDS = ("position", "announced", "title", "artist",
|
||||||
|
"artwork_url", "permalink_url", "duration_ms")
|
||||||
|
|
||||||
|
def _sanitize_track(t: dict) -> dict:
|
||||||
|
"""Return only the fields the public frontend needs."""
|
||||||
|
return {k: t[k] for k in _PUBLIC_TRACK_FIELDS if k in t}
|
||||||
|
|
||||||
|
def _censor_tracks(tracks: list[dict]) -> list[dict]:
|
||||||
|
"""Strip details from unannounced tracks, sanitize announced ones."""
|
||||||
|
result = []
|
||||||
|
for t in tracks:
|
||||||
|
if t.get("announced"):
|
||||||
|
result.append(_sanitize_track(t))
|
||||||
|
else:
|
||||||
|
result.append({"position": t["position"], "announced": 0})
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def index_page():
|
||||||
|
html = (STATIC_DIR / "index.html").read_text()
|
||||||
|
return HTMLResponse(html)
|
||||||
|
|
||||||
|
@app.get("/public/playlist")
|
||||||
|
def public_playlist():
|
||||||
|
show = _current_show()
|
||||||
|
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": _censor_tracks(tracks),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/public/shows")
|
||||||
|
def public_list_shows(limit: int = 50, offset: int = 0):
|
||||||
|
shows = db.list_shows(limit=limit, offset=offset)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": s.id,
|
||||||
|
"episode_number": s.episode_number,
|
||||||
|
"week_start": s.week_start.isoformat(),
|
||||||
|
"week_end": s.week_end.isoformat(),
|
||||||
|
}
|
||||||
|
for s in shows
|
||||||
|
]
|
||||||
|
|
||||||
|
@app.get("/public/shows/{show_id}")
|
||||||
|
def public_show_detail(show_id: int):
|
||||||
|
shows = db.list_shows(limit=1000, offset=0)
|
||||||
|
show = next((s for s in shows if s.id == show_id), None)
|
||||||
|
if show is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Show not found")
|
||||||
|
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": [_sanitize_track(t) for t in tracks],
|
||||||
|
}
|
||||||
|
|
||||||
@app.post("/admin/refresh")
|
@app.post("/admin/refresh")
|
||||||
async def admin_refresh(body: RefreshRequest = RefreshRequest(), _=Depends(_require_admin)):
|
async def admin_refresh(body: RefreshRequest = RefreshRequest(), _=Depends(_require_admin)):
|
||||||
await poller.poll_once(full=body.full)
|
await poller.poll_once(full=body.full)
|
||||||
@@ -158,11 +226,13 @@ def create_app(
|
|||||||
|
|
||||||
if all([web_user, web_password, secret_key]):
|
if all([web_user, web_password, secret_key]):
|
||||||
from ntr_fetcher.dashboard import create_dashboard_router
|
from ntr_fetcher.dashboard import create_dashboard_router
|
||||||
from ntr_fetcher.websocket import AnnounceManager
|
from ntr_fetcher.websocket import AnnounceManager, PublicManager
|
||||||
manager = AnnounceManager()
|
manager = AnnounceManager()
|
||||||
|
public_manager = PublicManager()
|
||||||
dashboard_router = create_dashboard_router(
|
dashboard_router = create_dashboard_router(
|
||||||
db=db,
|
db=db,
|
||||||
manager=manager,
|
manager=manager,
|
||||||
|
public_manager=public_manager,
|
||||||
admin_token=admin_token,
|
admin_token=admin_token,
|
||||||
web_user=web_user,
|
web_user=web_user,
|
||||||
web_password=web_password,
|
web_password=web_password,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ntr_fetcher.db import Database
|
from ntr_fetcher.db import Database
|
||||||
from ntr_fetcher.websocket import AnnounceManager
|
from ntr_fetcher.websocket import AnnounceManager, PublicManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,6 +44,12 @@ class AnnounceRequest(BaseModel):
|
|||||||
position: int
|
position: int
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncedRequest(BaseModel):
|
||||||
|
show_id: int
|
||||||
|
position: int
|
||||||
|
announced: bool
|
||||||
|
|
||||||
|
|
||||||
class PingRequest(BaseModel):
|
class PingRequest(BaseModel):
|
||||||
target: str
|
target: str
|
||||||
message: str
|
message: str
|
||||||
@@ -52,6 +58,7 @@ class PingRequest(BaseModel):
|
|||||||
def create_dashboard_router(
|
def create_dashboard_router(
|
||||||
db: Database,
|
db: Database,
|
||||||
manager: AnnounceManager,
|
manager: AnnounceManager,
|
||||||
|
public_manager: PublicManager,
|
||||||
admin_token: str,
|
admin_token: str,
|
||||||
web_user: str,
|
web_user: str,
|
||||||
web_password: str,
|
web_password: str,
|
||||||
@@ -126,8 +133,52 @@ def create_dashboard_router(
|
|||||||
f"{track['title']} by {track['artist']} - {track['permalink_url']}"
|
f"{track['title']} by {track['artist']} - {track['permalink_url']}"
|
||||||
)
|
)
|
||||||
await manager.broadcast({"type": "announce", "message": message})
|
await manager.broadcast({"type": "announce", "message": message})
|
||||||
|
db.set_track_announced(body.show_id, body.position, True)
|
||||||
|
await public_manager.broadcast({
|
||||||
|
"type": "reveal",
|
||||||
|
"position": track["position"],
|
||||||
|
"track": {
|
||||||
|
"title": track["title"],
|
||||||
|
"artist": track["artist"],
|
||||||
|
"artwork_url": track["artwork_url"],
|
||||||
|
"permalink_url": track["permalink_url"],
|
||||||
|
"duration_ms": track["duration_ms"],
|
||||||
|
},
|
||||||
|
})
|
||||||
return {"status": "announced", "message": message}
|
return {"status": "announced", "message": message}
|
||||||
|
|
||||||
|
@router.post("/admin/announced")
|
||||||
|
async def set_announced(body: AnnouncedRequest, 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}")
|
||||||
|
|
||||||
|
db.set_track_announced(body.show_id, body.position, body.announced)
|
||||||
|
if body.announced:
|
||||||
|
await public_manager.broadcast({
|
||||||
|
"type": "reveal",
|
||||||
|
"position": track["position"],
|
||||||
|
"track": {
|
||||||
|
"title": track["title"],
|
||||||
|
"artist": track["artist"],
|
||||||
|
"artwork_url": track["artwork_url"],
|
||||||
|
"permalink_url": track["permalink_url"],
|
||||||
|
"duration_ms": track["duration_ms"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
await public_manager.broadcast({
|
||||||
|
"type": "hide",
|
||||||
|
"position": track["position"],
|
||||||
|
})
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
@router.post("/admin/ping")
|
@router.post("/admin/ping")
|
||||||
async def ping(body: PingRequest, request: Request):
|
async def ping(body: PingRequest, request: Request):
|
||||||
user = _get_session_user(request)
|
user = _get_session_user(request)
|
||||||
@@ -175,4 +226,16 @@ def create_dashboard_router(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@router.websocket("/ws/public")
|
||||||
|
async def ws_public(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
public_manager.add_client(websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
public_manager.remove_client(websocket)
|
||||||
|
|
||||||
return router
|
return router
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ class Database:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
conn.execute("ALTER TABLE show_tracks ADD COLUMN announced INTEGER NOT NULL DEFAULT 0")
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def upsert_track(self, track: Track) -> None:
|
def upsert_track(self, track: Track) -> None:
|
||||||
@@ -154,9 +159,9 @@ class Database:
|
|||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT st.show_id, st.track_id, st.position, t.title, t.artist,
|
SELECT st.show_id, st.track_id, st.position, st.announced,
|
||||||
t.permalink_url, t.artwork_url, t.duration_ms, t.license,
|
t.title, t.artist, t.permalink_url, t.artwork_url,
|
||||||
t.liked_at, t.raw_json
|
t.duration_ms, t.license, t.liked_at, t.raw_json
|
||||||
FROM show_tracks st
|
FROM show_tracks st
|
||||||
JOIN tracks t ON st.track_id = t.id
|
JOIN tracks t ON st.track_id = t.id
|
||||||
WHERE st.show_id = ?
|
WHERE st.show_id = ?
|
||||||
@@ -173,9 +178,9 @@ class Database:
|
|||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT st.show_id, st.track_id, st.position, t.title, t.artist,
|
SELECT st.show_id, st.track_id, st.position, st.announced,
|
||||||
t.permalink_url, t.artwork_url, t.duration_ms, t.license,
|
t.title, t.artist, t.permalink_url, t.artwork_url,
|
||||||
t.liked_at, t.raw_json
|
t.duration_ms, t.license, t.liked_at, t.raw_json
|
||||||
FROM show_tracks st
|
FROM show_tracks st
|
||||||
JOIN tracks t ON st.track_id = t.id
|
JOIN tracks t ON st.track_id = t.id
|
||||||
WHERE st.show_id = ? AND st.position = ?
|
WHERE st.show_id = ? AND st.position = ?
|
||||||
@@ -185,6 +190,17 @@ class Database:
|
|||||||
conn.close()
|
conn.close()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def set_track_announced(
|
||||||
|
self, show_id: int, position: int, announced: bool
|
||||||
|
) -> None:
|
||||||
|
conn = self._connect()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE show_tracks SET announced = ? WHERE show_id = ? AND position = ?",
|
||||||
|
(int(announced), show_id, position),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def set_show_tracks(self, show_id: int, track_ids: list[int]) -> None:
|
def set_show_tracks(self, show_id: int, track_ids: list[int]) -> None:
|
||||||
conn = self._connect()
|
conn = self._connect()
|
||||||
if track_ids:
|
if track_ids:
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ def run() -> None:
|
|||||||
|
|
||||||
if args.init:
|
if args.init:
|
||||||
sc = SoundCloudClient()
|
sc = SoundCloudClient()
|
||||||
asyncio.run(
|
|
||||||
run_backfill(
|
async def _backfill_and_close():
|
||||||
|
try:
|
||||||
|
await run_backfill(
|
||||||
db=db,
|
db=db,
|
||||||
soundcloud=sc,
|
soundcloud=sc,
|
||||||
soundcloud_user=settings.soundcloud_user,
|
soundcloud_user=settings.soundcloud_user,
|
||||||
@@ -59,8 +61,10 @@ def run() -> None:
|
|||||||
anchor_episode=args.show,
|
anchor_episode=args.show,
|
||||||
anchor_aired=args.aired,
|
anchor_aired=args.aired,
|
||||||
)
|
)
|
||||||
)
|
finally:
|
||||||
asyncio.run(sc.close())
|
await sc.close()
|
||||||
|
|
||||||
|
asyncio.run(_backfill_and_close())
|
||||||
logger.info("Backfill complete")
|
logger.info("Backfill complete")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,8 @@
|
|||||||
border-bottom-color: #4caf50;
|
border-bottom-color: #4caf50;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.btn-group { display: flex; gap: 4px; }
|
.btn-group { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.announced-check { accent-color: #4caf50; cursor: pointer; width: 28px; height: 28px; margin: 0; }
|
||||||
.ping-section {
|
.ping-section {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
padding-top: 1.5rem;
|
padding-top: 1.5rem;
|
||||||
@@ -130,6 +131,10 @@
|
|||||||
let showCache = {};
|
let showCache = {};
|
||||||
let activeShowId = null;
|
let activeShowId = null;
|
||||||
|
|
||||||
|
function authFetch(url) {
|
||||||
|
return fetch(url, { headers: { "Authorization": "Bearer " + WS_TOKEN } });
|
||||||
|
}
|
||||||
|
|
||||||
function showToast(msg, isError) {
|
function showToast(msg, isError) {
|
||||||
const t = document.getElementById("toast");
|
const t = document.getElementById("toast");
|
||||||
t.textContent = msg;
|
t.textContent = msg;
|
||||||
@@ -149,7 +154,10 @@
|
|||||||
html += '<th class="track-num">#</th><th>Title</th><th>Artist</th><th></th>';
|
html += '<th class="track-num">#</th><th>Title</th><th>Artist</th><th></th>';
|
||||||
html += '</tr></thead><tbody>';
|
html += '</tr></thead><tbody>';
|
||||||
for (const t of tracks) {
|
for (const t of tracks) {
|
||||||
const disabled = subscriberCount === 0 ? 'disabled title="No bots connected"' : "";
|
const noBot = subscriberCount === 0;
|
||||||
|
const announced = !!t.announced;
|
||||||
|
const annDisabled = (noBot || announced) ? 'disabled' : '';
|
||||||
|
const annTitle = noBot ? 'title="No bots connected"' : announced ? 'title="Already announced"' : '';
|
||||||
const copyText = `${t.title} by ${t.artist} - ${t.permalink_url}`;
|
const copyText = `${t.title} by ${t.artist} - ${t.permalink_url}`;
|
||||||
html += `<tr>
|
html += `<tr>
|
||||||
<td class="track-num">${t.position}</td>
|
<td class="track-num">${t.position}</td>
|
||||||
@@ -159,8 +167,11 @@
|
|||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn-sm copy-btn outline"
|
<button class="btn-sm copy-btn outline"
|
||||||
onclick="copyTrack(this, '${esc(copyText).replace(/'/g, "\\'")}')">Copy</button>
|
onclick="copyTrack(this, '${esc(copyText).replace(/'/g, "\\'")}')">Copy</button>
|
||||||
<button class="btn-sm announce-btn" ${disabled}
|
<button class="btn-sm announce-btn" ${annDisabled} ${annTitle}
|
||||||
onclick="announce(${showId}, ${t.position}, this)">Announce</button>
|
onclick="announce(${showId}, ${t.position}, this)">Announce</button>
|
||||||
|
<input type="checkbox" class="announced-check"
|
||||||
|
${announced ? 'checked' : ''}
|
||||||
|
onchange="toggleAnnounced(${showId}, ${t.position}, this)">
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
@@ -171,7 +182,7 @@
|
|||||||
|
|
||||||
async function loadAllShows() {
|
async function loadAllShows() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/shows?limit=200");
|
const resp = await authFetch("/shows?limit=200");
|
||||||
if (!resp.ok) throw new Error("Failed to load shows");
|
if (!resp.ok) throw new Error("Failed to load shows");
|
||||||
allShows = await resp.json();
|
allShows = await resp.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -179,7 +190,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/playlist");
|
const resp = await authFetch("/playlist");
|
||||||
if (!resp.ok) throw new Error("Failed to load current playlist");
|
if (!resp.ok) throw new Error("Failed to load current playlist");
|
||||||
const current = await resp.json();
|
const current = await resp.json();
|
||||||
showCache[current.show_id] = current;
|
showCache[current.show_id] = current;
|
||||||
@@ -228,7 +239,7 @@
|
|||||||
|
|
||||||
document.getElementById("show-content").innerHTML = "<p>Loading...</p>";
|
document.getElementById("show-content").innerHTML = "<p>Loading...</p>";
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/shows/${showId}`);
|
const resp = await authFetch(`/shows/${showId}`);
|
||||||
if (!resp.ok) throw new Error("Failed to load show");
|
if (!resp.ok) throw new Error("Failed to load show");
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
showCache[showId] = data;
|
showCache[showId] = data;
|
||||||
@@ -275,10 +286,18 @@
|
|||||||
}
|
}
|
||||||
btn.textContent = "\u2713";
|
btn.textContent = "\u2713";
|
||||||
btn.classList.add("success");
|
btn.classList.add("success");
|
||||||
|
const cb = btn.closest("tr").querySelector(".announced-check");
|
||||||
|
if (cb) cb.checked = true;
|
||||||
|
const cached = showCache[showId];
|
||||||
|
if (cached) {
|
||||||
|
const track = cached.tracks.find(t => t.position === position);
|
||||||
|
if (track) track.announced = 1;
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.textContent = "Announce";
|
btn.textContent = "Announce";
|
||||||
btn.classList.remove("success");
|
btn.classList.remove("success");
|
||||||
btn.disabled = false;
|
btn.disabled = true;
|
||||||
|
btn.title = "Already announced";
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, true);
|
showToast(e.message, true);
|
||||||
@@ -287,6 +306,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleAnnounced(showId, position, cb) {
|
||||||
|
const newVal = cb.checked;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/admin/announced", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify({show_id: showId, position: position, announced: newVal}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || "Failed to update");
|
||||||
|
}
|
||||||
|
const cached = showCache[showId];
|
||||||
|
if (cached) {
|
||||||
|
const track = cached.tracks.find(t => t.position === position);
|
||||||
|
if (track) track.announced = newVal ? 1 : 0;
|
||||||
|
}
|
||||||
|
const annBtn = cb.closest("tr").querySelector(".announce-btn");
|
||||||
|
if (annBtn) {
|
||||||
|
if (newVal) {
|
||||||
|
annBtn.disabled = true;
|
||||||
|
annBtn.title = "Already announced";
|
||||||
|
} else if (subscriberCount > 0) {
|
||||||
|
annBtn.disabled = false;
|
||||||
|
annBtn.title = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
cb.checked = !newVal;
|
||||||
|
showToast(e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function sendPing(btn) {
|
async function sendPing(btn) {
|
||||||
const target = document.getElementById("ping-target").value.trim();
|
const target = document.getElementById("ping-target").value.trim();
|
||||||
const message = document.getElementById("ping-message").value.trim();
|
const message = document.getElementById("ping-message").value.trim();
|
||||||
@@ -344,9 +396,12 @@
|
|||||||
detail.style.display = "none";
|
detail.style.display = "none";
|
||||||
}
|
}
|
||||||
document.querySelectorAll(".announce-btn").forEach(btn => {
|
document.querySelectorAll(".announce-btn").forEach(btn => {
|
||||||
if (count === 0) {
|
const row = btn.closest("tr");
|
||||||
|
const cb = row ? row.querySelector(".announced-check") : null;
|
||||||
|
const isAnnounced = cb && cb.checked;
|
||||||
|
if (count === 0 || isAnnounced) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.title = "No bots connected";
|
btn.title = isAnnounced ? "Already announced" : "No bots connected";
|
||||||
} else {
|
} else {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.title = "";
|
btn.title = "";
|
||||||
|
|||||||
435
src/ntr_fetcher/static/index.html
Normal file
435
src/ntr_fetcher/static/index.html
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
<!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</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Work+Sans:wght@400;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ntr-bg: #000;
|
||||||
|
--ntr-card: #111;
|
||||||
|
--ntr-card-hidden: #0a0a0a;
|
||||||
|
--ntr-text: #ededed;
|
||||||
|
--ntr-muted: #888;
|
||||||
|
--ntr-dim: #444;
|
||||||
|
--ntr-accent: #f70;
|
||||||
|
--ntr-border: #222;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: var(--ntr-bg);
|
||||||
|
color: var(--ntr-text);
|
||||||
|
font-family: 'Work Sans', sans-serif;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
font-family: 'Press Start 2P', cursive;
|
||||||
|
}
|
||||||
|
header { text-align: center; margin-bottom: 1.5rem; }
|
||||||
|
header h1 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
header small { color: var(--ntr-muted); font-family: 'Work Sans', sans-serif; }
|
||||||
|
|
||||||
|
.track-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.track-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--ntr-card);
|
||||||
|
border: 1px solid var(--ntr-border);
|
||||||
|
min-height: 80px;
|
||||||
|
transition: background 0.3s, opacity 0.3s, transform 0.3s;
|
||||||
|
}
|
||||||
|
.track-card.hidden-track {
|
||||||
|
background: var(--ntr-card-hidden);
|
||||||
|
border-color: #181818;
|
||||||
|
}
|
||||||
|
.track-card.revealing {
|
||||||
|
animation: revealCard 0.5s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes revealCard {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-num {
|
||||||
|
font-family: 'Press Start 2P', cursive;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ntr-dim);
|
||||||
|
min-width: 36px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.hidden-track .track-num { color: #2a2a2a; }
|
||||||
|
|
||||||
|
.track-art {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #1a1a1a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.track-art-placeholder {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #2a2a2a;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-info { flex: 1; min-width: 0; }
|
||||||
|
.track-title {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.track-artist {
|
||||||
|
color: var(--ntr-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.hidden-track .track-info-placeholder {
|
||||||
|
height: 1em;
|
||||||
|
width: 60%;
|
||||||
|
background: #181818;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.hidden-track .track-info-placeholder.short {
|
||||||
|
width: 35%;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.track-duration { color: #666; font-size: 0.85rem; }
|
||||||
|
.track-link {
|
||||||
|
color: var(--ntr-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.track-link:hover { text-decoration: underline; color: #ff9933; }
|
||||||
|
.hidden-track .track-meta-placeholder {
|
||||||
|
width: 50px;
|
||||||
|
height: 1em;
|
||||||
|
background: #181818;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-divider {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--ntr-border);
|
||||||
|
}
|
||||||
|
.section-divider h3 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.past-episodes {
|
||||||
|
list-style: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 0 0.5rem 0 !important;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.past-episodes li {
|
||||||
|
list-style: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
.past-episodes li::before {
|
||||||
|
content: none !important;
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.past-episodes li::marker {
|
||||||
|
content: none;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ep-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--ntr-card);
|
||||||
|
border: 1px solid var(--ntr-border);
|
||||||
|
color: #bbb;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: 'Work Sans', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.ep-btn:hover { background: #1a1a1a; border-color: #333; color: #fff; }
|
||||||
|
.ep-btn.active { background: var(--ntr-accent); border-color: var(--ntr-accent); color: #fff; }
|
||||||
|
|
||||||
|
#past-show-content { margin-top: 1rem; }
|
||||||
|
#past-show-content h4 {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
#past-show-content h4 small {
|
||||||
|
font-family: 'Work Sans', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-status {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #333;
|
||||||
|
margin-left: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.ws-status.connected { background: #4caf50; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<header>
|
||||||
|
<h1>NtR Playlist <span class="ws-status" id="ws-dot" title="Live connection"></span></h1>
|
||||||
|
<small id="show-subtitle">Loading...</small>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="current-show">
|
||||||
|
<ul class="track-list" id="track-list">
|
||||||
|
<li>Loading playlist...</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section-divider" id="past-section" style="display:none">
|
||||||
|
<h3>Past Episodes</h3>
|
||||||
|
<ul class="past-episodes" id="past-list"></ul>
|
||||||
|
<div id="past-show-content"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentShowId = null;
|
||||||
|
let trackData = {};
|
||||||
|
let activePastShowId = null;
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = s || "";
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms) {
|
||||||
|
const totalSec = Math.floor(ms / 1000);
|
||||||
|
const min = Math.floor(totalSec / 60);
|
||||||
|
const sec = totalSec % 60;
|
||||||
|
return min + ":" + String(sec).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateRange(start, end) {
|
||||||
|
const opts = { month: "short", day: "numeric" };
|
||||||
|
const s = new Date(start);
|
||||||
|
const e = new Date(end);
|
||||||
|
return s.toLocaleDateString(undefined, opts) + " \u2013 " + e.toLocaleDateString(undefined, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTrackCard(t) {
|
||||||
|
if (!t.announced) {
|
||||||
|
return '<li class="track-card hidden-track" data-position="' + t.position + '">'
|
||||||
|
+ '<span class="track-num">' + t.position + '</span>'
|
||||||
|
+ '<div class="track-art-placeholder"></div>'
|
||||||
|
+ '<div class="track-info">'
|
||||||
|
+ '<div class="track-info-placeholder"></div>'
|
||||||
|
+ '<div class="track-info-placeholder short"></div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="track-meta">'
|
||||||
|
+ '<span class="track-meta-placeholder"></span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</li>';
|
||||||
|
}
|
||||||
|
const artHtml = t.artwork_url
|
||||||
|
? '<img class="track-art" src="' + esc(t.artwork_url) + '" alt="">'
|
||||||
|
: '<div class="track-art-placeholder">♫</div>';
|
||||||
|
return '<li class="track-card" data-position="' + t.position + '">'
|
||||||
|
+ '<span class="track-num">' + t.position + '</span>'
|
||||||
|
+ artHtml
|
||||||
|
+ '<div class="track-info">'
|
||||||
|
+ '<div class="track-title">' + esc(t.title) + '</div>'
|
||||||
|
+ '<div class="track-artist">' + esc(t.artist) + '</div>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="track-meta">'
|
||||||
|
+ '<span class="track-duration">' + formatDuration(t.duration_ms) + '</span>'
|
||||||
|
+ '<a class="track-link" href="' + esc(t.permalink_url) + '" target="_blank" rel="noopener">SoundCloud</a>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</li>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCurrentShow(data) {
|
||||||
|
currentShowId = data.show_id;
|
||||||
|
trackData = {};
|
||||||
|
for (const t of data.tracks) {
|
||||||
|
trackData[t.position] = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ep = data.episode_number ? "Episode " + data.episode_number : "This Week\u2019s Show";
|
||||||
|
const range = formatDateRange(data.week_start, data.week_end);
|
||||||
|
document.getElementById("show-subtitle").textContent = ep + " \u2014 " + range;
|
||||||
|
|
||||||
|
const list = document.getElementById("track-list");
|
||||||
|
if (!data.tracks.length) {
|
||||||
|
list.innerHTML = "<li>No tracks yet.</li>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = data.tracks.map(renderTrackCard).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealTrack(position, track) {
|
||||||
|
trackData[position] = Object.assign({}, trackData[position] || { position: position }, track, { announced: 1 });
|
||||||
|
const card = document.querySelector('[data-position="' + position + '"]');
|
||||||
|
if (!card) return;
|
||||||
|
const temp = document.createElement("div");
|
||||||
|
temp.innerHTML = renderTrackCard(trackData[position]);
|
||||||
|
const newCard = temp.firstChild;
|
||||||
|
newCard.classList.add("revealing");
|
||||||
|
card.replaceWith(newCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideTrack(position) {
|
||||||
|
if (trackData[position]) {
|
||||||
|
trackData[position] = { position: position, announced: 0 };
|
||||||
|
}
|
||||||
|
const card = document.querySelector('[data-position="' + position + '"]');
|
||||||
|
if (!card) return;
|
||||||
|
const temp = document.createElement("div");
|
||||||
|
temp.innerHTML = renderTrackCard({ position: position, announced: 0 });
|
||||||
|
card.replaceWith(temp.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCurrentShow() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/public/playlist");
|
||||||
|
if (!resp.ok) throw new Error("Failed to load playlist");
|
||||||
|
const data = await resp.json();
|
||||||
|
renderCurrentShow(data);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("track-list").innerHTML = "<li>Failed to load playlist.</li>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPastEpisodes() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/public/shows?limit=100");
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const shows = await resp.json();
|
||||||
|
const past = shows.filter(s => s.id !== currentShowId);
|
||||||
|
if (!past.length) return;
|
||||||
|
|
||||||
|
document.getElementById("past-section").style.display = "";
|
||||||
|
const list = document.getElementById("past-list");
|
||||||
|
list.innerHTML = past.map(s => {
|
||||||
|
const label = s.episode_number ? "Ep " + s.episode_number : "Show " + s.id;
|
||||||
|
return '<li><button class="ep-btn" data-show-id="' + s.id + '">' + esc(label) + '</button></li>';
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
list.addEventListener("click", function(e) {
|
||||||
|
const btn = e.target.closest("button[data-show-id]");
|
||||||
|
if (!btn) return;
|
||||||
|
const showId = parseInt(btn.dataset.showId, 10);
|
||||||
|
|
||||||
|
if (activePastShowId === showId) {
|
||||||
|
btn.classList.remove("active");
|
||||||
|
activePastShowId = null;
|
||||||
|
document.getElementById("past-show-content").innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.querySelectorAll(".ep-btn").forEach(el => el.classList.remove("active"));
|
||||||
|
btn.classList.add("active");
|
||||||
|
activePastShowId = showId;
|
||||||
|
loadPastShow(showId);
|
||||||
|
});
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPastShow(showId) {
|
||||||
|
const content = document.getElementById("past-show-content");
|
||||||
|
content.innerHTML = "<p>Loading...</p>";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/public/shows/" + showId);
|
||||||
|
if (!resp.ok) throw new Error("Failed to load show");
|
||||||
|
const data = await resp.json();
|
||||||
|
const ep = data.episode_number ? "Episode " + data.episode_number : "Show " + data.show_id;
|
||||||
|
const range = formatDateRange(data.week_start, data.week_end);
|
||||||
|
let html = '<h4>' + esc(ep) + ' <small>(' + esc(range) + ')</small></h4>';
|
||||||
|
html += '<ul class="track-list">';
|
||||||
|
for (const t of data.tracks) {
|
||||||
|
const full = Object.assign({}, t, { announced: 1 });
|
||||||
|
html += renderTrackCard(full);
|
||||||
|
}
|
||||||
|
html += '</ul>';
|
||||||
|
content.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
content.innerHTML = "<p>Failed to load show.</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let wsBackoff = 1000;
|
||||||
|
function connectWS() {
|
||||||
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const ws = new WebSocket(proto + "//" + location.host + "/ws/public");
|
||||||
|
const dot = document.getElementById("ws-dot");
|
||||||
|
|
||||||
|
ws.onopen = function() {
|
||||||
|
dot.classList.add("connected");
|
||||||
|
dot.title = "Live";
|
||||||
|
wsBackoff = 1000;
|
||||||
|
};
|
||||||
|
ws.onmessage = function(e) {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === "reveal") {
|
||||||
|
revealTrack(msg.position, msg.track);
|
||||||
|
} else if (msg.type === "hide") {
|
||||||
|
hideTrack(msg.position);
|
||||||
|
}
|
||||||
|
} catch (err) { /* ignore malformed */ }
|
||||||
|
};
|
||||||
|
ws.onclose = function() {
|
||||||
|
dot.classList.remove("connected");
|
||||||
|
dot.title = "Disconnected \u2014 reconnecting...";
|
||||||
|
setTimeout(connectWS, wsBackoff);
|
||||||
|
wsBackoff = Math.min(wsBackoff * 2, 60000);
|
||||||
|
};
|
||||||
|
ws.onerror = function() { ws.close(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCurrentShow().then(loadPastEpisodes);
|
||||||
|
connectWS();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -72,3 +72,33 @@ class AnnounceManager:
|
|||||||
"subscribers": self.bot_count,
|
"subscribers": self.bot_count,
|
||||||
"clients": self.bot_clients,
|
"clients": self.bot_clients,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class PublicManager:
|
||||||
|
"""Lightweight broadcast-only manager for unauthenticated public viewers."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._websockets: list[object] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client_count(self) -> int:
|
||||||
|
return len(self._websockets)
|
||||||
|
|
||||||
|
def add_client(self, websocket) -> None:
|
||||||
|
self._websockets.append(websocket)
|
||||||
|
logger.info("Public client connected (%d total)", self.client_count)
|
||||||
|
|
||||||
|
def remove_client(self, websocket) -> None:
|
||||||
|
self._websockets = [ws for ws in self._websockets if ws is not websocket]
|
||||||
|
logger.info("Public client disconnected (%d total)", self.client_count)
|
||||||
|
|
||||||
|
async def broadcast(self, message: dict) -> None:
|
||||||
|
dead = []
|
||||||
|
for ws in self._websockets:
|
||||||
|
try:
|
||||||
|
await ws.send_json(message)
|
||||||
|
except Exception:
|
||||||
|
dead.append(ws)
|
||||||
|
logger.warning("Removing dead public client")
|
||||||
|
for ws in dead:
|
||||||
|
self.remove_client(ws)
|
||||||
|
|||||||
@@ -57,9 +57,12 @@ def test_health(client):
|
|||||||
assert data["poller_alive"] is True
|
assert data["poller_alive"] is True
|
||||||
|
|
||||||
|
|
||||||
|
AUTH = {"Authorization": "Bearer test-token"}
|
||||||
|
|
||||||
|
|
||||||
def test_playlist(client, db):
|
def test_playlist(client, db):
|
||||||
_seed_show(db)
|
_seed_show(db)
|
||||||
resp = client.get("/playlist")
|
resp = client.get("/playlist", headers=AUTH)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "episode_number" in data
|
assert "episode_number" in data
|
||||||
@@ -70,20 +73,20 @@ def test_playlist(client, db):
|
|||||||
|
|
||||||
def test_playlist_by_position(client, db):
|
def test_playlist_by_position(client, db):
|
||||||
_seed_show(db)
|
_seed_show(db)
|
||||||
resp = client.get("/playlist/2")
|
resp = client.get("/playlist/2", headers=AUTH)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["title"] == "Song B"
|
assert resp.json()["title"] == "Song B"
|
||||||
|
|
||||||
|
|
||||||
def test_playlist_by_position_not_found(client, db):
|
def test_playlist_by_position_not_found(client, db):
|
||||||
_seed_show(db)
|
_seed_show(db)
|
||||||
resp = client.get("/playlist/99")
|
resp = client.get("/playlist/99", headers=AUTH)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_shows_list(client, db):
|
def test_shows_list(client, db):
|
||||||
_seed_show(db)
|
_seed_show(db)
|
||||||
resp = client.get("/shows")
|
resp = client.get("/shows", headers=AUTH)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert len(data) >= 1
|
assert len(data) >= 1
|
||||||
@@ -92,7 +95,7 @@ def test_shows_list(client, db):
|
|||||||
|
|
||||||
def test_shows_detail(client, db):
|
def test_shows_detail(client, db):
|
||||||
show = _seed_show(db)
|
show = _seed_show(db)
|
||||||
resp = client.get(f"/shows/{show.id}")
|
resp = client.get(f"/shows/{show.id}", headers=AUTH)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "episode_number" in data
|
assert "episode_number" in data
|
||||||
@@ -132,7 +135,7 @@ def test_show_by_episode(client, db):
|
|||||||
datetime(2026, 3, 14, 1, 0, 0, tzinfo=timezone.utc), "{}")
|
datetime(2026, 3, 14, 1, 0, 0, tzinfo=timezone.utc), "{}")
|
||||||
db.upsert_track(t1)
|
db.upsert_track(t1)
|
||||||
db.set_show_tracks(show.id, [t1.id])
|
db.set_show_tracks(show.id, [t1.id])
|
||||||
resp = client.get("/shows/by-episode/530")
|
resp = client.get("/shows/by-episode/530", headers=AUTH)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["episode_number"] == 530
|
assert data["episode_number"] == 530
|
||||||
@@ -140,7 +143,7 @@ def test_show_by_episode(client, db):
|
|||||||
|
|
||||||
|
|
||||||
def test_show_by_episode_not_found(client):
|
def test_show_by_episode_not_found(client):
|
||||||
resp = client.get("/shows/by-episode/999")
|
resp = client.get("/shows/by-episode/999", headers=AUTH)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@@ -152,3 +155,112 @@ def test_no_dashboard_routes_without_config(client):
|
|||||||
def test_no_login_route_without_config(client):
|
def test_no_login_route_without_config(client):
|
||||||
resp = client.get("/login")
|
resp = client.get("/login")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# --- Auth-required (401) tests ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_playlist_requires_auth(client, db):
|
||||||
|
_seed_show(db)
|
||||||
|
resp = client.get("/playlist")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_playlist_with_token(client, db):
|
||||||
|
_seed_show(db)
|
||||||
|
resp = client.get("/playlist", headers=AUTH)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_playlist_by_position_requires_auth(client, db):
|
||||||
|
_seed_show(db)
|
||||||
|
resp = client.get("/playlist/1")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_shows_requires_auth(client):
|
||||||
|
resp = client.get("/shows")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_shows_detail_requires_auth(client, db):
|
||||||
|
show = _seed_show(db)
|
||||||
|
resp = client.get(f"/shows/{show.id}")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_by_episode_requires_auth(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)
|
||||||
|
db.get_or_create_show(week_start, week_end, episode_number=530)
|
||||||
|
resp = client.get("/shows/by-episode/530")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# --- Public endpoint tests ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_page(client):
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "NtR Playlist" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_playlist_censors_unannounced(client, db):
|
||||||
|
show = _seed_show(db)
|
||||||
|
db.set_track_announced(show.id, 1, True)
|
||||||
|
|
||||||
|
resp = client.get("/public/playlist")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data["tracks"]) == 2
|
||||||
|
|
||||||
|
revealed = data["tracks"][0]
|
||||||
|
assert revealed["announced"] == 1
|
||||||
|
assert revealed["title"] == "Song A"
|
||||||
|
assert revealed["artist"] == "Artist A"
|
||||||
|
assert "raw_json" not in revealed
|
||||||
|
assert "track_id" not in revealed
|
||||||
|
assert "show_id" not in revealed
|
||||||
|
|
||||||
|
hidden = data["tracks"][1]
|
||||||
|
assert hidden["announced"] == 0
|
||||||
|
assert hidden["position"] == 2
|
||||||
|
assert "title" not in hidden
|
||||||
|
assert "artist" not in hidden
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_playlist_all_hidden(client, db):
|
||||||
|
_seed_show(db)
|
||||||
|
resp = client.get("/public/playlist")
|
||||||
|
data = resp.json()
|
||||||
|
for t in data["tracks"]:
|
||||||
|
assert t["announced"] == 0
|
||||||
|
assert "title" not in t
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_shows_list(client, db):
|
||||||
|
_seed_show(db)
|
||||||
|
resp = client.get("/public/shows")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) >= 1
|
||||||
|
assert "id" in data[0]
|
||||||
|
assert "episode_number" in data[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_show_detail_fully_revealed(client, db):
|
||||||
|
show = _seed_show(db)
|
||||||
|
resp = client.get(f"/public/shows/{show.id}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data["tracks"]) == 2
|
||||||
|
assert data["tracks"][0]["title"] == "Song A"
|
||||||
|
assert data["tracks"][1]["title"] == "Song B"
|
||||||
|
assert "raw_json" not in data["tracks"][0]
|
||||||
|
assert "track_id" not in data["tracks"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_show_detail_not_found(client):
|
||||||
|
resp = client.get("/public/shows/999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from ntr_fetcher.config import Settings
|
|||||||
|
|
||||||
|
|
||||||
def test_settings_defaults():
|
def test_settings_defaults():
|
||||||
settings = Settings(admin_token="test-secret")
|
settings = Settings(admin_token="test-secret", _env_file=None)
|
||||||
assert settings.port == 8000
|
assert settings.port == 8000
|
||||||
assert settings.host == "127.0.0.1"
|
assert settings.host == "127.0.0.1"
|
||||||
assert settings.db_path == "./ntr_fetcher.db"
|
assert settings.db_path == "./ntr_fetcher.db"
|
||||||
@@ -17,7 +17,7 @@ def test_settings_from_env(monkeypatch):
|
|||||||
monkeypatch.setenv("NTR_HOST", "0.0.0.0")
|
monkeypatch.setenv("NTR_HOST", "0.0.0.0")
|
||||||
monkeypatch.setenv("NTR_ADMIN_TOKEN", "my-secret")
|
monkeypatch.setenv("NTR_ADMIN_TOKEN", "my-secret")
|
||||||
monkeypatch.setenv("NTR_SOUNDCLOUD_USER", "someoneelse")
|
monkeypatch.setenv("NTR_SOUNDCLOUD_USER", "someoneelse")
|
||||||
settings = Settings()
|
settings = Settings(_env_file=None)
|
||||||
assert settings.port == 9090
|
assert settings.port == 9090
|
||||||
assert settings.host == "0.0.0.0"
|
assert settings.host == "0.0.0.0"
|
||||||
assert settings.admin_token == "my-secret"
|
assert settings.admin_token == "my-secret"
|
||||||
@@ -27,7 +27,7 @@ def test_settings_from_env(monkeypatch):
|
|||||||
def test_settings_admin_token_required():
|
def test_settings_admin_token_required():
|
||||||
import pytest
|
import pytest
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
Settings()
|
Settings(_env_file=None)
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_config_absent(monkeypatch):
|
def test_dashboard_config_absent(monkeypatch):
|
||||||
@@ -35,7 +35,7 @@ def test_dashboard_config_absent(monkeypatch):
|
|||||||
monkeypatch.delenv("NTR_WEB_USER", raising=False)
|
monkeypatch.delenv("NTR_WEB_USER", raising=False)
|
||||||
monkeypatch.delenv("NTR_WEB_PASSWORD", raising=False)
|
monkeypatch.delenv("NTR_WEB_PASSWORD", raising=False)
|
||||||
monkeypatch.delenv("NTR_SECRET_KEY", raising=False)
|
monkeypatch.delenv("NTR_SECRET_KEY", raising=False)
|
||||||
s = Settings()
|
s = Settings(_env_file=None)
|
||||||
assert s.web_user is None
|
assert s.web_user is None
|
||||||
assert s.web_password is None
|
assert s.web_password is None
|
||||||
assert s.secret_key is None
|
assert s.secret_key is None
|
||||||
@@ -47,7 +47,7 @@ def test_dashboard_config_present(monkeypatch):
|
|||||||
monkeypatch.setenv("NTR_WEB_USER", "nick")
|
monkeypatch.setenv("NTR_WEB_USER", "nick")
|
||||||
monkeypatch.setenv("NTR_WEB_PASSWORD", "secret")
|
monkeypatch.setenv("NTR_WEB_PASSWORD", "secret")
|
||||||
monkeypatch.setenv("NTR_SECRET_KEY", "signme")
|
monkeypatch.setenv("NTR_SECRET_KEY", "signme")
|
||||||
s = Settings()
|
s = Settings(_env_file=None)
|
||||||
assert s.web_user == "nick"
|
assert s.web_user == "nick"
|
||||||
assert s.web_password == "secret"
|
assert s.web_password == "secret"
|
||||||
assert s.secret_key == "signme"
|
assert s.secret_key == "signme"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
|
|||||||
from ntr_fetcher.dashboard import create_dashboard_router
|
from ntr_fetcher.dashboard import create_dashboard_router
|
||||||
from ntr_fetcher.db import Database
|
from ntr_fetcher.db import Database
|
||||||
from ntr_fetcher.models import Track
|
from ntr_fetcher.models import Track
|
||||||
from ntr_fetcher.websocket import AnnounceManager
|
from ntr_fetcher.websocket import AnnounceManager, PublicManager
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -23,11 +23,17 @@ def manager():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def app(db, manager):
|
def public_manager():
|
||||||
|
return PublicManager()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(db, manager, public_manager):
|
||||||
a = FastAPI()
|
a = FastAPI()
|
||||||
router = create_dashboard_router(
|
router = create_dashboard_router(
|
||||||
db=db,
|
db=db,
|
||||||
manager=manager,
|
manager=manager,
|
||||||
|
public_manager=public_manager,
|
||||||
admin_token="test-token",
|
admin_token="test-token",
|
||||||
web_user="nick",
|
web_user="nick",
|
||||||
web_password="secret",
|
web_password="secret",
|
||||||
@@ -187,3 +193,122 @@ def test_ws_subscribe_with_invalid_token(app):
|
|||||||
ws.send_json({"type": "subscribe", "token": "wrong"})
|
ws.send_json({"type": "subscribe", "token": "wrong"})
|
||||||
with pytest.raises(Exception):
|
with pytest.raises(Exception):
|
||||||
ws.receive_json()
|
ws.receive_json()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Announced endpoint tests ---
|
||||||
|
|
||||||
|
def test_announce_sets_announced_flag(client, db):
|
||||||
|
show = _seed_show(db)
|
||||||
|
resp = client.post(
|
||||||
|
"/admin/announce",
|
||||||
|
json={"show_id": show.id, "position": 1},
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tracks = db.get_show_tracks(show.id)
|
||||||
|
assert tracks[0]["announced"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_announced_with_bearer(client, db):
|
||||||
|
show = _seed_show(db)
|
||||||
|
resp = client.post(
|
||||||
|
"/admin/announced",
|
||||||
|
json={"show_id": show.id, "position": 1, "announced": True},
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "ok"
|
||||||
|
tracks = db.get_show_tracks(show.id)
|
||||||
|
assert tracks[0]["announced"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_announced_toggle_off(client, db):
|
||||||
|
show = _seed_show(db)
|
||||||
|
client.post(
|
||||||
|
"/admin/announced",
|
||||||
|
json={"show_id": show.id, "position": 1, "announced": True},
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/admin/announced",
|
||||||
|
json={"show_id": show.id, "position": 1, "announced": False},
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tracks = db.get_show_tracks(show.id)
|
||||||
|
assert tracks[0]["announced"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_announced_without_auth(client, db):
|
||||||
|
_seed_show(db)
|
||||||
|
resp = client.post(
|
||||||
|
"/admin/announced",
|
||||||
|
json={"show_id": 1, "position": 1, "announced": True},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_announced_invalid_position(client, db):
|
||||||
|
_seed_show(db)
|
||||||
|
resp = client.post(
|
||||||
|
"/admin/announced",
|
||||||
|
json={"show_id": 1, "position": 99, "announced": True},
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# --- Public WebSocket tests ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_public_connects_without_auth(app):
|
||||||
|
with TestClient(app) as c:
|
||||||
|
with c.websocket_connect("/ws/public") as ws:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_public_receives_reveal_on_announce(app, db, public_manager):
|
||||||
|
_seed_show(db)
|
||||||
|
with TestClient(app) as c:
|
||||||
|
with c.websocket_connect("/ws/public") as ws:
|
||||||
|
c.post(
|
||||||
|
"/admin/announce",
|
||||||
|
json={"show_id": 1, "position": 1},
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
msg = ws.receive_json()
|
||||||
|
assert msg["type"] == "reveal"
|
||||||
|
assert msg["position"] == 1
|
||||||
|
assert msg["track"]["title"] == "Song A"
|
||||||
|
assert msg["track"]["artist"] == "Artist A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_public_receives_reveal_on_announced_toggle(app, db, public_manager):
|
||||||
|
_seed_show(db)
|
||||||
|
with TestClient(app) as c:
|
||||||
|
with c.websocket_connect("/ws/public") as ws:
|
||||||
|
c.post(
|
||||||
|
"/admin/announced",
|
||||||
|
json={"show_id": 1, "position": 1, "announced": True},
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
msg = ws.receive_json()
|
||||||
|
assert msg["type"] == "reveal"
|
||||||
|
assert msg["position"] == 1
|
||||||
|
assert msg["track"]["title"] == "Song A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ws_public_receives_hide_on_unannounce(app, db, public_manager):
|
||||||
|
show = _seed_show(db)
|
||||||
|
db.set_track_announced(show.id, 1, True)
|
||||||
|
with TestClient(app) as c:
|
||||||
|
with c.websocket_connect("/ws/public") as ws:
|
||||||
|
c.post(
|
||||||
|
"/admin/announced",
|
||||||
|
json={"show_id": 1, "position": 1, "announced": False},
|
||||||
|
headers={"Authorization": "Bearer test-token"},
|
||||||
|
)
|
||||||
|
msg = ws.receive_json()
|
||||||
|
assert msg["type"] == "hide"
|
||||||
|
assert msg["position"] == 1
|
||||||
|
assert "track" not in msg
|
||||||
|
|||||||
@@ -295,3 +295,51 @@ def test_has_track_in_show(db):
|
|||||||
db.set_show_tracks(show.id, [t1.id])
|
db.set_show_tracks(show.id, [t1.id])
|
||||||
assert db.has_track_in_show(show.id, 1) is True
|
assert db.has_track_in_show(show.id, 1) is True
|
||||||
assert db.has_track_in_show(show.id, 999) is False
|
assert db.has_track_in_show(show.id, 999) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_announced_defaults_to_zero(db):
|
||||||
|
week_start = datetime(2026, 3, 13, 2, 0, 0, tzinfo=timezone.utc)
|
||||||
|
week_end = datetime(2026, 3, 20, 2, 0, 0, tzinfo=timezone.utc)
|
||||||
|
show = db.get_or_create_show(week_start, week_end)
|
||||||
|
t1 = _make_track(1, "2026-03-14T01:00:00+00:00")
|
||||||
|
db.upsert_track(t1)
|
||||||
|
db.set_show_tracks(show.id, [t1.id])
|
||||||
|
tracks = db.get_show_tracks(show.id)
|
||||||
|
assert tracks[0]["announced"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_track_announced(db):
|
||||||
|
week_start = datetime(2026, 3, 13, 2, 0, 0, tzinfo=timezone.utc)
|
||||||
|
week_end = datetime(2026, 3, 20, 2, 0, 0, tzinfo=timezone.utc)
|
||||||
|
show = db.get_or_create_show(week_start, week_end)
|
||||||
|
t1 = _make_track(1, "2026-03-14T01:00:00+00:00")
|
||||||
|
db.upsert_track(t1)
|
||||||
|
db.set_show_tracks(show.id, [t1.id])
|
||||||
|
db.set_track_announced(show.id, 1, True)
|
||||||
|
tracks = db.get_show_tracks(show.id)
|
||||||
|
assert tracks[0]["announced"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_track_announced_toggle_off(db):
|
||||||
|
week_start = datetime(2026, 3, 13, 2, 0, 0, tzinfo=timezone.utc)
|
||||||
|
week_end = datetime(2026, 3, 20, 2, 0, 0, tzinfo=timezone.utc)
|
||||||
|
show = db.get_or_create_show(week_start, week_end)
|
||||||
|
t1 = _make_track(1, "2026-03-14T01:00:00+00:00")
|
||||||
|
db.upsert_track(t1)
|
||||||
|
db.set_show_tracks(show.id, [t1.id])
|
||||||
|
db.set_track_announced(show.id, 1, True)
|
||||||
|
db.set_track_announced(show.id, 1, False)
|
||||||
|
tracks = db.get_show_tracks(show.id)
|
||||||
|
assert tracks[0]["announced"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_show_track_by_position_includes_announced(db):
|
||||||
|
week_start = datetime(2026, 3, 13, 2, 0, 0, tzinfo=timezone.utc)
|
||||||
|
week_end = datetime(2026, 3, 20, 2, 0, 0, tzinfo=timezone.utc)
|
||||||
|
show = db.get_or_create_show(week_start, week_end)
|
||||||
|
t1 = _make_track(1, "2026-03-14T01:00:00+00:00")
|
||||||
|
db.upsert_track(t1)
|
||||||
|
db.set_show_tracks(show.id, [t1.id])
|
||||||
|
db.set_track_announced(show.id, 1, True)
|
||||||
|
result = db.get_show_track_by_position(show.id, 1)
|
||||||
|
assert result["announced"] == 1
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from ntr_fetcher.websocket import AnnounceManager
|
from ntr_fetcher.websocket import AnnounceManager, PublicManager
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -98,3 +98,61 @@ async def test_status_broadcast_includes_clients(manager):
|
|||||||
assert msg["subscribers"] == 1
|
assert msg["subscribers"] == 1
|
||||||
assert len(msg["clients"]) == 1
|
assert len(msg["clients"]) == 1
|
||||||
assert msg["clients"][0]["client_id"] == "my-bot"
|
assert msg["clients"][0]["client_id"] == "my-bot"
|
||||||
|
|
||||||
|
|
||||||
|
# --- PublicManager tests ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def public_manager():
|
||||||
|
return PublicManager()
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_no_clients_initially(public_manager):
|
||||||
|
assert public_manager.client_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_public_add_remove_client(public_manager):
|
||||||
|
class FakeWS:
|
||||||
|
async def send_json(self, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
ws = FakeWS()
|
||||||
|
public_manager.add_client(ws)
|
||||||
|
assert public_manager.client_count == 1
|
||||||
|
|
||||||
|
public_manager.remove_client(ws)
|
||||||
|
assert public_manager.client_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_public_broadcast(public_manager):
|
||||||
|
received = []
|
||||||
|
|
||||||
|
class FakeWS:
|
||||||
|
async def send_json(self, data):
|
||||||
|
received.append(data)
|
||||||
|
|
||||||
|
ws1 = FakeWS()
|
||||||
|
ws2 = FakeWS()
|
||||||
|
public_manager.add_client(ws1)
|
||||||
|
public_manager.add_client(ws2)
|
||||||
|
|
||||||
|
await public_manager.broadcast({"type": "reveal", "position": 1})
|
||||||
|
assert len(received) == 2
|
||||||
|
assert all(m["type"] == "reveal" for m in received)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_public_broadcast_removes_dead_clients(public_manager):
|
||||||
|
class DeadWS:
|
||||||
|
async def send_json(self, data):
|
||||||
|
raise Exception("closed")
|
||||||
|
|
||||||
|
ws = DeadWS()
|
||||||
|
public_manager.add_client(ws)
|
||||||
|
assert public_manager.client_count == 1
|
||||||
|
|
||||||
|
await public_manager.broadcast({"type": "reveal", "position": 1})
|
||||||
|
assert public_manager.client_count == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user