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