Add: Backup notification (#3225)

* Formatting updates

* Add: backup completion notification

* Fix: comment for backup

* Add: backup size units to notification

* Add: failed backup notification

* Add: calls to failed backup notification

* Update: notification OpenAPI spec

* Update notifications to first check if any are active for an event, update JS docs

---------

Co-authored-by: advplyr <advplyr@protonmail.com>
This commit is contained in:
Nicholas W 2024-08-18 14:32:05 -05:00 committed by GitHub
parent 5308fd8b46
commit 27b3a44147
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 144 additions and 25 deletions

View File

@ -22,7 +22,7 @@ components:
notificationEventName: notificationEventName:
type: string type: string
description: The name of the event the notification will fire on. description: The name of the event the notification will fire on.
enum: ['onPodcastEpisodeDownloaded', 'onTest'] enum: ['onPodcastEpisodeDownloaded', 'onBackupCompleted', 'onBackupFailed', 'onTest']
urls: urls:
type: array type: array
items: items:

View File

@ -3226,6 +3226,8 @@
"description": "The name of the event the notification will fire on.", "description": "The name of the event the notification will fire on.",
"enum": [ "enum": [
"onPodcastEpisodeDownloaded", "onPodcastEpisodeDownloaded",
"onBackupCompleted",
"onBackupFailed",
"onTest" "onTest"
] ]
}, },

View File

@ -68,7 +68,7 @@ class Server {
// Managers // Managers
this.notificationManager = new NotificationManager() this.notificationManager = new NotificationManager()
this.emailManager = new EmailManager() this.emailManager = new EmailManager()
this.backupManager = new BackupManager() this.backupManager = new BackupManager(this.notificationManager)
this.abMergeManager = new AbMergeManager() this.abMergeManager = new AbMergeManager()
this.playbackSessionManager = new PlaybackSessionManager() this.playbackSessionManager = new PlaybackSessionManager()
this.podcastManager = new PodcastManager(this.watcher, this.notificationManager) this.podcastManager = new PodcastManager(this.watcher, this.notificationManager)

View File

@ -16,9 +16,11 @@ const { getFileSize } = require('../utils/fileUtils')
const Backup = require('../objects/Backup') const Backup = require('../objects/Backup')
class BackupManager { class BackupManager {
constructor() { constructor(notificationManager) {
this.ItemsMetadataPath = Path.join(global.MetadataPath, 'items') this.ItemsMetadataPath = Path.join(global.MetadataPath, 'items')
this.AuthorsMetadataPath = Path.join(global.MetadataPath, 'authors') this.AuthorsMetadataPath = Path.join(global.MetadataPath, 'authors')
/** @type {import('./NotificationManager')} */
this.notificationManager = notificationManager
this.scheduleTask = null this.scheduleTask = null
@ -294,6 +296,8 @@ class BackupManager {
// Create backup sqlite file // Create backup sqlite file
const sqliteBackupPath = await this.backupSqliteDb(newBackup).catch((error) => { const sqliteBackupPath = await this.backupSqliteDb(newBackup).catch((error) => {
Logger.error(`[BackupManager] Failed to backup sqlite db`, error) Logger.error(`[BackupManager] Failed to backup sqlite db`, error)
const errorMsg = error?.message || error || 'Unknown Error'
this.notificationManager.onBackupFailed(errorMsg)
return false return false
}) })
@ -304,6 +308,8 @@ class BackupManager {
// Zip sqlite file, /metadata/items, and /metadata/authors folders // Zip sqlite file, /metadata/items, and /metadata/authors folders
const zipResult = await this.zipBackup(sqliteBackupPath, newBackup).catch((error) => { const zipResult = await this.zipBackup(sqliteBackupPath, newBackup).catch((error) => {
Logger.error(`[BackupManager] Backup Failed ${error}`) Logger.error(`[BackupManager] Backup Failed ${error}`)
const errorMsg = error?.message || error || 'Unknown Error'
this.notificationManager.onBackupFailed(errorMsg)
return false return false
}) })
@ -324,13 +330,18 @@ class BackupManager {
} }
// Check remove oldest backup // Check remove oldest backup
if (this.backups.length > this.backupsToKeep) { const removeOldest = this.backups.length > this.backupsToKeep
if (removeOldest) {
this.backups.sort((a, b) => a.createdAt - b.createdAt) this.backups.sort((a, b) => a.createdAt - b.createdAt)
const oldBackup = this.backups.shift() const oldBackup = this.backups.shift()
Logger.debug(`[BackupManager] Removing old backup ${oldBackup.id}`) Logger.debug(`[BackupManager] Removing old backup ${oldBackup.id}`)
this.removeBackup(oldBackup) this.removeBackup(oldBackup)
} }
// Notification for backup successfully completed
this.notificationManager.onBackupCompleted(newBackup, this.backups.length, removeOldest)
return true return true
} }
@ -348,7 +359,6 @@ class BackupManager {
/** /**
* @see https://github.com/TryGhost/node-sqlite3/pull/1116 * @see https://github.com/TryGhost/node-sqlite3/pull/1116
* @param {Backup} backup * @param {Backup} backup
* @promise
*/ */
backupSqliteDb(backup) { backupSqliteDb(backup) {
const db = new sqlite3.Database(Database.dbPath) const db = new sqlite3.Database(Database.dbPath)

View File

@ -1,5 +1,5 @@
const axios = require('axios') const axios = require('axios')
const Logger = require("../Logger") const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const { notificationData } = require('../utils/notifications') const { notificationData } = require('../utils/notifications')
@ -17,6 +17,11 @@ class NotificationManager {
async onPodcastEpisodeDownloaded(libraryItem, episode) { async onPodcastEpisodeDownloaded(libraryItem, episode) {
if (!Database.notificationSettings.isUseable) return if (!Database.notificationSettings.isUseable) return
if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onPodcastEpisodeDownloaded')) {
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: No active notifications`)
return
}
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`) Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`)
const library = await Database.libraryModel.getOldById(libraryItem.libraryId) const library = await Database.libraryModel.getOldById(libraryItem.libraryId)
const eventData = { const eventData = {
@ -36,10 +41,60 @@ class NotificationManager {
this.triggerNotification('onPodcastEpisodeDownloaded', eventData) this.triggerNotification('onPodcastEpisodeDownloaded', eventData)
} }
/**
*
* @param {import('../objects/Backup')} backup
* @param {number} totalBackupCount
* @param {boolean} removedOldest - If oldest backup was removed
*/
async onBackupCompleted(backup, totalBackupCount, removedOldest) {
if (!Database.notificationSettings.isUseable) return
if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onBackupCompleted')) {
Logger.debug(`[NotificationManager] onBackupCompleted: No active notifications`)
return
}
Logger.debug(`[NotificationManager] onBackupCompleted: Backup completed`)
const eventData = {
completionTime: backup.createdAt,
backupPath: backup.fullPath,
backupSize: backup.fileSize,
backupCount: totalBackupCount || 'Invalid',
removedOldest: removedOldest || 'false'
}
this.triggerNotification('onBackupCompleted', eventData)
}
/**
*
* @param {string} errorMsg
*/
async onBackupFailed(errorMsg) {
if (!Database.notificationSettings.isUseable) return
if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onBackupFailed')) {
Logger.debug(`[NotificationManager] onBackupFailed: No active notifications`)
return
}
Logger.debug(`[NotificationManager] onBackupFailed: Backup failed (${errorMsg})`)
const eventData = {
errorMsg: errorMsg || 'Backup failed'
}
this.triggerNotification('onBackupFailed', eventData)
}
onTest() { onTest() {
this.triggerNotification('onTest') this.triggerNotification('onTest')
} }
/**
*
* @param {string} eventName
* @param {any} eventData
* @param {boolean} [intentionallyFail=false] - If true, will intentionally fail the notification
*/
async triggerNotification(eventName, eventData, intentionallyFail = false) { async triggerNotification(eventName, eventData, intentionallyFail = false) {
if (!Database.notificationSettings.isUseable) return if (!Database.notificationSettings.isUseable) return
@ -52,7 +107,8 @@ class NotificationManager {
const success = intentionallyFail ? false : await this.sendNotification(notification, eventData) const success = intentionallyFail ? false : await this.sendNotification(notification, eventData)
notification.updateNotificationFired(success) notification.updateNotificationFired(success)
if (!success) { // Failed notification if (!success) {
// Failed notification
if (notification.numConsecutiveFailedAttempts >= Database.notificationSettings.maxFailedAttempts) { if (notification.numConsecutiveFailedAttempts >= Database.notificationSettings.maxFailedAttempts) {
Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} reached max failed attempts`) Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} reached max failed attempts`)
notification.enabled = false notification.enabled = false
@ -68,7 +124,12 @@ class NotificationManager {
this.notificationFinished() this.notificationFinished()
} }
// Return TRUE if notification should be triggered now /**
*
* @param {string} eventName
* @param {any} eventData
* @returns {boolean} - TRUE if notification should be triggered now
*/
checkTriggerNotification(eventName, eventData) { checkTriggerNotification(eventName, eventData) {
if (this.sendingNotification) { if (this.sendingNotification) {
if (this.notificationQueue.length >= Database.notificationSettings.maxNotificationQueue) { if (this.notificationQueue.length >= Database.notificationSettings.maxNotificationQueue) {
@ -87,7 +148,8 @@ class NotificationManager {
// Delay between events then run next notification in queue // Delay between events then run next notification in queue
setTimeout(() => { setTimeout(() => {
this.sendingNotification = false this.sendingNotification = false
if (this.notificationQueue.length) { // Send next notification in queue if (this.notificationQueue.length) {
// Send next notification in queue
const nextNotificationEvent = this.notificationQueue.shift() const nextNotificationEvent = this.notificationQueue.shift()
this.triggerNotification(nextNotificationEvent.eventName, nextNotificationEvent.eventData) this.triggerNotification(nextNotificationEvent.eventName, nextNotificationEvent.eventData)
} }
@ -95,7 +157,7 @@ class NotificationManager {
} }
sendTestNotification(notification) { sendTestNotification(notification) {
const eventData = notificationData.events.find(e => e.name === notification.eventName) const eventData = notificationData.events.find((e) => e.name === notification.eventName)
if (!eventData) { if (!eventData) {
Logger.error(`[NotificationManager] sendTestNotification: Event not found ${notification.eventName}`) Logger.error(`[NotificationManager] sendTestNotification: Event not found ${notification.eventName}`)
return false return false
@ -106,13 +168,16 @@ class NotificationManager {
sendNotification(notification, eventData) { sendNotification(notification, eventData) {
const payload = notification.getApprisePayload(eventData) const payload = notification.getApprisePayload(eventData)
return axios.post(Database.notificationSettings.appriseApiUrl, payload, { timeout: 6000 }).then((response) => { return axios
Logger.debug(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} response=`, response.data) .post(Database.notificationSettings.appriseApiUrl, payload, { timeout: 6000 })
return true .then((response) => {
}).catch((error) => { Logger.debug(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} response=`, response.data)
Logger.error(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} error=`, error) return true
return false })
}) .catch((error) => {
Logger.error(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} error=`, error)
return false
})
} }
} }
module.exports = NotificationManager module.exports = NotificationManager

View File

@ -20,7 +20,7 @@ class NotificationSettings {
construct(settings) { construct(settings) {
this.appriseType = settings.appriseType this.appriseType = settings.appriseType
this.appriseApiUrl = settings.appriseApiUrl || null this.appriseApiUrl = settings.appriseApiUrl || null
this.notifications = (settings.notifications || []).map(n => new Notification(n)) this.notifications = (settings.notifications || []).map((n) => new Notification(n))
this.maxFailedAttempts = settings.maxFailedAttempts || 5 this.maxFailedAttempts = settings.maxFailedAttempts || 5
this.maxNotificationQueue = settings.maxNotificationQueue || 20 this.maxNotificationQueue = settings.maxNotificationQueue || 20
this.notificationDelay = settings.notificationDelay || 1000 this.notificationDelay = settings.notificationDelay || 1000
@ -31,7 +31,7 @@ class NotificationSettings {
id: this.id, id: this.id,
appriseType: this.appriseType, appriseType: this.appriseType,
appriseApiUrl: this.appriseApiUrl, appriseApiUrl: this.appriseApiUrl,
notifications: this.notifications.map(n => n.toJSON()), notifications: this.notifications.map((n) => n.toJSON()),
maxFailedAttempts: this.maxFailedAttempts, maxFailedAttempts: this.maxFailedAttempts,
maxNotificationQueue: this.maxNotificationQueue, maxNotificationQueue: this.maxNotificationQueue,
notificationDelay: this.notificationDelay notificationDelay: this.notificationDelay
@ -42,17 +42,29 @@ class NotificationSettings {
return !!this.appriseApiUrl return !!this.appriseApiUrl
} }
/**
* @param {string} eventName
* @returns {boolean} - TRUE if there are active notifications for the event
*/
getHasActiveNotificationsForEvent(eventName) {
return this.notifications.some((n) => n.eventName === eventName && n.enabled)
}
/**
* @param {string} eventName
* @returns {Notification[]}
*/
getActiveNotificationsForEvent(eventName) { getActiveNotificationsForEvent(eventName) {
return this.notifications.filter(n => n.eventName === eventName && n.enabled) return this.notifications.filter((n) => n.eventName === eventName && n.enabled)
} }
getNotification(id) { getNotification(id) {
return this.notifications.find(n => n.id === id) return this.notifications.find((n) => n.id === id)
} }
removeNotification(id) { removeNotification(id) {
if (this.notifications.some(n => n.id === id)) { if (this.notifications.some((n) => n.id === id)) {
this.notifications = this.notifications.filter(n => n.id !== id) this.notifications = this.notifications.filter((n) => n.id !== id)
return true return true
} }
return false return false
@ -94,7 +106,7 @@ class NotificationSettings {
updateNotification(payload) { updateNotification(payload) {
if (!payload) return false if (!payload) return false
const notification = this.notifications.find(n => n.id === payload.id) const notification = this.notifications.find((n) => n.id === payload.id)
if (!notification) { if (!notification) {
Logger.error(`[NotificationSettings] updateNotification: Notification not found ${payload.id}`) Logger.error(`[NotificationSettings] updateNotification: Notification not found ${payload.id}`)
return false return false
@ -103,4 +115,4 @@ class NotificationSettings {
return notification.update(payload) return notification.update(payload)
} }
} }
module.exports = NotificationSettings module.exports = NotificationSettings

View File

@ -27,6 +27,36 @@ module.exports.notificationData = {
episodeDescription: 'Some description of the podcast episode.' episodeDescription: 'Some description of the podcast episode.'
} }
}, },
{
name: 'onBackupCompleted',
requiresLibrary: false,
description: 'Triggered when a backup is completed',
variables: ['completionTime', 'backupPath', 'backupSize', 'backupCount', 'removedOldest'],
defaults: {
title: 'Backup Completed',
body: 'Backup has been completed successfully.\n\nPath: {{backupPath}}\nSize: {{backupSize}} bytes\nCount: {{backupCount}}\nRemoved Oldest: {{removedOldest}}'
},
testData: {
completionTime: '12:00 AM',
backupPath: 'path/to/backup',
backupSize: '1.23 MB',
backupCount: '1',
removedOldest: 'false'
}
},
{
name: 'onBackupFailed',
requiresLibrary: false,
description: 'Triggered when a backup fails',
variables: ['errorMsg'],
defaults: {
title: 'Backup Failed',
body: 'Backup failed, check ABS logs for more information.\nError message: {{errorMsg}}'
},
testData: {
errorMsg: 'Example error message'
}
},
{ {
name: 'onTest', name: 'onTest',
requiresLibrary: false, requiresLibrary: false,