This commit is contained in:
Nathan Rashleigh committed 2026-05-26 16:27:50 +10:00
1 parent 32adb5ddac
commit 1e49010aa9
17 files changed
+1049

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
/**
* FLAC -> MP3 V0 joint-stereo conversion via ffmpeg, with source-hash dedup.
*
* The SHA-256 of the source FLAC is stamped into a TXXX:MAPLE_SRC_SHA256 ID3
* frame on the produced MP3. Reruns skip conversion when the existing MP3
* carries the matching hash, so the cache is content-addressed against inputs.
*/
import { mkdir, access } from "node:fs/promises";
import { dirname } from "node:path";
import { parseFile } from "music-metadata";
import NodeID3 from "node-id3";
const HASH_FRAME_DESC = "MAPLE_SRC_SHA256";
export async function fileSha256(path: string): Promise<string> {
const hasher = new Bun.CryptoHasher("sha256");
const stream = Bun.file(path).stream();
for await (const chunk of stream) hasher.update(chunk);
return hasher.digest("hex");
}
async function exists(p: string): Promise<boolean> {
try { await access(p); return true; } catch { return false; }
}
function readSourceHash(mp3: string): string | null {
const tags = NodeID3.read(mp3);
const frames = tags.userDefinedText;
if (!frames) return null;
for (const f of frames) {
if (f.description === HASH_FRAME_DESC && f.value) return f.value;
}
return null;
}
function writeSourceHash(mp3: string, sha: string): void {
NodeID3.update({ userDefinedText: [{ description: HASH_FRAME_DESC, value: sha }] }, mp3);
}
async function which(cmd: string): Promise<boolean> {
const p = Bun.spawn({ cmd: ["which", cmd], stdout: "pipe", stderr: "pipe" });
await p.exited;
return p.exitCode === 0;
}
export async function convertFlacToMp3(flac: string, mp3: string): Promise<boolean> {
const srcHash = await fileSha256(flac);
if (await exists(mp3)) {
try {
if (readSourceHash(mp3) === srcHash) return false;
} catch {}
}
if (!(await which("ffmpeg"))) throw new Error("ffmpeg not found on PATH");
await mkdir(dirname(mp3), { recursive: true });
const proc = Bun.spawn({
cmd: [
"ffmpeg", "-nostdin", "-loglevel", "error", "-y",
"-i", flac,
"-codec:a", "libmp3lame",
"-q:a", "0",
"-joint_stereo", "1",
"-map_metadata", "0",
mp3,
],
stdout: "inherit",
stderr: "inherit",
});
await proc.exited;
if (proc.exitCode !== 0) throw new Error(`ffmpeg failed for ${flac}`);
writeSourceHash(mp3, srcHash);
return true;
}
async function duration(path: string): Promise<number> {
try {
const md = await parseFile(path, { duration: true });
return md.format.duration ?? 0;
} catch {
return 0;
}
}
export async function verifyDuration(src: string, dst: string, tolerance = 0.1): Promise<void> {
const [a, b] = await Promise.all([duration(src), duration(dst)]);
if (Math.abs(a - b) > tolerance) {
throw new Error(`duration mismatch: ${src} = ${a.toFixed(3)}s vs ${dst} = ${b.toFixed(3)}s (Δ ${Math.abs(a - b).toFixed(3)}s)`);
}
}