blastoise/routes/tracks.ts

120 lines
4.3 KiB
TypeScript

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