From ea40251e0e63c83556f1872473b6a2afa8d262cc Mon Sep 17 00:00:00 2001 From: cottongin Date: Sat, 28 Mar 2026 12:02:42 -0400 Subject: [PATCH] Add web dashboard auth system and session management actions Implement per-admin authentication with IRC-managed accounts (addSongAdmin/removeSongAdmin/listSongAdmins), session-based login, and admin presence tracking via WebSocket. Legacy webAuthToken retained as fallback. Add rename, clear, and delete actions for archived sessions with themed modal confirmations (admin-only UI). Made-with: Cursor --- .../apple_music_integration_a6a44bfc.plan.md | 258 ++++++++++++ SongRequest/config.py | 5 +- SongRequest/plugin.py | 41 ++ SongRequest/store.py | 137 ++++++ SongRequest/templates/index.html | 396 ++++++++++++++++-- SongRequest/templates/login.html | 241 +++++++++++ SongRequest/web.py | 218 ++++++++-- 7 files changed, 1221 insertions(+), 75 deletions(-) create mode 100644 .cursor/plans/apple_music_integration_a6a44bfc.plan.md create mode 100644 SongRequest/templates/login.html diff --git a/.cursor/plans/apple_music_integration_a6a44bfc.plan.md b/.cursor/plans/apple_music_integration_a6a44bfc.plan.md new file mode 100644 index 0000000..0f4eb3d --- /dev/null +++ b/.cursor/plans/apple_music_integration_a6a44bfc.plan.md @@ -0,0 +1,258 @@ +--- +name: Apple Music Integration +overview: Exploratory investigation of Apple Music API integration options for adding requests to a user's Apple Music queue/playlist and automatically detecting when songs are played, with assessment of feasibility, prerequisites, and architectural approaches. +todos: + - id: prereqs + content: Set up Apple Developer Program account, create MusicKit identifier, generate private key + status: pending + - id: apple-music-module + content: Create SongRequest/apple_music.py with JWT generation and REST API wrapper + status: pending + - id: musickit-js-embed + content: Embed MusicKit JS v3 in the dashboard, add authorize flow and queue control + status: pending + - id: now-playing-detection + content: Add playbackStateDidChange listener in dashboard JS + /api/now-playing endpoint in web.py + status: pending + - id: auto-played-matching + content: Implement trackId matching logic in store/plugin to auto-mark requests as played + status: pending + - id: config-values + content: Add config entries for Apple Developer key, team ID, key ID, playlist ID, and feature toggles + status: pending + - id: recently-played-poller + content: (Optional) Add server-side polling of recently-played API as a fallback + status: pending +isProject: false +--- + +# Apple Music Integration: Feasibility and Implementation Plan + +## Prerequisites (Required for Both Features) + +Before any integration work, you need: + +- **Paid Apple Developer Program membership** ($99/year) -- required to generate MusicKit keys +- **MusicKit identifier + private key** from Certificates, Identifiers & Profiles in the Apple Developer portal +- **Developer Token (JWT)** -- an ES256-signed JWT with your Team ID and key ID, valid up to 6 months +- **Music User Token** -- obtained via MusicKit JS authorization flow in the browser; required for any personal library/playlist operations. The dashboard operator must have an **active Apple Music subscription** + +The current plugin uses the **public iTunes Search API** (no auth needed). Both proposed features require authenticated access to a specific user's Apple Music account. + +--- + +## Feature 1: Add Requests to Apple Music Playlist/Queue + +### How It Would Work + +There are two viable approaches, each with different trade-offs: + +### Approach A: Server-Side REST API (Add to Playlist) + +The Apple Music REST API supports adding tracks to a user's library playlist: + +``` +POST https://api.music.apple.com/v1/me/library/playlists/{playlist_id}/tracks +Authorization: Bearer {developer_token} +Music-User-Token: {user_token} + +{ + "data": [ + { "id": "{catalog_song_id}", "type": "songs" } + ] +} +``` + +**Architecture:** + +```mermaid +sequenceDiagram + participant IRC as IRC User + participant Bot as Limnoria Bot + participant iTunes as iTunes Search API + participant Dashboard as Web Dashboard + participant AM as Apple Music API + + IRC->>Bot: !request Artist - Title + Bot->>iTunes: Search query + iTunes-->>Bot: Track results (includes trackId) + Bot->>Dashboard: WebSocket push (new card) + Dashboard->>AM: POST /v1/me/.../tracks (on approve) + AM-->>Dashboard: 200 OK (added to playlist) + Dashboard->>Bot: WebSocket notify (added) +``` + + + +**Implementation steps:** + +- Add a new module `[SongRequest/apple_music.py](SongRequest/apple_music.py)` for JWT generation (using `PyJWT` + `cryptography`) and REST API calls +- Add config values for Apple Developer key path, key ID, team ID, target playlist ID, and stored Music User Token +- The web dashboard handles the MusicKit JS authorization flow once (popup login), stores the Music User Token, and sends it to the bot backend +- When a request is approved (or auto-approved), the bot calls `POST /v1/me/library/playlists/{id}/tracks` with the iTunes `trackId` (which maps to Apple Music catalog IDs) +- New config option: `appleMusicAutoAdd` (boolean) to toggle this behavior + +**Key concern:** The `trackId` from the iTunes Search API should map to Apple Music catalog song IDs, but this needs verification. The iTunes Search API returns `trackId` (numeric), while the Apple Music API expects string catalog IDs. These are typically the same value, just as a string. + +**Pros:** Fully server-side, works without the dashboard open, songs appear in a persistent playlist +**Cons:** Adds to a playlist only (not the live playback queue), tracks go to the end of the playlist, no position control, Music User Token expires and needs periodic re-auth + +### Approach B: MusicKit JS Web Player (Add to Queue + Play) + +Embed MusicKit JS in the web dashboard to directly control Apple Music playback: + +```javascript +const music = MusicKit.getInstance(); +await music.authorize(); +await music.setQueue({ songs: [catalogSongId] }); +// Or append to existing queue +await music.playNext({ songs: [catalogSongId] }); +``` + +**Architecture:** + +```mermaid +sequenceDiagram + participant IRC as IRC User + participant Bot as Limnoria Bot + participant Dashboard as Web Dashboard + MusicKit JS + participant AM as Apple Music (Playback) + + IRC->>Bot: !request Artist - Title + Bot->>Dashboard: WebSocket push (new request) + Note over Dashboard: Operator approves + Dashboard->>AM: MusicKit.playNext(songId) + AM-->>Dashboard: Song added to queue + Note over Dashboard: Apple Music plays on this device +``` + + + +**Implementation steps:** + +- Add MusicKit JS SDK to `[SongRequest/templates/index.html](SongRequest/templates/index.html)` via ` diff --git a/SongRequest/templates/login.html b/SongRequest/templates/login.html new file mode 100644 index 0000000..ed5fbe3 --- /dev/null +++ b/SongRequest/templates/login.html @@ -0,0 +1,241 @@ + + + + + + Admin Login - Song Requests + + + +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + + + diff --git a/SongRequest/web.py b/SongRequest/web.py index 4384b3d..81d7615 100644 --- a/SongRequest/web.py +++ b/SongRequest/web.py @@ -43,7 +43,7 @@ def _render_alternates(request_id: int, alternates_json: str, status: str) -> st approve_btn = ( f'' ) items.append( @@ -76,7 +76,7 @@ def render_request_card(req: SongRequestModel) -> str: actions = "" if req.status == "pending": actions = f""" -
+
@@ -86,7 +86,7 @@ def render_request_card(req: SongRequestModel) -> str:
""" elif req.status == "approved": actions = f""" -
+
@@ -130,6 +130,7 @@ class WebServer: self._thread: Optional[threading.Thread] = None self._runner: Optional[web.AppRunner] = None self._ws_clients: weakref.WeakSet = weakref.WeakSet() + self._ws_admin_map: dict = {} def start(self): self._loop = asyncio.new_event_loop() @@ -172,7 +173,11 @@ class WebServer: async def _start_app(self): app = web.Application() app.router.add_get("/", self._handle_dashboard) + app.router.add_get("/login", self._handle_login_page) app.router.add_get("/ws", self._handle_ws) + app.router.add_post("/api/auth/login", self._handle_auth_login) + app.router.add_post("/api/auth/logout", self._handle_auth_logout) + app.router.add_get("/api/auth/me", self._handle_auth_me) app.router.add_get("/api/channels", self._handle_channels) 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) @@ -185,8 +190,11 @@ class WebServer: 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_patch("/api/sessions/{session_id}", self._handle_rename_session) + app.router.add_delete("/api/sessions/{session_id}", self._handle_delete_session) + app.router.add_post("/api/sessions/{session_id}/clear", self._handle_clear_session) app.router.add_static("/static", STATIC_DIR) - # Catch-all for /{channel} URL routing — must be last + # Catch-all for /{channel} URL routing -- must be last app.router.add_get("/{channel}", self._handle_dashboard) self._runner = web.AppRunner(app) @@ -222,13 +230,96 @@ class WebServer: for ws in dead: self._ws_clients.discard(ws) - def _check_auth(self, request: web.Request) -> bool: - token = self._get_auth_token() + # ------------------------------------------------------------------ + # Auth helpers + # ------------------------------------------------------------------ + + def _extract_token(self, request: web.Request) -> str: + return ( + request.headers.get("X-Auth-Token", "") + or request.query.get("token", "") + ) + + def _check_auth(self, request: web.Request) -> Optional[str]: + """Validate request auth. Returns admin username or None. + + Accepts either a valid session token or the legacy webAuthToken. + """ + token = self._extract_token(request) if not token: - return True - q_token = request.query.get("token", "") - h_token = request.headers.get("X-Auth-Token", "") - return q_token == token or h_token == token + return None + + username = self._store.validate_session(token) + if username: + return username + + legacy = self._get_auth_token() + if legacy and token == legacy: + return "__legacy__" + + return None + + def _require_auth(self, request: web.Request) -> Optional[web.Response]: + """Return a 403 Response if auth fails, or None if auth passes.""" + if self._check_auth(request) is None: + return web.Response(text="Forbidden", status=403) + return None + + def _get_online_admins(self) -> list: + return sorted(set(self._ws_admin_map.values())) + + async def _broadcast_presence(self): + admins = self._get_online_admins() + payload = json.dumps({"event": "admin-presence", "admins": admins}) + await self._broadcast_raw(payload) + + # ------------------------------------------------------------------ + # Auth routes + # ------------------------------------------------------------------ + + async def _handle_login_page(self, request: web.Request) -> web.Response: + template_path = os.path.join(TEMPLATES_DIR, "login.html") + try: + with open(template_path, "r", encoding="utf-8") as f: + content = f.read() + except FileNotFoundError: + return web.Response(text="Login template not found", status=500) + return web.Response(text=content, content_type="text/html") + + async def _handle_auth_login(self, request: web.Request) -> web.Response: + try: + data = await request.json() + except Exception: + return web.Response(text="Bad request", status=400) + + username = (data.get("username") or "").strip() + key = data.get("key") or "" + if not username or not key: + return web.json_response({"error": "Username and key are required"}, status=400) + + admin_id = self._store.validate_admin(username, key) + if admin_id is None: + return web.json_response({"error": "Invalid credentials"}, status=401) + + token = self._store.create_session_token(admin_id, username) + return web.json_response({"token": token, "username": username}) + + async def _handle_auth_logout(self, request: web.Request) -> web.Response: + token = self._extract_token(request) + if token: + self._store.delete_session(token) + return web.json_response({"ok": True}) + + async def _handle_auth_me(self, request: web.Request) -> web.Response: + token = self._extract_token(request) + username = self._store.validate_session(token) if token else None + if not username: + return web.json_response({"error": "Not authenticated"}, status=401) + return web.json_response({"username": username}) + + # ------------------------------------------------------------------ + # Dashboard & WebSocket + # ------------------------------------------------------------------ async def _handle_dashboard(self, request: web.Request) -> web.Response: template_path = os.path.join(TEMPLATES_DIR, "index.html") @@ -237,23 +328,35 @@ class WebServer: content = f.read() except FileNotFoundError: return web.Response(text="Dashboard template not found", status=500) - - token = self._get_auth_token() - content = content.replace("{{AUTH_TOKEN}}", html.escape(token or "")) return web.Response(text=content, content_type="text/html") async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse: ws = web.WebSocketResponse() await ws.prepare(request) self._ws_clients.add(ws) + + token = request.query.get("token", "") + admin_username = self._store.validate_session(token) if token else None + if admin_username: + self._ws_admin_map[ws] = admin_username + await self._broadcast_presence() + try: async for msg in ws: if msg.type == aiohttp.WSMsgType.ERROR: break finally: self._ws_clients.discard(ws) + was_admin = ws in self._ws_admin_map + self._ws_admin_map.pop(ws, None) + if was_admin: + await self._broadcast_presence() return ws + # ------------------------------------------------------------------ + # Data API + # ------------------------------------------------------------------ + async def _handle_channels(self, request: web.Request) -> web.Response: channels = self._store.get_channels() return web.json_response(channels) @@ -273,8 +376,9 @@ class WebServer: return web.Response(text=cards, content_type="text/html") async def _handle_api_action(self, request: web.Request) -> web.Response: - if not self._check_auth(request): - return web.Response(text="Forbidden", status=403) + denied = self._require_auth(request) + if denied: + return denied try: request_id = int(request.match_info["request_id"]) @@ -303,8 +407,9 @@ class WebServer: return web.Response(text=card_html, content_type="text/html") async def _handle_approve_alt(self, request: web.Request) -> web.Response: - if not self._check_auth(request): - return web.Response(text="Forbidden", status=403) + denied = self._require_auth(request) + if denied: + return denied try: request_id = int(request.match_info["request_id"]) @@ -342,8 +447,9 @@ class WebServer: return web.Response(text=card_html, content_type="text/html") async def _handle_export_markdown(self, request: web.Request) -> web.Response: - if not self._check_auth(request): - return web.Response(text="Forbidden", status=403) + denied = self._require_auth(request) + if denied: + return denied channel_filter = request.query.get("channel") or None reqs = self._store.get_history(limit=5000, channel=channel_filter) @@ -367,8 +473,9 @@ class WebServer: return web.json_response({"open": is_open}) async def _handle_post_status(self, request: web.Request) -> web.Response: - if not self._check_auth(request): - return web.Response(text="Forbidden", status=403) + denied = self._require_auth(request) + if denied: + return denied try: data = await request.json() @@ -389,8 +496,9 @@ class WebServer: return web.json_response({"open": is_open}) async def _handle_clear_history(self, request: web.Request) -> web.Response: - if not self._check_auth(request): - return web.Response(text="Forbidden", status=403) + denied = self._require_auth(request) + if denied: + return denied count = self._store.clear_history() payload = json.dumps({"event": "history-cleared"}) @@ -422,8 +530,9 @@ class WebServer: 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) + denied = self._require_auth(request) + if denied: + return denied existing = self._store.get_active_session() if existing: @@ -444,8 +553,9 @@ class WebServer: 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) + denied = self._require_auth(request) + if denied: + return denied active = self._store.get_active_session() if not active: @@ -476,3 +586,57 @@ class WebServer: 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") + + async def _handle_rename_session(self, request: web.Request) -> web.Response: + denied = self._require_auth(request) + if denied: + return denied + + try: + session_id = int(request.match_info["session_id"]) + except (ValueError, KeyError): + return web.Response(text="Bad request", status=400) + + try: + data = await request.json() + except Exception: + return web.Response(text="Bad request", status=400) + + name = data.get("name", "").strip() + if not name: + return web.json_response({"error": "Name cannot be empty"}, status=400) + + session = self._store.rename_session(session_id, name) + if not session: + return web.json_response({"error": "Session not found"}, status=404) + + return web.json_response(session.to_dict()) + + async def _handle_clear_session(self, request: web.Request) -> web.Response: + denied = self._require_auth(request) + if denied: + return denied + + try: + session_id = int(request.match_info["session_id"]) + except (ValueError, KeyError): + return web.Response(text="Bad request", status=400) + + count = self._store.clear_session_requests(session_id) + return web.json_response({"cleared": count}) + + async def _handle_delete_session(self, request: web.Request) -> web.Response: + denied = self._require_auth(request) + if denied: + return denied + + try: + session_id = int(request.match_info["session_id"]) + except (ValueError, KeyError): + return web.Response(text="Bad request", status=400) + + deleted = self._store.delete_session(session_id) + if not deleted: + return web.json_response({"error": "Session not found"}, status=404) + + return web.json_response({"deleted": True})