import { StatusMap } from "elysia"; import { fileTypeFromFile } from "file-type"; import type { FileListingWithStatus, Metadata } from "music-server-shared/types"; import { readdir, stat } from "node:fs/promises"; import path from "node:path"; import { coverRegex, excludeExtension, fileTypeCache, mediaInfoDisabled, mediaTypes, musicRoot, PathInfo, probeCache, ServerError, } from "./shared"; import { parseFile } from "music-metadata"; import tmp from "tmp"; import { readFileSync, writeFileSync } from "node:fs"; import MediaInfo from "mediainfo.js"; export async function findAsync(arr: Array, asyncCallback: (value: T) => Promise) { const promises = arr.map(asyncCallback); const results = await Promise.all(promises); const index = results.findIndex((result) => result); return arr[index]; } type CoverResult = { info: PathInfo; path: string } | { info: PathInfo; content: Uint8Array } | { info: ServerError }; const mediaInfo = await MediaInfo(); export async function findCover(targetPath: string): Promise { try { if ((await stat(targetPath)).isFile()) { const parsed = await parseFile(targetPath, { duration: true, skipCovers: false }); if (!parsed.common.picture || parsed.common.picture.length === 0) return findCover(path.dirname(targetPath)); return { info: new PathInfo(parsed.common.picture[0].format), content: parsed.common.picture[0].data }; } } catch (e) { return { info: new ServerError(StatusMap["Internal Server Error"], `error getting cover ${e}`) }; } if (!coverRegex) return { info: new ServerError(StatusMap["Not Found"], "Cover not found") }; let coverFile: string | undefined; try { coverFile = await findAsync(await readdir(targetPath), async (file) => { return file.match(coverRegex) != null && (await stat(path.join(targetPath, file))).isFile(); }); const above = path.dirname(targetPath); if (!coverFile && isBelow(musicRoot, above)) { return findCover(above); } // biome-ignore lint/suspicious/noExplicitAny: } catch (error: any) { if (error?.code !== "ENOENT") { console.error("error getting cover: readdirSync failed:", error); return { info: new ServerError(StatusMap["Internal Server Error"], "error getting cover: readdirSync failed") }; } return { info: new ServerError(StatusMap["Not Found"], "Directory not found") }; } if (coverFile) { const info = (await getPathInfo(path.join(targetPath, coverFile))) as PathInfo | ServerError; if (info instanceof ServerError) { return { info: info }; } return { path: path.join(targetPath, coverFile), info }; } return { info: new ServerError(StatusMap["Not Found"], "Cover not found") }; } export async function probeFile(filePath: string): Promise { const cached = probeCache.get(filePath); if (cached) return cached; const parsed = await parseFile(filePath, { duration: true, skipCovers: false }); let probeData: Metadata = { duration: parsed.format.duration || 0, title: parsed.common.title, artist: parsed.common.artist, album: parsed.common.album, genre: parsed.common.genre, trackNumber: parsed.common.track.no || undefined, }; if (!mediaInfoDisabled) { const file = Bun.file(filePath); const fileSize = (await file.stat()).size; const chunkReader = async (size: number, offset: number) => { return await file.slice(offset, offset + size).bytes(); }; const info = await mediaInfo.analyzeData(fileSize, chunkReader); probeData = { ...probeData, audioCodec: info.media?.track.find((track) => track["@type"] === "Audio")?.Format, audioBitrate: info.media?.track.find((track) => track["@type"] === "Audio")?.BitRate, videoCodec: info.media?.track.find((track) => track["@type"] === "Video")?.Format, videoBitrate: info.media?.track.find((track) => track["@type"] === "Video")?.BitRate, container: info.media?.track.find((track) => track["@type"] === "General")?.Format, }; } probeCache.set(filePath, probeData); return probeData; } export async function getPathInfo(filePath: string): Promise { try { if (excludeExtension.includes(path.extname(filePath).slice(1))) return new ServerError(404, "Excluded extension"); const stats = await stat(filePath); if (stats.isDirectory()) { return undefined; } let mimeType = fileTypeCache.get(filePath); if (!mimeType) { const type = await fileTypeFromFile(filePath); mimeType = type?.mime; if (mimeType) { fileTypeCache.set(filePath, mimeType); } else { console.error("Failed to determine MIME type for:", filePath); return new ServerError(StatusMap["Internal Server Error"], "Filetype scan failed"); } } return new PathInfo(mimeType); // biome-ignore lint/suspicious/noExplicitAny: } catch (error: any) { if (error?.code === "ENOENT") { return new ServerError(StatusMap["Not Found"], "File not found"); } console.error("Error scanning file:", filePath, error.message); return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error"); } } export function matchesType(mimeType: string, types: string[]): boolean { return types.includes(mimeType.split("/", 1)[0]); } export function isBelow(basePath: string, targetPath: string) { const relativePath = path.relative(basePath, targetPath); return relativePath && !relativePath.startsWith(".."); } export async function listFiles(subPath: string, recursive: boolean): Promise { const files: FileListingWithStatus = new Map(); try { for (const fileName of await readdir(subPath)) { const itemPath = path.join(subPath, fileName); if (!isBelow(musicRoot, itemPath)) { console.log(`Skipping ${itemPath} as it is outside the base path.`); continue; } const pathInfoResult = await getPathInfo(itemPath); if (!pathInfoResult) { if (recursive) { const subListing = await listFiles(itemPath, true); if (subListing instanceof ServerError) throw subListing; files.set(fileName, { files: subListing, status: "Scanned" }); } else { files.set(fileName, { files: new Map(), status: "Unknown" }); } } else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) { try { const probe = await probeFile(itemPath); files.set(fileName, { metadata: probe }); } catch (probeError) { console.error("probing error:", probeError); } } } return files; // biome-ignore lint/suspicious/noExplicitAny: } catch (error: any) { if (error?.code === "ENOENT") { return new ServerError(StatusMap["Not Found"], "Directory not found"); } console.error("Error listing path:", subPath, error.message); return new ServerError(StatusMap["Internal Server Error"], "Internal Server Error"); } } export async function readStream(stream: ReadableStream): Promise { const reader = stream.getReader(); const chunks: Uint8Array[] = []; try { while (true) { const { done, value } = await reader.read(); if (done) break; if (value) chunks.push(value); } const combined = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0)); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.length; } return combined; } finally { reader.releaseLock(); } } export function packWithTar(files: string[]) { // Ensure all files are within musicRoot const absoluteFiles = files.map((f) => path.join(musicRoot, f)); for (const f of absoluteFiles) { if (!isBelow(musicRoot, f)) { throw new ServerError(StatusMap.Forbidden, "One or more files are outside the music root"); } } const relFiles = absoluteFiles.map((f) => path.relative(musicRoot, f)); const tarArgs = [ "--transform=s|.*/||", //only supported in gnu tar TODO: add fallback "-C", musicRoot, "-c", // create archive ...relFiles, ]; return Bun.spawn(["tar", ...tarArgs], { stdin: "ignore", stdout: "pipe", stderr: "pipe", onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => { if (exitCode !== 0 && exitCode !== null) { console.error("tar failed with status code", exitCode); console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream))); } }, }); } //copy to a temp file and then use avifenc to convert export async function toAvif( data: Uint8Array, mimeType: string, setContentType: (mimeType: string) => void, ): Promise { const tmpFile = tmp.fileSync({ postfix: mimeType.includes("image/jpeg") ? ".jpg" : mimeType.includes("image/jpeg") ? ".png" : undefined, }); const tmpFile2 = tmp.fileSync({ postfix: ".avif" }); try { writeFileSync(tmpFile.fd, data); const cmd = Bun.spawn(["avifenc", "-q", "50", tmpFile.name, tmpFile2.name], { stdin: "ignore", stdout: "pipe", stderr: "pipe", onExit: async (subprocess: Bun.Subprocess<"ignore", "pipe", "pipe">, exitCode: number | null) => { if (exitCode !== 0 && exitCode !== null) { console.error("avifenc failed with status code", exitCode); console.error(new TextDecoder().decode(await readStream(subprocess.stderr as ReadableStream))); } }, }); await cmd.exited; setContentType("image/avif"); return readFileSync(tmpFile2.fd); } catch (e) { setContentType(mimeType); return data; } finally { tmpFile.removeCallback(); tmpFile2.removeCallback(); } }