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