From 70f466d03c4c27d99070d764a2eddac0bdccc9f8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 28 Nov 2024 17:18:34 -0600 Subject: [PATCH 01/15] Add migration for v2.17.3 to fix dropped fk constraints --- server/migrations/changelog.md | 13 +- server/migrations/v2.17.3-fk-constraints.js | 219 ++++++++++++++++++++ 2 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 server/migrations/v2.17.3-fk-constraints.js diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 8960ade2..51e82600 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,9 +2,10 @@ Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. -| Server Version | Migration Script Name | Description | -| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | -| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | -| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | -| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | -| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | +| Server Version | Migration Script Name | Description | +| -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | +| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | +| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | +| v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration | diff --git a/server/migrations/v2.17.3-fk-constraints.js b/server/migrations/v2.17.3-fk-constraints.js new file mode 100644 index 00000000..a62307a3 --- /dev/null +++ b/server/migrations/v2.17.3-fk-constraints.js @@ -0,0 +1,219 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script changes foreign key constraints for the + * libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, and mediaProgresses tables. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints') + + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) + + // Disable foreign key constraints for the next sequence of operations + await execQuery(`PRAGMA foreign_keys = OFF;`) + + try { + await execQuery(`BEGIN TRANSACTION;`) + + logger.info('[2.17.3 migration] Updating libraryItems constraints') + const libraryItemsConstraints = [ + { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, + { field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } + ] + await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints) + logger.info('[2.17.3 migration] Finished updating libraryItems constraints') + + logger.info('[2.17.3 migration] Updating feeds constraints') + const feedsConstraints = [{ field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'feeds', feedsConstraints) + logger.info('[2.17.3 migration] Finished updating 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' }] + await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints) + logger.info('[2.17.3 migration] Finished updating 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' } + ] + await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints) + logger.info('[2.17.3 migration] Finished updating playbackSessions constraints') + + logger.info('[2.17.3 migration] Updating playlistMediaItems constraints') + const playlistMediaItemsConstraints = [{ field: 'playlistId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints) + logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints') + + logger.info('[2.17.3 migration] Updating mediaProgresses constraints') + const mediaProgressesConstraints = [{ field: 'userId', onDelete: 'CASCADE', onUpdate: 'CASCADE' }] + await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints) + logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints') + + await execQuery(`COMMIT;`) + } catch (error) { + logger.error(`[2.17.3 migration] Migration failed - rolling back. Error:`, error) + await execQuery(`ROLLBACK;`) + } + + await execQuery(`PRAGMA foreign_keys = ON;`) + + // Completed migration + logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints') +} + +/** + * This downward migration script is a no-op. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-fk-constraints') + + // This migration is a no-op + logger.info('[2.17.3 migration] No action required for downgrade') + + // Completed migration + logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-fk-constraints') +} + +/** + * @typedef ConstraintUpdateObj + * @property {string} field - The field to update + * @property {string} onDelete - The onDelete constraint + * @property {string} onUpdate - The onUpdate constraint + */ + +const formatFKsPragmaToSequelizeFK = (fk) => { + let onDelete = fk['on_delete'] + let onUpdate = fk['on_update'] + + if (fk.from === 'userId' || fk.from === 'libraryId' || fk.from === 'deviceId') { + onDelete = 'SET NULL' + onUpdate = 'CASCADE' + } + + return { + references: { + model: fk.table, + key: fk.to + }, + constraints: { + onDelete, + onUpdate + } + } +} + +/** + * Extends the Sequelize describeTable function to include the foreign keys constraints in sqlite dbs + * @param {import('sequelize').QueryInterface} queryInterface + * @param {String} tableName - The table name + * @param {ConstraintUpdateObj[]} constraints - constraints to update + */ +async function describeTableWithFKs(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});`) + + const foreignKeysByColName = foreignKeys.reduce((prev, curr) => { + const fk = formatFKsPragmaToSequelizeFK(curr) + return { ...prev, [curr.from]: fk } + }, {}) + + const tableDescription = await queryInterface.describeTable(tableName) + + const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => { + let extendedAttributes = attributes + + if (foreignKeysByColName[col]) { + // Use the constraints from the constraints array if they exist, otherwise use the existing constraints + const onDelete = constraints.find((c) => c.field === col)?.onDelete || foreignKeysByColName[col].constraints.onDelete + const onUpdate = constraints.find((c) => c.field === col)?.onUpdate || foreignKeysByColName[col].constraints.onUpdate + + extendedAttributes = { + ...extendedAttributes, + references: foreignKeysByColName[col].references, + onDelete, + onUpdate + } + } + 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 + */ +async function changeConstraints(queryInterface, tableName, constraints) { + 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, constraints) + + 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 Promise.resolve() + } catch (error) { + return Promise.reject(error) + } +} + +module.exports = { up, down } From 4b52f31d58216875f9429f1ace2314bb4061d19f Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 30 Nov 2024 15:48:20 -0600 Subject: [PATCH 02/15] Update v2.17.3 migration file to first check if constraints need to be updated, add unit test --- server/migrations/v2.17.3-fk-constraints.js | 116 ++++++--- .../migrations/v2.17.3-fk-constraints.test.js | 230 ++++++++++++++++++ 2 files changed, 308 insertions(+), 38 deletions(-) create mode 100644 test/server/migrations/v2.17.3-fk-constraints.test.js diff --git a/server/migrations/v2.17.3-fk-constraints.js b/server/migrations/v2.17.3-fk-constraints.js index a62307a3..5f8a5c9a 100644 --- a/server/migrations/v2.17.3-fk-constraints.js +++ b/server/migrations/v2.17.3-fk-constraints.js @@ -31,19 +31,28 @@ async function up({ context: { queryInterface, logger } }) { { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, { field: 'libraryFolderId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } ] - await changeConstraints(queryInterface, 'libraryItems', libraryItemsConstraints) - logger.info('[2.17.3 migration] Finished updating libraryItems constraints') + 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' }] - await changeConstraints(queryInterface, 'feeds', feedsConstraints) - logger.info('[2.17.3 migration] Finished updating feeds constraints') + 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' }] - await changeConstraints(queryInterface, 'mediaItemShares', mediaItemSharesConstraints) - logger.info('[2.17.3 migration] Finished updating mediaItemShares constraints') + 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') } @@ -54,18 +63,27 @@ async function up({ context: { queryInterface, logger } }) { { field: 'libraryId', onDelete: 'SET NULL', onUpdate: 'CASCADE' }, { field: 'userId', onDelete: 'SET NULL', onUpdate: 'CASCADE' } ] - await changeConstraints(queryInterface, 'playbackSessions', playbackSessionsConstraints) - logger.info('[2.17.3 migration] Finished updating playbackSessions constraints') + 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' }] - await changeConstraints(queryInterface, 'playlistMediaItems', playlistMediaItemsConstraints) - logger.info('[2.17.3 migration] Finished updating playlistMediaItems constraints') + 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' }] - await changeConstraints(queryInterface, 'mediaProgresses', mediaProgressesConstraints) - logger.info('[2.17.3 migration] Finished updating mediaProgresses constraints') + 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) { @@ -103,59 +121,75 @@ async function down({ context: { queryInterface, logger } }) { * @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) => { - let onDelete = fk['on_delete'] - let onUpdate = fk['on_update'] - - if (fk.from === 'userId' || fk.from === 'libraryId' || fk.from === 'deviceId') { - onDelete = 'SET NULL' - onUpdate = 'CASCADE' - } - return { references: { model: fk.table, key: fk.to }, - constraints: { - onDelete, - onUpdate - } + onDelete: fk['on_delete'], + onUpdate: fk['on_update'] } } /** - * Extends the Sequelize describeTable function to include the foreign keys constraints in sqlite dbs + * * @param {import('sequelize').QueryInterface} queryInterface - * @param {String} tableName - The table name - * @param {ConstraintUpdateObj[]} constraints - constraints to update + * @param {string} tableName + * @param {ConstraintUpdateObj[]} constraints + * @returns {Promise|null>} */ -async function describeTableWithFKs(queryInterface, tableName, constraints) { +async function getUpdatedForeignKeys(queryInterface, tableName, constraints) { const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) const quotedTableName = queryInterface.quoteIdentifier(tableName) const foreignKeys = await execQuery(`PRAGMA foreign_key_list(${quotedTableName});`) + let hasUpdates = false const foreignKeysByColName = foreignKeys.reduce((prev, curr) => { const fk = formatFKsPragmaToSequelizeFK(curr) + + const constraint = constraints.find((c) => c.field === curr.from) + if (constraint && (constraint.onDelete !== fk.onDelete || constraint.onUpdate !== fk.onUpdate)) { + fk.onDelete = constraint.onDelete + fk.onUpdate = constraint.onUpdate + hasUpdates = true + } + return { ...prev, [curr.from]: fk } }, {}) + return hasUpdates ? foreignKeysByColName : null +} + +/** + * Extends the Sequelize describeTable function to include the updated foreign key constraints + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {String} tableName + * @param {Record} updatedForeignKeys + */ +async function describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) { const tableDescription = await queryInterface.describeTable(tableName) const tableDescriptionWithFks = Object.entries(tableDescription).reduce((prev, [col, attributes]) => { let extendedAttributes = attributes - if (foreignKeysByColName[col]) { - // Use the constraints from the constraints array if they exist, otherwise use the existing constraints - const onDelete = constraints.find((c) => c.field === col)?.onDelete || foreignKeysByColName[col].constraints.onDelete - const onUpdate = constraints.find((c) => c.field === col)?.onUpdate || foreignKeysByColName[col].constraints.onUpdate - + if (updatedForeignKeys[col]) { extendedAttributes = { ...extendedAttributes, - references: foreignKeysByColName[col].references, - onDelete, - onUpdate + ...updatedForeignKeys[col] } } return { ...prev, [col]: extendedAttributes } @@ -171,8 +205,14 @@ async function describeTableWithFKs(queryInterface, tableName, constraints) { * @param {import('sequelize').QueryInterface} queryInterface * @param {string} tableName * @param {ConstraintUpdateObj[]} constraints + * @returns {Promise} - Return false if no changes are needed, true otherwise */ async function changeConstraints(queryInterface, tableName, constraints) { + const updatedForeignKeys = await getUpdatedForeignKeys(queryInterface, tableName, constraints) + if (!updatedForeignKeys) { + return false + } + const execQuery = queryInterface.sequelize.query.bind(queryInterface.sequelize) const quotedTableName = queryInterface.quoteIdentifier(tableName) @@ -180,7 +220,7 @@ async function changeConstraints(queryInterface, tableName, constraints) { const quotedBackupTableName = queryInterface.quoteIdentifier(backupTableName) try { - const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, constraints) + const tableDescriptionWithFks = await describeTableWithFKs(queryInterface, tableName, updatedForeignKeys) const attributes = queryInterface.queryGenerator.attributesToSQL(tableDescriptionWithFks) @@ -210,7 +250,7 @@ async function changeConstraints(queryInterface, tableName, constraints) { return Promise.reject(`Foreign key violations detected: ${JSON.stringify(result, null, 2)}`) } - return Promise.resolve() + return true } catch (error) { return Promise.reject(error) } diff --git a/test/server/migrations/v2.17.3-fk-constraints.test.js b/test/server/migrations/v2.17.3-fk-constraints.test.js new file mode 100644 index 00000000..33be43ce --- /dev/null +++ b/test/server/migrations/v2.17.3-fk-constraints.test.js @@ -0,0 +1,230 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { up } = require('../../../server/migrations/v2.17.3-fk-constraints') +const { Sequelize, QueryInterface } = require('sequelize') +const Logger = require('../../../server/Logger') + +describe('migration-v2.17.3-fk-constraints', () => { + let sequelize + /** @type {QueryInterface} */ + let queryInterface + let loggerInfoStub + + beforeEach(() => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + loggerInfoStub = sinon.stub(Logger, 'info') + }) + + afterEach(() => { + sinon.restore() + }) + + describe('up', () => { + beforeEach(async () => { + // Create associated tables: Users, libraries, libraryFolders, playlists, devices + await queryInterface.sequelize.query('CREATE TABLE `users` (`id` UUID PRIMARY KEY);') + await queryInterface.sequelize.query('CREATE TABLE `libraries` (`id` UUID PRIMARY KEY);') + await queryInterface.sequelize.query('CREATE TABLE `libraryFolders` (`id` UUID PRIMARY KEY);') + await queryInterface.sequelize.query('CREATE TABLE `playlists` (`id` UUID PRIMARY KEY);') + await queryInterface.sequelize.query('CREATE TABLE `devices` (`id` UUID PRIMARY KEY);') + }) + + afterEach(async () => { + await queryInterface.dropAllTables() + }) + + it('should fix table foreign key constraints', async () => { + // Create tables with missing foreign key constraints: libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, mediaProgresses + await queryInterface.sequelize.query('CREATE TABLE `libraryItems` (`id` UUID UNIQUE PRIMARY KEY, `libraryId` UUID REFERENCES `libraries` (`id`), `libraryFolderId` UUID REFERENCES `libraryFolders` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `feeds` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `mediaItemShares` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `playbackSessions` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`), `deviceId` UUID REFERENCES `devices` (`id`), `libraryId` UUID REFERENCES `libraries` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `playlistMediaItems` (`id` UUID UNIQUE PRIMARY KEY, `playlistId` UUID REFERENCES `playlists` (`id`));') + await queryInterface.sequelize.query('CREATE TABLE `mediaProgresses` (`id` UUID UNIQUE PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`));') + + // + // Validate that foreign key constraints are missing + // + let libraryItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(libraryItems);`) + expect(libraryItemsForeignKeys).to.have.deep.members([ + { id: 0, seq: 0, table: 'libraryFolders', from: 'libraryFolderId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }, + { id: 1, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' } + ]) + + let feedsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(feeds);`) + expect(feedsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }]) + + let mediaItemSharesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaItemShares);`) + expect(mediaItemSharesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }]) + + let playbackSessionForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playbackSessions);`) + expect(playbackSessionForeignKeys).to.deep.equal([ + { id: 0, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }, + { id: 1, seq: 0, table: 'devices', from: 'deviceId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }, + { id: 2, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' } + ]) + + let playlistMediaItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playlistMediaItems);`) + expect(playlistMediaItemsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'playlists', from: 'playlistId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }]) + + let mediaProgressesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaProgresses);`) + expect(mediaProgressesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'NO ACTION', on_delete: 'NO ACTION', match: 'NONE' }]) + + // + // Insert test data into tables + // + await queryInterface.bulkInsert('users', [{ id: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('libraries', [{ id: 'a41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('libraryFolders', [{ id: 'b41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('playlists', [{ id: 'f41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('devices', [{ id: 'g41a40e3-f516-40f5-810d-757ab668ebba' }]) + + await queryInterface.bulkInsert('libraryItems', [{ id: 'c1a96857-48a8-43b6-8966-abc909c55b0f', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba', libraryFolderId: 'b41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('feeds', [{ id: 'd1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('mediaItemShares', [{ id: 'h1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('playbackSessions', [{ id: 'f1a96857-48a8-43b6-8966-abc909c55b0x', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f', deviceId: 'g41a40e3-f516-40f5-810d-757ab668ebba', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('playlistMediaItems', [{ id: 'i1a96857-48a8-43b6-8966-abc909c55b0f', playlistId: 'f41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('mediaProgresses', [{ id: 'j1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + + // + // Query data before migration + // + const libraryItems = await queryInterface.sequelize.query('SELECT * FROM libraryItems;') + const feeds = await queryInterface.sequelize.query('SELECT * FROM feeds;') + const mediaItemShares = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;') + const playbackSessions = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;') + const playlistMediaItems = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;') + const mediaProgresses = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;') + + // + // Run migration + // + await up({ context: { queryInterface, logger: Logger } }) + + // + // Validate that foreign key constraints are updated + // + libraryItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(libraryItems);`) + expect(libraryItemsForeignKeys).to.have.deep.members([ + { id: 0, seq: 0, table: 'libraryFolders', from: 'libraryFolderId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }, + { id: 1, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' } + ]) + + feedsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(feeds);`) + expect(feedsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }]) + + mediaItemSharesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaItemShares);`) + expect(mediaItemSharesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }]) + + playbackSessionForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playbackSessions);`) + expect(playbackSessionForeignKeys).to.deep.equal([ + { id: 0, seq: 0, table: 'libraries', from: 'libraryId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }, + { id: 1, seq: 0, table: 'devices', from: 'deviceId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' }, + { id: 2, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'SET NULL', match: 'NONE' } + ]) + + playlistMediaItemsForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(playlistMediaItems);`) + expect(playlistMediaItemsForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'playlists', from: 'playlistId', to: 'id', on_update: 'CASCADE', on_delete: 'CASCADE', match: 'NONE' }]) + + mediaProgressesForeignKeys = await queryInterface.sequelize.query(`PRAGMA foreign_key_list(mediaProgresses);`) + expect(mediaProgressesForeignKeys).to.deep.equal([{ id: 0, seq: 0, table: 'users', from: 'userId', to: 'id', on_update: 'CASCADE', on_delete: 'CASCADE', match: 'NONE' }]) + + // + // Validate that data is not changed + // + const libraryItemsAfter = await queryInterface.sequelize.query('SELECT * FROM libraryItems;') + expect(libraryItemsAfter).to.deep.equal(libraryItems) + + const feedsAfter = await queryInterface.sequelize.query('SELECT * FROM feeds;') + expect(feedsAfter).to.deep.equal(feeds) + + const mediaItemSharesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;') + expect(mediaItemSharesAfter).to.deep.equal(mediaItemShares) + + const playbackSessionsAfter = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;') + expect(playbackSessionsAfter).to.deep.equal(playbackSessions) + + const playlistMediaItemsAfter = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;') + expect(playlistMediaItemsAfter).to.deep.equal(playlistMediaItems) + + const mediaProgressesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;') + expect(mediaProgressesAfter).to.deep.equal(mediaProgresses) + }) + + it('should keep correct table foreign key constraints', async () => { + // Create tables with correct foreign key constraints: libraryItems, feeds, mediaItemShares, playbackSessions, playlistMediaItems, mediaProgresses + await queryInterface.sequelize.query('CREATE TABLE `libraryItems` (`id` UUID PRIMARY KEY, `libraryId` UUID REFERENCES `libraries` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `libraryFolderId` UUID REFERENCES `libraryFolders` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `feeds` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `mediaItemShares` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `playbackSessions` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `deviceId` UUID REFERENCES `devices` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, `libraryId` UUID REFERENCES `libraries` (`id`) ON DELETE SET NULL ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `playlistMediaItems` (`id` UUID PRIMARY KEY, `playlistId` UUID REFERENCES `playlists` (`id`) ON DELETE CASCADE ON UPDATE CASCADE);') + await queryInterface.sequelize.query('CREATE TABLE `mediaProgresses` (`id` UUID PRIMARY KEY, `userId` UUID REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE);') + + // + // Insert test data into tables + // + await queryInterface.bulkInsert('users', [{ id: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('libraries', [{ id: 'a41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('libraryFolders', [{ id: 'b41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('playlists', [{ id: 'f41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('devices', [{ id: 'g41a40e3-f516-40f5-810d-757ab668ebba' }]) + + await queryInterface.bulkInsert('libraryItems', [{ id: 'c1a96857-48a8-43b6-8966-abc909c55b0f', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba', libraryFolderId: 'b41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('feeds', [{ id: 'd1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('mediaItemShares', [{ id: 'h1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + await queryInterface.bulkInsert('playbackSessions', [{ id: 'f1a96857-48a8-43b6-8966-abc909c55b0x', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f', deviceId: 'g41a40e3-f516-40f5-810d-757ab668ebba', libraryId: 'a41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('playlistMediaItems', [{ id: 'i1a96857-48a8-43b6-8966-abc909c55b0f', playlistId: 'f41a40e3-f516-40f5-810d-757ab668ebba' }]) + await queryInterface.bulkInsert('mediaProgresses', [{ id: 'j1a96857-48a8-43b6-8966-abc909c55b0f', userId: 'e1a96857-48a8-43b6-8966-abc909c55b0f' }]) + + // + // Query data before migration + // + const libraryItems = await queryInterface.sequelize.query('SELECT * FROM libraryItems;') + const feeds = await queryInterface.sequelize.query('SELECT * FROM feeds;') + const mediaItemShares = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;') + const playbackSessions = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;') + const playlistMediaItems = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;') + const mediaProgresses = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;') + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(14) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-fk-constraints'))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.17.3 migration] Updating libraryItems constraints'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.17.3 migration] No changes needed for libraryItems constraints'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.17.3 migration] Updating feeds constraints'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.17.3 migration] No changes needed for feeds constraints'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.17.3 migration] Updating mediaItemShares constraints'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.17.3 migration] No changes needed for mediaItemShares constraints'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.17.3 migration] Updating playbackSessions constraints'))).to.be.true + expect(loggerInfoStub.getCall(8).calledWith(sinon.match('[2.17.3 migration] No changes needed for playbackSessions constraints'))).to.be.true + expect(loggerInfoStub.getCall(9).calledWith(sinon.match('[2.17.3 migration] Updating playlistMediaItems constraints'))).to.be.true + expect(loggerInfoStub.getCall(10).calledWith(sinon.match('[2.17.3 migration] No changes needed for playlistMediaItems constraints'))).to.be.true + expect(loggerInfoStub.getCall(11).calledWith(sinon.match('[2.17.3 migration] Updating mediaProgresses constraints'))).to.be.true + expect(loggerInfoStub.getCall(12).calledWith(sinon.match('[2.17.3 migration] No changes needed for mediaProgresses constraints'))).to.be.true + expect(loggerInfoStub.getCall(13).calledWith(sinon.match('[2.17.3 migration] UPGRADE END: 2.17.3-fk-constraints'))).to.be.true + + // + // Validate that data is not changed + // + const libraryItemsAfter = await queryInterface.sequelize.query('SELECT * FROM libraryItems;') + expect(libraryItemsAfter).to.deep.equal(libraryItems) + + const feedsAfter = await queryInterface.sequelize.query('SELECT * FROM feeds;') + expect(feedsAfter).to.deep.equal(feeds) + + const mediaItemSharesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaItemShares;') + expect(mediaItemSharesAfter).to.deep.equal(mediaItemShares) + + const playbackSessionsAfter = await queryInterface.sequelize.query('SELECT * FROM playbackSessions;') + expect(playbackSessionsAfter).to.deep.equal(playbackSessions) + + const playlistMediaItemsAfter = await queryInterface.sequelize.query('SELECT * FROM playlistMediaItems;') + expect(playlistMediaItemsAfter).to.deep.equal(playlistMediaItems) + + const mediaProgressesAfter = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses;') + expect(mediaProgressesAfter).to.deep.equal(mediaProgresses) + }) + }) +}) From 60ba0163af004d590efeb0b0f3fc4c513b973662 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Fri, 22 Nov 2024 19:03:08 +0000 Subject: [PATCH 03/15] Translated using Weblate (German) Currently translated at 99.9% (1071 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/de.json b/client/strings/de.json index 6dff9338..030f8f1b 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird", "LabelUpdatedAt": "Aktualisiert am", "LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern", + "LabelUploaderDragAndDropFilesOnly": "Dateien per Drag & Drop hierher ziehen", "LabelUploaderDropFiles": "Dateien löschen", "LabelUploaderItemFetchMetadataHelp": "Automatisches Aktualisieren von Titel, Autor und Serie", "LabelUseAdvancedOptions": "Nutze Erweiterte Optionen", From d2c28fc69cf9a52ee41bcc9ffea05500382cf4f4 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Fri, 22 Nov 2024 09:14:11 +0000 Subject: [PATCH 04/15] Translated using Weblate (Spanish) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/es.json b/client/strings/es.json index b45d2534..76a62c16 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados", "LabelUpdatedAt": "Actualizado En", "LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas", + "LabelUploaderDragAndDropFilesOnly": "Arrastrar y soltar archivos", "LabelUploaderDropFiles": "Suelte los Archivos", "LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente", "LabelUseAdvancedOptions": "Usar opciones avanzadas", From 0449fb5ef92e7093929cef3951390b907ef80c18 Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Sat, 23 Nov 2024 11:40:05 +0000 Subject: [PATCH 05/15] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/uk.json b/client/strings/uk.json index 81cd13f4..448bbf4c 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Дозволити перезапис наявних подробиць обраних книг після віднайдення", "LabelUpdatedAt": "Оновлення", "LabelUploaderDragAndDrop": "Перетягніть файли або теки", + "LabelUploaderDragAndDropFilesOnly": "Перетягніть і скиньте файли", "LabelUploaderDropFiles": "Перетягніть файли", "LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію", "LabelUseAdvancedOptions": "Використовувати розширені налаштування", From 7278ad4ee759661091c1b650ab7cac38a3742184 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Fri, 22 Nov 2024 06:05:51 +0000 Subject: [PATCH 06/15] Translated using Weblate (Slovenian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index 366c8479..02c1fb13 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Dovoli prepisovanje obstoječih podrobnosti za izbrane knjige, ko se najde ujemanje", "LabelUpdatedAt": "Posodobljeno ob", "LabelUploaderDragAndDrop": "Povleci in spusti datoteke ali mape", + "LabelUploaderDragAndDropFilesOnly": "Povleci in spusti datoteke", "LabelUploaderDropFiles": "Spusti datoteke", "LabelUploaderItemFetchMetadataHelp": "Samodejno pridobi naslov, avtorja in serijo", "LabelUseAdvancedOptions": "Uporabi napredne možnosti", From 293e53029766c3324421816cf00954bf073da895 Mon Sep 17 00:00:00 2001 From: biuklija Date: Sun, 24 Nov 2024 08:57:39 +0000 Subject: [PATCH 07/15] Translated using Weblate (Croatian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/hr.json b/client/strings/hr.json index 502973c4..a7f2562b 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Dopusti prepisivanje postojećih podataka za odabrane knjige kada se prepoznaju", "LabelUpdatedAt": "Ažurirano", "LabelUploaderDragAndDrop": "Pritisni i prevuci datoteke ili mape", + "LabelUploaderDragAndDropFilesOnly": "Pritisni i prevuci datoteke", "LabelUploaderDropFiles": "Ispusti datoteke", "LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal", "LabelUseAdvancedOptions": "Koristi se naprednim opcijama", From ddcbfd450013c3a9f2310579a3543f34ad811a37 Mon Sep 17 00:00:00 2001 From: Soaibuzzaman Date: Mon, 25 Nov 2024 08:40:51 +0000 Subject: [PATCH 08/15] Translated using Weblate (Bengali) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bn/ --- client/strings/bn.json | 82 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/client/strings/bn.json b/client/strings/bn.json index b705a802..16f8a447 100644 --- a/client/strings/bn.json +++ b/client/strings/bn.json @@ -66,6 +66,7 @@ "ButtonPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কার করুন", "ButtonQueueAddItem": "সারিতে যোগ করুন", "ButtonQueueRemoveItem": "সারি থেকে মুছে ফেলুন", + "ButtonQuickEmbed": "দ্রুত এম্বেড করুন", "ButtonQuickEmbedMetadata": "মেটাডেটা দ্রুত এম্বেড করুন", "ButtonQuickMatch": "দ্রুত ম্যাচ", "ButtonReScan": "পুনরায় স্ক্যান", @@ -162,6 +163,7 @@ "HeaderNotificationUpdate": "বিজ্ঞপ্তি আপডেট করুন", "HeaderNotifications": "বিজ্ঞপ্তি", "HeaderOpenIDConnectAuthentication": "ওপেনআইডি সংযোগ প্রমাণীকরণ", + "HeaderOpenListeningSessions": "শোনার সেশন খুলুন", "HeaderOpenRSSFeed": "আরএসএস ফিড খুলুন", "HeaderOtherFiles": "অন্যান্য ফাইল", "HeaderPasswordAuthentication": "পাসওয়ার্ড প্রমাণীকরণ", @@ -179,6 +181,7 @@ "HeaderRemoveEpisodes": "{0}টি পর্ব সরান", "HeaderSavedMediaProgress": "মিডিয়া সংরক্ষণের অগ্রগতি", "HeaderSchedule": "সময়সূচী", + "HeaderScheduleEpisodeDownloads": "স্বয়ংক্রিয় পর্ব ডাউনলোডের সময়সূচী নির্ধারন করুন", "HeaderScheduleLibraryScans": "স্বয়ংক্রিয় লাইব্রেরি স্ক্যানের সময়সূচী", "HeaderSession": "সেশন", "HeaderSetBackupSchedule": "ব্যাকআপ সময়সূচী সেট করুন", @@ -224,7 +227,11 @@ "LabelAllUsersExcludingGuests": "অতিথি ব্যতীত সকল ব্যবহারকারী", "LabelAllUsersIncludingGuests": "অতিথি সহ সকল ব্যবহারকারী", "LabelAlreadyInYourLibrary": "ইতিমধ্যেই আপনার লাইব্রেরিতে রয়েছে", + "LabelApiToken": "API টোকেন", "LabelAppend": "সংযোজন", + "LabelAudioBitrate": "অডিও বিটরেট (যেমন- 128k)", + "LabelAudioChannels": "অডিও চ্যানেল (১ বা ২)", + "LabelAudioCodec": "অডিও কোডেক", "LabelAuthor": "লেখক", "LabelAuthorFirstLast": "লেখক (প্রথম শেষ)", "LabelAuthorLastFirst": "লেখক (শেষ, প্রথম)", @@ -237,6 +244,7 @@ "LabelAutoRegister": "স্বয়ংক্রিয় নিবন্ধন", "LabelAutoRegisterDescription": "লগ ইন করার পর স্বয়ংক্রিয়ভাবে নতুন ব্যবহারকারী তৈরি করুন", "LabelBackToUser": "ব্যবহারকারীর কাছে ফিরে যান", + "LabelBackupAudioFiles": "অডিও ফাইলগুলো ব্যাকআপ", "LabelBackupLocation": "ব্যাকআপ অবস্থান", "LabelBackupsEnableAutomaticBackups": "স্বয়ংক্রিয় ব্যাকআপ সক্ষম করুন", "LabelBackupsEnableAutomaticBackupsHelp": "ব্যাকআপগুলি /মেটাডাটা/ব্যাকআপে সংরক্ষিত", @@ -245,15 +253,18 @@ "LabelBackupsNumberToKeep": "ব্যাকআপের সংখ্যা রাখুন", "LabelBackupsNumberToKeepHelp": "এক সময়ে শুধুমাত্র ১ টি ব্যাকআপ সরানো হবে তাই যদি আপনার কাছে ইতিমধ্যে এর চেয়ে বেশি ব্যাকআপ থাকে তাহলে আপনাকে ম্যানুয়ালি সেগুলি সরিয়ে ফেলতে হবে।", "LabelBitrate": "বিটরেট", + "LabelBonus": "উপরিলাভ", "LabelBooks": "বইগুলো", "LabelButtonText": "ঘর পাঠ্য", "LabelByAuthor": "দ্বারা {0}", "LabelChangePassword": "পাসওয়ার্ড পরিবর্তন করুন", "LabelChannels": "চ্যানেল", + "LabelChapterCount": "{0} অধ্যায়", "LabelChapterTitle": "অধ্যায়ের শিরোনাম", "LabelChapters": "অধ্যায়", "LabelChaptersFound": "অধ্যায় পাওয়া গেছে", "LabelClickForMoreInfo": "আরো তথ্যের জন্য ক্লিক করুন", + "LabelClickToUseCurrentValue": "বর্তমান মান ব্যবহার করতে ক্লিক করুন", "LabelClosePlayer": "প্লেয়ার বন্ধ করুন", "LabelCodec": "কোডেক", "LabelCollapseSeries": "সিরিজ সঙ্কুচিত করুন", @@ -303,12 +314,25 @@ "LabelEmailSettingsTestAddress": "পরীক্ষার ঠিকানা", "LabelEmbeddedCover": "এম্বেডেড কভার", "LabelEnable": "সক্ষম করুন", + "LabelEncodingBackupLocation": "আপনার আসল অডিও ফাইলগুলোর একটি ব্যাকআপ এখানে সংরক্ষণ করা হবে:", + "LabelEncodingChaptersNotEmbedded": "মাল্টি-ট্র্যাক অডিওবুকগুলোতে অধ্যায় এম্বেড করা হয় না।", + "LabelEncodingClearItemCache": "পর্যায়ক্রমে আইটেম ক্যাশে পরিষ্কার করতে ভুলবেন না।", + "LabelEncodingFinishedM4B": "সমাপ্ত হওয়া M4B-গুলো আপনার অডিওবুক ফোল্ডারে এখানে রাখা হবে:", + "LabelEncodingInfoEmbedded": "আপনার অডিওবুক ফোল্ডারের ভিতরে অডিও ট্র্যাকগুলোতে মেটাডেটা এমবেড করা হবে।", + "LabelEncodingStartedNavigation": "একবার টাস্ক শুরু হলে আপনি এই পৃষ্ঠা থেকে অন্যত্র যেতে পারেন।", + "LabelEncodingTimeWarning": "এনকোডিং ৩০ মিনিট পর্যন্ত সময় নিতে পারে।", + "LabelEncodingWarningAdvancedSettings": "সতর্কতা: এই সেটিংস আপডেট করবেন না, যদি না আপনি ffmpeg এনকোডিং বিকল্পগুলোর সাথে পরিচিত হন।", + "LabelEncodingWatcherDisabled": "আপনার যদি পর্যবেক্ষক অক্ষম থাকে তবে আপনাকে পরে এই অডিওবুকটি পুনরায় স্ক্যান করতে হবে।", "LabelEnd": "সমাপ্ত", "LabelEndOfChapter": "অধ্যায়ের সমাপ্তি", "LabelEpisode": "পর্ব", + "LabelEpisodeNotLinkedToRssFeed": "পর্বটি আরএসএস ফিডের সাথে সংযুক্ত করা হয়নি", + "LabelEpisodeNumber": "পর্ব #{0}", "LabelEpisodeTitle": "পর্বের শিরোনাম", "LabelEpisodeType": "পর্বের ধরন", + "LabelEpisodeUrlFromRssFeed": "আরএসএস ফিড থেকে পর্ব URL", "LabelEpisodes": "পর্বগুলো", + "LabelEpisodic": "প্রাসঙ্গিক", "LabelExample": "উদাহরণ", "LabelExpandSeries": "সিরিজ প্রসারিত করুন", "LabelExpandSubSeries": "সাব সিরিজ প্রসারিত করুন", @@ -336,6 +360,7 @@ "LabelFontScale": "ফন্ট স্কেল", "LabelFontStrikethrough": "অবচ্ছেদন রেখা", "LabelFormat": "ফরম্যাট", + "LabelFull": "পূর্ণ", "LabelGenre": "ঘরানা", "LabelGenres": "ঘরানাগুলো", "LabelHardDeleteFile": "জোরপূর্বক ফাইল মুছে ফেলুন", @@ -391,6 +416,10 @@ "LabelLowestPriority": "সর্বনিম্ন অগ্রাধিকার", "LabelMatchExistingUsersBy": "বিদ্যমান ব্যবহারকারীদের দ্বারা মিলিত করুন", "LabelMatchExistingUsersByDescription": "বিদ্যমান ব্যবহারকারীদের সংযোগ করার জন্য ব্যবহৃত হয়। একবার সংযুক্ত হলে, ব্যবহারকারীদের আপনার SSO প্রদানকারীর থেকে একটি অনন্য আইডি দ্বারা মিলিত হবে", + "LabelMaxEpisodesToDownload": "সর্বাধিক # টি পর্ব ডাউনলোড করা হবে। অসীমের জন্য 0 ব্যবহার করুন।", + "LabelMaxEpisodesToDownloadPerCheck": "প্রতি কিস্তিতে সর্বাধিক # টি নতুন পর্ব ডাউনলোড করা হবে", + "LabelMaxEpisodesToKeep": "সর্বোচ্চ # টি পর্ব রাখা হবে", + "LabelMaxEpisodesToKeepHelp": "০ কোন সর্বোচ্চ সীমা সেট করে না। একটি নতুন পর্ব স্বয়ংক্রিয়-ডাউনলোড হওয়ার পরে আপনার যদি X-এর বেশি পর্ব থাকে তবে এটি সবচেয়ে পুরানো পর্বটি মুছে ফেলবে। এটি প্রতি নতুন ডাউনলোডের জন্য শুধুমাত্র ১ টি পর্ব মুছে ফেলবে।", "LabelMediaPlayer": "মিডিয়া প্লেয়ার", "LabelMediaType": "মিডিয়ার ধরন", "LabelMetaTag": "মেটা ট্যাগ", @@ -436,12 +465,14 @@ "LabelOpenIDGroupClaimDescription": "ওপেনআইডি দাবির নাম যাতে ব্যবহারকারীর গোষ্ঠীর একটি তালিকা থাকে। সাধারণত গ্রুপ হিসাবে উল্লেখ করা হয়। কনফিগার করা থাকলে, অ্যাপ্লিকেশনটি স্বয়ংক্রিয়ভাবে এর উপর ভিত্তি করে ব্যবহারকারীর গোষ্ঠীর সদস্যপদ নির্ধারণ করবে, শর্ত এই যে এই গোষ্ঠীগুলি কেস-অসংবেদনশীলভাবে দাবিতে 'অ্যাডমিন', 'ব্যবহারকারী' বা 'অতিথি' নাম দেওয়া হয়৷ দাবিতে একটি তালিকা থাকা উচিত এবং যদি একজন ব্যবহারকারী একাধিক গোষ্ঠীর অন্তর্গত হয় তবে অ্যাপ্লিকেশনটি বরাদ্দ করবে সর্বোচ্চ স্তরের অ্যাক্সেসের সাথে সঙ্গতিপূর্ণ ভূমিকা৷ যদি কোনও গোষ্ঠীর সাথে মেলে না, তবে অ্যাক্সেস অস্বীকার করা হবে।", "LabelOpenRSSFeed": "আরএসএস ফিড খুলুন", "LabelOverwrite": "পুনঃলিখিত", + "LabelPaginationPageXOfY": "{1} টির মধ্যে {0} পৃষ্ঠা", "LabelPassword": "পাসওয়ার্ড", "LabelPath": "পথ", "LabelPermanent": "স্থায়ী", "LabelPermissionsAccessAllLibraries": "সমস্ত লাইব্রেরি অ্যাক্সেস করতে পারবে", "LabelPermissionsAccessAllTags": "সমস্ত ট্যাগ অ্যাক্সেস করতে পারবে", "LabelPermissionsAccessExplicitContent": "স্পষ্ট বিষয়বস্তু অ্যাক্সেস করতে পারে", + "LabelPermissionsCreateEreader": "ইরিডার তৈরি করতে পারেন", "LabelPermissionsDelete": "মুছে দিতে পারবে", "LabelPermissionsDownload": "ডাউনলোড করতে পারবে", "LabelPermissionsUpdate": "আপডেট করতে পারবে", @@ -465,6 +496,8 @@ "LabelPubDate": "প্রকাশের তারিখ", "LabelPublishYear": "প্রকাশের বছর", "LabelPublishedDate": "প্রকাশিত {0}", + "LabelPublishedDecade": "প্রকাশনার দশক", + "LabelPublishedDecades": "প্রকাশনার দশকগুলো", "LabelPublisher": "প্রকাশক", "LabelPublishers": "প্রকাশকরা", "LabelRSSFeedCustomOwnerEmail": "কাস্টম মালিকের ইমেইল", @@ -484,21 +517,28 @@ "LabelRedo": "পুনরায় করুন", "LabelRegion": "অঞ্চল", "LabelReleaseDate": "উন্মোচনের তারিখ", + "LabelRemoveAllMetadataAbs": "সমস্ত metadata.abs ফাইল সরান", + "LabelRemoveAllMetadataJson": "সমস্ত metadata.json ফাইল সরান", "LabelRemoveCover": "কভার সরান", + "LabelRemoveMetadataFile": "লাইব্রেরি আইটেম ফোল্ডারে মেটাডেটা ফাইল সরান", + "LabelRemoveMetadataFileHelp": "আপনার {0} ফোল্ডারের সমস্ত metadata.json এবং metadata.abs ফাইলগুলি সরান।", "LabelRowsPerPage": "প্রতি পৃষ্ঠায় সারি", "LabelSearchTerm": "অনুসন্ধান শব্দ", "LabelSearchTitle": "অনুসন্ধান শিরোনাম", "LabelSearchTitleOrASIN": "অনুসন্ধান শিরোনাম বা ASIN", "LabelSeason": "সেশন", + "LabelSeasonNumber": "মরসুম #{0}", "LabelSelectAll": "সব নির্বাচন করুন", "LabelSelectAllEpisodes": "সমস্ত পর্ব নির্বাচন করুন", "LabelSelectEpisodesShowing": "দেখানো {0}টি পর্ব নির্বাচন করুন", "LabelSelectUsers": "ব্যবহারকারী নির্বাচন করুন", "LabelSendEbookToDevice": "ই-বই পাঠান...", "LabelSequence": "ক্রম", + "LabelSerial": "ধারাবাহিক", "LabelSeries": "সিরিজ", "LabelSeriesName": "সিরিজের নাম", "LabelSeriesProgress": "সিরিজের অগ্রগতি", + "LabelServerLogLevel": "সার্ভার লগ লেভেল", "LabelServerYearReview": "সার্ভারের বাৎসরিক পর্যালোচনা ({0})", "LabelSetEbookAsPrimary": "প্রাথমিক হিসাবে সেট করুন", "LabelSetEbookAsSupplementary": "পরিপূরক হিসেবে সেট করুন", @@ -523,6 +563,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "যে সিরিজগুলোতে একটি বই আছে সেগুলো সিরিজের পাতা এবং নীড় পেজের তাক থেকে লুকিয়ে রাখা হবে।", "LabelSettingsHomePageBookshelfView": "নীড় পেজে বুকশেলফ ভিউ ব্যবহার করুন", "LabelSettingsLibraryBookshelfView": "লাইব্রেরি বুকশেলফ ভিউ ব্যবহার করুন", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "শতকরা সম্পূর্ণ এর চেয়ে বেশি", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "বাকি সময় (সেকেন্ড) এর চেয়ে কম", + "LabelSettingsLibraryMarkAsFinishedWhen": "মিডিয়া আইটেমকে সমাপ্ত হিসাবে চিহ্নিত করুন যখন", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "কন্টিনিউ সিরিজে আগের বইগুলো এড়িয়ে যান", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "কন্টিনিউ সিরিজের নীড় পেজ শেল্ফ দেখায় যে সিরিজে শুরু হয়নি এমন প্রথম বই যার অন্তত একটি বই শেষ হয়েছে এবং কোনো বই চলছে না। এই সেটিংটি সক্ষম করলে শুরু না হওয়া প্রথম বইটির পরিবর্তে সবচেয়ে দূরের সম্পূর্ণ বই থেকে সিরিজ চলতে থাকবে।", "LabelSettingsParseSubtitles": "সাবটাইটেল পার্স করুন", @@ -587,6 +630,7 @@ "LabelTimeDurationXMinutes": "{0} মিনিট", "LabelTimeDurationXSeconds": "{0} সেকেন্ড", "LabelTimeInMinutes": "মিনিটে সময়", + "LabelTimeLeft": "{0} বাকি", "LabelTimeListened": "সময় শোনা হয়েছে", "LabelTimeListenedToday": "আজ শোনার সময়", "LabelTimeRemaining": "{0}টি অবশিষ্ট", @@ -594,6 +638,7 @@ "LabelTitle": "শিরোনাম", "LabelToolsEmbedMetadata": "মেটাডেটা এম্বেড করুন", "LabelToolsEmbedMetadataDescription": "কভার ইমেজ এবং অধ্যায় সহ অডিও ফাইলগুলিতে মেটাডেটা এম্বেড করুন।", + "LabelToolsM4bEncoder": "M4B এনকোডার", "LabelToolsMakeM4b": "M4B অডিওবুক ফাইল তৈরি করুন", "LabelToolsMakeM4bDescription": "এমবেডেড মেটাডেটা, কভার ইমেজ এবং অধ্যায় সহ একটি .M4B অডিওবুক ফাইল তৈরি করুন।", "LabelToolsSplitM4b": "M4B কে MP3 তে বিভক্ত করুন", @@ -606,6 +651,7 @@ "LabelTracksMultiTrack": "মাল্টি-ট্র্যাক", "LabelTracksNone": "কোন ট্র্যাক নেই", "LabelTracksSingleTrack": "একক-ট্র্যাক", + "LabelTrailer": "আনুগমিক", "LabelType": "টাইপ", "LabelUnabridged": "অসংলগ্ন", "LabelUndo": "পূর্বাবস্থা", @@ -617,10 +663,13 @@ "LabelUpdateDetailsHelp": "একটি মিল থাকা অবস্থায় নির্বাচিত বইগুলির বিদ্যমান বিবরণ ওভাররাইট করার অনুমতি দিন", "LabelUpdatedAt": "আপডেট করা হয়েছে", "LabelUploaderDragAndDrop": "ফাইল বা ফোল্ডার টেনে আনুন এবং ফেলে দিন", + "LabelUploaderDragAndDropFilesOnly": "ফাইল টেনে আনুন", "LabelUploaderDropFiles": "ফাইলগুলো ফেলে দিন", "LabelUploaderItemFetchMetadataHelp": "স্বয়ংক্রিয়ভাবে শিরোনাম, লেখক এবং সিরিজ আনুন", + "LabelUseAdvancedOptions": "উন্নত বিকল্প ব্যবহার করুন", "LabelUseChapterTrack": "অধ্যায় ট্র্যাক ব্যবহার করুন", "LabelUseFullTrack": "সম্পূর্ণ ট্র্যাক ব্যবহার করুন", + "LabelUseZeroForUnlimited": "অসীমের জন্য 0 ব্যবহার করুন", "LabelUser": "ব্যবহারকারী", "LabelUsername": "ব্যবহারকারীর নাম", "LabelValue": "মান", @@ -667,6 +716,7 @@ "MessageConfirmDeleteMetadataProvider": "আপনি কি নিশ্চিতভাবে কাস্টম মেটাডেটা প্রদানকারী \"{0}\" মুছতে চান?", "MessageConfirmDeleteNotification": "আপনি কি নিশ্চিতভাবে এই বিজ্ঞপ্তিটি মুছতে চান?", "MessageConfirmDeleteSession": "আপনি কি নিশ্চিত আপনি এই অধিবেশন মুছে দিতে চান?", + "MessageConfirmEmbedMetadataInAudioFiles": "আপনি কি {0}টি অডিও ফাইলে মেটাডেটা এম্বেড করার বিষয়ে নিশ্চিত?", "MessageConfirmForceReScan": "আপনি কি নিশ্চিত যে আপনি জোর করে পুনরায় স্ক্যান করতে চান?", "MessageConfirmMarkAllEpisodesFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্ব সমাপ্ত হিসাবে চিহ্নিত করতে চান?", "MessageConfirmMarkAllEpisodesNotFinished": "আপনি কি নিশ্চিত যে আপনি সমস্ত পর্বকে শেষ হয়নি বলে চিহ্নিত করতে চান?", @@ -678,6 +728,7 @@ "MessageConfirmPurgeCache": "ক্যাশে পরিষ্কারক /metadata/cache-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।

আপনি কি নিশ্চিত আপনি ক্যাশে ডিরেক্টরি সরাতে চান?", "MessageConfirmPurgeItemsCache": "আইটেম ক্যাশে পরিষ্কারক /metadata/cache/items-এ সম্পূর্ণ ডিরেক্টরি মুছে ফেলবে।
আপনি কি নিশ্চিত?", "MessageConfirmQuickEmbed": "সতর্কতা! দ্রুত এম্বেড আপনার অডিও ফাইলের ব্যাকআপ করবে না। নিশ্চিত করুন যে আপনার অডিও ফাইলগুলির একটি ব্যাকআপ আছে।

আপনি কি চালিয়ে যেতে চান?", + "MessageConfirmQuickMatchEpisodes": "একটি মিল পাওয়া গেলে দ্রুত ম্যাচিং পর্বগুলি বিস্তারিত ওভাররাইট করবে। শুধুমাত্র অতুলনীয় পর্ব আপডেট করা হবে। আপনি কি নিশ্চিত?", "MessageConfirmReScanLibraryItems": "আপনি কি নিশ্চিত যে আপনি {0}টি আইটেম পুনরায় স্ক্যান করতে চান?", "MessageConfirmRemoveAllChapters": "আপনি কি নিশ্চিত যে আপনি সমস্ত অধ্যায় সরাতে চান?", "MessageConfirmRemoveAuthor": "আপনি কি নিশ্চিত যে আপনি লেখক \"{0}\" অপসারণ করতে চান?", @@ -685,6 +736,7 @@ "MessageConfirmRemoveEpisode": "আপনি কি নিশ্চিত আপনি \"{0}\" পর্বটি সরাতে চান?", "MessageConfirmRemoveEpisodes": "আপনি কি নিশ্চিত যে আপনি {0}টি পর্ব সরাতে চান?", "MessageConfirmRemoveListeningSessions": "আপনি কি নিশ্চিত যে আপনি {0}টি শোনার সেশন সরাতে চান?", + "MessageConfirmRemoveMetadataFiles": "আপনি কি আপনার লাইব্রেরি আইটেম ফোল্ডারে থাকা সমস্ত মেটাডেটা {0} ফাইল মুছে ফেলার বিষয়ে নিশ্চিত?", "MessageConfirmRemoveNarrator": "আপনি কি \"{0}\" বর্ণনাকারীকে সরানোর বিষয়ে নিশ্চিত?", "MessageConfirmRemovePlaylist": "আপনি কি নিশ্চিত যে আপনি আপনার প্লেলিস্ট \"{0}\" সরাতে চান?", "MessageConfirmRenameGenre": "আপনি কি নিশ্চিত যে আপনি সমস্ত আইটেমের জন্য \"{0}\" ধারার নাম পরিবর্তন করে \"{1}\" করতে চান?", @@ -700,6 +752,7 @@ "MessageDragFilesIntoTrackOrder": "সঠিক ট্র্যাক অর্ডারে ফাইল টেনে আনুন", "MessageEmbedFailed": "এম্বেড ব্যর্থ হয়েছে!", "MessageEmbedFinished": "এম্বেড করা শেষ!", + "MessageEmbedQueue": "মেটাডেটা এম্বেডের জন্য সারিবদ্ধ ({0} সারিতে)", "MessageEpisodesQueuedForDownload": "{0} পর্ব(গুলি) ডাউনলোডের জন্য সারিবদ্ধ", "MessageEreaderDevices": "ই-বুক সরবরাহ নিশ্চিত করতে, আপনাকে নীচে তালিকাভুক্ত প্রতিটি ডিভাইসের জন্য একটি বৈধ প্রেরক হিসাবে উপরের ইমেল ঠিকানাটি যুক্ত করতে হতে পারে।", "MessageFeedURLWillBe": "ফিড URL হবে {0}", @@ -744,6 +797,7 @@ "MessageNoLogs": "কোনও লগ নেই", "MessageNoMediaProgress": "মিডিয়া অগ্রগতি নেই", "MessageNoNotifications": "কোনো বিজ্ঞপ্তি নেই", + "MessageNoPodcastFeed": "অবৈধ পডকাস্ট: কোনো ফিড নেই", "MessageNoPodcastsFound": "কোন পডকাস্ট পাওয়া যায়নি", "MessageNoResults": "কোন ফলাফল নেই", "MessageNoSearchResultsFor": "\"{0}\" এর জন্য কোন অনুসন্ধান ফলাফল নেই", @@ -760,6 +814,10 @@ "MessagePlaylistCreateFromCollection": "সংগ্রহ থেকে প্লেলিস্ট তৈরি করুন", "MessagePleaseWait": "অনুগ্রহ করে অপেক্ষা করুন..।", "MessagePodcastHasNoRSSFeedForMatching": "পডকাস্টের সাথে মিলের জন্য ব্যবহার করার জন্য কোন RSS ফিড ইউআরএল নেই", + "MessagePodcastSearchField": "অনুসন্ধান শব্দ বা RSS ফিড URL লিখুন", + "MessageQuickEmbedInProgress": "দ্রুত এম্বেড করা হচ্ছে", + "MessageQuickEmbedQueue": "দ্রুত এম্বেড করার জন্য সারিবদ্ধ ({0} সারিতে)", + "MessageQuickMatchAllEpisodes": "দ্রুত ম্যাচ সব পর্ব", "MessageQuickMatchDescription": "খালি আইটেমের বিশদ বিবরণ এবং '{0}' থেকে প্রথম ম্যাচের ফলাফলের সাথে কভার করুন। সার্ভার সেটিং সক্ষম না থাকলে বিশদ ওভাররাইট করে না।", "MessageRemoveChapter": "অধ্যায় সরান", "MessageRemoveEpisodes": "{0}টি পর্ব(গুলি) সরান", @@ -802,6 +860,9 @@ "MessageTaskOpmlImportFeedPodcastExists": "পডকাস্ট আগে থেকেই পাথে বিদ্যমান", "MessageTaskOpmlImportFeedPodcastFailed": "পডকাস্ট তৈরি করতে ব্যর্থ", "MessageTaskOpmlImportFinished": "{0}টি পডকাস্ট যোগ করা হয়েছে", + "MessageTaskOpmlParseFailed": "OPML ফাইল পার্স করতে ব্যর্থ হয়েছে", + "MessageTaskOpmlParseFastFail": "অবৈধ OPML ফাইল ট্যাগ পাওয়া যায়নি বা একটি ট্যাগ পাওয়া যায়নি", + "MessageTaskOpmlParseNoneFound": "OPML ফাইলে কোনো ফিড পাওয়া যায়নি", "MessageTaskScanItemsAdded": "{0}টি করা হয়েছে", "MessageTaskScanItemsMissing": "{0}টি অনুপস্থিত", "MessageTaskScanItemsUpdated": "{0} টি আপডেট করা হয়েছে", @@ -826,6 +887,10 @@ "NoteUploaderFoldersWithMediaFiles": "মিডিয়া ফাইল সহ ফোল্ডারগুলি আলাদা লাইব্রেরি আইটেম হিসাবে পরিচালনা করা হবে।", "NoteUploaderOnlyAudioFiles": "যদি শুধুমাত্র অডিও ফাইল আপলোড করা হয় তবে প্রতিটি অডিও ফাইল একটি পৃথক অডিওবুক হিসাবে পরিচালনা করা হবে।", "NoteUploaderUnsupportedFiles": "অসমর্থিত ফাইলগুলি উপেক্ষা করা হয়। একটি ফোল্ডার বেছে নেওয়া বা ফেলে দেওয়ার সময়, আইটেম ফোল্ডারে নেই এমন অন্যান্য ফাইলগুলি উপেক্ষা করা হয়।", + "NotificationOnBackupCompletedDescription": "ব্যাকআপ সম্পূর্ণ হলে ট্রিগার হবে", + "NotificationOnBackupFailedDescription": "ব্যাকআপ ব্যর্থ হলে ট্রিগার হবে", + "NotificationOnEpisodeDownloadedDescription": "একটি পডকাস্ট পর্ব স্বয়ংক্রিয়ভাবে ডাউনলোড হলে ট্রিগার হবে", + "NotificationOnTestDescription": "বিজ্ঞপ্তি সিস্টেম পরীক্ষার জন্য ইভেন্ট", "PlaceholderNewCollection": "নতুন সংগ্রহের নাম", "PlaceholderNewFolderPath": "নতুন ফোল্ডার পথ", "PlaceholderNewPlaylist": "নতুন প্লেলিস্টের নাম", @@ -851,6 +916,7 @@ "StatsYearInReview": "বাৎসরিক পর্যালোচনা", "ToastAccountUpdateSuccess": "অ্যাকাউন্ট আপডেট করা হয়েছে", "ToastAppriseUrlRequired": "একটি Apprise ইউআরএল লিখতে হবে", + "ToastAsinRequired": "ASIN প্রয়োজন", "ToastAuthorImageRemoveSuccess": "লেখকের ছবি সরানো হয়েছে", "ToastAuthorNotFound": "লেখক \"{0}\" খুঁজে পাওয়া যায়নি", "ToastAuthorRemoveSuccess": "লেখক সরানো হয়েছে", @@ -870,6 +936,8 @@ "ToastBackupUploadSuccess": "ব্যাকআপ আপলোড হয়েছে", "ToastBatchDeleteFailed": "ব্যাচ মুছে ফেলতে ব্যর্থ হয়েছে", "ToastBatchDeleteSuccess": "ব্যাচ মুছে ফেলা সফল হয়েছে", + "ToastBatchQuickMatchFailed": "ব্যাচ কুইক ম্যাচ ব্যর্থ!", + "ToastBatchQuickMatchStarted": "{0}টি বইয়ের ব্যাচ কুইক ম্যাচ শুরু হয়েছে!", "ToastBatchUpdateFailed": "ব্যাচ আপডেট ব্যর্থ হয়েছে", "ToastBatchUpdateSuccess": "ব্যাচ আপডেট সাফল্য", "ToastBookmarkCreateFailed": "বুকমার্ক তৈরি করতে ব্যর্থ", @@ -881,6 +949,7 @@ "ToastChaptersHaveErrors": "অধ্যায়ে ত্রুটি আছে", "ToastChaptersMustHaveTitles": "অধ্যায়ের শিরোনাম থাকতে হবে", "ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে", + "ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে", "ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে", "ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে", "ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে", @@ -898,11 +967,14 @@ "ToastEncodeCancelSucces": "এনকোড বাতিল করা হয়েছে", "ToastEpisodeDownloadQueueClearFailed": "সারি সাফ করতে ব্যর্থ হয়েছে", "ToastEpisodeDownloadQueueClearSuccess": "পর্ব ডাউনলোড সারি পরিষ্কার করা হয়েছে", + "ToastEpisodeUpdateSuccess": "{0}টি পর্ব আপডেট করা হয়েছে", "ToastErrorCannotShare": "এই ডিভাইসে স্থানীয়ভাবে শেয়ার করা যাবে না", "ToastFailedToLoadData": "ডেটা লোড করা যায়নি", + "ToastFailedToMatch": "মেলাতে ব্যর্থ হয়েছে", "ToastFailedToShare": "শেয়ার করতে ব্যর্থ", "ToastFailedToUpdate": "আপডেট করতে ব্যর্থ হয়েছে", "ToastInvalidImageUrl": "অকার্যকর ছবির ইউআরএল", + "ToastInvalidMaxEpisodesToDownload": "ডাউনলোড করার জন্য অবৈধ সর্বোচ্চ পর্ব", "ToastInvalidUrl": "অকার্যকর ইউআরএল", "ToastItemCoverUpdateSuccess": "আইটেম কভার আপডেট করা হয়েছে", "ToastItemDeletedFailed": "আইটেম মুছে ফেলতে ব্যর্থ", @@ -920,14 +992,22 @@ "ToastLibraryScanFailedToStart": "স্ক্যান শুরু করতে ব্যর্থ", "ToastLibraryScanStarted": "লাইব্রেরি স্ক্যান শুরু হয়েছে", "ToastLibraryUpdateSuccess": "লাইব্রেরি \"{0}\" আপডেট করা হয়েছে", + "ToastMatchAllAuthorsFailed": "সমস্ত লেখকের সাথে মিলতে ব্যর্থ হয়েছে", + "ToastMetadataFilesRemovedError": "মেটাডেটা সরানোর সময় ত্রুটি {0} ফাইল", + "ToastMetadataFilesRemovedNoneFound": "কোনো মেটাডেটা নেই।লাইব্রেরিতে {0} ফাইল পাওয়া গেছে", + "ToastMetadataFilesRemovedNoneRemoved": "কোনো মেটাডেটা নেই।{0} ফাইল সরানো হয়েছে", + "ToastMetadataFilesRemovedSuccess": "{0} মেটাডেটা৷{1} ফাইল সরানো হয়েছে", + "ToastMustHaveAtLeastOnePath": "অন্তত একটি পথ থাকতে হবে", "ToastNameEmailRequired": "নাম এবং ইমেইল আবশ্যক", "ToastNameRequired": "নাম আবশ্যক", + "ToastNewEpisodesFound": "{0}টি নতুন পর্ব পাওয়া গেছে", "ToastNewUserCreatedFailed": "অ্যাকাউন্ট তৈরি করতে ব্যর্থ: \"{0}\"", "ToastNewUserCreatedSuccess": "নতুন একাউন্ট তৈরি হয়েছে", "ToastNewUserLibraryError": "অন্তত একটি লাইব্রেরি নির্বাচন করতে হবে", "ToastNewUserPasswordError": "অন্তত একটি পাসওয়ার্ড থাকতে হবে, শুধুমাত্র রুট ব্যবহারকারীর একটি খালি পাসওয়ার্ড থাকতে পারে", "ToastNewUserTagError": "অন্তত একটি ট্যাগ নির্বাচন করতে হবে", "ToastNewUserUsernameError": "একটি ব্যবহারকারীর নাম লিখুন", + "ToastNoNewEpisodesFound": "কোন নতুন পর্ব পাওয়া যায়নি", "ToastNoUpdatesNecessary": "কোন আপডেটের প্রয়োজন নেই", "ToastNotificationCreateFailed": "বিজ্ঞপ্তি তৈরি করতে ব্যর্থ", "ToastNotificationDeleteFailed": "বিজ্ঞপ্তি মুছে ফেলতে ব্যর্থ", @@ -946,6 +1026,7 @@ "ToastPodcastGetFeedFailed": "পডকাস্ট ফিড পেতে ব্যর্থ হয়েছে", "ToastPodcastNoEpisodesInFeed": "আরএসএস ফিডে কোনো পর্ব পাওয়া যায়নি", "ToastPodcastNoRssFeed": "পডকাস্টের কোন আরএসএস ফিড নেই", + "ToastProgressIsNotBeingSynced": "অগ্রগতি সিঙ্ক হচ্ছে না, প্লেব্যাক পুনরায় চালু করুন", "ToastProviderCreatedFailed": "প্রদানকারী যোগ করতে ব্যর্থ হয়েছে", "ToastProviderCreatedSuccess": "নতুন প্রদানকারী যোগ করা হয়েছে", "ToastProviderNameAndUrlRequired": "নাম এবং ইউআরএল আবশ্যক", @@ -972,6 +1053,7 @@ "ToastSessionCloseFailed": "অধিবেশন বন্ধ করতে ব্যর্থ হয়েছে", "ToastSessionDeleteFailed": "সেশন মুছে ফেলতে ব্যর্থ", "ToastSessionDeleteSuccess": "সেশন মুছে ফেলা হয়েছে", + "ToastSleepTimerDone": "স্লিপ টাইমার হয়ে গেছে... zZzzZz", "ToastSlugMustChange": "স্লাগে অবৈধ অক্ষর রয়েছে", "ToastSlugRequired": "স্লাগ আবশ্যক", "ToastSocketConnected": "সকেট সংযুক্ত", From a5457d7e22238432f3cfc4064cb6bfec670f6777 Mon Sep 17 00:00:00 2001 From: Pierrick Guillaume Date: Mon, 25 Nov 2024 05:04:14 +0000 Subject: [PATCH 09/15] Translated using Weblate (French) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/fr.json b/client/strings/fr.json index a1f5c2c8..28cdd342 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée", "LabelUpdatedAt": "Mis à jour à", "LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers", + "LabelUploaderDragAndDropFilesOnly": "Glisser & déposer des fichiers", "LabelUploaderDropFiles": "Déposer des fichiers", "LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, l’auteur et la série", "LabelUseAdvancedOptions": "Utiliser les options avancées", From 1ff1ba66fdd57d8a109ac240fb988be5186ad887 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Mon, 25 Nov 2024 19:38:25 +0000 Subject: [PATCH 10/15] Translated using Weblate (Russian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/ru.json b/client/strings/ru.json index b0743af6..2ec2fbd1 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены", "LabelUpdatedAt": "Обновлено в", "LabelUploaderDragAndDrop": "Перетащите файлы или каталоги", + "LabelUploaderDragAndDropFilesOnly": "Перетаскивание файлов", "LabelUploaderDropFiles": "Перетащите файлы", "LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии", "LabelUseAdvancedOptions": "Используйте расширенные опции", From 31e302ea599e43a095cc76f8e219330b1b66eda2 Mon Sep 17 00:00:00 2001 From: Charlie Date: Fri, 29 Nov 2024 13:49:11 +0000 Subject: [PATCH 11/15] Translated using Weblate (French) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index 28cdd342..42dfefc5 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -870,10 +870,10 @@ "MessageTaskScanningFileChanges": "Analyse des modifications du fichier dans « {0} »", "MessageTaskScanningLibrary": "Analyse de la bibliothèque « {0} »", "MessageTaskTargetDirectoryNotWritable": "Le répertoire cible n’est pas accessible en écriture", - "MessageThinking": "Je cherche…", + "MessageThinking": "À la recherche de…", "MessageUploaderItemFailed": "Échec du téléversement", "MessageUploaderItemSuccess": "Téléversement effectué !", - "MessageUploading": "Téléversement…", + "MessageUploading": "Téléchargement…", "MessageValidCronExpression": "Expression cron valide", "MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur", "MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !", From 468a5478642baec96abb3110c5bc1892eca893b7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 30 Nov 2024 16:26:48 -0600 Subject: [PATCH 12/15] Version bump v2.17.3 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index b8b17f3e..588ad79d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.17.2", + "version": "2.17.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.17.2", + "version": "2.17.3", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 924220e0..c1a43e52 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.17.2", + "version": "2.17.3", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 3f9f7a44..062fb032 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.17.2", + "version": "2.17.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.17.2", + "version": "2.17.3", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 8cbbb029..db63261b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.17.2", + "version": "2.17.3", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From c496db7c95752407164aa55d4dc6f35e207db9f1 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 1 Dec 2024 09:51:26 -0600 Subject: [PATCH 13/15] Fix:Remove authors with no books when a books is removed #3668 - Handles bulk delete, single delete, deleting library folder, and removing items with issues - Also handles bulk editing and removing authors --- server/controllers/LibraryController.js | 66 +++++++++- server/controllers/LibraryItemController.js | 127 +++++++++++++++----- server/routers/ApiRouter.js | 67 +++-------- 3 files changed, 177 insertions(+), 83 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 84d6193d..fc15488d 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -400,19 +400,48 @@ class LibraryController { model: Database.podcastEpisodeModel, attributes: ['id'] } + }, + { + model: Database.bookModel, + attributes: ['id'], + include: [ + { + model: Database.bookAuthorModel, + attributes: ['authorId'] + }, + { + model: Database.bookSeriesModel, + attributes: ['seriesId'] + } + ] } ] }) Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`) + const seriesIds = [] + const authorIds = [] for (const libraryItem of libraryItemsInFolder) { let mediaItemIds = [] if (req.library.isPodcast) { mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) } else { mediaItemIds.push(libraryItem.mediaId) + if (libraryItem.media.bookAuthors.length) { + authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId)) + } + if (libraryItem.media.bookSeries.length) { + seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId)) + } } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`) - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + } + + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) } // Remove folder @@ -501,7 +530,7 @@ class LibraryController { mediaItemIds.push(libraryItem.mediaId) } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`) - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) } // Set PlaybackSessions libraryId to null @@ -580,6 +609,8 @@ class LibraryController { * DELETE: /api/libraries/:id/issues * Remove all library items missing or invalid * + * @this {import('../routers/ApiRouter')} + * * @param {LibraryControllerRequest} req * @param {Response} res */ @@ -605,6 +636,20 @@ class LibraryController { model: Database.podcastEpisodeModel, attributes: ['id'] } + }, + { + model: Database.bookModel, + attributes: ['id'], + include: [ + { + model: Database.bookAuthorModel, + attributes: ['authorId'] + }, + { + model: Database.bookSeriesModel, + attributes: ['seriesId'] + } + ] } ] }) @@ -615,15 +660,30 @@ class LibraryController { } Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`) + const authorIds = [] + const seriesIds = [] for (const libraryItem of libraryItemsWithIssues) { let mediaItemIds = [] if (req.library.isPodcast) { mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id) } else { mediaItemIds.push(libraryItem.mediaId) + if (libraryItem.media.bookAuthors.length) { + authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId)) + } + if (libraryItem.media.bookSeries.length) { + seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId)) + } } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`) - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + } + + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) } // Set numIssues to 0 for library filter data diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 64069ac5..92bc3833 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -96,6 +96,8 @@ class LibraryItemController { * Optional query params: * ?hard=1 * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -103,14 +105,36 @@ class LibraryItemController { const hardDelete = req.query.hard == 1 // Delete from file system const libraryItemPath = req.libraryItem.path - const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id] - await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds) + const mediaItemIds = [] + const authorIds = [] + const seriesIds = [] + if (req.libraryItem.isPodcast) { + mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id)) + } else { + mediaItemIds.push(req.libraryItem.media.id) + if (req.libraryItem.media.metadata.authors?.length) { + authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id)) + } + if (req.libraryItem.media.metadata.series?.length) { + seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id)) + } + } + + await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds) if (hardDelete) { Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) await fs.remove(libraryItemPath).catch((error) => { Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) }) } + + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) + } + await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId) res.sendStatus(200) } @@ -212,15 +236,6 @@ class LibraryItemController { if (hasUpdates) { libraryItem.updatedAt = Date.now() - if (seriesRemoved.length) { - // Check remove empty series - Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`) - await this.checkRemoveEmptySeries( - libraryItem.media.id, - seriesRemoved.map((se) => se.id) - ) - } - if (isPodcastAutoDownloadUpdated) { this.cronManager.checkUpdatePodcastCron(libraryItem) } @@ -232,10 +247,12 @@ class LibraryItemController { if (authorsRemoved.length) { // Check remove empty authors Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`) - await this.checkRemoveAuthorsWithNoBooks( - libraryItem.libraryId, - authorsRemoved.map((au) => au.id) - ) + await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id)) + } + if (seriesRemoved.length) { + // Check remove empty series + Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`) + await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id)) } } res.json({ @@ -450,6 +467,8 @@ class LibraryItemController { * Optional query params: * ?hard=1 * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -477,14 +496,33 @@ class LibraryItemController { for (const libraryItem of itemsToDelete) { const libraryItemPath = libraryItem.path Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`) - const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id] - await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds) + const mediaItemIds = [] + const seriesIds = [] + const authorIds = [] + if (libraryItem.isPodcast) { + mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id)) + } else { + mediaItemIds.push(libraryItem.media.id) + if (libraryItem.media.metadata.series?.length) { + seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id)) + } + if (libraryItem.media.metadata.authors?.length) { + authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id)) + } + } + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) if (hardDelete) { Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) await fs.remove(libraryItemPath).catch((error) => { Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error) }) } + if (seriesIds.length) { + await this.checkRemoveEmptySeries(seriesIds) + } + if (authorIds.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIds) + } } await Database.resetLibraryIssuesFilterData(libraryId) @@ -494,6 +532,8 @@ class LibraryItemController { /** * POST: /api/items/batch/update * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -503,39 +543,62 @@ class LibraryItemController { return res.sendStatus(500) } + // Ensure that each update payload has a unique library item id + const libraryItemIds = [...new Set(updatePayloads.map((up) => up.id))] + if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) { + Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`) + return res.sendStatus(400) + } + + // Get all library items to update + const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ + id: libraryItemIds + }) + if (updatePayloads.length !== libraryItems.length) { + Logger.error(`[LibraryItemController] Batch update failed. Not all library items found`) + return res.sendStatus(404) + } + let itemsUpdated = 0 + const seriesIdsRemoved = [] + const authorIdsRemoved = [] + for (const updatePayload of updatePayloads) { const mediaPayload = updatePayload.mediaPayload - const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id) - if (!libraryItem) return null + const libraryItem = libraryItems.find((li) => li.id === updatePayload.id) await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) - let seriesRemoved = [] - if (libraryItem.isBook && mediaPayload.metadata?.series) { - const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id) - seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) + if (libraryItem.isBook) { + if (Array.isArray(mediaPayload.metadata?.series)) { + const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id) + const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) + seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id)) + } + if (Array.isArray(mediaPayload.metadata?.authors)) { + const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) + const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) + authorIdsRemoved.push(...authorsRemoved.map((au) => au.id)) + } } if (libraryItem.media.update(mediaPayload)) { Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) - if (seriesRemoved.length) { - // Check remove empty series - Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`) - await this.checkRemoveEmptySeries( - libraryItem.media.id, - seriesRemoved.map((se) => se.id) - ) - } - await Database.updateLibraryItem(libraryItem) SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) itemsUpdated++ } } + if (seriesIdsRemoved.length) { + await this.checkRemoveEmptySeries(seriesIdsRemoved) + } + if (authorIdsRemoved.length) { + await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved) + } + res.json({ success: true, updates: itemsUpdated diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 7f21c3ac..0657b389 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -348,11 +348,10 @@ class ApiRouter { // /** * Remove library item and associated entities - * @param {string} mediaType * @param {string} libraryItemId * @param {string[]} mediaItemIds array of bookId or podcastEpisodeId */ - async handleDeleteLibraryItem(mediaType, libraryItemId, mediaItemIds) { + async handleDeleteLibraryItem(libraryItemId, mediaItemIds) { const numProgressRemoved = await Database.mediaProgressModel.destroy({ where: { mediaItemId: mediaItemIds @@ -362,29 +361,6 @@ class ApiRouter { Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`) } - // TODO: Remove open sessions for library item - - // Remove series if empty - if (mediaType === 'book') { - // TODO: update filter data - const bookSeries = await Database.bookSeriesModel.findAll({ - where: { - bookId: mediaItemIds[0] - }, - include: { - model: Database.seriesModel, - include: { - model: Database.bookModel - } - } - }) - for (const bs of bookSeries) { - if (bs.series.books.length === 1) { - await this.removeEmptySeries(bs.series) - } - } - } - // remove item from playlists const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds) for (const playlist of playlistsWithItem) { @@ -423,6 +399,7 @@ class ApiRouter { // purge cover cache await CacheManager.purgeCoverCache(libraryItemId) + // Remove metadata file if in /metadata/items dir const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId) if (await fs.pathExists(itemMetadataPath)) { Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`) @@ -437,32 +414,27 @@ class ApiRouter { } /** - * Used when a series is removed from a book - * Series is removed if it only has 1 book + * After deleting book(s), remove empty series * - * @param {string} bookId * @param {string[]} seriesIds */ - async checkRemoveEmptySeries(bookId, seriesIds) { + async checkRemoveEmptySeries(seriesIds) { if (!seriesIds?.length) return - const bookSeries = await Database.bookSeriesModel.findAll({ + const series = await Database.seriesModel.findAll({ where: { - bookId, - seriesId: seriesIds + id: seriesIds }, - include: [ - { - model: Database.seriesModel, - include: { - model: Database.bookModel - } - } - ] + attributes: ['id', 'name', 'libraryId'], + include: { + model: Database.bookModel, + attributes: ['id'] + } }) - for (const bs of bookSeries) { - if (bs.series.books.length === 1) { - await this.removeEmptySeries(bs.series) + + for (const s of series) { + if (!s.books.length) { + await this.removeEmptySeries(s) } } } @@ -471,11 +443,10 @@ class ApiRouter { * Remove authors with no books and unset asin, description and imagePath * Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged) * - * @param {string} libraryId * @param {string[]} authorIds * @returns {Promise} */ - async checkRemoveAuthorsWithNoBooks(libraryId, authorIds) { + async checkRemoveAuthorsWithNoBooks(authorIds) { if (!authorIds?.length) return const bookAuthorsToRemove = ( @@ -495,10 +466,10 @@ class ApiRouter { }, sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0) ], - attributes: ['id', 'name'], + attributes: ['id', 'name', 'libraryId'], raw: true }) - ).map((au) => ({ id: au.id, name: au.name })) + ).map((au) => ({ id: au.id, name: au.name, libraryId: au.libraryId })) if (bookAuthorsToRemove.length) { await Database.authorModel.destroy({ @@ -506,7 +477,7 @@ class ApiRouter { id: bookAuthorsToRemove.map((au) => au.id) } }) - bookAuthorsToRemove.forEach(({ id, name }) => { + bookAuthorsToRemove.forEach(({ id, name, libraryId }) => { Database.removeAuthorFromFilterData(libraryId, id) // TODO: Clients were expecting full author in payload but its unnecessary SocketAuthority.emitter('author_removed', { id, libraryId }) From 2b5484243b649ed2dac3c996eb1d9b8e907b4c4a Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 1 Dec 2024 12:44:21 -0600 Subject: [PATCH 14/15] Add LibraryItemController test for delete/batchDelete/updateMedia endpoint functions to correctly remove authors & series with no books --- server/managers/CacheManager.js | 1 + server/objects/LibraryItem.js | 2 +- server/routers/ApiRouter.js | 10 +- .../controllers/LibraryItemController.test.js | 202 ++++++++++++++++++ 4 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 test/server/controllers/LibraryItemController.test.js diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js index f0375691..b44b65de 100644 --- a/server/managers/CacheManager.js +++ b/server/managers/CacheManager.js @@ -86,6 +86,7 @@ class CacheManager { } async purgeEntityCache(entityId, cachePath) { + if (!entityId || !cachePath) return [] return Promise.all( (await fs.readdir(cachePath)).reduce((promises, file) => { if (file.startsWith(entityId)) { diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 0259ee4c..84a37897 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -262,7 +262,7 @@ class LibraryItem { * @returns {Promise} null if not saved */ async saveMetadata() { - if (this.isSavingMetadata) return null + if (this.isSavingMetadata || !global.MetadataPath) return null this.isSavingMetadata = true diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 0657b389..a92796e8 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -400,10 +400,12 @@ class ApiRouter { await CacheManager.purgeCoverCache(libraryItemId) // Remove metadata file if in /metadata/items dir - const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId) - if (await fs.pathExists(itemMetadataPath)) { - Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`) - await fs.remove(itemMetadataPath) + if (global.MetadataPath) { + const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId) + if (await fs.pathExists(itemMetadataPath)) { + Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`) + await fs.remove(itemMetadataPath) + } } await Database.libraryItemModel.removeById(libraryItemId) diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js new file mode 100644 index 00000000..3e7c58b2 --- /dev/null +++ b/test/server/controllers/LibraryItemController.test.js @@ -0,0 +1,202 @@ +const { expect } = require('chai') +const { Sequelize } = require('sequelize') +const sinon = require('sinon') + +const Database = require('../../../server/Database') +const ApiRouter = require('../../../server/routers/ApiRouter') +const LibraryItemController = require('../../../server/controllers/LibraryItemController') +const ApiCacheManager = require('../../../server/managers/ApiCacheManager') +const RssFeedManager = require('../../../server/managers/RssFeedManager') +const Logger = require('../../../server/Logger') + +describe('LibraryItemController', () => { + /** @type {ApiRouter} */ + let apiRouter + + beforeEach(async () => { + global.ServerSettings = {} + Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') + await Database.buildModels() + + apiRouter = new ApiRouter({ + apiCacheManager: new ApiCacheManager(), + rssFeedManager: new RssFeedManager() + }) + + sinon.stub(Logger, 'info') + }) + + afterEach(async () => { + sinon.restore() + + // Clear all tables + await Database.sequelize.sync({ force: true }) + }) + + describe('checkRemoveAuthorsAndSeries', () => { + let libraryItem1Id + let libraryItem2Id + let author1Id + let author2Id + let author3Id + let series1Id + let series2Id + + beforeEach(async () => { + const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id }) + + const newBook = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + const newLibraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id }) + libraryItem1Id = newLibraryItem.id + + const newBook2 = await Database.bookModel.create({ title: 'Test Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + const newLibraryItem2 = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook2.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id }) + libraryItem2Id = newLibraryItem2.id + + const newAuthor = await Database.authorModel.create({ name: 'Test Author', libraryId: newLibrary.id }) + author1Id = newAuthor.id + const newAuthor2 = await Database.authorModel.create({ name: 'Test Author 2', libraryId: newLibrary.id }) + author2Id = newAuthor2.id + const newAuthor3 = await Database.authorModel.create({ name: 'Test Author 3', imagePath: '/fake/path/author.png', libraryId: newLibrary.id }) + author3Id = newAuthor3.id + + // Book 1 has Author 1, Author 2 and Author 3 + await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor.id }) + await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor2.id }) + await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor3.id }) + + // Book 2 has Author 2 + await Database.bookAuthorModel.create({ bookId: newBook2.id, authorId: newAuthor2.id }) + + const newSeries = await Database.seriesModel.create({ name: 'Test Series', libraryId: newLibrary.id }) + series1Id = newSeries.id + const newSeries2 = await Database.seriesModel.create({ name: 'Test Series 2', libraryId: newLibrary.id }) + series2Id = newSeries2.id + + // Book 1 is in Series 1 and Series 2 + await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries.id }) + await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries2.id }) + + // Book 2 is in Series 2 + await Database.bookSeriesModel.create({ bookId: newBook2.id, seriesId: newSeries2.id }) + }) + + it('should remove authors and series with no books on library item delete', async () => { + const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id) + + const fakeReq = { + query: {}, + libraryItem: oldLibraryItem + } + const fakeRes = { + sendStatus: sinon.spy() + } + await LibraryItemController.delete.bind(apiRouter)(fakeReq, fakeRes) + + expect(fakeRes.sendStatus.calledWith(200)).to.be.true + + // Author 1 should be removed because it has no books + const author1Exists = await Database.authorModel.checkExistsById(author1Id) + expect(author1Exists).to.be.false + + // Author 2 should not be removed because it still has Book 2 + const author2Exists = await Database.authorModel.checkExistsById(author2Id) + expect(author2Exists).to.be.true + + // Author 3 should not be removed because it has an image + const author3Exists = await Database.authorModel.checkExistsById(author3Id) + expect(author3Exists).to.be.true + + // Series 1 should be removed because it has no books + const series1Exists = await Database.seriesModel.checkExistsById(series1Id) + expect(series1Exists).to.be.false + + // Series 2 should not be removed because it still has Book 2 + const series2Exists = await Database.seriesModel.checkExistsById(series2Id) + expect(series2Exists).to.be.true + }) + + it('should remove authors and series with no books on library item batch delete', async () => { + // Batch delete library item 1 + const fakeReq = { + query: {}, + user: { + canDelete: true + }, + body: { + libraryItemIds: [libraryItem1Id] + } + } + const fakeRes = { + sendStatus: sinon.spy() + } + await LibraryItemController.batchDelete.bind(apiRouter)(fakeReq, fakeRes) + + expect(fakeRes.sendStatus.calledWith(200)).to.be.true + + // Author 1 should be removed because it has no books + const author1Exists = await Database.authorModel.checkExistsById(author1Id) + expect(author1Exists).to.be.false + + // Author 2 should not be removed because it still has Book 2 + const author2Exists = await Database.authorModel.checkExistsById(author2Id) + expect(author2Exists).to.be.true + + // Author 3 should not be removed because it has an image + const author3Exists = await Database.authorModel.checkExistsById(author3Id) + expect(author3Exists).to.be.true + + // Series 1 should be removed because it has no books + const series1Exists = await Database.seriesModel.checkExistsById(series1Id) + expect(series1Exists).to.be.false + + // Series 2 should not be removed because it still has Book 2 + const series2Exists = await Database.seriesModel.checkExistsById(series2Id) + expect(series2Exists).to.be.true + }) + + it('should remove authors and series with no books on library item update media', async () => { + const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id) + + // Update library item 1 remove all authors and series + const fakeReq = { + query: {}, + body: { + metadata: { + authors: [], + series: [] + } + }, + libraryItem: oldLibraryItem + } + const fakeRes = { + json: sinon.spy() + } + await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + + // Author 1 should be removed because it has no books + const author1Exists = await Database.authorModel.checkExistsById(author1Id) + expect(author1Exists).to.be.false + + // Author 2 should not be removed because it still has Book 2 + const author2Exists = await Database.authorModel.checkExistsById(author2Id) + expect(author2Exists).to.be.true + + // Author 3 should not be removed because it has an image + const author3Exists = await Database.authorModel.checkExistsById(author3Id) + expect(author3Exists).to.be.true + + // Series 1 should be removed because it has no books + const series1Exists = await Database.seriesModel.checkExistsById(series1Id) + expect(series1Exists).to.be.false + + // Series 2 should not be removed because it still has Book 2 + const series2Exists = await Database.seriesModel.checkExistsById(series2Id) + expect(series2Exists).to.be.true + }) + }) +}) From 0dedb09a07c3286d2383892520c107dfb06603c2 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 1 Dec 2024 12:49:39 -0600 Subject: [PATCH 15/15] Update:batchUpdate endpoint validate req.body is an array of objects --- server/controllers/LibraryItemController.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 92bc3833..5aaacee0 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -539,12 +539,13 @@ class LibraryItemController { */ async batchUpdate(req, res) { const updatePayloads = req.body - if (!updatePayloads?.length) { - return res.sendStatus(500) + if (!Array.isArray(updatePayloads) || !updatePayloads.length) { + Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`) + return res.sendStatus(400) } // Ensure that each update payload has a unique library item id - const libraryItemIds = [...new Set(updatePayloads.map((up) => up.id))] + const libraryItemIds = [...new Set(updatePayloads.map((up) => up?.id).filter((id) => id))] if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) { Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`) return res.sendStatus(400)