import { StatusMap } from "elysia"; import { fileTypeFromFile } from "file-type"; import { type FileListingWithStatus, MediaFormat } from "music-server-shared/types"; import { readdir, stat } from "node:fs/promises"; import path from "node:path"; import { coverRegex, fileTypeCache, mediaTypes, musicRoot, PathInfo, ServerError } from "./shared"; import { parseFile } from "music-metadata"; 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 = { duration: number; bitrate?: number; }; export async function probeFile(filePath: string): Promise { const parsed = await parseFile(filePath, { duration: true, skipCovers: true }); return { duration: parsed.format.duration as number, bitrate: parsed.format.bitrate }; } 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 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)) { let duration = 0; try { const probe = await probeFile(itemPath); duration = probe.duration || 0; } catch (probeError) { console.error("probing error:", probeError); } files.set(fileName, { metadata: { duration } }); } } 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"); } } 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, format: MediaFormat) { let codec = undefined; let container = undefined; let mimeType = undefined; switch (format) { case MediaFormat["opus/ogg"]: { codec = "libopus"; container = "ogg"; mimeType = 'audio/ogg; codecs="opus"'; break; } case MediaFormat["opus/webm"]: { codec = "libopus"; container = "webm"; mimeType = 'audio/webm; codecs="opus"'; break; } case MediaFormat.mp3: { codec = "libmp3lame"; container = "mp3"; mimeType = "audio/mpeg"; break; } case MediaFormat["vorbis/ogg"]: { codec = "libvorbis"; container = "ogg"; mimeType = 'audio/ogg; codecs="vorbis"'; break; } case MediaFormat["vorbis/webm"]: { codec = "libvorbis"; container = "webm"; mimeType = 'audio/webm; codecs="vorbis"'; break; } default: { throw new Error(`Unsupported format: ${format}`); } } const cmd = Bun.spawn( ["ffmpeg", "-i", filePath, "-vn", "-c:a", codec, "-f", container, "-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, mimeType }; } 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, exitCode: number | null) => { if (exitCode !== 0 && exitCode !== null) { console.error("tar failed with status code", exitCode); console.error(await streamToString(subprocess.stderr as ReadableStream)); } }, }); }