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