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