272 lines
9.6 KiB
TypeScript
272 lines
9.6 KiB
TypeScript
import { Channel, type Track } from "../channel";
|
|
import {
|
|
saveChannel,
|
|
deleteChannelFromDb,
|
|
saveChannelQueue,
|
|
updateChannelState,
|
|
updateChannelName,
|
|
} 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 });
|
|
}
|
|
|
|
// PATCH /api/channels/:id - rename channel
|
|
export async function handleRenameChannel(req: Request, server: any, channelId: string): 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 rename channels" }, { status: 403 });
|
|
}
|
|
const channel = state.channels.get(channelId);
|
|
if (!channel) {
|
|
return Response.json({ error: "Channel not found" }, { status: 404 });
|
|
}
|
|
try {
|
|
const { name } = 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 });
|
|
}
|
|
const newName = name.trim();
|
|
channel.name = newName;
|
|
updateChannelName(channelId, newName);
|
|
console.log(`[Channel] Renamed "${channelId}" to "${newName}" by user ${user.id}`);
|
|
broadcastChannelList();
|
|
return Response.json({ success: true, name: newName });
|
|
} catch {
|
|
return Response.json({ error: "Invalid request" }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
// 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, insertAt } = 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);
|
|
if (typeof insertAt === "number") {
|
|
channel.insertTracksAt(tracks, insertAt);
|
|
} else {
|
|
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 });
|
|
}
|
|
}
|