Add dashboard features and UX polish

Dashboard features round:
- Toggle label text for requests open/closed state
- URL-based channel routing (hash and path)
- Day/night/system theme with localStorage persistence
- Larger typography and album art across the app
- Session management with start/stop, archiving, and history
- Auto-approve config option per channel
- Mobile responsive layout for all new components

UX polish round:
- Auto-switch to history tab when queue empties
- Toast notifications and queue tab pulse for new requests
- Themed modal dialogs replacing native confirm/prompt calls

Database: new sessions table, session_id column on requests,
migration ordering fix for existing databases.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-28 11:29:53 -04:00
parent 6723413250
commit 7340d59b8e
5 changed files with 1205 additions and 78 deletions

View File

@@ -161,6 +161,16 @@ conf.registerChannelValue(
), ),
) )
conf.registerChannelValue(
SongRequest,
"autoApprove",
registry.Boolean(
False,
_("""When True, incoming requests are automatically approved
instead of entering the pending queue."""),
),
)
conf.registerChannelValue( conf.registerChannelValue(
SongRequest, SongRequest,
"passiveDetection", "passiveDetection",

View File

@@ -190,9 +190,18 @@ class SongRequest(callbacks.Plugin):
alternates_json=alt_json, alternates_json=alt_json,
) )
req = self.store.add(req) req = self.store.add(req)
card = render_request_card(req)
self._web_server.publish("request-new", card) channel = msg.channel or ""
if not self.registryValue("quietQueued", msg.channel, irc.network): if self.registryValue("autoApprove", channel, irc.network):
req = self.store.update_status(req.id, "approved")
card = render_request_card(req)
self._web_server.publish("request-new", card)
self._on_web_status_change(req)
else:
card = render_request_card(req)
self._web_server.publish("request-new", card)
if not self.registryValue("quietQueued", channel, irc.network):
irc.reply( irc.reply(
f"Queued: {track.display()} \x02|\x02 {track.apple_music_url}", f"Queued: {track.display()} \x02|\x02 {track.apple_music_url}",
prefixNick=True, prefixNick=True,
@@ -449,5 +458,40 @@ class SongRequest(callbacks.Plugin):
clearhistory = wrap(clearhistory, ["admin"]) clearhistory = wrap(clearhistory, ["admin"])
def startsession(self, irc, msg, args, name):
"""[<name>]
Start a new request session. Optionally provide a name. Requires admin.
"""
active = self.store.get_active_session()
if active:
irc.reply(f"Session \"{active.name}\" is already active. Stop it first.", prefixNick=True)
return
if not name:
name = time.strftime("%Y-%m-%d %H:%M")
session = self.store.start_session(name)
self._web_server.publish_json({"event": "session-started", "session": session.to_dict()})
irc.reply(f"Session \"{session.name}\" started.", prefixNick=True)
startsession = wrap(startsession, ["admin", optional("text")])
def stopsession(self, irc, msg, args):
"""(takes no arguments)
Stop the active request session and archive it. Requires admin.
"""
active = self.store.get_active_session()
if not active:
irc.reply("No active session to stop.", prefixNick=True)
return
session = self.store.stop_session(active.id)
self._web_server.publish_json({"event": "session-stopped", "session": session.to_dict() if session else None})
irc.reply(f"Session \"{active.name}\" stopped and archived.", prefixNick=True)
stopsession = wrap(stopsession, ["admin"])
Class = SongRequest Class = SongRequest

View File

@@ -28,8 +28,7 @@ CREATE TABLE IF NOT EXISTS requests (
network TEXT NOT NULL DEFAULT '', network TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
created_at REAL NOT NULL, created_at REAL NOT NULL,
updated_at REAL NOT NULL, updated_at REAL NOT NULL
alternates_json TEXT NOT NULL DEFAULT ''
); );
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status); CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
@@ -38,9 +37,28 @@ CREATE INDEX IF NOT EXISTS idx_requests_channel ON requests(channel);
MIGRATIONS = [ MIGRATIONS = [
"ALTER TABLE requests ADD COLUMN alternates_json TEXT NOT NULL DEFAULT ''", "ALTER TABLE requests ADD COLUMN alternates_json TEXT NOT NULL DEFAULT ''",
"ALTER TABLE requests ADD COLUMN session_id INTEGER DEFAULT NULL",
"""CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
started_at REAL NOT NULL,
ended_at REAL
)""",
"CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id)",
] ]
@dataclass
class Session:
id: Optional[int]
name: str
started_at: float
ended_at: Optional[float]
def to_dict(self) -> dict:
return asdict(self)
@dataclass @dataclass
class SongRequest: class SongRequest:
id: Optional[int] id: Optional[int]
@@ -58,6 +76,7 @@ class SongRequest:
created_at: float created_at: float
updated_at: float updated_at: float
alternates_json: str = "" alternates_json: str = ""
session_id: Optional[int] = None
def to_dict(self) -> dict: def to_dict(self) -> dict:
return asdict(self) return asdict(self)
@@ -85,26 +104,101 @@ class RequestStore:
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
return conn return conn
# ------------------------------------------------------------------
# Sessions
# ------------------------------------------------------------------
def start_session(self, name: str = "") -> Session:
now = time.time()
with self._lock, self._connect() as conn:
cur = conn.execute(
"INSERT INTO sessions (name, started_at) VALUES (?, ?)",
(name, now),
)
return Session(id=cur.lastrowid, name=name, started_at=now, ended_at=None)
def stop_session(self, session_id: int) -> Optional[Session]:
now = time.time()
with self._lock, self._connect() as conn:
conn.execute(
"UPDATE sessions SET ended_at = ? WHERE id = ?",
(now, session_id),
)
row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
return Session(**dict(row)) if row else None
def get_active_session(self) -> Optional[Session]:
with self._lock, self._connect() as conn:
row = conn.execute(
"SELECT * FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1"
).fetchone()
return Session(**dict(row)) if row else None
def get_archived_sessions(self) -> List[Session]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"SELECT * FROM sessions WHERE ended_at IS NOT NULL ORDER BY started_at DESC"
).fetchall()
return [Session(**dict(r)) for r in rows]
def get_session_requests(self, session_id: int, status: Optional[str] = None) -> List[SongRequest]:
with self._lock, self._connect() as conn:
if status:
rows = conn.execute(
"SELECT * FROM requests WHERE session_id = ? AND status = ? ORDER BY created_at ASC",
(session_id, status),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM requests WHERE session_id = ? ORDER BY created_at ASC",
(session_id,),
).fetchall()
return [self._row_to_request(r) for r in rows]
def get_session_played_count(self, session_id: int) -> int:
with self._lock, self._connect() as conn:
row = conn.execute(
"SELECT COUNT(*) as cnt FROM requests WHERE session_id = ? AND status = ?",
(session_id, STATUS_PLAYED),
).fetchone()
return row["cnt"] if row else 0
def clear_session_non_played(self, session_id: int) -> int:
with self._lock, self._connect() as conn:
cur = conn.execute(
"DELETE FROM requests WHERE session_id = ? AND status != ?",
(session_id, STATUS_PLAYED),
)
return cur.rowcount
# ------------------------------------------------------------------
# Requests
# ------------------------------------------------------------------
def add(self, req: SongRequest) -> SongRequest: def add(self, req: SongRequest) -> SongRequest:
now = time.time() now = time.time()
req.created_at = now req.created_at = now
req.updated_at = now req.updated_at = now
req.status = STATUS_PENDING req.status = STATUS_PENDING
with self._lock, self._connect() as conn: with self._lock, self._connect() as conn:
active = conn.execute(
"SELECT id FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1"
).fetchone()
req.session_id = active["id"] if active else None
cur = conn.execute( cur = conn.execute(
"""INSERT INTO requests """INSERT INTO requests
(itunes_track_id, title, artist, album, artwork_url, (itunes_track_id, title, artist, album, artwork_url,
apple_music_url, requester_nick, requester_host, apple_music_url, requester_nick, requester_host,
channel, network, status, created_at, updated_at, channel, network, status, created_at, updated_at,
alternates_json) alternates_json, session_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
req.itunes_track_id, req.title, req.artist, req.album, req.itunes_track_id, req.title, req.artist, req.album,
req.artwork_url, req.apple_music_url, req.artwork_url, req.apple_music_url,
req.requester_nick, req.requester_host, req.requester_nick, req.requester_host,
req.channel, req.network, req.status, req.channel, req.network, req.status,
req.created_at, req.updated_at, req.created_at, req.updated_at,
req.alternates_json, req.alternates_json, req.session_id,
), ),
) )
req.id = cur.lastrowid req.id = cur.lastrowid
@@ -140,6 +234,29 @@ class RequestStore:
).fetchall() ).fetchall()
return [self._row_to_request(r) for r in rows] return [self._row_to_request(r) for r in rows]
def get_channels(self) -> List[str]:
with self._lock, self._connect() as conn:
rows = conn.execute(
"SELECT DISTINCT channel FROM requests WHERE channel != '' ORDER BY channel"
).fetchall()
return [r["channel"] for r in rows]
def get_by_status_and_channel(self, channel: Optional[str], *statuses: str) -> List[SongRequest]:
placeholders = ",".join("?" for _ in statuses)
if channel:
with self._lock, self._connect() as conn:
rows = conn.execute(
f"SELECT * FROM requests WHERE status IN ({placeholders}) AND channel = ? ORDER BY created_at ASC",
(*statuses, channel),
).fetchall()
else:
with self._lock, self._connect() as conn:
rows = conn.execute(
f"SELECT * FROM requests WHERE status IN ({placeholders}) ORDER BY created_at ASC",
statuses,
).fetchall()
return [self._row_to_request(r) for r in rows]
def get_pending(self) -> List[SongRequest]: def get_pending(self) -> List[SongRequest]:
return self.get_by_status(STATUS_PENDING) return self.get_by_status(STATUS_PENDING)
@@ -149,16 +266,67 @@ class RequestStore:
def get_active(self) -> List[SongRequest]: def get_active(self) -> List[SongRequest]:
return self.get_by_status(STATUS_PENDING, STATUS_APPROVED) return self.get_by_status(STATUS_PENDING, STATUS_APPROVED)
def get_history(self, limit: int = 50) -> List[SongRequest]: def get_history(self, limit: int = 50, channel: Optional[str] = None) -> List[SongRequest]:
with self._lock, self._connect() as conn: with self._lock, self._connect() as conn:
rows = conn.execute( if channel:
"SELECT * FROM requests ORDER BY created_at DESC LIMIT ?", rows = conn.execute(
(limit,), "SELECT * FROM requests WHERE channel = ? ORDER BY created_at DESC LIMIT ?",
).fetchall() (channel, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM requests ORDER BY created_at DESC LIMIT ?",
(limit,),
).fetchall()
return [self._row_to_request(r) for r in rows]
def get_session_history(self, session_id: int, channel: Optional[str] = None) -> List[SongRequest]:
"""Get played requests for a specific session, optionally filtered by channel."""
with self._lock, self._connect() as conn:
if channel:
rows = conn.execute(
"SELECT * FROM requests WHERE session_id = ? AND status = ? AND channel = ? ORDER BY created_at DESC",
(session_id, STATUS_PLAYED, channel),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM requests WHERE session_id = ? AND status = ? ORDER BY created_at DESC",
(session_id, STATUS_PLAYED),
).fetchall()
return [self._row_to_request(r) for r in rows]
def get_current_session_history(self, channel: Optional[str] = None) -> List[SongRequest]:
"""Get played/rejected from the active session, or unsessioned if no active session."""
with self._lock, self._connect() as conn:
active = conn.execute(
"SELECT id FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1"
).fetchone()
if active:
sid = active["id"]
if channel:
rows = conn.execute(
"SELECT * FROM requests WHERE session_id = ? AND status IN (?, ?) AND channel = ? ORDER BY created_at DESC",
(sid, STATUS_PLAYED, STATUS_REJECTED, channel),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM requests WHERE session_id = ? AND status IN (?, ?) ORDER BY created_at DESC",
(sid, STATUS_PLAYED, STATUS_REJECTED),
).fetchall()
else:
if channel:
rows = conn.execute(
"SELECT * FROM requests WHERE status IN (?, ?) AND channel = ? ORDER BY created_at DESC LIMIT 100",
(STATUS_PLAYED, STATUS_REJECTED, channel),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM requests WHERE status IN (?, ?) ORDER BY created_at DESC LIMIT 100",
(STATUS_PLAYED, STATUS_REJECTED),
).fetchall()
return [self._row_to_request(r) for r in rows] return [self._row_to_request(r) for r in rows]
def swap_alternate(self, request_id: int, alt: dict) -> None: def swap_alternate(self, request_id: int, alt: dict) -> None:
"""Replace a request's track data with an alternate's data."""
now = time.time() now = time.time()
with self._lock, self._connect() as conn: with self._lock, self._connect() as conn:
conn.execute( conn.execute(

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@ from aiohttp import web
import supybot.log as log import supybot.log as log
from .store import SongRequest as SongRequestModel, VALID_STATUSES from .store import SongRequest as SongRequestModel, Session, VALID_STATUSES
TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates") TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
@@ -94,7 +94,7 @@ def render_request_card(req: SongRequestModel) -> str:
alternates_html = _render_alternates(req.id, req.alternates_json, req.status) alternates_html = _render_alternates(req.id, req.alternates_json, req.status)
return f"""<div id="request-{req.id}" class="card-wrapper" data-status="{esc(req.status)}"> return f"""<div id="request-{req.id}" class="card-wrapper" data-status="{esc(req.status)}" data-channel="{esc(req.channel)}">
<div class="card-link request-card {esc(status_cls)}" <div class="card-link request-card {esc(status_cls)}"
onclick="window.open('{esc(req.apple_music_url)}','_blank')"> onclick="window.open('{esc(req.apple_music_url)}','_blank')">
<img class="album-art" src="{esc(req.artwork_url)}" alt="Album art" loading="lazy" /> <img class="album-art" src="{esc(req.artwork_url)}" alt="Album art" loading="lazy" />
@@ -156,6 +156,14 @@ class WebServer:
self._broadcast(event_type, data), self._broadcast(event_type, data),
) )
def publish_json(self, payload: dict):
"""Thread-safe publish of a raw JSON dict to all WS clients."""
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(
asyncio.ensure_future,
self._broadcast_raw(json.dumps(payload)),
)
def _run(self): def _run(self):
asyncio.set_event_loop(self._loop) asyncio.set_event_loop(self._loop)
self._loop.run_until_complete(self._start_app()) self._loop.run_until_complete(self._start_app())
@@ -165,6 +173,7 @@ class WebServer:
app = web.Application() app = web.Application()
app.router.add_get("/", self._handle_dashboard) app.router.add_get("/", self._handle_dashboard)
app.router.add_get("/ws", self._handle_ws) app.router.add_get("/ws", self._handle_ws)
app.router.add_get("/api/channels", self._handle_channels)
app.router.add_get("/api/requests", self._handle_api_get) app.router.add_get("/api/requests", self._handle_api_get)
app.router.add_post("/api/requests/{request_id}/approve-alt/{alt_idx}", self._handle_approve_alt) app.router.add_post("/api/requests/{request_id}/approve-alt/{alt_idx}", self._handle_approve_alt)
app.router.add_post("/api/requests/{request_id}/{action}", self._handle_api_action) app.router.add_post("/api/requests/{request_id}/{action}", self._handle_api_action)
@@ -172,7 +181,13 @@ class WebServer:
app.router.add_get("/api/status", self._handle_get_status) app.router.add_get("/api/status", self._handle_get_status)
app.router.add_post("/api/status", self._handle_post_status) app.router.add_post("/api/status", self._handle_post_status)
app.router.add_post("/api/history/clear", self._handle_clear_history) app.router.add_post("/api/history/clear", self._handle_clear_history)
app.router.add_get("/api/sessions", self._handle_get_sessions)
app.router.add_post("/api/sessions/start", self._handle_start_session)
app.router.add_post("/api/sessions/stop", self._handle_stop_session)
app.router.add_get("/api/sessions/{session_id}/requests", self._handle_session_requests)
app.router.add_static("/static", STATIC_DIR) app.router.add_static("/static", STATIC_DIR)
# Catch-all for /{channel} URL routing — must be last
app.router.add_get("/{channel}", self._handle_dashboard)
self._runner = web.AppRunner(app) self._runner = web.AppRunner(app)
await self._runner.setup() await self._runner.setup()
@@ -197,6 +212,16 @@ class WebServer:
for ws in dead: for ws in dead:
self._ws_clients.discard(ws) self._ws_clients.discard(ws)
async def _broadcast_raw(self, raw_json: str):
dead = []
for ws in set(self._ws_clients):
try:
await ws.send_str(raw_json)
except (ConnectionResetError, Exception):
dead.append(ws)
for ws in dead:
self._ws_clients.discard(ws)
def _check_auth(self, request: web.Request) -> bool: def _check_auth(self, request: web.Request) -> bool:
token = self._get_auth_token() token = self._get_auth_token()
if not token: if not token:
@@ -229,14 +254,20 @@ class WebServer:
self._ws_clients.discard(ws) self._ws_clients.discard(ws)
return ws return ws
async def _handle_channels(self, request: web.Request) -> web.Response:
channels = self._store.get_channels()
return web.json_response(channels)
async def _handle_api_get(self, request: web.Request) -> web.Response: async def _handle_api_get(self, request: web.Request) -> web.Response:
status_filter = request.query.get("status") status_filter = request.query.get("status")
channel_filter = request.query.get("channel") or None
if status_filter == "history": if status_filter == "history":
reqs = self._store.get_by_status("played", "rejected") reqs = self._store.get_current_session_history(channel=channel_filter)
elif status_filter and status_filter in VALID_STATUSES: elif status_filter and status_filter in VALID_STATUSES:
reqs = self._store.get_by_status(status_filter) reqs = self._store.get_by_status_and_channel(channel_filter, status_filter)
else: else:
reqs = self._store.get_active() reqs = self._store.get_by_status_and_channel(channel_filter, "pending", "approved")
cards = "\n".join(render_request_card(r) for r in reqs) cards = "\n".join(render_request_card(r) for r in reqs)
return web.Response(text=cards, content_type="text/html") return web.Response(text=cards, content_type="text/html")
@@ -272,7 +303,6 @@ class WebServer:
return web.Response(text=card_html, content_type="text/html") return web.Response(text=card_html, content_type="text/html")
async def _handle_approve_alt(self, request: web.Request) -> web.Response: async def _handle_approve_alt(self, request: web.Request) -> web.Response:
"""Approve an alternate track: swap it into the main request and approve."""
if not self._check_auth(request): if not self._check_auth(request):
return web.Response(text="Forbidden", status=403) return web.Response(text="Forbidden", status=403)
@@ -315,9 +345,11 @@ class WebServer:
if not self._check_auth(request): if not self._check_auth(request):
return web.Response(text="Forbidden", status=403) return web.Response(text="Forbidden", status=403)
reqs = self._store.get_history(limit=5000) channel_filter = request.query.get("channel") or None
reqs = self._store.get_history(limit=5000, channel=channel_filter)
today = datetime.date.today().isoformat() today = datetime.date.today().isoformat()
lines = [f"# Song Requests Export ({today})", "", "| Title | Artist | Album | Requested By | Status | Time | Apple Music |", suffix = f" - {channel_filter}" if channel_filter else ""
lines = [f"# Song Requests Export ({today}{suffix})", "", "| Title | Artist | Album | Requested By | Status | Time | Apple Music |",
"| --- | --- | --- | --- | --- | --- | --- |"] "| --- | --- | --- | --- | --- | --- | --- |"]
for r in reqs: for r in reqs:
ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(r.created_at)) ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(r.created_at))
@@ -371,3 +403,76 @@ class WebServer:
for ws_client in dead: for ws_client in dead:
self._ws_clients.discard(ws_client) self._ws_clients.discard(ws_client)
return web.json_response({"cleared": count}) return web.json_response({"cleared": count})
# ------------------------------------------------------------------
# Sessions API
# ------------------------------------------------------------------
async def _handle_get_sessions(self, request: web.Request) -> web.Response:
active = self._store.get_active_session()
archived = self._store.get_archived_sessions()
result = {
"active": active.to_dict() if active else None,
"archived": [],
}
for s in archived:
d = s.to_dict()
d["played_count"] = self._store.get_session_played_count(s.id)
result["archived"].append(d)
return web.json_response(result)
async def _handle_start_session(self, request: web.Request) -> web.Response:
if not self._check_auth(request):
return web.Response(text="Forbidden", status=403)
existing = self._store.get_active_session()
if existing:
return web.json_response({"error": "A session is already active"}, status=409)
try:
data = await request.json()
except Exception:
data = {}
name = data.get("name", "").strip()
if not name:
name = time.strftime("%Y-%m-%d %H:%M")
session = self._store.start_session(name)
payload = json.dumps({"event": "session-started", "session": session.to_dict()})
await self._broadcast_raw(payload)
return web.json_response(session.to_dict())
async def _handle_stop_session(self, request: web.Request) -> web.Response:
if not self._check_auth(request):
return web.Response(text="Forbidden", status=403)
active = self._store.get_active_session()
if not active:
return web.json_response({"error": "No active session"}, status=404)
try:
data = await request.json()
except Exception:
data = {}
clear_remaining = bool(data.get("clear_remaining", False))
session = self._store.stop_session(active.id)
if clear_remaining:
self._store.clear_session_non_played(active.id)
payload = json.dumps({"event": "session-stopped", "session": session.to_dict() if session else None})
await self._broadcast_raw(payload)
return web.json_response(session.to_dict() if session else {})
async def _handle_session_requests(self, request: web.Request) -> web.Response:
try:
session_id = int(request.match_info["session_id"])
except (ValueError, KeyError):
return web.Response(text="Bad request", status=400)
channel_filter = request.query.get("channel") or None
reqs = self._store.get_session_history(session_id, channel=channel_filter)
cards = "\n".join(render_request_card(r) for r in reqs)
return web.Response(text=cards, content_type="text/html")