Add ntfy.sh as notification provider alongside Apprise

Support sending notifications via ntfy.sh or self-hosted ntfy servers
as an alternative to Apprise. Each notification can be configured to
use either provider via a dropdown in the create/edit modal.

Server changes:
- Add ntfyUrl and ntfyToken fields to NotificationSettings
- Add ntfyTopic field to Notification model with getNtfyPayload()
- Route notifications to Apprise or ntfy based on ntfyTopic presence
- Add null guard for appriseApiUrl before sending
- Add undefined guards in update() to prevent partial PATCH from
  resetting unrelated fields

Client changes:
- Add ntfy.sh settings section on the notifications config page
- Add provider dropdown (Apprise/ntfy) in notification edit modal
- Extract shared saveSettings() helper to deduplicate form submissions
- Use i18n for all user-visible strings
This commit is contained in:
Mikkel Mikkelsen 2026-04-15 11:02:02 +02:00 committed by Mikkel Mikkelsen
parent 88667d00a1
commit 9391a940f5
8 changed files with 150 additions and 36 deletions

View file

@ -14,7 +14,7 @@
<ui-icon-btn bg-color="bg-error" :size="7" icon-font-size="1.2rem" icon="delete" @click="deleteNotificationClick" />
</div>
<div class="pt-4">
<p class="text-gray-300 text-xs md:text-sm mb-2">{{ notification.urls.join(', ') }}</p>
<p class="text-gray-300 text-xs md:text-sm mb-2">{{ notification.ntfyTopic ? $getString('LabelNotificationNtfyTopicPrefix', [notification.ntfyTopic]) : notification.urls.join(', ') }}</p>
<p v-if="lastFiredAt && lastAttemptFailed" class="text-red-300 text-xs">Last attempt failed {{ $dateDistanceFromNow(lastFiredAt) }} ({{ numConsecutiveFailedAttempts }} attempt{{ numConsecutiveFailedAttempts === 1 ? '' : 's' }})</p>
<p v-else-if="lastFiredAt" class="text-gray-400 text-xs">Last fired {{ $dateDistanceFromNow(lastFiredAt) }}</p>

View file

@ -10,7 +10,11 @@
<div class="w-full px-3 py-5 md:p-12">
<ui-dropdown v-model="newNotification.eventName" :label="$strings.LabelNotificationEvent" :items="eventOptions" class="mb-4" @input="eventOptionUpdated" />
<ui-multi-select ref="urlsInput" v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
<ui-dropdown v-model="selectedProvider" :label="$strings.LabelNotificationProvider" :items="providerOptions" class="mb-4" />
<ui-multi-select v-if="selectedProvider === 'apprise'" ref="urlsInput" v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
<ui-text-input-with-label v-if="selectedProvider === 'ntfy'" v-model="newNotification.ntfyTopic" :label="$strings.LabelNotificationNtfyTopic" class="mb-2" />
<ui-text-input-with-label v-model="newNotification.titleTemplate" :label="$strings.LabelNotificationTitleTemplate" class="mb-2" />
@ -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()
}

View file

@ -24,11 +24,22 @@
<ui-btn :loading="savingSettings" type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</form>
</app-settings-content>
<div class="w-full h-px bg-white/10 my-6" />
<app-settings-content :header-text="$strings.HeaderNtfyNotificationSettings" :description="$strings.MessageNtfyDescription">
<form @submit.prevent="submitNtfyForm">
<ui-text-input-with-label ref="ntfyUrlInput" v-model="ntfyUrl" :disabled="savingSettings" label="ntfy Server URL" placeholder="https://ntfy.sh" class="mb-2" />
<ui-text-input-with-label ref="ntfyTokenInput" v-model="ntfyToken" :disabled="savingSettings" label="ntfy Auth Token" type="password" class="mb-2" />
<div class="flex items-center justify-end pt-4">
<ui-btn :loading="savingSettings" type="submit">{{ $strings.ButtonSave }}</ui-btn>
</div>
</form>
</app-settings-content>
<app-settings-content :header-text="$strings.HeaderNotifications">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold">{{ $strings.HeaderNotifications }}</h2>
<ui-btn small color="bg-success" class="flex items-center" @click="clickCreate">{{ $strings.ButtonCreate }} <span class="material-symbols text-lg pl-2">add</span></ui-btn>
</div>
@ -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 || []

View file

@ -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 <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
"MessageNtfyDescription": "Send notifications using <a href=\"https://ntfy.sh\" target=\"_blank\">ntfy.sh</a> or a self-hosted ntfy server. The server URL defaults to <code>https://ntfy.sh</code>. 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 <a href=\"/config/api-keys\">API Keys</a> 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",

View file

@ -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)

View file

@ -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
})
}

View file

@ -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

View file

@ -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)