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