2022-04-22 01:52:28 +02:00
|
|
|
|
|
|
|
const Path = require('path')
|
2022-07-06 02:53:01 +02:00
|
|
|
const fs = require('../libs/fsExtra')
|
2022-04-22 01:52:28 +02:00
|
|
|
|
|
|
|
const workerThreads = require('worker_threads')
|
|
|
|
const Logger = require('../Logger')
|
2022-10-02 21:16:17 +02:00
|
|
|
const Task = require('../objects/Task')
|
2022-04-22 01:52:28 +02:00
|
|
|
const filePerms = require('../utils/filePerms')
|
2022-09-27 01:07:31 +02:00
|
|
|
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
|
|
|
const toneHelpers = require('../utils/toneHelpers')
|
2022-04-22 01:52:28 +02:00
|
|
|
|
|
|
|
class AbMergeManager {
|
2022-11-24 22:53:58 +01:00
|
|
|
constructor(db, taskManager) {
|
2022-04-22 01:52:28 +02:00
|
|
|
this.db = db
|
2022-10-02 21:16:17 +02:00
|
|
|
this.taskManager = taskManager
|
2022-04-22 01:52:28 +02:00
|
|
|
|
2022-10-02 21:16:17 +02:00
|
|
|
this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items')
|
2022-04-22 01:52:28 +02:00
|
|
|
this.downloadDirPath = Path.join(global.MetadataPath, 'downloads')
|
2022-06-07 03:51:08 +02:00
|
|
|
this.downloadDirPathExist = false
|
2022-04-22 01:52:28 +02:00
|
|
|
|
2022-09-27 01:07:31 +02:00
|
|
|
this.pendingTasks = []
|
2022-04-22 01:52:28 +02:00
|
|
|
}
|
|
|
|
|
2022-10-02 21:31:04 +02:00
|
|
|
getPendingTaskByLibraryItemId(libraryItemId) {
|
|
|
|
return this.pendingTasks.find(t => t.task.data.libraryItemId === libraryItemId)
|
|
|
|
}
|
|
|
|
|
|
|
|
cancelEncode(task) {
|
|
|
|
return this.removeTask(task, true)
|
|
|
|
}
|
|
|
|
|
2022-06-07 03:51:08 +02:00
|
|
|
async ensureDownloadDirPath() { // Creates download path if necessary and sets owner and permissions
|
|
|
|
if (this.downloadDirPathExist) return
|
|
|
|
|
|
|
|
var pathCreated = false
|
|
|
|
if (!(await fs.pathExists(this.downloadDirPath))) {
|
|
|
|
await fs.mkdir(this.downloadDirPath)
|
|
|
|
pathCreated = true
|
|
|
|
}
|
|
|
|
|
|
|
|
if (pathCreated) {
|
|
|
|
await filePerms.setDefault(this.downloadDirPath)
|
|
|
|
}
|
|
|
|
|
|
|
|
this.downloadDirPathExist = true
|
|
|
|
}
|
|
|
|
|
2022-04-22 01:52:28 +02:00
|
|
|
async startAudiobookMerge(user, libraryItem) {
|
2022-10-02 21:16:17 +02:00
|
|
|
const task = new Task()
|
|
|
|
|
|
|
|
const audiobookDirname = Path.basename(libraryItem.path)
|
|
|
|
const targetFilename = audiobookDirname + '.m4b'
|
|
|
|
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
|
|
|
|
const tempFilepath = Path.join(itemCachePath, targetFilename)
|
|
|
|
const taskData = {
|
2022-04-22 01:52:28 +02:00
|
|
|
libraryItemId: libraryItem.id,
|
2022-09-27 01:07:31 +02:00
|
|
|
libraryItemPath: libraryItem.path,
|
2022-10-02 21:16:17 +02:00
|
|
|
userId: user.id,
|
|
|
|
originalTrackPaths: libraryItem.media.tracks.map(t => t.metadata.path),
|
|
|
|
tempFilepath,
|
|
|
|
targetFilename,
|
|
|
|
targetFilepath: Path.join(libraryItem.path, targetFilename),
|
|
|
|
itemCachePath,
|
2022-11-05 19:13:52 +01:00
|
|
|
toneJsonObject: null
|
2022-04-22 01:52:28 +02:00
|
|
|
}
|
2022-10-02 21:16:17 +02:00
|
|
|
const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`
|
|
|
|
task.setData('encode-m4b', 'Encoding M4b', taskDescription, taskData)
|
|
|
|
this.taskManager.addTask(task)
|
|
|
|
Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`)
|
2022-04-22 01:52:28 +02:00
|
|
|
|
2022-10-02 21:16:17 +02:00
|
|
|
if (!await fs.pathExists(taskData.itemCachePath)) {
|
|
|
|
await fs.mkdir(taskData.itemCachePath)
|
2022-04-22 01:52:28 +02:00
|
|
|
}
|
|
|
|
|
2022-10-02 21:16:17 +02:00
|
|
|
this.runAudiobookMerge(libraryItem, task)
|
2022-04-22 01:52:28 +02:00
|
|
|
}
|
|
|
|
|
2022-10-02 21:16:17 +02:00
|
|
|
async runAudiobookMerge(libraryItem, task) {
|
2022-04-22 01:52:28 +02:00
|
|
|
// If changing audio file type then encoding is needed
|
|
|
|
var audioTracks = libraryItem.media.tracks
|
2022-10-02 21:16:17 +02:00
|
|
|
var audioRequiresEncode = audioTracks[0].metadata.ext !== '.m4b'
|
2022-04-22 01:52:28 +02:00
|
|
|
var firstTrackIsM4b = audioTracks[0].metadata.ext.toLowerCase() === '.m4b'
|
|
|
|
var isOneTrack = audioTracks.length === 1
|
|
|
|
|
|
|
|
const ffmpegInputs = []
|
|
|
|
|
|
|
|
if (!isOneTrack) {
|
2022-10-02 21:16:17 +02:00
|
|
|
var concatFilePath = Path.join(task.data.itemCachePath, 'files.txt')
|
2022-04-22 01:52:28 +02:00
|
|
|
await writeConcatFile(audioTracks, concatFilePath)
|
|
|
|
ffmpegInputs.push({
|
|
|
|
input: concatFilePath,
|
|
|
|
options: ['-safe 0', '-f concat']
|
|
|
|
})
|
|
|
|
} else {
|
|
|
|
ffmpegInputs.push({
|
|
|
|
input: audioTracks[0].metadata.path,
|
|
|
|
options: firstTrackIsM4b ? ['-f mp4'] : []
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
|
|
|
|
var ffmpegOptions = [`-loglevel ${logLevel}`]
|
2022-10-02 21:16:17 +02:00
|
|
|
var ffmpegOutputOptions = ['-f mp4']
|
2022-04-22 01:52:28 +02:00
|
|
|
|
|
|
|
if (audioRequiresEncode) {
|
|
|
|
ffmpegOptions = ffmpegOptions.concat([
|
|
|
|
'-map 0:a',
|
|
|
|
'-acodec aac',
|
|
|
|
'-ac 2',
|
2022-09-27 01:07:31 +02:00
|
|
|
'-b:a 64k'
|
2022-04-22 01:52:28 +02:00
|
|
|
])
|
|
|
|
} else {
|
|
|
|
ffmpegOptions.push('-max_muxing_queue_size 1000')
|
|
|
|
|
2022-10-02 21:16:17 +02:00
|
|
|
if (isOneTrack && firstTrackIsM4b) {
|
2022-04-22 01:52:28 +02:00
|
|
|
ffmpegOptions.push('-c copy')
|
|
|
|
} else {
|
|
|
|
ffmpegOptions.push('-c:a copy')
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-11-03 02:40:50 +01:00
|
|
|
var toneJsonPath = null
|
|
|
|
try {
|
2022-11-05 19:13:52 +01:00
|
|
|
toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json')
|
|
|
|
await toneHelpers.writeToneMetadataJsonFile(libraryItem, libraryItem.media.chapters, toneJsonPath, 1)
|
2022-11-03 02:40:50 +01:00
|
|
|
} catch (error) {
|
2022-11-05 19:13:52 +01:00
|
|
|
Logger.error(`[AbMergeManager] Write metadata.json failed`, error)
|
2022-11-03 02:40:50 +01:00
|
|
|
toneJsonPath = null
|
2022-04-22 01:52:28 +02:00
|
|
|
}
|
|
|
|
|
2022-11-03 02:40:50 +01:00
|
|
|
task.data.toneJsonObject = {
|
|
|
|
'ToneJsonFile': toneJsonPath,
|
|
|
|
'TrackNumber': 1,
|
|
|
|
}
|
2022-09-27 01:07:31 +02:00
|
|
|
|
2022-04-22 01:52:28 +02:00
|
|
|
var workerData = {
|
|
|
|
inputs: ffmpegInputs,
|
|
|
|
options: ffmpegOptions,
|
|
|
|
outputOptions: ffmpegOutputOptions,
|
2022-10-02 21:16:17 +02:00
|
|
|
output: task.data.tempFilepath
|
2022-04-22 01:52:28 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
var worker = null
|
|
|
|
try {
|
|
|
|
var workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js')
|
|
|
|
worker = new workerThreads.Worker(workerPath, { workerData })
|
|
|
|
} catch (error) {
|
|
|
|
Logger.error(`[AbMergeManager] Start worker thread failed`, error)
|
2022-10-02 21:16:17 +02:00
|
|
|
task.setFailed('Failed to start worker thread')
|
|
|
|
this.removeTask(task, true)
|
2022-04-22 01:52:28 +02:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
worker.on('message', (message) => {
|
|
|
|
if (message != null && typeof message === 'object') {
|
|
|
|
if (message.type === 'RESULT') {
|
2022-10-02 21:16:17 +02:00
|
|
|
this.sendResult(task, message)
|
2022-04-22 01:52:28 +02:00
|
|
|
} else if (message.type === 'FFMPEG') {
|
|
|
|
if (Logger[message.level]) {
|
|
|
|
Logger[message.level](message.log)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
2022-09-27 01:07:31 +02:00
|
|
|
this.pendingTasks.push({
|
2022-10-02 21:16:17 +02:00
|
|
|
id: task.id,
|
|
|
|
task,
|
2022-04-22 01:52:28 +02:00
|
|
|
worker
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-10-02 21:16:17 +02:00
|
|
|
async sendResult(task, result) {
|
2022-09-27 01:07:31 +02:00
|
|
|
// Remove pending task
|
2022-10-02 21:16:17 +02:00
|
|
|
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
|
2022-04-22 01:52:28 +02:00
|
|
|
|
|
|
|
if (result.isKilled) {
|
2022-10-02 21:16:17 +02:00
|
|
|
task.setFailed('Ffmpeg task killed')
|
|
|
|
this.removeTask(task, true)
|
2022-04-22 01:52:28 +02:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!result.success) {
|
2022-10-02 21:16:17 +02:00
|
|
|
task.setFailed('Encoding failed')
|
|
|
|
this.removeTask(task, true)
|
2022-09-27 01:07:31 +02:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Write metadata to merged file
|
2022-11-03 02:40:50 +01:00
|
|
|
const success = await toneHelpers.tagAudioFile(task.data.tempFilepath, task.data.toneJsonObject)
|
2022-09-27 01:07:31 +02:00
|
|
|
if (!success) {
|
2022-10-02 21:16:17 +02:00
|
|
|
Logger.error(`[AbMergeManager] Failed to write metadata to file "${task.data.tempFilepath}"`)
|
|
|
|
task.setFailed('Failed to write metadata to m4b file')
|
|
|
|
this.removeTask(task, true)
|
2022-04-22 01:52:28 +02:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2022-09-27 01:07:31 +02:00
|
|
|
// Move library item tracks to cache
|
2022-10-02 21:16:17 +02:00
|
|
|
for (const trackPath of task.data.originalTrackPaths) {
|
2022-09-27 01:07:31 +02:00
|
|
|
const trackFilename = Path.basename(trackPath)
|
2022-10-02 21:16:17 +02:00
|
|
|
const moveToPath = Path.join(task.data.itemCachePath, trackFilename)
|
2022-09-27 01:07:31 +02:00
|
|
|
Logger.debug(`[AbMergeManager] Backing up original track "${trackPath}" to ${moveToPath}`)
|
|
|
|
await fs.move(trackPath, moveToPath, { overwrite: true }).catch((err) => {
|
|
|
|
Logger.error(`[AbMergeManager] Failed to move track "${trackPath}" to "${moveToPath}"`, err)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-10-02 21:16:17 +02:00
|
|
|
// Move m4b to target
|
|
|
|
Logger.debug(`[AbMergeManager] Moving m4b from ${task.data.tempFilepath} to ${task.data.targetFilepath}`)
|
|
|
|
await fs.move(task.data.tempFilepath, task.data.targetFilepath)
|
2022-04-22 01:52:28 +02:00
|
|
|
|
2022-10-02 21:16:17 +02:00
|
|
|
// Set file permissions and ownership
|
|
|
|
await filePerms.setDefault(task.data.targetFilepath)
|
|
|
|
await filePerms.setDefault(task.data.itemCachePath)
|
2022-04-22 01:52:28 +02:00
|
|
|
|
2022-10-02 21:16:17 +02:00
|
|
|
task.setFinished()
|
|
|
|
await this.removeTask(task, false)
|
|
|
|
Logger.info(`[AbMergeManager] Ab task finished ${task.id}`)
|
2022-04-22 01:52:28 +02:00
|
|
|
}
|
|
|
|
|
2022-10-02 21:16:17 +02:00
|
|
|
async removeTask(task, removeTempFilepath = false) {
|
|
|
|
Logger.info('[AbMergeManager] Removing task ' + task.id)
|
2022-04-22 01:52:28 +02:00
|
|
|
|
2022-10-02 21:16:17 +02:00
|
|
|
const pendingDl = this.pendingTasks.find(d => d.id === task.id)
|
2022-04-22 01:52:28 +02:00
|
|
|
if (pendingDl) {
|
2022-10-02 21:16:17 +02:00
|
|
|
this.pendingTasks = this.pendingTasks.filter(d => d.id !== task.id)
|
2022-04-22 01:52:28 +02:00
|
|
|
if (pendingDl.worker) {
|
2022-11-19 20:28:06 +01:00
|
|
|
Logger.warn(`[AbMergeManager] Removing download in progress - stopping worker`)
|
2022-04-22 01:52:28 +02:00
|
|
|
try {
|
|
|
|
pendingDl.worker.postMessage('STOP')
|
2022-10-02 21:31:04 +02:00
|
|
|
return
|
2022-04-22 01:52:28 +02:00
|
|
|
} catch (error) {
|
|
|
|
Logger.error('[AbMergeManager] Error posting stop message to worker', error)
|
|
|
|
}
|
2022-11-19 20:28:06 +01:00
|
|
|
} else {
|
|
|
|
Logger.debug(`[AbMergeManager] Removing download in progress - no worker`)
|
2022-04-22 01:52:28 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-10-02 21:16:17 +02:00
|
|
|
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)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.taskManager.taskFinished(task)
|
2022-04-22 01:52:28 +02:00
|
|
|
}
|
|
|
|
}
|
2022-06-05 02:50:26 +02:00
|
|
|
module.exports = AbMergeManager
|