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