From 3bb53d939d8367494d5203b404b254e6b03144a2 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 1 Jul 2026 22:30:21 +0300 Subject: [PATCH] Add all minified fields to expanded library item JSON so socket updates remain a strict superset of shelf payloads --- server/models/Book.js | 19 ++- server/models/LibraryItem.js | 29 ++-- server/models/Podcast.js | 26 +-- .../models/oldJsonSerialization.test.js | 158 ++++++++++++++++++ 4 files changed, 196 insertions(+), 36 deletions(-) create mode 100644 test/server/models/oldJsonSerialization.test.js diff --git a/server/models/Book.js b/server/models/Book.js index d9f2ff132..7e1a2e30c 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -647,6 +647,11 @@ class Book extends Model { } } + /** + * Minified book JSON for list/shelf endpoints. + * `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded + * with the same value semantics. Only additive changes to expanded; never remove or rename keys. + */ toOldJSONMinified() { if (!this.authors) { throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`) @@ -669,6 +674,12 @@ class Book extends Model { } } + /** + * Expanded book JSON for item detail and socket events. + * Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields. + * + * @param {string} libraryItemId + */ toOldJSONExpanded(libraryItemId) { if (!libraryItemId) { throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`) @@ -681,16 +692,12 @@ class Book extends Model { } return { - id: this.id, - libraryItemId: libraryItemId, + ...this.toOldJSONMinified(), + libraryItemId, metadata: this.oldMetadataToJSONExpanded(), - coverPath: this.coverPath, - tags: [...(this.tags || [])], audioFiles: structuredClone(this.audioFiles), chapters: structuredClone(this.chapters), ebookFile: structuredClone(this.ebookFile), - duration: this.duration, - size: this.size, tracks: this.getTracklist(libraryItemId) } } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index b33fa585c..c03667efc 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -1004,6 +1004,11 @@ class LibraryItem extends Model { } } + /** + * Minified library item JSON for list/shelf endpoints. + * `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded + * with the same value semantics. Only additive changes to expanded; never remove or rename keys. + */ toOldJSONMinified() { if (!this.media) { throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`) @@ -1032,30 +1037,18 @@ class LibraryItem extends Model { } } + /** + * Expanded library item JSON for item detail and socket events. + * Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields. + */ toOldJSONExpanded() { return { - id: this.id, - ino: this.ino, - oldLibraryItemId: this.extraData?.oldLibraryItemId || null, - libraryId: this.libraryId, - folderId: this.libraryFolderId, - path: this.path, - relPath: this.relPath, - isFile: this.isFile, - mtimeMs: this.mtime?.valueOf(), - ctimeMs: this.ctime?.valueOf(), - birthtimeMs: this.birthtime?.valueOf(), - addedAt: this.createdAt.valueOf(), - updatedAt: this.updatedAt.valueOf(), + ...this.toOldJSONMinified(), lastScan: this.lastScan?.valueOf(), scanVersion: this.lastScanVersion, - isMissing: !!this.isMissing, - isInvalid: !!this.isInvalid, - mediaType: this.mediaType, media: this.media.toOldJSONExpanded(this.id), // LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database - libraryFiles: this.getLibraryFilesJson(), - size: this.size + libraryFiles: this.getLibraryFilesJson() } } } diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 6dbe1cd19..9c222e48a 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -451,6 +451,11 @@ class Podcast extends Model { } } + /** + * Minified podcast JSON for list/shelf endpoints. + * `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded + * with the same value semantics. Only additive changes to expanded; never remove or rename keys. + */ toOldJSONMinified() { return { id: this.id, @@ -468,6 +473,12 @@ class Podcast extends Model { } } + /** + * Expanded podcast JSON for item detail and socket events. + * Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields. + * + * @param {string} libraryItemId + */ toOldJSONExpanded(libraryItemId) { if (!libraryItemId) { throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`) @@ -477,18 +488,9 @@ class Podcast extends Model { } return { - id: this.id, - libraryItemId: libraryItemId, - metadata: this.oldMetadataToJSONExpanded(), - coverPath: this.coverPath, - tags: [...(this.tags || [])], - episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)), - autoDownloadEpisodes: this.autoDownloadEpisodes, - autoDownloadSchedule: this.autoDownloadSchedule, - lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null, - maxEpisodesToKeep: this.maxEpisodesToKeep, - maxNewEpisodesToDownload: this.maxNewEpisodesToDownload, - size: this.size + ...this.toOldJSONMinified(), + libraryItemId, + episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)) } } } diff --git a/test/server/models/oldJsonSerialization.test.js b/test/server/models/oldJsonSerialization.test.js new file mode 100644 index 000000000..98bab6afa --- /dev/null +++ b/test/server/models/oldJsonSerialization.test.js @@ -0,0 +1,158 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') + +const Database = require('../../../server/Database') + +/** + * Assert that `expanded` contains every key from `minified` with matching values. + * Used to enforce the contract: toOldJSONExpanded() is a strict superset of toOldJSONMinified(). + * + * @param {object} minified + * @param {object} expanded + * @param {string} [path] + */ +function assertExpandedSuperset(minified, expanded, path = '') { + for (const key of Object.keys(minified)) { + const keyPath = path ? `${path}.${key}` : key + expect(expanded, `missing key ${keyPath}`).to.have.property(key) + + const minifiedValue = minified[key] + const expandedValue = expanded[key] + + if (minifiedValue !== null && typeof minifiedValue === 'object' && !Array.isArray(minifiedValue)) { + assertExpandedSuperset(minifiedValue, expandedValue, keyPath) + } else if (Array.isArray(minifiedValue)) { + expect(expandedValue, `expected array at ${keyPath}`).to.be.an('array') + expect(expandedValue).to.deep.equal(minifiedValue) + } else { + expect(expandedValue, `value mismatch at ${keyPath}`).to.equal(minifiedValue) + } + } +} + +describe('old JSON serialization', () => { + let bookLibraryItemId + let podcastLibraryItemId + + 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 bookLibrary = await Database.libraryModel.create({ name: 'Book Library', mediaType: 'book' }) + const bookLibraryFolder = await Database.libraryFolderModel.create({ path: '/books', libraryId: bookLibrary.id }) + + const book = await Database.bookModel.create({ + title: 'Test Book', + audioFiles: [{ index: 1, ino: '1', metadata: { filename: 'track.mp3', ext: '.mp3', path: '/track.mp3', relPath: 'track.mp3', size: 1000 } }], + tags: ['fiction'], + narrators: ['Narrator One'], + genres: ['Fantasy'], + chapters: [{ id: 0, start: 0, end: 100, title: 'Chapter 1' }], + ebookFile: { ino: '2', metadata: { filename: 'book.epub', ext: '.epub', path: '/book.epub', relPath: 'book.epub', size: 500 }, ebookFormat: 'epub' } + }) + const bookLibraryItem = await Database.libraryItemModel.create({ + libraryFiles: [{ ino: '1', metadata: { filename: 'track.mp3', ext: '.mp3', path: '/track.mp3', relPath: 'track.mp3' } }], + mediaId: book.id, + mediaType: 'book', + libraryId: bookLibrary.id, + libraryFolderId: bookLibraryFolder.id + }) + bookLibraryItemId = bookLibraryItem.id + + const author = await Database.authorModel.create({ name: 'Test Author', libraryId: bookLibrary.id }) + await Database.bookAuthorModel.create({ bookId: book.id, authorId: author.id }) + + const series = await Database.seriesModel.create({ name: 'Test Series', libraryId: bookLibrary.id }) + await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id, sequence: '2' }) + + const podcastLibrary = await Database.libraryModel.create({ name: 'Podcast Library', mediaType: 'podcast' }) + const podcastLibraryFolder = await Database.libraryFolderModel.create({ path: '/podcasts', libraryId: podcastLibrary.id }) + + const podcast = await Database.podcastModel.create({ + title: 'Test Podcast', + tags: ['news'], + genres: ['Technology'], + autoDownloadEpisodes: false + }) + const podcastLibraryItem = await Database.libraryItemModel.create({ + libraryFiles: [], + mediaId: podcast.id, + mediaType: 'podcast', + libraryId: podcastLibrary.id, + libraryFolderId: podcastLibraryFolder.id + }) + podcastLibraryItemId = podcastLibraryItem.id + + await Database.podcastEpisodeModel.create({ + podcastId: podcast.id, + title: 'Episode 1', + index: 1, + audioFile: { ino: '3', metadata: { filename: 'ep1.mp3', ext: '.mp3', path: '/ep1.mp3', relPath: 'ep1.mp3' } } + }) + }) + + afterEach(async () => { + await Database.sequelize.sync({ force: true }) + }) + + describe('Book', () => { + it('toOldJSONExpanded is a strict superset of toOldJSONMinified', async () => { + const libraryItem = await Database.libraryItemModel.getExpandedById(bookLibraryItemId) + const minified = libraryItem.media.toOldJSONMinified() + const expanded = libraryItem.media.toOldJSONExpanded(libraryItem.id) + + assertExpandedSuperset(minified, expanded) + + expect(expanded).to.have.property('libraryItemId', libraryItem.id) + expect(expanded).to.have.property('audioFiles') + expect(expanded).to.have.property('chapters') + expect(expanded).to.have.property('tracks') + expect(expanded.numTracks).to.equal(expanded.tracks.length) + expect(expanded.numAudioFiles).to.equal(expanded.audioFiles.length) + expect(expanded.numChapters).to.equal(expanded.chapters.length) + expect(expanded.ebookFormat).to.equal('epub') + }) + }) + + describe('Podcast', () => { + it('toOldJSONExpanded is a strict superset of toOldJSONMinified', async () => { + const libraryItem = await Database.libraryItemModel.getExpandedById(podcastLibraryItemId) + const minified = libraryItem.media.toOldJSONMinified() + const expanded = libraryItem.media.toOldJSONExpanded(libraryItem.id) + + assertExpandedSuperset(minified, expanded) + + expect(expanded).to.have.property('libraryItemId', libraryItem.id) + expect(expanded).to.have.property('episodes') + expect(expanded.numEpisodes).to.equal(expanded.episodes.length) + }) + }) + + describe('LibraryItem', () => { + it('book library item expanded is a strict superset of minified', async () => { + const libraryItem = await Database.libraryItemModel.getExpandedById(bookLibraryItemId) + const minified = libraryItem.toOldJSONMinified() + const expanded = libraryItem.toOldJSONExpanded() + + assertExpandedSuperset(minified, expanded) + + expect(expanded).to.have.property('libraryFiles') + expect(expanded).to.have.property('lastScan') + expect(expanded.numFiles).to.equal(expanded.libraryFiles.length) + expect(expanded.media.numTracks).to.equal(expanded.media.tracks.length) + }) + + it('podcast library item expanded is a strict superset of minified', async () => { + const libraryItem = await Database.libraryItemModel.getExpandedById(podcastLibraryItemId) + const minified = libraryItem.toOldJSONMinified() + const expanded = libraryItem.toOldJSONExpanded() + + assertExpandedSuperset(minified, expanded) + + expect(expanded).to.have.property('libraryFiles') + expect(expanded.media.numEpisodes).to.equal(expanded.media.episodes.length) + }) + }) +})