import { readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { StatusMap } from "elysia"; import { fileTypeFromBlob } from "file-type"; import { parseFile } from "music-metadata"; import { AsyncSemaphore } from "music-server-shared/semaphore"; import type { FileListingWithStatus, Metadata } from "music-server-shared/types"; import tmp from "tmp"; import { coverRegex, excludeExtension, fileTypeCache, mediaTypes, musicRoot, PathInfo, probeCache, ServerError, scanConcurrency, } from "./shared"; //fs errors are untyped, so pull the errno code out of the unknown catch value function errorCode(error: unknown): string | undefined { return error && typeof error === "object" && "code" in error ? String(error.code) : undefined; } 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]; } //success carries a bytes thunk so the handler doesn't care whether the cover came from a file on //disk or from embedded metadata export type CoverResult = | { info: PathInfo; bytes: () => Promise> } | { info: ServerError }; //the mediainfo CLI JSON output emits every value as a string; broken text encodings arrive //as objects carrying an "@dt" attribute instead (detected in probeFile) interface MediaInfoTrack { "@type": string; [field: string]: string | undefined; } interface MediaInfoResult { media?: { track: MediaInfoTrack[]; } | null; } const mediaInfoSemaphore = new AsyncSemaphore(scanConcurrency); export async function findCover(targetPath: string): Promise { try { if ((await stat(targetPath)).isFile()) { //duration is not needed for cover extraction and would force a full-file parse for some formats const parsed = await parseFile(targetPath, { duration: false, skipCovers: false }); if (!parsed.common.picture || parsed.common.picture.length === 0) return findCover(path.dirname(targetPath)); const picture = parsed.common.picture[0]; return { info: new PathInfo(picture.format), bytes: () => Promise.resolve(picture.data as Uint8Array), }; } } 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); } } catch (error) { if (errorCode(error) !== "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 coverPath = path.join(targetPath, coverFile); const info = (await getPathInfo(coverPath)) as PathInfo | ServerError; if (info instanceof ServerError) { return { info: info }; } return { info, bytes: async () => (await Bun.file(coverPath).bytes()) as Uint8Array }; } return { info: new ServerError(StatusMap["Not Found"], "Cover not found") }; } export async function analyzeWithMediainfo(filePath: string): Promise { await mediaInfoSemaphore.acquire(); try { const cmd = Bun.spawn(["mediainfo", "--Output=JSON", filePath], { stdin: "ignore", stdout: "pipe", stderr: "pipe", }); const [output, stderr, exitCode] = await Promise.all([ new Response(cmd.stdout).text(), new Response(cmd.stderr).text(), cmd.exited, ]); if (exitCode !== 0) throw new Error(`mediainfo failed with status code ${exitCode}: ${stderr}`); return JSON.parse(output); } finally { mediaInfoSemaphore.release(); } } //in-flight probes keyed by path, so concurrent callers for the same uncached file share one analysis const inflightProbes = new Map>(); export async function probeFile(filePath: string): Promise { const cached = probeCache.get(filePath); if (cached) return cached; const inflight = inflightProbes.get(filePath); if (inflight) return inflight; const promise = (async (): Promise => { const info = await analyzeWithMediainfo(filePath); // for (const track of info.media?.track || []) { // console.log(track); // } const generalTrack = info.media?.track.find((track) => track["@type"] === "General"); const videoTrack = info.media?.track.find((track) => track["@type"] === "Video"); const audioTrack = info.media?.track.find((track) => track["@type"] === "Audio"); const audioTracks = (info.media?.track || []) .filter((track) => track["@type"] === "Audio") .map((track) => ({ language: track.Language, default: track.Default === "Yes" })); if (!generalTrack?.Duration) throw new Error(); //the CLI emits all values as strings, so numeric fields need explicit coercion const num = (value: string | undefined) => (value === undefined ? undefined : Number(value)); let probeResult: Metadata = { duration: Number(generalTrack.Duration), title: generalTrack.Title, artist: generalTrack.Performer || generalTrack.Album_Performer, album: generalTrack.Album, genre: [generalTrack.Genre || ""], trackNumber: num(generalTrack.Track_Position), container: generalTrack.Format, audioCodec: audioTrack?.Format, audioBitrate: num(audioTrack?.BitRate), videoCodec: videoTrack?.Format, videoBitrate: num(videoTrack?.BitRate), videoWidth: num(videoTrack?.Width), videoHeight: num(videoTrack?.Height), //prefer the exact rational over the rounded FrameRate: 24000/1001 reported as "23.976" is //not precise enough to land on the right side of a codec level boundary frameRate: videoTrack?.FrameRate_Num && videoTrack?.FrameRate_Den ? Number(videoTrack.FrameRate_Num) / Number(videoTrack.FrameRate_Den) : num(videoTrack?.FrameRate), interlaced: videoTrack?.ScanType === undefined ? undefined : videoTrack.ScanType !== "Progressive", audioTracks, }; //if file has broken encodings in their metadata, the "string" are actually objects, and contain this "@dt" field //music-metadata can usually parse the correctly anyway, so use the library in that case if (Object.values(probeResult).some((field) => typeof field === "object" && "@dt" in field)) { const parsed = await parseFile(filePath, { duration: true, skipCovers: false, mkvUseIndex: true }); probeResult = { ...probeResult, title: parsed.common.title, artist: parsed.common.artist, album: parsed.common.album, genre: parsed.common.genre, trackNumber: parsed.common.track.no || undefined, }; } //only cache on success so a rejected probe can be retried later probeCache.set(filePath, probeResult); return probeResult; })(); inflightProbes.set(filePath, promise); try { return await promise; } finally { inflightProbes.delete(filePath); } } 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) { //fileTypeFromFile does a runtime import("strtok3") that breaks inside bun-compiled binaries, //fileTypeFromBlob uses the statically bundled strtok3/core instead const type = await fileTypeFromBlob(Bun.file(filePath)); mimeType = type?.mime; if (!mimeType) { //fileTypeFromBlob gives up on some valid media (e.g. MP3s padded before the first frame sync). //confirm the file is genuinely decodable with mediainfo (probeFile caches the result, so the probe //listFiles runs next for this same file is a cache hit, not a second mediainfo spawn), then take the //MIME type from the extension via Bun's mime-db, accepting only audio/video types. try { await probeFile(filePath); const byExtension = Bun.file(filePath).type.split(";", 1)[0]; if (matchesType(byExtension, mediaTypes)) mimeType = byExtension; } catch { //not decodable media (e.g. a genuinely unrecognised file); fall through to the error below } } 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); } catch (error) { if (errorCode(error) === "ENOENT") { return new ServerError(StatusMap["Not Found"], "File not found"); } console.error("Error scanning file:", filePath, error); 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); //empty when both paths are equal, which counts as inside return !relativePath.startsWith(".."); } export async function listFiles(subPath: string, recursive: boolean): Promise { const files: FileListingWithStatus = {}; try { //process entries concurrently; the MediaInfo pool already bounds the expensive probing part await Promise.all( (await readdir(subPath)).map(async (fileName) => { const itemPath = path.join(subPath, fileName); if (!isBelow(musicRoot, itemPath)) { console.log(`Skipping ${itemPath} as it is outside the base path.`); return; } const pathInfoResult = await getPathInfo(itemPath); if (!pathInfoResult) { if (recursive) { const subListing = await listFiles(itemPath, true); if (subListing instanceof ServerError) throw subListing; files[fileName] = { files: subListing, status: "Scanned" }; } else { files[fileName] = { files: {}, status: "Unknown" }; } } else if (pathInfoResult instanceof PathInfo && matchesType(pathInfoResult.mimeType, mediaTypes)) { try { const probe = await probeFile(itemPath); files[fileName] = { metadata: probe }; } catch (probeError) { console.error("probing error:", probeError); } } }), ); return files; } catch (error) { if (error instanceof ServerError) return error; if (errorCode(error) === "ENOENT") { return new ServerError(StatusMap["Not Found"], "Directory not found"); } console.error("Error listing path:", subPath, error); 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/png") ? ".png" : undefined, }); const tmpFile2 = tmp.fileSync({ postfix: ".avif" }); try { await writeFile(tmpFile.name, 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))); } }, }); const exitCode = await cmd.exited; if (exitCode !== 0) throw new Error(`avifenc failed with status code ${exitCode}`); setContentType("image/avif"); return (await readFile(tmpFile2.name)) as Uint8Array; } catch { setContentType(mimeType); return data; } finally { tmpFile.removeCallback(); tmpFile2.removeCallback(); } }