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