audiobookshelf/test/server/utils/ffmpegHelpers.test.js

466 lines
18 KiB
JavaScript

const { expect } = require('chai')
const sinon = require('sinon')
const fileUtils = require('../../../server/utils/fileUtils')
const fs = require('../../../server/libs/fsExtra')
const EventEmitter = require('events')
const { generateFFMetadata, addCoverAndMetadataToFile, extractCoverArt } = require('../../../server/utils/ffmpegHelpers')
global.isWin = process.platform === 'win32'
describe('generateFFMetadata', () => {
function createTestSetup() {
const metadata = {
title: 'My Audiobook',
artist: 'John Doe',
album: 'Best Audiobooks'
}
const chapters = [
{ start: 0, end: 1000, title: 'Chapter 1' },
{ start: 1000, end: 2000, title: 'Chapter 2' }
]
return { metadata, chapters }
}
let metadata = null
let chapters = null
beforeEach(() => {
const input = createTestSetup()
metadata = input.metadata
chapters = input.chapters
})
it('should generate ffmetadata content with chapters', () => {
const result = generateFFMetadata(metadata, chapters)
expect(result).to.equal(';FFMETADATA1\ntitle=My Audiobook\nartist=John Doe\nalbum=Best Audiobooks\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000000\ntitle=Chapter 1\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=1000000\nEND=2000000\ntitle=Chapter 2\n')
})
it('should generate ffmetadata content without chapters', () => {
chapters = null
const result = generateFFMetadata(metadata, chapters)
expect(result).to.equal(';FFMETADATA1\ntitle=My Audiobook\nartist=John Doe\nalbum=Best Audiobooks\n')
})
it('should handle chapters with no title', () => {
chapters = [
{ start: 0, end: 1000 },
{ start: 1000, end: 2000 }
]
const result = generateFFMetadata(metadata, chapters)
expect(result).to.equal(';FFMETADATA1\ntitle=My Audiobook\nartist=John Doe\nalbum=Best Audiobooks\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000000\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=1000000\nEND=2000000\n')
})
it('should handle metadata escaping special characters (=, ;, #, and a newline)', () => {
metadata.title = 'My Audiobook; with = special # characters\n'
chapters[0].title = 'Chapter #1'
const result = generateFFMetadata(metadata, chapters)
expect(result).to.equal(';FFMETADATA1\ntitle=My Audiobook\\; with \\= special \\# characters\\\n\nartist=John Doe\nalbum=Best Audiobooks\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=1000000\ntitle=Chapter \\#1\n\n[CHAPTER]\nTIMEBASE=1/1000\nSTART=1000000\nEND=2000000\ntitle=Chapter 2\n')
})
})
describe('addCoverAndMetadataToFile', () => {
function createTestSetup() {
const audioFilePath = '/path/to/audio/file.mp3'
const coverFilePath = '/path/to/cover/image.jpg'
const metadataFilePath = '/path/to/metadata/file.txt'
const track = 1
const mimeType = 'audio/mpeg'
const ffmpegStub = new EventEmitter()
ffmpegStub.input = sinon.stub().returnsThis()
ffmpegStub.outputOptions = sinon.stub().returnsThis()
ffmpegStub.output = sinon.stub().returnsThis()
ffmpegStub.input = sinon.stub().returnsThis()
ffmpegStub.run = sinon.stub().callsFake(() => {
ffmpegStub.emit('end')
})
const copyStub = sinon.stub().resolves()
const fsRemoveStub = sinon.stub(fs, 'remove').resolves()
return { audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub, copyStub, fsRemoveStub }
}
let audioFilePath = null
let coverFilePath = null
let metadataFilePath = null
let track = null
let mimeType = null
let ffmpegStub = null
let copyStub = null
let fsRemoveStub = null
beforeEach(() => {
const input = createTestSetup()
audioFilePath = input.audioFilePath
coverFilePath = input.coverFilePath
metadataFilePath = input.metadataFilePath
track = input.track
mimeType = input.mimeType
ffmpegStub = input.ffmpegStub
copyStub = input.copyStub
fsRemoveStub = input.fsRemoveStub
})
it('should add cover image and metadata to audio file', async () => {
// Act
await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub, copyStub)
// Assert
expect(ffmpegStub.input.calledThrice).to.be.true
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath)
expect(ffmpegStub.outputOptions.callCount).to.equal(4)
expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])
expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])
expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3'])
expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover'])
expect(ffmpegStub.output.calledOnce).to.be.true
expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
expect(ffmpegStub.run.calledOnce).to.be.true
expect(copyStub.calledOnce).to.be.true
expect(copyStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
expect(copyStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
expect(fsRemoveStub.calledOnce).to.be.true
expect(fsRemoveStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
// Restore the stub
sinon.restore()
})
it('should handle missing cover image', async () => {
// Arrange
coverFilePath = null
// Act
await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub, copyStub)
// Assert
expect(ffmpegStub.input.calledTwice).to.be.true
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
expect(ffmpegStub.outputOptions.callCount).to.equal(4)
expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])
expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])
expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3'])
expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 0:v?'])
expect(ffmpegStub.output.calledOnce).to.be.true
expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
expect(ffmpegStub.run.calledOnce).to.be.true
expect(copyStub.callCount).to.equal(1)
expect(copyStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
expect(copyStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
expect(fsRemoveStub.calledOnce).to.be.true
expect(fsRemoveStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
// Restore the stub
sinon.restore()
})
it('should handle error during ffmpeg execution', async () => {
// Arrange
ffmpegStub.run = sinon.stub().callsFake(() => {
ffmpegStub.emit('error', new Error('FFmpeg error'))
})
// Act
try {
await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub, copyStub)
expect.fail('Expected an error to be thrown')
} catch (error) {
// Assert
expect(error.message).to.equal('FFmpeg error')
}
// Assert
expect(ffmpegStub.input.calledThrice).to.be.true
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath)
expect(ffmpegStub.outputOptions.callCount).to.equal(4)
expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])
expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])
expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3'])
expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover'])
expect(ffmpegStub.output.calledOnce).to.be.true
expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
expect(ffmpegStub.run.calledOnce).to.be.true
expect(copyStub.called).to.be.false
expect(fsRemoveStub.called).to.be.false
// Restore the stub
sinon.restore()
})
it('should handle m4b embedding', async () => {
// Arrange
mimeType = 'audio/mp4'
audioFilePath = '/path/to/audio/file.m4b'
// Act
await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, null, ffmpegStub, copyStub)
// Assert
expect(ffmpegStub.input.calledThrice).to.be.true
expect(ffmpegStub.input.getCall(0).args[0]).to.equal(audioFilePath)
expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath)
expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath)
expect(ffmpegStub.outputOptions.callCount).to.equal(4)
expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy'])
expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1'])
expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-f mp4'])
expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover'])
expect(ffmpegStub.output.calledOnce).to.be.true
expect(ffmpegStub.output.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
expect(ffmpegStub.run.calledOnce).to.be.true
expect(copyStub.calledOnce).to.be.true
expect(copyStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
expect(copyStub.firstCall.args[1]).to.equal('/path/to/audio/file.m4b')
expect(fsRemoveStub.calledOnce).to.be.true
expect(fsRemoveStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
// Restore the stub
sinon.restore()
})
})
describe('extractCoverArt', () => {
function createTestSetup() {
const filepath = '/path/to/audio/file.m4b'
const outputpath = '/path/to/output/cover.jpg'
const ffmpegCommandStub = new EventEmitter()
ffmpegCommandStub.addOption = sinon.stub().returnsThis()
ffmpegCommandStub.output = sinon.stub().returnsThis()
ffmpegCommandStub.run = sinon.stub().callsFake(() => {
ffmpegCommandStub.emit('end')
})
const ffmpegModuleStub = sinon.stub().returns(ffmpegCommandStub)
ffmpegModuleStub.ffprobe = sinon.stub()
const ensureDirStub = sinon.stub(fs, 'ensureDir').resolves()
return { filepath, outputpath, ffmpegCommandStub, ffmpegModuleStub, ensureDirStub }
}
let filepath = null
let outputpath = null
let ffmpegCommandStub = null
let ffmpegModuleStub = null
let ensureDirStub = null
beforeEach(() => {
const input = createTestSetup()
filepath = input.filepath
outputpath = input.outputpath
ffmpegCommandStub = input.ffmpegCommandStub
ffmpegModuleStub = input.ffmpegModuleStub
ensureDirStub = input.ensureDirStub
})
afterEach(() => {
sinon.restore()
})
it('should extract cover art from a file with a single video stream', async () => {
// Arrange
const metadata = {
streams: [
{ codec_type: 'audio', index: 0 },
{ codec_type: 'video', index: 1, width: 400, height: 400 }
]
}
ffmpegModuleStub.ffprobe.yields(null, metadata)
// Act
const result = await extractCoverArt(filepath, outputpath, ffmpegModuleStub)
// Assert
expect(ffmpegModuleStub.ffprobe.calledOnce).to.be.true
expect(ffmpegModuleStub.ffprobe.firstCall.args[0]).to.equal(filepath)
expect(ffmpegCommandStub.addOption.calledOnce).to.be.true
expect(ffmpegCommandStub.addOption.firstCall.args[0]).to.deep.equal(['-map 0:1', '-frames:v 1'])
expect(ffmpegCommandStub.output.calledOnce).to.be.true
expect(ffmpegCommandStub.output.firstCall.args[0]).to.equal(outputpath)
expect(ffmpegCommandStub.run.calledOnce).to.be.true
expect(result).to.equal(outputpath)
})
it('should select the largest video stream when multiple exist and ignore 1x1 placeholder', async () => {
// Arrange - simulate the Christmas Carol case with 1x1 and 400x400 streams
const metadata = {
streams: [
{ codec_type: 'audio', index: 0 },
{ codec_type: 'video', index: 1, width: 1, height: 1 },
{ codec_type: 'audio', index: 2 },
{ codec_type: 'video', index: 3, width: 400, height: 400 }
]
}
ffmpegModuleStub.ffprobe.yields(null, metadata)
// Act
const result = await extractCoverArt(filepath, outputpath, ffmpegModuleStub)
// Assert
expect(ffmpegModuleStub.ffprobe.calledOnce).to.be.true
expect(ffmpegCommandStub.addOption.calledOnce).to.be.true
// Should select index 3 (400x400) and ignore index 1 (1x1)
expect(ffmpegCommandStub.addOption.firstCall.args[0]).to.deep.equal(['-map 0:3', '-frames:v 1'])
expect(result).to.equal(outputpath)
})
it('should select the largest video stream among multiple sizes', async () => {
// Arrange - test with various resolutions
const metadata = {
streams: [
{ codec_type: 'video', index: 0, width: 100, height: 100 }, // 10,000 pixels
{ codec_type: 'video', index: 1, width: 200, height: 150 }, // 30,000 pixels
{ codec_type: 'video', index: 2, width: 300, height: 200 }, // 60,000 pixels (largest)
{ codec_type: 'video', index: 3, width: 150, height: 300 } // 45,000 pixels
]
}
ffmpegModuleStub.ffprobe.yields(null, metadata)
// Act
const result = await extractCoverArt(filepath, outputpath, ffmpegModuleStub)
// Assert
expect(ffmpegCommandStub.addOption.firstCall.args[0]).to.deep.equal(['-map 0:2', '-frames:v 1'])
expect(result).to.equal(outputpath)
})
it('should ignore video streams with missing width/height and select valid ones', async () => {
// Arrange
const metadata = {
streams: [
{ codec_type: 'video', index: 0 }, // no dimensions (will be filtered out)
{ codec_type: 'video', index: 1, width: 400, height: 400 }
]
}
ffmpegModuleStub.ffprobe.yields(null, metadata)
// Act
const result = await extractCoverArt(filepath, outputpath, ffmpegModuleStub)
// Assert
// Should ignore index 0 (no dimensions) and select index 1 (400x400)
expect(ffmpegCommandStub.addOption.firstCall.args[0]).to.deep.equal(['-map 0:1', '-frames:v 1'])
expect(result).to.equal(outputpath)
})
it('should return false when ffprobe fails', async () => {
// Arrange
ffmpegModuleStub.ffprobe.yields(new Error('ffprobe error'), null)
// Act
const result = await extractCoverArt(filepath, outputpath, ffmpegModuleStub)
// Assert
expect(ffmpegModuleStub.ffprobe.calledOnce).to.be.true
expect(ffmpegCommandStub.run.called).to.be.false
expect(result).to.be.false
})
it('should return false when no video streams found', async () => {
// Arrange
const metadata = {
streams: [
{ codec_type: 'audio', index: 0 },
{ codec_type: 'audio', index: 1 }
]
}
ffmpegModuleStub.ffprobe.yields(null, metadata)
// Act
const result = await extractCoverArt(filepath, outputpath, ffmpegModuleStub)
// Assert
expect(ffmpegModuleStub.ffprobe.calledOnce).to.be.true
expect(ffmpegCommandStub.run.called).to.be.false
expect(result).to.be.false
})
it('should return false when only tiny placeholder images exist (width or height <= 1)', async () => {
// Arrange
const metadata = {
streams: [
{ codec_type: 'audio', index: 0 },
{ codec_type: 'video', index: 1, width: 1, height: 1 },
{ codec_type: 'video', index: 2, width: 0, height: 100 },
{ codec_type: 'video', index: 3, width: 100, height: 1 }
]
}
ffmpegModuleStub.ffprobe.yields(null, metadata)
// Act
const result = await extractCoverArt(filepath, outputpath, ffmpegModuleStub)
// Assert
expect(ffmpegModuleStub.ffprobe.calledOnce).to.be.true
expect(ffmpegCommandStub.run.called).to.be.false
expect(result).to.be.false
})
it('should return false when ffmpeg extraction fails', async () => {
// Arrange
const metadata = {
streams: [
{ codec_type: 'video', index: 0, width: 400, height: 400 }
]
}
ffmpegModuleStub.ffprobe.yields(null, metadata)
ffmpegCommandStub.run = sinon.stub().callsFake(() => {
ffmpegCommandStub.emit('error', new Error('FFmpeg extraction error'))
})
// Act
const result = await extractCoverArt(filepath, outputpath, ffmpegModuleStub)
// Assert
expect(ffmpegModuleStub.ffprobe.calledOnce).to.be.true
expect(ffmpegCommandStub.run.calledOnce).to.be.true
expect(result).to.be.false
})
it('should ensure output directory exists', async () => {
// Arrange
const metadata = {
streams: [
{ codec_type: 'video', index: 0, width: 400, height: 400 }
]
}
ffmpegModuleStub.ffprobe.yields(null, metadata)
// Act
await extractCoverArt(filepath, outputpath, ffmpegModuleStub)
// Assert
expect(ensureDirStub.calledOnce).to.be.true
expect(ensureDirStub.firstCall.args[0]).to.equal('/path/to/output')
})
})