audiobookshelf/client/pages/config/notifications.vue
Mikkel Mikkelsen 9391a940f5 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
2026-04-15 11:02:02 +02:00

198 lines
7.3 KiB
Vue

<template>
<div>
<app-settings-content :header-text="$strings.HeaderAppriseNotificationSettings" :description="$strings.MessageAppriseDescription">
<form @submit.prevent="submitForm">
<ui-text-input-with-label ref="apiUrlInput" v-model="appriseApiUrl" :disabled="savingSettings" label="Apprise API Url" class="mb-2" />
<div class="flex items-center py-2">
<ui-text-input ref="maxNotificationQueueInput" type="number" v-model="maxNotificationQueue" no-spinner :disabled="savingSettings" :padding-x="1" text-center class="w-10" />
<ui-tooltip :text="$strings.LabelNotificationsMaxQueueSizeHelp" direction="right">
<p class="pl-2 md:pl-4 text-base md:text-lg">{{ $strings.LabelNotificationsMaxQueueSize }}<span class="material-symbols icon-text ml-1">info</span></p>
</ui-tooltip>
</div>
<div class="flex items-center py-2">
<ui-text-input ref="maxFailedAttemptsInput" type="number" v-model="maxFailedAttempts" no-spinner :disabled="savingSettings" :padding-x="1" text-center class="w-10" />
<ui-tooltip :text="$strings.LabelNotificationsMaxFailedAttemptsHelp" direction="right">
<p class="pl-2 md:pl-4 text-base md:text-lg">{{ $strings.LabelNotificationsMaxFailedAttempts }}<span class="material-symbols icon-text ml-1">info</span></p>
</ui-tooltip>
</div>
<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.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">
<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>
<div v-if="!notifications.length" class="flex justify-center text-center">
<p class="text-lg text-gray-200">{{ $strings.MessageNoNotifications }}</p>
</div>
<template v-for="notification in notifications">
<cards-notification-card :key="notification.id" :notification="notification" @update="updateSettings" @edit="editNotification" />
</template>
</app-settings-content>
<modals-notification-edit-modal v-model="showEditModal" :notification="selectedNotification" :notification-data="notificationData" @update="updateSettings" />
</div>
</template>
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
}
},
data() {
return {
loading: false,
savingSettings: false,
appriseApiUrl: null,
ntfyUrl: null,
ntfyToken: null,
maxNotificationQueue: 0,
maxFailedAttempts: 0,
notifications: [],
notificationSettings: null,
notificationData: null,
showEditModal: false,
selectedNotification: null,
sendingTest: false
}
},
computed: {},
methods: {
updateSettings(settings) {
this.notificationSettings = settings
this.notifications = settings.notifications
},
editNotification(notification) {
this.selectedNotification = notification
this.showEditModal = true
},
clickCreate() {
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
}
},
validateForm() {
if (this.$refs.apiUrlInput) {
this.$refs.apiUrlInput.blur()
}
if (this.$refs.maxNotificationQueueInput) {
this.$refs.maxNotificationQueueInput.blur()
}
if (this.$refs.maxFailedAttemptsInput) {
this.$refs.maxFailedAttemptsInput.blur()
}
if (!this.validateAppriseApiUrl()) {
return false
}
if (isNaN(this.maxNotificationQueue) || this.maxNotificationQueue <= 0) {
this.$toast.error(this.$strings.ToastNotificationQueueMaximum)
return false
}
if (isNaN(this.maxFailedAttempts) || this.maxFailedAttempts <= 0) {
this.$toast.error(this.$strings.ToastNotificationFailedMaximum)
return false
}
return true
},
saveSettings(payload) {
this.savingSettings = true
this.$axios
.$patch('/api/notifications', payload)
.then(() => {
this.$toast.success(this.$strings.ToastNotificationSettingsUpdateSuccess)
})
.catch((error) => {
console.error('Failed to update notification settings', error)
this.$toast.error(this.$strings.ToastFailedToUpdate)
})
.finally(() => {
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) => {
console.error('Failed to get notification settings', error)
this.$toast.error(this.$strings.ToastFailedToLoadData)
return null
})
this.loading = false
if (!notificationResponse) {
return
}
this.notificationData = notificationResponse.data
this.setNotificationSettings(notificationResponse.settings)
},
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 || []
},
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)
}
}
</script>