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 { createUser, findUserByUsername, validatePassword, createSession, deleteSession, validateSession, hasPermission, getUserPermissions, getAllUsers, grantPermission, revokePermission, findUserById, } from "./db"; import { getUser, requireUser, requirePermission, setSessionCookie, clearSessionCookie, } from "./auth"; const MUSIC_DIR = join(import.meta.dir, "music"); const PLAYLIST_PATH = join(import.meta.dir, "playlist.json"); const PUBLIC_DIR = join(import.meta.dir, "public"); // Load track metadata 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> { const playlistData = await file(PLAYLIST_PATH).json(); const streams = new Map(); for (const cfg of playlistData.streams) { let trackFiles: string[] = cfg.tracks; if (trackFiles.length === 0) { trackFiles = await discoverTracks(); console.log(`Stream "${cfg.id}": auto-discovered ${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(); // 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 }; serve({ port: parseInt("3001"), 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 = getUser(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) if (path === "/api/streams") { const user = getUser(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); } // 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 = getUser(req, server); if (!user) { return Response.json({ user: null }); } const permissions = getUserPermissions(user.id); return Response.json({ user: { id: user.id, username: user.username, isAdmin: user.is_admin }, permissions, }); } // 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]; try { requirePermission(req, "stream", streamId, "control", server); } catch (e) { if (e instanceof Response) return e; } 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]; try { requirePermission(req, "stream", streamId, "control", server); } catch (e) { if (e instanceof Response) return e; } 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) const trackMatch = path.match(/^\/api\/tracks\/(.+)$/); if (trackMatch) { const user = getUser(req, server); if (!user) { return Response.json({ error: "Authentication required" }, { status: 401 }); } const filename = decodeURIComponent(trackMatch[1]); if (filename.includes("..")) return new Response("Forbidden", { status: 403 }); const filepath = join(MUSIC_DIR, filename); 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) return; // Check permission for control actions const userId = ws.data.userId; if (!userId) return; // Not logged in const user = findUserById(userId); if (!user) return; const canControl = user.is_admin || hasPermission(userId, "stream", ws.data.streamId, "control"); if (!canControl) return; try { const data = JSON.parse(String(message)); if (data.action === "pause") stream.pause(); else if (data.action === "unpause") stream.unpause(); } catch {} }, }, }); console.log("MusicRoom running on http://localhost:3001");