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