import { file, serve, type ServerWebSocket } from "bun"; import { Channel, type Track, type WsData } 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, createPlaylist, getPlaylist, updatePlaylist, deletePlaylist, getVisiblePlaylists, canViewPlaylist, canEditPlaylist, getPlaylistTracks, addTrackToPlaylist, removeTrackFromPlaylist, reorderPlaylistTrack, type Playlist, } 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 config: Config = await file(CONFIG_PATH).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 playlist 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(); async function init(): Promise { // Scan library first await library.scan(); library.startWatching(); // Create default channel with full library const allTracks = library.getAllTracks(); const tracks: Track[] = allTracks .filter(t => t.duration > 0) .map(t => ({ id: t.id, filename: t.filename, title: t.title || t.filename.replace(/\.[^.]+$/, ""), duration: t.duration, })); const defaultChannel = new Channel({ id: "main", name: "Main Channel", description: "All tracks from the library", tracks, isDefault: true, createdBy: null, }); channels.set("main", defaultChannel); 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)`); 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}`); 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(); } } }, 1000); 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 === "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 } }); 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, }); channels.set(channelId, channel); 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); 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 }); } // Playlist API: list playlists if (path === "/api/playlists" && req.method === "GET") { const { user, headers } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } const playlists = getVisiblePlaylists(user.id, user.is_guest); return Response.json(playlists, { headers }); } // Playlist API: create playlist if (path === "/api/playlists" && 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 playlists" }, { status: 403 }); } try { const { name, description, visibility } = await req.json(); if (!name || typeof name !== "string" || name.trim().length === 0) { return Response.json({ error: "Name is required" }, { status: 400 }); } const playlist = createPlaylist(user.id, name.trim(), visibility || "private", description); return Response.json(playlist, { status: 201 }); } catch { return Response.json({ error: "Invalid request" }, { status: 400 }); } } // Playlist API: get/update/delete single playlist const playlistMatch = path.match(/^\/api\/playlists\/([^/]+)$/); if (playlistMatch) { const playlistId = playlistMatch[1]; const { user, headers } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } const playlist = getPlaylist(playlistId); if (!playlist) { return Response.json({ error: "Playlist not found" }, { status: 404 }); } if (req.method === "GET") { if (!canViewPlaylist(playlist, user.id, user.is_guest)) { return Response.json({ error: "Access denied" }, { status: 403 }); } const tracks = getPlaylistTracks(playlistId); return Response.json({ ...playlist, tracks }, { headers }); } if (req.method === "PUT") { if (!canEditPlaylist(playlist, user.id)) { return Response.json({ error: "Access denied" }, { status: 403 }); } try { const { name, description, visibility } = await req.json(); updatePlaylist(playlistId, { name, description, visibility }); const updated = getPlaylist(playlistId); return Response.json(updated); } catch { return Response.json({ error: "Invalid request" }, { status: 400 }); } } if (req.method === "DELETE") { if (!canEditPlaylist(playlist, user.id)) { return Response.json({ error: "Access denied" }, { status: 403 }); } deletePlaylist(playlistId); return Response.json({ success: true }); } } // Playlist API: add tracks const playlistTracksMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks$/); if (playlistTracksMatch && req.method === "POST") { const playlistId = playlistTracksMatch[1]; const { user } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } const playlist = getPlaylist(playlistId); if (!playlist) { return Response.json({ error: "Playlist not found" }, { status: 404 }); } if (!canEditPlaylist(playlist, user.id)) { return Response.json({ error: "Access denied" }, { status: 403 }); } try { const { trackIds, position } = await req.json(); if (!Array.isArray(trackIds) || trackIds.length === 0) { return Response.json({ error: "trackIds array required" }, { status: 400 }); } for (const trackId of trackIds) { if (!library.getTrack(trackId)) { return Response.json({ error: `Track not found: ${trackId}` }, { status: 404 }); } } let insertPos = position; for (const trackId of trackIds) { addTrackToPlaylist(playlistId, trackId, user.id, insertPos); if (insertPos !== undefined) insertPos++; } const tracks = getPlaylistTracks(playlistId); return Response.json({ tracks }, { status: 201 }); } catch { return Response.json({ error: "Invalid request" }, { status: 400 }); } } // Playlist API: remove track const playlistTrackMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks\/(\d+)$/); if (playlistTrackMatch && req.method === "DELETE") { const playlistId = playlistTrackMatch[1]; const position = parseInt(playlistTrackMatch[2]); const { user } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } const playlist = getPlaylist(playlistId); if (!playlist) { return Response.json({ error: "Playlist not found" }, { status: 404 }); } if (!canEditPlaylist(playlist, user.id)) { return Response.json({ error: "Access denied" }, { status: 403 }); } removeTrackFromPlaylist(playlistId, position); return Response.json({ success: true }); } // Playlist API: reorder tracks const playlistReorderMatch = path.match(/^\/api\/playlists\/([^/]+)\/tracks\/reorder$/); if (playlistReorderMatch && req.method === "PUT") { const playlistId = playlistReorderMatch[1]; const { user } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } const playlist = getPlaylist(playlistId); if (!playlist) { return Response.json({ error: "Playlist not found" }, { status: 404 }); } if (!canEditPlaylist(playlist, user.id)) { return Response.json({ error: "Access denied" }, { status: 403 }); } try { const { from, to } = await req.json(); if (typeof from !== "number" || typeof to !== "number") { return Response.json({ error: "from and to positions required" }, { status: 400 }); } reorderPlaylistTrack(playlistId, from, to); const tracks = getPlaylistTracks(playlistId); return Response.json({ tracks }); } catch { return Response.json({ error: "Invalid request" }, { status: 400 }); } } // 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)}..."`); 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(/musicroom_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 }); } // 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 playlist 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: 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); // Send channel list on connect const list = [...channels.values()].map(c => c.getListInfo()); ws.send(JSON.stringify({ type: "channel_list", channels: list })); } }, close(ws: ServerWebSocket) { const channel = channels.get(ws.data.channelId); if (channel) channel.removeClient(ws); }, message(ws: ServerWebSocket, message: string | Buffer) { try { const data = JSON.parse(String(message)); console.log("[WS] Received message:", data.action, "from streamId:", ws.data.streamId); // 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 })); 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}`);