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