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 IsVideoResponse, MediaContainer, type Metadata, VideoCodec, VideoEncodingSetting, } from "music-server-shared/types"; import { decodePath } from "music-server-shared/utils"; import { convertWithFFmpeg } from "./ffmpeg"; import { allowedTypes, args, authTokens, fileTypeCache, generatedPlaylistIds, mediaTypes, musicRoot, type PathInfo, password, probeCache, ServerError, username, } from "./shared"; import { findCover, getPathInfo, isBelow, listFiles, matchesType, packWithTar, probeFile, readStream, toAvif, } from "./utils"; //TODO: transcoding cache? //TODO: add reasonable timeouts for caches //TODO: better ffmpeg errors //TODO: more cover detection //increase timeout to not abort when listing huge folders const setup = new Elysia({ serve: { idleTimeout: 255 } }); function resolveInRoot(encodedPath: string): string | ServerError { const filePath = path.join(musicRoot, decodePath(encodedPath)); if (!isBelow(musicRoot, filePath)) return new ServerError(StatusMap.Forbidden, "Path outside the music root"); return filePath; } async function resolveMediaFile(encodedPath: string): Promise<{ filePath: string; info: PathInfo } | ServerError> { const filePath = resolveInRoot(encodedPath); if (filePath instanceof ServerError) return filePath; const info = await getPathInfo(filePath); //undefined means it is a directory rather than a file if (!info || info instanceof ServerError) return info ?? new ServerError(StatusMap["Internal Server Error"], "Not a file"); if (!matchesType(info.mimeType, allowedTypes)) return new ServerError(StatusMap.Forbidden, "Forbidden file type"); return { filePath, info }; } type FileHandlerContext = Context<{ params: { "*": string } }>; //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}` : ""}`; } const downloadHandler = async ({ params, set }: FileHandlerContext) => { const resolved = await resolveMediaFile(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", ({ body, set }) => { const [givenUser, givenPassword] = (body as string).split(":", 2); if (givenUser === username && givenPassword === password) { set.status = 200; const millisInYear = 365 * 24 * 60 * 60 * 1000; const endDate = new Date(Date.now() + millisInYear); const token = randomUUIDv7(); authTokens.set(token, endDate); 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 } }) => { const authRequired = !!(username && password); //when no AUTH is configured every route is open, so treat the user as logged in const loggedIn = !authRequired || (!!authToken.value && authTokens.has(authToken.value)); return { authRequired, loggedIn }; }, { cookie: t.Cookie({ authToken: t.Optional(t.String()) }) }, ) .post( "/logout", ({ cookie: { authToken }, set }) => { if (authToken.value) authTokens.delete(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 (username && password && (!authToken.value || !authTokens.has(authToken.value))) { set.status = 401; return "Unauthorized"; } }, }, (guarded) => guarded .post("/reset-cache", () => { fileTypeCache.clear(); probeCache.clear(); }) .get("/download/*", downloadHandler) .head("/download/*", downloadHandler) .get( "/transcode/*", async ({ request, query, set, params }) => { const resolved = await resolveMediaFile(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"; } 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.languages || "", probe, ); request.signal.addEventListener("abort", () => cmd.kill("SIGKILL")); if (query.disableChunkedTranscoding) { const full = await readStream(cmd.stdout); if (full.length === 0) return transcodeFailed(set, await cmd.exited, await stderrText); set.headers["content-type"] = mimeType; return full; } //peek ffmpeg to check for failure and return 500 const reader = cmd.stdout.getReader(); const first = await reader.read(); if (first.done) { reader.releaseLock(); return transcodeFailed(set, await cmd.exited, 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) { void reader.cancel(reason); }, }), ); }, { query: t.Object({ seekTo: t.Optional(t.Number()), languages: 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 }) => { const dirPath = resolveInRoot(params["*"]); if (dirPath instanceof ServerError) { set.status = dirPath.status; return dirPath.error; } const fileList = await listFiles(dirPath, query.recursive || false); if (fileList instanceof ServerError) { set.status = fileList.status; return fileList.error; } set.headers["Content-Type"] = "application/json"; set.status = "OK"; return JSON.stringify(fileList); }, { query: t.Optional(t.Object({ recursive: t.Boolean() })) }, ) .get("/isVideo/*", async ({ params, set }) => { const dirPath = resolveInRoot(params["*"]); if (dirPath instanceof ServerError) { set.status = dirPath.status; return dirPath.error; } const probeData = await probeFile(dirPath).catch(() => ({}) as Metadata); return { isVideo: probeData.videoCodec !== undefined } as IsVideoResponse; }) .get( "/cover/*", async ({ params, set, query }) => { const dirPath = resolveInRoot(params["*"]); if (dirPath instanceof ServerError) { set.status = dirPath.status; return dirPath.error; } const result = await findCover(dirPath); 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, JSON.parse(body as string) as string[]); setTimeout( () => { generatedPlaylistIds.delete(id); }, 1000 * 60 * 60, // 1 hour ); return id; }) .get( "/download-playlist/:id", async ({ set, params }) => { const playlist = generatedPlaylistIds.get(params.id); if (!playlist) { set.status = "Not Found"; return "Playlist ID not found"; } set.status = "OK"; if (playlist.length === 1) { set.headers["Content-Disposition"] = `attachment; filename="${basename(playlist[0])}"`; return new Response(Bun.file(path.join(musicRoot, playlist[0]))); } set.headers["Content-Type"] = "application/x-tar"; set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`; return new Response(packWithTar(playlist).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?.hostname}:${app.server?.port}`);