diff --git a/artifacts/2026-02-14/m4b_conversion.md b/artifacts/2026-02-14/m4b_conversion.md new file mode 100644 index 000000000..4f50ae1f9 --- /dev/null +++ b/artifacts/2026-02-14/m4b_conversion.md @@ -0,0 +1,37 @@ +# M4B Conversion Specification + +## Overview +Audiobookshelf provides a tool to merge audiobook audio tracks into a single M4B file. This specification documents the improvements made to this tool to support "Stream Copy" (no re-encode), preserving audio quality and significantly reducing processing time. + +## Feature Goals +- Support merging multiple audio files (MP3, M4A, AAC) into a single M4B container. +- **Avoid re-encoding** when the source audio is already compatible (e.g., AAC in M4A/MP4 container) or when the user explicitly chooses "Copy". +- Preserving all metadata including: + - Title, Artist, Album, etc. + - Chapters (from library item metadata). + - Cover art. + +## Technical Details + +### Backend Implementation +The logic resides in `server/utils/ffmpegHelpers.js` and `server/managers/AbMergeManager.js`. + +#### FFmpeg Strategy for "Copy" +When `codec: 'copy'` is requested: +1. **Concatenation**: If multiple files exist, they are concatenated using the `concat` demuxer in FFmpeg. + - Command: `ffmpeg -f concat -safe 0 -i files.txt -c copy -f mp4 output.m4b` +2. **Metadata and Cover Embedding**: The concatenated file is then processed to add the `ffmetadata` and cover art. + - Command: `ffmpeg -i input.m4b -i metadata.txt -i cover.jpg -map 0:a -map 1 -map 2:v -c copy -disposition:v:0 attached_pic -f mp4 output_final.m4b` + +### Frontend Implementation +The user interface is accessible via the **Manage** page of a book, under the **M4B Encoder** tool. + +#### Options +- **Codec**: Options include `AAC`, `OPUS`, and `Copy`. +- **Bitrate**: Custom or presets (ignored for `Copy`). +- **Channels**: Custom or presets (ignored for `Copy`). + +## Current Status +- [x] Initial specification. +- [x] Backend implementation for stream copy. +- [x] Verification. diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index 80832cc77..4dcbbcda7 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -444,9 +444,7 @@ async function mergeAudioFiles(audioTracks, duration, itemCachePath, outputFileP const audioCodec = encodingOptions.codec || 'aac' const audioChannels = encodingOptions.channels || 2 - // TODO: Updated in 2.2.11 to always encode even if merging multiple m4b. This is because just using the file extension as was being done before is not enough. This can be an option or do more to check if a concat is possible. - // const audioRequiresEncode = audioTracks[0].metadata.ext !== '.m4b' - const audioRequiresEncode = true + const audioRequiresEncode = audioCodec !== 'copy' const firstTrackIsM4b = audioTracks[0].metadata.ext.toLowerCase() === '.m4b' const isOneTrack = audioTracks.length === 1 diff --git a/test/server/utils/ffmpegHelpers_merge.test.js b/test/server/utils/ffmpegHelpers_merge.test.js new file mode 100644 index 000000000..3df6bbd51 --- /dev/null +++ b/test/server/utils/ffmpegHelpers_merge.test.js @@ -0,0 +1,112 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const EventEmitter = require('events') +const fs = require('../../../server/libs/fsExtra') +const { mergeAudioFiles } = require('../../../server/utils/ffmpegHelpers') + +describe('mergeAudioFiles', () => { + let ffmpegStub + let audioTracks + let encodingOptions + let itemCachePath + let outputFilePath + + beforeEach(() => { + ffmpegStub = new EventEmitter() + ffmpegStub.input = sinon.stub().returnsThis() + ffmpegStub.inputOptions = sinon.stub().returnsThis() + ffmpegStub.outputOptions = sinon.stub().returnsThis() + ffmpegStub.output = sinon.stub().returnsThis() + ffmpegStub.run = sinon.stub().callsFake(() => { + ffmpegStub.emit('end') + }) + + audioTracks = [ + { + index: 0, + ino: 'ino1', + metadata: { path: '/path/to/track1.mp3', ext: '.mp3' }, + duration: 100 + }, + { + index: 1, + ino: 'ino2', + metadata: { path: '/path/to/track2.mp3', ext: '.mp3' }, + duration: 120 + } + ] + + encodingOptions = { + codec: 'aac', + bitrate: '128k', + channels: 2 + } + + itemCachePath = '/path/to/cache' + outputFilePath = '/path/to/output.m4b' + + sinon.stub(fs, 'writeFile').resolves() + sinon.stub(fs, 'remove').resolves() + }) + + afterEach(() => { + sinon.restore() + }) + + it('should use re-encoding by default (codec aac)', async () => { + await mergeAudioFiles(audioTracks, 220, itemCachePath, outputFilePath, encodingOptions, null, ffmpegStub) + + const calls = ffmpegStub.outputOptions.getCalls() + const allArgs = calls.flatMap(call => call.args[0]) + + expect(allArgs).to.include('-acodec aac') + expect(allArgs).to.include('-ac 2') + expect(allArgs).to.include('-b:a 128k') + }) + + it('should use stream copy when codec is copy', async () => { + encodingOptions.codec = 'copy' + + await mergeAudioFiles(audioTracks, 220, itemCachePath, outputFilePath, encodingOptions, null, ffmpegStub) + + // Should hit the 'else' block which uses '-c:a copy' + expect(ffmpegStub.outputOptions.calledWith(['-c:a copy'])).to.be.true + + // Should NOT contain bitrate or channels or acodec + const outputOptionsCalls = ffmpegStub.outputOptions.getCalls() + outputOptionsCalls.forEach(call => { + const args = call.args[0] + if (Array.isArray(args)) { + expect(args.some(arg => arg.includes('-b:a'))).to.be.false + expect(args.some(arg => arg.includes('-ac '))).to.be.false // space to avoid matching '-acodec' + expect(args.some(arg => arg.includes('-acodec'))).to.be.false + } + }) + }) + + it('should handle single track with copy', async () => { + encodingOptions.codec = 'copy' + const singleTrack = [audioTracks[0]] + + await mergeAudioFiles(singleTrack, 100, itemCachePath, outputFilePath, encodingOptions, null, ffmpegStub) + + expect(ffmpegStub.outputOptions.calledWith(['-c:a copy'])).to.be.true + }) + + it('should handle single track m4b with copy', async () => { + encodingOptions.codec = 'copy' + const singleTrack = [ + { + index: 0, + ino: 'ino1', + metadata: { path: '/path/to/track1.m4b', ext: '.m4b' }, + duration: 100 + } + ] + + await mergeAudioFiles(singleTrack, 100, itemCachePath, outputFilePath, encodingOptions, null, ffmpegStub) + + // Code says: if (isOneTrack && firstTrackIsM4b) ffmpeg.outputOptions(['-c copy']) + expect(ffmpegStub.outputOptions.calledWith(['-c copy'])).to.be.true + }) +})