diff --git a/client/components/cards/NotificationCard.vue b/client/components/cards/NotificationCard.vue index 57db9b47fd..05bf66de9b 100644 --- a/client/components/cards/NotificationCard.vue +++ b/client/components/cards/NotificationCard.vue @@ -1,17 +1,22 @@ @@ -28,14 +33,50 @@ export default { return { sendingTest: false, enabling: false, - deleting: false + deleting: false, + testing: false + } + }, + computed: { + eventName() { + return this.notification ? this.notification.eventName : null + }, + lastFiredAt() { + return this.notification ? this.notification.lastFiredAt : null + }, + lastAttemptFailed() { + return this.notification ? this.notification.lastAttemptFailed : null + }, + numConsecutiveFailedAttempts() { + return this.notification ? this.notification.numConsecutiveFailedAttempts : null } }, - computed: {}, methods: { + fireTestEventAndFail() { + this.fireTestEvent(true) + }, + fireTestEventAndSucceed() { + this.fireTestEvent(false) + }, + fireTestEvent(intentionallyFail = false) { + this.testing = true + this.$axios + .$get(`/api/notifications/test?fail=${intentionallyFail ? 1 : 0}`) + .then(() => { + this.$toast.success('Triggered onTest Event') + }) + .catch((error) => { + console.error('Failed', error) + const errorMsg = error.response ? error.response.data : null + this.$toast.error(`Failed: ${errorMsg}` || 'Failed to trigger onTest event') + }) + .finally(() => { + this.testing = false + }) + }, sendTestClick() { const payload = { - message: `Send a test notification to event ${this.notification.eventName}?`, + message: `Send a test notification to event ${this.eventName}?`, callback: (confirmed) => { if (confirmed) { this.sendTest() diff --git a/client/components/modals/notification/NotificationEditModal.vue b/client/components/modals/notification/NotificationEditModal.vue index dd36b60745..a9382a6887 100644 --- a/client/components/modals/notification/NotificationEditModal.vue +++ b/client/components/modals/notification/NotificationEditModal.vue @@ -6,7 +6,7 @@
-
+
@@ -16,7 +16,7 @@ -
+

Enabled

@@ -25,105 +25,6 @@ Submit
-
@@ -195,6 +96,11 @@ export default { if (this.$refs.modal) this.$refs.modal.setHide() }, submitForm() { + if (!this.newNotification.urls.length) { + this.$toast.error('Must enter an Apprise URL') + return + } + if (this.isNew) { this.submitCreate() } else { @@ -263,11 +169,12 @@ export default { libraryId: null, eventName: 'onTest', urls: [], - titleTemplate: 'Test Title', - bodyTemplate: 'Test Body', + titleTemplate: '', + bodyTemplate: '', enabled: true, type: null } + this.eventOptionUpdated() } } }, diff --git a/client/pages/config/notifications.vue b/client/pages/config/notifications.vue index aed7df90f6..7bda9d23dc 100644 --- a/client/pages/config/notifications.vue +++ b/client/pages/config/notifications.vue @@ -59,6 +59,15 @@ export default { this.selectedNotification = null this.showEditModal = true }, + validateAppriseApiUrl() { + try { + return new URL(this.appriseApiUrl) + } catch (error) { + console.log('URL error', error) + this.$toast.error(error.message) + return false + } + }, submitForm() { if (this.notificationSettings && this.notificationSettings.appriseApiUrl == this.appriseApiUrl) { this.$toast.info('No update necessary') @@ -69,7 +78,10 @@ export default { this.$refs.apiUrlInput.blur() } - // TODO: Validate apprise api url + const isValid = this.validateAppriseApiUrl() + if (!isValid) { + return + } const updatePayload = { appriseApiUrl: this.appriseApiUrl || null @@ -99,14 +111,25 @@ export default { if (!notificationResponse) { return } - this.notificationSettings = notificationResponse.settings this.notificationData = notificationResponse.data - this.appriseApiUrl = this.notificationSettings.appriseApiUrl - this.notifications = this.notificationSettings.notifications || [] + this.setNotificationSettings(notificationResponse.settings) + }, + setNotificationSettings(notificationSettings) { + this.notificationSettings = notificationSettings + this.appriseApiUrl = notificationSettings.appriseApiUrl + this.notifications = notificationSettings.notifications || [] + }, + notificationsUpdated(notificationSettings) { + console.log('Notifications updated', notificationSettings) + this.setNotificationSettings(notificationSettings) } }, mounted() { this.init() + this.$root.socket.on('notifications_updated', this.notificationsUpdated) + }, + beforeDestroy() { + this.$root.socket.off('notifications_updated', this.notificationsUpdated) } } \ No newline at end of file diff --git a/server/Server.js b/server/Server.js index f494fc23ae..0df379ee11 100644 --- a/server/Server.js +++ b/server/Server.js @@ -65,7 +65,7 @@ class Server { this.auth = new Auth(this.db) // Managers - this.notificationManager = new NotificationManager(this.db) + this.notificationManager = new NotificationManager(this.db, this.emitter.bind(this)) this.backupManager = new BackupManager(this.db, this.emitter.bind(this)) this.logManager = new LogManager(this.db) this.cacheManager = new CacheManager() diff --git a/server/controllers/NotificationController.js b/server/controllers/NotificationController.js index a51f353875..5714a81668 100644 --- a/server/controllers/NotificationController.js +++ b/server/controllers/NotificationController.js @@ -1,4 +1,5 @@ const Logger = require('../Logger') +const { version } = require('../../package.json') class NotificationController { constructor() { } @@ -22,6 +23,11 @@ class NotificationController { res.json(this.notificationManager.getData()) } + async fireTestEvent(req, res) { + await this.notificationManager.triggerNotification('onTest', { version: `v${version}` }, req.query.fail === '1') + res.sendStatus(200) + } + async createNotification(req, res) { const success = this.db.notificationSettings.createNotification(req.body) @@ -40,17 +46,18 @@ class NotificationController { async updateNotification(req, res) { const success = this.db.notificationSettings.updateNotification(req.body) - console.log('Update notification', success, req.body) if (success) { await this.db.updateEntity('settings', this.db.notificationSettings) } res.json(this.db.notificationSettings) } - sendNotificationTest(req, res) { - if (!this.db.notificationSettings.isUsable) return res.status(500).send('Apprise is not configured') - this.notificationManager.onTest() - res.sendStatus(200) + async sendNotificationTest(req, res) { + if (!this.db.notificationSettings.isUseable) return res.status(500).send('Apprise is not configured') + + const success = await this.notificationManager.sendTestNotification(req.notification) + if (success) res.sendStatus(200) + else res.sendStatus(500) } middleware(req, res, next) { diff --git a/server/managers/NotificationManager.js b/server/managers/NotificationManager.js index 63d273792c..f947f668be 100644 --- a/server/managers/NotificationManager.js +++ b/server/managers/NotificationManager.js @@ -3,8 +3,9 @@ const Logger = require("../Logger") const { notificationData } = require('../utils/notifications') class NotificationManager { - constructor(db) { + constructor(db, emitter) { this.db = db + this.emitter = emitter this.notificationFailedMap = {} } @@ -17,38 +18,59 @@ class NotificationManager { if (!this.db.notificationSettings.isUseable) return Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`) - this.triggerNotification('onPodcastEpisodeDownloaded', { libraryItem, episode }) + const library = this.db.libraries.find(lib => lib.id === libraryItem.libraryId) + const eventData = { + libraryItemId: libraryItem.id, + libraryId: libraryItem.libraryId, + libraryName: library ? library.name : 'Unknown', + podcastTitle: libraryItem.media.metadata.title, + episodeId: episode.id, + episodeTitle: episode.title + } + this.triggerNotification('onPodcastEpisodeDownloaded', eventData) } onTest() { this.triggerNotification('onTest') } - async triggerNotification(eventName, eventData) { - if (!this.db.notificationSettings.isUseable) return + async triggerNotification(eventName, eventData, intentionallyFail = false) { + if (!this.db.notificationSettings.isUseable) return false const notifications = this.db.notificationSettings.getNotificationsForEvent(eventName) for (const notification of notifications) { Logger.debug(`[NotificationManager] triggerNotification: Sending ${eventName} notification ${notification.id}`) - const success = await this.sendNotification(notification, eventData) + const success = intentionallyFail ? false : await this.sendNotification(notification, eventData) + notification.updateNotificationFired(success) if (!success) { // Failed notification - if (!this.notificationFailedMap[notification.id]) this.notificationFailedMap[notification.id] = 1 - else this.notificationFailedMap[notification.id]++ - - if (this.notificationFailedMap[notification.id] > 2) { + if (notification.numConsecutiveFailedAttempts > 2) { Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} reached max failed attempts`) - // TODO: Do something like disable the notification + notification.enabled = false + } else { + Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} ${notification.numConsecutiveFailedAttempts} failed attempts`) } - } else { // Successful notification - delete this.notificationFailedMap[notification.id] } } + + await this.db.updateEntity('settings', this.db.notificationSettings) + this.emitter('notifications_updated', this.db.notificationSettings) + return true + } + + sendTestNotification(notification) { + const eventData = notificationData.events.find(e => e.name === notification.eventName) + if (!eventData) { + Logger.error(`[NotificationManager] sendTestNotification: Event not found ${notification.eventName}`) + return false + } + + return this.sendNotification(notification, eventData.testData) } sendNotification(notification, eventData) { const payload = notification.getApprisePayload(eventData) - return axios.post(`${this.db.notificationSettings.appriseApiUrl}/notify`, payload, { timeout: 6000 }).then((response) => { + return axios.post(this.db.notificationSettings.appriseApiUrl, payload, { timeout: 6000 }).then((response) => { Logger.debug(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} response=`, response.data) return true }).catch((error) => { diff --git a/server/objects/Notification.js b/server/objects/Notification.js index 202e4c7ea8..4dffe040e6 100644 --- a/server/objects/Notification.js +++ b/server/objects/Notification.js @@ -11,6 +11,10 @@ class Notification { this.type = 'info' this.enabled = false + this.lastFiredAt = null + this.lastAttemptFailed = false + this.numConsecutiveFailedAttempts = 0 + this.numTimesFired = 0 this.createdAt = null if (notification) { @@ -27,6 +31,10 @@ class Notification { this.bodyTemplate = notification.bodyTemplate || '' this.type = notification.type || 'info' this.enabled = !!notification.enabled + this.lastFiredAt = notification.lastFiredAt || null + this.lastAttemptFailed = !!notification.lastAttemptFailed + this.numConsecutiveFailedAttempts = notification.numConsecutiveFailedAttempts || 0 + this.numTimesFired = notification.numTimesFired || 0 this.createdAt = notification.createdAt } @@ -40,6 +48,10 @@ class Notification { bodyTemplate: this.bodyTemplate, enabled: this.enabled, type: this.type, + lastFiredAt: this.lastFiredAt, + lastAttemptFailed: this.lastAttemptFailed, + numConsecutiveFailedAttempts: this.numConsecutiveFailedAttempts, + numTimesFired: this.numTimesFired, createdAt: this.createdAt } } @@ -57,6 +69,13 @@ class Notification { } update(payload) { + if (!this.enabled && payload.enabled) { + // Reset + this.lastFiredAt = null + this.lastAttemptFailed = false + this.numConsecutiveFailedAttempts = 0 + } + const keysToUpdate = ['libraryId', 'eventName', 'urls', 'titleTemplate', 'bodyTemplate', 'enabled', 'type'] var hasUpdated = false for (const key of keysToUpdate) { @@ -75,14 +94,32 @@ class Notification { return hasUpdated } + updateNotificationFired(success) { + this.lastFiredAt = Date.now() + this.lastAttemptFailed = !success + this.numConsecutiveFailedAttempts = success ? 0 : this.numConsecutiveFailedAttempts + 1 + this.numTimesFired++ + } + + replaceVariablesInTemplate(templateText, data) { + const ptrn = /{{ ?([a-zA-Z]+) ?}}/mg + + var match + var updatedTemplate = templateText + while ((match = ptrn.exec(templateText)) != null) { + if (data[match[1]]) { + updatedTemplate = updatedTemplate.replace(match[0], data[match[1]]) + } + } + return updatedTemplate + } + parseTitleTemplate(data) { - // TODO: Implement template parsing - return 'Test Title' + return this.replaceVariablesInTemplate(this.titleTemplate, data) } parseBodyTemplate(data) { - // TODO: Implement template parsing - return 'Test Body' + return this.replaceVariablesInTemplate(this.bodyTemplate, data) } getApprisePayload(data) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 812aa2b5ea..9f612cbefe 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -207,6 +207,7 @@ class ApiRouter { this.router.get('/notifications', NotificationController.middleware.bind(this), NotificationController.get.bind(this)) this.router.patch('/notifications', NotificationController.middleware.bind(this), NotificationController.update.bind(this)) this.router.get('/notificationdata', NotificationController.middleware.bind(this), NotificationController.getData.bind(this)) + this.router.get('/notifications/test', NotificationController.middleware.bind(this), NotificationController.fireTestEvent.bind(this)) this.router.post('/notifications', NotificationController.middleware.bind(this), NotificationController.createNotification.bind(this)) this.router.delete('/notifications/:id', NotificationController.middleware.bind(this), NotificationController.deleteNotification.bind(this)) this.router.patch('/notifications/:id', NotificationController.middleware.bind(this), NotificationController.updateNotification.bind(this)) diff --git a/server/utils/notifications.js b/server/utils/notifications.js index 4972f87542..efbbe4d8c9 100644 --- a/server/utils/notifications.js +++ b/server/utils/notifications.js @@ -1,12 +1,37 @@ +const { version } = require('../../package.json') + module.exports.notificationData = { events: [ + { + name: 'onPodcastEpisodeDownloaded', + requiresLibrary: true, + libraryMediaType: 'podcast', + description: 'Triggered when a podcast episode is auto-downloaded', + variables: ['libraryItemId', 'libraryId', 'podcastTitle', 'episodeTitle', 'libraryName', 'episodeId'], + defaults: { + title: 'New {{podcastTitle}} Episode!', + body: '{{episodeTitle}} has been added to {{libraryName}} library.' + }, + testData: { + libraryItemId: 'li_notification_test', + libraryId: 'lib_test', + libraryName: 'Podcasts', + podcastTitle: 'Abs Test Podcast', + episodeId: 'ep_notification_test', + episodeTitle: 'Successful Test' + } + }, { name: 'onTest', requiresLibrary: false, description: 'Notification for testing', + variables: ['version'], defaults: { - title: 'Test Title', - body: 'Test Body' + title: 'Test Notification on Abs {{version}}', + body: 'Test notificataion body for abs {{version}}.' + }, + testData: { + version: 'v' + version } } ]