use native mediainfo instead of wasm
MContainerfile
@@ -6,7 +6,7 @@ RUN bun install --cwd server
RUN bun run --cwd server compile
FROM docker.io/alpine:3.22
RUN apk add catatonit ffmpeg tar libavif-apps
RUN apk add catatonit ffmpeg tar libavif-apps mediainfo
WORKDIR /app
COPY --from=compile /app/server/dist .
MREADME.md
@@ -35,12 +35,13 @@ If you choose to not run this via the include Containerfile, you need to ensure
- `tar`: To bundle playlists to a single file when downloading them
- `ffmpeg`: For transcoding audio
- `avifenc`: For transcoding cover art
- `mediainfo`: For probing media file metadata
## Configuration
You can configure the backend with the following environment variables:
- `COVER_REGEX`: JS-compatible regex to match filenames in a directory to find a matching cover art. Searches the directory structure upwards. Set to an empty string to disable cover detection.
- `EXCLUDE_EXTENSION`: comma-separated list of of file extensions to ignore completely, e.g. `txt,log,nfo`. If unset, uses a safe set of text files common in downloaded music archives. Useful because some files can get mis-scanned, e.g. some CD-scan .log files get scanned as mp1
- `SCAN_CONCURRENCY`: maximum number of files probed in parallel while scanning uncached folders (also the size of the internal MediaInfo instance pool). Defaults to `8`; invalid or non-positive values fall back to the default.
- `SCAN_CONCURRENCY`: maximum number of parallel `mediainfo` processes while scanning uncached folders. Defaults to `8`; invalid or non-positive values fall back to the default.
- `AUTH`: Same format as the string used for basic auth (username and password joined by a colon `:` and encoded to base64). If set, the server APIs return 401 unless the users is signed in with the given login details. Basic assets are not protected, so the UI itself will load fine even when not logged in, so when you get signed out for whatever reason (e.g. by the server restarting), you can still access the UI and play already synced files without problems.
## Non-Goals
Mserver/package.json
@@ -6,11 +6,10 @@
"clean": "rm -rf dist node_modules ../webclient/dist ../webclient/node_modules",
"postinstall": "bun install --cwd ../webclient",
"build-watch:frontend": "cd ../webclient && bun run build -c vite.watch.config.ts --watch --emptyOutDir --outDir ../server/dist",
"copy-mediainfo-wasm": "cp node_modules/mediainfo.js/dist/MediaInfoModule.wasm dist/MediaInfoModule.wasm",
"start": "bun run copy-mediainfo-wasm && bun run --watch src/index.ts --serve dist",
"start": "bun run --watch src/index.ts --serve dist",
"dev": "concurrently \"bun run build-watch:frontend\" \"sleep 1 && bun run start\"",
"build:frontend": "cd ../webclient && bun run build -c vite.compile.config.ts --emptyOutDir --outDir ../server/dist",
"compile": "bun run build:frontend && bun build src/index.ts --compile --production --outfile dist/server && bun run copy-mediainfo-wasm"
"compile": "bun run build:frontend && bun build src/index.ts --compile --production --outfile dist/server"
},
"devDependencies": {
"@types/command-exists": "^1.2.3",
@@ -25,7 +24,6 @@
"@elysiajs/static": "1.2.0",
"command-exists": "^1.2.9",
"file-type": "^20.0.1",
"mediainfo.js": "^0.3.5",
"music-metadata": "^11.2.0",
"music-server-shared": "file:../shared",
"range-parser": "^1.2.1",
Mserver/src/utils.ts
@@ -3,14 +3,12 @@ import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import { StatusMap } from "elysia";
import { fileTypeFromFile } from "file-type";
import MediaInfo from "mediainfo.js";
import { parseFile } from "music-metadata";
import type { FileListingWithStatus, Metadata } from "music-server-shared/types";
import tmp from "tmp";
import {
PathInfo,
ServerError,
args,
coverRegex,
excludeExtension,
fileTypeCache,
@@ -29,41 +27,42 @@ export async function findAsync<T>(arr: Array<T>, asyncCallback: (value: T) => P
type CoverResult = { info: PathInfo; path: string } | { info: PathInfo; content: Uint8Array } | { info: ServerError };
//MediaInfo is overloaded, so infer the instance type from an actual construction call
function createMediaInfoInstance() {
return MediaInfo({
locateFile: () => `${args.serve}/MediaInfoModule.wasm`,
});
//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;
}
type MediaInfoInstance = Awaited<ReturnType<typeof createMediaInfoInstance>>;
//analyzeData is a stateful async sequence, so concurrent analyses on one instance corrupt each other.
//Keep a small pool of instances (lazily created up to scanConcurrency) and hand them out one at a time.
class MediaInfoPool {
private available: MediaInfoInstance[] = [];
private created = 0;
private waitQueue: ((instance: MediaInfoInstance) => void)[] = [];
//bound the number of concurrent mediainfo processes while scanning uncached folders
class Semaphore {
private running = 0;
private waitQueue: (() => void)[] = [];
constructor(private maxSize: number) {}
async acquire(): Promise<MediaInfoInstance> {
const existing = this.available.pop();
if (existing) return existing;
if (this.created < this.maxSize) {
this.created++;
return await createMediaInfoInstance();
async acquire(): Promise<void> {
if (this.running < this.maxSize) {
this.running++;
return;
}
//the released slot is handed over directly, so running stays unchanged
return new Promise((resolve) => this.waitQueue.push(resolve));
}
release(instance: MediaInfoInstance): void {
release(): void {
const next = this.waitQueue.shift();
if (next) next(instance);
else this.available.push(instance);
if (next) next();
else this.running--;
}
}
const mediaInfoPool = new MediaInfoPool(scanConcurrency);
const mediaInfoSemaphore = new Semaphore(scanConcurrency);
export async function findCover(targetPath: string): Promise<CoverResult> {
try {
@@ -107,18 +106,23 @@ export async function findCover(targetPath: string): Promise<CoverResult> {
return { info: new ServerError(StatusMap["Not Found"], "Cover not found") };
}
export async function analyzeWithMediainfo(filePath: string) {
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 mediaInfo = await mediaInfoPool.acquire();
export async function analyzeWithMediainfo(filePath: string): Promise<MediaInfoResult> {
await mediaInfoSemaphore.acquire();
try {
return await mediaInfo.analyzeData(fileSize, chunkReader);
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 {
mediaInfoPool.release(mediaInfo);
mediaInfoSemaphore.release();
}
}
@@ -144,19 +148,21 @@ export async function probeFile(filePath: string): Promise<Metadata> {
.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: generalTrack?.Duration,
title: generalTrack?.Title,
artist: generalTrack?.Performer || generalTrack?.Album_Performer,
album: generalTrack?.Album,
genre: [generalTrack?.Genre || ""],
trackNumber: generalTrack?.Track_Position,
container: generalTrack?.Format,
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: audioTrack?.BitRate,
audioBitrate: num(audioTrack?.BitRate),
videoCodec: videoTrack?.Format,
videoBitrate: videoTrack?.BitRate,
videoBitrate: num(videoTrack?.BitRate),
audioTracks,
};