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