index.ts
⎇
Raw
1import path, { basename } from "node:path";
2import staticPlugin from "@elysiajs/static";
3import { randomUUIDv7 } from "bun";
4import { type Context, Elysia, StatusMap, t } from "elysia";
5import {
6 AudioCodec,
7 type IsVideoResponse,
8 MediaContainer,
9 type Metadata,
10 VideoCodec,
11 VideoEncodingSetting,
12} from "music-server-shared/types";
13import { decodePath } from "music-server-shared/utils";
14import { convertSubtitleWithFFmpeg, convertWithFFmpeg } from "./ffmpeg";
15import {
16 allowedTypes,
17 args,
18 authTokens,
19 fileTypeCache,
20 generatedPlaylistIds,
21 isValidAuthToken,
22 mediaTypes,
23 musicRoot,
24 type PathInfo,
25 password,
26 probeCache,
27 ServerError,
28 username,
29 videoExtrasCache,
30} from "./shared";
31import {
32 findCover,
33 getPathInfo,
34 getVideoExtras,
35 isBelow,
36 listFiles,
37 matchesType,
38 packWithTar,
39 probeFile,
40 readStream,
41 toAvif,
42} from "./utils";
43
44//TODO: transcoding cache?
45//TODO: better ffmpeg errors
46//TODO: more cover detection
47
48//increase timeout to not abort when listing huge folders
49const setup = new Elysia({ serve: { idleTimeout: 255 } });
50
51function resolveInRoot(encodedPath: string): string | ServerError {
52 const filePath = path.join(musicRoot, decodePath(encodedPath));
53 if (!isBelow(musicRoot, filePath)) return new ServerError(StatusMap.Forbidden, "Path outside the music root");
54 return filePath;
55}
56
57//for paths that arrive already decoded (playlist entries), where resolveInRoot's decodePath would
58//double-decode - a legitimate "%" in a filename would throw in decodeURIComponent
59function resolveRelativeInRoot(relPath: string): string | ServerError {
60 const filePath = path.join(musicRoot, relPath);
61 if (!isBelow(musicRoot, filePath)) return new ServerError(StatusMap.Forbidden, "Path outside the music root");
62 return filePath;
63}
64
65async function resolveMediaFile(encodedPath: string): Promise<{ filePath: string; info: PathInfo } | ServerError> {
66 const filePath = resolveInRoot(encodedPath);
67 if (filePath instanceof ServerError) return filePath;
68 const info = await getPathInfo(filePath);
69 //undefined means it is a directory rather than a file
70 if (!info || info instanceof ServerError)
71 return info ?? new ServerError(StatusMap["Not Found"], "Path is a directory, not a file");
72 if (!matchesType(info.mimeType, allowedTypes)) return new ServerError(StatusMap.Forbidden, "Forbidden file type");
73 return { filePath, info };
74}
75
76type FileHandlerContext = Context<{ params: { "*": string } }>;
77
78//re-encoding above the source bitrate only costs bandwidth, it cannot add back detail. an unknown source
79//bitrate leaves the request as it is - there is nothing to compare against
80function clampToSource(requested: number | undefined, source: number | undefined): number | undefined {
81 return requested && source ? Math.min(requested, source) : requested;
82}
83
84function transcodeFailed(set: FileHandlerContext["set"], exitCode: number | null, stderr: string) {
85 set.status = "Internal Server Error";
86 //the first lines are the root cause; what follows is each thread unwinding and reporting the same
87 //failure again, so a tail would report the least informative part of it
88 const reason = stderr.trim().split("\n").slice(0, 3).join("\n");
89 return `Transcoding failed (ffmpeg exit ${exitCode})${reason ? `:\n${reason}` : ""}`;
90}
91
92function reportUnexpectedFFmpegExit(label: string, exitCode: number | null, stderr: string): void {
93 if (exitCode === 0 || exitCode === null) return;
94 const signal = exitCode > 128 ? ` (signal ${exitCode - 128})` : "";
95 console.error(`${label} failed with status code ${exitCode}${signal}`);
96 const reason = stderr.trim();
97 if (reason) console.error(reason);
98}
99
100//Bun does not reliably propagate a disconnect from a streaming Response to request.signal on every
101//browser/runtime combination. The frontend sends an explicit cancellation beacon as a second path.
102const activeTranscodes = new Map<string, { stop: () => void }>();
103const cancelledTranscodes = new Set<string>();
104
105function markTranscodeCancelled(id: string): void {
106 cancelledTranscodes.add(id);
107 const timer = setTimeout(() => cancelledTranscodes.delete(id), 60_000);
108 timer.unref?.();
109}
110
111function consumeTranscodeCancellation(id: string | undefined): boolean {
112 return id !== undefined && cancelledTranscodes.delete(id);
113}
114
115const downloadHandler = async ({ params, set }: FileHandlerContext) => {
116 const resolved = await resolveMediaFile(params["*"]);
117 if (resolved instanceof ServerError) {
118 set.status = resolved.status;
119 return resolved.error;
120 }
121
122 set.status = "OK";
123 //audio/flac seems to be better supported than the x-flac the sniffer reports
124 set.headers["content-type"] = resolved.info.mimeType === "audio/x-flac" ? "audio/flac" : resolved.info.mimeType;
125 //returning the BunFile directly lets elysia serve range requests natively (Accept-Ranges/206/416)
126 return Bun.file(resolved.filePath);
127};
128
129//the PWA entry points must always be revalidated: a stale index.html references hashed assets that no
130//longer exist, and a stale sw.js pins an outdated precache manifest. everything with a content hash in
131//its name can be cached forever instead. other static files keep the plugin's default (1 day + etag).
132const noCachePaths = new Set(["/", "/index.html", "/sw.js", "/registerSW.js", "/manifest.webmanifest"]);
133const hashedAssetPattern = /-[A-Za-z0-9_-]{8,}\.(js|css)$/;
134
135const app = setup
136 .onAfterHandle({ as: "global" }, ({ path, set, responseValue }) => {
137 const cacheControl = noCachePaths.has(path)
138 ? "no-cache"
139 : hashedAssetPattern.test(path)
140 ? "public, max-age=31536000, immutable"
141 : undefined;
142 if (!cacheControl) return;
143 set.headers["cache-control"] = cacheControl;
144 //the static plugin already put its own cache-control on the Response, and set.headers
145 //alone does not override that, so patch the response headers directly as well
146 if (responseValue instanceof Response) responseValue.headers.set("cache-control", cacheControl);
147 })
148 .use(staticPlugin({ assets: args.serve, prefix: "/" }))
149 .onBeforeHandle(({ request, path }) => {
150 if (path === "/remote-log") return;
151 console.info(request.method, path);
152 })
153 .post(
154 "/login",
155 ({ body, set }) => {
156 const separator = body.indexOf(":");
157 const givenUser = separator === -1 ? body : body.slice(0, separator);
158 const givenPassword = separator === -1 ? "" : body.slice(separator + 1);
159 if (givenUser === username && givenPassword === password) {
160 set.status = 200;
161 const millisInYear = 365 * 24 * 60 * 60 * 1000;
162 const endDate = new Date(Date.now() + millisInYear);
163 const token = randomUUIDv7();
164 authTokens.set(token, endDate);
165 set.headers["set-cookie"] =
166 `authToken=${token}; Expires=${endDate.toUTCString()}; Secure; HttpOnly; SameSite=Strict`;
167 return "Logged in successfully";
168 }
169 set.status = 401;
170 return "Invalid username or password";
171 },
172 { body: t.String() },
173 )
174 .get(
175 "/auth/status",
176 ({ cookie: { authToken } }) => {
177 const authRequired = !!(username && password);
178 //when no AUTH is configured every route is open, so treat the user as logged in
179 const loggedIn = !authRequired || (!!authToken.value && isValidAuthToken(authToken.value));
180 return { authRequired, loggedIn };
181 },
182 { cookie: t.Cookie({ authToken: t.Optional(t.String()) }) },
183 )
184 .post(
185 "/logout",
186 ({ cookie: { authToken }, set }) => {
187 if (authToken.value) authTokens.delete(authToken.value); //invalidate the token server-side
188 //the cookie is HttpOnly, so only the server can clear it - expire it in the past
189 set.headers["set-cookie"] = `authToken=; Expires=${new Date(0).toUTCString()}; Secure; HttpOnly; SameSite=Strict`;
190 return "Logged out";
191 },
192 { cookie: t.Cookie({ authToken: t.Optional(t.String()) }) },
193 )
194 .guard(
195 {
196 cookie: t.Cookie({ authToken: t.Optional(t.String()) }),
197 beforeHandle({ cookie: { authToken }, set }) {
198 if (username && password && (!authToken.value || !isValidAuthToken(authToken.value))) {
199 set.status = 401;
200 return "Unauthorized";
201 }
202 },
203 },
204 (guarded) =>
205 guarded
206 .post("/reset-cache", () => {
207 fileTypeCache.clear();
208 probeCache.clear();
209 videoExtrasCache.clear();
210 })
211 .post(
212 "/cancel-transcode/:id",
213 ({ params }) => {
214 const active = activeTranscodes.get(params.id);
215 if (active) {
216 activeTranscodes.delete(params.id);
217 active.stop();
218 } else {
219 //The beacon can arrive while the transcode route is still probing the file, before
220 //there is a child process to register.
221 markTranscodeCancelled(params.id);
222 }
223 },
224 { params: t.Object({ id: t.String({ minLength: 1 }) }) },
225 )
226 .get("/download/*", downloadHandler)
227 .head("/download/*", downloadHandler)
228 .get(
229 "/transcode/*",
230 async ({ request, query, set, params }) => {
231 const resolved = await resolveMediaFile(params["*"]);
232 if (resolved instanceof ServerError) {
233 set.status = resolved.status;
234 return resolved.error;
235 }
236 const { filePath, info: fileScan } = resolved;
237 const probe = await probeFile(filePath);
238 //don't use higher bitrate than what the file has, use requested bitrate if unknown
239 const audioBitrate = clampToSource(query.audioBitrate, probe.audioBitrate);
240 const videoBitrate = clampToSource(query.videoBitrate, probe.videoBitrate);
241
242 if (!matchesType(fileScan.mimeType, mediaTypes)) {
243 set.status = "Temporary Redirect";
244 set.headers.Location = `/download/${params["*"]}`;
245 return "Not a media file, redirecting to normal endpoint";
246 }
247
248 if (query.videoCodec && query.videoCodec !== VideoCodec.none && !videoBitrate) {
249 set.status = "Bad Request";
250 return "videoBitrate is required when videoCodec is set";
251 }
252 if (request.signal.aborted || consumeTranscodeCancellation(query.transcodeId)) return new Uint8Array();
253 const { cmd, mimeType, stderrText } = await convertWithFFmpeg(
254 filePath,
255 audioBitrate,
256 videoBitrate || 0,
257 query.container,
258 query.audioCodec,
259 query.videoCodec || VideoCodec.none,
260 query.videoEncodingSetting || VideoEncodingSetting.balanced,
261 query.seekTo,
262 query.audioLanguagePreference || "",
263 probe,
264 );
265 if (consumeTranscodeCancellation(query.transcodeId)) {
266 cmd.kill("SIGKILL");
267 return new Uint8Array();
268 }
269 let stoppedByClient = false;
270 const stopForClient = () => {
271 if (stoppedByClient) return;
272 stoppedByClient = true;
273 console.info("ffmpeg cancelled by client", filePath);
274 cmd.kill("SIGKILL");
275 };
276 const activeTranscode = { stop: stopForClient };
277 if (query.transcodeId) activeTranscodes.set(query.transcodeId, activeTranscode);
278 const unregister = () => {
279 if (query.transcodeId && activeTranscodes.get(query.transcodeId) === activeTranscode)
280 activeTranscodes.delete(query.transcodeId);
281 };
282 request.signal.addEventListener("abort", stopForClient, { once: true });
283 if (request.signal.aborted) stopForClient();
284 void cmd.exited
285 .then(async (exitCode) => {
286 if (!stoppedByClient) reportUnexpectedFFmpegExit("ffmpeg", exitCode, await stderrText);
287 })
288 .finally(unregister);
289
290 if (query.disableChunkedTranscoding) {
291 const full = await readStream(cmd.stdout).catch((error) => {
292 if (stoppedByClient) return new Uint8Array();
293 throw error;
294 });
295 if (full.length === 0) {
296 const exitCode = await cmd.exited;
297 if (stoppedByClient) return full;
298 return transcodeFailed(set, exitCode, await stderrText);
299 }
300 set.headers["content-type"] = mimeType;
301 return full;
302 }
303
304 //peek ffmpeg to check for failure and return 500
305 const reader = cmd.stdout.getReader();
306 let first: ReadableStreamReadResult<Uint8Array>;
307 try {
308 first = await reader.read();
309 } catch (error) {
310 reader.releaseLock();
311 if (stoppedByClient) return new Uint8Array();
312 throw error;
313 }
314 if (first.done) {
315 reader.releaseLock();
316 const exitCode = await cmd.exited;
317 if (stoppedByClient) return new Uint8Array();
318 return transcodeFailed(set, exitCode, await stderrText);
319 }
320 set.headers["content-type"] = mimeType;
321 return new Response(
322 new ReadableStream<Uint8Array>({
323 start(controller) {
324 controller.enqueue(first.value);
325 },
326 async pull(controller) {
327 const { done, value } = await reader.read();
328 if (done) controller.close();
329 else controller.enqueue(value);
330 },
331 cancel(reason) {
332 stopForClient();
333 void reader.cancel(reason);
334 },
335 }),
336 );
337 },
338 {
339 query: t.Object({
340 seekTo: t.Optional(t.Number()),
341 transcodeId: t.Optional(t.String()),
342 audioLanguagePreference: t.Optional(t.String()),
343 disableChunkedTranscoding: t.Optional(t.Boolean()),
344 container: t.Enum(MediaContainer),
345 videoCodec: t.Optional(t.Enum(VideoCodec)),
346 videoBitrate: t.Optional(t.Number()),
347 videoEncodingSetting: t.Optional(t.Enum(VideoEncodingSetting)),
348 audioCodec: t.Enum(AudioCodec),
349 audioBitrate: t.Optional(t.Number()),
350 }),
351 },
352 )
353 .get(
354 "/list/*",
355 async ({ params, set, query }) => {
356 const dirPath = resolveInRoot(params["*"]);
357 if (dirPath instanceof ServerError) {
358 set.status = dirPath.status;
359 return dirPath.error;
360 }
361 const fileList = await listFiles(dirPath, query.recursive || false);
362 if (fileList instanceof ServerError) {
363 set.status = fileList.status;
364 return fileList.error;
365 }
366 set.status = "OK";
367 return fileList;
368 },
369 { query: t.Optional(t.Object({ recursive: t.Boolean() })) },
370 )
371 .get("/isVideo/*", async ({ params, set }) => {
372 const dirPath = resolveInRoot(params["*"]);
373 if (dirPath instanceof ServerError) {
374 set.status = dirPath.status;
375 return dirPath.error;
376 }
377 const probeData = await probeFile(dirPath).catch(() => ({}) as Metadata);
378 return { isVideo: probeData.videoCodec !== undefined } as IsVideoResponse;
379 })
380 .get("/video-info/*", async ({ params, set }) => {
381 const resolved = await resolveMediaFile(params["*"]);
382 if (resolved instanceof ServerError) {
383 set.status = resolved.status;
384 return resolved.error;
385 }
386 if (!matchesType(resolved.info.mimeType, mediaTypes)) {
387 set.status = "Bad Request";
388 return "Video extras are only available for media files";
389 }
390 return await getVideoExtras(resolved.filePath);
391 })
392 .get(
393 "/subtitles/*",
394 async ({ request, params, query, set }) => {
395 const resolved = await resolveMediaFile(params["*"]);
396 if (resolved instanceof ServerError) {
397 set.status = resolved.status;
398 return resolved.error;
399 }
400 if (!matchesType(resolved.info.mimeType, mediaTypes)) {
401 set.status = "Bad Request";
402 return "Subtitles are only available for media files";
403 }
404 if (!Number.isInteger(query.track) || query.track < 0) {
405 set.status = "Bad Request";
406 return "Invalid subtitle track";
407 }
408 const extras = await getVideoExtras(resolved.filePath);
409 const track = extras.subtitleTracks.find((candidate) => candidate.streamIndex === query.track);
410 if (!track) {
411 set.status = "Not Found";
412 return "Subtitle track not found or unsupported";
413 }
414
415 if (request.signal.aborted) return new Uint8Array();
416 const { cmd, mimeType, stderrText } = await convertSubtitleWithFFmpeg(resolved.filePath, track.streamIndex);
417 let stoppedByClient = false;
418 const stopForClient = () => {
419 if (stoppedByClient) return;
420 stoppedByClient = true;
421 console.info("ffmpeg subtitle conversion cancelled by client", resolved.filePath);
422 cmd.kill("SIGKILL");
423 };
424 request.signal.addEventListener("abort", stopForClient, { once: true });
425 if (request.signal.aborted) stopForClient();
426 void cmd.exited.then(async (exitCode) => {
427 if (!stoppedByClient)
428 reportUnexpectedFFmpegExit("ffmpeg subtitle conversion", exitCode, await stderrText);
429 });
430
431 const bytes = await readStream(cmd.stdout).catch((error) => {
432 if (stoppedByClient) return new Uint8Array();
433 throw error;
434 });
435 const exitCode = await cmd.exited;
436 if (exitCode !== 0 || bytes.length === 0) {
437 if (stoppedByClient) return bytes;
438 return transcodeFailed(set, exitCode, await stderrText);
439 }
440 set.headers["content-type"] = mimeType;
441 return bytes;
442 },
443 {
444 query: t.Object({ track: t.Number() }),
445 },
446 )
447 .get(
448 "/cover/*",
449 async ({ params, set, query }) => {
450 const dirPath = resolveInRoot(params["*"]);
451 if (dirPath instanceof ServerError) {
452 set.status = dirPath.status;
453 return dirPath.error;
454 }
455 const result = await findCover(dirPath);
456 if (!("bytes" in result)) {
457 set.status = result.info.status;
458 return result.info.error;
459 }
460 const setContentType = (type: string) => {
461 set.headers["Content-Type"] = type;
462 };
463 set.status = "OK";
464 if (query.transcode)
465 return new Response(await toAvif(await result.bytes(), result.info.mimeType, setContentType));
466 set.headers["Content-Type"] = result.info.mimeType;
467 return new Response(await result.bytes());
468 },
469 { query: t.Optional(t.Object({ transcode: t.Boolean() })) },
470 )
471 .post(
472 "/prepare-playlist",
473 async ({ set, body }) => {
474 set.status = "OK";
475 set.headers["Content-Type"] = "text/plain";
476 const id = randomUUIDv7();
477 generatedPlaylistIds.set(id, body);
478 setTimeout(
479 () => {
480 generatedPlaylistIds.delete(id);
481 },
482 1000 * 60 * 60, // 1 hour
483 );
484 return id;
485 },
486 { body: t.Array(t.String()) },
487 )
488 .get(
489 "/download-playlist/:id",
490 async ({ set, params }) => {
491 const playlist = generatedPlaylistIds.get(params.id);
492 if (!playlist) {
493 set.status = "Not Found";
494 return "Playlist ID not found";
495 }
496 const resolvedPaths: string[] = [];
497 for (const entry of playlist) {
498 const resolved = resolveRelativeInRoot(entry);
499 if (resolved instanceof ServerError) {
500 set.status = resolved.status;
501 return resolved.error;
502 }
503 resolvedPaths.push(resolved);
504 }
505 set.status = "OK";
506 if (resolvedPaths.length === 1) {
507 const safeName = basename(resolvedPaths[0]).replace(/["\\\r\n]/g, "_");
508 set.headers["Content-Disposition"] = `attachment; filename="${safeName}"`;
509 return new Response(Bun.file(resolvedPaths[0]));
510 }
511 set.headers["Content-Type"] = "application/x-tar";
512 set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
513 return new Response(packWithTar(playlist).stdout);
514 },
515 { params: t.Object({ id: t.String({ minLength: 1 }) }) },
516 )
517 .post("/remote-log", ({ body }) => {
518 console.log(body);
519 }),
520 )
521 .listen(3000);
522
523console.log(`🦊 Elysia is running at ${app.server?.protocol}://${app.server?.hostname}:${app.server?.port}`);
524