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, 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: { 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}`); // Initialize library const library = new Library(MUSIC_DIR); // Load track metadata (for stream initialization - converts library tracks to stream format) async function loadTrack(filename: string): Promise { const filepath = join(MUSIC_DIR, filename); try { const metadata = await parseFile(filepath, { duration: true }); const duration = metadata.format.duration ?? 0; const title = metadata.common.title?.trim() || filename.replace(/\.[^.]+$/, ""); console.log(`Track: ${filename} | duration: ${duration}s | title: ${title}`); return { filename, title, duration }; } catch (e) { console.warn(`Could not read metadata for ${filename}, skipping`); return { filename, title: filename.replace(/\.[^.]+$/, ""), duration: 0 }; } } // 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 []; } } // Initialize streams async function init(): Promise> { // Scan library first await library.scan(); library.startWatching(); const playlistData = await file(PLAYLIST_PATH).json(); const streams = new Map(); // Get all discovered files for streams const allFiles = await discoverTracks(); for (const cfg of playlistData.streams) { let trackFiles: string[] = cfg.tracks; if (trackFiles.length === 0) { trackFiles = allFiles; console.log(`Stream "${cfg.id}": using all ${trackFiles.length} tracks`); } const tracks = await Promise.all(trackFiles.map(loadTrack)); const validTracks = tracks.filter((t) => t.duration > 0); if (validTracks.length === 0) { console.warn(`Stream "${cfg.id}" has no valid tracks, skipping`); continue; } const stream = new Stream({ id: cfg.id, name: cfg.name, tracks: validTracks }); streams.set(cfg.id, stream); console.log(`Stream "${cfg.id}": ${validTracks.length} tracks loaded`); } return streams; } const streams = await init(); // Broadcast to all connected clients across all streams function broadcastToAll(message: object) { const data = JSON.stringify(message); for (const stream of streams.values()) { for (const ws of stream.clients) { ws.send(data); } } } // 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 stream of streams.values()) { const changed = stream.tick(); if (!changed && tickCount % 30 === 0) { stream.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 === "stream" && config.defaultPermissions.stream?.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 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 } = 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: 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, headers } = getOrCreateUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } const list = [...streams.values()].map((s) => ({ id: s.id, name: s.name, trackCount: s.playlist.length, })); return Response.json(list, { headers }); } // 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.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, 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\/streams\/([^/]+)\/jump$/); if (jumpMatch && req.method === "POST") { const streamId = jumpMatch[1]; 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 }); try { const body = await req.json(); if (typeof body.index === "number") { stream.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 stream const seekMatch = path.match(/^\/api\/streams\/([^/]+)\/seek$/); if (seekMatch && req.method === "POST") { const streamId = seekMatch[1]; 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 }); try { const body = await req.json(); if (typeof body.timestamp === "number") { stream.seek(body.timestamp); return Response.json({ success: true }); } return new Response("Invalid timestamp", { status: 400 }); } catch { return new Response("Invalid JSON", { status: 400 }); } } // API: stream state const streamMatch = path.match(/^\/api\/streams\/([^/]+)$/); if (streamMatch) { const stream = streams.get(streamMatch[1]); if (!stream) return new Response("Not found", { status: 404 }); return Response.json(stream.getState()); } // 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" }, }); } if (path === "/trackStorage.js") { return new Response(file(join(PUBLIC_DIR, "trackStorage.js")), { headers: { "Content-Type": "application/javascript" }, }); } if (path === "/app.js") { return new Response(file(join(PUBLIC_DIR, "app.js")), { headers: { "Content-Type": "application/javascript" }, }); } return new Response("Not found", { status: 404 }); }, websocket: { open(ws: ServerWebSocket) { const stream = streams.get(ws.data.streamId); if (stream) stream.addClient(ws); }, close(ws: ServerWebSocket) { const stream = streams.get(ws.data.streamId); if (stream) stream.removeClient(ws); }, message(ws: ServerWebSocket, message: string | Buffer) { const stream = streams.get(ws.data.streamId); if (!stream) { console.log("[WS] No stream found for:", ws.data.streamId); 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.stream?.includes("control") || hasPermission(userId, "stream", ws.data.streamId, "control"); if (!canControl) { console.log("[WS] User lacks control permission:", user.username); return; } try { const data = JSON.parse(String(message)); console.log("[WS] Control action:", data.action, "from", user.username); if (data.action === "pause") stream.pause(); else if (data.action === "unpause") stream.unpause(); } catch {} }, }, }); console.log(`MusicRoom running on http://localhost:${config.port}`);