From 2711b989e153669aaf869521ebbc0e9f006834ef Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 13 Sep 2024 16:55:48 -0700 Subject: [PATCH 001/164] Add: series migration to be unique --- .../v2.13.5-series-column-unique.js | 126 ++++++++++ server/models/Series.js | 6 + .../v2.13.5-series-column-unique.test.js | 226 ++++++++++++++++++ 3 files changed, 358 insertions(+) create mode 100644 server/migrations/v2.13.5-series-column-unique.js create mode 100644 test/server/migrations/v2.13.5-series-column-unique.test.js diff --git a/server/migrations/v2.13.5-series-column-unique.js b/server/migrations/v2.13.5-series-column-unique.js new file mode 100644 index 000000000..e7201bae1 --- /dev/null +++ b/server/migrations/v2.13.5-series-column-unique.js @@ -0,0 +1,126 @@ +const Logger = require('../Logger') + +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script cleans any duplicate series in the `Series` table and + * adds a unique index on the `name` and `libraryId` columns. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('UPGRADE BEGIN: 2.13.5-series-column-unique ') + + // Use the queryInterface to get the series table and find duplicates in the `name` column + const [duplicates] = await queryInterface.sequelize.query(` + SELECT name, libraryId, MAX(updatedAt) AS latestUpdatedAt, COUNT(name) AS count + FROM Series + GROUP BY name, libraryId + HAVING COUNT(name) > 1 + `) + + // Print out how many duplicates were found + logger.info(`[2.13.5 migration] Found ${duplicates.length} duplicate series`) + + // Iterate over each duplicate series + for (const duplicate of duplicates) { + // Report the series name that is being deleted + logger.info(`[2.13.5 migration] Deduplicating series "${duplicate.name}" in library ${duplicate.libraryId}`) + + // Get all the most recent series which matches the `name` and `libraryId` + const [mostRecentSeries] = await queryInterface.sequelize.query( + ` + SELECT id + FROM Series + WHERE name = :name AND libraryId = :libraryId + ORDER BY updatedAt DESC + LIMIT 1 + `, + { + replacements: { + name: duplicate.name, + libraryId: duplicate.libraryId + }, + type: queryInterface.sequelize.QueryTypes.SELECT + } + ) + + if (mostRecentSeries) { + // Update all BookSeries records for this series to point to the most recent series + const [seriesUpdated] = await queryInterface.sequelize.query( + ` + UPDATE BookSeries + SET seriesId = :mostRecentSeriesId + WHERE seriesId IN ( + SELECT id + FROM Series + WHERE name = :name AND libraryId = :libraryId + AND id != :mostRecentSeriesId + ) + `, + { + replacements: { + name: duplicate.name, + libraryId: duplicate.libraryId, + mostRecentSeriesId: mostRecentSeries.id + } + } + ) + + // Delete the older series + const seriesDeleted = await queryInterface.sequelize.query( + ` + DELETE FROM Series + WHERE name = :name AND libraryId = :libraryId + AND id != :mostRecentSeriesId + `, + { + replacements: { + name: duplicate.name, + libraryId: duplicate.libraryId, + mostRecentSeriesId: mostRecentSeries.id + } + } + ) + } + } + + logger.info(`[2.13.5 migration] Deduplication complete`) + + // Create a unique index based on the name and library ID for the `Series` table + await queryInterface.addIndex('Series', ['name', 'libraryId'], { + unique: true, + name: 'unique_series_name_per_library' + }) + logger.info('Added unique index on Series.name and Series.libraryId') + + logger.info('UPGRADE END: 2.13.5-series-column-unique ') +} + +/** + * This removes the unique index on the `Series` table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('DOWNGRADE BEGIN: 2.13.5-series-column-unique ') + + // Remove the unique index + await queryInterface.removeIndex('Series', 'unique_series_name_per_library') + logger.info('Removed unique index on Series.name and Series.libraryId') + + logger.info('DOWNGRADE END: 2.13.5-series-column-unique ') +} + +module.exports = { up, down } diff --git a/server/models/Series.js b/server/models/Series.js index c57a1a116..731908e9c 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -83,6 +83,12 @@ class Series extends Model { // collate: 'NOCASE' // }] // }, + { + // unique constraint on name and libraryId + fields: ['name', 'libraryId'], + unique: true, + name: 'unique_series_name_per_library' + }, { fields: ['libraryId'] } diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.13.5-series-column-unique.test.js new file mode 100644 index 000000000..6d55b6293 --- /dev/null +++ b/test/server/migrations/v2.13.5-series-column-unique.test.js @@ -0,0 +1,226 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { up, down } = require('../../../server/migrations/v2.13.5-series-column-unique') +const { Sequelize } = require('sequelize') +const Logger = require('../../../server/Logger') +const { query } = require('express') +const { logger } = require('sequelize/lib/utils/logger') +const e = require('express') + +describe('migration_example', () => { + let sequelize + let queryInterface + let loggerInfoStub + let series1Id + let series2Id + let series3Id + let series1Id_dup + let series3Id_dup + let book1Id + let book2Id + let book3Id + let book4Id + let book5Id + let library1Id + let library2Id + let bookSeries1Id + let bookSeries2Id + let bookSeries3Id + let bookSeries1Id_dup + let bookSeries3Id_dup + + 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 () => { + await queryInterface.createTable('Series', { + id: { type: Sequelize.UUID, primaryKey: true }, + name: { type: Sequelize.STRING, allowNull: false }, + libraryId: { type: Sequelize.UUID, allowNull: false }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false } + }) + await queryInterface.createTable('BookSeries', { + id: { type: Sequelize.UUID, primaryKey: true }, + bookId: { type: Sequelize.UUID, allowNull: false }, + seriesId: { type: Sequelize.UUID, allowNull: false } + }) + // Set UUIDs for the tests + series1Id = 'fc086255-3fd2-4a95-8a28-840d9206501b' + series2Id = '70f46ac2-ee48-4b3c-9822-933cc15c29bd' + series3Id = '01cac008-142b-4e15-b0ff-cf7cc2c5b64e' + series1Id_dup = 'ad0b3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + series3Id_dup = '4b3b4b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + series1Id_dup2 = '0123456a-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + book1Id = '4a38b6e5-0ae4-4de4-b119-4e33891bd63f' + book2Id = '8bc2e61d-47f6-42ef-a3f4-93cf2f1de82f' + book3Id = 'ec9bbaaf-1e55-457f-b59c-bd2bd955a404' + book4Id = '876f3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + book5Id = '4e5b4b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + book6Id = 'abcda123-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + library1Id = '3a5a1c7c-a914-472e-88b0-b871ceae63e7' + library2Id = 'fd6c324a-4f3a-4bb0-99d6-7a330e765e7e' + bookSeries1Id = 'eca24687-2241-4ffa-a9b3-02a0ba03c763' + bookSeries2Id = '56f56105-813b-4395-9689-fd04198e7d5d' + bookSeries3Id = '404a1761-c710-4d86-9d78-68d9a9c0fb6b' + bookSeries1Id_dup = '8bea3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + bookSeries3Id_dup = '89656a3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + bookSeries1Id_dup2 = '9bea3b3b-4b3b-4b3b-4b3b-4b3b4b3b4b3b' + }) + afterEach(async () => { + await queryInterface.dropTable('Series') + await queryInterface.dropTable('BookSeries') + }) + it('upgrade with no duplicate series', async () => { + // Add some entries to the Series table using the UUID for the ids + await queryInterface.bulkInsert('Series', [ + { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } + ]) + // Add some entries to the BookSeries table + await queryInterface.bulkInsert('BookSeries', [ + { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }, + { id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }, + { id: bookSeries3Id, bookId: book3Id, seriesId: series3Id } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(5) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 0 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + // Validate rows in tables + const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(series).to.have.length(3) + expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) + expect(series).to.deep.include({ id: series2Id, name: 'Series 2', libraryId: library2Id }) + expect(series).to.deep.include({ id: series3Id, name: 'Series 3', libraryId: library1Id }) + const bookSeries = await queryInterface.sequelize.query('SELECT "id", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(bookSeries).to.have.length(3) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }) + expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }) + expect(bookSeries).to.deep.include({ id: bookSeries3Id, bookId: book3Id, seriesId: series3Id }) + }) + it('upgrade with duplicate series', async () => { + // Add some entries to the Series table using the UUID for the ids + await queryInterface.bulkInsert('Series', [ + { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series1Id_dup, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series3Id_dup, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series1Id_dup2, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } + ]) + // Add some entries to the BookSeries table + await queryInterface.bulkInsert('BookSeries', [ + { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }, + { id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }, + { id: bookSeries3Id, bookId: book3Id, seriesId: series3Id }, + { id: bookSeries1Id_dup, bookId: book4Id, seriesId: series1Id_dup }, + { id: bookSeries3Id_dup, bookId: book5Id, seriesId: series3Id_dup }, + { id: bookSeries1Id_dup2, bookId: book6Id, seriesId: series1Id_dup2 } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(7) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 2 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 3" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + // Validate rows + const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(series).to.have.length(3) + expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) + expect(series).to.deep.include({ id: series2Id, name: 'Series 2', libraryId: library2Id }) + expect(series).to.deep.include({ id: series3Id, name: 'Series 3', libraryId: library1Id }) + const bookSeries = await queryInterface.sequelize.query('SELECT "id", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(bookSeries).to.have.length(6) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }) + expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }) + expect(bookSeries).to.deep.include({ id: bookSeries3Id, bookId: book3Id, seriesId: series3Id }) + expect(bookSeries).to.deep.include({ id: bookSeries1Id_dup, bookId: book4Id, seriesId: series1Id }) + expect(bookSeries).to.deep.include({ id: bookSeries3Id_dup, bookId: book5Id, seriesId: series3Id }) + expect(bookSeries).to.deep.include({ id: bookSeries1Id_dup2, bookId: book6Id, seriesId: series1Id }) + }) + it('update with same series name in different libraries', async () => { + // Add some entries to the Series table using the UUID for the ids + await queryInterface.bulkInsert('Series', [ + { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series2Id, name: 'Series 1', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() } + ]) + // Add some entries to the BookSeries table + await queryInterface.bulkInsert('BookSeries', [ + { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }, + { id: bookSeries2Id, bookId: book2Id, seriesId: series2Id } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(5) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 0 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + // Validate rows + const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(series).to.have.length(2) + expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) + expect(series).to.deep.include({ id: series2Id, name: 'Series 1', libraryId: library2Id }) + const bookSeries = await queryInterface.sequelize.query('SELECT "id", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(bookSeries).to.have.length(2) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }) + expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }) + }) + }) + + describe('down', () => { + beforeEach(async () => { + await queryInterface.createTable('Series', { + id: { type: Sequelize.UUID, primaryKey: true }, + name: { type: Sequelize.STRING, allowNull: false }, + libraryId: { type: Sequelize.UUID, allowNull: false }, + createdAt: { type: Sequelize.DATE, allowNull: false }, + updatedAt: { type: Sequelize.DATE, allowNull: false } + }) + await queryInterface.createTable('BookSeries', { + id: { type: Sequelize.UUID, primaryKey: true }, + bookId: { type: Sequelize.UUID, allowNull: false }, + seriesId: { type: Sequelize.UUID, allowNull: false } + }) + }) + it('should not have unique constraint on series name and libraryId', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(8) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 0 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('DOWNGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Removed unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('DOWNGRADE END: 2.13.5-series-column-unique '))).to.be.true + // Ensure index does not exist + const indexes = await queryInterface.showIndex('Series') + expect(indexes).to.not.deep.include({ tableName: 'Series', unique: true, fields: ['name', 'libraryId'], name: 'unique_series_name_per_library' }) + }) + }) +}) From c163f84aec65da8de7d31390e100d83dff86312a Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 13 Sep 2024 17:01:48 -0700 Subject: [PATCH 002/164] Update migration changelog for series name unique --- server/migrations/changelog.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 2e3c295af..bac3ec25e 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,6 +2,6 @@ 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 | -| -------------- | --------------------- | ----------- | -| | | | +| Server Version | Migration Script Name | Description | +| -------------- | ---------------------------- | ------------------------------------------------- | +| v2.13.5 | v2.13.5-series-column-unique | Series must have unique names in the same library | From 8ae62da1389cd4fe87ba4327e9d76a66237b087a Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 14 Sep 2024 10:40:01 -0500 Subject: [PATCH 003/164] Update migration unit test name --- test/server/migrations/v2.13.5-series-column-unique.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.13.5-series-column-unique.test.js index 6d55b6293..ed950a01f 100644 --- a/test/server/migrations/v2.13.5-series-column-unique.test.js +++ b/test/server/migrations/v2.13.5-series-column-unique.test.js @@ -7,7 +7,7 @@ const { query } = require('express') const { logger } = require('sequelize/lib/utils/logger') const e = require('express') -describe('migration_example', () => { +describe('migration-v2.13.5-series-column-unique', () => { let sequelize let queryInterface let loggerInfoStub From 868659a2f1260d350694ffeda0d59c58d2aa2769 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 14 Sep 2024 11:44:19 -0700 Subject: [PATCH 004/164] Add: unique constraint on bookseries table --- .../v2.13.5-series-column-unique.test.js | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.13.5-series-column-unique.test.js index ed950a01f..d98fb4f79 100644 --- a/test/server/migrations/v2.13.5-series-column-unique.test.js +++ b/test/server/migrations/v2.13.5-series-column-unique.test.js @@ -48,11 +48,16 @@ describe('migration-v2.13.5-series-column-unique', () => { createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false } }) - await queryInterface.createTable('BookSeries', { - id: { type: Sequelize.UUID, primaryKey: true }, - bookId: { type: Sequelize.UUID, allowNull: false }, - seriesId: { type: Sequelize.UUID, allowNull: false } - }) + // Create a table for BookSeries, with a unique constraint of bookId and seriesId + await queryInterface.createTable( + 'BookSeries', + { + id: { type: Sequelize.UUID, primaryKey: true }, + bookId: { type: Sequelize.UUID, allowNull: false }, + seriesId: { type: Sequelize.UUID, allowNull: false } + }, + { uniqueKeys: { book_series_unique: { fields: ['bookId', 'seriesId'] } } } + ) // Set UUIDs for the tests series1Id = 'fc086255-3fd2-4a95-8a28-840d9206501b' series2Id = '70f46ac2-ee48-4b3c-9822-933cc15c29bd' @@ -199,11 +204,16 @@ describe('migration-v2.13.5-series-column-unique', () => { createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false } }) - await queryInterface.createTable('BookSeries', { - id: { type: Sequelize.UUID, primaryKey: true }, - bookId: { type: Sequelize.UUID, allowNull: false }, - seriesId: { type: Sequelize.UUID, allowNull: false } - }) + // Create a table for BookSeries, with a unique constraint of bookId and seriesId + await queryInterface.createTable( + 'BookSeries', + { + id: { type: Sequelize.UUID, primaryKey: true }, + bookId: { type: Sequelize.UUID, allowNull: false }, + seriesId: { type: Sequelize.UUID, allowNull: false } + }, + { uniqueKeys: { book_series_unique: { fields: ['bookId', 'seriesId'] } } } + ) }) it('should not have unique constraint on series name and libraryId', async () => { await up({ context: { queryInterface, logger: Logger } }) From fa451f362b0a83390bbe9dbc3b30abea0af21d8d Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 14 Sep 2024 12:11:31 -0700 Subject: [PATCH 005/164] Add: tests for one book in duplicate series --- .../v2.13.5-series-column-unique.test.js | 102 ++++++++++++++++-- 1 file changed, 95 insertions(+), 7 deletions(-) diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.13.5-series-column-unique.test.js index d98fb4f79..19dc4a8e4 100644 --- a/test/server/migrations/v2.13.5-series-column-unique.test.js +++ b/test/server/migrations/v2.13.5-series-column-unique.test.js @@ -53,6 +53,7 @@ describe('migration-v2.13.5-series-column-unique', () => { 'BookSeries', { id: { type: Sequelize.UUID, primaryKey: true }, + sequence: { type: Sequelize.STRING, allowNull: true }, bookId: { type: Sequelize.UUID, allowNull: false }, seriesId: { type: Sequelize.UUID, allowNull: false } }, @@ -93,9 +94,9 @@ describe('migration-v2.13.5-series-column-unique', () => { ]) // Add some entries to the BookSeries table await queryInterface.bulkInsert('BookSeries', [ - { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }, + { id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }, { id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }, - { id: bookSeries3Id, bookId: book3Id, seriesId: series3Id } + { id: bookSeries3Id, sequence: '1', bookId: book3Id, seriesId: series3Id } ]) await up({ context: { queryInterface, logger: Logger } }) @@ -112,13 +113,13 @@ describe('migration-v2.13.5-series-column-unique', () => { expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) expect(series).to.deep.include({ id: series2Id, name: 'Series 2', libraryId: library2Id }) expect(series).to.deep.include({ id: series3Id, name: 'Series 3', libraryId: library1Id }) - const bookSeries = await queryInterface.sequelize.query('SELECT "id", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) + const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(bookSeries).to.have.length(3) - expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }) - expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }) - expect(bookSeries).to.deep.include({ id: bookSeries3Id, bookId: book3Id, seriesId: series3Id }) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }) + expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: null, bookId: book2Id, seriesId: series2Id }) + expect(bookSeries).to.deep.include({ id: bookSeries3Id, sequence: '1', bookId: book3Id, seriesId: series3Id }) }) - it('upgrade with duplicate series', async () => { + it('upgrade with duplicate series and no sequence', async () => { // Add some entries to the Series table using the UUID for the ids await queryInterface.bulkInsert('Series', [ { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, @@ -193,6 +194,93 @@ describe('migration-v2.13.5-series-column-unique', () => { expect(bookSeries).to.deep.include({ id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }) expect(bookSeries).to.deep.include({ id: bookSeries2Id, bookId: book2Id, seriesId: series2Id }) }) + it('upgrade with one book in two of the same series, both sequence are null', async () => { + // Create two different series with the same name in the same library + await queryInterface.bulkInsert('Series', [ + { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } + ]) + // Create a book that is in both series + await queryInterface.bulkInsert('BookSeries', [ + { id: bookSeries1Id, bookId: book1Id, seriesId: series1Id }, + { id: bookSeries2Id, bookId: book1Id, seriesId: series2Id } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(6) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId')).to.be.true) + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + // validate rows + const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(series).to.have.length(1) + expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) + const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(bookSeries).to.have.length(1) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: null, bookId: book1Id, seriesId: series1Id }) + }) + it('upgrade with one book in two of the same series, one sequence is null', async () => { + // Create two different series with the same name in the same library + await queryInterface.bulkInsert('Series', [ + { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } + ]) + // Create a book that is in both series + await queryInterface.bulkInsert('BookSeries', [ + { id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }, + { id: bookSeries2Id, bookId: book1Id, seriesId: series2Id } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(6) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId')).to.be.true) + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + // validate rows + const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(series).to.have.length(1) + expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) + const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(bookSeries).to.have.length(1) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }) + }) + it('upgrade with one book in two of the same series, both sequence are not null', async () => { + // Create two different series with the same name in the same library + await queryInterface.bulkInsert('Series', [ + { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, + { id: series2Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } + ]) + // Create a book that is in both series + await queryInterface.bulkInsert('BookSeries', [ + { id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }, + { id: bookSeries2Id, sequence: '2', bookId: book1Id, seriesId: series2Id } + ]) + + await up({ context: { queryInterface, logger: Logger } }) + + expect(loggerInfoStub.callCount).to.equal(6) + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId')).to.be.true) + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + // validate rows + const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(series).to.have.length(1) + expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) + const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) + expect(bookSeries).to.have.length(1) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '1, 2', bookId: book1Id, seriesId: series1Id }) + }) }) describe('down', () => { From 999ada03d16fd5b6faf387c757c454e74dcb50a6 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 14 Sep 2024 14:36:47 -0700 Subject: [PATCH 006/164] Fix: missing variables --- test/server/migrations/v2.13.5-series-column-unique.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.13.5-series-column-unique.test.js index 19dc4a8e4..5ce5a465f 100644 --- a/test/server/migrations/v2.13.5-series-column-unique.test.js +++ b/test/server/migrations/v2.13.5-series-column-unique.test.js @@ -16,11 +16,13 @@ describe('migration-v2.13.5-series-column-unique', () => { let series3Id let series1Id_dup let series3Id_dup + let series1Id_dup2 let book1Id let book2Id let book3Id let book4Id let book5Id + let book6Id let library1Id let library2Id let bookSeries1Id @@ -28,6 +30,7 @@ describe('migration-v2.13.5-series-column-unique', () => { let bookSeries3Id let bookSeries1Id_dup let bookSeries3Id_dup + let bookSeries1Id_dup2 beforeEach(() => { sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) @@ -164,7 +167,7 @@ describe('migration-v2.13.5-series-column-unique', () => { expect(bookSeries).to.deep.include({ id: bookSeries3Id_dup, bookId: book5Id, seriesId: series3Id }) expect(bookSeries).to.deep.include({ id: bookSeries1Id_dup2, bookId: book6Id, seriesId: series1Id }) }) - it('update with same series name in different libraries', async () => { + it('upgrade with same series name in different libraries', async () => { // Add some entries to the Series table using the UUID for the ids await queryInterface.bulkInsert('Series', [ { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, From 836d772cd4502f109a06d8c78f824a03bad0d249 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 14 Sep 2024 15:23:29 -0700 Subject: [PATCH 007/164] Update: remove the same book if occurs multiple times in duplicate series --- .../v2.13.5-series-column-unique.js | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/server/migrations/v2.13.5-series-column-unique.js b/server/migrations/v2.13.5-series-column-unique.js index e7201bae1..21e4d4d5d 100644 --- a/server/migrations/v2.13.5-series-column-unique.js +++ b/server/migrations/v2.13.5-series-column-unique.js @@ -20,7 +20,15 @@ async function up({ context: { queryInterface, logger } }) { // Upwards migration script logger.info('UPGRADE BEGIN: 2.13.5-series-column-unique ') - // Use the queryInterface to get the series table and find duplicates in the `name` column + // The steps taken to deduplicate the series are as follows: + // 1. Find all duplicate series in the `Series` table. + // 2. Iterate over the duplicate series and find all book IDs that are associated with the duplicate series in `bookSeries` table. + // 2.a For each book ID, check if the ID occurs multiple times for the duplicate series. + // 2.b If so, keep only one of the rows that has this bookId and seriesId. + // 3. Update `bookSeries` table to point to the most recent series. + // 4. Delete the older series. + + // Use the queryInterface to get the series table and find duplicates in the `name` and `libraryId` column const [duplicates] = await queryInterface.sequelize.query(` SELECT name, libraryId, MAX(updatedAt) AS latestUpdatedAt, COUNT(name) AS count FROM Series @@ -36,6 +44,70 @@ async function up({ context: { queryInterface, logger } }) { // Report the series name that is being deleted logger.info(`[2.13.5 migration] Deduplicating series "${duplicate.name}" in library ${duplicate.libraryId}`) + // Determine any duplicate book IDs in the `bookSeries` table for the same series + const [duplicateBookIds] = await queryInterface.sequelize.query( + ` + SELECT bookId, COUNT(bookId) AS count + FROM BookSeries + WHERE seriesId IN ( + SELECT id + FROM Series + WHERE name = :name AND libraryId = :libraryId + ) + GROUP BY bookId + HAVING COUNT(bookId) > 1 + `, + { + replacements: { + name: duplicate.name, + libraryId: duplicate.libraryId + } + } + ) + + // Iterate over the duplicate book IDs if there is at least one and only keep the first row that has this bookId and seriesId + for (const { bookId } of duplicateBookIds) { + // Get all rows of `BookSeries` table that have the same `bookId` and `seriesId`. Sort by `sequence` with nulls sorted last + const [duplicateBookSeries] = await queryInterface.sequelize.query( + ` + SELECT id + FROM BookSeries + WHERE bookId = :bookId + AND seriesId IN ( + SELECT id + FROM Series + WHERE name = :name AND libraryId = :libraryId + ) + ORDER BY sequence NULLS LAST + `, + { + replacements: { + bookId, + name: duplicate.name, + libraryId: duplicate.libraryId + } + } + ) + + // remove the first element from the array + duplicateBookSeries.shift() + + // Delete the remaining duplicate rows + if (duplicateBookSeries.length > 0) { + const [deletedBookSeries] = await queryInterface.sequelize.query( + ` + DELETE FROM BookSeries + WHERE id IN (:ids) + `, + { + replacements: { + ids: duplicateBookSeries.map((row) => row.id) + } + } + ) + } + } + // Get all the most recent series which matches the `name` and `libraryId` const [mostRecentSeries] = await queryInterface.sequelize.query( ` From 691ed88096082a42f30e077c0f3c3170c84ab5af Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 14 Sep 2024 15:34:38 -0700 Subject: [PATCH 008/164] Add more logging, clean up typo --- .../v2.13.5-series-column-unique.js | 2 ++ .../v2.13.5-series-column-unique.test.js | 34 +++++++++++-------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/server/migrations/v2.13.5-series-column-unique.js b/server/migrations/v2.13.5-series-column-unique.js index 21e4d4d5d..8c9775882 100644 --- a/server/migrations/v2.13.5-series-column-unique.js +++ b/server/migrations/v2.13.5-series-column-unique.js @@ -67,6 +67,7 @@ async function up({ context: { queryInterface, logger } }) { // Iterate over the duplicate book IDs if there is at least one and only keep the first row that has this bookId and seriesId for (const { bookId } of duplicateBookIds) { + logger.info(`[2.13.5 migration] Deduplicating bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) // Get all rows of `BookSeries` table that have the same `bookId` and `seriesId`. Sort by `sequence` with nulls sorted last const [duplicateBookSeries] = await queryInterface.sequelize.query( ` @@ -106,6 +107,7 @@ async function up({ context: { queryInterface, logger } }) { } ) } + logger.info(`[2.13.5 migration] Finished cleanup of bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) } // Get all the most recent series which matches the `name` and `libraryId` diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.13.5-series-column-unique.test.js index 5ce5a465f..2fd59eeb4 100644 --- a/test/server/migrations/v2.13.5-series-column-unique.test.js +++ b/test/server/migrations/v2.13.5-series-column-unique.test.js @@ -211,13 +211,15 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) - expect(loggerInfoStub.callCount).to.equal(6) + expect(loggerInfoStub.callCount).to.equal(8) expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId')).to.be.true) - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true // validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(1) @@ -240,13 +242,15 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) - expect(loggerInfoStub.callCount).to.equal(6) + expect(loggerInfoStub.callCount).to.equal(8) expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId')).to.be.true) - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true // validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(1) @@ -263,26 +267,28 @@ describe('migration-v2.13.5-series-column-unique', () => { ]) // Create a book that is in both series await queryInterface.bulkInsert('BookSeries', [ - { id: bookSeries1Id, sequence: '1', bookId: book1Id, seriesId: series1Id }, + { id: bookSeries1Id, sequence: '3', bookId: book1Id, seriesId: series1Id }, { id: bookSeries2Id, sequence: '2', bookId: book1Id, seriesId: series2Id } ]) await up({ context: { queryInterface, logger: Logger } }) - expect(loggerInfoStub.callCount).to.equal(6) + expect(loggerInfoStub.callCount).to.equal(8) expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId')).to.be.true) - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 8bc2e61d-47f6-42ef-a3f4-93cf2f1de82f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 8bc2e61d-47f6-42ef-a3f4-93cf2f1de82f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true // validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(1) expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(bookSeries).to.have.length(1) - expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '1, 2', bookId: book1Id, seriesId: series1Id }) + expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '2', bookId: book1Id, seriesId: series1Id }) }) }) From 8b95dd65d917fe2c2b200918362b308f2514d6ee Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 14 Sep 2024 15:43:10 -0700 Subject: [PATCH 009/164] Fix: test cases checking the wrong bookSeriesId --- .../migrations/v2.13.5-series-column-unique.test.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.13.5-series-column-unique.test.js index 2fd59eeb4..3c5b1b040 100644 --- a/test/server/migrations/v2.13.5-series-column-unique.test.js +++ b/test/server/migrations/v2.13.5-series-column-unique.test.js @@ -226,7 +226,8 @@ describe('migration-v2.13.5-series-column-unique', () => { expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(bookSeries).to.have.length(1) - expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: null, bookId: book1Id, seriesId: series1Id }) + // Keep BookSeries 2 because it was edited last from cleaning up duplicate books + expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: null, bookId: book1Id, seriesId: series1Id }) }) it('upgrade with one book in two of the same series, one sequence is null', async () => { // Create two different series with the same name in the same library @@ -277,8 +278,8 @@ describe('migration-v2.13.5-series-column-unique', () => { expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 8bc2e61d-47f6-42ef-a3f4-93cf2f1de82f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 8bc2e61d-47f6-42ef-a3f4-93cf2f1de82f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true expect(loggerInfoStub.getCall(7).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true @@ -288,7 +289,8 @@ describe('migration-v2.13.5-series-column-unique', () => { expect(series).to.deep.include({ id: series1Id, name: 'Series 1', libraryId: library1Id }) const bookSeries = await queryInterface.sequelize.query('SELECT "id", "sequence", "bookId", "seriesId" FROM BookSeries', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(bookSeries).to.have.length(1) - expect(bookSeries).to.deep.include({ id: bookSeries1Id, sequence: '2', bookId: book1Id, seriesId: series1Id }) + // Keep BookSeries 2 because it is the lower sequence number + expect(bookSeries).to.deep.include({ id: bookSeries2Id, sequence: '2', bookId: book1Id, seriesId: series1Id }) }) }) From 66b290577c3663a62edd2f5aaf8291b18cb72b98 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Tue, 17 Sep 2024 20:00:06 -0700 Subject: [PATCH 010/164] Clean up unused parts of statement --- server/migrations/v2.13.5-series-column-unique.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/migrations/v2.13.5-series-column-unique.js b/server/migrations/v2.13.5-series-column-unique.js index 8c9775882..1860772d2 100644 --- a/server/migrations/v2.13.5-series-column-unique.js +++ b/server/migrations/v2.13.5-series-column-unique.js @@ -1,5 +1,3 @@ -const Logger = require('../Logger') - /** * @typedef MigrationContext * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. @@ -30,7 +28,7 @@ async function up({ context: { queryInterface, logger } }) { // Use the queryInterface to get the series table and find duplicates in the `name` and `libraryId` column const [duplicates] = await queryInterface.sequelize.query(` - SELECT name, libraryId, MAX(updatedAt) AS latestUpdatedAt, COUNT(name) AS count + SELECT name, libraryId FROM Series GROUP BY name, libraryId HAVING COUNT(name) > 1 @@ -47,7 +45,7 @@ async function up({ context: { queryInterface, logger } }) { // Determine any duplicate book IDs in the `bookSeries` table for the same series const [duplicateBookIds] = await queryInterface.sequelize.query( ` - SELECT bookId, COUNT(bookId) AS count + SELECT bookId FROM BookSeries WHERE seriesId IN ( SELECT id From 8a7b5cc87d0e8351569fa4e8772da781327d7cd4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 24 Sep 2024 16:47:09 -0500 Subject: [PATCH 011/164] Ensure series-column-unique migration is idempotent --- server/managers/MigrationManager.js | 3 +-- .../v2.13.5-series-column-unique.js | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/server/managers/MigrationManager.js b/server/managers/MigrationManager.js index 706e359cf..beaf8a4d8 100644 --- a/server/managers/MigrationManager.js +++ b/server/managers/MigrationManager.js @@ -38,6 +38,7 @@ class MigrationManager { if (!(await fs.pathExists(this.configPath))) throw new Error(`Config path does not exist: ${this.configPath}`) this.migrationsDir = path.join(this.configPath, 'migrations') + await fs.ensureDir(this.migrationsDir) this.serverVersion = this.extractVersionFromTag(serverVersion) if (!this.serverVersion) throw new Error(`Invalid server version: ${serverVersion}. Expected a version tag like v1.2.3.`) @@ -222,8 +223,6 @@ class MigrationManager { } async copyMigrationsToConfigDir() { - await fs.ensureDir(this.migrationsDir) // Ensure the target directory exists - if (!(await fs.pathExists(this.migrationsSourceDir))) return const files = await fs.readdir(this.migrationsSourceDir) diff --git a/server/migrations/v2.13.5-series-column-unique.js b/server/migrations/v2.13.5-series-column-unique.js index 1860772d2..2724221ab 100644 --- a/server/migrations/v2.13.5-series-column-unique.js +++ b/server/migrations/v2.13.5-series-column-unique.js @@ -16,7 +16,15 @@ */ async function up({ context: { queryInterface, logger } }) { // Upwards migration script - logger.info('UPGRADE BEGIN: 2.13.5-series-column-unique ') + logger.info('[2.13.5 migration] UPGRADE BEGIN: 2.13.5-series-column-unique ') + + // Check if the unique index already exists + const seriesIndexes = await queryInterface.showIndex('Series') + if (seriesIndexes.some((index) => index.name === 'unique_series_name_per_library')) { + logger.info('[2.13.5 migration] Unique index on Series.name and Series.libraryId already exists') + logger.info('[2.13.5 migration] UPGRADE END: 2.13.5-series-column-unique ') + return + } // The steps taken to deduplicate the series are as follows: // 1. Find all duplicate series in the `Series` table. @@ -173,9 +181,9 @@ async function up({ context: { queryInterface, logger } }) { unique: true, name: 'unique_series_name_per_library' }) - logger.info('Added unique index on Series.name and Series.libraryId') + logger.info('[2.13.5 migration] Added unique index on Series.name and Series.libraryId') - logger.info('UPGRADE END: 2.13.5-series-column-unique ') + logger.info('[2.13.5 migration] UPGRADE END: 2.13.5-series-column-unique ') } /** @@ -186,13 +194,13 @@ async function up({ context: { queryInterface, logger } }) { */ async function down({ context: { queryInterface, logger } }) { // Downward migration script - logger.info('DOWNGRADE BEGIN: 2.13.5-series-column-unique ') + logger.info('[2.13.5 migration] DOWNGRADE BEGIN: 2.13.5-series-column-unique ') // Remove the unique index await queryInterface.removeIndex('Series', 'unique_series_name_per_library') - logger.info('Removed unique index on Series.name and Series.libraryId') + logger.info('[2.13.5 migration] Removed unique index on Series.name and Series.libraryId') - logger.info('DOWNGRADE END: 2.13.5-series-column-unique ') + logger.info('[2.13.5 migration] DOWNGRADE END: 2.13.5-series-column-unique ') } module.exports = { up, down } From c67b5e950edbd86e74708e92d3c58c700bea5f1b Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 24 Sep 2024 16:54:13 -0500 Subject: [PATCH 012/164] Update MigrationManager.test.js - moved migrations ensureDir to init() --- test/server/managers/MigrationManager.test.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/server/managers/MigrationManager.test.js b/test/server/managers/MigrationManager.test.js index ae94cd75c..af2e9da8f 100644 --- a/test/server/managers/MigrationManager.test.js +++ b/test/server/managers/MigrationManager.test.js @@ -63,6 +63,8 @@ describe('MigrationManager', () => { await migrationManager.init(serverVersion) // Assert + expect(fsEnsureDirStub.calledOnce).to.be.true + expect(fsEnsureDirStub.calledWith(migrationManager.migrationsDir)).to.be.true expect(migrationManager.serverVersion).to.equal(serverVersion) expect(migrationManager.sequelize).to.equal(sequelizeStub) expect(migrationManager.migrationsDir).to.equal(path.join(__dirname, 'migrations')) @@ -353,8 +355,6 @@ describe('MigrationManager', () => { await migrationManager.copyMigrationsToConfigDir() // Assert - expect(fsEnsureDirStub.calledOnce).to.be.true - expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true expect(readdirStub.calledOnce).to.be.true expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true expect(fsCopyStub.calledTwice).to.be.true @@ -382,8 +382,6 @@ describe('MigrationManager', () => { } catch (error) {} // Assert - expect(fsEnsureDirStub.calledOnce).to.be.true - expect(fsEnsureDirStub.calledWith(targetDir)).to.be.true expect(readdirStub.calledOnce).to.be.true expect(readdirStub.calledWith(migrationsSourceDir)).to.be.true expect(fsCopyStub.calledTwice).to.be.true From 5154e31c1cba8bad9a088f5586632fabb3abd656 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 24 Sep 2024 17:06:00 -0500 Subject: [PATCH 013/164] Update migration to v2.14.0 --- server/migrations/changelog.md | 2 +- ...que.js => v2.14.0-series-column-unique.js} | 26 ++--- ...s => v2.14.0-series-column-unique.test.js} | 102 +++++++++--------- 3 files changed, 65 insertions(+), 65 deletions(-) rename server/migrations/{v2.13.5-series-column-unique.js => v2.14.0-series-column-unique.js} (88%) rename test/server/migrations/{v2.13.5-series-column-unique.test.js => v2.14.0-series-column-unique.test.js} (78%) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index bac3ec25e..3ab52ac3e 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -4,4 +4,4 @@ Please add a record of every database migration that you create to this file. Th | Server Version | Migration Script Name | Description | | -------------- | ---------------------------- | ------------------------------------------------- | -| v2.13.5 | v2.13.5-series-column-unique | Series must have unique names in the same library | +| v2.14.0 | v2.14.0-series-column-unique | Series must have unique names in the same library | diff --git a/server/migrations/v2.13.5-series-column-unique.js b/server/migrations/v2.14.0-series-column-unique.js similarity index 88% rename from server/migrations/v2.13.5-series-column-unique.js rename to server/migrations/v2.14.0-series-column-unique.js index 2724221ab..489b670bb 100644 --- a/server/migrations/v2.13.5-series-column-unique.js +++ b/server/migrations/v2.14.0-series-column-unique.js @@ -16,13 +16,13 @@ */ async function up({ context: { queryInterface, logger } }) { // Upwards migration script - logger.info('[2.13.5 migration] UPGRADE BEGIN: 2.13.5-series-column-unique ') + logger.info('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique ') // Check if the unique index already exists const seriesIndexes = await queryInterface.showIndex('Series') if (seriesIndexes.some((index) => index.name === 'unique_series_name_per_library')) { - logger.info('[2.13.5 migration] Unique index on Series.name and Series.libraryId already exists') - logger.info('[2.13.5 migration] UPGRADE END: 2.13.5-series-column-unique ') + logger.info('[2.14.0 migration] Unique index on Series.name and Series.libraryId already exists') + logger.info('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique ') return } @@ -43,12 +43,12 @@ async function up({ context: { queryInterface, logger } }) { `) // Print out how many duplicates were found - logger.info(`[2.13.5 migration] Found ${duplicates.length} duplicate series`) + logger.info(`[2.14.0 migration] Found ${duplicates.length} duplicate series`) // Iterate over each duplicate series for (const duplicate of duplicates) { // Report the series name that is being deleted - logger.info(`[2.13.5 migration] Deduplicating series "${duplicate.name}" in library ${duplicate.libraryId}`) + logger.info(`[2.14.0 migration] Deduplicating series "${duplicate.name}" in library ${duplicate.libraryId}`) // Determine any duplicate book IDs in the `bookSeries` table for the same series const [duplicateBookIds] = await queryInterface.sequelize.query( @@ -73,7 +73,7 @@ async function up({ context: { queryInterface, logger } }) { // Iterate over the duplicate book IDs if there is at least one and only keep the first row that has this bookId and seriesId for (const { bookId } of duplicateBookIds) { - logger.info(`[2.13.5 migration] Deduplicating bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) + logger.info(`[2.14.0 migration] Deduplicating bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) // Get all rows of `BookSeries` table that have the same `bookId` and `seriesId`. Sort by `sequence` with nulls sorted last const [duplicateBookSeries] = await queryInterface.sequelize.query( ` @@ -113,7 +113,7 @@ async function up({ context: { queryInterface, logger } }) { } ) } - logger.info(`[2.13.5 migration] Finished cleanup of bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) + logger.info(`[2.14.0 migration] Finished cleanup of bookId ${bookId} in series "${duplicate.name}" of library ${duplicate.libraryId}`) } // Get all the most recent series which matches the `name` and `libraryId` @@ -174,16 +174,16 @@ async function up({ context: { queryInterface, logger } }) { } } - logger.info(`[2.13.5 migration] Deduplication complete`) + logger.info(`[2.14.0 migration] Deduplication complete`) // Create a unique index based on the name and library ID for the `Series` table await queryInterface.addIndex('Series', ['name', 'libraryId'], { unique: true, name: 'unique_series_name_per_library' }) - logger.info('[2.13.5 migration] Added unique index on Series.name and Series.libraryId') + logger.info('[2.14.0 migration] Added unique index on Series.name and Series.libraryId') - logger.info('[2.13.5 migration] UPGRADE END: 2.13.5-series-column-unique ') + logger.info('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique ') } /** @@ -194,13 +194,13 @@ async function up({ context: { queryInterface, logger } }) { */ async function down({ context: { queryInterface, logger } }) { // Downward migration script - logger.info('[2.13.5 migration] DOWNGRADE BEGIN: 2.13.5-series-column-unique ') + logger.info('[2.14.0 migration] DOWNGRADE BEGIN: 2.14.0-series-column-unique ') // Remove the unique index await queryInterface.removeIndex('Series', 'unique_series_name_per_library') - logger.info('[2.13.5 migration] Removed unique index on Series.name and Series.libraryId') + logger.info('[2.14.0 migration] Removed unique index on Series.name and Series.libraryId') - logger.info('[2.13.5 migration] DOWNGRADE END: 2.13.5-series-column-unique ') + logger.info('[2.14.0 migration] DOWNGRADE END: 2.14.0-series-column-unique ') } module.exports = { up, down } diff --git a/test/server/migrations/v2.13.5-series-column-unique.test.js b/test/server/migrations/v2.14.0-series-column-unique.test.js similarity index 78% rename from test/server/migrations/v2.13.5-series-column-unique.test.js rename to test/server/migrations/v2.14.0-series-column-unique.test.js index 3c5b1b040..43acc9278 100644 --- a/test/server/migrations/v2.13.5-series-column-unique.test.js +++ b/test/server/migrations/v2.14.0-series-column-unique.test.js @@ -1,13 +1,13 @@ const { expect } = require('chai') const sinon = require('sinon') -const { up, down } = require('../../../server/migrations/v2.13.5-series-column-unique') +const { up, down } = require('../../../server/migrations/v2.14.0-series-column-unique') const { Sequelize } = require('sequelize') const Logger = require('../../../server/Logger') const { query } = require('express') const { logger } = require('sequelize/lib/utils/logger') const e = require('express') -describe('migration-v2.13.5-series-column-unique', () => { +describe('migration-v2.14.0-series-column-unique', () => { let sequelize let queryInterface let loggerInfoStub @@ -105,11 +105,11 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(5) - expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 0 duplicate series'))).to.be.true - expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.14.0 migration] Found 0 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.14.0 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.14.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique '))).to.be.true // Validate rows in tables const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(3) @@ -145,13 +145,13 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(7) - expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 2 duplicate series'))).to.be.true - expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 3" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(6).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.14.0 migration] Found 2 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.14.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.14.0 migration] Deduplicating series "Series 3" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.14.0 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.14.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique '))).to.be.true // Validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(3) @@ -182,11 +182,11 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(5) - expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 0 duplicate series'))).to.be.true - expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.14.0 migration] Found 0 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.14.0 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.14.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique '))).to.be.true // Validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(2) @@ -212,14 +212,14 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(8) - expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true - expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(7).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.14.0 migration] Found 1 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.14.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.14.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.14.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.14.0 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.14.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique '))).to.be.true // validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(1) @@ -244,14 +244,14 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(8) - expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true - expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(7).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.14.0 migration] Found 1 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.14.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.14.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.14.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.14.0 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.14.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique '))).to.be.true // validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(1) @@ -275,14 +275,14 @@ describe('migration-v2.13.5-series-column-unique', () => { await up({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(8) - expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 1 duplicate series'))).to.be.true - expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.13.5 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.13.5 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(7).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.14.0 migration] Found 1 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.14.0 migration] Deduplicating series "Series 1" in library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.14.0 migration] Deduplicating bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.14.0 migration] Finished cleanup of bookId 4a38b6e5-0ae4-4de4-b119-4e33891bd63f in series "Series 1" of library 3a5a1c7c-a914-472e-88b0-b871ceae63e7'))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.14.0 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.14.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique '))).to.be.true // validate rows const series = await queryInterface.sequelize.query('SELECT "id", "name", "libraryId" FROM Series', { type: queryInterface.sequelize.QueryTypes.SELECT }) expect(series).to.have.length(1) @@ -319,14 +319,14 @@ describe('migration-v2.13.5-series-column-unique', () => { await down({ context: { queryInterface, logger: Logger } }) expect(loggerInfoStub.callCount).to.equal(8) - expect(loggerInfoStub.getCall(0).calledWith(sinon.match('UPGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.13.5 migration] Found 0 duplicate series'))).to.be.true - expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.13.5 migration] Deduplication complete'))).to.be.true - expect(loggerInfoStub.getCall(3).calledWith(sinon.match('Added unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(4).calledWith(sinon.match('UPGRADE END: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(5).calledWith(sinon.match('DOWNGRADE BEGIN: 2.13.5-series-column-unique '))).to.be.true - expect(loggerInfoStub.getCall(6).calledWith(sinon.match('Removed unique index on Series.name and Series.libraryId'))).to.be.true - expect(loggerInfoStub.getCall(7).calledWith(sinon.match('DOWNGRADE END: 2.13.5-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(0).calledWith(sinon.match('[2.14.0 migration] UPGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(1).calledWith(sinon.match('[2.14.0 migration] Found 0 duplicate series'))).to.be.true + expect(loggerInfoStub.getCall(2).calledWith(sinon.match('[2.14.0 migration] Deduplication complete'))).to.be.true + expect(loggerInfoStub.getCall(3).calledWith(sinon.match('[2.14.0 migration] Added unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(4).calledWith(sinon.match('[2.14.0 migration] UPGRADE END: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(5).calledWith(sinon.match('[2.14.0 migration] DOWNGRADE BEGIN: 2.14.0-series-column-unique '))).to.be.true + expect(loggerInfoStub.getCall(6).calledWith(sinon.match('[2.14.0 migration] Removed unique index on Series.name and Series.libraryId'))).to.be.true + expect(loggerInfoStub.getCall(7).calledWith(sinon.match('[2.14.0 migration] DOWNGRADE END: 2.14.0-series-column-unique '))).to.be.true // Ensure index does not exist const indexes = await queryInterface.showIndex('Series') expect(indexes).to.not.deep.include({ tableName: 'Series', unique: true, fields: ['name', 'libraryId'], name: 'unique_series_name_per_library' }) From d8c5627cf8279fa4e0c50691f10d9e0ca4db9468 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 3 Oct 2024 18:34:43 -0500 Subject: [PATCH 014/164] Update audio player volume slider to be vertical --- client/components/controls/VolumeControl.vue | 43 ++++++++------------ client/components/player/PlayerUi.vue | 2 +- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/client/components/controls/VolumeControl.vue b/client/components/controls/VolumeControl.vue index 220cccaa0..a29ee7d10 100644 --- a/client/components/controls/VolumeControl.vue +++ b/client/components/controls/VolumeControl.vue @@ -4,10 +4,10 @@ {{ volumeIcon }} -
-
-
-
+
+
+
+
@@ -24,10 +24,10 @@ export default { isOpen: false, isDragging: false, isHovering: false, - posX: 0, + posY: 0, lastValue: 0.5, isMute: false, - trackWidth: 112 - 20, + trackHeight: 112 - 20, openTimeout: null } }, @@ -45,9 +45,9 @@ export default { this.$emit('input', val) } }, - cursorLeft() { - var left = this.trackWidth * this.volume - return left - 3 + cursorBottom() { + var bottom = this.trackHeight * this.volume + return bottom - 3 }, volumeIcon() { if (this.volume <= 0) return 'volume_mute' @@ -89,17 +89,10 @@ export default { }, 600) }, mousemove(e) { - var diff = this.posX - e.x - this.posX = e.x - var volShift = 0 - if (diff < 0) { - // Volume up - volShift = diff / this.trackWidth - } else { - // volume down - volShift = diff / this.trackWidth - } - var newVol = this.volume - volShift + var diff = this.posY - e.y + this.posY = e.y + var volShift = diff / this.trackHeight + var newVol = this.volume + volShift newVol = Math.min(Math.max(0, newVol), 1) this.volume = newVol e.preventDefault() @@ -113,8 +106,8 @@ export default { }, mousedownTrack(e) { this.isDragging = true - this.posX = e.x - var vol = e.offsetX / this.trackWidth + this.posY = e.y + var vol = 1 - e.offsetY / this.trackHeight vol = Math.min(Math.max(vol, 0), 1) this.volume = vol document.body.addEventListener('mousemove', this.mousemove) @@ -137,7 +130,7 @@ export default { this.clickVolumeIcon() }, clickVolumeTrack(e) { - var vol = e.offsetX / this.trackWidth + var vol = 1 - e.offsetY / this.trackHeight vol = Math.min(Math.max(vol, 0), 1) this.volume = vol } @@ -147,7 +140,7 @@ export default { this.isMute = true } const storageVolume = localStorage.getItem('volume') - if (storageVolume) { + if (storageVolume && !isNaN(storageVolume)) { this.volume = parseFloat(storageVolume) } }, @@ -157,4 +150,4 @@ export default { document.body.removeEventListener('mouseup', this.mouseup) } } - \ No newline at end of file + diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue index 689dd31a3..92179580b 100644 --- a/client/components/player/PlayerUi.vue +++ b/client/components/player/PlayerUi.vue @@ -4,7 +4,7 @@
- + From 37af753402ec3f0262a8469101dacaba526be392 Mon Sep 17 00:00:00 2001 From: kuci-JK Date: Wed, 2 Oct 2024 20:43:41 +0000 Subject: [PATCH 015/164] Translated using Weblate (Czech) Currently translated at 88.8% (880 of 990 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 108 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 6 deletions(-) diff --git a/client/strings/cs.json b/client/strings/cs.json index 778021432..9b429f5e6 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -28,6 +28,7 @@ "ButtonEdit": "Upravit", "ButtonEditChapters": "Upravit kapitoly", "ButtonEditPodcast": "Upravit podcast", + "ButtonEnable": "Povolit", "ButtonForceReScan": "Vynutit opětovné prohledání", "ButtonFullPath": "Úplná cesta", "ButtonHide": "Skrýt", @@ -44,10 +45,15 @@ "ButtonMatchAllAuthors": "Spárovat všechny autory", "ButtonMatchBooks": "Spárovat Knihy", "ButtonNevermind": "Nevadí", + "ButtonNext": "Další", "ButtonNextChapter": "Další Kapitola", + "ButtonNextItemInQueue": "Žádná další položka ve frontě", + "ButtonOk": "Ok", "ButtonOpenFeed": "Otevřít kanál", "ButtonOpenManager": "Otevřít správce", + "ButtonPause": "Pozastavit", "ButtonPlay": "Přehrát", + "ButtonPlayAll": "Přehrát vše", "ButtonPlaying": "Hraje", "ButtonPlaylists": "Seznamy skladeb", "ButtonPrevious": "Předchozí", @@ -88,6 +94,8 @@ "ButtonStartMetadataEmbed": "Spustit vkládání metadat", "ButtonStats": "Statistiky", "ButtonSubmit": "Odeslat", + "ButtonTest": "Test", + "ButtonUnlinkOpenId": "Odpojit OpenID", "ButtonUpload": "Nahrát", "ButtonUploadBackup": "Nahrát zálohu", "ButtonUploadCover": "Nahrát obálku", @@ -100,10 +108,12 @@ "ErrorUploadFetchMetadataNoResults": "Nepodařilo se načíst metadata - zkuste aktualizovat název a/nebo autora", "ErrorUploadLacksTitle": "Musí mít titul", "HeaderAccount": "Účet", + "HeaderAddCustomMetadataProvider": "Přidat vlastního poskytovatele metadat", "HeaderAdvanced": "Pokročilé", "HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise", "HeaderAudioTracks": "Zvukové stopy", "HeaderAudiobookTools": "Nástroje pro správu souborů audioknih", + "HeaderAuthentication": "Autentizace", "HeaderBackups": "Zálohy", "HeaderChangePassword": "Změnit heslo", "HeaderChapters": "Kapitoly", @@ -144,10 +154,13 @@ "HeaderMetadataToEmbed": "Metadata k vložení", "HeaderNewAccount": "Nový účet", "HeaderNewLibrary": "Nová knihovna", + "HeaderNotificationCreate": "Vytvořit notifikaci", + "HeaderNotificationUpdate": "Aktualizovat notifikaci", "HeaderNotifications": "Oznámení", "HeaderOpenIDConnectAuthentication": "Ověřování pomocí OpenID Connect", "HeaderOpenRSSFeed": "Otevřít RSS kanál", "HeaderOtherFiles": "Ostatní soubory", + "HeaderPasswordAuthentication": "Autentizace heslem", "HeaderPermissions": "Oprávnění", "HeaderPlayerQueue": "Fronta přehrávače", "HeaderPlayerSettings": "Nastavení přehrávače", @@ -200,6 +213,7 @@ "LabelAddToPlaylist": "Přidat do seznamu přehrávání", "LabelAddToPlaylistBatch": "Přidat {0} položky do seznamu přehrávání", "LabelAddedAt": "Přidáno v", + "LabelAddedDate": "Přidáno {0}", "LabelAdminUsersOnly": "Pouze administrátoři", "LabelAll": "Vše", "LabelAllUsers": "Všichni uživatelé", @@ -229,6 +243,7 @@ "LabelBitrate": "Datový tok", "LabelBooks": "Knihy", "LabelButtonText": "Text tlačítka", + "LabelByAuthor": "od {0}", "LabelChangePassword": "Změnit heslo", "LabelChannels": "Kanály", "LabelChapterTitle": "Název kapitoly", @@ -238,6 +253,7 @@ "LabelClosePlayer": "Zavřít přehrávač", "LabelCodec": "Kodek", "LabelCollapseSeries": "Sbalit sérii", + "LabelCollapseSubSeries": "Sbalit podsérie", "LabelCollection": "Kolekce", "LabelCollections": "Kolekce", "LabelComplete": "Dokončeno", @@ -288,16 +304,21 @@ "LabelEpisode": "Epizoda", "LabelEpisodeTitle": "Název epizody", "LabelEpisodeType": "Typ epizody", + "LabelEpisodes": "Epizody", "LabelExample": "Příklad", "LabelExpandSeries": "Rozbalit série", + "LabelExpandSubSeries": "Rozbalit podsérie", "LabelExplicit": "Explicitní", "LabelExplicitChecked": "Explicitní (zaškrtnuto)", "LabelExplicitUnchecked": "Není explicitní (nezaškrtnuto)", + "LabelExportOPML": "Export OPML", "LabelFeedURL": "URL zdroje", "LabelFetchingMetadata": "Získávání metadat", "LabelFile": "Soubor", "LabelFileBirthtime": "Čas vzniku souboru", + "LabelFileBornDate": "Vytvořeno {0}", "LabelFileModified": "Soubor změněn", + "LabelFileModifiedDate": "Změněno {0}", "LabelFilename": "Název souboru", "LabelFilterByUser": "Filtrovat podle uživatele", "LabelFindEpisodes": "Najít epizody", @@ -307,6 +328,7 @@ "LabelFontBold": "Tučně", "LabelFontBoldness": "Výraznost písma", "LabelFontFamily": "Rodina písem", + "LabelFontItalic": "Kurzíva", "LabelFontScale": "Měřítko písma", "LabelFontStrikethrough": "Přeškrtnutí", "LabelFormat": "Formát", @@ -325,6 +347,7 @@ "LabelInProgress": "Probíhá", "LabelIncludeInTracklist": "Zahrnout do seznamu stop", "LabelIncomplete": "Neúplné", + "LabelInterval": "Interval", "LabelIntervalCustomDailyWeekly": "Vlastní denně/týdně", "LabelIntervalEvery12Hours": "Každých 12 hodin", "LabelIntervalEvery15Minutes": "Každých 15 minut", @@ -421,17 +444,22 @@ "LabelPersonalYearReview": "Váš přehled roku ({0})", "LabelPhotoPathURL": "Cesta k fotografii/URL", "LabelPlayMethod": "Metoda přehrávání", + "LabelPlayerChapterNumberMarker": "{0} z {1}", "LabelPlaylists": "Seznamy skladeb", + "LabelPodcast": "Podcast", "LabelPodcastSearchRegion": "Oblast vyhledávání podcastu", "LabelPodcastType": "Typ podcastu", "LabelPodcasts": "Podcasty", + "LabelPort": "Port", "LabelPrefixesToIgnore": "Předpony, které se mají ignorovat (nerozlišují se malá a velká písmena)", "LabelPreventIndexing": "Zabránit indexování vašeho kanálu v adresářích podcastů iTunes a Google", "LabelPrimaryEbook": "Hlavní e-kniha", "LabelProgress": "Průběh", "LabelProvider": "Poskytovatel", + "LabelProviderAuthorizationValue": "Hodnota autorizačního headeru", "LabelPubDate": "Datum vydání", "LabelPublishYear": "Rok vydání", + "LabelPublishedDate": "Vydáno {0}", "LabelPublisher": "Vydavatel", "LabelPublishers": "Vydavatelé", "LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka", @@ -441,6 +469,7 @@ "LabelRSSFeedSlug": "RSS kanál Slug", "LabelRSSFeedURL": "URL RSS kanálu", "LabelRandomly": "Náhodně", + "LabelReAddSeriesToContinueListening": "Znovu přidat sérii k pokračování poslechu", "LabelRead": "Číst", "LabelReadAgain": "Číst znovu", "LabelReadEbookWithoutProgress": "Číst e-knihu bez zachování průběhu", @@ -448,6 +477,7 @@ "LabelRecentlyAdded": "Nedávno přidané", "LabelRecommended": "Doporučeno", "LabelRedo": "Přepracovat", + "LabelRegion": "Region", "LabelReleaseDate": "Datum vydání", "LabelRemoveCover": "Odstranit obálku", "LabelRowsPerPage": "Řádky na stránku", @@ -539,6 +569,7 @@ "LabelTagsNotAccessibleToUser": "Značky nepřístupné uživateli", "LabelTasks": "Spuštěné Úlohy", "LabelTextEditorBulletedList": "Seznam s odrážkami", + "LabelTextEditorLink": "Odkaz", "LabelTextEditorNumberedList": "Seznam s čísly", "LabelTextEditorUnlink": "Zrušit odkaz", "LabelTheme": "Téma", @@ -572,6 +603,7 @@ "LabelUnabridged": "Nezkráceno", "LabelUndo": "Zpět", "LabelUnknown": "Neznámý", + "LabelUnknownPublishDate": "Neznámé datum vydání", "LabelUpdateCover": "Aktualizovat obálku", "LabelUpdateCoverHelp": "Povolit přepsání existujících obálek pro vybrané knihy, pokud je nalezena shoda", "LabelUpdateDetails": "Aktualizovat podrobnosti", @@ -620,14 +652,19 @@ "MessageCheckingCron": "Kontrola cronu...", "MessageConfirmCloseFeed": "Opravdu chcete zavřít tento kanál?", "MessageConfirmDeleteBackup": "Opravdu chcete smazat zálohu pro {0}?", + "MessageConfirmDeleteDevice": "Opravdu chcete vymazat zařízení e-reader \"{0}\"?", "MessageConfirmDeleteFile": "Tento krok smaže soubor ze souborového systému. Jsi si jisti?", "MessageConfirmDeleteLibrary": "Opravdu chcete trvale smazat knihovnu \"{0}\"?", "MessageConfirmDeleteLibraryItem": "Tento krok odstraní položku knihovny z databáze a vašeho souborového systému. Jste si jisti?", "MessageConfirmDeleteLibraryItems": "Tímto smažete {0} položkek knihovny z databáze a vašeho souborového systému. Jsi si jisti?", + "MessageConfirmDeleteMetadataProvider": "Opravdu chcete vymazat vlastního poskytovatele metadat \"{0}\"?", + "MessageConfirmDeleteNotification": "Opravdu chcete vymazat tuto notifikaci?", "MessageConfirmDeleteSession": "Opravdu chcete smazat tuto relaci?", "MessageConfirmForceReScan": "Opravdu chcete vynutit opětovné prohledání?", "MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokončené?", "MessageConfirmMarkAllEpisodesNotFinished": "Opravdu chcete označit všechny epizody jako nedokončené?", + "MessageConfirmMarkItemFinished": "Opravdu chcete označit \"{0}\" jako dokončené?", + "MessageConfirmMarkItemNotFinished": "Opravdu chcete označit \"{0}\" jako nedokončené?", "MessageConfirmMarkSeriesFinished": "Opravdu chcete označit všechny knihy z této série jako dokončené?", "MessageConfirmMarkSeriesNotFinished": "Opravdu chcete označit všechny knihy z této série jako nedokončené?", "MessageConfirmPurgeCache": "Vyčistit mezipaměť odstraní celý adresář na adrese /metadata/cache.

Určitě chcete odstranit adresář mezipaměti?", @@ -648,7 +685,9 @@ "MessageConfirmRenameTag": "Opravdu chcete přejmenovat tag \"{0}\" na \"{1}\" pro všechny položky?", "MessageConfirmRenameTagMergeNote": "Poznámka: Tato značka již existuje, takže budou sloučeny.", "MessageConfirmRenameTagWarning": "Varování! Podobná značka s jinými velkými a malými písmeny již existuje \"{0}\".", + "MessageConfirmResetProgress": "Opravdu chcete zahodit svůj pokrok?", "MessageConfirmSendEbookToDevice": "Opravdu chcete odeslat e-knihu {0} {1}\" do zařízení \"{2}\"?", + "MessageConfirmUnlinkOpenId": "Opravdu chcete odpojit tohoto uživatele z OpenID?", "MessageDownloadingEpisode": "Stahuji epizodu", "MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop", "MessageEmbedFailed": "Vložení selhalo!", @@ -656,7 +695,7 @@ "MessageEpisodesQueuedForDownload": "{0} Epizody zařazené do fronty ke stažení", "MessageEreaderDevices": "Aby bylo zajištěno doručení elektronických knih, může být nutné přidat výše uvedenou e-mailovou adresu jako platného odesílatele pro každé zařízení uvedené níže.", "MessageFeedURLWillBe": "URL zdroje bude {0}", - "MessageFetching": "Stahování...", + "MessageFetching": "Načítání...", "MessageForceReScanDescription": "znovu prohledá všechny soubory jako při novém skenování. ID3 tagy zvukových souborů OPF soubory a textové soubory budou skenovány jako nové.", "MessageImportantNotice": "Důležité upozornění!", "MessageInsertChapterBelow": "Vložit kapitolu níže", @@ -683,6 +722,7 @@ "MessageNoCollections": "Žádné kolekce", "MessageNoCoversFound": "Nebyly nalezeny žádné obálky", "MessageNoDescription": "Bez popisu", + "MessageNoDevices": "Žádná zařízení", "MessageNoDownloadsInProgress": "Momentálně neprobíhá žádné stahování", "MessageNoDownloadsQueued": "Žádné stahování ve frontě", "MessageNoEpisodeMatchesFound": "Nebyly nalezeny žádné odpovídající epizody", @@ -710,6 +750,7 @@ "MessagePauseChapter": "Pozastavit přehrávání kapitoly", "MessagePlayChapter": "Poslechnout si začátek kapitoly", "MessagePlaylistCreateFromCollection": "Vytvořit seznam skladeb z kolekce", + "MessagePleaseWait": "Čekejte prosím...", "MessagePodcastHasNoRSSFeedForMatching": "Podcast nemá žádnou adresu URL kanálu RSS, kterou by mohl použít pro porovnávání", "MessageQuickMatchDescription": "Vyplňte prázdné detaily položky a obálku prvním výsledkem shody z '{0}'. Nepřepisuje podrobnosti, pokud není povoleno nastavení serveru \"Preferovat párování metadata\".", "MessageRemoveChapter": "Odstranit kapitolu", @@ -728,17 +769,46 @@ "MessageShareExpiresIn": "Expiruje za {0}", "MessageShareURLWillBe": "Sdílené URL bude {0}", "MessageStartPlaybackAtTime": "Spustit přehrávání pro \"{0}\" v {1}?", + "MessageTaskAudioFileNotWritable": "Nelze zapisovat do audio souboru \"{0}\"", + "MessageTaskCanceledByUser": "Task zrušen uživatelem", + "MessageTaskDownloadingEpisodeDescription": "Stahování epizody \"{0}\"", + "MessageTaskEmbeddingMetadata": "Vkládání metadat", + "MessageTaskEmbeddingMetadataDescription": "Vkládání metadat do audioknihy \"{0}\"", + "MessageTaskEncodingM4b": "Kódování M4B", + "MessageTaskEncodingM4bDescription": "Kódování audioknihy \"{0}\" do jednoho m4b souboru", + "MessageTaskFailed": "Selhalo", + "MessageTaskFailedToBackupAudioFile": "Zálohování audio souboru \"{0}\" se selhalo", + "MessageTaskFailedToCreateCacheDirectory": "Vytvoření cache adresáře selhalo", + "MessageTaskFailedToEmbedMetadataInFile": "Vkládání metadat do souboru \"{0}\" selhalo", + "MessageTaskFailedToMergeAudioFiles": "Spojení audio souborů selhalo", + "MessageTaskFailedToMoveM4bFile": "Přesunutí m4b souboru selhalo", + "MessageTaskFailedToWriteMetadataFile": "Zápis souboru metadat selhal", + "MessageTaskNoFilesToScan": "Žádné soubory ke skenování", + "MessageTaskOpmlImport": "Import OPML", + "MessageTaskOpmlImportDescription": "Vytváření podcastů z {0} RSS feedů", + "MessageTaskOpmlImportFeedDescription": "Importování RSS feedu \"{0}\"", + "MessageTaskOpmlImportFeedPodcastDescription": "Vytváření podcastu \"{0}\"", + "MessageTaskOpmlImportFeedPodcastExists": "Podcast se stejnou cestou již existuje", + "MessageTaskOpmlImportFeedPodcastFailed": "Vytváření podcastu selhalo", + "MessageTaskOpmlImportFinished": "Přidáno {0} podcastů", + "MessageTaskScanItemsAdded": "{0} přidáno", + "MessageTaskScanItemsMissing": "{0} chybí", + "MessageTaskScanItemsUpdated": "{0} aktualizováno", + "MessageTaskScanNoChangesNeeded": "Žádné změny nejsou nutné", + "MessageTaskScanningFileChanges": "Skenování změn souborů v \"{0}\"", + "MessageTaskScanningLibrary": "Skenování \"{0}\" knihovny", + "MessageTaskTargetDirectoryNotWritable": "Do cílové složky nelze zapisovat", "MessageThinking": "Přemýšlení...", - "MessageUploaderItemFailed": "Nahrávání se nezdařilo", - "MessageUploaderItemSuccess": "Nahráno bylo úspěšně!", - "MessageUploading": "Odesílám...", + "MessageUploaderItemFailed": "Nahrávání selhalo", + "MessageUploaderItemSuccess": "Úspěšně nahráno!", + "MessageUploading": "Nahrávám...", "MessageValidCronExpression": "Platný výraz cronu", "MessageWatcherIsDisabledGlobally": "Hlídač je globálně zakázán v nastavení serveru", "MessageXLibraryIsEmpty": "{0} knihovna je prázdná!", - "MessageYourAudiobookDurationIsLonger": "Doba trvání audioknihy je delší než nalezená délka", + "MessageYourAudiobookDurationIsLonger": "Délka audioknihy je delší, než byla nalezena", "MessageYourAudiobookDurationIsShorter": "Délka audioknihy je kratší, než byla nalezena", "NoteChangeRootPassword": "Uživatel root je jediný uživatel, který může mít prázdné heslo", - "NoteChapterEditorTimes": "Poznámka: Čas začátku první kapitoly musí zůstat v 0:00 a čas začátku poslední kapitoly nesmí překročit tuto dobu trvání audioknihy.", + "NoteChapterEditorTimes": "Poznámka: Čas začátku první kapitoly musí zůstat na 0:00 a čas začátku poslední kapitoly nesmí překročit dobu trvání audioknihy.", "NoteFolderPicker": "Poznámka: složky, které jsou již namapovány, nebudou zobrazeny", "NoteRSSFeedPodcastAppsHttps": "Upozornění: Většina aplikací pro podcasty bude vyžadovat, aby adresa URL kanálu RSS používala protokol HTTPS", "NoteRSSFeedPodcastAppsPubDate": "Upozornění: 1 nebo více epizod nemá datum vydání. Některé podcastové aplikace to vyžadují.", @@ -752,8 +822,10 @@ "PlaceholderSearchEpisode": "Hledat epizodu..", "StatsAuthorsAdded": "autoři přidáni", "StatsBooksAdded": "knihy přidány", + "StatsBooksAdditional": "Některé další zahrnují…", "StatsBooksFinished": "dokončené knihy", "StatsBooksFinishedThisYear": "Některé knihy dokončené tento rok…", + "StatsCollectionGrewTo": "Vaše kolekce knih se rozrostla na…", "StatsSessions": "sezení", "StatsSpentListening": "stráveno posloucháním", "StatsTopAuthor": "TOP AUTOR", @@ -764,17 +836,26 @@ "StatsTotalDuration": "S celkovou dobou…", "StatsYearInReview": "ROK V PŘEHLEDU", "ToastAccountUpdateSuccess": "Účet aktualizován", + "ToastAppriseUrlRequired": "Je nutné zadat Apprise URL", "ToastAuthorImageRemoveSuccess": "Obrázek autora odstraněn", + "ToastAuthorNotFound": "Author \"{0}\" nenalezen", + "ToastAuthorRemoveSuccess": "Autor odstraněn", + "ToastAuthorSearchNotFound": "Autor nenalezen", "ToastAuthorUpdateMerged": "Autor sloučen", "ToastAuthorUpdateSuccess": "Autor aktualizován", "ToastAuthorUpdateSuccessNoImageFound": "Autor aktualizován (nebyl nalezen žádný obrázek)", + "ToastBackupAppliedSuccess": "Záloha obnovena", "ToastBackupCreateFailed": "Vytvoření zálohy se nezdařilo", "ToastBackupCreateSuccess": "Záloha vytvořena", "ToastBackupDeleteFailed": "Nepodařilo se smazat zálohu", "ToastBackupDeleteSuccess": "Záloha smazána", + "ToastBackupInvalidMaxKeep": "Neplatný počet záloh k zachování", + "ToastBackupInvalidMaxSize": "Neplatná maximální velikost zálohy", "ToastBackupRestoreFailed": "Nepodařilo se obnovit zálohu", "ToastBackupUploadFailed": "Nepodařilo se nahrát zálohu", "ToastBackupUploadSuccess": "Záloha nahrána", + "ToastBatchDeleteFailed": "Hromadné smazání selhalo", + "ToastBatchDeleteSuccess": "Hromadné smazání proběhlo úspěšně", "ToastBatchUpdateFailed": "Dávková aktualizace se nezdařila", "ToastBatchUpdateSuccess": "Dávková aktualizace proběhla úspěšně", "ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo", @@ -785,19 +866,34 @@ "ToastCachePurgeSuccess": "Vyrovnávací paměť úspěšně vyčištěna", "ToastChaptersHaveErrors": "Kapitoly obsahují chyby", "ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy", + "ToastChaptersRemoved": "Kapitoly odstraněny", "ToastCollectionItemsRemoveSuccess": "Položky odstraněny z kolekce", "ToastCollectionRemoveSuccess": "Kolekce odstraněna", "ToastCollectionUpdateSuccess": "Kolekce aktualizována", + "ToastCoverUpdateFailed": "Aktualizace obálky selhala", "ToastDeleteFileFailed": "Nepodařilo se smazat soubor", "ToastDeleteFileSuccess": "Soubor smazán", + "ToastDeviceAddFailed": "Přidání zařízení selhalo", + "ToastDeviceNameAlreadyExists": "Zařízení se stejným jménem již existuje", + "ToastDeviceTestEmailFailed": "Odeslání testovacího emailu selhalo", + "ToastDeviceTestEmailSuccess": "Testovací email byl odeslán", + "ToastEmailSettingsUpdateSuccess": "Nastavení emailu aktualizována", + "ToastEpisodeDownloadQueueClearFailed": "Vyčištění fronty selhalo", "ToastErrorCannotShare": "Na tomto zařízení nelze nativně sdílet", "ToastFailedToLoadData": "Nepodařilo se načíst data", + "ToastFailedToShare": "Sdílení selhalo", + "ToastFailedToUpdate": "Aktualizace selhala", + "ToastInvalidImageUrl": "Neplatná URL obrázku", + "ToastInvalidUrl": "Neplatná URL", "ToastItemCoverUpdateSuccess": "Obálka předmětu byl aktualizována", + "ToastItemDeletedFailed": "Smazání položky selhalo", + "ToastItemDeletedSuccess": "Položka smazána", "ToastItemDetailsUpdateSuccess": "Podrobnosti o položce byly aktualizovány", "ToastItemMarkedAsFinishedFailed": "Nepodařilo se označit jako dokončené", "ToastItemMarkedAsFinishedSuccess": "Položka označena jako dokončená", "ToastItemMarkedAsNotFinishedFailed": "Nepodařilo se označit jako nedokončené", "ToastItemMarkedAsNotFinishedSuccess": "Položka označena jako nedokončená", + "ToastItemUpdateSuccess": "Položka aktualizována", "ToastLibraryCreateFailed": "Vytvoření knihovny se nezdařilo", "ToastLibraryCreateSuccess": "Knihovna \"{0}\" vytvořena", "ToastLibraryDeleteFailed": "Nepodařilo se smazat knihovnu", From c9522dc25d157ee6f70a23f9f63c671287bc144a Mon Sep 17 00:00:00 2001 From: biuklija Date: Thu, 3 Oct 2024 16:24:22 +0000 Subject: [PATCH 016/164] Translated using Weblate (Croatian) Currently translated at 100.0% (990 of 990 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 751a387ee..9d0ed0d56 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -901,6 +901,7 @@ "ToastErrorCannotShare": "Dijeljenje na ovaj uređaj nije moguće", "ToastFailedToLoadData": "Učitavanje podataka nije uspjelo", "ToastFailedToShare": "Dijeljenje nije uspjelo", + "ToastFailedToUpdate": "Ažuriranje nije uspjelo", "ToastInvalidImageUrl": "Neispravan URL slike", "ToastInvalidUrl": "Neispravan URL", "ToastItemCoverUpdateSuccess": "Naslovnica stavke ažurirana", From 212311a9802410f327bb9f4a8ad813d6be36fbe1 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Fri, 4 Oct 2024 12:32:23 +0000 Subject: [PATCH 017/164] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (990 of 990 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 07d69fd66..e1b1cbbc0 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -135,7 +135,7 @@ "HeaderEmailSettings": "邮箱设置", "HeaderEpisodes": "剧集", "HeaderEreaderDevices": "Ereader 设备", - "HeaderEreaderSettings": "Ereader 设置", + "HeaderEreaderSettings": "电子阅读器设置", "HeaderFiles": "文件", "HeaderFindChapters": "查找章节", "HeaderIgnoredFiles": "忽略的文件", @@ -901,6 +901,7 @@ "ToastErrorCannotShare": "无法在此设备上本地共享", "ToastFailedToLoadData": "加载数据失败", "ToastFailedToShare": "分享失败", + "ToastFailedToUpdate": "更新失败", "ToastInvalidImageUrl": "图片网址无效", "ToastInvalidUrl": "网址无效", "ToastItemCoverUpdateSuccess": "项目封面已更新", From cf5598aeb9b086a28e853d6a89b82a7467fd6969 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Oct 2024 16:10:07 -0500 Subject: [PATCH 018/164] Version bump v2.14.0 --- 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 48506f925..4c6df7952 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.13.4", + "version": "2.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.13.4", + "version": "2.14.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 53487bc91..ab70a30b2 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.13.4", + "version": "2.14.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 6f0a3587f..7c1798ed9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.13.4", + "version": "2.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.13.4", + "version": "2.14.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 752b2f8df..69bc41ddd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.13.4", + "version": "2.14.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 0bdc2fb05ea7e2033a787714b86171d3d1537b29 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 6 Oct 2024 18:25:08 +0300 Subject: [PATCH 019/164] Move authors to lazyBookshelf --- client/components/app/BookShelfRow.vue | 2 +- client/components/app/BookShelfToolbar.vue | 87 +++++++------ client/components/app/LazyBookshelf.vue | 46 +++++++ client/components/app/SideRail.vue | 2 +- client/components/cards/AuthorCard.vue | 63 +++++++--- client/components/widgets/ItemSlider.vue | 2 +- client/mixins/bookshelfCardsHelpers.js | 13 +- client/pages/author/_id.vue | 4 +- .../pages/library/_library/authors/index.vue | 115 ------------------ .../pages/library/_library/bookshelf/_id.vue | 2 +- client/strings/en-us.json | 1 + server/controllers/LibraryController.js | 63 +++++++++- 12 files changed, 216 insertions(+), 184 deletions(-) delete mode 100644 client/pages/library/_library/authors/index.vue diff --git a/client/components/app/BookShelfRow.vue b/client/components/app/BookShelfRow.vue index d6cc37ef2..50fa140a7 100644 --- a/client/components/app/BookShelfRow.vue +++ b/client/components/app/BookShelfRow.vue @@ -24,7 +24,7 @@
diff --git a/client/components/app/BookShelfToolbar.vue b/client/components/app/BookShelfToolbar.vue index 7ed07abb5..eabb5f74d 100644 --- a/client/components/app/BookShelfToolbar.vue +++ b/client/components/app/BookShelfToolbar.vue @@ -30,7 +30,7 @@

{{ $strings.ButtonCollections }}

- +

{{ $strings.ButtonAuthors }}

-
+
+
+

{{ $strings.LabelPermissionsCreateEreader }}

+
+
+ +
+
+

{{ $strings.LabelPermissionsAccessExplicitContent }}

@@ -354,7 +363,8 @@ export default { accessExplicitContent: type === 'admin', accessAllLibraries: true, accessAllTags: true, - selectedTagsNotAccessible: false + selectedTagsNotAccessible: false, + createEreader: type === 'admin' } }, init() { @@ -387,7 +397,8 @@ export default { accessAllLibraries: true, accessAllTags: true, accessExplicitContent: false, - selectedTagsNotAccessible: false + selectedTagsNotAccessible: false, + createEreader: false }, librariesAccessible: [], itemTagsSelected: [] diff --git a/client/components/modals/emails/UserEReaderDeviceModal.vue b/client/components/modals/emails/UserEReaderDeviceModal.vue new file mode 100644 index 000000000..b1706305c --- /dev/null +++ b/client/components/modals/emails/UserEReaderDeviceModal.vue @@ -0,0 +1,188 @@ + + + diff --git a/client/pages/account.vue b/client/pages/account.vue index b6c932a0f..4515ab1f6 100644 --- a/client/pages/account.vue +++ b/client/pages/account.vue @@ -32,9 +32,48 @@
+
+
+ + + + + + + + + + + + + + + +
{{ $strings.LabelName }}{{ $strings.LabelEmail }}
+

{{ device.name }}

+
+

{{ device.email }}

+
+
+ + +
+
+
+

{{ $strings.MessageNoDevices }}

+
+
+
+
logout{{ $strings.ButtonLogout }}
+ +
@@ -43,11 +82,20 @@ export default { data() { return { + loading: false, password: null, newPassword: null, confirmPassword: null, changingPassword: false, - selectedLanguage: '' + selectedLanguage: '', + newEReaderDevice: { + name: '', + email: '' + }, + ereaderDevices: [], + deletingDeviceName: null, + selectedEReaderDevice: null, + showEReaderDeviceModal: false } }, computed: { @@ -75,6 +123,12 @@ export default { }, showChangePasswordForm() { return !this.isGuest && this.isPasswordAuthEnabled + }, + showEreaderTable() { + return this.usertype !== 'root' && this.usertype !== 'admin' && this.user.permissions?.createEreader + }, + revisedEreaderDevices() { + return this.ereaderDevices.filter((device) => device.users?.length === 1) } }, methods: { @@ -142,10 +196,52 @@ export default { this.$toast.error(this.$strings.ToastUnknownError) this.changingPassword = false }) + }, + addNewDeviceClick() { + this.selectedEReaderDevice = null + this.showEReaderDeviceModal = true + }, + editDeviceClick(device) { + this.selectedEReaderDevice = device + this.showEReaderDeviceModal = true + }, + deleteDeviceClick(device) { + const payload = { + message: this.$getString('MessageConfirmDeleteDevice', [device.name]), + callback: (confirmed) => { + if (confirmed) { + this.deleteDevice(device) + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + }, + deleteDevice(device) { + const payload = { + ereaderDevices: this.revisedEreaderDevices.filter((d) => d.name !== device.name) + } + this.deletingDeviceName = device.name + this.$axios + .$post(`/api/me/ereader-devices`, payload) + .then((data) => { + this.ereaderDevicesUpdated(data.ereaderDevices) + }) + .catch((error) => { + console.error('Failed to delete device', error) + this.$toast.error(this.$strings.ToastRemoveFailed) + }) + .finally(() => { + this.deletingDeviceName = null + }) + }, + ereaderDevicesUpdated(ereaderDevices) { + this.ereaderDevices = ereaderDevices } }, mounted() { this.selectedLanguage = this.$languageCodes.current + this.ereaderDevices = this.$store.state.libraries.ereaderDevices || [] } } diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 918bf6856..8eb375500 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -472,6 +472,7 @@ "LabelPermissionsAccessAllLibraries": "Can Access All Libraries", "LabelPermissionsAccessAllTags": "Can Access All Tags", "LabelPermissionsAccessExplicitContent": "Can Access Explicit Content", + "LabelPermissionsCreateEreader": "Can Create Ereader", "LabelPermissionsDelete": "Can Delete", "LabelPermissionsDownload": "Can Download", "LabelPermissionsUpdate": "Can Update", diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index c7abbc232..cc67b320d 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -394,6 +394,58 @@ class MeController { res.json(req.user.toOldJSONForBrowser()) } + /** + * POST: /api/me/ereader-devices + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async updateUserEReaderDevices(req, res) { + if (!req.body.ereaderDevices || !Array.isArray(req.body.ereaderDevices)) { + return res.status(400).send('Invalid payload. ereaderDevices array required') + } + + const userEReaderDevices = req.body.ereaderDevices + for (const device of userEReaderDevices) { + if (!device.name || !device.email) { + return res.status(400).send('Invalid payload. ereaderDevices array items must have name and email') + } else if (device.availabilityOption !== 'specificUsers' || device.users?.length !== 1 || device.users[0] !== req.user.id) { + return res.status(400).send('Invalid payload. ereaderDevices array items must have availabilityOption "specificUsers" and only the current user') + } + } + + const otherDevices = Database.emailSettings.ereaderDevices.filter((device) => { + return !Database.emailSettings.checkUserCanAccessDevice(device, req.user) || device.users?.length !== 1 + }) + + const ereaderDevices = otherDevices.concat(userEReaderDevices) + + // Check for duplicate names + const nameSet = new Set() + const hasDupes = ereaderDevices.some((device) => { + if (nameSet.has(device.name)) { + return true // Duplicate found + } + nameSet.add(device.name) + return false + }) + + if (hasDupes) { + return res.status(400).send('Invalid payload. Duplicate "name" field found.') + } + + const updated = Database.emailSettings.update({ ereaderDevices }) + if (updated) { + await Database.updateSetting(Database.emailSettings) + SocketAuthority.clientEmitter(req.user.id, 'ereader-devices-updated', { + ereaderDevices: Database.emailSettings.ereaderDevices + }) + } + res.json({ + ereaderDevices: Database.emailSettings.getEReaderDevices(req.user) + }) + } + /** * GET: /api/me/stats/year/:year * diff --git a/server/models/User.js b/server/models/User.js index 8bd3f742d..906a7d68e 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -82,6 +82,7 @@ class User extends Model { canAccessExplicitContent: 'accessExplicitContent', canAccessAllLibraries: 'accessAllLibraries', canAccessAllTags: 'accessAllTags', + canCreateEReader: 'createEreader', tagsAreDenylist: 'selectedTagsNotAccessible', // Direct mapping for array-based permissions allowedLibraries: 'librariesAccessible', @@ -122,6 +123,7 @@ class User extends Model { update: type === 'root' || type === 'admin', delete: type === 'root', upload: type === 'root' || type === 'admin', + createEreader: type === 'root' || type === 'admin', accessAllLibraries: true, accessAllTags: true, accessExplicitContent: type === 'root' || type === 'admin', diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 57067ad8b..f81bc26df 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -190,6 +190,7 @@ class ApiRouter { this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this)) + this.router.post('/me/ereader-devices', MeController.updateUserEReaderDevices.bind(this)) // // Backup Routes From 39be3a2ef93acb8ee43f2d4795abdf1dccb55652 Mon Sep 17 00:00:00 2001 From: biuklija Date: Sat, 19 Oct 2024 18:02:33 +0000 Subject: [PATCH 113/164] Translated using Weblate (Croatian) Currently translated at 100.0% (1064 of 1064 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index cd15ec677..92636a1d4 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -252,9 +252,9 @@ "LabelBackupsNumberToKeepHelp": "Moguće je izbrisati samo jednu po jednu sigurnosnu kopiju, ako ih već imate više trebat ćete ih ručno ukloniti.", "LabelBitrate": "Protok", "LabelBonus": "Bonus", - "LabelBooks": "knjiga/e", + "LabelBooks": "Knjige", "LabelButtonText": "Tekst gumba", - "LabelByAuthor": "po {0}", + "LabelByAuthor": "autor: {0}", "LabelChangePassword": "Promijeni zaporku", "LabelChannels": "Kanali", "LabelChapterCount": "{0} Poglavlje/a", @@ -268,7 +268,7 @@ "LabelCollapseSeries": "Serijale prikaži sažeto", "LabelCollapseSubSeries": "Podserijale prikaži sažeto", "LabelCollection": "Zbirka", - "LabelCollections": "Zbirka/i", + "LabelCollections": "Zbirke", "LabelComplete": "Dovršeno", "LabelConfirmPassword": "Potvrda zaporke", "LabelContinueListening": "Nastavi slušati", @@ -358,6 +358,7 @@ "LabelFontScale": "Veličina slova", "LabelFontStrikethrough": "Precrtano", "LabelFormat": "Format", + "LabelFull": "Cijeli", "LabelGenre": "Žanr", "LabelGenres": "Žanrovi", "LabelHardDeleteFile": "Obriši datoteku zauvijek", @@ -491,8 +492,8 @@ "LabelPubDate": "Datum izdavanja", "LabelPublishYear": "Godina objavljivanja", "LabelPublishedDate": "Objavljeno {0}", - "LabelPublishedDecade": "Desetljeće objavljivanja", - "LabelPublishedDecades": "Desetljeća objavljivanja", + "LabelPublishedDecade": "Desetljeće izdanja", + "LabelPublishedDecades": "Desetljeća izdanja", "LabelPublisher": "Izdavač", "LabelPublishers": "Izdavači", "LabelRSSFeedCustomOwnerEmail": "Prilagođena adresa e-pošte vlasnika", @@ -530,7 +531,7 @@ "LabelSendEbookToDevice": "Pošalji e-knjigu", "LabelSequence": "Slijed", "LabelSerial": "Serijal", - "LabelSeries": "Serijal/a", + "LabelSeries": "Serijal", "LabelSeriesName": "Ime serijala", "LabelSeriesProgress": "Napredak u serijalu", "LabelServerLogLevel": "Razina zapisa poslužitelja", @@ -639,7 +640,7 @@ "LabelTotalTimeListened": "Sveukupno vrijeme slušanja", "LabelTrackFromFilename": "Naslov iz imena datoteke", "LabelTrackFromMetadata": "Naslov iz meta-podataka", - "LabelTracks": "Naslovi", + "LabelTracks": "Zvučni zapisi", "LabelTracksMultiTrack": "Više zvučnih zapisa", "LabelTracksNone": "Nema zapisa", "LabelTracksSingleTrack": "Jedan zvučni zapis", @@ -740,7 +741,7 @@ "MessageConfirmSendEbookToDevice": "Sigurno želite poslati {0} e-knjiga/u \"{1}\" na uređaj \"{2}\"?", "MessageConfirmUnlinkOpenId": "Sigurno želite odspojiti ovog korisnika s OpenID-ja?", "MessageDownloadingEpisode": "Preuzimam nastavak", - "MessageDragFilesIntoTrackOrder": "Ispravi redoslijed zapisa prevlačenje datoteka", + "MessageDragFilesIntoTrackOrder": "Prevlačenjem datoteka složite pravilan redoslijed", "MessageEmbedFailed": "Ugrađivanje nije uspjelo!", "MessageEmbedFinished": "Ugrađivanje je dovršeno!", "MessageEmbedQueue": "Ugrađivanje meta-podataka dodano u red obrade ({0} u redu)", From 2243fdddd34103a90fc802bab3f1ad4336870daf Mon Sep 17 00:00:00 2001 From: Plazec Date: Mon, 21 Oct 2024 10:31:49 +0000 Subject: [PATCH 114/164] Translated using Weblate (Czech) Currently translated at 82.8% (882 of 1064 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/cs.json b/client/strings/cs.json index 9b429f5e6..3f028f574 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -19,6 +19,7 @@ "ButtonChooseFiles": "Vybrat soubory", "ButtonClearFilter": "Vymazat filtr", "ButtonCloseFeed": "Zavřít kanál", + "ButtonCloseSession": "Zavřít otevřenou relaci", "ButtonCollections": "Kolekce", "ButtonConfigureScanner": "Konfigurovat Prohledávání", "ButtonCreate": "Vytvořit", @@ -175,6 +176,7 @@ "HeaderRemoveEpisodes": "Odstranit {0} epizody", "HeaderSavedMediaProgress": "Průběh uložených médií", "HeaderSchedule": "Plán", + "HeaderScheduleEpisodeDownloads": "Naplánovat automatické stahování epizod", "HeaderScheduleLibraryScans": "Naplánovat automatické prohledávání knihoven", "HeaderSession": "Relace", "HeaderSetBackupSchedule": "Nastavit plán zálohování", From 4e90f90c283e12f9cafe58d400f517d3746f7917 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Mon, 21 Oct 2024 10:19:16 +0000 Subject: [PATCH 115/164] Translated using Weblate (Spanish) Currently translated at 97.0% (1033 of 1064 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/es.json b/client/strings/es.json index d9410d7a4..dbd8bbc6e 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -251,6 +251,7 @@ "LabelBackupsNumberToKeep": "Numero de respaldos para conservar", "LabelBackupsNumberToKeepHelp": "Solamente 1 respaldo se removerá a la vez. Si tiene mas respaldos guardados, debe removerlos manualmente.", "LabelBitrate": "Tasa de bits", + "LabelBonus": "Bonus", "LabelBooks": "Libros", "LabelButtonText": "Texto del botón", "LabelByAuthor": "por {0}", @@ -329,6 +330,7 @@ "LabelEpisodeType": "Tipo de Episodio", "LabelEpisodeUrlFromRssFeed": "URL del episodio del feed RSS", "LabelEpisodes": "Episodios", + "LabelEpisodic": "Episodios", "LabelExample": "Ejemplo", "LabelExpandSeries": "Ampliar serie", "LabelExpandSubSeries": "Expandir la subserie", From 28d93d916093fe3aa83fd8a276d28fc25aba4ab7 Mon Sep 17 00:00:00 2001 From: Mathias Franco Date: Mon, 21 Oct 2024 15:26:09 +0000 Subject: [PATCH 116/164] Translated using Weblate (Dutch) Currently translated at 83.0% (884 of 1064 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 182 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 181 insertions(+), 1 deletion(-) diff --git a/client/strings/nl.json b/client/strings/nl.json index aad7f3015..7a246e3d0 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -65,6 +65,7 @@ "ButtonQueueAddItem": "In wachtrij zetten", "ButtonQueueRemoveItem": "Uit wachtrij verwijderen", "ButtonQuickEmbed": "Snel Embedden", + "ButtonQuickEmbedMetadata": "Snel Metadata Insluiten", "ButtonQuickMatch": "Snelle match", "ButtonReScan": "Nieuwe scan", "ButtonRead": "Lees", @@ -97,6 +98,8 @@ "ButtonStats": "Statistieken", "ButtonSubmit": "Indienen", "ButtonTest": "Testen", + "ButtonUnlinkOpenId": "OpenID Ontkoppelen", + "ButtonUpload": "Upload", "ButtonUploadBackup": "Upload back-up", "ButtonUploadCover": "Upload cover", "ButtonUploadOPMLFile": "Upload OPML-bestand", @@ -108,10 +111,12 @@ "ErrorUploadFetchMetadataNoResults": "Kan metadata niet ophalen - probeer de titel en/of auteur te updaten", "ErrorUploadLacksTitle": "Moet een titel hebben", "HeaderAccount": "Account", + "HeaderAddCustomMetadataProvider": "Aangepaste Metadataprovider Toevoegen", "HeaderAdvanced": "Geavanceerd", "HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen", "HeaderAudioTracks": "Audiotracks", "HeaderAudiobookTools": "Audioboekbestandbeheer tools", + "HeaderAuthentication": "Authenticatie", "HeaderBackups": "Back-ups", "HeaderChangePassword": "Wachtwoord wijzigen", "HeaderChapters": "Hoofdstukken", @@ -120,6 +125,8 @@ "HeaderCollectionItems": "Collectie-objecten", "HeaderCover": "Omslag", "HeaderCurrentDownloads": "Huidige downloads", + "HeaderCustomMessageOnLogin": "Aangepast Bericht bij Aanmelden", + "HeaderCustomMetadataProviders": "Aangepaste Metadata Providers", "HeaderDetails": "Details", "HeaderDownloadQueue": "Download-wachtrij", "HeaderEbookFiles": "Ebook bestanden", @@ -140,16 +147,26 @@ "HeaderLibraryStats": "Bibliotheekstatistieken", "HeaderListeningSessions": "Luistersessies", "HeaderListeningStats": "Luisterstatistieken", + "HeaderLogin": "Aanmelden", + "HeaderLogs": "Logboek", "HeaderManageGenres": "Genres beheren", "HeaderManageTags": "Tags beheren", + "HeaderMapDetails": "Map details", + "HeaderMatch": "Vergelijken", + "HeaderMetadataOrderOfPrecedence": "Metadata volgorde", "HeaderMetadataToEmbed": "In te sluiten metadata", "HeaderNewAccount": "Nieuwe account", "HeaderNewLibrary": "Nieuwe bibliotheek", + "HeaderNotificationCreate": "Notificatie Aanmaken", + "HeaderNotificationUpdate": "Update Notificatie", "HeaderNotifications": "Notificaties", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authenticatie", "HeaderOpenRSSFeed": "Open RSS-feed", "HeaderOtherFiles": "Andere bestanden", + "HeaderPasswordAuthentication": "Wachtwoord Authenticatie", "HeaderPermissions": "Toestemmingen", "HeaderPlayerQueue": "Afspeelwachtrij", + "HeaderPlayerSettings": "Speler Instellingen", "HeaderPlaylist": "Afspeellijst", "HeaderPlaylistItems": "Onderdelen in afspeellijst", "HeaderPodcastsToAdd": "Toe te voegen podcasts", @@ -161,6 +178,7 @@ "HeaderRemoveEpisodes": "Verwijder {0} afleveringen", "HeaderSavedMediaProgress": "Opgeslagen mediavoortgang", "HeaderSchedule": "Schema", + "HeaderScheduleEpisodeDownloads": "Automatische afleveringsdownloads plannen", "HeaderScheduleLibraryScans": "Schema automatische bibliotheekscans", "HeaderSession": "Sessie", "HeaderSetBackupSchedule": "Kies schema voor back-up", @@ -168,6 +186,7 @@ "HeaderSettingsDisplay": "Toon", "HeaderSettingsExperimental": "Experimentele functies", "HeaderSettingsGeneral": "Algemeen", + "HeaderSettingsScanner": "Scanner", "HeaderSleepTimer": "Slaaptimer", "HeaderStatsLargestItems": "Grootste items", "HeaderStatsLongestItems": "Langste items (uren)", @@ -176,13 +195,18 @@ "HeaderStatsTop10Authors": "Top 10 auteurs", "HeaderStatsTop5Genres": "Top 5 genres", "HeaderTableOfContents": "Inhoudsopgave", + "HeaderTools": "Gereedschap", "HeaderUpdateAccount": "Account bijwerken", "HeaderUpdateAuthor": "Auteur bijwerken", "HeaderUpdateDetails": "Details bijwerken", "HeaderUpdateLibrary": "Bibliotheek bijwerken", "HeaderUsers": "Gebruikers", + "HeaderYearReview": "Jaar {0} in Review", "HeaderYourStats": "Je statistieken", "LabelAbridged": "Verkort", + "LabelAbridgedChecked": "Verkort (gechecked)", + "LabelAbridgedUnchecked": "Onverkort (niet gechecked)", + "LabelAccessibleBy": "Toegankelijk door", "LabelAccountType": "Accounttype", "LabelAccountTypeAdmin": "Beheerder", "LabelAccountTypeGuest": "Gast", @@ -193,32 +217,54 @@ "LabelAddToPlaylist": "Toevoegen aan afspeellijst", "LabelAddToPlaylistBatch": "{0} onderdelen toevoegen aan afspeellijst", "LabelAddedAt": "Toegevoegd op", + "LabelAddedDate": "Toegevoegd {0}", + "LabelAdminUsersOnly": "Enkel Admin gebruikers", "LabelAll": "Alle", "LabelAllUsers": "Alle gebruikers", + "LabelAllUsersExcludingGuests": "Alle gebruikers exclusief gasten", + "LabelAllUsersIncludingGuests": "Alle gebruikers inclusief gasten", "LabelAlreadyInYourLibrary": "Reeds in je bibliotheek", "LabelAppend": "Achteraan toevoegen", + "LabelAudioBitrate": "Audio Bitrate (b.v. 128k)", + "LabelAudioChannels": "Audio Kanalen (1 of 2)", + "LabelAudioCodec": "Audio Codec", "LabelAuthor": "Auteur", "LabelAuthorFirstLast": "Auteur (Voornaam Achternaam)", "LabelAuthorLastFirst": "Auteur (Achternaam, Voornaam)", "LabelAuthors": "Auteurs", "LabelAutoDownloadEpisodes": "Afleveringen automatisch downloaden", + "LabelAutoFetchMetadata": "Automatisch Metadata Ophalen", + "LabelAutoFetchMetadataHelp": "Haalt metadata op voor titel, auteur en serie om het uploaden te stroomlijnen. Aanvullende metadata moet mogelijk worden gematcht na het uploaden.", + "LabelAutoLaunch": "Automatisch Openen", + "LabelAutoLaunchDescription": "Automatisch doorverwijzen naar de auth-provider bij het navigeren naar de inlogpagina (handmatig pad /login?autoLaunch=0)", + "LabelAutoRegister": "Automatisch Registreren", + "LabelAutoRegisterDescription": "Automatisch nieuwe gebruikers aanmaken na inloggen", "LabelBackToUser": "Terug naar gebruiker", + "LabelBackupAudioFiles": "Back-up audiobestanden", "LabelBackupLocation": "Back-up locatie", "LabelBackupsEnableAutomaticBackups": "Automatische back-ups inschakelen", "LabelBackupsEnableAutomaticBackupsHelp": "Back-ups opgeslagen in /metadata/backups", - "LabelBackupsMaxBackupSize": "Maximale back-up-grootte (in GB)", + "LabelBackupsMaxBackupSize": "Maximale back-up-grootte (in GB) (0 voor ongelimiteerd)", "LabelBackupsMaxBackupSizeHelp": "Als een beveiliging tegen verkeerde instelling, zullen back-up mislukken als ze de ingestelde grootte overschrijden.", "LabelBackupsNumberToKeep": "Aantal te bewaren back-ups", "LabelBackupsNumberToKeepHelp": "Er wordt slechts 1 back-up per keer verwijderd, dus als je reeds meer back-ups dan dit hebt moet je ze handmatig verwijderen.", + "LabelBitrate": "Bitrate", + "LabelBonus": "Bonus", "LabelBooks": "Boeken", + "LabelButtonText": "Knop Tekst", + "LabelByAuthor": "Door {0}", "LabelChangePassword": "Wachtwoord wijzigen", "LabelChannels": "Kanalen", + "LabelChapterCount": "{0} Hoofdstukken", "LabelChapterTitle": "Hoofdstuktitel", "LabelChapters": "Hoofdstukken", "LabelChaptersFound": "Hoofdstukken gevonden", "LabelClickForMoreInfo": "Klik voor meer informatie", + "LabelClickToUseCurrentValue": "Klik om huidige waarde te gebruiken", "LabelClosePlayer": "Sluit speler", + "LabelCodec": "Codec", "LabelCollapseSeries": "Series inklappen", + "LabelCollapseSubSeries": "Subserie samenvouwen", "LabelCollection": "Collectie", "LabelCollections": "Collecties", "LabelComplete": "Compleet", @@ -226,6 +272,7 @@ "LabelContinueListening": "Verder Luisteren", "LabelContinueReading": "Verder lezen", "LabelContinueSeries": "Doorgaan met Serie", + "LabelCover": "Omslag", "LabelCoverImageURL": "Coverafbeelding URL", "LabelCreatedAt": "Gecreëerd op", "LabelCronExpression": "Cron-uitdrukking", @@ -234,38 +281,68 @@ "LabelCustomCronExpression": "Aangepaste Cron-uitdrukking:", "LabelDatetime": "Datum-tijd", "LabelDays": "Dagen", + "LabelDeleteFromFileSystemCheckbox": "Verwijderen uit bestandssysteem (uncheck om alleen uit database te verwijderen)", "LabelDescription": "Beschrijving", "LabelDeselectAll": "Deselecteer alle", "LabelDevice": "Apparaat", "LabelDeviceInfo": "Apparaat info", + "LabelDeviceIsAvailableTo": "Apparaat is beschikbaar voor...", "LabelDirectory": "Map", "LabelDiscFromFilename": "Schijf uit bestandsnaam", "LabelDiscFromMetadata": "Schijf uit metadata", "LabelDiscover": "Ontdekken", "LabelDownload": "Download", + "LabelDownloadNEpisodes": "Download {0} afleveringen", "LabelDuration": "Duur", + "LabelDurationComparisonExactMatch": "(exacte overeenkomst)", + "LabelDurationComparisonLonger": "({0} langer)", + "LabelDurationComparisonShorter": "({0} korter)", "LabelDurationFound": "Gevonden duur:", "LabelEbook": "Ebook", "LabelEbooks": "Eboeken", "LabelEdit": "Wijzig", + "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "Van-adres", + "LabelEmailSettingsRejectUnauthorized": "Ongeautoriseerde certificaten afwijzen", + "LabelEmailSettingsRejectUnauthorizedHelp": "Het uitschakelen van SSL-certificaatvalidatie kan uw verbinding blootstellen aan beveiligingsrisico's, zoals man-in-the-middle-aanvallen. Schakel deze optie alleen uit als u de implicaties begrijpt en de mailserver waarmee u verbinding maakt vertrouwt.", "LabelEmailSettingsSecure": "Veilig", "LabelEmailSettingsSecureHelp": "Als 'waar', dan gebruikt de verbinding TLS om met de server te verbinden. Als 'onwaar', dan wordt TLS gebruikt als de server de STARTTLS-extensie ondersteunt. In de meeste gevallen kies je voor 'waar' verbindt met poort 465. Voo poort 587 of 25, laat op 'onwaar'. (van nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Test-adres", "LabelEmbeddedCover": "Ingesloten cover", "LabelEnable": "Inschakelen", + "LabelEncodingBackupLocation": "Er wordt een back-up van uw originele audiobestanden opgeslagen in:", + "LabelEncodingChaptersNotEmbedded": "Hoofdstukken zijn niet ingesloten in audioboeken met meerdere sporen.", + "LabelEncodingClearItemCache": "Zorg ervoor dat u de cache van items regelmatig wist.", + "LabelEncodingFinishedM4B": "Een voltooide M4B wordt in uw audioboekfolder geplaatst in:", + "LabelEncodingInfoEmbedded": "Metagegevens worden ingesloten in de audiotracks in uw audioboekmap.", + "LabelEncodingStartedNavigation": "Eenmaal de taak is gestart kan u weg navigeren van deze pagina.", + "LabelEncodingTimeWarning": "Encoding kan tot 30 minuten duren.", + "LabelEncodingWarningAdvancedSettings": "Waarschuwing: update deze instellingen niet tenzij u bekend bent met de coderingsopties van ffmpeg.", + "LabelEncodingWatcherDisabled": "Als u de watcher hebt uitgeschakeld, moet u het audioboek daarna opnieuw scannen.", "LabelEnd": "Einde", "LabelEndOfChapter": "Einde van het Hoofdstuk", "LabelEpisode": "Aflevering", + "LabelEpisodeNotLinkedToRssFeed": "Aflevering niet gelinkt aan RSS feed", + "LabelEpisodeNumber": "Aflevering #{0}", "LabelEpisodeTitle": "Afleveringtitel", "LabelEpisodeType": "Afleveringtype", + "LabelEpisodeUrlFromRssFeed": "Aflevering URL van RSS feed", + "LabelEpisodes": "Afleveringen", + "LabelEpisodic": "Episodisch", "LabelExample": "Voorbeeld", + "LabelExpandSeries": "Serie Uitvouwen", + "LabelExpandSubSeries": "Subserie Uitvouwen", "LabelExplicit": "Expliciet", + "LabelExplicitChecked": "Expliciet (gechecked)", + "LabelExplicitUnchecked": "Niet Expliciet (niet gechecked)", + "LabelExportOPML": "OPML exporteren", "LabelFeedURL": "Feed URL", "LabelFetchingMetadata": "Metadata ophalen", "LabelFile": "Bestand", "LabelFileBirthtime": "Aanmaaktijd bestand", + "LabelFileBornDate": "Geboren {0}", "LabelFileModified": "Bestand gewijzigd", + "LabelFileModifiedDate": "Gewijzigd {0}", "LabelFilename": "Bestandsnaam", "LabelFilterByUser": "Filter op gebruiker", "LabelFindEpisodes": "Zoek afleveringen", @@ -275,20 +352,27 @@ "LabelFontBold": "Vetgedrukt", "LabelFontBoldness": "Font Boldness", "LabelFontFamily": "Lettertypefamilie", + "LabelFontItalic": "Cursief", "LabelFontScale": "Lettertype schaal", + "LabelFontStrikethrough": "Doorgestreept", "LabelFormat": "Formaat", + "LabelFull": "Vol", "LabelGenre": "Genre", "LabelGenres": "Genres", "LabelHardDeleteFile": "Hard-delete bestand", "LabelHasEbook": "Heeft Ebook", "LabelHasSupplementaryEbook": "Heeft aanvullend Ebook", + "LabelHideSubtitles": "Ondertitels Verstoppen", + "LabelHighestPriority": "Hoogste Prioriteit", "LabelHost": "Host", "LabelHour": "Uur", "LabelHours": "Uren", "LabelIcon": "Icoon", + "LabelImageURLFromTheWeb": "Afbeelding URL van web", "LabelInProgress": "Bezig", "LabelIncludeInTracklist": "Includeer in tracklijst", "LabelIncomplete": "Incompleet", + "LabelInterval": "Interval", "LabelIntervalCustomDailyWeekly": "Aangepast dagelijks/wekelijks", "LabelIntervalEvery12Hours": "Iedere 12 uur", "LabelIntervalEvery15Minutes": "Iedere 15 minuten", @@ -299,8 +383,11 @@ "LabelIntervalEveryHour": "Ieder uur", "LabelInvert": "Omdraaien", "LabelItem": "Onderdeel", + "LabelJumpBackwardAmount": "Terugspoelen hoeveelheid", + "LabelJumpForwardAmount": "Vooruitspoelen hoeveelheid", "LabelLanguage": "Taal", "LabelLanguageDefaultServer": "Standaard servertaal", + "LabelLanguages": "Talen", "LabelLastBookAdded": "Laatst toegevoegde boek", "LabelLastBookUpdated": "Laatst bijgewerkte boek", "LabelLastSeen": "Laatst gezien", @@ -312,20 +399,36 @@ "LabelLess": "Minder", "LabelLibrariesAccessibleToUser": "Voor gebruiker toegankelijke bibliotheken", "LabelLibrary": "Bibliotheek", + "LabelLibraryFilterSublistEmpty": "Nee {0}", "LabelLibraryItem": "Bibliotheekonderdeel", "LabelLibraryName": "Bibliotheeknaam", "LabelLimit": "Limiet", "LabelLineSpacing": "Regelruimte", "LabelListenAgain": "Opnieuw Beluisteren", + "LabelLogLevelDebug": "Debug", + "LabelLogLevelInfo": "Informatie", "LabelLogLevelWarn": "Waarschuwing", "LabelLookForNewEpisodesAfterDate": "Zoek naar nieuwe afleveringen na deze datum", + "LabelLowestPriority": "Laagste Prioriteit", + "LabelMatchExistingUsersBy": "Bestaande gebruikers matchen op", + "LabelMatchExistingUsersByDescription": "Wordt gebruikt om bestaande gebruikers te verbinden. Zodra ze verbonden zijn, worden gebruikers gekoppeld aan een unieke id van uw SSO-provider.", + "LabelMaxEpisodesToDownload": "Maximale # afleveringen om te downloaden. Gebruik 0 voor ongelimiteerd.", + "LabelMaxEpisodesToDownloadPerCheck": "Maximale # nieuwe afleveringen om te downloaden per check", + "LabelMaxEpisodesToKeep": "Maximale # afleveringen om te houden", + "LabelMaxEpisodesToKeepHelp": "Waarde van 0 stelt geen maximumlimiet in. Nadat een nieuwe aflevering automatisch is gedownload, wordt de oudste aflevering verwijderd als u meer dan X afleveringen hebt. Hiermee wordt slechts 1 aflevering per nieuwe download verwijderd.", "LabelMediaPlayer": "Mediaspeler", "LabelMediaType": "Mediatype", "LabelMetaTag": "Meta-tag", "LabelMetaTags": "Meta-tags", + "LabelMetadataOrderOfPrecedenceDescription": "Metadatabronnen met een hogere prioriteit zullen metadatabronnen met een lagere prioriteit overschrijven", "LabelMetadataProvider": "Metadatabron", "LabelMinute": "Minuut", + "LabelMinutes": "Minuten", "LabelMissing": "Ontbrekend", + "LabelMissingEbook": "Heeft geen ebook", + "LabelMissingSupplementaryEbook": "Heeft geen supplementair ebook", + "LabelMobileRedirectURIs": "Toegestane mobiele omleidings-URL's", + "LabelMobileRedirectURIsDescription": "Dit is een whitelist met geldige redirect-URI's voor mobiele apps. De standaard is audiobookshelf://oauth, die u kunt verwijderen of aanvullen met extra URI's voor integratie met apps van derden. Als u een asterisk (*) als enige invoer gebruikt, is elke URI toegestaan.", "LabelMore": "Meer", "LabelMoreInfo": "Meer info", "LabelName": "Naam", @@ -337,10 +440,12 @@ "LabelNewestEpisodes": "Nieuwste Afleveringen", "LabelNextBackupDate": "Volgende back-up datum", "LabelNextScheduledRun": "Volgende geplande run", + "LabelNoCustomMetadataProviders": "Geen custom metadata bronnen", "LabelNoEpisodesSelected": "Geen afleveringen geselecteerd", "LabelNotFinished": "Niet Voltooid", "LabelNotStarted": "Niet Gestart", "LabelNotes": "Notities", + "LabelNotificationAppriseURL": "URL(s) van kennisgeving", "LabelNotificationAvailableVariables": "Beschikbare variabelen", "LabelNotificationBodyTemplate": "Body-template", "LabelNotificationEvent": "Notificatie gebeurtenis", @@ -351,10 +456,14 @@ "LabelNotificationsMaxQueueSizeHelp": "Gebeurtenissen zijn beperkt tot 1 aftrap per seconde. Gebeurtenissen zullen genegeerd worden als de rij aan de maximale grootte zit. Dit voorkomt notificatie-spamming.", "LabelNumberOfBooks": "Aantal Boeken", "LabelNumberOfEpisodes": "# afleveringen", + "LabelOpenIDAdvancedPermsClaimDescription": "Naam van de OpenID-claim die geavanceerde machtigingen bevat voor gebruikersacties binnen de applicatie die van toepassing zijn op niet-beheerdersrollen (indien geconfigureerd). Als de claim ontbreekt in het antwoord, wordt toegang tot ABS geweigerd. Als er één optie ontbreekt, wordt deze behandeld als false. Zorg ervoor dat de claim van de identiteitsprovider overeenkomt met de verwachte structuur:", + "LabelOpenIDClaims": "Laat de volgende opties leeg om geavanceerde groeps- en machtigingstoewijzing uit te schakelen en de groep 'Gebruiker' automatisch toe te wijzen.", + "LabelOpenIDGroupClaimDescription": "Naam van de OpenID-claim die een lijst met de groepen van de gebruiker bevat. Vaak aangeduid als groepen. Indien geconfigureerd, zal de applicatie automatisch rollen toewijzen op basis van de groepslidmaatschappen van de gebruiker, op voorwaarde dat deze groepen hoofdlettergevoelig 'admin', 'gebruiker' of 'gast' worden genoemd in de claim. De claim moet een lijst bevatten en als een gebruiker tot meerdere groepen behoort, zal de applicatie de rol toewijzen die overeenkomt met het hoogste toegangsniveau. Als er geen groep overeenkomt, wordt de toegang geweigerd.", "LabelOpenRSSFeed": "Open RSS-feed", "LabelOverwrite": "Overschrijf", "LabelPassword": "Wachtwoord", "LabelPath": "Pad", + "LabelPermanent": "Permanent", "LabelPermissionsAccessAllLibraries": "Heeft toegang tot all bibliotheken", "LabelPermissionsAccessAllTags": "Heeft toegang tot alle tags", "LabelPermissionsAccessExplicitContent": "Heeft toegang tot expliciete inhoud", @@ -362,21 +471,29 @@ "LabelPermissionsDownload": "Kan downloaden", "LabelPermissionsUpdate": "Kan bijwerken", "LabelPermissionsUpload": "Kan uploaden", + "LabelPersonalYearReview": "Jouw jaar in review ({0})", "LabelPhotoPathURL": "Foto pad/URL", "LabelPlayMethod": "Afspeelwijze", + "LabelPlayerChapterNumberMarker": "{0} van {1}", "LabelPlaylists": "Afspeellijsten", "LabelPodcast": "Podcast", "LabelPodcastSearchRegion": "Podcast zoekregio", "LabelPodcastType": "Podcasttype", + "LabelPodcasts": "Podcasts", "LabelPort": "Poort", "LabelPrefixesToIgnore": "Te negeren voorzetsels (ongeacht hoofdlettergebruik)", "LabelPreventIndexing": "Voorkom indexering van je feed door iTunes- en Google podcastmappen", "LabelPrimaryEbook": "Primair ebook", "LabelProgress": "Voortgang", "LabelProvider": "Bron", + "LabelProviderAuthorizationValue": "Autorisatie Header Waarde", "LabelPubDate": "Publicatiedatum", "LabelPublishYear": "Jaar van uitgave", + "LabelPublishedDate": "Gepubliceerd {0}", + "LabelPublishedDecade": "Gepubliceerd Decennium", + "LabelPublishedDecades": "Gepubliceerd Decennia", "LabelPublisher": "Uitgever", + "LabelPublishers": "Uitgevers", "LabelRSSFeedCustomOwnerEmail": "Aangepast e-mailadres eigenaar", "LabelRSSFeedCustomOwnerName": "Aangepaste naam eigenaar", "LabelRSSFeedOpen": "RSS-feed open", @@ -384,31 +501,44 @@ "LabelRSSFeedSlug": "RSS-feed slug", "LabelRSSFeedURL": "RSS-feed URL", "LabelRandomly": "Willekeurig", + "LabelReAddSeriesToContinueListening": "Serie opnieuw toevoegen aan verder luisteren", "LabelRead": "Lees", "LabelReadAgain": "Opnieuw Lezen", "LabelReadEbookWithoutProgress": "Lees ebook zonder voortgang bij te houden", "LabelRecentSeries": "Recente Serie", "LabelRecentlyAdded": "Recent Toegevoegd", "LabelRecommended": "Aangeraden", + "LabelRedo": "Opnieuw", "LabelRegion": "Regio", "LabelReleaseDate": "Verschijningsdatum", + "LabelRemoveAllMetadataAbs": "Verwijder alle metadata.abs bestanden", + "LabelRemoveAllMetadataJson": "Verwijder alle metadata.json bestanden", "LabelRemoveCover": "Verwijder cover", + "LabelRemoveMetadataFile": "Verwijder metadata bestanden in bibliotheek item folders", + "LabelRemoveMetadataFileHelp": "Verwijder alle metadata.json en metadata.abs bestanden in uw {0} folders.", + "LabelRowsPerPage": "Rijen per pagina", "LabelSearchTerm": "Zoekterm", "LabelSearchTitle": "Zoek titel", "LabelSearchTitleOrASIN": "Zoek titel of ASIN", "LabelSeason": "Seizoen", + "LabelSeasonNumber": "Seizoen #{0}", + "LabelSelectAll": "Alles selecteren", "LabelSelectAllEpisodes": "Selecteer alle afleveringen", "LabelSelectEpisodesShowing": "Selecteer {0} afleveringen laten zien", + "LabelSelectUsers": "Selecteer gebruikers", "LabelSendEbookToDevice": "Stuur ebook naar...", "LabelSequence": "Sequentie", "LabelSeries": "Serie", "LabelSeriesName": "Naam serie", "LabelSeriesProgress": "Voortgang serie", + "LabelServerLogLevel": "Server Log Niveau", + "LabelServerYearReview": "Server Jaar in Review ({0})", "LabelSetEbookAsPrimary": "Stel in als primair", "LabelSetEbookAsSupplementary": "Stel in als supplementair", "LabelSettingsAudiobooksOnly": "Alleen audiobooks", "LabelSettingsAudiobooksOnlyHelp": "Deze instelling inschakelen zorgt ervoor dat ebook-bestanden genegeerd worden tenzij ze in een audiobook-map staan, in welk geval ze worden ingesteld als supplementaire ebooks", "LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken", + "LabelSettingsChromecastSupport": "Chromecast ondersteuning", "LabelSettingsDateFormat": "Datum format", "LabelSettingsDisableWatcher": "Watcher uitschakelen", "LabelSettingsDisableWatcherForLibrary": "Map-watcher voor bibliotheek uitschakelen", @@ -416,6 +546,8 @@ "LabelSettingsEnableWatcher": "Watcher inschakelen", "LabelSettingsEnableWatcherForLibrary": "Map-watcher voor bibliotheek inschakelen", "LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server", + "LabelSettingsEpubsAllowScriptedContent": "Sta scripted content toe in epubs", + "LabelSettingsEpubsAllowScriptedContentHelp": "Sta toe dat epub-bestanden scripts uitvoeren. Het wordt aanbevolen om deze instelling uitgeschakeld te houden, tenzij u de bron van de epub-bestanden vertrouwt.", "LabelSettingsExperimentalFeatures": "Experimentele functies", "LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.", "LabelSettingsFindCovers": "Zoek covers", @@ -424,6 +556,8 @@ "LabelSettingsHideSingleBookSeriesHelp": "Series die slechts een enkel boek bevatten worden verborgen op de seriespagina en de homepagina-planken.", "LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina", "LabelSettingsLibraryBookshelfView": "Boekenplank-view voor bibliotheek", + "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sla eedere boeken in Serie Verderzetten over", + "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "De Continue Series home page shelf toont het eerste boek dat nog niet is begonnen in series waarvan er minstens één is voltooid en er geen boeken in uitvoering zijn. Als u deze instelling inschakelt, wordt de serie voortgezet vanaf het boek dat het verst is voltooid in plaats van het eerste boek dat nog niet is begonnen.", "LabelSettingsParseSubtitles": "Parseer subtitel", "LabelSettingsParseSubtitlesHelp": "Haal subtitels uit mapnaam van audioboek.
Subtitel moet gescheiden zijn met \" - \"
b.v. \"Boektitel - Een Subtitel Hier\" heeft als subtitel \"Een Subtitel Hier\"", "LabelSettingsPreferMatchedMetadata": "Prefereer gematchte metadata", @@ -439,9 +573,15 @@ "LabelSettingsStoreMetadataWithItem": "Bewaar metadata bij onderdeel", "LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden", "LabelSettingsTimeFormat": "Tijdformat", + "LabelShare": "Delen", + "LabelShareOpen": "Delen Open", + "LabelShareURL": "URL Delen", "LabelShowAll": "Toon alle", + "LabelShowSeconds": "Laat seconden zien", + "LabelShowSubtitles": "Laat Ondertitels zien", "LabelSize": "Grootte", "LabelSleepTimer": "Slaaptimer", + "LabelSlug": "Slak", "LabelStart": "Start", "LabelStartTime": "Starttijd", "LabelStarted": "Gestart", @@ -468,10 +608,19 @@ "LabelTagsAccessibleToUser": "Tags toegankelijk voor de gebruiker", "LabelTagsNotAccessibleToUser": "Tags niet toegankelijk voor de gebruiker", "LabelTasks": "Lopende taken", + "LabelTextEditorBulletedList": "Opgesomde lijst", + "LabelTextEditorLink": "Link", + "LabelTextEditorNumberedList": "Genummerde lijst", + "LabelTextEditorUnlink": "Unlink", "LabelTheme": "Thema", "LabelThemeDark": "Donker", "LabelThemeLight": "Licht", "LabelTimeBase": "Tijdsbasis", + "LabelTimeDurationXHours": "{0} Uren", + "LabelTimeDurationXMinutes": "{0} minuten", + "LabelTimeDurationXSeconds": "{0} seconden", + "LabelTimeInMinutes": "Tijd in minuten", + "LabelTimeLeft": "{0} over", "LabelTimeListened": "Tijd geluisterd", "LabelTimeListenedToday": "Tijd geluisterd vandaag", "LabelTimeRemaining": "{0} te gaan", @@ -479,6 +628,7 @@ "LabelTitle": "Titel", "LabelToolsEmbedMetadata": "Metadata insluiten", "LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief coverafbeelding en hoofdstukken.", + "LabelToolsM4bEncoder": "M4B Encoder", "LabelToolsMakeM4b": "Maak M4B-audioboekbestand", "LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, coverafbeelding en hoofdstukken.", "LabelToolsSplitM4b": "Splitst M4B in MP3's", @@ -488,12 +638,15 @@ "LabelTrackFromFilename": "Track vanuit bestandsnaam", "LabelTrackFromMetadata": "Track vanuit metadata", "LabelTracks": "Audiosporen", + "LabelTracksMultiTrack": "Multi-spoor", "LabelTracksNone": "Geen tracks", "LabelTracksSingleTrack": "Enkele track", + "LabelTrailer": "Trailer", "LabelType": "Type", "LabelUnabridged": "Onverkort", "LabelUndo": "Ongedaan maken", "LabelUnknown": "Onbekend", + "LabelUnknownPublishDate": "Onbekende uitgeefdatum", "LabelUpdateCover": "Cover bijwerken", "LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden", "LabelUpdateDetails": "Details bijwerken", @@ -501,16 +654,25 @@ "LabelUpdatedAt": "Bijgewerkt op", "LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen", "LabelUploaderDropFiles": "Bestanden neerzetten", + "LabelUploaderItemFetchMetadataHelp": "Automatisch titel, auteur en serie ophalen", + "LabelUseAdvancedOptions": "Gebruik Geavanceerde Instellingen", "LabelUseChapterTrack": "Gebruik hoofdstuktrack", "LabelUseFullTrack": "Gebruik volledige track", + "LabelUseZeroForUnlimited": "Gebruik 0 voor ongelimiteerd", "LabelUser": "Gebruiker", "LabelUsername": "Gebruikersnaam", "LabelValue": "Waarde", "LabelVersion": "Versie", "LabelViewBookmarks": "Bekijk boekwijzers", "LabelViewChapters": "Bekijk hoofdstukken", + "LabelViewPlayerSettings": "Laat spelerinstellingen zien", "LabelViewQueue": "Bekijk afspeelwachtrij", + "LabelVolume": "Volume", "LabelWeekdaysToRun": "Weekdagen om te draaien", + "LabelXBooks": "{0} boeken", + "LabelXItems": "{0} items", + "LabelYearReviewHide": "Verberg Jaar in Review", + "LabelYearReviewShow": "Laat Jaar in Review zien", "LabelYourAudiobookDuration": "Je audioboekduur", "LabelYourBookmarks": "Je boekwijzers", "LabelYourPlaylists": "Je afspeellijsten", @@ -518,10 +680,14 @@ "MessageAddToPlayerQueue": "Toevoegen aan wachtrij", "MessageAppriseDescription": "Om deze functie te gebruiken heb je een draaiende instantie van Apprise API nodig of een api die dezelfde requests afhandelt.
De Apprise API Url moet het volledige URL-pad zijn om de notificatie te verzenden, b.v., als je API-instantie draait op http://192.168.1.1:8337 dan zou je http://192.168.1.1:8337/notify gebruiken.", "MessageBackupsDescription": "Back-ups omvatten gebruikers, gebruikers' voortgang, bibliotheekonderdeeldetails, serverinstellingen en afbeeldingen bewaard in /metadata/items & /metadata/authors. Back-ups bevatten niet de bestanden bewaard in je bibliotheekmappen.", + "MessageBackupsLocationEditNote": "Let op: het bijwerken van de back-uplocatie zal bestaande back-ups niet verplaatsen of wijzigen", + "MessageBackupsLocationNoEditNote": "Let op: De back-uplocatie wordt ingesteld via een omgevingsvariabele en kan hier niet worden gewijzigd.", + "MessageBackupsLocationPathEmpty": "Backup locatie pad kan niet leeg zijn", "MessageBatchQuickMatchDescription": "Quick Match zal proberen ontbrekende covers en metadata voor de geselecteerde onderdelen te matchten. Schakel de opties hieronder in om Quick Match toe te staan bestaande covers en/of metadata te overschrijven.", "MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt", "MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend", "MessageBookshelfNoResultsForFilter": "Geen resultaten voor filter \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "Geen resultaten voor query", "MessageBookshelfNoSeries": "Je hebt geen series", "MessageChapterEndIsAfter": "Hoofdstukeinde is na het einde van je audioboek", "MessageChapterErrorFirstNotZero": "Eerste hoofdstuk moet starten op 0", @@ -529,15 +695,29 @@ "MessageChapterErrorStartLtPrev": "Ongeldig: starttijd moet be groter zijn dan of equal aan starttijd van vorig hoofdstuk", "MessageChapterStartIsAfter": "Start van hoofdstuk is na het einde van je audioboek", "MessageCheckingCron": "Cron aan het checken...", + "MessageConfirmCloseFeed": "Ben je zeker dat je deze feed wil sluiten?", "MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?", + "MessageConfirmDeleteDevice": "Ben je zeker dat je e-reader apparaat \"{0}\" wil verwijderen?", "MessageConfirmDeleteFile": "Dit verwijdert het bestand uit het bestandssysteem. Weet je het zeker?", "MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?", + "MessageConfirmDeleteLibraryItem": "Hiermee wordt het bibliotheekitem uit de database en uw bestandssysteem verwijderd. Bent u zeker?", + "MessageConfirmDeleteLibraryItems": "Hiermee worden {0} bibliotheekitems uit de database en uw bestandssysteem verwijderd. Bent u zeker?", + "MessageConfirmDeleteMetadataProvider": "Weet u zeker dat u de aangepaste metadataprovider \"{0}\" wilt verwijderen?", + "MessageConfirmDeleteNotification": "Weet u zeker dat u deze melding wil verwijderen?", "MessageConfirmDeleteSession": "Weet je zeker dat je deze sessie wil verwijderen?", + "MessageConfirmEmbedMetadataInAudioFiles": "Weet u zeker dat u metagegevens wilt insluiten in {0} audiobestanden?", "MessageConfirmForceReScan": "Weet je zeker dat je geforceerd opnieuw wil scannen?", "MessageConfirmMarkAllEpisodesFinished": "Weet je zeker dat je alle afleveringen als voltooid wil markeren?", "MessageConfirmMarkAllEpisodesNotFinished": "Weet je zeker dat je alle afleveringen als niet-voltooid wil markeren?", + "MessageConfirmMarkItemFinished": "Weet u zeker dat u \"{0}\" als voltooid wilt markeren?", + "MessageConfirmMarkItemNotFinished": "Weet u zeker dat u \"{0}\" als niet voltooid wilt markeren?", "MessageConfirmMarkSeriesFinished": "Weet je zeker dat je alle boeken in deze serie wil markeren als voltooid?", "MessageConfirmMarkSeriesNotFinished": "Weet je zeker dat je alle boeken in deze serie wil markeren als niet voltooid?", + "MessageConfirmNotificationTestTrigger": "Trigger deze melding met test data?", + "MessageConfirmPurgeCache": "Met Purge cache wordt de gehele directory op /metadata/cache verwijderd.

Weet u zeker dat u de cachedirectory wilt verwijderen?", + "MessageConfirmPurgeItemsCache": "Met Purge items cache wordt de gehele directory op /metadata/cache/items verwijderd.
Weet u het zeker?", + "MessageConfirmQuickEmbed": "Waarschuwing! Quick embed maakt geen back-up van uw audiobestanden. Zorg ervoor dat u een back-up van uw audiobestanden hebt.

Wilt u doorgaan?", + "MessageConfirmQuickMatchEpisodes": "Snel matchende afleveringen overschrijven details als er een match is gevonden. Alleen niet-matchende afleveringen worden bijgewerkt. Weet u het zeker?", "MessageConfirmRemoveAllChapters": "Weet je zeker dat je alle hoofdstukken wil verwijderen?", "MessageConfirmRemoveAuthor": "Weet je zeker dat je auteur \"{0}\" wil verwijderen?", "MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?", From a7a2fbbca8c4c1cef6e096af044e41d885f91f10 Mon Sep 17 00:00:00 2001 From: Plazec Date: Tue, 22 Oct 2024 08:22:27 +0000 Subject: [PATCH 117/164] Translated using Weblate (Czech) Currently translated at 83.3% (887 of 1064 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/strings/cs.json b/client/strings/cs.json index 3f028f574..f1c53efc5 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -30,6 +30,8 @@ "ButtonEditChapters": "Upravit kapitoly", "ButtonEditPodcast": "Upravit podcast", "ButtonEnable": "Povolit", + "ButtonFireAndFail": "Spustit a selhat", + "ButtonFireOnTest": "Spustit událost onTest", "ButtonForceReScan": "Vynutit opětovné prohledání", "ButtonFullPath": "Úplná cesta", "ButtonHide": "Skrýt", @@ -59,10 +61,12 @@ "ButtonPlaylists": "Seznamy skladeb", "ButtonPrevious": "Předchozí", "ButtonPreviousChapter": "Předchozí Kapitola", + "ButtonProbeAudioFile": "Prozkoumat audio soubor", "ButtonPurgeAllCache": "Vyčistit veškerou mezipaměť", "ButtonPurgeItemsCache": "Vyčistit mezipaměť položek", "ButtonQueueAddItem": "Přidat do fronty", "ButtonQueueRemoveItem": "Odstranit z fronty", + "ButtonQuickEmbed": "Rychle Zapsat", "ButtonQuickEmbedMetadata": "Rychle Zapsat Metadata", "ButtonQuickMatch": "Rychlé přiřazení", "ButtonReScan": "Znovu prohledat", @@ -223,6 +227,7 @@ "LabelAllUsersIncludingGuests": "Všichni uživatelé včetně hostů", "LabelAlreadyInYourLibrary": "Již ve vaší knihovně", "LabelAppend": "Připojit", + "LabelAudioBitrate": "Bitový tok zvuku (např. 128k)", "LabelAuthor": "Autor", "LabelAuthorFirstLast": "Autor (jméno a příjmení)", "LabelAuthorLastFirst": "Autor (příjmení a jméno)", From e1caf13233b8847cd71c05cfef2029c1defc956a Mon Sep 17 00:00:00 2001 From: Mathias Franco Date: Tue, 22 Oct 2024 11:46:57 +0000 Subject: [PATCH 118/164] Translated using Weblate (Dutch) Currently translated at 83.7% (891 of 1064 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/strings/nl.json b/client/strings/nl.json index 7a246e3d0..0fb0b5fa4 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -528,6 +528,7 @@ "LabelSelectUsers": "Selecteer gebruikers", "LabelSendEbookToDevice": "Stuur ebook naar...", "LabelSequence": "Sequentie", + "LabelSerial": "Serie", "LabelSeries": "Serie", "LabelSeriesName": "Naam serie", "LabelSeriesProgress": "Voortgang serie", @@ -718,12 +719,14 @@ "MessageConfirmPurgeItemsCache": "Met Purge items cache wordt de gehele directory op /metadata/cache/items verwijderd.
Weet u het zeker?", "MessageConfirmQuickEmbed": "Waarschuwing! Quick embed maakt geen back-up van uw audiobestanden. Zorg ervoor dat u een back-up van uw audiobestanden hebt.

Wilt u doorgaan?", "MessageConfirmQuickMatchEpisodes": "Snel matchende afleveringen overschrijven details als er een match is gevonden. Alleen niet-matchende afleveringen worden bijgewerkt. Weet u het zeker?", + "MessageConfirmReScanLibraryItems": "Bent u zeker dat u {0} items opnieuw wil scannen?", "MessageConfirmRemoveAllChapters": "Weet je zeker dat je alle hoofdstukken wil verwijderen?", "MessageConfirmRemoveAuthor": "Weet je zeker dat je auteur \"{0}\" wil verwijderen?", "MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?", "MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?", "MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?", "MessageConfirmRemoveListeningSessions": "Weet je zeker dat je {0} luistersessies wilt verwijderen?", + "MessageConfirmRemoveMetadataFiles": "Bent u zeker dat u alle metadata wil verwijderen. {0} bestanden in uw bibliotheel item folders?", "MessageConfirmRemoveNarrator": "Weet je zeker dat je verteller \"{0}\" wil verwijderen?", "MessageConfirmRemovePlaylist": "Weet je zeker dat je je afspeellijst \"{0}\" wil verwijderen?", "MessageConfirmRenameGenre": "Weet je zeker dat je genre \"{0}\" wil hernoemen naar \"{1}\" voor alle onderdelen?", @@ -732,9 +735,12 @@ "MessageConfirmRenameTag": "Weet je zeker dat je tag \"{0}\" wil hernoemen naar\"{1}\" voor alle onderdelen?", "MessageConfirmRenameTagMergeNote": "Opmerking: Deze tag bestaat al, dus zullen ze worden samengevoegd.", "MessageConfirmRenameTagWarning": "Waarschuwing! Een gelijknamige tag met ander hoofdlettergebruik bestaat al: \"{0}\".", + "MessageConfirmResetProgress": "Bet u zeker dat u uw voortgang wil resetten?", "MessageConfirmSendEbookToDevice": "Weet je zeker dat je {0} ebook \"{1}\" naar apparaat \"{2}\" wil sturen?", + "MessageConfirmUnlinkOpenId": "Bent u zeker dat u deze gebruiker wil ontkoppelen van OpenID?", "MessageDownloadingEpisode": "Aflevering aan het dowloaden", "MessageDragFilesIntoTrackOrder": "Sleep bestanden in de juiste trackvolgorde", + "MessageEmbedFailed": "Insluiten Mislukt!", "MessageEmbedFinished": "Insluiting voltooid!", "MessageEpisodesQueuedForDownload": "{0} aflevering(en) in de rij om te downloaden", "MessageFeedURLWillBe": "Feed URL zal {0} zijn", From e76c4ed2a450cf09bf61bf350709b35da3b31ce2 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Tue, 22 Oct 2024 04:58:53 +0000 Subject: [PATCH 119/164] Translated using Weblate (Slovenian) Currently translated at 100.0% (1064 of 1064 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index 66d5bf37e..dadb787b0 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -243,7 +243,7 @@ "LabelAutoRegisterDescription": "Po prijavi samodejno ustvari nove uporabnike", "LabelBackToUser": "Nazaj na uporabnika", "LabelBackupAudioFiles": "Varnostno kopiranje zvočnih datotek", - "LabelBackupLocation": "Lokacija rezervne kopije", + "LabelBackupLocation": "Lokacija varnostnih kopij", "LabelBackupsEnableAutomaticBackups": "Omogoči samodejno varnostno kopiranje", "LabelBackupsEnableAutomaticBackupsHelp": "Varnostne kopije shranjene v /metadata/backups", "LabelBackupsMaxBackupSize": "Največja velikost varnostne kopije (v GB) (0 za neomejeno)", @@ -325,7 +325,7 @@ "LabelEndOfChapter": "Konec poglavja", "LabelEpisode": "Epizoda", "LabelEpisodeNotLinkedToRssFeed": "Epizoda ni povezana z virom RSS", - "LabelEpisodeNumber": "Epizoda #{0}", + "LabelEpisodeNumber": "{0}. epizoda", "LabelEpisodeTitle": "Naslov epizode", "LabelEpisodeType": "Tip epizode", "LabelEpisodeUrlFromRssFeed": "URL epizode iz vira RSS", @@ -393,7 +393,7 @@ "LabelLastBookAdded": "Zadnja dodana knjiga", "LabelLastBookUpdated": "Zadnja posodobljena knjiga", "LabelLastSeen": "Nazadnje viden", - "LabelLastTime": "Zadnji čas", + "LabelLastTime": "Nazadnje", "LabelLastUpdate": "Zadnja posodobitev", "LabelLayout": "Postavitev", "LabelLayoutSinglePage": "Ena stran", @@ -457,7 +457,7 @@ "LabelNotificationsMaxQueueSize": "Največja velikost čakalne vrste za dogodke obvestil", "LabelNotificationsMaxQueueSizeHelp": "Dogodki so omejeni na sprožitev 1 na sekundo. Dogodki bodo prezrti, če je čakalna vrsta najvišja. To preprečuje neželeno pošiljanje obvestil.", "LabelNumberOfBooks": "Število knjig", - "LabelNumberOfEpisodes": "# od epizod", + "LabelNumberOfEpisodes": "število epizod", "LabelOpenIDAdvancedPermsClaimDescription": "Ime zahtevka OpenID, ki vsebuje napredna dovoljenja za uporabniška dejanja v aplikaciji, ki bodo veljala za neskrbniške vloge (če je konfigurirano). Če trditev manjka v odgovoru, bo dostop do ABS zavrnjen. Če ena možnost manjka, bo obravnavana kot false. Zagotovite, da se zahtevek ponudnika identitete ujema s pričakovano strukturo:", "LabelOpenIDClaims": "Pustite naslednje možnosti prazne, da onemogočite napredno dodeljevanje skupin in dovoljenj, nato pa samodejno dodelite skupino 'Uporabnik'.", "LabelOpenIDGroupClaimDescription": "Ime zahtevka OpenID, ki vsebuje seznam uporabnikovih skupin. Običajno imenovane skupine. Če je konfigurirana, bo aplikacija samodejno dodelila vloge na podlagi članstva v skupini uporabnika, pod pogojem, da so te skupine v zahtevku poimenovane 'admin', 'user' ali 'guest' brez razlikovanja med velikimi in malimi črkami. Zahtevek mora vsebovati seznam in če uporabnik pripada več skupinam, mu aplikacija dodeli vlogo, ki ustreza najvišjemu nivoju dostopa. Če se nobena skupina ne ujema, bo dostop zavrnjen.", @@ -586,7 +586,7 @@ "LabelSleepTimer": "Časovnik za spanje", "LabelSlug": "Slug", "LabelStart": "Začetek", - "LabelStartTime": "Začetni čas", + "LabelStartTime": "Čas začetka", "LabelStarted": "Začeto", "LabelStartedAt": "Začeto ob", "LabelStatsAudioTracks": "Zvočni posnetki", From 1fefc1af92db31381434a4a5db830f9ee4cf3740 Mon Sep 17 00:00:00 2001 From: Mathias Franco Date: Wed, 23 Oct 2024 14:03:19 +0000 Subject: [PATCH 120/164] Translated using Weblate (Dutch) Currently translated at 92.8% (991 of 1067 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 102 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/client/strings/nl.json b/client/strings/nl.json index 0fb0b5fa4..8c9499d73 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -30,6 +30,8 @@ "ButtonEditChapters": "Hoofdstukken wijzigen", "ButtonEditPodcast": "Podcast wijzigen", "ButtonEnable": "Aanzetten", + "ButtonFireAndFail": "Fire and Fail", + "ButtonFireOnTest": "Fire onTest event", "ButtonForceReScan": "Forceer nieuwe scan", "ButtonFullPath": "Volledig pad", "ButtonHide": "Verberg", @@ -161,6 +163,7 @@ "HeaderNotificationUpdate": "Update Notificatie", "HeaderNotifications": "Notificaties", "HeaderOpenIDConnectAuthentication": "OpenID Connect Authenticatie", + "HeaderOpenListeningSessions": "Open Luistersessies", "HeaderOpenRSSFeed": "Open RSS-feed", "HeaderOtherFiles": "Andere bestanden", "HeaderPasswordAuthentication": "Wachtwoord Authenticatie", @@ -224,6 +227,7 @@ "LabelAllUsersExcludingGuests": "Alle gebruikers exclusief gasten", "LabelAllUsersIncludingGuests": "Alle gebruikers inclusief gasten", "LabelAlreadyInYourLibrary": "Reeds in je bibliotheek", + "LabelApiToken": "API Token", "LabelAppend": "Achteraan toevoegen", "LabelAudioBitrate": "Audio Bitrate (b.v. 128k)", "LabelAudioChannels": "Audio Kanalen (1 of 2)", @@ -461,6 +465,7 @@ "LabelOpenIDGroupClaimDescription": "Naam van de OpenID-claim die een lijst met de groepen van de gebruiker bevat. Vaak aangeduid als groepen. Indien geconfigureerd, zal de applicatie automatisch rollen toewijzen op basis van de groepslidmaatschappen van de gebruiker, op voorwaarde dat deze groepen hoofdlettergevoelig 'admin', 'gebruiker' of 'gast' worden genoemd in de claim. De claim moet een lijst bevatten en als een gebruiker tot meerdere groepen behoort, zal de applicatie de rol toewijzen die overeenkomt met het hoogste toegangsniveau. Als er geen groep overeenkomt, wordt de toegang geweigerd.", "LabelOpenRSSFeed": "Open RSS-feed", "LabelOverwrite": "Overschrijf", + "LabelPaginationPageXOfY": "Pagina {0} van {1}", "LabelPassword": "Wachtwoord", "LabelPath": "Pad", "LabelPermanent": "Permanent", @@ -742,7 +747,9 @@ "MessageDragFilesIntoTrackOrder": "Sleep bestanden in de juiste trackvolgorde", "MessageEmbedFailed": "Insluiten Mislukt!", "MessageEmbedFinished": "Insluiting voltooid!", + "MessageEmbedQueue": "In de wachtrij voor metadata-embed ({0} in wachtrij)", "MessageEpisodesQueuedForDownload": "{0} aflevering(en) in de rij om te downloaden", + "MessageEreaderDevices": "Om de levering van e-books te garanderen, moet u mogelijk bovenstaand e-mailadres opgeven als geldige afzender voor elk hieronder vermeld apparaat.", "MessageFeedURLWillBe": "Feed URL zal {0} zijn", "MessageFetching": "Aan het ophalen...", "MessageForceReScanDescription": "zal alle bestanden opnieuw scannen als een verse scan. Audiobestanden ID3-tags, OPF-bestanden en textbestanden zullen als nieuw worden gescand.", @@ -754,6 +761,7 @@ "MessageListeningSessionsInTheLastYear": "{0} luistersessies in het laatste jaar", "MessageLoading": "Aan het laden...", "MessageLoadingFolders": "Mappen aan het laden...", + "MessageLogsDescription": "Logs worden opgeslagen in /metadata/logs als JSON-bestanden. Crashlogs worden opgeslagen in /metadata/logs/crash_logs.txt.", "MessageM4BFailed": "M4B mislukt!", "MessageM4BFinished": "M4B voltooid!", "MessageMapChapterTitles": "Map hoofdstuktitels naar je bestaande audioboekhoofdstukken zonder aanpassing van tijden", @@ -770,6 +778,7 @@ "MessageNoCollections": "Geen collecties", "MessageNoCoversFound": "Geen covers gevonden", "MessageNoDescription": "Geen beschrijving", + "MessageNoDevices": "Geen Apparaten", "MessageNoDownloadsInProgress": "Geen downloads bezig op dit moment", "MessageNoDownloadsQueued": "Geen downloads in de wachtrij", "MessageNoEpisodeMatchesFound": "Geen afleveringsmatches gevonden", @@ -783,6 +792,7 @@ "MessageNoLogs": "Geen logs", "MessageNoMediaProgress": "Geen mediavoortgang", "MessageNoNotifications": "Geen notificaties", + "MessageNoPodcastFeed": "Ongeldige podcast: Geen Feed", "MessageNoPodcastsFound": "Geen podcasts gevonden", "MessageNoResults": "Geen resultaten", "MessageNoSearchResultsFor": "Geen zoekresultaten voor \"{0}\"", @@ -792,11 +802,17 @@ "MessageNoUpdatesWereNecessary": "Geen bijwerkingen waren noodzakelijk", "MessageNoUserPlaylists": "Je hebt geen afspeellijsten", "MessageNotYetImplemented": "Nog niet geimplementeerd", + "MessageOpmlPreviewNote": "Let op: Dit is een preview van het geparseerde OPML-bestand. De werkelijke podcasttitel wordt overgenomen uit de RSS-feed.", "MessageOr": "of", "MessagePauseChapter": "Pauzeer afspelen hoofdstuk", "MessagePlayChapter": "Luister naar begin van hoofdstuk", "MessagePlaylistCreateFromCollection": "Afspeellijst aanmaken vanuit collectie", + "MessagePleaseWait": "Even geduld...", "MessagePodcastHasNoRSSFeedForMatching": "Podcast heeft geen RSS-feed URL om te gebruiken voor matching", + "MessagePodcastSearchField": "Voer zoekterm of RSS-feed-URL in", + "MessageQuickEmbedInProgress": "Snelle inbedding in uitvoering", + "MessageQuickEmbedQueue": "In de wachtrij voor snelle insluiting ({0} in wachtrij)", + "MessageQuickMatchAllEpisodes": "Alle Afleveringen Snel Matchen", "MessageQuickMatchDescription": "Vul lege onderdeeldetails & cover met eerste matchresultaat van '{0}'. Overschrijft geen details tenzij 'Prefereer gematchte metadata' serverinstelling is ingeschakeld.", "MessageRemoveChapter": "Verwijder hoofdstuk", "MessageRemoveEpisodes": "Verwijder {0} aflevering(en)", @@ -807,10 +823,48 @@ "MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op", "MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.

Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.

Alle clients die van je server gebruik maken zullen automatisch worden ververst.", "MessageSearchResultsFor": "Zoekresultaten voor", + "MessageSelected": "{0} geselecteerd", "MessageServerCouldNotBeReached": "Server niet bereikbaar", "MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel", + "MessageShareExpirationWillBe": "Vervaldatum is {0}", "MessageShareExpiresIn": "Vervalt in {0}", + "MessageShareURLWillBe": "De gedeelde URL wordt {0}", "MessageStartPlaybackAtTime": "Afspelen van \"{0}\" beginnen op {1}?", + "MessageTaskAudioFileNotWritable": "Audiobestand \"{0}\" is niet beschrijfbaar", + "MessageTaskCanceledByUser": "Taak geannuleerd door gebruiker", + "MessageTaskDownloadingEpisodeDescription": "Aflevering \"{0}\" downloaden", + "MessageTaskEmbeddingMetadata": "Metadata insluiten", + "MessageTaskEmbeddingMetadataDescription": "Metadata insluiten in audioboek \"{0}\"", + "MessageTaskEncodingM4b": "M4B Encoden", + "MessageTaskEncodingM4bDescription": "Audioboek \"{0}\" coderen in één m4b-bestand", + "MessageTaskFailed": "Mislukt", + "MessageTaskFailedToBackupAudioFile": "Het is niet gelukt om een back-up te maken van audiobestand \"{0}\"", + "MessageTaskFailedToCreateCacheDirectory": "Het is niet gelukt om een cachemap te maken", + "MessageTaskFailedToEmbedMetadataInFile": "Het is niet gelukt om metagegevens in bestand \"{0}\" in te sluiten", + "MessageTaskFailedToMergeAudioFiles": "Audiobestanden samenvoegen mislukt", + "MessageTaskFailedToMoveM4bFile": "m4b bestand verplaatsen mislukt", + "MessageTaskFailedToWriteMetadataFile": "Metadata bestand schrijven mislukt", + "MessageTaskMatchingBooksInLibrary": "Overeenkomende boeken in bibliotheek \"{0}\"", + "MessageTaskNoFilesToScan": "Geen bestanden om te scannen", + "MessageTaskOpmlImport": "OPML importeren", + "MessageTaskOpmlImportDescription": "Podcasts maken van {0} RSS feeds", + "MessageTaskOpmlImportFeed": "OPML feed importeren", + "MessageTaskOpmlImportFeedDescription": "RSS feed \"{0}\" importeren", + "MessageTaskOpmlImportFeedFailed": "Podcastfeed kon niet worden opgehaald", + "MessageTaskOpmlImportFeedPodcastDescription": "Podcast \"{0}\" maken", + "MessageTaskOpmlImportFeedPodcastExists": "Podcast bestaat al in pad", + "MessageTaskOpmlImportFeedPodcastFailed": "Mislukt om podcast aan te maken", + "MessageTaskOpmlImportFinished": "{0} podcasts toegevoegd", + "MessageTaskOpmlParseFailed": "Het is niet gelukt om het OPML-bestand te parseren", + "MessageTaskOpmlParseFastFail": "Ongeldig OPML-bestand tag niet gevonden OF een tag is niet gevonden", + "MessageTaskOpmlParseNoneFound": "Geen feeds gevonden in OPML bestand", + "MessageTaskScanItemsAdded": "{0} toegevoegd", + "MessageTaskScanItemsMissing": "{0} missend", + "MessageTaskScanItemsUpdated": "{0} bijgewerkt", + "MessageTaskScanNoChangesNeeded": "Geen aanpassingen nodig", + "MessageTaskScanningFileChanges": "Scannen van bestandswijzigingen in \"{0}\"", + "MessageTaskScanningLibrary": "Scannen van bibliotheek \"{0}\"", + "MessageTaskTargetDirectoryNotWritable": "Doelmap is niet beschrijfbaar", "MessageThinking": "Aan het denken...", "MessageUploaderItemFailed": "Uploaden mislukt", "MessageUploaderItemSuccess": "Uploaden gelukt!", @@ -828,34 +882,82 @@ "NoteUploaderFoldersWithMediaFiles": "Mappen met mediabestanden zullen worden behandeld als aparte bibliotheekonderdelen.", "NoteUploaderOnlyAudioFiles": "Bij uploaden van uitsluitend audiobestanden wordt ieder audiobestand als apart audiobook worden behandeld.", "NoteUploaderUnsupportedFiles": "Niet-ondersteunde bestanden worden genegeerd. Bij het kiezen of neerzetten van een map worden andere bestanden die niet in de map staan genegeerd.", + "NotificationOnBackupCompletedDescription": "Wordt geactiveerd wanneer een back-up is voltooid", + "NotificationOnBackupFailedDescription": "Wordt geactiveerd wanneer een back-up mislukt", + "NotificationOnEpisodeDownloadedDescription": "Wordt geactiveerd wanneer een podcastaflevering automatisch wordt gedownload", + "NotificationOnTestDescription": "Event voor het testen van het notificatiesysteem", "PlaceholderNewCollection": "Nieuwe naam collectie", "PlaceholderNewFolderPath": "Nieuwe locatie map", "PlaceholderNewPlaylist": "Nieuwe naam afspeellijst", "PlaceholderSearch": "Zoeken..", "PlaceholderSearchEpisode": "Aflevering zoeken..", + "StatsAuthorsAdded": "auteurs toegevoegd", + "StatsBooksAdded": "boeken toegevoegd", + "StatsBooksAdditional": "Enkele toevoegingen zijn…", + "StatsBooksFinished": "boeken voltooid", + "StatsBooksFinishedThisYear": "Enkele boeken voltooid dit jaar…", + "StatsBooksListenedTo": "geluisterde boeken", + "StatsCollectionGrewTo": "Je boeken collectie groeide tot…", + "StatsSessions": "sessies", + "StatsSpentListening": "tijd geluisterd", + "StatsTopAuthor": "TOP AUTEUR", + "StatsTopAuthors": "TOP AUTEURS", + "StatsTopGenre": "TOP GENRE", + "StatsTopGenres": "TOP GENRES", + "StatsTopMonth": "TOP MAAND", + "StatsTopNarrator": "TOP VERTELLER", + "StatsTopNarrators": "TOP VERTELLERS", + "StatsTotalDuration": "Met een totale tijd van…", + "StatsYearInReview": "JAAR IN REVIEW", "ToastAccountUpdateSuccess": "Account bijgewerkt", + "ToastAppriseUrlRequired": "Moet een Apprise URL invoeren", + "ToastAsinRequired": "ASIN is vereist", "ToastAuthorImageRemoveSuccess": "Afbeelding auteur verwijderd", + "ToastAuthorNotFound": "Auteur \"{0}\" niet gevonden", + "ToastAuthorRemoveSuccess": "Auteur verwijderd", + "ToastAuthorSearchNotFound": "Auteur niet gevonden", "ToastAuthorUpdateMerged": "Auteur samengevoegd", "ToastAuthorUpdateSuccess": "Auteur bijgewerkt", "ToastAuthorUpdateSuccessNoImageFound": "Auteur bijgewerkt (geen afbeelding gevonden)", + "ToastBackupAppliedSuccess": "Backup toegepast", "ToastBackupCreateFailed": "Back-up maken mislukt", "ToastBackupCreateSuccess": "Back-up gemaakt", "ToastBackupDeleteFailed": "Verwijderen back-up mislukt", "ToastBackupDeleteSuccess": "Back-up verwijderd", + "ToastBackupInvalidMaxKeep": "Ongeldig aantal backups om bij te houden", + "ToastBackupInvalidMaxSize": "Ongeldige maximum backupgrootte", "ToastBackupRestoreFailed": "Herstellen back-up mislukt", "ToastBackupUploadFailed": "Uploaden back-up mislukt", "ToastBackupUploadSuccess": "Back-up geüpload", + "ToastBatchDeleteFailed": "Batch verwijderen mislukt", + "ToastBatchDeleteSuccess": "Batch verwijderen gelukt", + "ToastBatchQuickMatchFailed": "Batch Snel Vergelijken mislukt!", + "ToastBatchQuickMatchStarted": "Bulk Snel Vergelijken van {0} boeken gestart!", "ToastBatchUpdateFailed": "Bulk-bijwerking mislukt", "ToastBatchUpdateSuccess": "Bulk-bijwerking gelukt", "ToastBookmarkCreateFailed": "Aanmaken boekwijzer mislukt", "ToastBookmarkCreateSuccess": "boekwijzer toegevoegd", "ToastBookmarkRemoveSuccess": "Boekwijzer verwijderd", "ToastBookmarkUpdateSuccess": "Boekwijzer bijgewerkt", + "ToastCachePurgeFailed": "Cache wissen is mislukt", + "ToastCachePurgeSuccess": "Cache succesvol verwijderd", "ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten", "ToastChaptersMustHaveTitles": "Hoofdstukken moeten titels hebben", + "ToastChaptersRemoved": "Hoofdstukken verwijderd", + "ToastChaptersUpdated": "Hoofdstukken bijgewerkt", + "ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt", + "ToastCollectionItemsAddSuccess": "Item(s) toegevoegd aan collectie gelukt", "ToastCollectionItemsRemoveSuccess": "Onderdeel (of onderdelen) verwijderd uit collectie", "ToastCollectionRemoveSuccess": "Collectie verwijderd", "ToastCollectionUpdateSuccess": "Collectie bijgewerkt", + "ToastCoverUpdateFailed": "Cover update mislukt", + "ToastDeleteFileFailed": "Bestand verwijderen mislukt", + "ToastDeleteFileSuccess": "Bestand verwijderd", + "ToastDeviceAddFailed": "Apparaat toevoegen mislukt", + "ToastDeviceNameAlreadyExists": "Er bestaat al een e-reader met die naam", + "ToastDeviceTestEmailFailed": "Het is niet gelukt om een test-e-mail te verzenden", + "ToastDeviceTestEmailSuccess": "Test e-mail verzonden", + "ToastEmailSettingsUpdateSuccess": "Emaill intellingen bijgewerkt", "ToastItemCoverUpdateSuccess": "Cover onderdeel bijgewerkt", "ToastItemDetailsUpdateSuccess": "Details onderdeel bijgewerkt", "ToastItemMarkedAsFinishedFailed": "Markeren als Voltooid mislukt", From e534daf5d41a4017c64b0235901cf05160ee0605 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Wed, 23 Oct 2024 04:11:10 +0000 Subject: [PATCH 121/164] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1067 of 1067 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 37bf38fc8..d92d833a5 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "更新通知", "HeaderNotifications": "通知", "HeaderOpenIDConnectAuthentication": "OpenID 连接身份验证", + "HeaderOpenListeningSessions": "打开收听会话", "HeaderOpenRSSFeed": "打开 RSS 源", "HeaderOtherFiles": "其他文件", "HeaderPasswordAuthentication": "密码认证", @@ -226,6 +227,7 @@ "LabelAllUsersExcludingGuests": "除访客外的所有用户", "LabelAllUsersIncludingGuests": "包括访客的所有用户", "LabelAlreadyInYourLibrary": "已存在你的库中", + "LabelApiToken": "API 令牌", "LabelAppend": "附加", "LabelAudioBitrate": "音频比特率 (例如: 128k)", "LabelAudioChannels": "音频通道 (1 或 2)", @@ -463,6 +465,7 @@ "LabelOpenIDGroupClaimDescription": "OpenID 声明的名称, 该声明包含用户组的列表. 通常称为如果已配置, 应用程序将根据用户的组成员身份自动分配角色, 前提是这些组在声明中以不区分大小写的方式命名为 'Admin', 'User' 或 'Guest'. 声明应包含一个列表, 如果用户属于多个组, 则应用程序将分配与最高访问级别相对应的角色. 如果没有组匹配, 访问将被拒绝.", "LabelOpenRSSFeed": "打开 RSS 源", "LabelOverwrite": "覆盖", + "LabelPaginationPageXOfY": "第 {0} 页 共 {1} 页", "LabelPassword": "密码", "LabelPath": "路径", "LabelPermanent": "永久的", From be8c44721688e695d117a5bc87e754ab3dfbbc00 Mon Sep 17 00:00:00 2001 From: kuci-JK Date: Thu, 24 Oct 2024 16:56:07 +0000 Subject: [PATCH 122/164] Translated using Weblate (Czech) Currently translated at 83.5% (891 of 1067 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/strings/cs.json b/client/strings/cs.json index f1c53efc5..7a898a370 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -226,8 +226,11 @@ "LabelAllUsersExcludingGuests": "Všichni uživatelé kromě hostů", "LabelAllUsersIncludingGuests": "Všichni uživatelé včetně hostů", "LabelAlreadyInYourLibrary": "Již ve vaší knihovně", + "LabelApiToken": "API Token", "LabelAppend": "Připojit", "LabelAudioBitrate": "Bitový tok zvuku (např. 128k)", + "LabelAudioChannels": "Zvukové kanály (1 nebo 2)", + "LabelAudioCodec": "Kodek audia", "LabelAuthor": "Autor", "LabelAuthorFirstLast": "Autor (jméno a příjmení)", "LabelAuthorLastFirst": "Autor (příjmení a jméno)", @@ -240,6 +243,7 @@ "LabelAutoRegister": "Automatická registrace", "LabelAutoRegisterDescription": "Automaticky vytvářet nové uživatele po přihlášení", "LabelBackToUser": "Zpět k uživateli", + "LabelBackupAudioFiles": "Zálohovat zvukové soubory", "LabelBackupLocation": "Umístění zálohy", "LabelBackupsEnableAutomaticBackups": "Povolit automatické zálohování", "LabelBackupsEnableAutomaticBackupsHelp": "Zálohy uložené v /metadata/backups", @@ -248,6 +252,7 @@ "LabelBackupsNumberToKeep": "Počet záloh, které se mají uchovat", "LabelBackupsNumberToKeepHelp": "Najednou bude odstraněna pouze 1 záloha, takže pokud již máte více záloh, měli byste je odstranit ručně.", "LabelBitrate": "Datový tok", + "LabelBonus": "Bonus", "LabelBooks": "Knihy", "LabelButtonText": "Text tlačítka", "LabelByAuthor": "od {0}", From 84003cd67ef29d805f57b4f819a0f27a02199972 Mon Sep 17 00:00:00 2001 From: Henning Date: Thu, 24 Oct 2024 09:37:46 +0000 Subject: [PATCH 123/164] Translated using Weblate (German) Currently translated at 99.5% (1062 of 1067 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 51 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/client/strings/de.json b/client/strings/de.json index 37c48d8b6..f0c167377 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Benachrichtigung bearbeiten", "HeaderNotifications": "Benachrichtigungen", "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentifizierung", + "HeaderOpenListeningSessions": "Aktive Hörbuch-Sitzungen", "HeaderOpenRSSFeed": "RSS-Feed öffnen", "HeaderOtherFiles": "Sonstige Dateien", "HeaderPasswordAuthentication": "Passwortauthentifizierung", @@ -180,6 +181,7 @@ "HeaderRemoveEpisodes": "Entferne {0} Episoden", "HeaderSavedMediaProgress": "Gespeicherte Hörfortschritte", "HeaderSchedule": "Zeitplan", + "HeaderScheduleEpisodeDownloads": "Automatische Episoden-Downloads planen", "HeaderScheduleLibraryScans": "Automatische Bibliotheksscans", "HeaderSession": "Sitzung", "HeaderSetBackupSchedule": "Zeitplan für die Datensicherung festlegen", @@ -225,6 +227,7 @@ "LabelAllUsersExcludingGuests": "Alle Benutzer außer Gästen", "LabelAllUsersIncludingGuests": "Alle Benutzer und Gäste", "LabelAlreadyInYourLibrary": "Bereits in der Bibliothek", + "LabelApiToken": "API Schlüssel", "LabelAppend": "Anhängen", "LabelAudioBitrate": "Audiobitrate (z. B. 128 kbit/s)", "LabelAudioChannels": "Audiokanäle (1 oder 2)", @@ -255,10 +258,12 @@ "LabelByAuthor": "von {0}", "LabelChangePassword": "Passwort ändern", "LabelChannels": "Kanäle", + "LabelChapterCount": "{0} Kapitel", "LabelChapterTitle": "Kapitelüberschrift", "LabelChapters": "Kapitel", "LabelChaptersFound": "Gefundene Kapitel", "LabelClickForMoreInfo": "Klicken für mehr Informationen", + "LabelClickToUseCurrentValue": "Anklicken um aktuellen Wert zu verwenden", "LabelClosePlayer": "Player schließen", "LabelCodec": "Codec", "LabelCollapseSeries": "Serien einklappen", @@ -316,11 +321,15 @@ "LabelEncodingStartedNavigation": "Sobald die Aufgabe gestartet ist, kann die Seite verlassen werden.", "LabelEncodingTimeWarning": "Kodierung kann bis zu 30 Minuten dauern.", "LabelEncodingWarningAdvancedSettings": "Achtung: Ändere diese Einstellungen nur, wenn du dich mit ffmpeg Kodierung auskennst.", + "LabelEncodingWatcherDisabled": "Wenn der Watcher deaktiviert ist musst du das Hörbuch danach erneut scannen.", "LabelEnd": "Ende", "LabelEndOfChapter": "Ende des Kapitels", "LabelEpisode": "Episode", + "LabelEpisodeNotLinkedToRssFeed": "Episode nicht mit RSS-Feed verknüpft", + "LabelEpisodeNumber": "Episode #{0}", "LabelEpisodeTitle": "Episodentitel", "LabelEpisodeType": "Episodentyp", + "LabelEpisodeUrlFromRssFeed": "Episoden URL vom RSS-Feed", "LabelEpisodes": "Episoden", "LabelExample": "Beispiel", "LabelExpandSeries": "Serie ausklappen", @@ -349,6 +358,7 @@ "LabelFontScale": "Schriftgröße", "LabelFontStrikethrough": "Durchgestrichen", "LabelFormat": "Format", + "LabelFull": "Voll", "LabelGenre": "Kategorie", "LabelGenres": "Kategorien", "LabelHardDeleteFile": "Datei dauerhaft löschen", @@ -404,6 +414,10 @@ "LabelLowestPriority": "Niedrigste Priorität", "LabelMatchExistingUsersBy": "Zuordnen existierender Benutzer mit", "LabelMatchExistingUsersByDescription": "Wird zum Verbinden vorhandener Benutzer verwendet. Sobald die Verbindung hergestellt ist, wird den Benutzern eine eindeutige ID vom SSO-Anbieter zugeordnet", + "LabelMaxEpisodesToDownload": "Max. Anzahl an Episoden zum Herunterladen, 0 für unbegrenzte Episoden.", + "LabelMaxEpisodesToDownloadPerCheck": "Max. Anzahl neuer Episoden zum Herunterladen pro Abfrage", + "LabelMaxEpisodesToKeep": "Max. Anzahl zu behaltender Episoden", + "LabelMaxEpisodesToKeepHelp": "0 setzt keine Begrenzung. Wenn eine neue Episode automatisch heruntergeladen wird, wird die älteste Episode gelöscht, wenn du mehr als X Episoden gespeichert hast. Es wird nur eine Episode pro neuem Download gelöscht.", "LabelMediaPlayer": "Mediaplayer", "LabelMediaType": "Medientyp", "LabelMetaTag": "Meta Schlagwort", @@ -449,6 +463,7 @@ "LabelOpenIDGroupClaimDescription": "Name des OpenID-Claims, der eine Liste der Benutzergruppen enthält. Wird häufig als groups bezeichnet. Wenn konfiguriert, wird die Anwendung automatisch Rollen basierend auf den Gruppenmitgliedschaften des Benutzers zuweisen, vorausgesetzt, dass diese Gruppen im Claim als 'admin', 'user' oder 'guest' benannt sind (Groß/Kleinschreibung ist irrelevant). Der Claim eine Liste sein, und wenn ein Benutzer mehreren Gruppen angehört, wird die Anwendung die Rolle zuordnen, die dem höchsten Zugriffslevel entspricht. Wenn keine Gruppe übereinstimmt, wird der Zugang verweigert.", "LabelOpenRSSFeed": "Öffne RSS-Feed", "LabelOverwrite": "Überschreiben", + "LabelPaginationPageXOfY": "Seite {0} von {1}", "LabelPassword": "Passwort", "LabelPath": "Pfad", "LabelPermanent": "Dauerhaft", @@ -499,12 +514,17 @@ "LabelRedo": "Wiederholen", "LabelRegion": "Region", "LabelReleaseDate": "Veröffentlichungsdatum", + "LabelRemoveAllMetadataAbs": "Alle metadata.abs Dateien löschen", + "LabelRemoveAllMetadataJson": "Alle metadata.json Dateien löschen", "LabelRemoveCover": "Entferne Titelbild", + "LabelRemoveMetadataFile": "Metadaten-Dateien in Bibliotheksordnern löschen", + "LabelRemoveMetadataFileHelp": "Alle metadata.json und metadata.abs Dateien aus den Ordnern {0} löschen.", "LabelRowsPerPage": "Zeilen pro Seite", "LabelSearchTerm": "Begriff suchen", "LabelSearchTitle": "Titel suchen", "LabelSearchTitleOrASIN": "Titel oder ASIN suchen", "LabelSeason": "Staffel", + "LabelSeasonNumber": "Staffel #{0}", "LabelSelectAll": "Alles auswählen", "LabelSelectAllEpisodes": "Alle Episoden auswählen", "LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt", @@ -603,6 +623,7 @@ "LabelTimeDurationXMinutes": "{0} Minuten", "LabelTimeDurationXSeconds": "{0} Sekunden", "LabelTimeInMinutes": "Zeit in Minuten", + "LabelTimeLeft": "{0} verbleibend", "LabelTimeListened": "Gehörte Zeit", "LabelTimeListenedToday": "Heute gehörte Zeit", "LabelTimeRemaining": "{0} verbleibend", @@ -639,6 +660,7 @@ "LabelUseAdvancedOptions": "Nutze Erweiterte Optionen", "LabelUseChapterTrack": "Kapiteldatei verwenden", "LabelUseFullTrack": "Gesamte Datei verwenden", + "LabelUseZeroForUnlimited": "0 für unbegrenzt", "LabelUser": "Benutzer", "LabelUsername": "Benutzername", "LabelValue": "Wert", @@ -704,6 +726,7 @@ "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird entfernt! Bist du dir sicher?", "MessageConfirmRemoveEpisodes": "{0} Episoden werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveListeningSessions": "Bist du dir sicher, dass du {0} Hörsitzungen enfernen möchtest?", + "MessageConfirmRemoveMetadataFiles": "Bist du sicher, dass du alle metadata.{0} Dateien in deinen Bibliotheksordnern löschen willst?", "MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird entfernt! Bist du dir sicher?", "MessageConfirmRemovePlaylist": "Wiedergabeliste \"{0}\" wird entfernt! Bist du dir sicher?", "MessageConfirmRenameGenre": "Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Bist du dir sicher?", @@ -719,6 +742,7 @@ "MessageDragFilesIntoTrackOrder": "Verschiebe die Dateien in die richtige Reihenfolge", "MessageEmbedFailed": "Einbetten fehlgeschlagen!", "MessageEmbedFinished": "Einbettung abgeschlossen!", + "MessageEmbedQueue": "Eingereiht zum einbinden von Metadaten ({0} in Warteschlange)", "MessageEpisodesQueuedForDownload": "{0} Episode(n) in der Warteschlange zum Herunterladen", "MessageEreaderDevices": "Um die Zustellung von E-Büchern sicherzustellen, musst du eventuell die oben genannte E-Mail-Adresse als gültigen Absender für jedes unten aufgeführte Gerät hinzufügen.", "MessageFeedURLWillBe": "Feed-URL wird {0} sein", @@ -780,6 +804,10 @@ "MessagePlaylistCreateFromCollection": "Erstelle eine Wiedergabeliste aus der Sammlung", "MessagePleaseWait": "Bitte warten...", "MessagePodcastHasNoRSSFeedForMatching": "Der Podcast hat keine RSS-Feed-Url welche für den Online-Abgleich verwendet werden kann", + "MessagePodcastSearchField": "Suchbegriff oder RSS-Feed URL eingeben", + "MessageQuickEmbedInProgress": "Schnellabgleich läuft", + "MessageQuickEmbedQueue": "In Warteschlange für Schnelles einbinden ({0} eingereiht)", + "MessageQuickMatchAllEpisodes": "Quick Match aller Episoden", "MessageQuickMatchDescription": "Füllt leere Details und Titelbilder mit dem ersten Treffer aus '{0}'. Überschreibt keine Details, es sei denn, die Server-Einstellung \"Passende Metadaten bevorzugen\" ist aktiviert.", "MessageRemoveChapter": "Kapitel entfernen", "MessageRemoveEpisodes": "Entferne {0} Episode(n)", @@ -822,6 +850,9 @@ "MessageTaskOpmlImportFeedPodcastExists": "Der Podcast ist bereits im Pfad vorhanden", "MessageTaskOpmlImportFeedPodcastFailed": "Erstellen des Podcasts fehlgeschlagen", "MessageTaskOpmlImportFinished": "{0} Podcasts hinzugefügt", + "MessageTaskOpmlParseFailed": "Fehler beim lesen der OPML Datei", + "MessageTaskOpmlParseFastFail": "Ungültie OPML Datei: ODER tag wurde nicht gefunden", + "MessageTaskOpmlParseNoneFound": "Keine feeds in der OPML Datei gefunden", "MessageTaskScanItemsAdded": "{0} hinzugefügt", "MessageTaskScanItemsMissing": "{0} fehlend", "MessageTaskScanItemsUpdated": "{0} aktualisiert", @@ -846,6 +877,10 @@ "NoteUploaderFoldersWithMediaFiles": "Ordner mit Mediendateien werden als separate Bibliothekselemente behandelt.", "NoteUploaderOnlyAudioFiles": "Wenn du nur Audiodateien hochlädst, wird jede Audiodatei als ein separates Medium behandelt.", "NoteUploaderUnsupportedFiles": "Nicht unterstützte Dateien werden ignoriert. Bei der Auswahl oder dem Löschen eines Ordners werden andere Dateien, die sich nicht in einem Elementordner befinden, ignoriert.", + "NotificationOnBackupCompletedDescription": "Wird ausgeführt wenn ein Backup erstellt wurde", + "NotificationOnBackupFailedDescription": "Wird ausgeführt wenn ein Backup fehlgeschlagen ist", + "NotificationOnEpisodeDownloadedDescription": "Wird ausgeführt wenn eine Podcast Folge automatisch heruntergeladen wird", + "NotificationOnTestDescription": "Wird ausgeführt wenn das Benachrichtigungssystem getestet wird", "PlaceholderNewCollection": "Neuer Sammlungsname", "PlaceholderNewFolderPath": "Neuer Ordnerpfad", "PlaceholderNewPlaylist": "Neuer Wiedergabelistenname", @@ -871,6 +906,7 @@ "StatsYearInReview": "DAS JAHR IM RÜCKBLICK", "ToastAccountUpdateSuccess": "Konto aktualisiert", "ToastAppriseUrlRequired": "Eine Apprise-URL ist notwendig", + "ToastAsinRequired": "ASIN ist erforderlich", "ToastAuthorImageRemoveSuccess": "Autorenbild entfernt", "ToastAuthorNotFound": "Autor \"{0}\" nicht gefunden", "ToastAuthorRemoveSuccess": "Autor entfernt", @@ -890,6 +926,8 @@ "ToastBackupUploadSuccess": "Sicherung hochgeladen", "ToastBatchDeleteFailed": "Batch-Löschen fehlgeschlagen", "ToastBatchDeleteSuccess": "Batch-Löschung erfolgreich", + "ToastBatchQuickMatchFailed": "Batch-Schnellabgleich fehlgeschlagen!", + "ToastBatchQuickMatchStarted": "Batch-Schnellabgleich für {0} Bücher gestartet!", "ToastBatchUpdateFailed": "Stapelaktualisierung fehlgeschlagen", "ToastBatchUpdateSuccess": "Stapelaktualisierung erfolgreich", "ToastBookmarkCreateFailed": "Lesezeichen konnte nicht erstellt werden", @@ -901,6 +939,7 @@ "ToastChaptersHaveErrors": "Kapitel sind fehlerhaft", "ToastChaptersMustHaveTitles": "Kapitel benötigen eindeutige Namen", "ToastChaptersRemoved": "Kapitel entfernt", + "ToastChaptersUpdated": "Kapitel aktualisiert", "ToastCollectionItemsAddFailed": "Das Hinzufügen von Element(en) zur Sammlung ist fehlgeschlagen", "ToastCollectionItemsAddSuccess": "Element(e) erfolgreich zur Sammlung hinzugefügt", "ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt", @@ -918,11 +957,14 @@ "ToastEncodeCancelSucces": "Encoding abgebrochen", "ToastEpisodeDownloadQueueClearFailed": "Warteschlange konnte nicht gelöscht werden", "ToastEpisodeDownloadQueueClearSuccess": "Warteschlange für Episoden-Downloads gelöscht", + "ToastEpisodeUpdateSuccess": "{0} Episoden aktualisiert", "ToastErrorCannotShare": "Das kann nicht nativ auf diesem Gerät freigegeben werden", "ToastFailedToLoadData": "Daten laden fehlgeschlagen", + "ToastFailedToMatch": "Fehler beim Abgleich", "ToastFailedToShare": "Fehler beim Teilen", "ToastFailedToUpdate": "Aktualisierung ist fehlgeschlagen", "ToastInvalidImageUrl": "Ungültiger Bild URL", + "ToastInvalidMaxEpisodesToDownload": "Ungültige Max. Anzahl an Episoden zum Herunterladen", "ToastInvalidUrl": "Ungültiger URL", "ToastItemCoverUpdateSuccess": "Titelbild aktualisiert", "ToastItemDeletedFailed": "Fehler beim löschen des Artikels", @@ -941,14 +983,21 @@ "ToastLibraryScanStarted": "Bibliotheksscan gestartet", "ToastLibraryUpdateSuccess": "Bibliothek \"{0}\" aktualisiert", "ToastMatchAllAuthorsFailed": "Nicht alle Autoren konnten zugeordnet werden", + "ToastMetadataFilesRemovedError": "Fehler beim löschen von metadata.{0} Dateien", + "ToastMetadataFilesRemovedNoneFound": "Keine metadata.{0} Dateien in Bibliothek gefunden", + "ToastMetadataFilesRemovedNoneRemoved": "Keine metadata.{0} Dateien gelöscht", + "ToastMetadataFilesRemovedSuccess": "{0} metadata.{1} Datei(en) gelöscht", + "ToastMustHaveAtLeastOnePath": "Es muss mindestens ein Pfad angegeben werden", "ToastNameEmailRequired": "Name und E-Mail sind erforderlich", "ToastNameRequired": "Name ist erforderlich", + "ToastNewEpisodesFound": "{0} neue Episoden gefunden", "ToastNewUserCreatedFailed": "Fehler beim erstellen des Accounts: \"{ 0}\"", "ToastNewUserCreatedSuccess": "Neuer Account erstellt", "ToastNewUserLibraryError": "Mindestens eine Bibliothek muss ausgewählt werden", "ToastNewUserPasswordError": "Passwort erforderlich, nur der root Benutzer darf ein leeres Passwort haben", "ToastNewUserTagError": "Mindestens ein Tag muss ausgewählt sein", "ToastNewUserUsernameError": "Nutzername eingeben", + "ToastNoNewEpisodesFound": "Keine neuen Episoden gefunden", "ToastNoUpdatesNecessary": "Keine Änderungen nötig", "ToastNotificationCreateFailed": "Fehler beim erstellen der Benachrichtig", "ToastNotificationDeleteFailed": "Fehler beim löschen der Benachrichtigung", @@ -967,6 +1016,7 @@ "ToastPodcastGetFeedFailed": "Fehler beim abrufen des Podcast Feeds", "ToastPodcastNoEpisodesInFeed": "Keine Episoden in RSS Feed gefunden", "ToastPodcastNoRssFeed": "Podcast enthält keinen RSS Feed", + "ToastProgressIsNotBeingSynced": "Fortschritt wird nicht synchronisiert, Wiedergabe wird neu gestartet", "ToastProviderCreatedFailed": "Fehler beim hinzufügen des Anbieters", "ToastProviderCreatedSuccess": "Neuer Anbieter hinzugefügt", "ToastProviderNameAndUrlRequired": "Name und URL notwendig", @@ -993,6 +1043,7 @@ "ToastSessionCloseFailed": "Fehler beim schließen der Sitzung", "ToastSessionDeleteFailed": "Sitzung konnte nicht gelöscht werden", "ToastSessionDeleteSuccess": "Sitzung gelöscht", + "ToastSleepTimerDone": "Einschlaf-Timer aktiviert... zZzzZz", "ToastSlugMustChange": "URL-Schlüssel enthält ungültige Zeichen", "ToastSlugRequired": "URL-Schlüssel erforderlich", "ToastSocketConnected": "Verbindung zum WebSocket hergestellt", From 9ba2ecbc216cb671a35b76e289feba9c592e41fb Mon Sep 17 00:00:00 2001 From: biuklija Date: Thu, 24 Oct 2024 17:34:38 +0000 Subject: [PATCH 124/164] Translated using Weblate (Croatian) Currently translated at 100.0% (1067 of 1067 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/strings/hr.json b/client/strings/hr.json index 92636a1d4..54a7ce02d 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Ažuriraj obavijest", "HeaderNotifications": "Obavijesti", "HeaderOpenIDConnectAuthentication": "Prijava na OpenID Connect", + "HeaderOpenListeningSessions": "Otvorene sesije slušanja", "HeaderOpenRSSFeed": "Otvori RSS izvor", "HeaderOtherFiles": "Druge datoteke", "HeaderPasswordAuthentication": "Provjera autentičnosti zaporkom", @@ -226,6 +227,7 @@ "LabelAllUsersExcludingGuests": "Svi korisnici osim gostiju", "LabelAllUsersIncludingGuests": "Svi korisnici uključujući i goste", "LabelAlreadyInYourLibrary": "Već u vašoj knjižnici", + "LabelApiToken": "API Token", "LabelAppend": "Pridodaj", "LabelAudioBitrate": "Kvaliteta zvučnog zapisa (npr. 128k)", "LabelAudioChannels": "Broj zvučnih kanala (1 ili 2)", @@ -463,6 +465,7 @@ "LabelOpenIDGroupClaimDescription": "Naziv OpenID zahtjeva koji sadrži popis korisnikovih grupa. Često se naziva groups. Ako se konfigurira, aplikacija će automatski dodijeliti uloge temeljem korisnikovih članstava u grupama, pod uvjetom da se iste zovu 'admin', 'user' ili 'guest' u zahtjevu (ne razlikuju se velika i mala slova). Zahtjev treba sadržavati popis i ako je korisnik član više grupa, aplikacija će dodijeliti ulogu koja odgovara najvišoj razini pristupa. Ukoliko se niti jedna grupa ne podudara, pristup će biti onemogućen.", "LabelOpenRSSFeed": "Otvori RSS Feed", "LabelOverwrite": "Prepiši", + "LabelPaginationPageXOfY": "Stranica {0} od {1}", "LabelPassword": "Zaporka", "LabelPath": "Putanja", "LabelPermanent": "Trajno", From d576efe759a1b104378af99dae1d71eff9e24d09 Mon Sep 17 00:00:00 2001 From: Mathias Franco Date: Thu, 24 Oct 2024 09:29:56 +0000 Subject: [PATCH 125/164] Translated using Weblate (Dutch) Currently translated at 100.0% (1067 of 1067 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 76 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/client/strings/nl.json b/client/strings/nl.json index 8c9499d73..bc5a40ca2 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -958,12 +958,28 @@ "ToastDeviceTestEmailFailed": "Het is niet gelukt om een test-e-mail te verzenden", "ToastDeviceTestEmailSuccess": "Test e-mail verzonden", "ToastEmailSettingsUpdateSuccess": "Emaill intellingen bijgewerkt", + "ToastEncodeCancelFailed": "Het is niet gelukt om het coderen te annuleren", + "ToastEncodeCancelSucces": "Encode geannuleerd", + "ToastEpisodeDownloadQueueClearFailed": "Wachtrij legen mislukt", + "ToastEpisodeDownloadQueueClearSuccess": "Aflevering download-wachtrij geleegt", + "ToastEpisodeUpdateSuccess": "{0} afleveringen bijgewerkt", + "ToastErrorCannotShare": "Kan niet native delen op dit apparaat", + "ToastFailedToLoadData": "Data laden mislukt", + "ToastFailedToMatch": "Match mislukt", + "ToastFailedToShare": "Delen mislukt", + "ToastFailedToUpdate": "Update mislukt", + "ToastInvalidImageUrl": "Ongeldige afbeeldings-URL", + "ToastInvalidMaxEpisodesToDownload": "Ongeldig maximum aantal afleveringen om te downloaden", + "ToastInvalidUrl": "Ongeldige URL", "ToastItemCoverUpdateSuccess": "Cover onderdeel bijgewerkt", + "ToastItemDeletedFailed": "Item verwijderen mislukt", + "ToastItemDeletedSuccess": "Verwijderd item", "ToastItemDetailsUpdateSuccess": "Details onderdeel bijgewerkt", "ToastItemMarkedAsFinishedFailed": "Markeren als Voltooid mislukt", "ToastItemMarkedAsFinishedSuccess": "Onderdeel gemarkeerd als Voltooid", "ToastItemMarkedAsNotFinishedFailed": "Markeren als Niet Voltooid mislukt", "ToastItemMarkedAsNotFinishedSuccess": "Onderdeel gemarkeerd als Niet Voltooid", + "ToastItemUpdateSuccess": "Item bijgewerkt", "ToastLibraryCreateFailed": "Bibliotheek aanmaken mislukt", "ToastLibraryCreateSuccess": "Bibliotheek \"{0}\" aangemaakt", "ToastLibraryDeleteFailed": "Bibliotheek verwijderen mislukt", @@ -971,25 +987,83 @@ "ToastLibraryScanFailedToStart": "Starten scan mislukt", "ToastLibraryScanStarted": "Scannen bibliotheek gestart", "ToastLibraryUpdateSuccess": "Bibliotheek \"{0}\" bijgewerkt", + "ToastMatchAllAuthorsFailed": "Alle auteurs matchen mislukt", + "ToastMetadataFilesRemovedError": "Fout bij verwijderen van metadata. {0} bestanden", + "ToastMetadataFilesRemovedNoneFound": "Geen metadata. {0} bestanden gevonden in bibliotheek", + "ToastMetadataFilesRemovedNoneRemoved": "Geen metadata. {0} bestanden verwijderd", + "ToastMetadataFilesRemovedSuccess": "{0} metadata. {1} bestanden verwijderd", + "ToastMustHaveAtLeastOnePath": "Moet ten minste een pad hebben", + "ToastNameEmailRequired": "Naam en email zijn vereist", + "ToastNameRequired": "Naam is vereist", + "ToastNewEpisodesFound": "{0} nieuwe afleveringen gevonden", + "ToastNewUserCreatedFailed": "Account: \"{0}\" aanmaken mislukt", + "ToastNewUserCreatedSuccess": "Nieuw account aangemaakt", + "ToastNewUserLibraryError": "Moet ten minste een bibliotheek selecteren", + "ToastNewUserPasswordError": "Moet een wachtwoord hebben, enkel root gebruiker kan een leeg wachtwoord gebruiken", + "ToastNewUserTagError": "Moet ten minste een tag selecteren", + "ToastNewUserUsernameError": "Voer een gebruikersnaam in", + "ToastNoNewEpisodesFound": "Geen nieuwe afleveringen gevonden", + "ToastNoUpdatesNecessary": "Geen updates nodig", + "ToastNotificationCreateFailed": "Nieuwe melding aanmaken mislukt", + "ToastNotificationDeleteFailed": "Melding verwijderen mislukt", + "ToastNotificationFailedMaximum": "Maximum aantal pogingen moet >=0", + "ToastNotificationQueueMaximum": "Maximale meldingen wachtrij moet >=0", + "ToastNotificationSettingsUpdateSuccess": "Meldingsinstellingen bijgewerkt", + "ToastNotificationTestTriggerFailed": "Het is niet gelukt om een testmelding te activeren", + "ToastNotificationTestTriggerSuccess": "Geactiveerde testmelding", + "ToastNotificationUpdateSuccess": "Melding bijgewerkt", "ToastPlaylistCreateFailed": "Aanmaken afspeellijst mislukt", "ToastPlaylistCreateSuccess": "Afspeellijst aangemaakt", "ToastPlaylistRemoveSuccess": "Afspeellijst verwijderd", "ToastPlaylistUpdateSuccess": "Afspeellijst bijgewerkt", "ToastPodcastCreateFailed": "Podcast aanmaken mislukt", "ToastPodcastCreateSuccess": "Podcast aangemaakt", + "ToastPodcastGetFeedFailed": "Podcast feed ophalen mislukt", + "ToastPodcastNoEpisodesInFeed": "Geen afleveringen gevonden in RSS feed", + "ToastPodcastNoRssFeed": "Podcast heeft geen RSS feed", + "ToastProgressIsNotBeingSynced": "De voortgang wordt niet gesynchroniseerd, start het afspelen opnieuw", + "ToastProviderCreatedFailed": "Provider toevoegen mislukt", + "ToastProviderCreatedSuccess": "Nieuwe provider toegevoegd", + "ToastProviderNameAndUrlRequired": "Naam en URL vereist", + "ToastProviderRemoveSuccess": "Provider verwijderd", "ToastRSSFeedCloseFailed": "Sluiten RSS-feed mislukt", "ToastRSSFeedCloseSuccess": "RSS-feed gesloten", + "ToastRemoveFailed": "Verwijderen mislukt", "ToastRemoveItemFromCollectionFailed": "Onderdeel verwijderen uit collectie mislukt", "ToastRemoveItemFromCollectionSuccess": "Onderdeel verwijderd uit collectie", + "ToastRemoveItemsWithIssuesFailed": "Verwijderen van bibliotheekitems met problemen mislukt", + "ToastRemoveItemsWithIssuesSuccess": "Bibliotheekitems met problemen verwijderd", + "ToastRenameFailed": "Hernoemen mislukt", + "ToastRescanFailed": "Opnieuw scannen mislukt voor {0}", + "ToastRescanRemoved": "Opnieuw scannen voltooid, item is verwijderd", + "ToastRescanUpToDate": "Rescan voltooid, item is up to date", + "ToastRescanUpdated": "Rescan voltooid, item is geupdated", + "ToastScanFailed": "Bibliotheek item scannen mislukt", + "ToastSelectAtLeastOneUser": "Selecteer ten minste een gebruiker", "ToastSendEbookToDeviceFailed": "Ebook naar apparaat sturen mislukt", "ToastSendEbookToDeviceSuccess": "Ebook verstuurd naar apparaat \"{0}\"", "ToastSeriesUpdateFailed": "Bijwerken serie mislukt", "ToastSeriesUpdateSuccess": "Bijwerken serie gelukt", + "ToastServerSettingsUpdateSuccess": "Server instellingen bijgewerkt", + "ToastSessionCloseFailed": "Sessie sluiten mislukt", "ToastSessionDeleteFailed": "Verwijderen sessie mislukt", "ToastSessionDeleteSuccess": "Sessie verwijderd", + "ToastSleepTimerDone": "Slaap timer voltooid... zZzzZz", + "ToastSlugMustChange": "Slug bevat ongeldige symbolen", + "ToastSlugRequired": "Slug is vereist", "ToastSocketConnected": "Socket verbonden", "ToastSocketDisconnected": "Socket niet verbonden", "ToastSocketFailedToConnect": "Verbinding Socket mislukt", + "ToastSortingPrefixesEmptyError": "Moet ten minste 1 sorteer-prefix bevatten", + "ToastSortingPrefixesUpdateSuccess": "Sorteer prefixes geupdated ({0} items)", + "ToastTitleRequired": "Titel is vereist", + "ToastUnknownError": "Onbekende fout", + "ToastUnlinkOpenIdFailed": "Gebruiker ontkoppelen van OpenID mislukt", + "ToastUnlinkOpenIdSuccess": "Gebruiker ontkoppeld van OpenID", "ToastUserDeleteFailed": "Verwijderen gebruiker mislukt", - "ToastUserDeleteSuccess": "Gebruiker verwijderd" + "ToastUserDeleteSuccess": "Gebruiker verwijderd", + "ToastUserPasswordChangeSuccess": "Wachtwoord succesvol gewijzigd", + "ToastUserPasswordMismatch": "Wachtwoorden komen niet overeen", + "ToastUserPasswordMustChange": "Het nieuwe wachtwoord kan niet overeenkomen met het oude wachtwoord", + "ToastUserRootRequireName": "U moet een root-gebruikersnaam invoeren" } From 69a639f76c7ffddcf75778c7f310c2fb391ba337 Mon Sep 17 00:00:00 2001 From: Ahetek Date: Thu, 24 Oct 2024 06:57:13 +0000 Subject: [PATCH 126/164] Translated using Weblate (Polish) Currently translated at 75.5% (806 of 1067 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/ --- client/strings/pl.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/client/strings/pl.json b/client/strings/pl.json index 57bb577a0..85c7b769b 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -64,6 +64,7 @@ "ButtonPurgeItemsCache": "Wyczyść dane tymczasowe pozycji", "ButtonQueueAddItem": "Dodaj do kolejki", "ButtonQueueRemoveItem": "Usuń z kolejki", + "ButtonQuickEmbed": "Szybkie wstawienie", "ButtonQuickEmbedMetadata": "Szybkie wstawianie metadanych", "ButtonQuickMatch": "Szybkie dopasowanie", "ButtonReScan": "Ponowne skanowanie", @@ -95,7 +96,7 @@ "ButtonStartM4BEncode": "Eksportuj jako plik M4B", "ButtonStartMetadataEmbed": "Osadź metadane", "ButtonStats": "Statystyki", - "ButtonSubmit": "Pobierz", + "ButtonSubmit": "Zapisz", "ButtonTest": "Test", "ButtonUnlinkOpenId": "Odłącz OpenID", "ButtonUpload": "Wgraj", @@ -138,6 +139,7 @@ "HeaderFindChapters": "Wyszukaj rozdziały", "HeaderIgnoredFiles": "Zignoruj pliki", "HeaderItemFiles": "Pliki", + "HeaderItemMetadataUtils": "Narzędzia dla metadanych", "HeaderLastListeningSession": "Ostatnia sesja słuchania", "HeaderLatestEpisodes": "Najnowsze odcinki", "HeaderLibraries": "Biblioteki", @@ -176,6 +178,7 @@ "HeaderRemoveEpisodes": "Usuń {0} odcinków", "HeaderSavedMediaProgress": "Zapisany postęp", "HeaderSchedule": "Harmonogram", + "HeaderScheduleEpisodeDownloads": "Planowanie automatycznego ściągania odcinków", "HeaderScheduleLibraryScans": "Zaplanuj automatyczne skanowanie biblioteki", "HeaderSession": "Sesja", "HeaderSetBackupSchedule": "Ustaw harmonogram tworzenia kopii zapasowej", @@ -221,7 +224,11 @@ "LabelAllUsersExcludingGuests": "Wszyscy użytkownicy z wyłączeniem gości", "LabelAllUsersIncludingGuests": "Wszyscy użytkownicy, łącznie z gośćmi", "LabelAlreadyInYourLibrary": "Już istnieje w twojej bibliotece", + "LabelApiToken": "API Token", "LabelAppend": "Dołącz", + "LabelAudioBitrate": "Audio Bitrate (np. 128k)", + "LabelAudioChannels": "Kanały dźwięku (1 lub 2)", + "LabelAudioCodec": "Kodek audio", "LabelAuthor": "Autor", "LabelAuthorFirstLast": "Autor (Rosnąco)", "LabelAuthorLastFirst": "Author (Malejąco)", @@ -233,6 +240,7 @@ "LabelAutoRegister": "Automatyczna rejestracja", "LabelAutoRegisterDescription": "Automatycznie utwórz nowych użytkowników po zalogowaniu", "LabelBackToUser": "Powrót", + "LabelBackupAudioFiles": "Kopia zapasowa plików audio", "LabelBackupLocation": "Lokalizacja kopii zapasowej", "LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe", "LabelBackupsEnableAutomaticBackupsHelp": "Kopie zapasowe są zapisywane w folderze /metadata/backups", @@ -241,15 +249,18 @@ "LabelBackupsNumberToKeep": "Liczba kopii zapasowych do przechowywania", "LabelBackupsNumberToKeepHelp": "Tylko 1 kopia zapasowa zostanie usunięta, więc jeśli masz już więcej kopii zapasowych, powinieneś je ręcznie usunąć.", "LabelBitrate": "Bitrate", + "LabelBonus": "Bonus", "LabelBooks": "Książki", "LabelButtonText": "Tekst przycisku", "LabelByAuthor": "autorstwa {0}", "LabelChangePassword": "Zmień hasło", "LabelChannels": "Kanały", + "LabelChapterCount": "{0} rozdziałów", "LabelChapterTitle": "Tytuł rozdziału", "LabelChapters": "Rozdziały", "LabelChaptersFound": "Znalezione rozdziały", "LabelClickForMoreInfo": "Kliknij po więcej szczegółów", + "LabelClickToUseCurrentValue": "Kliknij by zastosować aktualną wartość", "LabelClosePlayer": "Zamknij odtwarzacz", "LabelCodec": "Kodek", "LabelCollapseSeries": "Podsumuj serię", @@ -299,6 +310,7 @@ "LabelEmailSettingsTestAddress": "Adres testowy", "LabelEmbeddedCover": "Wbudowana okładka", "LabelEnable": "Włącz", + "LabelEncodingBackupLocation": "Kopia zapasowa twoich oryginalnych plików audio będzie się znajdować w:", "LabelEnd": "Zakończ", "LabelEndOfChapter": "Koniec rozdziału", "LabelEpisode": "Odcinek", From d9c345b0f38e2ceb5403d16366bc1a718abb69b6 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Fri, 25 Oct 2024 12:08:14 +0000 Subject: [PATCH 127/164] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1070 of 1070 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index d92d833a5..1754bf564 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -562,6 +562,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "只有一本书的系列将从系列页面和主页书架中隐藏.", "LabelSettingsHomePageBookshelfView": "首页使用书架视图", "LabelSettingsLibraryBookshelfView": "媒体库使用书架视图", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "完成百分比大于", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "剩余时间少于 (秒)", + "LabelSettingsLibraryMarkAsFinishedWhen": "当发生以下情况时将媒体项目标记为已完成", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "跳过继续系列中的早期书籍", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "继续系列主页书架显示系列中未开始的第一本书, 该系列至少有一本书已完成且没有正在进行的书. 启用此设置将从最远完成的书开始系列, 而不是从第一本书开始.", "LabelSettingsParseSubtitles": "解析副标题", From 449dc1a0e290b7a777193ebb66f7800c83232fdd Mon Sep 17 00:00:00 2001 From: thehijacker Date: Fri, 25 Oct 2024 09:13:00 +0000 Subject: [PATCH 128/164] Translated using Weblate (Slovenian) Currently translated at 100.0% (1070 of 1070 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index dadb787b0..d07696f00 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Posodobi obvestilo", "HeaderNotifications": "Obvestila", "HeaderOpenIDConnectAuthentication": "Prijava z OpenID Connect", + "HeaderOpenListeningSessions": "Odprte seje poslušanja", "HeaderOpenRSSFeed": "Odpri vir RSS", "HeaderOtherFiles": "Ostale datoteke", "HeaderPasswordAuthentication": "Preverjanje pristnosti z geslom", @@ -226,6 +227,7 @@ "LabelAllUsersExcludingGuests": "Vsi uporabniki razen gosti", "LabelAllUsersIncludingGuests": "Vsi uporabniki vključno z gosti", "LabelAlreadyInYourLibrary": "Že v tvoji knjižnici", + "LabelApiToken": "API žeton", "LabelAppend": "Priloži", "LabelAudioBitrate": "Avdio bitna hitrost (npr. 128k)", "LabelAudioChannels": "Avdio kanali (1 ali 2)", @@ -463,6 +465,7 @@ "LabelOpenIDGroupClaimDescription": "Ime zahtevka OpenID, ki vsebuje seznam uporabnikovih skupin. Običajno imenovane skupine. Če je konfigurirana, bo aplikacija samodejno dodelila vloge na podlagi članstva v skupini uporabnika, pod pogojem, da so te skupine v zahtevku poimenovane 'admin', 'user' ali 'guest' brez razlikovanja med velikimi in malimi črkami. Zahtevek mora vsebovati seznam in če uporabnik pripada več skupinam, mu aplikacija dodeli vlogo, ki ustreza najvišjemu nivoju dostopa. Če se nobena skupina ne ujema, bo dostop zavrnjen.", "LabelOpenRSSFeed": "Odpri vir RSS", "LabelOverwrite": "Prepiši", + "LabelPaginationPageXOfY": "Stran {0} od {1}", "LabelPassword": "Geslo", "LabelPath": "Pot", "LabelPermanent": "Trajno", @@ -559,6 +562,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Serije, ki imajo eno knjigo, bodo skrite na strani serije in policah domače strani.", "LabelSettingsHomePageBookshelfView": "Domača stran bo imela pogled knjižne police", "LabelSettingsLibraryBookshelfView": "Knjižnična uporaba pogleda knjižne police", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Odstotek dokončanega je večji od", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostali čas je manj kot (sekund)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Označi medijski element kot končan, ko", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči prejšnje knjige v nadaljevanju serije", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Polica z domačo stranjo Nadaljuj serijo prikazuje prvo nezačeto knjigo v seriji, ki ima vsaj eno dokončano knjigo in ni nobene knjige v teku. Če omogočite to nastavitev, se bo serija nadaljevala od najbolj dokončane knjige namesto od prve nezačete knjige.", "LabelSettingsParseSubtitles": "Uporabi podnapise", From fba9cce82ec1a94e002d34a6b012eadf34c759a0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 27 Oct 2024 15:15:44 -0500 Subject: [PATCH 129/164] Version bump v2.16.0 --- 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 d85027666..e82b55144 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.15.1", + "version": "2.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.15.1", + "version": "2.16.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 9b9baf0ad..441af9afd 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.15.1", + "version": "2.16.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index e17041a4b..15c162214 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.15.1", + "version": "2.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.15.1", + "version": "2.16.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 26ab93db7..723cafe06 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.15.1", + "version": "2.16.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 9084055b95417340c003b2f3946753d8f133289d Mon Sep 17 00:00:00 2001 From: mikiher Date: Mon, 28 Oct 2024 08:03:31 +0200 Subject: [PATCH 130/164] Add proper error handing for file downloads --- server/controllers/LibraryItemController.js | 61 +++++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index fe8539bc3..0b4d3d0cd 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -115,6 +115,16 @@ class LibraryItemController { res.sendStatus(200) } + #handleDownloadError(error, res) { + if (!res.headersSent) { + if (error.code === 'ENOENT') { + return res.status(404).send('File not found') + } else { + return res.status(500).send('Download failed') + } + } + } + /** * GET: /api/items/:id/download * Download library item. Zip file if multiple files. @@ -122,7 +132,7 @@ class LibraryItemController { * @param {RequestWithUser} req * @param {Response} res */ - download(req, res) { + async download(req, res) { if (!req.user.canDownload) { Logger.warn(`User "${req.user.username}" attempted to download without permission`) return res.sendStatus(403) @@ -130,21 +140,26 @@ class LibraryItemController { const libraryItemPath = req.libraryItem.path const itemTitle = req.libraryItem.media.metadata.title - // If library item is a single file in root dir then no need to zip - if (req.libraryItem.isFile) { - // Express does not set the correct mimetype for m4b files so use our defined mimetypes if available - const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryItemPath)) - if (audioMimeType) { - res.setHeader('Content-Type', audioMimeType) - } - Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`) - res.download(libraryItemPath, req.libraryItem.relPath) - return - } - Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`) - const filename = `${itemTitle}.zip` - zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res) + + try { + // If library item is a single file in root dir then no need to zip + if (req.libraryItem.isFile) { + // Express does not set the correct mimetype for m4b files so use our defined mimetypes if available + const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(libraryItemPath)) + if (audioMimeType) { + res.setHeader('Content-Type', audioMimeType) + } + await new Promise((resolve, reject) => res.download(libraryItemPath, req.libraryItem.relPath, (error) => (error ? reject(error) : resolve()))) + } else { + const filename = `${itemTitle}.zip` + await zipHelpers.zipDirectoryPipe(libraryItemPath, filename, res) + } + Logger.info(`[LibraryItemController] Downloaded item "${itemTitle}" at "${libraryItemPath}"`) + } catch (error) { + Logger.error(`[LibraryItemController] Download failed for item "${itemTitle}" at "${libraryItemPath}"`, error) + this.#handleDownloadError(error, res) + } } /** @@ -845,7 +860,13 @@ class LibraryItemController { res.setHeader('Content-Type', audioMimeType) } - res.download(libraryFile.metadata.path, libraryFile.metadata.filename) + try { + await new Promise((resolve, reject) => res.download(libraryFile.metadata.path, libraryFile.metadata.filename, (error) => (error ? reject(error) : resolve()))) + Logger.info(`[LibraryItemController] Downloaded file "${libraryFile.metadata.path}"`) + } catch (error) { + Logger.error(`[LibraryItemController] Failed to download file "${libraryFile.metadata.path}"`, error) + this.#handleDownloadError(error, res) + } } /** @@ -883,7 +904,13 @@ class LibraryItemController { return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send() } - res.sendFile(ebookFilePath) + try { + await new Promise((resolve, reject) => res.sendFile(ebookFilePath, (error) => (error ? reject(error) : resolve()))) + Logger.info(`[LibraryItemController] Downloaded ebook file "${ebookFilePath}"`) + } catch (error) { + Logger.error(`[LibraryItemController] Failed to download ebook file "${ebookFilePath}"`, error) + this.#handleDownloadError(error, res) + } } /** From 8f113d17c231df6502a4f2e1697ec9f7228b5470 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 28 Oct 2024 16:57:37 -0500 Subject: [PATCH 131/164] Fix:Ensure library has all settings defined when validating settings for update #3559 --- server/controllers/LibraryController.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 82fd34f07..61ffb5bd2 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -235,12 +235,14 @@ class LibraryController { for (const key of keysToCheck) { if (!req.body[key]) continue if (typeof req.body[key] !== 'string') { + Logger.error(`[LibraryController] Invalid request. ${key} must be a string`) return res.status(400).send(`Invalid request. ${key} must be a string`) } updatePayload[key] = req.body[key] } if (req.body.displayOrder !== undefined) { if (isNaN(req.body.displayOrder)) { + Logger.error(`[LibraryController] Invalid request. displayOrder must be a number`) return res.status(400).send('Invalid request. displayOrder must be a number') } updatePayload.displayOrder = req.body.displayOrder @@ -259,6 +261,13 @@ class LibraryController { const updatedSettings = { ...(req.library.settings || defaultLibrarySettings) } + // In case new settings are added in the future, ensure all settings are present + for (const key in defaultLibrarySettings) { + if (updatedSettings[key] === undefined) { + updatedSettings[key] = defaultLibrarySettings[key] + } + } + let hasUpdates = false let hasUpdatedDisableWatcher = false let hasUpdatedScanCron = false @@ -270,6 +279,7 @@ class LibraryController { if (key === 'metadataPrecedence') { if (!Array.isArray(req.body.settings[key])) { + Logger.error(`[LibraryController] Invalid request. Settings "metadataPrecedence" must be an array`) return res.status(400).send('Invalid request. Settings "metadataPrecedence" must be an array') } if (JSON.stringify(req.body.settings[key]) !== JSON.stringify(updatedSettings[key])) { @@ -279,6 +289,7 @@ class LibraryController { } } else if (key === 'autoScanCronExpression' || key === 'podcastSearchRegion') { if (req.body.settings[key] !== null && typeof req.body.settings[key] !== 'string') { + Logger.error(`[LibraryController] Invalid request. Settings "${key}" must be a string`) return res.status(400).send(`Invalid request. Settings "${key}" must be a string`) } if (req.body.settings[key] !== updatedSettings[key]) { @@ -290,8 +301,10 @@ class LibraryController { } } else if (key === 'markAsFinishedPercentComplete') { if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) { + Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be a number`) return res.status(400).send(`Invalid request. Setting "${key}" must be a number`) } else if (req.body.settings[key] !== null && (Number(req.body.settings[key]) < 0 || Number(req.body.settings[key]) > 100)) { + Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be between 0 and 100`) return res.status(400).send(`Invalid request. Setting "${key}" must be between 0 and 100`) } if (req.body.settings[key] !== updatedSettings[key]) { @@ -301,8 +314,10 @@ class LibraryController { } } else if (key === 'markAsFinishedTimeRemaining') { if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) { + Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be a number`) return res.status(400).send(`Invalid request. Setting "${key}" must be a number`) } else if (req.body.settings[key] !== null && Number(req.body.settings[key]) < 0) { + Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be greater than or equal to 0`) return res.status(400).send(`Invalid request. Setting "${key}" must be greater than or equal to 0`) } if (req.body.settings[key] !== updatedSettings[key]) { @@ -312,6 +327,7 @@ class LibraryController { } } else { if (typeof req.body.settings[key] !== typeof updatedSettings[key]) { + Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be of type ${typeof updatedSettings[key]}`) return res.status(400).send(`Invalid request. Setting "${key}" must be of type ${typeof updatedSettings[key]}`) } if (req.body.settings[key] !== updatedSettings[key]) { @@ -353,6 +369,7 @@ class LibraryController { return false }) if (!success) { + Logger.error(`[LibraryController] Invalid folder directory "${path}"`) return res.status(400).send(`Invalid folder directory "${path}"`) } } From 8c8c4a15c3180bec169d0a3773a066eef45199ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A1lint=20Krist=C3=B3f?= Date: Mon, 28 Oct 2024 08:18:14 +0000 Subject: [PATCH 132/164] Translated using Weblate (Hungarian) Currently translated at 75.6% (810 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/ --- client/strings/hu.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/hu.json b/client/strings/hu.json index 79852a9cd..0590ab604 100644 --- a/client/strings/hu.json +++ b/client/strings/hu.json @@ -66,6 +66,7 @@ "ButtonPurgeItemsCache": "Elemek gyorsítótárának törlése", "ButtonQueueAddItem": "Hozzáadás a sorhoz", "ButtonQueueRemoveItem": "Eltávolítás a sorból", + "ButtonQuickEmbed": "Gyors beágyazás", "ButtonQuickEmbedMetadata": "Metaadat gyors beágyazása", "ButtonQuickMatch": "Gyors egyeztetés", "ButtonReScan": "Újraszkennelés", From 94e2ea9df396e085cf5ba48d71d0a6379e209c89 Mon Sep 17 00:00:00 2001 From: Frantisek Nagy Date: Sat, 26 Oct 2024 20:55:42 +0000 Subject: [PATCH 133/164] Translated using Weblate (Hungarian) Currently translated at 75.6% (810 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/ --- client/strings/hu.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/hu.json b/client/strings/hu.json index 0590ab604..8ab6be9e9 100644 --- a/client/strings/hu.json +++ b/client/strings/hu.json @@ -344,7 +344,7 @@ "LabelHasSupplementaryEbook": "Van kiegészítő e-könyve", "LabelHideSubtitles": "Alcím elrejtése", "LabelHighestPriority": "Legmagasabb prioritás", - "LabelHost": "Házigazda", + "LabelHost": "Kiszolgáló", "LabelHour": "Óra", "LabelHours": "Órák", "LabelIcon": "Ikon", From 7ed711730e999eda9e88187f8282a4759c8410ac Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sun, 27 Oct 2024 17:51:29 +0000 Subject: [PATCH 134/164] Translated using Weblate (Russian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 116 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/client/strings/ru.json b/client/strings/ru.json index 04002ebae..d27f138f8 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -66,6 +66,7 @@ "ButtonPurgeItemsCache": "Очистить кэш элементов", "ButtonQueueAddItem": "Добавить в очередь", "ButtonQueueRemoveItem": "Удалить из очереди", + "ButtonQuickEmbed": "Быстрое внедрение", "ButtonQuickEmbedMetadata": "Быстрое встраивание метаданных", "ButtonQuickMatch": "Быстрый поиск", "ButtonReScan": "Пересканировать", @@ -162,6 +163,7 @@ "HeaderNotificationUpdate": "Уведомление об обновлении", "HeaderNotifications": "Уведомления", "HeaderOpenIDConnectAuthentication": "Аутентификация OpenID Connect", + "HeaderOpenListeningSessions": "Открытые сеансы прослушивания", "HeaderOpenRSSFeed": "Открыть RSS-канал", "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": "Аудиоканалы (1 или 2)", + "LabelAudioCodec": "Аудиокодек", "LabelAuthor": "Автор", "LabelAuthorFirstLast": "Автор (Имя Фамилия)", "LabelAuthorLastFirst": "Автор (Фамилия, Имя)", @@ -237,6 +244,7 @@ "LabelAutoRegister": "Автоматическая регистрация", "LabelAutoRegisterDescription": "Автоматическое создание новых пользователей после входа в систему", "LabelBackToUser": "Назад к пользователю", + "LabelBackupAudioFiles": "Резервное копирование аудиофайлов", "LabelBackupLocation": "Путь для бэкапов", "LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование", "LabelBackupsEnableAutomaticBackupsHelp": "Бэкапы сохраняются в /metadata/backups", @@ -245,15 +253,18 @@ "LabelBackupsNumberToKeep": "Сохранять бэкапов", "LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.", "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": "Кодирование может занять до 30 минут.", + "LabelEncodingWarningAdvancedSettings": "Предупреждение: Не обновляйте эти настройки, если вы не знакомы с параметрами кодировки ffmpeg.", + "LabelEncodingWatcherDisabled": "Если у вас отключено наблюдение за папкой, вам нужно будет повторно пересканировать эту аудиокнигу.", "LabelEnd": "Конец", "LabelEndOfChapter": "Конец главы", "LabelEpisode": "Эпизод", + "LabelEpisodeNotLinkedToRssFeed": "Эпизод, не связанный с RSS-каналом", + "LabelEpisodeNumber": "Эпизод #{0}", "LabelEpisodeTitle": "Имя эпизода", "LabelEpisodeType": "Тип эпизода", + "LabelEpisodeUrlFromRssFeed": "URL-адрес эпизода из RSS-ленты", "LabelEpisodes": "Эпизодов", + "LabelEpisodic": "Эпизодический", "LabelExample": "Пример", "LabelExpandSeries": "Развернуть серию", "LabelExpandSubSeries": "Развернуть подсерию", @@ -336,6 +360,7 @@ "LabelFontScale": "Масштаб шрифта", "LabelFontStrikethrough": "Зачеркнутый", "LabelFormat": "Формат", + "LabelFull": "Полный", "LabelGenre": "Жанр", "LabelGenres": "Жанры", "LabelHardDeleteFile": "Жесткое удаление файла", @@ -391,6 +416,10 @@ "LabelLowestPriority": "Самый низкий приоритет", "LabelMatchExistingUsersBy": "Сопоставление существующих пользователей по", "LabelMatchExistingUsersByDescription": "Используется для подключения существующих пользователей. После подключения пользователям будет присвоен уникальный идентификатор от поставщика единого входа", + "LabelMaxEpisodesToDownload": "Максимальное количество эпизодов для загрузки. Используйте 0 для неограниченного количества.", + "LabelMaxEpisodesToDownloadPerCheck": "Максимальное количество новых эпизодов для загрузки за одну проверку", + "LabelMaxEpisodesToKeep": "Максимальное количество сохраняемых эпизодов", + "LabelMaxEpisodesToKeepHelp": "Значение 0 не устанавливает максимального ограничения. После автоматической загрузки нового эпизода самый старый эпизод будет удален, если у вас более X эпизодов. При этом будет удален только 1 эпизод за каждую новую загрузку.", "LabelMediaPlayer": "Медиа проигрыватель", "LabelMediaType": "Тип медиа", "LabelMetaTag": "Мета тег", @@ -436,12 +465,14 @@ "LabelOpenIDGroupClaimDescription": "Имя утверждения OpenID, содержащего список групп пользователя. Обычно их называют groups. Если эта настройка настроена, приложение будет автоматически назначать роли на основе членства пользователя в группах при условии, что эти группы названы в утверждении без учета регистра \"admin\", \"user\" или \"guest\". Утверждение должно содержать список, и если пользователь принадлежит к нескольким группам, то приложение назначит роль, соответствующую самому высокому уровню доступа. Если ни одна из групп не совпадает, доступ будет запрещен.", "LabelOpenRSSFeed": "Открыть RSS-канал", "LabelOverwrite": "Перезаписать", + "LabelPaginationPageXOfY": "Страница {0} из {1}", "LabelPassword": "Пароль", "LabelPath": "Путь", "LabelPermanent": "Постоянный", "LabelPermissionsAccessAllLibraries": "Есть доступ ко всем библиотекам", "LabelPermissionsAccessAllTags": "Есть доступ ко всем тегам", "LabelPermissionsAccessExplicitContent": "Есть доступ к явному содержимому", + "LabelPermissionsCreateEreader": "Можно создать читалку", "LabelPermissionsDelete": "Может удалять", "LabelPermissionsDownload": "Может скачивать", "LabelPermissionsUpdate": "Может обновлять", @@ -465,6 +496,8 @@ "LabelPubDate": "Дата публикации", "LabelPublishYear": "Год публикации", "LabelPublishedDate": "Опубликовано {0}", + "LabelPublishedDecade": "Опубликованное десятилетие", + "LabelPublishedDecades": "Опубликованные десятилетия", "LabelPublisher": "Издатель", "LabelPublishers": "Издатели", "LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца", @@ -484,21 +517,28 @@ "LabelRedo": "Повторить", "LabelRegion": "Регион", "LabelReleaseDate": "Дата выхода", + "LabelRemoveAllMetadataAbs": "Удалите все файлы metadata.abs", + "LabelRemoveAllMetadataJson": "Удалите все файлы metadata.json", "LabelRemoveCover": "Удалить обложку", + "LabelRemoveMetadataFile": "Удаление файлов метаданных в папках элементов библиотеки", + "LabelRemoveMetadataFileHelp": "Удалите все файлы metadata.json и metadata.abs из ваших папок {0}.", "LabelRowsPerPage": "Строк на странице", "LabelSearchTerm": "Поисковый запрос", "LabelSearchTitle": "Поиск по названию", "LabelSearchTitleOrASIN": "Поиск по названию или ASIN", "LabelSeason": "Сезон", + "LabelSeasonNumber": "Сезон #{0}", "LabelSelectAll": "Выбрать все", "LabelSelectAllEpisodes": "Выбрать все эпизоды", "LabelSelectEpisodesShowing": "Выберите {0} эпизодов для показа", "LabelSelectUsers": "Выбор пользователей", "LabelSendEbookToDevice": "Отправить e-книгу в...", "LabelSequence": "Последовательность", + "LabelSerial": "Серийный", "LabelSeries": "Серия", "LabelSeriesName": "Имя серии", "LabelSeriesProgress": "Прогресс серии", + "LabelServerLogLevel": "Уровень журнала сервера", "LabelServerYearReview": "Итоги года всего сервера ({0})", "LabelSetEbookAsPrimary": "Установить как основную", "LabelSetEbookAsSupplementary": "Установить как дополнительную", @@ -523,6 +563,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Серии, в которых всего одна книга, будут скрыты со страницы серий и полок домашней страницы.", "LabelSettingsHomePageBookshelfView": "Вид книжной полки на Домашней странице", "LabelSettingsLibraryBookshelfView": "Вид книжной полки в Библиотеке", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Процент выполнения больше, чем", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Оставшееся время составляет менее (секунд)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Отметьте мультимедийный элемент как законченный, когда", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропустить предыдущие книги в \"Продолжить серию\"", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "На домашней странице \"Продолжить серию\" отображается первая книга, не начатая в серии, в которой закончена хотя бы одна книга и нет начатых книг. При включении этого параметра серия будет продолжена с самой последней завершенной книги, а не с первой, которая не начата.", "LabelSettingsParseSubtitles": "Разбор подзаголовков", @@ -587,13 +630,15 @@ "LabelTimeDurationXMinutes": "{0} минут", "LabelTimeDurationXSeconds": "{0} секунд", "LabelTimeInMinutes": "Время в минутах", + "LabelTimeLeft": "{0} осталось", "LabelTimeListened": "Время прослушивания", "LabelTimeListenedToday": "Время прослушивания сегодня", "LabelTimeRemaining": "{0} осталось", - "LabelTimeToShift": "Время смещения в сек.", + "LabelTimeToShift": "Время смещения в секундах", "LabelTitle": "Название", "LabelToolsEmbedMetadata": "Встроить метаданные", "LabelToolsEmbedMetadataDescription": "Встроить метаданные в аудио файлы, включая обложку и главы.", + "LabelToolsM4bEncoder": "Кодировщик M4B", "LabelToolsMakeM4b": "Создать M4B файл аудиокниги", "LabelToolsMakeM4bDescription": "Создает .M4B файл аудиокниги с встроенными метаданными, обложкой и главами.", "LabelToolsSplitM4b": "Разделить M4B на MP3 файлы", @@ -606,6 +651,7 @@ "LabelTracksMultiTrack": "Мультитрек", "LabelTracksNone": "Нет треков", "LabelTracksSingleTrack": "Один трек", + "LabelTrailer": "Трейлер", "LabelType": "Тип", "LabelUnabridged": "Полное издание", "LabelUndo": "Отменить", @@ -619,8 +665,10 @@ "LabelUploaderDragAndDrop": "Перетащите файлы или каталоги", "LabelUploaderDropFiles": "Перетащите файлы", "LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии", + "LabelUseAdvancedOptions": "Используйте расширенные опции", "LabelUseChapterTrack": "Показывать время главы", "LabelUseFullTrack": "Показывать время книги", + "LabelUseZeroForUnlimited": "Используйте 0 для неограниченного количества", "LabelUser": "Пользователь", "LabelUsername": "Имя пользователя", "LabelValue": "Значение", @@ -667,6 +715,7 @@ "MessageConfirmDeleteMetadataProvider": "Вы уверены, что хотите удалить пользовательский поставщик метаданных \"{0}\"?", "MessageConfirmDeleteNotification": "Вы уверены, что хотите удалить это уведомление?", "MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?", + "MessageConfirmEmbedMetadataInAudioFiles": "Вы уверены, что хотите вставить метаданные в {0} аудиофайлов?", "MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?", "MessageConfirmMarkAllEpisodesFinished": "Вы уверены, что хотите отметить все эпизоды как завершенные?", "MessageConfirmMarkAllEpisodesNotFinished": "Вы уверены, что хотите отметить все эпизоды как не завершенные?", @@ -678,6 +727,7 @@ "MessageConfirmPurgeCache": "Очистка кэша удалит весь каталог в /metadata/cache.

Вы уверены, что хотите удалить каталог кэша?", "MessageConfirmPurgeItemsCache": "Очистка кэша элементов удалит весь каталог в /metadata/cache/items.
Вы уверены?", "MessageConfirmQuickEmbed": "Предупреждение! Быстрое встраивание не позволяет создавать резервные копии аудиофайлов. Убедитесь, что у вас есть резервная копия аудиофайлов.

Хотите продолжить?", + "MessageConfirmQuickMatchEpisodes": "При обнаружении совпадений информация о эпизодах быстрого поиска будет перезаписана. Будут обновлены только несопоставимые эпизоды. Вы уверены?", "MessageConfirmReScanLibraryItems": "Вы уверены, что хотите пересканировать {0} элементов?", "MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?", "MessageConfirmRemoveAuthor": "Вы уверены, что хотите удалить автора \"{0}\"?", @@ -685,6 +735,7 @@ "MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?", "MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?", "MessageConfirmRemoveListeningSessions": "Вы уверены, что хотите удалить {0} сеансов прослушивания?", + "MessageConfirmRemoveMetadataFiles": "Вы уверены, что хотите удалить все файлы metadata. {0} файлов из папок элементов вашей библиотеки?", "MessageConfirmRemoveNarrator": "Вы уверены, что хотите удалить чтеца \"{0}\"?", "MessageConfirmRemovePlaylist": "Вы уверены, что хотите удалить плейлист \"{0}\"?", "MessageConfirmRenameGenre": "Вы уверены, что хотите переименовать жанр \"{0}\" в \"{1}\" для всех элементов?", @@ -700,6 +751,7 @@ "MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков", "MessageEmbedFailed": "Вставка не удалась!", "MessageEmbedFinished": "Встраивание завершено!", + "MessageEmbedQueue": "Поставлен в очередь для внедрения метаданных ({0} в очереди)", "MessageEpisodesQueuedForDownload": "{0} Эпизод(ов) запланировано для закачки", "MessageEreaderDevices": "Чтобы обеспечить доставку электронных книг, вам может потребоваться добавить указанный выше адрес электронной почты в качестве действительного отправителя для каждого устройства, перечисленного ниже.", "MessageFeedURLWillBe": "URL канала будет {0}", @@ -744,6 +796,7 @@ "MessageNoLogs": "Нет логов", "MessageNoMediaProgress": "Нет прогресса медиа", "MessageNoNotifications": "Нет уведомлений", + "MessageNoPodcastFeed": "Недопустимый подкаст: Нет канала", "MessageNoPodcastsFound": "Подкасты не найдены", "MessageNoResults": "Нет результатов", "MessageNoSearchResultsFor": "Нет результатов поиска для \"{0}\"", @@ -760,6 +813,10 @@ "MessagePlaylistCreateFromCollection": "Создать плейлист из коллекции", "MessagePleaseWait": "Пожалуйста подождите...", "MessagePodcastHasNoRSSFeedForMatching": "Подкаст не имеет URL-адреса RSS-канала, который можно использовать для поиска", + "MessagePodcastSearchField": "Введите поисковый запрос или URL-адрес RSS-канала", + "MessageQuickEmbedInProgress": "Быстрое внедрение в процессе выполнения", + "MessageQuickEmbedQueue": "Поставлен в очередь для быстрого внедрения ({0} в очереди)", + "MessageQuickMatchAllEpisodes": "Быстрое сопоставление всех эпизодов", "MessageQuickMatchDescription": "Заполняет пустые детали элемента и обложку первым результатом поиска из «{0}». Не перезаписывает сведения, если не включен параметр сервера 'Предпочитать метаданные поиска'.", "MessageRemoveChapter": "Удалить главу", "MessageRemoveEpisodes": "Удалить {0} эпизод(ов)", @@ -777,6 +834,41 @@ "MessageShareExpiresIn": "Срок действия истекает через {0}", "MessageShareURLWillBe": "URL-адрес общего доступа будет {0}", "MessageStartPlaybackAtTime": "Начать воспроизведение для \"{0}\" с {1}?", + "MessageTaskAudioFileNotWritable": "Аудиофайл \"{0}\" недоступен для записи", + "MessageTaskCanceledByUser": "Задание отменено пользователем", + "MessageTaskDownloadingEpisodeDescription": "Загрузка эпизода \"{0}\"", + "MessageTaskEmbeddingMetadata": "Внедрение метаданных", + "MessageTaskEmbeddingMetadataDescription": "Встраивание метаданных в аудиокнигу \"{0}\"", + "MessageTaskEncodingM4b": "Кодировка M4B", + "MessageTaskEncodingM4bDescription": "Кодирование аудиокниги \"{0}\" в один файл формата m4b", + "MessageTaskFailed": "Неудачный", + "MessageTaskFailedToBackupAudioFile": "Не удалось создать резервную копию аудиофайла \"{0}\"", + "MessageTaskFailedToCreateCacheDirectory": "Не удалось создать каталог кэша", + "MessageTaskFailedToEmbedMetadataInFile": "Не удалось вставить метаданные в файл \"{0}\"", + "MessageTaskFailedToMergeAudioFiles": "Не удалось объединить аудиофайлы", + "MessageTaskFailedToMoveM4bFile": "Не удалось переместить файл m4b", + "MessageTaskFailedToWriteMetadataFile": "Не удалось записать файл метаданных", + "MessageTaskMatchingBooksInLibrary": "Сопоставление книг в библиотеке \"{0}\"", + "MessageTaskNoFilesToScan": "Нет файлов для сканирования", + "MessageTaskOpmlImport": "Импорт OPML", + "MessageTaskOpmlImportDescription": "Создание подкастов из {0} RSS-каналов", + "MessageTaskOpmlImportFeed": "Канал импорта OPML", + "MessageTaskOpmlImportFeedDescription": "Импорт RSS-канала \"{0}\"", + "MessageTaskOpmlImportFeedFailed": "Не удалось получить ленту подкаста", + "MessageTaskOpmlImportFeedPodcastDescription": "Создание подкаста \"{0}\"", + "MessageTaskOpmlImportFeedPodcastExists": "Подкаст уже существует по адресу", + "MessageTaskOpmlImportFeedPodcastFailed": "Не удалось создать подкаст", + "MessageTaskOpmlImportFinished": "Добавлено {0} подкастов", + "MessageTaskOpmlParseFailed": "Не удалось разобрать OPML-файл", + "MessageTaskOpmlParseFastFail": "Недопустимый тег файла OPML не найден ИЛИ тег не найден", + "MessageTaskOpmlParseNoneFound": "В OPML-файле не найдено ни одного канала", + "MessageTaskScanItemsAdded": "{0} добавлено", + "MessageTaskScanItemsMissing": "{0} отсутствует", + "MessageTaskScanItemsUpdated": "{0} обновлено", + "MessageTaskScanNoChangesNeeded": "Никаких изменений не требуется", + "MessageTaskScanningFileChanges": "Проверка изменений файлов в \"{0}\"", + "MessageTaskScanningLibrary": "Сканирование библиотеки \"{0}\"", + "MessageTaskTargetDirectoryNotWritable": "Целевой каталог недоступен для записи", "MessageThinking": "Думаю...", "MessageUploaderItemFailed": "Не удалось загрузить", "MessageUploaderItemSuccess": "Успешно загружено!", @@ -794,6 +886,10 @@ "NoteUploaderFoldersWithMediaFiles": "Папки с медиафайлами будут обрабатываться как отдельные элементы библиотеки.", "NoteUploaderOnlyAudioFiles": "Если загружать только аудиофайлы, то каждый аудиофайл будет обрабатываться как отдельная аудиокнига.", "NoteUploaderUnsupportedFiles": "Неподдерживаемые файлы игнорируются. При выборе или удалении папки другие файлы, не находящиеся в папке элемента, игнорируются.", + "NotificationOnBackupCompletedDescription": "Запускается при завершении резервного копирования", + "NotificationOnBackupFailedDescription": "Срабатывает при сбое резервного копирования", + "NotificationOnEpisodeDownloadedDescription": "Запускается при автоматической загрузке эпизода подкаста", + "NotificationOnTestDescription": "Событие для тестирования системы оповещения", "PlaceholderNewCollection": "Новое имя коллекции", "PlaceholderNewFolderPath": "Путь к новой папке", "PlaceholderNewPlaylist": "Новое название плейлиста", @@ -819,6 +915,7 @@ "StatsYearInReview": "ИТОГИ ГОДА", "ToastAccountUpdateSuccess": "Учетная запись обновлена", "ToastAppriseUrlRequired": "Необходимо ввести URL-адрес Apprise", + "ToastAsinRequired": "Требуется ASIN", "ToastAuthorImageRemoveSuccess": "Изображение автора удалено", "ToastAuthorNotFound": "Автор \"{0}\" не найден", "ToastAuthorRemoveSuccess": "Автор удален", @@ -838,6 +935,8 @@ "ToastBackupUploadSuccess": "Бэкап загружен", "ToastBatchDeleteFailed": "Не удалось выполнить пакетное удаление", "ToastBatchDeleteSuccess": "Успешное пакетное удаление", + "ToastBatchQuickMatchFailed": "Не удалось выполнить пакетное быстрое сопоставление!", + "ToastBatchQuickMatchStarted": "Начато пакетное быстрое сопоставление {0} книг!", "ToastBatchUpdateFailed": "Сбой пакетного обновления", "ToastBatchUpdateSuccess": "Успешное пакетное обновление", "ToastBookmarkCreateFailed": "Не удалось создать закладку", @@ -849,6 +948,7 @@ "ToastChaptersHaveErrors": "Главы имеют ошибки", "ToastChaptersMustHaveTitles": "Главы должны содержать названия", "ToastChaptersRemoved": "Удалены главы", + "ToastChaptersUpdated": "Обновленные главы", "ToastCollectionItemsAddFailed": "Не удалось добавить элемент(ы) в коллекцию", "ToastCollectionItemsAddSuccess": "Элемент(ы) добавлены в коллекцию", "ToastCollectionItemsRemoveSuccess": "Элемент(ы), удалены из коллекции", @@ -866,10 +966,14 @@ "ToastEncodeCancelSucces": "Кодирование отменено", "ToastEpisodeDownloadQueueClearFailed": "Не удалось очистить очередь", "ToastEpisodeDownloadQueueClearSuccess": "Очередь загрузки эпизода очищена", + "ToastEpisodeUpdateSuccess": "{0 эпизодов обновлено", "ToastErrorCannotShare": "Невозможно предоставить общий доступ на этом устройстве", "ToastFailedToLoadData": "Не удалось загрузить данные", + "ToastFailedToMatch": "Не удалось найти совпадения", "ToastFailedToShare": "Не удалось поделиться", + "ToastFailedToUpdate": "Не удалось обновить", "ToastInvalidImageUrl": "Неверный URL изображения", + "ToastInvalidMaxEpisodesToDownload": "Недопустимое максимальное количество загружаемых эпизодов", "ToastInvalidUrl": "Неверный URL", "ToastItemCoverUpdateSuccess": "Обложка элемента обновлена", "ToastItemDeletedFailed": "Не удалось удалить элемент", @@ -887,14 +991,22 @@ "ToastLibraryScanFailedToStart": "Не удалось запустить сканирование", "ToastLibraryScanStarted": "Запущено сканирование библиотеки", "ToastLibraryUpdateSuccess": "Библиотека \"{0}\" обновлена", + "ToastMatchAllAuthorsFailed": "Не удалось найти совпадения со всеми авторами", + "ToastMetadataFilesRemovedError": "Ошибка при удалении файлов metadata.{0}", + "ToastMetadataFilesRemovedNoneFound": "В библиотеке не найдено файлов metadata.{0}", + "ToastMetadataFilesRemovedNoneRemoved": "Нет удаленных файлов metadata.{0}", + "ToastMetadataFilesRemovedSuccess": "{0} metadata.{1} файлов удалено", + "ToastMustHaveAtLeastOnePath": "Должен быть хотя бы один путь", "ToastNameEmailRequired": "Имя и адрес электронной почты обязательны", "ToastNameRequired": "Имя обязательно для заполнения", + "ToastNewEpisodesFound": "{0} новых эпизодов найдено", "ToastNewUserCreatedFailed": "Не удалось создать учетную запись: \"{0}\"", "ToastNewUserCreatedSuccess": "Новая учетная запись создана", "ToastNewUserLibraryError": "Необходимо выбрать хотя бы одну библиотеку", "ToastNewUserPasswordError": "Должен иметь пароль, только пользователь root может иметь пустой пароль", "ToastNewUserTagError": "Необходимо выбрать хотя бы один тег", "ToastNewUserUsernameError": "Введите имя пользователя", + "ToastNoNewEpisodesFound": "Новых эпизодов не найдено", "ToastNoUpdatesNecessary": "Обновления не требуются", "ToastNotificationCreateFailed": "Не удалось создать уведомление", "ToastNotificationDeleteFailed": "Не удалось удалить уведомление", @@ -913,6 +1025,7 @@ "ToastPodcastGetFeedFailed": "Не удалось получить ленту подкастов", "ToastPodcastNoEpisodesInFeed": "В RSS-ленте эпизодов не найдено", "ToastPodcastNoRssFeed": "В подкасте нет RSS-канала", + "ToastProgressIsNotBeingSynced": "Прогресс не синхронизируется, перезапустите воспроизведение", "ToastProviderCreatedFailed": "Не удалось добавить провайдера", "ToastProviderCreatedSuccess": "Добавлен новый провайдер", "ToastProviderNameAndUrlRequired": "Имя и URL обязательные", @@ -939,6 +1052,7 @@ "ToastSessionCloseFailed": "Не удалось закрыть сеанс", "ToastSessionDeleteFailed": "Не удалось удалить сеанс", "ToastSessionDeleteSuccess": "Сеанс удален", + "ToastSleepTimerDone": "Выполнен таймер сна... Хр-р-р-р", "ToastSlugMustChange": "Slug содержит недопустимые символы", "ToastSlugRequired": "Требуется Slug", "ToastSocketConnected": "Сокет подключен", From f83f4d41f1c4969d9a7fe576265a419c9da10e1e Mon Sep 17 00:00:00 2001 From: thehijacker Date: Sun, 27 Oct 2024 12:56:04 +0000 Subject: [PATCH 135/164] Translated using Weblate (Slovenian) Currently translated at 100.0% (1071 of 1071 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 d07696f00..b4e4383fd 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -472,6 +472,7 @@ "LabelPermissionsAccessAllLibraries": "Lahko dostopa do vseh knjižnic", "LabelPermissionsAccessAllTags": "Lahko dostopa do vseh oznak", "LabelPermissionsAccessExplicitContent": "Lahko dostopa do eksplicitne vsebine", + "LabelPermissionsCreateEreader": "Lahko ustvari e-bralnik", "LabelPermissionsDelete": "Lahko briše", "LabelPermissionsDownload": "Lahko prenaša", "LabelPermissionsUpdate": "Lahko posodablja", From d986673dfd761fcd3124e20524d91163fd789869 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Mon, 28 Oct 2024 13:41:21 +0000 Subject: [PATCH 136/164] Translated using Weblate (German) Currently translated at 99.8% (1069 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/strings/de.json b/client/strings/de.json index f0c167377..a427c2885 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -253,6 +253,7 @@ "LabelBackupsNumberToKeep": "Anzahl der aufzubewahrenden Sicherungen", "LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn du bereits mehrere Sicherungen als die definierte max. Anzahl hast, solltest du diese manuell entfernen.", "LabelBitrate": "Bitrate", + "LabelBonus": "Bonus", "LabelBooks": "Bücher", "LabelButtonText": "Knopftext", "LabelByAuthor": "von {0}", @@ -331,6 +332,7 @@ "LabelEpisodeType": "Episodentyp", "LabelEpisodeUrlFromRssFeed": "Episoden URL vom RSS-Feed", "LabelEpisodes": "Episoden", + "LabelEpisodic": "Episodisch", "LabelExample": "Beispiel", "LabelExpandSeries": "Serie ausklappen", "LabelExpandSubSeries": "Unterserie ausklappen", @@ -470,6 +472,7 @@ "LabelPermissionsAccessAllLibraries": "Zugriff auf alle Bibliotheken", "LabelPermissionsAccessAllTags": "Zugriff auf alle Schlagwörter", "LabelPermissionsAccessExplicitContent": "Zugriff auf explizite (alterbeschränkte) Inhalte", + "LabelPermissionsCreateEreader": "Kann E-Reader erstellen", "LabelPermissionsDelete": "Darf Löschen", "LabelPermissionsDownload": "Herunterladen", "LabelPermissionsUpdate": "Aktualisieren", @@ -559,6 +562,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Serien, die nur ein einzelnes Buch enthalten, werden auf der Startseite und in der Serienansicht ausgeblendet.", "LabelSettingsHomePageBookshelfView": "Startseite verwendet die Bücherregalansicht", "LabelSettingsLibraryBookshelfView": "Bibliothek verwendet die Bücherregalansicht", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "In Prozent gehört größer als", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Verbleibende Zeit ist weniger als (Sekunden)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Markiere Mediendateien als fertig, wenn", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Überspringe vorherige Bücher in fortführender Serie", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Die Startseite von \"Fortführende Serien\" zeigt das erste noch nicht begonnene Buch in Serien an, die mindestens ein Buch abgeschlossen und keine Bücher begonnen haben. Wenn diese Einstellung aktiviert wird, werden Serien ab dem letzten abgeschlossenen Buch fortgesetzt und nicht ab dem ersten nicht begonnenen Buch.", "LabelSettingsParseSubtitles": "Analysiere Untertitel", @@ -644,6 +650,7 @@ "LabelTracksMultiTrack": "Mehrfachdatei", "LabelTracksNone": "Keine Dateien", "LabelTracksSingleTrack": "Einzeldatei", + "LabelTrailer": "Vorschau", "LabelType": "Typ", "LabelUnabridged": "Ungekürzt", "LabelUndo": "Rückgängig machen", @@ -719,6 +726,7 @@ "MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis /metadata/cache löschen.

Bist du dir sicher, dass das Cache Verzeichnis gelöscht werden soll?", "MessageConfirmPurgeItemsCache": "Durch Elementcache leeren wird das gesamte Verzeichnis unter /metadata/cache/items gelöscht.
Bist du dir sicher?", "MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achte darauf, dass du eine Sicherungskopie der Audiodateien besitzt.

Möchtest du fortfahren?", + "MessageConfirmQuickMatchEpisodes": "Schnelles Zuordnen von Episoden überschreibt die Details, wenn eine Übereinstimmung gefunden wird. Nur nicht zugeordnete Episoden werden aktualisiert. Bist du sicher?", "MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?", "MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", From 399c40debd50ec47bbb7ef034478ae750fae7001 Mon Sep 17 00:00:00 2001 From: biuklija Date: Mon, 28 Oct 2024 19:42:08 +0000 Subject: [PATCH 137/164] Translated using Weblate (Croatian) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/strings/hr.json b/client/strings/hr.json index 54a7ce02d..d7d0fde54 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -472,6 +472,7 @@ "LabelPermissionsAccessAllLibraries": "Ima pristup svim knjižnicama", "LabelPermissionsAccessAllTags": "Ima pristup svim oznakama", "LabelPermissionsAccessExplicitContent": "Ima pristup eksplicitnom sadržaju", + "LabelPermissionsCreateEreader": "Može stvoriti e-čitač", "LabelPermissionsDelete": "Smije brisati", "LabelPermissionsDownload": "Smije preuzimati", "LabelPermissionsUpdate": "Smije ažurirati", @@ -562,6 +563,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Serijali koji se sastoje od samo jedne knjige neće se prikazivati na stranici serijala i na policama početne stranice.", "LabelSettingsHomePageBookshelfView": "Prikaži početnu stranicu kao policu s knjigama", "LabelSettingsLibraryBookshelfView": "Prikaži knjižnicu kao policu s knjigama", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Postotak dovršenosti veći od", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako uključite ovu opciju, serijal će vam se nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.", "LabelSettingsParseSubtitles": "Raščlani podnaslove", From d40086fea1e80befe9cdc6b2cf3929872c76bbf3 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Mon, 28 Oct 2024 11:48:51 +0000 Subject: [PATCH 138/164] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1071 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 1754bf564..2830a7101 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -472,6 +472,7 @@ "LabelPermissionsAccessAllLibraries": "可以访问所有媒体库", "LabelPermissionsAccessAllTags": "可以访问所有标签", "LabelPermissionsAccessExplicitContent": "可以访问显式内容", + "LabelPermissionsCreateEreader": "可以创建电子阅读器", "LabelPermissionsDelete": "可以删除", "LabelPermissionsDownload": "可以下载", "LabelPermissionsUpdate": "可以更新", From 50fd659749128a321ecc3284e128d014433145a9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 28 Oct 2024 17:05:47 -0500 Subject: [PATCH 139/164] Version bump v2.16.1 --- 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 e82b55144..f69dab4d7 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.16.0", + "version": "2.16.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.16.0", + "version": "2.16.1", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 441af9afd..703d7f661 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.16.0", + "version": "2.16.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 15c162214..189781ba9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.16.0", + "version": "2.16.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.16.0", + "version": "2.16.1", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 723cafe06..09c797115 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.16.0", + "version": "2.16.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 524cf5ec5b8f97a44b778dfed70e11efc2b35c9f Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 29 Oct 2024 21:42:44 +0200 Subject: [PATCH 140/164] Fix incorrect call to handleDownloadError --- server/controllers/LibraryItemController.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 0b4d3d0cd..a51a6e062 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -115,7 +115,7 @@ class LibraryItemController { res.sendStatus(200) } - #handleDownloadError(error, res) { + static handleDownloadError(error, res) { if (!res.headersSent) { if (error.code === 'ENOENT') { return res.status(404).send('File not found') @@ -158,7 +158,7 @@ class LibraryItemController { Logger.info(`[LibraryItemController] Downloaded item "${itemTitle}" at "${libraryItemPath}"`) } catch (error) { Logger.error(`[LibraryItemController] Download failed for item "${itemTitle}" at "${libraryItemPath}"`, error) - this.#handleDownloadError(error, res) + LibraryItemController.handleDownloadError(error, res) } } @@ -865,7 +865,7 @@ class LibraryItemController { Logger.info(`[LibraryItemController] Downloaded file "${libraryFile.metadata.path}"`) } catch (error) { Logger.error(`[LibraryItemController] Failed to download file "${libraryFile.metadata.path}"`, error) - this.#handleDownloadError(error, res) + LibraryItemController.handleDownloadError(error, res) } } @@ -909,7 +909,7 @@ class LibraryItemController { Logger.info(`[LibraryItemController] Downloaded ebook file "${ebookFilePath}"`) } catch (error) { Logger.error(`[LibraryItemController] Failed to download ebook file "${ebookFilePath}"`, error) - this.#handleDownloadError(error, res) + LibraryItemController.handleDownloadError(error, res) } } From 6eba467b91af8a65b47e683509294183b0aa891e Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 29 Oct 2024 15:41:31 -0500 Subject: [PATCH 141/164] Fix:Session sync for streaming podcast episodes using incorrect duration #3560 --- server/managers/PlaybackSessionManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index 33a3ccd23..ce43fc8c4 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -366,7 +366,7 @@ class PlaybackSessionManager { libraryItemId: libraryItem.id, episodeId: session.episodeId, // duration no longer required (v2.15.1) but used if available - duration: syncData.duration || libraryItem.media.duration || 0, + duration: syncData.duration || session.duration || 0, currentTime: syncData.currentTime, progress: session.progress, markAsFinishedTimeRemaining: library.librarySettings.markAsFinishedTimeRemaining, From c69e97ea241b30c2cbbaaee993ae4948fc3b2a66 Mon Sep 17 00:00:00 2001 From: Charlie Date: Mon, 28 Oct 2024 23:05:33 +0000 Subject: [PATCH 142/164] Translated using Weblate (French) Currently translated at 95.7% (1025 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/fr.json b/client/strings/fr.json index 3674acc30..d31c59714 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -250,11 +250,13 @@ "LabelBackupsNumberToKeep": "Nombre de sauvegardes à conserver", "LabelBackupsNumberToKeepHelp": "Seule une sauvegarde sera supprimée à la fois. Si vous avez déjà plus de sauvegardes à effacer, vous devez les supprimer manuellement.", "LabelBitrate": "Débit binaire", + "LabelBonus": "Bonus", "LabelBooks": "Livres", "LabelButtonText": "Texte du bouton", "LabelByAuthor": "par {0}", "LabelChangePassword": "Modifier le mot de passe", "LabelChannels": "Canaux", + "LabelChapterCount": "{0} Chapitres", "LabelChapterTitle": "Titre du chapitre", "LabelChapters": "Chapitres", "LabelChaptersFound": "chapitres trouvés", From e05cb0ef4de000eb20e46b6557a35fa12cc6b9a0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 29 Oct 2024 16:11:36 -0500 Subject: [PATCH 143/164] Version bump v2.16.2 --- 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 f69dab4d7..f31266cbf 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.16.1", + "version": "2.16.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.16.1", + "version": "2.16.2", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 703d7f661..2feb833b2 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.16.1", + "version": "2.16.2", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 189781ba9..6e3276cea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.16.1", + "version": "2.16.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.16.1", + "version": "2.16.2", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 09c797115..d31f20220 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.16.1", + "version": "2.16.2", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 63fdf0d18e9efceab3e6442ff6b50a1a64bdae9a Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Tue, 29 Oct 2024 18:22:38 -0700 Subject: [PATCH 144/164] Update: user directive in docker compose file --- docker-compose.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 68e012fb8..b8d428a23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,4 @@ ### EXAMPLE DOCKER COMPOSE ### -version: "3.7" - services: audiobookshelf: image: ghcr.io/advplyr/audiobookshelf:latest @@ -23,8 +21,7 @@ services: # you are running ABS on - ./config:/config restart: unless-stopped - # You can use the following environment variable to run the ABS + # You can use the following user directive to run the ABS # docker container as a specific user. You will need to change # the UID and GID to the correct values for your user. - #environment: - # - user=1000:1000 + # user: 1000:1000 From e0c66ea6dfa86f4a213864532999a703e99feeab Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 30 Oct 2024 15:27:18 -0500 Subject: [PATCH 145/164] Fix:Global search unclickable from trackpad due to blur event closing menu --- client/components/controls/GlobalSearch.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/components/controls/GlobalSearch.vue b/client/components/controls/GlobalSearch.vue index cb46895a5..58796fded 100644 --- a/client/components/controls/GlobalSearch.vue +++ b/client/components/controls/GlobalSearch.vue @@ -9,7 +9,7 @@ close
-
+
  • {{ $strings.MessageThinking }}

    @@ -157,7 +157,7 @@ export default { clearTimeout(this.focusTimeout) this.focusTimeout = setTimeout(() => { this.showMenu = false - }, 200) + }, 100) }, async runSearch(value) { this.lastSearch = value From 32105665c19187a9360311388da7eb15bb8fdf7b Mon Sep 17 00:00:00 2001 From: Achim Date: Thu, 31 Oct 2024 15:29:40 +0100 Subject: [PATCH 146/164] 'mpg' and 'mpeg' added as supported audio-type/file-extension --- server/utils/globals.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/globals.js b/server/utils/globals.js index 877cf07a0..5a5bd9513 100644 --- a/server/utils/globals.js +++ b/server/utils/globals.js @@ -1,6 +1,6 @@ const globals = { SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'], - SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'], + SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpg', 'mpeg'], SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], TextFileTypes: ['txt', 'nfo'], MetadataFileTypes: ['opf', 'abs', 'xml', 'json'] From f3d2b781ab38569e52000815079957252195bb4a Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 1 Nov 2024 09:12:40 -0500 Subject: [PATCH 147/164] Add mime types for MPEG/MPG --- client/players/LocalAudioPlayer.js | 11 +++++------ client/plugins/constants.js | 8 +++----- server/utils/constants.js | 4 +++- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/client/players/LocalAudioPlayer.js b/client/players/LocalAudioPlayer.js index eb1484bb6..7fc17e7aa 100644 --- a/client/players/LocalAudioPlayer.js +++ b/client/players/LocalAudioPlayer.js @@ -147,7 +147,7 @@ export default class LocalAudioPlayer extends EventEmitter { timeoutRetry: { maxNumRetry: 4, retryDelayMs: 0, - maxRetryDelayMs: 0, + maxRetryDelayMs: 0 }, errorRetry: { maxNumRetry: 8, @@ -160,7 +160,7 @@ export default class LocalAudioPlayer extends EventEmitter { } return retry } - }, + } } } } @@ -194,7 +194,7 @@ export default class LocalAudioPlayer extends EventEmitter { setDirectPlay() { // Set initial track and track time offset - var trackIndex = this.audioTracks.findIndex(t => this.startTime >= t.startOffset && this.startTime < (t.startOffset + t.duration)) + var trackIndex = this.audioTracks.findIndex((t) => this.startTime >= t.startOffset && this.startTime < t.startOffset + t.duration) this.currentTrackIndex = trackIndex >= 0 ? trackIndex : 0 this.loadCurrentTrack() @@ -270,7 +270,7 @@ export default class LocalAudioPlayer extends EventEmitter { // Seeking Direct play if (time < this.currentTrack.startOffset || time > this.currentTrack.startOffset + this.currentTrack.duration) { // Change Track - var trackIndex = this.audioTracks.findIndex(t => time >= t.startOffset && time < (t.startOffset + t.duration)) + var trackIndex = this.audioTracks.findIndex((t) => time >= t.startOffset && time < t.startOffset + t.duration) if (trackIndex >= 0) { this.startTime = time this.currentTrackIndex = trackIndex @@ -293,7 +293,6 @@ export default class LocalAudioPlayer extends EventEmitter { this.player.volume = volume } - // Utils isValidDuration(duration) { if (duration && !isNaN(duration) && duration !== Number.POSITIVE_INFINITY && duration !== Number.NEGATIVE_INFINITY) { @@ -338,4 +337,4 @@ export default class LocalAudioPlayer extends EventEmitter { var last = bufferedRanges[bufferedRanges.length - 1] return last.end } -} \ No newline at end of file +} diff --git a/client/plugins/constants.js b/client/plugins/constants.js index d89fbbbd6..90c40b8c4 100644 --- a/client/plugins/constants.js +++ b/client/plugins/constants.js @@ -1,6 +1,6 @@ const SupportedFileTypes = { image: ['png', 'jpg', 'jpeg', 'webp'], - audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'], + audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpeg', 'mpg'], ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], info: ['nfo'], text: ['txt'], @@ -81,11 +81,9 @@ const Hotkeys = { } } -export { - Constants -} +export { Constants } export default ({ app }, inject) => { inject('constants', Constants) inject('keynames', KeyNames) inject('hotkeys', Hotkeys) -} \ No newline at end of file +} diff --git a/server/utils/constants.js b/server/utils/constants.js index cbfe65f20..dd52e2e1b 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -49,5 +49,7 @@ module.exports.AudioMimeType = { WEBMA: 'audio/webm', MKA: 'audio/x-matroska', AWB: 'audio/amr-wb', - CAF: 'audio/x-caf' + CAF: 'audio/x-caf', + MPEG: 'audio/mpeg', + MPG: 'audio/mpeg' } From 431ae97593da6930e5a9be0bed126f5afc8e135d Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 2 Nov 2024 09:02:23 +0200 Subject: [PATCH 148/164] add Database.getLibraryItemCoverPath --- server/Database.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/server/Database.js b/server/Database.js index 9bce26050..9b9f9cf98 100644 --- a/server/Database.js +++ b/server/Database.js @@ -808,6 +808,28 @@ class Database { return `${normalizedColumn} LIKE ${pattern}` } } + + async getLibraryItemCoverPath(libraryItemId) { + const libraryItem = await this.libraryItemModel.findByPk(libraryItemId, { + attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], + include: [ + { + model: this.bookModel, + attributes: ['id', 'coverPath'] + }, + { + model: this.podcastModel, + attributes: ['id', 'coverPath'] + } + ] + }) + if (!libraryItem) { + Logger.warn(`[Database] getCover: Library item "${libraryItemId}" does not exist`) + return null + } + + return libraryItem.media.coverPath + } } module.exports = new Database() From 9e990d79272f05ef79415d5b94835787d25200cd Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 2 Nov 2024 09:05:30 +0200 Subject: [PATCH 149/164] Optimize LibraryItemController.getCover --- server/controllers/LibraryItemController.js | 43 ++++++--------------- server/managers/CacheManager.js | 19 ++++++--- 2 files changed, 25 insertions(+), 37 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 0b4d3d0cd..f0f5c86d8 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -342,44 +342,25 @@ class LibraryItemController { query: { width, height, format, raw } } = req - const libraryItem = await Database.libraryItemModel.findByPk(req.params.id, { - attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], - include: [ - { - model: Database.bookModel, - attributes: ['id', 'coverPath', 'tags', 'explicit'] - }, - { - model: Database.podcastModel, - attributes: ['id', 'coverPath', 'tags', 'explicit'] - } - ] - }) - if (!libraryItem) { - Logger.warn(`[LibraryItemController] getCover: Library item "${req.params.id}" does not exist`) - return res.sendStatus(404) - } - - // Check if user can access this library item - if (!req.user.checkCanAccessLibraryItem(libraryItem)) { - return res.sendStatus(403) - } - - // Check if library item media has a cover path - if (!libraryItem.media.coverPath || !(await fs.pathExists(libraryItem.media.coverPath))) { - return res.sendStatus(404) - } - if (req.query.ts) res.set('Cache-Control', 'private, max-age=86400') + const libraryItemId = req.params.id + if (!libraryItemId) { + return res.sendStatus(400) + } + if (raw) { + const coverPath = await Database.getLibraryItemCoverPath(libraryItemId) + if (!coverPath || !(await fs.pathExists(coverPath))) { + return res.sendStatus(404) + } // any value if (global.XAccel) { - const encodedURI = encodeUriPath(global.XAccel + libraryItem.media.coverPath) + const encodedURI = encodeUriPath(global.XAccel + coverPath) Logger.debug(`Use X-Accel to serve static file ${encodedURI}`) return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send() } - return res.sendFile(libraryItem.media.coverPath) + return res.sendFile(coverPath) } const options = { @@ -387,7 +368,7 @@ class LibraryItemController { height: height ? parseInt(height) : null, width: width ? parseInt(width) : null } - return CacheManager.handleCoverCache(res, libraryItem.id, libraryItem.media.coverPath, options) + return CacheManager.handleCoverCache(res, libraryItemId, options) } /** diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js index b4d2f270c..d1b274238 100644 --- a/server/managers/CacheManager.js +++ b/server/managers/CacheManager.js @@ -4,6 +4,7 @@ const stream = require('stream') const Logger = require('../Logger') const { resizeImage } = require('../utils/ffmpegHelpers') const { encodeUriPath } = require('../utils/fileUtils') +const Database = require('../Database') class CacheManager { constructor() { @@ -29,24 +30,24 @@ class CacheManager { await fs.ensureDir(this.ItemCachePath) } - async handleCoverCache(res, libraryItemId, coverPath, options = {}) { + async handleCoverCache(res, libraryItemId, options = {}) { const format = options.format || 'webp' const width = options.width || 400 const height = options.height || null res.type(`image/${format}`) - const path = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format + const cachePath = Path.join(this.CoverCachePath, `${libraryItemId}_${width}${height ? `x${height}` : ''}`) + '.' + format // Cache exists - if (await fs.pathExists(path)) { + if (await fs.pathExists(cachePath)) { if (global.XAccel) { - const encodedURI = encodeUriPath(global.XAccel + path) + const encodedURI = encodeUriPath(global.XAccel + cachePath) Logger.debug(`Use X-Accel to serve static file ${encodedURI}`) return res.status(204).header({ 'X-Accel-Redirect': encodedURI }).send() } - const r = fs.createReadStream(path) + const r = fs.createReadStream(cachePath) const ps = new stream.PassThrough() stream.pipeline(r, ps, (err) => { if (err) { @@ -57,7 +58,13 @@ class CacheManager { return ps.pipe(res) } - const writtenFile = await resizeImage(coverPath, path, width, height) + // Cached cover does not exist, generate it + const coverPath = await Database.getLibraryItemCoverPath(libraryItemId) + if (!coverPath || !(await fs.pathExists(coverPath))) { + return res.sendStatus(404) + } + + const writtenFile = await resizeImage(coverPath, cachePath, width, height) if (!writtenFile) return res.sendStatus(500) if (global.XAccel) { From 4224b8a486b106d8c26185a5f24253a0d4587975 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 2 Nov 2024 15:17:11 +0200 Subject: [PATCH 150/164] No auth and req.user for cover images --- server/Auth.js | 20 ++++++++++++++++++++ server/Server.js | 14 +++++++------- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 60af2a1e0..6e5a46212 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -18,6 +18,26 @@ class Auth { constructor() { // Map of openId sessions indexed by oauth2 state-variable this.openIdAuthSession = new Map() + this.ignorePattern = /\/api\/items\/[^/]+\/cover/ + } + + /** + * Checks if the request should not be authenticated. + * @param {import('express').Request} req + * @returns {boolean} + * @private + */ + authNotNeeded(req) { + return req.method === 'GET' && this.ignorePattern.test(req.originalUrl) + } + + ifAuthNeeded(middleware) { + return (req, res, next) => { + if (this.authNotNeeded(req)) { + return next() + } + middleware(req, res, next) + } } /** diff --git a/server/Server.js b/server/Server.js index d82652376..58a2079ef 100644 --- a/server/Server.js +++ b/server/Server.js @@ -238,7 +238,7 @@ class Server { // init passport.js app.use(passport.initialize()) // register passport in express-session - app.use(passport.session()) + app.use(this.auth.ifAuthNeeded(passport.session())) // config passport.js await this.auth.initPassportJs() @@ -268,6 +268,10 @@ class Server { router.use(express.urlencoded({ extended: true, limit: '5mb' })) router.use(express.json({ limit: '5mb' })) + router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router) + router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router) + router.use('/public', this.publicRouter.router) + // Static path to generated nuxt const distPath = Path.join(global.appRoot, '/client/dist') router.use(express.static(distPath)) @@ -275,10 +279,6 @@ class Server { // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) - router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router) - router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router) - router.use('/public', this.publicRouter.router) - // RSS Feed temp route router.get('/feed/:slug', (req, res) => { Logger.info(`[Server] Requesting rss feed ${req.params.slug}`) @@ -296,7 +296,7 @@ class Server { await this.auth.initAuthRoutes(router) // Client dynamic routes - const dyanimicRoutes = [ + const dynamicRoutes = [ '/item/:id', '/author/:id', '/audiobook/:id/chapters', @@ -319,7 +319,7 @@ class Server { '/playlist/:id', '/share/:slug' ] - dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) + dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) router.post('/init', (req, res) => { if (Database.hasRootUser) { From c25acb41fa9a6302e9d3e1261c5da38ef4a7a499 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 2 Nov 2024 15:37:14 +0200 Subject: [PATCH 151/164] Remove token from cover image urls --- client/store/globals.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/store/globals.js b/client/store/globals.js index c0e7d7885..65878fb44 100644 --- a/client/store/globals.js +++ b/client/store/globals.js @@ -98,7 +98,7 @@ export const getters = { const userToken = rootGetters['user/getToken'] const lastUpdate = libraryItem.updatedAt || Date.now() const libraryItemId = libraryItem.libraryItemId || libraryItem.id // Workaround for /users/:id page showing media progress covers - return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}${raw ? '&raw=1' : ''}` + return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?ts=${lastUpdate}${raw ? '&raw=1' : ''}` }, getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => @@ -106,7 +106,7 @@ export const getters = { const placeholder = `${rootState.routerBasePath}/book_placeholder.jpg` if (!libraryItemId) return placeholder const userToken = rootGetters['user/getToken'] - return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}` + return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}` }, getIsBatchSelectingMediaItems: (state) => { return state.selectedMediaItems.length From 7a1623e6a11307842060360bac97ee10256548f9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 2 Nov 2024 12:56:40 -0500 Subject: [PATCH 152/164] Move cover path func to LibraryItem model --- server/Auth.js | 2 +- server/Database.js | 22 ----------------- server/controllers/LibraryItemController.js | 2 +- server/managers/CacheManager.js | 2 +- server/models/LibraryItem.js | 27 +++++++++++++++++++++ 5 files changed, 30 insertions(+), 25 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 6e5a46212..da124b72a 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -23,7 +23,7 @@ class Auth { /** * Checks if the request should not be authenticated. - * @param {import('express').Request} req + * @param {Request} req * @returns {boolean} * @private */ diff --git a/server/Database.js b/server/Database.js index 9b9f9cf98..9bce26050 100644 --- a/server/Database.js +++ b/server/Database.js @@ -808,28 +808,6 @@ class Database { return `${normalizedColumn} LIKE ${pattern}` } } - - async getLibraryItemCoverPath(libraryItemId) { - const libraryItem = await this.libraryItemModel.findByPk(libraryItemId, { - attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], - include: [ - { - model: this.bookModel, - attributes: ['id', 'coverPath'] - }, - { - model: this.podcastModel, - attributes: ['id', 'coverPath'] - } - ] - }) - if (!libraryItem) { - Logger.warn(`[Database] getCover: Library item "${libraryItemId}" does not exist`) - return null - } - - return libraryItem.media.coverPath - } } module.exports = new Database() diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index f0f5c86d8..1976c34df 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -350,7 +350,7 @@ class LibraryItemController { } if (raw) { - const coverPath = await Database.getLibraryItemCoverPath(libraryItemId) + const coverPath = await Database.libraryItemModel.getCoverPath(libraryItemId) if (!coverPath || !(await fs.pathExists(coverPath))) { return res.sendStatus(404) } diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js index d1b274238..83efae907 100644 --- a/server/managers/CacheManager.js +++ b/server/managers/CacheManager.js @@ -59,7 +59,7 @@ class CacheManager { } // Cached cover does not exist, generate it - const coverPath = await Database.getLibraryItemCoverPath(libraryItemId) + const coverPath = await Database.libraryItemModel.getCoverPath(libraryItemId) if (!coverPath || !(await fs.pathExists(coverPath))) { return res.sendStatus(404) } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index dd07747a9..9815b2169 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -863,6 +863,33 @@ class LibraryItem extends Model { return this.getOldLibraryItem(libraryItem) } + /** + * + * @param {string} libraryItemId + * @returns {Promise} + */ + static async getCoverPath(libraryItemId) { + const libraryItem = await this.findByPk(libraryItemId, { + attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], + include: [ + { + model: this.bookModel, + attributes: ['id', 'coverPath'] + }, + { + model: this.podcastModel, + attributes: ['id', 'coverPath'] + } + ] + }) + if (!libraryItem) { + Logger.warn(`[LibraryItem] getCoverPath: Library item "${libraryItemId}" does not exist`) + return null + } + + return libraryItem.media.coverPath + } + /** * * @param {import('sequelize').FindOptions} options From 7a49681dd205ec36a8b083cdaee1cf8e6c78d1e2 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 2 Nov 2024 13:02:40 -0500 Subject: [PATCH 153/164] Fix includes --- server/models/LibraryItem.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 9815b2169..17c3b1258 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -873,11 +873,11 @@ class LibraryItem extends Model { attributes: ['id', 'mediaType', 'mediaId', 'libraryId'], include: [ { - model: this.bookModel, + model: this.sequelize.models.book, attributes: ['id', 'coverPath'] }, { - model: this.podcastModel, + model: this.sequelize.models.podcast, attributes: ['id', 'coverPath'] } ] From 3bc29414456b60d0653d32b9a3fc72c88c3b2400 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 3 Nov 2024 08:44:57 +0200 Subject: [PATCH 154/164] No db access for author image if in disk cache --- server/controllers/AuthorController.js | 21 ++++++++++++++------- server/managers/CacheManager.js | 17 +++++++++++------ server/routers/ApiRouter.js | 2 +- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 54a641856..45bbdf84b 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -381,16 +381,23 @@ class AuthorController { */ async getImage(req, res) { const { - query: { width, height, format, raw }, - author + query: { width, height, format, raw } } = req - if (!author.imagePath || !(await fs.pathExists(author.imagePath))) { - Logger.warn(`[AuthorController] Author "${author.name}" has invalid imagePath: ${author.imagePath}`) - return res.sendStatus(404) - } + const authorId = req.params.id if (raw) { + const author = await Database.authorModel.findByPk(authorId) + if (!author) { + Logger.warn(`[AuthorController] Author "${authorId}" not found`) + return res.sendStatus(404) + } + + if (!author.imagePath || !(await fs.pathExists(author.imagePath))) { + Logger.warn(`[AuthorController] Author "${author.name}" has invalid imagePath: ${author.imagePath}`) + return res.sendStatus(404) + } + return res.sendFile(author.imagePath) } @@ -399,7 +406,7 @@ class AuthorController { height: height ? parseInt(height) : null, width: width ? parseInt(width) : null } - return CacheManager.handleAuthorCache(res, author, options) + return CacheManager.handleAuthorCache(res, authorId, options) } /** diff --git a/server/managers/CacheManager.js b/server/managers/CacheManager.js index 83efae907..f03756918 100644 --- a/server/managers/CacheManager.js +++ b/server/managers/CacheManager.js @@ -134,22 +134,22 @@ class CacheManager { /** * * @param {import('express').Response} res - * @param {import('../models/Author')} author + * @param {String} authorId * @param {{ format?: string, width?: number, height?: number }} options * @returns */ - async handleAuthorCache(res, author, options = {}) { + async handleAuthorCache(res, authorId, options = {}) { const format = options.format || 'webp' const width = options.width || 400 const height = options.height || null res.type(`image/${format}`) - var path = Path.join(this.ImageCachePath, `${author.id}_${width}${height ? `x${height}` : ''}`) + '.' + format + var cachePath = Path.join(this.ImageCachePath, `${authorId}_${width}${height ? `x${height}` : ''}`) + '.' + format // Cache exists - if (await fs.pathExists(path)) { - const r = fs.createReadStream(path) + if (await fs.pathExists(cachePath)) { + const r = fs.createReadStream(cachePath) const ps = new stream.PassThrough() stream.pipeline(r, ps, (err) => { if (err) { @@ -160,7 +160,12 @@ class CacheManager { return ps.pipe(res) } - let writtenFile = await resizeImage(author.imagePath, path, width, height) + const author = await Database.authorModel.findByPk(authorId) + if (!author || !author.imagePath || !(await fs.pathExists(author.imagePath))) { + return res.sendStatus(404) + } + + let writtenFile = await resizeImage(author.imagePath, cachePath, width, height) if (!writtenFile) return res.sendStatus(500) var readStream = fs.createReadStream(writtenFile) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index f81bc26df..c9399d798 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -216,7 +216,7 @@ class ApiRouter { this.router.patch('/authors/:id', AuthorController.middleware.bind(this), AuthorController.update.bind(this)) this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this)) this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this)) - this.router.get('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.getImage.bind(this)) + this.router.get('/authors/:id/image', AuthorController.getImage.bind(this)) this.router.post('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.uploadImage.bind(this)) this.router.delete('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.deleteImage.bind(this)) From bf8407274e3ee300af1927ee660d078a7a801e1c Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 3 Nov 2024 08:45:43 +0200 Subject: [PATCH 155/164] No auth for author images --- server/Auth.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index da124b72a..5b2d8bcda 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -18,7 +18,7 @@ class Auth { constructor() { // Map of openId sessions indexed by oauth2 state-variable this.openIdAuthSession = new Map() - this.ignorePattern = /\/api\/items\/[^/]+\/cover/ + this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/] } /** @@ -28,7 +28,7 @@ class Auth { * @private */ authNotNeeded(req) { - return req.method === 'GET' && this.ignorePattern.test(req.originalUrl) + return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.originalUrl)) } ifAuthNeeded(middleware) { From 68fd1d67cb867c1deeb0cac772dede2935a63527 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 3 Nov 2024 08:46:09 +0200 Subject: [PATCH 156/164] Remove token from author image URLs --- client/components/covers/AuthorImage.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/covers/AuthorImage.vue b/client/components/covers/AuthorImage.vue index 019263633..e320e552e 100644 --- a/client/components/covers/AuthorImage.vue +++ b/client/components/covers/AuthorImage.vue @@ -56,7 +56,7 @@ export default { }, imgSrc() { if (!this.imagePath) return null - return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}` + return `${this.$config.routerBasePath}/api/authors/${this.authorId}/image?ts=${this.updatedAt}` } }, methods: { From 7ef14aabedae8c4a038943633c6b2c2ec52b067e Mon Sep 17 00:00:00 2001 From: snakehnb Date: Mon, 4 Nov 2024 16:13:14 +0800 Subject: [PATCH 157/164] Avoid parsing first and last names in Chinese, Japanese and Korean languages --- server/utils/parsers/parseNameString.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/utils/parsers/parseNameString.js b/server/utils/parsers/parseNameString.js index 741beb091..4b16b496f 100644 --- a/server/utils/parsers/parseNameString.js +++ b/server/utils/parsers/parseNameString.js @@ -52,6 +52,13 @@ module.exports.parse = (nameString) => { } if (splitNames.length) splitNames = splitNames.map((a) => a.trim()) + // If names are in Chinese,Japanese and Korean languages, return as is. + if (/[\u4e00-\u9fff\u3040-\u30ff\u31f0-\u31ff]/.test(splitNames[0])) { + return { + names: splitNames + } + } + var names = [] // 1 name FIRST LAST From 41fe5373a7096fc050962acb7eaf1522d5fd5d43 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Wed, 6 Nov 2024 22:06:58 -0700 Subject: [PATCH 158/164] Add: check that `migrationsMeta` table is well formed --- server/managers/MigrationManager.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/managers/MigrationManager.js b/server/managers/MigrationManager.js index dc2f9235d..003f8dfa1 100644 --- a/server/managers/MigrationManager.js +++ b/server/managers/MigrationManager.js @@ -191,7 +191,21 @@ class MigrationManager { const queryInterface = this.sequelize.getQueryInterface() let migrationsMetaTableExists = await queryInterface.tableExists(MigrationManager.MIGRATIONS_META_TABLE) + // If the table exists, check that the `version` and `maxVersion` rows exist + if (migrationsMetaTableExists) { + const [{ count }] = await this.sequelize.query("SELECT COUNT(*) as count FROM :migrationsMeta WHERE key IN ('version', 'maxVersion')", { + replacements: { migrationsMeta: MigrationManager.MIGRATIONS_META_TABLE }, + type: Sequelize.QueryTypes.SELECT + }) + if (count < 2) { + Logger.warn(`[MigrationManager] migrationsMeta table exists but is missing 'version' or 'maxVersion' row. Dropping it...`) + await queryInterface.dropTable(MigrationManager.MIGRATIONS_META_TABLE) + migrationsMetaTableExists = false + } + } + if (this.isDatabaseNew && migrationsMetaTableExists) { + Logger.warn(`[MigrationManager] migrationsMeta table already exists. Dropping it...`) // This can happen if database was initialized with force: true await queryInterface.dropTable(MigrationManager.MIGRATIONS_META_TABLE) migrationsMetaTableExists = false From a5ebd89817cf1e22306252ea613190fcf0315a08 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 7 Nov 2024 16:32:05 -0600 Subject: [PATCH 159/164] Update FolderWatcher to singleton --- server/Server.js | 9 ++++----- server/Watcher.js | 2 +- server/controllers/LibraryController.js | 7 ++++--- server/controllers/MiscController.js | 7 ++++--- server/managers/PodcastManager.js | 9 ++++----- server/routers/ApiRouter.js | 2 -- 6 files changed, 17 insertions(+), 19 deletions(-) diff --git a/server/Server.js b/server/Server.js index 58a2079ef..be1294646 100644 --- a/server/Server.js +++ b/server/Server.js @@ -62,7 +62,6 @@ class Server { fs.mkdirSync(global.MetadataPath) } - this.watcher = new Watcher() this.auth = new Auth() // Managers @@ -70,7 +69,7 @@ class Server { this.backupManager = new BackupManager() this.abMergeManager = new AbMergeManager() this.playbackSessionManager = new PlaybackSessionManager() - this.podcastManager = new PodcastManager(this.watcher) + this.podcastManager = new PodcastManager() this.audioMetadataManager = new AudioMetadataMangaer() this.rssFeedManager = new RssFeedManager() this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager) @@ -147,9 +146,9 @@ class Server { if (Database.serverSettings.scannerDisableWatcher) { Logger.info(`[Server] Watcher is disabled`) - this.watcher.disabled = true + Watcher.disabled = true } else { - this.watcher.initWatcher(libraries) + Watcher.initWatcher(libraries) } } @@ -435,7 +434,7 @@ class Server { */ async stop() { Logger.info('=== Stopping Server ===') - await this.watcher.close() + Watcher.close() Logger.info('Watcher Closed') return new Promise((resolve) => { diff --git a/server/Watcher.js b/server/Watcher.js index 0e34fc66b..8c2d652e3 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -409,4 +409,4 @@ class FolderWatcher extends EventEmitter { }, 5000) } } -module.exports = FolderWatcher +module.exports = new FolderWatcher() diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 61ffb5bd2..0bd499f1c 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -17,6 +17,7 @@ const naturalSort = createNewSortInstance({ const LibraryScanner = require('../scanner/LibraryScanner') const Scanner = require('../scanner/Scanner') const Database = require('../Database') +const Watcher = require('../Watcher') const libraryFilters = require('../utils/queries/libraryFilters') const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') const authorFilters = require('../utils/queries/authorFilters') @@ -158,7 +159,7 @@ class LibraryController { SocketAuthority.emitter('library_added', library.toOldJSON(), userFilter) // Add library watcher - this.watcher.addLibrary(library) + Watcher.addLibrary(library) res.json(library.toOldJSON()) } @@ -440,7 +441,7 @@ class LibraryController { req.library.libraryFolders = await req.library.getLibraryFolders() // Update watcher - this.watcher.updateLibrary(req.library) + Watcher.updateLibrary(req.library) hasUpdates = true } @@ -466,7 +467,7 @@ class LibraryController { */ async delete(req, res) { // Remove library watcher - this.watcher.removeLibrary(req.library) + Watcher.removeLibrary(req.library) // Remove collections for library const numCollectionsRemoved = await Database.collectionModel.removeAllForLibrary(req.library.id) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index f3dd0c6d4..cf901bea0 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -5,6 +5,7 @@ const fs = require('../libs/fsExtra') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') +const Watcher = require('../Watcher') const libraryItemFilters = require('../utils/queries/libraryItemFilters') const patternValidation = require('../libs/nodeCron/pattern-validation') @@ -557,10 +558,10 @@ class MiscController { switch (type) { case 'add': - this.watcher.onFileAdded(libraryId, path) + Watcher.onFileAdded(libraryId, path) break case 'unlink': - this.watcher.onFileRemoved(libraryId, path) + Watcher.onFileRemoved(libraryId, path) break case 'rename': const oldPath = req.body.oldPath @@ -568,7 +569,7 @@ class MiscController { Logger.error(`[MiscController] Invalid request body for updateWatchedPath. oldPath is required for rename.`) return res.sendStatus(400) } - this.watcher.onFileRename(libraryId, oldPath, path) + Watcher.onFileRename(libraryId, oldPath, path) break default: Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`) diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 503f47c0c..f9eb72e4f 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -1,6 +1,7 @@ const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') +const Watcher = require('../Watcher') const fs = require('../libs/fsExtra') @@ -23,9 +24,7 @@ const AudioFile = require('../objects/files/AudioFile') const LibraryItem = require('../objects/LibraryItem') class PodcastManager { - constructor(watcher) { - this.watcher = watcher - + constructor() { this.downloadQueue = [] this.currentDownload = null @@ -97,7 +96,7 @@ class PodcastManager { } // Ignores all added files to this dir - this.watcher.addIgnoreDir(this.currentDownload.libraryItem.path) + Watcher.addIgnoreDir(this.currentDownload.libraryItem.path) // Make sure podcast library item folder exists if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) { @@ -151,7 +150,7 @@ class PodcastManager { SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient()) SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails()) - this.watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) + Watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) this.currentDownload = null if (this.downloadQueue.length) { this.startPodcastEpisodeDownload(this.downloadQueue.shift()) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index c9399d798..7f21c3ac5 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -45,8 +45,6 @@ class ApiRouter { this.abMergeManager = Server.abMergeManager /** @type {import('../managers/BackupManager')} */ this.backupManager = Server.backupManager - /** @type {import('../Watcher')} */ - this.watcher = Server.watcher /** @type {import('../managers/PodcastManager')} */ this.podcastManager = Server.podcastManager /** @type {import('../managers/AudioMetadataManager')} */ From 850ed4895577e5ed073556e4dd93f08f1a028bf7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 7 Nov 2024 17:26:51 -0600 Subject: [PATCH 160/164] Fix:Podcast episodes duplicated when a scan runs while the episode is downloading #2785 --- server/Server.js | 3 +++ server/Watcher.js | 22 ++++++++++++++++++++-- server/managers/PodcastManager.js | 3 +++ server/objects/PodcastEpisodeDownload.js | 9 +++++---- server/scanner/LibraryItemScanner.js | 9 +++++++++ 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/server/Server.js b/server/Server.js index be1294646..e40e7c574 100644 --- a/server/Server.js +++ b/server/Server.js @@ -149,6 +149,9 @@ class Server { Watcher.disabled = true } else { Watcher.initWatcher(libraries) + Watcher.on('scanFilesChanged', (pendingFileUpdates, pendingTask) => { + LibraryScanner.scanFilesChanged(pendingFileUpdates, pendingTask) + }) } } diff --git a/server/Watcher.js b/server/Watcher.js index 8c2d652e3..85c13e2a4 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -2,7 +2,6 @@ const Path = require('path') const EventEmitter = require('events') const Watcher = require('./libs/watcher/watcher') const Logger = require('./Logger') -const LibraryScanner = require('./scanner/LibraryScanner') const Task = require('./objects/Task') const TaskManager = require('./managers/TaskManager') @@ -31,6 +30,8 @@ class FolderWatcher extends EventEmitter { this.filesBeingAdded = new Set() + /** @type {Set} */ + this.ignoreFilePathsDownloading = new Set() /** @type {string[]} */ this.ignoreDirs = [] /** @type {string[]} */ @@ -333,7 +334,7 @@ class FolderWatcher extends EventEmitter { } if (this.pendingFileUpdates.length) { - LibraryScanner.scanFilesChanged(this.pendingFileUpdates, this.pendingTask) + this.emit('scanFilesChanged', this.pendingFileUpdates, this.pendingTask) } else { const taskFinishedString = { text: 'No files to scan', @@ -348,12 +349,29 @@ class FolderWatcher extends EventEmitter { }, this.pendingDelay) } + /** + * + * @param {string} path + * @returns {boolean} + */ checkShouldIgnorePath(path) { return !!this.ignoreDirs.find((dirpath) => { return isSameOrSubPath(dirpath, path) }) } + /** + * When scanning a library item folder these files should be ignored + * Either a podcast episode downloading or a file that is pending by the watcher + * + * @param {string} path + * @returns {boolean} + */ + checkShouldIgnoreFilePath(path) { + if (this.pendingFilePaths.includes(path)) return true + return this.ignoreFilePathsDownloading.has(path) + } + /** * Convert to POSIX and remove trailing slash * @param {string} path diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index f9eb72e4f..96ffcb6a4 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -97,6 +97,7 @@ class PodcastManager { // Ignores all added files to this dir Watcher.addIgnoreDir(this.currentDownload.libraryItem.path) + Watcher.ignoreFilePathsDownloading.add(this.currentDownload.targetPath) // Make sure podcast library item folder exists if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) { @@ -151,6 +152,8 @@ class PodcastManager { SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails()) Watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) + + Watcher.ignoreFilePathsDownloading.delete(this.currentDownload.targetPath) this.currentDownload = null if (this.downloadQueue.length) { this.startPodcastEpisodeDownload(this.downloadQueue.shift()) diff --git a/server/objects/PodcastEpisodeDownload.js b/server/objects/PodcastEpisodeDownload.js index 2dfdc52e7..86a858016 100644 --- a/server/objects/PodcastEpisodeDownload.js +++ b/server/objects/PodcastEpisodeDownload.js @@ -1,6 +1,6 @@ const Path = require('path') -const uuidv4 = require("uuid").v4 -const { sanitizeFilename } = require('../utils/fileUtils') +const uuidv4 = require('uuid').v4 +const { sanitizeFilename, filePathToPOSIX } = require('../utils/fileUtils') const globals = require('../utils/globals') class PodcastEpisodeDownload { @@ -60,7 +60,7 @@ class PodcastEpisodeDownload { return sanitizeFilename(filename) } get targetPath() { - return Path.join(this.libraryItem.path, this.targetFilename) + return filePathToPOSIX(Path.join(this.libraryItem.path, this.targetFilename)) } get targetRelPath() { return this.targetFilename @@ -74,7 +74,8 @@ class PodcastEpisodeDownload { this.podcastEpisode = podcastEpisode const url = podcastEpisode.enclosure.url - if (decodeURIComponent(url) !== url) { // Already encoded + if (decodeURIComponent(url) !== url) { + // Already encoded this.url = url } else { this.url = encodeURI(url) diff --git a/server/scanner/LibraryItemScanner.js b/server/scanner/LibraryItemScanner.js index 38608e479..5edfc2e2b 100644 --- a/server/scanner/LibraryItemScanner.js +++ b/server/scanner/LibraryItemScanner.js @@ -4,7 +4,9 @@ const { LogLevel, ScanResult } = require('../utils/constants') const fileUtils = require('../utils/fileUtils') const scanUtils = require('../utils/scandir') const libraryFilters = require('../utils/queries/libraryFilters') +const Logger = require('../Logger') const Database = require('../Database') +const Watcher = require('../Watcher') const LibraryScan = require('./LibraryScan') const LibraryItemScanData = require('./LibraryItemScanData') const BookScanner = require('./BookScanner') @@ -128,6 +130,13 @@ class LibraryItemScanner { const libraryFiles = [] for (let i = 0; i < fileItems.length; i++) { const fileItem = fileItems[i] + + if (Watcher.checkShouldIgnoreFilePath(fileItem.fullpath)) { + // Skip file if it's pending + Logger.info(`[LibraryItemScanner] Skipping watcher pending file "${fileItem.fullpath}" during scan of library item path "${libraryItemPath}"`) + continue + } + const newLibraryFile = new LibraryFile() // fileItem.path is the relative path await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path) From d7e810fc2f873e05eedb8ec41919656e21496412 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 8 Nov 2024 08:04:50 -0600 Subject: [PATCH 161/164] Update readme localization chart to for web client only --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 44551acb7..e2d1f1076 100644 --- a/readme.md +++ b/readme.md @@ -114,7 +114,7 @@ server { proxy_pass http://; proxy_redirect http:// https://; - # Prevent 413 Request Entity Too Large error + # Prevent 413 Request Entity Too Large error # by increasing the maximum allowed size of the client request body # For example, set it to 10 GiB client_max_body_size 10240M; @@ -339,7 +339,7 @@ This application is built using [NodeJs](https://nodejs.org/). ### Localization -Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations). Translation status +Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations). Translation status ### Dev Container Setup From 1fa67535f9c88466aab8da662f40c7f136fe5e49 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 8 Nov 2024 11:20:02 -0700 Subject: [PATCH 162/164] Update: only run CodeQL and Integration actions if code changed --- .github/workflows/codeql.yml | 77 +++++++++++++++----------- .github/workflows/integration-test.yml | 7 +++ 2 files changed, 52 insertions(+), 32 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a77ab3e0a..8d43311b7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,11 +1,25 @@ -name: "CodeQL" +name: 'CodeQL' on: push: - branches: [ 'master' ] + branches: ['master'] + # Only build when files in these directories have been changed + paths: + - client/** + - server/** + - test/** + - index.js + - package.json pull_request: # The branches below must be a subset of the branches above - branches: [ 'master' ] + branches: ['master'] + # Only build when files in these directories have been changed + paths: + - client/** + - server/** + - test/** + - index.js + - package.json schedule: - cron: '16 5 * * 4' @@ -21,45 +35,44 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'javascript' ] + language: ['javascript'] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Use only 'java' to analyze code written in Java, Kotlin or both # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout repository + uses: actions/checkout@v3 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: '/language:${{matrix.language}}' diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 3e499468e..580c0f500 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -5,6 +5,13 @@ on: push: branches-ignore: - 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests + # Only build when files in these directories have been changed + paths: + - client/** + - server/** + - test/** + - index.js + - package.json jobs: build: From 2e970cbb3984e4f65a214993c66474e2800e5803 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 9 Nov 2024 18:03:50 -0600 Subject: [PATCH 163/164] Fix:Series Progress filters incorrect - showing for any users progress #2923 --- server/utils/queries/seriesFilters.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/utils/queries/seriesFilters.js b/server/utils/queries/seriesFilters.js index 06ca25479..c293f1dfb 100644 --- a/server/utils/queries/seriesFilters.js +++ b/server/utils/queries/seriesFilters.js @@ -73,15 +73,19 @@ module.exports = { userPermissionBookWhere.replacements.filterValue = filterValue } else if (filterGroup === 'progress') { if (filterValue === 'not-finished') { - attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' + userPermissionBookWhere.replacements.userId = user.id } else if (filterValue === 'finished') { - const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' + const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)' seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0)) + userPermissionBookWhere.replacements.userId = user.id } else if (filterValue === 'not-started') { - const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)' + const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)' seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0)) + userPermissionBookWhere.replacements.userId = user.id } else if (filterValue === 'in-progress') { - attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0' + attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id AND mp.userId = :userId WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0' + userPermissionBookWhere.replacements.userId = user.id } } From a38248217303413fd9bb14bd96c79801b43c60b2 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 10 Nov 2024 08:34:47 +0200 Subject: [PATCH 164/164] Add in-memory user cache --- server/Auth.js | 30 ++++------- server/models/User.js | 112 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 117 insertions(+), 25 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 5b2d8bcda..b0046799b 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -990,28 +990,18 @@ class Auth { }) } } - - Database.userModel - .update( - { - pash: pw - }, - { - where: { id: matchingUser.id } - } - ) - .then(() => { - Logger.info(`[Auth] User "${matchingUser.username}" changed password`) - res.json({ - success: true - }) + try { + await matchingUser.update({ pash: pw }) + Logger.info(`[Auth] User "${matchingUser.username}" changed password`) + res.json({ + success: true }) - .catch((error) => { - Logger.error(`[Auth] User "${matchingUser.username}" failed to change password`, error) - res.json({ - error: 'Unknown error' - }) + } catch (error) { + Logger.error(`[Auth] User "${matchingUser.username}" failed to change password`, error) + res.json({ + error: 'Unknown error' }) + } } } diff --git a/server/models/User.js b/server/models/User.js index 906a7d68e..259f841ca 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -3,6 +3,53 @@ const sequelize = require('sequelize') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const { isNullOrNaN } = require('../utils') +const { LRUCache } = require('lru-cache') + +class UserCache { + constructor() { + this.cache = new LRUCache({ max: 100 }) + } + + getById(id) { + const user = this.cache.get(id) + return user + } + + getByEmail(email) { + const user = this.cache.find((u) => u.email === email) + return user + } + + getByUsername(username) { + const user = this.cache.find((u) => u.username === username) + return user + } + + getByOldId(oldUserId) { + const user = this.cache.find((u) => u.extraData?.oldUserId === oldUserId) + return user + } + + getByOpenIDSub(sub) { + const user = this.cache.find((u) => u.extraData?.authOpenIDSub === sub) + return user + } + + set(user) { + user.fromCache = true + this.cache.set(user.id, user) + } + + delete(userId) { + this.cache.delete(userId) + } + + maybeInvalidate(user) { + if (!user.fromCache) this.delete(user.id) + } +} + +const userCache = new UserCache() const { DataTypes, Model } = sequelize @@ -206,7 +253,11 @@ class User extends Model { */ static async getUserByUsername(username) { if (!username) return null - return this.findOne({ + + const cachedUser = userCache.getByUsername(username) + if (cachedUser) return cachedUser + + const user = await this.findOne({ where: { username: { [sequelize.Op.like]: username @@ -214,6 +265,10 @@ class User extends Model { }, include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -223,7 +278,11 @@ class User extends Model { */ static async getUserByEmail(email) { if (!email) return null - return this.findOne({ + + const cachedUser = userCache.getByEmail(email) + if (cachedUser) return cachedUser + + const user = await this.findOne({ where: { email: { [sequelize.Op.like]: email @@ -231,6 +290,10 @@ class User extends Model { }, include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -240,9 +303,17 @@ class User extends Model { */ static async getUserById(userId) { if (!userId) return null - return this.findByPk(userId, { + + const cachedUser = userCache.getById(userId) + if (cachedUser) return cachedUser + + const user = await this.findByPk(userId, { include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -254,12 +325,19 @@ class User extends Model { */ static async getUserByIdOrOldId(userId) { if (!userId) return null - return this.findOne({ + const cachedUser = userCache.getById(userId) || userCache.getByOldId(userId) + if (cachedUser) return cachedUser + + const user = await this.findOne({ where: { [sequelize.Op.or]: [{ id: userId }, { 'extraData.oldUserId': userId }] }, include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -269,10 +347,18 @@ class User extends Model { */ static async getUserByOpenIDSub(sub) { if (!sub) return null - return this.findOne({ + + const cachedUser = userCache.getByOpenIDSub(sub) + if (cachedUser) return cachedUser + + const user = await this.findOne({ where: sequelize.where(sequelize.literal(`extraData->>"authOpenIDSub"`), sub), include: this.sequelize.models.mediaProgress }) + + if (user) userCache.set(user) + + return user } /** @@ -623,6 +709,7 @@ class User extends Model { mediaProgress = await this.sequelize.models.mediaProgress.create(newMediaProgressPayload) this.mediaProgresses.push(mediaProgress) } + userCache.maybeInvalidate(this) return { mediaProgress } @@ -804,6 +891,21 @@ class User extends Model { return hasUpdates } + + async update(values, options) { + userCache.maybeInvalidate(this) + return await super.update(values, options) + } + + async save(options) { + userCache.maybeInvalidate(this) + return await super.save(options) + } + + async destroy(options) { + userCache.delete(this.id) + await super.destroy(options) + } } module.exports = User