mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-08-14 13:47:16 +02:00
Merge 7bafc63550
into 9c0c7b6b08
This commit is contained in:
commit
bc12ca5fa2
@ -2,6 +2,8 @@ const SocketIO = require('socket.io')
|
|||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
const Database = require('./Database')
|
const Database = require('./Database')
|
||||||
const Auth = require('./Auth')
|
const Auth = require('./Auth')
|
||||||
|
const NotificationManager = require('./managers/NotificationManager')
|
||||||
|
const { flattenAny } = require('./utils/objectUtils')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef SocketClient
|
* @typedef SocketClient
|
||||||
@ -15,11 +17,25 @@ class SocketAuthority {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.Server = null
|
this.Server = null
|
||||||
this.socketIoServers = []
|
this.socketIoServers = []
|
||||||
|
this.emittedNotifications = new Set(['item_added', 'item_updated', 'user_online', 'task_started', 'task_finished'])
|
||||||
|
|
||||||
/** @type {Object.<string, SocketClient>} */
|
/** @type {Object.<string, SocketClient>} */
|
||||||
this.clients = {}
|
this.clients = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires a notification if enabled and the event is whitelisted
|
||||||
|
* @param {string} event - The event name fired. Needs to be whitelisted in this.emitted_notifications
|
||||||
|
* @param {any} payload - The payload to send with the event. For user-specific events, this includes the userId
|
||||||
|
*/
|
||||||
|
_fireNotification(event, payload) {
|
||||||
|
// Should be O(1) so no real performance hit
|
||||||
|
if (!this.emittedNotifications.has(event)) return
|
||||||
|
Logger.debug(`[SocketAuthority] fireNotification - ${event}`)
|
||||||
|
|
||||||
|
NotificationManager.fireNotificationFromSocket(event, flattenAny(payload))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* returns an array of User.toJSONForPublic with `connections` for the # of socket connections
|
* returns an array of User.toJSONForPublic with `connections` for the # of socket connections
|
||||||
* a user can have many socket connections
|
* a user can have many socket connections
|
||||||
@ -53,6 +69,7 @@ class SocketAuthority {
|
|||||||
* @param {Function} [filter] optional filter function to only send event to specific users
|
* @param {Function} [filter] optional filter function to only send event to specific users
|
||||||
*/
|
*/
|
||||||
emitter(evt, data, filter = null) {
|
emitter(evt, data, filter = null) {
|
||||||
|
void this._fireNotification(evt, data)
|
||||||
for (const socketId in this.clients) {
|
for (const socketId in this.clients) {
|
||||||
if (this.clients[socketId].user) {
|
if (this.clients[socketId].user) {
|
||||||
if (filter && !filter(this.clients[socketId].user)) continue
|
if (filter && !filter(this.clients[socketId].user)) continue
|
||||||
@ -64,6 +81,7 @@ class SocketAuthority {
|
|||||||
|
|
||||||
// Emits event to all clients for a specific user
|
// Emits event to all clients for a specific user
|
||||||
clientEmitter(userId, evt, data) {
|
clientEmitter(userId, evt, data) {
|
||||||
|
void this._fireNotification(evt, data)
|
||||||
const clients = this.getClientsForUser(userId)
|
const clients = this.getClientsForUser(userId)
|
||||||
if (!clients.length) {
|
if (!clients.length) {
|
||||||
return Logger.debug(`[SocketAuthority] clientEmitter - no clients found for user ${userId}`)
|
return Logger.debug(`[SocketAuthority] clientEmitter - no clients found for user ${userId}`)
|
||||||
@ -77,6 +95,7 @@ class SocketAuthority {
|
|||||||
|
|
||||||
// Emits event to all admin user clients
|
// Emits event to all admin user clients
|
||||||
adminEmitter(evt, data) {
|
adminEmitter(evt, data) {
|
||||||
|
void this._fireNotification(evt, data)
|
||||||
for (const socketId in this.clients) {
|
for (const socketId in this.clients) {
|
||||||
if (this.clients[socketId].user?.isAdminOrUp) {
|
if (this.clients[socketId].user?.isAdminOrUp) {
|
||||||
this.clients[socketId].socket.emit(evt, data)
|
this.clients[socketId].socket.emit(evt, data)
|
||||||
@ -92,6 +111,7 @@ class SocketAuthority {
|
|||||||
* @param {import('./models/LibraryItem')} libraryItem
|
* @param {import('./models/LibraryItem')} libraryItem
|
||||||
*/
|
*/
|
||||||
libraryItemEmitter(evt, libraryItem) {
|
libraryItemEmitter(evt, libraryItem) {
|
||||||
|
void this._fireNotification(evt, libraryItem.toOldJSONMinified())
|
||||||
for (const socketId in this.clients) {
|
for (const socketId in this.clients) {
|
||||||
if (this.clients[socketId].user?.checkCanAccessLibraryItem(libraryItem)) {
|
if (this.clients[socketId].user?.checkCanAccessLibraryItem(libraryItem)) {
|
||||||
this.clients[socketId].socket.emit(evt, libraryItem.toOldJSONExpanded())
|
this.clients[socketId].socket.emit(evt, libraryItem.toOldJSONExpanded())
|
||||||
|
@ -43,7 +43,7 @@ class NotificationManager {
|
|||||||
episodeSubtitle: episode.subtitle || '',
|
episodeSubtitle: episode.subtitle || '',
|
||||||
episodeDescription: episode.description || ''
|
episodeDescription: episode.description || ''
|
||||||
}
|
}
|
||||||
this.triggerNotification('onPodcastEpisodeDownloaded', eventData)
|
void this.triggerNotification('onPodcastEpisodeDownloaded', eventData)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -68,7 +68,7 @@ class NotificationManager {
|
|||||||
backupCount: totalBackupCount || 'Invalid',
|
backupCount: totalBackupCount || 'Invalid',
|
||||||
removedOldest: removedOldest || 'false'
|
removedOldest: removedOldest || 'false'
|
||||||
}
|
}
|
||||||
this.triggerNotification('onBackupCompleted', eventData)
|
void this.triggerNotification('onBackupCompleted', eventData)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -135,11 +135,11 @@ class NotificationManager {
|
|||||||
const eventData = {
|
const eventData = {
|
||||||
errorMsg: errorMsg || 'Backup failed'
|
errorMsg: errorMsg || 'Backup failed'
|
||||||
}
|
}
|
||||||
this.triggerNotification('onBackupFailed', eventData)
|
void this.triggerNotification('onBackupFailed', eventData)
|
||||||
}
|
}
|
||||||
|
|
||||||
onTest() {
|
onTest() {
|
||||||
this.triggerNotification('onTest')
|
void this.triggerNotification('onTest')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -172,7 +172,8 @@ class NotificationManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Database.updateSetting(Database.notificationSettings)
|
await Database.updateSetting(Database.notificationSettings)
|
||||||
SocketAuthority.emitter('notifications_updated', Database.notificationSettings.toJSON())
|
// Currently results in circular dependency TODO: Fix this
|
||||||
|
// SocketAuthority.emitter('notifications_updated', Database.notificationSettings.toJSON())
|
||||||
|
|
||||||
this.notificationFinished()
|
this.notificationFinished()
|
||||||
}
|
}
|
||||||
@ -204,7 +205,7 @@ class NotificationManager {
|
|||||||
if (this.notificationQueue.length) {
|
if (this.notificationQueue.length) {
|
||||||
// Send next notification in queue
|
// Send next notification in queue
|
||||||
const nextNotificationEvent = this.notificationQueue.shift()
|
const nextNotificationEvent = this.notificationQueue.shift()
|
||||||
this.triggerNotification(nextNotificationEvent.eventName, nextNotificationEvent.eventData)
|
void this.triggerNotification(nextNotificationEvent.eventName, nextNotificationEvent.eventData)
|
||||||
}
|
}
|
||||||
}, Database.notificationSettings.notificationDelay)
|
}, Database.notificationSettings.notificationDelay)
|
||||||
}
|
}
|
||||||
@ -232,5 +233,22 @@ class NotificationManager {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fireNotificationFromSocket(eventName, eventData) {
|
||||||
|
if (!Database.notificationSettings || !Database.notificationSettings.isUseable) return
|
||||||
|
|
||||||
|
const eventNameModified = eventName.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
|
||||||
|
const eventKey = `on${eventNameModified.charAt(0).toUpperCase()}${eventNameModified.slice(1)}`
|
||||||
|
|
||||||
|
if (!Database.notificationSettings.getHasActiveNotificationsForEvent(eventKey)) {
|
||||||
|
// No logging to prevent console spam
|
||||||
|
//Logger.debug(`[NotificationManager] fireSocketNotification: No active notifications`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.debug(`[NotificationManager] fireNotificationFromSocket: ${eventKey} event fired`)
|
||||||
|
|
||||||
|
void this.triggerNotification(eventKey, eventData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = new NotificationManager()
|
module.exports = new NotificationManager()
|
||||||
|
@ -102,7 +102,7 @@ class Notification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
replaceVariablesInTemplate(templateText, data) {
|
replaceVariablesInTemplate(templateText, data) {
|
||||||
const ptrn = /{{ ?([a-zA-Z]+) ?}}/mg
|
const ptrn = /{{ ?([a-zA-Z.]+) ?}}/mg
|
||||||
|
|
||||||
var match
|
var match
|
||||||
var updatedTemplate = templateText
|
var updatedTemplate = templateText
|
||||||
|
@ -1,4 +1,78 @@
|
|||||||
const { version } = require('../../package.json')
|
const { version } = require('../../package.json')
|
||||||
|
const LibraryItem = require('../models/LibraryItem')
|
||||||
|
|
||||||
|
const libraryItemVariables = [
|
||||||
|
'id',
|
||||||
|
'ino',
|
||||||
|
'oldLibraryItemId',
|
||||||
|
'libraryId',
|
||||||
|
'folderId',
|
||||||
|
'path',
|
||||||
|
'relPath',
|
||||||
|
'isFile',
|
||||||
|
'mtimeMs',
|
||||||
|
'ctimeMs',
|
||||||
|
'birthtimeMs',
|
||||||
|
'addedAt',
|
||||||
|
'updatedAt',
|
||||||
|
'isMissing',
|
||||||
|
'isInvalid',
|
||||||
|
'mediaType',
|
||||||
|
'media.id',
|
||||||
|
'media.metadata.title',
|
||||||
|
'media.metadata.titleIgnorePrefix',
|
||||||
|
'media.metadata.subtitle',
|
||||||
|
'media.metadata.authorName',
|
||||||
|
'media.metadata.authorNameLF',
|
||||||
|
'media.metadata.narratorName',
|
||||||
|
'media.metadata.seriesName',
|
||||||
|
'media.metadata.genres',
|
||||||
|
'media.metadata.publishedYear',
|
||||||
|
'media.metadata.publishedDate',
|
||||||
|
'media.metadata.publisher',
|
||||||
|
'media.metadata.description',
|
||||||
|
'media.metadata.isbn',
|
||||||
|
'media.metadata.asin',
|
||||||
|
'media.metadata.language',
|
||||||
|
'media.metadata.explicit',
|
||||||
|
'media.metadata.abridged',
|
||||||
|
'media.coverPath',
|
||||||
|
'media.tags',
|
||||||
|
'media.numTracks',
|
||||||
|
'media.numAudioFiles',
|
||||||
|
'media.numChapters',
|
||||||
|
'media.duration',
|
||||||
|
'media.size',
|
||||||
|
'media.ebookFormat',
|
||||||
|
'numFiles',
|
||||||
|
'size'
|
||||||
|
]
|
||||||
|
|
||||||
|
const libraryItemTestData = {
|
||||||
|
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||||
|
ino: '9876543',
|
||||||
|
path: '/audiobooks/Frank Herbert/Dune',
|
||||||
|
relPath: 'Frank Herbert/Dune',
|
||||||
|
mediaId: 'abcdef12-3456-7890-abcd-ef1234567890',
|
||||||
|
mediaType: 'book',
|
||||||
|
isFile: true,
|
||||||
|
isMissing: false,
|
||||||
|
isInvalid: false,
|
||||||
|
mtime: new Date('2023-11-15T10:20:30.400Z'),
|
||||||
|
ctime: new Date('2023-11-15T10:20:30.400Z'),
|
||||||
|
birthtime: new Date('2023-11-15T10:20:30.390Z'),
|
||||||
|
size: 987654321,
|
||||||
|
lastScan: new Date('2024-01-10T08:15:00.000Z'),
|
||||||
|
lastScanVersion: '3.2.0',
|
||||||
|
title: 'Dune',
|
||||||
|
titleIgnorePrefix: 'Dune',
|
||||||
|
authorNamesFirstLast: 'Frank Herbert',
|
||||||
|
authorNamesLastFirst: 'Herbert, Frank',
|
||||||
|
createdAt: new Date('2023-11-15T10:21:00.000Z'),
|
||||||
|
updatedAt: new Date('2024-05-15T18:30:36.940Z'),
|
||||||
|
libraryId: 'fedcba98-7654-3210-fedc-ba9876543210',
|
||||||
|
libraryFolderId: '11223344-5566-7788-99aa-bbccddeeff00'
|
||||||
|
}
|
||||||
|
|
||||||
module.exports.notificationData = {
|
module.exports.notificationData = {
|
||||||
events: [
|
events: [
|
||||||
@ -60,6 +134,67 @@ module.exports.notificationData = {
|
|||||||
errorMsg: 'Example error message'
|
errorMsg: 'Example error message'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Sockets - Silently crying because not using typescript
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'onItemAdded',
|
||||||
|
requiresLibrary: true,
|
||||||
|
description: 'Triggered when an item is added',
|
||||||
|
descriptionKey: 'NotificationOnItemAddedDescription',
|
||||||
|
variables: libraryItemVariables,
|
||||||
|
defaults: {
|
||||||
|
title: 'Item Added: {{media.metadata.title}}',
|
||||||
|
body: 'Item {{media.metadata.title}} has been added.\n\nPath: {{path}}\nSize: {{size}} bytes\nLibrary ID: {{libraryId}}'
|
||||||
|
},
|
||||||
|
testData: libraryItemTestData
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'onItemUpdated',
|
||||||
|
requiresLibrary: true,
|
||||||
|
description: 'Triggered when an item is updated',
|
||||||
|
descriptionKey: 'NotificationOnItemUpdatedDescription',
|
||||||
|
variables: libraryItemVariables,
|
||||||
|
defaults: {
|
||||||
|
title: 'Item Updated: {{media.metadata.title}}',
|
||||||
|
body: 'Item {{media.metadata.title}} has been added.\n\nPath: {{path}}\nSize: {{size}} bytes\nLibrary ID: {{libraryId}}'
|
||||||
|
},
|
||||||
|
testData: libraryItemTestData
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'onUserOnline',
|
||||||
|
requiresLibrary: false,
|
||||||
|
description: 'Triggered when a user comes online',
|
||||||
|
descriptionKey: 'NotificationOnUserOnlineDescription',
|
||||||
|
variables: ['id', 'username', 'type', 'session', 'lastSeen', 'createdAt'],
|
||||||
|
defaults: {
|
||||||
|
title: 'User Online: {{username}}',
|
||||||
|
body: 'User {{username}} (ID: {{id}}) is now online.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'onTaskStarted',
|
||||||
|
requiresLibrary: false,
|
||||||
|
description: 'Triggered when a task starts',
|
||||||
|
descriptionKey: 'NotificationOnTaskStartedDescription',
|
||||||
|
variables: ['id', 'action', 'data.libraryId', 'data.libraryName', 'title', 'titleKey', 'titleSubs', 'description', 'descriptionKey', 'descriptionSubs', 'error', 'errorKey', 'errorSubs', 'showSuccess', 'isFailed', 'isFinished', 'startedAt', 'finishedAt'],
|
||||||
|
defaults: {
|
||||||
|
title: 'Task Started: {{title}}',
|
||||||
|
body: 'Task {{title}} has started.\n\nAction: {{action}}\nLibrary ID: {{data.libraryId}}\nLibrary Name: {{data.libraryName}}'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'onTaskFinished',
|
||||||
|
requiresLibrary: false,
|
||||||
|
description: 'Triggered when a task finishes',
|
||||||
|
descriptionKey: 'NotificationOnTaskFinishesDescription',
|
||||||
|
variables: ['id', 'action', 'data.libraryId', 'data.libraryName', 'title', 'titleKey', 'titleSubs', 'description', 'descriptionKey', 'descriptionSubs', 'error', 'errorKey', 'errorSubs', 'showSuccess', 'isFailed', 'isFinished', 'startedAt', 'finishedAt'],
|
||||||
|
defaults: {
|
||||||
|
title: 'Task Started: {{title}}',
|
||||||
|
body: 'Task {{title}} has started.\n\nAction: {{action}}\nLibrary ID: {{data.libraryId}}\nLibrary Name: {{data.libraryName}}'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Test
|
||||||
{
|
{
|
||||||
name: 'onRSSFeedFailed',
|
name: 'onRSSFeedFailed',
|
||||||
requiresLibrary: true,
|
requiresLibrary: true,
|
||||||
|
26
server/utils/objectUtils.js
Normal file
26
server/utils/objectUtils.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
function flattenAny(obj, prefix = '', result = {}) {
|
||||||
|
const entries =
|
||||||
|
obj instanceof Map
|
||||||
|
? obj.entries()
|
||||||
|
: Object.entries(obj);
|
||||||
|
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
const newKey = prefix ? `${prefix}.${key}` : `${key}`;
|
||||||
|
if (
|
||||||
|
value instanceof Map ||
|
||||||
|
(typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
!Array.isArray(value))
|
||||||
|
) {
|
||||||
|
flattenAny(value, newKey, result);
|
||||||
|
} else {
|
||||||
|
result[newKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
flattenAny
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user