diff --git a/client/package-lock.json b/client/package-lock.json index 1e2d52c1..299741bd 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.32.1", + "version": "2.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.32.1", + "version": "2.33.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 0eaffb10..a1503a50 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.32.1", + "version": "2.33.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/client/pages/config/sessions.vue b/client/pages/config/sessions.vue index 86ec7eec..4341ea07 100644 --- a/client/pages/config/sessions.vue +++ b/client/pages/config/sessions.vue @@ -66,7 +66,11 @@

{{ getPlayMethodName(session.playMethod) }}

-

+

+ +

{{ $elapsedPrettyLocalized(session.timeListening) }}

@@ -130,7 +134,11 @@

{{ getPlayMethodName(session.playMethod) }}

-

+

+ +

{{ $elapsedPretty(session.timeListening) }}

@@ -172,7 +180,11 @@

{{ getPlayMethodName(session.playMethod) }}

-

+

+ +

{{ $secondsToTimestamp(session.currentTime) }}

@@ -433,16 +445,16 @@ export default { this.selectedSession = session this.showSessionModal = true }, - getDeviceInfoString(deviceInfo) { - if (!deviceInfo) return '' - var lines = [] + getDeviceInfoLines(deviceInfo) { + if (!deviceInfo) return [] + const lines = [] if (deviceInfo.clientName) lines.push(`${deviceInfo.clientName} ${deviceInfo.clientVersion || ''}`) if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`) if (deviceInfo.browserName) lines.push(deviceInfo.browserName) if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`) if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`) - return lines.join('
') + return lines }, getPlayMethodName(playMethod) { if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play' diff --git a/client/pages/config/users/_id/sessions.vue b/client/pages/config/users/_id/sessions.vue index 060f34be..cb18f5ee 100644 --- a/client/pages/config/users/_id/sessions.vue +++ b/client/pages/config/users/_id/sessions.vue @@ -38,8 +38,12 @@

{{ getPlayMethodName(session.playMethod) }}

-

- +

+ +

+

{{ $elapsedPrettyLocalized(session.timeListening) }}

@@ -193,16 +197,16 @@ export default { this.selectedSession = session this.showSessionModal = true }, - getDeviceInfoString(deviceInfo) { - if (!deviceInfo) return '' - var lines = [] + getDeviceInfoLines(deviceInfo) { + if (!deviceInfo) return [] + const lines = [] if (deviceInfo.clientName) lines.push(`${deviceInfo.clientName} ${deviceInfo.clientVersion || ''}`) if (deviceInfo.osName) lines.push(`${deviceInfo.osName} ${deviceInfo.osVersion}`) if (deviceInfo.browserName) lines.push(deviceInfo.browserName) if (deviceInfo.manufacturer && deviceInfo.model) lines.push(`${deviceInfo.manufacturer} ${deviceInfo.model}`) if (deviceInfo.sdkVersion) lines.push(`SDK Version: ${deviceInfo.sdkVersion}`) - return lines.join('
') + return lines }, getPlayMethodName(playMethod) { if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play' diff --git a/package-lock.json b/package-lock.json index 08707893..e07fba51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.32.1", + "version": "2.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.32.1", + "version": "2.33.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 3ee3fb39..3108b517 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.32.1", + "version": "2.33.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", diff --git a/server/managers/ApiCacheManager.js b/server/managers/ApiCacheManager.js index 2d8eece8..8a67e489 100644 --- a/server/managers/ApiCacheManager.js +++ b/server/managers/ApiCacheManager.js @@ -5,6 +5,9 @@ const Database = require('../Database') class ApiCacheManager { defaultCacheOptions = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: (item) => item.body.length + JSON.stringify(item.headers).length } defaultTtlOptions = { ttl: 30 * 60 * 1000 } + highChurnModels = new Set(['session', 'mediaProgress', 'playbackSession', 'device']) + modelsInvalidatingPersonalized = new Set(['mediaProgress']) + modelsInvalidatingMe = new Set(['session', 'mediaProgress', 'playbackSession', 'device']) constructor(cache = new LRUCache(this.defaultCacheOptions), ttlOptions = this.defaultTtlOptions) { this.cache = cache @@ -16,8 +19,44 @@ class ApiCacheManager { hooks.forEach((hook) => database.sequelize.addHook(hook, (model) => this.clear(model, hook))) } + getModelName(model) { + if (typeof model?.name === 'string') return model.name + if (typeof model?.model?.name === 'string') return model.model.name + if (typeof model?.constructor?.name === 'string' && model.constructor.name !== 'Object') return model.constructor.name + return 'unknown' + } + + clearByUrlPattern(urlPattern) { + let removed = 0 + for (const key of this.cache.keys()) { + try { + const parsed = JSON.parse(key) + if (typeof parsed?.url === 'string' && urlPattern.test(parsed.url)) { + if (this.cache.delete(key)) removed++ + } + } catch { + if (this.cache.delete(key)) removed++ + } + } + return removed + } + + clearUserProgressSlices(modelName, hook) { + const removedPersonalized = this.modelsInvalidatingPersonalized.has(modelName) ? this.clearByUrlPattern(/^\/libraries\/[^/]+\/personalized/) : 0 + const removedMe = this.modelsInvalidatingMe.has(modelName) ? this.clearByUrlPattern(/^\/me(\/|\?|$)/) : 0 + Logger.debug( + `[ApiCacheManager] ${modelName}.${hook}: cleared user-progress cache slices (personalized=${removedPersonalized}, me=${removedMe})` + ) + } + clear(model, hook) { - Logger.debug(`[ApiCacheManager] ${model.constructor.name}.${hook}: Clearing cache`) + const modelName = this.getModelName(model) + if (this.highChurnModels.has(modelName)) { + this.clearUserProgressSlices(modelName, hook) + return + } + + Logger.debug(`[ApiCacheManager] ${modelName}.${hook}: Clearing cache`) this.cache.clear() } diff --git a/server/migrations/v2.33.0-add-discover-query-indexes.js b/server/migrations/v2.33.0-add-discover-query-indexes.js new file mode 100644 index 00000000..ebd92bba --- /dev/null +++ b/server/migrations/v2.33.0-add-discover-query-indexes.js @@ -0,0 +1,74 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface + * @property {import('../Logger')} logger + * + * @typedef MigrationOptions + * @property {MigrationContext} context + */ + +const migrationVersion = '2.33.0' +const migrationName = `${migrationVersion}-add-discover-query-indexes` +const loggerPrefix = `[${migrationVersion} migration]` + +const indexes = [ + { + table: 'mediaProgresses', + name: 'media_progresses_user_item_finished_time', + fields: ['userId', 'mediaItemId', 'isFinished', 'currentTime'] + }, + { + table: 'bookSeries', + name: 'book_series_series_book', + fields: ['seriesId', 'bookId'] + } +] + +async function up({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + for (const index of indexes) { + await addIndexIfMissing(queryInterface, logger, index) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +async function down({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + for (const index of indexes) { + await removeIndexIfExists(queryInterface, logger, index) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +async function addIndexIfMissing(queryInterface, logger, index) { + const existing = await queryInterface.showIndex(index.table) + if (existing.some((i) => i.name === index.name)) { + logger.info(`${loggerPrefix} index ${index.name} already exists on ${index.table}`) + return + } + + logger.info(`${loggerPrefix} adding index ${index.name} on ${index.table}(${index.fields.join(', ')})`) + await queryInterface.addIndex(index.table, { + name: index.name, + fields: index.fields + }) + logger.info(`${loggerPrefix} added index ${index.name}`) +} + +async function removeIndexIfExists(queryInterface, logger, index) { + const existing = await queryInterface.showIndex(index.table) + if (!existing.some((i) => i.name === index.name)) { + logger.info(`${loggerPrefix} index ${index.name} does not exist on ${index.table}`) + return + } + + logger.info(`${loggerPrefix} removing index ${index.name}`) + await queryInterface.removeIndex(index.table, index.name) + logger.info(`${loggerPrefix} removed index ${index.name}`) +} + +module.exports = { up, down } diff --git a/server/models/BookSeries.js b/server/models/BookSeries.js index 31eccb9f..e71f28a8 100644 --- a/server/models/BookSeries.js +++ b/server/models/BookSeries.js @@ -48,6 +48,10 @@ class BookSeries extends Model { { name: 'bookSeries_seriesId', fields: ['seriesId'] + }, + { + name: 'book_series_series_book', + fields: ['seriesId', 'bookId'] } ] } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 16a52161..b33fa585 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -340,6 +340,15 @@ class LibraryItem extends Model { const shelves = [] + const timed = async (loader) => { + const start = Date.now() + const payload = await loader() + return { + payload, + elapsedSeconds: ((Date.now() - start) / 1000).toFixed(2) + } + } + // "Continue Listening" shelf const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false) if (itemsInProgressPayload.items.length) { @@ -371,11 +380,18 @@ class LibraryItem extends Model { } Logger.debug(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) - let start = Date.now() if (library.isBook) { - start = Date.now() + const [continueSeriesResult, mostRecentResult, seriesMostRecentResult, discoverResult, mediaFinishedResult, newestAuthorsResult] = await Promise.all([ + timed(() => libraryFilters.getLibraryItemsContinueSeries(library, user, include, limit)), + timed(() => libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)), + timed(() => libraryFilters.getSeriesMostRecentlyAdded(library, user, include, 5)), + timed(() => libraryFilters.getLibraryItemsToDiscover(library, user, include, limit)), + timed(() => libraryFilters.getMediaFinished(library, user, include, limit)), + timed(() => libraryFilters.getNewestAuthors(library, user, limit)) + ]) + + const continueSeriesPayload = continueSeriesResult.payload // "Continue Series" shelf - const continueSeriesPayload = await libraryFilters.getLibraryItemsContinueSeries(library, user, include, limit) if (continueSeriesPayload.libraryItems.length) { shelves.push({ id: 'continue-series', @@ -386,42 +402,24 @@ class LibraryItem extends Model { total: continueSeriesPayload.count }) } - Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) - } else if (library.isPodcast) { - // "Newest Episodes" shelf - const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit) - if (newestEpisodesPayload.libraryItems.length) { + Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${continueSeriesResult.elapsedSeconds}s`) + + const mostRecentPayload = mostRecentResult.payload + // "Recently Added" shelf + if (mostRecentPayload.libraryItems.length) { shelves.push({ - id: 'newest-episodes', - label: 'Newest Episodes', - labelStringKey: 'LabelNewestEpisodes', - type: 'episode', - entities: newestEpisodesPayload.libraryItems, - total: newestEpisodesPayload.count + id: 'recently-added', + label: 'Recently Added', + labelStringKey: 'LabelRecentlyAdded', + type: library.mediaType, + entities: mostRecentPayload.libraryItems, + total: mostRecentPayload.count }) } - Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`) - } + Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${mostRecentResult.elapsedSeconds}s`) - start = Date.now() - // "Recently Added" shelf - const mostRecentPayload = await libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit) - if (mostRecentPayload.libraryItems.length) { - shelves.push({ - id: 'recently-added', - label: 'Recently Added', - labelStringKey: 'LabelRecentlyAdded', - type: library.mediaType, - entities: mostRecentPayload.libraryItems, - total: mostRecentPayload.count - }) - } - Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`) - - if (library.isBook) { - start = Date.now() + const seriesMostRecentPayload = seriesMostRecentResult.payload // "Recent Series" shelf - const seriesMostRecentPayload = await libraryFilters.getSeriesMostRecentlyAdded(library, user, include, 5) if (seriesMostRecentPayload.series.length) { shelves.push({ id: 'recent-series', @@ -432,11 +430,10 @@ class LibraryItem extends Model { total: seriesMostRecentPayload.count }) } - Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${seriesMostRecentResult.elapsedSeconds}s`) - start = Date.now() + const discoverLibraryItemsPayload = discoverResult.payload // "Discover" shelf - const discoverLibraryItemsPayload = await libraryFilters.getLibraryItemsToDiscover(library, user, include, limit) if (discoverLibraryItemsPayload.libraryItems.length) { shelves.push({ id: 'discover', @@ -447,45 +444,41 @@ class LibraryItem extends Model { total: discoverLibraryItemsPayload.count }) } - Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`) - } + Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${discoverResult.elapsedSeconds}s`) - start = Date.now() - // "Listen Again" shelf - const mediaFinishedPayload = await libraryFilters.getMediaFinished(library, user, include, limit) - if (mediaFinishedPayload.items.length) { - const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks) - const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast') + const mediaFinishedPayload = mediaFinishedResult.payload + // "Listen Again" shelf + if (mediaFinishedPayload.items.length) { + const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks) + const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast') - if (audioItemsInProgress.length) { - shelves.push({ - id: 'listen-again', - label: 'Listen Again', - labelStringKey: 'LabelListenAgain', - type: library.isPodcast ? 'episode' : 'book', - entities: audioItemsInProgress, - total: mediaFinishedPayload.count - }) + if (audioItemsInProgress.length) { + shelves.push({ + id: 'listen-again', + label: 'Listen Again', + labelStringKey: 'LabelListenAgain', + type: library.isPodcast ? 'episode' : 'book', + entities: audioItemsInProgress, + total: mediaFinishedPayload.count + }) + } + + if (ebookOnlyItemsInProgress.length) { + // "Read Again" shelf + shelves.push({ + id: 'read-again', + label: 'Read Again', + labelStringKey: 'LabelReadAgain', + type: 'book', + entities: ebookOnlyItemsInProgress, + total: mediaFinishedPayload.count + }) + } } + Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${mediaFinishedResult.elapsedSeconds}s`) - // "Read Again" shelf - if (ebookOnlyItemsInProgress.length) { - shelves.push({ - id: 'read-again', - label: 'Read Again', - labelStringKey: 'LabelReadAgain', - type: 'book', - entities: ebookOnlyItemsInProgress, - total: mediaFinishedPayload.count - }) - } - } - Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`) - - if (library.isBook) { - start = Date.now() + const newestAuthorsPayload = newestAuthorsResult.payload // "Newest Authors" shelf - const newestAuthorsPayload = await libraryFilters.getNewestAuthors(library, user, limit) if (newestAuthorsPayload.authors.length) { shelves.push({ id: 'newest-authors', @@ -496,7 +489,72 @@ class LibraryItem extends Model { total: newestAuthorsPayload.count }) } - Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${newestAuthorsResult.elapsedSeconds}s`) + } else if (library.isPodcast) { + const [newestEpisodesResult, mostRecentResult, mediaFinishedResult] = await Promise.all([ + timed(() => libraryFilters.getNewestPodcastEpisodes(library, user, limit)), + timed(() => libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)), + timed(() => libraryFilters.getMediaFinished(library, user, include, limit)) + ]) + + const newestEpisodesPayload = newestEpisodesResult.payload + // "Newest Episodes" shelf + if (newestEpisodesPayload.libraryItems.length) { + shelves.push({ + id: 'newest-episodes', + label: 'Newest Episodes', + labelStringKey: 'LabelNewestEpisodes', + type: 'episode', + entities: newestEpisodesPayload.libraryItems, + total: newestEpisodesPayload.count + }) + } + Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${newestEpisodesResult.elapsedSeconds}s`) + + const mostRecentPayload = mostRecentResult.payload + // "Recently Added" shelf + if (mostRecentPayload.libraryItems.length) { + shelves.push({ + id: 'recently-added', + label: 'Recently Added', + labelStringKey: 'LabelRecentlyAdded', + type: library.mediaType, + entities: mostRecentPayload.libraryItems, + total: mostRecentPayload.count + }) + } + Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${mostRecentResult.elapsedSeconds}s`) + + const mediaFinishedPayload = mediaFinishedResult.payload + // "Listen Again" shelf + if (mediaFinishedPayload.items.length) { + const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks) + const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks || li.mediaType === 'podcast') + + if (audioItemsInProgress.length) { + shelves.push({ + id: 'listen-again', + label: 'Listen Again', + labelStringKey: 'LabelListenAgain', + type: 'episode', + entities: audioItemsInProgress, + total: mediaFinishedPayload.count + }) + } + + if (ebookOnlyItemsInProgress.length) { + // "Read Again" shelf + shelves.push({ + id: 'read-again', + label: 'Read Again', + labelStringKey: 'LabelReadAgain', + type: 'book', + entities: ebookOnlyItemsInProgress, + total: mediaFinishedPayload.count + }) + } + } + Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${mediaFinishedResult.elapsedSeconds}s`) } Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index 0ebe2f59..9c0269a9 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -80,6 +80,10 @@ class MediaProgress extends Model { indexes: [ { fields: ['updatedAt'] + }, + { + name: 'media_progresses_user_item_finished_time', + fields: ['userId', 'mediaItemId', 'isFinished', 'currentTime'] } ] } diff --git a/server/objects/DeviceInfo.js b/server/objects/DeviceInfo.js index ceff6c32..22ebfbea 100644 --- a/server/objects/DeviceInfo.js +++ b/server/objects/DeviceInfo.js @@ -1,6 +1,10 @@ -const uuidv4 = require("uuid").v4 +const uuidv4 = require('uuid').v4 +const { stripAllTags } = require('../utils/htmlSanitizer') class DeviceInfo { + /** @type {string[]} Fields to sanitize when loading from stored data */ + static stringFields = ['deviceId', 'clientVersion', 'manufacturer', 'model', 'sdkVersion', 'clientName', 'deviceName'] + constructor(deviceInfo = null) { this.id = null this.userId = null @@ -31,7 +35,7 @@ class DeviceInfo { construct(deviceInfo) { for (const key in deviceInfo) { if (deviceInfo[key] !== undefined && this[key] !== undefined) { - this[key] = deviceInfo[key] + this[key] = DeviceInfo.stringFields.includes(key) ? stripAllTags(deviceInfo[key]) : deviceInfo[key] } } } @@ -63,7 +67,8 @@ class DeviceInfo { } get deviceDescription() { - if (this.model) { // Set from mobile apps + if (this.model) { + // Set from mobile apps if (this.sdkVersion) return `${this.model} SDK ${this.sdkVersion} / v${this.clientVersion}` return `${this.model} / v${this.clientVersion}` } @@ -72,18 +77,7 @@ class DeviceInfo { // When client doesn't send a device id getTempDeviceId() { - const keys = [ - this.userId, - this.browserName, - this.browserVersion, - this.osName, - this.osVersion, - this.clientVersion, - this.manufacturer, - this.model, - this.sdkVersion, - this.ipAddress - ].map(k => k || '') + const keys = [this.userId, this.browserName, this.browserVersion, this.osName, this.osVersion, this.clientVersion, this.manufacturer, this.model, this.sdkVersion, this.ipAddress].map((k) => k || '') return 'temp-' + Buffer.from(keys.join('-'), 'utf-8').toString('base64') } @@ -99,12 +93,12 @@ class DeviceInfo { this.osVersion = ua?.os.version || null this.deviceType = ua?.device.type || null - this.clientVersion = clientDeviceInfo?.clientVersion || serverVersion - this.manufacturer = clientDeviceInfo?.manufacturer || null - this.model = clientDeviceInfo?.model || null - this.sdkVersion = clientDeviceInfo?.sdkVersion || null + this.clientVersion = stripAllTags(clientDeviceInfo?.clientVersion) || serverVersion + this.manufacturer = stripAllTags(clientDeviceInfo?.manufacturer) || null + this.model = stripAllTags(clientDeviceInfo?.model) || null + this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null - this.clientName = clientDeviceInfo?.clientName || null + this.clientName = stripAllTags(clientDeviceInfo?.clientName) || null if (this.sdkVersion) { if (!this.clientName) this.clientName = 'Abs Android' this.deviceName = `${this.manufacturer || 'Unknown'} ${this.model || ''}` @@ -149,4 +143,4 @@ class DeviceInfo { return hasUpdates } } -module.exports = DeviceInfo \ No newline at end of file +module.exports = DeviceInfo diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index a03e17c7..99f5b76a 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -3,6 +3,7 @@ const packageJson = require('../../../package.json') const { BookshelfView } = require('../../utils/constants') const Logger = require('../../Logger') const User = require('../../models/User') +const { sanitize } = require('../../utils/htmlSanitizer') class ServerSettings { constructor(settings) { @@ -126,7 +127,7 @@ class ServerSettings { this.version = settings.version || null this.buildNumber = settings.buildNumber || 0 // Added v2.4.5 - this.authLoginCustomMessage = settings.authLoginCustomMessage || null // Added v2.8.0 + this.authLoginCustomMessage = sanitize(settings.authLoginCustomMessage) || null // Added v2.8.0 this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local'] this.authOpenIDIssuerURL = settings.authOpenIDIssuerURL || null @@ -309,7 +310,7 @@ class ServerSettings { get authFormData() { const clientFormData = { - authLoginCustomMessage: this.authLoginCustomMessage + authLoginCustomMessage: sanitize(this.authLoginCustomMessage) } if (this.authActiveAuthMethods.includes('openid')) { clientFormData.authOpenIDButtonText = this.authOpenIDButtonText @@ -327,6 +328,9 @@ class ServerSettings { update(payload) { let hasUpdates = false for (const key in payload) { + if (key === 'authLoginCustomMessage') { + payload[key] = sanitize(payload[key]) + } if (key === 'sortingPrefixes') { // Sorting prefixes are updated with the /api/sorting-prefixes endpoint continue diff --git a/server/utils/htmlSanitizer.js b/server/utils/htmlSanitizer.js index 4ed30e72..be839b7c 100644 --- a/server/utils/htmlSanitizer.js +++ b/server/utils/htmlSanitizer.js @@ -5,11 +5,10 @@ const { entities } = require('./htmlEntities') * * @param {string} html * @returns {string} - * @throws {Error} if input is not a string */ function sanitize(html) { if (typeof html !== 'string') { - throw new Error('sanitizeHtml: input must be a string') + return '' } const sanitizerOptions = { @@ -27,6 +26,8 @@ function sanitize(html) { module.exports.sanitize = sanitize function stripAllTags(html, shouldDecodeEntities = true) { + if (typeof html !== 'string') return '' + const sanitizerOptions = { allowedTags: [], disallowedTagsMode: 'discard' diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 7ae1dc86..fbe0c4f0 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -888,28 +888,79 @@ module.exports = { }) } - // Step 2: Get books not started and not in a series OR is the first book of a series not started (ordered randomly) - const { rows: books, count } = await Database.bookModel.findAndCountAll({ - where: [ - { - '$mediaProgresses.isFinished$': { - [Sequelize.Op.or]: [null, 0] - }, - '$mediaProgresses.currentTime$': { - [Sequelize.Op.or]: [null, 0] - }, - [Sequelize.Op.or]: [ - Sequelize.where(Sequelize.literal(`(SELECT COUNT(*) FROM bookSeries bs where bs.bookId = book.id)`), 0), - { - id: { - [Sequelize.Op.in]: booksFromSeriesToInclude - } - } - ] + const discoverWhere = [ + { + '$mediaProgresses.isFinished$': { + [Sequelize.Op.or]: [null, 0] }, - ...userPermissionBookWhere.bookWhere - ], + '$mediaProgresses.currentTime$': { + [Sequelize.Op.or]: [null, 0] + }, + [Sequelize.Op.or]: [ + Sequelize.where(Sequelize.literal(`(SELECT COUNT(*) FROM bookSeries bs where bs.bookId = book.id)`), 0), + { + id: { + [Sequelize.Op.in]: booksFromSeriesToInclude + } + } + ] + }, + ...userPermissionBookWhere.bookWhere + ] + + const baseDiscoverInclude = [ + { + model: Database.libraryItemModel, + where: { + libraryId + } + }, + { + model: Database.mediaProgressModel, + where: { + userId: user.id + }, + required: false + } + ] + + // Step 2a: Count with lightweight includes only + const count = await Database.bookModel.count({ + where: discoverWhere, replacements: userPermissionBookWhere.replacements, + include: baseDiscoverInclude, + distinct: true, + col: 'id', + subQuery: false + }) + + // Step 2b: Select random IDs with lightweight includes only + const randomSelection = await Database.bookModel.findAll({ + attributes: ['id'], + where: discoverWhere, + replacements: userPermissionBookWhere.replacements, + include: baseDiscoverInclude, + subQuery: false, + distinct: true, + limit, + order: Database.sequelize.random() + }) + + const selectedIds = randomSelection.map((b) => b.id).filter(Boolean) + if (!selectedIds.length) { + return { + libraryItems: [], + count + } + } + + // Step 2c: Hydrate selected IDs with full metadata for API response + const books = await Database.bookModel.findAll({ + where: { + id: { + [Sequelize.Op.in]: selectedIds + } + }, include: [ { model: Database.libraryItemModel, @@ -918,13 +969,6 @@ module.exports = { }, include: libraryItemIncludes }, - { - model: Database.mediaProgressModel, - where: { - userId: user.id - }, - required: false - }, { model: Database.bookAuthorModel, attributes: ['authorId'], @@ -942,14 +986,14 @@ module.exports = { separate: true } ], - subQuery: false, - distinct: true, - limit, - order: Database.sequelize.random() + subQuery: false }) + const booksById = new Map(books.map((b) => [b.id, b])) + const orderedBooks = selectedIds.map((id) => booksById.get(id)).filter(Boolean) + // Step 3: Map books to library items - const libraryItems = books.map((bookExpanded) => { + const libraryItems = orderedBooks.map((bookExpanded) => { const libraryItem = bookExpanded.libraryItem const book = bookExpanded delete book.libraryItem