From 11f13c86b544ba84275f8dd30b71c10852143f17 Mon Sep 17 00:00:00 2001 From: cottongin Date: Wed, 1 Apr 2026 22:56:43 -0400 Subject: [PATCH] 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 --- .gitignore | 1 + src/ntr_fetcher/dashboard.py | 22 ++++++++++ src/ntr_fetcher/db.py | 28 +++++++++--- src/ntr_fetcher/static/dashboard.html | 63 ++++++++++++++++++++++++--- tests/test_config.py | 10 ++--- tests/test_dashboard.py | 63 +++++++++++++++++++++++++++ tests/test_db.py | 48 ++++++++++++++++++++ 7 files changed, 218 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 55d3191..9f5c251 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ build/ # AI session artifacts chat-summaries/ +.superpowers/ diff --git a/src/ntr_fetcher/dashboard.py b/src/ntr_fetcher/dashboard.py index baef0ee..f90612e 100644 --- a/src/ntr_fetcher/dashboard.py +++ b/src/ntr_fetcher/dashboard.py @@ -44,6 +44,12 @@ class AnnounceRequest(BaseModel): position: int +class AnnouncedRequest(BaseModel): + show_id: int + position: int + announced: bool + + class PingRequest(BaseModel): target: str message: str @@ -126,8 +132,24 @@ def create_dashboard_router( f"{track['title']} by {track['artist']} - {track['permalink_url']}" ) await manager.broadcast({"type": "announce", "message": message}) + db.set_track_announced(body.show_id, body.position, True) 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) + return {"status": "ok"} + @router.post("/admin/ping") async def ping(body: PingRequest, request: Request): user = _get_session_user(request) diff --git a/src/ntr_fetcher/db.py b/src/ntr_fetcher/db.py index 703f2fe..236a88e 100644 --- a/src/ntr_fetcher/db.py +++ b/src/ntr_fetcher/db.py @@ -55,6 +55,11 @@ class Database: conn.commit() except sqlite3.OperationalError: pass + try: + conn.execute("ALTER TABLE show_tracks ADD COLUMN announced INTEGER NOT NULL DEFAULT 0") + conn.commit() + except sqlite3.OperationalError: + pass conn.close() def upsert_track(self, track: Track) -> None: @@ -154,9 +159,9 @@ class Database: conn = self._connect() rows = conn.execute( """ - SELECT st.show_id, st.track_id, st.position, t.title, t.artist, - t.permalink_url, t.artwork_url, t.duration_ms, t.license, - t.liked_at, t.raw_json + SELECT st.show_id, st.track_id, st.position, st.announced, + t.title, t.artist, t.permalink_url, t.artwork_url, + t.duration_ms, t.license, t.liked_at, t.raw_json FROM show_tracks st JOIN tracks t ON st.track_id = t.id WHERE st.show_id = ? @@ -173,9 +178,9 @@ class Database: conn = self._connect() row = conn.execute( """ - SELECT st.show_id, st.track_id, st.position, t.title, t.artist, - t.permalink_url, t.artwork_url, t.duration_ms, t.license, - t.liked_at, t.raw_json + SELECT st.show_id, st.track_id, st.position, st.announced, + t.title, t.artist, t.permalink_url, t.artwork_url, + t.duration_ms, t.license, t.liked_at, t.raw_json FROM show_tracks st JOIN tracks t ON st.track_id = t.id WHERE st.show_id = ? AND st.position = ? @@ -185,6 +190,17 @@ class Database: conn.close() 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: conn = self._connect() if track_ids: diff --git a/src/ntr_fetcher/static/dashboard.html b/src/ntr_fetcher/static/dashboard.html index 9ce93fe..8c2661d 100644 --- a/src/ntr_fetcher/static/dashboard.html +++ b/src/ntr_fetcher/static/dashboard.html @@ -66,7 +66,8 @@ border-bottom-color: #4caf50; 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 { margin-top: 2rem; padding-top: 1.5rem; @@ -149,7 +150,10 @@ html += '#TitleArtist'; html += ''; 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}`; html += ` ${t.position} @@ -159,8 +163,11 @@
- +
`; @@ -275,10 +282,18 @@ } btn.textContent = "\u2713"; 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(() => { btn.textContent = "Announce"; btn.classList.remove("success"); - btn.disabled = false; + btn.disabled = true; + btn.title = "Already announced"; }, 2000); } catch (e) { showToast(e.message, true); @@ -287,6 +302,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) { const target = document.getElementById("ping-target").value.trim(); const message = document.getElementById("ping-message").value.trim(); @@ -344,9 +392,12 @@ detail.style.display = "none"; } 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.title = "No bots connected"; + btn.title = isAnnounced ? "Already announced" : "No bots connected"; } else { btn.disabled = false; btn.title = ""; diff --git a/tests/test_config.py b/tests/test_config.py index a7e5f92..ebf95a0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,7 @@ from ntr_fetcher.config import Settings 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.host == "127.0.0.1" 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_ADMIN_TOKEN", "my-secret") monkeypatch.setenv("NTR_SOUNDCLOUD_USER", "someoneelse") - settings = Settings() + settings = Settings(_env_file=None) assert settings.port == 9090 assert settings.host == "0.0.0.0" assert settings.admin_token == "my-secret" @@ -27,7 +27,7 @@ def test_settings_from_env(monkeypatch): def test_settings_admin_token_required(): import pytest with pytest.raises(Exception): - Settings() + Settings(_env_file=None) 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_PASSWORD", 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_password 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_PASSWORD", "secret") monkeypatch.setenv("NTR_SECRET_KEY", "signme") - s = Settings() + s = Settings(_env_file=None) assert s.web_user == "nick" assert s.web_password == "secret" assert s.secret_key == "signme" diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 6cd91c2..0fec0f1 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -187,3 +187,66 @@ def test_ws_subscribe_with_invalid_token(app): ws.send_json({"type": "subscribe", "token": "wrong"}) with pytest.raises(Exception): 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 diff --git a/tests/test_db.py b/tests/test_db.py index 6af48c9..bee74a4 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -295,3 +295,51 @@ def test_has_track_in_show(db): 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, 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