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} from "./shared";
14import { getPathInfo, listFiles, findCover, matchesType, convertWithFFmpeg, packWithTar, probeFile } from "./utils";
15import { stat } from "node:fs/promises";
16
17import parseRange from "range-parser";
18import { MediaFormat } from "music-server-shared/types";
19import { decodePath } from "music-server-shared/utils";
20import { stringifyMap } from "music-server-shared/mapconversion";
21import { randomUUIDv7 } from "bun";
22
23//TODO: transcoding cache?
24//TODO: add reasonable timeouts for caches
25//TODO: better ffmpeg errors
26//TODO: more cover detection
27
28const { values } = parseArgs({
29 args: Bun.argv,
30 options: {
31 serve: {
32 type: "string",
33 },
34 },
35 strict: true,
36 allowPositionals: true,
37});
38
39if (!values.serve) {
40 console.error("Missing --serve, set it to the directory containing the frontend code");
41 process.exit(1);
42}
43
44const setup = new Elysia();
45
46const paramType = t.ArrayString();
47type DownloadHandler = InferHandler<typeof setup, "/download/*", { params: typeof paramType }>;
48
49const downloadHandler: DownloadHandler = async ({ headers, params, set, request }) => {
50 if (request.method !== "GET" && request.method !== "HEAD") {
51 set.status = "Not Found";
52 return "NOT_FOUND";
53 }
54 const filePath = path.join(musicRoot, decodePath(params["*"]));
55 const fileScan = await getPathInfo(filePath);
56 if (!fileScan || fileScan instanceof ServerError) {
57 set.status = fileScan?.status || "Internal Server Error";
58 return fileScan?.error || "Not a file";
59 }
60 if (!matchesType(fileScan.mimeType, allowedTypes)) {
61 set.status = "Forbidden";
62 return "Forbidden file type";
63 }
64
65 set.status = "OK";
66 if (fileScan.mimeType === "audio/x-flac") fileScan.mimeType = "audio/flac"; //seems to be better supported
67 set.headers["content-type"] = fileScan.mimeType;
68
69 const statResult = await stat(filePath);
70 const fileSize = statResult.size;
71
72 if (request.method === "HEAD") {
73 set.headers["accept-ranges"] = "bytes";
74 set.headers["content-length"] = fileSize;
75 return;
76 }
77
78 if (!headers.range) {
79 set.status = "OK";
80 return Bun.file(filePath);
81 }
82
83 const rangeData = parseRange(fileSize, headers.range, { combine: true });
84 if (rangeData === -1 || rangeData === -2 || rangeData.length > 1) {
85 set.status = "Range Not Satisfiable";
86 return "Range Not Satisfiable";
87 }
88 const { start, end } = rangeData[0];
89 set.status = "Partial Content";
90 set.headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
91 // set.headers["content-length"] = end - start + 1; //currently broken in bun/elysiajs, overwritten in final response, seems to be fine though
92 // return createReadStream(filePath, { start, end });
93 return new Response(Bun.file(filePath).slice(start, end));
94};
95
96const app = setup
97 .use(staticPlugin({ assets: values.serve, prefix: "/" }))
98 .onBeforeHandle(({ request, path }) => {
99 if (path === "/remote-log") return;
100 console.info(request.method, path);
101 })
102 .post("/reset-cache", () => {
103 fileTypeCache.clear();
104 probeCache.clear();
105 })
106 .get("/download/*", downloadHandler)
107 .head("/download/*", downloadHandler)
108 .get(
109 "/transcode/*",
110 async ({ request, query, set, params }) => {
111 const filePath = path.join(musicRoot, decodePath(params["*"]));
112 const fileScan = await getPathInfo(filePath);
113 if (!fileScan || fileScan instanceof ServerError) {
114 set.status = fileScan?.status || "Internal Server Error";
115 return fileScan?.error || "Not a file";
116 }
117 if (!matchesType(fileScan.mimeType, allowedTypes)) {
118 set.status = "Forbidden";
119 return "Forbidden file type";
120 }
121 const probe = await probeFile(filePath);
122 //don't use higher bitrate than what the file has, use requested bitrate if unknown, default to 96kbit if not provided
123 const bitrate = query.bitrate && probe.bitrate ? Math.min(query.bitrate, probe.bitrate) : query.bitrate || 96000;
124 if (!matchesType(fileScan.mimeType, mediaTypes)) {
125 set.status = "Temporary Redirect";
126 set.headers.Location = `/download/${params["*"]}`;
127 return "Not a media file, redirecting to normal endpoint";
128 }
129
130 const { cmd, mimeType } = convertWithFFmpeg(filePath, bitrate, query.format ?? MediaFormat["opus/ogg"]);
131 request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
132 set.headers["content-type"] = mimeType;
133 if (query.disableChunkedTranscoding) {
134 const chunks: Uint8Array[] = [];
135 for await (const chunk of cmd.stdout) {
136 chunks.push(chunk);
137 }
138 const full = new Uint8Array(chunks.reduce((acc, val) => acc + val.length, 0));
139 let offset = 0;
140 for (const chunk of chunks) {
141 full.set(chunk, offset);
142 offset += chunk.length;
143 }
144 return full;
145 }
146 return new Response(cmd.stdout);
147 },
148 {
149 query: t.Optional(
150 t.Object({ bitrate: t.Number(), format: t.Enum(MediaFormat), disableChunkedTranscoding: t.Boolean() }),
151 ),
152 },
153 )
154 .get(
155 "/list/*",
156 async ({ params, set, query }) => {
157 const dirPath = path.join(musicRoot, decodePath(params["*"]));
158 const fileList = await listFiles(dirPath, query.recursive || false);
159 if (fileList instanceof ServerError) {
160 set.status = fileList.status;
161 return fileList.error;
162 }
163 set.headers["Content-Type"] = "application/json";
164 set.status = "OK";
165 return stringifyMap(fileList);
166 },
167 { query: t.Optional(t.Object({ recursive: t.Boolean() })) },
168 )
169 .get("/cover/*", async ({ params, set }) => {
170 set.status = "Not Found";
171 const dirPath = path.join(musicRoot, decodePath(params["*"]));
172 const fileScanResult = await findCover(dirPath);
173 if ("path" in fileScanResult) {
174 set.headers["Content-Type"] = fileScanResult.info.mimeType;
175 set.status = "OK";
176 return Bun.file(fileScanResult.path);
177 }
178 set.status = fileScanResult.info.status;
179 return fileScanResult.info.error;
180 })
181 .post("/prepare-playlist", async ({ set, body }) => {
182 set.status = "OK";
183 set.headers["Content-Type"] = "text/plain";
184 const id = randomUUIDv7();
185 console.log(typeof body);
186 generatedPlaylistIds.set(id, JSON.parse(body as string) as string[]);
187 setTimeout(
188 () => {
189 generatedPlaylistIds.delete(id);
190 },
191 1000 * 60 * 60, // 1 hour
192 );
193 return id;
194 })
195 .get(
196 "/download-playlist/:id",
197 async ({ set, params }) => {
198 const playlist = generatedPlaylistIds.get(params.id);
199 if (!playlist) {
200 set.status = "Not Found";
201 return "Playlist ID not found";
202 }
203 set.status = "OK";
204 if (playlist.length === 1) {
205 set.headers["Content-Disposition"] = `attachment; filename="${basename(playlist[0])}"`;
206 return Bun.file(path.join(musicRoot, playlist[0]));
207 }
208 set.headers["Content-Type"] = "application/x-tar";
209 set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
210 return new Response(packWithTar(playlist).stdout);
211 },
212 { params: t.Object({ id: t.String({ minLength: 1 }) }) },
213 )
214 .post("remote-log", ({ body }) => {
215 console.log(body);
216 })
217
218 .listen(3000);
219
220console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);
221