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