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 { createReadStream } from "node:fs";
19import { MediaFormat } from "music-server-shared/types";
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, decodeURI(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, not present in final response
92 //Bun.file().slice() should work, but currently only returns the chunk size as the total range, which confuses browsers
93 //using a stream here causes "Transfer-Encoding: chunked" to be set, but seems fine in practice
94 return createReadStream(filePath, { start, end });
95};
96
97const app = setup
98 .use(staticPlugin({ assets: values.serve, prefix: "/" }))
99 .onBeforeHandle(({ request, path }) => {
100 if (path === "/remote-log") return;
101 console.info(request.method, path);
102 })
103 .post("/reset-cache", () => {
104 fileTypeCache.clear();
105 probeCache.clear();
106 })
107 .get("/download/*", downloadHandler)
108 .head("/download/*", downloadHandler)
109 .get(
110 "/transcode/*",
111 async ({ request, query, set, params }) => {
112 const filePath = path.join(musicRoot, decodeURI(params["*"]));
113 const fileScan = await getPathInfo(filePath);
114 if (!fileScan || fileScan instanceof ServerError) {
115 set.status = fileScan?.status || "Internal Server Error";
116 return fileScan?.error || "Not a file";
117 }
118 if (!matchesType(fileScan.mimeType, allowedTypes)) {
119 set.status = "Forbidden";
120 return "Forbidden file type";
121 }
122 const probe = await probeFile(filePath);
123 //don't use higher bitrate than what the file has, use requested bitrate if unknown, default to 96kbit if not provided
124 const bitrate = query.bitrate && probe.bitrate ? Math.min(query.bitrate, probe.bitrate) : query.bitrate || 96000;
125 if (!matchesType(fileScan.mimeType, mediaTypes)) {
126 set.status = "Temporary Redirect";
127 set.headers.Location = `/download/${params["*"]}`;
128 return "Not a media file, redirecting to normal endpoint";
129 }
130
131 const { cmd, mimeType } = convertWithFFmpeg(filePath, bitrate, query.format ?? MediaFormat["opus/ogg"]);
132 request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
133 set.headers["content-type"] = mimeType;
134 if (query.disableChunkedTranscoding) {
135 const chunks: Uint8Array[] = [];
136 for await (const chunk of cmd.stdout) {
137 chunks.push(chunk);
138 }
139 const full = new Uint8Array(chunks.reduce((acc, val) => acc + val.length, 0));
140 let offset = 0;
141 for (const chunk of chunks) {
142 full.set(chunk, offset);
143 offset += chunk.length;
144 }
145 return full;
146 }
147 return cmd.stdout;
148 },
149 {
150 query: t.Optional(
151 t.Object({ bitrate: t.Number(), format: t.Enum(MediaFormat), disableChunkedTranscoding: t.Boolean() }),
152 ),
153 },
154 )
155 .get(
156 "/list/*",
157 async ({ params, set, query }) => {
158 const dirPath = path.join(musicRoot, decodeURI(params["*"]));
159 const fileList = await listFiles(dirPath, query.recursive || false);
160 if (fileList instanceof ServerError) {
161 set.status = fileList.status;
162 return fileList.error;
163 }
164 set.headers["Content-Type"] = "application/json";
165 set.status = "OK";
166 return stringifyMap(fileList);
167 },
168 { query: t.Optional(t.Object({ recursive: t.Boolean() })) },
169 )
170 .get("/cover/*", async ({ params, set }) => {
171 set.status = "Not Found";
172 const dirPath = path.join(musicRoot, decodeURI(params["*"]));
173 const fileScanResult = await findCover(dirPath);
174 if ("path" in fileScanResult) {
175 set.headers["Content-Type"] = fileScanResult.info.mimeType;
176 set.status = "OK";
177 return Bun.file(fileScanResult.path);
178 }
179 set.status = fileScanResult.info.status;
180 return fileScanResult.info.error;
181 })
182 .post("/prepare-playlist", async ({ set, body }) => {
183 set.status = "OK";
184 set.headers["Content-Type"] = "text/plain";
185 const id = randomUUIDv7();
186 console.log(typeof body);
187 generatedPlaylistIds.set(id, JSON.parse(body as string) as string[]);
188 setTimeout(
189 () => {
190 generatedPlaylistIds.delete(id);
191 },
192 1000 * 60 * 60, // 1 hour
193 );
194 return id;
195 })
196 .get(
197 "/download-playlist/:id",
198 async ({ set, params }) => {
199 const playlist = generatedPlaylistIds.get(params.id);
200 if (!playlist) {
201 set.status = "Not Found";
202 return "Playlist ID not found";
203 }
204 set.status = "OK";
205 if (playlist.length === 1) {
206 set.headers["Content-Disposition"] = `attachment; filename="${basename(playlist[0])}"`;
207 return Bun.file(path.join(musicRoot, playlist[0]));
208 }
209 set.headers["Content-Type"] = "application/x-tar";
210 set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
211 return new Response(packWithTar(playlist).stdout);
212 },
213 { params: t.Object({ id: t.String({ minLength: 1 }) }) },
214 )
215 .post("remote-log", ({ body }) => {
216 console.log(body);
217 })
218
219 .listen(3000);
220
221console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);
222