import staticPlugin from "@elysiajs/static"; import { Elysia, type InferHandler, t } from "elysia"; import path, { basename } from "node:path"; import { parseArgs } from "node:util"; import { musicRoot, fileTypeCache, ServerError, allowedTypes, probeCache, mediaTypes, generatedPlaylistIds, username, password, authTokens, } from "./shared"; import { getPathInfo, listFiles, findCover, matchesType, convertWithFFmpeg, packWithTar, probeFile, toAvif, } from "./utils"; import { stat } from "node:fs/promises"; import parseRange from "range-parser"; import { decodePath } from "music-server-shared/utils"; import { stringifyMap } from "music-server-shared/mapconversion"; import { randomUUIDv7 } from "bun"; import { AudioCodec, MediaContainer, VideoCodec } from "music-server-shared/types"; //TODO: transcoding cache? //TODO: add reasonable timeouts for caches //TODO: better ffmpeg errors //TODO: more cover detection const { values } = parseArgs({ args: Bun.argv, options: { serve: { type: "string", }, }, strict: true, allowPositionals: true, }); if (!values.serve) { console.error("Missing --serve, set it to the directory containing the frontend code"); process.exit(1); } //increase timeout to not abort when listing huge folders const setup = new Elysia({ serve: { idleTimeout: 255 } }); const paramType = t.ArrayString(); type DownloadHandler = InferHandler; const downloadHandler: DownloadHandler = async ({ headers, params, set, request }) => { if (request.method !== "GET" && request.method !== "HEAD") { set.status = "Not Found"; return "NOT_FOUND"; } const filePath = path.join(musicRoot, decodePath(params["*"])); const fileScan = await getPathInfo(filePath); if (!fileScan || fileScan instanceof ServerError) { set.status = fileScan?.status || "Internal Server Error"; return fileScan?.error || "Not a file"; } if (!matchesType(fileScan.mimeType, allowedTypes)) { set.status = "Forbidden"; return "Forbidden file type"; } set.status = "OK"; if (fileScan.mimeType === "audio/x-flac") fileScan.mimeType = "audio/flac"; //seems to be better supported set.headers["content-type"] = fileScan.mimeType; const statResult = await stat(filePath); const fileSize = statResult.size; if (request.method === "HEAD") { set.headers["accept-ranges"] = "bytes"; set.headers["content-length"] = fileSize; return; } if (!headers.range) { set.status = "OK"; return Bun.file(filePath); } const rangeData = parseRange(fileSize, headers.range, { combine: true }); if (rangeData === -1 || rangeData === -2 || rangeData.length > 1) { set.status = "Range Not Satisfiable"; return "Range Not Satisfiable"; } const { start, end } = rangeData[0]; set.status = "Partial Content"; set.headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`; // set.headers["content-length"] = end - start + 1; //currently broken in bun/elysiajs, overwritten in final response, seems to be fine though // return createReadStream(filePath, { start, end }); return new Response(Bun.file(filePath).slice(start, end)); }; const app = setup .use(staticPlugin({ assets: values.serve, prefix: "/" })) .onBeforeHandle(({ request, path }) => { if (path === "/remote-log") return; console.info(request.method, path); }) .post( "/login", ({ body, set }) => { const [givenUser, givenPassword] = (body as string).split(":", 2); if (givenUser === username && givenPassword === password) { set.status = 200; const millisInYear = 365 * 24 * 60 * 60 * 1000; const endDate = new Date(Date.now() + millisInYear); const token = randomUUIDv7(); authTokens.set(token, endDate); set.headers["set-cookie"] = `authToken=${token}; Expires=${endDate.toUTCString()}; Secure; HttpOnly; SameSite=Strict"`; return "Logged in successfully"; } set.status = 401; return "Invalid username or password"; }, { body: t.String() }, ) .guard( { beforeHandle({ cookie: { authToken }, set }) { if (username && password && (!authToken.value || !authTokens.has(authToken.value))) { set.status = 401; set.headers.location; return "Unauthorized"; } }, }, (guarded) => guarded .post("/reset-cache", () => { fileTypeCache.clear(); probeCache.clear(); }) .get("/download/*", downloadHandler) .head("/download/*", downloadHandler) .get( "/transcode/*", async ({ request, query, set, params }) => { const filePath = path.join(musicRoot, decodePath(params["*"])); const fileScan = await getPathInfo(filePath); if (!fileScan || fileScan instanceof ServerError) { set.status = fileScan?.status || "Internal Server Error"; return fileScan?.error || "Not a file"; } if (!matchesType(fileScan.mimeType, allowedTypes)) { set.status = "Forbidden"; return "Forbidden file type"; } const probe = await probeFile(filePath); //don't use higher bitrate than what the file has, use requested bitrate if unknown const audioBitrate = probe.audioBitrate ? Math.min(query.audioBitrate, probe.audioBitrate) : query.audioBitrate; const videoBitrate = probe.videoBitrate ? Math.min(query.videoBitrate, probe.videoBitrate) : query.videoBitrate; if (!matchesType(fileScan.mimeType, mediaTypes)) { set.status = "Temporary Redirect"; set.headers.Location = `/download/${params["*"]}`; return "Not a media file, redirecting to normal endpoint"; } const { cmd, mimeType } = convertWithFFmpeg( filePath, audioBitrate, videoBitrate, query.container, query.audioCodec, query.videoCodec, query.seekTo, ); request.signal.addEventListener("abort", () => cmd.kill("SIGKILL")); set.headers["content-type"] = mimeType; if (query.disableChunkedTranscoding) { const chunks: Uint8Array[] = []; for await (const chunk of cmd.stdout) { chunks.push(chunk); } const full = new Uint8Array(chunks.reduce((acc, val) => acc + val.length, 0)); let offset = 0; for (const chunk of chunks) { full.set(chunk, offset); offset += chunk.length; } return full; } return new Response(cmd.stdout); }, { query: t.Object({ audioCodec: t.Enum(AudioCodec), audioBitrate: t.Number(), videoCodec: t.Enum(VideoCodec), videoBitrate: t.Number(), container: t.Enum(MediaContainer), disableChunkedTranscoding: t.Optional(t.Boolean()), seekTo: t.Optional(t.Number()), }), }, ) .get( "/list/*", async ({ params, set, query }) => { const dirPath = path.join(musicRoot, decodePath(params["*"])); const fileList = await listFiles(dirPath, query.recursive || false); if (fileList instanceof ServerError) { set.status = fileList.status; return fileList.error; } set.headers["Content-Type"] = "application/json"; set.status = "OK"; return stringifyMap(fileList); }, { query: t.Optional(t.Object({ recursive: t.Boolean() })) }, ) .get( "/cover/*", async ({ params, set, query }) => { set.status = "Not Found"; const dirPath = path.join(musicRoot, decodePath(params["*"])); const fileScanResult = await findCover(dirPath); const setContentType = (type: string) => { set.headers["Content-Type"] = type; }; if ("path" in fileScanResult) { set.headers["Content-Type"] = fileScanResult.info.mimeType; set.status = "OK"; if (query.transcode) return new Response( await toAvif( await Bun.file(fileScanResult.path).bytes(), fileScanResult.info.mimeType, setContentType, ), ); //wrapping in new response discards the accept-range header, which we don't support here return new Response(Bun.file(fileScanResult.path)); } if ("content" in fileScanResult) { set.headers["Content-Type"] = fileScanResult.info.mimeType; set.status = "OK"; if (query.transcode) return new Response(await toAvif(fileScanResult.content, fileScanResult.info.mimeType, setContentType)); return new Response(fileScanResult.content); } set.status = fileScanResult.info.status; return fileScanResult.info.error; }, { query: t.Optional(t.Object({ transcode: t.Boolean() })) }, ) .post("/prepare-playlist", async ({ set, body }) => { set.status = "OK"; set.headers["Content-Type"] = "text/plain"; const id = randomUUIDv7(); generatedPlaylistIds.set(id, JSON.parse(body as string) as string[]); setTimeout( () => { generatedPlaylistIds.delete(id); }, 1000 * 60 * 60, // 1 hour ); return id; }) .get( "/download-playlist/:id", async ({ set, params }) => { const playlist = generatedPlaylistIds.get(params.id); if (!playlist) { set.status = "Not Found"; return "Playlist ID not found"; } set.status = "OK"; if (playlist.length === 1) { set.headers["Content-Disposition"] = `attachment; filename="${basename(playlist[0])}"`; return new Response(Bun.file(path.join(musicRoot, playlist[0]))); } set.headers["Content-Type"] = "application/x-tar"; set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`; return new Response(packWithTar(playlist).stdout); }, { params: t.Object({ id: t.String({ minLength: 1 }) }) }, ) .post("remote-log", ({ body }) => { console.log(body); }), ) .listen(3000); console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);