Some stuff
This commit is contained in:
@@ -94,6 +94,9 @@ COLLABORA_PASSWORD=
|
|||||||
#
|
#
|
||||||
PIHOLE_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)
|
# Jitsi settings (comms.scarif.space)
|
||||||
# Image: jitsi/web:latest
|
# 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
|
# PLAYWRIGHT_MICROSERVICE_URL: http://playwright:3000/scrape
|
||||||
|
|
||||||
services:
|
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:
|
foundry:
|
||||||
profiles: ["prod"]
|
profiles: ["prod"]
|
||||||
@@ -62,7 +47,7 @@ services:
|
|||||||
profiles: ["prod", "dev"]
|
profiles: ["prod", "dev"]
|
||||||
<<: *logging
|
<<: *logging
|
||||||
build: ./nextcloud
|
build: ./nextcloud
|
||||||
image: nextcloud:32-fpm-alpine
|
image: nextcloud:33-fpm-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: 1000:1000
|
user: 1000:1000
|
||||||
ports:
|
ports:
|
||||||
@@ -166,61 +151,125 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|
||||||
librechat:
|
audiobookshelf:
|
||||||
image: ghcr.io/danny-avila/librechat-dev-api:latest
|
image: ghcr.io/advplyr/audiobookshelf:latest
|
||||||
ports:
|
volumes:
|
||||||
- 3080:3080
|
- /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:
|
networks:
|
||||||
- nginx
|
- nginx
|
||||||
- redis
|
|
||||||
- db
|
kavita:
|
||||||
depends_on:
|
image: jvmilazz0/kavita:latest # Using the stable branch from the official dockerhub repo.
|
||||||
- mongodb
|
container_name: kavita
|
||||||
# - 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:
|
volumes:
|
||||||
- type: bind
|
# - /your/path/to/comics:/comics
|
||||||
source: ./librechat/librechat.yaml
|
- /mnt/tower/stardust/chris/files/Library/Books:/books
|
||||||
target: /app/librechat.yaml
|
- /mnt/tower/stardust/chris/files/Library/Books/.kavita/config:/kavita/config
|
||||||
- /mnt/tower/stardust/chris/files/.droid/images:/app/client/public/images
|
environment:
|
||||||
- /mnt/tower/stardust/chris/files/.droid/uploads:/app/uploads
|
- TZ=Europe/London
|
||||||
- /mnt/tower/stardust/chris/files/.droid/logs:/app/logs
|
- VIRTUAL_HOST=datapad.${DOMAIN}
|
||||||
- /mnt/tower/stardust/chris/files/:/files
|
- 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:
|
searxng:
|
||||||
image: docker.io/searxng/searxng:latest
|
image: docker.io/searxng/searxng:latest
|
||||||
@@ -239,69 +288,6 @@ services:
|
|||||||
- CERT_NAME=${DOMAIN}
|
- CERT_NAME=${DOMAIN}
|
||||||
<<: *logging
|
<<: *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:
|
minecraft:
|
||||||
profiles: ["prod"]
|
profiles: ["prod"]
|
||||||
<<: *logging
|
<<: *logging
|
||||||
@@ -355,7 +341,7 @@ services:
|
|||||||
- ND_BASEURL=https://radio.${DOMAIN}
|
- ND_BASEURL=https://radio.${DOMAIN}
|
||||||
volumes:
|
volumes:
|
||||||
- navidrome:/data
|
- navidrome:/data
|
||||||
- /mnt/tower/stardust/chris/files/Music:/music:ro
|
- /mnt/tower/stardust/chris/files/Library/Music:/music:ro
|
||||||
networks:
|
networks:
|
||||||
- nginx
|
- 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; \
|
RUN set -ex; \
|
||||||
\
|
\
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ brand:
|
|||||||
wiki_url: https://github.com/searxng/searxng/wiki
|
wiki_url: https://github.com/searxng/searxng/wiki
|
||||||
issue_url: https://github.com/searxng/searxng/issues
|
issue_url: https://github.com/searxng/searxng/issues
|
||||||
# custom:
|
# custom:
|
||||||
# maintainer: "Jon Doe"
|
|
||||||
# # Custom entries in the footer: [title]: [link]
|
# # Custom entries in the footer: [title]: [link]
|
||||||
# links:
|
# links:
|
||||||
# Uptime: https://uptime.searxng.org/history/darmarit-org
|
# 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
|
# 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.
|
# it decreases the privacy, since the browser can records the page titles.
|
||||||
query_in_title: false
|
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
|
# ui theme
|
||||||
default_theme: simple
|
default_theme: simple
|
||||||
# center the results ?
|
# center the results ?
|
||||||
@@ -171,7 +168,6 @@ ui:
|
|||||||
# - locale
|
# - locale
|
||||||
# - theme
|
# - theme
|
||||||
# - results_on_new_tab
|
# - results_on_new_tab
|
||||||
# - infinite_scroll
|
|
||||||
# - search_on_category_select
|
# - search_on_category_select
|
||||||
# - method
|
# - method
|
||||||
# - image_proxy
|
# - image_proxy
|
||||||
@@ -223,6 +219,7 @@ outgoing:
|
|||||||
# - 1.1.1.2
|
# - 1.1.1.2
|
||||||
# - fe80::/126
|
# - fe80::/126
|
||||||
|
|
||||||
|
|
||||||
# Plugin configuration, for more details see
|
# Plugin configuration, for more details see
|
||||||
# https://docs.searxng.org/admin/settings/settings_plugins.html
|
# https://docs.searxng.org/admin/settings/settings_plugins.html
|
||||||
#
|
#
|
||||||
@@ -359,25 +356,6 @@ engines:
|
|||||||
timeout: 6
|
timeout: 6
|
||||||
disabled: true
|
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
|
- name: astrophysics data system
|
||||||
engine: astrophysics_data_system
|
engine: astrophysics_data_system
|
||||||
shortcut: ads
|
shortcut: ads
|
||||||
@@ -685,7 +663,6 @@ engines:
|
|||||||
# cf_ai_model_assistant: 'prompts_for_assistant_role'
|
# cf_ai_model_assistant: 'prompts_for_assistant_role'
|
||||||
# cf_ai_model_system: 'prompts_for_system_role'
|
# cf_ai_model_system: 'prompts_for_system_role'
|
||||||
timeout: 30
|
timeout: 30
|
||||||
disabled: true
|
|
||||||
inactive: true
|
inactive: true
|
||||||
|
|
||||||
- name: core.ac.uk
|
- name: core.ac.uk
|
||||||
@@ -803,21 +780,6 @@ engines:
|
|||||||
require_api_key: false
|
require_api_key: false
|
||||||
results: HTML
|
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
|
- name: elasticsearch
|
||||||
shortcut: els
|
shortcut: els
|
||||||
engine: elasticsearch
|
engine: elasticsearch
|
||||||
|
|||||||
Reference in New Issue
Block a user