mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Extraction always used Matroska (.mka) because it takes essentially any audio codec. That is right for most codecs but wrong for AAC: MP4 records the AAC encoder priming (the ~1000 warm-up samples every AAC encoder emits) in an edit list, and the edit list does not survive into Matroska. The remuxed track then decodes ~23 ms longer than the source, with the priming samples played at the head instead of discarded. Measured on a 5s 640x480 fixture: source audio decodes to 962980 bytes of PCM, the .mka to 967440 — 4460 bytes / ~23 ms extra, peaking at -66 dBFS. Inaudible in practice, but it also puts the track fractionally out of step with its own reported duration, for no reason. Pick the container by codec instead: aac -> .m4a (keeps the edit list), everything else -> .mka as before. If the preferred container refuses the codec, retry into .mka before falling back to keeping the whole video. AAC is worth the special case because mp4 / mov / m4v — what people actually upload — almost always carry it. Adds the strongest available test of the "lossless" claim: decode the audio straight out of the source mp4, decode the stored extract, assert the PCM is byte-for-byte equal. Forcing .mka fails it with exactly the 4460-byte delta. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
110 lines
4.6 KiB
TypeScript
110 lines
4.6 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
// unlinkSync/rmdirSync are NOT mocked below, so the test's own fixture
|
|
// teardown is unaffected by the simulated lock on *.mp4.
|
|
import { mkdtempSync, statSync, existsSync, readFileSync, unlinkSync, readdirSync, rmdirSync } from "node:fs";
|
|
import { spawnSync } from "node:child_process";
|
|
import { createRequire } from "node:module";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
/**
|
|
* #149: when the audio track is extracted successfully but the source video
|
|
* cannot be deleted (Windows keeps files locked briefly — rmSync with
|
|
* force:true still throws EBUSY/EPERM), the record must fall back to the
|
|
* ORIGINAL container completely: both the path AND the recorded size.
|
|
*
|
|
* Committing the size before the delete succeeded would leave the record
|
|
* claiming the small extracted size while still holding the whole video, so
|
|
* totalBytes() under-counts and the upload directory grows past its quota.
|
|
*
|
|
* This lives in its own file because it partially mocks node:fs, which would
|
|
* otherwise leak into every other test in local.test.ts.
|
|
*/
|
|
vi.mock("node:fs", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:fs")>();
|
|
return {
|
|
...actual,
|
|
default: actual,
|
|
rmSync: (path: string, opts?: object) => {
|
|
// Simulate the lock on the source video only; every other delete
|
|
// (the discarded .mka, temp dirs, the reject path) behaves normally.
|
|
if (typeof path === "string" && path.endsWith(".mp4")) {
|
|
const err = new Error("EBUSY: resource busy or locked") as NodeJS.ErrnoException;
|
|
err.code = "EBUSY";
|
|
throw err;
|
|
}
|
|
return actual.rmSync(path, opts as never);
|
|
},
|
|
};
|
|
});
|
|
|
|
const { LocalMusicProvider } = await import("./local.js");
|
|
|
|
const ffmpeg: string | null = (() => {
|
|
try {
|
|
return createRequire(import.meta.url)("ffmpeg-static") as string;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
const have = !!ffmpeg && spawnSync(ffmpeg, ["-version"], { stdio: "ignore" }).status === 0;
|
|
|
|
let dir: string;
|
|
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "local-extract-fallback-")); });
|
|
afterEach(() => {
|
|
// Recursive teardown without rmSync (mocked above for *.mp4).
|
|
for (const f of readdirSync(dir)) {
|
|
try { unlinkSync(join(dir, f)); } catch { /* best effort */ }
|
|
}
|
|
try { rmdirSync(dir); } catch { /* best effort */ }
|
|
});
|
|
|
|
describe("LocalMusicProvider: source video cannot be deleted after extraction (#149)", () => {
|
|
it.runIf(have)("keeps the original container AND its real size, not the extracted size", async () => {
|
|
const src = join(dir, "fixture.mp4");
|
|
const r = spawnSync(ffmpeg!, [
|
|
"-y", "-hide_banner", "-loglevel", "error",
|
|
"-f", "lavfi", "-i", "testsrc=s=320x240:r=25:d=3",
|
|
"-f", "lavfi", "-i", "sine=f=440:d=3",
|
|
"-c:v", "libx264", "-b:v", "800k", "-c:a", "aac", "-shortest", src,
|
|
], { stdio: "ignore" });
|
|
expect(r.status).toBe(0);
|
|
|
|
const bytes = readFileSync(src);
|
|
unlinkSync(src); // uploadAudio writes its own copy under a uuid name
|
|
|
|
const p = new LocalMusicProvider(dir);
|
|
const song = await p.uploadAudio({
|
|
buffer: bytes, originalName: "fixture.mp4", mimeType: "video/mp4",
|
|
});
|
|
|
|
const resolved = await p.getSongUrl(song.id);
|
|
expect(resolved).not.toBeNull();
|
|
|
|
// Fell back to the original container — the extract was discarded.
|
|
expect(resolved!.url.endsWith(".mp4")).toBe(true);
|
|
expect(existsSync(resolved!.url)).toBe(true);
|
|
expect(existsSync(resolved!.url.replace(/\.mp4$/, ".m4a"))).toBe(false);
|
|
expect(existsSync(resolved!.url.replace(/\.mp4$/, ".mka"))).toBe(false);
|
|
|
|
const onDisk = statSync(resolved!.url).size;
|
|
expect(onDisk).toBe(bytes.length);
|
|
|
|
// The RECORDED size drives the quota (totalBytes()), so it must describe
|
|
// the file actually retained. It is not exposed through search()/toSong,
|
|
// but it is persisted to index.json — read it back from there.
|
|
const record = (JSON.parse(readFileSync(join(dir, "index.json"), "utf8")) as Array<{
|
|
id: string; size: number; filePath: string;
|
|
}>).find((r) => r.id === song.id);
|
|
expect(record).toBeDefined();
|
|
expect(record!.filePath.endsWith(".mp4")).toBe(true);
|
|
// Before the fix this was the (much smaller) .mka size while the whole
|
|
// .mp4 stayed on disk, so the quota under-counted the retained bytes.
|
|
expect(record!.size).toBe(bytes.length);
|
|
|
|
// Sanity: the extract really is much smaller, so a wrong commit order
|
|
// would have been clearly observable rather than a rounding error.
|
|
expect(onDisk).toBeGreaterThan(50_000);
|
|
}, 60000);
|
|
});
|