From dc52a6b2d336a30029695fc6bdebcaf4cf86d63b Mon Sep 17 00:00:00 2001 From: Jens Bickel <235654990+JensBickel@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:19:20 +0100 Subject: [PATCH] feat(db): add libraryItems.isPlaceholder migration and model serialization support --- server/migrations/changelog.md | 1 + ...2.32.2-add-library-items-is-placeholder.js | 85 +++++++++++++++++++ server/models/LibraryItem.js | 6 ++ ...2-add-library-items-is-placeholder.test.js | 82 ++++++++++++++++++ .../models/LibraryItem.placeholder.test.js | 1 + 5 files changed, 175 insertions(+) create mode 100644 server/migrations/v2.32.2-add-library-items-is-placeholder.js create mode 100644 test/server/migrations/v2.32.2-add-library-items-is-placeholder.test.js diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 0fcbe6754..41d56b3fb 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -16,3 +16,4 @@ Please add a record of every database migration that you create to this file. Th | v2.19.1 | v2.19.1-copy-title-to-library-items | Copies title and titleIgnorePrefix to the libraryItems table, creates update triggers and indices | | v2.19.4 | v2.19.4-improve-podcast-queries | Adds numEpisodes to podcasts, adds podcastId to mediaProgresses, copies podcast title to libraryItems | | v2.20.0 | v2.20.0-improve-author-sort-queries | Adds AuthorNames(FirstLast\|LastFirst) to libraryItems to improve author sort queries | +| v2.32.2 | v2.32.2-add-library-items-is-placeholder | Adds isPlaceholder to libraryItems with a default false backfill | diff --git a/server/migrations/v2.32.2-add-library-items-is-placeholder.js b/server/migrations/v2.32.2-add-library-items-is-placeholder.js new file mode 100644 index 000000000..2957c84d6 --- /dev/null +++ b/server/migrations/v2.32.2-add-library-items-is-placeholder.js @@ -0,0 +1,85 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.32.2' +const migrationName = `${migrationVersion}-add-library-items-is-placeholder` +const loggerPrefix = `[${migrationVersion} migration]` + +const tableName = 'libraryItems' +const columnName = 'isPlaceholder' + +// Note: no index added; evaluate query patterns before adding. + +/** + * This migration script adds the isPlaceholder column to the libraryItems table and backfills nulls. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists(tableName)) { + const tableDescription = await queryInterface.describeTable(tableName) + if (!tableDescription[columnName]) { + logger.info(`${loggerPrefix} Adding ${columnName} column to ${tableName} table`) + await queryInterface.addColumn(tableName, columnName, { + type: queryInterface.sequelize.Sequelize.DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false + }) + logger.info(`${loggerPrefix} Added ${columnName} column to ${tableName} table`) + } else { + logger.info(`${loggerPrefix} ${columnName} column already exists in ${tableName} table`) + } + + await backfillNullPlaceholders({ queryInterface, logger }) + } else { + logger.info(`${loggerPrefix} ${tableName} table does not exist`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This migration script removes the isPlaceholder column from the libraryItems table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists(tableName)) { + const tableDescription = await queryInterface.describeTable(tableName) + if (tableDescription[columnName]) { + logger.info(`${loggerPrefix} Removing ${columnName} column from ${tableName} table`) + await queryInterface.removeColumn(tableName, columnName) + logger.info(`${loggerPrefix} Removed ${columnName} column from ${tableName} table`) + } else { + logger.info(`${loggerPrefix} ${columnName} column does not exist in ${tableName} table`) + } + } else { + logger.info(`${loggerPrefix} ${tableName} table does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +async function backfillNullPlaceholders({ queryInterface, logger }) { + logger.info(`${loggerPrefix} Backfilling NULL ${columnName} values in ${tableName}`) + await queryInterface.sequelize.query(` + UPDATE ${tableName} + SET ${columnName} = 0 + WHERE ${columnName} IS NULL; + `) + logger.info(`${loggerPrefix} Backfilled NULL ${columnName} values in ${tableName}`) +} + +module.exports = { up, down } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 16a521615..146529d7d 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -46,6 +46,8 @@ class LibraryItem extends Model { this.isMissing /** @type {boolean} */ this.isInvalid + /** @type {boolean} */ + this.isPlaceholder /** @type {Date} */ this.mtime /** @type {Date} */ @@ -677,6 +679,7 @@ class LibraryItem extends Model { isFile: DataTypes.BOOLEAN, isMissing: DataTypes.BOOLEAN, isInvalid: DataTypes.BOOLEAN, + isPlaceholder: DataTypes.BOOLEAN, mtime: DataTypes.DATE(6), ctime: DataTypes.DATE(6), birthtime: DataTypes.DATE(6), @@ -939,6 +942,7 @@ class LibraryItem extends Model { scanVersion: this.lastScanVersion, isMissing: !!this.isMissing, isInvalid: !!this.isInvalid, + isPlaceholder: !!this.isPlaceholder, mediaType: this.mediaType, media: this.media.toOldJSON(this.id), // LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database @@ -967,6 +971,7 @@ class LibraryItem extends Model { updatedAt: this.updatedAt.valueOf(), isMissing: !!this.isMissing, isInvalid: !!this.isInvalid, + isPlaceholder: !!this.isPlaceholder, mediaType: this.mediaType, media: this.media.toOldJSONMinified(), numFiles: this.libraryFiles.length, @@ -993,6 +998,7 @@ class LibraryItem extends Model { scanVersion: this.lastScanVersion, isMissing: !!this.isMissing, isInvalid: !!this.isInvalid, + isPlaceholder: !!this.isPlaceholder, 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 diff --git a/test/server/migrations/v2.32.2-add-library-items-is-placeholder.test.js b/test/server/migrations/v2.32.2-add-library-items-is-placeholder.test.js new file mode 100644 index 000000000..daaa2e703 --- /dev/null +++ b/test/server/migrations/v2.32.2-add-library-items-is-placeholder.test.js @@ -0,0 +1,82 @@ +const chai = require('chai') +const sinon = require('sinon') +const { expect } = chai + +const { DataTypes, Sequelize } = require('sequelize') +const Logger = require('../../../server/Logger') + +const { up, down } = require('../../../server/migrations/v2.32.2-add-library-items-is-placeholder') + +describe('Migration v2.32.2-add-library-items-is-placeholder', () => { + let sequelize + let queryInterface + + beforeEach(async () => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + sinon.stub(Logger, 'info') + }) + + afterEach(() => { + sinon.restore() + }) + + const baseLibraryItemsSchema = { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + libraryId: { type: DataTypes.INTEGER, allowNull: false }, + mediaType: { type: DataTypes.STRING, allowNull: false }, + mediaId: { type: DataTypes.INTEGER, allowNull: false }, + createdAt: { type: DataTypes.DATE, allowNull: false } + } + + describe('up', () => { + it('should add isPlaceholder with default false and backfill existing rows', async () => { + await queryInterface.createTable('libraryItems', baseLibraryItemsSchema) + + await queryInterface.bulkInsert('libraryItems', [ + { id: 1, libraryId: 1, mediaType: 'book', mediaId: 10, createdAt: '2025-01-01 00:00:00.000 +00:00' }, + { id: 2, libraryId: 2, mediaType: 'book', mediaId: 11, createdAt: '2025-01-02 00:00:00.000 +00:00' } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems ORDER BY id') + expect(libraryItems).to.deep.equal([ + { id: 1, libraryId: 1, mediaType: 'book', mediaId: 10, createdAt: '2025-01-01 00:00:00.000 +00:00', isPlaceholder: 0 }, + { id: 2, libraryId: 2, mediaType: 'book', mediaId: 11, createdAt: '2025-01-02 00:00:00.000 +00:00', isPlaceholder: 0 } + ]) + }) + + it('should backfill null values when the column already exists', async () => { + await queryInterface.createTable('libraryItems', { + ...baseLibraryItemsSchema, + isPlaceholder: { type: DataTypes.BOOLEAN, allowNull: true } + }) + + await queryInterface.bulkInsert('libraryItems', [ + { id: 1, libraryId: 1, mediaType: 'book', mediaId: 12, createdAt: '2025-01-03 00:00:00.000 +00:00', isPlaceholder: null }, + { id: 2, libraryId: 2, mediaType: 'book', mediaId: 13, createdAt: '2025-01-04 00:00:00.000 +00:00', isPlaceholder: 1 } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems ORDER BY id') + expect(libraryItems).to.deep.equal([ + { id: 1, libraryId: 1, mediaType: 'book', mediaId: 12, createdAt: '2025-01-03 00:00:00.000 +00:00', isPlaceholder: 0 }, + { id: 2, libraryId: 2, mediaType: 'book', mediaId: 13, createdAt: '2025-01-04 00:00:00.000 +00:00', isPlaceholder: 1 } + ]) + }) + }) + + describe('down', () => { + it('should remove isPlaceholder from libraryItems', async () => { + await queryInterface.createTable('libraryItems', baseLibraryItemsSchema) + await up({ context: { queryInterface, logger: Logger } }) + + await down({ context: { queryInterface, logger: Logger } }) + + const tableDescription = await queryInterface.describeTable('libraryItems') + expect(tableDescription.isPlaceholder).to.equal(undefined) + }) + }) +}) diff --git a/test/server/models/LibraryItem.placeholder.test.js b/test/server/models/LibraryItem.placeholder.test.js index 20a6377a5..3a459d8bd 100644 --- a/test/server/models/LibraryItem.placeholder.test.js +++ b/test/server/models/LibraryItem.placeholder.test.js @@ -7,6 +7,7 @@ describe('LibraryItem placeholder serialization', () => { beforeEach(async () => { Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + global.ServerSettings = { sortingPrefixes: [] } await Database.buildModels() })