Don't encode on copy

This commit is contained in:
Tiberiu Ichim 2026-02-14 21:17:52 +02:00
parent 96707200b8
commit 98ce898f41
3 changed files with 150 additions and 3 deletions

View file

@ -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.

View file

@ -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

View file

@ -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
})
})