import { StatusMap } from "elysia"; import { fileTypeFromFile } from "file-type"; import type { Directory, FileListing } from "music-server-shared/types"; import { readdir, stat } from "node:fs/promises"; import path from "node:path"; import { coverRegex, fileTypeCache, mediaTypes, musicRoot, PathInfo, probeCache, ServerError } from "./shared"; 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 = { path: string; info: PathInfo } | { info: ServerError }; export async function findCover(dirPath: string): Promise { let coverFile: string | undefined; try { coverFile = await findAsync(await readdir(dirPath), async (file) => { return file.match(coverRegex) != null && (await stat(path.join(dirPath, file))).isFile(); }); const above = path.dirname(dirPath); 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(dirPath, coverFile))) as PathInfo | ServerError; if (info instanceof ServerError) { return { info: info }; } return { path: path.join(dirPath, coverFile), info }; } return { info: new ServerError(StatusMap["Not Found"], "Cover not found") }; } export type FfprobeData = { format: { duration: number; bit_rate: number; }; }; // Rewritten to spawn ffprobe via bun.spawn export async function probeFile(filePath: string): Promise { const cached = probeCache.get(filePath); if (cached) { return cached; } const ffprobeArgs = ["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", filePath]; const proc = Bun.spawn(["ffprobe", ...ffprobeArgs], { stdout: "pipe", stderr: "pipe", }); const text = await new Response(proc.stdout).text(); let json: FfprobeData; try { json = JSON.parse(text); } catch (e) { const errText = await new Response(proc.stderr).text(); throw new ServerError(StatusMap["Internal Server Error"], `ffprobe failed: ${errText || text}`); } probeCache.set(filePath, json); return json; } export async function getPathInfo(filePath: string): Promise { try { 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 needsTranscoding(probe: FfprobeData, requestBitrate?: unknown) { const bitrate = Number(requestBitrate); return probe.format.bit_rate && bitrate > 0 && bitrate < probe.format.bit_rate; } 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: FileListing = []; try { for (const file of await readdir(subPath)) { const itemPath = path.join(subPath, file); 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.push({ name: file, files: subListing.files }); } else { files.push({ name: file, files: [] }); } } else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) { let duration = 0; try { const probe = await probeFile(itemPath); duration = probe.format.duration || 0; } catch (probeError) { console.error("ffprobe error:", probeError); } files.push({ name: file, duration: duration }); } } files.sort((a, b) => a.name.localeCompare(b.name)); return { name: path.basename(path.relative(musicRoot, subPath)), 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"); } } async function streamToString(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 new TextDecoder().decode(combined); } finally { reader.releaseLock(); } } export function convertWithFFmpeg(filePath: string, bitrate: number) { const cmd = Bun.spawn(["ffmpeg", "-i", filePath, "-f", "opus", "-b:a", bitrate.toString(), "-"], { stdin: "ignore", stdout: "pipe", stderr: "pipe", onExit: async (subprocess: Bun.Subprocess, exitCode: number | null) => { if (exitCode !== 0 && exitCode !== null) { console.error("ffmpeg failed with status code", exitCode); console.error(await streamToString(subprocess.stderr as ReadableStream)); } }, }); return cmd; }