ignoring shm and wal files

This commit is contained in:
peterino2 2026-02-05 22:57:04 -08:00
parent d3e5c24962
commit c2e852f2cc
15 changed files with 1356 additions and 1184 deletions

2
.gitignore vendored
View File

@ -38,3 +38,5 @@ library_cache.db
musicroom.db
blastoise.db
config.json
*.db-shm
*.db-wal

34
broadcast.ts Normal file
View File

@ -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 });
}

58
config.ts Normal file
View File

@ -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}`);

254
init.ts Normal file
View File

@ -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<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
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<void> {
// 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);
}

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NeoRose</title>
<title>Blastoise! A very special music server</title>
<link rel="stylesheet" href="styles.css?v=20">
</head>
<body>

179
routes/auth.ts Normal file
View File

@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
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 });
}
}

234
routes/channels.ts Normal file
View File

@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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 });
}
}

109
routes/fetch.ts Normal file
View File

@ -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<Response> {
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<Response> {
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 });
}

39
routes/helpers.ts Normal file
View File

@ -0,0 +1,39 @@
import { hasPermission, createGuestSession } from "../db";
import { getUser, setSessionCookie, getClientInfo } from "../auth";
import { config } from "../config";
type User = ReturnType<typeof getUser>;
// 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);
}

175
routes/index.ts Normal file
View File

@ -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<Response | undefined> {
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 });
};
}

29
routes/static.ts Normal file
View File

@ -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<Response | null> {
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;
}

119
routes/tracks.ts Normal file
View File

@ -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<Response> {
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<Response> {
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",
},
});
}

1190
server.ts

File diff suppressed because it is too large Load Diff

14
state.ts Normal file
View File

@ -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<string, Channel>(),
userConnections: new Map<number, Set<ServerWebSocket<WsData>>>(),
library: null as unknown as Library,
};
export function setLibrary(lib: Library) {
state.library = lib;
}

102
websocket.ts Normal file
View File

@ -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<WsData>) {
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<WsData>) {
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<WsData>, 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 {}
},
};