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