From 2711b989e153669aaf869521ebbc0e9f006834ef Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 13 Sep 2024 16:55:48 -0700 Subject: [PATCH 001/282] 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 0000000000..e7201bae1c --- /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 c57a1a116f..731908e9ca 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 0000000000..6d55b62932 --- /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/282] 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 2e3c295af1..bac3ec25e5 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/282] 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 6d55b62932..ed950a01f0 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/282] 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 ed950a01f0..d98fb4f79c 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/282] 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 d98fb4f79c..19dc4a8e45 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/282] 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 19dc4a8e45..5ce5a465f0 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/282] 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 e7201bae1c..21e4d4d5dd 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/282] 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 21e4d4d5dd..8c97758821 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 5ce5a465f0..2fd59eeb4a 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/282] 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 2fd59eeb4a..3c5b1b0407 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/282] 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 8c97758821..1860772d2a 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/282] 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 706e359cf1..beaf8a4d8c 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 1860772d2a..2724221aba 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/282] 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 ae94cd75cc..af2e9da8f0 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/282] 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 bac3ec25e5..3ab52ac3ef 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 2724221aba..489b670bb8 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 3c5b1b0407..43acc9278b 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 0d31d20f0f335cc7fde10ee938c2cae5a8f7ecc9 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Tue, 24 Sep 2024 23:00:19 +0000 Subject: [PATCH 014/282] Center align player chapter title --- client/components/player/PlayerUi.vue | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue index 968b0ea1f2..c3bb4cdacf 100644 --- a/client/components/player/PlayerUi.vue +++ b/client/components/player/PlayerUi.vue @@ -48,15 +48,19 @@ -
-

00:00:00

- -
-

- {{ currentChapterName }}  ({{ $getString('LabelPlayerChapterNumberMarker', [currentChapterIndex + 1, chapters.length]) }}) -

-
-

{{ timeRemainingPretty }}

+
+
+

00:00:00

+ +
+
+

+ {{ currentChapterName }}  ({{ $getString('LabelPlayerChapterNumberMarker', [currentChapterIndex + 1, chapters.length]) }}) +

+
+
+

{{ timeRemainingPretty }}

+
From b2d41f05839c0b12cedefc305239e760c217ab14 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Tue, 24 Sep 2024 23:17:26 +0000 Subject: [PATCH 015/282] Move playback speed control next to player volume control --- .../player/PlayerPlaybackControls.vue | 83 +++++++------------ client/components/player/PlayerUi.vue | 8 ++ 2 files changed, 40 insertions(+), 51 deletions(-) diff --git a/client/components/player/PlayerPlaybackControls.vue b/client/components/player/PlayerPlaybackControls.vue index 39ce4f3c40..1b96a24b23 100644 --- a/client/components/player/PlayerPlaybackControls.vue +++ b/client/components/player/PlayerPlaybackControls.vue @@ -1,38 +1,37 @@ @@ -41,7 +40,6 @@ export default { props: { loading: Boolean, seekLoading: Boolean, - playbackRate: Number, paused: Boolean, hasNextChapter: Boolean, hasNextItemInQueue: Boolean @@ -50,14 +48,6 @@ export default { return {} }, computed: { - playbackRateInput: { - get() { - return this.playbackRate - }, - set(val) { - this.$emit('update:playbackRate', val) - } - }, jumpForwardText() { return this.getJumpText('jumpForwardAmount', this.$strings.ButtonJumpForward) }, @@ -89,15 +79,6 @@ export default { jumpForward() { this.$emit('jumpForward') }, - playbackRateUpdated(playbackRate) { - this.$emit('setPlaybackRate', playbackRate) - }, - playbackRateChanged(playbackRate) { - this.$emit('setPlaybackRate', playbackRate) - this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => { - console.error('Failed to update settings', err) - }) - }, getJumpText(setting, prefix) { const amount = this.$store.getters['user/getUserSetting'](setting) if (!amount) return prefix diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue index c3bb4cdacf..e341da4a3f 100644 --- a/client/components/player/PlayerUi.vue +++ b/client/components/player/PlayerUi.vue @@ -4,6 +4,8 @@
+ + @@ -228,6 +230,12 @@ export default { this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1)) this.setPlaybackRate(this.playbackRate) }, + playbackRateChanged(playbackRate) { + this.setPlaybackRate(playbackRate) + this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => { + console.error('Failed to update settings', err) + }) + }, setPlaybackRate(playbackRate) { this.$emit('setPlaybackRate', playbackRate) }, From 0bc58c254f5ae1df66811b66b682eceb6489f236 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 25 Sep 2024 16:49:24 -0500 Subject: [PATCH 016/282] Update playback speed to no longer use font-mono, adjust position of popup --- .../components/controls/PlaybackSpeedControl.vue | 16 ++++++++-------- client/components/player/PlayerUi.vue | 4 +--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/client/components/controls/PlaybackSpeedControl.vue b/client/components/controls/PlaybackSpeedControl.vue index c4cc29010f..9e9f0d54c9 100644 --- a/client/components/controls/PlaybackSpeedControl.vue +++ b/client/components/controls/PlaybackSpeedControl.vue @@ -1,9 +1,9 @@ - @@ -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 918bf68560..8eb375500d 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 c7abbc2327..cc67b320d7 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 8bd3f742d6..906a7d68e7 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 57067ad8bb..f81bc26df5 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 167/282] 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 cd15ec677e..92636a1d49 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 168/282] 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 9b429f5e66..3f028f574f 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 169/282] 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 d9410d7a4b..dbd8bbc6ec 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 170/282] 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 aad7f30156..7a246e3d0d 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 171/282] 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 3f028f574f..f1c53efc5d 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 172/282] 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 7a246e3d0d..0fb0b5fa48 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 173/282] 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 66d5bf37e8..dadb787b03 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 174/282] 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 0fb0b5fa48..8c9499d73c 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 175/282] 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 37bf38fc8d..d92d833a57 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 176/282] 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 f1c53efc5d..7a898a3708 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 177/282] 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 37c48d8b6d..f0c167377d 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 178/282] 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 92636a1d49..54a7ce02db 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 179/282] 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 8c9499d73c..bc5a40ca25 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 180/282] 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 57bb577a01..85c7b769b1 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 181/282] 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 d92d833a57..1754bf5642 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 182/282] 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 dadb787b03..d07696f00d 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 183/282] 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 d850276660..e82b551440 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 9b9baf0ad0..441af9afd8 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 e17041a4ba..15c1622141 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 26ab93db7d..723cafe066 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 184/282] Add proper error handing for file downloads --- server/controllers/LibraryItemController.js | 59 +++++++++++++++------ 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index fe8539bc32..0b4d3d0cd2 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}"`) + + 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] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`) - res.download(libraryItemPath, req.libraryItem.relPath) - return + 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) } - - Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`) - const filename = `${itemTitle}.zip` - zipHelpers.zipDirectoryPipe(libraryItemPath, filename, 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 185/282] 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 82fd34f07d..61ffb5bd2d 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 186/282] 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 79852a9cd9..0590ab604c 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 187/282] 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 0590ab604c..8ab6be9e99 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 188/282] 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 04002ebae6..d27f138f8b 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 189/282] 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 d07696f00d..b4e4383fd0 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 190/282] 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 f0c167377d..a427c2885b 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 191/282] 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 54a7ce02db..d7d0fde54e 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 192/282] 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 1754bf5642..2830a7101a 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 193/282] 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 e82b551440..f69dab4d78 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 441af9afd8..703d7f661c 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 15c1622141..189781ba9a 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 723cafe066..09c7971156 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 194/282] 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 0b4d3d0cd2..a51a6e062e 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 195/282] 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 33a3ccd238..ce43fc8c41 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 196/282] 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 3674acc303..d31c59714c 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 197/282] 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 f69dab4d78..f31266cbf2 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 703d7f661c..2feb833b25 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 189781ba9a..6e3276cea2 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 09c7971156..d31f202202 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 198/282] 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 68e012fb87..b8d428a233 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 199/282] 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 cb46895a59..58796fded1 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 200/282] '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 877cf07a08..5a5bd9513e 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 201/282] 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 eb1484bb6c..7fc17e7aae 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 d89fbbbd6b..90c40b8c44 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 cbfe65f207..dd52e2e1b2 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 202/282] add Database.getLibraryItemCoverPath --- server/Database.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/server/Database.js b/server/Database.js index 9bce260503..9b9f9cf987 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 203/282] Optimize LibraryItemController.getCover --- server/controllers/LibraryItemController.js | 41 ++++++--------------- server/managers/CacheManager.js | 19 +++++++--- 2 files changed, 24 insertions(+), 36 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 0b4d3d0cd2..f0f5c86d84 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) - } + if (req.query.ts) res.set('Cache-Control', 'private, max-age=86400') - // Check if library item media has a cover path - if (!libraryItem.media.coverPath || !(await fs.pathExists(libraryItem.media.coverPath))) { - return res.sendStatus(404) + const libraryItemId = req.params.id + if (!libraryItemId) { + return res.sendStatus(400) } - if (req.query.ts) res.set('Cache-Control', 'private, max-age=86400') - 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 b4d2f270c2..d1b274238e 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 204/282] 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 60af2a1e05..6e5a46212f 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 d82652376a..58a2079ef5 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 205/282] 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 c0e7d78854..65878fb445 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 206/282] 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 6e5a46212f..da124b72a9 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 9b9f9cf987..9bce260503 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 f0f5c86d84..1976c34df9 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 d1b274238e..83efae9075 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 dd07747a91..9815b2169a 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 207/282] 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 9815b2169a..17c3b12586 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 208/282] 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 54a6418563..45bbdf84bd 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 83efae9075..f03756918a 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 f81bc26df5..c9399d7987 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 209/282] 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 da124b72a9..5b2d8bcda4 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 210/282] 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 0192636339..e320e552e8 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 211/282] 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 741beb0911..4b16b496f1 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 212/282] 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 dc2f9235df..003f8dfa11 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 213/282] 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 58a2079ef5..be12946468 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 0e34fc66bf..8c2d652e32 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 61ffb5bd2d..0bd499f1cf 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 f3dd0c6d40..cf901bea03 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 503f47c0ce..f9eb72e4f0 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 c9399d7987..7f21c3ac51 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 214/282] 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 be12946468..e40e7c5748 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 8c2d652e32..85c13e2a49 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 f9eb72e4f0..96ffcb6a46 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 2dfdc52e77..86a8580164 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 38608e479f..5edfc2e2b6 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 215/282] 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 44551acb7a..e2d1f10763 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 435b7fda7e8ddc3ef413a4e583ea09f91627d485 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 8 Nov 2024 09:09:18 -0700 Subject: [PATCH 216/282] Add: check for changes to library items --- server/utils/queries/libraryFilters.js | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 34c3fe544b..f66df56872 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -485,6 +485,60 @@ module.exports = { } } } else { + // To reduce the cold-start load time, first check if any library items, series, + // or authors have had an "updatedAt" timestamp since the last time the filter + // data was loaded. If so, we can skip loading all of the data. + // Because many items could change, just check the count of items. + const lastLoadedAt = cachedFilterData ? cachedFilterData.loadedAt : 0 + + const changedBooks = await Database.bookModel.count({ + include: { + model: Database.libraryItemModel, + attributes: [], + where: { + libraryId: libraryId, + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + } + }, + where: { + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + }, + limit: 1 + }) + + const changedSeries = await Database.seriesModel.count({ + where: { + libraryId: libraryId, + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + }, + limit: 1 + }) + + const changedAuthors = await Database.authorModel.count({ + where: { + libraryId: libraryId, + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + }, + limit: 1 + }) + + if (changedBooks + changedSeries + changedAuthors === 0) { + // If nothing has changed, update the cache to current time for 30 minute + // cache time and return the cached data + Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) + Database.libraryFilterData[libraryId].loadedAt = Date.now() + return cachedFilterData + } + + // Something has changed in one of the tables, so reload all of the filter data for library const books = await Database.bookModel.findAll({ include: { model: Database.libraryItemModel, From e57d4cc54435dfa651a7de30a1a4b0a8fcd8d926 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 8 Nov 2024 09:33:34 -0700 Subject: [PATCH 217/282] Add: filter update check to podcast libraries --- server/utils/queries/libraryFilters.js | 41 ++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index f66df56872..2be415e252 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -462,7 +462,42 @@ module.exports = { numIssues: 0 } + const lastLoadedAt = cachedFilterData ? cachedFilterData.loadedAt : 0 + if (mediaType === 'podcast') { + // To reduce the cold-start load time, first check if any podcasts + // have an "updatedAt" timestamp since the last time the filter + // data was loaded. If so, we can skip loading all of the data. + // Because many items could change, just check the count of items instead + // of actually loading the data twice + const changedPodcasts = await Database.podcastModel.count({ + include: { + model: Database.libraryItemModel, + attributes: [], + where: { + libraryId: libraryId, + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + } + }, + where: { + updatedAt: { + [Sequelize.Op.gt]: new Date(lastLoadedAt) + } + }, + limit: 1 + }) + + if (changedPodcasts === 0) { + // If nothing has changed, update the cache to current time for 30 minute + // cache time and return the cached data + Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) + Database.libraryFilterData[libraryId].loadedAt = Date.now() + return cachedFilterData + } + + // Something has changed in the podcasts table, so reload all of the filter data for library const podcasts = await Database.podcastModel.findAll({ include: { model: Database.libraryItemModel, @@ -486,10 +521,10 @@ module.exports = { } } else { // To reduce the cold-start load time, first check if any library items, series, - // or authors have had an "updatedAt" timestamp since the last time the filter + // or authors have an "updatedAt" timestamp since the last time the filter // data was loaded. If so, we can skip loading all of the data. - // Because many items could change, just check the count of items. - const lastLoadedAt = cachedFilterData ? cachedFilterData.loadedAt : 0 + // Because many items could change, just check the count of items instead + // of actually loading the data twice const changedBooks = await Database.bookModel.count({ include: { From e8d8b67c0aa170f5b0fe4fe8c5996a00b49bbdc0 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 8 Nov 2024 10:49:12 -0700 Subject: [PATCH 218/282] Add: check for deleted items --- server/utils/queries/libraryFilters.js | 72 ++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 2be415e252..64ad07ee08 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -459,12 +459,29 @@ module.exports = { languages: new Set(), publishers: new Set(), publishedDecades: new Set(), + bookCount: 0, // How many books returned from database query + authorCount: 0, // How many authors returned from database query + seriesCount: 0, // How many series returned from database query + podcastCount: 0, // How many podcasts returned from database query numIssues: 0 } const lastLoadedAt = cachedFilterData ? cachedFilterData.loadedAt : 0 if (mediaType === 'podcast') { + // Check how many podcasts are in library to determine if we need to load all of the data + // This is done to handle the edge case of podcasts having been deleted and not having + // an updatedAt timestamp to trigger a reload of the filter data + const podcastCountFromDatabase = await Database.podcastModel.count({ + include: { + model: Database.libraryItemModel, + attributes: [], + where: { + libraryId: libraryId + } + } + }) + // To reduce the cold-start load time, first check if any podcasts // have an "updatedAt" timestamp since the last time the filter // data was loaded. If so, we can skip loading all of the data. @@ -490,11 +507,14 @@ module.exports = { }) if (changedPodcasts === 0) { - // If nothing has changed, update the cache to current time for 30 minute - // cache time and return the cached data - Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) - Database.libraryFilterData[libraryId].loadedAt = Date.now() - return cachedFilterData + // If nothing has changed, check if the number of podcasts in + // library is still the same as prior check before updating cache creation time + + if (podcastCountFromDatabase === Database.libraryFilterData[libraryId].podcastCount) { + Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) + Database.libraryFilterData[libraryId].loadedAt = Date.now() + return cachedFilterData + } } // Something has changed in the podcasts table, so reload all of the filter data for library @@ -519,7 +539,32 @@ module.exports = { data.languages.add(podcast.language) } } + + // Set podcast count for later comparison + data.podcastCount = podcastCountFromDatabase } else { + const bookCountFromDatabase = await Database.bookModel.count({ + include: { + model: Database.libraryItemModel, + attributes: [], + where: { + libraryId: libraryId + } + } + }) + + const seriesCountFromDatabase = await Database.seriesModel.count({ + where: { + libraryId: libraryId + } + }) + + const authorCountFromDatabase = await Database.authorModel.count({ + where: { + libraryId: libraryId + } + }) + // To reduce the cold-start load time, first check if any library items, series, // or authors have an "updatedAt" timestamp since the last time the filter // data was loaded. If so, we can skip loading all of the data. @@ -566,13 +611,20 @@ module.exports = { }) if (changedBooks + changedSeries + changedAuthors === 0) { - // If nothing has changed, update the cache to current time for 30 minute - // cache time and return the cached data - Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) - Database.libraryFilterData[libraryId].loadedAt = Date.now() - return cachedFilterData + // If nothing has changed, check if the number of authors, series, and books + // matches the prior check before updating cache creation time + if (bookCountFromDatabase === Database.libraryFilterData[libraryId].bookCount && seriesCountFromDatabase === Database.libraryFilterData[libraryId].seriesCount && authorCountFromDatabase === Database.libraryFilterData[libraryId].authorCount) { + Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) + Database.libraryFilterData[libraryId].loadedAt = Date.now() + return cachedFilterData + } } + // Store the counts for later comparison + data.bookCount = bookCountFromDatabase + data.seriesCount = seriesCountFromDatabase + data.authorCount = authorCountFromDatabase + // Something has changed in one of the tables, so reload all of the filter data for library const books = await Database.bookModel.findAll({ include: { From 1fa67535f9c88466aab8da662f40c7f136fe5e49 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Fri, 8 Nov 2024 11:20:02 -0700 Subject: [PATCH 219/282] 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 a77ab3e0ab..8d43311b7f 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 3e499468ee..580c0f500a 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 713bdcbc419b6b5db9af68effd3b0984be93aa41 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 9 Nov 2024 13:10:46 -0700 Subject: [PATCH 220/282] Add: migration for mediaId to use UUID instead of UUIDV4 --- server/migrations/v2.16.3-uuid-replacement.js | 50 +++++++++++++++++++ server/models/LibraryItem.js | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 server/migrations/v2.16.3-uuid-replacement.js diff --git a/server/migrations/v2.16.3-uuid-replacement.js b/server/migrations/v2.16.3-uuid-replacement.js new file mode 100644 index 0000000000..66bf21ac98 --- /dev/null +++ b/server/migrations/v2.16.3-uuid-replacement.js @@ -0,0 +1,50 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script changes the `mediaId` column in the `libraryItems` table to be a UUID and match other tables. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('[2.16.3 migration] UPGRADE BEGIN: 2.16.3-uuid-replacement') + + // Change mediaId column to using the query interface + logger.info('[2.16.3 migration] Changing mediaId column to UUID') + await queryInterface.changeColumn('libraryItems', 'mediaId', { + type: 'UUID' + }) + + // Completed migration + logger.info('[2.16.3 migration] UPGRADE END: 2.16.3-uuid-replacement') +} + +/** + * This downward migration script changes the `mediaId` column in the `libraryItems` table to be a UUIDV4 again. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('[2.16.3 migration] DOWNGRADE BEGIN: 2.16.3-uuid-replacement') + + // Change mediaId column to using the query interface + logger.info('[2.16.3 migration] Changing mediaId column to UUIDV4') + await queryInterface.changeColumn('libraryItems', 'mediaId', { + type: 'UUIDV4' + }) + + // Completed migration + logger.info('[2.16.3 migration] DOWNGRADE END: 2.16.3-uuid-replacement') +} + +module.exports = { up, down } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 17c3b12586..c2a0178584 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -1059,7 +1059,7 @@ class LibraryItem extends Model { ino: DataTypes.STRING, path: DataTypes.STRING, relPath: DataTypes.STRING, - mediaId: DataTypes.UUIDV4, + mediaId: DataTypes.UUID, mediaType: DataTypes.STRING, isFile: DataTypes.BOOLEAN, isMissing: DataTypes.BOOLEAN, From 161a3f4da925821d1f2ff41e0d9954291588a308 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 9 Nov 2024 13:20:59 -0700 Subject: [PATCH 221/282] Update migrations changelog for 2.16.3 --- server/migrations/changelog.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 3623300f4c..bffd4682d6 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,8 +2,9 @@ Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. -| Server Version | Migration Script Name | Description | -| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | -| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | -| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | -| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| Server Version | Migration Script Name | Description | +| -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------- | +| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | +| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | +| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| v2.16.3 | v2.16.3-uuid-replacement | Changes `mediaId` column in `libraryItem` table to match the primary key type of `books` and `podcasts` | From 2e970cbb3984e4f65a214993c66474e2800e5803 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 9 Nov 2024 18:03:50 -0600 Subject: [PATCH 222/282] 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 06ca254793..c293f1dfb0 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 223/282] 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 5b2d8bcda4..b0046799b9 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 906a7d68e7..259f841ca0 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 From 0d54b571517343427ef2a314a831330a196adf00 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Mon, 11 Nov 2024 21:20:53 -0700 Subject: [PATCH 224/282] Add: PR template --- .github/pull_request_template.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..f41e46cce7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,26 @@ + + +## Brief summary + + + +## In-depth Description + + + +## How have you tested this? + + + +## Screenshots + + From b50d7f09278c74eeccffdff1ecc0f2eb4cb8f04f Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 12 Nov 2024 07:25:10 +0200 Subject: [PATCH 225/282] Remove unnecessary socket event causing OOM --- client/pages/library/_library/podcast/download-queue.vue | 5 ----- server/managers/PodcastManager.js | 2 -- 2 files changed, 7 deletions(-) diff --git a/client/pages/library/_library/podcast/download-queue.vue b/client/pages/library/_library/podcast/download-queue.vue index 777ddfc16d..5f4bab629c 100644 --- a/client/pages/library/_library/podcast/download-queue.vue +++ b/client/pages/library/_library/podcast/download-queue.vue @@ -104,9 +104,6 @@ export default { this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id) } }, - episodeDownloadQueueUpdated(downloadQueueDetails) { - this.episodeDownloadsQueued = downloadQueueDetails.queue.filter((q) => q.libraryId == this.libraryId) - }, async loadInitialDownloadQueue() { this.processing = true const queuePayload = await this.$axios.$get(`/api/libraries/${this.libraryId}/episode-downloads`).catch((error) => { @@ -128,7 +125,6 @@ export default { this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.on('episode_download_started', this.episodeDownloadStarted) this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished) - this.$root.socket.on('episode_download_queue_updated', this.episodeDownloadQueueUpdated) } }, mounted() { @@ -138,7 +134,6 @@ export default { this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.off('episode_download_started', this.episodeDownloadStarted) this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished) - this.$root.socket.off('episode_download_queue_updated', this.episodeDownloadQueueUpdated) } } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 96ffcb6a46..01e661d9ed 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -63,7 +63,6 @@ class PodcastManager { } async startPodcastEpisodeDownload(podcastEpisodeDownload) { - SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails()) if (this.currentDownload) { this.downloadQueue.push(podcastEpisodeDownload) SocketAuthority.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient()) @@ -149,7 +148,6 @@ class PodcastManager { TaskManager.taskFinished(task) SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient()) - SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails()) Watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) From 8626fa3e00871555a80a647e058cd8f62ba2ea59 Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 12 Nov 2024 07:37:38 +0200 Subject: [PATCH 226/282] Add episode_download_queue_cleared socket event --- client/pages/item/_id/index.vue | 7 +++++++ server/managers/PodcastManager.js | 1 + 2 files changed, 8 insertions(+) diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 57a1ae7435..1baf521c70 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -638,6 +638,11 @@ export default { this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id) } }, + episodeDownloadQueueCleared(libraryItemId) { + if (libraryItemId === this.libraryItemId) { + this.episodeDownloadsQueued = [] + } + }, rssFeedOpen(data) { if (data.entityId === this.libraryItemId) { console.log('RSS Feed Opened', data) @@ -776,6 +781,7 @@ export default { this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.on('episode_download_started', this.episodeDownloadStarted) this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished) + this.$root.socket.on('episode_download_queue_cleared', this.episodeDownloadQueueCleared) }, beforeDestroy() { this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated) @@ -787,6 +793,7 @@ export default { this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued) this.$root.socket.off('episode_download_started', this.episodeDownloadStarted) this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished) + this.$root.socket.off('episode_download_queue_cleared', this.episodeDownloadQueueCleared) } } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 01e661d9ed..0a32e3cad1 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -46,6 +46,7 @@ class PodcastManager { var itemDownloads = this.getEpisodeDownloadsInQueue(libraryItemId) Logger.info(`[PodcastManager] Clearing downloads in queue for item "${libraryItemId}" (${itemDownloads.length})`) this.downloadQueue = this.downloadQueue.filter((d) => d.libraryItemId !== libraryItemId) + SocketAuthority.emitter('episode_download_queue_cleared', libraryItemId) } } From c1b626da147c55c1723f236a7a6eed4d9b4deae7 Mon Sep 17 00:00:00 2001 From: Charlie Date: Wed, 30 Oct 2024 15:42:23 +0000 Subject: [PATCH 227/282] Translated using Weblate (French) Currently translated at 97.5% (1045 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index d31c59714c..a32296f768 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -180,6 +180,7 @@ "HeaderRemoveEpisodes": "Suppression de {0} épisodes", "HeaderSavedMediaProgress": "Progression de la sauvegarde des médias", "HeaderSchedule": "Programmation", + "HeaderScheduleEpisodeDownloads": "Programmer des téléchargements automatiques d'épisodes", "HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque", "HeaderSession": "Session", "HeaderSetBackupSchedule": "Activer la sauvegarde automatique", @@ -225,6 +226,7 @@ "LabelAllUsersExcludingGuests": "Tous les utilisateurs à l’exception des invités", "LabelAllUsersIncludingGuests": "Tous les utilisateurs, y compris les invités", "LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque", + "LabelApiToken": "Token API", "LabelAppend": "Ajouter", "LabelAudioBitrate": "Débit audio (par exemple 128k)", "LabelAudioChannels": "Canaux audio (1 ou 2)", @@ -261,6 +263,7 @@ "LabelChapters": "Chapitres", "LabelChaptersFound": "chapitres trouvés", "LabelClickForMoreInfo": "Cliquez ici pour plus d’informations", + "LabelClickToUseCurrentValue": "Cliquez pour utiliser la valeur actuelle", "LabelClosePlayer": "Fermer le lecteur", "LabelCodec": "Codec", "LabelCollapseSeries": "Réduire les séries", @@ -306,7 +309,7 @@ "LabelEmailSettingsRejectUnauthorized": "Rejeter les certificats non autorisés", "LabelEmailSettingsRejectUnauthorizedHelp": "Désactiver la validation du certificat SSL peut exposer votre connexion à des risques de sécurité, tels que des attaques de type « Attaque de l’homme du milieu ». Ne désactivez cette option que si vous en comprenez les implications et si vous faites confiance au serveur de messagerie auquel vous vous connectez.", "LabelEmailSettingsSecure": "Sécurisé", - "LabelEmailSettingsSecureHelp": "Si cette option est activée, la connexion utilisera TLS lors de la connexion au serveur. Si elle est désactivée, TLS sera utilisé uniquement si le serveur prend en charge l’extension STARTTLS. Dans la plupart des cas, définissez cette valeur sur « true » si vous vous connectez au port 465. Pour les ports 587 ou 25, laissez-la sur « false ». (source : nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsSecureHelp": "Si cette option est activée, la connexion utilisera TLS lors de la connexion au serveur. Si elle est désactivée, TLS sera utilisé uniquement si le serveur prend en charge l’extension STARTTLS. Dans la plupart des cas, définissez cette valeur sur « true » si vous vous connectez au port 465. Pour les ports 587 ou 25, laissez-la sur « false ». (source : nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Adresse de test", "LabelEmbeddedCover": "Couverture du livre intégrée", "LabelEnable": "Activer", @@ -322,9 +325,12 @@ "LabelEnd": "Fin", "LabelEndOfChapter": "Fin du chapitre", "LabelEpisode": "Épisode", + "LabelEpisodeNotLinkedToRssFeed": "Épisode non lié au flux RSS", + "LabelEpisodeNumber": "Épisode n°{0}", "LabelEpisodeTitle": "Titre de l’épisode", "LabelEpisodeType": "Type de l’épisode", "LabelEpisodes": "Épisodes", + "LabelEpisodic": "Épisodique", "LabelExample": "Exemple", "LabelExpandSeries": "Développer la série", "LabelExpandSubSeries": "Développer les sous-séries", @@ -352,6 +358,7 @@ "LabelFontScale": "Taille de la police de caractère", "LabelFontStrikethrough": "Barrer", "LabelFormat": "Format", + "LabelFull": "Complet", "LabelGenre": "Genre", "LabelGenres": "Genres", "LabelHardDeleteFile": "Suppression du fichier", @@ -407,6 +414,9 @@ "LabelLowestPriority": "Priorité la plus basse", "LabelMatchExistingUsersBy": "Correspondance avec les utilisateurs existants", "LabelMatchExistingUsersByDescription": "Utilisé pour connecter les utilisateurs existants. Une fois connectés, les utilisateurs seront associés à un identifiant unique provenant de votre fournisseur SSO", + "LabelMaxEpisodesToDownload": "Nombre maximum d’épisodes à télécharger. 0 pour illimité.", + "LabelMaxEpisodesToDownloadPerCheck": "Nombre maximum de nouveaux épisodes à télécharger par vérification", + "LabelMaxEpisodesToKeep": "Nombre maximum d’épisodes à conserver", "LabelMediaPlayer": "Lecteur multimédia", "LabelMediaType": "Type de média", "LabelMetaTag": "Balise de métadonnée", @@ -452,12 +462,14 @@ "LabelOpenIDGroupClaimDescription": "Nom de la demande OpenID qui contient une liste des groupes de l’utilisateur. Communément appelé groups. Si elle est configurée, l’application attribuera automatiquement des rôles en fonction de l’appartenance de l’utilisateur à un groupe, à condition que ces groupes soient nommés -sensible à la casse- tel que « admin », « user » ou « guest » dans la demande. Elle doit contenir une liste, et si un utilisateur appartient à plusieurs groupes, l’application attribuera le rôle correspondant au niveau d’accès le plus élevé. Si aucun groupe ne correspond, l’accès sera refusé.", "LabelOpenRSSFeed": "Ouvrir le flux RSS", "LabelOverwrite": "Écraser", + "LabelPaginationPageXOfY": "Page {0} sur {1}", "LabelPassword": "Mot de passe", "LabelPath": "Chemin", "LabelPermanent": "Permanent", "LabelPermissionsAccessAllLibraries": "Peut accéder à toutes les bibliothèque", "LabelPermissionsAccessAllTags": "Peut accéder à toutes les étiquettes", "LabelPermissionsAccessExplicitContent": "Peut accéder au contenu restreint", + "LabelPermissionsCreateEreader": "Peut créer une liseuse", "LabelPermissionsDelete": "Peut supprimer", "LabelPermissionsDownload": "Peut télécharger", "LabelPermissionsUpdate": "Peut mettre à jour", @@ -502,18 +514,24 @@ "LabelRedo": "Refaire", "LabelRegion": "Région", "LabelReleaseDate": "Date de parution", + "LabelRemoveAllMetadataAbs": "Supprimer tous les fichiers metadata.abs", + "LabelRemoveAllMetadataJson": "Supprimer tous les fichiers metadata.json", "LabelRemoveCover": "Supprimer la couverture", + "LabelRemoveMetadataFile": "Supprimer les fichiers de métadonnées dans les dossiers des éléments de la bibliothèque", + "LabelRemoveMetadataFileHelp": "Supprimer tous les fichiers metadata.json et metadata.abs de vos dossiers {0}.", "LabelRowsPerPage": "Lignes par page", "LabelSearchTerm": "Terme de recherche", "LabelSearchTitle": "Titre de recherche", "LabelSearchTitleOrASIN": "Recherche du titre ou ASIN", "LabelSeason": "Saison", + "LabelSeasonNumber": "Saison n°{0}", "LabelSelectAll": "Tout sélectionner", "LabelSelectAllEpisodes": "Sélectionner tous les épisodes", "LabelSelectEpisodesShowing": "Sélectionner {0} épisode(s) en cours", "LabelSelectUsers": "Sélectionner les utilisateurs", "LabelSendEbookToDevice": "Envoyer le livre numérique à…", "LabelSequence": "Séquence", + "LabelSerial": "N° de série", "LabelSeries": "Séries", "LabelSeriesName": "Nom de la série", "LabelSeriesProgress": "Progression de séries", @@ -542,6 +560,7 @@ "LabelSettingsHideSingleBookSeriesHelp": "Les séries qui ne comportent qu’un seul livre seront masquées sur la page de la série et sur les étagères de la page d’accueil.", "LabelSettingsHomePageBookshelfView": "Utiliser la vue étagère sur la page d’accueil", "LabelSettingsLibraryBookshelfView": "Utiliser la vue étagère pour la bibliothèque", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Le pourcentage d'achèvement est supérieur à", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sauter les livres précédents dans « Continuer la série »", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "L’étagère de la page d’accueil « Continuer la série » affiche le premier livre non commencé dans les séries dont au moins un livre est terminé et aucun livre n’est en cours. L’activation de ce paramètre permet de poursuivre la série à partir du dernier livre terminé au lieu du premier livre non commencé.", "LabelSettingsParseSubtitles": "Analyser les sous-titres", @@ -626,6 +645,7 @@ "LabelTracksMultiTrack": "Piste multiple", "LabelTracksNone": "Aucune piste", "LabelTracksSingleTrack": "Piste simple", + "LabelTrailer": "Bande-annonce", "LabelType": "Type", "LabelUnabridged": "Version intégrale", "LabelUndo": "Annuler", From 3a5f6ab6f14f18b3194934cefd96e5195dabc4a5 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Wed, 30 Oct 2024 20:17:01 +0000 Subject: [PATCH 228/282] 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 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/strings/ru.json b/client/strings/ru.json index d27f138f8b..b0743af630 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -229,7 +229,7 @@ "LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке", "LabelApiToken": "Токен API", "LabelAppend": "Добавить", - "LabelAudioBitrate": "Битрейт аудио (напр. 128k)", + "LabelAudioBitrate": "Битрейт (напр. 128k)", "LabelAudioChannels": "Аудиоканалы (1 или 2)", "LabelAudioCodec": "Аудиокодек", "LabelAuthor": "Автор", @@ -366,7 +366,7 @@ "LabelHardDeleteFile": "Жесткое удаление файла", "LabelHasEbook": "Есть e-книга", "LabelHasSupplementaryEbook": "Есть дополнительная e-книга", - "LabelHideSubtitles": "Скрыть субтитры", + "LabelHideSubtitles": "Скрыть серии", "LabelHighestPriority": "Наивысший приоритет", "LabelHost": "Хост", "LabelHour": "Часы", @@ -496,8 +496,8 @@ "LabelPubDate": "Дата публикации", "LabelPublishYear": "Год публикации", "LabelPublishedDate": "Опубликовано {0}", - "LabelPublishedDecade": "Опубликованное десятилетие", - "LabelPublishedDecades": "Опубликованные десятилетия", + "LabelPublishedDecade": "Декада публикации", + "LabelPublishedDecades": "Декады публикации", "LabelPublisher": "Издатель", "LabelPublishers": "Издатели", "LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца", @@ -588,7 +588,7 @@ "LabelShareURL": "Общедоступный URL", "LabelShowAll": "Показать все", "LabelShowSeconds": "Отображать секунды", - "LabelShowSubtitles": "Показать субтитры", + "LabelShowSubtitles": "Показать серии", "LabelSize": "Размер", "LabelSleepTimer": "Таймер сна", "LabelSlug": "Слизень", From f161158d838e00cebd1b458a4097048c90b2fad8 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Fri, 1 Nov 2024 15:55:01 +0000 Subject: [PATCH 229/282] Translated using Weblate (Spanish) Currently translated at 98.3% (1053 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/client/strings/es.json b/client/strings/es.json index dbd8bbc6ec..fea1a85f34 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -415,6 +415,9 @@ "LabelMatchExistingUsersBy": "Emparejar a los usuarios existentes por", "LabelMatchExistingUsersByDescription": "Se utiliza para conectar usuarios existentes. Una vez conectados, los usuarios serán emparejados por un identificador único de su proveedor de SSO", "LabelMaxEpisodesToDownload": "Número máximo # de episodios para descargar. Usa 0 para descargar una cantidad ilimitada.", + "LabelMaxEpisodesToDownloadPerCheck": "Número máximo de episodios nuevos que se descargarán por comprobación", + "LabelMaxEpisodesToKeep": "Número máximo de episodios que se mantendrán", + "LabelMaxEpisodesToKeepHelp": "El valor 0 no establece un límite máximo. Después de que se descargue automáticamente un nuevo episodio, esto eliminará el episodio más antiguo si tiene más de X episodios. Esto solo eliminará 1 episodio por nueva descarga.", "LabelMediaPlayer": "Reproductor de Medios", "LabelMediaType": "Tipo de multimedia", "LabelMetaTag": "Metaetiqueta", @@ -460,12 +463,14 @@ "LabelOpenIDGroupClaimDescription": "Nombre de la declaración OpenID que contiene una lista de grupos del usuario. Comúnmente conocidos como grupos. Si se configura, la aplicación asignará automáticamente roles en función de la pertenencia a grupos del usuario, siempre que estos grupos se denominen \"admin\", \"user\" o \"guest\" en la notificación. La solicitud debe contener una lista, y si un usuario pertenece a varios grupos, la aplicación asignará el rol correspondiente al mayor nivel de acceso. Si ningún grupo coincide, se denegará el acceso.", "LabelOpenRSSFeed": "Abrir Fuente RSS", "LabelOverwrite": "Sobrescribir", + "LabelPaginationPageXOfY": "Página {0} de {1}", "LabelPassword": "Contraseña", "LabelPath": "Ruta de carpeta", "LabelPermanent": "Permanente", "LabelPermissionsAccessAllLibraries": "Puede Accesar a Todas las bibliotecas", "LabelPermissionsAccessAllTags": "Pueda Accesar a Todas las Etiquetas", "LabelPermissionsAccessExplicitContent": "Puede Accesar a Contenido Explicito", + "LabelPermissionsCreateEreader": "Puede crear un gestor de proyectos", "LabelPermissionsDelete": "Puede Eliminar", "LabelPermissionsDownload": "Puede Descargar", "LabelPermissionsUpdate": "Puede Actualizar", @@ -510,18 +515,24 @@ "LabelRedo": "Rehacer", "LabelRegion": "Región", "LabelReleaseDate": "Fecha de Estreno", + "LabelRemoveAllMetadataAbs": "Eliminar todos los archivos metadata.abs", + "LabelRemoveAllMetadataJson": "Eliminar todos los archivos metadata.json", "LabelRemoveCover": "Remover Portada", + "LabelRemoveMetadataFile": "Eliminar archivos de metadatos en carpetas de elementos de biblioteca", + "LabelRemoveMetadataFileHelp": "Elimine todos los archivos metadata.json y metadata.abs de sus carpetas {0}.", "LabelRowsPerPage": "Filas por página", "LabelSearchTerm": "Buscar Termino", "LabelSearchTitle": "Buscar Titulo", "LabelSearchTitleOrASIN": "Buscar Título o ASIN", "LabelSeason": "Temporada", + "LabelSeasonNumber": "Sesión #{0}", "LabelSelectAll": "Seleccionar todo", "LabelSelectAllEpisodes": "Seleccionar todos los episodios", "LabelSelectEpisodesShowing": "Seleccionar los {0} episodios visibles", "LabelSelectUsers": "Seleccionar usuarios", "LabelSendEbookToDevice": "Enviar Ebook a...", "LabelSequence": "Secuencia", + "LabelSerial": "Serial", "LabelSeries": "Series", "LabelSeriesName": "Nombre de la Serie", "LabelSeriesProgress": "Progreso de la Serie", @@ -550,6 +561,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Las series con un solo libro no aparecerán en la página de series ni la repisa para series de la página principal.", "LabelSettingsHomePageBookshelfView": "Usar la vista de librero en la página principal", "LabelSettingsLibraryBookshelfView": "Usar la vista de librero en la biblioteca", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "El porcentaje completado es mayor que", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "El tiempo restante es menor a (segundos)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Marcar el archivo multimedia como terminado cuando", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Saltar libros anteriores de la serie Continuada", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "El estante de la página de inicio de Continuar Serie muestra el primer libro no iniciado de una serie que tenga por lo menos un libro finalizado y no tenga libros en progreso. Habilitar esta opción le permitirá continuar series desde el último libro que ha completado en vez del primer libro que no ha empezado.", "LabelSettingsParseSubtitles": "Extraer Subtítulos", @@ -614,6 +628,7 @@ "LabelTimeDurationXMinutes": "{0} minutos", "LabelTimeDurationXSeconds": "{0} segundos", "LabelTimeInMinutes": "Tiempo en minutos", + "LabelTimeLeft": "Quedan {0}", "LabelTimeListened": "Tiempo Escuchando", "LabelTimeListenedToday": "Tiempo Escuchando Hoy", "LabelTimeRemaining": "{0} restante", @@ -634,6 +649,7 @@ "LabelTracksMultiTrack": "Varias pistas", "LabelTracksNone": "Ninguna pista", "LabelTracksSingleTrack": "Una pista", + "LabelTrailer": "Tráiler", "LabelType": "Tipo", "LabelUnabridged": "No Abreviado", "LabelUndo": "Deshacer", @@ -650,6 +666,7 @@ "LabelUseAdvancedOptions": "Usar opciones avanzadas", "LabelUseChapterTrack": "Usar pista por capitulo", "LabelUseFullTrack": "Usar pista completa", + "LabelUseZeroForUnlimited": "Utilice 0 para ilimitado", "LabelUser": "Usuario", "LabelUsername": "Nombre de Usuario", "LabelValue": "Valor", @@ -708,6 +725,7 @@ "MessageConfirmPurgeCache": "Purgar el caché eliminará el directorio completo ubicado en /metadata/cache.

    ¿Está seguro que desea eliminar el directorio del caché?", "MessageConfirmPurgeItemsCache": "Purgar la caché de los elementos eliminará todo el directorio /metadata/cache/items.
    ¿Estás seguro?", "MessageConfirmQuickEmbed": "¡Advertencia! La integración rápida no realiza copias de seguridad a ninguno de tus archivos de audio. Asegúrate de haber realizado una copia de los mismos previamente.

    ¿Deseas continuar?", + "MessageConfirmQuickMatchEpisodes": "El reconocimiento rápido de extensiones sobrescribirá los detalles si se encuentra una coincidencia. Se actualizarán las extensiones no reconocidas. ¿Está seguro?", "MessageConfirmReScanLibraryItems": "¿Estás seguro de querer re escanear {0} elemento(s)?", "MessageConfirmRemoveAllChapters": "¿Está seguro de que desea remover todos los capitulos?", "MessageConfirmRemoveAuthor": "¿Está seguro de que desea remover el autor \"{0}\"?", @@ -715,6 +733,7 @@ "MessageConfirmRemoveEpisode": "¿Está seguro de que desea remover el episodio \"{0}\"?", "MessageConfirmRemoveEpisodes": "¿Está seguro de que desea remover {0} episodios?", "MessageConfirmRemoveListeningSessions": "¿Está seguro que desea remover {0} sesiones de escuchar?", + "MessageConfirmRemoveMetadataFiles": "¿Está seguro de que desea eliminar todos los archivos de metadatos.{0} en las carpetas de elementos de su biblioteca?", "MessageConfirmRemoveNarrator": "¿Está seguro de que desea remover el narrador \"{0}\"?", "MessageConfirmRemovePlaylist": "¿Está seguro de que desea remover la lista de reproducción \"{0}\"?", "MessageConfirmRenameGenre": "¿Está seguro de que desea renombrar el genero \"{0}\" a \"{1}\" de todos los elementos?", @@ -893,6 +912,7 @@ "StatsYearInReview": "RESEÑA DEL AÑO", "ToastAccountUpdateSuccess": "Cuenta actualizada", "ToastAppriseUrlRequired": "Debes ingresar una URL de Apprise", + "ToastAsinRequired": "Se requiere ASIN", "ToastAuthorImageRemoveSuccess": "Se eliminó la imagen del autor", "ToastAuthorNotFound": "No se encontró el autor \"{0}\"", "ToastAuthorRemoveSuccess": "Autor eliminado", From 4be2909b245b0598e3490a9ef1085ffab8b7d81a Mon Sep 17 00:00:00 2001 From: Charlie Date: Fri, 1 Nov 2024 14:39:45 +0000 Subject: [PATCH 230/282] Translated using Weblate (French) 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/fr/ --- client/strings/fr.json | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index a32296f768..a1f5c2c86a 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -122,7 +122,7 @@ "HeaderBackups": "Sauvegardes", "HeaderChangePassword": "Modifier le mot de passe", "HeaderChapters": "Chapitres", - "HeaderChooseAFolder": "Choisir un dossier", + "HeaderChooseAFolder": "Sélectionner un dossier", "HeaderCollection": "Collection", "HeaderCollectionItems": "Entrées de la collection", "HeaderCover": "Couverture", @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Mise à jour de la notification", "HeaderNotifications": "Notifications", "HeaderOpenIDConnectAuthentication": "Authentification via OpenID Connect", + "HeaderOpenListeningSessions": "Ouvrir les sessions d'écoutes", "HeaderOpenRSSFeed": "Ouvrir le flux RSS", "HeaderOtherFiles": "Autres fichiers", "HeaderPasswordAuthentication": "Authentification par mot de passe", @@ -329,6 +330,7 @@ "LabelEpisodeNumber": "Épisode n°{0}", "LabelEpisodeTitle": "Titre de l’épisode", "LabelEpisodeType": "Type de l’épisode", + "LabelEpisodeUrlFromRssFeed": "URL de l’épisode à partir du flux RSS", "LabelEpisodes": "Épisodes", "LabelEpisodic": "Épisodique", "LabelExample": "Exemple", @@ -417,6 +419,7 @@ "LabelMaxEpisodesToDownload": "Nombre maximum d’épisodes à télécharger. 0 pour illimité.", "LabelMaxEpisodesToDownloadPerCheck": "Nombre maximum de nouveaux épisodes à télécharger par vérification", "LabelMaxEpisodesToKeep": "Nombre maximum d’épisodes à conserver", + "LabelMaxEpisodesToKeepHelp": "La valeur 0 ne définit aucune limite maximale. Une fois qu’un nouvel épisode est téléchargé automatiquement, l’épisode le plus ancien sera supprimé si vous avez plus de X épisodes. Cela ne supprimera qu’un seul épisode par nouveau téléchargement.", "LabelMediaPlayer": "Lecteur multimédia", "LabelMediaType": "Type de média", "LabelMetaTag": "Balise de métadonnée", @@ -561,6 +564,8 @@ "LabelSettingsHomePageBookshelfView": "Utiliser la vue étagère sur la page d’accueil", "LabelSettingsLibraryBookshelfView": "Utiliser la vue étagère pour la bibliothèque", "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Le pourcentage d'achèvement est supérieur à", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Le temps restant est inférieur à (secondes)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Marquer l’élément multimédia comme terminé lorsque", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sauter les livres précédents dans « Continuer la série »", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "L’étagère de la page d’accueil « Continuer la série » affiche le premier livre non commencé dans les séries dont au moins un livre est terminé et aucun livre n’est en cours. L’activation de ce paramètre permet de poursuivre la série à partir du dernier livre terminé au lieu du premier livre non commencé.", "LabelSettingsParseSubtitles": "Analyser les sous-titres", @@ -625,6 +630,7 @@ "LabelTimeDurationXMinutes": "{0} minutes", "LabelTimeDurationXSeconds": "{0} secondes", "LabelTimeInMinutes": "Temps en minutes", + "LabelTimeLeft": "{0} restant", "LabelTimeListened": "Temps d’écoute", "LabelTimeListenedToday": "Nombres d’écoutes aujourd’hui", "LabelTimeRemaining": "{0} restantes", @@ -662,6 +668,7 @@ "LabelUseAdvancedOptions": "Utiliser les options avancées", "LabelUseChapterTrack": "Utiliser la piste du chapitre", "LabelUseFullTrack": "Utiliser la piste complète", + "LabelUseZeroForUnlimited": "0 pour illimité", "LabelUser": "Utilisateur", "LabelUsername": "Nom d’utilisateur", "LabelValue": "Valeur", @@ -708,7 +715,7 @@ "MessageConfirmDeleteMetadataProvider": "Êtes-vous sûr·e de vouloir supprimer le fournisseur de métadonnées personnalisées « {0} » ?", "MessageConfirmDeleteNotification": "Êtes-vous sûr·e de vouloir supprimer cette notification ?", "MessageConfirmDeleteSession": "Êtes-vous sûr·e de vouloir supprimer cette session ?", - "MessageConfirmEmbedMetadataInAudioFiles": "Souhaitez-vous vraiment intégrer des métadonnées dans {0} fichiers audio ?", + "MessageConfirmEmbedMetadataInAudioFiles": "Êtes-vous sûr·e de vouloir intégrer des métadonnées dans {0} fichiers audio ?", "MessageConfirmForceReScan": "Êtes-vous sûr·e de vouloir lancer une analyse forcée ?", "MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr·e de marquer tous les épisodes comme terminés ?", "MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr·e de vouloir marquer tous les épisodes comme non terminés ?", @@ -719,7 +726,8 @@ "MessageConfirmNotificationTestTrigger": "Déclencher cette notification avec des données de test ?", "MessageConfirmPurgeCache": "La purge du cache supprimera l’intégralité du répertoire à /metadata/cache.

    Êtes-vous sûr·e de vouloir supprimer le répertoire de cache ?", "MessageConfirmPurgeItemsCache": "Purger le cache des éléments supprimera l'ensemble du répertoire /metadata/cache/items.
    Êtes-vous sûr ?", - "MessageConfirmQuickEmbed": "Attention ! L'intégration rapide ne permet pas de sauvegarder vos fichiers audio. Assurez-vous d’avoir effectuer une sauvegarde de vos fichiers audio.

    Souhaitez-vous continuer ?", + "MessageConfirmQuickEmbed": "Attention ! L'intégration rapide ne permet pas de sauvegarder vos fichiers audio. Assurez-vous d’avoir effectuer une sauvegarde de vos fichiers audio.

    Êtes-vous sûr·e de vouloir continuer ?", + "MessageConfirmQuickMatchEpisodes": "Les épisodes correspondants seront écrasés si une correspondance est trouvée. Seuls les épisodes non correspondants seront mis à jour. Êtes-vous sûr·e ?", "MessageConfirmReScanLibraryItems": "Êtes-vous sûr·e de vouloir réanalyser {0} éléments ?", "MessageConfirmRemoveAllChapters": "Êtes-vous sûr·e de vouloir supprimer tous les chapitres ?", "MessageConfirmRemoveAuthor": "Êtes-vous sûr·e de vouloir supprimer l’auteur « {0} » ?", @@ -727,6 +735,7 @@ "MessageConfirmRemoveEpisode": "Êtes-vous sûr·e de vouloir supprimer l’épisode « {0} » ?", "MessageConfirmRemoveEpisodes": "Êtes-vous sûr·e de vouloir supprimer {0} épisodes ?", "MessageConfirmRemoveListeningSessions": "Êtes-vous sûr·e de vouloir supprimer {0} sessions d’écoute ?", + "MessageConfirmRemoveMetadataFiles": "Êtes-vous sûr·e de vouloir supprimer tous les fichiers « metatadata.{0} » des dossiers d’éléments de votre bibliothèque ?", "MessageConfirmRemoveNarrator": "Êtes-vous sûr·e de vouloir supprimer le narrateur « {0} » ?", "MessageConfirmRemovePlaylist": "Êtes-vous sûr·e de vouloir supprimer la liste de lecture « {0} » ?", "MessageConfirmRenameGenre": "Êtes-vous sûr·e de vouloir renommer le genre « {0} » en « {1} » pour tous les éléments ?", @@ -807,6 +816,7 @@ "MessagePodcastSearchField": "Saisissez le terme de recherche ou l'URL du flux RSS", "MessageQuickEmbedInProgress": "Intégration rapide en cours", "MessageQuickEmbedQueue": "En file d'attente pour une intégration rapide ({0} dans la file d'attente)", + "MessageQuickMatchAllEpisodes": "Associer rapidement tous les épisodes", "MessageQuickMatchDescription": "Renseigne les détails manquants ainsi que la couverture avec la première correspondance de « {0} ». N’écrase pas les données présentes à moins que le paramètre « Préférer les Métadonnées par correspondance » soit activé.", "MessageRemoveChapter": "Supprimer le chapitre", "MessageRemoveEpisodes": "Suppression de {0} épisode(s)", @@ -905,6 +915,7 @@ "StatsYearInReview": "BILAN DE L’ANNÉE", "ToastAccountUpdateSuccess": "Compte mis à jour", "ToastAppriseUrlRequired": "Vous devez entrer une URL Apprise", + "ToastAsinRequired": "ASIN requis", "ToastAuthorImageRemoveSuccess": "Image de l’auteur supprimée", "ToastAuthorNotFound": "Auteur \"{0}\" non trouvé", "ToastAuthorRemoveSuccess": "Auteur supprimé", @@ -924,6 +935,8 @@ "ToastBackupUploadSuccess": "Sauvegarde téléversée", "ToastBatchDeleteFailed": "Échec de la suppression par lot", "ToastBatchDeleteSuccess": "Suppression par lot réussie", + "ToastBatchQuickMatchFailed": "Échec de la correspondance rapide par lot !", + "ToastBatchQuickMatchStarted": "La correspondance rapide par lots de {0} livres a commencé !", "ToastBatchUpdateFailed": "Échec de la mise à jour par lot", "ToastBatchUpdateSuccess": "Mise à jour par lot terminée", "ToastBookmarkCreateFailed": "Échec de la création de signet", @@ -935,6 +948,7 @@ "ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs", "ToastChaptersMustHaveTitles": "Les chapitre doivent avoir un titre", "ToastChaptersRemoved": "Chapitres supprimés", + "ToastChaptersUpdated": "Chapitres mis à jour", "ToastCollectionItemsAddFailed": "Échec de l’ajout de(s) élément(s) à la collection", "ToastCollectionItemsAddSuccess": "Ajout de(s) élément(s) à la collection réussi", "ToastCollectionItemsRemoveSuccess": "Élément(s) supprimé(s) de la collection", @@ -952,11 +966,14 @@ "ToastEncodeCancelSucces": "Encodage annulé", "ToastEpisodeDownloadQueueClearFailed": "Échec de la suppression de la file d'attente", "ToastEpisodeDownloadQueueClearSuccess": "File d’attente de téléchargement des épisodes effacée", + "ToastEpisodeUpdateSuccess": "{0} épisodes mis à jour", "ToastErrorCannotShare": "Impossible de partager nativement sur cet appareil", "ToastFailedToLoadData": "Échec du chargement des données", + "ToastFailedToMatch": "Échec de la correspondance", "ToastFailedToShare": "Échec du partage", "ToastFailedToUpdate": "Échec de la mise à jour", "ToastInvalidImageUrl": "URL de l'image invalide", + "ToastInvalidMaxEpisodesToDownload": "Nombre maximum d’épisodes à télécharger non valide", "ToastInvalidUrl": "URL invalide", "ToastItemCoverUpdateSuccess": "Couverture mise à jour", "ToastItemDeletedFailed": "La suppression de l'élément à échouée", @@ -975,14 +992,21 @@ "ToastLibraryScanStarted": "Analyse de la bibliothèque démarrée", "ToastLibraryUpdateSuccess": "Bibliothèque « {0} » mise à jour", "ToastMatchAllAuthorsFailed": "Tous les auteurs et autrices n’ont pas pu être classés", + "ToastMetadataFilesRemovedError": "Erreur lors de la suppression des fichiers « metadata.{0} »", + "ToastMetadataFilesRemovedNoneFound": "Aucun fichier « metadata.{0} » trouvé dans la bibliothèque", + "ToastMetadataFilesRemovedNoneRemoved": "Aucun fichier « metadata.{0} » n’a été supprimé", + "ToastMetadataFilesRemovedSuccess": "{0} fichiers metadata.{1} supprimés", + "ToastMustHaveAtLeastOnePath": "Doit avoir au moins un chemin", "ToastNameEmailRequired": "Le nom et le courriel sont requis", "ToastNameRequired": "Le nom est requis", + "ToastNewEpisodesFound": "{0} nouveaux épisodes trouvés", "ToastNewUserCreatedFailed": "La création du compte à échouée : « {0} »", "ToastNewUserCreatedSuccess": "Nouveau compte créé", "ToastNewUserLibraryError": "Au moins une bibliothèque est requise", "ToastNewUserPasswordError": "Un mot de passe est requis, seul l’utilisateur root peut avoir un mot de passe vide", "ToastNewUserTagError": "Au moins une étiquette est requise", "ToastNewUserUsernameError": "Entrez un nom d’utilisateur", + "ToastNoNewEpisodesFound": "Aucun nouvel épisode trouvé", "ToastNoUpdatesNecessary": "Aucune mise à jour nécessaire", "ToastNotificationCreateFailed": "La création de la notification à échouée", "ToastNotificationDeleteFailed": "La suppression de la notification à échouée", @@ -1001,6 +1025,7 @@ "ToastPodcastGetFeedFailed": "Échec de la récupération du flux du podcast", "ToastPodcastNoEpisodesInFeed": "Aucun épisode trouvé dans le flux RSS", "ToastPodcastNoRssFeed": "Le podcast n’a pas de flux RSS", + "ToastProgressIsNotBeingSynced": "La progression n’est pas synchronisée, redémarrez la lecture", "ToastProviderCreatedFailed": "Échec de l’ajout du fournisseur", "ToastProviderCreatedSuccess": "Nouveau fournisseur ajouté", "ToastProviderNameAndUrlRequired": "Nom et URL requis", @@ -1027,6 +1052,7 @@ "ToastSessionCloseFailed": "Échec de la fermeture de la session", "ToastSessionDeleteFailed": "Échec de la suppression de session", "ToastSessionDeleteSuccess": "Session supprimée", + "ToastSleepTimerDone": "Minuterie de mise en veille terminée… zZzzZz", "ToastSlugMustChange": "L’identifiant d’URL contient des caractères invalides", "ToastSlugRequired": "L’identifiant d’URL est requis", "ToastSocketConnected": "WebSocket connecté", From 9bf46b6367a09c511edaf0eeb7b3989e075f7576 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Sun, 3 Nov 2024 14:05:56 +0000 Subject: [PATCH 231/282] Translated using Weblate (Spanish) Currently translated at 98.4% (1054 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/es.json b/client/strings/es.json index fea1a85f34..5ad3111a51 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -226,6 +226,7 @@ "LabelAllUsersExcludingGuests": "Todos los usuarios excepto invitados", "LabelAllUsersIncludingGuests": "Todos los usuarios e invitados", "LabelAlreadyInYourLibrary": "Ya existe en la Biblioteca", + "LabelApiToken": "Token de la API", "LabelAppend": "Adjuntar", "LabelAudioBitrate": "Tasa de bits del audio (por ejemplo, 128k)", "LabelAudioChannels": "Canales de audio (1 o 2)", From 4ad130a11a78b5fd06fcc0f39263ab0c1620ce0f Mon Sep 17 00:00:00 2001 From: thehijacker Date: Sat, 2 Nov 2024 18:21:27 +0000 Subject: [PATCH 232/282] 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 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index b4e4383fd0..9966f7d95b 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -773,7 +773,7 @@ "MessageMarkAllEpisodesNotFinished": "Označi vse epizode kot nedokončane", "MessageMarkAsFinished": "Označi kot dokončano", "MessageMarkAsNotFinished": "Označi kot nedokončano", - "MessageMatchBooksDescription": "bo poskušal povezati knjige v knjižnici s knjigo izbranega ponudnika iskanja in izpolniti prazne podatke in naslovnico. Ne prepisuje čez obstoječe podatke.", + "MessageMatchBooksDescription": "bo poskušalo povezati knjige v knjižnici s knjigo izbranega ponudnika iskanja in izpolniti prazne podatke in naslovnico. Ne prepisuje čez obstoječe podatke.", "MessageNoAudioTracks": "Ni zvočnih posnetkov", "MessageNoAuthors": "Brez avtorjev", "MessageNoBackups": "Brez varnostnih kopij", @@ -902,7 +902,7 @@ "StatsBooksFinishedThisYear": "Nekaj knjig, ki so bile dokončane letos…", "StatsBooksListenedTo": "poslušanih knjig", "StatsCollectionGrewTo": "Vaša zbirka knjig se je povečala na …", - "StatsSessions": "sej", + "StatsSessions": "seje", "StatsSpentListening": "porabil za poslušanje", "StatsTopAuthor": "TOP AVTOR", "StatsTopAuthors": "TOP AVTORJI", From 7cbb1c60a277bab5daf259eaa49a65eb1f96095d Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Wed, 6 Nov 2024 10:22:29 +0000 Subject: [PATCH 233/282] Translated using Weblate (Ukrainian) Currently translated at 88.6% (949 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 97 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/client/strings/uk.json b/client/strings/uk.json index 4c1941d4e6..6005954d7d 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -19,6 +19,7 @@ "ButtonChooseFiles": "Обрати файли", "ButtonClearFilter": "Очистити фільтр", "ButtonCloseFeed": "Закрити стрічку", + "ButtonCloseSession": "Закрити відкритий сеанс", "ButtonCollections": "Добірки", "ButtonConfigureScanner": "Налаштувати сканер", "ButtonCreate": "Створити", @@ -28,6 +29,9 @@ "ButtonEdit": "Редагувати", "ButtonEditChapters": "Редагувати глави", "ButtonEditPodcast": "Редагувати подкаст", + "ButtonEnable": "Увімкнути", + "ButtonFireAndFail": "Вогонь і невдача", + "ButtonFireOnTest": "Випробування на вогнестійкість", "ButtonForceReScan": "Примусово сканувати", "ButtonFullPath": "Повний шлях", "ButtonHide": "Приховати", @@ -46,19 +50,23 @@ "ButtonNevermind": "Скасувати", "ButtonNext": "Наступний", "ButtonNextChapter": "Наступна глава", + "ButtonNextItemInQueue": "Наступний елемент у черзі", "ButtonOk": "Гаразд", "ButtonOpenFeed": "Відкрити стрічку", "ButtonOpenManager": "Відкрити менеджер", "ButtonPause": "Пауза", "ButtonPlay": "Слухати", + "ButtonPlayAll": "Відтворити все", "ButtonPlaying": "Відтворюється", "ButtonPlaylists": "Списки відтворення", "ButtonPrevious": "Попередній", "ButtonPreviousChapter": "Попередня глава", + "ButtonProbeAudioFile": "Перевірити аудіофайл", "ButtonPurgeAllCache": "Очистити весь кеш", "ButtonPurgeItemsCache": "Очистити кеш елементів", "ButtonQueueAddItem": "Додати до черги", "ButtonQueueRemoveItem": "Вилучити з черги", + "ButtonQuickEmbed": "Швидке вбудовування", "ButtonQuickEmbedMetadata": "Швидко вбудувати метадані", "ButtonQuickMatch": "Швидкий пошук", "ButtonReScan": "Пересканувати", @@ -92,6 +100,7 @@ "ButtonStats": "Статистика", "ButtonSubmit": "Надіслати", "ButtonTest": "Перевірити", + "ButtonUnlinkOpenId": "Вимкнути OpenID", "ButtonUpload": "Завантажити", "ButtonUploadBackup": "Завантажити резервну копію", "ButtonUploadCover": "Завантажити обкладинку", @@ -104,6 +113,7 @@ "ErrorUploadFetchMetadataNoResults": "Не вдалося отримати метадані — спробуйте оновити заголовок та/або автора", "ErrorUploadLacksTitle": "Назва обов'язкова", "HeaderAccount": "Профіль", + "HeaderAddCustomMetadataProvider": "Додати користувацький постачальник метаданих", "HeaderAdvanced": "Розширені", "HeaderAppriseNotificationSettings": "Налаштування сповіщень Apprise", "HeaderAudioTracks": "Аудіодоріжки", @@ -149,8 +159,11 @@ "HeaderMetadataToEmbed": "Вбудувати метадані", "HeaderNewAccount": "Новий профіль", "HeaderNewLibrary": "Нова бібліотека", + "HeaderNotificationCreate": "Створити сповіщення", + "HeaderNotificationUpdate": "Оновити сповіщення", "HeaderNotifications": "Сповіщення", "HeaderOpenIDConnectAuthentication": "Автентифікація OpenID Connect", + "HeaderOpenListeningSessions": "Відкриті сеанси прослуховування", "HeaderOpenRSSFeed": "Відкрити RSS-канал", "HeaderOtherFiles": "Інші файли", "HeaderPasswordAuthentication": "Автентифікація за паролем", @@ -168,6 +181,7 @@ "HeaderRemoveEpisodes": "Видалити епізодів: {0}", "HeaderSavedMediaProgress": "Збережений прогрес медіа", "HeaderSchedule": "Розклад", + "HeaderScheduleEpisodeDownloads": "Запланувати автоматичне завантаження епізодів", "HeaderScheduleLibraryScans": "Розклад автосканування бібліотеки", "HeaderSession": "Сеанс", "HeaderSetBackupSchedule": "Встановити розклад резервного копіювання", @@ -206,13 +220,18 @@ "LabelAddToPlaylist": "Додати до списку відтворення", "LabelAddToPlaylistBatch": "Додано елементів у список відтворення: {0}", "LabelAddedAt": "Дата додавання", + "LabelAddedDate": "Додано {0}", "LabelAdminUsersOnly": "Тільки для адміністраторів", "LabelAll": "Усе", "LabelAllUsers": "Усі користувачі", "LabelAllUsersExcludingGuests": "Усі, крім гостей", "LabelAllUsersIncludingGuests": "Усі, включно з гостями", "LabelAlreadyInYourLibrary": "Вже у вашій бібліотеці", + "LabelApiToken": "Токен API", "LabelAppend": "Додати", + "LabelAudioBitrate": "Бітрейт аудіо (напр. 128k)", + "LabelAudioChannels": "Канали аудіо (1 або 2)", + "LabelAudioCodec": "Аудіокодек", "LabelAuthor": "Автор", "LabelAuthorFirstLast": "Автор (за ім'ям)", "LabelAuthorLastFirst": "Автор (за прізвищем)", @@ -225,6 +244,7 @@ "LabelAutoRegister": "Автореєстрація", "LabelAutoRegisterDescription": "Автоматично створювати нових користувачів після входу", "LabelBackToUser": "Повернутися до користувача", + "LabelBackupAudioFiles": "Резервне копіювання аудіофайлів", "LabelBackupLocation": "Розташування резервних копій", "LabelBackupsEnableAutomaticBackups": "Автоматичне резервне копіювання", "LabelBackupsEnableAutomaticBackupsHelp": "Резервні копії збережено у /metadata/backups", @@ -233,18 +253,22 @@ "LabelBackupsNumberToKeep": "Кількість резервних копій", "LabelBackupsNumberToKeepHelp": "Лиш 1 резервну копію буде видалено за раз, тож якщо їх багато, то вам варто видалити їх вручну.", "LabelBitrate": "Бітрейт", + "LabelBonus": "Бонус", "LabelBooks": "Книги", "LabelButtonText": "Текст кнопки", "LabelByAuthor": "від {0}", "LabelChangePassword": "Змінити пароль", "LabelChannels": "Канали", + "LabelChapterCount": "{0} Глав", "LabelChapterTitle": "Назва глави", "LabelChapters": "Глави", "LabelChaptersFound": "глав знайдено", "LabelClickForMoreInfo": "Натисніть, щоб дізнатися більше", + "LabelClickToUseCurrentValue": "Натисніть, щоб використати поточне значення", "LabelClosePlayer": "Закрити програвач", "LabelCodec": "Кодек", "LabelCollapseSeries": "Згорнути серії", + "LabelCollapseSubSeries": "Згорнути підсерії", "LabelCollection": "Добірка", "LabelCollections": "Добірки", "LabelComplete": "Завершити", @@ -290,13 +314,28 @@ "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": "Розгорнути підсерії", "LabelExplicit": "Відверта", "LabelExplicitChecked": "Відверта (з прапорцем)", "LabelExplicitUnchecked": "Не відверта (без прапорця)", @@ -305,7 +344,9 @@ "LabelFetchingMetadata": "Отримання метаданих", "LabelFile": "Файл", "LabelFileBirthtime": "Дата створення", + "LabelFileBornDate": "Народився {0}", "LabelFileModified": "Дата змінення", + "LabelFileModifiedDate": "Змінено {0}", "LabelFilename": "Ім'я файлу", "LabelFilterByUser": "Фільтрувати за користувачем", "LabelFindEpisodes": "Знайти епізоди", @@ -319,6 +360,7 @@ "LabelFontScale": "Розмір шрифту", "LabelFontStrikethrough": "Закреслений", "LabelFormat": "Формат", + "LabelFull": "Повний", "LabelGenre": "Жанр", "LabelGenres": "Жанри", "LabelHardDeleteFile": "Остаточно видалити файл", @@ -361,6 +403,7 @@ "LabelLess": "Менше", "LabelLibrariesAccessibleToUser": "Бібліотеки, доступні користувачу", "LabelLibrary": "Бібліотека", + "LabelLibraryFilterSublistEmpty": "Ні {0}", "LabelLibraryItem": "Елемент бібліотеки", "LabelLibraryName": "Назва бібліотеки", "LabelLimit": "Обмеження", @@ -373,6 +416,10 @@ "LabelLowestPriority": "Найнижчий пріоритет", "LabelMatchExistingUsersBy": "Шукати наявних користувачів за", "LabelMatchExistingUsersByDescription": "Використовується для підключення наявних користувачів. Після підключення користувач отримає унікальний id від вашого сервісу SSO", + "LabelMaxEpisodesToDownload": "Максимальна кількість епізодів для завантаження. Використовуйте 0 для необмеженої кількості.", + "LabelMaxEpisodesToDownloadPerCheck": "Максимальна кількість нових епізодів для завантаження за перевірку", + "LabelMaxEpisodesToKeep": "Максимальна кількість епізодів для зберігання", + "LabelMaxEpisodesToKeepHelp": "Значення 0 не встановлює обмеження. Після автоматичного завантаження нового епізоду, буде видалено найстаріший епізод, якщо у вас більше ніж X епізодів. Видаляється лише 1 епізод за одне нове завантаження.", "LabelMediaPlayer": "Програвач медіа", "LabelMediaType": "Тип медіа", "LabelMetaTag": "Метатег", @@ -418,12 +465,14 @@ "LabelOpenIDGroupClaimDescription": "Ім'я OpenID claim, що містить список груп користувачів. Зазвичай їх називають групами. Якщо налаштовано, застосунок автоматично призначатиме ролі на основі членства користувача в групах, за умови, що ці групи названі в claim'і без урахування реєстру 'admin', 'user' або 'guest'. Claim мусить містити список, і якщо користувач належить до кількох груп, програма призначить йому роль, що відповідає найвищому рівню доступу. Якщо жодна група не збігається, у доступі буде відмовлено.", "LabelOpenRSSFeed": "Відкрити RSS-канал", "LabelOverwrite": "Перезаписати", + "LabelPaginationPageXOfY": "Сторінка {0} з {1}", "LabelPassword": "Пароль", "LabelPath": "Шлях", "LabelPermanent": "Постійний", "LabelPermissionsAccessAllLibraries": "Доступ до усіх бібліотек", "LabelPermissionsAccessAllTags": "Доступ до усіх міток", "LabelPermissionsAccessExplicitContent": "Доступ до відвертого вмісту", + "LabelPermissionsCreateEreader": "Можна створити читалку", "LabelPermissionsDelete": "Може видаляти", "LabelPermissionsDownload": "Може завантажувати", "LabelPermissionsUpdate": "Може оновлювати", @@ -431,6 +480,7 @@ "LabelPersonalYearReview": "Ваші підсумки року ({0})", "LabelPhotoPathURL": "Шлях/URL фото", "LabelPlayMethod": "Метод відтворення", + "LabelPlayerChapterNumberMarker": "{0} з {1}", "LabelPlaylists": "Списки відтворення", "LabelPodcast": "Подкаст", "LabelPodcastSearchRegion": "Регіон пошуку подкасту", @@ -442,8 +492,12 @@ "LabelPrimaryEbook": "Основна електронна книга", "LabelProgress": "Прогрес", "LabelProvider": "Джерело", + "LabelProviderAuthorizationValue": "Значення заголовка авторизації", "LabelPubDate": "Дата публікації", "LabelPublishYear": "Рік публікації", + "LabelPublishedDate": "Опубліковано {0}", + "LabelPublishedDecade": "Десятиліття публікації", + "LabelPublishedDecades": "Опубліковані десятиліття", "LabelPublisher": "Видавець", "LabelPublishers": "Видавці", "LabelRSSFeedCustomOwnerEmail": "Користувацька електронна адреса власника", @@ -463,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": "Надіслати електронну книгу на...", "LabelSequence": "Послідовність", + "LabelSerial": "Серійний", "LabelSeries": "Серії", "LabelSeriesName": "Назва серії", "LabelSeriesProgress": "Прогрес серії", + "LabelServerLogLevel": "Рівень журналу сервера", "LabelServerYearReview": "Підсумки року сервера ({0})", "LabelSetEbookAsPrimary": "Зробити основною", "LabelSetEbookAsSupplementary": "Зробити додатковою", @@ -502,6 +563,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Серії, що містять одну книгу, будуть приховані зі сторінки серій та полиць головної сторінки.", "LabelSettingsHomePageBookshelfView": "Полиці на головній сторінці", "LabelSettingsLibraryBookshelfView": "Показувати полиці у бібліотеці", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Відсоток виконання більше ніж", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Час, що залишився, менше ніж (секунди)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Позначити медіа-елемент як завершений, коли", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропускати попередні книги у Продовжити серії", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Полиця Продовжити серії на головній сторінці показує найпершу непочату книгу з тих серій, у яких ви завершили хоча б одну книгу та не маєте книг у процесі. Якщо увімкнути це налаштування, то серії продовжуватимуться з останньої завершеної книги, а не з першої непочатої.", "LabelSettingsParseSubtitles": "Дістати підзаголовки", @@ -566,6 +630,7 @@ "LabelTimeDurationXMinutes": "{0} хвилини", "LabelTimeDurationXSeconds": "{0} секунди", "LabelTimeInMinutes": "Час у хвилинах", + "LabelTimeLeft": "{0} залишилось", "LabelTimeListened": "Часу прослухано", "LabelTimeListenedToday": "Сьогодні прослухано", "LabelTimeRemaining": "Лишилося: {0}", @@ -573,6 +638,7 @@ "LabelTitle": "Назва", "LabelToolsEmbedMetadata": "Вбудувати метадані", "LabelToolsEmbedMetadataDescription": "Вбудувати метадані в аудіофайли, включно з обкладинками та главами.", + "LabelToolsM4bEncoder": "Кодувальник M4B", "LabelToolsMakeM4b": "Створити M4B-файл аудіокниги", "LabelToolsMakeM4bDescription": "Створити .M4B-аудіокнигу з вбудованими метаданими, обкладинкою та главами.", "LabelToolsSplitM4b": "Розділити M4B на MP3", @@ -585,10 +651,12 @@ "LabelTracksMultiTrack": "Декілька доріжок", "LabelTracksNone": "Доріжки відсутні", "LabelTracksSingleTrack": "Одна доріжка", + "LabelTrailer": "Трейлер", "LabelType": "Тип", "LabelUnabridged": "Повна", "LabelUndo": "Скасувати", "LabelUnknown": "Невідомо", + "LabelUnknownPublishDate": "Невідома дата публікації", "LabelUpdateCover": "Оновити обкладинку", "LabelUpdateCoverHelp": "Дозволити перезапис наявних обкладинок обраних книг після віднайдення", "LabelUpdateDetails": "Оновити подробиці", @@ -597,8 +665,10 @@ "LabelUploaderDragAndDrop": "Перетягніть файли або теки", "LabelUploaderDropFiles": "Перетягніть файли", "LabelUploaderItemFetchMetadataHelp": "Автоматично шукати назву, автора та серію", + "LabelUseAdvancedOptions": "Використовувати розширені налаштування", "LabelUseChapterTrack": "Прогрес глави", "LabelUseFullTrack": "Використовувати доріжку повністю", + "LabelUseZeroForUnlimited": "Використовуйте 0 для необмеженої кількості", "LabelUser": "Користувач", "LabelUsername": "Ім’я користувача", "LabelValue": "Значення", @@ -637,19 +707,27 @@ "MessageCheckingCron": "Перевірка планувальника...", "MessageConfirmCloseFeed": "Ви дійсно бажаєте закрити цей канал?", "MessageConfirmDeleteBackup": "Ви дійсно бажаєте видалити резервну копію за {0}?", + "MessageConfirmDeleteDevice": "Ви впевнені, що хочете видалити пристрій для читання \"{0}\"?", "MessageConfirmDeleteFile": "Файл буде видалено з вашої файлової системи. Ви впевнені?", "MessageConfirmDeleteLibrary": "Ви дійсно бажаєте назавжди видалити бібліотеку \"{0}\"?", "MessageConfirmDeleteLibraryItem": "Елемент бібліотеки буде видалено з бази даних та вашої файлової системи. Ви впевнені?", "MessageConfirmDeleteLibraryItems": "З бази даних та вашої файлової системи будуть видалені елементи бібліотеки: {0}. Ви впевнені?", + "MessageConfirmDeleteMetadataProvider": "Ви впевнені, що хочете видалити користувацького постачальника метаданих \"{0}\"?", + "MessageConfirmDeleteNotification": "Ви впевнені, що хочете видалити це сповіщення?", "MessageConfirmDeleteSession": "Ви дійсно бажаєте видалити цей сеанс?", + "MessageConfirmEmbedMetadataInAudioFiles": "Ви впевнені, що хочете вставити метадані в {0} аудіофайлів?", "MessageConfirmForceReScan": "Ви дійсно бажаєте примусово пересканувати?", "MessageConfirmMarkAllEpisodesFinished": "Ви дійсно бажаєте позначити усі епізоди завершеними?", "MessageConfirmMarkAllEpisodesNotFinished": "Ви дійсно бажаєте позначити усі епізоди незавершеними?", + "MessageConfirmMarkItemFinished": "Ви впевнені, що хочете позначити \"{0}\" як завершене?", + "MessageConfirmMarkItemNotFinished": "Ви впевнені, що хочете позначити \"{0}\" як незавершене?", "MessageConfirmMarkSeriesFinished": "Ви дійсно бажаєте позначити усі книги серії завершеними?", "MessageConfirmMarkSeriesNotFinished": "Ви дійсно бажаєте позначити всі книги серії незавершеними?", + "MessageConfirmNotificationTestTrigger": "Активувати це сповіщення з тестовими даними?", "MessageConfirmPurgeCache": "Очищення кешу видалить усю теку /metadata/cache.

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

    Продовжити?", + "MessageConfirmQuickMatchEpisodes": "При виявленні співпадінь інформація про епізоди швидкого пошуку буде перезаписана. Будуть оновлені тільки несуперечливі епізоди. Ви впевнені?", "MessageConfirmReScanLibraryItems": "Ви дійсно бажаєте пересканувати елементи: {0}?", "MessageConfirmRemoveAllChapters": "Ви дійсно бажаєте видалити усі глави?", "MessageConfirmRemoveAuthor": "Ви дійсно бажаєте видалити автора \"{0}\"?", @@ -657,6 +735,7 @@ "MessageConfirmRemoveEpisode": "Ви дійсно бажаєте видалити епізод \"{0}\"?", "MessageConfirmRemoveEpisodes": "Ви дійсно бажаєте видалити епізодів: {0}?", "MessageConfirmRemoveListeningSessions": "Ви дійсно бажаєте видалити сеанси прослуховування: {0}?", + "MessageConfirmRemoveMetadataFiles": "Ви впевнені, що хочете видалити всі файли metadata.{0} у папках елементів вашої бібліотеки?", "MessageConfirmRemoveNarrator": "Ви дійсно бажаєте видалити читця \"{0}\"?", "MessageConfirmRemovePlaylist": "Ви дійсно бажаєте видалити список відтворення \"{0}\"?", "MessageConfirmRenameGenre": "Ви дійсно бажаєте замінити жанр \"{0}\" на \"{1}\" для усіх елементів?", @@ -665,11 +744,14 @@ "MessageConfirmRenameTag": "Ви дійсно бажаєте замінити мітку \"{0}\" на \"{1}\" для усіх елементів?", "MessageConfirmRenameTagMergeNote": "Примітка: така мітка вже існує, тож їх буде об'єднано.", "MessageConfirmRenameTagWarning": "Увага! Вже існує схожа мітка у іншому регістрі \"{0}\".", + "MessageConfirmResetProgress": "Ви впевнені, що хочете скинути свій прогрес?", "MessageConfirmSendEbookToDevice": "Ви дійсно хочете відправити на пристрій \"{2}\" електроні книги: {0}, \"{1}\"?", + "MessageConfirmUnlinkOpenId": "Ви впевнені, що хочете відв'язати цього користувача від OpenID?", "MessageDownloadingEpisode": "Завантаження епізоду", "MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку", "MessageEmbedFailed": "Не вдалося вбудувати!", "MessageEmbedFinished": "Вбудовано!", + "MessageEmbedQueue": "В черзі на вбудовування метаданих ({0} в черзі)", "MessageEpisodesQueuedForDownload": "Епізодів у черзі завантаження: {0}", "MessageEreaderDevices": "Аби гарантувати отримання електронних книг, вам може знадобитися додати вказану вище адресу електронної пошти як правильного відправника на кожному з пристроїв зі списку нижче.", "MessageFeedURLWillBe": "URL-адреса каналу буде {0}", @@ -700,6 +782,7 @@ "MessageNoCollections": "Добірки відсутні", "MessageNoCoversFound": "Обкладинок не знайдено", "MessageNoDescription": "Без опису", + "MessageNoDevices": "Немає пристроїв", "MessageNoDownloadsInProgress": "Немає активних завантажень", "MessageNoDownloadsQueued": "Немає завантажень у черзі", "MessageNoEpisodeMatchesFound": "Відповідних епізодів не знайдено", @@ -713,6 +796,7 @@ "MessageNoLogs": "Журнал порожній", "MessageNoMediaProgress": "Прогрес відсутній", "MessageNoNotifications": "Сповіщення відсутні", + "MessageNoPodcastFeed": "Невірний подкаст: Немає каналу", "MessageNoPodcastsFound": "Подкастів не знайдено", "MessageNoResults": "Немає результатів", "MessageNoSearchResultsFor": "Немає результатів пошуку для \"{0}\"", @@ -727,7 +811,12 @@ "MessagePauseChapter": "Призупинити відтворення глави", "MessagePlayChapter": "Слухати початок глави", "MessagePlaylistCreateFromCollection": "Створити список відтворення з добірки", + "MessagePleaseWait": "Будь ласка, зачекайте...", "MessagePodcastHasNoRSSFeedForMatching": "Подкаст не має RSS-каналу для пошуку", + "MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS фіду", + "MessageQuickEmbedInProgress": "Швидке вбудовування в процесі", + "MessageQuickEmbedQueue": "В черзі на швидке вбудовування ({0} в черзі)", + "MessageQuickMatchAllEpisodes": "Швидке співставлення всіх епізодів", "MessageQuickMatchDescription": "Заповнити відсутні подробиці та обкладинку першим результатом пошуку '{0}'. Не перезаписує подробиці, якщо не увімкнено параметр \"Надавати перевагу віднайденим метаданим\".", "MessageRemoveChapter": "Видалити главу", "MessageRemoveEpisodes": "Видалити епізодів: {0}", @@ -745,6 +834,14 @@ "MessageShareExpiresIn": "Сплине за {0}", "MessageShareURLWillBe": "Поширюваний URL - {0}", "MessageStartPlaybackAtTime": "Почати відтворення \"{0}\" з {1}?", + "MessageTaskAudioFileNotWritable": "Аудіофайл \"{0}\" недоступний для запису", + "MessageTaskCanceledByUser": "Задача скасована користувачем", + "MessageTaskDownloadingEpisodeDescription": "Завантаження епізоду \"{0}\"", + "MessageTaskEmbeddingMetadata": "Вбудовування метаданих", + "MessageTaskEmbeddingMetadataDescription": "Вбудовування метаданих у аудіокнигу \"{0}\"", + "MessageTaskEncodingM4b": "Кодування M4B", + "MessageTaskEncodingM4bDescription": "Кодування аудіокниги \"{0}\" в один файл m4b", + "MessageTaskFailed": "Неуспішно", "MessageThinking": "Думаю…", "MessageUploaderItemFailed": "Не вдалося завантажити", "MessageUploaderItemSuccess": "Успішно завантажено!", From cc42aa32efda563ea7c530e53eb780e959e56232 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Wed, 6 Nov 2024 07:36:43 +0000 Subject: [PATCH 234/282] 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index 9966f7d95b..e3320ea877 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -625,7 +625,7 @@ "LabelTheme": "Tema", "LabelThemeDark": "Temna", "LabelThemeLight": "Svetla", - "LabelTimeBase": "Odvisna od časa", + "LabelTimeBase": "Osnovni čas", "LabelTimeDurationXHours": "{0} ur", "LabelTimeDurationXMinutes": "{0} minut", "LabelTimeDurationXSeconds": "{0} sekund", From 023ceed2869d0db65fb1052e57ed21dc48dc07e5 Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Thu, 7 Nov 2024 09:09:11 +0000 Subject: [PATCH 235/282] Translated using Weblate (Ukrainian) 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/uk/ --- client/strings/uk.json | 124 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index 6005954d7d..668ccd33e7 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -842,6 +842,33 @@ "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": "Успішно завантажено!", @@ -859,6 +886,10 @@ "NoteUploaderFoldersWithMediaFiles": "Теки з медіафайлами буде оброблено як окремі елементи бібліотеки.", "NoteUploaderOnlyAudioFiles": "Якщо завантажувати лише аудіофайли, то кожен файл буде оброблено як окрему книгу.", "NoteUploaderUnsupportedFiles": "Непідтримувані файли пропущено. Під час вибору або перетягування теки, файли, що знаходяться поза текою, пропускаються.", + "NotificationOnBackupCompletedDescription": "Запускається після завершення резервного копіювання", + "NotificationOnBackupFailedDescription": "Срабатывает при збої резервного копіювання", + "NotificationOnEpisodeDownloadedDescription": "Запускається при автоматичному завантаженні епізоду подкасту", + "NotificationOnTestDescription": "Подія для тестування системи сповіщень", "PlaceholderNewCollection": "Нова назва добірки", "PlaceholderNewFolderPath": "Новий шлях до теки", "PlaceholderNewPlaylist": "Нова назва списку", @@ -883,17 +914,29 @@ "StatsTotalDuration": "Загальною довжиною…", "StatsYearInReview": "ОГЛЯД РОКУ", "ToastAccountUpdateSuccess": "Профіль оновлено", + "ToastAppriseUrlRequired": "Необхідно ввести URL для Apprise", + "ToastAsinRequired": "ASIN є обов'язковим", "ToastAuthorImageRemoveSuccess": "Фото автора видалено", + "ToastAuthorNotFound": "Автор \"{0}\" не знайдений", + "ToastAuthorRemoveSuccess": "Автор видалений", + "ToastAuthorSearchNotFound": "Автор не знайдений", "ToastAuthorUpdateMerged": "Автора об'єднано", "ToastAuthorUpdateSuccess": "Автора оновлено", "ToastAuthorUpdateSuccessNoImageFound": "Автора оновлено (фото не знайдено)", + "ToastBackupAppliedSuccess": "Резервна копія застосована", "ToastBackupCreateFailed": "Не вдалося створити резервну копію", "ToastBackupCreateSuccess": "Резервну копію створено", "ToastBackupDeleteFailed": "Не вдалося видалити резервну копію", "ToastBackupDeleteSuccess": "Резервну копію видалено", + "ToastBackupInvalidMaxKeep": "Невірна кількість резервних копій для зберігання", + "ToastBackupInvalidMaxSize": "Невірний максимальний розмір резервної копії", "ToastBackupRestoreFailed": "Не вдалося відновити резервну копію", "ToastBackupUploadFailed": "Не вдалося завантажити резервну копію", "ToastBackupUploadSuccess": "Резервну копію завантажено", + "ToastBatchDeleteFailed": "Помилка при пакетному видаленні", + "ToastBatchDeleteSuccess": "Пакетне видалення успішне", + "ToastBatchQuickMatchFailed": "Не вдалося виконати пакетне швидке співпадіння!", + "ToastBatchQuickMatchStarted": "Пакетне швидке співпадіння {0} книг розпочато!", "ToastBatchUpdateFailed": "Не вдалося оновити обрані", "ToastBatchUpdateSuccess": "Обрані успішно оновлено", "ToastBookmarkCreateFailed": "Не вдалося створити закладку", @@ -904,19 +947,43 @@ "ToastCachePurgeSuccess": "Кеш очищено", "ToastChaptersHaveErrors": "Глави містять помилки", "ToastChaptersMustHaveTitles": "Глави повинні мати назви", + "ToastChaptersRemoved": "Розділи видалені", + "ToastChaptersUpdated": "Розділи оновлені", + "ToastCollectionItemsAddFailed": "Не вдалося додати елемент(и) до колекції", + "ToastCollectionItemsAddSuccess": "Елемент(и) успішно додано до колекції", "ToastCollectionItemsRemoveSuccess": "Елемент(и) видалено з добірки", "ToastCollectionRemoveSuccess": "Добірку видалено", "ToastCollectionUpdateSuccess": "Добірку оновлено", + "ToastCoverUpdateFailed": "Не вдалося оновити обкладинку", "ToastDeleteFileFailed": "Не вдалося видалити файл", "ToastDeleteFileSuccess": "Файл видалено", + "ToastDeviceAddFailed": "Не вдалося додати пристрій", + "ToastDeviceNameAlreadyExists": "Пристрій для електронних книг з таким ім'ям вже існує", + "ToastDeviceTestEmailFailed": "Не вдалося надіслати тестовий електронний лист", + "ToastDeviceTestEmailSuccess": "Тестовий електронний лист надіслано", + "ToastEmailSettingsUpdateSuccess": "Налаштування електронної пошти оновлено", + "ToastEncodeCancelFailed": "Не вдалося скасувати кодування", + "ToastEncodeCancelSucces": "Кодування скасовано", + "ToastEpisodeDownloadQueueClearFailed": "Не вдалося очистити чергу", + "ToastEpisodeDownloadQueueClearSuccess": "Чергу на завантаження епізодів очищено", + "ToastEpisodeUpdateSuccess": "{0} епізодів оновлено", "ToastErrorCannotShare": "Не можна типово поширити на цей пристрій", "ToastFailedToLoadData": "Не вдалося завантажити дані", + "ToastFailedToMatch": "Не вдалося знайти відповідність", + "ToastFailedToShare": "Не вдалося поділитися", + "ToastFailedToUpdate": "Не вдалося оновити", + "ToastInvalidImageUrl": "Невірний URL зображення", + "ToastInvalidMaxEpisodesToDownload": "Невірна кількість епізодів для завантаження", + "ToastInvalidUrl": "Невірний URL", "ToastItemCoverUpdateSuccess": "Обкладинку елемента оновлено", + "ToastItemDeletedFailed": "Не вдалося видалити елемент", + "ToastItemDeletedSuccess": "Видалений елемент", "ToastItemDetailsUpdateSuccess": "Подробиці про елемент оновлено", "ToastItemMarkedAsFinishedFailed": "Не вдалося позначити як завершене", "ToastItemMarkedAsFinishedSuccess": "Елемент позначено як завершений", "ToastItemMarkedAsNotFinishedFailed": "Не вдалося позначити незавершеним", "ToastItemMarkedAsNotFinishedSuccess": "Елемент позначено незавершеним", + "ToastItemUpdateSuccess": "Елемент оновлено", "ToastLibraryCreateFailed": "Не вдалося створити бібліотеку", "ToastLibraryCreateSuccess": "Бібліотеку \"{0}\" створено", "ToastLibraryDeleteFailed": "Не вдалося видалити бібліотеку", @@ -924,28 +991,83 @@ "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": "Не вдалося видалити сповіщення", + "ToastNotificationFailedMaximum": "Максимальна кількість невдалих спроб повинна бути >= 0", + "ToastNotificationQueueMaximum": "Максимальна кількість сповіщень у черзі повинна бути >= 0", + "ToastNotificationSettingsUpdateSuccess": "Налаштування сповіщень оновлено", + "ToastNotificationTestTriggerFailed": "Не вдалося ініціювати тестове сповіщення", + "ToastNotificationTestTriggerSuccess": "Спрацьовувало сповіщення про тестування", + "ToastNotificationUpdateSuccess": "Сповіщення оновлено", "ToastPlaylistCreateFailed": "Не вдалося створити список", "ToastPlaylistCreateSuccess": "Список відтворення створено", "ToastPlaylistRemoveSuccess": "Список відтворення видалено", "ToastPlaylistUpdateSuccess": "Список відтворення оновлено", "ToastPodcastCreateFailed": "Не вдалося створити подкаст", "ToastPodcastCreateSuccess": "Подкаст успішно створено", + "ToastPodcastGetFeedFailed": "Не вдалося отримати фід подкасту", + "ToastPodcastNoEpisodesInFeed": "У RSS-каналі не знайдено епізодів", + "ToastPodcastNoRssFeed": "Подкаст не має RSS-каналу", + "ToastProgressIsNotBeingSynced": "Прогрес не синхронізується, перезапустіть відтворення", + "ToastProviderCreatedFailed": "Не вдалося додати постачальника", + "ToastProviderCreatedSuccess": "Новий постачальник доданий", + "ToastProviderNameAndUrlRequired": "Ім'я та URL обов'язкові", + "ToastProviderRemoveSuccess": "Постачальник видалений", "ToastRSSFeedCloseFailed": "Не вдалося закрити RSS-канал", "ToastRSSFeedCloseSuccess": "RSS-канал закрито", + "ToastRemoveFailed": "Не вдалося видалити", "ToastRemoveItemFromCollectionFailed": "Не вдалося видалити елемент із добірки", "ToastRemoveItemFromCollectionSuccess": "Елемент видалено з добірки", + "ToastRemoveItemsWithIssuesFailed": "Не вдалося видалити елементи бібліотеки з проблемами", + "ToastRemoveItemsWithIssuesSuccess": "Видалено елементи бібліотеки з проблемами", + "ToastRenameFailed": "Не вдалося перейменувати", + "ToastRescanFailed": "Не вдалося повторно сканувати для {0}", + "ToastRescanRemoved": "Повторне сканування завершено, елемент був видалений", + "ToastRescanUpToDate": "Повторне сканування завершено, елемент актуальний", + "ToastRescanUpdated": "Повторне сканування завершено, елемент оновлено", + "ToastScanFailed": "Не вдалося сканувати елемент бібліотеки", + "ToastSelectAtLeastOneUser": "Виберіть хоча б одного користувача", "ToastSendEbookToDeviceFailed": "Не вдалося надіслати електронну книгу на пристрій", "ToastSendEbookToDeviceSuccess": "Електронну книгу надіслано на пристрій \"{0}\"", "ToastSeriesUpdateFailed": "Не вдалося оновити серію", "ToastSeriesUpdateSuccess": "Серію успішно оновлено", "ToastServerSettingsUpdateSuccess": "Налаштування сервера оновлено", + "ToastSessionCloseFailed": "Не вдалося закрити сесію", "ToastSessionDeleteFailed": "Не вдалося видалити сесію", "ToastSessionDeleteSuccess": "Сесію видалено", + "ToastSleepTimerDone": "Час сну завершено... зЗзЗз", + "ToastSlugMustChange": "Slug містить недопустимі символи", + "ToastSlugRequired": "Slug обов'язковий", "ToastSocketConnected": "Сокет під'єднано", "ToastSocketDisconnected": "Сокет від'єднано", "ToastSocketFailedToConnect": "Не вдалося під'єднатися до сокета", "ToastSortingPrefixesEmptyError": "Мусить мати хоча б 1 префікс сортування", "ToastSortingPrefixesUpdateSuccess": "Префікси сортування оновлено ({0})", + "ToastTitleRequired": "Заголовок обов'язковий", + "ToastUnknownError": "Невідома помилка", + "ToastUnlinkOpenIdFailed": "Не вдалося відв'язати користувача від OpenID", + "ToastUnlinkOpenIdSuccess": "Користувача відв'язано від OpenID", "ToastUserDeleteFailed": "Не вдалося видалити користувача", - "ToastUserDeleteSuccess": "Користувача видалено" + "ToastUserDeleteSuccess": "Користувача видалено", + "ToastUserPasswordChangeSuccess": "Пароль успішно змінено", + "ToastUserPasswordMismatch": "Паролі не збігаються", + "ToastUserPasswordMustChange": "Новий пароль не може співпадати з попереднім", + "ToastUserRootRequireName": "Потрібно ввести ім'я користувача root" } From 876fcf3296e5e3c8348966068d8351fe7fbd14e3 Mon Sep 17 00:00:00 2001 From: Languages add-on Date: Fri, 8 Nov 2024 02:12:28 +0100 Subject: [PATCH 236/282] Added translation using Weblate (Arabic) --- client/strings/ar.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 client/strings/ar.json diff --git a/client/strings/ar.json b/client/strings/ar.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/client/strings/ar.json @@ -0,0 +1 @@ +{} From ec4c4a4d5aaf245f312ca9a3b4da096e4350bd00 Mon Sep 17 00:00:00 2001 From: kuci-JK Date: Sun, 10 Nov 2024 21:32:17 +0000 Subject: [PATCH 237/282] Translated using Weblate (Czech) Currently translated at 83.4% (894 of 1071 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 7a898a3708..40b8553fee 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Aktualizovat notifikaci", "HeaderNotifications": "Oznámení", "HeaderOpenIDConnectAuthentication": "Ověřování pomocí OpenID Connect", + "HeaderOpenListeningSessions": "Otevřené relace přehrávače", "HeaderOpenRSSFeed": "Otevřít RSS kanál", "HeaderOtherFiles": "Ostatní soubory", "HeaderPasswordAuthentication": "Autentizace heslem", @@ -258,6 +259,7 @@ "LabelByAuthor": "od {0}", "LabelChangePassword": "Změnit heslo", "LabelChannels": "Kanály", + "LabelChapterCount": "{0} Kapitol", "LabelChapterTitle": "Název kapitoly", "LabelChapters": "Kapitoly", "LabelChaptersFound": "Kapitoly nalezeny", From 12c2071358f222d882e6e2da6854aa9a41851f53 Mon Sep 17 00:00:00 2001 From: Pavel Vachek Date: Sun, 10 Nov 2024 21:30:41 +0000 Subject: [PATCH 238/282] Translated using Weblate (Czech) Currently translated at 83.4% (894 of 1071 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, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/cs.json b/client/strings/cs.json index 40b8553fee..0dd6367c8b 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -67,7 +67,7 @@ "ButtonQueueAddItem": "Přidat do fronty", "ButtonQueueRemoveItem": "Odstranit z fronty", "ButtonQuickEmbed": "Rychle Zapsat", - "ButtonQuickEmbedMetadata": "Rychle Zapsat Metadata", + "ButtonQuickEmbedMetadata": "Rychle zapsat Metadata", "ButtonQuickMatch": "Rychlé přiřazení", "ButtonReScan": "Znovu prohledat", "ButtonRead": "Číst", From bb6377fb22dac62aea9e30a51a7c52dfabaeb6d3 Mon Sep 17 00:00:00 2001 From: Nicholas W Date: Tue, 12 Nov 2024 04:15:18 +0100 Subject: [PATCH 239/282] Deleted translation using Weblate (English (United States)) --- client/strings/en_US.json | 1 - 1 file changed, 1 deletion(-) delete mode 100644 client/strings/en_US.json diff --git a/client/strings/en_US.json b/client/strings/en_US.json deleted file mode 100644 index 0967ef424b..0000000000 --- a/client/strings/en_US.json +++ /dev/null @@ -1 +0,0 @@ -{} From 3f0347253ef748a0c2c288e6a9750c21802f4d47 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Mon, 11 Nov 2024 10:46:15 +0000 Subject: [PATCH 240/282] Translated using Weblate (Spanish) 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/es/ --- client/strings/es.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/client/strings/es.json b/client/strings/es.json index 5ad3111a51..d9e24723c7 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Notificación de actualización", "HeaderNotifications": "Notificaciones", "HeaderOpenIDConnectAuthentication": "Autenticación OpenID Connect", + "HeaderOpenListeningSessions": "Sesiones públicas de escucha", "HeaderOpenRSSFeed": "Abrir fuente RSS", "HeaderOtherFiles": "Otros Archivos", "HeaderPasswordAuthentication": "Autenticación por contraseña", @@ -815,6 +816,7 @@ "MessagePodcastSearchField": "Introduzca el término de búsqueda o la URL de la fuente RSS", "MessageQuickEmbedInProgress": "Integración rápida en proceso", "MessageQuickEmbedQueue": "En cola para inserción rápida ({0} en cola)", + "MessageQuickMatchAllEpisodes": "Combina rápidamente todos los episodios", "MessageQuickMatchDescription": "Rellenar detalles de elementos vacíos y portada con los primeros resultados de '{0}'. No sobrescribe los detalles a menos que la opción \"Preferir Metadatos Encontrados\" del servidor esté habilitada.", "MessageRemoveChapter": "Remover capítulos", "MessageRemoveEpisodes": "Remover {0} episodio(s)", @@ -933,6 +935,8 @@ "ToastBackupUploadSuccess": "Respaldo cargado", "ToastBatchDeleteFailed": "Error al eliminar por lotes", "ToastBatchDeleteSuccess": "Borrado por lotes correcto", + "ToastBatchQuickMatchFailed": "¡Error en la sincronización rápida por lotes!", + "ToastBatchQuickMatchStarted": "¡Se inició el lote de búsqueda rápida de {0} libros!", "ToastBatchUpdateFailed": "Subida masiva fallida", "ToastBatchUpdateSuccess": "Subida masiva exitosa", "ToastBookmarkCreateFailed": "Error al crear marcador", @@ -944,6 +948,7 @@ "ToastChaptersHaveErrors": "Los capítulos tienen errores", "ToastChaptersMustHaveTitles": "Los capítulos tienen que tener un título", "ToastChaptersRemoved": "Capítulos eliminados", + "ToastChaptersUpdated": "Capítulos actualizados", "ToastCollectionItemsAddFailed": "Artículo(s) añadido(s) a la colección fallido(s)", "ToastCollectionItemsAddSuccess": "Artículo(s) añadido(s) a la colección correctamente", "ToastCollectionItemsRemoveSuccess": "Elementos(s) removidos de la colección", @@ -961,11 +966,14 @@ "ToastEncodeCancelSucces": "Codificación cancelada", "ToastEpisodeDownloadQueueClearFailed": "No se pudo borrar la cola", "ToastEpisodeDownloadQueueClearSuccess": "Se borró la cola de descargas de los episodios", + "ToastEpisodeUpdateSuccess": "{0} episodio(s) actualizado(s)", "ToastErrorCannotShare": "No se puede compartir de forma nativa en este dispositivo", "ToastFailedToLoadData": "Error al cargar data", + "ToastFailedToMatch": "Error al emparejar", "ToastFailedToShare": "Error al compartir", "ToastFailedToUpdate": "Error al actualizar", "ToastInvalidImageUrl": "URL de la imagen no válida", + "ToastInvalidMaxEpisodesToDownload": "Número máximo de episodios para descargar no válidos", "ToastInvalidUrl": "URL no válida", "ToastItemCoverUpdateSuccess": "Portada del elemento actualizada", "ToastItemDeletedFailed": "Error al eliminar el elemento", @@ -984,14 +992,21 @@ "ToastLibraryScanStarted": "Se inició el escaneo de la biblioteca", "ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualizada", "ToastMatchAllAuthorsFailed": "No coincide con todos los autores", + "ToastMetadataFilesRemovedError": "Error al eliminar metadatos de {0} archivo(s)", + "ToastMetadataFilesRemovedNoneFound": "No hay metadatos.{0} archivo(s) encontrado(s) en la biblioteca", + "ToastMetadataFilesRemovedNoneRemoved": "Sin metadatos.{0} archivo(s) eliminado(s)", + "ToastMetadataFilesRemovedSuccess": "{0} metadatos.{1} archivos eliminados", + "ToastMustHaveAtLeastOnePath": "Debe tener al menos una ruta", "ToastNameEmailRequired": "Son obligatorios el nombre y el correo electrónico", "ToastNameRequired": "Nombre obligatorio", + "ToastNewEpisodesFound": "{0} nuevo(s) episodio(s) encontrado(s)", "ToastNewUserCreatedFailed": "Error al crear la cuenta: \"{0}\"", "ToastNewUserCreatedSuccess": "Nueva cuenta creada", "ToastNewUserLibraryError": "Debes seleccionar al menos una biblioteca", "ToastNewUserPasswordError": "Debes tener una contraseña, solo el usuario root puede estar sin contraseña", "ToastNewUserTagError": "Debes seleccionar al menos una etiqueta", "ToastNewUserUsernameError": "Introduce un nombre de usuario", + "ToastNoNewEpisodesFound": "No se encontraron nuevos episodios", "ToastNoUpdatesNecessary": "No es necesario actualizar", "ToastNotificationCreateFailed": "Error al crear notificación", "ToastNotificationDeleteFailed": "Error al borrar la notificación", @@ -1010,6 +1025,7 @@ "ToastPodcastGetFeedFailed": "No se puede obtener el podcast", "ToastPodcastNoEpisodesInFeed": "No se han encontrado episodios en el feed del RSS", "ToastPodcastNoRssFeed": "El podcast no tiene feed RSS", + "ToastProgressIsNotBeingSynced": "El progreso no se sincroniza, reinicia la reproducción", "ToastProviderCreatedFailed": "Error al añadir el proveedor", "ToastProviderCreatedSuccess": "Nuevo proveedor añadido", "ToastProviderNameAndUrlRequired": "Nombre y Url obligatorios", @@ -1036,6 +1052,7 @@ "ToastSessionCloseFailed": "Error al cerrar la sesión", "ToastSessionDeleteFailed": "Error al eliminar sesión", "ToastSessionDeleteSuccess": "Sesión eliminada", + "ToastSleepTimerDone": "Temporizador de apagado automático activado... zZzzZz", "ToastSlugMustChange": "El slug contiene caracteres no válidos", "ToastSlugRequired": "Slug obligatorio", "ToastSocketConnected": "Socket conectado", From 2dd30c7a26d632bfaf0d3acb8c8fe1d59d97fcab Mon Sep 17 00:00:00 2001 From: Tamanegii Date: Mon, 11 Nov 2024 11:52:00 +0000 Subject: [PATCH 241/282] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 71.3% (764 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hant/ --- client/strings/zh-tw.json | 54 +++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/client/strings/zh-tw.json b/client/strings/zh-tw.json index f6c4b6c8cf..080d1bac04 100644 --- a/client/strings/zh-tw.json +++ b/client/strings/zh-tw.json @@ -1,5 +1,5 @@ { - "ButtonAdd": "增加", + "ButtonAdd": "添加", "ButtonAddChapters": "新增章節", "ButtonAddDevice": "新增設備", "ButtonAddLibrary": "新增庫", @@ -17,7 +17,7 @@ "ButtonCheckAndDownloadNewEpisodes": "檢查並下載新劇集", "ButtonChooseAFolder": "選擇資料夾", "ButtonChooseFiles": "選擇檔案", - "ButtonClearFilter": "清除過濾器", + "ButtonClearFilter": "清楚過濾器", "ButtonCloseFeed": "關閉源", "ButtonCloseSession": "關閉開放會話", "ButtonCollections": "收藏", @@ -35,6 +35,8 @@ "ButtonHide": "隱藏", "ButtonHome": "首頁", "ButtonIssues": "問題", + "ButtonJumpBackward": "向後跳轉", + "ButtonJumpForward": "向前跳轉", "ButtonLatest": "最新", "ButtonLibrary": "媒體庫", "ButtonLogout": "登出", @@ -53,6 +55,7 @@ "ButtonPlay": "播放", "ButtonPlaying": "正在播放", "ButtonPlaylists": "播放列表", + "ButtonPrevious": "上一個", "ButtonPreviousChapter": "過去的章節", "ButtonPurgeAllCache": "清理所有快取", "ButtonPurgeItemsCache": "清理項目快取", @@ -76,7 +79,7 @@ "ButtonSaveTracklist": "保存音軌列表", "ButtonScan": "掃描", "ButtonScanLibrary": "掃描庫", - "ButtonSearch": "查找", + "ButtonSearch": "搜索", "ButtonSelectFolderPath": "選擇資料夾路徑", "ButtonSeries": "系列", "ButtonSetChaptersFromTracks": "將音軌設定為章節", @@ -97,7 +100,7 @@ "ErrorUploadFetchMetadataAPI": "獲取元數據時出錯", "ErrorUploadFetchMetadataNoResults": "無法獲取元數據 - 嘗試更新標題和/或作者", "ErrorUploadLacksTitle": "必須有標題", - "HeaderAccount": "帳號", + "HeaderAccount": "賬號", "HeaderAdvanced": "高級", "HeaderAppriseNotificationSettings": "測試通知設定", "HeaderAudioTracks": "音軌", @@ -111,6 +114,7 @@ "HeaderCollectionItems": "收藏項目", "HeaderCover": "封面", "HeaderCurrentDownloads": "當前下載", + "HeaderCustomMessageOnLogin": "登錄時的自定義信息", "HeaderCustomMetadataProviders": "自訂 Metadata 提供者", "HeaderDetails": "詳情", "HeaderDownloadQueue": "下載佇列", @@ -144,7 +148,7 @@ "HeaderNewLibrary": "新建媒體庫", "HeaderNotifications": "通知", "HeaderOpenIDConnectAuthentication": "OpenID 連接身份驗證", - "HeaderOpenRSSFeed": "打開 RSS 源", + "HeaderOpenRSSFeed": "打開 Rss 源", "HeaderOtherFiles": "其他檔案", "HeaderPasswordAuthentication": "密碼認證", "HeaderPermissions": "權限", @@ -168,7 +172,7 @@ "HeaderSettingsExperimental": "實驗功能", "HeaderSettingsGeneral": "通用", "HeaderSettingsScanner": "掃描", - "HeaderSleepTimer": "睡眠計時", + "HeaderSleepTimer": "睡眠定時", "HeaderStatsLargestItems": "最大的項目", "HeaderStatsLongestItems": "項目時長(小時)", "HeaderStatsMinutesListeningChart": "收聽分鐘數(最近7天)", @@ -182,8 +186,12 @@ "HeaderUpdateDetails": "更新詳情", "HeaderUpdateLibrary": "更新媒體庫", "HeaderUsers": "使用者", + "HeaderYearReview": "{0} 年回顧", "HeaderYourStats": "你的統計數據", "LabelAbridged": "概要", + "LabelAbridgedChecked": "刪節版(已勾選)", + "LabelAbridgedUnchecked": "未刪節版(未勾選)", + "LabelAccessibleBy": "可訪問", "LabelAccountType": "帳號類型", "LabelAccountTypeAdmin": "管理員", "LabelAccountTypeGuest": "來賓", @@ -260,26 +268,32 @@ "LabelDownload": "下載", "LabelDownloadNEpisodes": "下載 {0} 集", "LabelDuration": "持續時間", + "LabelDurationComparisonExactMatch": "(完全匹配)", + "LabelDurationComparisonLonger": "({0} 更長)", + "LabelDurationComparisonShorter": "({0} 更短)", "LabelDurationFound": "找到持續時間:", "LabelEbook": "電子書", "LabelEbooks": "電子書", "LabelEdit": "編輯", "LabelEmail": "郵箱", "LabelEmailSettingsFromAddress": "發件人位址", + "LabelEmailSettingsRejectUnauthorized": "拒絕未經授權的證書", + "LabelEmailSettingsRejectUnauthorizedHelp": "停用 SSL 證書驗證可能會使您的連接暴露於安全風險中,例如中間人攻擊。僅在您了解其含義並信任您所連接的郵件伺服器的情況下才停用此選項。", "LabelEmailSettingsSecure": "安全", "LabelEmailSettingsSecureHelp": "如果選是, 則連接將在連接到伺服器時使用TLS. 如果選否, 則若伺服器支援STARTTLS擴展, 則使用TLS. 在大多數情況下, 如果連接到465埠, 請將該值設定為是. 對於587或25埠, 請保持為否. (來自nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "測試位址", "LabelEmbeddedCover": "嵌入封面", "LabelEnable": "啟用", "LabelEnd": "結束", + "LabelEndOfChapter": "章節結束", "LabelEpisode": "劇集", "LabelEpisodeTitle": "劇集標題", "LabelEpisodeType": "劇集類型", "LabelExample": "示例", "LabelExplicit": "信息準確", - "LabelFeedURL": "源 URL", + "LabelFeedURL": "源鏈接", "LabelFetchingMetadata": "正在獲取元數據", - "LabelFile": "檔案", + "LabelFile": "文件", "LabelFileBirthtime": "檔案創建時間", "LabelFileModified": "檔案修改時間", "LabelFilename": "檔名", @@ -288,6 +302,7 @@ "LabelFinished": "已聽完", "LabelFolder": "資料夾", "LabelFolders": "資料夾", + "LabelFontBoldness": "字體粗細", "LabelFontFamily": "字體系列", "LabelFontItalic": "斜體", "LabelFontScale": "字體比例", @@ -353,7 +368,7 @@ "LabelMobileRedirectURIs": "允許移動應用重定向 URI", "LabelMobileRedirectURIsDescription": "這是移動應用程序的有效重定向 URI 白名單. 預設值為 audiobookshelf://oauth,您可以刪除它或加入其他 URI 以進行第三方應用集成. 使用星號 (*) 作為唯一條目允許任何 URI.", "LabelMore": "更多", - "LabelMoreInfo": "更多..", + "LabelMoreInfo": "更多信息", "LabelName": "名稱", "LabelNarrator": "講述者", "LabelNarrators": "講述者", @@ -399,7 +414,7 @@ "LabelPodcasts": "播客", "LabelPort": "埠", "LabelPrefixesToIgnore": "忽略的前綴 (不區分大小寫)", - "LabelPreventIndexing": "防止 iTunes 和 Google 播客目錄對你的源進行索引", + "LabelPreventIndexing": "防止您的訂閱源被 iTunes 和 Google 播客目錄索引", "LabelPrimaryEbook": "主電子書", "LabelProgress": "進度", "LabelProvider": "供應商", @@ -412,6 +427,7 @@ "LabelRSSFeedPreventIndexing": "防止索引", "LabelRSSFeedSlug": "RSS 源段", "LabelRSSFeedURL": "RSS 源 URL", + "LabelRandomly": "隨機", "LabelRead": "閱讀", "LabelReadAgain": "再次閱讀", "LabelReadEbookWithoutProgress": "閱讀電子書而不保存進度", @@ -635,20 +651,20 @@ "MessageNoFoldersAvailable": "沒有可用資料夾", "MessageNoGenres": "無流派", "MessageNoIssues": "無問題", - "MessageNoItems": "無項目", - "MessageNoItemsFound": "未找到任何項目", - "MessageNoListeningSessions": "無收聽會話", + "MessageNoItems": "沒有項目", + "MessageNoItemsFound": "沒有找到任何項目", + "MessageNoListeningSessions": "沒有收聽會話", "MessageNoLogs": "無日誌", "MessageNoMediaProgress": "無媒體進度", "MessageNoNotifications": "無通知", - "MessageNoPodcastsFound": "未找到播客", + "MessageNoPodcastsFound": "沒有找到播客", "MessageNoResults": "無結果", "MessageNoSearchResultsFor": "沒有搜尋到結果 \"{0}\"", "MessageNoSeries": "無系列", "MessageNoTags": "無標籤", "MessageNoTasksRunning": "沒有正在運行的任務", "MessageNoUpdatesWereNecessary": "無需更新", - "MessageNoUserPlaylists": "你沒有播放列表", + "MessageNoUserPlaylists": "您沒有播放列表", "MessageNotYetImplemented": "尚未實施", "MessageOr": "或", "MessagePauseChapter": "暫停章節播放", @@ -660,7 +676,7 @@ "MessageRemoveEpisodes": "移除 {0} 劇集", "MessageRemoveFromPlayerQueue": "從播放佇列中移除", "MessageRemoveUserWarning": "是否確實要永久刪除使用者 \"{0}\"?", - "MessageReportBugsAndContribute": "報告錯誤、請求功能和貢獻在", + "MessageReportBugsAndContribute": "報告錯誤、請求功能和做出貢獻", "MessageResetChaptersConfirm": "你確定要重置章節並撤消你所做的更改嗎?", "MessageRestoreBackupConfirm": "你確定要恢復創建的這個備份", "MessageRestoreBackupWarning": "恢復備份將覆蓋位於 /config 的整個資料庫並覆蓋 /metadata/items & /metadata/authors 中的圖像.

    備份不會修改媒體庫資料夾中的任何檔案. 如果您已啟用伺服器設定將封面和元數據存儲在庫資料夾中,則不會備份或覆蓋這些內容.

    將自動刷新使用伺服器的所有客戶端.", @@ -681,8 +697,8 @@ "NoteChangeRootPassword": "Root 是唯一可以擁有空密碼的使用者", "NoteChapterEditorTimes": "注意: 第一章開始時間必須保持在 0:00, 最後一章開始時間不能超過有聲書持續時間.", "NoteFolderPicker": "注意: 將不顯示已映射的資料夾", - "NoteRSSFeedPodcastAppsHttps": "警告: 大多數播客應用程序都需要 RSS 源 URL 使用 HTTPS", - "NoteRSSFeedPodcastAppsPubDate": "警告: 您的一集或多集沒有發布日期. 一些播客應用程序要求這樣做.", + "NoteRSSFeedPodcastAppsHttps": "警告:大多數播客應用程式要求 RSS 訂閱源 URL 使用 HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "警告:您的一個或多個劇集沒有發布日期。某些播客應用程式要求提供此資訊。", "NoteUploaderFoldersWithMediaFiles": "包含媒體檔案的資料夾將作為單獨的媒體庫項目處理.", "NoteUploaderOnlyAudioFiles": "如果只上傳音頻檔, 則每個音頻檔將作為單獨的有聲書處理.", "NoteUploaderUnsupportedFiles": "不支援的檔案將被忽略. 選擇或刪除資料夾時, 將忽略不在項目資料夾中的其他檔案.", @@ -705,7 +721,7 @@ "ToastBackupUploadSuccess": "備份已上傳", "ToastBatchUpdateFailed": "批量更新失敗", "ToastBatchUpdateSuccess": "批量更新成功", - "ToastBookmarkCreateFailed": "創建書籤失敗", + "ToastBookmarkCreateFailed": "創建書簽失敗", "ToastBookmarkCreateSuccess": "書籤已新增", "ToastBookmarkRemoveSuccess": "書籤已刪除", "ToastBookmarkUpdateSuccess": "書籤已更新", From 92d083164f0a15728578dd97152444cd1458e31e Mon Sep 17 00:00:00 2001 From: thehijacker Date: Mon, 11 Nov 2024 18:35:36 +0000 Subject: [PATCH 242/282] 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 | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index e3320ea877..0e04164dfe 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -136,7 +136,7 @@ "HeaderEmailSettings": "Nastavitve e-pošte", "HeaderEpisodes": "Epizode", "HeaderEreaderDevices": "E-bralniki", - "HeaderEreaderSettings": "Nastavitve ebralnika", + "HeaderEreaderSettings": "Nastavitve e-bralnika", "HeaderFiles": "Datoteke", "HeaderFindChapters": "Najdi poglavja", "HeaderIgnoredFiles": "Prezrte datoteke", @@ -366,7 +366,7 @@ "LabelHardDeleteFile": "Trdo brisanje datoteke", "LabelHasEbook": "Ima e-knjigo", "LabelHasSupplementaryEbook": "Ima dodatno e-knjigo", - "LabelHideSubtitles": "Skrij podnapise", + "LabelHideSubtitles": "Skrij podnaslove", "LabelHighestPriority": "Najvišja prioriteta", "LabelHost": "Gostitelj", "LabelHour": "Ura", @@ -568,8 +568,8 @@ "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", - "LabelSettingsParseSubtitlesHelp": "Izvleci podnapise iz imen map zvočnih knjig.
    Podnapis mora biti ločen z \" - \"
    npr. \"Naslov knjige – tu podnapis\" ima podnapis \"tu podnapis\"", + "LabelSettingsParseSubtitles": "Razčleni podnaslove", + "LabelSettingsParseSubtitlesHelp": "Izvleci padnaslove iz imen map zvočnih knjig.
    Podnaslov mora biti ločen z \" - \"
    npr. \"Naslov knjige – tu podnaslove\" ima podnaslov \"tu podnaslov\"", "LabelSettingsPreferMatchedMetadata": "Prednost imajo ujemajoči se metapodatki", "LabelSettingsPreferMatchedMetadataHelp": "Pri uporabi hitrega ujemanja bodo ujemajoči se podatki preglasili podrobnosti artikla. Hitro ujemanje bo privzeto izpolnil samo manjkajoče podrobnosti.", "LabelSettingsSkipMatchingBooksWithASIN": "Preskoči ujemajoče se knjige, ki že imajo ASIN", @@ -588,7 +588,7 @@ "LabelShareURL": "Deli URL", "LabelShowAll": "Prikaži vse", "LabelShowSeconds": "Prikaži sekunde", - "LabelShowSubtitles": "Prikaži podnapise", + "LabelShowSubtitles": "Prikaži podnaslove", "LabelSize": "Velikost", "LabelSleepTimer": "Časovnik za spanje", "LabelSlug": "Slug", @@ -611,7 +611,7 @@ "LabelStatsOverallDays": "Skupaj dnevi", "LabelStatsOverallHours": "Skupaj ur", "LabelStatsWeekListening": "Tednov poslušanja", - "LabelSubtitle": "Podnapis", + "LabelSubtitle": "Podnaslov", "LabelSupportedFileTypes": "Podprte vrste datotek", "LabelTag": "Oznaka", "LabelTags": "Oznake", From f941ea650032ecfc5a7dfcc7dbb06d329f5e2676 Mon Sep 17 00:00:00 2001 From: burghy86 Date: Tue, 12 Nov 2024 19:43:54 +0000 Subject: [PATCH 243/282] Translated using Weblate (Italian) 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/it/ --- client/strings/it.json | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/client/strings/it.json b/client/strings/it.json index 1450b972c3..42e83fe061 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -163,6 +163,7 @@ "HeaderNotificationUpdate": "Aggiornamento della notifica", "HeaderNotifications": "Notifiche", "HeaderOpenIDConnectAuthentication": "Autenticazione OpenID Connect", + "HeaderOpenListeningSessions": "Apri sessioni di ascolto", "HeaderOpenRSSFeed": "Apri il flusso RSS", "HeaderOtherFiles": "Altri File", "HeaderPasswordAuthentication": "Autenticazione della password", @@ -180,6 +181,7 @@ "HeaderRemoveEpisodes": "Rimuovi {0} Episodi", "HeaderSavedMediaProgress": "Progressi salvati", "HeaderSchedule": "Schedula", + "HeaderScheduleEpisodeDownloads": "Imposta il download automatico degli episodi", "HeaderScheduleLibraryScans": "Schedula la scansione della libreria", "HeaderSession": "Sessione", "HeaderSetBackupSchedule": "Imposta programmazione Backup", @@ -225,6 +227,7 @@ "LabelAllUsersExcludingGuests": "Tutti gli Utenti Esclusi gli ospiti", "LabelAllUsersIncludingGuests": "Tutti gli Utenti Inclusi gli ospiti", "LabelAlreadyInYourLibrary": "Già esistente nella libreria", + "LabelApiToken": "API Token", "LabelAppend": "Appese", "LabelAudioBitrate": "Audio Bitrate (es. 128k)", "LabelAudioChannels": "Canali Audio (1 o 2)", @@ -250,15 +253,18 @@ "LabelBackupsNumberToKeep": "Numero di backup da mantenere", "LabelBackupsNumberToKeepHelp": "Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.", "LabelBitrate": "Velocità di trasmissione", + "LabelBonus": "Bonus", "LabelBooks": "Libri", "LabelButtonText": "Buttone Testo", "LabelByAuthor": "da {0}", "LabelChangePassword": "Cambia Password", "LabelChannels": "Canali", + "LabelChapterCount": "{0} Capitoli", "LabelChapterTitle": "Titoli dei Capitoli", "LabelChapters": "Capitoli", "LabelChaptersFound": "Capitoli Trovati", "LabelClickForMoreInfo": "Click per altre Info", + "LabelClickToUseCurrentValue": "Clicca per usare il valore corrente", "LabelClosePlayer": "Chiudi player", "LabelCodec": "Codec", "LabelCollapseSeries": "Comprimi Serie", @@ -320,9 +326,13 @@ "LabelEnd": "Fine", "LabelEndOfChapter": "Fine Capitolo", "LabelEpisode": "Episodio", + "LabelEpisodeNotLinkedToRssFeed": "Episode non linkati nel RSS feed", + "LabelEpisodeNumber": "Episodio #{0}", "LabelEpisodeTitle": "Titolo Episodio", "LabelEpisodeType": "Tipo Episodio", + "LabelEpisodeUrlFromRssFeed": "URL dell'episodio dal RSS feed", "LabelEpisodes": "Episodi", + "LabelEpisodic": "Episodico", "LabelExample": "Esempio", "LabelExpandSeries": "Espandi Serie", "LabelExpandSubSeries": "Espandi Sub Serie", @@ -350,6 +360,7 @@ "LabelFontScale": "Dimensione font", "LabelFontStrikethrough": "Barrato", "LabelFormat": "Formato", + "LabelFull": "Pieno", "LabelGenre": "Genere", "LabelGenres": "Generi", "LabelHardDeleteFile": "Elimina Definitivamente", @@ -405,6 +416,10 @@ "LabelLowestPriority": "Priorità Minima", "LabelMatchExistingUsersBy": "Abbina gli utenti esistenti per", "LabelMatchExistingUsersByDescription": "Utilizzato per connettere gli utenti esistenti. Una volta connessi, gli utenti verranno abbinati a un ID univoco dal tuo provider SSO", + "LabelMaxEpisodesToDownload": "Max # di episodi da scaricare. Usa 0 per illimitati.", + "LabelMaxEpisodesToDownloadPerCheck": "Massimo # di nuovi episodi da scaricare per il controllo", + "LabelMaxEpisodesToKeep": "Massimo # di episodi da tenere", + "LabelMaxEpisodesToKeepHelp": "Il valore 0 non imposta alcun limite massimo. Dopo che un nuovo episodio è stato scaricato automaticamente, questo eliminerà l'episodio più vecchio se hai più di X episodi. Questo eliminerà solo 1 episodio per ogni nuovo download.", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Tipo Media", "LabelMetaTag": "Meta Tag", @@ -450,12 +465,14 @@ "LabelOpenIDGroupClaimDescription": "Nome dell'attestazione OpenID che contiene un elenco dei gruppi dell'utente. Comunemente indicato come gruppo. se configurato, l'applicazione assegnerà automaticamente i ruoli in base alle appartenenze ai gruppi dell'utente, a condizione che tali gruppi siano denominati \"admin\", \"utente\" o \"ospite\" senza distinzione tra maiuscole e minuscole nell'attestazione. L'attestazione deve contenere un elenco e, se un utente appartiene a più gruppi, l'applicazione assegnerà il ruolo corrispondente al livello di accesso più alto. Se nessun gruppo corrisponde, l'accesso verrà negato.", "LabelOpenRSSFeed": "Apri RSS Feed", "LabelOverwrite": "Sovrascrivi", + "LabelPaginationPageXOfY": "Pagina {0} di {1}", "LabelPassword": "Password", "LabelPath": "Percorso", "LabelPermanent": "Permanente", "LabelPermissionsAccessAllLibraries": "Può accedere a tutte le librerie", "LabelPermissionsAccessAllTags": "Può accedere a tutti i tag", "LabelPermissionsAccessExplicitContent": "Può accedere a contenuti espliciti", + "LabelPermissionsCreateEreader": "Può creare un e-reader", "LabelPermissionsDelete": "Può Cancellare", "LabelPermissionsDownload": "Può Scaricare", "LabelPermissionsUpdate": "Può Aggiornare", @@ -500,18 +517,24 @@ "LabelRedo": "Rifai", "LabelRegion": "Regione", "LabelReleaseDate": "Data Release", + "LabelRemoveAllMetadataAbs": "Remuovi tutti i metadata.abs files", + "LabelRemoveAllMetadataJson": "Rimuovi tutti i metadata.json files", "LabelRemoveCover": "Rimuovi cover", + "LabelRemoveMetadataFile": "Rimuovi i file metadata nella cartella della libreria", + "LabelRemoveMetadataFileHelp": "Rimuovi tutti i file metadata.json e i file metadata.abs nelle tue {0} cartelle.", "LabelRowsPerPage": "Righe per pagina", "LabelSearchTerm": "Ricerca", "LabelSearchTitle": "Cerca Titolo", "LabelSearchTitleOrASIN": "Cerca titolo o ASIN", "LabelSeason": "Stagione", + "LabelSeasonNumber": "Stagione #{0}", "LabelSelectAll": "Seleziona tutto", "LabelSelectAllEpisodes": "Seleziona tutti gli Episodi", "LabelSelectEpisodesShowing": "Selezionati {0} episodi da visualizzare", "LabelSelectUsers": "Selezione Utenti", "LabelSendEbookToDevice": "Invia il libro a...", "LabelSequence": "Sequenza", + "LabelSerial": "Seriale", "LabelSeries": "Serie", "LabelSeriesName": "Nome Serie", "LabelSeriesProgress": "Cominciato", @@ -540,6 +563,9 @@ "LabelSettingsHideSingleBookSeriesHelp": "Le serie che hanno un solo libro saranno nascoste dalla pagina della serie e dagli scaffali della home page.", "LabelSettingsHomePageBookshelfView": "Home page con sfondo legno", "LabelSettingsLibraryBookshelfView": "Libreria con sfondo legno", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "La percentuale di completamento è maggiore di", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Il tempo rimanente è inferiore a (secondi)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Contrassegna l'elemento multimediale come terminato quando", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Salta i libri precedenti nella serie Continua", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Lo scaffale della home page Continua serie mostra il primo libro non iniziato della serie che ha almeno un libro finito e nessun libro in corso. Abilitando questa impostazione le serie continueranno dal libro completato più lontano invece che dal primo libro non iniziato.", "LabelSettingsParseSubtitles": "Analizza sottotitoli", @@ -604,6 +630,7 @@ "LabelTimeDurationXMinutes": "{0} minuti", "LabelTimeDurationXSeconds": "{0} secondi", "LabelTimeInMinutes": "Tempo in minuti", + "LabelTimeLeft": "{0} sinistra", "LabelTimeListened": "Tempo di Ascolto", "LabelTimeListenedToday": "Tempo di Ascolto Oggi", "LabelTimeRemaining": "{0} rimanente", @@ -624,6 +651,7 @@ "LabelTracksMultiTrack": "Multi-traccia", "LabelTracksNone": "Nessuna traccia", "LabelTracksSingleTrack": "Traccia-singola", + "LabelTrailer": "Trailer", "LabelType": "Tipo", "LabelUnabridged": "Integrale", "LabelUndo": "Annulla", @@ -640,6 +668,7 @@ "LabelUseAdvancedOptions": "Usa le opzioni avanzate", "LabelUseChapterTrack": "Usa il Capitolo della Traccia", "LabelUseFullTrack": "Usa la traccia totale", + "LabelUseZeroForUnlimited": "Usa 0 per illimitato", "LabelUser": "Utente", "LabelUsername": "Nome utente", "LabelValue": "Valore", @@ -698,6 +727,7 @@ "MessageConfirmPurgeCache": "L'eliminazione della cache eliminerà l'intera directory dei /metadata/cache.

    Sei sicuro di voler rimuovere la directory della cache?", "MessageConfirmPurgeItemsCache": "L'eliminazione della cache degli elementi eliminerà l'intera directory /metadata/cache/oggetti.
    Sei sicuro?", "MessageConfirmQuickEmbed": "Attenzione! L'incorporamento rapido non eseguirà il backup dei file audio. Assicurati di avere un backup dei tuoi file audio.

    Vuoi Continuare?", + "MessageConfirmQuickMatchEpisodes": "Gli episodi di corrispondenza rapida sovrascriveranno i dettagli se viene trovata una corrispondenza. Saranno aggiornati solo gli episodi non corrispondenti. Sei sicuro?", "MessageConfirmReScanLibraryItems": "Sei sicuro di voler ripetere la scansione? {0} oggetti?", "MessageConfirmRemoveAllChapters": "Sei sicuro di voler rimuovere tutti i capitoli?", "MessageConfirmRemoveAuthor": "Sei sicuro di voler rimuovere l'autore? \"{0}\"?", @@ -705,6 +735,7 @@ "MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?", "MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?", "MessageConfirmRemoveListeningSessions": "Sei sicuro di voler rimuovere {0} sessioni di Ascolto?", + "MessageConfirmRemoveMetadataFiles": "Vuoi davvero rimuovere tutti i metadati.{0} file nelle cartelle degli elementi della tua libreria?", "MessageConfirmRemoveNarrator": "Sei sicuro di voler rimuovere il narratore \"{0}\"?", "MessageConfirmRemovePlaylist": "Sei sicuro di voler rimuovere la tua playlist \"{0}\"?", "MessageConfirmRenameGenre": "Sei sicuro di voler rinominare il genere \"{0}\" in \"{1}\" per tutti gli oggetti?", @@ -785,6 +816,7 @@ "MessagePodcastSearchField": "Inserisci il termine di ricerca o l'URL del feed RSS", "MessageQuickEmbedInProgress": "Incorporamento rapido in corso", "MessageQuickEmbedQueue": "In coda per incorporamento rapido ({0} in coda)", + "MessageQuickMatchAllEpisodes": "Associamento veloce di Tutti gli episodi", "MessageQuickMatchDescription": "Compila i dettagli dell'articolo vuoto e copri con il risultato della prima corrispondenza di '{0}'. Non sovrascrive i dettagli a meno che non sia abilitata l'impostazione del server \"Preferisci metadati corrispondenti\".", "MessageRemoveChapter": "Rimuovi Capitolo", "MessageRemoveEpisodes": "rimuovi {0} episodio(i)", @@ -883,6 +915,7 @@ "StatsYearInReview": "ANNO IN RASSEGNA", "ToastAccountUpdateSuccess": "Account Aggiornato", "ToastAppriseUrlRequired": "È necessario immettere un indirizzo Apprise", + "ToastAsinRequired": "L'ASIN è obbligatorio", "ToastAuthorImageRemoveSuccess": "Immagine Autore Rimossa", "ToastAuthorNotFound": "Autore\"{0}\" non trovato", "ToastAuthorRemoveSuccess": "Autore rimosso", @@ -902,6 +935,8 @@ "ToastBackupUploadSuccess": "Backup caricato", "ToastBatchDeleteFailed": "Eliminazione batch non riuscita", "ToastBatchDeleteSuccess": "Eliminazione batch riuscita", + "ToastBatchQuickMatchFailed": "Batch Quick Match non riuscito!", + "ToastBatchQuickMatchStarted": "Avviata la ricerca rapida in batch di {0} libri!", "ToastBatchUpdateFailed": "Batch di aggiornamento fallito", "ToastBatchUpdateSuccess": "Batch di aggiornamento finito", "ToastBookmarkCreateFailed": "Creazione segnalibro fallita", @@ -913,6 +948,7 @@ "ToastChaptersHaveErrors": "I capitoli contengono errori", "ToastChaptersMustHaveTitles": "I capitoli devono avere titoli", "ToastChaptersRemoved": "Capitoli rimossi", + "ToastChaptersUpdated": "Capitoli aggiornati", "ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito", "ToastCollectionItemsAddSuccess": "L'aggiunta dell'elemento(i) alla raccolta è riuscito", "ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta", @@ -930,11 +966,14 @@ "ToastEncodeCancelSucces": "Codifica annullata", "ToastEpisodeDownloadQueueClearFailed": "Impossibile cancellare la coda", "ToastEpisodeDownloadQueueClearSuccess": "Coda di download degli episodi cancellata", + "ToastEpisodeUpdateSuccess": "{0} episodi aggiornati", "ToastErrorCannotShare": "Impossibile condividere in modo nativo su questo dispositivo", "ToastFailedToLoadData": "Impossibile caricare i dati", + "ToastFailedToMatch": "Impossibile abbinare", "ToastFailedToShare": "Impossibile condividere", "ToastFailedToUpdate": "Non aggiornato", "ToastInvalidImageUrl": "URL dell'immagine non valido", + "ToastInvalidMaxEpisodesToDownload": "Numero massimo di episodi non valido da scaricare", "ToastInvalidUrl": "URL non valido", "ToastItemCoverUpdateSuccess": "Cover aggiornata", "ToastItemDeletedFailed": "Impossibile eliminare l'elemento", @@ -953,14 +992,21 @@ "ToastLibraryScanStarted": "Scansione Libreria iniziata", "ToastLibraryUpdateSuccess": "Libreria \"{0}\" aggiornata", "ToastMatchAllAuthorsFailed": "Tutti gli autori non sono potuti essere classificati", + "ToastMetadataFilesRemovedError": "Errore durante la rimozione dei metadati. {0} file", + "ToastMetadataFilesRemovedNoneFound": "Nessun metadato. {0} file trovati nella libreria", + "ToastMetadataFilesRemovedNoneRemoved": "Nessun metadato. {0} file rimossi", + "ToastMetadataFilesRemovedSuccess": "{0} metadati.{1} file rimossi", + "ToastMustHaveAtLeastOnePath": "Deve avere almeno un percorso", "ToastNameEmailRequired": "Nome ed email sono obbligatori", "ToastNameRequired": "Il nome è obbligatorio", + "ToastNewEpisodesFound": "{0} nuovi episodi trovati", "ToastNewUserCreatedFailed": "Impossibile creare l'account: \"{0}\"", "ToastNewUserCreatedSuccess": "Nuovo account creato", "ToastNewUserLibraryError": "È necessario selezionare almeno una libreria", "ToastNewUserPasswordError": "Deve avere una password, solo l'utente root può avere una password vuota", "ToastNewUserTagError": "Devi selezionare almeno un tag", "ToastNewUserUsernameError": "Inserisci un nome utente", + "ToastNoNewEpisodesFound": "Nessun nuovo episodio trovato", "ToastNoUpdatesNecessary": "Nessun aggiornamento necessario", "ToastNotificationCreateFailed": "Impossibile creare la notifica", "ToastNotificationDeleteFailed": "Impossibile eliminare la notifica", @@ -979,6 +1025,7 @@ "ToastPodcastGetFeedFailed": "Impossibile ottenere il feed del podcast", "ToastPodcastNoEpisodesInFeed": "Nessun episodio trovato nel feed RSS", "ToastPodcastNoRssFeed": "Il podcast non ha un feed RSS", + "ToastProgressIsNotBeingSynced": "L'avanzamento non è sincronizzato, riavviare la riproduzione", "ToastProviderCreatedFailed": "Impossibile aggiungere il provider", "ToastProviderCreatedSuccess": "Aggiunto nuovo provider", "ToastProviderNameAndUrlRequired": "Nome e URL richiesti", @@ -1005,6 +1052,7 @@ "ToastSessionCloseFailed": "Disconnessione Fallita", "ToastSessionDeleteFailed": "Errore eliminazione sessione", "ToastSessionDeleteSuccess": "Sessione cancellata", + "ToastSleepTimerDone": "Timer di spegnimento eseguito... zZzzZz", "ToastSlugMustChange": "Lo slug contiene caratteri non validi", "ToastSlugRequired": "È richiesto lo slug", "ToastSocketConnected": "Socket connesso", From 997afc1b2f6bd0055f75e47c16db95d119c2e617 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Tue, 12 Nov 2024 06:13:00 +0000 Subject: [PATCH 244/282] 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index 0e04164dfe..bbcf8055a2 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -407,7 +407,7 @@ "LabelLibraryItem": "Element knjižnice", "LabelLibraryName": "Ime knjižnice", "LabelLimit": "Omejitev", - "LabelLineSpacing": "Razmik med vrsticami", + "LabelLineSpacing": "Vrstični razmak", "LabelListenAgain": "Poslušaj znova", "LabelLogLevelDebug": "Odpravljanje napak", "LabelLogLevelInfo": "Info", From 45f8b06d569df68a6cf7b14ae4db72e1fff34ac9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 15 Nov 2024 08:30:54 -0600 Subject: [PATCH 245/282] Fix:CBC Radio podcast RSS feeds not accepting our user-agent string #3322 --- server/utils/podcastUtils.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 92679903ba..ac96c8d049 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -228,6 +228,13 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`) + let userAgent = 'audiobookshelf (+https://audiobookshelf.org; like iTMS)' + // Workaround for CBC RSS feeds rejecting our user agent string + // See: https://github.com/advplyr/audiobookshelf/issues/3322 + if (feedUrl.startsWith('https://www.cbc.ca')) { + userAgent = 'audiobookshelf (+https://audiobookshelf.org; like iTMS) - CBC' + } + return axios({ url: feedUrl, method: 'GET', @@ -235,7 +242,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { responseType: 'arraybuffer', headers: { Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8', - 'User-Agent': 'audiobookshelf (+https://audiobookshelf.org; like iTMS)' + 'User-Agent': userAgent }, httpAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl), httpsAgent: global.DisableSsrfRequestFilter ? null : ssrfFilter(feedUrl) From 5ccf5d7150bb94319987070ba10652b93cdbcab2 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 16 Nov 2024 06:26:32 +0200 Subject: [PATCH 246/282] Use a simpler database fetch in fullUpdateFromOld --- server/models/LibraryItem.js | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 17c3b12586..e867a96a8b 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -237,35 +237,7 @@ class LibraryItem extends Model { * @returns {Promise} true if updates were made */ static async fullUpdateFromOld(oldLibraryItem) { - const libraryItemExpanded = await this.findByPk(oldLibraryItem.id, { - include: [ - { - model: this.sequelize.models.book, - include: [ - { - model: this.sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: this.sequelize.models.series, - through: { - attributes: ['id', 'sequence'] - } - } - ] - }, - { - model: this.sequelize.models.podcast, - include: [ - { - model: this.sequelize.models.podcastEpisode - } - ] - } - ] - }) + const libraryItemExpanded = await this.getExpandedById(oldLibraryItem.id) if (!libraryItemExpanded) return false let hasUpdates = false From d5fbc1d45592414a5684a89bc40940a42020a020 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sun, 17 Nov 2024 12:22:15 -0700 Subject: [PATCH 247/282] Add: statement about workflows passing --- .github/pull_request_template.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f41e46cce7..0cd521a5b5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,13 +1,20 @@ ## Brief summary - + + +## Which issue is fixed? + + ## In-depth Description From 2b7e3f0efe6fae0d6138cb95ac72224f81b31bfc Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 17 Nov 2024 15:45:21 -0600 Subject: [PATCH 248/282] Update uuid migration to v2.17.0 and for all tables still using UUIDv4 --- server/migrations/changelog.md | 12 +-- server/migrations/v2.16.3-uuid-replacement.js | 50 ---------- server/migrations/v2.17.0-uuid-replacement.js | 98 +++++++++++++++++++ server/models/Feed.js | 2 +- server/models/MediaItemShare.js | 2 +- server/models/MediaProgress.js | 2 +- server/models/PlaybackSession.js | 2 +- server/models/PlaylistMediaItem.js | 2 +- 8 files changed, 109 insertions(+), 61 deletions(-) delete mode 100644 server/migrations/v2.16.3-uuid-replacement.js create mode 100644 server/migrations/v2.17.0-uuid-replacement.js diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index bffd4682d6..8960ade2f7 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,9 +2,9 @@ Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. -| Server Version | Migration Script Name | Description | -| -------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------- | -| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | -| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | -| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | -| v2.16.3 | v2.16.3-uuid-replacement | Changes `mediaId` column in `libraryItem` table to match the primary key type of `books` and `podcasts` | +| Server Version | Migration Script Name | Description | +| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | +| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | +| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | +| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | diff --git a/server/migrations/v2.16.3-uuid-replacement.js b/server/migrations/v2.16.3-uuid-replacement.js deleted file mode 100644 index 66bf21ac98..0000000000 --- a/server/migrations/v2.16.3-uuid-replacement.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * @typedef MigrationContext - * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. - * @property {import('../Logger')} logger - a Logger object. - * - * @typedef MigrationOptions - * @property {MigrationContext} context - an object containing the migration context. - */ - -/** - * This upward migration script changes the `mediaId` column in the `libraryItems` table to be a UUID and match other tables. - * - * @param {MigrationOptions} options - an object containing the migration context. - * @returns {Promise} - A promise that resolves when the migration is complete. - */ -async function up({ context: { queryInterface, logger } }) { - // Upwards migration script - logger.info('[2.16.3 migration] UPGRADE BEGIN: 2.16.3-uuid-replacement') - - // Change mediaId column to using the query interface - logger.info('[2.16.3 migration] Changing mediaId column to UUID') - await queryInterface.changeColumn('libraryItems', 'mediaId', { - type: 'UUID' - }) - - // Completed migration - logger.info('[2.16.3 migration] UPGRADE END: 2.16.3-uuid-replacement') -} - -/** - * This downward migration script changes the `mediaId` column in the `libraryItems` table to be a UUIDV4 again. - * - * @param {MigrationOptions} options - an object containing the migration context. - * @returns {Promise} - A promise that resolves when the migration is complete. - */ -async function down({ context: { queryInterface, logger } }) { - // Downward migration script - logger.info('[2.16.3 migration] DOWNGRADE BEGIN: 2.16.3-uuid-replacement') - - // Change mediaId column to using the query interface - logger.info('[2.16.3 migration] Changing mediaId column to UUIDV4') - await queryInterface.changeColumn('libraryItems', 'mediaId', { - type: 'UUIDV4' - }) - - // Completed migration - logger.info('[2.16.3 migration] DOWNGRADE END: 2.16.3-uuid-replacement') -} - -module.exports = { up, down } diff --git a/server/migrations/v2.17.0-uuid-replacement.js b/server/migrations/v2.17.0-uuid-replacement.js new file mode 100644 index 0000000000..6460b79526 --- /dev/null +++ b/server/migrations/v2.17.0-uuid-replacement.js @@ -0,0 +1,98 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration script changes table columns with data type UUIDv4 to UUID to match associated models. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('[2.17.0 migration] UPGRADE BEGIN: 2.17.0-uuid-replacement') + + logger.info('[2.17.0 migration] Changing libraryItems.mediaId column to UUID') + await queryInterface.changeColumn('libraryItems', 'mediaId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing feeds.entityId column to UUID') + await queryInterface.changeColumn('feeds', 'entityId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID') + await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUID') + await queryInterface.changeColumn('playbackSessions', 'mediaItemId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing playlistMediaItems.mediaItemId column to UUID') + await queryInterface.changeColumn('playlistMediaItems', 'mediaItemId', { + type: 'UUID' + }) + + logger.info('[2.17.0 migration] Changing mediaProgresses.mediaItemId column to UUID') + await queryInterface.changeColumn('mediaProgresses', 'mediaItemId', { + type: 'UUID' + }) + + // Completed migration + logger.info('[2.17.0 migration] UPGRADE END: 2.17.0-uuid-replacement') +} + +/** + * This downward migration script changes table columns data type back to UUIDv4. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('[2.17.0 migration] DOWNGRADE BEGIN: 2.17.0-uuid-replacement') + + logger.info('[2.17.0 migration] Changing libraryItems.mediaId column to UUIDV4') + await queryInterface.changeColumn('libraryItems', 'mediaId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing feeds.entityId column to UUIDV4') + await queryInterface.changeColumn('feeds', 'entityId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('playbackSessions', 'mediaItemId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing playlistMediaItems.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('playlistMediaItems', 'mediaItemId', { + type: 'UUIDV4' + }) + + logger.info('[2.17.0 migration] Changing mediaProgresses.mediaItemId column to UUIDV4') + await queryInterface.changeColumn('mediaProgresses', 'mediaItemId', { + type: 'UUIDV4' + }) + + // Completed migration + logger.info('[2.17.0 migration] DOWNGRADE END: 2.17.0-uuid-replacement') +} + +module.exports = { up, down } diff --git a/server/models/Feed.js b/server/models/Feed.js index 72321da926..4f51e66d9e 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -274,7 +274,7 @@ class Feed extends Model { }, slug: DataTypes.STRING, entityType: DataTypes.STRING, - entityId: DataTypes.UUIDV4, + entityId: DataTypes.UUID, entityUpdatedAt: DataTypes.DATE, serverAddress: DataTypes.STRING, feedURL: DataTypes.STRING, diff --git a/server/models/MediaItemShare.js b/server/models/MediaItemShare.js index ffdc3ddd1f..38b8dbbf4b 100644 --- a/server/models/MediaItemShare.js +++ b/server/models/MediaItemShare.js @@ -109,7 +109,7 @@ class MediaItemShare extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + mediaItemId: DataTypes.UUID, mediaItemType: DataTypes.STRING, slug: DataTypes.STRING, pash: DataTypes.STRING, diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index d6a527f742..80204ef5cc 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -93,7 +93,7 @@ class MediaProgress extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + mediaItemId: DataTypes.UUID, mediaItemType: DataTypes.STRING, duration: DataTypes.FLOAT, currentTime: DataTypes.FLOAT, diff --git a/server/models/PlaybackSession.js b/server/models/PlaybackSession.js index c7c6323af6..196fbda6ce 100644 --- a/server/models/PlaybackSession.js +++ b/server/models/PlaybackSession.js @@ -179,7 +179,7 @@ class PlaybackSession extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + mediaItemId: DataTypes.UUID, mediaItemType: DataTypes.STRING, displayTitle: DataTypes.STRING, displayAuthor: DataTypes.STRING, diff --git a/server/models/PlaylistMediaItem.js b/server/models/PlaylistMediaItem.js index 25e7b8c553..1c53bea115 100644 --- a/server/models/PlaylistMediaItem.js +++ b/server/models/PlaylistMediaItem.js @@ -45,7 +45,7 @@ class PlaylistMediaItem extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - mediaItemId: DataTypes.UUIDV4, + mediaItemId: DataTypes.UUID, mediaItemType: DataTypes.STRING, order: DataTypes.INTEGER }, From 75eef8d722f0f84c0ebbc5a5d714baf3602baf56 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 17 Nov 2024 16:00:44 -0600 Subject: [PATCH 249/282] Fix:Book library sort by publishedYear #3620 - Updated sort to cast publishedYear to INTEGER --- server/utils/queries/libraryItemsBookFilters.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index e8b424ed97..b2784f5ddd 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -259,7 +259,7 @@ module.exports = { } else if (sortBy === 'media.duration') { return [['duration', dir]] } else if (sortBy === 'media.metadata.publishedYear') { - return [['publishedYear', dir]] + return [[Sequelize.literal(`CAST(\`book\`.\`publishedYear\` AS INTEGER)`), dir]] } else if (sortBy === 'media.metadata.authorNameLF') { return [[Sequelize.literal('author_name COLLATE NOCASE'), dir]] } else if (sortBy === 'media.metadata.authorName') { From 9940f1d6dbd12773b2a41059b81dc12228ea8457 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Fri, 15 Nov 2024 08:28:00 +0000 Subject: [PATCH 250/282] Translated using Weblate (Spanish) 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/es/ --- client/strings/es.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/strings/es.json b/client/strings/es.json index d9e24723c7..06aa1b8b52 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -71,8 +71,8 @@ "ButtonQuickMatch": "Encontrar Rápido", "ButtonReScan": "Re-Escanear", "ButtonRead": "Leer", - "ButtonReadLess": "Lea menos", - "ButtonReadMore": "Lea mas", + "ButtonReadLess": "Leer menos", + "ButtonReadMore": "Leer más", "ButtonRefresh": "Refrecar", "ButtonRemove": "Remover", "ButtonRemoveAll": "Remover Todos", @@ -220,7 +220,7 @@ "LabelAddToPlaylist": "Añadido a la lista de reproducción", "LabelAddToPlaylistBatch": "Se Añadieron {0} Artículos a la Lista de Reproducción", "LabelAddedAt": "Añadido", - "LabelAddedDate": "Añadido {0}", + "LabelAddedDate": "{0} Añadido", "LabelAdminUsersOnly": "Solamente usuarios administradores", "LabelAll": "Todos", "LabelAllUsers": "Todos los Usuarios", @@ -681,8 +681,8 @@ "LabelWeekdaysToRun": "Correr en Días de la Semana", "LabelXBooks": "{0} libros", "LabelXItems": "{0} elementos", - "LabelYearReviewHide": "Ocultar Year in Review", - "LabelYearReviewShow": "Ver Year in Review", + "LabelYearReviewHide": "Ocultar Resumen del año", + "LabelYearReviewShow": "Resumen del año", "LabelYourAudiobookDuration": "Duración de tu Audiolibro", "LabelYourBookmarks": "Tus Marcadores", "LabelYourPlaylists": "Tus Listas", @@ -779,7 +779,7 @@ "MessageNoBackups": "Sin Respaldos", "MessageNoBookmarks": "Sin marcadores", "MessageNoChapters": "Sin capítulos", - "MessageNoCollections": "Sin Colecciones", + "MessageNoCollections": "Sin colecciones", "MessageNoCoversFound": "Ninguna Portada Encontrada", "MessageNoDescription": "Sin Descripción", "MessageNoDevices": "Sin dispositivos", From 26ef33a4b6188bda0a480e290c95f8c98b903000 Mon Sep 17 00:00:00 2001 From: biuklija Date: Thu, 14 Nov 2024 12:02:48 +0000 Subject: [PATCH 251/282] 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index d7d0fde54e..502973c441 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -813,7 +813,7 @@ "MessagePlaylistCreateFromCollection": "Stvori popis za izvođenje od zbirke", "MessagePleaseWait": "Molimo pričekajte...", "MessagePodcastHasNoRSSFeedForMatching": "Podcast nema adresu RSS izvora za prepoznavanje", - "MessagePodcastSearchField": "Unesite upit za pretragu ili URL RSS izvora", + "MessagePodcastSearchField": "Upišite izraz za pretraživanje ili URL RSS izvora", "MessageQuickEmbedInProgress": "Brzo ugrađivanje u tijeku", "MessageQuickEmbedQueue": "Dodano u red za brzo ugrađivanje ({0} u redu izvođenja)", "MessageQuickMatchAllEpisodes": "Brzo prepoznavanje svih nastavaka", From 3e6a2d670ece6433a8fe4c198d96d09b4d969c8c Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Thu, 14 Nov 2024 20:19:09 +0000 Subject: [PATCH 252/282] Translated using Weblate (Ukrainian) 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/uk/ --- client/strings/uk.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index 668ccd33e7..e4aecc90ad 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -813,7 +813,7 @@ "MessagePlaylistCreateFromCollection": "Створити список відтворення з добірки", "MessagePleaseWait": "Будь ласка, зачекайте...", "MessagePodcastHasNoRSSFeedForMatching": "Подкаст не має RSS-каналу для пошуку", - "MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS фіду", + "MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS-стрічки", "MessageQuickEmbedInProgress": "Швидке вбудовування в процесі", "MessageQuickEmbedQueue": "В черзі на швидке вбудовування ({0} в черзі)", "MessageQuickMatchAllEpisodes": "Швидке співставлення всіх епізодів", From cf19dd23cf2d6dfcb5f7e1d8db6528694e3cfa45 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Fri, 15 Nov 2024 11:26:33 +0000 Subject: [PATCH 253/282] 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 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 2830a7101a..072cbd39ef 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -71,7 +71,7 @@ "ButtonQuickMatch": "快速匹配", "ButtonReScan": "重新扫描", "ButtonRead": "读取", - "ButtonReadLess": "阅读更少", + "ButtonReadLess": "阅读较少", "ButtonReadMore": "阅读更多", "ButtonRefresh": "刷新", "ButtonRemove": "移除", @@ -220,7 +220,7 @@ "LabelAddToPlaylist": "添加到播放列表", "LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表", "LabelAddedAt": "添加于", - "LabelAddedDate": "添加 {0}", + "LabelAddedDate": "已添加 {0}", "LabelAdminUsersOnly": "仅限管理员用户", "LabelAll": "全部", "LabelAllUsers": "所有用户", From b5f0a6f4a6f9c43fdbe06bc6a3aeb68b2c07aec2 Mon Sep 17 00:00:00 2001 From: DR Date: Sat, 16 Nov 2024 21:01:30 +0000 Subject: [PATCH 254/282] Translated using Weblate (Hebrew) Currently translated at 70.5% (756 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/he/ --- client/strings/he.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/he.json b/client/strings/he.json index 9f7822b9b1..23b9fb723d 100644 --- a/client/strings/he.json +++ b/client/strings/he.json @@ -8,10 +8,10 @@ "ButtonAddYourFirstLibrary": "הוסף את הספרייה הראשונה שלך", "ButtonApply": "החל", "ButtonApplyChapters": "החל פרקים", - "ButtonAuthors": "יוצרים", + "ButtonAuthors": "סופרים", "ButtonBack": "חזור", "ButtonBrowseForFolder": "עיין בתיקייה", - "ButtonCancel": "בטל", + "ButtonCancel": "ביטול", "ButtonCancelEncode": "בטל קידוד", "ButtonChangeRootPassword": "שנה סיסמת root", "ButtonCheckAndDownloadNewEpisodes": "בדוק והורד פרקים חדשים", From d25a21cd3241b5be585a70f7e973fb5dd44f90c3 Mon Sep 17 00:00:00 2001 From: Paulo Henrique Dos Santos Garcia Date: Sat, 16 Nov 2024 09:59:01 +0000 Subject: [PATCH 255/282] Translated using Weblate (Portuguese (Brazil)) Currently translated at 72.6% (778 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pt_BR/ --- client/strings/pt-br.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/strings/pt-br.json b/client/strings/pt-br.json index 68fad736ef..7df7c47dec 100644 --- a/client/strings/pt-br.json +++ b/client/strings/pt-br.json @@ -258,12 +258,15 @@ "LabelDiscFromFilename": "Disco a partir do nome do arquivo", "LabelDiscFromMetadata": "Disco a partir dos metadados", "LabelDiscover": "Descobrir", + "LabelDownload": "Download", "LabelDownloadNEpisodes": "Download de {0} Episódios", "LabelDuration": "Duração", "LabelDurationComparisonExactMatch": "(exato)", "LabelDurationComparisonLonger": "({0} maior)", "LabelDurationComparisonShorter": "({0} menor)", "LabelDurationFound": "Duração comprovada:", + "LabelEbook": "Ebook", + "LabelEbooks": "Ebooks", "LabelEdit": "Editar", "LabelEmailSettingsFromAddress": "Remetente", "LabelEmailSettingsRejectUnauthorized": "Rejeitar certificados não autorizados", From 4cfd18c81ac4a4b0859d112a9875f47ee4a49bf4 Mon Sep 17 00:00:00 2001 From: Mohamad Dahhan Date: Sat, 16 Nov 2024 00:46:34 +0000 Subject: [PATCH 256/282] Translated using Weblate (Arabic) Currently translated at 3.8% (41 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/ --- client/strings/ar.json | 44 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/client/strings/ar.json b/client/strings/ar.json index 0967ef424b..673b0238f5 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -1 +1,43 @@ -{} +{ + "ButtonAdd": "إضافة", + "ButtonAddChapters": "إضافة الفصول", + "ButtonAddDevice": "إضافة جهاز", + "ButtonAddLibrary": "إضافة مكتبة", + "ButtonAddPodcasts": "إضافة بودكاست", + "ButtonAddUser": "إضافة مستخدم", + "ButtonAddYourFirstLibrary": "أضف مكتبتك الأولى", + "ButtonApply": "حفظ", + "ButtonApplyChapters": "حفظ الفصول", + "ButtonAuthors": "المؤلفون", + "ButtonBack": "الرجوع", + "ButtonBrowseForFolder": "البحث عن المجلد", + "ButtonCancel": "إلغاء", + "ButtonCancelEncode": "إلغاء الترميز", + "ButtonChangeRootPassword": "تغيير كلمة المرور الرئيسية", + "ButtonCheckAndDownloadNewEpisodes": "التحقق من الحلقات الجديدة وتنزيلها", + "ButtonChooseAFolder": "اختر المجلد", + "ButtonChooseFiles": "اختر الملفات", + "ButtonClearFilter": "تصفية الفرز", + "ButtonCloseFeed": "إغلاق", + "ButtonCloseSession": "إغلاق الجلسة المفتوحة", + "ButtonCollections": "المجموعات", + "ButtonConfigureScanner": "إعدادات الماسح الضوئي", + "ButtonCreate": "إنشاء", + "ButtonCreateBackup": "إنشاء نسخة احتياطية", + "ButtonDelete": "حذف", + "ButtonDownloadQueue": "قائمة", + "ButtonEdit": "تعديل", + "ButtonEditChapters": "تعديل الفصول", + "ButtonEditPodcast": "تعديل البودكاست", + "ButtonEnable": "تفعيل", + "ButtonForceReScan": "فرض إعادة المسح", + "ButtonFullPath": "المسار الكامل", + "ButtonHide": "إخفاء", + "ButtonHome": "الرئيسية", + "ButtonIssues": "مشاكل", + "ButtonJumpBackward": "اقفز للخلف", + "ButtonJumpForward": "اقفز للأمام", + "ButtonLatest": "أحدث", + "ButtonLibrary": "المكتبة", + "ButtonLogout": "تسجيل الخروج" +} From 6786df6965df49c498603e34137b8c7a5dfb1321 Mon Sep 17 00:00:00 2001 From: Julio Cesar de jesus Date: Sat, 16 Nov 2024 23:05:38 +0000 Subject: [PATCH 257/282] Translated using Weblate (Spanish) 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/es/ --- client/strings/es.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/es.json b/client/strings/es.json index 06aa1b8b52..b45d253457 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -1,6 +1,6 @@ { - "ButtonAdd": "Agregar", - "ButtonAddChapters": "Agregar Capitulo", + "ButtonAdd": "Agregaro", + "ButtonAddChapters": "Agregar", "ButtonAddDevice": "Agregar Dispositivo", "ButtonAddLibrary": "Crear Biblioteca", "ButtonAddPodcasts": "Agregar Podcasts", From 10a7cd0987e0068f63094732639aa9f004f743a0 Mon Sep 17 00:00:00 2001 From: Julio Cesar de jesus Date: Sat, 16 Nov 2024 23:02:17 +0000 Subject: [PATCH 258/282] Translated using Weblate (Ukrainian) 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/uk/ --- client/strings/uk.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index e4aecc90ad..81cd13f4b7 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -928,7 +928,7 @@ "ToastBackupCreateSuccess": "Резервну копію створено", "ToastBackupDeleteFailed": "Не вдалося видалити резервну копію", "ToastBackupDeleteSuccess": "Резервну копію видалено", - "ToastBackupInvalidMaxKeep": "Невірна кількість резервних копій для зберігання", + "ToastBackupInvalidMaxKeep": "Профіль оновленоПрофіль оновлено", "ToastBackupInvalidMaxSize": "Невірний максимальний розмір резервної копії", "ToastBackupRestoreFailed": "Не вдалося відновити резервну копію", "ToastBackupUploadFailed": "Не вдалося завантажити резервну копію", From fe25d1dccda12521a637c91dc3865b4ecd0a0a6b Mon Sep 17 00:00:00 2001 From: Mohamad Dahhan Date: Sat, 16 Nov 2024 23:53:31 +0000 Subject: [PATCH 259/282] Translated using Weblate (Arabic) Currently translated at 11.9% (128 of 1071 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/ --- client/strings/ar.json | 90 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/client/strings/ar.json b/client/strings/ar.json index 673b0238f5..c4891f1951 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -30,6 +30,8 @@ "ButtonEditChapters": "تعديل الفصول", "ButtonEditPodcast": "تعديل البودكاست", "ButtonEnable": "تفعيل", + "ButtonFireAndFail": "النار والفشل", + "ButtonFireOnTest": "حادثة إطلاق النار", "ButtonForceReScan": "فرض إعادة المسح", "ButtonFullPath": "المسار الكامل", "ButtonHide": "إخفاء", @@ -39,5 +41,91 @@ "ButtonJumpForward": "اقفز للأمام", "ButtonLatest": "أحدث", "ButtonLibrary": "المكتبة", - "ButtonLogout": "تسجيل الخروج" + "ButtonLogout": "تسجيل الخروج", + "ButtonLookup": "البحث", + "ButtonManageTracks": "إدارة المقاطع", + "ButtonMapChapterTitles": "مطابقة عناوين الفصول", + "ButtonMatchAllAuthors": "مطابقة كل المؤلفون", + "ButtonMatchBooks": "مطابقة الكتب", + "ButtonNevermind": "لا تهتم", + "ButtonNext": "التالي", + "ButtonNextChapter": "الفصل التالي", + "ButtonNextItemInQueue": "العنصر التالي في قائمة الانتظار", + "ButtonOk": "نعم", + "ButtonOpenFeed": "فتح التغذية", + "ButtonOpenManager": "فتح الإدارة", + "ButtonPause": "تَوَقَّف", + "ButtonPlay": "تشغيل", + "ButtonPlayAll": "تشغيل الكل", + "ButtonPlaying": "مشغل الآن", + "ButtonPlaylists": "قوائم التشغيل", + "ButtonPrevious": "سابِق", + "ButtonPreviousChapter": "الفصل السابق", + "ButtonProbeAudioFile": "فحص ملف الصوت", + "ButtonPurgeAllCache": "مسح كافة ذاكرة التخزين المؤقتة", + "ButtonPurgeItemsCache": "مسح ذاكرة التخزين المؤقتة للعناصر", + "ButtonQueueAddItem": "أضف إلى قائمة الانتظار", + "ButtonQueueRemoveItem": "إزالة من قائمة الانتظار", + "ButtonQuickEmbed": "التضمين السريع", + "ButtonQuickEmbedMetadata": "إدراج سريع للبيانات الوصفية", + "ButtonQuickMatch": "مطابقة سريعة", + "ButtonReScan": "إعادة البحث", + "ButtonRead": "اقرأ", + "ButtonReadLess": "قلص", + "ButtonReadMore": "المزيد", + "ButtonRefresh": "تحديث", + "ButtonRemove": "إزالة", + "ButtonRemoveAll": "إزالة الكل", + "ButtonRemoveAllLibraryItems": "إزالة كافة عناصر المكتبة", + "ButtonRemoveFromContinueListening": "إزالة من متابعة الاستماع", + "ButtonRemoveFromContinueReading": "إزالة من متابعة القراءة", + "ButtonRemoveSeriesFromContinueSeries": "إزالة السلسلة من استمرار السلسلة", + "ButtonReset": "إعادة ضبط", + "ButtonResetToDefault": "إعادة ضبط إلى الوضع الافتراضي", + "ButtonRestore": "إستِعادة", + "ButtonSave": "حفظ", + "ButtonSaveAndClose": "حفظ و إغلاق", + "ButtonSaveTracklist": "حفظ قائمة التشغيل", + "ButtonScan": "تَحَقُق", + "ButtonScanLibrary": "تَحَقُق من المكتبة", + "ButtonSearch": "بحث", + "ButtonSelectFolderPath": "حدد مسار المجلد", + "ButtonSeries": "سلسلة", + "ButtonSetChaptersFromTracks": "تعيين الفصول من الملفات", + "ButtonShare": "نشر", + "ButtonShiftTimes": "أوقات العمل", + "ButtonShow": "عرض", + "ButtonStartM4BEncode": "ابدأ ترميز M4B", + "ButtonStartMetadataEmbed": "ابدأ تضمين البيانات الوصفية", + "ButtonStats": "الإحصائيات", + "ButtonSubmit": "تقديم", + "ButtonTest": "اختبار", + "ButtonUnlinkOpenId": "إلغاء ربط المعرف", + "ButtonUpload": "رفع", + "ButtonUploadBackup": "تحميل النسخة الاحتياطية", + "ButtonUploadCover": "ارفق الغلاف", + "ButtonUploadOPMLFile": "رفع ملف OPML", + "ButtonUserDelete": "حذف المستخدم {0}", + "ButtonUserEdit": "تعديل المستخدم {0}", + "ButtonViewAll": "عرض الكل", + "ButtonYes": "نعم", + "ErrorUploadFetchMetadataAPI": "خطأ في جلب البيانات الوصفية", + "ErrorUploadFetchMetadataNoResults": "لم يتم العثور على البيانات الوصفية - حاول تحديث العنوان و/أو المؤلف", + "ErrorUploadLacksTitle": "يجب أن يكون له عنوان", + "HeaderAccount": "الحساب", + "HeaderAddCustomMetadataProvider": "إضافة موفر بيانات تعريفية مخصص", + "HeaderAdvanced": "متقدم", + "HeaderAppriseNotificationSettings": "إعدادات الإشعارات", + "HeaderAudioTracks": "المسارات الصوتية", + "HeaderAudiobookTools": "أدوات إدارة ملفات الكتب الصوتية", + "HeaderAuthentication": "المصادقة", + "HeaderBackups": "النسخ الاحتياطية", + "HeaderChangePassword": "تغيير كلمة المرور", + "HeaderChapters": "الفصول", + "HeaderChooseAFolder": "اختيار المجلد", + "HeaderCollection": "مجموعة", + "HeaderCollectionItems": "عناصر المجموعة", + "HeaderCover": "الغلاف", + "HeaderCurrentDownloads": "التنزيلات الجارية", + "HeaderCustomMessageOnLogin": "رسالة مخصصة عند تسجيل الدخول" } From 2b0ba7d1e28f8cc0bcce153cbcde1a06927f93d9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 17 Nov 2024 16:25:40 -0600 Subject: [PATCH 260/282] Version bump v2.17.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 f31266cbf2..49fd4fa3bb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.16.2", + "version": "2.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.16.2", + "version": "2.17.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 2feb833b25..f579b8681e 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.16.2", + "version": "2.17.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 6e3276cea2..17d8403e96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.16.2", + "version": "2.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.16.2", + "version": "2.17.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index d31f202202..d7aa3261c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.16.2", + "version": "2.17.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 778256ca165fb1248cdb5463146ac4e0561f2c82 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 18 Nov 2024 07:42:24 -0600 Subject: [PATCH 261/282] Fix:Server crash on new libraries when getting filter data #3623 --- server/utils/queries/libraryFilters.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 64ad07ee08..be164eb23f 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -510,7 +510,7 @@ module.exports = { // If nothing has changed, check if the number of podcasts in // library is still the same as prior check before updating cache creation time - if (podcastCountFromDatabase === Database.libraryFilterData[libraryId].podcastCount) { + if (podcastCountFromDatabase === Database.libraryFilterData[libraryId]?.podcastCount) { Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) Database.libraryFilterData[libraryId].loadedAt = Date.now() return cachedFilterData @@ -613,7 +613,7 @@ module.exports = { if (changedBooks + changedSeries + changedAuthors === 0) { // If nothing has changed, check if the number of authors, series, and books // matches the prior check before updating cache creation time - if (bookCountFromDatabase === Database.libraryFilterData[libraryId].bookCount && seriesCountFromDatabase === Database.libraryFilterData[libraryId].seriesCount && authorCountFromDatabase === Database.libraryFilterData[libraryId].authorCount) { + if (bookCountFromDatabase === Database.libraryFilterData[libraryId]?.bookCount && seriesCountFromDatabase === Database.libraryFilterData[libraryId]?.seriesCount && authorCountFromDatabase === Database.libraryFilterData[libraryId].authorCount) { Logger.debug(`Filter data for ${libraryId} has not changed, returning cached data and updating cache time after ${((Date.now() - start) / 1000).toFixed(2)}s`) Database.libraryFilterData[libraryId].loadedAt = Date.now() return cachedFilterData From a5e38d14737ff8d43ed5b12f5f782978961b532c Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 18 Nov 2024 07:59:02 -0600 Subject: [PATCH 262/282] Fix:Error adding new series if a series has a null title #3622 --- client/components/widgets/SeriesInputWidget.vue | 2 -- server/objects/metadata/BookMetadata.js | 7 ++++++- server/utils/queries/libraryFilters.js | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client/components/widgets/SeriesInputWidget.vue b/client/components/widgets/SeriesInputWidget.vue index e770eed3fb..1d8b64feb8 100644 --- a/client/components/widgets/SeriesInputWidget.vue +++ b/client/components/widgets/SeriesInputWidget.vue @@ -71,8 +71,6 @@ export default { this.showSeriesForm = true }, submitSeriesForm() { - console.log('submit series form', this.value, this.selectedSeries) - if (!this.selectedSeries.name) { this.$toast.error('Must enter a series') return diff --git a/server/objects/metadata/BookMetadata.js b/server/objects/metadata/BookMetadata.js index 6d3dae4326..c6192f116c 100644 --- a/server/objects/metadata/BookMetadata.js +++ b/server/objects/metadata/BookMetadata.js @@ -29,7 +29,12 @@ class BookMetadata { this.subtitle = metadata.subtitle this.authors = metadata.authors?.map ? metadata.authors.map((a) => ({ ...a })) : [] this.narrators = metadata.narrators ? [...metadata.narrators].filter((n) => n) : [] - this.series = metadata.series?.map ? metadata.series.map((s) => ({ ...s })) : [] + this.series = metadata.series?.map + ? metadata.series.map((s) => ({ + ...s, + name: s.name || 'No Title' + })) + : [] this.genres = metadata.genres ? [...metadata.genres] : [] this.publishedYear = metadata.publishedYear || null this.publishedDate = metadata.publishedDate || null diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index be164eb23f..bdddde7595 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -662,7 +662,7 @@ module.exports = { }, attributes: ['id', 'name'] }) - series.forEach((s) => data.series.push({ id: s.id, name: s.name })) + series.forEach((s) => data.series.push({ id: s.id, name: s.name || 'No Title' })) const authors = await Database.authorModel.findAll({ where: { From 4adb15c11b209c045a88a43416d8dbd7b60a474f Mon Sep 17 00:00:00 2001 From: Clara Papke Date: Mon, 18 Nov 2024 09:33:40 +0000 Subject: [PATCH 263/282] Translated using Weblate (German) 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/de/ --- client/strings/de.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index a427c2885b..6dff93381b 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -71,8 +71,8 @@ "ButtonQuickMatch": "Schnellabgleich", "ButtonReScan": "Neu scannen", "ButtonRead": "Lesen", - "ButtonReadLess": "Weniger anzeigen", - "ButtonReadMore": "Mehr anzeigen", + "ButtonReadLess": "weniger Anzeigen", + "ButtonReadMore": "Mehr Anzeigen", "ButtonRefresh": "Neu Laden", "ButtonRemove": "Entfernen", "ButtonRemoveAll": "Alles entfernen", @@ -220,7 +220,7 @@ "LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen", "LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu", "LabelAddedAt": "Hinzugefügt am", - "LabelAddedDate": "Hinzugefügt {0}", + "LabelAddedDate": "{0} Hinzugefügt", "LabelAdminUsersOnly": "Nur Admin Benutzer", "LabelAll": "Alle", "LabelAllUsers": "Alle Benutzer", @@ -534,6 +534,7 @@ "LabelSelectUsers": "Benutzer auswählen", "LabelSendEbookToDevice": "E-Buch senden an …", "LabelSequence": "Reihenfolge", + "LabelSerial": "fortlaufend", "LabelSeries": "Serien", "LabelSeriesName": "Serienname", "LabelSeriesProgress": "Serienfortschritt", @@ -680,8 +681,8 @@ "LabelWeekdaysToRun": "Wochentage für die Ausführung", "LabelXBooks": "{0} Bücher", "LabelXItems": "{0} Medien", - "LabelYearReviewHide": "Verstecke Jahr in Übersicht", - "LabelYearReviewShow": "Zeige Jahr in Übersicht", + "LabelYearReviewHide": "Jahresrückblick verbergen", + "LabelYearReviewShow": "Jahresrückblick anzeigen", "LabelYourAudiobookDuration": "Laufzeit deines Mediums", "LabelYourBookmarks": "Lesezeichen", "LabelYourPlaylists": "Eigene Wiedergabelisten", From dd3467efa2071675e110296feaa1f773ae8977e4 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Mon, 18 Nov 2024 09:13:14 +0000 Subject: [PATCH 264/282] 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 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index bbcf8055a2..366c8479b3 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -495,7 +495,7 @@ "LabelProviderAuthorizationValue": "Vrednost glave avtorizacije", "LabelPubDate": "Datum objave", "LabelPublishYear": "Leto izdaje", - "LabelPublishedDate": "Izdano {0}", + "LabelPublishedDate": "Objavljeno {0}", "LabelPublishedDecade": "Desetletje izdaje", "LabelPublishedDecades": "Desetletja izdaje", "LabelPublisher": "Izdajatelj", @@ -682,7 +682,7 @@ "LabelXBooks": "{0} knjig", "LabelXItems": "{0} elementov", "LabelYearReviewHide": "Skrij pregled leta", - "LabelYearReviewShow": "Poglej pregled leta", + "LabelYearReviewShow": "Poglej si pregled leta", "LabelYourAudiobookDuration": "Trajanje tvojih zvočnih knjig", "LabelYourBookmarks": "Tvoji zaznamki", "LabelYourPlaylists": "Tvoje seznami predvajanj", From 22f85d3af9815f4946eeeb2218d532cf5f543da8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 18 Nov 2024 08:02:46 -0600 Subject: [PATCH 265/282] Version bump v2.17.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 49fd4fa3bb..c7d01fb45c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.17.0", + "version": "2.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.17.0", + "version": "2.17.1", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index f579b8681e..361130eac1 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.17.0", + "version": "2.17.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 17d8403e96..96d85ececc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.17.0", + "version": "2.17.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.17.0", + "version": "2.17.1", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index d7aa3261c2..ec15388905 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.17.0", + "version": "2.17.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From ee6e2d2983f1a84f5b7fae4922b72757e8a751d4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 19 Nov 2024 16:48:05 -0600 Subject: [PATCH 266/282] Update:Persist podcast episode table sort and filter options in local storage #1321 --- client/components/tables/podcast/LazyEpisodesTable.vue | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/components/tables/podcast/LazyEpisodesTable.vue b/client/components/tables/podcast/LazyEpisodesTable.vue index 963cd7c96b..0dae11b36b 100644 --- a/client/components/tables/podcast/LazyEpisodesTable.vue +++ b/client/components/tables/podcast/LazyEpisodesTable.vue @@ -25,7 +25,6 @@
-
@@ -515,6 +514,10 @@ export default { } }, filterSortChanged() { + // Save filterKey and sortKey to local storage + localStorage.setItem('podcastEpisodesFilter', this.filterKey) + localStorage.setItem('podcastEpisodesSortBy', this.sortKey + (this.sortDesc ? '-desc' : '')) + this.init() }, refresh() { @@ -537,6 +540,11 @@ export default { } }, mounted() { + this.filterKey = localStorage.getItem('podcastEpisodesFilter') || 'incomplete' + const sortBy = localStorage.getItem('podcastEpisodesSortBy') || 'publishedAt-desc' + this.sortKey = sortBy.split('-')[0] + this.sortDesc = sortBy.split('-')[1] === 'desc' + this.episodesCopy = this.episodes.map((ep) => ({ ...ep })) this.initListeners() this.init() From ff026a06bbfbd974032a58bfd32c67c53f0aebff Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 20 Nov 2024 16:48:09 -0600 Subject: [PATCH 267/282] Fix v2.17.0 migration to ensure mediaItemShares table exists --- server/migrations/v2.17.0-uuid-replacement.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/migrations/v2.17.0-uuid-replacement.js b/server/migrations/v2.17.0-uuid-replacement.js index 6460b79526..4316cd7694 100644 --- a/server/migrations/v2.17.0-uuid-replacement.js +++ b/server/migrations/v2.17.0-uuid-replacement.js @@ -27,10 +27,14 @@ async function up({ context: { queryInterface, logger } }) { type: 'UUID' }) - logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID') - await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', { - type: 'UUID' - }) + if (await queryInterface.tableExists('mediaItemShares')) { + logger.info('[2.17.0 migration] Changing mediaItemShares.mediaItemId column to UUID') + await queryInterface.changeColumn('mediaItemShares', 'mediaItemId', { + type: 'UUID' + }) + } else { + logger.info('[2.17.0 migration] mediaItemShares table does not exist, skipping column change') + } logger.info('[2.17.0 migration] Changing playbackSessions.mediaItemId column to UUID') await queryInterface.changeColumn('playbackSessions', 'mediaItemId', { From fc5f35b3887044e057367331dbc384d12522db70 Mon Sep 17 00:00:00 2001 From: Harrison Rose Date: Thu, 21 Nov 2024 02:06:53 +0000 Subject: [PATCH 268/282] on iOS, do not restrict file types for upload --- client/pages/upload/index.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 0efa1456d1..8bc57de544 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -84,7 +84,7 @@
- +
@@ -127,6 +127,10 @@ export default { }) return extensions }, + isIOS() { + const ua = window.navigator.userAgent + return /iPad|iPhone|iPod/.test(ua) && !window.MSStream + }, streamLibraryItem() { return this.$store.state.streamLibraryItem }, From 268fb2ce9a29ff5acce81d030537141fca2a7bc1 Mon Sep 17 00:00:00 2001 From: Harrison Rose Date: Thu, 21 Nov 2024 04:43:03 +0000 Subject: [PATCH 269/282] on iOS, hide UI on upload page related to folder selection (since iOS Webkit does not support folder selection) --- client/pages/upload/index.vue | 8 ++++---- client/strings/en-us.json | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 8bc57de544..7c1b476765 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -34,12 +34,12 @@
-

{{ isDragging ? $strings.LabelUploaderDropFiles : $strings.LabelUploaderDragAndDrop }}

+

{{ isDragging ? $strings.LabelUploaderDropFiles : $strings.LabelUploaderDragAndDrop + (isIOS ? '' : ' ' + $strings.LabelUploaderDragAndDropOrFolders) }}

{{ $strings.MessageOr }}

{{ $strings.ButtonChooseFiles }} - {{ $strings.ButtonChooseAFolder }} + {{ $strings.ButtonChooseAFolder }}
@@ -48,7 +48,7 @@

- {{ $strings.NoteUploaderFoldersWithMediaFiles }} {{ $strings.NoteUploaderOnlyAudioFiles }} + {{ $strings.NoteUploaderFoldersWithMediaFiles }} {{ $strings.NoteUploaderOnlyAudioFiles }}

@@ -85,7 +85,7 @@ - + diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 8eb375500d..e6392c0f90 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -662,7 +662,8 @@ "LabelUpdateDetails": "Update Details", "LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located", "LabelUpdatedAt": "Updated At", - "LabelUploaderDragAndDrop": "Drag & drop files or folders", + "LabelUploaderDragAndDrop": "Drag & drop files", + "LabelUploaderDragAndDropOrFolders": "or folders", "LabelUploaderDropFiles": "Drop files", "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", "LabelUseAdvancedOptions": "Use Advanced Options", From 784b761629af9212d34cdf36d01005c221b125f6 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 21 Nov 2024 14:19:40 -0600 Subject: [PATCH 270/282] Fix:Unable to edit series sequence #3636 --- server/models/LibraryItem.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 5b96ad5211..10395c49c2 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -479,7 +479,7 @@ class LibraryItem extends Model { { model: this.sequelize.models.series, through: { - attributes: ['sequence'] + attributes: ['id', 'sequence'] } } ], From 1d4e6993fc09a954b150eeaed69156559cc892c8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 21 Nov 2024 14:56:43 -0600 Subject: [PATCH 271/282] Upload page UI updates for mobile --- client/mixins/uploadHelpers.js | 32 ++++++++++++++++---------------- client/pages/upload/index.vue | 16 ++++++++-------- client/strings/en-us.json | 4 ++-- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/client/mixins/uploadHelpers.js b/client/mixins/uploadHelpers.js index 2d7a554f0a..994d36c696 100644 --- a/client/mixins/uploadHelpers.js +++ b/client/mixins/uploadHelpers.js @@ -28,10 +28,8 @@ export default { var validOtherFiles = [] var ignoredFiles = [] files.forEach((file) => { - // var filetype = this.checkFileType(file.name) if (!file.filetype) ignoredFiles.push(file) else { - // file.filetype = filetype if (file.filetype === 'audio' || (file.filetype === 'ebook' && mediaType === 'book')) validItemFiles.push(file) else validOtherFiles.push(file) } @@ -165,7 +163,7 @@ export default { var firstBookPath = Path.dirname(firstBookFile.filepath) - var dirs = firstBookPath.split('/').filter(d => !!d && d !== '.') + var dirs = firstBookPath.split('/').filter((d) => !!d && d !== '.') if (dirs.length) { audiobook.title = dirs.pop() if (dirs.length > 1) { @@ -189,7 +187,7 @@ export default { var firstAudioFile = podcast.itemFiles[0] if (!firstAudioFile.filepath) return podcast // No path var firstPath = Path.dirname(firstAudioFile.filepath) - var dirs = firstPath.split('/').filter(d => !!d && d !== '.') + var dirs = firstPath.split('/').filter((d) => !!d && d !== '.') if (dirs.length) { podcast.title = dirs.length > 1 ? dirs[1] : dirs[0] } else { @@ -212,13 +210,15 @@ export default { } var ignoredFiles = itemData.ignoredFiles var index = 1 - var items = itemData.items.filter((ab) => { - if (!ab.itemFiles.length) { - if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles) - if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles) - } - return ab.itemFiles.length - }).map(ab => this.cleanItem(ab, mediaType, index++)) + var items = itemData.items + .filter((ab) => { + if (!ab.itemFiles.length) { + if (ab.otherFiles.length) ignoredFiles = ignoredFiles.concat(ab.otherFiles) + if (ab.ignoredFiles.length) ignoredFiles = ignoredFiles.concat(ab.ignoredFiles) + } + return ab.itemFiles.length + }) + .map((ab) => this.cleanItem(ab, mediaType, index++)) return { items, ignoredFiles @@ -259,7 +259,7 @@ export default { otherFiles.forEach((file) => { var dir = Path.dirname(file.filepath) - var findItem = Object.values(itemMap).find(b => dir.startsWith(b.path)) + var findItem = Object.values(itemMap).find((b) => dir.startsWith(b.path)) if (findItem) { findItem.otherFiles.push(file) } else { @@ -270,18 +270,18 @@ export default { var items = [] var index = 1 // If book media type and all files are audio files then treat each one as an audiobook - if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some(f => f.filetype !== 'audio')) { + if (itemMap[''] && !otherFiles.length && mediaType === 'book' && !itemMap[''].itemFiles.some((f) => f.filetype !== 'audio')) { items = itemMap[''].itemFiles.map((audioFile) => { return this.cleanItem({ itemFiles: [audioFile], otherFiles: [], ignoredFiles: [] }, mediaType, index++) }) } else { - items = Object.values(itemMap).map(i => this.cleanItem(i, mediaType, index++)) + items = Object.values(itemMap).map((i) => this.cleanItem(i, mediaType, index++)) } return { items, ignoredFiles: ignoredFiles } - }, + } } -} \ No newline at end of file +} diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 7c1b476765..441ce88eea 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -1,20 +1,20 @@ @@ -54,7 +71,7 @@ export default { return this.episode.description || '' }, media() { - return this.libraryItem ? this.libraryItem.media || {} : {} + return this.libraryItem?.media || {} }, mediaMetadata() { return this.media.metadata || {} @@ -65,6 +82,14 @@ export default { podcastAuthor() { return this.mediaMetadata.author }, + audioFileFilename() { + return this.episode.audioFile?.metadata?.filename || '' + }, + audioFileSize() { + const size = this.episode.audioFile?.metadata?.size || 0 + + return this.$bytesPretty(size) + }, bookCoverAspectRatio() { return this.$store.getters['libraries/getBookCoverAspectRatio'] }