diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 050e7e2f2..fbc0f9a5a 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -2,6 +2,8 @@ const SocketIO = require('socket.io') const Logger = require('./Logger') const Database = require('./Database') const Auth = require('./Auth') +const NotificationManager = require('./managers/NotificationManager') +const { flattenAny } = require('./utils/objectUtils') /** * @typedef SocketClient @@ -15,11 +17,25 @@ class SocketAuthority { constructor() { this.Server = null this.socketIoServers = [] + this.emittedNotifications = new Set(['item_added', 'item_updated', 'user_online', 'task_started', 'task_finished']) /** @type {Object.} */ this.clients = {} } + /** + * Fires a notification if enabled and the event is whitelisted + * @param {string} event - The event name fired. Needs to be whitelisted in this.emitted_notifications + * @param {any} payload - The payload to send with the event. For user-specific events, this includes the userId + */ + _fireNotification(event, payload) { + // Should be O(1) so no real performance hit + if (!this.emittedNotifications.has(event)) return + Logger.debug(`[SocketAuthority] fireNotification - ${event}`) + + NotificationManager.fireNotificationFromSocket(event, flattenAny(payload)) + } + /** * returns an array of User.toJSONForPublic with `connections` for the # of socket connections * a user can have many socket connections @@ -53,6 +69,7 @@ class SocketAuthority { * @param {Function} [filter] optional filter function to only send event to specific users */ emitter(evt, data, filter = null) { + void this._fireNotification(evt, data) for (const socketId in this.clients) { if (this.clients[socketId].user) { if (filter && !filter(this.clients[socketId].user)) continue @@ -64,6 +81,7 @@ class SocketAuthority { // Emits event to all clients for a specific user clientEmitter(userId, evt, data) { + void this._fireNotification(evt, data) const clients = this.getClientsForUser(userId) if (!clients.length) { return Logger.debug(`[SocketAuthority] clientEmitter - no clients found for user ${userId}`) @@ -77,6 +95,7 @@ class SocketAuthority { // Emits event to all admin user clients adminEmitter(evt, data) { + void this._fireNotification(evt, data) for (const socketId in this.clients) { if (this.clients[socketId].user?.isAdminOrUp) { this.clients[socketId].socket.emit(evt, data) @@ -92,6 +111,7 @@ class SocketAuthority { * @param {import('./models/LibraryItem')} libraryItem */ libraryItemEmitter(evt, libraryItem) { + void this._fireNotification(evt, libraryItem.toOldJSONMinified()) for (const socketId in this.clients) { if (this.clients[socketId].user?.checkCanAccessLibraryItem(libraryItem)) { this.clients[socketId].socket.emit(evt, libraryItem.toOldJSONExpanded()) diff --git a/server/managers/NotificationManager.js b/server/managers/NotificationManager.js index 567da38ec..7f3b78a37 100644 --- a/server/managers/NotificationManager.js +++ b/server/managers/NotificationManager.js @@ -43,7 +43,7 @@ class NotificationManager { episodeSubtitle: episode.subtitle || '', episodeDescription: episode.description || '' } - this.triggerNotification('onPodcastEpisodeDownloaded', eventData) + void this.triggerNotification('onPodcastEpisodeDownloaded', eventData) } /** @@ -68,7 +68,7 @@ class NotificationManager { backupCount: totalBackupCount || 'Invalid', removedOldest: removedOldest || 'false' } - this.triggerNotification('onBackupCompleted', eventData) + void this.triggerNotification('onBackupCompleted', eventData) } /** @@ -135,11 +135,11 @@ class NotificationManager { const eventData = { errorMsg: errorMsg || 'Backup failed' } - this.triggerNotification('onBackupFailed', eventData) + void this.triggerNotification('onBackupFailed', eventData) } onTest() { - this.triggerNotification('onTest') + void this.triggerNotification('onTest') } /** @@ -172,7 +172,8 @@ class NotificationManager { } await Database.updateSetting(Database.notificationSettings) - SocketAuthority.emitter('notifications_updated', Database.notificationSettings.toJSON()) + // Currently results in circular dependency TODO: Fix this + // SocketAuthority.emitter('notifications_updated', Database.notificationSettings.toJSON()) this.notificationFinished() } @@ -204,7 +205,7 @@ class NotificationManager { if (this.notificationQueue.length) { // Send next notification in queue const nextNotificationEvent = this.notificationQueue.shift() - this.triggerNotification(nextNotificationEvent.eventName, nextNotificationEvent.eventData) + void this.triggerNotification(nextNotificationEvent.eventName, nextNotificationEvent.eventData) } }, Database.notificationSettings.notificationDelay) } @@ -232,5 +233,22 @@ class NotificationManager { return false }) } + + fireNotificationFromSocket(eventName, eventData) { + if (!Database.notificationSettings || !Database.notificationSettings.isUseable) return + + const eventNameModified = eventName.replace(/_([a-z])/g, (_, c) => c.toUpperCase()) + const eventKey = `on${eventNameModified.charAt(0).toUpperCase()}${eventNameModified.slice(1)}` + + if (!Database.notificationSettings.getHasActiveNotificationsForEvent(eventKey)) { + // No logging to prevent console spam + //Logger.debug(`[NotificationManager] fireSocketNotification: No active notifications`) + return + } + + Logger.debug(`[NotificationManager] fireNotificationFromSocket: ${eventKey} event fired`) + + void this.triggerNotification(eventKey, eventData) + } } module.exports = new NotificationManager() diff --git a/server/objects/Notification.js b/server/objects/Notification.js index d075e1011..8d97697c0 100644 --- a/server/objects/Notification.js +++ b/server/objects/Notification.js @@ -102,7 +102,7 @@ class Notification { } replaceVariablesInTemplate(templateText, data) { - const ptrn = /{{ ?([a-zA-Z]+) ?}}/mg + const ptrn = /{{ ?([a-zA-Z.]+) ?}}/mg var match var updatedTemplate = templateText @@ -130,4 +130,4 @@ class Notification { } } } -module.exports = Notification \ No newline at end of file +module.exports = Notification diff --git a/server/utils/notifications.js b/server/utils/notifications.js index 700d3c389..0956de218 100644 --- a/server/utils/notifications.js +++ b/server/utils/notifications.js @@ -1,4 +1,78 @@ const { version } = require('../../package.json') +const LibraryItem = require('../models/LibraryItem') + +const libraryItemVariables = [ + 'id', + 'ino', + 'oldLibraryItemId', + 'libraryId', + 'folderId', + 'path', + 'relPath', + 'isFile', + 'mtimeMs', + 'ctimeMs', + 'birthtimeMs', + 'addedAt', + 'updatedAt', + 'isMissing', + 'isInvalid', + 'mediaType', + 'media.id', + 'media.metadata.title', + 'media.metadata.titleIgnorePrefix', + 'media.metadata.subtitle', + 'media.metadata.authorName', + 'media.metadata.authorNameLF', + 'media.metadata.narratorName', + 'media.metadata.seriesName', + 'media.metadata.genres', + 'media.metadata.publishedYear', + 'media.metadata.publishedDate', + 'media.metadata.publisher', + 'media.metadata.description', + 'media.metadata.isbn', + 'media.metadata.asin', + 'media.metadata.language', + 'media.metadata.explicit', + 'media.metadata.abridged', + 'media.coverPath', + 'media.tags', + 'media.numTracks', + 'media.numAudioFiles', + 'media.numChapters', + 'media.duration', + 'media.size', + 'media.ebookFormat', + 'numFiles', + 'size' +] + +const libraryItemTestData = { + id: '123e4567-e89b-12d3-a456-426614174000', + ino: '9876543', + path: '/audiobooks/Frank Herbert/Dune', + relPath: 'Frank Herbert/Dune', + mediaId: 'abcdef12-3456-7890-abcd-ef1234567890', + mediaType: 'book', + isFile: true, + isMissing: false, + isInvalid: false, + mtime: new Date('2023-11-15T10:20:30.400Z'), + ctime: new Date('2023-11-15T10:20:30.400Z'), + birthtime: new Date('2023-11-15T10:20:30.390Z'), + size: 987654321, + lastScan: new Date('2024-01-10T08:15:00.000Z'), + lastScanVersion: '3.2.0', + title: 'Dune', + titleIgnorePrefix: 'Dune', + authorNamesFirstLast: 'Frank Herbert', + authorNamesLastFirst: 'Herbert, Frank', + createdAt: new Date('2023-11-15T10:21:00.000Z'), + updatedAt: new Date('2024-05-15T18:30:36.940Z'), + libraryId: 'fedcba98-7654-3210-fedc-ba9876543210', + libraryFolderId: '11223344-5566-7788-99aa-bbccddeeff00' +} module.exports.notificationData = { events: [ @@ -60,6 +134,67 @@ module.exports.notificationData = { errorMsg: 'Example error message' } }, + // Sockets - Silently crying because not using typescript + + { + name: 'onItemAdded', + requiresLibrary: true, + description: 'Triggered when an item is added', + descriptionKey: 'NotificationOnItemAddedDescription', + variables: libraryItemVariables, + defaults: { + title: 'Item Added: {{media.metadata.title}}', + body: 'Item {{media.metadata.title}} has been added.\n\nPath: {{path}}\nSize: {{size}} bytes\nLibrary ID: {{libraryId}}' + }, + testData: libraryItemTestData + }, + { + name: 'onItemUpdated', + requiresLibrary: true, + description: 'Triggered when an item is updated', + descriptionKey: 'NotificationOnItemUpdatedDescription', + variables: libraryItemVariables, + defaults: { + title: 'Item Updated: {{media.metadata.title}}', + body: 'Item {{media.metadata.title}} has been added.\n\nPath: {{path}}\nSize: {{size}} bytes\nLibrary ID: {{libraryId}}' + }, + testData: libraryItemTestData + }, + { + name: 'onUserOnline', + requiresLibrary: false, + description: 'Triggered when a user comes online', + descriptionKey: 'NotificationOnUserOnlineDescription', + variables: ['id', 'username', 'type', 'session', 'lastSeen', 'createdAt'], + defaults: { + title: 'User Online: {{username}}', + body: 'User {{username}} (ID: {{id}}) is now online.' + } + }, + { + name: 'onTaskStarted', + requiresLibrary: false, + description: 'Triggered when a task starts', + descriptionKey: 'NotificationOnTaskStartedDescription', + variables: ['id', 'action', 'data.libraryId', 'data.libraryName', 'title', 'titleKey', 'titleSubs', 'description', 'descriptionKey', 'descriptionSubs', 'error', 'errorKey', 'errorSubs', 'showSuccess', 'isFailed', 'isFinished', 'startedAt', 'finishedAt'], + defaults: { + title: 'Task Started: {{title}}', + body: 'Task {{title}} has started.\n\nAction: {{action}}\nLibrary ID: {{data.libraryId}}\nLibrary Name: {{data.libraryName}}' + } + }, + { + name: 'onTaskFinished', + requiresLibrary: false, + description: 'Triggered when a task finishes', + descriptionKey: 'NotificationOnTaskFinishesDescription', + variables: ['id', 'action', 'data.libraryId', 'data.libraryName', 'title', 'titleKey', 'titleSubs', 'description', 'descriptionKey', 'descriptionSubs', 'error', 'errorKey', 'errorSubs', 'showSuccess', 'isFailed', 'isFinished', 'startedAt', 'finishedAt'], + defaults: { + title: 'Task Started: {{title}}', + body: 'Task {{title}} has started.\n\nAction: {{action}}\nLibrary ID: {{data.libraryId}}\nLibrary Name: {{data.libraryName}}' + } + }, + + // Test { name: 'onRSSFeedFailed', requiresLibrary: true, diff --git a/server/utils/objectUtils.js b/server/utils/objectUtils.js new file mode 100644 index 000000000..6ac3133e5 --- /dev/null +++ b/server/utils/objectUtils.js @@ -0,0 +1,26 @@ +function flattenAny(obj, prefix = '', result = {}) { + const entries = + obj instanceof Map + ? obj.entries() + : Object.entries(obj); + + for (const [key, value] of entries) { + const newKey = prefix ? `${prefix}.${key}` : `${key}`; + if ( + value instanceof Map || + (typeof value === 'object' && + value !== null && + !Array.isArray(value)) + ) { + flattenAny(value, newKey, result); + } else { + result[newKey] = value; + } + } + return result; +} + + +module.exports = { + flattenAny +}