diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index 50cec860..af7e0f65 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -82,6 +82,11 @@ export default { id: 'config-log', title: 'Logs', path: '/config/log' + }, + { + id: 'config-notifications', + title: 'Notifications', + path: '/config/notifications' } ] diff --git a/client/pages/config/notifications.vue b/client/pages/config/notifications.vue new file mode 100644 index 00000000..6f944335 --- /dev/null +++ b/client/pages/config/notifications.vue @@ -0,0 +1,73 @@ + + + \ No newline at end of file diff --git a/server/Db.js b/server/Db.js index 4b23d436..de5c274a 100644 --- a/server/Db.js +++ b/server/Db.js @@ -9,8 +9,8 @@ const Library = require('./objects/Library') const Author = require('./objects/entities/Author') const Series = require('./objects/entities/Series') const ServerSettings = require('./objects/settings/ServerSettings') +const NotificationSettings = require('./objects/settings/NotificationSettings') const PlaybackSession = require('./objects/PlaybackSession') -const Feed = require('./objects/Feed') class Db { constructor() { @@ -43,6 +43,7 @@ class Db { this.series = [] this.serverSettings = null + this.notificationSettings = null // Stores previous version only if upgraded this.previousVersion = null @@ -125,6 +126,10 @@ class Db { this.serverSettings = new ServerSettings() await this.insertEntity('settings', this.serverSettings) } + if (!this.notificationSettings) { + this.notificationSettings = new NotificationSettings() + await this.insertEntity('settings', this.notificationSettings) + } global.ServerSettings = this.serverSettings.toJSON() } @@ -166,6 +171,11 @@ class Db { } } } + + var notificationSettings = this.settings.find(s => s.id === 'notification-settings') + if (notificationSettings) { + this.notificationSettings = new NotificationSettings(notificationSettings) + } } }) var p5 = this.collectionsDb.select(() => true).then((results) => { diff --git a/server/controllers/NotificationController.js b/server/controllers/NotificationController.js new file mode 100644 index 00000000..80754271 --- /dev/null +++ b/server/controllers/NotificationController.js @@ -0,0 +1,25 @@ +const Logger = require('../Logger') + +class NotificationController { + constructor() { } + + get(req, res) { + res.json(this.db.notificationSettings) + } + + async update(req, res) { + const updated = this.db.notificationSettings.update(req.body) + if (updated) { + await this.db.updateEntity('settings', this.db.notificationSettings) + } + res.sendStatus(200) + } + + middleware(req, res, next) { + if (!req.user.isAdminOrUp) { + return res.sendStatus(404) + } + next() + } +} +module.exports = new NotificationController() \ No newline at end of file diff --git a/server/managers/NotificationManager.js b/server/managers/NotificationManager.js index 9b71bfad..be479046 100644 --- a/server/managers/NotificationManager.js +++ b/server/managers/NotificationManager.js @@ -1,10 +1,55 @@ +const axios = require('axios') const Logger = require("../Logger") class NotificationManager { - constructor() { } + constructor(db) { + this.db = db - onNewPodcastEpisode(libraryItem, episode) { - Logger.debug(`[NotificationManager] onNewPodcastEpisode: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`) + this.notificationFailedMap = {} + } + + onPodcastEpisodeDownloaded(libraryItem, episode) { + if (!this.db.notificationSettings.isUseable) return + + Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`) + this.triggerNotification('onPodcastEpisodeDownloaded', { libraryItem, episode }) + } + + onTest() { + this.triggerNotification('onTest') + } + + async triggerNotification(eventName, eventData) { + if (!this.db.notificationSettings.isUseable) return + + const notifications = this.db.notificationSettings.getNotificationsForEvent(eventName) + for (const notification of notifications) { + Logger.debug(`[NotificationManager] triggerNotification: Sending ${eventName} notification ${notification.id}`) + const success = await this.sendNotification(notification, eventData) + + if (!success) { // Failed notification + if (!this.notificationFailedMap[notification.id]) this.notificationFailedMap[notification.id] = 1 + else this.notificationFailedMap[notification.id]++ + + if (this.notificationFailedMap[notification.id] > 2) { + Logger.error(`[NotificationManager] triggerNotification: ${notification.eventName}/${notification.id} reached max failed attempts`) + // TODO: Do something like disable the notification + } + } else { // Successful notification + delete this.notificationFailedMap[notification.id] + } + } + } + + sendNotification(notification, eventData) { + const payload = notification.getApprisePayload(eventData) + return axios.post(`${this.db.notificationSettings.appriseApiUrl}/notify`, payload, { timeout: 6000 }).then((data) => { + Logger.debug(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} response=${data}`) + return true + }).catch((error) => { + Logger.error(`[NotificationManager] sendNotification: ${notification.eventName}/${notification.id} error=`, error) + return false + }) } } module.exports = NotificationManager \ No newline at end of file diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 73f35f19..a80be30c 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -136,7 +136,7 @@ class PodcastManager { this.emitter('item_updated', libraryItem.toJSONExpanded()) if (this.currentDownload.isAutoDownload) { // Notifications only for auto downloaded episodes - this.notificationManager.onNewPodcastEpisode(libraryItem, podcastEpisode) + this.notificationManager.onPodcastEpisodeDownloaded(libraryItem, podcastEpisode) } return true diff --git a/server/objects/Notification.js b/server/objects/Notification.js index 754e1997..9e7bfab2 100644 --- a/server/objects/Notification.js +++ b/server/objects/Notification.js @@ -1,10 +1,12 @@ class Notification { constructor(notification = null) { this.id = null + this.libraryId = null this.eventName = '' this.urls = [] this.titleTemplate = '' this.bodyTemplate = '' + this.type = 'info' this.enabled = false this.createdAt = null @@ -16,10 +18,12 @@ class Notification { construct(notification) { this.id = notification.id + this.libraryId = notification.libraryId || null this.eventName = notification.eventName this.urls = notification.urls || [] this.titleTemplate = notification.titleTemplate || '' this.bodyTemplate = notification.bodyTemplate || '' + this.type = notification.type || 'info' this.enabled = !!notification.enabled this.createdAt = notification.createdAt } @@ -27,13 +31,33 @@ class Notification { toJSON() { return { id: this.id, + libraryId: this.libraryId, eventName: this.eventName, urls: this.urls, titleTemplate: this.titleTemplate, bodyTemplate: this.bodyTemplate, enabled: this.enabled, + type: this.type, createdAt: this.createdAt } } + + parseTitleTemplate(data) { + // TODO: Implement template parsing + return 'Test Title' + } + + parseBodyTemplate(data) { + // TODO: Implement template parsing + return 'Test Body' + } + + getApprisePayload(data) { + return { + urls: this.urls, + title: this.parseTitleTemplate(data), + body: this.parseBodyTemplate(data) + } + } } module.exports = Notification \ No newline at end of file diff --git a/server/objects/settings/NotificationSettings.js b/server/objects/settings/NotificationSettings.js new file mode 100644 index 00000000..901749ff --- /dev/null +++ b/server/objects/settings/NotificationSettings.js @@ -0,0 +1,45 @@ +class NotificationSettings { + constructor(settings = null) { + this.id = 'notification-settings' + this.appriseType = 'api' + this.appriseApiUrl = null + this.notifications = [] + + if (settings) { + this.construct(settings) + } + } + + construct(settings) { + this.appriseType = settings.appriseType + this.appriseApiUrl = settings.appriseApiUrl || null + this.notifications = (settings.notifications || []).map(n => ({ ...n })) + } + + toJSON() { + return { + id: this.id, + appriseType: this.appriseType, + appriseApiUrl: this.appriseApiUrl, + notifications: this.notifications.map(n => n.toJSON()) + } + } + + get isUseable() { + return !!this.appriseApiUrl + } + + getNotificationsForEvent(eventName) { + return this.notifications.filter(n => n.eventName === eventName) + } + + update(payload) { + if (!payload) return false + if (payload.appriseApiUrl !== this.appriseApiUrl) { + this.appriseApiUrl = payload.appriseApiUrl || null + return true + } + return false + } +} +module.exports = NotificationSettings \ No newline at end of file diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index edb293df..12912b0e 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -14,6 +14,7 @@ const SeriesController = require('../controllers/SeriesController') const AuthorController = require('../controllers/AuthorController') const SessionController = require('../controllers/SessionController') const PodcastController = require('../controllers/PodcastController') +const NotificationController = require('../controllers/NotificationController') const MiscController = require('../controllers/MiscController') const BookFinder = require('../finders/BookFinder') @@ -199,6 +200,12 @@ class ApiRouter { this.router.patch('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.updateEpisode.bind(this)) this.router.delete('/podcasts/:id/episode/:episodeId', PodcastController.middleware.bind(this), PodcastController.removeEpisode.bind(this)) + // + // Notification Routes + // + this.router.get('/notifications', NotificationController.middleware.bind(this), NotificationController.get.bind(this)) + this.router.patch('/notifications', NotificationController.middleware.bind(this), NotificationController.update.bind(this)) + // // Misc Routes //