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