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