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