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
This commit is contained in:
258
.cursor/plans/apple_music_integration_a6a44bfc.plan.md
Normal file
258
.cursor/plans/apple_music_integration_a6a44bfc.plan.md
Normal file
@@ -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 `<script src="https://js-cdn.music.apple.com/musickit/v3/musickit.js">`
|
||||||
|
- Add a "Connect Apple Music" button in the dashboard that calls `music.authorize()`
|
||||||
|
- On approve/auto-approve, call `music.playNext({ songs: [trackId] })` to queue the song
|
||||||
|
- Optionally, add a mini player widget to the dashboard showing the current queue
|
||||||
|
|
||||||
|
**Pros:** Actually controls the live playback queue, songs play in order, real-time control from the dashboard
|
||||||
|
**Cons:** Requires the dashboard to be open in a browser on the machine doing playback, browser tab must stay active, only controls playback on that specific device/session
|
||||||
|
|
||||||
|
### Recommendation
|
||||||
|
|
||||||
|
**Approach B (MusicKit JS)** is better suited to the DJ/streamer use case -- it puts songs directly into the playback queue rather than just appending to a playlist. **Approach A** is useful as a secondary feature (e.g., building a "requested songs" playlist for later). Both can coexist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature 2: Track When Requests Get Played Automatically
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
Apple Music has **no server-side webhook or push notification** for "now playing" events. There is no way for the bot to passively learn that a song started playing without active client-side monitoring.
|
||||||
|
|
||||||
|
### Viable Approaches
|
||||||
|
|
||||||
|
### Approach A: MusicKit JS Event Monitoring (Best Fit)
|
||||||
|
|
||||||
|
If MusicKit JS is already embedded (from Feature 1, Approach B), the dashboard can listen for playback events:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const music = MusicKit.getInstance();
|
||||||
|
music.addEventListener('playbackStateDidChange', (event) => {
|
||||||
|
if (event.state === MusicKit.PlaybackStates.playing) {
|
||||||
|
const item = music.nowPlayingItem;
|
||||||
|
// Send track ID to bot backend via WebSocket/fetch
|
||||||
|
fetch('/api/now-playing', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ trackId: item.id, title: item.title })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant AM as Apple Music (Playback)
|
||||||
|
participant Dashboard as Web Dashboard + MusicKit JS
|
||||||
|
participant Bot as Limnoria Bot
|
||||||
|
participant IRC as IRC Channel
|
||||||
|
|
||||||
|
AM->>Dashboard: playbackStateDidChange (playing)
|
||||||
|
Dashboard->>Dashboard: Check nowPlayingItem.id
|
||||||
|
Dashboard->>Bot: POST /api/now-playing {trackId}
|
||||||
|
Bot->>Bot: Match trackId against approved requests
|
||||||
|
Bot->>Bot: Update status to "played"
|
||||||
|
Bot->>IRC: [NOW PLAYING] Song - Artist (requested by nick)
|
||||||
|
Bot->>Dashboard: WebSocket push (card updated)
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Implementation steps:**
|
||||||
|
|
||||||
|
- Add `playbackStateDidChange` and `nowPlayingItemDidChange` event listeners in the dashboard JS
|
||||||
|
- When a new song starts playing, the dashboard POSTs the track info to a new `/api/now-playing` endpoint
|
||||||
|
- The bot matches the `trackId` against approved requests in the database
|
||||||
|
- If matched, auto-updates the request status to "played" and announces in IRC
|
||||||
|
- Add a `played_at` timestamp column to the requests table for precise tracking
|
||||||
|
|
||||||
|
**Pros:** Automatic, real-time, no manual "Mark Played" clicking needed
|
||||||
|
**Cons:** Requires the dashboard + MusicKit JS to be running, only detects playback from the MusicKit JS session (not from the Apple Music app directly)
|
||||||
|
|
||||||
|
### Approach B: Recently Played Polling (Server-Side Fallback)
|
||||||
|
|
||||||
|
Poll the Apple Music REST API's recently-played endpoint:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://api.music.apple.com/v1/me/recent/played/tracks
|
||||||
|
Authorization: Bearer {developer_token}
|
||||||
|
Music-User-Token: {user_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation steps:**
|
||||||
|
|
||||||
|
- Add a background polling loop (every 30-60 seconds) that fetches recently played tracks
|
||||||
|
- Compare against approved requests by `trackId`
|
||||||
|
- Auto-mark matched requests as "played"
|
||||||
|
|
||||||
|
**Pros:** Works without the dashboard being open, detects plays from any device/app
|
||||||
|
**Cons:** Not real-time (30-60s delay), the recently-played API has undocumented limitations (unclear update frequency, unclear history depth, may miss short plays), Music User Token must be stored server-side and refreshed
|
||||||
|
|
||||||
|
### Approach C: Hybrid
|
||||||
|
|
||||||
|
Use MusicKit JS events (Approach A) when the dashboard is open, and fall back to recently-played polling (Approach B) when it's not. The bot prefers the real-time signal but has the poller as a safety net.
|
||||||
|
|
||||||
|
### Recommendation
|
||||||
|
|
||||||
|
**Approach A (MusicKit JS events)** is the most reliable and real-time option, especially since the dashboard is already the operator's control panel. **Approach B** could supplement it but has reliability concerns due to the poorly-documented recently-played API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Feasibility
|
||||||
|
|
||||||
|
|
||||||
|
| Aspect | Feasibility | Notes |
|
||||||
|
| -------------------------------- | ------------ | ------------------------------------------ |
|
||||||
|
| Add to playlist (REST) | High | Well-documented API, straightforward |
|
||||||
|
| Add to queue (MusicKit JS) | High | Requires dashboard open during playback |
|
||||||
|
| Auto-detect played (MusicKit JS) | High | Real-time, but scoped to dashboard session |
|
||||||
|
| Auto-detect played (server-side) | Medium | Recently-played API is poorly documented |
|
||||||
|
| Server-side webhook/push | Not possible | Apple provides no such mechanism |
|
||||||
|
|
||||||
|
|
||||||
|
## Key Files to Modify
|
||||||
|
|
||||||
|
- `[SongRequest/templates/index.html](SongRequest/templates/index.html)` -- embed MusicKit JS, add authorize flow, add event listeners
|
||||||
|
- `[SongRequest/web.py](SongRequest/web.py)` -- new `/api/now-playing` endpoint, MusicKit config endpoint
|
||||||
|
- `[SongRequest/plugin.py](SongRequest/plugin.py)` -- auto-play detection callback, IRC announcements
|
||||||
|
- `[SongRequest/config.py](SongRequest/config.py)` -- new config values for Apple Developer credentials
|
||||||
|
- New: `[SongRequest/apple_music.py](SongRequest/apple_music.py)` -- JWT generation, REST API wrapper (for Approach A playlist add and/or recently-played polling)
|
||||||
|
- `[SongRequest/store.py](SongRequest/store.py)` -- optional `played_at` column, query for matching trackId against approved requests
|
||||||
|
|
||||||
|
## New Dependencies
|
||||||
|
|
||||||
|
- `PyJWT` + `cryptography` -- for generating Apple Developer JWT tokens (server-side)
|
||||||
|
- No new JS dependencies -- MusicKit JS is loaded from Apple's CDN
|
||||||
|
|
||||||
@@ -59,8 +59,9 @@ conf.registerGlobalValue(
|
|||||||
"webAuthToken",
|
"webAuthToken",
|
||||||
registry.String(
|
registry.String(
|
||||||
"",
|
"",
|
||||||
_("""Secret token required for web dashboard admin actions.
|
_("""(Deprecated) Legacy shared token for web dashboard auth.
|
||||||
Set this to a random string."""),
|
Prefer per-admin accounts via addSongAdmin. If set, this
|
||||||
|
token is still accepted as a fallback for API access."""),
|
||||||
private=True,
|
private=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -493,5 +493,46 @@ class SongRequest(callbacks.Plugin):
|
|||||||
|
|
||||||
stopsession = wrap(stopsession, ["admin"])
|
stopsession = wrap(stopsession, ["admin"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Web admin management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def addsongadmin(self, irc, msg, args, username, key):
|
||||||
|
"""<username> <adminkey>
|
||||||
|
|
||||||
|
Add a web dashboard admin account. Requires bot admin.
|
||||||
|
"""
|
||||||
|
if self.store.add_admin(username, key):
|
||||||
|
irc.reply(f'Web admin "{username}" added.', prefixNick=True, private=True)
|
||||||
|
else:
|
||||||
|
irc.reply(f'Username "{username}" already exists.', prefixNick=True, private=True)
|
||||||
|
|
||||||
|
addsongadmin = wrap(addsongadmin, ["admin", "somethingWithoutSpaces", "text"])
|
||||||
|
|
||||||
|
def removesongadmin(self, irc, msg, args, username):
|
||||||
|
"""<username>
|
||||||
|
|
||||||
|
Remove a web dashboard admin account. Requires bot admin.
|
||||||
|
"""
|
||||||
|
if self.store.remove_admin(username):
|
||||||
|
irc.reply(f'Web admin "{username}" removed.', prefixNick=True, private=True)
|
||||||
|
else:
|
||||||
|
irc.reply(f'No admin named "{username}".', prefixNick=True, private=True)
|
||||||
|
|
||||||
|
removesongadmin = wrap(removesongadmin, ["admin", "somethingWithoutSpaces"])
|
||||||
|
|
||||||
|
def listsongadmins(self, irc, msg, args):
|
||||||
|
"""(takes no arguments)
|
||||||
|
|
||||||
|
List all web dashboard admin accounts. Requires bot admin.
|
||||||
|
"""
|
||||||
|
admins = self.store.list_admins()
|
||||||
|
if admins:
|
||||||
|
irc.reply("Web admins: " + ", ".join(admins), prefixNick=True, private=True)
|
||||||
|
else:
|
||||||
|
irc.reply("No web admins configured.", prefixNick=True, private=True)
|
||||||
|
|
||||||
|
listsongadmins = wrap(listsongadmins, ["admin"])
|
||||||
|
|
||||||
|
|
||||||
Class = SongRequest
|
Class = SongRequest
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
@@ -35,6 +37,8 @@ CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_requests_channel ON requests(channel);
|
CREATE INDEX IF NOT EXISTS idx_requests_channel ON requests(channel);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
SESSION_EXPIRY_SECONDS = 86400 # 24 hours
|
||||||
|
|
||||||
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",
|
"ALTER TABLE requests ADD COLUMN session_id INTEGER DEFAULT NULL",
|
||||||
@@ -45,6 +49,22 @@ MIGRATIONS = [
|
|||||||
ended_at REAL
|
ended_at REAL
|
||||||
)""",
|
)""",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id)",
|
"CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id)",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS web_admins (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
salt TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL
|
||||||
|
)""",
|
||||||
|
"""CREATE TABLE IF NOT EXISTS web_sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
admin_id INTEGER NOT NULL REFERENCES web_admins(id),
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
expires_at REAL NOT NULL
|
||||||
|
)""",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_web_sessions_token ON web_sessions(token)",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -104,6 +124,100 @@ class RequestStore:
|
|||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Web Admins
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hash_key(key: str, salt: str) -> str:
|
||||||
|
return hashlib.sha256((salt + key).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def add_admin(self, username: str, key: str) -> bool:
|
||||||
|
salt = secrets.token_hex(16)
|
||||||
|
key_hash = self._hash_key(key, salt)
|
||||||
|
now = time.time()
|
||||||
|
try:
|
||||||
|
with self._lock, self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO web_admins (username, key_hash, salt, created_at) VALUES (?, ?, ?, ?)",
|
||||||
|
(username, key_hash, salt, now),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_admin(self, username: str) -> bool:
|
||||||
|
with self._lock, self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM web_admins WHERE username = ?", (username,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
admin_id = row["id"]
|
||||||
|
conn.execute("DELETE FROM web_sessions WHERE admin_id = ?", (admin_id,))
|
||||||
|
conn.execute("DELETE FROM web_admins WHERE id = ?", (admin_id,))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list_admins(self) -> List[str]:
|
||||||
|
with self._lock, self._connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT username FROM web_admins ORDER BY username"
|
||||||
|
).fetchall()
|
||||||
|
return [r["username"] for r in rows]
|
||||||
|
|
||||||
|
def validate_admin(self, username: str, key: str) -> Optional[int]:
|
||||||
|
with self._lock, self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, key_hash, salt FROM web_admins WHERE username = ?",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
expected = self._hash_key(key, row["salt"])
|
||||||
|
if not secrets.compare_digest(expected, row["key_hash"]):
|
||||||
|
return None
|
||||||
|
return row["id"]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Web Sessions
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def create_session_token(self, admin_id: int, username: str) -> str:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
now = time.time()
|
||||||
|
expires = now + SESSION_EXPIRY_SECONDS
|
||||||
|
with self._lock, self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO web_sessions (token, admin_id, username, created_at, expires_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(token, admin_id, username, now, expires),
|
||||||
|
)
|
||||||
|
return token
|
||||||
|
|
||||||
|
def validate_session(self, token: str) -> Optional[str]:
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
now = time.time()
|
||||||
|
with self._lock, self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT username, expires_at FROM web_sessions WHERE token = ?",
|
||||||
|
(token,),
|
||||||
|
).fetchone()
|
||||||
|
if not row or row["expires_at"] < now:
|
||||||
|
return None
|
||||||
|
return row["username"]
|
||||||
|
|
||||||
|
def delete_session(self, token: str) -> None:
|
||||||
|
with self._lock, self._connect() as conn:
|
||||||
|
conn.execute("DELETE FROM web_sessions WHERE token = ?", (token,))
|
||||||
|
|
||||||
|
def cleanup_expired_sessions(self) -> int:
|
||||||
|
now = time.time()
|
||||||
|
with self._lock, self._connect() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM web_sessions WHERE expires_at < ?", (now,)
|
||||||
|
)
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Sessions
|
# Sessions
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -171,6 +285,29 @@ class RequestStore:
|
|||||||
)
|
)
|
||||||
return cur.rowcount
|
return cur.rowcount
|
||||||
|
|
||||||
|
def rename_session(self, session_id: int, name: str) -> Optional[Session]:
|
||||||
|
with self._lock, self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET name = ? WHERE id = ?",
|
||||||
|
(name, session_id),
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||||
|
return Session(**dict(row)) if row else None
|
||||||
|
|
||||||
|
def clear_session_requests(self, session_id: int) -> int:
|
||||||
|
with self._lock, self._connect() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM requests WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
def delete_session(self, session_id: int) -> bool:
|
||||||
|
with self._lock, self._connect() as conn:
|
||||||
|
conn.execute("DELETE FROM requests WHERE session_id = ?", (session_id,))
|
||||||
|
cur = conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Requests
|
# Requests
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -76,6 +76,11 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-only { display: none !important; }
|
||||||
|
body.is-admin .admin-only { display: flex !important; }
|
||||||
|
body.is-admin .admin-only-inline { display: inline-block !important; }
|
||||||
|
.admin-only-inline { display: none !important; }
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -117,6 +122,31 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-user {
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-link:hover { border-color: #444; color: var(--text); }
|
||||||
|
|
||||||
.toggle-label {
|
.toggle-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -579,6 +609,37 @@
|
|||||||
padding: 0 1rem 1rem;
|
padding: 0 1rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archive-actions {
|
||||||
|
display: none;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.is-admin .archive-actions { display: flex; }
|
||||||
|
|
||||||
|
.archive-actions .btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem 0.35rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-actions .btn-icon:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-actions .btn-icon.btn-danger:hover {
|
||||||
|
background: var(--reject-bg);
|
||||||
|
color: var(--reject);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Toast notifications ---- */
|
/* ---- Toast notifications ---- */
|
||||||
#toast-container {
|
#toast-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -705,6 +766,61 @@
|
|||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Admin presence ---- */
|
||||||
|
#admin-presence {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 800;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.25);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#admin-presence.visible { display: flex; }
|
||||||
|
|
||||||
|
.presence-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-pills {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
background: var(--surface-hover);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--approve);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.container { padding: 1rem; }
|
.container { padding: 1rem; }
|
||||||
|
|
||||||
@@ -714,6 +830,8 @@
|
|||||||
|
|
||||||
.toggle-text { display: none; }
|
.toggle-text { display: none; }
|
||||||
|
|
||||||
|
.auth-user { display: none; }
|
||||||
|
|
||||||
.album-art { width: 72px; height: 72px; }
|
.album-art { width: 72px; height: 72px; }
|
||||||
|
|
||||||
.request-card { gap: 0.75rem; padding: 0.75rem; }
|
.request-card { gap: 0.75rem; padding: 0.75rem; }
|
||||||
@@ -739,6 +857,22 @@
|
|||||||
.session-bar .btn { margin-left: 0; }
|
.session-bar .btn { margin-left: 0; }
|
||||||
|
|
||||||
#toast-container { right: 0.5rem; left: 0.5rem; max-width: none; }
|
#toast-container { right: 0.5rem; left: 0.5rem; max-width: none; }
|
||||||
|
|
||||||
|
#admin-presence {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
max-width: none;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presence-pills {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.presence-pills::-webkit-scrollbar { display: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -748,11 +882,14 @@
|
|||||||
<div class="logo">♫</div>
|
<div class="logo">♫</div>
|
||||||
<h1>Song Requests</h1>
|
<h1>Song Requests</h1>
|
||||||
<div class="header-controls">
|
<div class="header-controls">
|
||||||
<label class="toggle-label" title="Open/close requests">
|
<label class="toggle-label admin-only" title="Open/close requests">
|
||||||
<span class="toggle-text" id="toggle-text">Open</span>
|
<span class="toggle-text" id="toggle-text">Open</span>
|
||||||
<input type="checkbox" id="requests-toggle" checked onchange="toggleRequests(this.checked)" />
|
<input type="checkbox" id="requests-toggle" checked onchange="toggleRequests(this.checked)" />
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="auth-controls" id="auth-controls">
|
||||||
|
<a class="auth-link" href="/login" id="auth-login-link">Admin Login</a>
|
||||||
|
</div>
|
||||||
<button class="theme-btn" id="theme-btn" onclick="cycleTheme()" title="Toggle theme">☾</button>
|
<button class="theme-btn" id="theme-btn" onclick="cycleTheme()" title="Toggle theme">☾</button>
|
||||||
<div id="status-dot" class="status-dot disconnected" title="Disconnected"></div>
|
<div id="status-dot" class="status-dot disconnected" title="Disconnected"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -762,7 +899,7 @@
|
|||||||
<div id="session-bar" class="session-bar" style="display:none;">
|
<div id="session-bar" class="session-bar" style="display:none;">
|
||||||
<span class="session-name" id="session-name"></span>
|
<span class="session-name" id="session-name"></span>
|
||||||
<span class="session-duration" id="session-duration"></span>
|
<span class="session-duration" id="session-duration"></span>
|
||||||
<button class="btn btn-secondary" id="session-btn" onclick="handleSessionAction()">Start Session</button>
|
<button class="btn btn-secondary admin-only" id="session-btn" onclick="handleSessionAction()">Start Session</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="channel-bar" class="channel-bar">
|
<div id="channel-bar" class="channel-bar">
|
||||||
@@ -792,7 +929,7 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<div class="section-title" id="history-title">History</div>
|
<div class="section-title" id="history-title">History</div>
|
||||||
<div class="section-actions">
|
<div class="section-actions admin-only">
|
||||||
<a id="export-btn" class="btn btn-secondary" href="#" onclick="exportMarkdown(event)">Export .md</a>
|
<a id="export-btn" class="btn btn-secondary" href="#" onclick="exportMarkdown(event)">Export .md</a>
|
||||||
<button class="btn btn-secondary btn-danger-text" onclick="clearHistory()">Clear History</button>
|
<button class="btn btn-secondary btn-danger-text" onclick="clearHistory()">Clear History</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -806,6 +943,11 @@
|
|||||||
|
|
||||||
<div id="toast-container"></div>
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
|
<div id="admin-presence">
|
||||||
|
<span class="presence-label">Online:</span>
|
||||||
|
<div class="presence-pills" id="presence-pills"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="modal-overlay">
|
<div class="modal-overlay" id="modal-overlay">
|
||||||
<div class="modal-box" id="modal-box">
|
<div class="modal-box" id="modal-box">
|
||||||
<div class="modal-title" id="modal-title"></div>
|
<div class="modal-title" id="modal-title"></div>
|
||||||
@@ -816,7 +958,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var AUTH_TOKEN = "{{AUTH_TOKEN}}";
|
var isAdmin = false;
|
||||||
|
var adminUsername = '';
|
||||||
var ws = null;
|
var ws = null;
|
||||||
var reconnectDelay = 1000;
|
var reconnectDelay = 1000;
|
||||||
var maxReconnectDelay = 30000;
|
var maxReconnectDelay = 30000;
|
||||||
@@ -826,6 +969,83 @@
|
|||||||
var sessionTimerInterval = null;
|
var sessionTimerInterval = null;
|
||||||
var currentTab = 'queue';
|
var currentTab = 'queue';
|
||||||
|
|
||||||
|
function getAuthToken() {
|
||||||
|
return localStorage.getItem('authToken') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
var token = getAuthToken();
|
||||||
|
var h = {'Content-Type': 'application/json'};
|
||||||
|
if (token) h['X-Auth-Token'] = token;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenParam(url) {
|
||||||
|
var token = getAuthToken();
|
||||||
|
if (!token) return url;
|
||||||
|
return url + (url.indexOf('?') >= 0 ? '&' : '?') + 'token=' + encodeURIComponent(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Auth state ----
|
||||||
|
function applyAdminState(authenticated, username) {
|
||||||
|
isAdmin = authenticated;
|
||||||
|
adminUsername = username || '';
|
||||||
|
if (authenticated) {
|
||||||
|
document.body.classList.add('is-admin');
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('is-admin');
|
||||||
|
}
|
||||||
|
renderAuthControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuthControls() {
|
||||||
|
var el = document.getElementById('auth-controls');
|
||||||
|
if (isAdmin) {
|
||||||
|
el.innerHTML = '<span class="auth-user">' + escapeHtml(adminUsername) + '</span>'
|
||||||
|
+ '<a class="auth-link" href="#" onclick="doLogout(event)">Logout</a>';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = '<a class="auth-link" href="/login">Admin Login</a>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doLogout(e) {
|
||||||
|
if (e) e.preventDefault();
|
||||||
|
var token = getAuthToken();
|
||||||
|
if (token) {
|
||||||
|
fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-Auth-Token': token}
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
localStorage.removeItem('adminUsername');
|
||||||
|
applyAdminState(false, '');
|
||||||
|
if (ws) { ws.close(); }
|
||||||
|
setTimeout(connectWS, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAuth() {
|
||||||
|
var token = getAuthToken();
|
||||||
|
if (!token) {
|
||||||
|
applyAdminState(false, '');
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return fetch('/api/auth/me', { headers: {'X-Auth-Token': token} })
|
||||||
|
.then(function(r) {
|
||||||
|
if (r.ok) return r.json();
|
||||||
|
throw new Error('invalid');
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
localStorage.setItem('adminUsername', data.username);
|
||||||
|
applyAdminState(true, data.username);
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
localStorage.removeItem('adminUsername');
|
||||||
|
applyAdminState(false, '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Theme ----
|
// ---- Theme ----
|
||||||
var themeOrder = ['system', 'light', 'dark'];
|
var themeOrder = ['system', 'light', 'dark'];
|
||||||
var themeIcons = { system: '\u263E', light: '\u2600', dark: '\u263E' };
|
var themeIcons = { system: '\u263E', light: '\u2600', dark: '\u263E' };
|
||||||
@@ -855,7 +1075,7 @@
|
|||||||
var hash = location.hash.replace(/^#/, '');
|
var hash = location.hash.replace(/^#/, '');
|
||||||
if (hash) return hash.startsWith('#') ? hash : (hash.startsWith('%23') ? decodeURIComponent(hash) : '#' + hash);
|
if (hash) return hash.startsWith('#') ? hash : (hash.startsWith('%23') ? decodeURIComponent(hash) : '#' + hash);
|
||||||
var path = location.pathname.replace(/^\//, '');
|
var path = location.pathname.replace(/^\//, '');
|
||||||
if (path && path !== '' && !path.startsWith('api/') && !path.startsWith('static/') && !path.startsWith('ws')) {
|
if (path && path !== '' && !path.startsWith('api/') && !path.startsWith('static/') && !path.startsWith('ws') && path !== 'login') {
|
||||||
return path.startsWith('#') ? path : '#' + path;
|
return path.startsWith('#') ? path : '#' + path;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -930,7 +1150,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fetchInto(elementId, url) {
|
function fetchInto(elementId, url) {
|
||||||
if (AUTH_TOKEN) url += (url.indexOf('?') >= 0 ? '&' : '?') + 'token=' + encodeURIComponent(AUTH_TOKEN);
|
url = tokenParam(url);
|
||||||
fetch(url).then(function(r) { return r.text(); }).then(function(html) {
|
fetch(url).then(function(r) { return r.text(); }).then(function(html) {
|
||||||
var el = document.getElementById(elementId);
|
var el = document.getElementById(elementId);
|
||||||
if (el) { el.innerHTML = html; htmx.process(el); }
|
if (el) { el.innerHTML = html; htmx.process(el); }
|
||||||
@@ -938,8 +1158,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fetchChannels() {
|
function fetchChannels() {
|
||||||
var url = '/api/channels';
|
var url = tokenParam('/api/channels');
|
||||||
if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN);
|
|
||||||
fetch(url).then(function(r) { return r.json(); }).then(function(channels) {
|
fetch(url).then(function(r) { return r.json(); }).then(function(channels) {
|
||||||
var bar = document.getElementById('channel-bar');
|
var bar = document.getElementById('channel-bar');
|
||||||
var pills = '<button class="channel-pill' + (activeChannel === null ? ' active' : '') + '" onclick="setChannel(null, this)">All</button>';
|
var pills = '<button class="channel-pill' + (activeChannel === null ? ' active' : '') + '" onclick="setChannel(null, this)">All</button>';
|
||||||
@@ -953,8 +1172,9 @@
|
|||||||
|
|
||||||
// ---- Auth header for HTMX ----
|
// ---- Auth header for HTMX ----
|
||||||
document.body.addEventListener('htmx:configRequest', function(e) {
|
document.body.addEventListener('htmx:configRequest', function(e) {
|
||||||
if (AUTH_TOKEN) {
|
var token = getAuthToken();
|
||||||
e.detail.headers['X-Auth-Token'] = AUTH_TOKEN;
|
if (token) {
|
||||||
|
e.detail.headers['X-Auth-Token'] = token;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -971,20 +1191,18 @@
|
|||||||
function toggleRequests(isOpen) {
|
function toggleRequests(isOpen) {
|
||||||
fetch('/api/status', {
|
fetch('/api/status', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: Object.assign({'Content-Type': 'application/json'},
|
headers: authHeaders(),
|
||||||
AUTH_TOKEN ? {'X-Auth-Token': AUTH_TOKEN} : {}),
|
|
||||||
body: JSON.stringify({ open: isOpen })
|
body: JSON.stringify({ open: isOpen })
|
||||||
});
|
});
|
||||||
applyRequestsOpen(isOpen);
|
applyRequestsOpen(isOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
(function fetchInitialStatus() {
|
function fetchInitialStatus() {
|
||||||
var url = '/api/status';
|
fetch(tokenParam('/api/status'))
|
||||||
if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN);
|
.then(function(r) { return r.json(); })
|
||||||
fetch(url).then(function(r) { return r.json(); }).then(function(data) {
|
.then(function(data) { applyRequestsOpen(data.open); })
|
||||||
applyRequestsOpen(data.open);
|
.catch(function() {});
|
||||||
}).catch(function() {});
|
}
|
||||||
})();
|
|
||||||
|
|
||||||
// ---- Toast notifications ----
|
// ---- Toast notifications ----
|
||||||
function showToast(message, duration) {
|
function showToast(message, duration) {
|
||||||
@@ -1103,6 +1321,22 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Admin presence ----
|
||||||
|
function updatePresence(admins) {
|
||||||
|
var container = document.getElementById('admin-presence');
|
||||||
|
var pillsEl = document.getElementById('presence-pills');
|
||||||
|
if (!admins || admins.length === 0) {
|
||||||
|
container.classList.remove('visible');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '';
|
||||||
|
admins.forEach(function(name) {
|
||||||
|
html += '<span class="presence-pill"><span class="presence-dot"></span>' + escapeHtml(name) + '</span>';
|
||||||
|
});
|
||||||
|
pillsEl.innerHTML = html;
|
||||||
|
container.classList.add('visible');
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Sessions ----
|
// ---- Sessions ----
|
||||||
function formatDuration(startTs) {
|
function formatDuration(startTs) {
|
||||||
var elapsed = Math.floor(Date.now() / 1000 - startTs);
|
var elapsed = Math.floor(Date.now() / 1000 - startTs);
|
||||||
@@ -1127,7 +1361,7 @@
|
|||||||
nameEl.textContent = session.name;
|
nameEl.textContent = session.name;
|
||||||
durEl.textContent = formatDuration(session.started_at);
|
durEl.textContent = formatDuration(session.started_at);
|
||||||
btn.textContent = 'Stop Session';
|
btn.textContent = 'Stop Session';
|
||||||
btn.className = 'btn btn-reject';
|
btn.className = 'btn btn-reject admin-only';
|
||||||
btn.style.fontSize = '0.75rem';
|
btn.style.fontSize = '0.75rem';
|
||||||
btn.style.padding = '0.3rem 0.65rem';
|
btn.style.padding = '0.3rem 0.65rem';
|
||||||
titleEl.textContent = 'Session: ' + session.name;
|
titleEl.textContent = 'Session: ' + session.name;
|
||||||
@@ -1139,7 +1373,7 @@
|
|||||||
nameEl.textContent = 'No active session';
|
nameEl.textContent = 'No active session';
|
||||||
durEl.textContent = '';
|
durEl.textContent = '';
|
||||||
btn.textContent = 'Start Session';
|
btn.textContent = 'Start Session';
|
||||||
btn.className = 'btn btn-secondary';
|
btn.className = 'btn btn-secondary admin-only';
|
||||||
btn.style.fontSize = '0.75rem';
|
btn.style.fontSize = '0.75rem';
|
||||||
btn.style.padding = '0.3rem 0.65rem';
|
btn.style.padding = '0.3rem 0.65rem';
|
||||||
titleEl.textContent = 'History';
|
titleEl.textContent = 'History';
|
||||||
@@ -1160,7 +1394,7 @@
|
|||||||
onClick: function() {
|
onClick: function() {
|
||||||
fetch('/api/sessions/stop', {
|
fetch('/api/sessions/stop', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: Object.assign({'Content-Type': 'application/json'}, AUTH_TOKEN ? {'X-Auth-Token': AUTH_TOKEN} : {}),
|
headers: authHeaders(),
|
||||||
body: JSON.stringify({ clear_remaining: false })
|
body: JSON.stringify({ clear_remaining: false })
|
||||||
}).then(function() { fetchSessions(); reloadLists(); reloadArchivedSessions(); });
|
}).then(function() { fetchSessions(); reloadLists(); reloadArchivedSessions(); });
|
||||||
}
|
}
|
||||||
@@ -1168,7 +1402,7 @@
|
|||||||
onConfirm: function() {
|
onConfirm: function() {
|
||||||
fetch('/api/sessions/stop', {
|
fetch('/api/sessions/stop', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: Object.assign({'Content-Type': 'application/json'}, AUTH_TOKEN ? {'X-Auth-Token': AUTH_TOKEN} : {}),
|
headers: authHeaders(),
|
||||||
body: JSON.stringify({ clear_remaining: true })
|
body: JSON.stringify({ clear_remaining: true })
|
||||||
}).then(function() { fetchSessions(); reloadLists(); reloadArchivedSessions(); });
|
}).then(function() { fetchSessions(); reloadLists(); reloadArchivedSessions(); });
|
||||||
}
|
}
|
||||||
@@ -1184,7 +1418,7 @@
|
|||||||
onConfirm: function(name) {
|
onConfirm: function(name) {
|
||||||
fetch('/api/sessions/start', {
|
fetch('/api/sessions/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: Object.assign({'Content-Type': 'application/json'}, AUTH_TOKEN ? {'X-Auth-Token': AUTH_TOKEN} : {}),
|
headers: authHeaders(),
|
||||||
body: JSON.stringify({ name: name || '' })
|
body: JSON.stringify({ name: name || '' })
|
||||||
}).then(function() { fetchSessions(); reloadLists(); });
|
}).then(function() { fetchSessions(); reloadLists(); });
|
||||||
}
|
}
|
||||||
@@ -1193,9 +1427,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fetchSessions() {
|
function fetchSessions() {
|
||||||
var url = '/api/sessions';
|
fetch(tokenParam('/api/sessions'))
|
||||||
if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN);
|
.then(function(r) { return r.json(); })
|
||||||
fetch(url).then(function(r) { return r.json(); }).then(function(data) {
|
.then(function(data) {
|
||||||
applySessionState(data.active);
|
applySessionState(data.active);
|
||||||
renderArchivedSessions(data.archived);
|
renderArchivedSessions(data.archived);
|
||||||
}).catch(function() {});
|
}).catch(function() {});
|
||||||
@@ -1221,6 +1455,11 @@
|
|||||||
html += '<span>' + escapeHtml(s.name) + '</span>';
|
html += '<span>' + escapeHtml(s.name) + '</span>';
|
||||||
html += '<span class="archive-meta">' + start + (end ? ' \u2014 ' + end : '') + '</span>';
|
html += '<span class="archive-meta">' + start + (end ? ' \u2014 ' + end : '') + '</span>';
|
||||||
html += '<span class="archive-count">' + countLabel + '</span>';
|
html += '<span class="archive-count">' + countLabel + '</span>';
|
||||||
|
html += '<span class="archive-actions">'
|
||||||
|
+ '<button class="btn-icon" title="Rename" onclick="event.stopPropagation(); renameSession(' + s.id + ', this)">✎</button>'
|
||||||
|
+ '<button class="btn-icon" title="Clear requests" onclick="event.stopPropagation(); clearSession(' + s.id + ', this)">🗑</button>'
|
||||||
|
+ '<button class="btn-icon btn-danger" title="Delete session" onclick="event.stopPropagation(); deleteSession(' + s.id + ', this)">✕</button>'
|
||||||
|
+ '</span>';
|
||||||
html += '</summary>';
|
html += '</summary>';
|
||||||
html += '<div class="archive-body"><div class="request-list" id="archive-list-' + s.id + '"></div></div>';
|
html += '<div class="archive-body"><div class="request-list" id="archive-list-' + s.id + '"></div></div>';
|
||||||
html += '</details>';
|
html += '</details>';
|
||||||
@@ -1234,8 +1473,7 @@
|
|||||||
var sid = el.getAttribute('data-session-id');
|
var sid = el.getAttribute('data-session-id');
|
||||||
var listEl = document.getElementById('archive-list-' + sid);
|
var listEl = document.getElementById('archive-list-' + sid);
|
||||||
if (listEl && !listEl.dataset.loaded) {
|
if (listEl && !listEl.dataset.loaded) {
|
||||||
var aUrl = '/api/sessions/' + sid + '/requests?_=1' + channelParam();
|
var aUrl = tokenParam('/api/sessions/' + sid + '/requests?_=1' + channelParam());
|
||||||
if (AUTH_TOKEN) aUrl += '&token=' + encodeURIComponent(AUTH_TOKEN);
|
|
||||||
fetch(aUrl).then(function(r) { return r.text(); }).then(function(h) {
|
fetch(aUrl).then(function(r) { return r.text(); }).then(function(h) {
|
||||||
listEl.innerHTML = h || '<div class="empty-state"><p>No played requests</p></div>';
|
listEl.innerHTML = h || '<div class="empty-state"><p>No played requests</p></div>';
|
||||||
listEl.dataset.loaded = '1';
|
listEl.dataset.loaded = '1';
|
||||||
@@ -1247,6 +1485,64 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renameSession(sessionId, btn) {
|
||||||
|
var details = btn.closest('.archived-session');
|
||||||
|
var currentName = details ? details.querySelector('summary > span').textContent : '';
|
||||||
|
showModal({
|
||||||
|
title: 'Rename Session',
|
||||||
|
body: 'Enter a new name for this session.',
|
||||||
|
input: true,
|
||||||
|
inputPlaceholder: currentName,
|
||||||
|
inputDefault: currentName,
|
||||||
|
confirmText: 'Rename',
|
||||||
|
confirmClass: 'btn-approve',
|
||||||
|
onConfirm: function(name) {
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
fetch('/api/sessions/' + sessionId, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify({ name: name.trim() })
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) fetchSessions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSession(sessionId) {
|
||||||
|
showModal({
|
||||||
|
title: 'Clear Session Requests',
|
||||||
|
body: 'This will permanently remove all requests from this session. The session itself will remain. Continue?',
|
||||||
|
confirmText: 'Clear',
|
||||||
|
confirmClass: 'btn-reject',
|
||||||
|
onConfirm: function() {
|
||||||
|
fetch('/api/sessions/' + sessionId + '/clear', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders()
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) fetchSessions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSession(sessionId) {
|
||||||
|
showModal({
|
||||||
|
title: 'Delete Session',
|
||||||
|
body: 'This will permanently delete this session and all its requests. This cannot be undone. Continue?',
|
||||||
|
confirmText: 'Delete',
|
||||||
|
confirmClass: 'btn-reject',
|
||||||
|
onConfirm: function() {
|
||||||
|
fetch('/api/sessions/' + sessionId, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authHeaders()
|
||||||
|
}).then(function(r) {
|
||||||
|
if (r.ok) fetchSessions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
var d = document.createElement('div');
|
var d = document.createElement('div');
|
||||||
d.textContent = str;
|
d.textContent = str;
|
||||||
@@ -1259,8 +1555,7 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
var url = '/api/export/markdown?_=1';
|
var url = '/api/export/markdown?_=1';
|
||||||
if (activeChannel) url += '&channel=' + encodeURIComponent(activeChannel);
|
if (activeChannel) url += '&channel=' + encodeURIComponent(activeChannel);
|
||||||
if (AUTH_TOKEN) url += '&token=' + encodeURIComponent(AUTH_TOKEN);
|
window.location.href = tokenParam(url);
|
||||||
window.location.href = url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearHistory() {
|
function clearHistory() {
|
||||||
@@ -1270,10 +1565,10 @@
|
|||||||
confirmText: 'Clear',
|
confirmText: 'Clear',
|
||||||
confirmClass: 'btn-reject',
|
confirmClass: 'btn-reject',
|
||||||
onConfirm: function() {
|
onConfirm: function() {
|
||||||
var url = '/api/history/clear';
|
fetch(tokenParam('/api/history/clear'), {
|
||||||
if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN);
|
method: 'POST',
|
||||||
fetch(url, { method: 'POST', headers: AUTH_TOKEN ? { 'X-Auth-Token': AUTH_TOKEN } : {} })
|
headers: authHeaders()
|
||||||
.then(function() {
|
}).then(function() {
|
||||||
var hl = document.getElementById('history-list');
|
var hl = document.getElementById('history-list');
|
||||||
if (hl) hl.innerHTML = '';
|
if (hl) hl.innerHTML = '';
|
||||||
});
|
});
|
||||||
@@ -1285,7 +1580,8 @@
|
|||||||
function connectWS() {
|
function connectWS() {
|
||||||
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
var url = proto + '//' + location.host + '/ws';
|
var url = proto + '//' + location.host + '/ws';
|
||||||
if (AUTH_TOKEN) url += '?token=' + encodeURIComponent(AUTH_TOKEN);
|
var token = getAuthToken();
|
||||||
|
if (token) url += '?token=' + encodeURIComponent(token);
|
||||||
|
|
||||||
ws = new WebSocket(url);
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
@@ -1310,6 +1606,11 @@
|
|||||||
var msg;
|
var msg;
|
||||||
try { msg = JSON.parse(e.data); } catch (_) { return; }
|
try { msg = JSON.parse(e.data); } catch (_) { return; }
|
||||||
|
|
||||||
|
if (msg.event === 'admin-presence') {
|
||||||
|
updatePresence(msg.admins);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.event === 'history-cleared') {
|
if (msg.event === 'history-cleared') {
|
||||||
var hl = document.getElementById('history-list');
|
var hl = document.getElementById('history-list');
|
||||||
if (hl) hl.innerHTML = '';
|
if (hl) hl.innerHTML = '';
|
||||||
@@ -1385,10 +1686,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Init ----
|
// ---- Init ----
|
||||||
|
initAuth().then(function() {
|
||||||
fetchChannels();
|
fetchChannels();
|
||||||
reloadLists();
|
reloadLists();
|
||||||
fetchSessions();
|
fetchSessions();
|
||||||
|
fetchInitialStatus();
|
||||||
connectWS();
|
connectWS();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
241
SongRequest/templates/login.html
Normal file
241
SongRequest/templates/login.html
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="system">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Admin Login - Song Requests</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f0f0f;
|
||||||
|
--surface: #1a1a1a;
|
||||||
|
--surface-hover: #242424;
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--text: #e8e8e8;
|
||||||
|
--text-muted: #888;
|
||||||
|
--accent: #fa2d48;
|
||||||
|
--accent-hover: #ff4562;
|
||||||
|
--approve: #2dd4a0;
|
||||||
|
--radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #f5f5f7;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-hover: #f0f0f0;
|
||||||
|
--border: #e0e0e0;
|
||||||
|
--text: #1d1d1f;
|
||||||
|
--text-muted: #6e6e73;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
[data-theme="system"] {
|
||||||
|
--bg: #f5f5f7;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-hover: #f0f0f0;
|
||||||
|
--border: #e0e0e0;
|
||||||
|
--text: #1d1d1f;
|
||||||
|
--text-muted: #6e6e73;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #fc6076);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: var(--surface-hover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus { border-color: var(--accent); }
|
||||||
|
.field input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
min-height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem;
|
||||||
|
background: var(--accent);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login:hover { background: var(--accent-hover); }
|
||||||
|
.btn-login:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer a:hover { color: var(--text); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="login-logo">♫</div>
|
||||||
|
<h1>Admin Login</h1>
|
||||||
|
<p>Sign in to manage song requests</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="login-form" onsubmit="return handleLogin(event)">
|
||||||
|
<div class="field">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" placeholder="Your admin username" autocomplete="username" required />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="key">Admin Key</label>
|
||||||
|
<input type="password" id="key" name="key" placeholder="Your admin key" autocomplete="current-password" required />
|
||||||
|
</div>
|
||||||
|
<div class="error-msg" id="error-msg"></div>
|
||||||
|
<button type="submit" class="btn-login" id="login-btn">Sign In</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="login-footer">
|
||||||
|
<a href="/">View dashboard (read-only)</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function initTheme() {
|
||||||
|
var saved = localStorage.getItem('theme') || 'system';
|
||||||
|
document.documentElement.setAttribute('data-theme', saved);
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (localStorage.getItem('authToken')) {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogin(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var btn = document.getElementById('login-btn');
|
||||||
|
var errEl = document.getElementById('error-msg');
|
||||||
|
var username = document.getElementById('username').value.trim();
|
||||||
|
var key = document.getElementById('key').value;
|
||||||
|
|
||||||
|
if (!username || !key) {
|
||||||
|
errEl.textContent = 'Both fields are required.';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Signing in...';
|
||||||
|
errEl.textContent = '';
|
||||||
|
|
||||||
|
fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({username: username, key: key})
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json().then(function(d) { return {ok: r.ok, data: d}; }); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.ok && res.data.token) {
|
||||||
|
localStorage.setItem('authToken', res.data.token);
|
||||||
|
localStorage.setItem('adminUsername', res.data.username);
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
errEl.textContent = res.data.error || 'Login failed.';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Sign In';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
errEl.textContent = 'Connection error. Try again.';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Sign In';
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -43,7 +43,7 @@ def _render_alternates(request_id: int, alternates_json: str, status: str) -> st
|
|||||||
approve_btn = (
|
approve_btn = (
|
||||||
f'<button hx-post="/api/requests/{request_id}/approve-alt/{idx}"'
|
f'<button hx-post="/api/requests/{request_id}/approve-alt/{idx}"'
|
||||||
f' hx-swap="outerHTML" hx-target="#request-{request_id}"'
|
f' hx-swap="outerHTML" hx-target="#request-{request_id}"'
|
||||||
f' class="btn btn-approve btn-sm"'
|
f' class="btn btn-approve btn-sm admin-only"'
|
||||||
f' onclick="event.stopPropagation();">Approve</button>'
|
f' onclick="event.stopPropagation();">Approve</button>'
|
||||||
)
|
)
|
||||||
items.append(
|
items.append(
|
||||||
@@ -76,7 +76,7 @@ def render_request_card(req: SongRequestModel) -> str:
|
|||||||
actions = ""
|
actions = ""
|
||||||
if req.status == "pending":
|
if req.status == "pending":
|
||||||
actions = f"""
|
actions = f"""
|
||||||
<div class="card-actions">
|
<div class="card-actions admin-only">
|
||||||
<button hx-post="/api/requests/{req.id}/approve"
|
<button hx-post="/api/requests/{req.id}/approve"
|
||||||
hx-swap="outerHTML" hx-target="#request-{req.id}"
|
hx-swap="outerHTML" hx-target="#request-{req.id}"
|
||||||
class="btn btn-approve" onclick="event.stopPropagation()">Approve</button>
|
class="btn btn-approve" onclick="event.stopPropagation()">Approve</button>
|
||||||
@@ -86,7 +86,7 @@ def render_request_card(req: SongRequestModel) -> str:
|
|||||||
</div>"""
|
</div>"""
|
||||||
elif req.status == "approved":
|
elif req.status == "approved":
|
||||||
actions = f"""
|
actions = f"""
|
||||||
<div class="card-actions">
|
<div class="card-actions admin-only">
|
||||||
<button hx-post="/api/requests/{req.id}/played"
|
<button hx-post="/api/requests/{req.id}/played"
|
||||||
hx-swap="outerHTML" hx-target="#request-{req.id}"
|
hx-swap="outerHTML" hx-target="#request-{req.id}"
|
||||||
class="btn btn-played" onclick="event.stopPropagation()">Mark Played</button>
|
class="btn btn-played" onclick="event.stopPropagation()">Mark Played</button>
|
||||||
@@ -130,6 +130,7 @@ class WebServer:
|
|||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
self._runner: Optional[web.AppRunner] = None
|
self._runner: Optional[web.AppRunner] = None
|
||||||
self._ws_clients: weakref.WeakSet = weakref.WeakSet()
|
self._ws_clients: weakref.WeakSet = weakref.WeakSet()
|
||||||
|
self._ws_admin_map: dict = {}
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
self._loop = asyncio.new_event_loop()
|
self._loop = asyncio.new_event_loop()
|
||||||
@@ -172,7 +173,11 @@ class WebServer:
|
|||||||
async def _start_app(self):
|
async def _start_app(self):
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.router.add_get("/", self._handle_dashboard)
|
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_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/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)
|
||||||
@@ -185,8 +190,11 @@ class WebServer:
|
|||||||
app.router.add_post("/api/sessions/start", self._handle_start_session)
|
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_post("/api/sessions/stop", self._handle_stop_session)
|
||||||
app.router.add_get("/api/sessions/{session_id}/requests", self._handle_session_requests)
|
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)
|
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)
|
app.router.add_get("/{channel}", self._handle_dashboard)
|
||||||
|
|
||||||
self._runner = web.AppRunner(app)
|
self._runner = web.AppRunner(app)
|
||||||
@@ -222,13 +230,96 @@ class WebServer:
|
|||||||
for ws in dead:
|
for ws in dead:
|
||||||
self._ws_clients.discard(ws)
|
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:
|
if not token:
|
||||||
return True
|
return None
|
||||||
q_token = request.query.get("token", "")
|
|
||||||
h_token = request.headers.get("X-Auth-Token", "")
|
username = self._store.validate_session(token)
|
||||||
return q_token == token or h_token == 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:
|
async def _handle_dashboard(self, request: web.Request) -> web.Response:
|
||||||
template_path = os.path.join(TEMPLATES_DIR, "index.html")
|
template_path = os.path.join(TEMPLATES_DIR, "index.html")
|
||||||
@@ -237,23 +328,35 @@ class WebServer:
|
|||||||
content = f.read()
|
content = f.read()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return web.Response(text="Dashboard template not found", status=500)
|
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")
|
return web.Response(text=content, content_type="text/html")
|
||||||
|
|
||||||
async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
|
async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
|
||||||
ws = web.WebSocketResponse()
|
ws = web.WebSocketResponse()
|
||||||
await ws.prepare(request)
|
await ws.prepare(request)
|
||||||
self._ws_clients.add(ws)
|
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:
|
try:
|
||||||
async for msg in ws:
|
async for msg in ws:
|
||||||
if msg.type == aiohttp.WSMsgType.ERROR:
|
if msg.type == aiohttp.WSMsgType.ERROR:
|
||||||
break
|
break
|
||||||
finally:
|
finally:
|
||||||
self._ws_clients.discard(ws)
|
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
|
return ws
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Data API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def _handle_channels(self, request: web.Request) -> web.Response:
|
async def _handle_channels(self, request: web.Request) -> web.Response:
|
||||||
channels = self._store.get_channels()
|
channels = self._store.get_channels()
|
||||||
return web.json_response(channels)
|
return web.json_response(channels)
|
||||||
@@ -273,8 +376,9 @@ class WebServer:
|
|||||||
return web.Response(text=cards, content_type="text/html")
|
return web.Response(text=cards, content_type="text/html")
|
||||||
|
|
||||||
async def _handle_api_action(self, request: web.Request) -> web.Response:
|
async def _handle_api_action(self, request: web.Request) -> web.Response:
|
||||||
if not self._check_auth(request):
|
denied = self._require_auth(request)
|
||||||
return web.Response(text="Forbidden", status=403)
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request_id = int(request.match_info["request_id"])
|
request_id = int(request.match_info["request_id"])
|
||||||
@@ -303,8 +407,9 @@ 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:
|
||||||
if not self._check_auth(request):
|
denied = self._require_auth(request)
|
||||||
return web.Response(text="Forbidden", status=403)
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request_id = int(request.match_info["request_id"])
|
request_id = int(request.match_info["request_id"])
|
||||||
@@ -342,8 +447,9 @@ 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_export_markdown(self, request: web.Request) -> web.Response:
|
async def _handle_export_markdown(self, request: web.Request) -> web.Response:
|
||||||
if not self._check_auth(request):
|
denied = self._require_auth(request)
|
||||||
return web.Response(text="Forbidden", status=403)
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
channel_filter = request.query.get("channel") or None
|
channel_filter = request.query.get("channel") or None
|
||||||
reqs = self._store.get_history(limit=5000, channel=channel_filter)
|
reqs = self._store.get_history(limit=5000, channel=channel_filter)
|
||||||
@@ -367,8 +473,9 @@ class WebServer:
|
|||||||
return web.json_response({"open": is_open})
|
return web.json_response({"open": is_open})
|
||||||
|
|
||||||
async def _handle_post_status(self, request: web.Request) -> web.Response:
|
async def _handle_post_status(self, request: web.Request) -> web.Response:
|
||||||
if not self._check_auth(request):
|
denied = self._require_auth(request)
|
||||||
return web.Response(text="Forbidden", status=403)
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
@@ -389,8 +496,9 @@ class WebServer:
|
|||||||
return web.json_response({"open": is_open})
|
return web.json_response({"open": is_open})
|
||||||
|
|
||||||
async def _handle_clear_history(self, request: web.Request) -> web.Response:
|
async def _handle_clear_history(self, request: web.Request) -> web.Response:
|
||||||
if not self._check_auth(request):
|
denied = self._require_auth(request)
|
||||||
return web.Response(text="Forbidden", status=403)
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
count = self._store.clear_history()
|
count = self._store.clear_history()
|
||||||
payload = json.dumps({"event": "history-cleared"})
|
payload = json.dumps({"event": "history-cleared"})
|
||||||
@@ -422,8 +530,9 @@ class WebServer:
|
|||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
|
|
||||||
async def _handle_start_session(self, request: web.Request) -> web.Response:
|
async def _handle_start_session(self, request: web.Request) -> web.Response:
|
||||||
if not self._check_auth(request):
|
denied = self._require_auth(request)
|
||||||
return web.Response(text="Forbidden", status=403)
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
existing = self._store.get_active_session()
|
existing = self._store.get_active_session()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -444,8 +553,9 @@ class WebServer:
|
|||||||
return web.json_response(session.to_dict())
|
return web.json_response(session.to_dict())
|
||||||
|
|
||||||
async def _handle_stop_session(self, request: web.Request) -> web.Response:
|
async def _handle_stop_session(self, request: web.Request) -> web.Response:
|
||||||
if not self._check_auth(request):
|
denied = self._require_auth(request)
|
||||||
return web.Response(text="Forbidden", status=403)
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
active = self._store.get_active_session()
|
active = self._store.get_active_session()
|
||||||
if not active:
|
if not active:
|
||||||
@@ -476,3 +586,57 @@ class WebServer:
|
|||||||
reqs = self._store.get_session_history(session_id, channel=channel_filter)
|
reqs = self._store.get_session_history(session_id, channel=channel_filter)
|
||||||
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")
|
||||||
|
|
||||||
|
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})
|
||||||
|
|||||||
Reference in New Issue
Block a user