From 034b8956a2402cc9d9a78ba16390fb76547a8b4c Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 2 Apr 2023 16:13:18 -0500 Subject: [PATCH] Add:Batch embed metadata and queue system for metadata embedding #700 --- client/components/app/Appbar.vue | 60 ++++- client/components/modals/item/tabs/Tools.vue | 60 ++++- .../components/widgets/NotificationWidget.vue | 2 + client/layouts/default.vue | 16 +- client/pages/audiobook/_id/manage.vue | 58 ++--- client/store/tasks.js | 26 ++- server/controllers/MiscController.js | 14 +- server/controllers/ToolsController.js | 85 ++++++-- server/managers/AudioMetadataManager.js | 205 ++++++++++++------ server/routers/ApiRouter.js | 7 +- server/utils/toneHelpers.js | 78 +------ 11 files changed, 402 insertions(+), 209 deletions(-) diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index 5f22febe..382f5cf3 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -58,9 +58,6 @@ play_arrow {{ $strings.ButtonPlay }} - - - @@ -75,8 +72,11 @@ - - close + + + + + close @@ -160,9 +160,59 @@ export default { }, isHttps() { return location.protocol === 'https:' || process.env.NODE_ENV === 'development' + }, + contextMenuItems() { + if (!this.userIsAdminOrUp) return [] + + const options = [ + { + text: this.$strings.ButtonQuickMatch, + action: 'quick-match' + } + ] + + if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) { + options.push({ + text: 'Quick Embed Metadata', + action: 'quick-embed' + }) + } + + return options } }, methods: { + requestBatchQuickEmbed() { + const payload = { + message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files.

Would you like to continue?', + callback: (confirmed) => { + if (confirmed) { + this.$axios + .$post(`/api/tools/batch/embed-metadata`, { + libraryItemIds: this.selectedMediaItems.map((i) => i.id) + }) + .then(() => { + console.log('Audio metadata embed started') + this.cancelSelectionMode() + }) + .catch((error) => { + console.error('Audio metadata embed failed', error) + const errorMsg = error.response.data || 'Failed to embed metadata' + this.$toast.error(errorMsg) + }) + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + }, + contextMenuAction(action) { + if (action === 'quick-embed') { + this.requestBatchQuickEmbed() + } else if (action === 'quick-match') { + this.batchAutoMatchClick() + } + }, async playSelectedItems() { this.$store.commit('setProcessingBatch', true) diff --git a/client/components/modals/item/tabs/Tools.vue b/client/components/modals/item/tabs/Tools.vue index ed1953c8..4a76482a 100644 --- a/client/components/modals/item/tabs/Tools.vue +++ b/client/components/modals/item/tabs/Tools.vue @@ -46,8 +46,20 @@ >{{ $strings.ButtonOpenManager }} launch + + Quick Embed + + + +

Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)

+
+ + + +

Currently embedding metadata

+

{{ $strings.MessageNoAudioTracks }}

@@ -71,10 +83,10 @@ export default { return this.$store.state.showExperimentalFeatures }, libraryItemId() { - return this.libraryItem ? this.libraryItem.id : null + return this.libraryItem?.id || null }, media() { - return this.libraryItem ? this.libraryItem.media || {} : {} + return this.libraryItem?.media || {} }, mediaTracks() { return this.media.tracks || [] @@ -92,9 +104,49 @@ export default { showMp3Split() { if (!this.mediaTracks.length) return false return this.isSingleM4b && this.chapters.length + }, + queuedEmbedLIds() { + return this.$store.state.tasks.queuedEmbedLIds || [] + }, + isMetadataEmbedQueued() { + return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId) + }, + tasks() { + return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId) + }, + embedTask() { + return this.tasks.find((t) => t.action === 'embed-metadata') + }, + encodeTask() { + return this.tasks.find((t) => t.action === 'encode-m4b') + }, + isEmbedTaskRunning() { + return this.embedTask && !this.embedTask?.isFinished + }, + isEncodeTaskRunning() { + return this.encodeTask && !this.encodeTask?.isFinished } }, - methods: {}, - mounted() {} + methods: { + quickEmbed() { + const payload = { + message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files.

Would you like to continue?', + callback: (confirmed) => { + if (confirmed) { + this.$axios + .$post(`/api/tools/item/${this.libraryItemId}/embed-metadata`) + .then(() => { + console.log('Audio metadata encode started') + }) + .catch((error) => { + console.error('Audio metadata encode failed', error) + }) + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + } + } } \ No newline at end of file diff --git a/client/components/widgets/NotificationWidget.vue b/client/components/widgets/NotificationWidget.vue index f70f840d..a88ddbf6 100644 --- a/client/components/widgets/NotificationWidget.vue +++ b/client/components/widgets/NotificationWidget.vue @@ -73,6 +73,8 @@ export default { return `/library/${task.data.libraryId}/podcast/download-queue` case 'encode-m4b': return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b` + case 'embed-metadata': + return `/audiobook/${task.data.libraryItemId}/manage?tool=embed` default: return '' } diff --git a/client/layouts/default.vue b/client/layouts/default.vue index d5ab8937..10794605 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -278,6 +278,13 @@ export default { console.log('Task finished', task) this.$store.commit('tasks/addUpdateTask', task) }, + metadataEmbedQueueUpdate(data) { + if (data.queued) { + this.$store.commit('tasks/addQueuedEmbedLId', data.libraryItemId) + } else { + this.$store.commit('tasks/removeQueuedEmbedLId', data.libraryItemId) + } + }, userUpdated(user) { if (this.$store.state.user.user.id === user.id) { this.$store.commit('user/setUser', user) @@ -418,6 +425,7 @@ export default { // Task Listeners this.socket.on('task_started', this.taskStarted) this.socket.on('task_finished', this.taskFinished) + this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate) this.socket.on('backup_applied', this.backupApplied) @@ -531,12 +539,18 @@ export default { }, loadTasks() { this.$axios - .$get('/api/tasks') + .$get('/api/tasks?include=queue') .then((payload) => { console.log('Fetched tasks', payload) if (payload.tasks) { this.$store.commit('tasks/setTasks', payload.tasks) } + if (payload.queuedTaskData?.embedMetadata?.length) { + this.$store.commit( + 'tasks/setQueuedEmbedLIds', + payload.queuedTaskData.embedMetadata.map((td) => td.libraryItemId) + ) + } }) .catch((error) => { console.error('Failed to load tasks', error) diff --git a/client/pages/audiobook/_id/manage.vue b/client/pages/audiobook/_id/manage.vue index d2c24e90..cc5f9752 100644 --- a/client/pages/audiobook/_id/manage.vue +++ b/client/pages/audiobook/_id/manage.vue @@ -62,14 +62,20 @@
-
- + + +

Audiobook is queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)

+
+ +
+
- {{ $strings.ButtonStartMetadataEmbed }} + {{ $strings.ButtonStartMetadataEmbed }}

{{ $strings.MessageEmbedFinished }}

+
+
@@ -191,6 +198,7 @@ export default { cnosole.error('No audio files') return redirect('/?error=no audio files') } + return { libraryItem } @@ -200,7 +208,6 @@ export default { processing: false, audiofilesEncoding: {}, audiofilesFinished: {}, - isFinished: false, toneObject: null, selectedTool: 'embed', isCancelingEncode: false, @@ -272,11 +279,28 @@ export default { isTaskFinished() { return this.task && this.task.isFinished }, + tasks() { + return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId) + }, + embedTask() { + return this.tasks.find((t) => t.action === 'embed-metadata') + }, + encodeTask() { + return this.tasks.find((t) => t.action === 'encode-m4b') + }, task() { - return this.$store.getters['tasks/getTaskByLibraryItemId'](this.libraryItemId) + if (this.isEmbedTool) return this.embedTask + else if (this.isM4BTool) return this.encodeTask + return null }, taskRunning() { return this.task && !this.task.isFinished + }, + queuedEmbedLIds() { + return this.$store.state.tasks.queuedEmbedLIds || [] + }, + isMetadataEmbedQueued() { + return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId) } }, methods: { @@ -322,7 +346,7 @@ export default { .catch((error) => { var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error' this.$toast.error(errorMsg) - this.processing = true + this.processing = false }) }, embedClick() { @@ -349,24 +373,6 @@ export default { this.processing = false }) }, - audioMetadataStarted(data) { - console.log('audio metadata started', data) - if (data.libraryItemId !== this.libraryItemId) return - this.audiofilesFinished = {} - }, - audioMetadataFinished(data) { - console.log('audio metadata finished', data) - if (data.libraryItemId !== this.libraryItemId) return - this.processing = false - this.audiofilesEncoding = {} - - if (data.failed) { - this.$toast.error(data.error) - } else { - this.isFinished = true - this.$toast.success('Audio file metadata updated') - } - }, audiofileMetadataStarted(data) { if (data.libraryItemId !== this.libraryItemId) return this.$set(this.audiofilesEncoding, data.ino, true) @@ -412,14 +418,10 @@ export default { }, mounted() { this.init() - this.$root.socket.on('audio_metadata_started', this.audioMetadataStarted) - this.$root.socket.on('audio_metadata_finished', this.audioMetadataFinished) this.$root.socket.on('audiofile_metadata_started', this.audiofileMetadataStarted) this.$root.socket.on('audiofile_metadata_finished', this.audiofileMetadataFinished) }, beforeDestroy() { - this.$root.socket.off('audio_metadata_started', this.audioMetadataStarted) - this.$root.socket.off('audio_metadata_finished', this.audioMetadataFinished) this.$root.socket.off('audiofile_metadata_started', this.audiofileMetadataStarted) this.$root.socket.off('audiofile_metadata_finished', this.audiofileMetadataFinished) } diff --git a/client/store/tasks.js b/client/store/tasks.js index 55e3121e..e8422c77 100644 --- a/client/store/tasks.js +++ b/client/store/tasks.js @@ -1,11 +1,12 @@ export const state = () => ({ - tasks: [] + tasks: [], + queuedEmbedLIds: [] }) export const getters = { - getTaskByLibraryItemId: (state) => (libraryItemId) => { - return state.tasks.find(t => t.data && t.data.libraryItemId === libraryItemId) + getTasksByLibraryItemId: (state) => (libraryItemId) => { + return state.tasks.filter(t => t.data && t.data.libraryItemId === libraryItemId) } } @@ -18,14 +19,31 @@ export const mutations = { state.tasks = tasks }, addUpdateTask(state, task) { - var index = state.tasks.findIndex(d => d.id === task.id) + const index = state.tasks.findIndex(d => d.id === task.id) if (index >= 0) { state.tasks.splice(index, 1, task) } else { + // Remove duplicate (only have one library item per action) + state.tasks = state.tasks.filter(_task => { + if (!_task.data?.libraryItemId || _task.action !== task.action) return true + return _task.data.libraryItemId !== task.data.libraryItemId + }) + state.tasks.push(task) } }, removeTask(state, task) { state.tasks = state.tasks.filter(d => d.id !== task.id) + }, + setQueuedEmbedLIds(state, libraryItemIds) { + state.queuedEmbedLIds = libraryItemIds + }, + addQueuedEmbedLId(state, libraryItemId) { + if (!state.queuedEmbedLIds.some(lid => lid === libraryItemId)) { + state.queuedEmbedLIds.push(libraryItemId) + } + }, + removeQueuedEmbedLId(state, libraryItemId) { + state.queuedEmbedLIds = state.queuedEmbedLIds.filter(lid => lid !== libraryItemId) } } \ No newline at end of file diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index c7ef950f..ec0cc447 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -90,9 +90,19 @@ class MiscController { // GET: api/tasks getTasks(req, res) { - res.json({ + const includeArray = (req.query.include || '').split(',') + + const data = { tasks: this.taskManager.tasks.map(t => t.toJSON()) - }) + } + + if (includeArray.includes('queue')) { + data.queuedTaskData = { + embedMetadata: this.audioMetadataManager.getQueuedTaskData() + } + } + + res.json(data) } // PATCH: api/settings (admin) diff --git a/server/controllers/ToolsController.js b/server/controllers/ToolsController.js index 1243175e..3f21c5dd 100644 --- a/server/controllers/ToolsController.js +++ b/server/controllers/ToolsController.js @@ -3,14 +3,8 @@ const Logger = require('../Logger') class ToolsController { constructor() { } - // POST: api/tools/item/:id/encode-m4b async encodeM4b(req, res) { - if (!req.user.isAdminOrUp) { - Logger.error('[MiscController] encodeM4b: Non-admin user attempting to make m4b', req.user) - return res.sendStatus(403) - } - if (req.libraryItem.isMissing || req.libraryItem.isInvalid) { Logger.error(`[MiscController] encodeM4b: library item not found or invalid ${req.params.id}`) return res.status(404).send('Audiobook not found') @@ -34,11 +28,6 @@ class ToolsController { // DELETE: api/tools/item/:id/encode-m4b async cancelM4bEncode(req, res) { - if (!req.user.isAdminOrUp) { - Logger.error('[MiscController] cancelM4bEncode: Non-admin user attempting to cancel m4b encode', req.user) - return res.sendStatus(403) - } - const workerTask = this.abMergeManager.getPendingTaskByLibraryItemId(req.params.id) if (!workerTask) return res.sendStatus(404) @@ -49,14 +38,14 @@ class ToolsController { // POST: api/tools/item/:id/embed-metadata async embedAudioFileMetadata(req, res) { - if (!req.user.isAdminOrUp) { - Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user) - return res.sendStatus(403) + if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) { + Logger.error(`[ToolsController] Invalid library item`) + return res.sendStatus(500) } - if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) { - Logger.error(`[LibraryItemController] Invalid library item`) - return res.sendStatus(500) + if (this.audioMetadataManager.getIsLibraryItemQueuedOrProcessing(req.libraryItem.id)) { + Logger.error(`[ToolsController] Library item (${req.libraryItem.id}) is already in queue or processing`) + return res.status(500).send('Library item is already in queue or processing') } const options = { @@ -67,16 +56,66 @@ class ToolsController { res.sendStatus(200) } - itemMiddleware(req, res, next) { - var item = this.db.libraryItems.find(li => li.id === req.params.id) - if (!item || !item.media) return res.sendStatus(404) + // POST: api/tools/batch/embed-metadata + async batchEmbedMetadata(req, res) { + const libraryItemIds = req.body.libraryItemIds || [] + if (!libraryItemIds.length) { + return res.status(400).send('Invalid request payload') + } - // Check user can access this library item - if (!req.user.checkCanAccessLibraryItem(item)) { + const libraryItems = [] + for (const libraryItemId of libraryItemIds) { + const libraryItem = this.db.getLibraryItem(libraryItemId) + if (!libraryItem) { + Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`) + return res.sendStatus(404) + } + + // Check user can access this library item + if (!req.user.checkCanAccessLibraryItem(libraryItem)) { + Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not accessible to user`, req.user) + return res.sendStatus(403) + } + + if (libraryItem.isMissing || !libraryItem.hasAudioFiles || !libraryItem.isBook) { + Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`) + return res.sendStatus(500) + } + + if (this.audioMetadataManager.getIsLibraryItemQueuedOrProcessing(libraryItemId)) { + Logger.error(`[ToolsController] Batch embed library item (${libraryItemId}) is already in queue or processing`) + return res.status(500).send('Library item is already in queue or processing') + } + + libraryItems.push(libraryItem) + } + + const options = { + forceEmbedChapters: req.query.forceEmbedChapters === '1', + backup: req.query.backup === '1' + } + this.audioMetadataManager.handleBatchEmbed(req.user, libraryItems, options) + res.sendStatus(200) + } + + middleware(req, res, next) { + if (!req.user.isAdminOrUp) { + Logger.error(`[LibraryItemController] Non-root user attempted to access tools route`, req.user) return res.sendStatus(403) } - req.libraryItem = item + if (req.params.id) { + const item = this.db.libraryItems.find(li => li.id === req.params.id) + if (!item || !item.media) return res.sendStatus(404) + + // Check user can access this library item + if (!req.user.checkCanAccessLibraryItem(item)) { + return res.sendStatus(403) + } + + req.libraryItem = item + } + next() } } diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js index c32049cc..ac65c0f7 100644 --- a/server/managers/AudioMetadataManager.js +++ b/server/managers/AudioMetadataManager.js @@ -5,18 +5,42 @@ const Logger = require('../Logger') const fs = require('../libs/fsExtra') -const { secondsToTimestamp } = require('../utils/index') const toneHelpers = require('../utils/toneHelpers') -const filePerms = require('../utils/filePerms') + +const Task = require('../objects/Task') class AudioMetadataMangaer { constructor(db, taskManager) { this.db = db this.taskManager = taskManager + + this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items') + + this.MAX_CONCURRENT_TASKS = 1 + this.tasksRunning = [] + this.tasksQueued = [] + } + + /** + * Get queued task data + * @return {Array} + */ + getQueuedTaskData() { + return this.tasksQueued.map(t => t.data) + } + + getIsLibraryItemQueuedOrProcessing(libraryItemId) { + return this.tasksQueued.some(t => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some(t => t.data.libraryItemId === libraryItemId) } getToneMetadataObjectForApi(libraryItem) { - return toneHelpers.getToneMetadataObject(libraryItem) + return toneHelpers.getToneMetadataObject(libraryItem, libraryItem.media.chapters, libraryItem.media.tracks.length) + } + + handleBatchEmbed(user, libraryItems, options = {}) { + libraryItems.forEach((li) => { + this.updateMetadataForItem(user, li, options) + }) } async updateMetadataForItem(user, libraryItem, options = {}) { @@ -25,99 +49,144 @@ class AudioMetadataMangaer { const audioFiles = libraryItem.media.includedAudioFiles - const itemAudioMetadataPayload = { - userId: user.id, + const task = new Task() + + const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id) + + // Only writing chapters for single file audiobooks + const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters.map(c => ({ ...c })) : null + + // Create task + const taskData = { libraryItemId: libraryItem.id, - startedAt: Date.now(), - audioFiles: audioFiles.map(af => ({ index: af.index, ino: af.ino, filename: af.metadata.filename })) + libraryItemPath: libraryItem.path, + userId: user.id, + audioFiles: audioFiles.map(af => ( + { + index: af.index, + ino: af.ino, + filename: af.metadata.filename, + path: af.metadata.path, + cachePath: Path.join(itemCachePath, af.metadata.filename) + } + )), + coverPath: libraryItem.media.coverPath, + metadataObject: toneHelpers.getToneMetadataObject(libraryItem, chapters, audioFiles.length), + itemCachePath, + chapters, + options: { + forceEmbedChapters, + backupFiles + } } + const taskDescription = `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".` + task.setData('embed-metadata', 'Embedding Metadata', taskDescription, taskData) - SocketAuthority.emitter('audio_metadata_started', itemAudioMetadataPayload) + if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) { + Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`) + SocketAuthority.adminEmitter('metadata_embed_queue_update', { + libraryItemId: libraryItem.id, + queued: true + }) + this.tasksQueued.push(task) + } else { + this.runMetadataEmbed(task) + } + } - // Ensure folder for backup files - const itemCacheDir = Path.join(global.MetadataPath, `cache/items/${libraryItem.id}`) + async runMetadataEmbed(task) { + this.tasksRunning.push(task) + this.taskManager.addTask(task) + + Logger.info(`[AudioMetadataManager] Starting metadata embed task`, task.description) + + // Ensure item cache dir exists let cacheDirCreated = false - if (!await fs.pathExists(itemCacheDir)) { - await fs.mkdir(itemCacheDir) - await filePerms.setDefault(itemCacheDir, true) + if (!await fs.pathExists(task.data.itemCachePath)) { + await fs.mkdir(task.data.itemCachePath) cacheDirCreated = true } - // Write chapters file - const toneJsonPath = Path.join(itemCacheDir, 'metadata.json') - + // Create metadata json file + const toneJsonPath = Path.join(task.data.itemCachePath, 'metadata.json') try { - const chapters = (audioFiles.length == 1 || forceEmbedChapters) ? libraryItem.media.chapters : null - await toneHelpers.writeToneMetadataJsonFile(libraryItem, chapters, toneJsonPath, audioFiles.length) + await fs.writeFile(toneJsonPath, JSON.stringify({ meta: task.data.metadataObject }, null, 2)) } catch (error) { Logger.error(`[AudioMetadataManager] Write metadata.json failed`, error) - - itemAudioMetadataPayload.failed = true - itemAudioMetadataPayload.error = 'Failed to write metadata.json' - SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload) + task.setFailed('Failed to write metadata.json') + this.handleTaskFinished(task) return } - const results = [] - for (const af of audioFiles) { - const result = await this.updateAudioFileMetadataWithTone(libraryItem, af, toneJsonPath, itemCacheDir, backupFiles) - results.push(result) + // Tag audio files + for (const af of task.data.audioFiles) { + SocketAuthority.adminEmitter('audiofile_metadata_started', { + libraryItemId: task.data.libraryItemId, + ino: af.ino + }) + + // Backup audio file + if (task.data.options.backupFiles) { + try { + const backupFilePath = Path.join(task.data.itemCachePath, af.filename) + await fs.copy(af.path, backupFilePath) + Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`) + } catch (err) { + Logger.error(`[AudioMetadataManager] Failed to backup audio file "${af.path}"`, err) + } + } + + const _toneMetadataObject = { + 'ToneJsonFile': toneJsonPath, + 'TrackNumber': af.index, + } + + if (task.data.coverPath) { + _toneMetadataObject['CoverFile'] = task.data.coverPath + } + + const success = await toneHelpers.tagAudioFile(af.path, _toneMetadataObject) + if (success) { + Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${af.path}"`) + } + + SocketAuthority.adminEmitter('audiofile_metadata_finished', { + libraryItemId: task.data.libraryItemId, + ino: af.ino + }) } // Remove temp cache file/folder if not backing up - if (!backupFiles) { + if (!task.data.options.backupFiles) { // If cache dir was created from this then remove it if (cacheDirCreated) { - await fs.remove(itemCacheDir) + await fs.remove(task.data.itemCachePath) } else { await fs.remove(toneJsonPath) } } - const elapsed = Date.now() - itemAudioMetadataPayload.startedAt - Logger.debug(`[AudioMetadataManager] Elapsed ${secondsToTimestamp(elapsed / 1000, true)}`) - itemAudioMetadataPayload.results = results - itemAudioMetadataPayload.elapsed = elapsed - itemAudioMetadataPayload.finishedAt = Date.now() - SocketAuthority.emitter('audio_metadata_finished', itemAudioMetadataPayload) + task.setFinished() + this.handleTaskFinished(task) } - async updateAudioFileMetadataWithTone(libraryItem, audioFile, toneJsonPath, itemCacheDir, backupFiles) { - const resultPayload = { - libraryItemId: libraryItem.id, - index: audioFile.index, - ino: audioFile.ino, - filename: audioFile.metadata.filename - } - SocketAuthority.emitter('audiofile_metadata_started', resultPayload) + handleTaskFinished(task) { + this.taskManager.taskFinished(task) + this.tasksRunning = this.tasksRunning.filter(t => t.id !== task.id) - // Backup audio file - if (backupFiles) { - try { - const backupFilePath = Path.join(itemCacheDir, audioFile.metadata.filename) - await fs.copy(audioFile.metadata.path, backupFilePath) - Logger.debug(`[AudioMetadataManager] Backed up audio file at "${backupFilePath}"`) - } catch (err) { - Logger.error(`[AudioMetadataManager] Failed to backup audio file "${audioFile.metadata.path}"`, err) - } + if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) { + Logger.info(`[AudioMetadataManager] Task finished and dequeueing next task. ${this.tasksQueued} tasks queued.`) + const nextTask = this.tasksQueued.shift() + SocketAuthority.emitter('metadata_embed_queue_update', { + libraryItemId: nextTask.data.libraryItemId, + queued: false + }) + this.runMetadataEmbed(nextTask) + } else if (this.tasksRunning.length > 0) { + Logger.debug(`[AudioMetadataManager] Task finished but not dequeueing. Currently running ${this.tasksRunning.length} tasks. ${this.tasksQueued.length} tasks queued.`) + } else { + Logger.debug(`[AudioMetadataManager] Task finished and no tasks remain in queue`) } - - const _toneMetadataObject = { - 'ToneJsonFile': toneJsonPath, - 'TrackNumber': audioFile.index, - } - - if (libraryItem.media.coverPath) { - _toneMetadataObject['CoverFile'] = libraryItem.media.coverPath - } - - resultPayload.success = await toneHelpers.tagAudioFile(audioFile.metadata.path, _toneMetadataObject) - if (resultPayload.success) { - Logger.info(`[AudioMetadataManager] Successfully tagged audio file "${audioFile.metadata.path}"`) - } - - SocketAuthority.emitter('audiofile_metadata_finished', resultPayload) - return resultPayload } } module.exports = AudioMetadataMangaer diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 3f294130..3918bc2c 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -271,9 +271,10 @@ class ApiRouter { // // Tools Routes (Admin and up) // - this.router.post('/tools/item/:id/encode-m4b', ToolsController.itemMiddleware.bind(this), ToolsController.encodeM4b.bind(this)) - this.router.delete('/tools/item/:id/encode-m4b', ToolsController.itemMiddleware.bind(this), ToolsController.cancelM4bEncode.bind(this)) - this.router.post('/tools/item/:id/embed-metadata', ToolsController.itemMiddleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this)) + this.router.post('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.encodeM4b.bind(this)) + this.router.delete('/tools/item/:id/encode-m4b', ToolsController.middleware.bind(this), ToolsController.cancelM4bEncode.bind(this)) + this.router.post('/tools/item/:id/embed-metadata', ToolsController.middleware.bind(this), ToolsController.embedAudioFileMetadata.bind(this)) + this.router.post('/tools/batch/embed-metadata', ToolsController.middleware.bind(this), ToolsController.batchEmbedMetadata.bind(this)) // // RSS Feed Routes (Admin and up) diff --git a/server/utils/toneHelpers.js b/server/utils/toneHelpers.js index dac6fa40..d1a2b166 100644 --- a/server/utils/toneHelpers.js +++ b/server/utils/toneHelpers.js @@ -1,78 +1,8 @@ const tone = require('node-tone') const fs = require('../libs/fsExtra') const Logger = require('../Logger') -const { secondsToTimestamp } = require('./index') -module.exports.writeToneChaptersFile = (chapters, filePath) => { - var chaptersTxt = '' - for (const chapter of chapters) { - chaptersTxt += `${secondsToTimestamp(chapter.start, true, true)} ${chapter.title}\n` - } - return fs.writeFile(filePath, chaptersTxt) -} - -module.exports.getToneMetadataObject = (libraryItem, chaptersFile) => { - const coverPath = libraryItem.media.coverPath - const bookMetadata = libraryItem.media.metadata - - const metadataObject = { - 'Title': bookMetadata.title || '', - 'Album': bookMetadata.title || '', - 'TrackTotal': libraryItem.media.tracks.length - } - const additionalFields = [] - - if (bookMetadata.subtitle) { - metadataObject['Subtitle'] = bookMetadata.subtitle - } - if (bookMetadata.authorName) { - metadataObject['Artist'] = bookMetadata.authorName - metadataObject['AlbumArtist'] = bookMetadata.authorName - } - if (bookMetadata.description) { - metadataObject['Comment'] = bookMetadata.description - metadataObject['Description'] = bookMetadata.description - } - if (bookMetadata.narratorName) { - metadataObject['Narrator'] = bookMetadata.narratorName - metadataObject['Composer'] = bookMetadata.narratorName - } - if (bookMetadata.firstSeriesName) { - metadataObject['MovementName'] = bookMetadata.firstSeriesName - } - if (bookMetadata.firstSeriesSequence) { - metadataObject['Movement'] = bookMetadata.firstSeriesSequence - } - if (bookMetadata.genres.length) { - metadataObject['Genre'] = bookMetadata.genres.join('/') - } - if (bookMetadata.publisher) { - metadataObject['Publisher'] = bookMetadata.publisher - } - if (bookMetadata.asin) { - additionalFields.push(`ASIN=${bookMetadata.asin}`) - } - if (bookMetadata.isbn) { - additionalFields.push(`ISBN=${bookMetadata.isbn}`) - } - if (coverPath) { - metadataObject['CoverFile'] = coverPath - } - if (parsePublishedYear(bookMetadata.publishedYear)) { - metadataObject['PublishingDate'] = parsePublishedYear(bookMetadata.publishedYear) - } - if (chaptersFile) { - metadataObject['ChaptersFile'] = chaptersFile - } - - if (additionalFields.length) { - metadataObject['AdditionalFields'] = additionalFields - } - - return metadataObject -} - -module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal) => { +function getToneMetadataObject(libraryItem, chapters, trackTotal) { const bookMetadata = libraryItem.media.metadata const coverPath = libraryItem.media.coverPath @@ -133,6 +63,12 @@ module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, tra metadataObject['chapters'] = metadataChapters } + return metadataObject +} +module.exports.getToneMetadataObject = getToneMetadataObject + +module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal) => { + const metadataObject = getToneMetadataObject(libraryItem, chapters, trackTotal) return fs.writeFile(filePath, JSON.stringify({ meta: metadataObject }, null, 2)) }