Some stuff

This commit is contained in:
2026-08-04 22:02:01 +01:00
parent 4ba21094ca
commit 8faa717ebb
22 changed files with 2349 additions and 173 deletions
+3
View File
@@ -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
View File
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 }
+119 -133
View File
@@ -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:
@@ -165,62 +150,126 @@ services:
- nginx - nginx
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
+233
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
FROM nextcloud:32-fpm-alpine FROM nextcloud:34-fpm-alpine
RUN set -ex; \ RUN set -ex; \
\ \
+1 -39
View File
@@ -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