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 { findCover, getPathInfo, listFiles, matchesType, packWithTar, probeFile, toAvif } from "./utils";
31
32//TODO: transcoding cache?
33//TODO: add reasonable timeouts for caches
34//TODO: better ffmpeg errors
35//TODO: more cover detection
36
37//increase timeout to not abort when listing huge folders
38const setup = new Elysia({ serve: { idleTimeout: 255 } });
39
40async function resolveMediaFile(encodedPath: string): Promise<{ filePath: string; info: PathInfo } | ServerError> {
41 const filePath = path.join(musicRoot, decodePath(encodedPath));
42 const info = await getPathInfo(filePath);
43 //undefined means it is a directory rather than a file
44 if (!info || info instanceof ServerError)
45 return info ?? new ServerError(StatusMap["Internal Server Error"], "Not a file");
46 if (!matchesType(info.mimeType, allowedTypes)) return new ServerError(StatusMap.Forbidden, "Forbidden file type");
47 return { filePath, info };
48}
49
50type FileHandlerContext = Context<{ params: { "*": string } }>;
51
52const downloadHandler = async ({ params, set }: FileHandlerContext) => {
53 const resolved = await resolveMediaFile(params["*"]);
54 if (resolved instanceof ServerError) {
55 set.status = resolved.status;
56 return resolved.error;
57 }
58
59 set.status = "OK";
60 //audio/flac seems to be better supported than the x-flac the sniffer reports
61 set.headers["content-type"] = resolved.info.mimeType === "audio/x-flac" ? "audio/flac" : resolved.info.mimeType;
62 //returning the BunFile directly lets elysia serve range requests natively (Accept-Ranges/206/416)
63 return Bun.file(resolved.filePath);
64};
65
66//the PWA entry points must always be revalidated: a stale index.html references hashed assets that no
67//longer exist, and a stale sw.js pins an outdated precache manifest. everything with a content hash in
68//its name can be cached forever instead. other static files keep the plugin's default (1 day + etag).
69const noCachePaths = new Set(["/", "/index.html", "/sw.js", "/registerSW.js", "/manifest.webmanifest"]);
70const hashedAssetPattern = /-[A-Za-z0-9_-]{8,}\.(js|css)$/;
71
72const app = setup
73 .onAfterHandle({ as: "global" }, ({ path, set, responseValue }) => {
74 const cacheControl = noCachePaths.has(path)
75 ? "no-cache"
76 : hashedAssetPattern.test(path)
77 ? "public, max-age=31536000, immutable"
78 : undefined;
79 if (!cacheControl) return;
80 set.headers["cache-control"] = cacheControl;
81 //the static plugin already put its own cache-control on the Response, and set.headers
82 //alone does not override that, so patch the response headers directly as well
83 if (responseValue instanceof Response) responseValue.headers.set("cache-control", cacheControl);
84 })
85 .use(staticPlugin({ assets: args.serve, prefix: "/" }))
86 .onBeforeHandle(({ request, path }) => {
87 if (path === "/remote-log") return;
88 console.info(request.method, path);
89 })
90 .post(
91 "/login",
92 ({ body, set }) => {
93 const [givenUser, givenPassword] = (body as string).split(":", 2);
94 if (givenUser === username && givenPassword === password) {
95 set.status = 200;
96 const millisInYear = 365 * 24 * 60 * 60 * 1000;
97 const endDate = new Date(Date.now() + millisInYear);
98 const token = randomUUIDv7();
99 authTokens.set(token, endDate);
100 set.headers["set-cookie"] =
101 `authToken=${token}; Expires=${endDate.toUTCString()}; Secure; HttpOnly; SameSite=Strict`;
102 return "Logged in successfully";
103 }
104 set.status = 401;
105 return "Invalid username or password";
106 },
107 { body: t.String() },
108 )
109 .get(
110 "/auth/status",
111 ({ cookie: { authToken } }) => {
112 const authRequired = !!(username && password);
113 //when no AUTH is configured every route is open, so treat the user as logged in
114 const loggedIn = !authRequired || (!!authToken.value && authTokens.has(authToken.value));
115 return { authRequired, loggedIn };
116 },
117 { cookie: t.Cookie({ authToken: t.Optional(t.String()) }) },
118 )
119 .post(
120 "/logout",
121 ({ cookie: { authToken }, set }) => {
122 if (authToken.value) authTokens.delete(authToken.value); //invalidate the token server-side
123 //the cookie is HttpOnly, so only the server can clear it - expire it in the past
124 set.headers["set-cookie"] = `authToken=; Expires=${new Date(0).toUTCString()}; Secure; HttpOnly; SameSite=Strict`;
125 return "Logged out";
126 },
127 { cookie: t.Cookie({ authToken: t.Optional(t.String()) }) },
128 )
129 .guard(
130 {
131 cookie: t.Cookie({ authToken: t.Optional(t.String()) }),
132 beforeHandle({ cookie: { authToken }, set }) {
133 if (username && password && (!authToken.value || !authTokens.has(authToken.value))) {
134 set.status = 401;
135 return "Unauthorized";
136 }
137 },
138 },
139 (guarded) =>
140 guarded
141 .post("/reset-cache", () => {
142 fileTypeCache.clear();
143 probeCache.clear();
144 })
145 .get("/download/*", downloadHandler)
146 .head("/download/*", downloadHandler)
147 .get(
148 "/transcode/*",
149 async ({ request, query, set, params }) => {
150 const resolved = await resolveMediaFile(params["*"]);
151 if (resolved instanceof ServerError) {
152 set.status = resolved.status;
153 return resolved.error;
154 }
155 const { filePath, info: fileScan } = resolved;
156 const probe = await probeFile(filePath);
157 //don't use higher bitrate than what the file has, use requested bitrate if unknown
158 const audioBitrate = probe.audioBitrate
159 ? Math.min(query.audioBitrate, probe.audioBitrate)
160 : query.audioBitrate;
161 const videoBitrate =
162 probe.videoBitrate && query.videoBitrate
163 ? Math.min(query.videoBitrate, probe.videoBitrate)
164 : query.videoBitrate;
165
166 if (!matchesType(fileScan.mimeType, mediaTypes)) {
167 set.status = "Temporary Redirect";
168 set.headers.Location = `/download/${params["*"]}`;
169 return "Not a media file, redirecting to normal endpoint";
170 }
171 const { cmd, mimeType } = await convertWithFFmpeg(
172 filePath,
173 audioBitrate,
174 videoBitrate || 0,
175 query.container,
176 query.audioCodec,
177 query.videoCodec || VideoCodec.none,
178 query.videoEncodingSetting || VideoEncodingSetting.balanced,
179 query.seekTo,
180 query.languages || "",
181 probe,
182 );
183 request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
184 set.headers["content-type"] = mimeType;
185 if (query.disableChunkedTranscoding) {
186 const chunks: Uint8Array[] = [];
187 for await (const chunk of cmd.stdout) {
188 chunks.push(chunk);
189 }
190 const full = new Uint8Array(chunks.reduce((acc, val) => acc + val.length, 0));
191 let offset = 0;
192 for (const chunk of chunks) {
193 full.set(chunk, offset);
194 offset += chunk.length;
195 }
196 return full;
197 }
198 return new Response(cmd.stdout);
199 },
200 {
201 query: t.Object({
202 seekTo: t.Optional(t.Number()),
203 languages: t.Optional(t.String()),
204 disableChunkedTranscoding: t.Optional(t.Boolean()),
205 container: t.Enum(MediaContainer),
206 videoCodec: t.Optional(t.Enum(VideoCodec)),
207 videoBitrate: t.Optional(t.Number()),
208 videoEncodingSetting: t.Optional(t.Enum(VideoEncodingSetting)),
209 audioCodec: t.Enum(AudioCodec),
210 audioBitrate: t.Number(),
211 }),
212 },
213 )
214 .get(
215 "/list/*",
216 async ({ params, set, query }) => {
217 const dirPath = path.join(musicRoot, decodePath(params["*"]));
218 const fileList = await listFiles(dirPath, query.recursive || false);
219 if (fileList instanceof ServerError) {
220 set.status = fileList.status;
221 return fileList.error;
222 }
223 set.headers["Content-Type"] = "application/json";
224 set.status = "OK";
225 return stringifyMap(fileList);
226 },
227 { query: t.Optional(t.Object({ recursive: t.Boolean() })) },
228 )
229 .get("/isVideo/*", async ({ params }) => {
230 const dirPath = path.join(musicRoot, decodePath(params["*"]));
231 const probeData = await probeFile(dirPath).catch(() => ({}) as Metadata);
232 return { isVideo: probeData.videoCodec !== undefined } as IsVideoResponse;
233 })
234 .get(
235 "/cover/*",
236 async ({ params, set, query }) => {
237 set.status = "Not Found";
238 const dirPath = path.join(musicRoot, decodePath(params["*"]));
239 const fileScanResult = await findCover(dirPath);
240 const setContentType = (type: string) => {
241 set.headers["Content-Type"] = type;
242 };
243 if ("path" in fileScanResult) {
244 set.status = "OK";
245 if (query.transcode)
246 return new Response(
247 await toAvif(
248 await Bun.file(fileScanResult.path).bytes(),
249 fileScanResult.info.mimeType,
250 setContentType,
251 ),
252 );
253 set.headers["Content-Type"] = fileScanResult.info.mimeType;
254 //wrapping in new response discards the accept-range header, which we don't support here
255 return new Response(Bun.file(fileScanResult.path));
256 }
257 if ("content" in fileScanResult) {
258 set.status = "OK";
259 if (query.transcode)
260 return new Response(await toAvif(fileScanResult.content, fileScanResult.info.mimeType, setContentType));
261 set.headers["Content-Type"] = fileScanResult.info.mimeType;
262 return new Response(fileScanResult.content);
263 }
264 set.status = fileScanResult.info.status;
265 return fileScanResult.info.error;
266 },
267 { query: t.Optional(t.Object({ transcode: t.Boolean() })) },
268 )
269 .post("/prepare-playlist", async ({ set, body }) => {
270 set.status = "OK";
271 set.headers["Content-Type"] = "text/plain";
272 const id = randomUUIDv7();
273 generatedPlaylistIds.set(id, JSON.parse(body as string) as string[]);
274 setTimeout(
275 () => {
276 generatedPlaylistIds.delete(id);
277 },
278 1000 * 60 * 60, // 1 hour
279 );
280 return id;
281 })
282 .get(
283 "/download-playlist/:id",
284 async ({ set, params }) => {
285 const playlist = generatedPlaylistIds.get(params.id);
286 if (!playlist) {
287 set.status = "Not Found";
288 return "Playlist ID not found";
289 }
290 set.status = "OK";
291 if (playlist.length === 1) {
292 set.headers["Content-Disposition"] = `attachment; filename="${basename(playlist[0])}"`;
293 return new Response(Bun.file(path.join(musicRoot, playlist[0])));
294 }
295 set.headers["Content-Type"] = "application/x-tar";
296 set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
297 return new Response(packWithTar(playlist).stdout);
298 },
299 { params: t.Object({ id: t.String({ minLength: 1 }) }) },
300 )
301 .post("remote-log", ({ body }) => {
302 console.log(body);
303 }),
304 )
305 .listen(3000);
306
307console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);
308