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