index.ts
⎇
Raw
1import path, { basename } from "node:path";
2import staticPlugin from "@elysiajs/static";
3import { randomUUIDv7 } from "bun";
4import { type Context, Elysia, StatusMap, t } from "elysia";
5import {
6 AudioCodec,
7 type IsVideoResponse,
8 MediaContainer,
9 type Metadata,
10 VideoCodec,
11 VideoEncodingSetting,
12} from "music-server-shared/types";
13import { decodePath } from "music-server-shared/utils";
14import { convertWithFFmpeg } from "./ffmpeg";
15import {
16 allowedTypes,
17 args,
18 authTokens,
19 fileTypeCache,
20 generatedPlaylistIds,
21 isValidAuthToken,
22 mediaTypes,
23 musicRoot,
24 type PathInfo,
25 password,
26 probeCache,
27 ServerError,
28 username,
29} from "./shared";
30import {
31 findCover,
32 getPathInfo,
33 isBelow,
34 listFiles,
35 matchesType,
36 packWithTar,
37 probeFile,
38 readStream,
39 toAvif,
40} from "./utils";
41
42//TODO: transcoding cache?
43//TODO: add reasonable timeouts for caches
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
50function resolveInRoot(encodedPath: string): string | ServerError {
51 const filePath = path.join(musicRoot, decodePath(encodedPath));
52 if (!isBelow(musicRoot, filePath)) return new ServerError(StatusMap.Forbidden, "Path outside the music root");
53 return filePath;
54}
55
56//for paths that arrive already decoded (playlist entries), where resolveInRoot's decodePath would
57//double-decode - a legitimate "%" in a filename would throw in decodeURIComponent
58function resolveRelativeInRoot(relPath: string): string | ServerError {
59 const filePath = path.join(musicRoot, relPath);
60 if (!isBelow(musicRoot, filePath)) return new ServerError(StatusMap.Forbidden, "Path outside the music root");
61 return filePath;
62}
63
64async function resolveMediaFile(encodedPath: string): Promise<{ filePath: string; info: PathInfo } | ServerError> {
65 const filePath = resolveInRoot(encodedPath);
66 if (filePath instanceof ServerError) return filePath;
67 const info = await getPathInfo(filePath);
68 //undefined means it is a directory rather than a file
69 if (!info || info instanceof ServerError)
70 return info ?? new ServerError(StatusMap["Internal Server Error"], "Not a file");
71 if (!matchesType(info.mimeType, allowedTypes)) return new ServerError(StatusMap.Forbidden, "Forbidden file type");
72 return { filePath, info };
73}
74
75type FileHandlerContext = Context<{ params: { "*": string } }>;
76
77//re-encoding above the source bitrate only costs bandwidth, it cannot add back detail. an unknown source
78//bitrate leaves the request as it is - there is nothing to compare against
79function clampToSource(requested: number | undefined, source: number | undefined): number | undefined {
80 return requested && source ? Math.min(requested, source) : requested;
81}
82
83function transcodeFailed(set: FileHandlerContext["set"], exitCode: number | null, stderr: string) {
84 set.status = "Internal Server Error";
85 //the first lines are the root cause; what follows is each thread unwinding and reporting the same
86 //failure again, so a tail would report the least informative part of it
87 const reason = stderr.trim().split("\n").slice(0, 3).join("\n");
88 return `Transcoding failed (ffmpeg exit ${exitCode})${reason ? `:\n${reason}` : ""}`;
89}
90
91const downloadHandler = async ({ params, set }: FileHandlerContext) => {
92 const resolved = await resolveMediaFile(params["*"]);
93 if (resolved instanceof ServerError) {
94 set.status = resolved.status;
95 return resolved.error;
96 }
97
98 set.status = "OK";
99 //audio/flac seems to be better supported than the x-flac the sniffer reports
100 set.headers["content-type"] = resolved.info.mimeType === "audio/x-flac" ? "audio/flac" : resolved.info.mimeType;
101 //returning the BunFile directly lets elysia serve range requests natively (Accept-Ranges/206/416)
102 return Bun.file(resolved.filePath);
103};
104
105//the PWA entry points must always be revalidated: a stale index.html references hashed assets that no
106//longer exist, and a stale sw.js pins an outdated precache manifest. everything with a content hash in
107//its name can be cached forever instead. other static files keep the plugin's default (1 day + etag).
108const noCachePaths = new Set(["/", "/index.html", "/sw.js", "/registerSW.js", "/manifest.webmanifest"]);
109const hashedAssetPattern = /-[A-Za-z0-9_-]{8,}\.(js|css)$/;
110
111const app = setup
112 .onAfterHandle({ as: "global" }, ({ path, set, responseValue }) => {
113 const cacheControl = noCachePaths.has(path)
114 ? "no-cache"
115 : hashedAssetPattern.test(path)
116 ? "public, max-age=31536000, immutable"
117 : undefined;
118 if (!cacheControl) return;
119 set.headers["cache-control"] = cacheControl;
120 //the static plugin already put its own cache-control on the Response, and set.headers
121 //alone does not override that, so patch the response headers directly as well
122 if (responseValue instanceof Response) responseValue.headers.set("cache-control", cacheControl);
123 })
124 .use(staticPlugin({ assets: args.serve, prefix: "/" }))
125 .onBeforeHandle(({ request, path }) => {
126 if (path === "/remote-log") return;
127 console.info(request.method, path);
128 })
129 .post(
130 "/login",
131 ({ body, set }) => {
132 const [givenUser, givenPassword] = (body as string).split(":", 2);
133 if (givenUser === username && givenPassword === password) {
134 set.status = 200;
135 const millisInYear = 365 * 24 * 60 * 60 * 1000;
136 const endDate = new Date(Date.now() + millisInYear);
137 const token = randomUUIDv7();
138 authTokens.set(token, endDate);
139 set.headers["set-cookie"] =
140 `authToken=${token}; Expires=${endDate.toUTCString()}; Secure; HttpOnly; SameSite=Strict`;
141 return "Logged in successfully";
142 }
143 set.status = 401;
144 return "Invalid username or password";
145 },
146 { body: t.String() },
147 )
148 .get(
149 "/auth/status",
150 ({ cookie: { authToken } }) => {
151 const authRequired = !!(username && password);
152 //when no AUTH is configured every route is open, so treat the user as logged in
153 const loggedIn = !authRequired || (!!authToken.value && isValidAuthToken(authToken.value));
154 return { authRequired, loggedIn };
155 },
156 { cookie: t.Cookie({ authToken: t.Optional(t.String()) }) },
157 )
158 .post(
159 "/logout",
160 ({ cookie: { authToken }, set }) => {
161 if (authToken.value) authTokens.delete(authToken.value); //invalidate the token server-side
162 //the cookie is HttpOnly, so only the server can clear it - expire it in the past
163 set.headers["set-cookie"] = `authToken=; Expires=${new Date(0).toUTCString()}; Secure; HttpOnly; SameSite=Strict`;
164 return "Logged out";
165 },
166 { cookie: t.Cookie({ authToken: t.Optional(t.String()) }) },
167 )
168 .guard(
169 {
170 cookie: t.Cookie({ authToken: t.Optional(t.String()) }),
171 beforeHandle({ cookie: { authToken }, set }) {
172 if (username && password && (!authToken.value || !isValidAuthToken(authToken.value))) {
173 set.status = 401;
174 return "Unauthorized";
175 }
176 },
177 },
178 (guarded) =>
179 guarded
180 .post("/reset-cache", () => {
181 fileTypeCache.clear();
182 probeCache.clear();
183 })
184 .get("/download/*", downloadHandler)
185 .head("/download/*", downloadHandler)
186 .get(
187 "/transcode/*",
188 async ({ request, query, set, params }) => {
189 const resolved = await resolveMediaFile(params["*"]);
190 if (resolved instanceof ServerError) {
191 set.status = resolved.status;
192 return resolved.error;
193 }
194 const { filePath, info: fileScan } = resolved;
195 const probe = await probeFile(filePath);
196 //don't use higher bitrate than what the file has, use requested bitrate if unknown
197 const audioBitrate = clampToSource(query.audioBitrate, probe.audioBitrate);
198 const videoBitrate = clampToSource(query.videoBitrate, probe.videoBitrate);
199
200 if (!matchesType(fileScan.mimeType, mediaTypes)) {
201 set.status = "Temporary Redirect";
202 set.headers.Location = `/download/${params["*"]}`;
203 return "Not a media file, redirecting to normal endpoint";
204 }
205
206 if (query.videoCodec && query.videoCodec !== VideoCodec.none && !videoBitrate) {
207 set.status = "Bad Request";
208 return "videoBitrate is required when videoCodec is set";
209 }
210 const { cmd, mimeType, stderrText } = await convertWithFFmpeg(
211 filePath,
212 audioBitrate,
213 videoBitrate || 0,
214 query.container,
215 query.audioCodec,
216 query.videoCodec || VideoCodec.none,
217 query.videoEncodingSetting || VideoEncodingSetting.balanced,
218 query.seekTo,
219 query.languages || "",
220 probe,
221 );
222 request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
223
224 if (query.disableChunkedTranscoding) {
225 const full = await readStream(cmd.stdout);
226 if (full.length === 0) return transcodeFailed(set, await cmd.exited, await stderrText);
227 set.headers["content-type"] = mimeType;
228 return full;
229 }
230
231 //peek ffmpeg to check for failure and return 500
232 const reader = cmd.stdout.getReader();
233 const first = await reader.read();
234 if (first.done) {
235 reader.releaseLock();
236 return transcodeFailed(set, await cmd.exited, await stderrText);
237 }
238 set.headers["content-type"] = mimeType;
239 return new Response(
240 new ReadableStream<Uint8Array>({
241 start(controller) {
242 controller.enqueue(first.value);
243 },
244 async pull(controller) {
245 const { done, value } = await reader.read();
246 if (done) controller.close();
247 else controller.enqueue(value);
248 },
249 cancel(reason) {
250 void reader.cancel(reason);
251 },
252 }),
253 );
254 },
255 {
256 query: t.Object({
257 seekTo: t.Optional(t.Number()),
258 languages: t.Optional(t.String()),
259 disableChunkedTranscoding: t.Optional(t.Boolean()),
260 container: t.Enum(MediaContainer),
261 videoCodec: t.Optional(t.Enum(VideoCodec)),
262 videoBitrate: t.Optional(t.Number()),
263 videoEncodingSetting: t.Optional(t.Enum(VideoEncodingSetting)),
264 audioCodec: t.Enum(AudioCodec),
265 audioBitrate: t.Optional(t.Number()),
266 }),
267 },
268 )
269 .get(
270 "/list/*",
271 async ({ params, set, query }) => {
272 const dirPath = resolveInRoot(params["*"]);
273 if (dirPath instanceof ServerError) {
274 set.status = dirPath.status;
275 return dirPath.error;
276 }
277 const fileList = await listFiles(dirPath, query.recursive || false);
278 if (fileList instanceof ServerError) {
279 set.status = fileList.status;
280 return fileList.error;
281 }
282 set.status = "OK";
283 return fileList;
284 },
285 { query: t.Optional(t.Object({ recursive: t.Boolean() })) },
286 )
287 .get("/isVideo/*", async ({ params, set }) => {
288 const dirPath = resolveInRoot(params["*"]);
289 if (dirPath instanceof ServerError) {
290 set.status = dirPath.status;
291 return dirPath.error;
292 }
293 const probeData = await probeFile(dirPath).catch(() => ({}) as Metadata);
294 return { isVideo: probeData.videoCodec !== undefined } as IsVideoResponse;
295 })
296 .get(
297 "/cover/*",
298 async ({ params, set, query }) => {
299 const dirPath = resolveInRoot(params["*"]);
300 if (dirPath instanceof ServerError) {
301 set.status = dirPath.status;
302 return dirPath.error;
303 }
304 const result = await findCover(dirPath);
305 if (!("bytes" in result)) {
306 set.status = result.info.status;
307 return result.info.error;
308 }
309 const setContentType = (type: string) => {
310 set.headers["Content-Type"] = type;
311 };
312 set.status = "OK";
313 if (query.transcode)
314 return new Response(await toAvif(await result.bytes(), result.info.mimeType, setContentType));
315 set.headers["Content-Type"] = result.info.mimeType;
316 return new Response(await result.bytes());
317 },
318 { query: t.Optional(t.Object({ transcode: t.Boolean() })) },
319 )
320 .post(
321 "/prepare-playlist",
322 async ({ set, body }) => {
323 set.status = "OK";
324 set.headers["Content-Type"] = "text/plain";
325 const id = randomUUIDv7();
326 generatedPlaylistIds.set(id, body);
327 setTimeout(
328 () => {
329 generatedPlaylistIds.delete(id);
330 },
331 1000 * 60 * 60, // 1 hour
332 );
333 return id;
334 },
335 { body: t.Array(t.String()) },
336 )
337 .get(
338 "/download-playlist/:id",
339 async ({ set, params }) => {
340 const playlist = generatedPlaylistIds.get(params.id);
341 if (!playlist) {
342 set.status = "Not Found";
343 return "Playlist ID not found";
344 }
345 const resolvedPaths: string[] = [];
346 for (const entry of playlist) {
347 const resolved = resolveRelativeInRoot(entry);
348 if (resolved instanceof ServerError) {
349 set.status = resolved.status;
350 return resolved.error;
351 }
352 resolvedPaths.push(resolved);
353 }
354 set.status = "OK";
355 if (resolvedPaths.length === 1) {
356 const safeName = basename(resolvedPaths[0]).replace(/["\\\r\n]/g, "_");
357 set.headers["Content-Disposition"] = `attachment; filename="${safeName}"`;
358 return new Response(Bun.file(resolvedPaths[0]));
359 }
360 set.headers["Content-Type"] = "application/x-tar";
361 set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
362 return new Response(packWithTar(playlist).stdout);
363 },
364 { params: t.Object({ id: t.String({ minLength: 1 }) }) },
365 )
366 .post("remote-log", ({ body }) => {
367 console.log(body);
368 }),
369 )
370 .listen(3000);
371
372console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);
373