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