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