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