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...
+
+
+
+
+
+
+
+
+
+
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