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:
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
channel = msg.channel or ""
|
||||||
|
if self.registryValue("autoApprove", channel, irc.network):
|
||||||
|
req = self.store.update_status(req.id, "approved")
|
||||||
card = render_request_card(req)
|
card = render_request_card(req)
|
||||||
self._web_server.publish("request-new", card)
|
self._web_server.publish("request-new", card)
|
||||||
if not self.registryValue("quietQueued", msg.channel, irc.network):
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
if channel:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM requests WHERE channel = ? ORDER BY created_at DESC LIMIT ?",
|
||||||
|
(channel, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM requests ORDER BY created_at DESC LIMIT ?",
|
"SELECT * FROM requests ORDER BY created_at DESC LIMIT ?",
|
||||||
(limit,),
|
(limit,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [self._row_to_request(r) for r in rows]
|
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]
|
||||||
|
|
||||||
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
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user