blastoise/routes/channels.ts

278 lines
9.8 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, move, to } = body;
if (Array.isArray(set)) {
const tracks = buildTracksFromIds(set, state.library);
channel.setQueue(tracks);
return Response.json({ success: true, queueLength: channel.queue.length });
}
// Move/reorder tracks within queue
if (Array.isArray(move) && typeof to === "number") {
channel.moveTracks(move, to);
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 });
}
}