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 += '
# | Title | Artist | | ';
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