index.ts
⎇
Raw
1import staticPlugin from "@elysiajs/static";
2import { Elysia, ElysiaFile, type InferHandler, t } from "elysia";
3import path from "node:path";
4import { parseArgs } from "node:util";
5import { musicRoot, fileTypeCache, ServerError, allowedTypes, probeCache, mediaTypes } from "./shared";
6import { getPathInfo, listFiles, findCover, matchesType, convertWithFFmpeg, packWithTar, probeFile } from "./utils";
7import { stat } from "node:fs/promises";
8
9import parseRange from "range-parser";
10import { createReadStream, ReadStream } from "node:fs";
11import { MediaFormat } from "music-server-shared/types";
12import { stringifyMap } from "music-server-shared/mapconversion";
13
14//TODO: transcoding cache?
15//TODO: add reasonable timeouts for caches
16//TODO: better ffmpeg errors
17
18const { values } = parseArgs({
19 args: Bun.argv,
20 options: {
21 serve: {
22 type: "string",
23 },
24 },
25 strict: true,
26 allowPositionals: true,
27});
28
29if (!values.serve) {
30 console.error("Please define the serve option");
31 process.exit(1);
32}
33
34const setup = new Elysia();
35
36const paramType = t.ArrayString();
37type DownloadHandler = InferHandler<typeof setup, "/download/*", { params: typeof paramType }>;
38
39const downloadHandler: DownloadHandler = async ({ headers, params, set, request }) => {
40 if (request.method !== "GET" && request.method !== "HEAD") {
41 set.status = "Not Found";
42 return "NOT_FOUND";
43 }
44 const filePath = path.join(musicRoot, decodeURI(params["*"]));
45 const fileScan = await getPathInfo(filePath);
46 if (!fileScan || fileScan instanceof ServerError) {
47 set.status = fileScan?.error ? "Internal Server Error" : "Bad Request";
48 return fileScan?.error || "Not a file";
49 }
50 if (!matchesType(fileScan.mimeType, allowedTypes)) {
51 set.status = "Forbidden";
52 return "Forbidden file type";
53 }
54
55 set.status = "OK";
56 if (fileScan.mimeType === "audio/x-flac") fileScan.mimeType = "audio/flac"; //seems to be better supported
57 set.headers["content-type"] = fileScan.mimeType;
58
59 const statResult = await stat(filePath);
60 const fileSize = statResult.size;
61
62 if (request.method === "HEAD") {
63 set.headers["accept-ranges"] = "bytes";
64 set.headers["content-length"] = fileSize;
65 return;
66 }
67
68 if (!headers.range) {
69 set.status = "OK";
70 return Bun.file(filePath);
71 }
72
73 const rangeData = parseRange(fileSize, headers.range, { combine: true });
74 if (rangeData === -1 || rangeData === -2 || rangeData.length > 1) {
75 set.status = "Range Not Satisfiable";
76 return "Range Not Satisfiable";
77 }
78 const { start, end } = rangeData[0];
79 set.status = "Partial Content";
80 set.headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
81 // set.headers["Content-Length"] = end-start+1; //currently broken in bun/elysiajs, not present in final response
82 //Bun.file().slice() should work, but currently only returns the chunk size as the total range, which confuses browsers
83 //using a stream here causes "Transfer-Encoding: chunked" to be set, but seems fine in practice
84 return createReadStream(filePath, { start, end });
85};
86
87const app = setup
88 .use(staticPlugin({ assets: values.serve, prefix: "/" }))
89 .onBeforeHandle(({ request, path }) => {
90 if (path === "/remote-log") return;
91 console.info(request.method, path);
92 })
93 .post("/reset-cache", () => {
94 fileTypeCache.clear();
95 probeCache.clear();
96 })
97 .get("/download/*", downloadHandler)
98 .head("/download/*", downloadHandler)
99 .get(
100 "/transcode/*",
101 async ({ request, query, set, params }) => {
102 const filePath = path.join(musicRoot, decodeURI(params["*"]));
103 const fileScan = await getPathInfo(filePath);
104 if (!fileScan || fileScan instanceof ServerError) {
105 set.status = fileScan?.error ? "Internal Server Error" : "Bad Request";
106 return fileScan?.error || "Not a file";
107 }
108 if (!matchesType(fileScan.mimeType, allowedTypes)) {
109 set.status = "Forbidden";
110 return "Forbidden file type";
111 }
112 const probe = await probeFile(filePath);
113 //don't use higher bitrate than what the file has, use requested bitrate if unknown, default to 96kbit if not provided
114 const bitrate = query.bitrate && probe.bitrate ? Math.min(query.bitrate, probe.bitrate) : query.bitrate || 96000;
115 if (!matchesType(fileScan.mimeType, mediaTypes)) {
116 set.status = "Temporary Redirect";
117 set.headers.Location = `/download/${params["*"]}`;
118 return "Not a media file, redirecting to normal endpoint";
119 }
120
121 const { cmd, mimeType } = convertWithFFmpeg(filePath, bitrate, query.format ?? MediaFormat["opus/ogg"]);
122 request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
123 set.headers["content-type"] = mimeType;
124 return new Response(cmd.stdout);
125 },
126 { query: t.Optional(t.Object({ bitrate: t.Number(), format: t.Enum(MediaFormat) })) },
127 )
128 .get(
129 "/list/*",
130 async ({ params, set, query }) => {
131 const dirPath = path.join(musicRoot, decodeURI(params["*"]));
132 const fileList = await listFiles(dirPath, query.recursive || false);
133 if (fileList instanceof ServerError) {
134 set.status = fileList.status;
135 return fileList.error;
136 }
137 set.headers["Content-Type"] = "application/json";
138 set.status = "OK";
139 return stringifyMap(fileList);
140 },
141 { query: t.Optional(t.Object({ recursive: t.Boolean() })) },
142 )
143 .get("/cover/*", async ({ params, set }) => {
144 set.status = "Not Found";
145 const dirPath = path.join(musicRoot, decodeURI(params["*"]));
146 const fileScanResult = await findCover(dirPath);
147 if ("path" in fileScanResult) {
148 set.headers["Content-Type"] = fileScanResult.info.mimeType;
149 set.status = "OK";
150 return new ElysiaFile(fileScanResult.path);
151 }
152 set.status = fileScanResult.info.status;
153 return fileScanResult.info.error;
154 })
155 .post(
156 "/download-playlist",
157 async ({ set, body: { files } }) => {
158 set.status = "OK";
159 set.headers["Content-Type"] = "application/x-tar";
160 set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
161 //needed to support stream download with complex body in request
162 return new Response(packWithTar(JSON.parse(decodeURIComponent(atob(files))) as string[]).stdout);
163 },
164 { body: t.Object({ files: t.String({ minLength: 1 }) }) },
165 )
166 .post("remote-log", ({ body }) => {
167 console.log(body);
168 })
169
170 .listen(3000);
171
172console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);
173