From 425a7047c35370e7d17169f537887dc22788e65e Mon Sep 17 00:00:00 2001 From: cottongin Date: Wed, 1 Apr 2026 23:41:17 -0400 Subject: [PATCH] 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 --- src/ntr_fetcher/api.py | 72 ++++- src/ntr_fetcher/dashboard.py | 43 ++- src/ntr_fetcher/main.py | 28 +- src/ntr_fetcher/static/index.html | 435 ++++++++++++++++++++++++++++++ src/ntr_fetcher/websocket.py | 30 +++ tests/test_api.py | 69 +++++ tests/test_dashboard.py | 66 ++++- tests/test_websocket.py | 60 ++++- 8 files changed, 786 insertions(+), 17 deletions(-) create mode 100644 src/ntr_fetcher/static/index.html diff --git a/src/ntr_fetcher/api.py b/src/ntr_fetcher/api.py index c14f402..04af4d7 100644 --- a/src/ntr_fetcher/api.py +++ b/src/ntr_fetcher/api.py @@ -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, diff --git a/src/ntr_fetcher/dashboard.py b/src/ntr_fetcher/dashboard.py index f90612e..55c169e 100644 --- a/src/ntr_fetcher/dashboard.py +++ b/src/ntr_fetcher/dashboard.py @@ -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 diff --git a/src/ntr_fetcher/main.py b/src/ntr_fetcher/main.py index 67efd0a..e160dd4 100644 --- a/src/ntr_fetcher/main.py +++ b/src/ntr_fetcher/main.py @@ -49,18 +49,22 @@ def run() -> None: if args.init: sc = SoundCloudClient() - asyncio.run( - run_backfill( - db=db, - soundcloud=sc, - soundcloud_user=settings.soundcloud_user, - show_day=settings.show_day, - show_hour=settings.show_hour, - anchor_episode=args.show, - anchor_aired=args.aired, - ) - ) - asyncio.run(sc.close()) + + async def _backfill_and_close(): + try: + await run_backfill( + db=db, + soundcloud=sc, + soundcloud_user=settings.soundcloud_user, + show_day=settings.show_day, + show_hour=settings.show_hour, + anchor_episode=args.show, + anchor_aired=args.aired, + ) + finally: + await sc.close() + + asyncio.run(_backfill_and_close()) logger.info("Backfill complete") return diff --git a/src/ntr_fetcher/static/index.html b/src/ntr_fetcher/static/index.html new file mode 100644 index 0000000..ee336f3 --- /dev/null +++ b/src/ntr_fetcher/static/index.html @@ -0,0 +1,435 @@ + + + + + + NtR Playlist + + + + + +
+
+

NtR Playlist

+ Loading... +
+ +
+
    +
  • Loading playlist...
  • +
+
+ + +
+ + + + diff --git a/src/ntr_fetcher/websocket.py b/src/ntr_fetcher/websocket.py index 5002d88..249cad3 100644 --- a/src/ntr_fetcher/websocket.py +++ b/src/ntr_fetcher/websocket.py @@ -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) diff --git a/tests/test_api.py b/tests/test_api.py index c766cf1..14db6e2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 0fec0f1..938cbcd 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -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 diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 2fbefdb..93b6726 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -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