utils.ts
⎇
Raw
1import { StatusMap } from "elysia";
2import { fileTypeFromFile } from "file-type";
3import type { Directory, AudioFile } 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 console.log("scanned MIME type:", mimeType, "for", filePath);
90 fileTypeCache.set(filePath, mimeType);
91 } else {
92 console.error("Failed to determine MIME type for:", filePath);
93 return new ServerError(StatusMap["Internal Server Error"], "Filetype scan failed");
94 }
95 } else {
96 console.log("cached MIME type:", mimeType, "for", filePath);
97 }
98 return new PathInfo(mimeType);
99 // biome-ignore lint/suspicious/noExplicitAny:
100 } catch (error: any) {
101 if (error?.code === "ENOENT") {
102 return new ServerError(StatusMap["Not Found"], "File not found");
103 }
104 console.error("Error scanning file:", filePath, error.message);
105 return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
106 }
107}
108
109export function matchesType(mimeType: string, types: string[]): boolean {
110 return types.includes(mimeType.split("/", 1)[0]);
111}
112
113export function needsTranscoding(probe: FfprobeData, requestBitrate?: unknown) {
114 const bitrate = Number(requestBitrate);
115 return probe.format.bit_rate && bitrate > 0 && bitrate < probe.format.bit_rate;
116}
117
118export function isBelow(basePath: string, targetPath: string) {
119 const relativePath = path.relative(basePath, targetPath);
120 return relativePath && !relativePath.startsWith("..");
121}
122
123export async function listFiles(subPath: string, recursive: boolean): Promise<Directory | ServerError> {
124 const files: (Directory | AudioFile)[] = [];
125
126 try {
127 for (const file of await readdir(subPath)) {
128 const itemPath = path.join(subPath, file);
129 if (!isBelow(musicRoot, itemPath)) {
130 console.log(`Skipping ${itemPath} as it is outside the base path.`);
131 continue;
132 }
133
134 const pathInfoResult = await getPathInfo(itemPath);
135 if (!pathInfoResult) {
136 if (recursive) {
137 const subListing = await listFiles(itemPath, true);
138 if (subListing instanceof ServerError) throw subListing;
139 files.push({ name: file, files: subListing.files });
140 } else {
141 files.push({ name: file, files: [] });
142 }
143 } else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) {
144 let duration = 0;
145 try {
146 const probe = await probeFile(itemPath);
147 duration = probe.format.duration || 0;
148 } catch (probeError) {
149 console.error("ffprobe error:", probeError);
150 }
151 files.push({ name: file, duration: duration });
152 }
153 }
154 files.sort((a, b) => a.name.localeCompare(b.name));
155 return { name: path.basename(path.relative(musicRoot, subPath)), files };
156 // biome-ignore lint/suspicious/noExplicitAny: <explanation>
157 } catch (error: any) {
158 if (error?.code === "ENOENT") {
159 return new ServerError(StatusMap["Not Found"], "Directory not found");
160 }
161 console.error("Error listing path:", subPath, error.message);
162 return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
163 }
164}
165
166async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
167 const reader = stream.getReader();
168 const chunks: Uint8Array[] = [];
169
170 try {
171 while (true) {
172 const { done, value } = await reader.read();
173 if (done) break;
174 if (value) chunks.push(value);
175 }
176
177 const combined = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
178 let offset = 0;
179 for (const chunk of chunks) {
180 combined.set(chunk, offset);
181 offset += chunk.length;
182 }
183
184 return new TextDecoder().decode(combined);
185 } finally {
186 reader.releaseLock();
187 }
188}
189
190export function convertWithFFmpeg(filePath: string, bitrate: number) {
191 const cmd = Bun.spawn(["ffmpeg", "-i", filePath, "-f", "opus", "-b:a", bitrate.toString(), "-"], {
192 stdin: "ignore",
193 stdout: "pipe",
194 stderr: "pipe",
195 onExit: async (subprocess: Bun.Subprocess, exitCode: number | null) => {
196 if (exitCode !== 0 && exitCode !== null) {
197 console.error("ffmpeg failed with status code", exitCode);
198 console.error(await streamToString(subprocess.stderr as ReadableStream));
199 }
200 },
201 });
202 return cmd;
203}
204