91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
/**
|
|
* 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)`);
|
|
}
|
|
}
|