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
This commit is contained in:
cottongin
2026-04-01 22:56:43 -04:00
parent a5f77187b3
commit 11f13c86b5
7 changed files with 218 additions and 17 deletions

View File

@@ -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)

View File

@@ -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:

View File

@@ -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 += '<th class="track-num">#</th><th>Title</th><th>Artist</th><th></th>';
html += '</tr></thead><tbody>';
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 += `<tr>
<td class="track-num">${t.position}</td>
@@ -159,8 +163,11 @@
<div class="btn-group">
<button class="btn-sm copy-btn outline"
onclick="copyTrack(this, '${esc(copyText).replace(/'/g, "\\'")}')">Copy</button>
<button class="btn-sm announce-btn" ${disabled}
<button class="btn-sm announce-btn" ${annDisabled} ${annTitle}
onclick="announce(${showId}, ${t.position}, this)">Announce</button>
<input type="checkbox" class="announced-check"
${announced ? 'checked' : ''}
onchange="toggleAnnounced(${showId}, ${t.position}, this)">
</div>
</td>
</tr>`;
@@ -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 = "";