import staticPlugin from "@elysiajs/static"; import { Elysia, ElysiaFile, type InferHandler, t } from "elysia"; import path from "node:path"; import { parseArgs } from "node:util"; import { musicRoot, fileTypeCache, ServerError, allowedTypes, probeCache, mediaTypes } from "./shared"; import { getPathInfo, listFiles, findCover, matchesType, convertWithFFmpeg, packWithTar, probeFile } from "./utils"; import { stat } from "node:fs/promises"; import parseRange from "range-parser"; import { createReadStream, ReadStream } from "node:fs"; import { MediaFormat } from "music-server-shared/types"; import { stringifyMap } from "music-server-shared/mapconversion"; //TODO: transcoding cache? //TODO: add reasonable timeouts for caches //TODO: better ffmpeg errors const { values } = parseArgs({ args: Bun.argv, options: { serve: { type: "string", }, }, strict: true, allowPositionals: true, }); if (!values.serve) { console.error("Please define the serve option"); process.exit(1); } const setup = new Elysia(); 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, decodeURI(params["*"])); const fileScan = await getPathInfo(filePath); if (!fileScan || fileScan instanceof ServerError) { set.status = fileScan?.error ? "Internal Server Error" : "Bad Request"; 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, not present in final response //Bun.file().slice() should work, but currently only returns the chunk size as the total range, which confuses browsers //using a stream here causes "Transfer-Encoding: chunked" to be set, but seems fine in practice return createReadStream(filePath, { 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("/reset-cache", () => { fileTypeCache.clear(); probeCache.clear(); }) .get("/download/*", downloadHandler) .head("/download/*", downloadHandler) .get( "/transcode/*", async ({ request, query, set, params }) => { const filePath = path.join(musicRoot, decodeURI(params["*"])); const fileScan = await getPathInfo(filePath); if (!fileScan || fileScan instanceof ServerError) { set.status = fileScan?.error ? "Internal Server Error" : "Bad Request"; 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, default to 96kbit if not provided const bitrate = query.bitrate && probe.bitrate ? Math.min(query.bitrate, probe.bitrate) : query.bitrate || 96000; 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, bitrate, query.format ?? MediaFormat["opus/ogg"]); request.signal.addEventListener("abort", () => cmd.kill("SIGKILL")); set.headers["content-type"] = mimeType; return new Response(cmd.stdout); }, { query: t.Optional(t.Object({ bitrate: t.Number(), format: t.Enum(MediaFormat) })) }, ) .get( "/list/*", async ({ params, set, query }) => { const dirPath = path.join(musicRoot, decodeURI(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 }) => { set.status = "Not Found"; const dirPath = path.join(musicRoot, decodeURI(params["*"])); const fileScanResult = await findCover(dirPath); if ("path" in fileScanResult) { set.headers["Content-Type"] = fileScanResult.info.mimeType; set.status = "OK"; return new ElysiaFile(fileScanResult.path); } set.status = fileScanResult.info.status; return fileScanResult.info.error; }) .post( "/download-playlist", async ({ set, body: { files } }) => { set.status = "OK"; set.headers["Content-Type"] = "application/x-tar"; set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`; //needed to support stream download with complex body in request return new Response(packWithTar(JSON.parse(decodeURIComponent(atob(files))) as string[]).stdout); }, { body: t.Object({ files: 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}`);