diff --git a/client/components/modals/item/tabs/Manage.vue b/client/components/modals/item/tabs/Manage.vue index 8b4a6d61..48a0a4ec 100644 --- a/client/components/modals/item/tabs/Manage.vue +++ b/client/components/modals/item/tabs/Manage.vue @@ -16,8 +16,8 @@ Start Merge
- Download - +

Size: {{ $bytesPretty(abmergeDownload.size) }}

@@ -34,18 +34,7 @@
-

Download Failed

-

Download Ready!

-

Download Expired

- - Not yet implemented -
-
- Download - -
-

Size: {{ $bytesPretty(abmergeDownload.size) }}

-
+ Not yet implemented
@@ -164,7 +153,7 @@ export default { this.tempDisable = true this.$axios - .$delete(`/api/download/${downloadId}`) + .$delete(`/api/ab-manager-tasks/${downloadId}`) .then(() => { this.tempDisable = false this.$toast.success('Merge download deleted') @@ -192,7 +181,7 @@ export default { }, downloadWithProgress(download) { var downloadId = download.id - var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}` + var downloadUrl = `${process.env.serverUrl}/api/ab-manager-tasks/${downloadId}` var filename = download.filename this.isDownloading = true @@ -242,18 +231,18 @@ export default { }, loadDownloads() { this.$axios - .$get(`/api/downloads`) + .$get(`/api/ab-manager-tasks`) .then((data) => { - var pendingDownloads = data.pendingDownloads.map((pd) => { + var pendingTasks = data.pendingTasks.map((pd) => { pd.download.status = this.$constants.DownloadStatus.PENDING return pd.download }) - var downloads = data.downloads.map((d) => { + var tasks = data.tasks.map((d) => { d.status = this.$constants.DownloadStatus.READY return d }) - var allDownloads = downloads.concat(pendingDownloads) - this.$store.commit('downloads/setDownloads', allDownloads) + var allTasks = tasks.concat(pendingTasks) + this.$store.commit('downloads/setDownloads', allTasks) }) .catch((error) => { console.error('Failed to load downloads', error) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index f0b71d51..300257c0 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -110,53 +110,53 @@ class MiscController { res.sendStatus(200) } - // GET: api/download/:id - async getDownload(req, res) { + // GET: api/ab-manager-tasks/:id + async getAbManagerTask(req, res) { if (!req.user.canDownload) { Logger.error('User attempting to download without permission', req.user) return res.sendStatus(403) } - var downloadId = req.params.id - Logger.info('Download Request', downloadId) - var download = this.abMergeManager.getDownload(downloadId) - if (!download) { - Logger.error('Download request not found', downloadId) + var taskId = req.params.id + Logger.info('Download Request', taskId) + var task = this.abMergeManager.getTask(taskId) + if (!task) { + Logger.error('Ab manager task request not found', taskId) return res.sendStatus(404) } var options = { headers: { - 'Content-Type': download.mimeType + 'Content-Type': task.mimeType } } - res.download(download.path, download.filename, options, (err) => { + res.download(task.path, task.filename, options, (err) => { if (err) { Logger.error('Download Error', err) } }) } - // DELETE: api/download/:id - async removeDownload(req, res) { + // DELETE: api/ab-manager-tasks/:id + async removeAbManagerTask(req, res) { if (!req.user.canDownload || !req.user.canDelete) { - Logger.error('User attempting to remove download without permission', req.user.username) + Logger.error('User attempting to remove ab manager task without permission', req.user.username) return res.sendStatus(403) } - this.abMergeManager.removeDownloadById(req.params.id) + this.abMergeManager.removeTaskById(req.params.id) res.sendStatus(200) } - // GET: api/downloads - async getDownloads(req, res) { + // GET: api/ab-manager-tasks + async getAbManagerTasks(req, res) { if (!req.user.canDownload) { - Logger.error('User attempting to get downloads without permission', req.user.username) + Logger.error('User attempting to get ab manager tasks without permission', req.user.username) return res.sendStatus(403) } - var downloads = { - downloads: this.abMergeManager.downloads, - pendingDownloads: this.abMergeManager.pendingDownloads + var taskData = { + tasks: this.abMergeManager.tasks, + pendingTasks: this.abMergeManager.pendingTasks } - res.json(downloads) + res.json(taskData) } // PATCH: api/settings (admin) diff --git a/server/managers/AbMergeManager.js b/server/managers/AbMergeManager.js index c5f6fe59..65d81060 100644 --- a/server/managers/AbMergeManager.js +++ b/server/managers/AbMergeManager.js @@ -4,10 +4,11 @@ const fs = require('../libs/fsExtra') const workerThreads = require('worker_threads') const Logger = require('../Logger') -const Download = require('../objects/Download') +const AbManagerTask = require('../objects/AbManagerTask') const filePerms = require('../utils/filePerms') const { getId } = require('../utils/index') -const { writeConcatFile, writeMetadataFile } = require('../utils/ffmpegHelpers') +const { writeConcatFile } = require('../utils/ffmpegHelpers') +const toneHelpers = require('../utils/toneHelpers') const { getFileSize } = require('../utils/fileUtils') class AbMergeManager { @@ -18,8 +19,8 @@ class AbMergeManager { this.downloadDirPath = Path.join(global.MetadataPath, 'downloads') this.downloadDirPathExist = false - this.pendingDownloads = [] - this.downloads = [] + this.pendingTasks = [] + this.tasks = [] } async ensureDownloadDirPath() { // Creates download path if necessary and sets owner and permissions @@ -38,14 +39,14 @@ class AbMergeManager { this.downloadDirPathExist = true } - getDownload(downloadId) { - return this.downloads.find(d => d.id === downloadId) + getTask(taskId) { + return this.tasks.find(d => d.id === taskId) } - removeDownloadById(downloadId) { - var download = this.getDownload(downloadId) - if (download) { - this.removeDownload(download) + removeTaskById(taskId) { + var task = this.getTask(taskId) + if (task) { + this.removeTask(task) } } @@ -68,46 +69,46 @@ class AbMergeManager { } async startAudiobookMerge(user, libraryItem) { - var downloadId = getId('abmerge') - var dlpath = Path.join(this.downloadDirPath, downloadId) - Logger.info(`Start audiobook merge for ${libraryItem.id} - DownloadId: ${downloadId} - ${dlpath}`) + var taskId = getId('abmerge') + var dlpath = Path.join(this.downloadDirPath, taskId) + Logger.info(`Start audiobook merge for ${libraryItem.id} - TaskId: ${taskId} - ${dlpath}`) var audiobookDirname = Path.basename(libraryItem.path) var filename = audiobookDirname + '.m4b' - var downloadData = { - id: downloadId, + var taskData = { + id: taskId, libraryItemId: libraryItem.id, type: 'abmerge', dirpath: dlpath, path: Path.join(dlpath, filename), filename, ext: '.m4b', - userId: user.id + userId: user.id, + libraryItemPath: libraryItem.path, + originalTrackPaths: libraryItem.media.tracks.map(t => t.metadata.path) } - var download = new Download() - download.setData(downloadData) - download.setTimeoutTimer(this.downloadTimedOut.bind(this)) - + var abManagerTask = new AbManagerTask() + abManagerTask.setData(taskData) + abManagerTask.setTimeoutTimer(this.downloadTimedOut.bind(this)) try { - await fs.mkdir(download.dirpath) + await fs.mkdir(abManagerTask.dirpath) } catch (error) { - Logger.error(`[AbMergeManager] Failed to make directory ${download.dirpath}`) + Logger.error(`[AbMergeManager] Failed to make directory ${abManagerTask.dirpath}`) Logger.debug(`[AbMergeManager] Make directory error: ${error}`) - var downloadJson = download.toJSON() - this.clientEmitter(user.id, 'abmerge_failed', downloadJson) + var taskJson = abManagerTask.toJSON() + this.clientEmitter(user.id, 'abmerge_failed', taskJson) return } - this.clientEmitter(user.id, 'abmerge_started', download.toJSON()) - this.runAudiobookMerge(libraryItem, download) + this.clientEmitter(user.id, 'abmerge_started', abManagerTask.toJSON()) + this.runAudiobookMerge(libraryItem, abManagerTask) } - async runAudiobookMerge(libraryItem, download) { - + async runAudiobookMerge(libraryItem, abManagerTask) { // If changing audio file type then encoding is needed var audioTracks = libraryItem.media.tracks - var audioRequiresEncode = audioTracks[0].metadata.ext !== download.ext + var audioRequiresEncode = audioTracks[0].metadata.ext !== abManagerTask.ext var shouldIncludeCover = libraryItem.media.coverPath var firstTrackIsM4b = audioTracks[0].metadata.ext.toLowerCase() === '.m4b' var isOneTrack = audioTracks.length === 1 @@ -115,7 +116,7 @@ class AbMergeManager { const ffmpegInputs = [] if (!isOneTrack) { - var concatFilePath = Path.join(download.dirpath, 'files.txt') + var concatFilePath = Path.join(abManagerTask.dirpath, 'files.txt') console.log('Write files.txt', concatFilePath) await writeConcatFile(audioTracks, concatFilePath) ffmpegInputs.push({ @@ -138,8 +139,8 @@ class AbMergeManager { '-map 0:a', '-acodec aac', '-ac 2', - '-b:a 64k', - '-movflags use_metadata_tags' + '-b:a 64k' + // '-movflags use_metadata_tags' ]) } else { ffmpegOptions.push('-max_muxing_queue_size 1000') @@ -150,34 +151,33 @@ class AbMergeManager { ffmpegOptions.push('-c:a copy') } } - if (download.ext === '.m4b') { + if (abManagerTask.ext === '.m4b') { ffmpegOutputOptions.push('-f mp4') } - // Create ffmetadata file - var metadataFilePath = Path.join(download.dirpath, 'metadata.txt') - await writeMetadataFile(libraryItem, metadataFilePath) - ffmpegInputs.push({ - input: metadataFilePath - }) - ffmpegOptions.push('-map_metadata 1') - // Embed cover art - if (shouldIncludeCover) { - var coverPath = libraryItem.media.coverPath.replace(/\\/g, '/') - ffmpegInputs.push({ - input: coverPath, - options: ['-f image2pipe'] - }) - ffmpegOptions.push('-c:v copy') - ffmpegOptions.push('-map 2:v') + var chaptersFilePath = null + if (libraryItem.media.chapters.length) { + chaptersFilePath = Path.join(abManagerTask.dirpath, 'chapters.txt') + try { + await toneHelpers.writeToneChaptersFile(libraryItem.media.chapters, chaptersFilePath) + } catch (error) { + Logger.error(`[AbMergeManager] Write chapters.txt failed`, error) + chaptersFilePath = null + } } + const toneMetadataObject = toneHelpers.getToneMetadataObject(libraryItem, chaptersFilePath) + toneMetadataObject.TrackNumber = 1 + abManagerTask.toneMetadataObject = toneMetadataObject + + Logger.debug(`[AbMergeManager] Book "${libraryItem.media.metadata.title}" tone metadata object=`, toneMetadataObject) + var workerData = { inputs: ffmpegInputs, options: ffmpegOptions, outputOptions: ffmpegOutputOptions, - output: download.path, + output: abManagerTask.path, } var worker = null @@ -186,19 +186,19 @@ class AbMergeManager { worker = new workerThreads.Worker(workerPath, { workerData }) } catch (error) { Logger.error(`[AbMergeManager] Start worker thread failed`, error) - if (download.userId) { - var downloadJson = download.toJSON() - this.clientEmitter(download.userId, 'abmerge_failed', downloadJson) + if (abManagerTask.userId) { + var taskJson = abManagerTask.toJSON() + this.clientEmitter(abManagerTask.userId, 'abmerge_failed', taskJson) } - this.removeDownload(download) + this.removeTask(abManagerTask) return } worker.on('message', (message) => { if (message != null && typeof message === 'object') { if (message.type === 'RESULT') { - if (!download.isTimedOut) { - this.sendResult(download, message) + if (!abManagerTask.isTimedOut) { + this.sendResult(abManagerTask, message) } } else if (message.type === 'FFMPEG') { if (Logger[message.level]) { @@ -209,78 +209,114 @@ class AbMergeManager { Logger.error('Invalid worker message', message) } }) - this.pendingDownloads.push({ - id: download.id, - download, + this.pendingTasks.push({ + id: abManagerTask.id, + abManagerTask, worker }) } - async sendResult(download, result) { - download.clearTimeoutTimer() + async sendResult(abManagerTask, result) { + abManagerTask.clearTimeoutTimer() - // Remove pending download - this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id) + // Remove pending task + this.pendingTasks = this.pendingTasks.filter(d => d.id !== abManagerTask.id) if (result.isKilled) { - if (download.userId) { - this.clientEmitter(download.userId, 'abmerge_killed', download.toJSON()) + if (abManagerTask.userId) { + this.clientEmitter(abManagerTask.userId, 'abmerge_killed', abManagerTask.toJSON()) } return } if (!result.success) { - if (download.userId) { - this.clientEmitter(download.userId, 'abmerge_failed', download.toJSON()) + if (abManagerTask.userId) { + this.clientEmitter(abManagerTask.userId, 'abmerge_failed', abManagerTask.toJSON()) } - this.removeDownload(download) + this.removeTask(abManagerTask) return } + // Write metadata to merged file + const success = await toneHelpers.tagAudioFile(abManagerTask.path, abManagerTask.toneMetadataObject) + if (!success) { + Logger.error(`[AbMergeManager] Failed to write metadata to file "${abManagerTask.path}"`) + if (abManagerTask.userId) { + this.clientEmitter(abManagerTask.userId, 'abmerge_failed', abManagerTask.toJSON()) + } + this.removeTask(abManagerTask) + return + } + + // Move library item tracks to cache + const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${abManagerTask.libraryItemId}`) + await fs.ensureDir(itemCacheDir) + for (const trackPath of abManagerTask.originalTrackPaths) { + const trackFilename = Path.basename(trackPath) + const moveToPath = Path.join(itemCacheDir, trackFilename) + 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) + }) + } + // Set file permissions and ownership - await filePerms.setDefault(download.path) + await filePerms.setDefault(abManagerTask.path) + await filePerms.setDefault(itemCacheDir) - var filesize = await getFileSize(download.path) - download.setComplete(filesize) - if (download.userId) { - this.clientEmitter(download.userId, 'abmerge_ready', download.toJSON()) + // Move merged file to library item + const moveToPath = Path.join(abManagerTask.libraryItemPath, abManagerTask.filename) + Logger.debug(`[AbMergeManager] Moving merged audiobook to library item at "${moveToPath}"`) + const moveSuccess = await fs.move(abManagerTask.path, moveToPath, { overwrite: true }).then(() => true).catch((err) => { + Logger.error(`[AbMergeManager] Failed to move merged audiobook from "${abManagerTask.path}" to "${moveToPath}"`, err) + return false + }) + if (!moveSuccess) { + // TODO: Revert cached og files? } - download.setExpirationTimer(this.downloadExpired.bind(this)) - this.downloads.push(download) - Logger.info(`[AbMergeManager] Download Ready ${download.id}`) + var filesize = await getFileSize(abManagerTask.path) + abManagerTask.setComplete(filesize) + if (abManagerTask.userId) { + this.clientEmitter(abManagerTask.userId, 'abmerge_ready', abManagerTask.toJSON()) + } + // abManagerTask.setExpirationTimer(this.downloadExpired.bind(this)) + + // this.tasks.push(abManagerTask) + await this.removeTask(abManagerTask) + Logger.info(`[AbMergeManager] Ab task finished ${abManagerTask.id}`) } - async downloadExpired(download) { - Logger.info(`[AbMergeManager] Download ${download.id} expired`) + // async downloadExpired(abManagerTask) { + // Logger.info(`[AbMergeManager] Download ${abManagerTask.id} expired`) - if (download.userId) { - this.clientEmitter(download.userId, 'abmerge_expired', download.toJSON()) + // if (abManagerTask.userId) { + // this.clientEmitter(abManagerTask.userId, 'abmerge_expired', abManagerTask.toJSON()) + // } + // this.removeTask(abManagerTask) + // } + + async downloadTimedOut(abManagerTask) { + Logger.info(`[AbMergeManager] Download ${abManagerTask.id} timed out (${abManagerTask.timeoutTimeMs}ms)`) + + if (abManagerTask.userId) { + var taskJson = abManagerTask.toJSON() + taskJson.isTimedOut = true + this.clientEmitter(abManagerTask.userId, 'abmerge_failed', taskJson) } - this.removeDownload(download) + this.removeTask(abManagerTask) } - async downloadTimedOut(download) { - Logger.info(`[AbMergeManager] Download ${download.id} timed out (${download.timeoutTimeMs}ms)`) + async removeTask(abManagerTask) { + Logger.info('[AbMergeManager] Removing task ' + abManagerTask.id) - if (download.userId) { - var downloadJson = download.toJSON() - downloadJson.isTimedOut = true - this.clientEmitter(download.userId, 'abmerge_failed', downloadJson) - } - this.removeDownload(download) - } + abManagerTask.clearTimeoutTimer() + // abManagerTask.clearExpirationTimer() - async removeDownload(download) { - Logger.info('[AbMergeManager] Removing download ' + download.id) - - download.clearTimeoutTimer() - download.clearExpirationTimer() - - var pendingDl = this.pendingDownloads.find(d => d.id === download.id) + var pendingDl = this.pendingTasks.find(d => d.id === abManagerTask.id) if (pendingDl) { - this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id) + this.pendingTasks = this.pendingTasks.filter(d => d.id !== abManagerTask.id) Logger.warn(`[AbMergeManager] Removing download in progress - stopping worker`) if (pendingDl.worker) { try { @@ -291,12 +327,12 @@ class AbMergeManager { } } - await fs.remove(download.dirpath).then(() => { - Logger.info('[AbMergeManager] Deleted download', download.dirpath) + await fs.remove(abManagerTask.dirpath).then(() => { + Logger.info('[AbMergeManager] Deleted download', abManagerTask.dirpath) }).catch((err) => { Logger.error('[AbMergeManager] Failed to delete download', err) }) - this.downloads = this.downloads.filter(d => d.id !== download.id) + this.tasks = this.tasks.filter(d => d.id !== abManagerTask.id) } } module.exports = AbMergeManager diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js index 258c39a2..9af3927e 100644 --- a/server/managers/AudioMetadataManager.js +++ b/server/managers/AudioMetadataManager.js @@ -43,10 +43,7 @@ class AudioMetadataMangaer { // Write chapters file var chaptersFilePath = null - var cachePath = Path.join(global.MetadataPath, 'cache/items') - console.log('Items Cache Path', cachePath) - - var itemCacheDir = Path.join(cachePath, libraryItem.id) + const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${libraryItem.id}`) await fs.ensureDir(itemCacheDir) if (libraryItem.media.chapters.length) { diff --git a/server/objects/Download.js b/server/objects/AbManagerTask.js similarity index 87% rename from server/objects/Download.js rename to server/objects/AbManagerTask.js index 5baa3367..9d857c09 100644 --- a/server/objects/Download.js +++ b/server/objects/AbManagerTask.js @@ -3,10 +3,11 @@ const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils') const DEFAULT_EXPIRATION = 1000 * 60 * 60 // 60 minutes const DEFAULT_TIMEOUT = 1000 * 60 * 30 // 30 minutes -class Download { - constructor(download) { +class AbManagerTask { + constructor() { this.id = null this.libraryItemId = null + this.libraryItemPath = null this.type = null this.dirpath = null @@ -14,6 +15,8 @@ class Download { this.ext = null this.filename = null this.size = 0 + this.toneMetadataObject = null + this.originalTrackPaths = [] this.userId = null this.isReady = false @@ -28,10 +31,6 @@ class Download { this.timeoutTimer = null this.expirationTimer = null - - if (download) { - this.construct(download) - } } get mimeType() { @@ -52,13 +51,21 @@ class Download { isReady: this.isReady, startedAt: this.startedAt, finishedAt: this.finishedAt, - expirationSeconds: this.expirationSeconds + expirationSeconds: this.expirationSeconds, + toneMetadataObject: this.toneMetadataObject } } + setData(downloadData) { + downloadData.startedAt = Date.now() + downloadData.isProcessing = true + this.construct(downloadData) + } + construct(download) { this.id = download.id this.libraryItemId = download.libraryItemId + this.libraryItemPath = download.libraryItemPath this.type = download.type this.dirpath = download.dirpath @@ -66,6 +73,7 @@ class Download { this.ext = download.ext this.filename = download.filename this.size = download.size || 0 + this.originalTrackPaths = download.originalTrackPaths this.userId = download.userId this.isReady = !!download.isReady @@ -79,12 +87,6 @@ class Download { this.expiresAt = download.expiresAt || null } - setData(downloadData) { - downloadData.startedAt = Date.now() - downloadData.isProcessing = true - this.construct(downloadData) - } - setComplete(fileSize) { this.finishedAt = Date.now() this.size = fileSize @@ -117,4 +119,4 @@ class Download { clearTimeout(this.expirationTimer) } } -module.exports = Download \ No newline at end of file +module.exports = AbManagerTask \ No newline at end of file diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 38d534a1..0e082b86 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -221,9 +221,9 @@ class ApiRouter { // this.router.post('/upload', MiscController.handleUpload.bind(this)) this.router.get('/audiobook-merge/:id', MiscController.mergeAudiobook.bind(this)) - this.router.get('/download/:id', MiscController.getDownload.bind(this)) - this.router.delete('/download/:id', MiscController.removeDownload.bind(this)) - this.router.get('/downloads', MiscController.getDownloads.bind(this)) + this.router.get('/ab-manager-tasks/:id', MiscController.getAbManagerTask.bind(this)) + this.router.delete('/ab-manager-tasks/:id', MiscController.removeAbManagerTask.bind(this)) + this.router.get('/ab-manager-tasks', MiscController.getAbManagerTasks.bind(this)) this.router.patch('/settings', MiscController.updateServerSettings.bind(this)) // Root only this.router.post('/purgecache', MiscController.purgeCache.bind(this)) // Root only this.router.post('/authorize', MiscController.authorize.bind(this))