From 42c679898106a5d521a6024bb013519df6997687 Mon Sep 17 00:00:00 2001 From: Conor Bronsdon <120674402+conorbronsdon@users.noreply.github.com> Date: Thu, 9 Jul 2026 02:59:25 -0700 Subject: [PATCH] Apply minified serialization to books inside collections The GET /api/libraries/:id/collections endpoint captured the minified flag on the response payload but never passed it down to the book serialization, so each book was always returned fully expanded with its complete libraryFiles array and heavy media fields (audioFiles, chapters, tracks). For libraries with multi-file audiobooks this produced responses of tens of megabytes even when minified=1 was requested. Thread the minified flag through getOldCollectionsJsonExpanded and Collection.toOldJSONExpanded so that, when requested, books use the existing library item minified form. Non-minified requests are unchanged. Fixes #5170 --- server/controllers/LibraryController.js | 2 +- server/models/Collection.js | 13 ++- test/server/models/Collection.test.js | 107 ++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 test/server/models/Collection.test.js diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 73b3d5c60..a8ddbf725 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -830,7 +830,7 @@ class LibraryController { } // TODO: Create paginated queries - let collections = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user, req.library.id, include) + let collections = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user, req.library.id, include, payload.minified) payload.total = collections.length diff --git a/server/models/Collection.js b/server/models/Collection.js index d5f36ba9c..7789170dd 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -29,9 +29,10 @@ class Collection extends Model { * @param {import('./User')} user * @param {string} [libraryId] * @param {string[]} [include] + * @param {boolean} [minified=false] * @async */ - static async getOldCollectionsJsonExpanded(user, libraryId, include) { + static async getOldCollectionsJsonExpanded(user, libraryId, include, minified = false) { let collectionWhere = null if (libraryId) { collectionWhere = { @@ -98,7 +99,7 @@ class Collection extends Model { this.books = books - const collectionExpanded = c.toOldJSONExpanded() + const collectionExpanded = c.toOldJSONExpanded(minified) // Map feed if found if (c.feeds?.length) { @@ -272,7 +273,11 @@ class Collection extends Model { } } - toOldJSONExpanded() { + /** + * @param {boolean} [minified=false] When true, books use the minified library item serialization + * (omits libraryFiles and heavy media fields like chapters/tracks/audioFiles) + */ + toOldJSONExpanded(minified = false) { if (!this.books) { throw new Error('Books are required to expand Collection') } @@ -282,7 +287,7 @@ class Collection extends Model { const libraryItem = book.libraryItem delete book.libraryItem libraryItem.media = book - return libraryItem.toOldJSONExpanded() + return minified ? libraryItem.toOldJSONMinified() : libraryItem.toOldJSONExpanded() }) return json diff --git a/test/server/models/Collection.test.js b/test/server/models/Collection.test.js new file mode 100644 index 000000000..962106acd --- /dev/null +++ b/test/server/models/Collection.test.js @@ -0,0 +1,107 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') + +const Database = require('../../../server/Database') + +describe('Collection', () => { + /** @type {string} */ + let libraryId + /** @type {string} */ + let collectionId + + beforeEach(async () => { + global.ServerSettings = {} + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + + const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + libraryId = library.id + const libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id }) + + // Build a book with heavy media: many audio files + chapters + const audioFiles = [] + const chapters = [] + for (let i = 0; i < 30; i++) { + audioFiles.push({ + index: i + 1, + ino: `ino-${i}`, + duration: 600, + exclude: false, + mimeType: 'audio/mpeg', + metadata: { filename: `track-${i}.mp3`, ext: '.mp3', path: `/test/book/track-${i}.mp3`, relPath: `track-${i}.mp3`, size: 12345678 } + }) + chapters.push({ id: i, start: i * 600, end: (i + 1) * 600, title: `Chapter ${i + 1}` }) + } + + const book = await Database.bookModel.create({ title: 'Heavy Book', audioFiles, tags: [], narrators: [], genres: [], chapters }) + + // libraryItem carries a large libraryFiles array (dropped by the minified form) + const libraryFiles = [] + for (let i = 0; i < 30; i++) { + libraryFiles.push({ + ino: `lf-${i}`, + metadata: { filename: `track-${i}.mp3`, ext: '.mp3', path: `/test/book/track-${i}.mp3`, relPath: `track-${i}.mp3`, size: 12345678, mtimeMs: 0, ctimeMs: 0, birthtimeMs: 0 } + }) + } + await Database.libraryItemModel.create({ libraryFiles, mediaId: book.id, mediaType: 'book', libraryId: library.id, libraryFolderId: libraryFolder.id }) + + const collection = await Database.collectionModel.create({ name: 'Test Collection', libraryId: library.id }) + collectionId = collection.id + await Database.collectionBookModel.create({ collectionId: collection.id, bookId: book.id, order: 1 }) + }) + + afterEach(async () => { + await Database.sequelize.close() + }) + + describe('getOldCollectionsJsonExpanded', () => { + it('returns fully expanded book objects when minified is not requested', async () => { + const collections = await Database.collectionModel.getOldCollectionsJsonExpanded(null, libraryId, [], false) + expect(collections).to.have.lengthOf(1) + const book = collections[0].books[0] + + // Library item keeps the full libraryFiles array + expect(book).to.have.property('libraryFiles') + expect(book.libraryFiles).to.be.an('array').with.lengthOf(30) + + // Media keeps the heavy arrays + expect(book.media).to.have.property('audioFiles').that.is.an('array').with.lengthOf(30) + expect(book.media).to.have.property('chapters').that.is.an('array').with.lengthOf(30) + expect(book.media).to.have.property('tracks').that.is.an('array').with.lengthOf(30) + }) + + it('returns minified book objects that omit libraryFiles and heavy media when minified is requested', async () => { + const collections = await Database.collectionModel.getOldCollectionsJsonExpanded(null, libraryId, [], true) + expect(collections).to.have.lengthOf(1) + const book = collections[0].books[0] + + // Library item drops libraryFiles in favor of a numFiles count + expect(book).to.not.have.property('libraryFiles') + expect(book).to.have.property('numFiles', 30) + + // Media drops the heavy arrays in favor of counts + expect(book.media).to.not.have.property('audioFiles') + expect(book.media).to.not.have.property('chapters') + expect(book.media).to.not.have.property('tracks') + expect(book.media).to.have.property('numAudioFiles', 30) + expect(book.media).to.have.property('numChapters', 30) + expect(book.media).to.have.property('numTracks', 30) + + // Essential display fields survive minification + expect(book.media).to.have.property('coverPath') + expect(book.media).to.have.property('duration') + expect(book.media.metadata).to.have.property('title', 'Heavy Book') + }) + + it('produces a materially smaller payload when minified', async () => { + const expanded = await Database.collectionModel.getOldCollectionsJsonExpanded(null, libraryId, [], false) + const minified = await Database.collectionModel.getOldCollectionsJsonExpanded(null, libraryId, [], true) + + const expandedSize = JSON.stringify(expanded).length + const minifiedSize = JSON.stringify(minified).length + + expect(minifiedSize).to.be.lessThan(expandedSize * 0.5) + }) + }) +})