diff --git a/.env.example b/.env.example index 5af4be6..95fbc45 100644 --- a/.env.example +++ b/.env.example @@ -94,6 +94,9 @@ COLLABORA_PASSWORD= # PIHOLE_PASSWORD= +# Hardcover API Key required for Hardcover as a Metadata Provider, get one here: https://docs.hardcover.app/api/getting-started/ +HARDCOVER_TOKEN= + # # Jitsi settings (comms.scarif.space) # Image: jitsi/web:latest diff --git a/audiobookshelf/.gitkeep b/audiobookshelf/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/audiobookshelf/absdatabase.sqlite b/audiobookshelf/absdatabase.sqlite new file mode 100644 index 0000000..6689b21 Binary files /dev/null and b/audiobookshelf/absdatabase.sqlite differ diff --git a/audiobookshelf/migrations/v2.15.0-series-column-unique.js b/audiobookshelf/migrations/v2.15.0-series-column-unique.js new file mode 100644 index 0000000..7f8526f --- /dev/null +++ b/audiobookshelf/migrations/v2.15.0-series-column-unique.js @@ -0,0 +1,210 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script cleans any duplicate series in the `Series` table and + * adds a unique index on the `name` and `libraryId` columns. + * + * @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 } }) { + // Upwards migration script + logger.info('[2.15.0 migration] UPGRADE BEGIN: 2.15.0-series-column-unique ') + + // Run reindex nocase to fix potential corruption issues due to the bad sqlite extension introduced in v2.12.0 + logger.info('[2.15.0 migration] Reindexing NOCASE indices to fix potential hidden corruption issues') + await queryInterface.sequelize.query('REINDEX NOCASE;') + + // Check if the unique index already exists + const seriesIndexes = await queryInterface.showIndex('Series') + if (seriesIndexes.some((index) => index.name === 'unique_series_name_per_library')) { + logger.info('[2.15.0 migration] Unique index on Series.name and Series.libraryId already exists') + logger.info('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique ') + return + } + + // The steps taken to deduplicate the series are as follows: + // 1. Find all duplicate series in the `Series` table. + // 2. Iterate over the duplicate series and find all book IDs that are associated with the duplicate series in `bookSeries` table. + // 2.a For each book ID, check if the ID occurs multiple times for the duplicate series. + // 2.b If so, keep only one of the rows that has this bookId and seriesId. + // 3. Update `bookSeries` table to point to the most recent series. + // 4. Delete the older series. + + // Use the queryInterface to get the series table and find duplicates in the `name` and `libraryId` column + const [duplicates] = await queryInterface.sequelize.query(` + SELECT name, libraryId + FROM Series + GROUP BY name, libraryId + HAVING COUNT(name) > 1 + `) + + // Print out how many duplicates were found + logger.info(`[2.15.0 migration] Found ${duplicates.length} duplicate series`) + + // Iterate over each duplicate series + for (const duplicate of duplicates) { + // Report the series name that is being deleted + logger.info(`[2.15.0 migration] Deduplicating series "${duplicate.name}" in library ${duplicate.libraryId}`) + + // Determine any duplicate book IDs in the `bookSeries` table for the same series + const [duplicateBookIds] = await queryInterface.sequelize.query( + ` + SELECT bookId + FROM BookSeries + WHERE seriesId IN ( + SELECT id + FROM Series + WHERE name = :name AND libraryId = :libraryId + ) + GROUP BY bookId + HAVING COUNT(bookId) > 1 + `, + { + replacements: { + name: duplicate.name, + libraryId: duplicate.libraryId + } + } + ) + + // Iterate over the duplicate book IDs if there is at least one and only keep the first row that has this bookId and seriesId + for (const { bookId } of duplicateBookIds) { + logger.info(`[2.15.0 migration] Deduplicating bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) + // Get all rows of `BookSeries` table that have the same `bookId` and `seriesId`. Sort by `sequence` with nulls sorted last + const [duplicateBookSeries] = await queryInterface.sequelize.query( + ` + SELECT id + FROM BookSeries + WHERE bookId = :bookId + AND seriesId IN ( + SELECT id + FROM Series + WHERE name = :name AND libraryId = :libraryId + ) + ORDER BY sequence NULLS LAST + `, + { + replacements: { + bookId, + name: duplicate.name, + libraryId: duplicate.libraryId + } + } + ) + + // remove the first element from the array + duplicateBookSeries.shift() + + // Delete the remaining duplicate rows + if (duplicateBookSeries.length > 0) { + const [deletedBookSeries] = await queryInterface.sequelize.query( + ` + DELETE FROM BookSeries + WHERE id IN (:ids) + `, + { + replacements: { + ids: duplicateBookSeries.map((row) => row.id) + } + } + ) + } + logger.info(`[2.15.0 migration] Finished cleanup of bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) + } + + // Get all the most recent series which matches the `name` and `libraryId` + const [mostRecentSeries] = await queryInterface.sequelize.query( + ` + SELECT id + FROM Series + WHERE name = :name AND libraryId = :libraryId + ORDER BY updatedAt DESC + LIMIT 1 + `, + { + replacements: { + name: duplicate.name, + libraryId: duplicate.libraryId + }, + type: queryInterface.sequelize.QueryTypes.SELECT + } + ) + + if (mostRecentSeries) { + // Update all BookSeries records for this series to point to the most recent series + const [seriesUpdated] = await queryInterface.sequelize.query( + ` + UPDATE BookSeries + SET seriesId = :mostRecentSeriesId + WHERE seriesId IN ( + SELECT id + FROM Series + WHERE name = :name AND libraryId = :libraryId + AND id != :mostRecentSeriesId + ) + `, + { + replacements: { + name: duplicate.name, + libraryId: duplicate.libraryId, + mostRecentSeriesId: mostRecentSeries.id + } + } + ) + + // Delete the older series + const seriesDeleted = await queryInterface.sequelize.query( + ` + DELETE FROM Series + WHERE name = :name AND libraryId = :libraryId + AND id != :mostRecentSeriesId + `, + { + replacements: { + name: duplicate.name, + libraryId: duplicate.libraryId, + mostRecentSeriesId: mostRecentSeries.id + } + } + ) + } + } + + logger.info(`[2.15.0 migration] Deduplication complete`) + + // Create a unique index based on the name and library ID for the `Series` table + await queryInterface.addIndex('Series', ['name', 'libraryId'], { + unique: true, + name: 'unique_series_name_per_library' + }) + logger.info('[2.15.0 migration] Added unique index on Series.name and Series.libraryId') + + logger.info('[2.15.0 migration] UPGRADE END: 2.15.0-series-column-unique ') +} + +/** + * This removes the unique index on the `Series` 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 } }) { + // Downward migration script + logger.info('[2.15.0 migration] DOWNGRADE BEGIN: 2.15.0-series-column-unique ') + + // Remove the unique index + await queryInterface.removeIndex('Series', 'unique_series_name_per_library') + logger.info('[2.15.0 migration] Removed unique index on Series.name and Series.libraryId') + + logger.info('[2.15.0 migration] DOWNGRADE END: 2.15.0-series-column-unique ') +} + +module.exports = { up, down } diff --git a/audiobookshelf/migrations/v2.15.1-reindex-nocase.js b/audiobookshelf/migrations/v2.15.1-reindex-nocase.js new file mode 100644 index 0000000..2ec9487 --- /dev/null +++ b/audiobookshelf/migrations/v2.15.1-reindex-nocase.js @@ -0,0 +1,43 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script fixes old database corruptions due to the a bad sqlite extension introduced in v2.12.0. + * + * @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 } }) { + // Upwards migration script + logger.info('[2.15.1 migration] UPGRADE BEGIN: 2.15.1-reindex-nocase ') + + // Run reindex nocase to fix potential corruption issues due to the bad sqlite extension introduced in v2.12.0 + logger.info('[2.15.1 migration] Reindexing NOCASE indices to fix potential hidden corruption issues') + await queryInterface.sequelize.query('REINDEX NOCASE;') + + logger.info('[2.15.1 migration] UPGRADE END: 2.15.1-reindex-nocase ') +} + +/** + * This downward migration script is a no-op. + * + * @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 } }) { + // Downward migration script + logger.info('[2.15.1 migration] DOWNGRADE BEGIN: 2.15.1-reindex-nocase ') + + // This migration is a no-op + logger.info('[2.15.1 migration] No action required for downgrade') + + logger.info('[2.15.1 migration] DOWNGRADE END: 2.15.1-reindex-nocase ') +} + +module.exports = { up, down } diff --git a/audiobookshelf/migrations/v2.15.2-index-creation.js b/audiobookshelf/migrations/v2.15.2-index-creation.js new file mode 100644 index 0000000..f1302dd --- /dev/null +++ b/audiobookshelf/migrations/v2.15.2-index-creation.js @@ -0,0 +1,93 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script adds indexes to speed up queries on the `BookAuthor`, `BookSeries`, and `podcastEpisodes` tables. + * + * @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 } }) { + // Upwards migration script + logger.info('[2.15.2 migration] UPGRADE BEGIN: 2.15.2-index-creation') + + // Create index for bookAuthors + logger.info('[2.15.2 migration] Creating index for bookAuthors') + const bookAuthorsIndexes = await queryInterface.showIndex('bookAuthors') + if (!bookAuthorsIndexes.some((index) => index.name === 'bookAuthor_authorId')) { + await queryInterface.addIndex('bookAuthors', ['authorId'], { + name: 'bookAuthor_authorId' + }) + } else { + logger.info('[2.15.2 migration] Index bookAuthor_authorId already exists') + } + + // Create index for bookSeries + logger.info('[2.15.2 migration] Creating index for bookSeries') + const bookSeriesIndexes = await queryInterface.showIndex('bookSeries') + if (!bookSeriesIndexes.some((index) => index.name === 'bookSeries_seriesId')) { + await queryInterface.addIndex('bookSeries', ['seriesId'], { + name: 'bookSeries_seriesId' + }) + } else { + logger.info('[2.15.2 migration] Index bookSeries_seriesId already exists') + } + + // Delete existing podcastEpisode index + logger.info('[2.15.2 migration] Deleting existing podcastEpisode index') + await queryInterface.removeIndex('podcastEpisodes', 'podcast_episodes_created_at') + + // Create index for podcastEpisode and createdAt + logger.info('[2.15.2 migration] Creating index for podcastEpisode and createdAt') + const podcastEpisodesIndexes = await queryInterface.showIndex('podcastEpisodes') + if (!podcastEpisodesIndexes.some((index) => index.name === 'podcastEpisode_createdAt_podcastId')) { + await queryInterface.addIndex('podcastEpisodes', ['createdAt', 'podcastId'], { + name: 'podcastEpisode_createdAt_podcastId' + }) + } else { + logger.info('[2.15.2 migration] Index podcastEpisode_createdAt_podcastId already exists') + } + + // Completed migration + logger.info('[2.15.2 migration] UPGRADE END: 2.15.2-index-creation') +} + +/** + * This downward migration script removes the newly created indexes and re-adds the old index on the `podcastEpisodes` 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 } }) { + // Downward migration script + logger.info('[2.15.2 migration] DOWNGRADE BEGIN: 2.15.2-index-creation') + + // Remove index for bookAuthors + logger.info('[2.15.2 migration] Removing index for bookAuthors') + await queryInterface.removeIndex('bookAuthors', 'bookAuthor_authorId') + + // Remove index for bookSeries + logger.info('[2.15.2 migration] Removing index for bookSeries') + await queryInterface.removeIndex('bookSeries', 'bookSeries_seriesId') + + // Delete existing podcastEpisode index + logger.info('[2.15.2 migration] Deleting existing podcastEpisode index') + await queryInterface.removeIndex('podcastEpisodes', 'podcastEpisode_createdAt_podcastId') + + // Create index for podcastEpisode and createdAt + logger.info('[2.15.2 migration] Creating original index for podcastEpisode createdAt') + await queryInterface.addIndex('podcastEpisodes', ['createdAt'], { + name: 'podcast_episodes_created_at' + }) + + // Finished migration + logger.info('[2.15.2 migration] DOWNGRADE END: 2.15.2-index-creation') +} + +module.exports = { up, down } diff --git a/audiobookshelf/migrations/v2.17.0-uuid-replacement.js b/audiobookshelf/migrations/v2.17.0-uuid-replacement.js new file mode 100644 index 0000000..4316cd7 --- /dev/null +++ b/audiobookshelf/migrations/v2.17.0-uuid-replacement.js @@ -0,0 +1,102 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script changes table columns with data type UUIDv4 to UUID to match associated models. + * + * @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 } }) { + // Upwards migration script + logger.info('[2.17.0 migration] UPGRADE BEGIN: 2.17.0-uuid-replacement') + + logger.info('[2.17.0 migration] Changing libraryItems.mediaId column to UUID') + await queryInterface.changeColumn('libraryItems', 'mediaId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing feeds.entityId column to UUID') + await queryInterface.changeColumn('feeds', 'entityId', { + type: 'UUID' + }) + + if (await queryInterface.tableExists('mediaItemShares')) { + logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID') + await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', { + type: 'UUID' + }) + } else { + logger.info('[2.17.0 migration] mediaItemShares table does not exist, skipping column change') + } + + logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUID') + await queryInterface.changeColumn('playbackSessions', 'mediaItemId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing playlistMediaItems.mediaItemId column to UUID') + await queryInterface.changeColumn('playlistMediaItems', 'mediaItemId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing mediaProgresses.mediaItemId column to UUID') + await queryInterface.changeColumn('mediaProgresses', 'mediaItemId', { + type: 'UUID' + }) + + // Completed migration + logger.info('[2.17.0 migration] UPGRADE END: 2.17.0-uuid-replacement') +} + +/** + * This downward migration script changes table columns data type back to UUIDv4. + * + * @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 } }) { + // Downward migration script + logger.info('[2.17.0 migration] DOWNGRADE BEGIN: 2.17.0-uuid-replacement') + + logger.info('[2.17.0 migration] Changing libraryItems.mediaId column to UUIDV4') + await queryInterface.changeColumn('libraryItems', 'mediaId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing feeds.entityId column to UUIDV4') + await queryInterface.changeColumn('feeds', 'entityId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('playbackSessions', 'mediaItemId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing playlistMediaItems.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('playlistMediaItems', 'mediaItemId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing mediaProgresses.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('mediaProgresses', 'mediaItemId', { + type: 'UUIDV4' + }) + + // Completed migration + logger.info('[2.17.0 migration] DOWNGRADE END: 2.17.0-uuid-replacement') +} + +module.exports = { up, down } diff --git a/audiobookshelf/migrations/v2.17.3-fk-constraints.js b/audiobookshelf/migrations/v2.17.3-fk-constraints.js new file mode 100644 index 0000000..5f8a5c9 --- /dev/null +++ b/audiobookshelf/migrations/v2.17.3-fk-constraints.js @@ -0,0 +1,259 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script changes foreign key constraints for the + * libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, and mediaProgresses tables. + * + * @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 } }) { + // Upwards migration script + logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints') + + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) + + // Disable foreign key constraints for the next sequence of operations + await execQuery(`PRAGMA foreign_keys = OFF;`) + + try { + await execQuery(`BEGIN TRANSACTION;`) + + logger.info('[2.17.3 migration] Updating libraryItems constraints') + const libraryItemsConstraints = [ + { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, + { field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } + ] + if (await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints)) { + logger.info('[2.17.3 migration] Finished updating libraryItems constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for libraryItems constraints') + } + + logger.info('[2.17.3 migration] Updating feeds constraints') + const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }] + if (await changeConstraints(queryInterface, 'feeds', feedsConstraints)) { + logger.info('[2.17.3 migration] Finished updating feeds constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for feeds constraints') + } + + if (await queryInterface.tableExists('mediaItemShares')) { + logger.info('[2.17.3 migration] Updating mediaItemShares constraints') + const mediaItemSharesConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }] + if (await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints)) { + logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for mediaItemShares constraints') + } + } else { + logger.info('[2.17.3 migration] mediaItemShares table does not exist, skipping column change') + } + + logger.info('[2.17.3 migration] Updating playbackSessions constraints') + const playbackSessionsConstraints = [ + { field: 'deviceId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, + { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, + { field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } + ] + if (await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints)) { + logger.info('[2.17.3 migration] Finished updating playbackSessions constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for playbackSessions constraints') + } + + logger.info('[2.17.3 migration] Updating playlistMediaItems constraints') + const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }] + if (await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints)) { + logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for playlistMediaItems constraints') + } + + logger.info('[2.17.3 migration] Updating mediaProgresses constraints') + const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }] + if (await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints)) { + logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints') + } else { + logger.info('[2.17.3 migration] No changes needed for mediaProgresses constraints') + } + + await execQuery(`COMMIT;`) + } catch (error) { + logger.error(`[2.17.3 migration] Migration failed - rolling back. Error:`, error) + await execQuery(`ROLLBACK;`) + } + + await execQuery(`PRAGMA foreign_keys = ON;`) + + // Completed migration + logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints') +} + +/** + * This downward migration script is a no-op. + * + * @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 } }) { + // Downward migration script + logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-fk-constraints') + + // This migration is a no-op + logger.info('[2.17.3 migration] No action required for downgrade') + + // Completed migration + logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-fk-constraints') +} + +/** + * @typedef ConstraintUpdateObj + * @property {string} field - The field to update + * @property {string} onDelete - The onDelete constraint + * @property {string} onUpdate - The onUpdate constraint + */ + +/** + * @typedef SequelizeFKObj + * @property {{ model: string, key: string }} references + * @property {string} onDelete + * @property {string} onUpdate + */ + +/** + * @param {Object} fk - The foreign key object from PRAGMA foreign_key_list + * @returns {SequelizeFKObj} - The foreign key object formatted for Sequelize + */ +const formatFKsPragmaToSequelizeFK = (fk) => { + return { + references: { + model: fk.table, + key: fk.to + }, + onDelete: fk['on_delete'], + onUpdate: fk['on_update'] + } +} + +/** + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {string} tableName + * @param {ConstraintUpdateObj[]} constraints + * @returns {Promise|null>} + */ +async function getUpdatedForeignKeys(queryInterface, tableName, constraints) { + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) + const quotedTableName = queryInterface.quoteIdentifier(tableName) + + const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`) + + let hasUpdates = false + const foreignKeysByColName = foreignKeys.reduce((prev, curr) => { + const fk = formatFKsPragmaToSequelizeFK(curr) + + const constraint = constraints.find((c) => c.field === curr.from) + if (constraint && (constraint.onDelete !== fk.onDelete || constraint.onUpdate !== fk.onUpdate)) { + fk.onDelete = constraint.onDelete + fk.onUpdate = constraint.onUpdate + hasUpdates = true + } + + return { ...prev, [curr.from]: fk } + }, {}) + + return hasUpdates ? foreignKeysByColName : null +} + +/** + * Extends the Sequelize describeTable function to include the updated foreign key constraints + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {String} tableName + * @param {Record} updatedForeignKeys + */ +async function describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) { + const tableDescription = await queryInterface.describeTable(tableName) + + const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => { + let extendedAttributes = attributes + + if (updatedForeignKeys[col]) { + extendedAttributes = { + ...extendedAttributes, + ...updatedForeignKeys[col] + } + } + return { ...prev, [col]: extendedAttributes } + }, {}) + + return tableDescriptionWithFks +} + +/** + * @see https://www.sqlite.org/lang_altertable.html#otheralter + * @see https://sequelize.org/docs/v6/other-topics/query-interface/#changing-and-removing-columns-in-sqlite + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {string} tableName + * @param {ConstraintUpdateObj[]} constraints + * @returns {Promise} - Return false if no changes are needed, true otherwise + */ +async function changeConstraints(queryInterface, tableName, constraints) { + const updatedForeignKeys = await getUpdatedForeignKeys(queryInterface, tableName, constraints) + if (!updatedForeignKeys) { + return false + } + + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) + const quotedTableName = queryInterface.quoteIdentifier(tableName) + + const backupTableName = `${tableName}_${Math.round(Math.random() * 100)}_backup` + const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName) + + try { + const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) + + const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks) + + // Create the backup table + await queryInterface.createTable(backupTableName, attributes) + + const attributeNames = Object.keys(attributes) + .map((attr) => queryInterface.quoteIdentifier(attr)) + .join(', ') + + // Copy all data from the target table to the backup table + await execQuery(`INSERT INTO ${quotedBackupTableName} SELECT ${attributeNames} FROM ${quotedTableName};`) + + // Drop the old (original) table + await queryInterface.dropTable(tableName) + + // Rename the backup table to the original table's name + await queryInterface.renameTable(backupTableName, tableName) + + // Validate that all foreign key constraints are correct + const result = await execQuery(`PRAGMA foreign_key_check(${quotedTableName});`, { + type: queryInterface.sequelize.Sequelize.QueryTypes.SELECT + }) + + // There are foreign key violations, exit + if (result.length) { + return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`) + } + + return true + } catch (error) { + return Promise.reject(error) + } +} + +module.exports = { up, down } diff --git a/audiobookshelf/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js b/audiobookshelf/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js new file mode 100644 index 0000000..03797e3 --- /dev/null +++ b/audiobookshelf/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js @@ -0,0 +1,84 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration adds an subfolder setting for OIDC redirect URIs. + * It updates existing OIDC setups to set this option to None (empty subfolder), so they continue to work as before. + * IF OIDC is not enabled, no action is taken (i.e. the subfolder is left undefined), + * so that future OIDC setups will use the default subfolder. + * + * @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 } }) { + // Upwards migration script + logger.info('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris') + + const serverSettings = await getServerSettings(queryInterface, logger) + if (serverSettings.authActiveAuthMethods?.includes('openid')) { + logger.info('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings') + serverSettings.authOpenIDSubfolderForRedirectURLs = '' + await updateServerSettings(queryInterface, logger, serverSettings) + } else { + logger.info('[2.17.4 migration] OIDC is not enabled, no action required') + } + + logger.info('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris') +} + +/** + * This downward migration script removes the subfolder setting for OIDC redirect URIs. + * + * @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 } }) { + // Downward migration script + logger.info('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ') + + // Remove the OIDC subfolder option from the server settings + const serverSettings = await getServerSettings(queryInterface, logger) + if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) { + logger.info('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings') + delete serverSettings.authOpenIDSubfolderForRedirectURLs + await updateServerSettings(queryInterface, logger, serverSettings) + } else { + logger.info('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required') + } + + logger.info('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ') +} + +async function getServerSettings(queryInterface, logger) { + const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";') + if (!result[0].length) { + logger.error('[2.17.4 migration] Server settings not found') + throw new Error('Server settings not found') + } + + let serverSettings = null + try { + serverSettings = JSON.parse(result[0][0].value) + } catch (error) { + logger.error('[2.17.4 migration] Error parsing server settings:', error) + throw error + } + + return serverSettings +} + +async function updateServerSettings(queryInterface, logger, serverSettings) { + await queryInterface.sequelize.query('UPDATE settings SET value = :value WHERE key = "server-settings";', { + replacements: { + value: JSON.stringify(serverSettings) + } + }) +} + +module.exports = { up, down } diff --git a/audiobookshelf/migrations/v2.17.5-remove-host-from-feed-urls.js b/audiobookshelf/migrations/v2.17.5-remove-host-from-feed-urls.js new file mode 100644 index 0000000..e08877f --- /dev/null +++ b/audiobookshelf/migrations/v2.17.5-remove-host-from-feed-urls.js @@ -0,0 +1,74 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.17.5' +const migrationName = `${migrationVersion}-remove-host-from-feed-urls` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables. + * + * @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 } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + logger.info(`${loggerPrefix} Removing serverAddress from Feeds table URLs`) + await queryInterface.sequelize.query(` + UPDATE Feeds + SET feedUrl = REPLACE(feedUrl, COALESCE(serverAddress, ''), ''), + imageUrl = REPLACE(imageUrl, COALESCE(serverAddress, ''), ''), + siteUrl = REPLACE(siteUrl, COALESCE(serverAddress, ''), ''); + `) + logger.info(`${loggerPrefix} Removed serverAddress from Feeds table URLs`) + + logger.info(`${loggerPrefix} Removing serverAddress from FeedEpisodes table URLs`) + await queryInterface.sequelize.query(` + UPDATE FeedEpisodes + SET siteUrl = REPLACE(siteUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''), + enclosureUrl = REPLACE(enclosureUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''); + `) + logger.info(`${loggerPrefix} Removed serverAddress from FeedEpisodes table URLs`) + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script adds the host (serverAddress) back to URL columns in the feeds and feedEpisodes tables. + * + * @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 } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + logger.info(`${loggerPrefix} Adding serverAddress back to Feeds table URLs`) + await queryInterface.sequelize.query(` + UPDATE Feeds + SET feedUrl = COALESCE(serverAddress, '') || feedUrl, + imageUrl = COALESCE(serverAddress, '') || imageUrl, + siteUrl = COALESCE(serverAddress, '') || siteUrl; + `) + logger.info(`${loggerPrefix} Added serverAddress back to Feeds table URLs`) + + logger.info(`${loggerPrefix} Adding serverAddress back to FeedEpisodes table URLs`) + await queryInterface.sequelize.query(` + UPDATE FeedEpisodes + SET siteUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.siteUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), + enclosureUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.enclosureUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId); + `) + logger.info(`${loggerPrefix} Added serverAddress back to FeedEpisodes table URLs`) + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/audiobookshelf/migrations/v2.17.6-share-add-isdownloadable.js b/audiobookshelf/migrations/v2.17.6-share-add-isdownloadable.js new file mode 100644 index 0000000..9434d28 --- /dev/null +++ b/audiobookshelf/migrations/v2.17.6-share-add-isdownloadable.js @@ -0,0 +1,68 @@ +/** + * @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.17.6' +const migrationName = `${migrationVersion}-share-add-isdownloadable` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This migration script adds the isDownloadable column to the mediaItemShares table. + * + * @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('mediaItemShares')) { + const tableDescription = await queryInterface.describeTable('mediaItemShares') + if (!tableDescription.isDownloadable) { + logger.info(`${loggerPrefix} Adding isDownloadable column to mediaItemShares table`) + await queryInterface.addColumn('mediaItemShares', 'isDownloadable', { + type: queryInterface.sequelize.Sequelize.DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false + }) + logger.info(`${loggerPrefix} Added isDownloadable column to mediaItemShares table`) + } else { + logger.info(`${loggerPrefix} isDownloadable column already exists in mediaItemShares table`) + } + } else { + logger.info(`${loggerPrefix} mediaItemShares table does not exist`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This migration script removes the isDownloadable column from the mediaItemShares 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('mediaItemShares')) { + const tableDescription = await queryInterface.describeTable('mediaItemShares') + if (tableDescription.isDownloadable) { + logger.info(`${loggerPrefix} Removing isDownloadable column from mediaItemShares table`) + await queryInterface.removeColumn('mediaItemShares', 'isDownloadable') + logger.info(`${loggerPrefix} Removed isDownloadable column from mediaItemShares table`) + } else { + logger.info(`${loggerPrefix} isDownloadable column does not exist in mediaItemShares table`) + } + } else { + logger.info(`${loggerPrefix} mediaItemShares table does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/audiobookshelf/migrations/v2.17.7-add-indices.js b/audiobookshelf/migrations/v2.17.7-add-indices.js new file mode 100644 index 0000000..b3821de --- /dev/null +++ b/audiobookshelf/migrations/v2.17.7-add-indices.js @@ -0,0 +1,83 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.17.7' +const migrationName = `${migrationVersion}-add-indices` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration adds some indices to the libraryItems and books tables to improve query performance + * + * @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 } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size']) + await addIndex(queryInterface, logger, 'books', ['duration']) + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script removes the indices added in the upward migration script + * + * @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 } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size']) + await removeIndex(queryInterface, logger, 'books', ['duration']) + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +/** + * Utility function to add an index to a table. If the index already exists, it logs a message and continues. + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import ('../Logger')} logger + * @param {string} tableName + * @param {string[]} columns + */ +async function addIndex(queryInterface, logger, tableName, columns) { + try { + logger.info(`${loggerPrefix} adding index [${columns.join(', ')}] to table "${tableName}"`) + await queryInterface.addIndex(tableName, columns) + logger.info(`${loggerPrefix} added index [${columns.join(', ')}] to table "${tableName}"`) + } catch (error) { + if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) { + logger.info(`${loggerPrefix} index [${columns.join(', ')}] for table "${tableName}" already exists`) + } else { + throw error + } + } +} + +/** + * Utility function to remove an index from a table. + * Sequelize implemets it using DROP INDEX IF EXISTS, so it won't throw an error if the index doesn't exist. + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import ('../Logger')} logger + * @param {string} tableName + * @param {string[]} columns + */ +async function removeIndex(queryInterface, logger, tableName, columns) { + logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table "${tableName}"`) + await queryInterface.removeIndex(tableName, columns) + logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table "${tableName}"`) +} + +module.exports = { up, down } diff --git a/audiobookshelf/migrations/v2.19.1-copy-title-to-library-items.js b/audiobookshelf/migrations/v2.19.1-copy-title-to-library-items.js new file mode 100644 index 0000000..7b75fa8 --- /dev/null +++ b/audiobookshelf/migrations/v2.19.1-copy-title-to-library-items.js @@ -0,0 +1,164 @@ +const util = require('util') + +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.19.1' +const migrationName = `${migrationVersion}-copy-title-to-library-items` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration adds a title column to the libraryItems table, copies the title from the book to the libraryItem, + * and creates a new index on the title column. In addition it sets a trigger on the books table to update the title column + * in the libraryItems table when a book is updated. + * + * @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 } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + await addColumn(queryInterface, logger, 'libraryItems', 'title', { type: queryInterface.sequelize.Sequelize.STRING, allowNull: true }) + await copyColumn(queryInterface, logger, 'books', 'title', 'id', 'libraryItems', 'title', 'mediaId') + await addTrigger(queryInterface, logger, 'books', 'title', 'id', 'libraryItems', 'title', 'mediaId') + await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', { name: 'title', collate: 'NOCASE' }]) + + await addColumn(queryInterface, logger, 'libraryItems', 'titleIgnorePrefix', { type: queryInterface.sequelize.Sequelize.STRING, allowNull: true }) + await copyColumn(queryInterface, logger, 'books', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId') + await addTrigger(queryInterface, logger, 'books', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId') + await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', { name: 'titleIgnorePrefix', collate: 'NOCASE' }]) + + await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'createdAt']) + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script removes the title column from the libraryItems table, removes the trigger on the books table, + * and removes the index on the title column. + * + * @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 } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'title']) + await removeTrigger(queryInterface, logger, 'libraryItems', 'title') + await removeColumn(queryInterface, logger, 'libraryItems', 'title') + + await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'titleIgnorePrefix']) + await removeTrigger(queryInterface, logger, 'libraryItems', 'titleIgnorePrefix') + await removeColumn(queryInterface, logger, 'libraryItems', 'titleIgnorePrefix') + + await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'createdAt']) + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +/** + * Utility function to add an index to a table. If the index already z`exists, it logs a message and continues. + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import ('../Logger')} logger + * @param {string} tableName + * @param {string[]} columns + */ +async function addIndex(queryInterface, logger, tableName, columns) { + const columnString = columns.map((column) => util.inspect(column)).join(', ') + const indexName = convertToSnakeCase(`${tableName}_${columns.map((column) => (typeof column === 'string' ? column : column.name)).join('_')}`) + try { + logger.info(`${loggerPrefix} adding index on [${columnString}] to table ${tableName}. index name: ${indexName}"`) + await queryInterface.addIndex(tableName, columns) + logger.info(`${loggerPrefix} added index on [${columnString}] to table ${tableName}. index name: ${indexName}"`) + } catch (error) { + if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) { + logger.info(`${loggerPrefix} index [${columnString}] for table "${tableName}" already exists`) + } else { + throw error + } + } +} + +/** + * Utility function to remove an index from a table. + * Sequelize implemets it using DROP INDEX IF EXISTS, so it won't throw an error if the index doesn't exist. + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import ('../Logger')} logger + * @param {string} tableName + * @param {string[]} columns + */ +async function removeIndex(queryInterface, logger, tableName, columns) { + logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table "${tableName}"`) + await queryInterface.removeIndex(tableName, columns) + logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table "${tableName}"`) +} + +async function addColumn(queryInterface, logger, table, column, options) { + logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`) + const tableDescription = await queryInterface.describeTable(table) + if (!tableDescription[column]) { + await queryInterface.addColumn(table, column, options) + logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`) + } else { + logger.info(`${loggerPrefix} column "${column}" already exists in table "${table}"`) + } +} + +async function removeColumn(queryInterface, logger, table, column) { + logger.info(`${loggerPrefix} removing column "${column}" from table "${table}"`) + await queryInterface.removeColumn(table, column) + logger.info(`${loggerPrefix} removed column "${column}" from table "${table}"`) +} + +async function copyColumn(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) { + logger.info(`${loggerPrefix} copying column "${sourceColumn}" from table "${sourceTable}" to table "${targetTable}"`) + await queryInterface.sequelize.query(` + UPDATE ${targetTable} + SET ${targetColumn} = ${sourceTable}.${sourceColumn} + FROM ${sourceTable} + WHERE ${targetTable}.${targetIdColumn} = ${sourceTable}.${sourceIdColumn} + `) + logger.info(`${loggerPrefix} copied column "${sourceColumn}" from table "${sourceTable}" to table "${targetTable}"`) +} + +async function addTrigger(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) { + logger.info(`${loggerPrefix} adding trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`) + const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}`) + + await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + + await queryInterface.sequelize.query(` + CREATE TRIGGER ${triggerName} + AFTER UPDATE OF ${sourceColumn} ON ${sourceTable} + FOR EACH ROW + BEGIN + UPDATE ${targetTable} + SET ${targetColumn} = NEW.${sourceColumn} + WHERE ${targetTable}.${targetIdColumn} = NEW.${sourceIdColumn}; + END; + `) + logger.info(`${loggerPrefix} added trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`) +} + +async function removeTrigger(queryInterface, logger, targetTable, targetColumn) { + logger.info(`${loggerPrefix} removing trigger to update ${targetTable}.${targetColumn}`) + const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}`) + await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + logger.info(`${loggerPrefix} removed trigger to update ${targetTable}.${targetColumn}`) +} + +function convertToSnakeCase(str) { + return str.replace(/([A-Z])/g, '_$1').toLowerCase() +} + +module.exports = { up, down } diff --git a/audiobookshelf/migrations/v2.19.4-improve-podcast-queries.js b/audiobookshelf/migrations/v2.19.4-improve-podcast-queries.js new file mode 100644 index 0000000..689795c --- /dev/null +++ b/audiobookshelf/migrations/v2.19.4-improve-podcast-queries.js @@ -0,0 +1,219 @@ +const util = require('util') + +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.19.4' +const migrationName = `${migrationVersion}-improve-podcast-queries` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration adds a numEpisodes column to the podcasts table and populates it. + * It also adds a podcastId column to the mediaProgresses table and populates it. + * It also copies the title and titleIgnorePrefix columns from the podcasts table to the libraryItems table, + * and adds triggers to update them when the corresponding columns in the podcasts table are updated. + * + * @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 } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + // Add numEpisodes column to podcasts table + await addColumn(queryInterface, logger, 'podcasts', 'numEpisodes', { type: queryInterface.sequelize.Sequelize.INTEGER, allowNull: false, defaultValue: 0 }) + + // Populate numEpisodes column with the number of episodes for each podcast + await populateNumEpisodes(queryInterface, logger) + + // Add podcastId column to mediaProgresses table + await addColumn(queryInterface, logger, 'mediaProgresses', 'podcastId', { type: queryInterface.sequelize.Sequelize.UUID, allowNull: true }) + + // Populate podcastId column with the podcastId for each mediaProgress + await populatePodcastId(queryInterface, logger) + + // Copy title and titleIgnorePrefix columns from podcasts to libraryItems + await copyColumn(queryInterface, logger, 'podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId') + await copyColumn(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId') + + // Add triggers to update title and titleIgnorePrefix in libraryItems + await addTrigger(queryInterface, logger, 'podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId') + await addTrigger(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId') + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration removes the triggers on the podcasts table, + * the numEpisodes column from the podcasts table, and the podcastId column from the mediaProgresses 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 } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + // Remove triggers from libraryItems + await removeTrigger(queryInterface, logger, 'podcasts', 'title', 'libraryItems', 'title') + await removeTrigger(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'libraryItems', 'titleIgnorePrefix') + + // Remove numEpisodes column from podcasts table + await removeColumn(queryInterface, logger, 'podcasts', 'numEpisodes') + + // Remove podcastId column from mediaProgresses table + await removeColumn(queryInterface, logger, 'mediaProgresses', 'podcastId') + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +async function populateNumEpisodes(queryInterface, logger) { + logger.info(`${loggerPrefix} populating numEpisodes column in podcasts table`) + await queryInterface.sequelize.query(` + UPDATE podcasts + SET numEpisodes = (SELECT COUNT(*) FROM podcastEpisodes WHERE podcastEpisodes.podcastId = podcasts.id) + `) + logger.info(`${loggerPrefix} populated numEpisodes column in podcasts table`) +} + +async function populatePodcastId(queryInterface, logger) { + logger.info(`${loggerPrefix} populating podcastId column in mediaProgresses table`) + // bulk update podcastId to the podcastId of the podcastEpisode if the mediaItemType is podcastEpisode + await queryInterface.sequelize.query(` + UPDATE mediaProgresses + SET podcastId = (SELECT podcastId FROM podcastEpisodes WHERE podcastEpisodes.id = mediaProgresses.mediaItemId) + WHERE mediaItemType = 'podcastEpisode' + `) + logger.info(`${loggerPrefix} populated podcastId column in mediaProgresses table`) +} + +/** + * Utility function to add a column to a table. If the column already exists, it logs a message and continues. + * + * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @param {import('../Logger')} logger - a Logger object. + * @param {string} table - the name of the table to add the column to. + * @param {string} column - the name of the column to add. + * @param {Object} options - the options for the column. + */ +async function addColumn(queryInterface, logger, table, column, options) { + logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`) + const tableDescription = await queryInterface.describeTable(table) + if (!tableDescription[column]) { + await queryInterface.addColumn(table, column, options) + logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`) + } else { + logger.info(`${loggerPrefix} column "${column}" already exists in table "${table}"`) + } +} + +/** + * Utility function to remove a column from a table. If the column does not exist, it logs a message and continues. + * + * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @param {import('../Logger')} logger - a Logger object. + * @param {string} table - the name of the table to remove the column from. + * @param {string} column - the name of the column to remove. + */ +async function removeColumn(queryInterface, logger, table, column) { + logger.info(`${loggerPrefix} removing column "${column}" from table "${table}"`) + const tableDescription = await queryInterface.describeTable(table) + if (tableDescription[column]) { + await queryInterface.sequelize.query(`ALTER TABLE ${table} DROP COLUMN ${column}`) + logger.info(`${loggerPrefix} removed column "${column}" from table "${table}"`) + } else { + logger.info(`${loggerPrefix} column "${column}" does not exist in table "${table}"`) + } +} + +/** + * Utility function to add a trigger to update a column in a target table when a column in a source table is updated. + * If the trigger already exists, it drops it and creates a new one. + * sourceIdColumn and targetIdColumn are used to match the source and target rows. + * + * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @param {import('../Logger')} logger - a Logger object. + * @param {string} sourceTable - the name of the source table. + * @param {string} sourceColumn - the name of the column to update. + * @param {string} sourceIdColumn - the name of the id column of the source table. + * @param {string} targetTable - the name of the target table. + * @param {string} targetColumn - the name of the column to update. + * @param {string} targetIdColumn - the name of the id column of the target table. + */ +async function addTrigger(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) { + logger.info(`${loggerPrefix} adding trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`) + const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}_from_${sourceTable}_${sourceColumn}`) + + await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + + await queryInterface.sequelize.query(` + CREATE TRIGGER ${triggerName} + AFTER UPDATE OF ${sourceColumn} ON ${sourceTable} + FOR EACH ROW + BEGIN + UPDATE ${targetTable} + SET ${targetColumn} = NEW.${sourceColumn} + WHERE ${targetTable}.${targetIdColumn} = NEW.${sourceIdColumn}; + END; + `) + logger.info(`${loggerPrefix} added trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`) +} + +/** + * Utility function to remove an update trigger from a table. + * + * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @param {import('../Logger')} logger - a Logger object. + * @param {string} sourceTable - the name of the source table. + * @param {string} sourceColumn - the name of the column to update. + * @param {string} targetTable - the name of the target table. + * @param {string} targetColumn - the name of the column to update. + */ +async function removeTrigger(queryInterface, logger, sourceTable, sourceColumn, targetTable, targetColumn) { + logger.info(`${loggerPrefix} removing trigger to update ${targetTable}.${targetColumn}`) + const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}_from_${sourceTable}_${sourceColumn}`) + await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + logger.info(`${loggerPrefix} removed trigger to update ${targetTable}.${targetColumn}`) +} + +/** + * Utility function to copy a column from a source table to a target table. + * sourceIdColumn and targetIdColumn are used to match the source and target rows. + * + * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @param {import('../Logger')} logger - a Logger object. + * @param {string} sourceTable - the name of the source table. + * @param {string} sourceColumn - the name of the column to copy. + * @param {string} sourceIdColumn - the name of the id column of the source table. + * @param {string} targetTable - the name of the target table. + * @param {string} targetColumn - the name of the column to copy to. + * @param {string} targetIdColumn - the name of the id column of the target table. + */ +async function copyColumn(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) { + logger.info(`${loggerPrefix} copying column "${sourceColumn}" from table "${sourceTable}" to table "${targetTable}"`) + await queryInterface.sequelize.query(` + UPDATE ${targetTable} + SET ${targetColumn} = ${sourceTable}.${sourceColumn} + FROM ${sourceTable} + WHERE ${targetTable}.${targetIdColumn} = ${sourceTable}.${sourceIdColumn} + `) + logger.info(`${loggerPrefix} copied column "${sourceColumn}" from table "${sourceTable}" to table "${targetTable}"`) +} + +/** + * Utility function to convert a string to snake case, e.g. "titleIgnorePrefix" -> "title_ignore_prefix" + * + * @param {string} str - the string to convert to snake case. + * @returns {string} - the string in snake case. + */ +function convertToSnakeCase(str) { + return str.replace(/([A-Z])/g, '_$1').toLowerCase() +} + +module.exports = { up, down } diff --git a/audiobookshelf/migrations/v2.20.0-improve-author-sort-queries.js b/audiobookshelf/migrations/v2.20.0-improve-author-sort-queries.js new file mode 100644 index 0000000..53016c7 --- /dev/null +++ b/audiobookshelf/migrations/v2.20.0-improve-author-sort-queries.js @@ -0,0 +1,272 @@ +const util = require('util') +const { Sequelize } = require('sequelize') + +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.20.0' +const migrationName = `${migrationVersion}-improve-author-sort-queries` +const loggerPrefix = `[${migrationVersion} migration]` + +// Migration constants +const libraryItems = 'libraryItems' +const bookAuthors = 'bookAuthors' +const authors = 'authors' +const podcastEpisodes = 'podcastEpisodes' +const columns = [ + { name: 'authorNamesFirstLast', source: `${authors}.name`, spec: { type: Sequelize.STRING, allowNull: true } }, + { name: 'authorNamesLastFirst', source: `${authors}.lastFirst`, spec: { type: Sequelize.STRING, allowNull: true } } +] +const authorsSort = `${bookAuthors}.createdAt ASC` +const columnNames = columns.map((column) => column.name).join(', ') +const columnSourcesExpression = columns.map((column) => `GROUP_CONCAT(${column.source}, ', ' ORDER BY ${authorsSort})`).join(', ') +const authorsJoin = `${authors} JOIN ${bookAuthors} ON ${authors}.id = ${bookAuthors}.authorId` + +/** + * This upward migration adds an authorNames column to the libraryItems table and populates it. + * It also creates triggers to update the authorNames column when the corresponding bookAuthors and authors records are updated. + * It also creates an index on the authorNames column. + * + * It also adds an index on publishedAt to the podcastEpisodes table. + * + * @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 } }) { + const helper = new MigrationHelper(queryInterface, logger) + + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + // Add authorNames columns to libraryItems table + await helper.addColumns() + + // Populate authorNames columns with the author names for each libraryItem + await helper.populateColumnsFromSource() + + // Create triggers to update the authorNames column when the corresponding bookAuthors and authors records are updated + await helper.addTriggers() + + // Create indexes on the authorNames columns + await helper.addIndexes() + + // Add index on publishedAt to the podcastEpisodes table + await helper.addIndex(podcastEpisodes, ['publishedAt']) + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration removes the authorNames column from the libraryItems table, + * the triggers on the bookAuthors and authors tables, and the index on the authorNames column. + * + * It also removes the index on publishedAt from the podcastEpisodes 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 } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + const helper = new MigrationHelper(queryInterface, logger) + + // Remove triggers to update authorNames columns + await helper.removeTriggers() + + // Remove index on publishedAt from the podcastEpisodes table + await helper.removeIndex(podcastEpisodes, ['publishedAt']) + + // Remove indexes on the authorNames columns + await helper.removeIndexes() + + // Remove authorNames columns from libraryItems table + await helper.removeColumns() + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +class MigrationHelper { + constructor(queryInterface, logger) { + this.queryInterface = queryInterface + this.logger = logger + } + + async addColumn(table, column, options) { + this.logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`) + const tableDescription = await this.queryInterface.describeTable(table) + if (!tableDescription[column]) { + await this.queryInterface.addColumn(table, column, options) + this.logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`) + } else { + this.logger.info(`${loggerPrefix} column "${column}" already exists in table "${table}"`) + } + } + + async addColumns() { + this.logger.info(`${loggerPrefix} adding ${columnNames} columns to ${libraryItems} table`) + for (const column of columns) { + await this.addColumn(libraryItems, column.name, column.spec) + } + this.logger.info(`${loggerPrefix} added ${columnNames} columns to ${libraryItems} table`) + } + + async removeColumn(table, column) { + this.logger.info(`${loggerPrefix} removing column "${column}" from table "${table}"`) + const tableDescription = await this.queryInterface.describeTable(table) + if (tableDescription[column]) { + await this.queryInterface.sequelize.query(`ALTER TABLE ${table} DROP COLUMN ${column}`) + this.logger.info(`${loggerPrefix} removed column "${column}" from table "${table}"`) + } else { + this.logger.info(`${loggerPrefix} column "${column}" does not exist in table "${table}"`) + } + } + + async removeColumns() { + this.logger.info(`${loggerPrefix} removing ${columnNames} columns from ${libraryItems} table`) + for (const column of columns) { + await this.removeColumn(libraryItems, column.name) + } + this.logger.info(`${loggerPrefix} removed ${columnNames} columns from ${libraryItems} table`) + } + + async populateColumnsFromSource() { + this.logger.info(`${loggerPrefix} populating ${columnNames} columns in ${libraryItems} table`) + const authorNamesSubQuery = ` + SELECT ${columnSourcesExpression} + FROM ${authorsJoin} + WHERE ${bookAuthors}.bookId = ${libraryItems}.mediaId + ` + await this.queryInterface.sequelize.query(` + UPDATE ${libraryItems} + SET (${columnNames}) = (${authorNamesSubQuery}) + WHERE mediaType = 'book'; + `) + this.logger.info(`${loggerPrefix} populated ${columnNames} columns in ${libraryItems} table`) + } + + async addBookAuthorsTrigger(action) { + this.logger.info(`${loggerPrefix} adding trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`) + const modifiedRecord = action === 'delete' ? 'OLD' : 'NEW' + const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_${bookAuthors}_${action}`) + const authorNamesSubQuery = ` + SELECT ${columnSourcesExpression} + FROM ${authorsJoin} + WHERE ${bookAuthors}.bookId = ${modifiedRecord}.bookId + ` + await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + + await this.queryInterface.sequelize.query(` + CREATE TRIGGER ${triggerName} + AFTER ${action} ON ${bookAuthors} + FOR EACH ROW + BEGIN + UPDATE ${libraryItems} + SET (${columnNames}) = (${authorNamesSubQuery}) + WHERE mediaId = ${modifiedRecord}.bookId; + END; + `) + this.logger.info(`${loggerPrefix} added trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`) + } + + async addAuthorsUpdateTrigger() { + this.logger.info(`${loggerPrefix} adding trigger to update ${libraryItems} ${columnNames} on ${authors} update`) + const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_authors_update`) + const authorNamesSubQuery = ` + SELECT ${columnSourcesExpression} + FROM ${authorsJoin} + WHERE ${bookAuthors}.bookId = ${libraryItems}.mediaId + ` + + await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + + await this.queryInterface.sequelize.query(` + CREATE TRIGGER ${triggerName} + AFTER UPDATE OF name ON ${authors} + FOR EACH ROW + BEGIN + UPDATE ${libraryItems} + SET (${columnNames}) = (${authorNamesSubQuery}) + WHERE mediaId IN (SELECT bookId FROM ${bookAuthors} WHERE authorId = NEW.id); + END; + `) + this.logger.info(`${loggerPrefix} added trigger to update ${libraryItems} ${columnNames} on ${authors} update`) + } + + async addTriggers() { + await this.addBookAuthorsTrigger('insert') + await this.addBookAuthorsTrigger('delete') + await this.addAuthorsUpdateTrigger() + } + + async removeBookAuthorsTrigger(action) { + this.logger.info(`${loggerPrefix} removing trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`) + const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_${bookAuthors}_${action}`) + await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + this.logger.info(`${loggerPrefix} removed trigger to update ${libraryItems} ${columnNames} on ${bookAuthors} ${action}`) + } + + async removeAuthorsUpdateTrigger() { + this.logger.info(`${loggerPrefix} removing trigger to update ${libraryItems} ${columnNames} on ${authors} update`) + const triggerName = convertToSnakeCase(`update_${libraryItems}_authorNames_on_authors_update`) + await this.queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + this.logger.info(`${loggerPrefix} removed trigger to update ${libraryItems} ${columnNames} on ${authors} update`) + } + + async removeTriggers() { + await this.removeBookAuthorsTrigger('insert') + await this.removeBookAuthorsTrigger('delete') + await this.removeAuthorsUpdateTrigger() + } + + async addIndex(tableName, columns) { + const columnString = columns.map((column) => util.inspect(column)).join(', ') + const indexName = convertToSnakeCase(`${tableName}_${columns.map((column) => (typeof column === 'string' ? column : column.name)).join('_')}`) + try { + this.logger.info(`${loggerPrefix} adding index on [${columnString}] to table ${tableName}. index name: ${indexName}"`) + await this.queryInterface.addIndex(tableName, columns) + this.logger.info(`${loggerPrefix} added index on [${columnString}] to table ${tableName}. index name: ${indexName}"`) + } catch (error) { + if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) { + this.logger.info(`${loggerPrefix} index [${columnString}] for table "${tableName}" already exists`) + } else { + throw error + } + } + } + + async addIndexes() { + for (const column of columns) { + await this.addIndex(libraryItems, ['libraryId', 'mediaType', { name: column.name, collate: 'NOCASE' }]) + } + } + + async removeIndex(tableName, columns) { + this.logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table "${tableName}"`) + await this.queryInterface.removeIndex(tableName, columns) + this.logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table "${tableName}"`) + } + + async removeIndexes() { + for (const column of columns) { + await this.removeIndex(libraryItems, ['libraryId', 'mediaType', column.name]) + } + } +} +/** + * Utility function to convert a string to snake case, e.g. "titleIgnorePrefix" -> "title_ignore_prefix" + * + * @param {string} str - the string to convert to snake case. + * @returns {string} - the string in snake case. + */ +function convertToSnakeCase(str) { + return str.replace(/([A-Z])/g, '_$1').toLowerCase() +} + +module.exports = { up, down } diff --git a/audiobookshelf/migrations/v2.26.0-create-auth-tables.js b/audiobookshelf/migrations/v2.26.0-create-auth-tables.js new file mode 100644 index 0000000..a148046 --- /dev/null +++ b/audiobookshelf/migrations/v2.26.0-create-auth-tables.js @@ -0,0 +1,163 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.26.0' +const migrationName = `${migrationVersion}-create-auth-tables` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration creates a sessions table and apiKeys table. + * + * @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 } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + // Check if table exists + if (await queryInterface.tableExists('sessions')) { + logger.info(`${loggerPrefix} table "sessions" already exists`) + } else { + // Create table + logger.info(`${loggerPrefix} creating table "sessions"`) + const DataTypes = queryInterface.sequelize.Sequelize.DataTypes + await queryInterface.createTable('sessions', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + ipAddress: DataTypes.STRING, + userAgent: DataTypes.STRING, + refreshToken: { + type: DataTypes.STRING, + allowNull: false + }, + expiresAt: { + type: DataTypes.DATE, + allowNull: false + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + }, + userId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'users' + }, + key: 'id' + }, + allowNull: false, + onDelete: 'CASCADE' + } + }) + logger.info(`${loggerPrefix} created table "sessions"`) + } + + // Check if table exists + if (await queryInterface.tableExists('apiKeys')) { + logger.info(`${loggerPrefix} table "apiKeys" already exists`) + } else { + // Create table + logger.info(`${loggerPrefix} creating table "apiKeys"`) + const DataTypes = queryInterface.sequelize.Sequelize.DataTypes + await queryInterface.createTable('apiKeys', { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + description: DataTypes.TEXT, + expiresAt: DataTypes.DATE, + lastUsedAt: DataTypes.DATE, + isActive: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, + permissions: DataTypes.JSON, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + }, + userId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'users' + }, + key: 'id' + }, + onDelete: 'CASCADE' + }, + createdByUserId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'users', + as: 'createdByUser' + }, + key: 'id' + }, + onDelete: 'SET NULL' + } + }) + logger.info(`${loggerPrefix} created table "apiKeys"`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script removes the sessions table and apiKeys 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 } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + // Check if table exists + if (await queryInterface.tableExists('sessions')) { + logger.info(`${loggerPrefix} dropping table "sessions"`) + // Drop table + await queryInterface.dropTable('sessions') + logger.info(`${loggerPrefix} dropped table "sessions"`) + } else { + logger.info(`${loggerPrefix} table "sessions" does not exist`) + } + + if (await queryInterface.tableExists('apiKeys')) { + logger.info(`${loggerPrefix} dropping table "apiKeys"`) + await queryInterface.dropTable('apiKeys') + logger.info(`${loggerPrefix} dropped table "apiKeys"`) + } else { + logger.info(`${loggerPrefix} table "apiKeys" does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/audiobookshelf/migrations/v2.33.0-add-discover-query-indexes.js b/audiobookshelf/migrations/v2.33.0-add-discover-query-indexes.js new file mode 100644 index 0000000..ebd92bb --- /dev/null +++ b/audiobookshelf/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/audiobookshelf/migrations/v2.35.0-add-last-refresh-token.js b/audiobookshelf/migrations/v2.35.0-add-last-refresh-token.js new file mode 100644 index 0000000..0ad190e --- /dev/null +++ b/audiobookshelf/migrations/v2.35.0-add-last-refresh-token.js @@ -0,0 +1,84 @@ +/** + * @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.35.0' +const migrationName = `${migrationVersion}-add-last-refresh-token` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This migration script adds lastRefreshToken and lastRefreshTokenExpiresAt columns to the sessions table. + * + * @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('sessions')) { + const tableDescription = await queryInterface.describeTable('sessions') + + if (!tableDescription.lastRefreshToken) { + logger.info(`${loggerPrefix} Adding lastRefreshToken column to sessions table`) + await queryInterface.addColumn('sessions', 'lastRefreshToken', { + type: queryInterface.sequelize.Sequelize.DataTypes.STRING, + allowNull: true + }) + } else { + logger.info(`${loggerPrefix} lastRefreshToken column already exists in sessions table`) + } + + if (!tableDescription.lastRefreshTokenExpiresAt) { + logger.info(`${loggerPrefix} Adding lastRefreshTokenExpiresAt column to sessions table`) + await queryInterface.addColumn('sessions', 'lastRefreshTokenExpiresAt', { + type: queryInterface.sequelize.Sequelize.DataTypes.DATE, + allowNull: true + }) + } else { + logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column already exists in sessions table`) + } + } else { + logger.info(`${loggerPrefix} sessions table does not exist`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This migration script removes the lastRefreshToken and lastRefreshTokenExpiresAt columns from the sessions 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('sessions')) { + const tableDescription = await queryInterface.describeTable('sessions') + + if (tableDescription.lastRefreshToken) { + logger.info(`${loggerPrefix} Removing lastRefreshToken column from sessions table`) + await queryInterface.removeColumn('sessions', 'lastRefreshToken') + } else { + logger.info(`${loggerPrefix} lastRefreshToken column does not exist in sessions table`) + } + + if (tableDescription.lastRefreshTokenExpiresAt) { + logger.info(`${loggerPrefix} Removing lastRefreshTokenExpiresAt column from sessions table`) + await queryInterface.removeColumn('sessions', 'lastRefreshTokenExpiresAt') + } else { + logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column does not exist in sessions table`) + } + } else { + logger.info(`${loggerPrefix} sessions table does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/docker-compose.yml b/docker-compose.yml index 18f8130..0719077 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,21 +14,6 @@ x-logging: &logging # PLAYWRIGHT_MICROSERVICE_URL: http://playwright:3000/scrape services: - # change: - # <<: *logging - # profiles: ["prod"] - # container_name: change - # image: node:alpine - # build: ./change-game - # environment: - # - PORT=9000 - # - VIRTUAL_HOST=rec.${DOMAIN} - # - CERT_NAME=${DOMAIN} - # - VIRTUAL_PORT=9000 - # networks: - # - nginx - # volumes: - # - change:/change/public foundry: profiles: ["prod"] @@ -62,7 +47,7 @@ services: profiles: ["prod", "dev"] <<: *logging build: ./nextcloud - image: nextcloud:32-fpm-alpine + image: nextcloud:33-fpm-alpine restart: unless-stopped user: 1000:1000 ports: @@ -165,62 +150,126 @@ services: - nginx depends_on: - db - - librechat: - image: ghcr.io/danny-avila/librechat-dev-api:latest - ports: - - 3080:3080 + + audiobookshelf: + image: ghcr.io/advplyr/audiobookshelf:latest + volumes: + - /mnt/tower/stardust/chris/files/Library/Audiobooks:/audiobooks + - /mnt/tower/stardust/chris/files/Library/Podcasts:/podcasts + - /mnt/tower/stardust/chris/files/Library/Audiobooks/.audiobookshelf/config:/config + - /mnt/tower/stardust/chris/files/Library/Audiobooks/.audiobookshelf:/metadata + environment: + - TZ=Europe/London + - VIRTUAL_HOST=echo.${DOMAIN} + - VIRTUAL_PORT=80 networks: - nginx - - redis - - db - depends_on: - - mongodb - # - rag_api - restart: always - extra_hosts: - - "host.docker.internal:host-gateway" - <<: *logging - environment: - - VIRTUAL_HOST=droid.${DOMAIN} - - VIRTUAL_PORT=3080 - - CERT_NAME=${DOMAIN} - - HOST=0.0.0.0 - - NODE_ENV=production - - MONGO_URI=mongodb://${DB_USER}:${DB_PASSWORD}@mongodb:27017/LibreChat?authSource=admin - #- MEILI_HOST=http://meilisearch:7700 - #- MEILI_MASTER_KEY=${MEILI_MASTER_KEY} - #- RAG_PORT=8000 - #- RAG_API_URL=http://rag_api:8000 - #- SEARXNG_INSTANCE_URL=http://searxng:8080 - #- SEARXNG_API_KEY=${SEARXNG_SECRET_KEY} - #- FIRECRAWL_URL=http://firecrawl:3002 - #- FIRECRWAL_API_KEY= - - ALLOW_EMAIL_LOGIN=true - - ALLOW_REGISTRATION=false - - ALLOW_SOCIAL_LOGIN=false - - ALLOW_SOCIAL_REGISTRATION=false - - ALLOW_PASSWORD_RESET=false - - ALLOW_ACCOUNT_DELETION=false - - ALLOW_UNVERIFIED_EMAIL_LOGIN=true - - CREDS_KEY=${LIBRECHAT_CREDS_KEY} - - CREDS_IV=${LIBRECHAT_CREDS_IV} - - JWT_SECRET=${LIBRECHAT_JWT_SECRET} - - JWT_REFRESH_SECRET=${LIBRECHAT_JWT_REFRESH_SECRET} - - AIHUBMIX_KEY=${AIHUBMIX_KEY} - - OPENROUTER_KEY=${OPENROUTER_KEY} - - IMAGE_GEN_OAI_BASEURL=https://aihubmix.com/v1 - - IMAGE_GEN_OAI_API_KEY=${AIHUBMIX_KEY} - - IMAGE_GEN_OAI_MODEL=qwen-image-plus - #- JINA_API_KEY=${JINA_API_KEY} + + kavita: + image: jvmilazz0/kavita:latest # Using the stable branch from the official dockerhub repo. + container_name: kavita volumes: - - type: bind - source: ./librechat/librechat.yaml - target: /app/librechat.yaml - - /mnt/tower/stardust/chris/files/.droid/images:/app/client/public/images - - /mnt/tower/stardust/chris/files/.droid/uploads:/app/uploads - - /mnt/tower/stardust/chris/files/.droid/logs:/app/logs - - /mnt/tower/stardust/chris/files/:/files + # - /your/path/to/comics:/comics + - /mnt/tower/stardust/chris/files/Library/Books:/books + - /mnt/tower/stardust/chris/files/Library/Books/.kavita/config:/kavita/config + environment: + - TZ=Europe/London + - VIRTUAL_HOST=datapad.${DOMAIN} + - VIRTUAL_PORT=5000 + restart: unless-stopped + networks: + - nginx + + grocy: + image: lscr.io/linuxserver/grocy:latest + container_name: grocy + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/London + - VIRTUAL_HOST=assets.${DOMAIN} + - VIRTUAL_PORT=9283 + volumes: + - ./grocy/config:/config + restart: unless-stopped + + # calibre-web-automated: + # image: crocodilestick/calibre-web-automated:latest + # container_name: calibre-web-automated + # environment: + # - PUID=1000 + # - PGID=1000 + # - TZ=Europe/London + # - HARDCOVER_TOKEN=${HARDCOVER_TOKEN} + # - NETWORK_SHARE_MODE=false + # # If running behind multiple proxies (e.g., Cloudflare Tunnel + reverse proxy), set the total number of proxies + # # This ensures proper IP detection for session protection and rate limiting (default: 1) + # # - TRUSTED_PROXY_COUNT=2 + # - VIRTUAL_HOST=datapad.${DOMAIN} + # - VIRTUAL_PORT=8083 + # volumes: + # - /mnt/tower/stardust/chris/files/Library/Books/.calibre/config:/config + # - /mnt/tower/stardust/chris/files/Library/Books/.calibre/ingest:/cwa-book-ingest + # - /mnt/tower/stardust/chris/files/Library/Books/.calibre/library:/calibre-library + # networks: + # - nginx + # restart: unless-stopped + + # librechat: + # image: ghcr.io/danny-avila/librechat-dev-api:latest + # ports: + # - 3080:3080 + # networks: + # - nginx + # - redis + # - db + # depends_on: + # - mongodb + # # - rag_api + # restart: always + # extra_hosts: + # - "host.docker.internal:host-gateway" + # <<: *logging + # environment: + # - VIRTUAL_HOST=droid.${DOMAIN} + # - VIRTUAL_PORT=3080 + # - CERT_NAME=${DOMAIN} + # - HOST=0.0.0.0 + # - NODE_ENV=production + # - MONGO_URI=mongodb://${DB_USER}:${DB_PASSWORD}@mongodb:27017/LibreChat?authSource=admin + # #- MEILI_HOST=http://meilisearch:7700 + # #- MEILI_MASTER_KEY=${MEILI_MASTER_KEY} + # #- RAG_PORT=8000 + # #- RAG_API_URL=http://rag_api:8000 + # #- SEARXNG_INSTANCE_URL=http://searxng:8080 + # #- SEARXNG_API_KEY=${SEARXNG_SECRET_KEY} + # #- FIRECRAWL_URL=http://firecrawl:3002 + # #- FIRECRWAL_API_KEY= + # - ALLOW_EMAIL_LOGIN=true + # - ALLOW_REGISTRATION=false + # - ALLOW_SOCIAL_LOGIN=false + # - ALLOW_SOCIAL_REGISTRATION=false + # - ALLOW_PASSWORD_RESET=false + # - ALLOW_ACCOUNT_DELETION=false + # - ALLOW_UNVERIFIED_EMAIL_LOGIN=true + # - CREDS_KEY=${LIBRECHAT_CREDS_KEY} + # - CREDS_IV=${LIBRECHAT_CREDS_IV} + # - JWT_SECRET=${LIBRECHAT_JWT_SECRET} + # - JWT_REFRESH_SECRET=${LIBRECHAT_JWT_REFRESH_SECRET} + # - AIHUBMIX_KEY=${AIHUBMIX_KEY} + # - OPENROUTER_KEY=${OPENROUTER_KEY} + # - IMAGE_GEN_OAI_BASEURL=https://aihubmix.com/v1 + # - IMAGE_GEN_OAI_API_KEY=${AIHUBMIX_KEY} + # - IMAGE_GEN_OAI_MODEL=qwen-image-plus + # #- JINA_API_KEY=${JINA_API_KEY} + # volumes: + # - type: bind + # source: ./librechat/librechat.yaml + # target: /app/librechat.yaml + # - /mnt/tower/stardust/chris/files/.droid/images:/app/client/public/images + # - /mnt/tower/stardust/chris/files/.droid/uploads:/app/uploads + # - /mnt/tower/stardust/chris/files/.droid/logs:/app/logs + # - /mnt/tower/stardust/chris/files/:/files searxng: image: docker.io/searxng/searxng:latest @@ -239,69 +288,6 @@ services: - CERT_NAME=${DOMAIN} <<: *logging - openclaw-gateway: - image: alpine/openclaw - build: - args: - OPENCLAW_DOCKER_APT_PACKAGES: "git curl jq ffmpeg build-essentials fzf ripgrep fd imagemagick exiftool" - NEXTCLOUD_USER: ${NEXTCLOUD_OPENCLAW_USER} - NEXTCLOUD_PASSWORD: ${NEXTCLOUD_OPENCLAW_PASSWORD} - user: 1000:1000 - environment: - HOME: /home/node - TERM: xterm-256color - OPENROUTER_API_KEY: ${OPENROUTER_KEY} - OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN} - BRAVE_API_KEY: ${BRAVE_API_KEY} - TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} - VIRTUAL_HOST: kiwa.${DOMAIN} - VIRTUAL_PORT: "18789" - volumes: - - ./openclaw/config:/home/node/.openclaw - - ./openclaw/workspace:/home/node/.openclaw/workspace - - /mnt/tower/stardust/chris/files/:/files:ro - - openclaw:/home/node - ports: - - "${OPENCLAW_GATEWAY_PORT:-18789}:18789" - - "${OPENCLAW_BRIDGE_PORT:-18790}:18790" - networks: - - nginx - init: true - restart: unless-stopped - command: - [ - "node", - "dist/index.js", - "gateway", - "--bind", - "${OPENCLAW_GATEWAY_BIND:-lan}", - "--port", - "18789", - ] - - openclaw-cli: - image: alpine/openclaw - build: - args: - OPENCLAW_DOCKER_APT_PACKAGES: "git curl jq ffmpeg build-essentials fzf ripgrep fd imagemagick exiftool" - user: 1000:1000 - environment: - HOME: /home/node - TERM: xterm-256color - OPENROUTER_API_KEY: ${OPENROUTER_KEY} - BRAVE_API_KEY: ${BRAVE_API_KEY} - TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} - BROWSER: echo - volumes: - - ./openclaw/config:/home/node/.openclaw - - ./openclaw/workspace:/home/node/.openclaw/workspace - - /mnt/tower/stardust/chris/files/:/files:ro - - openclaw:/home/node - stdin_open: true - tty: true - init: true - entrypoint: ["node", "dist/index.js"] - minecraft: profiles: ["prod"] <<: *logging @@ -355,7 +341,7 @@ services: - ND_BASEURL=https://radio.${DOMAIN} volumes: - navidrome:/data - - /mnt/tower/stardust/chris/files/Music:/music:ro + - /mnt/tower/stardust/chris/files/Library/Music:/music:ro networks: - nginx diff --git a/grocy/config/config-dist.php b/grocy/config/config-dist.php new file mode 100644 index 0000000..501f71d --- /dev/null +++ b/grocy/config/config-dist.php @@ -0,0 +1,233 @@ + BASE_PATH = /grocy +// Example without URL Rewriting support: +// Root URL = https://example.com/grocy/public/index.php/ +// => BASE_PATH = /grocy/public/index.php +Setting('BASE_PATH', ''); + +// The base URL of your installation, +// should be just "/" when running directly under the root of a (sub)domain +// or for example "https://example.com/grocy" when using a subdirectory +Setting('BASE_URL', '/'); + +// The plugin to use for external barcode lookups, +// must be the filename (folder "/plugins" for built-in plugins or "/data/plugins" for user plugins) without the .php extension, +// see /plugins/DemoBarcodeLookupPlugin.php for a commented example implementation +// Leave empty to disable external barcode lookups +Setting('STOCK_BARCODE_LOOKUP_PLUGIN', 'OpenFoodFactsBarcodeLookupPlugin'); + +// If, however, your webserver does not support URL rewriting, set this to true +Setting('DISABLE_URL_REWRITING', false); + +// Specify an custom homepage if desired, by default the homepage will be set to the stock overview page +// This needs to be one of the following values: +// stock, shoppinglist, recipes, chores, tasks, batteries, equipment, calendar, mealplan +Setting('ENTRY_PAGE', 'stock'); + +// Set this to true if you want to disable authentication / the login screen, +// places where user context is needed will then use the default (first existing) user +Setting('DISABLE_AUTH', false); + +// Either "Grocy\Middleware\DefaultAuthMiddleware", "Grocy\Middleware\ReverseProxyAuthMiddleware" +// or any class that implements Grocy\Middleware\AuthMiddleware +Setting('AUTH_CLASS', 'Grocy\Middleware\DefaultAuthMiddleware'); + +// Options when using ReverseProxyAuthMiddleware +Setting('REVERSE_PROXY_AUTH_HEADER', 'REMOTE_USER'); // The name of the HTTP header which your reverse proxy uses to pass the username (on successful authentication) +Setting('REVERSE_PROXY_AUTH_USE_ENV', false); // Set to true if the username is passed as environment variable + +// Options when using LdapAuthMiddleware +Setting('LDAP_ADDRESS', ''); // Example value "ldap://vm-dc2019.local.berrnd.net" +Setting('LDAP_BASE_DN', ''); // Example value "DC=local,DC=berrnd,DC=net" +Setting('LDAP_BIND_DN', ''); // Example value "CN=grocy_bind_account,OU=service_accounts,DC=local,DC=berrnd,DC=net" +Setting('LDAP_BIND_PW', ''); // Password for the above account +Setting('LDAP_USER_FILTER', ''); // Example value "(OU=grocy_users)" +Setting('LDAP_UID_ATTR', ''); // Windows AD: "sAMAccountName", OpenLDAP: "uid", GLAuth: "cn" + +// Default permissions for new users +// the array needs to contain the technical/constant names +// See the file controllers/Users/User.php for possible values +Setting('DEFAULT_PERMISSIONS', ['ADMIN']); + +// "1D" (=> Code128) or "2D" (=> DataMatrix) +Setting('GROCYCODE_TYPE', '2D'); + + +// Label printer settings +Setting('LABEL_PRINTER_WEBHOOK', ''); // The URI that Grocy will POST to when asked to print a label +Setting('LABEL_PRINTER_RUN_SERVER', true); // Whether the webhook will be called server- or client-side +Setting('LABEL_PRINTER_PARAMS', ['font_family' => 'Source Sans Pro (Regular)']); // Additional parameters supplied to the webhook +Setting('LABEL_PRINTER_HOOK_JSON', true); // TRUE to use JSON or FALSE to use normal POST request variables + + +// Thermal printer options +// Thermal printers are receipt printers, not regular printers, +// the printer must support the ESC/POS protocol, see https://github.com/mike42/escpos-php +Setting('TPRINTER_IS_NETWORK_PRINTER', false); // Set to true if it's a network printer +Setting('TPRINTER_PRINT_QUANTITY_NAME', true); // Set to false if you do not want to print the quantity names (related to the shopping list) +Setting('TPRINTER_PRINT_NOTES', true); // Set to false if you do not want to print notes (related to the shopping list) +Setting('TPRINTER_IP', '127.0.0.1'); // IP of the network printer (does only matter if it's a network printer) +Setting('TPRINTER_PORT', 9100); // Port of the network printer (does only matter if it's a network printer) +Setting('TPRINTER_CONNECTOR', '/dev/usb/lp0'); // Printer device (does only matter if you use a locally attached printer) +// For USB on Linux this is often '/dev/usb/lp0', for serial printers it could be similar to '/dev/ttyS0' +// Make sure that the user that runs the webserver has permissions to write to the printer - on Linux add your webserver user to the LP group with usermod -a -G lp www-data + + +// Feature flags +// Here you can disable the parts which you don't need to have a less cluttered UI +// (set the setting to "false" to disable the corresponding part, which should be self explanatory) +Setting('FEATURE_FLAG_STOCK', true); +Setting('FEATURE_FLAG_SHOPPINGLIST', true); +Setting('FEATURE_FLAG_RECIPES', true); +Setting('FEATURE_FLAG_CHORES', true); +Setting('FEATURE_FLAG_TASKS', true); +Setting('FEATURE_FLAG_BATTERIES', true); +Setting('FEATURE_FLAG_EQUIPMENT', true); +Setting('FEATURE_FLAG_CALENDAR', true); +Setting('FEATURE_FLAG_LABEL_PRINTER', false); + +// Sub feature flags +Setting('FEATURE_FLAG_STOCK_PRICE_TRACKING', true); +Setting('FEATURE_FLAG_STOCK_LOCATION_TRACKING', true); +Setting('FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING', true); +Setting('FEATURE_FLAG_STOCK_PRODUCT_OPENED_TRACKING', true); +Setting('FEATURE_FLAG_STOCK_PRODUCT_FREEZING', true); +Setting('FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_FIELD_NUMBER_PAD', true); // Activate the number pad in due date fields on (supported) mobile browsers +Setting('FEATURE_FLAG_SHOPPINGLIST_MULTIPLE_LISTS', true); +Setting('FEATURE_FLAG_RECIPES_MEALPLAN', true); +Setting('FEATURE_FLAG_CHORES_ASSIGNMENTS', true); +Setting('FEATURE_FLAG_THERMAL_PRINTER', false); + +// Feature settings +Setting('FEATURE_FLAG_DISABLE_BROWSER_BARCODE_CAMERA_SCANNING', false); // Set this to true if you want to disable the ability to scan a barcode via the device camera (Browser API) +Setting('FEATURE_FLAG_AUTO_TORCH_ON_WITH_CAMERA', true); // Enables the torch automatically (if the device has one) + + +// Default user settings +// These settings can be changed per user and via the UI, +// below are the defaults which are used when the user has not changed the setting so far + +// Night mode related +DefaultUserSetting('night_mode', 'follow-system'); // "on" = Night mode is always on ; "off" = Night mode is always off / "follow-system" = System preferred color schema is used +DefaultUserSetting('auto_night_mode_enabled', false); // If night mode is enabled automatically when inside a given time range (see the two settings below) +DefaultUserSetting('auto_night_mode_time_range_from', '20:00'); // Format HH:mm +DefaultUserSetting('auto_night_mode_time_range_to', '07:00'); // Format HH:mm +DefaultUserSetting('auto_night_mode_time_range_goes_over_midnight', true); // If the time range above goes over midnight +DefaultUserSetting('night_mode_enabled_internal', false); // Internal setting if night mode is actually enabled (based on the other settings) + +// Generic settings +DefaultUserSetting('auto_reload_on_db_change', false); // If the page should be automatically reloaded when there was an external change +DefaultUserSetting('show_clock_in_header', false); // Show a clock in the header next to the logo or not +DefaultUserSetting('keep_screen_on', false); // If the screen should always be kept on +DefaultUserSetting('keep_screen_on_when_fullscreen_card', false); // If the screen should be kept on when a "fullscreen-card" is displayed + +// Stock settings +DefaultUserSetting('product_presets_location_id', -1); // Default location id for new products (-1 means no location is preset) +DefaultUserSetting('product_presets_product_group_id', -1); // Default product group id for new products (-1 means no product group is preset) +DefaultUserSetting('product_presets_qu_id', -1); // Default quantity unit id for new products (-1 means no quantity unit is preset) +DefaultUserSetting('product_presets_default_due_days', 0); // Default due days for new products (-1 means that the product will be never overdue) +DefaultUserSetting('product_presets_treat_opened_as_out_of_stock', true); // Default "Treat opened as out of stock" option for new products +DefaultUserSetting('product_presets_default_stock_label_type', 0); // "Default stock entry label" option for new products (0 = No label, 1 = Single Label, 2 = Label per unit) +DefaultUserSetting('stock_decimal_places_amounts', 4); // Default decimal places allowed for amounts +DefaultUserSetting('stock_decimal_places_prices_input', 2); // Default decimal places allowed for prices (input) +DefaultUserSetting('stock_decimal_places_prices_display', 2); // Default decimal places allowed for prices (display) +DefaultUserSetting('stock_auto_decimal_separator_prices', false); // If the decimal separator should be set automatically for amount inputs +DefaultUserSetting('stock_due_soon_days', 5); // The "expiring soon" days +DefaultUserSetting('stock_default_purchase_amount', 0); // The default amount prefilled on the purchase page +DefaultUserSetting('stock_default_consume_amount', 1); // The default amount prefilled on the consume page +DefaultUserSetting('stock_default_consume_amount_use_quick_consume_amount', false); // If the products quick consume amount should be prefilled on the consume page +DefaultUserSetting('scan_mode_consume_enabled', false); // If scan mode on the consume page is enabled +DefaultUserSetting('scan_mode_purchase_enabled', false); // If scan mode on the purchase page is enabled +DefaultUserSetting('show_icon_on_stock_overview_page_when_product_is_on_shopping_list', true); // When enabled, an icon is shown on the stock overview page (next to the product name) when the prodcut is currently on a shopping list +DefaultUserSetting('stock_overview_show_all_out_of_stock_products', false); // By default the stock overview page lists all products which are currently in stock or below their min. stock amount - when this is enabled, all (active) products are always shown +DefaultUserSetting('show_purchased_date_on_purchase', false); // Whether the purchased date should be editable on purchase (defaults to today otherwise) +DefaultUserSetting('show_warning_on_purchase_when_due_date_is_earlier_than_next', true); // Show a warning on purchase when the due date of the purchased product is earlier than the next due date in stock + +// Shopping list settings +DefaultUserSetting('shopping_list_to_stock_workflow_auto_submit_when_prefilled', false); // Automatically do the booking using the last price and the amount of the shopping list item, if the product has "Default due days" set +DefaultUserSetting('shopping_list_show_calendar', false); // When enabled, a small (month view) calendar will be shown on the shopping list page +DefaultUserSetting('shopping_list_round_up', false); // When enabled, all quantity amounts on the shopping list are always displayed rounded up to the nearest whole number +DefaultUserSetting('shopping_list_auto_add_below_min_stock_amount', false); // If products should be automatically added to the shopping list when they are below their min. stock amount +DefaultUserSetting('shopping_list_auto_add_below_min_stock_amount_list_id', 1); // When the above setting is enabled, the id of the shopping list to which the products will be added +DefaultUserSetting('shopping_list_print_show_header', true); // Default for the shopping list print option "Show header" +DefaultUserSetting('shopping_list_print_group_by_product_group', true); // Default for the shopping list print option "Group by product group" +DefaultUserSetting('shopping_list_print_layout_type', 'table'); // Default for the shopping list print option "Layout type" (table or list) + +// Recipe settings +DefaultUserSetting('recipe_ingredients_group_by_product_group', false); // Group recipe ingredients by their product group +DefaultUserSetting('recipes_show_list_side_by_side', true); // If the recipe should be displayed next to recipe list on the recipes page +DefaultUserSetting('recipes_show_ingredient_checkbox', false); // When enabled, a little checkbox will be shown next to each ingredient to mark it as done + +// Chores settings +DefaultUserSetting('chores_due_soon_days', 5); // The "due soon" days +DefaultUserSetting('chores_overview_swap_tracking_buttons', false); // When enabled, the "Track next chore schedule" and "Track chore execution now" buttons/menu items are swapped + +// Batteries settings +DefaultUserSetting('batteries_due_soon_days', 5); // The "due soon" days + +// Tasks settings +DefaultUserSetting('tasks_due_soon_days', 5); // The "due soon" days + +// Calendar settings +DefaultUserSetting('calendar_color_products', '#007bff'); // The event color (hex code) for due products +DefaultUserSetting('calendar_color_tasks', '#28a745'); // The event color (hex code) for due tasks +DefaultUserSetting('calendar_color_chores', '#ffc107'); // The event color (hex code) for due chores +DefaultUserSetting('calendar_color_batteries', '#17a2b8'); // The event color (hex code) for due battery charge cycles +DefaultUserSetting('calendar_color_meal_plan', '#6c757d'); // The event color (hex code) for meal plan items diff --git a/nextcloud/Dockerfile b/nextcloud/Dockerfile index c7d53a1..7f968fc 100644 --- a/nextcloud/Dockerfile +++ b/nextcloud/Dockerfile @@ -1,4 +1,4 @@ -FROM nextcloud:32-fpm-alpine +FROM nextcloud:34-fpm-alpine RUN set -ex; \ \ diff --git a/searxng/settings.yml.new b/searxng/settings.yml.new index 3b05662..7a98b05 100644 --- a/searxng/settings.yml.new +++ b/searxng/settings.yml.new @@ -23,7 +23,6 @@ brand: wiki_url: https://github.com/searxng/searxng/wiki issue_url: https://github.com/searxng/searxng/issues # custom: - # maintainer: "Jon Doe" # # Custom entries in the footer: [title]: [link] # links: # Uptime: https://uptime.searxng.org/history/darmarit-org @@ -133,8 +132,6 @@ ui: # query_in_title: When true, the result page's titles contains the query # it decreases the privacy, since the browser can records the page titles. query_in_title: false - # infinite_scroll: When true, automatically loads the next page when scrolling to bottom of the current page. - infinite_scroll: false # ui theme default_theme: simple # center the results ? @@ -171,7 +168,6 @@ ui: # - locale # - theme # - results_on_new_tab -# - infinite_scroll # - search_on_category_select # - method # - image_proxy @@ -223,6 +219,7 @@ outgoing: # - 1.1.1.2 # - fe80::/126 + # Plugin configuration, for more details see # https://docs.searxng.org/admin/settings/settings_plugins.html # @@ -359,25 +356,6 @@ engines: timeout: 6 disabled: true - - name: alexandria - engine: json_engine - shortcut: alx - categories: general - paging: true - search_url: https://api.alexandria.org/?a=1&q={query}&p={pageno} - results_query: results - title_query: title - url_query: url - content_query: snippet - timeout: 1.5 - disabled: true - about: - website: https://alexandria.org/ - official_api_documentation: https://github.com/alexandria-org/alexandria-api/raw/master/README.md - use_official_api: true - require_api_key: false - results: JSON - - name: astrophysics data system engine: astrophysics_data_system shortcut: ads @@ -685,7 +663,6 @@ engines: # cf_ai_model_assistant: 'prompts_for_assistant_role' # cf_ai_model_system: 'prompts_for_system_role' timeout: 30 - disabled: true inactive: true - name: core.ac.uk @@ -803,21 +780,6 @@ engines: require_api_key: false results: HTML -<<<<<<< HEAD - # - name: elasticsearch - # shortcut: els - # engine: elasticsearch - # base_url: http://localhost:9200 - # username: elastic - # password: changeme - # index: my-index - # enable_http: true - # # available options: match, simple_query_string, term, terms, custom - # query_type: match - # # if query_type is set to custom, provide your query here - # # custom_query_json: {"query":{"match_all": {}}} - # # show_metadata: false - # disabled: true - name: elasticsearch shortcut: els engine: elasticsearch