utils.ts
⎇
Raw
1import { StatusMap } from "elysia";
2import { fileTypeFromFile } from "file-type";
3import type { FileListingWithStatus, Metadata } from "music-server-shared/types";
4import { readdir, stat } from "node:fs/promises";
5import path from "node:path";
6import {
7 args,
8 coverRegex,
9 excludeExtension,
10 fileTypeCache,
11 mediaInfoDisabled,
12 mediaTypes,
13 musicRoot,
14 PathInfo,
15 probeCache,
16 ServerError,
17} from "./shared";
18import { parseFile } from "music-metadata";
19import tmp from "tmp";
20import { readFileSync, writeFileSync } from "node:fs";
21import MediaInfo from "mediainfo.js";
22
23export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => Promise<boolean>) {
24 const promises = arr.map(asyncCallback);
25 const results = await Promise.all(promises);
26 const index = results.findIndex((result) => result);
27 return arr[index];
28}
29
30type CoverResult = { info: PathInfo; path: string } | { info: PathInfo; content: Uint8Array } | { info: ServerError };
31
32const mediaInfo = await MediaInfo({
33 locateFile: () => `${args.serve}/MediaInfoModule.wasm`,
34});
35
36export async function findCover(targetPath: string): Promise<CoverResult> {
37 try {
38 if ((await stat(targetPath)).isFile()) {
39 const parsed = await parseFile(targetPath, { duration: true, skipCovers: false });
40 if (!parsed.common.picture || parsed.common.picture.length === 0) return findCover(path.dirname(targetPath));
41
42 return { info: new PathInfo(parsed.common.picture[0].format), content: parsed.common.picture[0].data };
43 }
44 } catch (e) {
45 return { info: new ServerError(StatusMap["Internal Server Error"], `error getting cover ${e}`) };
46 }
47
48 if (!coverRegex) return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
49 let coverFile: string | undefined;
50 try {
51 coverFile = await findAsync(await readdir(targetPath), async (file) => {
52 return file.match(coverRegex) != null && (await stat(path.join(targetPath, file))).isFile();
53 });
54 const above = path.dirname(targetPath);
55 if (!coverFile && isBelow(musicRoot, above)) {
56 return findCover(above);
57 }
58 // biome-ignore lint/suspicious/noExplicitAny:
59 } catch (error: any) {
60 if (error?.code !== "ENOENT") {
61 console.error("error getting cover: readdirSync failed:", error);
62 return { info: new ServerError(StatusMap["Internal Server Error"], "error getting cover: readdirSync failed") };
63 }
64 return { info: new ServerError(StatusMap["Not Found"], "Directory not found") };
65 }
66 if (coverFile) {
67 const info = (await getPathInfo(path.join(targetPath, coverFile))) as PathInfo | ServerError;
68 if (info instanceof ServerError) {
69 return { info: info };
70 }
71 return { path: path.join(targetPath, coverFile), info };
72 }
73 return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
74}
75
76export async function probeFile(filePath: string): Promise<Metadata> {
77 const cached = probeCache.get(filePath);
78 if (cached) return cached;
79 const parsed = await parseFile(filePath, { duration: true, skipCovers: false });
80
81 let probeData: Metadata = {
82 duration: parsed.format.duration || 0,
83 title: parsed.common.title,
84 artist: parsed.common.artist,
85 album: parsed.common.album,
86 genre: parsed.common.genre,
87 trackNumber: parsed.common.track.no || undefined,
88 };
89
90 if (!mediaInfoDisabled) {
91 const file = Bun.file(filePath);
92 const fileSize = (await file.stat()).size;
93 const chunkReader = async (size: number, offset: number) => {
94 return await file.slice(offset, offset + size).bytes();
95 };
96 const info = await mediaInfo.analyzeData(fileSize, chunkReader);
97 probeData = {
98 ...probeData,
99 audioCodec: info.media?.track.find((track) => track["@type"] === "Audio")?.Format,
100 audioBitrate: info.media?.track.find((track) => track["@type"] === "Audio")?.BitRate,
101 videoCodec: info.media?.track.find((track) => track["@type"] === "Video")?.Format,
102 videoBitrate: info.media?.track.find((track) => track["@type"] === "Video")?.BitRate,
103 container: info.media?.track.find((track) => track["@type"] === "General")?.Format,
104 };
105 }
106 probeCache.set(filePath, probeData);
107 return probeData;
108}
109
110export async function getPathInfo(filePath: string): Promise<PathInfo | ServerError | undefined> {
111 try {
112 if (excludeExtension.includes(path.extname(filePath).slice(1))) return new ServerError(404, "Excluded extension");
113 const stats = await stat(filePath);
114 if (stats.isDirectory()) {
115 return undefined;
116 }
117 let mimeType = fileTypeCache.get(filePath);
118 if (!mimeType) {
119 const type = await fileTypeFromFile(filePath);
120 mimeType = type?.mime;
121 if (mimeType) {
122 fileTypeCache.set(filePath, mimeType);
123 } else {
124 console.error("Failed to determine MIME type for:", filePath);
125 return new ServerError(StatusMap["Internal Server Error"], "Filetype scan failed");
126 }
127 }
128 return new PathInfo(mimeType);
129 // biome-ignore lint/suspicious/noExplicitAny:
130 } catch (error: any) {
131 if (error?.code === "ENOENT") {
132 return new ServerError(StatusMap["Not Found"], "File not found");
133 }
134 console.error("Error scanning file:", filePath, error.message);
135 return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
136 }
137}
138
139export function matchesType(mimeType: string, types: string[]): boolean {
140 return types.includes(mimeType.split("/", 1)[0]);
141}
142
143export function isBelow(basePath: string, targetPath: string) {
144 const relativePath = path.relative(basePath, targetPath);
145 return relativePath && !relativePath.startsWith("..");
146}
147
148export async function listFiles(subPath: string, recursive: boolean): Promise<FileListingWithStatus | ServerError> {
149 const files: FileListingWithStatus = new Map();
150
151 try {
152 for (const fileName of await readdir(subPath)) {
153 const itemPath = path.join(subPath, fileName);
154 if (!isBelow(musicRoot, itemPath)) {
155 console.log(`Skipping ${itemPath} as it is outside the base path.`);
156 continue;
157 }
158
159 const pathInfoResult = await getPathInfo(itemPath);
160 if (!pathInfoResult) {
161 if (recursive) {
162 const subListing = await listFiles(itemPath, true);
163 if (subListing instanceof ServerError) throw subListing;
164 files.set(fileName, { files: subListing, status: "Scanned" });
165 } else {
166 files.set(fileName, { files: new Map(), status: "Unknown" });
167 }
168 } else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) {
169 try {
170 const probe = await probeFile(itemPath);
171 files.set(fileName, { metadata: probe });
172 } catch (probeError) {
173 console.error("probing error:", probeError);
174 }
175 }
176 }
177 return files;
178 // biome-ignore lint/suspicious/noExplicitAny: <explanation>
179 } catch (error: any) {
180 if (error?.code === "ENOENT") {
181 return new ServerError(StatusMap["Not Found"], "Directory not found");
182 }
183 console.error("Error listing path:", subPath, error.message);
184 return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error");
185 }
186}
187
188export async function readStream(stream: ReadableStream<Uint8Array>): Promise<Uint8Array> {
189 const reader = stream.getReader();
190 const chunks: Uint8Array[] = [];
191
192 try {
193 while (true) {
194 const { done, value } = await reader.read();
195 if (done) break;
196 if (value) chunks.push(value);
197 }
198
199 const combined = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
200 let offset = 0;
201 for (const chunk of chunks) {
202 combined.set(chunk, offset);
203 offset += chunk.length;
204 }
205
206 return combined;
207 } finally {
208 reader.releaseLock();
209 }
210}
211
212export function packWithTar(files: string[]) {
213 // Ensure all files are within musicRoot
214 const absoluteFiles = files.map((f) => path.join(musicRoot, f));
215 for (const f of absoluteFiles) {
216 if (!isBelow(musicRoot, f)) {
217 throw new ServerError(StatusMap.Forbidden, "One or more files are outside the music root");
218 }
219 }
220
221 const relFiles = absoluteFiles.map((f) => path.relative(musicRoot, f));
222 const tarArgs = [
223 "--transform=s|.*/||", //only supported in gnu tar TODO: add fallback
224 "-C",
225 musicRoot,
226 "-c", // create archive
227 ...relFiles,
228 ];
229 return Bun.spawn(["tar", ...tarArgs], {
230 stdin: "ignore",
231 stdout: "pipe",
232 stderr: "pipe",
233 onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
234 if (exitCode !== 0 && exitCode !== null) {
235 console.error("tar failed with status code", exitCode);
236 console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
237 }
238 },
239 });
240}
241
242//copy to a temp file and then use avifenc to convert
243export async function toAvif(
244 data: Uint8Array,
245 mimeType: string,
246 setContentType: (mimeType: string) => void,
247): Promise<Uint8Array> {
248 const tmpFile = tmp.fileSync({
249 postfix: mimeType.includes("image/jpeg") ? ".jpg" : mimeType.includes("image/jpeg") ? ".png" : undefined,
250 });
251 const tmpFile2 = tmp.fileSync({ postfix: ".avif" });
252 try {
253 writeFileSync(tmpFile.fd, data);
254 const cmd = Bun.spawn(["avifenc", "-q", "50", tmpFile.name, tmpFile2.name], {
255 stdin: "ignore",
256 stdout: "pipe",
257 stderr: "pipe",
258 onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => {
259 if (exitCode !== 0 && exitCode !== null) {
260 console.error("avifenc failed with status code", exitCode);
261 console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream)));
262 }
263 },
264 });
265 await cmd.exited;
266 setContentType("image/avif");
267 return readFileSync(tmpFile2.fd);
268 } catch (e) {
269 setContentType(mimeType);
270 return data;
271 } finally {
272 tmpFile.removeCallback();
273 tmpFile2.removeCallback();
274 }
275}
276