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) }}
-
+
+
+ {{ line }}
+
+
|
{{ $elapsedPrettyLocalized(session.timeListening) }}
@@ -130,7 +134,11 @@
{{ getPlayMethodName(session.playMethod) }}
|
-
+
+
+ {{ line }}
+
+
|
{{ $elapsedPretty(session.timeListening) }}
@@ -172,7 +180,11 @@
{{ getPlayMethodName(session.playMethod) }}
|
-
+
+
+ {{ line }}
+
+
|
{{ $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) }}
|
-
- |
+
+
+
{{ line }}
+
+
+
{{ $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