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