Compare commits

..

3 Commits

Author SHA1 Message Date
cottongin
4dd3f43ae3 feat: lock playlist/shows endpoints behind Bearer token auth
The five internal endpoints (/playlist, /playlist/{position}, /shows,
/shows/by-episode/{ep}, /shows/{id}) now require admin authentication.
Dashboard JS, Sopel plugin, and Limnoria plugin updated to send the
token on GET requests. Six new 401 tests added.

Made-with: Cursor
2026-04-02 12:06:27 -04:00
cottongin
425a7047c3 feat: add public index page with censored playlist and live reveals
Public-facing page at / shows the current show's playlist with tracks
obscured until the admin marks them as announced. Tracks reveal in
real-time via a new unauthenticated /ws/public WebSocket. Server-side
censorship on /public/playlist strips track details from unannounced
items and sanitizes announced tracks to only expose frontend-needed
fields (no raw_json, track_id, etc). Past episodes are browsable with
fully revealed but sanitized tracklists.

Also fixes RuntimeError on backfill shutdown by closing the httpx
client on the same event loop that created it.

Made-with: Cursor
2026-04-01 23:50:41 -04:00
cottongin
11f13c86b5 feat: add announced checkbox to track rows
Add a persistent "announced" checkbox after each track's Announce button.
The state is stored in a new `announced` column on `show_tracks` and is
auto-set when the Announce button is pressed. The checkbox is also freely
togglable, and announced tracks have their Announce button disabled.

Also fixes .env leakage in test_config.py (pass _env_file=None) and adds
tests for the new DB method, API endpoint, and announce side-effect.

Made-with: Cursor
2026-04-01 23:00:09 -04:00
15 changed files with 1096 additions and 65 deletions

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ build/
# AI session artifacts # AI session artifacts
chat-summaries/ chat-summaries/
.superpowers/

View File

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

View File

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

View File

@@ -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,

View File

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

View File

@@ -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:

View File

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

View File

@@ -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 = "";

View 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">&#9835;</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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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