From f460297dafa6ddf1ced9bdacaef8609626a9f392 Mon Sep 17 00:00:00 2001 From: Conner McCall Date: Thu, 13 Feb 2025 09:06:53 -0600 Subject: [PATCH 01/35] fix: allow upgrading HTTP to HTTPS for redirects Re: #3142 and #3658 When adding certain podcasts, the server encountered a redirect from an HTTP URL to an HTTPS domain, causing an error that was difficult for end users to diagnose without inspecting logs or HTML. This issue arose due to SSRF security measures that blocked such redirects. Instead of failing in these cases, we now detect when the error is caused by an HTTP-to-HTTPS upgrade. If confirmed, we upgrade the initial URL to HTTPS and resend the request. Since this change does not allow cross-protocol or cross-domain redirections, it remains secure while resolving most of the reported issues. Affected podcasts that are now fixed: - D&D is for Nerds - The New Yorker: The Writer's Voice - New Fiction from The New Yorker - Radiolab --- server/utils/podcastUtils.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 485fccfbf..1ecb0a753 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -343,6 +343,14 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { return payload.podcast }) .catch((error) => { + // Check for failures due to redirecting from http to https. If original url was http, upgrade to https and try again + if (error.code === 'ERR_FR_REDIRECTION_FAILURE' && error.cause.code === 'ERR_INVALID_PROTOCOL') { + if (feedUrl.startsWith('http://') && error.request._options.protocol === 'https:') { + Logger.info('Redirection from http to https detected. Upgrading Request', error.request._options.href) + feedUrl = feedUrl.replace('http://', 'https://') + return this.getPodcastFeed(feedUrl, excludeEpisodeMetadata) + } + } Logger.error('[podcastUtils] getPodcastFeed Error', error) return null }) From 23a750214fcd0927d9bbbde52dcb4f17d040455d Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 08:35:51 +0200 Subject: [PATCH 02/35] Add migration in preparation for podcast query optimization --- server/migrations/changelog.md | 1 + .../v2.19.3-improve-podcast-queries.js | 219 +++++++++++++++ .../v2.19.3-improve-podcast-queries.test.js | 265 ++++++++++++++++++ 3 files changed, 485 insertions(+) create mode 100644 server/migrations/v2.19.3-improve-podcast-queries.js create mode 100644 test/server/migrations/v2.19.3-improve-podcast-queries.test.js diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index acccef90d..64b2d6711 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -14,3 +14,4 @@ Please add a record of every database migration that you create to this file. Th | v2.17.6 | v2.17.6-share-add-isdownloadable | Adds the isDownloadable column to the mediaItemShares table | | v2.17.7 | v2.17.7-add-indices | Adds indices to the libraryItems and books tables to reduce query times | | v2.19.1 | v2.19.1-copy-title-to-library-items | Copies title and titleIgnorePrefix to the libraryItems table, creates update triggers and indices | +| v2.19.3 | v2.19.3-improve-podcast-queries | Adds numEpisodes to podcasts, adds podcastId to mediaProgresses, copies podcast title to libraryItems | diff --git a/server/migrations/v2.19.3-improve-podcast-queries.js b/server/migrations/v2.19.3-improve-podcast-queries.js new file mode 100644 index 000000000..4e64d3ff4 --- /dev/null +++ b/server/migrations/v2.19.3-improve-podcast-queries.js @@ -0,0 +1,219 @@ +const util = require('util') + +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.19.3' +const migrationName = `${migrationVersion}-improve-podcast-queries` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration adds a numEpisodes column to the podcasts table and populates it. + * It also adds a podcastId column to the mediaProgresses table and populates it. + * It also copies the title and titleIgnorePrefix columns from the podcasts table to the libraryItems table, + * and adds triggers to update them when the corresponding columns in the podcasts table are updated. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + // Add numEpisodes column to podcasts table + await addColumn(queryInterface, logger, 'podcasts', 'numEpisodes', { type: queryInterface.sequelize.Sequelize.INTEGER, allowNull: false, defaultValue: 0 }) + + // Populate numEpisodes column with the number of episodes for each podcast + await populateNumEpisodes(queryInterface, logger) + + // Add podcastId column to mediaProgresses table + await addColumn(queryInterface, logger, 'mediaProgresses', 'podcastId', { type: queryInterface.sequelize.Sequelize.UUID, allowNull: true }) + + // Populate podcastId column with the podcastId for each mediaProgress + await populatePodcastId(queryInterface, logger) + + // Copy title and titleIgnorePrefix columns from podcasts to libraryItems + await copyColumn(queryInterface, logger, 'podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId') + await copyColumn(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId') + + // Add triggers to update title and titleIgnorePrefix in libraryItems + await addTrigger(queryInterface, logger, 'podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId') + await addTrigger(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId') + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration removes the triggers on the podcasts table, + * the numEpisodes column from the podcasts table, and the podcastId column from the mediaProgresses table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + // Remove triggers from libraryItems + await removeTrigger(queryInterface, logger, 'podcasts', 'title', 'libraryItems', 'title') + await removeTrigger(queryInterface, logger, 'podcasts', 'titleIgnorePrefix', 'libraryItems', 'titleIgnorePrefix') + + // Remove numEpisodes column from podcasts table + await removeColumn(queryInterface, logger, 'podcasts', 'numEpisodes') + + // Remove podcastId column from mediaProgresses table + await removeColumn(queryInterface, logger, 'mediaProgresses', 'podcastId') + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +async function populateNumEpisodes(queryInterface, logger) { + logger.info(`${loggerPrefix} populating numEpisodes column in podcasts table`) + await queryInterface.sequelize.query(` + UPDATE podcasts + SET numEpisodes = (SELECT COUNT(*) FROM podcastEpisodes WHERE podcastEpisodes.podcastId = podcasts.id) + `) + logger.info(`${loggerPrefix} populated numEpisodes column in podcasts table`) +} + +async function populatePodcastId(queryInterface, logger) { + logger.info(`${loggerPrefix} populating podcastId column in mediaProgresses table`) + // bulk update podcastId to the podcastId of the podcastEpisode if the mediaItemType is podcastEpisode + await queryInterface.sequelize.query(` + UPDATE mediaProgresses + SET podcastId = (SELECT podcastId FROM podcastEpisodes WHERE podcastEpisodes.id = mediaProgresses.mediaItemId) + WHERE mediaItemType = 'podcastEpisode' + `) + logger.info(`${loggerPrefix} populated podcastId column in mediaProgresses table`) +} + +/** + * Utility function to add a column to a table. If the column already exists, it logs a message and continues. + * + * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @param {import('../Logger')} logger - a Logger object. + * @param {string} table - the name of the table to add the column to. + * @param {string} column - the name of the column to add. + * @param {Object} options - the options for the column. + */ +async function addColumn(queryInterface, logger, table, column, options) { + logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`) + const tableDescription = await queryInterface.describeTable(table) + if (!tableDescription[column]) { + await queryInterface.addColumn(table, column, options) + logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`) + } else { + logger.info(`${loggerPrefix} column "${column}" already exists in table "${table}"`) + } +} + +/** + * Utility function to remove a column from a table. If the column does not exist, it logs a message and continues. + * + * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @param {import('../Logger')} logger - a Logger object. + * @param {string} table - the name of the table to remove the column from. + * @param {string} column - the name of the column to remove. + */ +async function removeColumn(queryInterface, logger, table, column) { + logger.info(`${loggerPrefix} removing column "${column}" from table "${table}"`) + const tableDescription = await queryInterface.describeTable(table) + if (tableDescription[column]) { + await queryInterface.sequelize.query(`ALTER TABLE ${table} DROP COLUMN ${column}`) + logger.info(`${loggerPrefix} removed column "${column}" from table "${table}"`) + } else { + logger.info(`${loggerPrefix} column "${column}" does not exist in table "${table}"`) + } +} + +/** + * Utility function to add a trigger to update a column in a target table when a column in a source table is updated. + * If the trigger already exists, it drops it and creates a new one. + * sourceIdColumn and targetIdColumn are used to match the source and target rows. + * + * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @param {import('../Logger')} logger - a Logger object. + * @param {string} sourceTable - the name of the source table. + * @param {string} sourceColumn - the name of the column to update. + * @param {string} sourceIdColumn - the name of the id column of the source table. + * @param {string} targetTable - the name of the target table. + * @param {string} targetColumn - the name of the column to update. + * @param {string} targetIdColumn - the name of the id column of the target table. + */ +async function addTrigger(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) { + logger.info(`${loggerPrefix} adding trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`) + const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}_from_${sourceTable}_${sourceColumn}`) + + await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + + await queryInterface.sequelize.query(` + CREATE TRIGGER ${triggerName} + AFTER UPDATE OF ${sourceColumn} ON ${sourceTable} + FOR EACH ROW + BEGIN + UPDATE ${targetTable} + SET ${targetColumn} = NEW.${sourceColumn} + WHERE ${targetTable}.${targetIdColumn} = NEW.${sourceIdColumn}; + END; + `) + logger.info(`${loggerPrefix} added trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`) +} + +/** + * Utility function to remove an update trigger from a table. + * + * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @param {import('../Logger')} logger - a Logger object. + * @param {string} sourceTable - the name of the source table. + * @param {string} sourceColumn - the name of the column to update. + * @param {string} targetTable - the name of the target table. + * @param {string} targetColumn - the name of the column to update. + */ +async function removeTrigger(queryInterface, logger, sourceTable, sourceColumn, targetTable, targetColumn) { + logger.info(`${loggerPrefix} removing trigger to update ${targetTable}.${targetColumn}`) + const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}_from_${sourceTable}_${sourceColumn}`) + await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + logger.info(`${loggerPrefix} removed trigger to update ${targetTable}.${targetColumn}`) +} + +/** + * Utility function to copy a column from a source table to a target table. + * sourceIdColumn and targetIdColumn are used to match the source and target rows. + * + * @param {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @param {import('../Logger')} logger - a Logger object. + * @param {string} sourceTable - the name of the source table. + * @param {string} sourceColumn - the name of the column to copy. + * @param {string} sourceIdColumn - the name of the id column of the source table. + * @param {string} targetTable - the name of the target table. + * @param {string} targetColumn - the name of the column to copy to. + * @param {string} targetIdColumn - the name of the id column of the target table. + */ +async function copyColumn(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) { + logger.info(`${loggerPrefix} copying column "${sourceColumn}" from table "${sourceTable}" to table "${targetTable}"`) + await queryInterface.sequelize.query(` + UPDATE ${targetTable} + SET ${targetColumn} = ${sourceTable}.${sourceColumn} + FROM ${sourceTable} + WHERE ${targetTable}.${targetIdColumn} = ${sourceTable}.${sourceIdColumn} + `) + logger.info(`${loggerPrefix} copied column "${sourceColumn}" from table "${sourceTable}" to table "${targetTable}"`) +} + +/** + * Utility function to convert a string to snake case, e.g. "titleIgnorePrefix" -> "title_ignore_prefix" + * + * @param {string} str - the string to convert to snake case. + * @returns {string} - the string in snake case. + */ +function convertToSnakeCase(str) { + return str.replace(/([A-Z])/g, '_$1').toLowerCase() +} + +module.exports = { up, down } diff --git a/test/server/migrations/v2.19.3-improve-podcast-queries.test.js b/test/server/migrations/v2.19.3-improve-podcast-queries.test.js new file mode 100644 index 000000000..ae3784b90 --- /dev/null +++ b/test/server/migrations/v2.19.3-improve-podcast-queries.test.js @@ -0,0 +1,265 @@ +const chai = require('chai') +const sinon = require('sinon') +const { expect } = chai + +const { DataTypes, Sequelize } = require('sequelize') +const Logger = require('../../../server/Logger') + +const { up, down } = require('../../../server/migrations/v2.19.3-improve-podcast-queries') + +describe('Migration v2.19.3-improve-podcast-queries', () => { + let sequelize + let queryInterface + let loggerInfoStub + + beforeEach(async () => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + loggerInfoStub = sinon.stub(Logger, 'info') + + await queryInterface.createTable('libraryItems', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + mediaId: { type: DataTypes.INTEGER, allowNull: false }, + title: { type: DataTypes.STRING, allowNull: true }, + titleIgnorePrefix: { type: DataTypes.STRING, allowNull: true } + }) + await queryInterface.createTable('podcasts', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + title: { type: DataTypes.STRING, allowNull: false }, + titleIgnorePrefix: { type: DataTypes.STRING, allowNull: false } + }) + + await queryInterface.createTable('podcastEpisodes', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + podcastId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'podcasts', key: 'id', onDelete: 'CASCADE' } } + }) + + await queryInterface.createTable('mediaProgresses', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + userId: { type: DataTypes.INTEGER, allowNull: false }, + mediaItemId: { type: DataTypes.INTEGER, allowNull: false }, + mediaItemType: { type: DataTypes.STRING, allowNull: false }, + isFinished: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false } + }) + + await queryInterface.bulkInsert('libraryItems', [ + { id: 1, mediaId: 1, title: null, titleIgnorePrefix: null }, + { id: 2, mediaId: 2, title: null, titleIgnorePrefix: null } + ]) + + await queryInterface.bulkInsert('podcasts', [ + { id: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' }, + { id: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' } + ]) + + await queryInterface.bulkInsert('podcastEpisodes', [ + { id: 1, podcastId: 1 }, + { id: 2, podcastId: 1 }, + { id: 3, podcastId: 2 } + ]) + + await queryInterface.bulkInsert('mediaProgresses', [ + { id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 1 }, + { id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 0 }, + { id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 1 }, + { id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 0 }, + { id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 1 }, + { id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 0 }, + { id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', isFinished: 1 }, + { id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', isFinished: 0 } + ]) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('up', () => { + it('should add numEpisodes column to podcasts', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts') + expect(podcasts).to.deep.equal([ + { id: 1, numEpisodes: 2, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' }, + { id: 2, numEpisodes: 1, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' } + ]) + + // Make sure podcastEpisodes are not affected due to ON DELETE CASCADE + const [podcastEpisodes] = await queryInterface.sequelize.query('SELECT * FROM podcastEpisodes') + expect(podcastEpisodes).to.deep.equal([ + { id: 1, podcastId: 1 }, + { id: 2, podcastId: 1 }, + { id: 3, podcastId: 2 } + ]) + }) + + it('should add podcastId column to mediaProgresses', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses') + expect(mediaProgresses).to.deep.equal([ + { id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 }, + { id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 }, + { id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 1 }, + { id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 }, + { id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 }, + { id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 0 }, + { id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', podcastId: null, isFinished: 1 }, + { id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', podcastId: null, isFinished: 0 } + ]) + }) + + it('should copy title and titleIgnorePrefix from podcasts to libraryItems', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems') + expect(libraryItems).to.deep.equal([ + { id: 1, mediaId: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' }, + { id: 2, mediaId: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' } + ]) + }) + + it('should add trigger to update title in libraryItems', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`) + expect(count).to.equal(1) + }) + + it('should add trigger to update titleIgnorePrefix in libraryItems', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`) + expect(count).to.equal(1) + }) + + it('should be idempotent', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await up({ context: { queryInterface, logger: Logger } }) + + const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts') + expect(podcasts).to.deep.equal([ + { id: 1, numEpisodes: 2, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' }, + { id: 2, numEpisodes: 1, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' } + ]) + + const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses') + expect(mediaProgresses).to.deep.equal([ + { id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 }, + { id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 }, + { id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 1 }, + { id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 0 }, + { id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', podcastId: 1, isFinished: 1 }, + { id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', podcastId: 2, isFinished: 0 }, + { id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', podcastId: null, isFinished: 1 }, + { id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', podcastId: null, isFinished: 0 } + ]) + + const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems') + expect(libraryItems).to.deep.equal([ + { id: 1, mediaId: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' }, + { id: 2, mediaId: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' } + ]) + + const [[{ count: count1 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`) + expect(count1).to.equal(1) + + const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`) + expect(count2).to.equal(1) + }) + }) + + describe('down', () => { + it('should remove numEpisodes column from podcasts', async () => { + await up({ context: { queryInterface, logger: Logger } }) + try { + await down({ context: { queryInterface, logger: Logger } }) + } catch (error) { + console.log(error) + } + + const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts') + expect(podcasts).to.deep.equal([ + { id: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' }, + { id: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' } + ]) + + // Make sure podcastEpisodes are not affected due to ON DELETE CASCADE + const [podcastEpisodes] = await queryInterface.sequelize.query('SELECT * FROM podcastEpisodes') + expect(podcastEpisodes).to.deep.equal([ + { id: 1, podcastId: 1 }, + { id: 2, podcastId: 1 }, + { id: 3, podcastId: 2 } + ]) + }) + + it('should remove podcastId column from mediaProgresses', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses') + expect(mediaProgresses).to.deep.equal([ + { id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 1 }, + { id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 0 }, + { id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 1 }, + { id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 0 }, + { id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 1 }, + { id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 0 }, + { id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', isFinished: 1 }, + { id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', isFinished: 0 } + ]) + }) + + it('should remove trigger to update title in libraryItems', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`) + expect(count).to.equal(0) + }) + + it('should remove trigger to update titleIgnorePrefix in libraryItems', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`) + expect(count).to.equal(0) + }) + + it('should be idempotent', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const [podcasts] = await queryInterface.sequelize.query('SELECT * FROM podcasts') + expect(podcasts).to.deep.equal([ + { id: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' }, + { id: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' } + ]) + + const [mediaProgresses] = await queryInterface.sequelize.query('SELECT * FROM mediaProgresses') + expect(mediaProgresses).to.deep.equal([ + { id: 1, userId: 1, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 1 }, + { id: 2, userId: 1, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 0 }, + { id: 3, userId: 1, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 1 }, + { id: 4, userId: 2, mediaItemId: 1, mediaItemType: 'podcastEpisode', isFinished: 0 }, + { id: 5, userId: 2, mediaItemId: 2, mediaItemType: 'podcastEpisode', isFinished: 1 }, + { id: 6, userId: 2, mediaItemId: 3, mediaItemType: 'podcastEpisode', isFinished: 0 }, + { id: 7, userId: 1, mediaItemId: 1, mediaItemType: 'book', isFinished: 1 }, + { id: 8, userId: 1, mediaItemId: 2, mediaItemType: 'book', isFinished: 0 } + ]) + + const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems') + expect(libraryItems).to.deep.equal([ + { id: 1, mediaId: 1, title: 'The Podcast 1', titleIgnorePrefix: 'Podcast 1, The' }, + { id: 2, mediaId: 2, title: 'The Podcast 2', titleIgnorePrefix: 'Podcast 2, The' } + ]) + + const [[{ count: count1 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_from_podcasts_title'`) + expect(count1).to.equal(0) + + const [[{ count: count2 }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix_from_podcasts_title_ignore_prefix'`) + expect(count2).to.equal(0) + }) + }) +}) From e2f1aeed757967ab4b068bf6486a225e30f37747 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 08:38:03 +0200 Subject: [PATCH 03/35] Add numEpisodes to podcast model --- server/models/Podcast.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/models/Podcast.js b/server/models/Podcast.js index ce47754be..356bd41e7 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -61,6 +61,8 @@ class Podcast extends Model { this.createdAt /** @type {Date} */ this.updatedAt + /** @type {number} */ + this.numEpisodes /** @type {import('./PodcastEpisode')[]} */ this.podcastEpisodes @@ -138,7 +140,8 @@ class Podcast extends Model { maxNewEpisodesToDownload: DataTypes.INTEGER, coverPath: DataTypes.STRING, tags: DataTypes.JSON, - genres: DataTypes.JSON + genres: DataTypes.JSON, + numEpisodes: DataTypes.INTEGER }, { sequelize, From 7282afcfded6d2adb34bf25fc5001f4a11c7acb8 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 08:42:09 +0200 Subject: [PATCH 04/35] Add podcastId to mediaProgress model --- server/models/MediaProgress.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index bb8276826..617ade2af 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -34,6 +34,8 @@ class MediaProgress extends Model { this.updatedAt /** @type {Date} */ this.createdAt + /** @type {UUIDV4} */ + this.podcastId } static removeById(mediaProgressId) { @@ -69,7 +71,8 @@ class MediaProgress extends Model { ebookLocation: DataTypes.STRING, ebookProgress: DataTypes.FLOAT, finishedAt: DataTypes.DATE, - extraData: DataTypes.JSON + extraData: DataTypes.JSON, + podcastId: DataTypes.UUID }, { sequelize, From f1de307bf9455d7209ef9c68282f35bccdf870c2 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 08:52:33 +0200 Subject: [PATCH 05/35] Update cached user whenever mediaProgress is removed --- server/models/MediaProgress.js | 10 ++++++++++ server/models/User.js | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index 617ade2af..3218d2e9f 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -126,6 +126,16 @@ class MediaProgress extends Model { } }) + // make sure to call the afterDestroy hook for each instance + MediaProgress.addHook('beforeBulkDestroy', (options) => { + options.individualHooks = true + }) + + // update the potentially cached user after destroying the media progress + MediaProgress.addHook('afterDestroy', (instance) => { + user.mediaProgressRemoved(instance) + }) + user.hasMany(MediaProgress, { onDelete: 'CASCADE' }) diff --git a/server/models/User.js b/server/models/User.js index 56d6ba0ea..153d6d48c 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -404,6 +404,14 @@ class User extends Model { return count > 0 } + static mediaProgressRemoved(mediaProgress) { + const cachedUser = userCache.getById(mediaProgress.userId) + if (cachedUser) { + Logger.debug(`[User] mediaProgressRemoved: ${mediaProgress.id} from user ${cachedUser.id}`) + cachedUser.mediaProgresses = cachedUser.mediaProgresses.filter((mp) => mp.id !== mediaProgress.id) + } + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize From da8fd2d9d5ceace0c3ba76e985aadbb58730615c Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 08:57:10 +0200 Subject: [PATCH 06/35] Set podcastId when mediaProgress is created --- server/models/User.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/models/User.js b/server/models/User.js index 153d6d48c..12f2f4bbc 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -634,6 +634,7 @@ class User extends Model { /** @type {import('./MediaProgress')|null} */ let mediaProgress = null let mediaItemId = null + let podcastId = null if (progressPayload.episodeId) { const podcastEpisode = await this.sequelize.models.podcastEpisode.findByPk(progressPayload.episodeId, { attributes: ['id', 'podcastId'], @@ -662,6 +663,7 @@ class User extends Model { } mediaItemId = podcastEpisode.id mediaProgress = podcastEpisode.mediaProgresses?.[0] + podcastId = podcastEpisode.podcastId } else { const libraryItem = await this.sequelize.models.libraryItem.findByPk(progressPayload.libraryItemId, { attributes: ['id', 'mediaId', 'mediaType'], @@ -694,6 +696,7 @@ class User extends Model { const newMediaProgressPayload = { userId: this.id, mediaItemId, + podcastId, mediaItemType: progressPayload.episodeId ? 'podcastEpisode' : 'book', duration: isNullOrNaN(progressPayload.duration) ? 0 : Number(progressPayload.duration), currentTime: isNullOrNaN(progressPayload.currentTime) ? 0 : Number(progressPayload.currentTime), From f1e46a351bc6a2fb062b087406cf39f2fa223979 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 09:05:54 +0200 Subject: [PATCH 07/35] Separate feed query from podcasts page query --- server/utils/queries/libraryItemsPodcastFilters.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 0cd159bac..6cfe8b55f 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -120,7 +120,8 @@ module.exports = { if (includeRSSFeed) { libraryItemIncludes.push({ model: Database.feedModel, - required: filterGroup === 'feed-open' + required: filterGroup === 'feed-open', + separate: true }) } if (filterGroup === 'issues') { From 2e48ec0dde68b2b6e08896cdc3d456e247c96cac Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 09:08:27 +0200 Subject: [PATCH 08/35] Use libraryItem.title[IgnorePrefix] for sorting podcasts page query --- server/utils/queries/libraryItemsPodcastFilters.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 6cfe8b55f..85dee37f8 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -84,9 +84,9 @@ module.exports = { return [[Sequelize.literal(`\`podcast\`.\`author\` COLLATE NOCASE ${nullDir}`)]] } else if (sortBy === 'media.metadata.title') { if (global.ServerSettings.sortingIgnorePrefix) { - return [[Sequelize.literal('`podcast`.`titleIgnorePrefix` COLLATE NOCASE'), dir]] + return [[Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]] } else { - return [[Sequelize.literal('`podcast`.`title` COLLATE NOCASE'), dir]] + return [[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]] } } else if (sortBy === 'media.numTracks') { return [['numEpisodes', dir]] From 707533df8f09979f4ea215698ed4101fb4ca3a9e Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 09:15:54 +0200 Subject: [PATCH 09/35] Remove numEpisodes subquery from podcasst page query --- server/utils/queries/libraryItemsPodcastFilters.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 85dee37f8..92002d797 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -159,7 +159,7 @@ module.exports = { replacements, distinct: true, attributes: { - include: [[Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'numEpisodes'], ...podcastIncludes] + include: [...podcastIncludes] }, include: [ { @@ -187,9 +187,6 @@ module.exports = { if (podcast.dataValues.numEpisodesIncomplete) { libraryItem.numEpisodesIncomplete = podcast.dataValues.numEpisodesIncomplete } - if (podcast.dataValues.numEpisodes) { - podcast.numEpisodes = podcast.dataValues.numEpisodes - } libraryItem.media = podcast From cb9fc3e0d141efec4e7b3823fffd0247e62a349f Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 09:22:06 +0200 Subject: [PATCH 10/35] Replace numEpisodesIncomplete subquery with cached user progress calculation --- server/utils/queries/libraryItemsPodcastFilters.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 92002d797..6b6d9cd7f 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -140,9 +140,6 @@ module.exports = { } const podcastIncludes = [] - if (includeNumEpisodesIncomplete) { - podcastIncludes.push([Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = pe.id AND mp.userId = :userId WHERE pe.podcastId = podcast.id AND (mp.isFinished = 0 OR mp.isFinished IS NULL))`), 'numEpisodesIncomplete']) - } let { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue) replacements.userId = user.id @@ -184,8 +181,15 @@ module.exports = { if (libraryItem.feeds?.length) { libraryItem.rssFeed = libraryItem.feeds[0] } - if (podcast.dataValues.numEpisodesIncomplete) { - libraryItem.numEpisodesIncomplete = podcast.dataValues.numEpisodesIncomplete + + if (includeNumEpisodesIncomplete) { + const numEpisodesComplete = user.mediaProgresses.reduce((acc, mp) => { + if (mp.podcastId === podcast.id && mp.isFinished) { + acc += 1 + } + return acc + }, 0) + libraryItem.numEpisodesIncomplete = podcast.numEpisodes - numEpisodesComplete } libraryItem.media = podcast From bd4f48ec3944faa88783dd2c03ebc157f766652e Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 09:29:57 +0200 Subject: [PATCH 11/35] Add required: true to includes in podcast episodes page query --- server/utils/queries/libraryItemsPodcastFilters.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 6b6d9cd7f..2c0ae4219 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -276,10 +276,12 @@ module.exports = { include: [ { model: Database.podcastModel, + required: true, where: userPermissionPodcastWhere.podcastWhere, include: [ { model: Database.libraryItemModel, + required: true, where: libraryItemWhere } ] From a5508cdc4c32a82f4c73286ff8a07ffea72dec10 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 09:32:00 +0200 Subject: [PATCH 12/35] Remove unnecessary 'distinct: true' from podcast episodes page query --- server/utils/queries/libraryItemsPodcastFilters.js | 1 - 1 file changed, 1 deletion(-) diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 2c0ae4219..6e152e8c2 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -288,7 +288,6 @@ module.exports = { }, ...podcastEpisodeIncludes ], - distinct: true, subQuery: false, order: podcastEpisodeOrder, limit, From 21343b5aa0ec0f9a971ff28fd49c66970c6c6752 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 09:40:29 +0200 Subject: [PATCH 13/35] Add count cache to libraryItemsPodcastQueries --- .../queries/libraryItemsPodcastFilters.js | 45 ++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 6e152e8c2..297de50c2 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -1,6 +1,9 @@ const Sequelize = require('sequelize') const Database = require('../../Database') const Logger = require('../../Logger') +const stringifySequelizeQuery = require('../stringifySequelizeQuery') + +const countCache = new Map() module.exports = { /** @@ -96,6 +99,28 @@ module.exports = { return [] }, + clearCountCache() { + countCache.clear() + }, + + async findAndCountAll(findOptions, model, limit, offset) { + const cacheKey = stringifySequelizeQuery(findOptions) + if (!countCache.has(cacheKey)) { + const count = await model.count(findOptions) + countCache.set(cacheKey, count) + } + + findOptions.limit = limit + findOptions.offset = offset + + const rows = await model.findAll(findOptions) + + return { + rows, + count: countCache.get(cacheKey) + } + }, + /** * Get library items for podcast media type using filter and sort * @param {string} libraryId @@ -151,7 +176,7 @@ module.exports = { replacements = { ...replacements, ...userPermissionPodcastWhere.replacements } podcastWhere.push(...userPermissionPodcastWhere.podcastWhere) - const { rows: podcasts, count } = await Database.podcastModel.findAndCountAll({ + const findOptions = { where: podcastWhere, replacements, distinct: true, @@ -167,10 +192,10 @@ module.exports = { } ], order: this.getOrder(sortBy, sortDesc), - subQuery: false, - limit: limit || null, - offset - }) + subQuery: false + } + + const { rows: podcasts, count } = await this.findAndCountAll(findOptions, Database.podcastModel, limit, offset) const libraryItems = podcasts.map((podcastExpanded) => { const libraryItem = podcastExpanded.libraryItem @@ -270,7 +295,7 @@ module.exports = { const userPermissionPodcastWhere = this.getUserPermissionPodcastWhereQuery(user) - const { rows: podcastEpisodes, count } = await Database.podcastEpisodeModel.findAndCountAll({ + const findOptions = { where: podcastEpisodeWhere, replacements: userPermissionPodcastWhere.replacements, include: [ @@ -289,10 +314,10 @@ module.exports = { ...podcastEpisodeIncludes ], subQuery: false, - order: podcastEpisodeOrder, - limit, - offset - }) + order: podcastEpisodeOrder + } + + const { rows: podcastEpisodes, count } = await this.findAndCountAll(findOptions, Database.podcastEpisodeModel, limit, offset) const libraryItems = podcastEpisodes.map((ep) => { const libraryItem = ep.podcast.libraryItem From 8f192b1b170c63f5daf299c4e8a65171c604f757 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 09:46:32 +0200 Subject: [PATCH 14/35] Add profiling to podcasts and podcast episodes page queries --- server/utils/queries/libraryItemsPodcastFilters.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 297de50c2..b15c01a97 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -1,6 +1,7 @@ const Sequelize = require('sequelize') const Database = require('../../Database') const Logger = require('../../Logger') +const { profile } = require('../../utils/profiler') const stringifySequelizeQuery = require('../stringifySequelizeQuery') const countCache = new Map() @@ -195,7 +196,9 @@ module.exports = { subQuery: false } - const { rows: podcasts, count } = await this.findAndCountAll(findOptions, Database.podcastModel, limit, offset) + const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll + + const { rows: podcasts, count } = await findAndCountAll(findOptions, Database.podcastModel, limit, offset) const libraryItems = podcasts.map((podcastExpanded) => { const libraryItem = podcastExpanded.libraryItem @@ -317,7 +320,9 @@ module.exports = { order: podcastEpisodeOrder } - const { rows: podcastEpisodes, count } = await this.findAndCountAll(findOptions, Database.podcastEpisodeModel, limit, offset) + const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll + + const { rows: podcastEpisodes, count } = await findAndCountAll(findOptions, Database.podcastEpisodeModel, limit, offset) const libraryItems = podcastEpisodes.map((ep) => { const libraryItem = ep.podcast.libraryItem From 0169bf551863516a737873b9ac2b8ec053e7c62b Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 12:38:44 +0200 Subject: [PATCH 15/35] Update podcast.numEpisodes when episodes are created or destroyed --- server/controllers/PodcastController.js | 4 ++++ server/managers/PodcastManager.js | 5 +++++ server/scanner/PodcastScanner.js | 8 +++++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index 90b2c3836..4bb6d0147 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -498,6 +498,10 @@ class PodcastController { req.libraryItem.changed('libraryFiles', true) await req.libraryItem.save() + // update number of episodes + req.libraryItem.media.numEpisodes = req.libraryItem.media.podcastEpisodes.length + await req.libraryItem.media.save() + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) res.json(req.libraryItem.toOldJSON()) } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 64d001a39..873a42830 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -232,6 +232,11 @@ class PodcastManager { await libraryItem.save() + if (libraryItem.media.numEpisodes !== libraryItem.media.podcastEpisodes.length) { + libraryItem.media.numEpisodes = libraryItem.media.podcastEpisodes.length + await libraryItem.media.save() + } + SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id) podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded() diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index 4958d5f77..8711362ca 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -131,6 +131,11 @@ class PodcastScanner { let hasMediaChanges = false + if (existingPodcastEpisodes.length !== media.numEpisodes) { + media.numEpisodes = existingPodcastEpisodes.length + hasMediaChanges = true + } + // Check if cover was removed if (media.coverPath && libraryItemData.imageLibraryFilesRemoved.some(lf => lf.metadata.path === media.coverPath)) { media.coverPath = null @@ -283,7 +288,8 @@ class PodcastScanner { lastEpisodeCheck: 0, maxEpisodesToKeep: 0, maxNewEpisodesToDownload: 3, - podcastEpisodes: newPodcastEpisodes + podcastEpisodes: newPodcastEpisodes, + numEpisodes: newPodcastEpisodes.length } const libraryItemObj = libraryItemData.libraryItemObject From bacefb5f6f5f7357671501dc52f8116f2ce2a4b3 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 12:41:47 +0200 Subject: [PATCH 16/35] Format PodcastScanner (Pretteier-only changes) --- server/scanner/PodcastScanner.js | 145 ++++++++++++++++--------------- 1 file changed, 77 insertions(+), 68 deletions(-) diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index 8711362ca..7d2d38e3c 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -1,4 +1,4 @@ -const uuidv4 = require("uuid").v4 +const uuidv4 = require('uuid').v4 const Path = require('path') const { LogLevel } = require('../utils/constants') const { getTitleIgnorePrefix } = require('../utils/index') @@ -8,9 +8,9 @@ const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtil const AudioFile = require('../objects/files/AudioFile') const CoverManager = require('../managers/CoverManager') const LibraryFile = require('../objects/files/LibraryFile') -const fsExtra = require("../libs/fsExtra") -const PodcastEpisode = require("../models/PodcastEpisode") -const AbsMetadataFileScanner = require("./AbsMetadataFileScanner") +const fsExtra = require('../libs/fsExtra') +const PodcastEpisode = require('../models/PodcastEpisode') +const AbsMetadataFileScanner = require('./AbsMetadataFileScanner') /** * Metadata for podcasts pulled from files @@ -32,13 +32,13 @@ const AbsMetadataFileScanner = require("./AbsMetadataFileScanner") */ class PodcastScanner { - constructor() { } + constructor() {} /** - * @param {import('../models/LibraryItem')} existingLibraryItem - * @param {import('./LibraryItemScanData')} libraryItemData + * @param {import('../models/LibraryItem')} existingLibraryItem + * @param {import('./LibraryItemScanData')} libraryItemData * @param {import('../models/Library').LibrarySettingsObject} librarySettings - * @param {import('./LibraryScan')} libraryScan + * @param {import('./LibraryScan')} libraryScan * @returns {Promise<{libraryItem:import('../models/LibraryItem'), wasUpdated:boolean}>} */ async rescanExistingPodcastLibraryItem(existingLibraryItem, libraryItemData, librarySettings, libraryScan) { @@ -59,28 +59,34 @@ class PodcastScanner { if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== existingPodcastEpisodes.length) { // Filter out and destroy episodes that were removed - existingPodcastEpisodes = await Promise.all(existingPodcastEpisodes.filter(async ep => { - if (libraryItemData.checkAudioFileRemoved(ep.audioFile)) { - libraryScan.addLog(LogLevel.INFO, `Podcast episode "${ep.title}" audio file was removed`) - // TODO: Should clean up other data linked to this episode - await ep.destroy() - return false - } - return true - })) + existingPodcastEpisodes = await Promise.all( + existingPodcastEpisodes.filter(async (ep) => { + if (libraryItemData.checkAudioFileRemoved(ep.audioFile)) { + libraryScan.addLog(LogLevel.INFO, `Podcast episode "${ep.title}" audio file was removed`) + // TODO: Should clean up other data linked to this episode + await ep.destroy() + return false + } + return true + }) + ) // Update audio files that were modified if (libraryItemData.audioLibraryFilesModified.length) { - let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesModified.map(lf => lf.new)) + let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans( + existingLibraryItem.mediaType, + libraryItemData, + libraryItemData.audioLibraryFilesModified.map((lf) => lf.new) + ) for (const podcastEpisode of existingPodcastEpisodes) { - let matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.metadata.path === podcastEpisode.audioFile.metadata.path) + let matchedScannedAudioFile = scannedAudioFiles.find((saf) => saf.metadata.path === podcastEpisode.audioFile.metadata.path) if (!matchedScannedAudioFile) { - matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.ino === podcastEpisode.audioFile.ino) + matchedScannedAudioFile = scannedAudioFiles.find((saf) => saf.ino === podcastEpisode.audioFile.ino) } if (matchedScannedAudioFile) { - scannedAudioFiles = scannedAudioFiles.filter(saf => saf !== matchedScannedAudioFile) + scannedAudioFiles = scannedAudioFiles.filter((saf) => saf !== matchedScannedAudioFile) const audioFile = new AudioFile(podcastEpisode.audioFile) audioFile.updateFromScan(matchedScannedAudioFile) podcastEpisode.audioFile = audioFile.toJSON() @@ -137,14 +143,14 @@ class PodcastScanner { } // Check if cover was removed - if (media.coverPath && libraryItemData.imageLibraryFilesRemoved.some(lf => lf.metadata.path === media.coverPath)) { + if (media.coverPath && libraryItemData.imageLibraryFilesRemoved.some((lf) => lf.metadata.path === media.coverPath)) { media.coverPath = null hasMediaChanges = true } // Update cover if it was modified if (media.coverPath && libraryItemData.imageLibraryFilesModified.length) { - let coverMatch = libraryItemData.imageLibraryFilesModified.find(iFile => iFile.old.metadata.path === media.coverPath) + let coverMatch = libraryItemData.imageLibraryFilesModified.find((iFile) => iFile.old.metadata.path === media.coverPath) if (coverMatch) { const coverPath = coverMatch.new.metadata.path if (coverPath !== media.coverPath) { @@ -159,7 +165,7 @@ class PodcastScanner { // Check if cover is not set and image files were found if (!media.coverPath && libraryItemData.imageLibraryFiles.length) { // Prefer using a cover image with the name "cover" otherwise use the first image - const coverMatch = libraryItemData.imageLibraryFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path)) + const coverMatch = libraryItemData.imageLibraryFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path)) media.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path hasMediaChanges = true } @@ -172,7 +178,7 @@ class PodcastScanner { if (key === 'genres') { const existingGenres = media.genres || [] - if (podcastMetadata.genres.some(g => !existingGenres.includes(g)) || existingGenres.some(g => !podcastMetadata.genres.includes(g))) { + if (podcastMetadata.genres.some((g) => !existingGenres.includes(g)) || existingGenres.some((g) => !podcastMetadata.genres.includes(g))) { libraryScan.addLog(LogLevel.DEBUG, `Updating podcast genres "${existingGenres.join(',')}" => "${podcastMetadata.genres.join(',')}" for podcast "${podcastMetadata.title}"`) media.genres = podcastMetadata.genres media.changed('genres', true) @@ -180,7 +186,7 @@ class PodcastScanner { } } else if (key === 'tags') { const existingTags = media.tags || [] - if (podcastMetadata.tags.some(t => !existingTags.includes(t)) || existingTags.some(t => !podcastMetadata.tags.includes(t))) { + if (podcastMetadata.tags.some((t) => !existingTags.includes(t)) || existingTags.some((t) => !podcastMetadata.tags.includes(t))) { libraryScan.addLog(LogLevel.DEBUG, `Updating podcast tags "${existingTags.join(',')}" => "${podcastMetadata.tags.join(',')}" for podcast "${podcastMetadata.title}"`) media.tags = podcastMetadata.tags media.changed('tags', true) @@ -195,7 +201,7 @@ class PodcastScanner { // If no cover then extract cover from audio file if available if (!media.coverPath && existingPodcastEpisodes.length) { - const audioFiles = existingPodcastEpisodes.map(ep => ep.audioFile) + const audioFiles = existingPodcastEpisodes.map((ep) => ep.audioFile) const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(audioFiles, existingLibraryItem.id, existingLibraryItem.path) if (extractedCoverPath) { libraryScan.addLog(LogLevel.DEBUG, `Updating podcast "${podcastMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`) @@ -227,10 +233,10 @@ class PodcastScanner { } /** - * - * @param {import('./LibraryItemScanData')} libraryItemData + * + * @param {import('./LibraryItemScanData')} libraryItemData * @param {import('../models/Library').LibrarySettingsObject} librarySettings - * @param {import('./LibraryScan')} libraryScan + * @param {import('./LibraryScan')} libraryScan * @returns {Promise} */ async scanNewPodcastLibraryItem(libraryItemData, librarySettings, libraryScan) { @@ -272,7 +278,7 @@ class PodcastScanner { // Set cover image from library file if (libraryItemData.imageLibraryFiles.length) { // Prefer using a cover image with the name "cover" otherwise use the first image - const coverMatch = libraryItemData.imageLibraryFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path)) + const coverMatch = libraryItemData.imageLibraryFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path)) podcastMetadata.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path } @@ -330,10 +336,10 @@ class PodcastScanner { } /** - * + * * @param {PodcastEpisode[]} podcastEpisodes Not the models for new podcasts - * @param {import('./LibraryItemScanData')} libraryItemData - * @param {import('./LibraryScan')} libraryScan + * @param {import('./LibraryItemScanData')} libraryItemData + * @param {import('./LibraryScan')} libraryScan * @param {string} [existingLibraryItemId] * @returns {Promise} */ @@ -370,8 +376,8 @@ class PodcastScanner { } /** - * - * @param {import('../models/LibraryItem')} libraryItem + * + * @param {import('../models/LibraryItem')} libraryItem * @param {import('./LibraryScan')} libraryScan * @returns {Promise} */ @@ -405,41 +411,44 @@ class PodcastScanner { explicit: !!libraryItem.media.explicit, podcastType: libraryItem.media.podcastType } - return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => { - // Add metadata.json to libraryFiles array if it is new - let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) - metadataLibraryFile = newLibraryFile.toJSON() - libraryItem.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino + return fsExtra + .writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)) + .then(async () => { + // Add metadata.json to libraryFiles array if it is new + let metadataLibraryFile = libraryItem.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath)) + if (storeMetadataWithItem) { + if (!metadataLibraryFile) { + const newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) + metadataLibraryFile = newLibraryFile.toJSON() + libraryItem.libraryFiles.push(metadataLibraryFile) + } else { + const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) + if (fileTimestamps) { + metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs + metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs + metadataLibraryFile.metadata.size = fileTimestamps.size + metadataLibraryFile.ino = fileTimestamps.ino + } + } + const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) + if (libraryItemDirTimestamps) { + libraryItem.mtime = libraryItemDirTimestamps.mtimeMs + libraryItem.ctime = libraryItemDirTimestamps.ctimeMs + let size = 0 + libraryItem.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) + libraryItem.size = size } } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) - if (libraryItemDirTimestamps) { - libraryItem.mtime = libraryItemDirTimestamps.mtimeMs - libraryItem.ctime = libraryItemDirTimestamps.ctimeMs - let size = 0 - libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) - libraryItem.size = size - } - } - libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) + libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) - return metadataLibraryFile - }).catch((error) => { - libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error) - return null - }) + return metadataLibraryFile + }) + .catch((error) => { + libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error) + return null + }) } } -module.exports = new PodcastScanner() \ No newline at end of file +module.exports = new PodcastScanner() From de5d8650e867bcbfc7bdfcf37aa5cb6cdd28f126 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 12:47:23 +0200 Subject: [PATCH 17/35] Add profiling to podcast library filterdata queries --- server/utils/queries/libraryFilters.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 5d5f0c83c..7312b9d5d 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -4,6 +4,7 @@ const Database = require('../../Database') const libraryItemsBookFilters = require('./libraryItemsBookFilters') const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters') const { createNewSortInstance } = require('../../libs/fastSort') +const { profile } = require('../../utils/profiler') const naturalSort = createNewSortInstance({ comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare }) @@ -474,7 +475,8 @@ module.exports = { // 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({ + const podcastModelCount = process.env.QUERY_PROFILING ? profile(Database.podcastModel.count.bind(Database.podcastModel)) : Database.podcastModel.count.bind(Database.podcastModel) + const podcastCountFromDatabase = await podcastModelCount({ include: { model: Database.libraryItemModel, attributes: [], @@ -489,7 +491,7 @@ module.exports = { // 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({ + const changedPodcasts = await podcastModelCount({ include: { model: Database.libraryItemModel, attributes: [], @@ -520,7 +522,8 @@ module.exports = { } // Something has changed in the podcasts table, so reload all of the filter data for library - const podcasts = await Database.podcastModel.findAll({ + const findAll = process.env.QUERY_PROFILING ? profile(Database.podcastModel.findAll.bind(Database.podcastModel)) : Database.podcastModel.findAll.bind(Database.podcastModel) + const podcasts = await findAll({ include: { model: Database.libraryItemModel, attributes: [], From 659164003f1a887440aca5c2272a468c912af295 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 13:27:47 +0200 Subject: [PATCH 18/35] Clear LibraryItemsPodcastFilters count cache after podcast[Episode] is created or destroryed --- server/models/Podcast.js | 9 +++++++++ server/models/PodcastEpisode.js | 10 +++++++++- server/utils/queries/libraryItemsPodcastFilters.js | 3 ++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 356bd41e7..fa27821db 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -1,6 +1,7 @@ const { DataTypes, Model } = require('sequelize') const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils') const Logger = require('../Logger') +const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') /** * @typedef PodcastExpandedProperties @@ -148,6 +149,14 @@ class Podcast extends Model { modelName: 'podcast' } ) + + Podcast.addHook('afterDestroy', async (instance) => { + libraryItemsPodcastFilters.clearCountCache('podcast', 'afterDestroy') + }) + + Podcast.addHook('afterCreate', async (instance) => { + libraryItemsPodcastFilters.clearCountCache('podcast', 'afterCreate') + }) } get hasMediaFiles() { diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 08baa4be3..4746f3150 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -1,5 +1,5 @@ const { DataTypes, Model } = require('sequelize') - +const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') /** * @typedef ChapterObject * @property {number} id @@ -132,6 +132,14 @@ class PodcastEpisode extends Model { onDelete: 'CASCADE' }) PodcastEpisode.belongsTo(podcast) + + PodcastEpisode.addHook('afterDestroy', async (instance) => { + libraryItemsPodcastFilters.clearCountCache('podcastEpisode', 'afterDestroy') + }) + + PodcastEpisode.addHook('afterCreate', async (instance) => { + libraryItemsPodcastFilters.clearCountCache('podcastEpisode', 'afterCreate') + }) } get size() { diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index b15c01a97..a04113811 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -100,7 +100,8 @@ module.exports = { return [] }, - clearCountCache() { + clearCountCache(model, hook) { + Logger.debug(`[LibraryItemsPodcastFilters] ${model}.${hook}: Clearing count cache`) countCache.clear() }, From 0a8186cbdaa92f11ac22d70c41e32615eac66cd8 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 13:38:54 +0200 Subject: [PATCH 19/35] Add ANALYZE to database init sequence --- server/Database.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/Database.js b/server/Database.js index 04d024dfb..0bdc3e902 100644 --- a/server/Database.js +++ b/server/Database.js @@ -191,6 +191,10 @@ class Database { Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', ')) await this.loadData() + + Logger.info(`[Database] running ANALYZE`) + await this.sequelize.query('ANALYZE') + Logger.info(`[Database] ANALYZE completed`) } /** From 7038f5730fb56caaa08220d1789b0b8969a4e7fa Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 16 Feb 2025 14:57:05 +0200 Subject: [PATCH 20/35] Set title[IgnorePrefix] when a podcast libraryItem is created --- server/controllers/PodcastController.js | 4 +++- server/managers/PodcastManager.js | 4 +++- server/scanner/PodcastScanner.js | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index 4bb6d0147..c66b4088d 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -107,7 +107,9 @@ class PodcastController { libraryFiles: [], extraData: {}, libraryId: library.id, - libraryFolderId: folder.id + libraryFolderId: folder.id, + title: podcast.title, + titleIgnorePrefix: podcast.titleIgnorePrefix }, { transaction } ) diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 873a42830..11e231ddc 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -627,7 +627,9 @@ class PodcastManager { libraryFiles: [], extraData: {}, libraryId: folder.libraryId, - libraryFolderId: folder.id + libraryFolderId: folder.id, + title: podcast.title, + titleIgnorePrefix: podcast.titleIgnorePrefix }, { transaction } ) diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index 7d2d38e3c..77ccf1342 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -303,6 +303,8 @@ class PodcastScanner { libraryItemObj.isMissing = false libraryItemObj.isInvalid = false libraryItemObj.extraData = {} + libraryItemObj.title = podcastObject.title + libraryItemObj.titleIgnorePrefix = getTitleIgnorePrefix(podcastObject.title) // If cover was not found in folder then check embedded covers in audio files if (!podcastObject.coverPath && scannedAudioFiles.length) { From 568bf0254dbff7324170ad4303a00b6574528b19 Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 18 Feb 2025 07:57:46 +0200 Subject: [PATCH 21/35] Change migration version to v2.19.4 --- server/migrations/changelog.md | 2 +- ...-podcast-queries.js => v2.19.4-improve-podcast-queries.js} | 2 +- ...ueries.test.js => v2.19.4-improve-podcast-queries.test.js} | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename server/migrations/{v2.19.3-improve-podcast-queries.js => v2.19.4-improve-podcast-queries.js} (99%) rename test/server/migrations/{v2.19.3-improve-podcast-queries.test.js => v2.19.4-improve-podcast-queries.test.js} (99%) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 64b2d6711..b447970f5 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -14,4 +14,4 @@ Please add a record of every database migration that you create to this file. Th | v2.17.6 | v2.17.6-share-add-isdownloadable | Adds the isDownloadable column to the mediaItemShares table | | v2.17.7 | v2.17.7-add-indices | Adds indices to the libraryItems and books tables to reduce query times | | v2.19.1 | v2.19.1-copy-title-to-library-items | Copies title and titleIgnorePrefix to the libraryItems table, creates update triggers and indices | -| v2.19.3 | v2.19.3-improve-podcast-queries | Adds numEpisodes to podcasts, adds podcastId to mediaProgresses, copies podcast title to libraryItems | +| v2.19.4 | v2.19.4-improve-podcast-queries | Adds numEpisodes to podcasts, adds podcastId to mediaProgresses, copies podcast title to libraryItems | diff --git a/server/migrations/v2.19.3-improve-podcast-queries.js b/server/migrations/v2.19.4-improve-podcast-queries.js similarity index 99% rename from server/migrations/v2.19.3-improve-podcast-queries.js rename to server/migrations/v2.19.4-improve-podcast-queries.js index 4e64d3ff4..689795c31 100644 --- a/server/migrations/v2.19.3-improve-podcast-queries.js +++ b/server/migrations/v2.19.4-improve-podcast-queries.js @@ -9,7 +9,7 @@ const util = require('util') * @property {MigrationContext} context - an object containing the migration context. */ -const migrationVersion = '2.19.3' +const migrationVersion = '2.19.4' const migrationName = `${migrationVersion}-improve-podcast-queries` const loggerPrefix = `[${migrationVersion} migration]` diff --git a/test/server/migrations/v2.19.3-improve-podcast-queries.test.js b/test/server/migrations/v2.19.4-improve-podcast-queries.test.js similarity index 99% rename from test/server/migrations/v2.19.3-improve-podcast-queries.test.js rename to test/server/migrations/v2.19.4-improve-podcast-queries.test.js index ae3784b90..0ca697d70 100644 --- a/test/server/migrations/v2.19.3-improve-podcast-queries.test.js +++ b/test/server/migrations/v2.19.4-improve-podcast-queries.test.js @@ -5,9 +5,9 @@ const { expect } = chai const { DataTypes, Sequelize } = require('sequelize') const Logger = require('../../../server/Logger') -const { up, down } = require('../../../server/migrations/v2.19.3-improve-podcast-queries') +const { up, down } = require('../../../server/migrations/v2.19.4-improve-podcast-queries') -describe('Migration v2.19.3-improve-podcast-queries', () => { +describe('Migration v2.19.4-improve-podcast-queries', () => { let sequelize let queryInterface let loggerInfoStub From 6290cfaeb1b93ceade37b871e8abd7550a0451f5 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 18 Feb 2025 17:19:06 -0600 Subject: [PATCH 22/35] Auto format --- server/utils/podcastUtils.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 1ecb0a753..53ed8e7e5 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -145,15 +145,15 @@ function extractEpisodeData(item) { if (item.enclosure?.[0]?.['$']?.url) { enclosure = item.enclosure[0]['$'] - } else if(item['media:content']?.find(c => c?.['$']?.url && (c?.['$']?.type ?? "").startsWith("audio"))) { - enclosure = item['media:content'].find(c => (c['$']?.type ?? "").startsWith("audio"))['$'] + } else if (item['media:content']?.find((c) => c?.['$']?.url && (c?.['$']?.type ?? '').startsWith('audio'))) { + enclosure = item['media:content'].find((c) => (c['$']?.type ?? '').startsWith('audio'))['$'] } else { Logger.error(`[podcastUtils] Invalid podcast episode data`) return null } const episode = { - enclosure: enclosure, + enclosure: enclosure } episode.enclosure.url = episode.enclosure.url.trim() From f9c0e52f18ea4c696f41c2900e06fd2f476236cf Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 19 Feb 2025 17:39:32 +0200 Subject: [PATCH 23/35] Add title triggers in new databases --- server/Database.js | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/server/Database.js b/server/Database.js index 0bdc3e902..498e9e5e7 100644 --- a/server/Database.js +++ b/server/Database.js @@ -190,6 +190,8 @@ class Database { await this.buildModels(force) Logger.info(`[Database] Db initialized with models:`, Object.keys(this.sequelize.models).join(', ')) + await this.addTriggers() + await this.loadData() Logger.info(`[Database] running ANALYZE`) @@ -771,6 +773,43 @@ class Database { return textQuery } + /** + * This is used to create necessary triggers for new databases. + * It adds triggers to update libraryItems.title[IgnorePrefix] when (books|podcasts).title[IgnorePrefix] is updated + */ + async addTriggers() { + await this.addTriggerIfNotExists('books', 'title', 'id', 'libraryItems', 'title', 'mediaId') + await this.addTriggerIfNotExists('books', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId') + await this.addTriggerIfNotExists('podcasts', 'title', 'id', 'libraryItems', 'title', 'mediaId') + await this.addTriggerIfNotExists('podcasts', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId') + } + + async addTriggerIfNotExists(sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) { + const action = `update_${targetTable}_${targetColumn}` + const fromSource = sourceTable === 'books' ? '' : `_from_${sourceTable}_${sourceColumn}` + const triggerName = this.convertToSnakeCase(`${action}${fromSource}`) + + const [[{ count }]] = await this.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='${triggerName}'`) + if (count > 0) return // Trigger already exists + + Logger.info(`[Database] Adding trigger ${triggerName}`) + + await this.sequelize.query(` + CREATE TRIGGER ${triggerName} + AFTER UPDATE OF ${sourceColumn} ON ${sourceTable} + FOR EACH ROW + BEGIN + UPDATE ${targetTable} + SET ${targetColumn} = NEW.${sourceColumn} + WHERE ${targetTable}.${targetIdColumn} = NEW.${sourceIdColumn}; + END; + `) + } + + convertToSnakeCase(str) { + return str.replace(/([A-Z])/g, '_$1').toLowerCase() + } + TextSearchQuery = class { constructor(sequelize, supportsUnaccent, query) { this.sequelize = sequelize From 2e8cb46c57081354e98a1f51a63be4a59eeca183 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 19 Feb 2025 21:04:07 +0200 Subject: [PATCH 24/35] Resort title-sorted bookshelf after title change --- client/components/app/LazyBookshelf.vue | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 22ab731d5..ab6d54a2b 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -547,6 +547,15 @@ export default { if (this.entityName === 'items' || this.entityName === 'series-books') { var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id) if (indexOf >= 0) { + if (this.entityName === 'items' && this.orderBy === 'media.metadata.title') { + const curTitle = this.entities[indexOf].media.metadata?.title + const newTitle = libraryItem.media.metadata?.title + if (curTitle != newTitle) { + console.log('Title changed. Re-sorting...') + this.resetEntities() + return + } + } this.entities[indexOf] = libraryItem if (this.entityComponentRefs[indexOf]) { this.entityComponentRefs[indexOf].setEntity(libraryItem) From 45f7f54b6c7fcb3c912aa93a7221eeab2d526096 Mon Sep 17 00:00:00 2001 From: Ivan Penchev Date: Wed, 12 Feb 2025 22:53:44 +0000 Subject: [PATCH 25/35] Translated using Weblate (Bulgarian) Currently translated at 70.8% (772 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bg/ --- client/strings/bg.json | 221 +++++++++++++++++++++-------------------- 1 file changed, 115 insertions(+), 106 deletions(-) diff --git a/client/strings/bg.json b/client/strings/bg.json index 086407bd1..a750820a9 100644 --- a/client/strings/bg.json +++ b/client/strings/bg.json @@ -11,14 +11,14 @@ "ButtonAuthors": "Автори", "ButtonBack": "Назад", "ButtonBrowseForFolder": "Прегледай за папка", - "ButtonCancel": "Откажи", + "ButtonCancel": "Отмени", "ButtonCancelEncode": "Откажи закодирането", "ButtonChangeRootPassword": "Промени паролата за Root", "ButtonCheckAndDownloadNewEpisodes": "Провери и Свали Нови Епизоди", "ButtonChooseAFolder": "Избери Папка", "ButtonChooseFiles": "Избери Файлове", - "ButtonClearFilter": "Изчисти Филтър", - "ButtonCloseFeed": "Затвори Feed", + "ButtonClearFilter": "Изчисти филтър", + "ButtonCloseFeed": "Затвори стената", "ButtonCollections": "Колекции", "ButtonConfigureScanner": "Конфигурирай Скенера", "ButtonCreate": "Създай", @@ -45,13 +45,14 @@ "ButtonMatchBooks": "Съвпадение на Книги", "ButtonNevermind": "Няма значение", "ButtonNextChapter": "Следваща Глава", - "ButtonOk": "Добре", - "ButtonOpenFeed": "Отвори Feed", + "ButtonOk": "Приемам", + "ButtonOpenFeed": "Отвори стената", "ButtonOpenManager": "Отвори Мениджър", - "ButtonPause": "Пауза", + "ButtonPause": "Паузирай", "ButtonPlay": "Пусни", "ButtonPlaying": "Пуска се", "ButtonPlaylists": "Плейлисти", + "ButtonPrevious": "Предишен", "ButtonPreviousChapter": "Предишна Глава", "ButtonPurgeAllCache": "Изчисти Всички Кешове", "ButtonPurgeItemsCache": "Изчисти Кеша на Елементи", @@ -60,8 +61,8 @@ "ButtonQuickMatch": "Бързо Съпоставяне", "ButtonReScan": "Пресканирай", "ButtonRead": "Прочети", - "ButtonReadLess": "Покажи по-малко", - "ButtonReadMore": "Покажи повече", + "ButtonReadLess": "Прочети кратко", + "ButtonReadMore": "Прочети дълго", "ButtonRefresh": "Обнови", "ButtonRemove": "Премахни", "ButtonRemoveAll": "Премахни Всички", @@ -77,7 +78,7 @@ "ButtonSaveTracklist": "Запази Списък с Канали", "ButtonScan": "Сканирай", "ButtonScanLibrary": "Сканирай Библиотека", - "ButtonSearch": "Търси", + "ButtonSearch": "Търси в", "ButtonSelectFolderPath": "Избери Път на Папка", "ButtonSeries": "Серии", "ButtonSetChaptersFromTracks": "Задай Глави от Песни", @@ -100,9 +101,9 @@ "ErrorUploadFetchMetadataNoResults": "Метаданните не могат да бъдат взети - опитайте да обновите заглавието и/или автора", "ErrorUploadLacksTitle": "Трябва да има Заглавие", "HeaderAccount": "Профил", - "HeaderAdvanced": "Разширени", + "HeaderAdvanced": "Разширени настройки", "HeaderAppriseNotificationSettings": "Apprise Notification Опции", - "HeaderAudioTracks": "Звуков Канал", + "HeaderAudioTracks": "Песни", "HeaderAudiobookTools": "Инструмент за Менижиране на Аудиокниги", "HeaderAuthentication": "Аутентикация", "HeaderBackups": "Архив", @@ -110,26 +111,26 @@ "HeaderChapters": "Глави", "HeaderChooseAFolder": "Избети Папка", "HeaderCollection": "Колекция", - "HeaderCollectionItems": "Елементи на Колекция", + "HeaderCollectionItems": "Елемент в колекция", "HeaderCover": "Корица", "HeaderCurrentDownloads": "Текущи Сваляния", "HeaderCustomMessageOnLogin": "Потребителско съобщение при влизане", "HeaderCustomMetadataProviders": "Потребителски Доставчици на Метаданни", "HeaderDetails": "Детайли", "HeaderDownloadQueue": "Опашка за Сваляне", - "HeaderEbookFiles": "Файлове на Електронни книги", + "HeaderEbookFiles": "Е-книги файлове", "HeaderEmail": "Емейл", "HeaderEmailSettings": "Настройки Емайл", "HeaderEpisodes": "Епизоди", "HeaderEreaderDevices": "Елктронни Четци", - "HeaderEreaderSettings": "Настройки на Електронни Четци", + "HeaderEreaderSettings": "Настройки на Е-четецът", "HeaderFiles": "Файлове", "HeaderFindChapters": "Намери Глави", "HeaderIgnoredFiles": "Игнорирани Файлове", "HeaderItemFiles": "Файлове на Елемент", "HeaderItemMetadataUtils": "Инструменти за Метаданни на Елемент", "HeaderLastListeningSession": "Последна Сесия на Слушане", - "HeaderLatestEpisodes": "Последни Епизоди", + "HeaderLatestEpisodes": "Последни епизоди", "HeaderLibraries": "Библиотеки", "HeaderLibraryFiles": "Файлове на Библиотека", "HeaderLibraryStats": "Статистика на Библиотека", @@ -147,17 +148,17 @@ "HeaderNewLibrary": "Нова Библиотека", "HeaderNotifications": "Известия", "HeaderOpenIDConnectAuthentication": "OpenID Connect Аутентикация", - "HeaderOpenRSSFeed": "Отвори RSS Feed", + "HeaderOpenRSSFeed": "Отвори RSS емисията", "HeaderOtherFiles": "Други Файлове", "HeaderPasswordAuthentication": "Паролна Аутентикация", "HeaderPermissions": "Права", "HeaderPlayerQueue": "Опашка на Плейъра", "HeaderPlaylist": "Плейлист", - "HeaderPlaylistItems": "Елементи на Плейлист", + "HeaderPlaylistItems": "Елементи от плейлист", "HeaderPodcastsToAdd": "Подкасти за Добавяне", "HeaderPreviewCover": "Преглед на Корица", - "HeaderRSSFeedGeneral": "RSS Детайли", - "HeaderRSSFeedIsOpen": "RSS Feed е Отворен", + "HeaderRSSFeedGeneral": "RSS подробности", + "HeaderRSSFeedIsOpen": "RSS емисията е отворена", "HeaderRSSFeeds": "RSS Feed-ове", "HeaderRemoveEpisode": "Премахни Епизод", "HeaderRemoveEpisodes": "Премахни {0} Епизоди", @@ -171,11 +172,11 @@ "HeaderSettingsExperimental": "Експериментални Функции", "HeaderSettingsGeneral": "Общи", "HeaderSettingsScanner": "Скенер", - "HeaderSleepTimer": "Таймер за Сън", + "HeaderSleepTimer": "Таймер за заспиване", "HeaderStatsLargestItems": "Най-Големите Елементи", "HeaderStatsLongestItems": "Най-Дългите Елементи (часове)", - "HeaderStatsMinutesListeningChart": "Минути на Слушане (последни 7 дни)", - "HeaderStatsRecentSessions": "Скорошни Сесии", + "HeaderStatsMinutesListeningChart": "Изслушани минути (последните 7 дни)", + "HeaderStatsRecentSessions": "Последни сесии", "HeaderStatsTop10Authors": "Топ 10 Автори", "HeaderStatsTop5Genres": "Топ 5 Жанрове", "HeaderTableOfContents": "Съдържание", @@ -186,7 +187,7 @@ "HeaderUpdateLibrary": "Обнови Библиотека", "HeaderUsers": "Потребители", "HeaderYearReview": "Преглед на {0} Година", - "HeaderYourStats": "Твоята Статистика", + "HeaderYourStats": "Вашата статистика", "LabelAbridged": "Съкратен", "LabelAbridgedChecked": "Съкратена (отбелязано)", "LabelAbridgedUnchecked": "Несъкратена (не отбелязано)", @@ -198,21 +199,22 @@ "LabelActivity": "Дейност", "LabelAddToCollection": "Добави в Колекция", "LabelAddToCollectionBatch": "Добави {0} Книги в Колекция", - "LabelAddToPlaylist": "Добави в Плейлист", + "LabelAddToPlaylist": "Добави в плейлист", "LabelAddToPlaylistBatch": "Добави {0} Елемент в Плейлист", - "LabelAddedAt": "Добавени На", + "LabelAddedAt": "Добавено в", + "LabelAddedDate": "Добавено", "LabelAdminUsersOnly": "Само за Администратори", - "LabelAll": "Всички", + "LabelAll": "Всичко", "LabelAllUsers": "Всички Потребители", "LabelAllUsersExcludingGuests": "Всички потребители без гости", "LabelAllUsersIncludingGuests": "Всички потребители включително гости", "LabelAlreadyInYourLibrary": "Вече е в твоята библиотека", "LabelAppend": "Добави", "LabelAuthor": "Автор", - "LabelAuthorFirstLast": "Автор (Първо Име, Фамилия)", - "LabelAuthorLastFirst": "Автор (Фамилия, Първо Име)", + "LabelAuthorFirstLast": "Автор (Първи, Последен)", + "LabelAuthorLastFirst": "Автор (Последен, Първи)", "LabelAuthors": "Автори", - "LabelAutoDownloadEpisodes": "Автоматично Сваляне на Епизоди", + "LabelAutoDownloadEpisodes": "Автоматично изтегляне на епизоди", "LabelAutoFetchMetadata": "Автоматично Взимане на Метаданни", "LabelAutoFetchMetadataHelp": "Взима метаданни за заглвие, автор и серии за да опрости качването. Допълнителни метаданни може да трябва да бъде взера след качване.", "LabelAutoLaunch": "Автоматично Стартиране", @@ -236,16 +238,16 @@ "LabelChapters": "Глави", "LabelChaptersFound": "намерени глави", "LabelClickForMoreInfo": "Кликни за повече информация", - "LabelClosePlayer": "Затвори Плейъра", + "LabelClosePlayer": "Затвори", "LabelCodec": "Кодек", - "LabelCollapseSeries": "Свий Серия", + "LabelCollapseSeries": "Скрий сериите", "LabelCollection": "Колекция", "LabelCollections": "Колекции", - "LabelComplete": "Завършено", + "LabelComplete": "Приключено", "LabelConfirmPassword": "Потвърди Парола", - "LabelContinueListening": "Продължи Слушане", - "LabelContinueReading": "Продължи Четене", - "LabelContinueSeries": "Продължи Серия", + "LabelContinueListening": "Продължи слушане", + "LabelContinueReading": "Продължи четене", + "LabelContinueSeries": "Продължи серии", "LabelCover": "Корица", "LabelCoverImageURL": "URL на Корица", "LabelCreatedAt": "Създадено на", @@ -263,15 +265,15 @@ "LabelDiscFromFilename": "Диск от Име на Файл", "LabelDiscFromMetadata": "Диск от Метаданни", "LabelDiscover": "Открий", - "LabelDownload": "Сваляне", + "LabelDownload": "Свали", "LabelDownloadNEpisodes": "Свали {0} епизоди", "LabelDuration": "Продължителност", "LabelDurationComparisonExactMatch": "(точно съвпадение)", "LabelDurationComparisonLonger": "({0} по-дълго)", "LabelDurationComparisonShorter": "({0} по-късо)", "LabelDurationFound": "Намерена продължителност:", - "LabelEbook": "Електронна книга", - "LabelEbooks": "Електронни книги", + "LabelEbook": "Е-Книга", + "LabelEbooks": "Е-книги", "LabelEdit": "Редакция", "LabelEmailSettingsFromAddress": "От Адрес", "LabelEmailSettingsRejectUnauthorized": "Отхвърли неавторизирани сертификати", @@ -280,41 +282,43 @@ "LabelEmailSettingsSecureHelp": "Ако е вярно възката ще изполва TLS когате се свързва със сървъра. Ако не е то TLS ще се използва ако сървъра поддържа разширението STARTTLS. В повечето случаи задайте тази стойност на истина ако се свързвате към порт 465. За порт 587 или 25 оставете я на лъжа. (от nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Тестов Адрес", "LabelEmbeddedCover": "Вградена Корица", - "LabelEnable": "Включи", + "LabelEnable": "Активирай", "LabelEnd": "Край", + "LabelEndOfChapter": "Край на глава", "LabelEpisode": "Епизод", "LabelEpisodeTitle": "Заглавие на Епизод", "LabelEpisodeType": "Тип на Епизод", "LabelExample": "Пример", "LabelExplicit": "Експлицитно", + "LabelFeedURL": "URL на емисия", "LabelFetchingMetadata": "Взимане на Метаданни", "LabelFile": "Файл", "LabelFileBirthtime": "Дата на създаване на файла", - "LabelFileModified": "Файлът променен", - "LabelFilename": "Име на Файл", + "LabelFileModified": "Дата на модификация на файла", + "LabelFilename": "Име на файла", "LabelFilterByUser": "Филтриране по Потребител", "LabelFindEpisodes": "Намери Епизоди", - "LabelFinished": "Завършено", + "LabelFinished": "Дата на приключване", "LabelFolder": "Папка", "LabelFolders": "Папки", "LabelFontBold": "Получерно", - "LabelFontBoldness": "Плътност на шрифта", + "LabelFontBoldness": "Дебелина на шрифта", "LabelFontFamily": "Шрифт", "LabelFontItalic": "Курсив", - "LabelFontScale": "Мащаб на Шрифта", + "LabelFontScale": "Мащаб на шрифта", "LabelFontStrikethrough": "Зачертан", "LabelFormat": "Формат", "LabelGenre": "Жанр", "LabelGenres": "Жанрове", "LabelHardDeleteFile": "Пълно Изтриване на Файл", - "LabelHasEbook": "Има електронна книга", - "LabelHasSupplementaryEbook": "Има допълнителна електронна книга", + "LabelHasEbook": "Има е-книга", + "LabelHasSupplementaryEbook": "Има допълнителна е-книга", "LabelHighestPriority": "Най-висок Приоритет", "LabelHost": "Хост", "LabelHour": "Час", "LabelIcon": "Икона", "LabelImageURLFromTheWeb": "URL на Изображение от Интернет", - "LabelInProgress": "В Прогрес", + "LabelInProgress": "В процес на изпълнение", "LabelIncludeInTracklist": "Включи в Списъка с Канали", "LabelIncomplete": "Незавършено", "LabelInterval": "Интервал", @@ -337,7 +341,7 @@ "LabelLastTime": "Последно Време", "LabelLastUpdate": "Последно Обновяване", "LabelLayout": "Оформление", - "LabelLayoutSinglePage": "Една Страница", + "LabelLayoutSinglePage": "Единична страница", "LabelLayoutSplitPage": "Разделена Страница", "LabelLess": "По-малко", "LabelLibrariesAccessibleToUser": "Библиотеки Достъпни за Потребителя", @@ -345,8 +349,8 @@ "LabelLibraryItem": "Елемент на Библиотека", "LabelLibraryName": "Име на Библиотека", "LabelLimit": "Лимит", - "LabelLineSpacing": "Линейно Разстояние", - "LabelListenAgain": "Слушай Отново", + "LabelLineSpacing": "Междуредие", + "LabelListenAgain": "Слушай отново", "LabelLogLevelDebug": "Дебъг", "LabelLogLevelInfo": "Информация", "LabelLogLevelWarn": "Предупреждение", @@ -355,7 +359,7 @@ "LabelMatchExistingUsersBy": "Съпостави съществуващи потребители по", "LabelMatchExistingUsersByDescription": "Използва се за свързване на съществуващи потребители. След свързване потребителите ще бъдат съпоставени по уникален идентификатор от вашия доставчик на SSO", "LabelMediaPlayer": "Медия Плейър", - "LabelMediaType": "Тип на Медията", + "LabelMediaType": "Тип медия", "LabelMetaTag": "Мета Таг", "LabelMetaTags": "Мета Тагове", "LabelMetadataOrderOfPrecedenceDescription": "По-високите източници на метаданни ще заменят по-ниските", @@ -367,19 +371,19 @@ "LabelMobileRedirectURIs": "Позволени URI за Мобилно Пренасочване", "LabelMobileRedirectURIsDescription": "Това е whitelist на валидни URI за пренасочване за мобилни приложения. По подразбиране е audiobookshelf://oauth, който може да премахнете или допълните с допълнителни URI за интеграция на приложения от трети страни. Използването на звезда (*) като единствен запис позволява всеки URI.", "LabelMore": "Повече", - "LabelMoreInfo": "Повече Информация", + "LabelMoreInfo": "Повече информация", "LabelName": "Име", "LabelNarrator": "Разказвач", "LabelNarrators": "Разказвачи", "LabelNew": "Нови", "LabelNewPassword": "Нова Парола", - "LabelNewestAuthors": "Най-нови Автори", - "LabelNewestEpisodes": "Най-нови Епизоди", + "LabelNewestAuthors": "Най-новите автори", + "LabelNewestEpisodes": "Най-новите епизоди", "LabelNextBackupDate": "Следваща Дата на Архивиране", "LabelNextScheduledRun": "Следващо Планирано Изпълнение", "LabelNoCustomMetadataProviders": "Няма потребителски доставчици на метаданни", "LabelNoEpisodesSelected": "Няма избрани епизоди", - "LabelNotFinished": "Не е завършено", + "LabelNotFinished": "Не е приключено", "LabelNotStarted": "Не е започнато", "LabelNotes": "Бележки", "LabelNotificationAppriseURL": "Apprise URL-и", @@ -392,7 +396,7 @@ "LabelNotificationsMaxQueueSize": "Максимален размер на опашката за известия", "LabelNotificationsMaxQueueSizeHelp": "Събитията са ограничени до изстрелване на 1 на секунда. Събитията ще бъдат игнорирани ако опашката е на максимален размер. Това предотвратява спамирането на известия.", "LabelNumberOfBooks": "Брой на Книги", - "LabelNumberOfEpisodes": "# Епизоди", + "LabelNumberOfEpisodes": "Брой епизоди", "LabelOpenRSSFeed": "Отвори RSS Feed", "LabelOverwrite": "Презапиши", "LabelPassword": "Парола", @@ -414,24 +418,26 @@ "LabelPodcasts": "Подкасти", "LabelPort": "Порт", "LabelPrefixesToIgnore": "Префикси за Игнориране (без значение за главни/малки букви)", - "LabelPreventIndexing": "Предотврати индексирането на вашия feed от iTunes и Google podcast директории", + "LabelPreventIndexing": "Предотвратете индексирането на вашата емисия от директориите на iTunes и Google за подкасти", "LabelPrimaryEbook": "Основна Електронна Книга", "LabelProgress": "Прогрес", "LabelProvider": "Доставчик", - "LabelPubDate": "Дата на Издаване", - "LabelPublishYear": "Година на Издаване", + "LabelPubDate": "Дата на публикуване", + "LabelPublishYear": "Година на публикуване", + "LabelPublishedDate": "Публикувани {0}", "LabelPublisher": "Издател", "LabelPublishers": "Издателство", - "LabelRSSFeedCustomOwnerEmail": "Потребителски собственик Email", - "LabelRSSFeedCustomOwnerName": "Потребителски собственик Име", + "LabelRSSFeedCustomOwnerEmail": "Персонализиран имейл на собственика", + "LabelRSSFeedCustomOwnerName": "Персонализирано име на собственика", "LabelRSSFeedOpen": "RSS Feed Оптворен", - "LabelRSSFeedPreventIndexing": "Предотврати индексиране", - "LabelRSSFeedSlug": "RSS Feed слъг", + "LabelRSSFeedPreventIndexing": "Предотвратете индексиране", + "LabelRSSFeedSlug": "идентификатор на RSS емисия", + "LabelRandomly": "Случайно", "LabelRead": "Прочети", - "LabelReadAgain": "Прочети Отново", + "LabelReadAgain": "Прочети отново", "LabelReadEbookWithoutProgress": "Прочети електронната книга без записване прогрес", - "LabelRecentSeries": "Скорошни Серии", - "LabelRecentlyAdded": "Наскоро Добавени", + "LabelRecentSeries": "Скорошни серии", + "LabelRecentlyAdded": "Скорошно добавени", "LabelRecommended": "Препоръчано", "LabelRedo": "Повтори", "LabelRegion": "Регион", @@ -448,12 +454,12 @@ "LabelSelectUsers": "Избери Потребители", "LabelSendEbookToDevice": "Изпрати електронна книга до ...", "LabelSequence": "Последователност", - "LabelSeries": "Серия", + "LabelSeries": "От сериите", "LabelSeriesName": "Име на Серия", "LabelSeriesProgress": "Прогрес на Серия", "LabelServerYearReview": "Преглед на годината на сървъра ({0})", - "LabelSetEbookAsPrimary": "Задай като основна", - "LabelSetEbookAsSupplementary": "Задай като допълнителна", + "LabelSetEbookAsPrimary": "Направи главен", + "LabelSetEbookAsSupplementary": "Направи второстепенен", "LabelSettingsAudiobooksOnly": "Само аудиокниги", "LabelSettingsAudiobooksOnlyHelp": "Активирането на тази настройка ще игнорира файловете на електронни книги, освен ако не са в папка с аудиокниги, в което случай ще бъдат зададени като допълнителни електронни книги", "LabelSettingsBookshelfViewHelp": "Скеуморфен дизайн с дървени рафтове", @@ -491,9 +497,9 @@ "LabelSettingsStoreMetadataWithItem": "Запази метаданните с елемента", "LabelSettingsStoreMetadataWithItemHelp": "По подразбиране метаданните се съхраняват в /metadata/items, като активирате тази настройка метаданните ще се съхраняват в папката на елемента на вашата библиотека", "LabelSettingsTimeFormat": "Формат на Време", - "LabelShowAll": "Покажи Всички", + "LabelShowAll": "Покажи всички", "LabelSize": "Размер", - "LabelSleepTimer": "Таймер за Сън", + "LabelSleepTimer": "Таймер за изключване", "LabelSlug": "Слъг", "LabelStart": "Старт", "LabelStartTime": "Начално Време", @@ -501,19 +507,19 @@ "LabelStartedAt": "Стартирано на", "LabelStatsAudioTracks": "Аудио Канали", "LabelStatsAuthors": "Автори", - "LabelStatsBestDay": "Най-добър Ден", - "LabelStatsDailyAverage": "Дневна Средна Стойност", - "LabelStatsDays": "Дни", - "LabelStatsDaysListened": "Дни Слушани", + "LabelStatsBestDay": "Най-добър ден", + "LabelStatsDailyAverage": "Средно дневно", + "LabelStatsDays": "Общо дни", + "LabelStatsDaysListened": "Общо слушани дни", "LabelStatsHours": "Часове", - "LabelStatsInARow": "подред", - "LabelStatsItemsFinished": "Завършени Елементи", + "LabelStatsInARow": "последователно", + "LabelStatsItemsFinished": "Приключени елементи", "LabelStatsItemsInLibrary": "Елементи в Библиотеката", "LabelStatsMinutes": "минути", - "LabelStatsMinutesListening": "Минути Слушани", + "LabelStatsMinutesListening": "Общо слушани минути", "LabelStatsOverallDays": "Общо Дни", "LabelStatsOverallHours": "Общо Часове", - "LabelStatsWeekListening": "Седмица Слушане", + "LabelStatsWeekListening": "Общо слушани седмици", "LabelSubtitle": "Подзаглавие", "LabelSupportedFileTypes": "Поддържани Типове Файлове", "LabelTag": "Таг", @@ -531,7 +537,7 @@ "LabelTimeBase": "Времева Основа", "LabelTimeListened": "Време Слушано", "LabelTimeListenedToday": "Време Слушано Днес", - "LabelTimeRemaining": "{0} оставащо време", + "LabelTimeRemaining": "{0} оставащи", "LabelTimeToShift": "Време за изместване в секунди", "LabelTitle": "Заглавие", "LabelToolsEmbedMetadata": "Вграждане на Метаданни", @@ -544,14 +550,14 @@ "LabelTotalTimeListened": "Общо Време Слушано", "LabelTrackFromFilename": "Канал от Име на Файл", "LabelTrackFromMetadata": "Канал от Метаданни", - "LabelTracks": "Канали", + "LabelTracks": "Тракове", "LabelTracksMultiTrack": "Многоканален", "LabelTracksNone": "Няма канали", "LabelTracksSingleTrack": "Единичен канал", "LabelType": "Тип", "LabelUnabridged": "Несъкратен", "LabelUndo": "Отмени", - "LabelUnknown": "Неизвестно", + "LabelUnknown": "Неизвестен", "LabelUpdateCover": "Обнови Корица", "LabelUpdateCoverHelp": "Позволи презаписване на съществуващите корици за избраните книги, когато се намери съвпадение", "LabelUpdateDetails": "Обнови Детайли", @@ -563,7 +569,7 @@ "LabelUseChapterTrack": "Използвай канал за глава", "LabelUseFullTrack": "Използвай пълен канал", "LabelUser": "Потребител", - "LabelUsername": "Потребителско Име", + "LabelUsername": "Потребителско име", "LabelValue": "Стойност", "LabelVersion": "Версия", "LabelViewBookmarks": "Виж Отметки", @@ -571,10 +577,12 @@ "LabelViewQueue": "Виж Опашка", "LabelVolume": "Сила на Звука", "LabelWeekdaysToRun": "Делници за изпълнение", + "LabelYearReviewHide": "Скрий ревю на годината ти", + "LabelYearReviewShow": "Виж ревю на годината ти", "LabelYourAudiobookDuration": "Продължителност на вашата аудиокнига", - "LabelYourBookmarks": "Вашите Отметки", + "LabelYourBookmarks": "Твойте отметки", "LabelYourPlaylists": "Вашите Плейлисти", - "LabelYourProgress": "Вашият Прогрес", + "LabelYourProgress": "Твоят прогрес", "MessageAddToPlayerQueue": "Добави към опашката на плейъра", "MessageAppriseDescription": "За да ползвате тази функция трябва да имате активна инстанция на Apprise API или на друго АПИ което да обработва тези заявки.
The Apprise API Url-а трябва дае пълния URL път за изпращане на известията, например, ако вашето АПИ ве подава от http://192.168.1.1:8337 трябва да сложитев http://192.168.1.1:8337/notify.", "MessageBatchQuickMatchDescription": "Бързото Съпоставяне ще опита да добави липсващи корици и метаданни за избраните елементи. Активирайте опциите по-долу, за да позволите на Бързото съпоставяне да презапише съществуващите корици и/или метаданни.", @@ -617,34 +625,34 @@ "MessageConfirmRenameTagMergeNote": "Забележка: Този таг вече съществува и ще бъде слято.", "MessageConfirmRenameTagWarning": "Внимание! Вече съществува подобен таг с различно писане \"{0}\".", "MessageConfirmSendEbookToDevice": "Сигурни ли сте, че искате да изпратите {0} електронна книга \"{1}\" до устройство \"{2}\"?", - "MessageDownloadingEpisode": "Изтегляне на епизод", + "MessageDownloadingEpisode": "Сваля епизод", "MessageDragFilesIntoTrackOrder": "Плъзнете файлове в правилния ред на каналите", "MessageEmbedFinished": "Вграждането завърши!", - "MessageEpisodesQueuedForDownload": "{0} епизод(и) в опашка за изтегляне", - "MessageFeedURLWillBe": "Feed URL-a ще бъде {0}", - "MessageFetching": "Взимане...", + "MessageEpisodesQueuedForDownload": "{0} Епизод(и) са сложени за сваляне", + "MessageFeedURLWillBe": "Адресът на емисията ще бъде {0}", + "MessageFetching": "Извличане...", "MessageForceReScanDescription": "ще сканира всички файлове отново като прясно сканиране. Аудио файлове ID3 тагове, OPF файлове и текстови файлове ще бъдат сканирани като нови.", "MessageImportantNotice": "Важно Съобщение!", "MessageInsertChapterBelow": "Вмъкни глава под", "MessageItemsSelected": "{0} избрани", "MessageItemsUpdated": "{0} елемента обновени", "MessageJoinUsOn": "Присъединете се към нас", - "MessageLoading": "Зареждане...", + "MessageLoading": "Зарежда...", "MessageLoadingFolders": "Зареждане на Папки...", "MessageM4BFailed": "M4B Провалено!", "MessageM4BFinished": "M4B Завършено!", "MessageMapChapterTitles": "Съпостави заглавията на главите със съществуващите глави на аудиокнигата без да променяш времената", "MessageMarkAllEpisodesFinished": "Маркирай всички епизоди като завършени", "MessageMarkAllEpisodesNotFinished": "Маркирай всички епизоди като незавършени", - "MessageMarkAsFinished": "Маркирай като Завършено", + "MessageMarkAsFinished": "Маркирай като завършено", "MessageMarkAsNotFinished": "Маркирай като Незавършено", "MessageMatchBooksDescription": "ще се опита да съпостави книги в библиотеката с книга от избрания доставчик за търсене и ще попълни празни детайли и корици. Не презаписва детайлите.", "MessageNoAudioTracks": "Няма аудио канали", "MessageNoAuthors": "Няма Автори", "MessageNoBackups": "Няма архиви", - "MessageNoBookmarks": "Няма Отметки", - "MessageNoChapters": "Няма Глави", - "MessageNoCollections": "Няма Колекции", + "MessageNoBookmarks": "Няма отметки", + "MessageNoChapters": "Няма глави", + "MessageNoCollections": "Няма колекции", "MessageNoCoversFound": "Не са намерени корици", "MessageNoDescription": "Няма описание", "MessageNoDownloadsInProgress": "Няма изтегляния в прогрес", @@ -654,9 +662,9 @@ "MessageNoFoldersAvailable": "Няма налични папки", "MessageNoGenres": "Няма Жанрове", "MessageNoIssues": "Няма проблеми", - "MessageNoItems": "Няма Елементи", + "MessageNoItems": "Няма елементи", "MessageNoItemsFound": "Няма намерени елементи", - "MessageNoListeningSessions": "Няма слушателски сесии", + "MessageNoListeningSessions": "Няма сесии за слушане", "MessageNoLogs": "Няма логове", "MessageNoMediaProgress": "Няма прогрес на медията", "MessageNoNotifications": "Няма известия", @@ -666,20 +674,21 @@ "MessageNoSeries": "Няма Серии", "MessageNoTags": "Няма Тагове", "MessageNoTasksRunning": "Няма вършещи се задачи", - "MessageNoUpdatesWereNecessary": "Не бяха необходими обновления", - "MessageNoUserPlaylists": "Няма плейлисти на потребителя", + "MessageNoUpdatesWereNecessary": "Няма нужда от обновяване", + "MessageNoUserPlaylists": "Нямате създадени плейлисти", "MessageNotYetImplemented": "Още не е изпълнено", "MessageOr": "или", "MessagePauseChapter": "Пауза на глава", "MessagePlayChapter": "Пусни налчалото на глава", "MessagePlaylistCreateFromCollection": "Създай плейлист от колекция", "MessagePodcastHasNoRSSFeedForMatching": "Подкастът няма URL адрес на RSS feed за използване за съпоставяне", + "MessagePodcastSearchField": "Въведи какво да търся или RSS емисия адрес", "MessageQuickMatchDescription": "Попълни празните детайли и корици с първия резултат от '{0}'. Не презаписва детайлите, освен ако не е активирана настройката 'Предпочети съвпадащи метаданни' на сървъра.", "MessageRemoveChapter": "Премахни глава", "MessageRemoveEpisodes": "Премахни {0} епизод(и)", "MessageRemoveFromPlayerQueue": "Премахни от опашката на плейъра", "MessageRemoveUserWarning": "Сигурни ли сте, че искате да изтриете потребител \"{0}\" завинаги?", - "MessageReportBugsAndContribute": "Съобщавайте за грешки, заявявайте функции и допринасяйте на", + "MessageReportBugsAndContribute": "Докладвайте грешки, поискайте нови функции и допринасяйте на", "MessageResetChaptersConfirm": "Сигурни ли сте, че искате да нулирате главите и да отмените промените, които сте направили?", "MessageRestoreBackupConfirm": "Сигурни ли сте, че искате да възстановите архива създаден на", "MessageRestoreBackupWarning": "Възстановяването на архив ще презапише цялата база данни, намираща се в /config и кориците в /metadata/items & /metadata/authors.

Архивите не променят файловете в папките на вашата библиотека. Ако сте активирали настройките на сървъра за съхранение на корици и метаданни в папките на вашата библиотека, те няма да бъдат архивирани или презаписани.

Всички клиенти, използващи вашия сървър, ще бъдат автоматично обновени.", @@ -700,8 +709,8 @@ "NoteChangeRootPassword": "Root потребителят е единственият потребител, който може да има празна парола", "NoteChapterEditorTimes": "Забележка: Първото време на начало на главата трябва да остане на 0:00, а последното време на начало на главата не може да надвишава продължителността на тази аудиокнига.", "NoteFolderPicker": "Забележка: папките, които вече са картографирани, няма да бъдат показани", - "NoteRSSFeedPodcastAppsHttps": "Внимание: Повечето приложения за подкасти изискват URL адреса на RSS feed да използва HTTPS", - "NoteRSSFeedPodcastAppsPubDate": "Внимание: 1 или повече от вашите епизоди нямат дата на публикуване. Някои приложения за подкасти изискват това", + "NoteRSSFeedPodcastAppsHttps": "Предупреждение: Повечето приложения за подкасти изискват URL адресът на RSS емисията да използва HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "Предупреждение: Един или повече от вашите епизоди нямат дата на публикуване. Някои приложения за подкасти изискват това.", "NoteUploaderFoldersWithMediaFiles": "Папките с медийни файлове ще бъдат обработени като отделни елементи на библиотеката.", "NoteUploaderOnlyAudioFiles": "Ако качвате само аудио файлове, то всеки аудио файл ще бъде обработен като отделна аудиокнига.", "NoteUploaderUnsupportedFiles": "Неподдържаните файлове се игнорират. При избор или пускане на папка, други файлове, които не са в папка на елемент, се игнорират.", @@ -731,9 +740,9 @@ "ToastCollectionUpdateSuccess": "Колекцията е обновена", "ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена", "ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени", - "ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като завършено", + "ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено", "ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен", - "ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като незавършено", + "ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено", "ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен", "ToastLibraryCreateFailed": "Неуспешно създаване на библиотека", "ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена", @@ -747,9 +756,9 @@ "ToastPlaylistRemoveSuccess": "Плейлистът е премахнат", "ToastPlaylistUpdateSuccess": "Плейлистът е обновен", "ToastPodcastCreateFailed": "Неуспешно създаване на подкаст", - "ToastPodcastCreateSuccess": "Подкастът е създаден", - "ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS feed", - "ToastRSSFeedCloseSuccess": "RSS feed затворен", + "ToastPodcastCreateSuccess": "Подкаст успешно създаден", + "ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията", + "ToastRSSFeedCloseSuccess": "RSS емисията е затворена", "ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция", "ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция", "ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство", From 293440006beb4eadb333ada9aec3555cb53adabd Mon Sep 17 00:00:00 2001 From: Ivan Penchev Date: Thu, 13 Feb 2025 00:54:20 +0000 Subject: [PATCH 26/35] Translated using Weblate (Bulgarian) Currently translated at 77.2% (841 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bg/ --- client/strings/bg.json | 75 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/client/strings/bg.json b/client/strings/bg.json index a750820a9..e7b15bc89 100644 --- a/client/strings/bg.json +++ b/client/strings/bg.json @@ -1,5 +1,5 @@ { - "ButtonAdd": "Добави", + "ButtonAdd": "Създай", "ButtonAddChapters": "Добави Глави", "ButtonAddDevice": "Добави Устройство", "ButtonAddLibrary": "Добави Библиотека", @@ -10,8 +10,10 @@ "ButtonApplyChapters": "Приложи Глави", "ButtonAuthors": "Автори", "ButtonBack": "Назад", + "ButtonBatchEditPopulateFromExisting": "Попълни от съществуващи", + "ButtonBatchEditPopulateMapDetails": "Попълни подробности за картата", "ButtonBrowseForFolder": "Прегледай за папка", - "ButtonCancel": "Отмени", + "ButtonCancel": "Отказ", "ButtonCancelEncode": "Откажи закодирането", "ButtonChangeRootPassword": "Промени паролата за Root", "ButtonCheckAndDownloadNewEpisodes": "Провери и Свали Нови Епизоди", @@ -19,6 +21,7 @@ "ButtonChooseFiles": "Избери Файлове", "ButtonClearFilter": "Изчисти филтър", "ButtonCloseFeed": "Затвори стената", + "ButtonCloseSession": "Затвори отворената сесия", "ButtonCollections": "Колекции", "ButtonConfigureScanner": "Конфигурирай Скенера", "ButtonCreate": "Създай", @@ -28,6 +31,9 @@ "ButtonEdit": "Редактирай", "ButtonEditChapters": "Редактирай Глави", "ButtonEditPodcast": "Редактирай Подкаст", + "ButtonEnable": "Активирай", + "ButtonFireAndFail": "Задействай и неуспей", + "ButtonFireOnTest": "Задействай събитие onTest", "ButtonForceReScan": "Принудително Пресканиране", "ButtonFullPath": "Пълен Път", "ButtonHide": "Скрий", @@ -44,20 +50,26 @@ "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": "Прочети", @@ -78,6 +90,8 @@ "ButtonSaveTracklist": "Запази Списък с Канали", "ButtonScan": "Сканирай", "ButtonScanLibrary": "Сканирай Библиотека", + "ButtonScrollLeft": "Скролни наляво", + "ButtonScrollRight": "Скролни надясно", "ButtonSearch": "Търси в", "ButtonSelectFolderPath": "Избери Път на Папка", "ButtonSeries": "Серии", @@ -87,8 +101,10 @@ "ButtonShow": "Покажи", "ButtonStartM4BEncode": "Започни M4B Кодиране", "ButtonStartMetadataEmbed": "Започни Вграждане на Метаданни", + "ButtonStats": "Статистики", "ButtonSubmit": "Изпрати", "ButtonTest": "Тест", + "ButtonUnlinkOpenId": "Премахни връзката с OpenID", "ButtonUpload": "Качи", "ButtonUploadBackup": "Качи Backup", "ButtonUploadCover": "Качи Корица", @@ -101,6 +117,7 @@ "ErrorUploadFetchMetadataNoResults": "Метаданните не могат да бъдат взети - опитайте да обновите заглавието и/или автора", "ErrorUploadLacksTitle": "Трябва да има Заглавие", "HeaderAccount": "Профил", + "HeaderAddCustomMetadataProvider": "Добави персонализиран доставчик на метаданни", "HeaderAdvanced": "Разширени настройки", "HeaderAppriseNotificationSettings": "Apprise Notification Опции", "HeaderAudioTracks": "Песни", @@ -146,13 +163,17 @@ "HeaderMetadataToEmbed": "Метаданни за Вграждане", "HeaderNewAccount": "Нов Профил", "HeaderNewLibrary": "Нова Библиотека", + "HeaderNotificationCreate": "Създай нотификация", + "HeaderNotificationUpdate": "Обнови нотификация", "HeaderNotifications": "Известия", "HeaderOpenIDConnectAuthentication": "OpenID Connect Аутентикация", + "HeaderOpenListeningSessions": "Отвори сесия", "HeaderOpenRSSFeed": "Отвори RSS емисията", "HeaderOtherFiles": "Други Файлове", "HeaderPasswordAuthentication": "Паролна Аутентикация", "HeaderPermissions": "Права", "HeaderPlayerQueue": "Опашка на Плейъра", + "HeaderPlayerSettings": "Настройки на плейъра", "HeaderPlaylist": "Плейлист", "HeaderPlaylistItems": "Елементи от плейлист", "HeaderPodcastsToAdd": "Подкасти за Добавяне", @@ -164,6 +185,7 @@ "HeaderRemoveEpisodes": "Премахни {0} Епизоди", "HeaderSavedMediaProgress": "Запазен Прогрес на Медията", "HeaderSchedule": "График", + "HeaderScheduleEpisodeDownloads": "Планирай автоматично изтегляне на епизоди", "HeaderScheduleLibraryScans": "График за Автоматично Сканиране на Библиотека", "HeaderSession": "Сесия", "HeaderSetBackupSchedule": "Задай График за Backup", @@ -172,6 +194,7 @@ "HeaderSettingsExperimental": "Експериментални Функции", "HeaderSettingsGeneral": "Общи", "HeaderSettingsScanner": "Скенер", + "HeaderSettingsWebClient": "Уеб клиент", "HeaderSleepTimer": "Таймер за заспиване", "HeaderStatsLargestItems": "Най-Големите Елементи", "HeaderStatsLongestItems": "Най-Дългите Елементи (часове)", @@ -209,7 +232,11 @@ "LabelAllUsersExcludingGuests": "Всички потребители без гости", "LabelAllUsersIncludingGuests": "Всички потребители включително гости", "LabelAlreadyInYourLibrary": "Вече е в твоята библиотека", + "LabelApiToken": "АПИ Токен", "LabelAppend": "Добави", + "LabelAudioBitrate": "Аудио битрейт (напр. 128k)", + "LabelAudioChannels": "Аудио канали (1 или 2)", + "LabelAudioCodec": "Аудио кодек", "LabelAuthor": "Автор", "LabelAuthorFirstLast": "Автор (Първи, Последен)", "LabelAuthorLastFirst": "Автор (Последен, Първи)", @@ -222,6 +249,7 @@ "LabelAutoRegister": "Автоматична Регистрация", "LabelAutoRegisterDescription": "Автоматично създаване на нови потребители след вход", "LabelBackToUser": "Обратно към Потребител", + "LabelBackupAudioFiles": "Създай резервно копие на аудио файлове", "LabelBackupLocation": "Местоположение на Архив", "LabelBackupsEnableAutomaticBackups": "Включи автоматично архивиране", "LabelBackupsEnableAutomaticBackupsHelp": "Архиви запазени в /metadata/backups", @@ -230,17 +258,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": "Приключено", @@ -251,10 +284,12 @@ "LabelCover": "Корица", "LabelCoverImageURL": "URL на Корица", "LabelCreatedAt": "Създадено на", + "LabelCronExpression": "Cron израз", "LabelCurrent": "Текущо", "LabelCurrently": "Текущо:", "LabelCustomCronExpression": "Потребителски Cron Expression:", "LabelDatetime": "Дата и Време", + "LabelDays": "Дни", "LabelDeleteFromFileSystemCheckbox": "Изтрий от файловата система (отмени за да бъдат премахни само от базата данни)", "LabelDescription": "Описание", "LabelDeselectAll": "Премахни всички", @@ -267,6 +302,7 @@ "LabelDiscover": "Открий", "LabelDownload": "Свали", "LabelDownloadNEpisodes": "Свали {0} епизоди", + "LabelDownloadable": "Може да се изтегли", "LabelDuration": "Продължителност", "LabelDurationComparisonExactMatch": "(точно съвпадение)", "LabelDurationComparisonLonger": "({0} по-дълго)", @@ -275,6 +311,7 @@ "LabelEbook": "Е-Книга", "LabelEbooks": "Е-книги", "LabelEdit": "Редакция", + "LabelEmail": "Имейл", "LabelEmailSettingsFromAddress": "От Адрес", "LabelEmailSettingsRejectUnauthorized": "Отхвърли неавторизирани сертификати", "LabelEmailSettingsRejectUnauthorizedHelp": "Спирането на валидацията на SSL сертификате може да изложи връзката ви на рискове, като man-in-the-middle атака. Спираите тази опция само ако знете имоликацийте от това и се доверявате на mail сървъра към който се свързвате.", @@ -283,13 +320,23 @@ "LabelEmailSettingsTestAddress": "Тестов Адрес", "LabelEmbeddedCover": "Вградена Корица", "LabelEnable": "Активирай", + "LabelEncodingBackupLocation": "Резервно копие на вашите оригинални аудио файлове ще бъде съхранено в:", + "LabelEncodingChaptersNotEmbedded": "Главите не са вградени в аудиокнигите с множество тракове.", + "LabelEncodingClearItemCache": "Уверете се, че периодично изчиствате кеша на елементите.", + "LabelEncodingFinishedM4B": "Завършеният M4B файл ще бъде поставен в папката на вашите аудиокниги на:", + "LabelEncodingInfoEmbedded": "Метаданните ще бъдат вградени в аудио траковете в папката на вашите аудиокниги.", "LabelEnd": "Край", "LabelEndOfChapter": "Край на глава", "LabelEpisode": "Епизод", "LabelEpisodeTitle": "Заглавие на Епизод", "LabelEpisodeType": "Тип на Епизод", "LabelExample": "Пример", - "LabelExplicit": "Експлицитно", + "LabelExpandSeries": "Покажи сериите", + "LabelExpandSubSeries": "Покажи съб сериите", + "LabelExplicit": "С нецензурно съдържание", + "LabelExplicitChecked": "С нецензурно съдържание (проверено)", + "LabelExplicitUnchecked": "Без нецензурно съдържание (непроверено)", + "LabelExportOPML": "Експортирай OPML", "LabelFeedURL": "URL на емисия", "LabelFetchingMetadata": "Взимане на Метаданни", "LabelFile": "Файл", @@ -397,6 +444,9 @@ "LabelNotificationsMaxQueueSizeHelp": "Събитията са ограничени до изстрелване на 1 на секунда. Събитията ще бъдат игнорирани ако опашката е на максимален размер. Това предотвратява спамирането на известия.", "LabelNumberOfBooks": "Брой на Книги", "LabelNumberOfEpisodes": "Брой епизоди", + "LabelOpenIDAdvancedPermsClaimDescription": "Име на OpenID твърдението, което съдържа разширени права за достъп до потребителски действия в приложението, които ще се прилагат за роли, различни от администраторските (ако е конфигурирано). Ако твърдението липсва в отговора, достъпът до ABS ще бъде отказан. Ако липсва една опция, тя ще се третира като false. Уверете се, че твърдението на доставчика на идентичност съответства на очакваната структура:", + "LabelOpenIDClaims": "Оставете следните опции празни, за да деактивирате разширеното присвояване на групи, като автоматично ще бъде присвоена групата 'Потребител'.", + "LabelOpenIDGroupClaimDescription": "Име на OpenID твърдението, което съдържа списък с групите на потребителя. Обикновено се нарича groups. Ако е конфигурирано, приложението автоматично ще присвоява роли въз основа на членството на потребителя в групи, при условие че тези групи са наименувани без чувствителност към регистъра като 'admin', 'user' или 'guest' в твърдението. Твърдението трябва да съдържа списък и ако потребителят принадлежи към множество групи, приложението ще присвои ролята, съответстваща на най-високото ниво на достъп. Ако няма съвпадение с група, достъпът ще бъде отказан.", "LabelOpenRSSFeed": "Отвори RSS Feed", "LabelOverwrite": "Презапиши", "LabelPassword": "Парола", @@ -432,6 +482,7 @@ "LabelRSSFeedOpen": "RSS Feed Оптворен", "LabelRSSFeedPreventIndexing": "Предотвратете индексиране", "LabelRSSFeedSlug": "идентификатор на RSS емисия", + "LabelRSSFeedURL": "URL на RSS емисия", "LabelRandomly": "Случайно", "LabelRead": "Прочети", "LabelReadAgain": "Прочети отново", @@ -482,6 +533,7 @@ "LabelSettingsHomePageBookshelfView": "Начална страница изглед на рафт", "LabelSettingsLibraryBookshelfView": "Библиотека изглед на рафт", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропусни предишни книги в Продължи Поредица", + "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Рафтът на началната страница 'Продължи поредицата' показва първата книга, която не е започната в поредици, в които има поне една завършена книга и няма книги в процес на четене. Активирането на тази настройка ще продължи поредицата от най-далечната завършена книга вместо от първата незапочната книга.", "LabelSettingsParseSubtitles": "Извлечи подзаглавия", "LabelSettingsParseSubtitlesHelp": "Извлича подзаглавия от имената на папките на аудиокнигите.
Подзаглавията трябва да бъдат разделени с \" - \"
например \"Заглавие на Книга - Тук е Подзаглавито\" има подзаглавие \"Тук е Подзаглавито\"", "LabelSettingsPreferMatchedMetadata": "Предпочети съвпадащи метаданни", @@ -498,6 +550,7 @@ "LabelSettingsStoreMetadataWithItemHelp": "По подразбиране метаданните се съхраняват в /metadata/items, като активирате тази настройка метаданните ще се съхраняват в папката на елемента на вашата библиотека", "LabelSettingsTimeFormat": "Формат на Време", "LabelShowAll": "Покажи всички", + "LabelShowSeconds": "Покажи секунди", "LabelSize": "Размер", "LabelSleepTimer": "Таймер за изключване", "LabelSlug": "Слъг", @@ -585,10 +638,12 @@ "LabelYourProgress": "Твоят прогрес", "MessageAddToPlayerQueue": "Добави към опашката на плейъра", "MessageAppriseDescription": "За да ползвате тази функция трябва да имате активна инстанция на Apprise API или на друго АПИ което да обработва тези заявки.
The Apprise API Url-а трябва дае пълния URL път за изпращане на известията, например, ако вашето АПИ ве подава от http://192.168.1.1:8337 трябва да сложитев http://192.168.1.1:8337/notify.", + "MessageBackupsDescription": "Резервните копия включват потребители, напредък на потребителите, подробности за елементите в библиотеката, настройки на сървъра и изображения, съхранени в /metadata/items и /metadata/authors. Резервните копия не включват никакви файлове, съхранени в папките на вашата библиотека.", "MessageBatchQuickMatchDescription": "Бързото Съпоставяне ще опита да добави липсващи корици и метаданни за избраните елементи. Активирайте опциите по-долу, за да позволите на Бързото съпоставяне да презапише съществуващите корици и/или метаданни.", "MessageBookshelfNoCollections": "Все още нямате създадени колекции", "MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове", "MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "Няма резултати от заявката", "MessageBookshelfNoSeries": "Нямаш сеЗЙ", "MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига", "MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0", @@ -608,6 +663,8 @@ "MessageConfirmMarkAllEpisodesNotFinished": "Сигурни ли сте, че искате да маркирате всички епизоди като незавършени?", "MessageConfirmMarkSeriesFinished": "Сигурни ли сте, че искате да маркирате всички книги в тази серия като завършени?", "MessageConfirmMarkSeriesNotFinished": "Сигурни ли сте, че искате да маркирате всички книги в тази серия като незавършени?", + "MessageConfirmPurgeCache": "Изчистването на кеша ще изтрие цялата директория в /metadata/cache.

Сигурни ли сте, че искате да премахнете директорията на кеша?", + "MessageConfirmPurgeItemsCache": "Изчистването на кеша на елементите ще изтрие цялата директория в /metadata/cache/items.
Сигурни ли сте?", "MessageConfirmQuickEmbed": "Внимание! Бързото вграждане няма да архивира вашите аудио файлове. Уверете се, че имате резервно копие на вашите аудио файлове.

Искате ли да продължите?", "MessageConfirmReScanLibraryItems": "Сигурни ли сте, че искате да сканирате отново {0} елемента?", "MessageConfirmRemoveAllChapters": "Сигурни ли сте, че искате да премахнете всички глави?", @@ -629,6 +686,7 @@ "MessageDragFilesIntoTrackOrder": "Плъзнете файлове в правилния ред на каналите", "MessageEmbedFinished": "Вграждането завърши!", "MessageEpisodesQueuedForDownload": "{0} Епизод(и) са сложени за сваляне", + "MessageEreaderDevices": "За да осигурите доставката на е-книги, може да се наложи да добавите горепосочения имейл адрес като валиден подател за всяко устройство, изброено по-долу.", "MessageFeedURLWillBe": "Адресът на емисията ще бъде {0}", "MessageFetching": "Извличане...", "MessageForceReScanDescription": "ще сканира всички файлове отново като прясно сканиране. Аудио файлове ID3 тагове, OPF файлове и текстови файлове ще бъдат сканирани като нови.", @@ -639,6 +697,7 @@ "MessageJoinUsOn": "Присъединете се към нас", "MessageLoading": "Зарежда...", "MessageLoadingFolders": "Зареждане на Папки...", + "MessageLogsDescription": "Логовете се съхраняват в /metadata/logs като JSON файлове. Дневниците за сривове се съхраняват в /metadata/logs/crash_logs.txt.", "MessageM4BFailed": "M4B Провалено!", "MessageM4BFinished": "M4B Завършено!", "MessageMapChapterTitles": "Съпостави заглавията на главите със съществуващите глави на аудиокнигата без да променяш времената", @@ -731,13 +790,20 @@ "ToastBackupRestoreFailed": "Неуспешно възстановяване на архив", "ToastBackupUploadFailed": "Неуспешно качване на архив", "ToastBackupUploadSuccess": "Архивът е качен", + "ToastBatchUpdateFailed": "Неуспешно групово актуализиране", + "ToastBatchUpdateSuccess": "Успешно групово актуализиране", "ToastBookmarkCreateFailed": "Неуспешно създаване на отметка", "ToastBookmarkCreateSuccess": "Отметката е създадена", "ToastBookmarkRemoveSuccess": "Отметката е премахната", + "ToastCachePurgeFailed": "Неуспешно изчистване на кеша", + "ToastCachePurgeSuccess": "Успешно изчистване на кеша", "ToastChaptersHaveErrors": "Главите имат грешки", "ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия", "ToastCollectionRemoveSuccess": "Колекцията е премахната", "ToastCollectionUpdateSuccess": "Колекцията е обновена", + "ToastDeleteFileFailed": "Неуспешно изтриване на файла", + "ToastDeleteFileSuccess": "Успешно изтриване на файла", + "ToastFailedToLoadData": "Неуспешно зареждане на данни", "ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена", "ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени", "ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено", @@ -765,11 +831,14 @@ "ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"", "ToastSeriesUpdateFailed": "Неуспешно обновяване на серия", "ToastSeriesUpdateSuccess": "Серията е обновена", + "ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани", "ToastSessionDeleteFailed": "Неуспешно изтриване на сесия", "ToastSessionDeleteSuccess": "Сесията е изтрита", "ToastSocketConnected": "Свързан сокет", "ToastSocketDisconnected": "Сокетът е прекъснат", "ToastSocketFailedToConnect": "Неуспешно свързване на сокет", + "ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране", + "ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)", "ToastUserDeleteFailed": "Неуспешно изтриване на потребител", "ToastUserDeleteSuccess": "Потребителят е изтрит" } From f7cea8ca124922d43de8df6c925a502d1067dcc5 Mon Sep 17 00:00:00 2001 From: A L Date: Thu, 13 Feb 2025 00:26:32 +0000 Subject: [PATCH 27/35] Translated using Weblate (Bulgarian) Currently translated at 77.2% (841 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bg/ --- client/strings/bg.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/bg.json b/client/strings/bg.json index e7b15bc89..72ca62f53 100644 --- a/client/strings/bg.json +++ b/client/strings/bg.json @@ -73,7 +73,7 @@ "ButtonQuickMatch": "Бързо Съпоставяне", "ButtonReScan": "Пресканирай", "ButtonRead": "Прочети", - "ButtonReadLess": "Прочети кратко", + "ButtonReadLess": "Изчети по-малко", "ButtonReadMore": "Прочети дълго", "ButtonRefresh": "Обнови", "ButtonRemove": "Премахни", From b1d57bc0b3e5b84d8d4d40190f1faa97c0fdb5a4 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Thu, 13 Feb 2025 11:18:01 +0000 Subject: [PATCH 28/35] Translated using Weblate (Swedish) Currently translated at 90.6% (987 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 1bc566a5b..db1272870 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -16,7 +16,7 @@ "ButtonCancel": "Avbryt", "ButtonCancelEncode": "Avbryt omkodning", "ButtonChangeRootPassword": "Ändra lösenordet för root", - "ButtonCheckAndDownloadNewEpisodes": "Sök & Ladda ner nya avsnitt", + "ButtonCheckAndDownloadNewEpisodes": "Sök & Hämta nya avsnitt", "ButtonChooseAFolder": "Välj en mapp", "ButtonChooseFiles": "Välj filer", "ButtonClearFilter": "Rensa filter", @@ -75,8 +75,8 @@ "ButtonRemove": "Ta bort", "ButtonRemoveAll": "Ta bort alla", "ButtonRemoveAllLibraryItems": "Ta bort alla objekt i biblioteket", - "ButtonRemoveFromContinueListening": "Radera från 'Fortsätt lyssna'", - "ButtonRemoveFromContinueReading": "Radera från 'Fortsätt läsa'", + "ButtonRemoveFromContinueListening": "Radera från 'Fortsätt att lyssna'", + "ButtonRemoveFromContinueReading": "Radera från 'Fortsätt att läsa'", "ButtonRemoveSeriesFromContinueSeries": "Radera från 'Fortsätt med serien'", "ButtonReset": "Tillbaka", "ButtonResetToDefault": "Återställ till standard", @@ -242,7 +242,7 @@ "LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla", "LabelBackupsNumberToKeepHelp": "Endast en gammal säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än det angivna värdet bör du ta bort dem manuellt.", "LabelBitrate": "Bitfrekvens", - "LabelBonus": "Bonus", + "LabelBonus": "Bonusavsnitt", "LabelBooks": "Böcker", "LabelButtonText": "Knapptext", "LabelByAuthor": "av {0}", @@ -312,9 +312,11 @@ "LabelEnd": "Slut", "LabelEndOfChapter": "Slut av kapitel", "LabelEpisode": "Avsnitt", + "LabelEpisodeNotLinkedToRssFeed": "Avsnittet är inte knutet till ett RSS-flöde", "LabelEpisodeNumber": "Avsnitt #{0}", "LabelEpisodeTitle": "Titel på avsnittet", "LabelEpisodeType": "Typ av avsnitt", + "LabelEpisodeUrlFromRssFeed": "URL-adress till avsnittet i RSS-flödet", "LabelEpisodes": "Avsnitt", "LabelEpisodic": "Uppdelad i avsnitt", "LabelExample": "Exempel", @@ -341,6 +343,7 @@ "LabelFontItalic": "Kursiv", "LabelFontScale": "Skala på typsnitt", "LabelFontStrikethrough": "Genomstruken", + "LabelFull": "Komplett", "LabelGenre": "Kategori", "LabelGenres": "Kategorier", "LabelHardDeleteFile": "Hård radering av fil", @@ -355,7 +358,7 @@ "LabelImageURLFromTheWeb": "Skriv URL-adressen till bilden på webben", "LabelInProgress": "Pågående", "LabelIncludeInTracklist": "Inkludera i spårlista", - "LabelIncomplete": "Ofullständig", + "LabelIncomplete": "Ofullständigt", "LabelInterval": "Intervall", "LabelIntervalCustomDailyWeekly": "Anpassad daglig/veckovis", "LabelIntervalEvery12Hours": "Var 12:e timme", @@ -416,7 +419,7 @@ "LabelNew": "Nytt", "LabelNewPassword": "Nytt lösenord", "LabelNewestAuthors": "Senaste författarna", - "LabelNewestEpisodes": "Senast adderade avsnitt", + "LabelNewestEpisodes": "Senaste avsnitten", "LabelNextBackupDate": "Nästa tillfälle för säkerhetskopiering", "LabelNextScheduledRun": "Nästa schemalagda körning", "LabelNoCustomMetadataProviders": "Ingen egen källa för metadata", @@ -472,7 +475,7 @@ "LabelRSSFeedOpen": "Öppna RSS-flöde", "LabelRSSFeedPreventIndexing": "Förhindra indexering", "LabelRSSFeedSlug": "RSS-flödesslag", - "LabelRSSFeedURL": "RSS-flöde URL", + "LabelRSSFeedURL": "URL-adress för RSS-flödet", "LabelRandomly": "Slumpartat", "LabelRead": "Läst", "LabelReadAgain": "Läs igen", @@ -693,6 +696,7 @@ "MessageConfirmPurgeCache": "När du rensar cashen kommer katalogen /metadata/cache att raderas.

Är du säker på att du vill radera katalogen?", "MessageConfirmPurgeItemsCache": "När du rensar cashen för föremål kommer katalogen /metadata/cache/items att raderas.

Är du säker på att du vill radera katalogen?", "MessageConfirmQuickEmbed": "VARNING! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler.

Vill du fortsätta?", + "MessageConfirmQuickMatchEpisodes": "Snabbmatchning av avsnitt kommer att ersätta befintlig information vid en träff. Endast omatchade avsnitt kommer att uppdateras. Vill du fortsätta?", "MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra en ny skanning för {0} objekt?", "MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?", "MessageConfirmRemoveAuthor": "Är du säker på att du vill ta bort författaren \"{0}\"?", @@ -735,7 +739,7 @@ "MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som ej avslutade", "MessageMarkAsFinished": "Markera som avslutad", "MessageMarkAsNotFinished": "Markera som ej avslutad", - "MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från
den valda källan och fylla i uppgifter som saknas och omslag.
Inga befintliga uppgifter kommer att ersättas.", + "MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda källan och fylla i uppgifter som saknas och omslag. Inga befintliga uppgifter kommer att ersättas.", "MessageNoAudioTracks": "Inga ljudspår har hittats", "MessageNoAuthors": "Inga författare", "MessageNoBackups": "Inga säkerhetskopior", @@ -829,6 +833,7 @@ "NoteUploaderFoldersWithMediaFiles": "Mappar som innehåller mediefiler hanteras som separata objekt i biblioteket.", "NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.", "NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.", + "NotificationOnEpisodeDownloadedDescription": "Aktiveras när avsnitt i en podcast automatiskt har hämtats", "PlaceholderNewCollection": "Nytt namn på samlingen", "PlaceholderNewFolderPath": "Nytt sökväg till mappen", "PlaceholderNewPlaylist": "Nytt namn på spellistan", From cfdcac9475cd5fdc3bc3840397cb442ac81e547f Mon Sep 17 00:00:00 2001 From: polarwood Date: Thu, 13 Feb 2025 11:30:57 +0000 Subject: [PATCH 29/35] Translated using Weblate (Turkish) Currently translated at 13.0% (142 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/tr/ --- client/strings/tr.json | 145 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 144 insertions(+), 1 deletion(-) diff --git a/client/strings/tr.json b/client/strings/tr.json index 0967ef424..e6cab1c9e 100644 --- a/client/strings/tr.json +++ b/client/strings/tr.json @@ -1 +1,144 @@ -{} +{ + "ButtonAdd": "Ekle", + "ButtonAddChapters": "Bölüm Ekle", + "ButtonAddDevice": "Cihaz Ekle", + "ButtonAddLibrary": "Kütüphane Ekle", + "ButtonAddPodcasts": "Podcast Ekle", + "ButtonAddUser": "Kullanıcı Ekle", + "ButtonAddYourFirstLibrary": "İlk kütüphaneni ekle", + "ButtonApply": "Uygula", + "ButtonApplyChapters": "Bölümleri Uygula", + "ButtonAuthors": "Yazarlar", + "ButtonBack": "Geri", + "ButtonBatchEditPopulateFromExisting": "Mevcut olandan çoğalt", + "ButtonBatchEditPopulateMapDetails": "Harita detaylarını çoğalt", + "ButtonBrowseForFolder": "Klasör için göz at", + "ButtonCancel": "İptal", + "ButtonChangeRootPassword": "Root Şifresini Değiştir", + "ButtonCheckAndDownloadNewEpisodes": "Yeni Bölümleri Kontrol Et & İndir", + "ButtonChooseAFolder": "Klasör seç", + "ButtonChooseFiles": "Dosya seç", + "ButtonClearFilter": "Filtreyi Temizle", + "ButtonCloseFeed": "Akışı Kapat", + "ButtonCloseSession": "Acık Oturumu Kapat", + "ButtonCollections": "Koleksiyonlar", + "ButtonCreate": "Oluştur", + "ButtonCreateBackup": "Yedek Oluştur", + "ButtonDelete": "Sil", + "ButtonDownloadQueue": "Sıra", + "ButtonEdit": "Düzenle", + "ButtonEditChapters": "Bölümleri Düzenle", + "ButtonEditPodcast": "Podcast Düzenle", + "ButtonEnable": "Etkinleştir", + "ButtonHome": "Ana sayfa", + "ButtonIssues": "Sorunlar", + "ButtonLatest": "En yeni", + "ButtonLibrary": "Kütüphane", + "ButtonOk": "Tamam", + "ButtonOpenFeed": "Akışı Aç", + "ButtonPause": "Durdur", + "ButtonPlay": "Oynat", + "ButtonPlaylists": "Oynatma listeleri", + "ButtonRead": "Oku", + "ButtonReadLess": "Daha az göster", + "ButtonReadMore": "Daha fazla göster", + "ButtonRemove": "Kaldır", + "ButtonSave": "Kaydet", + "ButtonSearch": "Ara", + "ButtonSubmit": "Gönder", + "ButtonYes": "Evet", + "HeaderAccount": "Hesap", + "HeaderAdvanced": "Gelişmiş", + "HeaderChapters": "Bölümler", + "HeaderCollection": "Koleksiyon", + "HeaderCollectionItems": "Koleksiyon Öğeleri", + "HeaderDetails": "Detaylar", + "HeaderEbookFiles": "Ebook Dosyaları", + "HeaderEpisodes": "Bölümler", + "HeaderEreaderSettings": "Ereader Ayarları", + "HeaderLatestEpisodes": "En son bölümler", + "HeaderLibraries": "Kütüphaneler", + "HeaderOpenRSSFeed": "RSS Akışını Aç", + "HeaderPlaylist": "Oynatma listesi", + "HeaderPlaylistItems": "Oynatma Listesi Öğeleri", + "HeaderRSSFeedGeneral": "RSS Detayları", + "HeaderRSSFeedIsOpen": "RSS Akışı Açık", + "HeaderSettings": "Ayarlar", + "HeaderSleepTimer": "Uyku Zamanlayıcısı", + "HeaderStatsMinutesListeningChart": "Dinlenilen Dakika (son 7 gün)", + "HeaderStatsRecentSessions": "Geçmiş Oturumlar", + "HeaderTableOfContents": "İçindekiler", + "HeaderYourStats": "İstatistiklerin", + "LabelAddToPlaylist": "Oynatma Listesine Ekle", + "LabelAddedAt": "Eklenme Zamanı", + "LabelAddedDate": "Eklendi {0}", + "LabelAll": "Hepsi", + "LabelAuthor": "Yazar", + "LabelAuthorFirstLast": "Yazar (İlk Son)", + "LabelAuthorLastFirst": "Yazar (Son, İlk)", + "LabelAuthors": "Yazarlar", + "LabelAutoDownloadEpisodes": "Bölümleri Otomatik Olarak İndir", + "LabelBooks": "Kitaplar", + "LabelChapters": "Bölümler", + "LabelClosePlayer": "Oynatıcıyı kapat", + "LabelComplete": "Tamamlandı", + "LabelContinueListening": "Dinlemeye Devam Et", + "LabelContinueReading": "Okumaya Devam Et", + "LabelDescription": "Açıklama", + "LabelDiscover": "Keşfet", + "LabelDownload": "İndir", + "LabelDuration": "Süre", + "LabelEbook": "Ekitap", + "LabelEbooks": "Ekitaplar", + "LabelEnable": "Etkinleştir", + "LabelEnd": "Son", + "LabelEndOfChapter": "Bölüm Sonu", + "LabelEpisode": "Bölüm", + "LabelFeedURL": "Akış URLsi", + "LabelFile": "Dosya", + "LabelFileBirthtime": "Dosya Oluşum Zamanı", + "LabelFileModified": "Dosya Düzenlendi", + "LabelFilename": "Dosya İsmi", + "LabelFinished": "Tamamlandı", + "LabelFolder": "Klasör", + "LabelFontBoldness": "Font Kalınlığı", + "LabelFontScale": "Font büyüklüğü", + "LabelGenre": "Tür", + "LabelGenres": "Türler", + "LabelHasEbook": "Ekitabı var", + "LabelHasSupplementaryEbook": "İlave ekitabı var", + "LabelInProgress": "İlerleme Halinde", + "LabelIncomplete": "Tamamlanmamış", + "LabelLanguage": "Dil", + "LabelLayout": "Düzen", + "LabelLayoutSinglePage": "Tek sayfa", + "LabelLineSpacing": "Satır aralığı", + "LabelListenAgain": "Tekrar Dinle", + "LabelMediaType": "Medya Türü", + "LabelMissing": "Kayıp", + "LabelMore": "Daha fazla", + "LabelMoreInfo": "Daha fazla bilgi", + "LabelName": "İsim", + "LabelNarrator": "Anlatıcı", + "LabelNarrators": "Anlatıcılar", + "LabelNewestAuthors": "En Yeni Yazarlar", + "LabelNewestEpisodes": "En Yeni Bölümler", + "LabelNotFinished": "Tamamlanmadı", + "LabelNotStarted": "Başlanmadı", + "LabelPassword": "Şifre", + "LabelPath": "Yol", + "LabelPodcast": "Podcast", + "LabelPodcasts": "Podcastler", + "LabelPreventIndexing": "Akışınızın iTunes ve Google podcast dizinleri tarafından dizinlenmesini önleyin", + "LabelProgress": "İlerleme", + "LabelPubDate": "Yay. Tarihi", + "LabelPublishYear": "Yayım Yılı", + "LabelPublishedDate": "Yayımlandı {0}", + "LabelRSSFeedCustomOwnerEmail": "Özelleştirilmiş sahip Emaili", + "LabelRSSFeedCustomOwnerName": "Özelleştirilmis sahip İsmi", + "LabelRSSFeedPreventIndexing": "Dizinlemeyi Önle", + "LabelRandomly": "Rastgele", + "LabelRead": "Oku", + "LabelReadAgain": "Tekrar Oku", + "LabelStart": "Başla" +} From adb3967f89863391dccbb4be68169eeb9d1c2855 Mon Sep 17 00:00:00 2001 From: Armanc Keser Date: Sat, 15 Feb 2025 12:02:21 +0000 Subject: [PATCH 30/35] Translated using Weblate (Turkish) Currently translated at 14.2% (155 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/tr/ --- client/strings/tr.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/client/strings/tr.json b/client/strings/tr.json index e6cab1c9e..2c4266a0c 100644 --- a/client/strings/tr.json +++ b/client/strings/tr.json @@ -14,6 +14,7 @@ "ButtonBatchEditPopulateMapDetails": "Harita detaylarını çoğalt", "ButtonBrowseForFolder": "Klasör için göz at", "ButtonCancel": "İptal", + "ButtonCancelEncode": "Kodlamayı Durdur", "ButtonChangeRootPassword": "Root Şifresini Değiştir", "ButtonCheckAndDownloadNewEpisodes": "Yeni Bölümleri Kontrol Et & İndir", "ButtonChooseAFolder": "Klasör seç", @@ -22,6 +23,7 @@ "ButtonCloseFeed": "Akışı Kapat", "ButtonCloseSession": "Acık Oturumu Kapat", "ButtonCollections": "Koleksiyonlar", + "ButtonConfigureScanner": "Tarayıcı Ayarları", "ButtonCreate": "Oluştur", "ButtonCreateBackup": "Yedek Oluştur", "ButtonDelete": "Sil", @@ -30,10 +32,18 @@ "ButtonEditChapters": "Bölümleri Düzenle", "ButtonEditPodcast": "Podcast Düzenle", "ButtonEnable": "Etkinleştir", + "ButtonFireAndFail": "Gönder ve hata al", + "ButtonFireOnTest": "onTest Gönder", + "ButtonForceReScan": "Zorla Yeniden Tara", + "ButtonFullPath": "Tam Dosya Yolu", + "ButtonHide": "Gizle", "ButtonHome": "Ana sayfa", "ButtonIssues": "Sorunlar", + "ButtonJumpBackward": "Geri Sar", + "ButtonJumpForward": "İleri Sar", "ButtonLatest": "En yeni", "ButtonLibrary": "Kütüphane", + "ButtonLogout": "Çıkış Yap", "ButtonOk": "Tamam", "ButtonOpenFeed": "Akışı Aç", "ButtonPause": "Durdur", @@ -45,10 +55,12 @@ "ButtonRemove": "Kaldır", "ButtonSave": "Kaydet", "ButtonSearch": "Ara", + "ButtonSeries": "Dizi", "ButtonSubmit": "Gönder", "ButtonYes": "Evet", "HeaderAccount": "Hesap", "HeaderAdvanced": "Gelişmiş", + "HeaderAudioTracks": "Ses Kanalları", "HeaderChapters": "Bölümler", "HeaderCollection": "Koleksiyon", "HeaderCollectionItems": "Koleksiyon Öğeleri", @@ -81,9 +93,11 @@ "LabelBooks": "Kitaplar", "LabelChapters": "Bölümler", "LabelClosePlayer": "Oynatıcıyı kapat", + "LabelCollapseSeries": "Seriyi Daralt", "LabelComplete": "Tamamlandı", "LabelContinueListening": "Dinlemeye Devam Et", "LabelContinueReading": "Okumaya Devam Et", + "LabelContinueSeries": "Seriye Devam Et", "LabelDescription": "Açıklama", "LabelDiscover": "Keşfet", "LabelDownload": "İndir", @@ -107,6 +121,7 @@ "LabelGenres": "Türler", "LabelHasEbook": "Ekitabı var", "LabelHasSupplementaryEbook": "İlave ekitabı var", + "LabelHost": "Sunucu", "LabelInProgress": "İlerleme Halinde", "LabelIncomplete": "Tamamlanmamış", "LabelLanguage": "Dil", From 49ba364b2acd866417cff246d72564271eb6e0e2 Mon Sep 17 00:00:00 2001 From: biuklija Date: Mon, 17 Feb 2025 16:55:52 +0000 Subject: [PATCH 31/35] Translated using Weblate (Croatian) Currently translated at 100.0% (1089 of 1089 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 9ac334765..e1d372a7e 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -678,7 +678,7 @@ "LabelUploaderDropFiles": "Ispusti datoteke", "LabelUploaderItemFetchMetadataHelp": "Automatski dohvati naslov, autora i serijal", "LabelUseAdvancedOptions": "Koristi se naprednim opcijama", - "LabelUseChapterTrack": "Koristi zvučni zapis poglavlja", + "LabelUseChapterTrack": "Upravljaj trakom poglavlja", "LabelUseFullTrack": "Koristi cijeli zvučni zapis", "LabelUseZeroForUnlimited": "0 za neograničeno", "LabelUser": "Korisnik", From 699644322b57c3e1da88902f5ae66958b28f094d Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Mon, 17 Feb 2025 06:32:34 +0000 Subject: [PATCH 32/35] Translated using Weblate (Swedish) Currently translated at 91.9% (1001 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index db1272870..771ec0196 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -231,6 +231,7 @@ "LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt", "LabelAutoFetchMetadata": "Automatisk nedladdning av metadata", "LabelAutoFetchMetadataHelp": "Hämtar metadata för titel, författare och serier. Kompletterande metadata kan manuellt adderas efter uppladdningen.", + "LabelAutoLaunch": "Automatisk start", "LabelAutoRegisterDescription": "Skapa automatiskt nya användare efter inloggning", "LabelBackToUser": "Tillbaka till användaren", "LabelBackupAudioFiles": "Säkerhetskopiera ljudfiler", @@ -329,6 +330,7 @@ "LabelFetchingMetadata": "Hämtar metadata", "LabelFile": "Fil", "LabelFileBirthtime": "Tidpunkt, fil skapad", + "LabelFileBornDate": "Skapad {0}", "LabelFileModified": "Tidpunkt, fil ändrad", "LabelFileModifiedDate": "Ändrad {0}", "LabelFilename": "Filnamn", @@ -470,6 +472,7 @@ "LabelPublishYear": "Publiceringsår", "LabelPublishedDecade": "Årtionde för publicering", "LabelPublisher": "Utgivare", + "LabelPublishers": "Utgivare", "LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post", "LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn", "LabelRSSFeedOpen": "Öppna RSS-flöde", @@ -553,6 +556,7 @@ "LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen '/metadata/items'. Genom att aktivera detta alternativ kommer metadatafilerna att lagra i din biblioteksmapp", "LabelSettingsTimeFormat": "Tidsformat", "LabelShare": "Dela", + "LabelShareURL": "Dela URL-länk", "LabelShowAll": "Visa alla", "LabelShowSeconds": "Visa sekunder", "LabelShowSubtitles": "Visa underrubriker", @@ -709,7 +713,7 @@ "MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?", "MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på kategorin \"{0}\" till \"{1}\" för alla objekt?", "MessageConfirmRenameGenreMergeNote": "OBS: Den här kategorin finns redan, så de kommer att slås samman.", - "MessageConfirmRenameGenreWarning": "Varning! En liknande kategori med annat skrivsätt finns redan \"{0}\".", + "MessageConfirmRenameGenreWarning": "VARNING! En liknande kategori med annat skrivsätt finns redan \"{0}\".", "MessageConfirmRenameTag": "Är du säker på att du vill byta namn på taggen \"{0}\" till \"{1}\" för alla objekt?", "MessageConfirmRenameTagMergeNote": "OBS: Den här taggen finns redan, så de kommer att slås samman.", "MessageConfirmRenameTagWarning": "VARNING! En liknande tagg med annat skrivsätt finns redan \"{0}\".", @@ -801,12 +805,15 @@ "MessageTaskEmbeddingMetadataDescription": "Infogar metadata i ljudboken \"{0}\"", "MessageTaskEncodingM4bDescription": "Omkodning av ljudbok \"{0}\" till en M4B-fil", "MessageTaskFailed": "Misslyckades", + "MessageTaskFailedToBackupAudioFile": "Misslyckades med att göra backup på ljudfil \"{0}\"", "MessageTaskFailedToCreateCacheDirectory": "Misslyckades med att skapa bibliotek för cachen", "MessageTaskFailedToEmbedMetadataInFile": "Misslyckades med att infoga metadata i \"{0}\"", "MessageTaskFailedToMergeAudioFiles": "Misslyckades med att sammanfoga ljudfilerna", "MessageTaskFailedToMoveM4bFile": "Misslyckades med att flytta M4B-filen", "MessageTaskFailedToWriteMetadataFile": "Misslyckades med att skapa filen med metadata", "MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"", + "MessageTaskOpmlImportFeedPodcastDescription": "Skapar podcast \"{0}\"", + "MessageTaskOpmlImportFeedPodcastFailed": "Misslyckades med att skapa podcast", "MessageTaskOpmlImportFinished": "Adderade {0} podcasts", "MessageTaskOpmlParseFailed": "Misslyckades att tolka OPML-filen", "MessageTaskOpmlParseFastFail": "Felaktig OPML-fil. Ingen tag eller tag finns i filen", @@ -818,7 +825,7 @@ "MessageTaskScanningLibrary": "Biblioteket \"{0}\" har skannats", "MessageThinking": "Tänker...", "MessageUploaderItemFailed": "Misslyckades med att ladda upp", - "MessageUploaderItemSuccess": "Uppladdning lyckades!", + "MessageUploaderItemSuccess": "har blivit uppladdad!", "MessageUploading": "Laddar upp...", "MessageValidCronExpression": "Giltigt cron-uttryck", "MessageWatcherIsDisabledGlobally": "Watcher är inaktiverad centralt under rubriken 'Inställningar'", @@ -833,6 +840,8 @@ "NoteUploaderFoldersWithMediaFiles": "Mappar som innehåller mediefiler hanteras som separata objekt i biblioteket.", "NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.", "NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.", + "NotificationOnBackupCompletedDescription": "Aktiveras när en backup är genomförd", + "NotificationOnBackupFailedDescription": "Aktiveras när en backup misslyckas", "NotificationOnEpisodeDownloadedDescription": "Aktiveras när avsnitt i en podcast automatiskt har hämtats", "PlaceholderNewCollection": "Nytt namn på samlingen", "PlaceholderNewFolderPath": "Nytt sökväg till mappen", @@ -893,9 +902,12 @@ "ToastDateTimeInvalidOrIncomplete": "Datum och klockslag är felaktigt eller ej komplett", "ToastDeleteFileFailed": "Misslyckades att radera filen", "ToastDeleteFileSuccess": "Filen har raderats", + "ToastDeviceAddFailed": "Misslyckades med att addera enheten", + "ToastDeviceNameAlreadyExists": "En enhet för att läsa e-böcker med det namnet finns redan", "ToastDeviceTestEmailFailed": "Misslyckades med att skicka ett testmail", "ToastDeviceTestEmailSuccess": "Ett testmail har skickats", "ToastEmailSettingsUpdateSuccess": "Inställningarna av e-post har uppdaterats", + "ToastEncodeCancelFailed": "Misslyckades med att avbryta omkodningen", "ToastEncodeCancelSucces": "Omkodningen avbruten", "ToastEpisodeDownloadQueueClearFailed": "Misslyckades med att tömma kön", "ToastEpisodeDownloadQueueClearSuccess": "Kö för nedladdning av avsnitt har tömts", @@ -936,6 +948,7 @@ "ToastNewUserTagError": "Minst en tagg måste läggas till", "ToastNewUserUsernameError": "Ange ett användarnamn", "ToastNoNewEpisodesFound": "Inga nya avsnitt kunde hittas", + "ToastNoRSSFeed": "Denna podcast har ingen RSS-flöde", "ToastNoUpdatesNecessary": "Inga uppdateringar var nödvändiga", "ToastNotificationCreateFailed": "Misslyckades med att skapa meddelandet", "ToastNotificationDeleteFailed": "Misslyckades med att radera meddelandet", @@ -947,6 +960,7 @@ "ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten", "ToastPodcastCreateSuccess": "Podcasten skapades framgångsrikt", "ToastPodcastNoEpisodesInFeed": "Inga avsnitt finns i RSS-flödet", + "ToastPodcastNoRssFeed": "Denna podcast har ingen RSS-flöde", "ToastProviderCreatedFailed": "Misslyckades med att addera en källa", "ToastProviderCreatedSuccess": "En ny källa har adderats", "ToastProviderNameAndUrlRequired": "Ett namn och en URL-adress krävs", From 4e33059ac86158093541fe2a1326dc4332067f45 Mon Sep 17 00:00:00 2001 From: polarwood Date: Mon, 17 Feb 2025 13:36:17 +0000 Subject: [PATCH 33/35] Translated using Weblate (Turkish) Currently translated at 18.8% (205 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/tr/ --- client/strings/tr.json | 52 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/client/strings/tr.json b/client/strings/tr.json index 2c4266a0c..d7a622cd2 100644 --- a/client/strings/tr.json +++ b/client/strings/tr.json @@ -44,15 +44,36 @@ "ButtonLatest": "En yeni", "ButtonLibrary": "Kütüphane", "ButtonLogout": "Çıkış Yap", + "ButtonLookup": "Sorgula", + "ButtonManageTracks": "Parçaları Yönet", + "ButtonMapChapterTitles": "Bölüm Başlıklarını Haritalandır", + "ButtonNevermind": "Vazgeç", + "ButtonNext": "Sonraki", + "ButtonNextChapter": "Sonraki Bölüm", + "ButtonNextItemInQueue": "Sıradaki Sonraki Öğe", "ButtonOk": "Tamam", "ButtonOpenFeed": "Akışı Aç", + "ButtonOpenManager": "Yöneticiyi Aç", "ButtonPause": "Durdur", "ButtonPlay": "Oynat", + "ButtonPlayAll": "Hepsini Oynat", + "ButtonPlaying": "Oynatılıyor", "ButtonPlaylists": "Oynatma listeleri", + "ButtonPrevious": "Önceki", + "ButtonPreviousChapter": "Önceki Bölüm", + "ButtonProbeAudioFile": "Ses Dosyasını Yokla", + "ButtonPurgeAllCache": "Bütün Önbelleği Temizle", + "ButtonPurgeItemsCache": "Öğenin Önbelleğini Temizle", + "ButtonQueueAddItem": "Sıraya ekle", + "ButtonQueueRemoveItem": "Sıradan çıkar", + "ButtonReScan": "Yeniden Tara", "ButtonRead": "Oku", "ButtonReadLess": "Daha az göster", "ButtonReadMore": "Daha fazla göster", + "ButtonRefresh": "Yenile", "ButtonRemove": "Kaldır", + "ButtonRemoveAll": "Hepsini Sil", + "ButtonRemoveAllLibraryItems": "Bütün Kütüphane Öğelerini Sil", "ButtonSave": "Kaydet", "ButtonSearch": "Ara", "ButtonSeries": "Dizi", @@ -140,6 +161,7 @@ "LabelNewestEpisodes": "En Yeni Bölümler", "LabelNotFinished": "Tamamlanmadı", "LabelNotStarted": "Başlanmadı", + "LabelNumberOfEpisodes": "Bölüm Sayısı", "LabelPassword": "Şifre", "LabelPath": "Yol", "LabelPodcast": "Podcast", @@ -155,5 +177,33 @@ "LabelRandomly": "Rastgele", "LabelRead": "Oku", "LabelReadAgain": "Tekrar Oku", - "LabelStart": "Başla" + "LabelRecentlyAdded": "Yakınlarda Eklenmiş", + "LabelSeason": "Sezon", + "LabelSetEbookAsPrimary": "Birincil olarak ayarla", + "LabelSetEbookAsSupplementary": "Yedek olarak ayarla", + "LabelShowAll": "Hepsini Göster", + "LabelSize": "Boyut", + "LabelSleepTimer": "Uyku Zamanlayıcısı", + "LabelStart": "Başla", + "LabelStatsBestDay": "En İyi Gün", + "LabelStatsDailyAverage": "Günlük Ortalama", + "LabelStatsDays": "Günler", + "LabelStatsDaysListened": "Dinlenen Günler", + "LabelStatsInARow": "art arda", + "LabelStatsItemsFinished": "Bitirilen Öğeler", + "LabelStatsMinutes": "dakika", + "LabelStatsMinutesListening": "Dinlenen Dakika", + "LabelTag": "Etiket", + "LabelTags": "Etiketler", + "LabelTheme": "Tema", + "LabelThemeDark": "Koyu", + "LabelThemeLight": "Açık", + "LabelTimeRemaining": "{0} kalan", + "LabelTitle": "Başlık", + "LabelTracks": "Parçalar", + "LabelType": "Tür", + "LabelUnknown": "Bilinmeyen", + "LabelUser": "Kullanıcı", + "LabelUsername": "Kullanıcı Adı", + "LabelYourBookmarks": "Yer İşaretleriniz" } From f04ef320aae0071172255adad607b6e53f3e0fb8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 19 Feb 2025 17:12:19 -0600 Subject: [PATCH 34/35] Restore scroll position on title change re-sort --- client/components/app/LazyBookshelf.vue | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index ab6d54a2b..2144b899c 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -419,7 +419,7 @@ export default { this.postScrollTimeout = setTimeout(this.postScroll, 500) }, - async resetEntities() { + async resetEntities(scrollPositionToRestore) { if (this.isFetchingEntities) { this.pendingReset = true return @@ -437,6 +437,12 @@ export default { await this.loadPage(0) var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf) this.mountEntities(0, lastBookIndex) + + if (scrollPositionToRestore) { + if (window.bookshelf) { + window.bookshelf.scrollTop = scrollPositionToRestore + } + } }, async rebuild() { this.initSizeData() @@ -444,9 +450,8 @@ export default { var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch) this.destroyEntityComponents() await this.loadPage(0) - var bookshelfEl = document.getElementById('bookshelf') - if (bookshelfEl) { - bookshelfEl.scrollTop = 0 + if (window.bookshelf) { + window.bookshelf.scrollTop = 0 } this.mountEntities(0, lastBookIndex) }, @@ -552,7 +557,7 @@ export default { const newTitle = libraryItem.media.metadata?.title if (curTitle != newTitle) { console.log('Title changed. Re-sorting...') - this.resetEntities() + this.resetEntities(this.currScrollTop) return } } From 42b0e31b4ac81a5c855559eebdce4ce894110a5d Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 19 Feb 2025 17:44:14 -0600 Subject: [PATCH 35/35] Version bump v2.19.4 --- 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 350a58690..2d3955f79 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.19.3", + "version": "2.19.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.19.3", + "version": "2.19.4", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 6fb5df14e..c11b3be06 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.19.3", + "version": "2.19.4", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 18c877c00..147a39540 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.19.3", + "version": "2.19.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.19.3", + "version": "2.19.4", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index b00f30c2d..801afddcd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.19.3", + "version": "2.19.4", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js",