index.ts
⎇
Raw
1import staticPlugin from "@elysiajs/static";
2import { Elysia, ElysiaFile, 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, ReadStream } 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
27const { values } = parseArgs({
28 args: Bun.argv,
29 options: {
30 serve: {
31 type: "string",
32 },
33 },
34 strict: true,
35 allowPositionals: true,
36});
37
38if (!values.serve) {
39 console.error("Please define the serve option");
40 process.exit(1);
41}
42
43const setup = new Elysia();
44
45const paramType = t.ArrayString();
46type DownloadHandler = InferHandler<typeof setup, "/download/*", { params: typeof paramType }>;
47
48const downloadHandler: DownloadHandler = async ({ headers, params, set, request }) => {
49 if (request.method !== "GET" && request.method !== "HEAD") {
50 set.status = "Not Found";
51 return "NOT_FOUND";
52 }
53 const filePath = path.join(musicRoot, decodeURI(params["*"]));
54 const fileScan = await getPathInfo(filePath);
55 if (!fileScan || fileScan instanceof ServerError) {
56 set.status = fileScan?.error ? "Internal Server Error" : "Bad Request";
57 return fileScan?.error || "Not a file";
58 }
59 if (!matchesType(fileScan.mimeType, allowedTypes)) {
60 set.status = "Forbidden";
61 return "Forbidden file type";
62 }
63
64 set.status = "OK";
65 if (fileScan.mimeType === "audio/x-flac") fileScan.mimeType = "audio/flac"; //seems to be better supported
66 set.headers["content-type"] = fileScan.mimeType;
67
68 const statResult = await stat(filePath);
69 const fileSize = statResult.size;
70
71 if (request.method === "HEAD") {
72 set.headers["accept-ranges"] = "bytes";
73 set.headers["content-length"] = fileSize;
74 return;
75 }
76
77 if (!headers.range) {
78 set.status = "OK";
79 return Bun.file(filePath);
80 }
81
82 const rangeData = parseRange(fileSize, headers.range, { combine: true });
83 if (rangeData === -1 || rangeData === -2 || rangeData.length > 1) {
84 set.status = "Range Not Satisfiable";
85 return "Range Not Satisfiable";
86 }
87 const { start, end } = rangeData[0];
88 set.status = "Partial Content";
89 set.headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
90 // set.headers["Content-Length"] = end-start+1; //currently broken in bun/elysiajs, not present in final response
91 //Bun.file().slice() should work, but currently only returns the chunk size as the total range, which confuses browsers
92 //using a stream here causes "Transfer-Encoding: chunked" to be set, but seems fine in practice
93 return createReadStream(filePath, { 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, decodeURI(params["*"]));
112 const fileScan = await getPathInfo(filePath);
113 if (!fileScan || fileScan instanceof ServerError) {
114 set.status = fileScan?.error ? "Internal Server Error" : "Bad Request";
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 console.log("Here");
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 new ElysiaFile(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