From c2e852f2cc10ce06796a5bbdff5bbd28e542e166 Mon Sep 17 00:00:00 2001 From: peterino2 Date: Thu, 5 Feb 2026 22:57:04 -0800 Subject: [PATCH] ignoring shm and wal files --- .gitignore | 2 + broadcast.ts | 34 ++ config.ts | 58 +++ init.ts | 254 ++++++++++ public/index.html | 2 +- routes/auth.ts | 179 +++++++ routes/channels.ts | 234 +++++++++ routes/fetch.ts | 109 ++++ routes/helpers.ts | 39 ++ routes/index.ts | 175 +++++++ routes/static.ts | 29 ++ routes/tracks.ts | 119 +++++ server.ts | 1190 +------------------------------------------- state.ts | 14 + websocket.ts | 102 ++++ 15 files changed, 1356 insertions(+), 1184 deletions(-) create mode 100644 broadcast.ts create mode 100644 config.ts create mode 100644 init.ts create mode 100644 routes/auth.ts create mode 100644 routes/channels.ts create mode 100644 routes/fetch.ts create mode 100644 routes/helpers.ts create mode 100644 routes/index.ts create mode 100644 routes/static.ts create mode 100644 routes/tracks.ts create mode 100644 state.ts create mode 100644 websocket.ts diff --git a/.gitignore b/.gitignore index aa77ef8..cbbde16 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ library_cache.db musicroom.db blastoise.db config.json +*.db-shm +*.db-wal diff --git a/broadcast.ts b/broadcast.ts new file mode 100644 index 0000000..db793f5 --- /dev/null +++ b/broadcast.ts @@ -0,0 +1,34 @@ +import type { ServerWebSocket } from "bun"; +import type { WsData } from "./channel"; +import { state } from "./state"; + +// Broadcast to all connected clients across all channels +export function broadcastToAll(message: object) { + const data = JSON.stringify(message); + let clientCount = 0; + for (const channel of state.channels.values()) { + for (const ws of channel.clients) { + ws.send(data); + clientCount++; + } + } + console.log(`[Broadcast] Sent to ${clientCount} clients`); +} + +// Send message to specific user's connections +export function sendToUser(userId: number, message: object) { + const connections = state.userConnections.get(userId); + if (connections) { + const data = JSON.stringify(message); + for (const ws of connections) { + ws.send(data); + } + } +} + +// Broadcast channel list to all clients +export function broadcastChannelList() { + const list = [...state.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 }); +} diff --git a/config.ts b/config.ts new file mode 100644 index 0000000..793e91c --- /dev/null +++ b/config.ts @@ -0,0 +1,58 @@ +import { file } from "bun"; +import { join, resolve } from "path"; + +export interface YtdlpConfig { + enabled: boolean; + command: string; + ffmpegCommand: string; + updateCommand: string | null; + fastQueueConcurrent: number; + slowQueueInterval: number; + allowPlaylists: boolean; + autoUpdate: boolean; + updateCheckInterval: number; +} + +export interface Config { + port: number; + musicDir: string; + allowGuests: boolean; + defaultPermissions: string[]; + ytdlp?: YtdlpConfig; +} + +const CONFIG_PATH = join(import.meta.dir, "config.json"); + +export const DEFAULT_CONFIG: Config = { + port: 3001, + musicDir: "./music", + allowGuests: true, + defaultPermissions: ["listen", "control"], + ytdlp: { + enabled: false, + command: "yt-dlp", + ffmpegCommand: "ffmpeg", + updateCommand: "yt-dlp -U", + fastQueueConcurrent: 2, + slowQueueInterval: 180, + allowPlaylists: true, + autoUpdate: true, + updateCheckInterval: 86400 + } +}; + +// 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)); + console.log("Config created at config.json. Have a look at it then restart the server. Bye!"); + process.exit(); +} + +export const config: Config = await configFile.json(); + +export const MUSIC_DIR = resolve(import.meta.dir, config.musicDir); +export const PUBLIC_DIR = join(import.meta.dir, "public"); + +console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`); diff --git a/init.ts b/init.ts new file mode 100644 index 0000000..c4727aa --- /dev/null +++ b/init.ts @@ -0,0 +1,254 @@ +import { readdir } from "fs/promises"; +import { Channel, type Track, type PersistenceCallback } from "./channel"; +import { Library } from "./library"; +import { + saveChannel, + updateChannelState, + loadAllChannels, + saveChannelQueue, + loadChannelQueue, + removeTrackFromQueues, +} from "./db"; +import { config, MUSIC_DIR, DEFAULT_CONFIG } from "./config"; +import { state, setLibrary } from "./state"; +import { broadcastToAll, broadcastChannelList, sendToUser } from "./broadcast"; +import { + initYtdlp, + setProgressCallback, +} from "./ytdlp"; + +// Auto-discover tracks if queue is empty +export 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 +export function generateChannelId(): string { + return Math.random().toString(36).slice(2, 10); +} + +// 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 +export 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 +export 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, + })); +} + +export async function init(): Promise { + // Initialize yt-dlp if configured + const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!; + await initYtdlp({ + enabled: ytdlpConfig.enabled, + command: ytdlpConfig.command, + ffmpegCommand: ytdlpConfig.ffmpegCommand, + musicDir: MUSIC_DIR, + fastQueueConcurrent: ytdlpConfig.fastQueueConcurrent, + slowQueueInterval: ytdlpConfig.slowQueueInterval, + allowPlaylists: ytdlpConfig.allowPlaylists + }); + + // Set up ytdlp progress callback + setProgressCallback((item) => { + sendToUser(item.userId, { + type: `fetch_${item.status === "downloading" ? "progress" : item.status}`, + id: item.id, + title: item.title, + status: item.status, + progress: item.progress, + queueType: item.queueType, + error: item.error + }); + }); + + // Initialize library + const library = new Library(MUSIC_DIR); + setLibrary(library); + + // 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) { + const trackIds = loadChannelQueue(row.id); + const tracks = buildTracksFromIds(trackIds, 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); + state.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); + state.channels.set("main", defaultChannel); + + 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`); + } + + // 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}`); + 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 state.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) { + broadcastToAll({ type: "scan_progress", scanning: false }); + } + }, 1000); +} diff --git a/public/index.html b/public/index.html index 002b1db..4b274d9 100644 --- a/public/index.html +++ b/public/index.html @@ -3,7 +3,7 @@ -NeoRose +Blastoise! A very special music server diff --git a/routes/auth.ts b/routes/auth.ts new file mode 100644 index 0000000..17d7b48 --- /dev/null +++ b/routes/auth.ts @@ -0,0 +1,179 @@ +import { + createUser, + findUserByUsername, + validatePassword, + createSession, + createGuestSession, + deleteSession, + getUserPermissions, + getAllUsers, + grantPermission, + revokePermission, +} from "../db"; +import { + getUser, + requirePermission, + setSessionCookie, + clearSessionCookie, + getClientInfo, +} from "../auth"; +import { config } from "../config"; +import { state } from "../state"; +import { getOrCreateUser, userHasPermission } from "./helpers"; + +// Auth: signup +export async function handleSignup(req: Request, server: any): Promise { + 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)}..."`); + state.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 +export async function handleLogin(req: Request, server: any): Promise { + 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 +export function handleLogout(req: Request): Response { + const token = req.headers.get("cookie")?.match(/blastoise_session=([^;]+)/)?.[1]; + if (token) { + const { validateSession } = require("../db"); + 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 +export function handleGetMe(req: Request, server: any): Response { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ user: null }); + } + const permissions = getUserPermissions(user.id); + const effectivePermissions = [...permissions]; + if (config.defaultPermissions) { + for (const perm of config.defaultPermissions) { + 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 +export function handleKickOthers(req: Request, server: any): Response { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Not authenticated" }, { status: 401 }); + } + + const connections = state.userConnections.get(user.id); + if (!connections || connections.size === 0) { + return Response.json({ kicked: 0 }); + } + + let kickedCount = 0; + for (const ws of connections) { + 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 +export function handleListUsers(req: Request, server: any): Response { + 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 +export async function handleGrantPermission(req: Request, server: any, userId: number): Promise { + try { + requirePermission(req, "global", null, "admin", server); + 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 +export async function handleRevokePermission(req: Request, server: any, userId: number): Promise { + try { + requirePermission(req, "global", null, "admin", server); + 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 }); + } +} diff --git a/routes/channels.ts b/routes/channels.ts new file mode 100644 index 0000000..2bac563 --- /dev/null +++ b/routes/channels.ts @@ -0,0 +1,234 @@ +import { Channel, type Track } from "../channel"; +import { + saveChannel, + deleteChannelFromDb, + saveChannelQueue, + updateChannelState, +} from "../db"; +import { state } from "../state"; +import { broadcastChannelList } from "../broadcast"; +import { generateChannelId, buildTracksFromIds } from "../init"; +import { getOrCreateUser, userHasPermission } from "./helpers"; + +// Helper for persistence callback +function getPersistCallback() { + return (channel: Channel, type: "state" | "queue") => { + 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)); + } + }; +} + +// GET /api/channels - list channels +export function handleListChannels(req: Request, server: any): Response { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + const list = [...state.channels.values()].map(c => c.getListInfo()); + return Response.json(list, { headers }); +} + +// POST /api/channels - create channel +export async function handleCreateChannel(req: Request, server: any): Promise { + 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 }); + } + if (name.trim().length > 64) { + return Response.json({ error: "Name must be 64 characters or less" }, { status: 400 }); + } + + let tracks: Track[]; + if (trackIds && Array.isArray(trackIds) && trackIds.length > 0) { + tracks = buildTracksFromIds(trackIds, state.library); + } else { + tracks = []; + } + + const channelId = generateChannelId(); + const channel = new Channel({ + id: channelId, + name: name.trim(), + description: description || "", + tracks, + createdBy: user.id, + isDefault: false, + }); + channel.setPersistenceCallback(getPersistCallback()); + state.channels.set(channelId, channel); + + 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 }); + } +} + +// DELETE /api/channels/:id - delete channel +export function handleDeleteChannel(req: Request, server: any, channelId: string): Response { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + const channel = state.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 }); + } + + // Move connected clients to default channel before deleting + const defaultChannel = [...state.channels.values()].find(c => c.isDefault); + if (defaultChannel && channel.clients.size > 0) { + for (const ws of channel.clients) { + channel.removeClient(ws); + ws.data.channelId = defaultChannel.id; + defaultChannel.addClient(ws); + ws.send(JSON.stringify({ type: "switched", channelId: defaultChannel.id })); + } + } + + state.channels.delete(channelId); + deleteChannelFromDb(channelId); + broadcastChannelList(); + return Response.json({ success: true }); +} + +// GET /api/channels/:id - get channel state +export function handleGetChannel(channelId: string): Response { + const channel = state.channels.get(channelId); + if (!channel) return new Response("Not found", { status: 404 }); + return Response.json(channel.getState()); +} + +// POST /api/channels/:id/jump - jump to track +export async function handleJump(req: Request, server: any, channelId: string): Promise { + const { user } = getOrCreateUser(req, server); + if (!userHasPermission(user, "channel", channelId, "control")) { + return new Response("Forbidden", { status: 403 }); + } + const channel = state.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 }); + } +} + +// POST /api/channels/:id/seek - seek in channel +export async function handleSeek(req: Request, server: any, channelId: string): Promise { + const { user } = getOrCreateUser(req, server); + if (!userHasPermission(user, "channel", channelId, "control")) { + return new Response("Forbidden", { status: 403 }); + } + const channel = state.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 }); + } +} + +// PATCH /api/channels/:id/queue - modify queue +export async function handleModifyQueue(req: Request, server: any, channelId: string): Promise { + const { user } = getOrCreateUser(req, server); + if (!userHasPermission(user, "channel", channelId, "control")) { + return new Response("Forbidden", { status: 403 }); + } + const channel = state.channels.get(channelId); + if (!channel) return new Response("Not found", { status: 404 }); + + try { + const body = await req.json(); + const { add, remove, set } = body; + + if (Array.isArray(set)) { + const tracks = buildTracksFromIds(set, state.library); + channel.setQueue(tracks); + return Response.json({ success: true, queueLength: channel.queue.length }); + } + + 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, state.library); + channel.addTracks(tracks); + } + + return Response.json({ success: true, queueLength: channel.queue.length }); + } catch { + return new Response("Invalid JSON", { status: 400 }); + } +} + +// POST /api/channels/:id/mode - set playback mode +export async function handleSetMode(req: Request, server: any, channelId: string): Promise { + const { user } = getOrCreateUser(req, server); + if (!userHasPermission(user, "channel", channelId, "control")) { + return new Response("Forbidden", { status: 403 }); + } + const channel = state.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 }); + } +} diff --git a/routes/fetch.ts b/routes/fetch.ts new file mode 100644 index 0000000..a53b632 --- /dev/null +++ b/routes/fetch.ts @@ -0,0 +1,109 @@ +import { config, DEFAULT_CONFIG } from "../config"; +import { + isAvailable as isYtdlpAvailable, + checkUrl, + addToFastQueue, + addToSlowQueue, + getUserQueues, +} from "../ytdlp"; +import { getOrCreateUser } from "./helpers"; + +const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!; + +// POST /api/fetch - fetch from URL (yt-dlp) +export async function handleFetch(req: Request, server: any): Promise { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + if (user.is_guest) { + return Response.json({ error: "Guests cannot fetch from URLs" }, { status: 403 }); + } + + if (!ytdlpConfig.enabled) { + return Response.json({ error: "Feature disabled" }, { status: 403 }); + } + if (!isYtdlpAvailable()) { + return Response.json({ error: "yt-dlp not available" }, { status: 503 }); + } + + try { + const { url } = await req.json(); + if (!url || typeof url !== "string") { + return Response.json({ error: "URL is required" }, { status: 400 }); + } + + console.log(`[Fetch] ${user.username} checking URL: ${url}`); + + const info = await checkUrl(url); + + if (info.type === "playlist") { + if (!ytdlpConfig.allowPlaylists) { + return Response.json({ error: "Playlist downloads are disabled" }, { status: 403 }); + } + return Response.json(info, { headers }); + } else { + const item = addToFastQueue(info.url, info.title, user.id); + console.log(`[Fetch] ${user.username} queued: ${info.title} (id=${item.id})`); + return Response.json({ + type: "single", + id: item.id, + title: item.title, + queueType: "fast" + }, { headers }); + } + } catch (e: any) { + console.error("[Fetch] Error:", e); + return Response.json({ error: e.message || "Invalid request" }, { status: 400 }); + } +} + +// POST /api/fetch/confirm - confirm playlist download +export async function handleFetchConfirm(req: Request, server: any): Promise { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + if (user.is_guest) { + return Response.json({ error: "Guests cannot fetch from URLs" }, { status: 403 }); + } + if (!ytdlpConfig.enabled || !isYtdlpAvailable()) { + return Response.json({ error: "Feature not available" }, { status: 503 }); + } + + try { + const { items } = await req.json(); + if (!Array.isArray(items) || items.length === 0) { + return Response.json({ error: "Items required" }, { status: 400 }); + } + + const queueItems = addToSlowQueue(items, user.id); + const estimatedMinutes = Math.ceil(queueItems.length * ytdlpConfig.slowQueueInterval / 60); + const hours = Math.floor(estimatedMinutes / 60); + const mins = estimatedMinutes % 60; + const estimatedTime = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; + + console.log(`[Fetch] ${user.username} confirmed playlist: ${queueItems.length} items`); + + return Response.json({ + message: `Added ${queueItems.length} items to queue`, + queueType: "slow", + estimatedTime, + items: queueItems.map(i => ({ id: i.id, title: i.title })) + }, { headers }); + } catch (e) { + console.error("[Fetch] Confirm error:", e); + return Response.json({ error: "Invalid request" }, { status: 400 }); + } +} + +// GET /api/fetch - get fetch queue status +export function handleGetFetchQueue(req: Request, server: any): Response { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + + const queues = getUserQueues(user.id); + return Response.json(queues, { headers }); +} diff --git a/routes/helpers.ts b/routes/helpers.ts new file mode 100644 index 0000000..f6eedfa --- /dev/null +++ b/routes/helpers.ts @@ -0,0 +1,39 @@ +import { hasPermission, createGuestSession } from "../db"; +import { getUser, setSessionCookie, getClientInfo } from "../auth"; +import { config } from "../config"; + +type User = ReturnType; + +// Helper to get or create guest session +export function getOrCreateUser(req: Request, server: any): { user: User, 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) +export function userHasPermission(user: User, 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?.includes(permission)) { + return true; + } + + // Check user-specific permissions + return hasPermission(user.id, resourceType, resourceId, permission); +} diff --git a/routes/index.ts b/routes/index.ts new file mode 100644 index 0000000..eba25f7 --- /dev/null +++ b/routes/index.ts @@ -0,0 +1,175 @@ +import { getStatus as getYtdlpStatus } from "../ytdlp"; +import { config } from "../config"; +import { state } from "../state"; +import { getOrCreateUser } from "./helpers"; + +// Auth routes +import { + handleSignup, + handleLogin, + handleLogout, + handleGetMe, + handleKickOthers, + handleListUsers, + handleGrantPermission, + handleRevokePermission, +} from "./auth"; + +// Channel routes +import { + handleListChannels, + handleCreateChannel, + handleDeleteChannel, + handleGetChannel, + handleJump, + handleSeek, + handleModifyQueue, + handleSetMode, +} from "./channels"; + +// Track routes +import { + handleGetLibrary, + handleUpload, + handleGetTrack, +} from "./tracks"; + +// Fetch routes (yt-dlp) +import { + handleFetch, + handleFetchConfirm, + handleGetFetchQueue, +} from "./fetch"; + +// Static file serving +import { handleStatic } from "./static"; + +export function createRouter() { + return async function fetch(req: Request, server: any): Promise { + 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 (!state.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: state.channels.size, + defaultPermissions: config.defaultPermissions, + ytdlp: getYtdlpStatus() + }); + } + + // Channel routes + if (path === "/api/channels" && req.method === "GET") { + return handleListChannels(req, server); + } + if (path === "/api/channels" && req.method === "POST") { + return handleCreateChannel(req, server); + } + + const channelDeleteMatch = path.match(/^\/api\/channels\/([^/]+)$/); + if (channelDeleteMatch && req.method === "DELETE") { + return handleDeleteChannel(req, server, channelDeleteMatch[1]); + } + + const channelGetMatch = path.match(/^\/api\/channels\/([^/]+)$/); + if (channelGetMatch && req.method === "GET") { + return handleGetChannel(channelGetMatch[1]); + } + + const jumpMatch = path.match(/^\/api\/channels\/([^/]+)\/jump$/); + if (jumpMatch && req.method === "POST") { + return handleJump(req, server, jumpMatch[1]); + } + + const seekMatch = path.match(/^\/api\/channels\/([^/]+)\/seek$/); + if (seekMatch && req.method === "POST") { + return handleSeek(req, server, seekMatch[1]); + } + + const queueMatch = path.match(/^\/api\/channels\/([^/]+)\/queue$/); + if (queueMatch && req.method === "PATCH") { + return handleModifyQueue(req, server, queueMatch[1]); + } + + const modeMatch = path.match(/^\/api\/channels\/([^/]+)\/mode$/); + if (modeMatch && req.method === "POST") { + return handleSetMode(req, server, modeMatch[1]); + } + + // Library routes + if (path === "/api/library") { + return handleGetLibrary(req, server); + } + + if (path === "/api/upload" && req.method === "POST") { + return handleUpload(req, server); + } + + // Fetch routes (yt-dlp) + if (path === "/api/fetch" && req.method === "POST") { + return handleFetch(req, server); + } + if (path === "/api/fetch/confirm" && req.method === "POST") { + return handleFetchConfirm(req, server); + } + if (path === "/api/fetch" && req.method === "GET") { + return handleGetFetchQueue(req, server); + } + + // Auth routes + if (path === "/api/auth/signup" && req.method === "POST") { + return handleSignup(req, server); + } + if (path === "/api/auth/login" && req.method === "POST") { + return handleLogin(req, server); + } + if (path === "/api/auth/logout" && req.method === "POST") { + return handleLogout(req); + } + if (path === "/api/auth/me") { + return handleGetMe(req, server); + } + if (path === "/api/auth/kick-others" && req.method === "POST") { + return handleKickOthers(req, server); + } + + // Admin routes + if (path === "/api/admin/users" && req.method === "GET") { + return handleListUsers(req, server); + } + + const permissionsMatch = path.match(/^\/api\/admin\/users\/(\d+)\/permissions$/); + if (permissionsMatch && req.method === "POST") { + return handleGrantPermission(req, server, parseInt(permissionsMatch[1])); + } + if (permissionsMatch && req.method === "DELETE") { + return handleRevokePermission(req, server, parseInt(permissionsMatch[1])); + } + + // Track serving (must be after other /api/tracks routes) + const trackMatch = path.match(/^\/api\/tracks\/(.+)$/); + if (trackMatch) { + return handleGetTrack(req, server, decodeURIComponent(trackMatch[1])); + } + + // Static files + const staticResponse = await handleStatic(path); + if (staticResponse) return staticResponse; + + return new Response("Not found", { status: 404 }); + }; +} diff --git a/routes/static.ts b/routes/static.ts new file mode 100644 index 0000000..d8806ec --- /dev/null +++ b/routes/static.ts @@ -0,0 +1,29 @@ +import { file } from "bun"; +import { join } from "path"; +import { PUBLIC_DIR } from "../config"; + +// Serve static files +export async function handleStatic(path: string): Promise { + 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.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 null; +} diff --git a/routes/tracks.ts b/routes/tracks.ts new file mode 100644 index 0000000..6e3e878 --- /dev/null +++ b/routes/tracks.ts @@ -0,0 +1,119 @@ +import { file } from "bun"; +import { join } from "path"; +import { config, MUSIC_DIR } from "../config"; +import { state } from "../state"; +import { getOrCreateUser } from "./helpers"; + +// GET /api/library - list all tracks +export function handleGetLibrary(req: Request, server: any): Response { + const { user, headers } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + const tracks = state.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 }); +} + +// POST /api/upload - upload audio file +export async function handleUpload(req: Request, server: any): Promise { + 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)) { + state.library.logActivity("upload_failed", { filename: "unknown" }, { id: user.id, username: user.username }); + return Response.json({ error: "No file provided" }, { status: 400 }); + } + + const validExts = [".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]; + const ext = uploadedFile.name.toLowerCase().match(/\.[^.]+$/)?.[0]; + if (!ext || !validExts.includes(ext)) { + state.library.logActivity("upload_rejected", { filename: uploadedFile.name }, { id: user.id, username: user.username }); + return Response.json({ error: "Invalid audio format" }, { status: 400 }); + } + + const safeName = uploadedFile.name.replace(/[^a-zA-Z0-9._-]/g, "_"); + const destPath = join(config.musicDir, safeName); + + const existingFile = Bun.file(destPath); + if (await existingFile.exists()) { + state.library.logActivity("upload_duplicate", { filename: safeName }, { id: user.id, username: user.username }); + return Response.json({ error: "File already exists" }, { status: 409 }); + } + + const arrayBuffer = await uploadedFile.arrayBuffer(); + await Bun.write(destPath, arrayBuffer); + + console.log(`[Upload] ${user.username} uploaded: ${safeName}`); + state.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); + state.library.logActivity("upload_error", { filename: "unknown" }, { id: user.id, username: user.username }); + return Response.json({ error: "Upload failed" }, { status: 500 }); + } +} + +// GET /api/tracks/:id - serve audio file +export async function handleGetTrack(req: Request, server: any, identifier: string): Promise { + const { user } = getOrCreateUser(req, server); + if (!user) { + return Response.json({ error: "Authentication required" }, { status: 401 }); + } + + if (identifier.includes("..")) return new Response("Forbidden", { status: 403 }); + + let filepath: string; + if (identifier.startsWith("sha256:")) { + const trackPath = state.library.getFilePath(identifier); + if (!trackPath) return new Response("Not found", { status: 404 }); + filepath = trackPath; + } else { + 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", + }, + }); +} diff --git a/server.ts b/server.ts index 0c24ce1..d331bc1 100644 --- a/server.ts +++ b/server.ts @@ -1,1191 +1,15 @@ -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"; -import { - initYtdlp, - getStatus as getYtdlpStatus, - isAvailable as isYtdlpAvailable, - checkUrl, - addToFastQueue, - addToSlowQueue, - getUserQueues, - setProgressCallback, - type QueueItem -} from "./ytdlp"; - -// Load config -interface YtdlpConfig { - enabled: boolean; - command: string; - ffmpegCommand: string; - updateCommand: string | null; - fastQueueConcurrent: number; - slowQueueInterval: number; - allowPlaylists: boolean; - autoUpdate: boolean; - updateCheckInterval: number; -} - -interface Config { - port: number; - musicDir: string; - allowGuests: boolean; - defaultPermissions: string[]; - ytdlp?: YtdlpConfig; -} - -const CONFIG_PATH = join(import.meta.dir, "config.json"); - -const DEFAULT_CONFIG: Config = { - port: 3001, - musicDir: "./music", - allowGuests: true, - defaultPermissions: ["listen", "control"], - ytdlp: { - enabled: false, - command: "yt-dlp", - ffmpegCommand: "ffmpeg", - updateCommand: "yt-dlp -U", - fastQueueConcurrent: 2, - slowQueueInterval: 180, - allowPlaylists: true, - autoUpdate: true, - updateCheckInterval: 86400 - } -}; - -// 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)); - console.log("Config created at config.json. Have a look at it then restart the server. Bye!"); - process.exit(); -} - -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 yt-dlp if configured -const ytdlpConfig = config.ytdlp || DEFAULT_CONFIG.ytdlp!; -const ytdlpStatus = await initYtdlp({ - enabled: ytdlpConfig.enabled, - command: ytdlpConfig.command, - ffmpegCommand: ytdlpConfig.ffmpegCommand, - musicDir: MUSIC_DIR, - fastQueueConcurrent: ytdlpConfig.fastQueueConcurrent, - slowQueueInterval: ytdlpConfig.slowQueueInterval, - allowPlaylists: ytdlpConfig.allowPlaylists -}); - -// 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`); - } -} +import { serve } from "bun"; +import { config } from "./config"; +import { init } from "./init"; +import { createRouter } from "./routes"; +import { websocketHandlers } from "./websocket"; 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`); -} - -// Send message to specific user's connections -function sendToUser(userId: number, message: object) { - const connections = userConnections.get(userId); - if (connections) { - const data = JSON.stringify(message); - for (const ws of connections) { - ws.send(data); - } - } -} - -// Set up ytdlp progress callback -setProgressCallback((item) => { - sendToUser(item.userId, { - type: `fetch_${item.status === "downloading" ? "progress" : item.status}`, - id: item.id, - title: item.title, - status: item.status, - progress: item.progress, - queueType: item.queueType, - error: item.error - }); -}); - -// 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?.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, - ytdlp: getYtdlpStatus() - }); - } - - // 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 }); - } - if (name.trim().length > 64) { - return Response.json({ error: "Name must be 64 characters or less" }, { 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 empty playlist - tracks = []; - } - - 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 }); - } - - // Move connected clients to default channel before deleting - const defaultChannel = [...channels.values()].find(c => c.isDefault); - if (defaultChannel && channel.clients.size > 0) { - for (const ws of channel.clients) { - channel.removeClient(ws); - ws.data.channelId = defaultChannel.id; - defaultChannel.addClient(ws); - ws.send(JSON.stringify({ type: "switched", channelId: defaultChannel.id })); - } - } - - 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 }); - } - } - - // API: fetch from URL (yt-dlp) - if (path === "/api/fetch" && req.method === "POST") { - const { user, headers } = getOrCreateUser(req, server); - if (!user) { - return Response.json({ error: "Authentication required" }, { status: 401 }); - } - if (user.is_guest) { - return Response.json({ error: "Guests cannot fetch from URLs" }, { status: 403 }); - } - - // Check if feature is enabled - if (!ytdlpConfig.enabled) { - return Response.json({ error: "Feature disabled" }, { status: 403 }); - } - if (!isYtdlpAvailable()) { - return Response.json({ error: "yt-dlp not available" }, { status: 503 }); - } - - try { - const { url } = await req.json(); - if (!url || typeof url !== "string") { - return Response.json({ error: "URL is required" }, { status: 400 }); - } - - console.log(`[Fetch] ${user.username} checking URL: ${url}`); - - // Check URL to detect playlist vs single video - const info = await checkUrl(url); - - if (info.type === "playlist") { - if (!ytdlpConfig.allowPlaylists) { - return Response.json({ error: "Playlist downloads are disabled" }, { status: 403 }); - } - // Return playlist info for confirmation - return Response.json(info, { headers }); - } else { - // Single video - add to fast queue immediately - const item = addToFastQueue(info.url, info.title, user.id); - console.log(`[Fetch] ${user.username} queued: ${info.title} (id=${item.id})`); - return Response.json({ - type: "single", - id: item.id, - title: item.title, - queueType: "fast" - }, { headers }); - } - } catch (e: any) { - console.error("[Fetch] Error:", e); - return Response.json({ error: e.message || "Invalid request" }, { status: 400 }); - } - } - - // API: confirm playlist download - if (path === "/api/fetch/confirm" && req.method === "POST") { - const { user, headers } = getOrCreateUser(req, server); - if (!user) { - return Response.json({ error: "Authentication required" }, { status: 401 }); - } - if (user.is_guest) { - return Response.json({ error: "Guests cannot fetch from URLs" }, { status: 403 }); - } - if (!ytdlpConfig.enabled || !isYtdlpAvailable()) { - return Response.json({ error: "Feature not available" }, { status: 503 }); - } - - try { - const { items } = await req.json(); - if (!Array.isArray(items) || items.length === 0) { - return Response.json({ error: "Items required" }, { status: 400 }); - } - - const queueItems = addToSlowQueue(items, user.id); - const estimatedMinutes = Math.ceil(queueItems.length * ytdlpConfig.slowQueueInterval / 60); - const hours = Math.floor(estimatedMinutes / 60); - const mins = estimatedMinutes % 60; - const estimatedTime = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; - - console.log(`[Fetch] ${user.username} confirmed playlist: ${queueItems.length} items`); - - return Response.json({ - message: `Added ${queueItems.length} items to queue`, - queueType: "slow", - estimatedTime, - items: queueItems.map(i => ({ id: i.id, title: i.title })) - }, { headers }); - } catch (e) { - console.error("[Fetch] Confirm error:", e); - return Response.json({ error: "Invalid request" }, { status: 400 }); - } - } - - // API: get fetch queue status for current user - if (path === "/api/fetch" && req.method === "GET") { - const { user, headers } = getOrCreateUser(req, server); - if (!user) { - return Response.json({ error: "Authentication required" }, { status: 401 }); - } - - const queues = getUserQueues(user.id); - return Response.json(queues, { headers }); - } - - // 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) { - for (const perm of config.defaultPermissions) { - // 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?.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 {} - }, - }, + fetch: createRouter(), + websocket: websocketHandlers, }); console.log(`MusicRoom running on http://localhost:${config.port}`); diff --git a/state.ts b/state.ts new file mode 100644 index 0000000..4f82f8c --- /dev/null +++ b/state.ts @@ -0,0 +1,14 @@ +import type { ServerWebSocket } from "bun"; +import { Channel, type WsData } from "./channel"; +import { Library } from "./library"; + +// Shared application state +export const state = { + channels: new Map(), + userConnections: new Map>>(), + library: null as unknown as Library, +}; + +export function setLibrary(lib: Library) { + state.library = lib; +} diff --git a/websocket.ts b/websocket.ts new file mode 100644 index 0000000..d29d15c --- /dev/null +++ b/websocket.ts @@ -0,0 +1,102 @@ +import type { ServerWebSocket } from "bun"; +import type { WsData } from "./channel"; +import { hasPermission, findUserById } from "./db"; +import { config } from "./config"; +import { state } from "./state"; +import { broadcastChannelList } from "./broadcast"; + +export const websocketHandlers = { + open(ws: ServerWebSocket) { + const channel = state.channels.get(ws.data.channelId); + if (channel) { + channel.addClient(ws); + broadcastChannelList(); + } + // Track connection by user ID + const userId = ws.data.userId; + if (userId) { + if (!state.userConnections.has(userId)) { + state.userConnections.set(userId, new Set()); + } + state.userConnections.get(userId)!.add(ws); + } + }, + + close(ws: ServerWebSocket) { + const channel = state.channels.get(ws.data.channelId); + if (channel) { + channel.removeClient(ws); + broadcastChannelList(); + } + // Remove from user connections tracking + const userId = ws.data.userId; + if (userId && state.userConnections.has(userId)) { + state.userConnections.get(userId)!.delete(ws); + if (state.userConnections.get(userId)!.size === 0) { + state.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 = state.channels.get(ws.data.channelId); + const newChannel = state.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 = state.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?.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 {} + }, +};