From 27b3a4414726a6a2778fb19e6038ce3ac1195aee Mon Sep 17 00:00:00 2001 From: Nicholas W Date: Sun, 18 Aug 2024 14:32:05 -0500 Subject: [PATCH] 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 --- docs/objects/Notification.yaml | 2 +- docs/openapi.json | 2 + server/Server.js | 2 +- server/managers/BackupManager.js | 16 +++- server/managers/NotificationManager.js | 89 ++++++++++++++++--- .../objects/settings/NotificationSettings.js | 28 ++++-- server/utils/notifications.js | 30 +++++++ 7 files changed, 144 insertions(+), 25 deletions(-) diff --git a/docs/objects/Notification.yaml b/docs/objects/Notification.yaml index bb9ce8bd..50299ec8 100644 --- a/docs/objects/Notification.yaml +++ b/docs/objects/Notification.yaml @@ -22,7 +22,7 @@ components: notificationEventName: type: string description: The name of the event the notification will fire on. - enum: ['onPodcastEpisodeDownloaded', 'onTest'] + enum: ['onPodcastEpisodeDownloaded', 'onBackupCompleted', 'onBackupFailed', 'onTest'] urls: type: array items: diff --git a/docs/openapi.json b/docs/openapi.json index 9767f579..48f30ecf 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -3226,6 +3226,8 @@ "description": "The name of the event the notification will fire on.", "enum": [ "onPodcastEpisodeDownloaded", + "onBackupCompleted", + "onBackupFailed", "onTest" ] }, diff --git a/server/Server.js b/server/Server.js index f1cfc7f4..0110ab6a 100644 --- a/server/Server.js +++ b/server/Server.js @@ -68,7 +68,7 @@ class Server { // Managers this.notificationManager = new NotificationManager() this.emailManager = new EmailManager() - this.backupManager = new BackupManager() + this.backupManager = new BackupManager(this.notificationManager) this.abMergeManager = new AbMergeManager() this.playbackSessionManager = new PlaybackSessionManager() this.podcastManager = new PodcastManager(this.watcher, this.notificationManager) diff --git a/server/managers/BackupManager.js b/server/managers/BackupManager.js index b8b1beea..71b8304c 100644 --- a/server/managers/BackupManager.js +++ b/server/managers/BackupManager.js @@ -16,9 +16,11 @@ const { getFileSize } = require('../utils/fileUtils') const Backup = require('../objects/Backup') class BackupManager { - constructor() { + constructor(notificationManager) { this.ItemsMetadataPath = Path.join(global.MetadataPath, 'items') this.AuthorsMetadataPath = Path.join(global.MetadataPath, 'authors') + /** @type {import('./NotificationManager')} */ + this.notificationManager = notificationManager this.scheduleTask = null @@ -294,6 +296,8 @@ class BackupManager { // Create backup sqlite file const sqliteBackupPath = await this.backupSqliteDb(newBackup).catch((error) => { Logger.error(`[BackupManager] Failed to backup sqlite db`, error) + const errorMsg = error?.message || error || 'Unknown Error' + this.notificationManager.onBackupFailed(errorMsg) return false }) @@ -304,6 +308,8 @@ class BackupManager { // Zip sqlite file, /metadata/items, and /metadata/authors folders const zipResult = await this.zipBackup(sqliteBackupPath, newBackup).catch((error) => { Logger.error(`[BackupManager] Backup Failed ${error}`) + const errorMsg = error?.message || error || 'Unknown Error' + this.notificationManager.onBackupFailed(errorMsg) return false }) @@ -324,13 +330,18 @@ class BackupManager { } // 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) const oldBackup = this.backups.shift() Logger.debug(`[BackupManager] Removing old backup ${oldBackup.id}`) this.removeBackup(oldBackup) } + + // Notification for backup successfully completed + this.notificationManager.onBackupCompleted(newBackup, this.backups.length, removeOldest) + return true } @@ -348,7 +359,6 @@ class BackupManager { /** * @see https://github.com/TryGhost/node-sqlite3/pull/1116 * @param {Backup} backup - * @promise */ backupSqliteDb(backup) { const db = new sqlite3.Database(Database.dbPath) diff --git a/server/managers/NotificationManager.js b/server/managers/NotificationManager.js index 9007261a..f8bd7511 100644 --- a/server/managers/NotificationManager.js +++ b/server/managers/NotificationManager.js @@ -1,5 +1,5 @@ const axios = require('axios') -const Logger = require("../Logger") +const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') const { notificationData } = require('../utils/notifications') @@ -17,6 +17,11 @@ class NotificationManager { async onPodcastEpisodeDownloaded(libraryItem, episode) { 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}`) const library = await Database.libraryModel.getOldById(libraryItem.libraryId) const eventData = { @@ -36,10 +41,60 @@ class NotificationManager { 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() { 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) { if (!Database.notificationSettings.isUseable) return @@ -52,7 +107,8 @@ class NotificationManager { const success = intentionallyFail ? false : await this.sendNotification(notification, eventData) notification.updateNotificationFired(success) - if (!success) { // Failed notification + if (!success) { + // Failed notification if (notification.numConsecutiveFailedAttempts >= Database.notificationSettings.maxFailedAttempts) { Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} reached max failed attempts`) notification.enabled = false @@ -68,7 +124,12 @@ class NotificationManager { 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) { if (this.sendingNotification) { if (this.notificationQueue.length >= Database.notificationSettings.maxNotificationQueue) { @@ -87,7 +148,8 @@ class NotificationManager { // Delay between events then run next notification in queue setTimeout(() => { 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() this.triggerNotification(nextNotificationEvent.eventName, nextNotificationEvent.eventData) } @@ -95,7 +157,7 @@ class NotificationManager { } sendTestNotification(notification) { - const eventData = notificationData.events.find(e => e.name === notification.eventName) + const eventData = notificationData.events.find((e) => e.name === notification.eventName) if (!eventData) { Logger.error(`[NotificationManager] sendTestNotification: Event not found ${notification.eventName}`) return false @@ -106,13 +168,16 @@ class NotificationManager { sendNotification(notification, eventData) { 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) - return true - }).catch((error) => { - Logger.error(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} error=`, error) - return false - }) + return axios + .post(Database.notificationSettings.appriseApiUrl, payload, { timeout: 6000 }) + .then((response) => { + Logger.debug(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} response=`, response.data) + return true + }) + .catch((error) => { + Logger.error(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} error=`, error) + return false + }) } } module.exports = NotificationManager diff --git a/server/objects/settings/NotificationSettings.js b/server/objects/settings/NotificationSettings.js index 04907f3c..aab8f3e4 100644 --- a/server/objects/settings/NotificationSettings.js +++ b/server/objects/settings/NotificationSettings.js @@ -20,7 +20,7 @@ class NotificationSettings { construct(settings) { this.appriseType = settings.appriseType 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.maxNotificationQueue = settings.maxNotificationQueue || 20 this.notificationDelay = settings.notificationDelay || 1000 @@ -31,7 +31,7 @@ class NotificationSettings { id: this.id, appriseType: this.appriseType, appriseApiUrl: this.appriseApiUrl, - notifications: this.notifications.map(n => n.toJSON()), + notifications: this.notifications.map((n) => n.toJSON()), maxFailedAttempts: this.maxFailedAttempts, maxNotificationQueue: this.maxNotificationQueue, notificationDelay: this.notificationDelay @@ -42,17 +42,29 @@ class NotificationSettings { 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) { - return this.notifications.filter(n => n.eventName === eventName && n.enabled) + return this.notifications.filter((n) => n.eventName === eventName && n.enabled) } getNotification(id) { - return this.notifications.find(n => n.id === id) + return this.notifications.find((n) => n.id === id) } removeNotification(id) { - if (this.notifications.some(n => n.id === id)) { - this.notifications = this.notifications.filter(n => n.id !== id) + if (this.notifications.some((n) => n.id === id)) { + this.notifications = this.notifications.filter((n) => n.id !== id) return true } return false @@ -94,7 +106,7 @@ class NotificationSettings { updateNotification(payload) { 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) { Logger.error(`[NotificationSettings] updateNotification: Notification not found ${payload.id}`) return false @@ -103,4 +115,4 @@ class NotificationSettings { return notification.update(payload) } } -module.exports = NotificationSettings \ No newline at end of file +module.exports = NotificationSettings diff --git a/server/utils/notifications.js b/server/utils/notifications.js index c90e3408..96e8ddf8 100644 --- a/server/utils/notifications.js +++ b/server/utils/notifications.js @@ -27,6 +27,36 @@ module.exports.notificationData = { 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', requiresLibrary: false,