diff --git a/client/components/cards/NotificationCard.vue b/client/components/cards/NotificationCard.vue index b336e5f78..d91934df8 100644 --- a/client/components/cards/NotificationCard.vue +++ b/client/components/cards/NotificationCard.vue @@ -14,7 +14,7 @@
-

{{ notification.urls.join(', ') }}

+

{{ notification.ntfyTopic ? $getString('LabelNotificationNtfyTopicPrefix', [notification.ntfyTopic]) : notification.urls.join(', ') }}

Last attempt failed {{ $dateDistanceFromNow(lastFiredAt) }} ({{ numConsecutiveFailedAttempts }} attempt{{ numConsecutiveFailedAttempts === 1 ? '' : 's' }})

Last fired {{ $dateDistanceFromNow(lastFiredAt) }}

diff --git a/client/components/modals/notification/NotificationEditModal.vue b/client/components/modals/notification/NotificationEditModal.vue index d69566344..14742ecc8 100644 --- a/client/components/modals/notification/NotificationEditModal.vue +++ b/client/components/modals/notification/NotificationEditModal.vue @@ -10,7 +10,11 @@
- + + + + + @@ -51,7 +55,8 @@ export default { return { processing: false, newNotification: {}, - isNew: true + isNew: true, + selectedProvider: 'apprise' } }, watch: { @@ -76,6 +81,12 @@ export default { if (!this.notificationData) return [] return this.notificationData.events || [] }, + providerOptions() { + return [ + { value: 'apprise', text: 'Apprise' }, + { value: 'ntfy', text: 'ntfy.sh' } + ] + }, eventOptions() { return this.notificationEvents.map((e) => { return { @@ -109,11 +120,19 @@ export default { if (this.$refs.modal) this.$refs.modal.setHide() }, submitForm() { - this.$refs.urlsInput?.forceBlur() - - if (!this.newNotification.urls.length) { - this.$toast.error(this.$strings.ToastAppriseUrlRequired) - return + if (this.selectedProvider === 'apprise') { + this.$refs.urlsInput?.forceBlur() + if (!this.newNotification.urls.length) { + this.$toast.error(this.$strings.ToastAppriseUrlRequired) + return + } + this.newNotification.ntfyTopic = null + } else { + if (!this.newNotification.ntfyTopic) { + this.$toast.error(this.$strings.ToastNtfyTopicRequired) + return + } + this.newNotification.urls = [] } if (this.isNew) { @@ -168,17 +187,20 @@ export default { init() { this.isNew = !this.notification if (this.notification) { + this.selectedProvider = this.notification.ntfyTopic ? 'ntfy' : 'apprise' this.newNotification = { id: this.notification.id, libraryId: this.notification.libraryId, eventName: this.notification.eventName, - urls: [...this.notification.urls], + urls: [...(this.notification.urls || [])], titleTemplate: this.notification.titleTemplate, bodyTemplate: this.notification.bodyTemplate, enabled: this.notification.enabled, - type: this.notification.type + type: this.notification.type, + ntfyTopic: this.notification.ntfyTopic || null } } else { + this.selectedProvider = 'apprise' this.newNotification = { libraryId: null, eventName: 'onTest', @@ -186,7 +208,8 @@ export default { titleTemplate: '', bodyTemplate: '', enabled: true, - type: null + type: null, + ntfyTopic: null } this.eventOptionUpdated() } diff --git a/client/pages/config/notifications.vue b/client/pages/config/notifications.vue index 46174773d..c84a423e6 100644 --- a/client/pages/config/notifications.vue +++ b/client/pages/config/notifications.vue @@ -24,11 +24,22 @@ {{ $strings.ButtonSave }}
+ -
+ +
+ + + +
+ {{ $strings.ButtonSave }} +
+ +
+ +
-

{{ $strings.HeaderNotifications }}

{{ $strings.ButtonCreate }} add
@@ -56,6 +67,8 @@ export default { loading: false, savingSettings: false, appriseApiUrl: null, + ntfyUrl: null, + ntfyToken: null, maxNotificationQueue: 0, maxFailedAttempts: 0, notifications: [], @@ -116,17 +129,10 @@ export default { return true }, - submitForm() { - if (!this.validateForm()) return - - const updatePayload = { - appriseApiUrl: this.appriseApiUrl || null, - maxNotificationQueue: Number(this.maxNotificationQueue), - maxFailedAttempts: Number(this.maxFailedAttempts) - } + saveSettings(payload) { this.savingSettings = true this.$axios - .$patch('/api/notifications', updatePayload) + .$patch('/api/notifications', payload) .then(() => { this.$toast.success(this.$strings.ToastNotificationSettingsUpdateSuccess) }) @@ -138,6 +144,21 @@ export default { this.savingSettings = false }) }, + submitForm() { + if (!this.validateForm()) return + + this.saveSettings({ + appriseApiUrl: this.appriseApiUrl || null, + maxNotificationQueue: Number(this.maxNotificationQueue), + maxFailedAttempts: Number(this.maxFailedAttempts) + }) + }, + submitNtfyForm() { + this.saveSettings({ + ntfyUrl: this.ntfyUrl || null, + ntfyToken: this.ntfyToken || null + }) + }, async init() { this.loading = true const notificationResponse = await this.$axios.$get('/api/notifications').catch((error) => { @@ -155,6 +176,8 @@ export default { setNotificationSettings(notificationSettings) { this.notificationSettings = notificationSettings this.appriseApiUrl = notificationSettings.appriseApiUrl + this.ntfyUrl = notificationSettings.ntfyUrl || null + this.ntfyToken = notificationSettings.ntfyToken || null this.maxNotificationQueue = notificationSettings.maxNotificationQueue this.maxFailedAttempts = notificationSettings.maxFailedAttempts this.notifications = notificationSettings.notifications || [] diff --git a/client/strings/en-us.json b/client/strings/en-us.json index fb2bcb281..bb285ad38 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -123,6 +123,7 @@ "HeaderAdvanced": "Advanced", "HeaderApiKeys": "API Keys", "HeaderAppriseNotificationSettings": "Apprise Notification Settings", + "HeaderNtfyNotificationSettings": "ntfy.sh Notification Settings", "HeaderAudioTracks": "Audio Tracks", "HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAuthentication": "Authentication", @@ -489,6 +490,9 @@ "LabelNotificationAvailableVariables": "Available variables", "LabelNotificationBodyTemplate": "Body Template", "LabelNotificationEvent": "Notification Event", + "LabelNotificationNtfyTopicPrefix": "ntfy: {0}", + "LabelNotificationNtfyTopic": "ntfy Topic", + "LabelNotificationProvider": "Provider", "LabelNotificationTitleTemplate": "Title Template", "LabelNotificationsMaxFailedAttempts": "Max failed attempts", "LabelNotificationsMaxFailedAttemptsHelp": "Notifications are disabled once they fail to send this many times", @@ -736,6 +740,7 @@ "LabelYourProgress": "Your Progress", "MessageAddToPlayerQueue": "Add to player queue", "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", + "MessageNtfyDescription": "Send notifications using ntfy.sh or a self-hosted ntfy server. The server URL defaults to https://ntfy.sh. Set a topic for each notification when creating it.", "MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.", "MessageAuthenticationLegacyTokenWarning": "Legacy API tokens will be removed in the future. Use API Keys instead.", "MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.", @@ -983,6 +988,7 @@ "StatsYearInReview": "YEAR IN REVIEW", "ToastAccountUpdateSuccess": "Account updated", "ToastAppriseUrlRequired": "Must enter an Apprise URL", + "ToastNtfyTopicRequired": "Must enter an ntfy topic", "ToastAsinRequired": "ASIN is required", "ToastAuthorImageRemoveSuccess": "Author image removed", "ToastAuthorNotFound": "Author \"{0}\" not found", diff --git a/server/controllers/NotificationController.js b/server/controllers/NotificationController.js index b3fd72400..d89686bbe 100644 --- a/server/controllers/NotificationController.js +++ b/server/controllers/NotificationController.js @@ -120,7 +120,7 @@ class NotificationController { * @param {Response} res */ async sendNotificationTest(req, res) { - if (!Database.notificationSettings.isUseable) return res.status(400).send('Apprise is not configured') + if (!Database.notificationSettings.isUseable) return res.status(400).send('No notification service is configured') const success = await NotificationManager.sendTestNotification(req.notification) if (success) res.sendStatus(200) diff --git a/server/managers/NotificationManager.js b/server/managers/NotificationManager.js index 567da38ec..5d605fbac 100644 --- a/server/managers/NotificationManager.js +++ b/server/managers/NotificationManager.js @@ -220,15 +220,45 @@ class NotificationManager { } sendNotification(notification, eventData) { + if (notification.ntfyTopic) { + return this.sendNtfyNotification(notification, eventData) + } + return this.sendAppriseNotification(notification, eventData) + } + + sendAppriseNotification(notification, eventData) { + if (!Database.notificationSettings.appriseApiUrl) { + Logger.error(`[NotificationManager] sendAppriseNotification: Apprise API URL is not configured`) + return Promise.resolve(false) + } const payload = notification.getApprisePayload(eventData) return axios .post(Database.notificationSettings.appriseApiUrl, payload, { timeout: 6000 }) .then((response) => { - Logger.debug(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} response=`, response.data) + Logger.debug(`[NotificationManager] sendAppriseNotification: ${notification.eventName}/${notification.id} response=`, response.data) return true }) .catch((error) => { - Logger.error(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} error=`, error) + Logger.error(`[NotificationManager] sendAppriseNotification: ${notification.eventName}/${notification.id} error=`, error) + return false + }) + } + + sendNtfyNotification(notification, eventData) { + const payload = notification.getNtfyPayload(eventData) + const ntfyUrl = Database.notificationSettings.ntfyUrl || 'https://ntfy.sh' + const headers = { 'Content-Type': 'application/json' } + if (Database.notificationSettings.ntfyToken) { + headers['Authorization'] = `Bearer ${Database.notificationSettings.ntfyToken}` + } + return axios + .post(ntfyUrl, payload, { timeout: 6000, headers }) + .then((response) => { + Logger.debug(`[NotificationManager] sendNtfyNotification: ${notification.eventName}/${notification.id} response=`, response.data) + return true + }) + .catch((error) => { + Logger.error(`[NotificationManager] sendNtfyNotification: ${notification.eventName}/${notification.id} error=`, error) return false }) } diff --git a/server/objects/Notification.js b/server/objects/Notification.js index d075e1011..19f5762ce 100644 --- a/server/objects/Notification.js +++ b/server/objects/Notification.js @@ -9,6 +9,7 @@ class Notification { this.titleTemplate = '' this.bodyTemplate = '' this.type = 'info' + this.ntfyTopic = null this.enabled = false this.lastFiredAt = null @@ -30,6 +31,7 @@ class Notification { this.titleTemplate = notification.titleTemplate || '' this.bodyTemplate = notification.bodyTemplate || '' this.type = notification.type || 'info' + this.ntfyTopic = notification.ntfyTopic || null this.enabled = !!notification.enabled this.lastFiredAt = notification.lastFiredAt || null this.lastAttemptFailed = !!notification.lastAttemptFailed @@ -48,6 +50,7 @@ class Notification { bodyTemplate: this.bodyTemplate, enabled: this.enabled, type: this.type, + ntfyTopic: this.ntfyTopic, lastFiredAt: this.lastFiredAt, lastAttemptFailed: this.lastAttemptFailed, numConsecutiveFailedAttempts: this.numConsecutiveFailedAttempts, @@ -60,11 +63,12 @@ class Notification { this.id = uuidv4() this.libraryId = payload.libraryId || null this.eventName = payload.eventName - this.urls = payload.urls + this.urls = payload.urls || [] this.titleTemplate = payload.titleTemplate this.bodyTemplate = payload.bodyTemplate this.enabled = !!payload.enabled this.type = payload.type || null + this.ntfyTopic = payload.ntfyTopic || null this.createdAt = Date.now() } @@ -76,7 +80,7 @@ class Notification { this.numConsecutiveFailedAttempts = 0 } - const keysToUpdate = ['libraryId', 'eventName', 'urls', 'titleTemplate', 'bodyTemplate', 'enabled', 'type'] + const keysToUpdate = ['libraryId', 'eventName', 'urls', 'titleTemplate', 'bodyTemplate', 'enabled', 'type', 'ntfyTopic'] var hasUpdated = false for (const key of keysToUpdate) { if (payload[key] !== undefined) { @@ -129,5 +133,13 @@ class Notification { body: this.parseBodyTemplate(data) } } + + getNtfyPayload(data) { + return { + topic: this.ntfyTopic, + title: this.parseTitleTemplate(data), + message: this.parseBodyTemplate(data) + } + } } module.exports = Notification \ No newline at end of file diff --git a/server/objects/settings/NotificationSettings.js b/server/objects/settings/NotificationSettings.js index aab8f3e4a..4491ffa03 100644 --- a/server/objects/settings/NotificationSettings.js +++ b/server/objects/settings/NotificationSettings.js @@ -7,6 +7,8 @@ class NotificationSettings { this.id = 'notification-settings' this.appriseType = 'api' this.appriseApiUrl = null + this.ntfyUrl = null + this.ntfyToken = null this.notifications = [] this.maxFailedAttempts = 5 this.maxNotificationQueue = 20 // once reached events will be ignored @@ -20,6 +22,8 @@ class NotificationSettings { construct(settings) { this.appriseType = settings.appriseType this.appriseApiUrl = settings.appriseApiUrl || null + this.ntfyUrl = settings.ntfyUrl || null + this.ntfyToken = settings.ntfyToken || null this.notifications = (settings.notifications || []).map((n) => new Notification(n)) this.maxFailedAttempts = settings.maxFailedAttempts || 5 this.maxNotificationQueue = settings.maxNotificationQueue || 20 @@ -31,6 +35,8 @@ class NotificationSettings { id: this.id, appriseType: this.appriseType, appriseApiUrl: this.appriseApiUrl, + ntfyUrl: this.ntfyUrl, + ntfyToken: this.ntfyToken, notifications: this.notifications.map((n) => n.toJSON()), maxFailedAttempts: this.maxFailedAttempts, maxNotificationQueue: this.maxNotificationQueue, @@ -39,7 +45,7 @@ class NotificationSettings { } get isUseable() { - return !!this.appriseApiUrl + return !!this.appriseApiUrl || !!this.ntfyUrl } /** @@ -74,29 +80,43 @@ class NotificationSettings { if (!payload) return false var hasUpdates = false - if (payload.appriseApiUrl !== this.appriseApiUrl) { + if (payload.appriseApiUrl !== undefined && payload.appriseApiUrl !== this.appriseApiUrl) { this.appriseApiUrl = payload.appriseApiUrl || null hasUpdates = true } - const _maxFailedAttempts = isNullOrNaN(payload.maxFailedAttempts) ? 5 : Number(payload.maxFailedAttempts) - if (_maxFailedAttempts !== this.maxFailedAttempts) { - this.maxFailedAttempts = _maxFailedAttempts + if (payload.ntfyUrl !== undefined && payload.ntfyUrl !== this.ntfyUrl) { + this.ntfyUrl = payload.ntfyUrl || null hasUpdates = true } - const _maxNotificationQueue = isNullOrNaN(payload.maxNotificationQueue) ? 20 : Number(payload.maxNotificationQueue) - if (_maxNotificationQueue !== this.maxNotificationQueue) { - this.maxNotificationQueue = _maxNotificationQueue + if (payload.ntfyToken !== undefined && payload.ntfyToken !== this.ntfyToken) { + this.ntfyToken = payload.ntfyToken || null hasUpdates = true } + if (payload.maxFailedAttempts !== undefined) { + const _maxFailedAttempts = isNullOrNaN(payload.maxFailedAttempts) ? 5 : Number(payload.maxFailedAttempts) + if (_maxFailedAttempts !== this.maxFailedAttempts) { + this.maxFailedAttempts = _maxFailedAttempts + hasUpdates = true + } + } + + if (payload.maxNotificationQueue !== undefined) { + const _maxNotificationQueue = isNullOrNaN(payload.maxNotificationQueue) ? 20 : Number(payload.maxNotificationQueue) + if (_maxNotificationQueue !== this.maxNotificationQueue) { + this.maxNotificationQueue = _maxNotificationQueue + hasUpdates = true + } + } + return hasUpdates } createNotification(payload) { if (!payload) return false - if (!payload.eventName || !payload.urls.length) return false + if (!payload.eventName || (!payload.urls?.length && !payload.ntfyTopic)) return false const notification = new Notification() notification.setData(payload)