import { file, serve, type ServerWebSocket } from "bun"; import { Channel, type Track, type WsData, type PersistenceCallback } from "./channel"; import { readdir } from "fs/promises"; import { join, resolve } from "path"; import { createUser, findUserByUsername, validatePassword, createSession, createGuestSession, deleteSession, validateSession, hasPermission, getUserPermissions, getAllUsers, grantPermission, revokePermission, findUserById, saveChannel, updateChannelState, loadAllChannels, deleteChannelFromDb, saveChannelQueue, loadChannelQueue, removeTrackFromQueues, } from "./db"; import { getUser, requireUser, requirePermission, setSessionCookie, clearSessionCookie, getClientInfo, } from "./auth"; import { Library } from "./library"; // Load config interface Config { port: number; musicDir: string; allowGuests: boolean; defaultPermissions: { channel?: string[]; }; } const CONFIG_PATH = join(import.meta.dir, "config.json"); const DEFAULT_CONFIG: Config = { port: 3001, musicDir: "./music", allowGuests: true }; // Create default config if missing const configFile = file(CONFIG_PATH); if (!(await configFile.exists())) { console.log("[Config] Creating default config.json..."); await Bun.write(CONFIG_PATH, JSON.stringify(DEFAULT_CONFIG, null, 2)); } const config: Config = await configFile.json(); const MUSIC_DIR = resolve(import.meta.dir, config.musicDir); const PUBLIC_DIR = join(import.meta.dir, "public"); console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`); // Initialize library const library = new Library(MUSIC_DIR); // Auto-discover tracks if queue is empty async function discoverTracks(): Promise { try { const files = await readdir(MUSIC_DIR); return files.filter((f) => /\.(mp3|ogg|flac|wav|m4a|aac)$/i.test(f)).sort(); } catch { return []; } } // Generate unique channel ID function generateChannelId(): string { return Math.random().toString(36).slice(2, 10); } // Initialize channels - create default channel with full library const channels = new Map(); // Track all WebSocket connections by user ID for kick functionality const userConnections = new Map>>(); // Persistence callback for channels const persistChannel: PersistenceCallback = (channel, type) => { if (type === "state") { updateChannelState(channel.id, { currentIndex: channel.currentIndex, startedAt: channel.startedAt, paused: channel.paused, pausedAt: channel.pausedAt, playbackMode: channel.playbackMode, }); } else if (type === "queue") { saveChannelQueue(channel.id, channel.queue.map(t => t.id)); } }; // Helper to build Track objects from track IDs using library function buildTracksFromIds(trackIds: string[], lib: Library): Track[] { const tracks: Track[] = []; for (const tid of trackIds) { const libTrack = lib.getTrack(tid); if (libTrack && libTrack.duration > 0) { tracks.push({ id: libTrack.id, filename: libTrack.filename, title: libTrack.title || libTrack.filename.replace(/\.[^.]+$/, ""), duration: libTrack.duration, }); } } return tracks; } // Helper to get all library tracks as Track objects function getAllLibraryTracks(lib: Library): Track[] { return lib.getAllTracks() .filter(t => t.duration > 0) .map(t => ({ id: t.id, filename: t.filename, title: t.title || t.filename.replace(/\.[^.]+$/, ""), duration: t.duration, })); } async function init(): Promise { // Scan library first await library.scan(); library.startWatching(); // Broadcast when scan completes library.onScanComplete(() => { broadcastToAll({ type: "scan_progress", scanning: false }); }); // Broadcast when tracks are added/updated library.on("added", (track) => { broadcastToAll({ type: "toast", message: `Added: ${track.title || track.filename}`, toastType: "info" }); library.logActivity("scan_added", { id: track.id, filename: track.filename, title: track.title }); }); library.on("changed", (track) => { broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" }); library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title }); }); // Load channels from database const savedChannels = loadAllChannels(); let hasDefault = false; for (const row of savedChannels) { // Load queue for this channel const trackIds = loadChannelQueue(row.id); const tracks = buildTracksFromIds(trackIds, library); // For default channel, if queue is empty, use full library const isDefault = row.is_default === 1; if (isDefault) { hasDefault = true; } const channelTracks = (isDefault && tracks.length === 0) ? getAllLibraryTracks(library) : tracks; const channel = new Channel({ id: row.id, name: row.name, description: row.description, tracks: channelTracks, createdBy: row.created_by, isDefault, currentIndex: row.current_index, startedAt: row.started_at, paused: row.paused === 1, pausedAt: row.paused_at, playbackMode: (row.playback_mode as "repeat-all" | "repeat-one" | "shuffle") || "repeat-all", }); channel.setPersistenceCallback(persistChannel); channels.set(row.id, channel); console.log(`Loaded channel "${row.name}" (id=${row.id}) with ${channelTracks.length} tracks`); } // Create default channel if it doesn't exist if (!hasDefault) { const tracks = getAllLibraryTracks(library); const defaultChannel = new Channel({ id: "main", name: "Main Channel", description: "All tracks from the library", tracks, isDefault: true, createdBy: null, }); defaultChannel.setPersistenceCallback(persistChannel); channels.set("main", defaultChannel); // Save to database saveChannel({ id: defaultChannel.id, name: defaultChannel.name, description: defaultChannel.description, createdBy: defaultChannel.createdBy, isDefault: true, currentIndex: defaultChannel.currentIndex, startedAt: defaultChannel.startedAt, paused: defaultChannel.paused, pausedAt: defaultChannel.pausedAt, playbackMode: defaultChannel.playbackMode, }); saveChannelQueue(defaultChannel.id, tracks.map(t => t.id)); console.log(`Default channel created: ${tracks.length} tracks`); } } await init(); // Broadcast to all connected clients across all channels function broadcastToAll(message: object) { const data = JSON.stringify(message); let clientCount = 0; for (const channel of channels.values()) { for (const ws of channel.clients) { ws.send(data); clientCount++; } } console.log(`[Broadcast] Sent to ${clientCount} clients`); } // Broadcast channel list to all clients function broadcastChannelList() { const list = [...channels.values()].map(c => c.getListInfo()); console.log(`[Broadcast] Sending channel_list to all clients (${list.length} channels)`, JSON.stringify(list.map(c => ({ id: c.id, listeners: c.listeners })))); broadcastToAll({ type: "channel_list", channels: list }); } // Listen for library changes and notify clients library.on("added", (track) => { console.log(`New track detected: ${track.title}`); const allTracks = library.getAllTracks().map(t => ({ id: t.id, title: t.title, duration: t.duration })); broadcastToAll({ type: "track_added", track: { id: track.id, title: track.title, duration: track.duration }, library: allTracks }); }); library.on("removed", (track) => { console.log(`Track removed: ${track.title}`); // Remove from database queue entries removeTrackFromQueues(track.id); const allTracks = library.getAllTracks().map(t => ({ id: t.id, title: t.title, duration: t.duration })); broadcastToAll({ type: "track_removed", track: { id: track.id, title: track.title }, library: allTracks }); }); // Tick interval: advance tracks when needed, broadcast every 30s let tickCount = 0; setInterval(() => { tickCount++; for (const channel of channels.values()) { const changed = channel.tick(); if (changed) { console.log(`[Tick] Channel "${channel.name}" advanced to track ${channel.currentIndex}`); } if (!changed && tickCount % 30 === 0) { console.log(`[Tick] Broadcasting state for channel "${channel.name}" (${channel.clients.size} clients)`); channel.broadcast(); } } // Broadcast scan progress every 2 seconds while scanning const scanProgress = library.scanProgress; if (scanProgress.scanning && tickCount % 2 === 0) { broadcastToAll({ type: "scan_progress", scanning: true, processed: scanProgress.processed, total: scanProgress.total }); } else if (!scanProgress.scanning && tickCount % 30 === 0) { // Periodically send "not scanning" to clear any stale UI broadcastToAll({ type: "scan_progress", scanning: false }); } }, 1000); // 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 === "channel" && config.defaultPermissions.channel?.includes(permission)) { return true; } // Check user-specific permissions return hasPermission(user.id, resourceType, resourceId, permission); } serve({ port: config.port, async fetch(req, server) { const url = new URL(req.url); const path = url.pathname; // WebSocket upgrade for channels if (path.match(/^\/api\/channels\/([^/]+)\/ws$/)) { const id = path.split("/")[3]; if (!channels.has(id)) return new Response("Channel not found", { status: 404 }); const { user } = getOrCreateUser(req, server); const ok = server.upgrade(req, { data: { channelId: id, userId: user?.id ?? null, username: user?.username ?? 'Guest' } }); if (ok) return undefined; return new Response("WebSocket upgrade failed", { status: 500 }); } // API: server status (public) if (path === "/api/status") { return Response.json({ name: "MusicRoom", version: "1.0.0", allowGuests: config.allowGuests, allowSignups: true, channelCount: channels.size, defaultPermissions: config.defaultPermissions, }); } // API: list channels (requires auth or guest) if (path === "/api/channels" && req.method === "GET") { const { user, headers } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } const list = [...channels.values()].map(c => c.getListInfo()); return Response.json(list, { headers }); } // API: create channel if (path === "/api/channels" && req.method === "POST") { const { user } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } if (user.is_guest) { return Response.json({ error: "Guests cannot create channels" }, { status: 403 }); } try { const { name, description, trackIds } = await req.json(); if (!name || typeof name !== "string" || name.trim().length === 0) { return Response.json({ error: "Name is required" }, { status: 400 }); } // Build track list from trackIds or default to full library let tracks: Track[]; if (trackIds && Array.isArray(trackIds) && trackIds.length > 0) { tracks = []; for (const tid of trackIds) { const libTrack = library.getTrack(tid); if (libTrack) { tracks.push({ id: libTrack.id, filename: libTrack.filename, title: libTrack.title || libTrack.filename.replace(/\.[^.]+$/, ""), duration: libTrack.duration, }); } } } else { // Default to full library tracks = library.getAllTracks() .filter(t => t.duration > 0) .map(t => ({ id: t.id, filename: t.filename, title: t.title || t.filename.replace(/\.[^.]+$/, ""), duration: t.duration, })); } const channelId = generateChannelId(); const channel = new Channel({ id: channelId, name: name.trim(), description: description || "", tracks, createdBy: user.id, isDefault: false, }); channel.setPersistenceCallback(persistChannel); channels.set(channelId, channel); // Save to database saveChannel({ id: channel.id, name: channel.name, description: channel.description, createdBy: channel.createdBy, isDefault: false, currentIndex: channel.currentIndex, startedAt: channel.startedAt, paused: channel.paused, pausedAt: channel.pausedAt, playbackMode: channel.playbackMode, }); saveChannelQueue(channel.id, tracks.map(t => t.id)); console.log(`[Channel] Created "${name.trim()}" (id=${channelId}) by user ${user.id}`); broadcastChannelList(); return Response.json(channel.getListInfo(), { status: 201 }); } catch { return Response.json({ error: "Invalid request" }, { status: 400 }); } } // API: delete channel const channelDeleteMatch = path.match(/^\/api\/channels\/([^/]+)$/); if (channelDeleteMatch && req.method === "DELETE") { const channelId = channelDeleteMatch[1]; const { user } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } const channel = channels.get(channelId); if (!channel) { return Response.json({ error: "Channel not found" }, { status: 404 }); } if (channel.isDefault) { return Response.json({ error: "Cannot delete default channel" }, { status: 403 }); } if (!user.is_admin && channel.createdBy !== user.id) { return Response.json({ error: "Access denied" }, { status: 403 }); } channels.delete(channelId); deleteChannelFromDb(channelId); broadcastChannelList(); return Response.json({ success: true }); } // API: get channel state const channelMatch = path.match(/^\/api\/channels\/([^/]+)$/); if (channelMatch && req.method === "GET") { const channel = channels.get(channelMatch[1]); if (!channel) return new Response("Not found", { status: 404 }); return Response.json(channel.getState()); } // API: get library (all tracks) if (path === "/api/library") { const { user, headers } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } const tracks = library.getAllTracks().map(t => ({ id: t.id, filename: t.filename, title: t.title, artist: t.artist, album: t.album, duration: t.duration, available: t.available, })); return Response.json(tracks, { headers }); } // API: upload audio file if (path === "/api/upload" && req.method === "POST") { const { user, headers } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } try { const formData = await req.formData(); const uploadedFile = formData.get("file"); if (!uploadedFile || !(uploadedFile instanceof File)) { library.logActivity("upload_failed", { filename: "unknown" }, { id: user.id, username: user.username }); return Response.json({ error: "No file provided" }, { status: 400 }); } // Validate extension const validExts = [".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]; const ext = uploadedFile.name.toLowerCase().match(/\.[^.]+$/)?.[0]; if (!ext || !validExts.includes(ext)) { library.logActivity("upload_rejected", { filename: uploadedFile.name }, { id: user.id, username: user.username }); return Response.json({ error: "Invalid audio format" }, { status: 400 }); } // Sanitize filename const safeName = uploadedFile.name.replace(/[^a-zA-Z0-9._-]/g, "_"); const destPath = join(config.musicDir, safeName); // Check if file already exists const existingFile = Bun.file(destPath); if (await existingFile.exists()) { library.logActivity("upload_duplicate", { filename: safeName }, { id: user.id, username: user.username }); return Response.json({ error: "File already exists" }, { status: 409 }); } // Write file const arrayBuffer = await uploadedFile.arrayBuffer(); await Bun.write(destPath, arrayBuffer); console.log(`[Upload] ${user.username} uploaded: ${safeName}`); library.logActivity("upload", { filename: safeName }, { id: user.id, username: user.username }); return Response.json({ success: true, filename: safeName }, { headers }); } catch (e) { console.error("[Upload] Error:", e); library.logActivity("upload_error", { filename: "unknown" }, { id: user.id, username: user.username }); return Response.json({ error: "Upload failed" }, { status: 500 }); } } // Auth: signup if (path === "/api/auth/signup" && req.method === "POST") { try { const { username, password } = await req.json(); if (!username || !password) { return Response.json({ error: "Username and password required" }, { status: 400 }); } if (username.length < 3 || password.length < 6) { return Response.json({ error: "Username min 3 chars, password min 6 chars" }, { status: 400 }); } const existing = findUserByUsername(username); if (existing) { return Response.json({ error: "Username already taken" }, { status: 400 }); } const user = await createUser(username, password); const userAgent = req.headers.get("user-agent") ?? undefined; const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? req.headers.get("x-real-ip") ?? server.requestIP(req)?.address ?? undefined; const token = createSession(user.id, userAgent, ipAddress); console.log(`[AUTH] Signup: user="${username}" id=${user.id} admin=${user.is_admin} session=${token} ip=${ipAddress} ua="${userAgent?.slice(0, 50)}..."`); library.logActivity("account_created", { title: user.is_admin ? "admin" : "user" }, { id: user.id, username: user.username }); return Response.json( { user: { id: user.id, username: user.username, isAdmin: user.is_admin } }, { headers: { "Set-Cookie": setSessionCookie(token) } } ); } catch (e) { return Response.json({ error: "Signup failed" }, { status: 500 }); } } // Auth: login if (path === "/api/auth/login" && req.method === "POST") { try { const { username, password } = await req.json(); const user = findUserByUsername(username); if (!user || !(await validatePassword(user, password))) { console.log(`[AUTH] Login failed: user="${username}"`); return Response.json({ error: "Invalid username or password" }, { status: 401 }); } const userAgent = req.headers.get("user-agent") ?? undefined; const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? req.headers.get("x-real-ip") ?? server.requestIP(req)?.address ?? undefined; const token = createSession(user.id, userAgent, ipAddress); console.log(`[AUTH] Login: user="${username}" id=${user.id} session=${token} ip=${ipAddress} ua="${userAgent?.slice(0, 50)}..."`); return Response.json( { user: { id: user.id, username: user.username, isAdmin: user.is_admin } }, { headers: { "Set-Cookie": setSessionCookie(token) } } ); } catch (e) { return Response.json({ error: "Login failed" }, { status: 500 }); } } // Auth: logout if (path === "/api/auth/logout" && req.method === "POST") { const token = req.headers.get("cookie")?.match(/blastoise_session=([^;]+)/)?.[1]; if (token) { const user = validateSession(token); console.log(`[AUTH] Logout: user="${user?.username ?? "unknown"}" session=${token}`); deleteSession(token); } return Response.json( { success: true }, { headers: { "Set-Cookie": clearSessionCookie() } } ); } // Auth: get current user if (path === "/api/auth/me") { 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.channel) { for (const perm of config.defaultPermissions.channel) { // Guests can never have control permission if (user.is_guest && perm === "control") continue; effectivePermissions.push({ id: 0, user_id: user.id, resource_type: "channel", resource_id: null, permission: perm, }); } } return Response.json({ user: { id: user.id, username: user.username, isAdmin: user.is_admin, isGuest: user.is_guest }, permissions: effectivePermissions, }, { headers }); } // Kick all other clients for current user if (path === "/api/auth/kick-others" && req.method === "POST") { const { user } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Not authenticated" }, { status: 401 }); } const connections = userConnections.get(user.id); if (!connections || connections.size === 0) { return Response.json({ kicked: 0 }); } // Get the current request's session to identify which connection NOT to kick const token = req.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1]; let kickedCount = 0; for (const ws of connections) { // Send kick message to all connections (client will handle it) ws.send(JSON.stringify({ type: "kick", reason: "Kicked by another session" })); kickedCount++; } console.log(`[Kick] User ${user.username} kicked ${kickedCount} other clients`); return Response.json({ kicked: kickedCount }); } // Admin: list users if (path === "/api/admin/users" && req.method === "GET") { try { requirePermission(req, "global", null, "admin", server); return Response.json(getAllUsers()); } catch (e) { if (e instanceof Response) return e; return Response.json({ error: "Failed" }, { status: 500 }); } } // Admin: grant permission if (path.match(/^\/api\/admin\/users\/(\d+)\/permissions$/) && req.method === "POST") { try { requirePermission(req, "global", null, "admin", server); const userId = parseInt(path.split("/")[4]); const { resourceType, resourceId, permission } = await req.json(); grantPermission(userId, resourceType, resourceId, permission); return Response.json({ success: true }); } catch (e) { if (e instanceof Response) return e; return Response.json({ error: "Failed" }, { status: 500 }); } } // Admin: revoke permission if (path.match(/^\/api\/admin\/users\/(\d+)\/permissions$/) && req.method === "DELETE") { try { requirePermission(req, "global", null, "admin", server); const userId = parseInt(path.split("/")[4]); const { resourceType, resourceId, permission } = await req.json(); revokePermission(userId, resourceType, resourceId, permission); return Response.json({ success: true }); } catch (e) { if (e instanceof Response) return e; return Response.json({ error: "Failed" }, { status: 500 }); } } // API: jump to track in queue const jumpMatch = path.match(/^\/api\/channels\/([^/]+)\/jump$/); if (jumpMatch && req.method === "POST") { const channelId = jumpMatch[1]; const { user } = getOrCreateUser(req, server); if (!userHasPermission(user, "channel", channelId, "control")) { return new Response("Forbidden", { status: 403 }); } const channel = channels.get(channelId); if (!channel) return new Response("Not found", { status: 404 }); try { const body = await req.json(); if (typeof body.index === "number") { channel.jumpTo(body.index); return Response.json({ success: true }); } return new Response("Invalid index", { status: 400 }); } catch { return new Response("Invalid JSON", { status: 400 }); } } // API: seek in channel const seekMatch = path.match(/^\/api\/channels\/([^/]+)\/seek$/); if (seekMatch && req.method === "POST") { const channelId = seekMatch[1]; const { user } = getOrCreateUser(req, server); if (!userHasPermission(user, "channel", channelId, "control")) { return new Response("Forbidden", { status: 403 }); } const channel = channels.get(channelId); if (!channel) return new Response("Not found", { status: 404 }); try { const body = await req.json(); if (typeof body.timestamp === "number") { channel.seek(body.timestamp); return Response.json({ success: true }); } return new Response("Invalid timestamp", { status: 400 }); } catch { return new Response("Invalid JSON", { status: 400 }); } } // API: modify channel queue (add/remove tracks) const queueMatch = path.match(/^\/api\/channels\/([^/]+)\/queue$/); if (queueMatch && req.method === "PATCH") { const channelId = queueMatch[1]; const { user } = getOrCreateUser(req, server); if (!userHasPermission(user, "channel", channelId, "control")) { return new Response("Forbidden", { status: 403 }); } const channel = channels.get(channelId); if (!channel) return new Response("Not found", { status: 404 }); try { const body = await req.json(); const { add, remove, set } = body; // If 'set' is provided, replace entire queue if (Array.isArray(set)) { const tracks = buildTracksFromIds(set, library); channel.setQueue(tracks); return Response.json({ success: true, queueLength: channel.queue.length }); } // Otherwise apply remove then add if (Array.isArray(remove) && remove.length > 0) { const indices = remove.filter((i: unknown) => typeof i === "number"); channel.removeTracksByIndex(indices); } if (Array.isArray(add) && add.length > 0) { const tracks = buildTracksFromIds(add, library); channel.addTracks(tracks); } return Response.json({ success: true, queueLength: channel.queue.length }); } catch { return new Response("Invalid JSON", { status: 400 }); } } // API: set channel playback mode const modeMatch = path.match(/^\/api\/channels\/([^/]+)\/mode$/); if (modeMatch && req.method === "POST") { const channelId = modeMatch[1]; const { user } = getOrCreateUser(req, server); if (!userHasPermission(user, "channel", channelId, "control")) { return new Response("Forbidden", { status: 403 }); } const channel = channels.get(channelId); if (!channel) return new Response("Not found", { status: 404 }); try { const body = await req.json(); const validModes = ["once", "repeat-all", "repeat-one", "shuffle"]; if (typeof body.mode === "string" && validModes.includes(body.mode)) { channel.setPlaybackMode(body.mode); return Response.json({ success: true, playbackMode: channel.playbackMode }); } return new Response("Invalid mode", { status: 400 }); } catch { return new Response("Invalid JSON", { status: 400 }); } } // API: serve audio file (requires auth or guest) // Supports both filename and track ID (sha256:...) const trackMatch = path.match(/^\/api\/tracks\/(.+)$/); if (trackMatch) { const { user } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } const identifier = decodeURIComponent(trackMatch[1]); if (identifier.includes("..")) return new Response("Forbidden", { status: 403 }); let filepath: string; if (identifier.startsWith("sha256:")) { // Track ID - look up in library const trackPath = library.getFilePath(identifier); if (!trackPath) return new Response("Not found", { status: 404 }); filepath = trackPath; } else { // Filename - direct path filepath = join(MUSIC_DIR, identifier); } const f = file(filepath); if (!(await f.exists())) return new Response("Not found", { status: 404 }); const size = f.size; const range = req.headers.get("range"); if (range) { const match = range.match(/bytes=(\d+)-(\d*)/); if (match) { const start = parseInt(match[1]); const end = match[2] ? parseInt(match[2]) : size - 1; const chunk = f.slice(start, end + 1); return new Response(chunk, { status: 206, headers: { "Content-Range": `bytes ${start}-${end}/${size}`, "Accept-Ranges": "bytes", "Content-Length": String(end - start + 1), "Content-Type": f.type || "audio/mpeg", }, }); } } return new Response(f, { headers: { "Accept-Ranges": "bytes", "Content-Length": String(size), "Content-Type": f.type || "audio/mpeg", }, }); } // Serve static client if (path === "/" || path === "/index.html") { return new Response(file(join(PUBLIC_DIR, "index.html")), { headers: { "Content-Type": "text/html" }, }); } if (path === "/styles.css") { return new Response(file(join(PUBLIC_DIR, "styles.css")), { headers: { "Content-Type": "text/css" }, }); } // Serve JS files from public directory if (path.endsWith(".js")) { const jsFile = file(join(PUBLIC_DIR, path.slice(1))); if (await jsFile.exists()) { return new Response(jsFile, { headers: { "Content-Type": "application/javascript" }, }); } } return new Response("Not found", { status: 404 }); }, websocket: { open(ws: ServerWebSocket) { const channel = channels.get(ws.data.channelId); if (channel) { channel.addClient(ws); // Broadcast updated channel list to all clients broadcastChannelList(); } // Track connection by user ID const userId = ws.data.userId; if (userId) { if (!userConnections.has(userId)) { userConnections.set(userId, new Set()); } userConnections.get(userId)!.add(ws); } }, close(ws: ServerWebSocket) { const channel = channels.get(ws.data.channelId); if (channel) { channel.removeClient(ws); broadcastChannelList(); } // Remove from user connections tracking const userId = ws.data.userId; if (userId && userConnections.has(userId)) { userConnections.get(userId)!.delete(ws); if (userConnections.get(userId)!.size === 0) { userConnections.delete(userId); } } }, message(ws: ServerWebSocket, message: string | Buffer) { try { const data = JSON.parse(String(message)); // Handle channel switching if (data.action === "switch" && data.channelId) { const oldChannel = channels.get(ws.data.channelId); const newChannel = channels.get(data.channelId); if (!newChannel) { ws.send(JSON.stringify({ type: "error", message: "Channel not found" })); return; } if (oldChannel) oldChannel.removeClient(ws); ws.data.channelId = data.channelId; newChannel.addClient(ws); ws.send(JSON.stringify({ type: "switched", channelId: data.channelId })); broadcastChannelList(); return; } const channel = channels.get(ws.data.channelId); if (!channel) { console.log("[WS] No channel found for:", ws.data.channelId); return; } // Check permission for control actions const userId = ws.data.userId; if (!userId) { console.log("[WS] No userId on connection"); return; } const user = findUserById(userId); if (!user) { console.log("[WS] User not found:", userId); return; } // Guests can never control playback if (user.is_guest) { console.log("[WS] Guest cannot control playback"); return; } // Check default permissions or user-specific permissions const canControl = user.is_admin || config.defaultPermissions.channel?.includes("control") || hasPermission(userId, "channel", ws.data.channelId, "control"); if (!canControl) { console.log("[WS] User lacks control permission:", user.username); return; } console.log("[WS] Control action:", data.action, "from", user.username); if (data.action === "pause") channel.pause(); else if (data.action === "unpause") channel.unpause(); else if (data.action === "seek" && typeof data.timestamp === "number") channel.seek(data.timestamp); else if (data.action === "jump" && typeof data.index === "number") channel.jumpTo(data.index); } catch {} }, }, }); console.log(`MusicRoom running on http://localhost:${config.port}`);