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