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