blastoise-archive/server.ts

995 lines
35 KiB
TypeScript

import { file, serve, type ServerWebSocket } from "bun";
import { Channel, type Track, type WsData, type PersistenceCallback } from "./channel";
import { readdir } from "fs/promises";
import { join, resolve } from "path";
import {
createUser,
findUserByUsername,
validatePassword,
createSession,
createGuestSession,
deleteSession,
validateSession,
hasPermission,
getUserPermissions,
getAllUsers,
grantPermission,
revokePermission,
findUserById,
saveChannel,
updateChannelState,
loadAllChannels,
deleteChannelFromDb,
saveChannelQueue,
loadChannelQueue,
removeTrackFromQueues,
} from "./db";
import {
getUser,
requireUser,
requirePermission,
setSessionCookie,
clearSessionCookie,
getClientInfo,
} from "./auth";
import { Library } from "./library";
// Load config
interface Config {
port: number;
musicDir: string;
allowGuests: boolean;
defaultPermissions: {
channel?: string[];
};
}
const CONFIG_PATH = join(import.meta.dir, "config.json");
const config: Config = await file(CONFIG_PATH).json();
const MUSIC_DIR = resolve(import.meta.dir, config.musicDir);
const PUBLIC_DIR = join(import.meta.dir, "public");
console.log(`Config loaded: port=${config.port}, musicDir=${MUSIC_DIR}, allowGuests=${config.allowGuests}`);
// Initialize library
const library = new Library(MUSIC_DIR);
// Auto-discover tracks if queue is empty
async function discoverTracks(): Promise<string[]> {
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<string, Channel>();
// Track all WebSocket connections by user ID for kick functionality
const userConnections = new Map<number, Set<ServerWebSocket<WsData>>>();
// 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<void> {
// Scan library first
await library.scan();
library.startWatching();
// Broadcast when scan completes
library.onScanComplete(() => {
broadcastToAll({ type: "scan_progress", scanning: false });
});
// Broadcast when tracks are added/updated
library.on("added", (track) => {
broadcastToAll({ type: "toast", message: `Added: ${track.title || track.filename}`, toastType: "info" });
library.logActivity("scan_added", { id: track.id, filename: track.filename, title: track.title });
});
library.on("changed", (track) => {
broadcastToAll({ type: "toast", message: `Updated: ${track.title || track.filename}`, toastType: "info" });
library.logActivity("scan_updated", { id: track.id, filename: track.filename, title: track.title });
});
// Load channels from database
const savedChannels = loadAllChannels();
let hasDefault = false;
for (const row of savedChannels) {
// Load queue for this channel
const trackIds = loadChannelQueue(row.id);
const tracks = buildTracksFromIds(trackIds, library);
// For default channel, if queue is empty, use full library
const isDefault = row.is_default === 1;
if (isDefault) {
hasDefault = true;
}
const channelTracks = (isDefault && tracks.length === 0)
? getAllLibraryTracks(library)
: tracks;
const channel = new Channel({
id: row.id,
name: row.name,
description: row.description,
tracks: channelTracks,
createdBy: row.created_by,
isDefault,
currentIndex: row.current_index,
startedAt: row.started_at,
paused: row.paused === 1,
pausedAt: row.paused_at,
playbackMode: (row.playback_mode as "repeat-all" | "repeat-one" | "shuffle") || "repeat-all",
});
channel.setPersistenceCallback(persistChannel);
channels.set(row.id, channel);
console.log(`Loaded channel "${row.name}" (id=${row.id}) with ${channelTracks.length} tracks`);
}
// Create default channel if it doesn't exist
if (!hasDefault) {
const tracks = getAllLibraryTracks(library);
const defaultChannel = new Channel({
id: "main",
name: "Main Channel",
description: "All tracks from the library",
tracks,
isDefault: true,
createdBy: null,
});
defaultChannel.setPersistenceCallback(persistChannel);
channels.set("main", defaultChannel);
// Save to database
saveChannel({
id: defaultChannel.id,
name: defaultChannel.name,
description: defaultChannel.description,
createdBy: defaultChannel.createdBy,
isDefault: true,
currentIndex: defaultChannel.currentIndex,
startedAt: defaultChannel.startedAt,
paused: defaultChannel.paused,
pausedAt: defaultChannel.pausedAt,
playbackMode: defaultChannel.playbackMode,
});
saveChannelQueue(defaultChannel.id, tracks.map(t => t.id));
console.log(`Default channel created: ${tracks.length} tracks`);
}
}
await init();
// Broadcast to all connected clients across all channels
function broadcastToAll(message: object) {
const data = JSON.stringify(message);
let clientCount = 0;
for (const channel of channels.values()) {
for (const ws of channel.clients) {
ws.send(data);
clientCount++;
}
}
console.log(`[Broadcast] Sent to ${clientCount} clients`);
}
// Broadcast channel list to all clients
function broadcastChannelList() {
const list = [...channels.values()].map(c => c.getListInfo());
console.log(`[Broadcast] Sending channel_list to all clients (${list.length} channels)`, JSON.stringify(list.map(c => ({ id: c.id, listeners: c.listeners }))));
broadcastToAll({ type: "channel_list", channels: list });
}
// Listen for library changes and notify clients
library.on("added", (track) => {
console.log(`New track detected: ${track.title}`);
const allTracks = library.getAllTracks().map(t => ({
id: t.id,
title: t.title,
duration: t.duration
}));
broadcastToAll({
type: "track_added",
track: { id: track.id, title: track.title, duration: track.duration },
library: allTracks
});
});
library.on("removed", (track) => {
console.log(`Track removed: ${track.title}`);
// Remove from database queue entries
removeTrackFromQueues(track.id);
const allTracks = library.getAllTracks().map(t => ({
id: t.id,
title: t.title,
duration: t.duration
}));
broadcastToAll({
type: "track_removed",
track: { id: track.id, title: track.title },
library: allTracks
});
});
// Tick interval: advance tracks when needed, broadcast every 30s
let tickCount = 0;
setInterval(() => {
tickCount++;
for (const channel of channels.values()) {
const changed = channel.tick();
if (changed) {
console.log(`[Tick] Channel "${channel.name}" advanced to track ${channel.currentIndex}`);
}
if (!changed && tickCount % 30 === 0) {
console.log(`[Tick] Broadcasting state for channel "${channel.name}" (${channel.clients.size} clients)`);
channel.broadcast();
}
}
// Broadcast scan progress every 2 seconds while scanning
const scanProgress = library.scanProgress;
if (scanProgress.scanning && tickCount % 2 === 0) {
broadcastToAll({
type: "scan_progress",
scanning: true,
processed: scanProgress.processed,
total: scanProgress.total
});
} else if (!scanProgress.scanning && tickCount % 30 === 0) {
// Periodically send "not scanning" to clear any stale UI
broadcastToAll({ type: "scan_progress", scanning: false });
}
}, 1000);
// Helper to get or create guest session
function getOrCreateUser(req: Request, server: any): { user: ReturnType<typeof getUser>, 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<typeof getUser>, resourceType: string, resourceId: string | null, permission: string): boolean {
if (!user) return false;
if (user.is_admin) return true;
// Guests can never control playback
if (user.is_guest && permission === "control") return false;
// Check default permissions from config
if (resourceType === "channel" && config.defaultPermissions.channel?.includes(permission)) {
return true;
}
// Check user-specific permissions
return hasPermission(user.id, resourceType, resourceId, permission);
}
serve({
port: config.port,
async fetch(req, server) {
const url = new URL(req.url);
const path = url.pathname;
// WebSocket upgrade for channels
if (path.match(/^\/api\/channels\/([^/]+)\/ws$/)) {
const id = path.split("/")[3];
if (!channels.has(id)) return new Response("Channel not found", { status: 404 });
const { user } = getOrCreateUser(req, server);
const ok = server.upgrade(req, { data: { channelId: id, userId: user?.id ?? null, username: user?.username ?? 'Guest' } });
if (ok) return undefined;
return new Response("WebSocket upgrade failed", { status: 500 });
}
// API: server status (public)
if (path === "/api/status") {
return Response.json({
name: "MusicRoom",
version: "1.0.0",
allowGuests: config.allowGuests,
allowSignups: true,
channelCount: channels.size,
defaultPermissions: config.defaultPermissions,
});
}
// API: list channels (requires auth or guest)
if (path === "/api/channels" && req.method === "GET") {
const { user, headers } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const list = [...channels.values()].map(c => c.getListInfo());
return Response.json(list, { headers });
}
// API: create channel
if (path === "/api/channels" && req.method === "POST") {
const { user } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
if (user.is_guest) {
return Response.json({ error: "Guests cannot create channels" }, { status: 403 });
}
try {
const { name, description, trackIds } = await req.json();
if (!name || typeof name !== "string" || name.trim().length === 0) {
return Response.json({ error: "Name is required" }, { status: 400 });
}
// Build track list from trackIds or default to full library
let tracks: Track[];
if (trackIds && Array.isArray(trackIds) && trackIds.length > 0) {
tracks = [];
for (const tid of trackIds) {
const libTrack = library.getTrack(tid);
if (libTrack) {
tracks.push({
id: libTrack.id,
filename: libTrack.filename,
title: libTrack.title || libTrack.filename.replace(/\.[^.]+$/, ""),
duration: libTrack.duration,
});
}
}
} else {
// Default to full library
tracks = library.getAllTracks()
.filter(t => t.duration > 0)
.map(t => ({
id: t.id,
filename: t.filename,
title: t.title || t.filename.replace(/\.[^.]+$/, ""),
duration: t.duration,
}));
}
const channelId = generateChannelId();
const channel = new Channel({
id: channelId,
name: name.trim(),
description: description || "",
tracks,
createdBy: user.id,
isDefault: false,
});
channel.setPersistenceCallback(persistChannel);
channels.set(channelId, channel);
// Save to database
saveChannel({
id: channel.id,
name: channel.name,
description: channel.description,
createdBy: channel.createdBy,
isDefault: false,
currentIndex: channel.currentIndex,
startedAt: channel.startedAt,
paused: channel.paused,
pausedAt: channel.pausedAt,
playbackMode: channel.playbackMode,
});
saveChannelQueue(channel.id, tracks.map(t => t.id));
console.log(`[Channel] Created "${name.trim()}" (id=${channelId}) by user ${user.id}`);
broadcastChannelList();
return Response.json(channel.getListInfo(), { status: 201 });
} catch {
return Response.json({ error: "Invalid request" }, { status: 400 });
}
}
// API: delete channel
const channelDeleteMatch = path.match(/^\/api\/channels\/([^/]+)$/);
if (channelDeleteMatch && req.method === "DELETE") {
const channelId = channelDeleteMatch[1];
const { user } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const channel = channels.get(channelId);
if (!channel) {
return Response.json({ error: "Channel not found" }, { status: 404 });
}
if (channel.isDefault) {
return Response.json({ error: "Cannot delete default channel" }, { status: 403 });
}
if (!user.is_admin && channel.createdBy !== user.id) {
return Response.json({ error: "Access denied" }, { status: 403 });
}
channels.delete(channelId);
deleteChannelFromDb(channelId);
broadcastChannelList();
return Response.json({ success: true });
}
// API: get channel state
const channelMatch = path.match(/^\/api\/channels\/([^/]+)$/);
if (channelMatch && req.method === "GET") {
const channel = channels.get(channelMatch[1]);
if (!channel) return new Response("Not found", { status: 404 });
return Response.json(channel.getState());
}
// API: get library (all tracks)
if (path === "/api/library") {
const { user, headers } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
const tracks = library.getAllTracks().map(t => ({
id: t.id,
filename: t.filename,
title: t.title,
artist: t.artist,
album: t.album,
duration: t.duration,
available: t.available,
}));
return Response.json(tracks, { headers });
}
// API: upload audio file
if (path === "/api/upload" && req.method === "POST") {
const { user, headers } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ error: "Authentication required" }, { status: 401 });
}
try {
const formData = await req.formData();
const uploadedFile = formData.get("file");
if (!uploadedFile || !(uploadedFile instanceof File)) {
library.logActivity("upload_failed", { filename: "unknown" }, { id: user.id, username: user.username });
return Response.json({ error: "No file provided" }, { status: 400 });
}
// Validate extension
const validExts = [".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"];
const ext = uploadedFile.name.toLowerCase().match(/\.[^.]+$/)?.[0];
if (!ext || !validExts.includes(ext)) {
library.logActivity("upload_rejected", { filename: uploadedFile.name }, { id: user.id, username: user.username });
return Response.json({ error: "Invalid audio format" }, { status: 400 });
}
// Sanitize filename
const safeName = uploadedFile.name.replace(/[^a-zA-Z0-9._-]/g, "_");
const destPath = join(config.musicDir, safeName);
// Check if file already exists
const existingFile = Bun.file(destPath);
if (await existingFile.exists()) {
library.logActivity("upload_duplicate", { filename: safeName }, { id: user.id, username: user.username });
return Response.json({ error: "File already exists" }, { status: 409 });
}
// Write file
const arrayBuffer = await uploadedFile.arrayBuffer();
await Bun.write(destPath, arrayBuffer);
console.log(`[Upload] ${user.username} uploaded: ${safeName}`);
library.logActivity("upload", { filename: safeName }, { id: user.id, username: user.username });
return Response.json({ success: true, filename: safeName }, { headers });
} catch (e) {
console.error("[Upload] Error:", e);
library.logActivity("upload_error", { filename: "unknown" }, { id: user.id, username: user.username });
return Response.json({ error: "Upload failed" }, { status: 500 });
}
}
// Auth: signup
if (path === "/api/auth/signup" && req.method === "POST") {
try {
const { username, password } = await req.json();
if (!username || !password) {
return Response.json({ error: "Username and password required" }, { status: 400 });
}
if (username.length < 3 || password.length < 6) {
return Response.json({ error: "Username min 3 chars, password min 6 chars" }, { status: 400 });
}
const existing = findUserByUsername(username);
if (existing) {
return Response.json({ error: "Username already taken" }, { status: 400 });
}
const user = await createUser(username, password);
const userAgent = req.headers.get("user-agent") ?? undefined;
const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
?? req.headers.get("x-real-ip")
?? server.requestIP(req)?.address
?? undefined;
const token = createSession(user.id, userAgent, ipAddress);
console.log(`[AUTH] Signup: user="${username}" id=${user.id} admin=${user.is_admin} session=${token} ip=${ipAddress} ua="${userAgent?.slice(0, 50)}..."`);
library.logActivity("account_created", { title: user.is_admin ? "admin" : "user" }, { id: user.id, username: user.username });
return Response.json(
{ user: { id: user.id, username: user.username, isAdmin: user.is_admin } },
{ headers: { "Set-Cookie": setSessionCookie(token) } }
);
} catch (e) {
return Response.json({ error: "Signup failed" }, { status: 500 });
}
}
// Auth: login
if (path === "/api/auth/login" && req.method === "POST") {
try {
const { username, password } = await req.json();
const user = findUserByUsername(username);
if (!user || !(await validatePassword(user, password))) {
console.log(`[AUTH] Login failed: user="${username}"`);
return Response.json({ error: "Invalid username or password" }, { status: 401 });
}
const userAgent = req.headers.get("user-agent") ?? undefined;
const ipAddress = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
?? req.headers.get("x-real-ip")
?? server.requestIP(req)?.address
?? undefined;
const token = createSession(user.id, userAgent, ipAddress);
console.log(`[AUTH] Login: user="${username}" id=${user.id} session=${token} ip=${ipAddress} ua="${userAgent?.slice(0, 50)}..."`);
return Response.json(
{ user: { id: user.id, username: user.username, isAdmin: user.is_admin } },
{ headers: { "Set-Cookie": setSessionCookie(token) } }
);
} catch (e) {
return Response.json({ error: "Login failed" }, { status: 500 });
}
}
// Auth: logout
if (path === "/api/auth/logout" && req.method === "POST") {
const token = req.headers.get("cookie")?.match(/musicroom_session=([^;]+)/)?.[1];
if (token) {
const user = validateSession(token);
console.log(`[AUTH] Logout: user="${user?.username ?? "unknown"}" session=${token}`);
deleteSession(token);
}
return Response.json(
{ success: true },
{ headers: { "Set-Cookie": clearSessionCookie() } }
);
}
// Auth: get current user
if (path === "/api/auth/me") {
const { user, headers } = getOrCreateUser(req, server);
if (!user) {
return Response.json({ user: null });
}
const permissions = getUserPermissions(user.id);
// Add default permissions for all users (except control for guests)
const effectivePermissions = [...permissions];
if (config.defaultPermissions.channel) {
for (const perm of config.defaultPermissions.channel) {
// Guests can never have control permission
if (user.is_guest && perm === "control") continue;
effectivePermissions.push({
id: 0,
user_id: user.id,
resource_type: "channel",
resource_id: null,
permission: perm,
});
}
}
return Response.json({
user: { id: user.id, username: user.username, isAdmin: user.is_admin, isGuest: user.is_guest },
permissions: effectivePermissions,
}, { headers });
}
// 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 = ["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<WsData>) {
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<WsData>) {
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<WsData>, message: string | Buffer) {
try {
const data = JSON.parse(String(message));
// Handle channel switching
if (data.action === "switch" && data.channelId) {
const oldChannel = channels.get(ws.data.channelId);
const newChannel = channels.get(data.channelId);
if (!newChannel) {
ws.send(JSON.stringify({ type: "error", message: "Channel not found" }));
return;
}
if (oldChannel) oldChannel.removeClient(ws);
ws.data.channelId = data.channelId;
newChannel.addClient(ws);
ws.send(JSON.stringify({ type: "switched", channelId: data.channelId }));
broadcastChannelList();
return;
}
const channel = channels.get(ws.data.channelId);
if (!channel) {
console.log("[WS] No channel found for:", ws.data.channelId);
return;
}
// Check permission for control actions
const userId = ws.data.userId;
if (!userId) {
console.log("[WS] No userId on connection");
return;
}
const user = findUserById(userId);
if (!user) {
console.log("[WS] User not found:", userId);
return;
}
// Guests can never control playback
if (user.is_guest) {
console.log("[WS] Guest cannot control playback");
return;
}
// Check default permissions or user-specific permissions
const canControl = user.is_admin
|| config.defaultPermissions.channel?.includes("control")
|| hasPermission(userId, "channel", ws.data.channelId, "control");
if (!canControl) {
console.log("[WS] User lacks control permission:", user.username);
return;
}
console.log("[WS] Control action:", data.action, "from", user.username);
if (data.action === "pause") channel.pause();
else if (data.action === "unpause") channel.unpause();
else if (data.action === "seek" && typeof data.timestamp === "number") channel.seek(data.timestamp);
else if (data.action === "jump" && typeof data.index === "number") channel.jumpTo(data.index);
} catch {}
},
},
});
console.log(`MusicRoom running on http://localhost:${config.port}`);