import { realpath } from "node:fs/promises"; import path, { basename } from "node:path"; import staticPlugin from "@elysiajs/static"; import { randomUUIDv7 } from "bun"; import { type Context, Elysia, StatusMap, t } from "elysia"; import { AudioCodec, type FileListingWithStatus, MediaContainer, VideoCodec, VideoEncodingSetting, } from "music-server-shared/types"; import { decodePath } from "music-server-shared/utils"; import { convertSubtitleWithFFmpeg, convertWithFFmpeg } from "./ffmpeg"; import { allowedTypes, args, authenticate, deleteAuthToken, fileTypeCache, generatedPlaylistIds, issueAuthToken, mediaTypes, type PathInfo, probeCache, ServerError, type User, userForToken, videoExtrasCache, } from "./shared"; import { findCover, getPathInfo, getVideoExtras, isBelow, listFiles, matchesType, packWithTar, probeFile, toAvif, } from "./utils"; //TODO: transcoding cache? //TODO: better ffmpeg errors //TODO: more cover detection //increase timeout to not abort when listing huge folders const setup = new Elysia({ serve: { idleTimeout: 255 } }); interface Resolved { filePath: string; //the root the path belongs to, needed as the boundary for anything walking upwards rootDir: string; } //the first path segment is the virtual root name; the rest is relative to that root's directory async function resolveRelativeInRoot(user: User, relPath: string): Promise { const [rootName, ...rest] = relPath.split("/"); const rootDir = user.rootDirs[rootName]; if (!rootDir) return new ServerError(StatusMap.Forbidden, "Unknown root"); const filePath = path.join(rootDir, ...rest); if (!isBelow(rootDir, filePath)) return new ServerError(StatusMap.Forbidden, "Path outside the root"); //isBelow is lexical, so it cannot see a symlink inside the root that points out of it. //rootDir is already a realpath (resolved at config load), so only the target needs resolving const realPath = await realpath(filePath).catch(() => undefined); if (realPath === undefined) return new ServerError(StatusMap["Not Found"], "File not found"); if (!isBelow(rootDir, realPath)) return new ServerError(StatusMap.Forbidden, "Path outside the root"); return { filePath: realPath, rootDir }; } function resolveInRoot(user: User, encodedPath: string): Promise { //playlist entries arrive already decoded and go through resolveRelativeInRoot instead, because //decoding twice would throw in decodeURIComponent on a legitimate "%" in a filename return resolveRelativeInRoot(user, decodePath(encodedPath)); } async function resolveMediaFile( user: User, encodedPath: string, ): Promise<(Resolved & { info: PathInfo }) | ServerError> { const resolved = await resolveInRoot(user, encodedPath); if (resolved instanceof ServerError) return resolved; const info = await getPathInfo(resolved.filePath); //undefined means it is a directory rather than a file if (!info || info instanceof ServerError) return info ?? new ServerError(StatusMap["Not Found"], "Path is a directory, not a file"); if (!matchesType(info.mimeType, allowedTypes)) return new ServerError(StatusMap.Forbidden, "Forbidden file type"); return { ...resolved, info }; } type FileHandlerContext = Context<{ params: { "*": string } }> & { user: User }; //re-encoding above the source bitrate only costs bandwidth, it cannot add back detail. an unknown source //bitrate leaves the request as it is - there is nothing to compare against function clampToSource(requested: number | undefined, source: number | undefined): number | undefined { return requested && source ? Math.min(requested, source) : requested; } function transcodeFailed(set: FileHandlerContext["set"], exitCode: number | null, stderr: string) { set.status = "Internal Server Error"; //the first lines are the root cause; what follows is each thread unwinding and reporting the same //failure again, so a tail would report the least informative part of it const reason = stderr.trim().split("\n").slice(0, 3).join("\n"); return `Transcoding failed (ffmpeg exit ${exitCode})${reason ? `:\n${reason}` : ""}`; } function reportUnexpectedFFmpegExit(label: string, exitCode: number | null, stderr: string): void { if (exitCode === 0 || exitCode === null) return; const signal = exitCode > 128 ? ` (signal ${exitCode - 128})` : ""; console.error(`${label} failed with status code ${exitCode}${signal}`); const reason = stderr.trim(); if (reason) console.error(reason); } //Bun does not reliably propagate a disconnect from a streaming Response to request.signal on every //browser/runtime combination. The frontend sends an explicit cancellation beacon as a second path. const activeTranscodes = new Map void }>(); const cancelledTranscodes = new Set(); function markTranscodeCancelled(id: string): void { cancelledTranscodes.add(id); const timer = setTimeout(() => cancelledTranscodes.delete(id), 60_000); timer.unref?.(); } function consumeTranscodeCancellation(id: string | undefined): boolean { return id !== undefined && cancelledTranscodes.delete(id); } const downloadHandler = async ({ params, set, user }: FileHandlerContext) => { const resolved = await resolveMediaFile(user, params["*"]); if (resolved instanceof ServerError) { set.status = resolved.status; return resolved.error; } set.status = "OK"; //audio/flac seems to be better supported than the x-flac the sniffer reports set.headers["content-type"] = resolved.info.mimeType === "audio/x-flac" ? "audio/flac" : resolved.info.mimeType; //returning the BunFile directly lets elysia serve range requests natively (Accept-Ranges/206/416) return Bun.file(resolved.filePath); }; //the PWA entry points must always be revalidated: a stale index.html references hashed assets that no //longer exist, and a stale sw.js pins an outdated precache manifest. everything with a content hash in //its name can be cached forever instead. other static files keep the plugin's default (1 day + etag). const noCachePaths = new Set(["/", "/index.html", "/sw.js", "/registerSW.js", "/manifest.webmanifest"]); const hashedAssetPattern = /-[A-Za-z0-9_-]{8,}\.(js|css)$/; const app = setup .onAfterHandle({ as: "global" }, ({ path, set, responseValue }) => { const cacheControl = noCachePaths.has(path) ? "no-cache" : hashedAssetPattern.test(path) ? "public, max-age=31536000, immutable" : undefined; if (!cacheControl) return; set.headers["cache-control"] = cacheControl; //the static plugin already put its own cache-control on the Response, and set.headers //alone does not override that, so patch the response headers directly as well if (responseValue instanceof Response) responseValue.headers.set("cache-control", cacheControl); }) .use(staticPlugin({ assets: args.serve, prefix: "/" })) .onBeforeHandle(({ request, path }) => { if (path === "/remote-log") return; console.info(request.method, path); }) .post( "/login", async ({ body, set }) => { const separator = body.indexOf(":"); const givenUser = separator === -1 ? body : body.slice(0, separator); const givenPassword = separator === -1 ? "" : body.slice(separator + 1); const user = await authenticate(givenUser, givenPassword); if (user) { set.status = 200; const millisInYear = 365 * 24 * 60 * 60 * 1000; const endDate = new Date(Date.now() + millisInYear); const token = randomUUIDv7(); issueAuthToken(token, endDate, user); set.headers["set-cookie"] = `authToken=${token}; Expires=${endDate.toUTCString()}; Secure; HttpOnly; SameSite=Strict`; return "Logged in successfully"; } set.status = 401; return "Invalid username or password"; }, { body: t.String() }, ) .get( "/auth/status", ({ cookie: { authToken } }) => { //a configured user is now mandatory, so there is no open mode any more return { authRequired: true, loggedIn: !!authToken.value && !!userForToken(authToken.value) }; }, { cookie: t.Cookie({ authToken: t.Optional(t.String()) }) }, ) .post( "/logout", ({ cookie: { authToken }, set }) => { if (authToken.value) deleteAuthToken(authToken.value); //invalidate the token server-side //the cookie is HttpOnly, so only the server can clear it - expire it in the past set.headers["set-cookie"] = `authToken=; Expires=${new Date(0).toUTCString()}; Secure; HttpOnly; SameSite=Strict`; return "Logged out"; }, { cookie: t.Cookie({ authToken: t.Optional(t.String()) }) }, ) .guard( { cookie: t.Cookie({ authToken: t.Optional(t.String()) }), beforeHandle({ cookie: { authToken }, set }) { if (!authToken.value || !userForToken(authToken.value)) { set.status = 401; return "Unauthorized"; } }, }, (guarded) => guarded //runs after beforeHandle, so the token is already known to be valid .resolve(({ cookie: { authToken } }) => ({ user: userForToken(authToken.value as string) as User })) .post("/reset-cache", () => { fileTypeCache.clear(); probeCache.clear(); videoExtrasCache.clear(); }) .post( "/cancel-transcode/:id", ({ params }) => { const active = activeTranscodes.get(params.id); if (active) { activeTranscodes.delete(params.id); active.stop(); } else { //The beacon can arrive while the transcode route is still probing the file, before //there is a child process to register. markTranscodeCancelled(params.id); } }, { params: t.Object({ id: t.String({ minLength: 1 }) }) }, ) .get("/download/*", downloadHandler) .head("/download/*", downloadHandler) .get( "/transcode/*", async ({ request, query, set, params, user }) => { const resolved = await resolveMediaFile(user, params["*"]); if (resolved instanceof ServerError) { set.status = resolved.status; return resolved.error; } const { filePath, info: fileScan } = resolved; const probe = await probeFile(filePath); //don't use higher bitrate than what the file has, use requested bitrate if unknown const audioBitrate = clampToSource(query.audioBitrate, probe.audioBitrate); const videoBitrate = clampToSource(query.videoBitrate, probe.videoBitrate); if (!matchesType(fileScan.mimeType, mediaTypes)) { set.status = "Temporary Redirect"; set.headers.Location = `/download/${params["*"]}`; return "Not a media file, redirecting to normal endpoint"; } if (query.videoCodec && query.videoCodec !== VideoCodec.none && !videoBitrate) { set.status = "Bad Request"; return "videoBitrate is required when videoCodec is set"; } if (request.signal.aborted || consumeTranscodeCancellation(query.transcodeId)) return new Uint8Array(); const { cmd, mimeType, stderrText } = await convertWithFFmpeg( filePath, audioBitrate, videoBitrate || 0, query.container, query.audioCodec, query.videoCodec || VideoCodec.none, query.videoEncodingSetting || VideoEncodingSetting.balanced, query.seekTo, query.audioLanguagePreference || "", probe, ); if (consumeTranscodeCancellation(query.transcodeId)) { cmd.kill("SIGKILL"); return new Uint8Array(); } let stoppedByClient = false; const stopForClient = () => { if (stoppedByClient) return; stoppedByClient = true; console.info("ffmpeg cancelled by client", filePath); cmd.kill("SIGKILL"); }; const activeTranscode = { stop: stopForClient }; if (query.transcodeId) activeTranscodes.set(query.transcodeId, activeTranscode); const unregister = () => { if (query.transcodeId && activeTranscodes.get(query.transcodeId) === activeTranscode) activeTranscodes.delete(query.transcodeId); }; request.signal.addEventListener("abort", stopForClient, { once: true }); if (request.signal.aborted) stopForClient(); void cmd.exited .then(async (exitCode) => { if (!stoppedByClient) reportUnexpectedFFmpegExit("ffmpeg", exitCode, await stderrText); }) .finally(unregister); if (query.disableChunkedTranscoding) { const full = await new Response(cmd.stdout).bytes().catch((error) => { if (stoppedByClient) return new Uint8Array(); throw error; }); if (full.length === 0) { const exitCode = await cmd.exited; if (stoppedByClient) return full; return transcodeFailed(set, exitCode, await stderrText); } set.headers["content-type"] = mimeType; return full; } //peek ffmpeg to check for failure and return 500 const reader = cmd.stdout.getReader(); let first: ReadableStreamReadResult; try { first = await reader.read(); } catch (error) { reader.releaseLock(); if (stoppedByClient) return new Uint8Array(); throw error; } if (first.done) { reader.releaseLock(); const exitCode = await cmd.exited; if (stoppedByClient) return new Uint8Array(); return transcodeFailed(set, exitCode, await stderrText); } set.headers["content-type"] = mimeType; return new Response( new ReadableStream({ start(controller) { controller.enqueue(first.value); }, async pull(controller) { const { done, value } = await reader.read(); if (done) controller.close(); else controller.enqueue(value); }, cancel(reason) { stopForClient(); void reader.cancel(reason); }, }), ); }, { query: t.Object({ seekTo: t.Optional(t.Number()), transcodeId: t.Optional(t.String()), audioLanguagePreference: t.Optional(t.String()), disableChunkedTranscoding: t.Optional(t.Boolean()), container: t.Enum(MediaContainer), videoCodec: t.Optional(t.Enum(VideoCodec)), videoBitrate: t.Optional(t.Number()), videoEncodingSetting: t.Optional(t.Enum(VideoEncodingSetting)), audioCodec: t.Enum(AudioCodec), audioBitrate: t.Optional(t.Number()), }), }, ) .get( "/list/*", async ({ params, set, query, user }) => { const recursive = query.recursive || false; //the top level is virtual: it lists the user's roots rather than a directory if (params["*"] === "") { const listing: FileListingWithStatus = {}; for (const rootName of user.roots) { if (!recursive) { listing[rootName] = { files: {}, status: "Unknown" }; continue; } const rootListing = await listFiles(user.rootDirs[rootName], user.rootDirs[rootName], true); if (rootListing instanceof ServerError) { set.status = rootListing.status; return rootListing.error; } listing[rootName] = { files: rootListing, status: "Scanned" }; } set.status = "OK"; return listing; } const resolved = await resolveInRoot(user, params["*"]); if (resolved instanceof ServerError) { set.status = resolved.status; return resolved.error; } const fileList = await listFiles(resolved.rootDir, resolved.filePath, recursive); if (fileList instanceof ServerError) { set.status = fileList.status; return fileList.error; } set.status = "OK"; return fileList; }, { query: t.Optional(t.Object({ recursive: t.Boolean() })) }, ) .get("/video-info/*", async ({ params, set, user }) => { const resolved = await resolveMediaFile(user, params["*"]); if (resolved instanceof ServerError) { set.status = resolved.status; return resolved.error; } if (!matchesType(resolved.info.mimeType, mediaTypes)) { set.status = "Bad Request"; return "Video extras are only available for media files"; } return await getVideoExtras(resolved.filePath); }) .get( "/subtitles/*", async ({ request, params, query, set, user }) => { const resolved = await resolveMediaFile(user, params["*"]); if (resolved instanceof ServerError) { set.status = resolved.status; return resolved.error; } if (!matchesType(resolved.info.mimeType, mediaTypes)) { set.status = "Bad Request"; return "Subtitles are only available for media files"; } if (!Number.isInteger(query.track) || query.track < 0) { set.status = "Bad Request"; return "Invalid subtitle track"; } const extras = await getVideoExtras(resolved.filePath); const track = extras.subtitleTracks.find((candidate) => candidate.streamIndex === query.track); if (!track) { set.status = "Not Found"; return "Subtitle track not found or unsupported"; } if (request.signal.aborted) return new Uint8Array(); const { cmd, mimeType, stderrText } = await convertSubtitleWithFFmpeg(resolved.filePath, track.streamIndex); let stoppedByClient = false; const stopForClient = () => { if (stoppedByClient) return; stoppedByClient = true; console.info("ffmpeg subtitle conversion cancelled by client", resolved.filePath); cmd.kill("SIGKILL"); }; request.signal.addEventListener("abort", stopForClient, { once: true }); if (request.signal.aborted) stopForClient(); void cmd.exited.then(async (exitCode) => { if (!stoppedByClient) reportUnexpectedFFmpegExit("ffmpeg subtitle conversion", exitCode, await stderrText); }); const bytes = await new Response(cmd.stdout).bytes().catch((error) => { if (stoppedByClient) return new Uint8Array(); throw error; }); const exitCode = await cmd.exited; if (exitCode !== 0 || bytes.length === 0) { if (stoppedByClient) return bytes; return transcodeFailed(set, exitCode, await stderrText); } set.headers["content-type"] = mimeType; return bytes; }, { query: t.Object({ track: t.Number() }), }, ) .get( "/cover/*", async ({ params, set, query, user }) => { const resolved = await resolveInRoot(user, params["*"]); if (resolved instanceof ServerError) { set.status = resolved.status; return resolved.error; } const result = await findCover(resolved.rootDir, resolved.filePath); if (!("bytes" in result)) { set.status = result.info.status; return result.info.error; } const setContentType = (type: string) => { set.headers["Content-Type"] = type; }; set.status = "OK"; if (query.transcode) return new Response(await toAvif(await result.bytes(), result.info.mimeType, setContentType)); set.headers["Content-Type"] = result.info.mimeType; return new Response(await result.bytes()); }, { query: t.Optional(t.Object({ transcode: t.Boolean() })) }, ) .post( "/prepare-playlist", async ({ set, body }) => { set.status = "OK"; set.headers["Content-Type"] = "text/plain"; const id = randomUUIDv7(); generatedPlaylistIds.set(id, body); setTimeout( () => { generatedPlaylistIds.delete(id); }, 1000 * 60 * 60, // 1 hour ); return id; }, { body: t.Array(t.String()) }, ) .get( "/download-playlist/:id", async ({ set, params, user }) => { const playlist = generatedPlaylistIds.get(params.id); if (!playlist) { set.status = "Not Found"; return "Playlist ID not found"; } const resolvedPaths: string[] = []; for (const entry of playlist) { const resolved = await resolveRelativeInRoot(user, entry); if (resolved instanceof ServerError) { set.status = resolved.status; return resolved.error; } resolvedPaths.push(resolved.filePath); } set.status = "OK"; if (resolvedPaths.length === 1) { const safeName = basename(resolvedPaths[0]).replace(/["\\\r\n]/g, "_"); set.headers["Content-Disposition"] = `attachment; filename="${safeName}"`; return new Response(Bun.file(resolvedPaths[0])); } set.headers["Content-Type"] = "application/x-tar"; set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`; return new Response(packWithTar(resolvedPaths).stdout); }, { params: t.Object({ id: t.String({ minLength: 1 }) }) }, ) .post("/remote-log", ({ body }) => { console.log(body); }), ) .listen(3000); console.log(`🦊 Elysia is running at ${app.server?.protocol}://${app.server?.hostname}:${app.server?.port}`);