index.ts
⎇
Raw
1import staticPlugin from "@elysiajs/static";
2import { Elysia, ElysiaFile, 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 } 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 app = new Elysia()
43 .use(staticPlugin({ assets: values.serve, prefix: "/" }))
44 .post("/reset-cache", () => {
45 fileTypeCache.clear();
46 probeCache.clear();
47 })
48 .get("/download/*", async ({ headers, params, set }) => {
49 const filePath = path.join(musicRoot, decodeURI(params["*"]));
50 const fileScan = await getPathInfo(filePath);
51 if (!fileScan || fileScan instanceof ServerError) {
52 set.status = fileScan?.error ? "Internal Server Error" : "Bad Request";
53 return fileScan?.error || "Not a file";
54 }
55 if (!matchesType(fileScan.mimeType, allowedTypes)) {
56 set.status = "Forbidden";
57 return "Forbidden file type";
58 }
59
60 set.status = "OK";
61 if (fileScan.mimeType === "audio/x-flac") fileScan.mimeType = "audio/flac"; //seems to be better supported
62 set.headers["Content-Type"] = fileScan.mimeType;
63
64 if (!headers.range) {
65 //elysias automatically sets Accept-Ranges here, adding it manually causes it to duplicate
66 set.status = "OK";
67 return Bun.file(filePath);
68 }
69
70 set.headers["Accept-Ranges"] = "bytes";
71 // Range support, elysia advertises ranges, but doesn't actually fulfill them, which breaks e.g. safari
72 const statResult = await stat(filePath);
73 const fileSize = statResult.size;
74 const rangeData = parseRange(fileSize, headers.range, { combine: true });
75 if (rangeData === -1 || rangeData === -2 || rangeData.length > 1) {
76 set.status = "Range Not Satisfiable";
77 return "Range Not Satisfiable";
78 }
79
80 const { start, end } = rangeData[0];
81 const chunkSize = end - start + 1;
82 const fileStream = createReadStream(filePath, { start, end });
83
84 set.status = "Partial Content";
85 set.headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
86 set.headers["Content-Length"] = chunkSize; //currently broken in bun/elysiajs, not present in final response
87 return fileStream;
88 })
89 .get(
90 "/transcode/*",
91 async ({ request, query, set, params }) => {
92 const filePath = path.join(musicRoot, decodeURI(params["*"]));
93 const fileScan = await getPathInfo(filePath);
94 if (!fileScan || fileScan instanceof ServerError) {
95 set.status = fileScan?.error ? "Internal Server Error" : "Bad Request";
96 return fileScan?.error || "Not a file";
97 }
98 if (!matchesType(fileScan.mimeType, allowedTypes)) {
99 set.status = "Forbidden";
100 return "Forbidden file type";
101 }
102 const bitrate = query.bitrate || 96000;
103 if (!matchesType(fileScan.mimeType, mediaTypes) || !needsTranscoding(await probeFile(filePath), bitrate)) {
104 set.status = "Temporary Redirect";
105 set.headers.Location = `/download/${params["*"]}`;
106 return "No transcoding necessary or probe failed, redirecting to normal endpoint";
107 }
108
109 const { cmd, mimeType } = convertWithFFmpeg(filePath, bitrate, query.format ?? MediaFormat["opus/ogg"]);
110 request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
111 set.headers["content-type"] = mimeType;
112 return new Response(cmd.stdout);
113 },
114 { query: t.Optional(t.Object({ bitrate: t.Number(), format: t.Enum(MediaFormat) })) },
115 )
116 .get(
117 "/list/*",
118 async ({ params, set, query }) => {
119 const dirPath = path.join(musicRoot, decodeURI(params["*"]));
120 const fileList = await listFiles(dirPath, query.recursive || false);
121 if (fileList instanceof ServerError) {
122 set.status = fileList.status;
123 return fileList.error;
124 }
125 const json = JSON.stringify(fileList);
126 set.headers["Content-Type"] = "application/json";
127 set.status = "OK";
128 return json;
129 },
130 { query: t.Optional(t.Object({ recursive: t.Boolean() })) },
131 )
132 .get("/cover/*", async ({ params, set }) => {
133 set.status = "Not Found";
134 const dirPath = path.join(musicRoot, decodeURI(params["*"]));
135 const fileScanResult = await findCover(dirPath);
136 if ("path" in fileScanResult) {
137 set.headers["Content-Type"] = fileScanResult.info.mimeType;
138 set.status = "OK";
139 return new ElysiaFile(fileScanResult.path);
140 }
141 set.status = fileScanResult.info.status;
142 return fileScanResult.info.error;
143 })
144 .post(
145 "/download-playlist",
146 async ({ set, body: { files } }) => {
147 console.log(files);
148 set.status = "OK";
149 set.headers["Content-Type"] = "application/x-tar";
150 set.headers["Content-Disposition"] = `attachment; filename="playlist.tar"`;
151 //needed to support stream download with complex body in request
152 return new Response(packWithTar(JSON.parse(decodeURIComponent(atob(files))) as string[]).stdout);
153 },
154 { body: t.Object({ files: t.String({ minLength: 1 }) }) },
155 )
156
157 .listen(3000);
158
159console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);
160