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