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} */ 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