utils.ts
⎇
Raw
1import { readFileSync, writeFileSync } from "node:fs";
2import { readdir, stat } from "node:fs/promises";
3import path from "node:path";
4import { StatusMap } from "elysia";
5import { fileTypeFromBlob } from "file-type";
6import { parseFile } from "music-metadata";
7import type { FileListingWithStatus, Metadata } from "music-server-shared/types";
8import tmp from "tmp";
9import {
10 coverRegex,
11 excludeExtension,
12 fileTypeCache,
13 mediaTypes,
14 musicRoot,
15 PathInfo,
16 probeCache,
17 ServerError,
18 scanConcurrency,
19} from "./shared";
20
21//fs errors are untyped, so pull the errno code out of the unknown catch value
22function errorCode(error: unknown): string | undefined {
23 return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
24}
25
26export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => Promise<boolean>) {
27 const promises = arr.map(asyncCallback);
28 const results = await Promise.all(promises);
29 const index = results.findIndex((result) => result);
30 return arr[index];
31}
32
33type CoverResult =
34 | { info: PathInfo; path: string }
35 | { info: PathInfo; content: Uint8Array<ArrayBuffer> }
36 | { info: ServerError };
37
38//the mediainfo CLI JSON output emits every value as a string; broken text encodings arrive
39//as objects carrying an "@dt" attribute instead (detected in probeFile)
40interface MediaInfoTrack {
41 "@type": string;
42 [field: string]: string | undefined;
43}
44interface MediaInfoResult {
45 media?: {
46 track: MediaInfoTrack[];
47 } | null;
48}
49
50//bound the number of concurrent mediainfo processes while scanning uncached folders
51class Semaphore {
52 private running = 0;
53 private waitQueue: (() => void)[] = [];
54
55 constructor(private maxSize: number) {}
56
57 async acquire(): Promise<void> {
58 if (this.running < this.maxSize) {
59 this.running++;
60 return;
61 }
62 //the released slot is handed over directly, so running stays unchanged
63 return new Promise((resolve) => this.waitQueue.push(resolve));
64 }
65
66 release(): void {
67 const next = this.waitQueue.shift();
68 if (next) next();
69 else this.running--;
70 }
71}
72
73const mediaInfoSemaphore = new Semaphore(scanConcurrency);
74
75export async function findCover(targetPath: string): Promise<CoverResult> {
76 try {
77 if ((await stat(targetPath)).isFile()) {
78 //TODO
79 //duration is not needed for cover extraction and would force a full-file parse for some formats
80 const parsed = await parseFile(targetPath, { duration: false, skipCovers: false });
81 if (!parsed.common.picture || parsed.common.picture.length === 0) return findCover(path.dirname(targetPath));
82
83 return {
84 info: new PathInfo(parsed.common.picture[0].format),
85 content: parsed.common.picture[0].data as Uint8Array<ArrayBuffer>,
86 };
87 }
88 } catch (e) {
89 return { info: new ServerError(StatusMap["Internal Server Error"], `error getting cover ${e}`) };
90 }
91
92 if (!coverRegex) return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
93 let coverFile: string | undefined;
94 try {
95 coverFile = await findAsync(await readdir(targetPath), async (file) => {
96 return file.match(coverRegex) != null && (await stat(path.join(targetPath, file))).isFile();
97 });
98 const above = path.dirname(targetPath);
99 if (!coverFile && isBelow(musicRoot, above)) {
100 return findCover(above);
101 }
102 } catch (error) {
103 if (errorCode(error) !== "ENOENT") {
104 console.error("error getting cover: readdirSync failed:", error);
105 return { info: new ServerError(StatusMap["Internal Server Error"], "error getting cover: readdirSync failed") };
106 }
107 return { info: new ServerError(StatusMap["Not Found"], "Directory not found") };
108 }
109 if (coverFile) {
110 const info = (await getPathInfo(path.join(targetPath, coverFile))) as PathInfo | ServerError;
111 if (info instanceof ServerError) {
112 return { info: info };
113 }
114 return { path: path.join(targetPath, coverFile), info };
115 }
116 return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
117}
118
119export async function analyzeWithMediainfo(filePath: string): Promise<MediaInfoResult> {
120 await mediaInfoSemaphore.acquire();
121 try {
122 const cmd = Bun.spawn(["mediainfo", "--Output=JSON", filePath], {
123 stdin: "ignore",
124 stdout: "pipe",
125 stderr: "pipe",
126 });
127 const [output, stderr, exitCode] = await Promise.all([
128 new Response(cmd.stdout).text(),
129 new Response(cmd.stderr).text(),
130 cmd.exited,
131 ]);
132 if (exitCode !== 0) throw new Error(`mediainfo failed with status code ${exitCode}: ${stderr}`);
133 return JSON.parse(output);
134 } finally {
135 mediaInfoSemaphore.release();
136 }
137}
138
139//in-flight probes keyed by path, so concurrent callers for the same uncached file share one analysis
140const inflightProbes = new Map<string, Promise<Metadata>>();
141
142export async function probeFile(filePath: string): Promise<Metadata> {
143 const cached = probeCache.get(filePath);
144 if (cached) return cached;
145
146 const inflight = inflightProbes.get(filePath);
147 if (inflight) return inflight;
148
149 const promise = (async (): Promise<Metadata> => {
150 const info = await analyzeWithMediainfo(filePath);
151 // for (const track of info.media?.track || []) {
152 // console.log(track);
153 // }
154 const generalTrack = info.media?.track.find((track) => track["@type"] === "General");
155 const videoTrack = info.media?.track.find((track) => track["@type"] === "Video");
156 const audioTrack = info.media?.track.find((track) => track["@type"] === "Audio");
157 const audioTracks = (info.media?.track || [])
158 .filter((track) => track["@type"] === "Audio")
159 .map((track) => ({ language: track.Language, default: track.Default === "Yes" }));
160 if (!generalTrack?.Duration) throw new Error();
161 //the CLI emits all values as strings, so numeric fields need explicit coercion
162 const num = (value: string | undefined) => (value === undefined ? undefined : Number(value));
163 let probeResult: Metadata = {
164 duration: Number(generalTrack.Duration),
165 title: generalTrack.Title,
166 artist: generalTrack.Performer || generalTrack.Album_Performer,
167 album: generalTrack.Album,
168 genre: [generalTrack.Genre || ""],
169
170 trackNumber: num(generalTrack.Track_Position),
171 container: generalTrack.Format,
172 audioCodec: audioTrack?.Format,
173 audioBitrate: num(audioTrack?.BitRate),
174 videoCodec: videoTrack?.Format,
175 videoBitrate: num(videoTrack?.BitRate),
176 audioTracks,
177 };
178
179 //if file has broken encodings in their metadata, the "string" are actually objects, and contain this "@dt" field
180 //music-metadata can usually parse the correctly anyway, so use the library in that case
181 if (Object.values(probeResult).some((field) => typeof field === "object" && "@dt" in field)) {
182 const parsed = await parseFile(filePath, { duration: true, skipCovers: false, mkvUseIndex: true });
183 probeResult = {
184 ...probeResult,
185 title: parsed.common.title,
186 artist: parsed.common.artist,
187 album: parsed.common.album,
188 genre: parsed.common.genre,
189 trackNumber: parsed.common.track.no || undefined,
190 };
191 }
192 //only cache on success so a rejected probe can be retried later
193 probeCache.set(filePath, probeResult);
194 return probeResult;
195 })();
196
197 inflightProbes.set(filePath, promise);
198 try {
199 return await promise;
200 } finally {
201 inflightProbes.delete(filePath);
202 }
203}
204
205export async function getPathInfo(filePath: string): Promise<PathInfo | ServerError | undefined> {
206 try {
207 if (excludeExtension.includes(path.extname(filePath).slice(1))) return new ServerError(404, "Excluded extension");
208 const stats = await stat(filePath);
209 if (stats.isDirectory()) {
210 return undefined;
211 }
212 let mimeType = fileTypeCache.get(filePath);
213 if (!mimeType) {
214 //fileTypeFromFile does a runtime import("strtok3") that breaks inside bun-compiled binaries,
215 //fileTypeFromBlob uses the statically bundled strtok3/core instead
216 const type = await fileTypeFromBlob(Bun.file(filePath));
217 mimeType = type?.mime;
218 if (!mimeType) {
219 //fileTypeFromBlob gives up on some valid media (e.g. MP3s padded before the first frame sync).
220 //confirm the file is genuinely decodable with mediainfo (probeFile caches the result, so the probe
221 //listFiles runs next for this same file is a cache hit, not a second mediainfo spawn), then take the
222 //MIME type from the extension via Bun's mime-db, accepting only audio/video types.
223 try {
224 await probeFile(filePath);
225 const byExtension = Bun.file(filePath).type.split(";", 1)[0];
226 if (matchesType(byExtension, mediaTypes)) mimeType = byExtension;
227 } catch {
228 //not decodable media (e.g. a genuinely unrecognised file); fall through to the error below
229 }
230 }
231 if (mimeType) {
232 fileTypeCache.set(filePath, mimeType);
233 } else {
234 console.error("Failed to determine MIME type for:", filePath);
235 return new ServerError(StatusMap["Internal Server Error"], "Filetype scan failed");
236 }
237 }
238 return new PathInfo(mimeType);
239 } catch (error) {
240 if (errorCode(error) === "ENOENT") {
241 return new ServerError(StatusMap["Not Found"], "File not found");
242 }
243 console.error("Error scanning file:", filePath, error);
244 return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
245 }
246}
247
248export function matchesType(mimeType: string, types: string[]): boolean {
249 return types.includes(mimeType.split("/", 1)[0]);
250}
251
252export function isBelow(basePath: string, targetPath: string) {
253 const relativePath = path.relative(basePath, targetPath);
254 return relativePath && !relativePath.startsWith("..");
255}
256
257export async function listFiles(subPath: string, recursive: boolean): Promise<FileListingWithStatus | ServerError> {
258 const files: FileListingWithStatus = new Map();
259
260 try {
261 //process entries concurrently; the MediaInfo pool already bounds the expensive probing part
262 await Promise.all(
263 (await readdir(subPath)).map(async (fileName) => {
264 const itemPath = path.join(subPath, fileName);
265 if (!isBelow(musicRoot, itemPath)) {
266 console.log(`Skipping ${itemPath} as it is outside the base path.`);
267 return;
268 }
269
270 const pathInfoResult = await getPathInfo(itemPath);
271 if (!pathInfoResult) {
272 if (recursive) {
273 const subListing = await listFiles(itemPath, true);
274 if (subListing instanceof ServerError) throw subListing;
275 files.set(fileName, { files: subListing, status: "Scanned" });
276 } else {
277 files.set(fileName, { files: new Map(), status: "Unknown" });
278 }
279 } else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) {
280 try {
281 const probe = await probeFile(itemPath);
282 files.set(fileName, { metadata: probe });
283 } catch (probeError) {
284 console.error("probing error:", probeError);
285 }
286 }
287 }),
288 );
289 return files;
290 } catch (error) {
291 if (errorCode(error) === "ENOENT") {
292 return new ServerError(StatusMap["Not Found"], "Directory not found");
293 }
294 console.error("Error listing path:", subPath, error);
295 return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
296 }
297}
298
299export async function readStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
300 const reader = stream.getReader();
301 const chunks: Uint8Array[] = [];
302
303 try {
304 while (true) {
305 const { done, value } = await reader.read();
306 if (done) break;
307 if (value) chunks.push(value);
308 }
309
310 const combined = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
311 let offset = 0;
312 for (const chunk of chunks) {
313 combined.set(chunk, offset);
314 offset += chunk.length;
315 }
316
317 return combined;
318 } finally {
319 reader.releaseLock();
320 }
321}
322
323export function packWithTar(files: string[]) {
324 // Ensure all files are within musicRoot
325 const absoluteFiles = files.map((f) => path.join(musicRoot, f));
326 for (const f of absoluteFiles) {
327 if (!isBelow(musicRoot, f)) {
328 throw new ServerError(StatusMap.Forbidden, "One or more files are outside the music root");
329 }
330 }
331
332 const relFiles = absoluteFiles.map((f) => path.relative(musicRoot, f));
333 const tarArgs = [
334 "--transform=s|.*/||", //only supported in gnu tar TODO: add fallback
335 "-C",
336 musicRoot,
337 "-c", // create archive
338 ...relFiles,
339 ];
340 return Bun.spawn(["tar", ...tarArgs], {
341 stdin: "ignore",
342 stdout: "pipe",
343 stderr: "pipe",
344 onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
345 if (exitCode !== 0 && exitCode !== null) {
346 console.error("tar failed with status code", exitCode);
347 console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
348 }
349 },
350 });
351}
352
353//copy to a temp file and then use avifenc to convert
354export async function toAvif(
355 data: Uint8Array<ArrayBuffer>,
356 mimeType: string,
357 setContentType: (mimeType: string) => void,
358): Promise<Uint8Array<ArrayBuffer>> {
359 const tmpFile = tmp.fileSync({
360 postfix: mimeType.includes("image/jpeg") ? ".jpg" : mimeType.includes("image/png") ? ".png" : undefined,
361 });
362 const tmpFile2 = tmp.fileSync({ postfix: ".avif" });
363 try {
364 writeFileSync(tmpFile.fd, data);
365 const cmd = Bun.spawn(["avifenc", "-q", "50", tmpFile.name, tmpFile2.name], {
366 stdin: "ignore",
367 stdout: "pipe",
368 stderr: "pipe",
369 onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
370 if (exitCode !== 0 && exitCode !== null) {
371 console.error("avifenc failed with status code", exitCode);
372 console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
373 }
374 },
375 });
376 await cmd.exited;
377 setContentType("image/avif");
378 return readFileSync(tmpFile2.fd) as Uint8Array<ArrayBuffer>;
379 } catch {
380 setContentType(mimeType);
381 return data;
382 } finally {
383 tmpFile.removeCallback();
384 tmpFile2.removeCallback();
385 }
386}
387