ignoring shm and wal files
This commit is contained in:
parent
d3e5c24962
commit
c2e852f2cc
|
|
@ -38,3 +38,5 @@ library_cache.db
|
|||
musicroom.db
|
||||
blastoise.db
|
||||
config.json
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue