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