mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user