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:
@@ -1,12 +1,16 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Depends, Header
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ntr_fetcher.db import Database
|
||||
from ntr_fetcher.week import get_current_show_week
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -127,6 +131,70 @@ def create_app(
|
||||
"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")
|
||||
async def admin_refresh(body: RefreshRequest = RefreshRequest(), _=Depends(_require_admin)):
|
||||
await poller.poll_once(full=body.full)
|
||||
@@ -158,11 +226,13 @@ def create_app(
|
||||
|
||||
if all([web_user, web_password, secret_key]):
|
||||
from ntr_fetcher.dashboard import create_dashboard_router
|
||||
from ntr_fetcher.websocket import AnnounceManager
|
||||
from ntr_fetcher.websocket import AnnounceManager, PublicManager
|
||||
manager = AnnounceManager()
|
||||
public_manager = PublicManager()
|
||||
dashboard_router = create_dashboard_router(
|
||||
db=db,
|
||||
manager=manager,
|
||||
public_manager=public_manager,
|
||||
admin_token=admin_token,
|
||||
web_user=web_user,
|
||||
web_password=web_password,
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ntr_fetcher.db import Database
|
||||
from ntr_fetcher.websocket import AnnounceManager
|
||||
from ntr_fetcher.websocket import AnnounceManager, PublicManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,6 +58,7 @@ class PingRequest(BaseModel):
|
||||
def create_dashboard_router(
|
||||
db: Database,
|
||||
manager: AnnounceManager,
|
||||
public_manager: PublicManager,
|
||||
admin_token: str,
|
||||
web_user: str,
|
||||
web_password: str,
|
||||
@@ -133,6 +134,17 @@ def create_dashboard_router(
|
||||
)
|
||||
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}
|
||||
|
||||
@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}")
|
||||
|
||||
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")
|
||||
@@ -197,4 +226,16 @@ def create_dashboard_router(
|
||||
except Exception:
|
||||
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
|
||||
|
||||
@@ -49,8 +49,10 @@ def run() -> None:
|
||||
|
||||
if args.init:
|
||||
sc = SoundCloudClient()
|
||||
asyncio.run(
|
||||
run_backfill(
|
||||
|
||||
async def _backfill_and_close():
|
||||
try:
|
||||
await run_backfill(
|
||||
db=db,
|
||||
soundcloud=sc,
|
||||
soundcloud_user=settings.soundcloud_user,
|
||||
@@ -59,8 +61,10 @@ def run() -> None:
|
||||
anchor_episode=args.show,
|
||||
anchor_aired=args.aired,
|
||||
)
|
||||
)
|
||||
asyncio.run(sc.close())
|
||||
finally:
|
||||
await sc.close()
|
||||
|
||||
asyncio.run(_backfill_and_close())
|
||||
logger.info("Backfill complete")
|
||||
return
|
||||
|
||||
|
||||
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,
|
||||
"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)
|
||||
|
||||
@@ -152,3 +152,72 @@ def test_no_dashboard_routes_without_config(client):
|
||||
def test_no_login_route_without_config(client):
|
||||
resp = client.get("/login")
|
||||
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
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi.testclient import TestClient
|
||||
from ntr_fetcher.dashboard import create_dashboard_router
|
||||
from ntr_fetcher.db import Database
|
||||
from ntr_fetcher.models import Track
|
||||
from ntr_fetcher.websocket import AnnounceManager
|
||||
from ntr_fetcher.websocket import AnnounceManager, PublicManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -23,11 +23,17 @@ def manager():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(db, manager):
|
||||
def public_manager():
|
||||
return PublicManager()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(db, manager, public_manager):
|
||||
a = FastAPI()
|
||||
router = create_dashboard_router(
|
||||
db=db,
|
||||
manager=manager,
|
||||
public_manager=public_manager,
|
||||
admin_token="test-token",
|
||||
web_user="nick",
|
||||
web_password="secret",
|
||||
@@ -250,3 +256,59 @@ def test_set_announced_invalid_position(client, db):
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import pytest
|
||||
from ntr_fetcher.websocket import AnnounceManager
|
||||
from ntr_fetcher.websocket import AnnounceManager, PublicManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -98,3 +98,61 @@ async def test_status_broadcast_includes_clients(manager):
|
||||
assert msg["subscribers"] == 1
|
||||
assert len(msg["clients"]) == 1
|
||||
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