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