mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	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:
		
							parent
							
								
									5308fd8b46
								
							
						
					
					
						commit
						27b3a44147
					
				| @ -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: | ||||||
|  | |||||||
| @ -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" | ||||||
|         ] |         ] | ||||||
|       }, |       }, | ||||||
|  | |||||||
| @ -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) | ||||||
|  | |||||||
| @ -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) | ||||||
|  | |||||||
| @ -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 | ||||||
|  | |||||||
| @ -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 | ||||||
|  | |||||||
| @ -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, | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user