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
200export function convertWithFFmpeg(filePath: string, bitrate: number, format: MediaFormat) {
201 let codec = undefined;
202 let container = undefined;
203 let mimeType = undefined;
204 switch (format) {
205 case MediaFormat["opus/ogg"]: {
206 codec = "libopus";
207 container = "ogg";
208 mimeType = 'audio/ogg; codecs="opus"';
209 break;
210 }
211 case MediaFormat["opus/webm"]: {
212 codec = "libopus";
213 container = "webm";
214 mimeType = 'audio/webm; codecs="opus"';
215 break;
216 }
217 case MediaFormat.mp3: {
218 codec = "libmp3lame";
219 container = "mp3";
220 mimeType = "audio/mpeg";
221 break;
222 }
223 case MediaFormat["vorbis/ogg"]: {
224 codec = "libvorbis";
225 container = "ogg";
226 mimeType = 'audio/ogg; codecs="vorbis"';
227 break;
228 }
229 case MediaFormat["vorbis/webm"]: {
230 codec = "libvorbis";
231 container = "webm";
232 mimeType = 'audio/webm; codecs="vorbis"';
233 break;
234 }
235 default: {
236 throw new Error(`Unsupported format: ${format}`);
237 }
238 }
239 const cmd = Bun.spawn(
240 ["ffmpeg", "-i", filePath, "-vn", "-c:a", codec, "-f", container, "-b:a", bitrate.toString(), "-"],
241 {
242 stdin: "ignore",
243 stdout: "pipe",
244 stderr: "pipe",
245 onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
246 if (exitCode !== 0 && exitCode !== null) {
247 console.error("ffmpeg failed with status code", exitCode);
248 console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
249 }
250 },
251 },
252 );
253 return { cmd, mimeType };
254}
255
256export function packWithTar(files: string[]) {
257 // Ensure all files are within musicRoot
258 const absoluteFiles = files.map((f) => path.join(musicRoot, f));
259 for (const f of absoluteFiles) {
260 if (!isBelow(musicRoot, f)) {
261 throw new ServerError(StatusMap.Forbidden, "One or more files are outside the music root");
262 }
263 }
264
265 const relFiles = absoluteFiles.map((f) => path.relative(musicRoot, f));
266 const tarArgs = [
267 "--transform=s|.*/||", //only supported in gnu tar TODO: add fallback
268 "-C",
269 musicRoot,
270 "-c", // create archive
271 ...relFiles,
272 ];
273 return Bun.spawn(["tar", ...tarArgs], {
274 stdin: "ignore",
275 stdout: "pipe",
276 stderr: "pipe",
277 onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
278 if (exitCode !== 0 && exitCode !== null) {
279 console.error("tar failed with status code", exitCode);
280 console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
281 }
282 },
283 });
284}
285
286//copy to a temp file and then use avifenc to convert
287export async function toAvif(
288 data: Uint8Array,
289 mimeType: string,
290 setContentType: (mimeType: string) => void,
291): Promise<Uint8Array> {
292 const tmpFile = tmp.fileSync({
293 postfix: mimeType.includes("image/jpeg") ? ".jpg" : mimeType.includes("image/jpeg") ? ".png" : undefined,
294 });
295 const tmpFile2 = tmp.fileSync({ postfix: ".avif" });
296 try {
297 writeFileSync(tmpFile.fd, data);
298 const cmd = Bun.spawn(["avifenc", "-q", "50", tmpFile.name, tmpFile2.name], {
299 stdin: "ignore",
300 stdout: "pipe",
301 stderr: "pipe",
302 onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
303 if (exitCode !== 0 && exitCode !== null) {
304 console.error("avifenc failed with status code", exitCode);
305 console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
306 }
307 },
308 });
309 await cmd.exited;
310 setContentType("image/avif");
311 return readFileSync(tmpFile2.fd);
312 } catch (e) {
313 setContentType(mimeType);
314 return data;
315 } finally {
316 tmpFile.removeCallback();
317 tmpFile2.removeCallback();
318 }
319}
320