1192 lines
41 KiB
TypeScript
1192 lines
41 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";
|
|
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<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`);
|
|
}
|
|
|
|
// 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<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?.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<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?.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}`);
|