utils.ts
⎇
Raw
1import { StatusMap } from "elysia";
2import { fileTypeFromFile } from "file-type";
3import { MediaFormat, type Directory, type FileListing } from "music-server-shared/types";
4import { readdir, stat } from "node:fs/promises";
5import path from "node:path";
6import { coverRegex, fileTypeCache, mediaTypes, musicRoot, PathInfo, probeCache, ServerError } from "./shared";
7
8export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => Promise<boolean>) {
9 const promises = arr.map(asyncCallback);
10 const results = await Promise.all(promises);
11 const index = results.findIndex((result) => result);
12 return arr[index];
13}
14
15type CoverResult = { path: string; info: PathInfo } | { info: ServerError };
16
17export async function findCover(dirPath: string): Promise<CoverResult> {
18 let coverFile: string | undefined;
19 try {
20 coverFile = await findAsync(await readdir(dirPath), async (file) => {
21 return file.match(coverRegex) != null && (await stat(path.join(dirPath, file))).isFile();
22 });
23 const above = path.dirname(dirPath);
24 if (!coverFile && isBelow(musicRoot, above)) {
25 return findCover(above);
26 }
27 // biome-ignore lint/suspicious/noExplicitAny:
28 } catch (error: any) {
29 if (error?.code !== "ENOENT") {
30 console.error("error getting cover: readdirSync failed:", error);
31 return { info: new ServerError(StatusMap["Internal Server Error"], "error getting cover: readdirSync failed") };
32 }
33 return { info: new ServerError(StatusMap["Not Found"], "Directory not found") };
34 }
35 if (coverFile) {
36 const info = (await getPathInfo(path.join(dirPath, coverFile))) as PathInfo | ServerError;
37 if (info instanceof ServerError) {
38 return { info: info };
39 }
40 return { path: path.join(dirPath, coverFile), info };
41 }
42 return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
43}
44
45export type FfprobeData = {
46 format: {
47 duration: number;
48 bit_rate: number;
49 };
50};
51
52// Rewritten to spawn ffprobe via bun.spawn
53export async function probeFile(filePath: string): Promise<FfprobeData> {
54 const cached = probeCache.get(filePath);
55 if (cached) {
56 return cached;
57 }
58
59 const ffprobeArgs = ["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath];
60
61 const proc = Bun.spawn(["ffprobe", ...ffprobeArgs], {
62 stdout: "pipe",
63 stderr: "pipe",
64 });
65 const text = await new Response(proc.stdout).text();
66 let json: FfprobeData;
67 try {
68 json = JSON.parse(text);
69 } catch (e) {
70 const errText = await new Response(proc.stderr).text();
71 throw new ServerError(StatusMap["Internal Server Error"], `ffprobe failed: ${errText || text}`);
72 }
73
74 probeCache.set(filePath, json);
75 return json;
76}
77
78export async function getPathInfo(filePath: string): Promise<PathInfo | ServerError | undefined> {
79 try {
80 const stats = await stat(filePath);
81 if (stats.isDirectory()) {
82 return undefined;
83 }
84 let mimeType = fileTypeCache.get(filePath);
85 if (!mimeType) {
86 const type = await fileTypeFromFile(filePath);
87 mimeType = type?.mime;
88 if (mimeType) {
89 fileTypeCache.set(filePath, mimeType);
90 } else {
91 console.error("Failed to determine MIME type for:", filePath);
92 return new ServerError(StatusMap["Internal Server Error"], "Filetype scan failed");
93 }
94 }
95 return new PathInfo(mimeType);
96 // biome-ignore lint/suspicious/noExplicitAny:
97 } catch (error: any) {
98 if (error?.code === "ENOENT") {
99 return new ServerError(StatusMap["Not Found"], "File not found");
100 }
101 console.error("Error scanning file:", filePath, error.message);
102 return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
103 }
104}
105
106export function matchesType(mimeType: string, types: string[]): boolean {
107 return types.includes(mimeType.split("/", 1)[0]);
108}
109
110export function needsTranscoding(probe: FfprobeData, requestBitrate?: unknown) {
111 const bitrate = Number(requestBitrate);
112 return probe.format.bit_rate && bitrate > 0 && bitrate < probe.format.bit_rate;
113}
114
115export function isBelow(basePath: string, targetPath: string) {
116 const relativePath = path.relative(basePath, targetPath);
117 return relativePath && !relativePath.startsWith("..");
118}
119
120export async function listFiles(subPath: string, recursive: boolean): Promise<Directory | ServerError> {
121 const files: FileListing = [];
122
123 try {
124 for (const file of await readdir(subPath)) {
125 const itemPath = path.join(subPath, file);
126 if (!isBelow(musicRoot, itemPath)) {
127 console.log(`Skipping ${itemPath} as it is outside the base path.`);
128 continue;
129 }
130
131 const pathInfoResult = await getPathInfo(itemPath);
132 if (!pathInfoResult) {
133 if (recursive) {
134 const subListing = await listFiles(itemPath, true);
135 if (subListing instanceof ServerError) throw subListing;
136 files.push({ name: file, files: subListing.files });
137 } else {
138 files.push({ name: file, files: [] });
139 }
140 } else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) {
141 let duration = 0;
142 try {
143 const probe = await probeFile(itemPath);
144 duration = probe.format.duration || 0;
145 } catch (probeError) {
146 console.error("ffprobe error:", probeError);
147 }
148 files.push({ name: file, duration: duration });
149 }
150 }
151 files.sort((a, b) => a.name.localeCompare(b.name));
152 return { name: path.basename(path.relative(musicRoot, subPath)), files };
153 // biome-ignore lint/suspicious/noExplicitAny: <explanation>
154 } catch (error: any) {
155 if (error?.code === "ENOENT") {
156 return new ServerError(StatusMap["Not Found"], "Directory not found");
157 }
158 console.error("Error listing path:", subPath, error.message);
159 return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
160 }
161}
162
163async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
164 const reader = stream.getReader();
165 const chunks: Uint8Array[] = [];
166
167 try {
168 while (true) {
169 const { done, value } = await reader.read();
170 if (done) break;
171 if (value) chunks.push(value);
172 }
173
174 const combined = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
175 let offset = 0;
176 for (const chunk of chunks) {
177 combined.set(chunk, offset);
178 offset += chunk.length;
179 }
180
181 return new TextDecoder().decode(combined);
182 } finally {
183 reader.releaseLock();
184 }
185}
186
187export function convertWithFFmpeg(filePath: string, bitrate: number, format: MediaFormat) {
188 let codec = undefined;
189 let container = undefined;
190 let mimeType = undefined;
191 switch (format) {
192 case MediaFormat["opus/ogg"]: {
193 codec = "libopus";
194 container = "ogg";
195 mimeType = 'audio/ogg; codecs="opus"';
196 break;
197 }
198 case MediaFormat["opus/webm"]: {
199 codec = "libopus";
200 container = "webm";
201 mimeType = 'audio/webm; codecs="opus"';
202 break;
203 }
204 case MediaFormat.mp3: {
205 codec = "libmp3lame";
206 container = "mp3";
207 mimeType = "audio/mpeg";
208 break;
209 }
210 case MediaFormat.vorbis: {
211 codec = "libvorbis";
212 container = "ogg";
213 mimeType = 'audio/ogg; codecs="vorbis"';
214 break;
215 }
216 default: {
217 throw new Error(`Unsupported format: ${format}`);
218 }
219 }
220 const cmd = Bun.spawn(
221 ["ffmpeg", "-i", filePath, "-vn", "-c:a", codec, "-f", container, "-b:a", bitrate.toString(), "-"],
222 {
223 stdin: "ignore",
224 stdout: "pipe",
225 stderr: "pipe",
226 onExit: async (subprocess: Bun.Subprocess, exitCode: number | null) => {
227 if (exitCode !== 0 && exitCode !== null) {
228 console.error("ffmpeg failed with status code", exitCode);
229 console.error(await streamToString(subprocess.stderr as ReadableStream));
230 }
231 },
232 },
233 );
234 return { cmd, mimeType };
235}
236
237export function packWithTar(files: string[]) {
238 console.log(files);
239 // Ensure all files are within musicRoot
240 const absoluteFiles = files.map((f) => path.join(musicRoot, decodeURI(f)));
241 for (const f of absoluteFiles) {
242 if (!isBelow(musicRoot, f)) {
243 throw new ServerError(StatusMap.Forbidden, "One or more files are outside the music root");
244 }
245 }
246
247 const relFiles = absoluteFiles.map((f) => path.relative(musicRoot, f));
248 const tarArgs = [
249 "--transform=s|.*/||", //only supported in gnu tar TODO: add fallback
250 "-C",
251 musicRoot,
252 "-c", // create archive
253 ...relFiles,
254 ];
255 return Bun.spawn(["tar", ...tarArgs], {
256 stdin: "ignore",
257 stdout: "pipe",
258 stderr: "pipe",
259 onExit: async (subprocess: Bun.Subprocess, exitCode: number | null) => {
260 if (exitCode !== 0 && exitCode !== null) {
261 console.error("tar failed with status code", exitCode);
262 console.error(await streamToString(subprocess.stderr as ReadableStream));
263 }
264 },
265 });
266}
267