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