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