diff --git a/server/src/modules/file-write/formats/audio/audio-metadata-embedder.test.ts b/server/src/modules/file-write/formats/audio/audio-metadata-embedder.test.ts index 733bff832..da10972e7 100644 --- a/server/src/modules/file-write/formats/audio/audio-metadata-embedder.test.ts +++ b/server/src/modules/file-write/formats/audio/audio-metadata-embedder.test.ts @@ -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' }]); diff --git a/server/src/modules/file-write/formats/audio/audio-metadata-embedder.ts b/server/src/modules/file-write/formats/audio/audio-metadata-embedder.ts index 2ad2e7adf..67acf0318 100644 --- a/server/src/modules/file-write/formats/audio/audio-metadata-embedder.ts +++ b/server/src/modules/file-write/formats/audio/audio-metadata-embedder.ts @@ -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'); } diff --git a/server/src/modules/file-write/formats/audio/audio-metadata-roundtrip.test.ts b/server/src/modules/file-write/formats/audio/audio-metadata-roundtrip.test.ts index de854e488..5cc190e92 100644 --- a/server/src/modules/file-write/formats/audio/audio-metadata-roundtrip.test.ts +++ b/server/src/modules/file-write/formats/audio/audio-metadata-roundtrip.test.ts @@ -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 { + 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 { + return (await probeChapters(filePath)).map((chapter) => chapter.title); + } + + async function probeChapters(filePath: string): Promise> { 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 {