index.ts
⎇
Raw
1import staticPlugin from "@elysiajs/static";
2import { Elysia, ElysiaFile, type InferHandler, t } from "elysia";
3import path from "node:path";
4import { parseArgs } from "node:util";
5import { musicRoot, fileTypeCache, ServerError, allowedTypes, probeCache, mediaTypes } from "./shared";
6import { getPathInfo, listFiles, findCover, matchesType, convertWithFFmpeg, packWithTar, probeFile } from "./utils";
7import { stat } from "node:fs/promises";
8
9import parseRange from "range-parser";
10import { createReadStream, ReadStream } from "node:fs";
11import { MediaFormat } from "music-server-shared/types";
12import { stringifyMap } from "music-server-shared/mapconversion";
13
14//TODO: transcoding cache?
15//TODO: add reasonable timeouts for caches
16//TODO: better ffmpeg errors
17
18const { values } = parseArgs({
19 args: Bun.argv,
20 options: {
21 serve: {
22 type: "string",
23 },
24 },
25 strict: true,
26 allowPositionals: true,
27});
28
29if (!values.serve) {
30 console.error("Please define the serve option");
31 process.exit(1);
32}
33
34const setup = new Elysia();
35
36const paramType = t.ArrayString();
37type DownloadHandler = InferHandler<typeof setup, "/download/*", { params: typeof paramType }>;
38
39const downloadHandler: DownloadHandler = async ({ headers, params, set, request }) => {
40 if (request.method !== "GET" && request.method !== "HEAD") {
41 set.status = "Not Found";
42 return "NOT_FOUND";
43 }
44 const filePath = path.join(musicRoot, decodeURI(params["*"]));
45 const fileScan = await getPathInfo(filePath);
46 if (!fileScan || fileScan instanceof ServerError) {
47 set.status = fileScan?.error ? "Internal Server Error" : "Bad Request";
48 return fileScan?.error || "Not a file";
49 }
50 if (!matchesType(fileScan.mimeType, allowedTypes)) {
51 set.status = "Forbidden";
52 return "Forbidden file type";
53 }
54
55 set.status = "OK";
56 if (fileScan.mimeType === "audio/x-flac") fileScan.mimeType = "audio/flac"; //seems to be better supported
57 set.headers["content-type"] = fileScan.mimeType;
58
59 const statResult = await stat(filePath);
60 const fileSize = statResult.size;
61
62 if (request.method === "HEAD") {
63 set.headers["accept-ranges"] = "bytes";
64 set.headers["content-length"] = fileSize;
65 return;
66 }
67
68 if (!headers.range) {
69 set.status = "OK";
70 return Bun.file(filePath);
71 }
72
73 const rangeData = parseRange(fileSize, headers.range, { combine: true });
74 if (rangeData === -1 || rangeData === -2 || rangeData.length > 1) {
75 set.status = "Range Not Satisfiable";
76 return "Range Not Satisfiable";
77 }
78 const { start, end } = rangeData[0];
79 set.status = "Partial Content";
80 set.headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
81 // set.headers["Content-Length"] = end-start+1; //currently broken in bun/elysiajs, not present in final response
82 //Bun.file().slice() should work, but currently only returns the chunk size as the total range, which confuses browsers
83 //using a stream here causes "Transfer-Encoding: chunked" to be set, but seems fine in practice
84 return createReadStream(filePath, { start, end });
85};
86
87const app = setup
88 .use(staticPlugin({ assets: values.serve, prefix: "/" }))
89 .onBeforeHandle(({ request, path }) => {
90 if (path === "/remote-log") return;
91 console.info(request.method, path);
92 })
93 .post("/reset-cache", () => {
94 fileTypeCache.clear();
95 probeCache.clear();
96 })
97 .get("/download/*", downloadHandler)
98 .head("/download/*", downloadHandler)
99 .get(
100 "/transcode/*",
101 async ({ request, query, set, params }) => {
102 const filePath = path.join(musicRoot, decodeURI(params["*"]));
103 const fileScan = await getPathInfo(filePath);
104 if (!fileScan || fileScan instanceof ServerError) {
105 set.status = fileScan?.error ? "Internal Server Error" : "Bad Request";
106 return fileScan?.error || "Not a file";
107 }
108 if (!matchesType(fileScan.mimeType, allowedTypes)) {
109 set.status = "Forbidden";
110 return "Forbidden file type";
111 }
112 const probe = await probeFile(filePath);
113 //don't use higher bitrate than what the file has, use requested bitrate if unknown, default to 96kbit if not provided
114 const bitrate = query.bitrate && probe.bitrate ? Math.min(query.bitrate, probe.bitrate) : query.bitrate || 96000;
115 if (!matchesType(fileScan.mimeType, mediaTypes)) {
116 set.status = "Temporary Redirect";
117 set.headers.Location = `/download/${params["*"]}`;
118 return "Not a media file, redirecting to normal endpoint";
119 }
120
121 const { cmd, mimeType } = convertWithFFmpeg(filePath, bitrate, query.format ?? MediaFormat["opus/ogg"]);
122 request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
123 set.headers["content-type"] = mimeType;
124 if (query.disableChunkedTranscoding) {
125 console.log("Here")
126 const chunks: Uint8Array[] = [];
127 for await (const chunk of cmd.stdout) {
128 chunks.push(chunk);
129 }
130 const full = new Uint8Array(chunks.reduce((acc, val) => acc + val.length, 0));
131 let offset = 0;
132 for (const chunk of chunks) {
133 full.set(chunk, offset);
134 offset += chunk.length;
135 }
136 return full;
137 }
138 return cmd.stdout;
139 },
140 {
141 query: t.Optional(
142 t.Object({ bitrate: t.Number(), format: t.Enum(MediaFormat), disableChunkedTranscoding: t.Boolean() }),
143 ),
144 },
145 )
146 .get(
147 "/list/*",
148 async ({ params, set, query }) => {
149 const dirPath = path.join(musicRoot, decodeURI(params["*"]));
150 const fileList = await listFiles(dirPath, query.recursive || false);
151 if (fileList instanceof ServerError) {
152 set.status = fileList.status;
153 return fileList.error;
154 }
155 set.headers["Content-Type"] = "application/json";
156 set.status = "OK";
157 return stringifyMap(fileList);
158 },
159 { query: t.Optional(t.Object({ recursive: t.Boolean() })) },
160 )
161 .get("/cover/*", async ({ params, set }) => {
162 set.status = "Not Found";
163 const dirPath = path.join(musicRoot, decodeURI(params["*"]));
164 const fileScanResult = await findCover(dirPath);
165 if ("path" in fileScanResult) {
166 set.headers["Content-Type"] = fileScanResult.info.mimeType;
167 set.status = "OK";
168 return new ElysiaFile(fileScanResult.path);
169 }
170 set.status = fileScanResult.info.status;
171 return fileScanResult.info.error;
172 })
173 .post(
174 "/download-playlist",
175 async ({ set, body: { files } }) => {
176 set.status = "OK";
177 set.headers["Content-Type"] = "application/x-tar";
178 set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
179 //needed to support stream download with complex body in request
180 return new Response(packWithTar(JSON.parse(decodeURIComponent(atob(files))) as string[]).stdout);
181 },
182 { body: t.Object({ files: t.String({ minLength: 1 }) }) },
183 )
184 .post("remote-log", ({ body }) => {
185 console.log(body);
186 })
187
188 .listen(3000);
189
190console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);
191