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