utils.ts
⎇
Raw
1import { StatusMap } from "elysia";
2import { fileTypeFromFile } from "file-type";
3import { type FileListingWithStatus, MediaFormat } from "music-server-shared/types";
4import { readdir, stat } from "node:fs/promises";
5import path from "node:path";
6import { coverRegex, fileTypeCache, mediaTypes, musicRoot, PathInfo, probeCache, ServerError } from "./shared";
7import { parseFile } from "music-metadata";
8
9export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => Promise<boolean>) {
10 const promises = arr.map(asyncCallback);
11 const results = await Promise.all(promises);
12 const index = results.findIndex((result) => result);
13 return arr[index];
14}
15
16type CoverResult = { path: string; info: PathInfo } | { info: ServerError };
17
18export async function findCover(dirPath: string): Promise<CoverResult> {
19 if (!coverRegex) return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
20 let coverFile: string | undefined;
21 try {
22 coverFile = await findAsync(await readdir(dirPath), async (file) => {
23 return file.match(coverRegex) != null && (await stat(path.join(dirPath, file))).isFile();
24 });
25 const above = path.dirname(dirPath);
26 if (!coverFile && isBelow(musicRoot, above)) {
27 return findCover(above);
28 }
29 // biome-ignore lint/suspicious/noExplicitAny:
30 } catch (error: any) {
31 if (error?.code !== "ENOENT") {
32 console.error("error getting cover: readdirSync failed:", error);
33 return { info: new ServerError(StatusMap["Internal Server Error"], "error getting cover: readdirSync failed") };
34 }
35 return { info: new ServerError(StatusMap["Not Found"], "Directory not found") };
36 }
37 if (coverFile) {
38 const info = (await getPathInfo(path.join(dirPath, coverFile))) as PathInfo | ServerError;
39 if (info instanceof ServerError) {
40 return { info: info };
41 }
42 return { path: path.join(dirPath, coverFile), info };
43 }
44 return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
45}
46
47export type ProbeData = {
48 duration: number;
49 bitrate?: number;
50};
51
52export async function probeFile(filePath: string): Promise<ProbeData> {
53 const cached = probeCache.get(filePath);
54 if (cached) return cached;
55 const parsed = await parseFile(filePath, { duration: true, skipCovers: true });
56 const probeData = { duration: parsed.format.duration as number, bitrate: parsed.format.bitrate };
57 probeCache.set(filePath, probeData);
58 return probeData;
59}
60
61export async function getPathInfo(filePath: string): Promise<PathInfo | ServerError | undefined> {
62 try {
63 const stats = await stat(filePath);
64 if (stats.isDirectory()) {
65 return undefined;
66 }
67 let mimeType = fileTypeCache.get(filePath);
68 if (!mimeType) {
69 const type = await fileTypeFromFile(filePath);
70 mimeType = type?.mime;
71 if (mimeType) {
72 fileTypeCache.set(filePath, mimeType);
73 } else {
74 console.error("Failed to determine MIME type for:", filePath);
75 return new ServerError(StatusMap["Internal Server Error"], "Filetype scan failed");
76 }
77 }
78 return new PathInfo(mimeType);
79 // biome-ignore lint/suspicious/noExplicitAny:
80 } catch (error: any) {
81 if (error?.code === "ENOENT") {
82 return new ServerError(StatusMap["Not Found"], "File not found");
83 }
84 console.error("Error scanning file:", filePath, error.message);
85 return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
86 }
87}
88
89export function matchesType(mimeType: string, types: string[]): boolean {
90 return types.includes(mimeType.split("/", 1)[0]);
91}
92
93export function isBelow(basePath: string, targetPath: string) {
94 const relativePath = path.relative(basePath, targetPath);
95 return relativePath && !relativePath.startsWith("..");
96}
97
98export async function listFiles(subPath: string, recursive: boolean): Promise<FileListingWithStatus | ServerError> {
99 const files: FileListingWithStatus = new Map();
100
101 try {
102 for (const fileName of await readdir(subPath)) {
103 const itemPath = path.join(subPath, fileName);
104 if (!isBelow(musicRoot, itemPath)) {
105 console.log(`Skipping ${itemPath} as it is outside the base path.`);
106 continue;
107 }
108
109 const pathInfoResult = await getPathInfo(itemPath);
110 if (!pathInfoResult) {
111 if (recursive) {
112 const subListing = await listFiles(itemPath, true);
113 if (subListing instanceof ServerError) throw subListing;
114 files.set(fileName, { files: subListing, status: "Scanned" });
115 } else {
116 files.set(fileName, { files: new Map(), status: "Unknown" });
117 }
118 } else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) {
119 let duration = 0;
120 try {
121 const probe = await probeFile(itemPath);
122 duration = probe.duration || 0;
123 } catch (probeError) {
124 console.error("probing error:", probeError);
125 }
126 files.set(fileName, { metadata: { duration } });
127 }
128 }
129 return files;
130 // biome-ignore lint/suspicious/noExplicitAny: <explanation>
131 } catch (error: any) {
132 if (error?.code === "ENOENT") {
133 return new ServerError(StatusMap["Not Found"], "Directory not found");
134 }
135 console.error("Error listing path:", subPath, error.message);
136 return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
137 }
138}
139
140async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
141 const reader = stream.getReader();
142 const chunks: Uint8Array[] = [];
143
144 try {
145 while (true) {
146 const { done, value } = await reader.read();
147 if (done) break;
148 if (value) chunks.push(value);
149 }
150
151 const combined = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
152 let offset = 0;
153 for (const chunk of chunks) {
154 combined.set(chunk, offset);
155 offset += chunk.length;
156 }
157
158 return new TextDecoder().decode(combined);
159 } finally {
160 reader.releaseLock();
161 }
162}
163
164export function convertWithFFmpeg(filePath: string, bitrate: number, format: MediaFormat) {
165 let codec = undefined;
166 let container = undefined;
167 let mimeType = undefined;
168 switch (format) {
169 case MediaFormat["opus/ogg"]: {
170 codec = "libopus";
171 container = "ogg";
172 mimeType = 'audio/ogg; codecs="opus"';
173 break;
174 }
175 case MediaFormat["opus/webm"]: {
176 codec = "libopus";
177 container = "webm";
178 mimeType = 'audio/webm; codecs="opus"';
179 break;
180 }
181 case MediaFormat.mp3: {
182 codec = "libmp3lame";
183 container = "mp3";
184 mimeType = "audio/mpeg";
185 break;
186 }
187 case MediaFormat["vorbis/ogg"]: {
188 codec = "libvorbis";
189 container = "ogg";
190 mimeType = 'audio/ogg; codecs="vorbis"';
191 break;
192 }
193 case MediaFormat["vorbis/webm"]: {
194 codec = "libvorbis";
195 container = "webm";
196 mimeType = 'audio/webm; codecs="vorbis"';
197 break;
198 }
199 default: {
200 throw new Error(`Unsupported format: ${format}`);
201 }
202 }
203 const cmd = Bun.spawn(
204 ["ffmpeg", "-i", filePath, "-vn", "-c:a", codec, "-f", container, "-b:a", bitrate.toString(), "-"],
205 {
206 stdin: "ignore",
207 stdout: "pipe",
208 stderr: "pipe",
209 onExit: async (subprocess: Bun.Subprocess, exitCode: number | null) => {
210 if (exitCode !== 0 && exitCode !== null) {
211 console.error("ffmpeg failed with status code", exitCode);
212 console.error(await streamToString(subprocess.stderr as ReadableStream));
213 }
214 },
215 },
216 );
217 return { cmd, mimeType };
218}
219
220export function packWithTar(files: string[]) {
221 // Ensure all files are within musicRoot
222 const absoluteFiles = files.map((f) => path.join(musicRoot, f));
223 for (const f of absoluteFiles) {
224 if (!isBelow(musicRoot, f)) {
225 throw new ServerError(StatusMap.Forbidden, "One or more files are outside the music root");
226 }
227 }
228
229 const relFiles = absoluteFiles.map((f) => path.relative(musicRoot, f));
230 const tarArgs = [
231 "--transform=s|.*/||", //only supported in gnu tar TODO: add fallback
232 "-C",
233 musicRoot,
234 "-c", // create archive
235 ...relFiles,
236 ];
237 return Bun.spawn(["tar", ...tarArgs], {
238 stdin: "ignore",
239 stdout: "pipe",
240 stderr: "pipe",
241 onExit: async (subprocess: Bun.Subprocess, exitCode: number | null) => {
242 if (exitCode !== 0 && exitCode !== null) {
243 console.error("tar failed with status code", exitCode);
244 console.error(await streamToString(subprocess.stderr as ReadableStream));
245 }
246 },
247 });
248}
249