utils.ts
⎇
Raw
1import { StatusMap } from "elysia";
2import { fileTypeFromFile } from "file-type";
3import {
4 AudioCodec,
5 type FileListingWithStatus,
6 MediaContainer,
7 type Metadata,
8 VideoCodec,
9} from "music-server-shared/types";
10import { readdir, stat } from "node:fs/promises";
11import path from "node:path";
12import {
13 coverRegex,
14 excludeExtension,
15 fileTypeCache,
16 mediaInfoDisabled,
17 mediaTypes,
18 musicRoot,
19 PathInfo,
20 probeCache,
21 ServerError,
22} from "./shared";
23import { parseFile } from "music-metadata";
24import tmp from "tmp";
25import { readFileSync, writeFileSync } from "node:fs";
26import MediaInfo from "mediainfo.js";
27
28export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => Promise<boolean>) {
29 const promises = arr.map(asyncCallback);
30 const results = await Promise.all(promises);
31 const index = results.findIndex((result) => result);
32 return arr[index];
33}
34
35type CoverResult = { info: PathInfo; path: string } | { info: PathInfo; content: Uint8Array } | { info: ServerError };
36
37const mediaInfo = await MediaInfo();
38
39export async function findCover(targetPath: string): Promise<CoverResult> {
40 try {
41 if ((await stat(targetPath)).isFile()) {
42 const parsed = await parseFile(targetPath, { duration: true, skipCovers: false });
43 if (!parsed.common.picture || parsed.common.picture.length === 0) return findCover(path.dirname(targetPath));
44
45 return { info: new PathInfo(parsed.common.picture[0].format), content: parsed.common.picture[0].data };
46 }
47 } catch (e) {
48 return { info: new ServerError(StatusMap["Internal Server Error"], `error getting cover ${e}`) };
49 }
50
51 if (!coverRegex) return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
52 let coverFile: string | undefined;
53 try {
54 coverFile = await findAsync(await readdir(targetPath), async (file) => {
55 return file.match(coverRegex) != null && (await stat(path.join(targetPath, file))).isFile();
56 });
57 const above = path.dirname(targetPath);
58 if (!coverFile && isBelow(musicRoot, above)) {
59 return findCover(above);
60 }
61 // biome-ignore lint/suspicious/noExplicitAny:
62 } catch (error: any) {
63 if (error?.code !== "ENOENT") {
64 console.error("error getting cover: readdirSync failed:", error);
65 return { info: new ServerError(StatusMap["Internal Server Error"], "error getting cover: readdirSync failed") };
66 }
67 return { info: new ServerError(StatusMap["Not Found"], "Directory not found") };
68 }
69 if (coverFile) {
70 const info = (await getPathInfo(path.join(targetPath, coverFile))) as PathInfo | ServerError;
71 if (info instanceof ServerError) {
72 return { info: info };
73 }
74 return { path: path.join(targetPath, coverFile), info };
75 }
76 return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
77}
78
79export async function probeFile(filePath: string): Promise<Metadata> {
80 const cached = probeCache.get(filePath);
81 if (cached) return cached;
82 const parsed = await parseFile(filePath, { duration: true, skipCovers: false });
83
84 let probeData: Metadata = {
85 duration: parsed.format.duration || 0,
86 title: parsed.common.title,
87 artist: parsed.common.artist,
88 album: parsed.common.album,
89 genre: parsed.common.genre,
90 trackNumber: parsed.common.track.no || undefined,
91 };
92
93 if (!mediaInfoDisabled) {
94 const file = Bun.file(filePath);
95 const fileSize = (await file.stat()).size;
96 const chunkReader = async (size: number, offset: number) => {
97 return await file.slice(offset, offset + size).bytes();
98 };
99 const info = await mediaInfo.analyzeData(fileSize, chunkReader);
100 probeData = {
101 ...probeData,
102 audioCodec: info.media?.track.find((track) => track["@type"] === "Audio")?.Format,
103 audioBitrate: info.media?.track.find((track) => track["@type"] === "Audio")?.BitRate,
104 videoCodec: info.media?.track.find((track) => track["@type"] === "Video")?.Format,
105 videoBitrate: info.media?.track.find((track) => track["@type"] === "Video")?.BitRate,
106 container: info.media?.track.find((track) => track["@type"] === "General")?.Format,
107 };
108 }
109 probeCache.set(filePath, probeData);
110 return probeData;
111}
112
113export async function getPathInfo(filePath: string): Promise<PathInfo | ServerError | undefined> {
114 try {
115 if (excludeExtension.includes(path.extname(filePath).slice(1))) return new ServerError(404, "Excluded extension");
116 const stats = await stat(filePath);
117 if (stats.isDirectory()) {
118 return undefined;
119 }
120 let mimeType = fileTypeCache.get(filePath);
121 if (!mimeType) {
122 const type = await fileTypeFromFile(filePath);
123 mimeType = type?.mime;
124 if (mimeType) {
125 fileTypeCache.set(filePath, mimeType);
126 } else {
127 console.error("Failed to determine MIME type for:", filePath);
128 return new ServerError(StatusMap["Internal Server Error"], "Filetype scan failed");
129 }
130 }
131 return new PathInfo(mimeType);
132 // biome-ignore lint/suspicious/noExplicitAny:
133 } catch (error: any) {
134 if (error?.code === "ENOENT") {
135 return new ServerError(StatusMap["Not Found"], "File not found");
136 }
137 console.error("Error scanning file:", filePath, error.message);
138 return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
139 }
140}
141
142export function matchesType(mimeType: string, types: string[]): boolean {
143 return types.includes(mimeType.split("/", 1)[0]);
144}
145
146export function isBelow(basePath: string, targetPath: string) {
147 const relativePath = path.relative(basePath, targetPath);
148 return relativePath && !relativePath.startsWith("..");
149}
150
151export async function listFiles(subPath: string, recursive: boolean): Promise<FileListingWithStatus | ServerError> {
152 const files: FileListingWithStatus = new Map();
153
154 try {
155 for (const fileName of await readdir(subPath)) {
156 const itemPath = path.join(subPath, fileName);
157 if (!isBelow(musicRoot, itemPath)) {
158 console.log(`Skipping ${itemPath} as it is outside the base path.`);
159 continue;
160 }
161
162 const pathInfoResult = await getPathInfo(itemPath);
163 if (!pathInfoResult) {
164 if (recursive) {
165 const subListing = await listFiles(itemPath, true);
166 if (subListing instanceof ServerError) throw subListing;
167 files.set(fileName, { files: subListing, status: "Scanned" });
168 } else {
169 files.set(fileName, { files: new Map(), status: "Unknown" });
170 }
171 } else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) {
172 try {
173 const probe = await probeFile(itemPath);
174 files.set(fileName, { metadata: probe });
175 } catch (probeError) {
176 console.error("probing error:", probeError);
177 }
178 }
179 }
180 return files;
181 // biome-ignore lint/suspicious/noExplicitAny: <explanation>
182 } catch (error: any) {
183 if (error?.code === "ENOENT") {
184 return new ServerError(StatusMap["Not Found"], "Directory not found");
185 }
186 console.error("Error listing path:", subPath, error.message);
187 return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
188 }
189}
190
191async function readStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
192 const reader = stream.getReader();
193 const chunks: Uint8Array[] = [];
194
195 try {
196 while (true) {
197 const { done, value } = await reader.read();
198 if (done) break;
199 if (value) chunks.push(value);
200 }
201
202 const combined = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
203 let offset = 0;
204 for (const chunk of chunks) {
205 combined.set(chunk, offset);
206 offset += chunk.length;
207 }
208
209 return combined;
210 } finally {
211 reader.releaseLock();
212 }
213}
214
215function secondsToFfmpeg(seconds: number): string {
216 // Ensure seconds is non-negative
217 if (seconds < 0) {
218 throw new Error("Seconds must be a non-negative number");
219 }
220
221 // Extract hours, minutes, seconds, and milliseconds
222 const hours = Math.floor(seconds / 3600);
223 const minutes = Math.floor((seconds % 3600) / 60);
224 const secs = Math.floor(seconds % 60);
225
226 // Calculate the fractional part for milliseconds
227 const milliseconds = Math.floor((seconds - Math.floor(seconds)) * 1000);
228
229 // Pad numbers with leading zeros
230 const hoursStr = hours.toString().padStart(2, "0");
231 const minutesStr = minutes.toString().padStart(2, "0");
232 const secondsStr = secs.toString().padStart(2, "0");
233 const millisecondsStr = milliseconds.toString().padStart(3, "0");
234
235 return `${hoursStr}:${minutesStr}:${secondsStr}.${millisecondsStr}`;
236}
237
238export function convertWithFFmpeg(
239 filePath: string,
240 audioBitrate: number,
241 videoBitrate: number,
242 container: MediaContainer,
243 requestedAudioCodec: AudioCodec,
244 requestedVideoCodec: VideoCodec,
245 seekTo: number | undefined,
246) {
247 let mimeType = undefined;
248 let ffmpegContainer = undefined;
249 switch (container) {
250 case MediaContainer.webm: {
251 ffmpegContainer = "webm";
252 mimeType = "video/webm";
253 break;
254 }
255 case MediaContainer.mp3: {
256 ffmpegContainer = "mp3";
257 mimeType = "audio/mpeg";
258 break;
259 }
260 case MediaContainer.ogg: {
261 ffmpegContainer = "ogg";
262 mimeType = "audio/ogg";
263 break;
264 }
265 default: {
266 throw new Error(`Unsupported container: ${container}`);
267 }
268 }
269
270 let audioCodec = undefined;
271 switch (requestedAudioCodec) {
272 case AudioCodec.none: {
273 break;
274 }
275 case AudioCodec.mp3: {
276 audioCodec = "libmp3lame";
277 break;
278 }
279 case AudioCodec.opus: {
280 audioCodec = "libopus";
281 break;
282 }
283 case AudioCodec.vorbis: {
284 audioCodec = "libvorbis";
285 break;
286 }
287 default: {
288 throw new Error(`Unsupported audio codec: ${requestedAudioCodec}`);
289 }
290 }
291
292 let videoCodec = undefined;
293 switch (requestedVideoCodec) {
294 case VideoCodec.none: {
295 break;
296 }
297 case VideoCodec.av1: {
298 videoCodec = "librav1e"; //TODO: support hardware encoding
299 break;
300 }
301 case VideoCodec.theora: {
302 videoCodec = "libtheora";
303 break;
304 }
305 case VideoCodec.vp8: {
306 videoCodec = "libvpx";
307 break;
308 }
309 case VideoCodec.vp9: {
310 videoCodec = "libvpx-vp9";
311 break;
312 }
313 default: {
314 throw new Error(`Unsupported video codec: ${requestedVideoCodec}`);
315 }
316 }
317
318 mimeType += `; codecs="${[requestedVideoCodec, requestedAudioCodec]
319 .filter((codec) => {
320 codec !== "none";
321 })
322 .join(", ")}`; //TODO: add RFC6381 compliance
323
324 const seekParams = seekTo === undefined ? [] : ["-ss", secondsToFfmpeg(seekTo)];
325
326 const cmd = Bun.spawn(
327 ["ffmpeg", "-i", filePath]
328 .concat(seekParams)
329 //.concat(videoCodec ? ["-c:v", videoCodec, "-b:v", videoBitrate.toString()] : ["-vn"]) //TODO
330 .concat(videoCodec ? ["-c:v", videoCodec, "-b:v", "1000000"] : ["-vn"])
331 .concat(audioCodec ? ["-c:a", audioCodec, "-b:a", audioBitrate.toString()] : ["-an"])
332 .concat(["-f", ffmpegContainer, "-"]),
333 {
334 stdin: "ignore",
335 stdout: "pipe",
336 stderr: "pipe",
337 onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
338 if (exitCode !== 0 && exitCode !== null) {
339 console.error("ffmpeg failed with status code", exitCode);
340 console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
341 }
342 },
343 },
344 );
345 return { cmd, mimeType };
346}
347
348export function packWithTar(files: string[]) {
349 // Ensure all files are within musicRoot
350 const absoluteFiles = files.map((f) => path.join(musicRoot, f));
351 for (const f of absoluteFiles) {
352 if (!isBelow(musicRoot, f)) {
353 throw new ServerError(StatusMap.Forbidden, "One or more files are outside the music root");
354 }
355 }
356
357 const relFiles = absoluteFiles.map((f) => path.relative(musicRoot, f));
358 const tarArgs = [
359 "--transform=s|.*/||", //only supported in gnu tar TODO: add fallback
360 "-C",
361 musicRoot,
362 "-c", // create archive
363 ...relFiles,
364 ];
365 return Bun.spawn(["tar", ...tarArgs], {
366 stdin: "ignore",
367 stdout: "pipe",
368 stderr: "pipe",
369 onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
370 if (exitCode !== 0 && exitCode !== null) {
371 console.error("tar failed with status code", exitCode);
372 console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
373 }
374 },
375 });
376}
377
378//copy to a temp file and then use avifenc to convert
379export async function toAvif(
380 data: Uint8Array,
381 mimeType: string,
382 setContentType: (mimeType: string) => void,
383): Promise<Uint8Array> {
384 const tmpFile = tmp.fileSync({
385 postfix: mimeType.includes("image/jpeg") ? ".jpg" : mimeType.includes("image/jpeg") ? ".png" : undefined,
386 });
387 const tmpFile2 = tmp.fileSync({ postfix: ".avif" });
388 try {
389 writeFileSync(tmpFile.fd, data);
390 const cmd = Bun.spawn(["avifenc", "-q", "50", tmpFile.name, tmpFile2.name], {
391 stdin: "ignore",
392 stdout: "pipe",
393 stderr: "pipe",
394 onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
395 if (exitCode !== 0 && exitCode !== null) {
396 console.error("avifenc failed with status code", exitCode);
397 console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
398 }
399 },
400 });
401 await cmd.exited;
402 setContentType("image/avif");
403 return readFileSync(tmpFile2.fd);
404 } catch (e) {
405 setContentType(mimeType);
406 return data;
407 } finally {
408 tmpFile.removeCallback();
409 tmpFile2.removeCallback();
410 }
411}
412