index.ts
⎇
Raw
1import staticPlugin from "@elysiajs/static";
2import { Elysia, type InferHandler, t } from "elysia";
3import path, { basename } from "node:path";
4import { parseArgs } from "node:util";
5import {
6 musicRoot,
7 fileTypeCache,
8 ServerError,
9 allowedTypes,
10 probeCache,
11 mediaTypes,
12 generatedPlaylistIds,
13} from "./shared";
14import {
15 getPathInfo,
16 listFiles,
17 findCover,
18 matchesType,
19 convertWithFFmpeg,
20 packWithTar,
21 probeFile,
22 toAvif,
23} from "./utils";
24import { stat } from "node:fs/promises";
25
26import parseRange from "range-parser";
27import { MediaFormat } from "music-server-shared/types";
28import { decodePath } from "music-server-shared/utils";
29import { stringifyMap } from "music-server-shared/mapconversion";
30import { randomUUIDv7 } from "bun";
31
32//TODO: transcoding cache?
33//TODO: add reasonable timeouts for caches
34//TODO: better ffmpeg errors
35//TODO: more cover detection
36
37const { values } = parseArgs({
38 args: Bun.argv,
39 options: {
40 serve: {
41 type: "string",
42 },
43 },
44 strict: true,
45 allowPositionals: true,
46});
47
48if (!values.serve) {
49 console.error("Missing --serve, set it to the directory containing the frontend code");
50 process.exit(1);
51}
52
53//increase timeout to not abort when listing huge folders
54const setup = new Elysia({ serve: { idleTimeout: 255 } });
55
56const paramType = t.ArrayString();
57type DownloadHandler = InferHandler<typeof setup, "/download/*", { params: typeof paramType }>;
58
59const downloadHandler: DownloadHandler = async ({ headers, params, set, request }) => {
60 if (request.method !== "GET" && request.method !== "HEAD") {
61 set.status = "Not Found";
62 return "NOT_FOUND";
63 }
64 const filePath = path.join(musicRoot, decodePath(params["*"]));
65 const fileScan = await getPathInfo(filePath);
66 if (!fileScan || fileScan instanceof ServerError) {
67 set.status = fileScan?.status || "Internal Server Error";
68 return fileScan?.error || "Not a file";
69 }
70 if (!matchesType(fileScan.mimeType, allowedTypes)) {
71 set.status = "Forbidden";
72 return "Forbidden file type";
73 }
74
75 set.status = "OK";
76 if (fileScan.mimeType === "audio/x-flac") fileScan.mimeType = "audio/flac"; //seems to be better supported
77 set.headers["content-type"] = fileScan.mimeType;
78
79 const statResult = await stat(filePath);
80 const fileSize = statResult.size;
81
82 if (request.method === "HEAD") {
83 set.headers["accept-ranges"] = "bytes";
84 set.headers["content-length"] = fileSize;
85 return;
86 }
87
88 if (!headers.range) {
89 set.status = "OK";
90 return Bun.file(filePath);
91 }
92
93 const rangeData = parseRange(fileSize, headers.range, { combine: true });
94 if (rangeData === -1 || rangeData === -2 || rangeData.length > 1) {
95 set.status = "Range Not Satisfiable";
96 return "Range Not Satisfiable";
97 }
98 const { start, end } = rangeData[0];
99 set.status = "Partial Content";
100 set.headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
101 // set.headers["content-length"] = end - start + 1; //currently broken in bun/elysiajs, overwritten in final response, seems to be fine though
102 // return createReadStream(filePath, { start, end });
103 return new Response(Bun.file(filePath).slice(start, end));
104};
105
106const app = setup
107 .use(staticPlugin({ assets: values.serve, prefix: "/" }))
108 .onBeforeHandle(({ request, path }) => {
109 if (path === "/remote-log") return;
110 console.info(request.method, path);
111 })
112 .post("/reset-cache", () => {
113 fileTypeCache.clear();
114 probeCache.clear();
115 })
116 .get("/download/*", downloadHandler)
117 .head("/download/*", downloadHandler)
118 .get(
119 "/transcode/*",
120 async ({ request, query, set, params }) => {
121 const filePath = path.join(musicRoot, decodePath(params["*"]));
122 const fileScan = await getPathInfo(filePath);
123 if (!fileScan || fileScan instanceof ServerError) {
124 set.status = fileScan?.status || "Internal Server Error";
125 return fileScan?.error || "Not a file";
126 }
127 if (!matchesType(fileScan.mimeType, allowedTypes)) {
128 set.status = "Forbidden";
129 return "Forbidden file type";
130 }
131 const probe = await probeFile(filePath);
132 //don't use higher bitrate than what the file has, use requested bitrate if unknown, default to 96kbit if not provided
133 const bitrate = query.bitrate && probe.bitrate ? Math.min(query.bitrate, probe.bitrate) : query.bitrate || 96000;
134 if (!matchesType(fileScan.mimeType, mediaTypes)) {
135 set.status = "Temporary Redirect";
136 set.headers.Location = `/download/${params["*"]}`;
137 return "Not a media file, redirecting to normal endpoint";
138 }
139
140 const { cmd, mimeType } = convertWithFFmpeg(filePath, bitrate, query.format ?? MediaFormat["opus/ogg"]);
141 request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
142 set.headers["content-type"] = mimeType;
143 if (query.disableChunkedTranscoding) {
144 const chunks: Uint8Array[] = [];
145 for await (const chunk of cmd.stdout) {
146 chunks.push(chunk);
147 }
148 const full = new Uint8Array(chunks.reduce((acc, val) => acc + val.length, 0));
149 let offset = 0;
150 for (const chunk of chunks) {
151 full.set(chunk, offset);
152 offset += chunk.length;
153 }
154 return full;
155 }
156 return new Response(cmd.stdout);
157 },
158 {
159 query: t.Optional(
160 t.Object({ bitrate: t.Number(), format: t.Enum(MediaFormat), disableChunkedTranscoding: t.Boolean() }),
161 ),
162 },
163 )
164 .get(
165 "/list/*",
166 async ({ params, set, query }) => {
167 const dirPath = path.join(musicRoot, decodePath(params["*"]));
168 const fileList = await listFiles(dirPath, query.recursive || false);
169 if (fileList instanceof ServerError) {
170 set.status = fileList.status;
171 return fileList.error;
172 }
173 set.headers["Content-Type"] = "application/json";
174 set.status = "OK";
175 return stringifyMap(fileList);
176 },
177 { query: t.Optional(t.Object({ recursive: t.Boolean() })) },
178 )
179 .get(
180 "/cover/*",
181 async ({ params, set, query }) => {
182 set.status = "Not Found";
183 const dirPath = path.join(musicRoot, decodePath(params["*"]));
184 const fileScanResult = await findCover(dirPath);
185 const setContentType = (type: string) => {
186 set.headers["Content-Type"] = type;
187 };
188 if ("path" in fileScanResult) {
189 set.headers["Content-Type"] = fileScanResult.info.mimeType;
190 set.status = "OK";
191 if (query.transcode)
192 return new Response(
193 await toAvif(await Bun.file(fileScanResult.path).bytes(), fileScanResult.info.mimeType, setContentType),
194 );
195 //wrapping in new response discards the accept-range header, which we don't support here
196 return new Response(Bun.file(fileScanResult.path));
197 }
198 if ("content" in fileScanResult) {
199 set.headers["Content-Type"] = fileScanResult.info.mimeType;
200 set.status = "OK";
201 if (query.transcode)
202 return new Response(await toAvif(fileScanResult.content, fileScanResult.info.mimeType, setContentType));
203 return new Response(fileScanResult.content);
204 }
205 set.status = fileScanResult.info.status;
206 return fileScanResult.info.error;
207 },
208 { query: t.Optional(t.Object({ transcode: t.Boolean() })) },
209 )
210 .post("/prepare-playlist", async ({ set, body }) => {
211 set.status = "OK";
212 set.headers["Content-Type"] = "text/plain";
213 const id = randomUUIDv7();
214 generatedPlaylistIds.set(id, JSON.parse(body as string) as string[]);
215 setTimeout(
216 () => {
217 generatedPlaylistIds.delete(id);
218 },
219 1000 * 60 * 60, // 1 hour
220 );
221 return id;
222 })
223 .get(
224 "/download-playlist/:id",
225 async ({ set, params }) => {
226 const playlist = generatedPlaylistIds.get(params.id);
227 if (!playlist) {
228 set.status = "Not Found";
229 return "Playlist ID not found";
230 }
231 set.status = "OK";
232 if (playlist.length === 1) {
233 set.headers["Content-Disposition"] = `attachment; filename="${basename(playlist[0])}"`;
234 return new Response(Bun.file(path.join(musicRoot, playlist[0])));
235 }
236 set.headers["Content-Type"] = "application/x-tar";
237 set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
238 return new Response(packWithTar(playlist).stdout);
239 },
240 { params: t.Object({ id: t.String({ minLength: 1 }) }) },
241 )
242 .post("remote-log", ({ body }) => {
243 console.log(body);
244 })
245
246 .listen(3000);
247
248console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);
249