Some stuff
This commit is contained in:
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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<void>} - 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<void>} - 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 }
|
||||
@@ -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<void>} - 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<void>} - 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 }
|
||||
@@ -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<void>} - 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<void>} - 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 }
|
||||
@@ -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<void>} - 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<void>} - 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 }
|
||||
@@ -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<void>} - 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<void>} - 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<Record<string, SequelizeFKObj>|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<string, SequelizeFKObj>} 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<boolean>} - 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 }
|
||||
@@ -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<void>} - 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<void>} - 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 }
|
||||
@@ -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<void>} - 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<void>} - 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 }
|
||||
@@ -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<void>} - 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<void>} - 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 }
|
||||
@@ -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<void>} - 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<void>} - 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 }
|
||||
@@ -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<void>} - 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<void>} - 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 }
|
||||
@@ -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<void>} - 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<void>} - 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 }
|
||||
@@ -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<void>} - 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<void>} - 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 }
|
||||
@@ -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<void>} - 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<void>} - 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 }
|
||||
@@ -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 }
|
||||
@@ -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<void>} - 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<void>} - 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 }
|
||||
+118
-132
@@ -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:
|
||||
@@ -166,61 +151,125 @@ services:
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
// Settings can also be overwritten in two ways:
|
||||
//
|
||||
// First priority:
|
||||
// A .txt file with the same name as the setting in /data/settingoverrides
|
||||
// the content of the file is used as the setting value
|
||||
//
|
||||
// Second priority:
|
||||
// An environment variable with the same name as the setting and prefix "GROCY_"
|
||||
// so for example "GROCY_BASE_URL"
|
||||
//
|
||||
// Third priority:
|
||||
// The settings defined here below
|
||||
|
||||
// Either "production", "dev", "demo" or "prerelease"
|
||||
// When not "production", the application will work in a demo mode which means
|
||||
// authentication is disabled and some demo data will be generated during the database schema migration
|
||||
// (pass the query parameter "nodemodata", e.g. https://grocy.example.com/?nodemodata to skip that)
|
||||
Setting('MODE', 'production');
|
||||
|
||||
// The directory name of one of the available localization folders
|
||||
// in the "/localization" directory (e.g. "en" or "de")
|
||||
// Grocy uses the first available locale / setting in this order
|
||||
// 1. Browser prefered locale
|
||||
// 2. The one set in user settings
|
||||
// 3. The one defined here below
|
||||
Setting('DEFAULT_LOCALE', 'en_GB');
|
||||
|
||||
// This is used to define the first day of a week for calendar views,
|
||||
// leave empty to use the locale default
|
||||
// Needs to be a number where Sunday = 0, Monday = 1 and so forth
|
||||
Setting('CALENDAR_FIRST_DAY_OF_WEEK', '');
|
||||
|
||||
// If calendars should show week numbers
|
||||
Setting('CALENDAR_SHOW_WEEK_OF_YEAR', true);
|
||||
|
||||
// Set this if you want to have a different start day for the weekly meal plan view,
|
||||
// leave empty to use CALENDAR_FIRST_DAY_OF_WEEK (see above)
|
||||
// Needs to be a number where Sunday = 0, Monday = 1 and so forth
|
||||
// Can also be set to -1 to dynamically start the meal plan week on "today"
|
||||
Setting('MEAL_PLAN_FIRST_DAY_OF_WEEK', '');
|
||||
|
||||
// To keep it simple: Grocy does not handle any currency conversions,
|
||||
// this here is used to format all money values,
|
||||
// so doesn't really matter, but needs to be the
|
||||
// ISO 4217 code of the currency ("USD", "EUR", "GBP", etc.)
|
||||
Setting('CURRENCY', 'GBP');
|
||||
|
||||
// Your preferred unit for energy
|
||||
// E.g. "kcal" or "kJ" or something else (doesn't really matter, it's only used to display energy values)
|
||||
Setting('ENERGY_UNIT', 'kcal');
|
||||
|
||||
// When running Grocy in a subdirectory, this should be set to the relative path, otherwise empty
|
||||
// It needs to be set to the part (of the URL) AFTER the document root,
|
||||
// if URL rewriting is disabled, including index.php
|
||||
// Example with URL Rewriting support:
|
||||
// Root URL = https://example.com/grocy
|
||||
// => 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
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM nextcloud:32-fpm-alpine
|
||||
FROM nextcloud:34-fpm-alpine
|
||||
|
||||
RUN set -ex; \
|
||||
\
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user