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