fix(metadata): preserve MP3 chapter timestamps on rewrite

Keep FFmpeg's MP3 input timestamps so metadata-only remuxes do not move embedded chapter boundaries on every write.

Fixes #2034
This commit is contained in:
neon
2026-11-02 01:55:30 +00:00
parent 9b853935bb
commit f1979f4049
3 changed files with 72 additions and 4 deletions
@@ -213,10 +213,18 @@ describe('AudioMetadataEmbedder', () => {
it('adds MP3-specific ID3v2 compatibility args', () => {
const args = testing.buildFfmpegArgs('/books/audio/book.mp3', '/books/audio/cover.jpg', '/books/audio/out.mp3', 'mp3', []);
expect(args).toContain('-copyts');
expect(args.indexOf('-copyts')).toBeLessThan(args.indexOf('-i'));
expect(args).toContain('-id3v2_version');
expect(args[args.indexOf('-id3v2_version') + 1]).toBe('3');
});
it.each(['m4b', 'm4a', 'flac'])('does not add MP3 timestamp handling to %s output', (format) => {
const args = testing.buildFfmpegArgs(`/books/audio/book.${format}`, null, `/books/audio/out.${format}`, format, []);
expect(args).not.toContain('-copyts');
});
it.each(['m4b', 'm4a'])('enables extended metadata tags for %s output', (format) => {
const args = testing.buildFfmpegArgs('/books/audio/book.m4b', null, `/books/audio/out.${format}`, format, [{ key: 'series', value: 'Series' }]);
@@ -113,7 +113,14 @@ function buildFfmpegArgs(
metadata: AudioMetadataArg[],
options?: { useMp4MetadataTags?: boolean; muxer?: string; chaptersPath?: string | null },
): string[] {
const args = ['-v', 'error', '-y', '-i', filePath];
const normalizedFormat = format.toLowerCase();
const args = ['-v', 'error', '-y'];
if (normalizedFormat === FORMAT_MP3) {
// MP3 encoder delay gives the input a positive start time that FFmpeg otherwise subtracts
// from carried chapters on every remux.
args.push('-copyts');
}
args.push('-i', filePath);
const chaptersPath = options?.chaptersPath ?? null;
if (coverPath) {
@@ -134,7 +141,7 @@ function buildFfmpegArgs(
const chaptersInput = chaptersPath ? (coverPath ? 2 : 1) : 0;
args.push('-map_metadata', '0', '-map_chapters', String(chaptersInput), '-c', 'copy');
if (format.toLowerCase() === FORMAT_MP3) {
if (normalizedFormat === FORMAT_MP3) {
args.push('-id3v2_version', '3');
}
@@ -106,6 +106,23 @@ describe('audio metadata round-trip', () => {
expect(extracted.authors).toEqual([{ name: 'Author One', sortName: null }]);
});
it('preserves MP3 chapter boundaries across repeated metadata writes', async () => {
const filePath = join(tempDir, 'chaptered.mp3');
await generateChapteredMp3Fixture(filePath);
const chaptersBefore = await probeChapters(filePath);
const writer = new Mp3AudioFormatWriter(new AudioMetadataEmbedder({ ffmpegPath, ffprobePath }));
expect(chaptersBefore).toEqual([
{ title: 'One', startMs: 0, endMs: 1_500 },
{ title: 'Two', startMs: 1_500, endMs: 3_000 },
]);
for (const title of ['First write', 'Second write']) {
await writer.write(filePath, { title }, { dryRun: false, fieldMask: new Set(['title']) });
expect(await probeChapters(filePath)).toEqual(chaptersBefore);
}
});
it.each([
['m4b', M4bAudioFormatWriter],
['m4a', M4aAudioFormatWriter],
@@ -511,10 +528,46 @@ describe('audio metadata round-trip', () => {
]);
}
async function generateChapteredMp3Fixture(filePath: string): Promise<void> {
const chaptersPath = join(tempDir, 'mp3-chapters.txt');
await writeFile(
chaptersPath,
';FFMETADATA1\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1500\ntitle=One\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=1500\nEND=3000\ntitle=Two\n',
);
await execFile(ffmpegPath, [
'-v',
'error',
'-y',
'-f',
'lavfi',
'-i',
'sine=frequency=1000:duration=3:sample_rate=44100',
'-i',
chaptersPath,
'-map_metadata',
'1',
'-map_chapters',
'1',
'-c:a',
'libmp3lame',
filePath,
]);
}
async function probeChapterTitles(filePath: string): Promise<string[]> {
return (await probeChapters(filePath)).map((chapter) => chapter.title);
}
async function probeChapters(filePath: string): Promise<Array<{ title: string; startMs: number; endMs: number }>> {
const { stdout } = await execFile(ffprobePath, ['-v', 'quiet', '-print_format', 'json', '-show_chapters', filePath]);
const parsed = JSON.parse(stdout) as { chapters?: Array<{ tags?: { title?: string } }> };
return (parsed.chapters ?? []).map((chapter) => chapter.tags?.title ?? '');
const parsed = JSON.parse(stdout) as {
chapters?: Array<{ start_time: string; end_time: string; tags?: { title?: string } }>;
};
return (parsed.chapters ?? []).map((chapter) => ({
title: chapter.tags?.title ?? '',
startMs: Math.round(Number(chapter.start_time) * 1000),
endMs: Math.round(Number(chapter.end_time) * 1000),
}));
}
async function extractArt(filePath: string): Promise<string> {