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