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
This commit is contained in:
cottongin
2026-04-01 23:41:17 -04:00
parent 11f13c86b5
commit 425a7047c3
8 changed files with 786 additions and 17 deletions

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__)
@@ -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__)
@@ -58,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,
@@ -133,6 +134,17 @@ def create_dashboard_router(
) )
await manager.broadcast({"type": "announce", "message": message}) await manager.broadcast({"type": "announce", "message": message})
db.set_track_announced(body.show_id, body.position, True) 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") @router.post("/admin/announced")
@@ -148,6 +160,23 @@ def create_dashboard_router(
raise HTTPException(status_code=404, detail=f"No track at position {body.position}") raise HTTPException(status_code=404, detail=f"No track at position {body.position}")
db.set_track_announced(body.show_id, body.position, body.announced) 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"} return {"status": "ok"}
@router.post("/admin/ping") @router.post("/admin/ping")
@@ -197,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

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

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

@@ -152,3 +152,72 @@ 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
# --- 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

@@ -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",
@@ -250,3 +256,59 @@ def test_set_announced_invalid_position(client, db):
headers={"Authorization": "Bearer test-token"}, headers={"Authorization": "Bearer test-token"},
) )
assert resp.status_code == 404 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

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