mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
Add unit tests for new ffmpegHelpers functions
This commit is contained in:
parent
1b015beba4
commit
8bdee51798
@ -5,6 +5,7 @@ const os = require('os')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const { filePathToPOSIX } = require('./fileUtils')
|
||||
const LibraryItem = require('../objects/LibraryItem')
|
||||
|
||||
function escapeSingleQuotes(path) {
|
||||
// return path.replace(/'/g, '\'\\\'\'')
|
||||
@ -220,6 +221,14 @@ function generateFFMetadata(metadata, chapters) {
|
||||
|
||||
module.exports.generateFFMetadata = generateFFMetadata
|
||||
|
||||
/**
|
||||
* Writes FFmpeg metadata file with the given metadata and chapters.
|
||||
*
|
||||
* @param {Object} metadata - The metadata object.
|
||||
* @param {Array} chapters - The array of chapter objects.
|
||||
* @param {string} ffmetadataPath - The path to the FFmpeg metadata file.
|
||||
* @returns {Promise<boolean>} - A promise that resolves to true if the file was written successfully, false otherwise.
|
||||
*/
|
||||
async function writeFFMetadataFile(metadata, chapters, ffmetadataPath) {
|
||||
try {
|
||||
await fs.writeFile(ffmetadataPath, generateFFMetadata(metadata, chapters))
|
||||
@ -235,23 +244,25 @@ module.exports.writeFFMetadataFile = writeFFMetadataFile
|
||||
|
||||
/**
|
||||
* Adds an ffmetadata and optionally a cover image to an audio file using fluent-ffmpeg.
|
||||
*
|
||||
* @param {string} audioFilePath - Path to the input audio file.
|
||||
* @param {string|null} coverFilePath - Path to the cover image file.
|
||||
* @param {string} metadataFilePath - Path to the ffmetadata file.
|
||||
* @param {number} track - The track number to embed in the audio file.
|
||||
* @param {string} mimeType - The MIME type of the audio file.
|
||||
* @param {Ffmpeg} ffmpeg - The Ffmpeg instance to use (optional). Used for dependency injection in tests.
|
||||
* @returns {Promise<boolean>} A promise that resolves to true if the operation is successful, false otherwise.
|
||||
*/
|
||||
async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType) {
|
||||
async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpeg = Ffmpeg()) {
|
||||
const isMp4 = mimeType === 'audio/mp4'
|
||||
const isMp3 = mimeType === 'audio/mpeg'
|
||||
|
||||
const audioFileDir = Path.dirname(audioFilePath)
|
||||
const audioFileExt = Path.extname(audioFilePath)
|
||||
const audioFileBaseName = Path.basename(audioFilePath, audioFileExt)
|
||||
const tempFilePath = Path.join(audioFileDir, `${audioFileBaseName}.tmp${audioFileExt}`)
|
||||
const tempFilePath = filePathToPOSIX(Path.join(audioFileDir, `${audioFileBaseName}.tmp${audioFileExt}`))
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let ffmpeg = Ffmpeg()
|
||||
ffmpeg.input(audioFilePath).input(metadataFilePath).outputOptions([
|
||||
'-map 0:a', // map audio stream from input file
|
||||
'-map_metadata 1', // map metadata tags from metadata file first
|
||||
@ -318,6 +329,13 @@ function escapeFFMetadataValue(value) {
|
||||
return value.replace(/([;=\n\\#])/g, '\\$1')
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the FFmpeg metadata object for a given library item.
|
||||
*
|
||||
* @param {LibraryItem} libraryItem - The library item containing the media metadata.
|
||||
* @param {number} audioFilesLength - The length of the audio files.
|
||||
* @returns {Object} - The FFmpeg metadata object.
|
||||
*/
|
||||
function getFFMetadataObject(libraryItem, audioFilesLength) {
|
||||
const metadata = libraryItem.media.metadata
|
||||
|
||||
|
249
test/server/utils/ffmpegHelpers.test.js
Normal file
249
test/server/utils/ffmpegHelpers.test.js
Normal file
@ -0,0 +1,249 @@
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const { generateFFMetadata, addCoverAndMetadataToFile } = require('../../../server/utils/ffmpegHelpers')
|
||||
const fs = require('../../../server/libs/fsExtra')
|
||||
const EventEmitter = require('events')
|
||||
|
||||
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 fsCopyFileSyncStub = sinon.stub(fs, 'copyFileSync')
|
||||
const fsUnlinkSyncStub = sinon.stub(fs, 'unlinkSync')
|
||||
|
||||
return { audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub, fsCopyFileSyncStub, fsUnlinkSyncStub }
|
||||
}
|
||||
|
||||
let audioFilePath = null
|
||||
let coverFilePath = null
|
||||
let metadataFilePath = null
|
||||
let track = null
|
||||
let mimeType = null
|
||||
let ffmpegStub = null
|
||||
let fsCopyFileSyncStub = null
|
||||
let fsUnlinkSyncStub = null
|
||||
beforeEach(() => {
|
||||
const input = createTestSetup()
|
||||
audioFilePath = input.audioFilePath
|
||||
coverFilePath = input.coverFilePath
|
||||
metadataFilePath = input.metadataFilePath
|
||||
track = input.track
|
||||
mimeType = input.mimeType
|
||||
ffmpegStub = input.ffmpegStub
|
||||
fsCopyFileSyncStub = input.fsCopyFileSyncStub
|
||||
fsUnlinkSyncStub = input.fsUnlinkSyncStub
|
||||
})
|
||||
|
||||
it('should add cover image and metadata to audio file', async () => {
|
||||
// Act
|
||||
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.true
|
||||
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(fsCopyFileSyncStub.calledOnce).to.be.true
|
||||
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
|
||||
|
||||
expect(fsUnlinkSyncStub.calledOnce).to.be.true
|
||||
expect(fsUnlinkSyncStub.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
|
||||
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.true
|
||||
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(fsCopyFileSyncStub.calledOnce).to.be.true
|
||||
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.mp3')
|
||||
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.mp3')
|
||||
|
||||
expect(fsUnlinkSyncStub.calledOnce).to.be.true
|
||||
expect(fsUnlinkSyncStub.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
|
||||
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.false
|
||||
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(fsCopyFileSyncStub.called).to.be.false
|
||||
|
||||
expect(fsUnlinkSyncStub.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
|
||||
const result = await addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpegStub)
|
||||
|
||||
// Assert
|
||||
expect(result).to.be.true
|
||||
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(fsCopyFileSyncStub.calledOnce).to.be.true
|
||||
expect(fsCopyFileSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
|
||||
expect(fsCopyFileSyncStub.firstCall.args[1]).to.equal('/path/to/audio/file.m4b')
|
||||
|
||||
expect(fsUnlinkSyncStub.calledOnce).to.be.true
|
||||
expect(fsUnlinkSyncStub.firstCall.args[0]).to.equal('/path/to/audio/file.tmp.m4b')
|
||||
|
||||
// Restore the stub
|
||||
sinon.restore()
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue
Block a user