mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
294 lines
11 KiB
JavaScript
294 lines
11 KiB
JavaScript
const Path = require('path')
|
|
const fs = require('../libs/fsExtra')
|
|
const Logger = require('../Logger')
|
|
const TaskManager = require('./TaskManager')
|
|
const Task = require('../objects/Task')
|
|
const ffmpegHelpers = require('../utils/ffmpegHelpers')
|
|
const Ffmpeg = require('../libs/fluentFfmpeg')
|
|
const SocketAuthority = require('../SocketAuthority')
|
|
const { isWritable, copyToExisting } = require('../utils/fileUtils')
|
|
const TrackProgressMonitor = require('../objects/TrackProgressMonitor')
|
|
|
|
/**
|
|
* @typedef AbMergeEncodeOptions
|
|
* @property {string} codec
|
|
* @property {string} channels
|
|
* @property {string} bitrate
|
|
*/
|
|
|
|
class AbMergeManager {
|
|
constructor() {
|
|
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
|
|
|
|
/** @type {Task[]} */
|
|
this.pendingTasks = []
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} libraryItemId
|
|
* @returns {Task|null}
|
|
*/
|
|
getPendingTaskByLibraryItemId(libraryItemId) {
|
|
return this.pendingTasks.find((t) => t.task.data.libraryItemId === libraryItemId)
|
|
}
|
|
|
|
/**
|
|
* Cancel and fail running task
|
|
*
|
|
* @param {Task} task
|
|
* @returns {Promise<void>}
|
|
*/
|
|
cancelEncode(task) {
|
|
const taskFailedString = {
|
|
text: 'Task canceled by user',
|
|
key: 'MessageTaskCanceledByUser'
|
|
}
|
|
task.setFailed(taskFailedString)
|
|
return this.removeTask(task, true)
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} userId
|
|
* @param {import('../objects/LibraryItem')} libraryItem
|
|
* @param {AbMergeEncodeOptions} [options={}]
|
|
*/
|
|
async startAudiobookMerge(userId, libraryItem, options = {}) {
|
|
const task = new Task()
|
|
|
|
const audiobookBaseName = libraryItem.isFile ? Path.basename(libraryItem.path, Path.extname(libraryItem.path)) : Path.basename(libraryItem.path)
|
|
const targetFilename = audiobookBaseName + '.m4b'
|
|
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
|
|
const tempFilepath = Path.join(itemCachePath, targetFilename)
|
|
const ffmetadataPath = Path.join(itemCachePath, 'ffmetadata.txt')
|
|
const libraryItemDir = libraryItem.isFile ? Path.dirname(libraryItem.path) : libraryItem.path
|
|
const taskData = {
|
|
libraryItemId: libraryItem.id,
|
|
libraryItemDir,
|
|
userId,
|
|
originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path),
|
|
inos: libraryItem.media.includedAudioFiles.map((f) => f.ino),
|
|
tempFilepath,
|
|
targetFilename,
|
|
targetFilepath: Path.join(libraryItemDir, targetFilename),
|
|
itemCachePath,
|
|
ffmetadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, 1),
|
|
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })),
|
|
coverPath: libraryItem.media.coverPath,
|
|
ffmetadataPath,
|
|
duration: libraryItem.media.duration,
|
|
encodeOptions: options
|
|
}
|
|
|
|
const taskTitleString = {
|
|
text: 'Encoding M4b',
|
|
key: 'MessageTaskEncodingM4b'
|
|
}
|
|
const taskDescriptionString = {
|
|
text: `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`,
|
|
key: 'MessageTaskEncodingM4bDescription',
|
|
subs: [libraryItem.media.metadata.title]
|
|
}
|
|
task.setData('encode-m4b', taskTitleString, taskDescriptionString, false, taskData)
|
|
TaskManager.addTask(task)
|
|
Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`)
|
|
|
|
if (!(await fs.pathExists(taskData.itemCachePath))) {
|
|
await fs.mkdir(taskData.itemCachePath)
|
|
}
|
|
|
|
this.runAudiobookMerge(libraryItem, task, options || {})
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {import('../objects/LibraryItem')} libraryItem
|
|
* @param {Task} task
|
|
* @param {AbMergeEncodeOptions} encodingOptions
|
|
*/
|
|
async runAudiobookMerge(libraryItem, task, encodingOptions) {
|
|
// Make sure the target directory is writable
|
|
if (!(await isWritable(task.data.libraryItemDir))) {
|
|
Logger.error(`[AbMergeManager] Target directory is not writable: ${task.data.libraryItemDir}`)
|
|
const taskFailedString = {
|
|
text: 'Target directory is not writable',
|
|
key: 'MessageTaskTargetDirectoryNotWritable'
|
|
}
|
|
task.setFailed(taskFailedString)
|
|
this.removeTask(task, true)
|
|
return
|
|
}
|
|
|
|
// Create ffmetadata file
|
|
if (!(await ffmpegHelpers.writeFFMetadataFile(task.data.ffmetadataObject, task.data.chapters, task.data.ffmetadataPath))) {
|
|
Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
|
|
const taskFailedString = {
|
|
text: 'Failed to write metadata file',
|
|
key: 'MessageTaskFailedToWriteMetadataFile'
|
|
}
|
|
task.setFailed(taskFailedString)
|
|
this.removeTask(task, true)
|
|
return
|
|
}
|
|
|
|
this.pendingTasks.push({
|
|
id: task.id,
|
|
task
|
|
})
|
|
|
|
const encodeFraction = 0.95
|
|
const embedFraction = 1 - encodeFraction
|
|
try {
|
|
const trackProgressMonitor = new TrackProgressMonitor(
|
|
libraryItem.media.tracks.map((t) => t.duration),
|
|
(trackIndex) => SocketAuthority.adminEmitter('track_started', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }),
|
|
(trackIndex, progressInTrack, taskProgress) => {
|
|
SocketAuthority.adminEmitter('track_progress', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex], progress: progressInTrack })
|
|
SocketAuthority.adminEmitter('task_progress', { libraryItemId: libraryItem.id, progress: taskProgress * encodeFraction })
|
|
},
|
|
(trackIndex) => SocketAuthority.adminEmitter('track_finished', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] })
|
|
)
|
|
task.data.ffmpeg = new Ffmpeg()
|
|
await ffmpegHelpers.mergeAudioFiles(libraryItem.media.tracks, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg)
|
|
delete task.data.ffmpeg
|
|
trackProgressMonitor.finish()
|
|
} catch (error) {
|
|
if (error.message === 'FFMPEG_CANCELED') {
|
|
Logger.info(`[AbMergeManager] Task cancelled ${task.id}`)
|
|
} else {
|
|
Logger.error(`[AbMergeManager] mergeAudioFiles failed`, error)
|
|
const taskFailedString = {
|
|
text: 'Failed to merge audio files',
|
|
key: 'MessageTaskFailedToMergeAudioFiles'
|
|
}
|
|
task.setFailed(taskFailedString)
|
|
this.removeTask(task, true)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Write metadata to merged file
|
|
try {
|
|
task.data.ffmpeg = new Ffmpeg()
|
|
await ffmpegHelpers.addCoverAndMetadataToFile(
|
|
task.data.tempFilepath,
|
|
task.data.coverPath,
|
|
task.data.ffmetadataPath,
|
|
1,
|
|
'audio/mp4',
|
|
(progress) => {
|
|
Logger.debug(`[AbMergeManager] Embedding metadata progress: ${100 * encodeFraction + progress * embedFraction}`)
|
|
SocketAuthority.adminEmitter('task_progress', { libraryItemId: libraryItem.id, progress: 100 * encodeFraction + progress * embedFraction })
|
|
},
|
|
task.data.ffmpeg
|
|
)
|
|
delete task.data.ffmpeg
|
|
} catch (error) {
|
|
if (error.message === 'FFMPEG_CANCELED') {
|
|
Logger.info(`[AbMergeManager] Task cancelled ${task.id}`)
|
|
} else {
|
|
Logger.error(`[AbMergeManager] Failed to embed metadata in file "${task.data.tempFilepath}"`)
|
|
const taskFailedString = {
|
|
text: `Failed to embed metadata in file ${Path.basename(task.data.tempFilepath)}`,
|
|
key: 'MessageTaskFailedToEmbedMetadataInFile',
|
|
subs: [Path.basename(task.data.tempFilepath)]
|
|
}
|
|
task.setFailed(taskFailedString)
|
|
this.removeTask(task, true)
|
|
}
|
|
return
|
|
}
|
|
|
|
// Move library item tracks to cache
|
|
for (const [index, trackPath] of task.data.originalTrackPaths.entries()) {
|
|
const trackFilename = Path.basename(trackPath)
|
|
const moveToPath = Path.join(task.data.itemCachePath, trackFilename)
|
|
Logger.debug(`[AbMergeManager] Backing up original track "${trackPath}" to ${moveToPath}`)
|
|
if (index === 0) {
|
|
// copy the first track to the cache directory
|
|
await fs.copy(trackPath, moveToPath).catch((err) => {
|
|
Logger.error(`[AbMergeManager] Failed to copy track "${trackPath}" to "${moveToPath}"`, err)
|
|
})
|
|
} else {
|
|
// move the rest of the tracks to the cache directory
|
|
await fs.move(trackPath, moveToPath, { overwrite: true }).catch((err) => {
|
|
Logger.error(`[AbMergeManager] Failed to move track "${trackPath}" to "${moveToPath}"`, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Move m4b to target, preserving the original track's permissions
|
|
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
|
|
try {
|
|
await copyToExisting(task.data.tempFilepath, task.data.originalTrackPaths[0])
|
|
await fs.rename(task.data.originalTrackPaths[0], task.data.targetFilepath)
|
|
await fs.remove(task.data.tempFilepath)
|
|
} catch (err) {
|
|
Logger.error(`[AbMergeManager] Failed to move m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`, err)
|
|
const taskFailedString = {
|
|
text: 'Failed to move m4b file',
|
|
key: 'MessageTaskFailedToMoveM4bFile'
|
|
}
|
|
task.setFailed(taskFailedString)
|
|
this.removeTask(task, true)
|
|
return
|
|
}
|
|
|
|
// Remove ffmetadata file
|
|
await fs.remove(task.data.ffmetadataPath)
|
|
|
|
task.setFinished()
|
|
await this.removeTask(task, false)
|
|
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
|
|
}
|
|
|
|
/**
|
|
* Remove ab merge task
|
|
*
|
|
* @param {Task} task
|
|
* @param {boolean} [removeTempFilepath=false]
|
|
*/
|
|
async removeTask(task, removeTempFilepath = false) {
|
|
Logger.info('[AbMergeManager] Removing task ' + task.id)
|
|
|
|
const pendingTask = this.pendingTasks.find((d) => d.id === task.id)
|
|
if (pendingTask) {
|
|
this.pendingTasks = this.pendingTasks.filter((d) => d.id !== task.id)
|
|
if (task.data.ffmpeg) {
|
|
Logger.warn(`[AbMergeManager] Killing ffmpeg process for task ${task.id}`)
|
|
task.data.ffmpeg.kill()
|
|
// wait for ffmpeg to exit, so that the output file is unlocked
|
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
}
|
|
}
|
|
|
|
if (removeTempFilepath) {
|
|
// On failed tasks remove the bad file if it exists
|
|
if (await fs.pathExists(task.data.tempFilepath)) {
|
|
await fs
|
|
.remove(task.data.tempFilepath)
|
|
.then(() => {
|
|
Logger.info('[AbMergeManager] Deleted target file', task.data.tempFilepath)
|
|
})
|
|
.catch((err) => {
|
|
Logger.error('[AbMergeManager] Failed to delete target file', err)
|
|
})
|
|
}
|
|
if (await fs.pathExists(task.data.ffmetadataPath)) {
|
|
await fs
|
|
.remove(task.data.ffmetadataPath)
|
|
.then(() => {
|
|
Logger.info('[AbMergeManager] Deleted ffmetadata file', task.data.ffmetadataPath)
|
|
})
|
|
.catch((err) => {
|
|
Logger.error('[AbMergeManager] Failed to delete ffmetadata file', err)
|
|
})
|
|
}
|
|
}
|
|
|
|
TaskManager.taskFinished(task)
|
|
}
|
|
}
|
|
module.exports = AbMergeManager
|