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} from "./utils";
15import { stat } from "node:fs/promises";
16
17import parseRange from "range-parser";
18import { createReadStream } from "node:fs";
19
20//TODO: transcoding cache?
21//TODO: add reasonable timeouts for caches
22//TODO: better ffmpeg errors
23
24const { values } = parseArgs({
25 args: Bun.argv,
26 options: {
27 serve: {
28 type: "string",
29 },
30 },
31 strict: true,
32 allowPositionals: true,
33});
34
35if (!values.serve) {
36 console.error("Please define the serve option");
37 process.exit(1);
38}
39
40const app = new Elysia()
41 .use(staticPlugin({ assets: values.serve, prefix: "/" }))
42 .post("/reset-cache", () => {
43 fileTypeCache.clear();
44 probeCache.clear();
45 })
46 .get("/download/*", async ({ headers, params, set }) => {
47 const filePath = path.join(musicRoot, decodeURI(params["*"]));
48 const fileScan = await getPathInfo(filePath);
49 if (!fileScan || fileScan instanceof ServerError) {
50 set.status = fileScan?.error ? "Internal Server Error" : "Bad Request";
51 return fileScan?.error || "Not a file";
52 }
53 if (!matchesType(fileScan.mimeType, allowedTypes)) {
54 set.status = "Forbidden";
55 return "Forbidden file type";
56 }
57
58 set.status = "OK";
59 if (fileScan.mimeType === "audio/x-flac") fileScan.mimeType = "audio/flac"; //seems to be better supported
60 set.headers["Content-Type"] = fileScan.mimeType;
61
62 if (!headers.range) {
63 //elysias automatically sets Accept-Ranges here, adding it manually causes it to duplicate
64 set.status = "OK";
65 return Bun.file(filePath);
66 }
67
68 set.headers["Accept-Ranges"] = "bytes";
69 // Range support, elysia advertises ranges, but doesn't actually fulfill them, which breaks e.g. safari
70 const statResult = await stat(filePath);
71 const fileSize = statResult.size;
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
78 const { start, end } = rangeData[0];
79 const chunkSize = end - start + 1;
80 const fileStream = createReadStream(filePath, { start, end });
81
82 set.status = "Partial Content";
83 set.headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
84 set.headers["Content-Length"] = chunkSize.toString();
85 return fileStream;
86 })
87 .get("/transcode/*", async ({ request, query, set, params }) => {
88 const filePath = path.join(musicRoot, decodeURI(params["*"]));
89 const fileScan = await getPathInfo(filePath);
90 if (!fileScan || fileScan instanceof ServerError) {
91 set.status = fileScan?.error ? "Internal Server Error" : "Bad Request";
92 return fileScan?.error || "Not a file";
93 }
94 if (!matchesType(fileScan.mimeType, allowedTypes)) {
95 set.status = "Forbidden";
96 return "Forbidden file type";
97 }
98 const bitrate = Number.parseInt(query.bitrate) || 96000;
99 if (!matchesType(fileScan.mimeType, mediaTypes) || !needsTranscoding(await probeFile(filePath), bitrate)) {
100 set.status = "Temporary Redirect";
101 set.headers.Location = `/download/${params["*"]}`;
102 return "No transcoding necessary or probe failed, redirecting to normal endpoint";
103 }
104
105 const cmd = convertWithFFmpeg(filePath, bitrate);
106 request.signal.addEventListener("abort", () => cmd.kill("SIGKILL"));
107 set.headers["content-type"] = "audio/opus";
108 return new Response(cmd.stdout);
109 })
110 .get(
111 "/list/*",
112 async ({ params, set, query }) => {
113 const dirPath = path.join(musicRoot, decodeURI(params["*"]));
114 const fileList = await listFiles(dirPath, query.recursive || false);
115 if (fileList instanceof ServerError) {
116 set.status = fileList.status;
117 return fileList.error;
118 }
119 const json = JSON.stringify(fileList);
120 set.headers["Content-Type"] = "application/json";
121 set.status = "OK";
122 return json;
123 },
124 { query: t.Optional(t.Object({ recursive: t.Boolean() })) },
125 )
126 .get("/cover/*", async ({ params, set }) => {
127 set.status = "Not Found";
128 const dirPath = path.join(musicRoot, decodeURI(params["*"]));
129 const fileScanResult = await findCover(dirPath);
130 if ("path" in fileScanResult) {
131 set.headers["Content-Type"] = fileScanResult.info.mimeType;
132 set.status = "OK";
133 return new ElysiaFile(fileScanResult.path);
134 }
135 set.status = fileScanResult.info.status;
136 return fileScanResult.info.error;
137 })
138 .listen(3000);
139
140console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);
141