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