From f02147e30239e1845c2bda7b6f5de917c9579fe8 Mon Sep 17 00:00:00 2001 From: peterino2 Date: Mon, 2 Feb 2026 19:49:16 -0800 Subject: [PATCH] added configs, lots of styling changes and fixed up performance issues --- auth.ts | 9 +++ config.json | 8 +++ db.ts | 55 +++++++++++++--- musicroom.db | Bin 32768 -> 32768 bytes public/app.js | 159 ++++++++++++++++++++++++++++++++++++++-------- public/index.html | 8 ++- public/styles.css | 17 +++-- server.ts | 130 ++++++++++++++++++++++++++++++------- 8 files changed, 319 insertions(+), 67 deletions(-) create mode 100644 config.json diff --git a/auth.ts b/auth.ts index 90ba6f4..4d9bb98 100644 --- a/auth.ts +++ b/auth.ts @@ -10,6 +10,15 @@ export function getSessionToken(req: Request): string | null { return match ? match[1] : null; } +export function getClientInfo(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): { userAgent: string; ipAddress: string } { + const userAgent = req.headers.get("user-agent") ?? "unknown"; + const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + ?? req.headers.get("x-real-ip") + ?? server?.requestIP?.(req)?.address + ?? "unknown"; + return { userAgent, ipAddress }; +} + export function getRequestMeta(req: Request, server?: { requestIP?: (req: Request) => { address: string } | null }): { userAgent?: string; ipAddress?: string } { const userAgent = req.headers.get("user-agent") ?? undefined; const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() diff --git a/config.json b/config.json new file mode 100644 index 0000000..9799fab --- /dev/null +++ b/config.json @@ -0,0 +1,8 @@ +{ + "port": 3001, + "musicDir": "./music", + "allowGuests": true, + "defaultPermissions": { + "stream": ["listen", "control"] + } +} diff --git a/db.ts b/db.ts index 49f3d90..15a6f93 100644 --- a/db.ts +++ b/db.ts @@ -2,6 +2,7 @@ import { Database } from "bun:sqlite"; const DB_PATH = "./musicroom.db"; const SESSION_EXPIRY_DAYS = 7; +const GUEST_SESSION_EXPIRY_HOURS = 24; const db = new Database(DB_PATH); @@ -12,10 +13,16 @@ db.run(` username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, is_admin INTEGER DEFAULT 0, + is_guest INTEGER DEFAULT 0, created_at INTEGER DEFAULT (unixepoch()) ) `); +// Migration: add is_guest column if it doesn't exist +try { + db.run(`ALTER TABLE users ADD COLUMN is_guest INTEGER DEFAULT 0`); +} catch {} + db.run(` CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, @@ -54,6 +61,7 @@ export interface User { username: string; password_hash: string; is_admin: boolean; + is_guest: boolean; created_at: number; } @@ -79,26 +87,38 @@ export async function createUser(username: string, password: string): Promise { @@ -106,9 +126,12 @@ export async function validatePassword(user: User, password: string): Promise[] { - const users = db.query("SELECT id, username, is_admin, created_at FROM users").all() as any[]; - return users.map(u => ({ ...u, is_admin: !!u.is_admin })); + const users = db.query("SELECT id, username, is_admin, is_guest, created_at FROM users WHERE is_guest = 0").all() as any[]; + return users.map(u => ({ ...u, is_admin: !!u.is_admin, is_guest: false })); } export function getUserSessions(userId: number): Omit[] { diff --git a/musicroom.db b/musicroom.db index 3e6880fd4e4f6d35337ff211bcb78bde2c83a5a7..51bad83a6d572447dda26e84c1e3ac10e1c54380 100644 GIT binary patch delta 817 zcma))L1@!Z7{`+|O_SB+byF!-CPC)b!jk1BFKHg0giRT27RIVm86`b0@|KeVb>lxBjpZ^iX1u7+MQNq@{l@VcJlcQ7FY?gk!)ZUlA50A@1y>frUudmC~ zH|M8rOwGxYQ`fJRXXa(C5N{8BVi@ULd{=DIEI6co#XlsO&Tvvlfrj)``YL^pwxuv0 z%q&aI9G9XBBF_rPb`=CIO^3)2qNQdo*(donGIh=!5+7Yk5;U+wfjzJTK7;q*5FEsV@-r|qlqk^sR9vrh zLX+&ZPAJ%3>tuQ&KA1R{InRmUGR38LY4AmI#Lk&}>0L2Na@b1SkbZs#6<{gPsbh?Y<@iIhSa`OT6GapbyQTL4ONm~G{*h2x;+Mc z%|)K?DVAX&1wqGDT*D+*2CC@@Vi)U}C00+m$<3}l3YM3h2~*YN`~rkm<(Xh*qainJ ztI}?krY9k?)S^bl=&EiH7lA|rVE z_Eiu)xJY={Xc%{9>oiNg-JAAkNnu{M+T~J*tRYQ-G(cbxv_J>!kv065=JJU$eVR!4 Wqe+A#0&58;S_%^?hlVD_F z;(yJsSwNwmf8vCY&9C)E1O%CQftIoGu4mvs&9B6l$h&^CqQHFK&Fg)O8Ce?Dm^db@ z`RlSRFv)C;IyL#8cmCuP{x&=;{ILxDvHWfPH~4|d6@2+8$Hs?E*7a9mWZHZqUPA!@ DXrDVY diff --git a/public/app.js b/public/app.js index 4d5738f..633ee1f 100644 --- a/public/app.js +++ b/public/app.js @@ -8,12 +8,14 @@ let serverTrackDuration = 0; let lastServerUpdate = 0; let serverPaused = true; - let synced = false; + let wantSync = true; // User intent - do they want to be synced? + let synced = false; // Actual state - are we currently synced? let preMuteVolume = 1; let localTimestamp = 0; let playlist = []; let currentIndex = 0; let currentUser = null; + let serverStatus = null; let prefetchController = null; let loadingSegments = new Set(); let trackCaches = new Map(); // Map of filename -> Set of cached segment indices @@ -140,11 +142,33 @@ if (currentUser) { $("#login-panel").classList.add("hidden"); $("#player-content").classList.add("visible"); - $("#current-username").textContent = currentUser.username; + if (currentUser.isGuest) { + $("#current-username").textContent = "Guest"; + $("#btn-logout").textContent = "Sign In"; + } else { + $("#current-username").textContent = currentUser.username; + $("#btn-logout").textContent = "Logout"; + } $("#admin-badge").style.display = currentUser.isAdmin ? "inline" : "none"; } else { $("#login-panel").classList.remove("hidden"); $("#player-content").classList.remove("visible"); + // Pause and unsync when login panel is shown + if (!audio.paused) { + localTimestamp = audio.currentTime; + audio.pause(); + } + if (synced && ws) { + synced = false; + ws.close(); + ws = null; + } + // Show guest button if server allows guests + if (serverStatus?.allowGuests) { + $("#guest-section").classList.remove("hidden"); + } else { + $("#guest-section").classList.add("hidden"); + } } updateUI(); } @@ -166,9 +190,10 @@ function updateUI() { const isPlaying = synced ? !serverPaused : !audio.paused; - $("#btn-sync").classList.toggle("synced", synced); - $("#btn-sync").title = synced ? "Unsync" : "Sync"; - $("#status").textContent = synced ? "Synced" : "Local"; + $("#btn-sync").classList.toggle("synced", wantSync); + $("#btn-sync").classList.toggle("connected", synced); + $("#btn-sync").title = wantSync ? "Unsync" : "Sync"; + $("#status").textContent = synced ? "Synced" : (wantSync ? "Connecting..." : "Local"); $("#sync-indicator").classList.toggle("visible", synced); $("#progress-bar").classList.toggle("synced", synced); $("#progress-bar").classList.toggle("local", !synced); @@ -181,6 +206,13 @@ $("#status-icon").style.cursor = hasControl || !synced ? "pointer" : "default"; } + // Track last values to avoid unnecessary DOM updates + let lastProgressPct = -1; + let lastTimeCurrent = ""; + let lastTimeTotal = ""; + let lastBufferPct = -1; + let lastSpeedText = ""; + // Update progress bar and buffer segments setInterval(() => { if (serverTrackDuration <= 0) return; @@ -193,9 +225,21 @@ dur = audio.duration || serverTrackDuration; } const pct = Math.min((t / dur) * 100, 100); - $("#progress-bar").style.width = pct + "%"; - $("#time-current").textContent = fmt(t); - $("#time-total").textContent = fmt(dur); + if (Math.abs(pct - lastProgressPct) > 0.1) { + $("#progress-bar").style.width = pct + "%"; + lastProgressPct = pct; + } + + const timeCurrent = fmt(t); + const timeTotal = fmt(dur); + if (timeCurrent !== lastTimeCurrent) { + $("#time-current").textContent = timeCurrent; + lastTimeCurrent = timeCurrent; + } + if (timeTotal !== lastTimeTotal) { + $("#time-total").textContent = timeTotal; + lastTimeTotal = timeTotal; + } // Update buffer segments const segments = $("#buffer-bar").children; @@ -217,8 +261,11 @@ } } if (available) availableCount++; - segments[i].classList.toggle("available", available); - segments[i].classList.toggle("loading", !available && loadingSegments.has(i)); + const isAvailable = segments[i].classList.contains("available"); + const isLoading = segments[i].classList.contains("loading"); + const shouldBeLoading = !available && loadingSegments.has(i); + if (available !== isAvailable) segments[i].classList.toggle("available", available); + if (shouldBeLoading !== isLoading) segments[i].classList.toggle("loading", shouldBeLoading); } // Update download speed display @@ -228,7 +275,11 @@ if (kbps > 0) { speedText = kbps >= 1024 ? ` | ${(kbps / 1024).toFixed(1)} Mb/s` : ` | ${Math.round(kbps)} kb/s`; } - $("#download-speed").textContent = `${bufferPct}% buffered${speedText}`; + if (bufferPct !== lastBufferPct || speedText !== lastSpeedText) { + $("#download-speed").textContent = `${bufferPct}% buffered${speedText}`; + lastBufferPct = bufferPct; + lastSpeedText = speedText; + } }, 250); // Prefetch missing segments @@ -374,9 +425,12 @@ ws = new WebSocket(proto + "//" + location.host + "/api/streams/" + id + "/ws"); ws.onmessage = (e) => handleUpdate(JSON.parse(e.data)); ws.onclose = () => { - if (synced && ws) { - $("#status").textContent = "Disconnected. Reconnecting..."; - $("#sync-indicator").classList.add("disconnected"); + synced = false; + ws = null; + $("#sync-indicator").classList.add("disconnected"); + updateUI(); + // Auto-reconnect if user wants to be synced + if (wantSync) { setTimeout(() => connectStream(id), 3000); } }; @@ -387,6 +441,15 @@ }; } + function flashPermissionDenied() { + const row = $("#progress-row"); + row.classList.remove("denied"); + // Trigger reflow to restart animation + void row.offsetWidth; + row.classList.add("denied"); + setTimeout(() => row.classList.remove("denied"), 500); + } + function renderPlaylist() { const container = $("#playlist"); container.innerHTML = ""; @@ -397,11 +460,12 @@ div.innerHTML = `${title}${fmt(track.duration)}`; div.onclick = async () => { if (synced && currentStreamId) { - fetch("/api/streams/" + currentStreamId + "/jump", { + const res = await fetch("/api/streams/" + currentStreamId + "/jump", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index: i }) }); + if (res.status === 403) flashPermissionDenied(); } else { currentIndex = i; currentFilename = track.filename; @@ -480,17 +544,22 @@ } $("#btn-sync").onclick = () => { - if (synced) { - // Unsync - go to local mode - synced = false; - localTimestamp = audio.currentTime || getServerTime(); - if (ws) ws.close(); - ws = null; - } else { - // Try to sync + wantSync = !wantSync; + if (wantSync) { + // User wants to sync - try to connect if (currentStreamId) { connectStream(currentStreamId); } + } else { + // User wants local mode - disconnect + synced = false; + localTimestamp = audio.currentTime || getServerTime(); + if (ws) { + const oldWs = ws; + ws = null; + oldWs.onclose = null; + oldWs.close(); + } } updateUI(); }; @@ -524,11 +593,12 @@ const newIndex = (index + playlist.length) % playlist.length; if (synced && currentStreamId) { - fetch("/api/streams/" + currentStreamId + "/jump", { + const res = await fetch("/api/streams/" + currentStreamId + "/jump", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index: newIndex }) }); + if (res.status === 403) flashPermissionDenied(); } else { const track = playlist[newIndex]; currentIndex = newIndex; @@ -575,7 +645,7 @@ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timestamp: seekTime }) - }); + }).then(res => { if (res.status === 403) flashPermissionDenied(); }); } else { if (!audio.src) { audio.src = getTrackUrl(currentFilename); @@ -700,12 +770,47 @@ } }; + $("#btn-guest").onclick = async () => { + // Fetch /api/auth/me which will create a guest session + try { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + currentUser = data.user; + if (currentUser && data.permissions) { + currentUser.permissions = data.permissions; + } + updateAuthUI(); + if (currentUser) loadStreams(); + } catch (e) { + $("#auth-error").textContent = "Failed to continue as guest"; + } + }; + $("#btn-logout").onclick = async () => { + const wasGuest = currentUser?.isGuest; await fetch("/api/auth/logout", { method: "POST" }); currentUser = null; - updateAuthUI(); + if (wasGuest) { + // Guest clicking "Sign In" - show login panel + updateAuthUI(); + } else { + // Regular user logging out - reload to get new guest session + updateAuthUI(); + } }; + // Fetch server status + async function loadServerStatus() { + try { + const res = await fetch("/api/status"); + serverStatus = await res.json(); + console.log("Server status:", serverStatus); + } catch (e) { + console.warn("Failed to load server status"); + serverStatus = null; + } + } + // Initialize storage and load cached tracks async function initStorage() { await TrackStorage.init(); @@ -714,7 +819,7 @@ } // Initialize - initStorage().then(() => { + Promise.all([initStorage(), loadServerStatus()]).then(() => { loadCurrentUser().then(() => { if (currentUser) loadStreams(); }); diff --git a/public/index.html b/public/index.html index 3591282..d8cb8ea 100644 --- a/public/index.html +++ b/public/index.html @@ -28,6 +28,10 @@
+
@@ -43,15 +47,15 @@
Loading... - sync
-
0:000:00
+ sync
+
0:00/0:00
diff --git a/public/styles.css b/public/styles.css index fbe9dcf..268adc5 100644 --- a/public/styles.css +++ b/public/styles.css @@ -9,12 +9,15 @@ h1 { font-size: 1.2rem; color: #888; margin-bottom: 1.5rem; display: flex; align #stream-select select { background: #222; color: #eee; border: 1px solid #333; padding: 0.4rem 0.8rem; border-radius: 4px; font-size: 0.9rem; } #now-playing { margin-bottom: 0.5rem; } #stream-name { font-size: 0.85rem; color: #888; margin-bottom: 0.2rem; } -#track-name { font-size: 1.4rem; font-weight: 600; display: flex; align-items: center; gap: 0.5rem; } -#btn-sync { font-size: 0.75rem; cursor: pointer; color: #666; transition: color 0.2s, text-shadow 0.2s; text-transform: uppercase; letter-spacing: 0.05em; } +#track-name { font-size: 1.4rem; font-weight: 600; } +#btn-sync { font-size: 0.75rem; cursor: pointer; color: #666; transition: color 0.2s, text-shadow 0.2s; letter-spacing: 0.05em; } #btn-sync:hover { color: #888; } -#btn-sync.synced { color: #4e8; text-shadow: 0 0 8px #4e8, 0 0 12px #4e8; } -#time { display: flex; justify-content: space-between; font-size: 0.8rem; color: #888; margin-bottom: 0.3rem; } +#btn-sync.synced { color: #eb0; text-shadow: 0 0 8px #eb0, 0 0 12px #eb0; } +#btn-sync.synced.connected { color: #4e8; text-shadow: 0 0 8px #4e8, 0 0 12px #4e8; } +#time { font-size: 0.8rem; color: #888; margin: 0; line-height: 1; } #progress-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +#progress-row.denied { animation: flash-red 0.5s ease-out; } +@keyframes flash-red { 0% { background: #e44; } 100% { background: transparent; } } #btn-prev, #btn-next { font-size: 0.8rem; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; } #btn-prev:hover, #btn-next:hover { opacity: 1; } #status-icon { font-size: 0.9rem; width: 1rem; text-align: center; cursor: pointer; } @@ -55,6 +58,12 @@ button:hover { background: #333; } #login-panel .submit-btn { background: #4e8; color: #111; border: none; padding: 0.6rem; border-radius: 4px; font-size: 0.95rem; cursor: pointer; font-weight: 600; } #login-panel .submit-btn:hover { background: #5fa; } #auth-error, #signup-error { color: #e44; font-size: 0.8rem; } +#guest-section { margin-top: 1rem; } +#guest-section.hidden { display: none; } +#guest-section .divider { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; color: #666; font-size: 0.85rem; } +#guest-section .divider::before, #guest-section .divider::after { content: ""; flex: 1; height: 1px; background: #333; } +#guest-section .guest-btn { width: 100%; background: #333; color: #eee; border: 1px solid #444; padding: 0.6rem; border-radius: 4px; font-size: 0.95rem; cursor: pointer; } +#guest-section .guest-btn:hover { background: #444; } #player-content { display: none; } #player-content.visible { display: block; } #playlist { margin-top: 1.5rem; max-height: 300px; overflow-y: auto; } diff --git a/server.ts b/server.ts index a29fb62..4d0887c 100644 --- a/server.ts +++ b/server.ts @@ -2,12 +2,13 @@ import { file, serve, type ServerWebSocket } from "bun"; import { parseFile } from "music-metadata"; import { Stream, type Track } from "./stream"; import { readdir, stat } from "fs/promises"; -import { join } from "path"; +import { join, resolve } from "path"; import { createUser, findUserByUsername, validatePassword, createSession, + createGuestSession, deleteSession, validateSession, hasPermission, @@ -23,12 +24,28 @@ import { requirePermission, setSessionCookie, clearSessionCookie, + getClientInfo, } from "./auth"; -const MUSIC_DIR = join(import.meta.dir, "music"); +// Load config +interface Config { + port: number; + musicDir: string; + allowGuests: boolean; + defaultPermissions: { + stream?: string[]; + }; +} + +const CONFIG_PATH = join(import.meta.dir, "config.json"); +const config: Config = await file(CONFIG_PATH).json(); + +const MUSIC_DIR = resolve(import.meta.dir, config.musicDir); const PLAYLIST_PATH = join(import.meta.dir, "playlist.json"); const PUBLIC_DIR = join(import.meta.dir, "public"); +console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`); + // Load track metadata async function loadTrack(filename: string): Promise { const filepath = join(MUSIC_DIR, filename); @@ -95,8 +112,42 @@ setInterval(() => { type WsData = { streamId: string; userId: number | null }; +// Helper to get or create guest session +function getOrCreateUser(req: Request, server: any): { user: ReturnType, headers?: Headers } { + let user = getUser(req, server); + if (user) return { user }; + + if (config.allowGuests) { + const { userAgent, ipAddress } = getClientInfo(req, server); + const guest = createGuestSession(userAgent, ipAddress); + console.log(`[AUTH] Guest session created: user="${guest.user.username}" id=${guest.user.id} ip=${ipAddress}`); + const headers = new Headers(); + headers.set("Set-Cookie", setSessionCookie(guest.token)); + return { user: guest.user, headers }; + } + + return { user: null }; +} + +// Check if user has permission (including default permissions) +function userHasPermission(user: ReturnType, resourceType: string, resourceId: string | null, permission: string): boolean { + if (!user) return false; + if (user.is_admin) return true; + + // Guests can never control playback + if (user.is_guest && permission === "control") return false; + + // Check default permissions from config + if (resourceType === "stream" && config.defaultPermissions.stream?.includes(permission)) { + return true; + } + + // Check user-specific permissions + return hasPermission(user.id, resourceType, resourceId, permission); +} + serve({ - port: parseInt("3001"), + port: config.port, async fetch(req, server) { const url = new URL(req.url); const path = url.pathname; @@ -105,15 +156,27 @@ serve({ if (path.match(/^\/api\/streams\/([^/]+)\/ws$/)) { const id = path.split("/")[3]; if (!streams.has(id)) return new Response("Stream not found", { status: 404 }); - const user = getUser(req, server); + const { user } = getOrCreateUser(req, server); const ok = server.upgrade(req, { data: { streamId: id, userId: user?.id ?? null } }); if (ok) return undefined; return new Response("WebSocket upgrade failed", { status: 500 }); } - // API: list streams (requires auth) + // API: server status (public) + if (path === "/api/status") { + return Response.json({ + name: "MusicRoom", + version: "1.0.0", + allowGuests: config.allowGuests, + allowSignups: true, + streamCount: streams.size, + defaultPermissions: config.defaultPermissions, + }); + } + + // API: list streams (requires auth or guest) if (path === "/api/streams") { - const user = getUser(req, server); + const { user, headers } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } @@ -122,7 +185,7 @@ serve({ name: s.name, trackCount: s.playlist.length, })); - return Response.json(list); + return Response.json(list, { headers }); } // Auth: signup @@ -197,15 +260,30 @@ serve({ // Auth: get current user if (path === "/api/auth/me") { - const user = getUser(req, server); + const { user, headers } = getOrCreateUser(req, server); if (!user) { return Response.json({ user: null }); } const permissions = getUserPermissions(user.id); + // Add default permissions for all users (except control for guests) + const effectivePermissions = [...permissions]; + if (config.defaultPermissions.stream) { + for (const perm of config.defaultPermissions.stream) { + // Guests can never have control permission + if (user.is_guest && perm === "control") continue; + effectivePermissions.push({ + id: 0, + user_id: user.id, + resource_type: "stream", + resource_id: null, + permission: perm, + }); + } + } return Response.json({ - user: { id: user.id, username: user.username, isAdmin: user.is_admin }, - permissions, - }); + user: { id: user.id, username: user.username, isAdmin: user.is_admin, isGuest: user.is_guest }, + permissions: effectivePermissions, + }, { headers }); } // Admin: list users @@ -251,10 +329,9 @@ serve({ const jumpMatch = path.match(/^\/api\/streams\/([^/]+)\/jump$/); if (jumpMatch && req.method === "POST") { const streamId = jumpMatch[1]; - try { - requirePermission(req, "stream", streamId, "control", server); - } catch (e) { - if (e instanceof Response) return e; + const { user } = getOrCreateUser(req, server); + if (!userHasPermission(user, "stream", streamId, "control")) { + return new Response("Forbidden", { status: 403 }); } const stream = streams.get(streamId); if (!stream) return new Response("Not found", { status: 404 }); @@ -274,10 +351,9 @@ serve({ const seekMatch = path.match(/^\/api\/streams\/([^/]+)\/seek$/); if (seekMatch && req.method === "POST") { const streamId = seekMatch[1]; - try { - requirePermission(req, "stream", streamId, "control", server); - } catch (e) { - if (e instanceof Response) return e; + const { user } = getOrCreateUser(req, server); + if (!userHasPermission(user, "stream", streamId, "control")) { + return new Response("Forbidden", { status: 403 }); } const stream = streams.get(streamId); if (!stream) return new Response("Not found", { status: 404 }); @@ -301,10 +377,10 @@ serve({ return Response.json(stream.getState()); } - // API: serve audio file (requires auth) + // API: serve audio file (requires auth or guest) const trackMatch = path.match(/^\/api\/tracks\/(.+)$/); if (trackMatch) { - const user = getUser(req, server); + const { user } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } @@ -384,12 +460,18 @@ serve({ // Check permission for control actions const userId = ws.data.userId; - if (!userId) return; // Not logged in + if (!userId) return; const user = findUserById(userId); if (!user) return; - const canControl = user.is_admin || hasPermission(userId, "stream", ws.data.streamId, "control"); + // Guests can never control playback + if (user.is_guest) return; + + // Check default permissions or user-specific permissions + const canControl = user.is_admin + || config.defaultPermissions.stream?.includes("control") + || hasPermission(userId, "stream", ws.data.streamId, "control"); if (!canControl) return; try { @@ -401,4 +483,4 @@ serve({ }, }); -console.log("MusicRoom running on http://localhost:3001"); +console.log(`MusicRoom running on http://localhost:${config.port}`);