utils.ts
⎇
Raw
1import { StatusMap } from "elysia";
2import { fileTypeFromFile } from "file-type";
3import { MediaFormat, type Directory, type FileListing } 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 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 as number };
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 needsTranscoding(probe: FfprobeData, requestBitrate?: unknown) {
89 const bitrate = Number(requestBitrate);
90 return probe.bitrate && bitrate > 0 && bitrate < probe.bitrate;
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<Directory | ServerError> {
99 const files: FileListing = [];
100
101 try {
102 for (const file of await readdir(subPath)) {
103 const itemPath = path.join(subPath, file);
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.push({ name: file, files: subListing.files });
115 } else {
116 files.push({ name: file, files: [] });
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.push({ name: file, duration: duration });
127 }
128 }
129 files.sort((a, b) => a.name.localeCompare(b.name));
130 return { name: path.basename(path.relative(musicRoot, subPath)), files };
131 // biome-ignore lint/suspicious/noExplicitAny: <explanation>
132 } catch (error: any) {
133 if (error?.code === "ENOENT") {
134 return new ServerError(StatusMap["Not Found"], "Directory not found");
135 }
136 console.error("Error listing path:", subPath, error.message);
137 return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
138 }
139}
140
141async function streamToString(stream: ReadableStream<Uint8Array>): Promise<string> {
142 const reader = stream.getReader();
143 const chunks: Uint8Array[] = [];
144
145 try {
146 while (true) {
147 const { done, value } = await reader.read();
148 if (done) break;
149 if (value) chunks.push(value);
150 }
151
152 const combined = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
153 let offset = 0;
154 for (const chunk of chunks) {
155 combined.set(chunk, offset);
156 offset += chunk.length;
157 }
158
159 return new TextDecoder().decode(combined);
160 } finally {
161 reader.releaseLock();
162 }
163}
164
165export function convertWithFFmpeg(filePath: string, bitrate: number, format: MediaFormat) {
166 let codec = undefined;
167 let container = undefined;
168 let mimeType = undefined;
169 switch (format) {
170 case MediaFormat["opus/ogg"]: {
171 codec = "libopus";
172 container = "ogg";
173 mimeType = 'audio/ogg; codecs="opus"';
174 break;
175 }
176 case MediaFormat["opus/webm"]: {
177 codec = "libopus";
178 container = "webm";
179 mimeType = 'audio/webm; codecs="opus"';
180 break;
181 }
182 case MediaFormat.mp3: {
183 codec = "libmp3lame";
184 container = "mp3";
185 mimeType = "audio/mpeg";
186 break;
187 }
188 case MediaFormat.vorbis: {
189 codec = "libvorbis";
190 container = "ogg";
191 mimeType = 'audio/ogg; 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