From da0a64daed44697e020ff734c53cee6e8b7fab43 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 24 Jan 2026 16:57:25 -0700 Subject: [PATCH 01/19] Add: 10 second grace period to access token cycle --- server/auth/TokenManager.js | 56 ++++++++++--- .../v2.33.0-add-last-refresh-token.js | 84 +++++++++++++++++++ server/models/Session.js | 12 +++ 3 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 server/migrations/v2.33.0-add-last-refresh-token.js diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index faa6774a3..5d8683553 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -12,9 +12,9 @@ class TokenManager { constructor() { /** @type {number} Refresh token expiry in seconds */ - this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 30 * 24 * 60 * 60 // 30 days + this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 3 // 3 seconds for testing /** @type {number} Access token expiry in seconds */ - this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 1 * 60 * 60 // 1 hour + this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 60 // 60 seconds for testing if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) { Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`) @@ -193,6 +193,11 @@ class TokenManager { // Calculate new expiration time const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) + // Set grace period of old refresh token in case of race condition in token rotation. + // This grace period may need to be longer if fetching the user data takes longer due to large progress objects + session.lastRefreshToken = session.refreshToken + session.lastRefreshTokenExpiresAt = new Date(Date.now() + 10 * 1000) // 10 seconds grace period + // Update the session with the new refresh token and expiration session.refreshToken = newRefreshToken session.expiresAt = newExpiresAt @@ -287,8 +292,10 @@ class TokenManager { } } - const session = await Database.sessionModel.findOne({ - where: { refreshToken: refreshToken } + let session = await Database.sessionModel.findOne({ + where: { + [Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }] + } }) if (!session) { @@ -298,12 +305,27 @@ class TokenManager { } } - // Check if session is expired in database - if (session.expiresAt < new Date()) { - Logger.info(`[TokenManager] Session expired in database, cleaning up`) - await session.destroy() - return { - error: 'Refresh token expired' + let isGracePeriod = false + if (session.refreshToken !== refreshToken) { + // Token matched lastRefreshToken + if (session.lastRefreshTokenExpiresAt && session.lastRefreshTokenExpiresAt > new Date()) { + isGracePeriod = true + Logger.debug(`[TokenManager] Grace period hit for user ${session.userId}`) + } else { + Logger.debug(`[TokenManager] Grace period expired for user ${session.userId}`) + return { + error: 'Invalid refresh token' + } + } + } else { + // Token matched current refreshToken + // Check if session is expired in database + if (session.expiresAt < new Date()) { + Logger.info(`[TokenManager] Session expired in database, cleaning up`) + await session.destroy() + return { + error: 'Refresh token expired' + } } } @@ -315,6 +337,20 @@ class TokenManager { } } + if (isGracePeriod) { + // Return the already rotated refresh token store in the database, + // and generate a new access token without changing the refresh token + // again + const accessToken = this.generateTempAccessToken(user) + this.setRefreshTokenCookie(req, res, session.refreshToken) + + return { + accessToken, + refreshToken: session.refreshToken, + user + } + } + const newTokens = await this.rotateTokensForSession(session, user, req, res) return { accessToken: newTokens.accessToken, diff --git a/server/migrations/v2.33.0-add-last-refresh-token.js b/server/migrations/v2.33.0-add-last-refresh-token.js new file mode 100644 index 000000000..5b53c749a --- /dev/null +++ b/server/migrations/v2.33.0-add-last-refresh-token.js @@ -0,0 +1,84 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.33.0' +const migrationName = `${migrationVersion}-add-last-refresh-token` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This migration script adds lastRefreshToken and lastRefreshTokenExpiresAt columns to the sessions table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('sessions')) { + const tableDescription = await queryInterface.describeTable('sessions') + + if (!tableDescription.lastRefreshToken) { + logger.info(`${loggerPrefix} Adding lastRefreshToken column to sessions table`) + await queryInterface.addColumn('sessions', 'lastRefreshToken', { + type: queryInterface.sequelize.Sequelize.DataTypes.STRING, + allowNull: true + }) + } else { + logger.info(`${loggerPrefix} lastRefreshToken column already exists in sessions table`) + } + + if (!tableDescription.lastRefreshTokenExpiresAt) { + logger.info(`${loggerPrefix} Adding lastRefreshTokenExpiresAt column to sessions table`) + await queryInterface.addColumn('sessions', 'lastRefreshTokenExpiresAt', { + type: queryInterface.sequelize.Sequelize.DataTypes.DATE, + allowNull: true + }) + } else { + logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column already exists in sessions table`) + } + } else { + logger.info(`${loggerPrefix} sessions table does not exist`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This migration script removes the lastRefreshToken and lastRefreshTokenExpiresAt columns from the sessions table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('sessions')) { + const tableDescription = await queryInterface.describeTable('sessions') + + if (tableDescription.lastRefreshToken) { + logger.info(`${loggerPrefix} Removing lastRefreshToken column from sessions table`) + await queryInterface.removeColumn('sessions', 'lastRefreshToken') + } else { + logger.info(`${loggerPrefix} lastRefreshToken column does not exist in sessions table`) + } + + if (tableDescription.lastRefreshTokenExpiresAt) { + logger.info(`${loggerPrefix} Removing lastRefreshTokenExpiresAt column from sessions table`) + await queryInterface.removeColumn('sessions', 'lastRefreshTokenExpiresAt') + } else { + logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column does not exist in sessions table`) + } + } else { + logger.info(`${loggerPrefix} sessions table does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/models/Session.js b/server/models/Session.js index fe9dd5425..3b85bd46a 100644 --- a/server/models/Session.js +++ b/server/models/Session.js @@ -18,6 +18,10 @@ class Session extends Model { this.userId /** @type {Date} */ this.expiresAt + /** @type {string} */ + this.lastRefreshToken + /** @type {Date} */ + this.lastRefreshTokenExpiresAt // Expanded properties @@ -66,6 +70,14 @@ class Session extends Model { expiresAt: { type: DataTypes.DATE, allowNull: false + }, + lastRefreshToken: { + type: DataTypes.STRING, + allowNull: true + }, + lastRefreshTokenExpiresAt: { + type: DataTypes.DATE, + allowNull: true } }, { From 7aa2f84daad13fc9f4b7646797385e25cf2f9872 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 24 Jan 2026 17:00:07 -0700 Subject: [PATCH 02/19] Revert default token expiry --- server/auth/TokenManager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 5d8683553..27c7a518b 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -12,9 +12,9 @@ class TokenManager { constructor() { /** @type {number} Refresh token expiry in seconds */ - this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 3 // 3 seconds for testing + this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 30 * 24 * 60 * 60 // 30 days /** @type {number} Access token expiry in seconds */ - this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 60 // 60 seconds for testing + this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 1 * 60 * 60 // 1 hour if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) { Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`) From e1ae4f2d315d153e206edebb8857883af896a5c7 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 24 Jan 2026 18:10:38 -0700 Subject: [PATCH 03/19] Fix: race condition in rotation --- server/auth/TokenManager.js | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 27c7a518b..bf987bb67 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -188,20 +188,32 @@ class TokenManager { async rotateTokensForSession(session, user, req, res) { // Generate new tokens const newAccessToken = this.generateTempAccessToken(user) - const newRefreshToken = this.generateRefreshToken(user) - - // Calculate new expiration time - const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) + let newRefreshToken = this.generateRefreshToken(user) // Set grace period of old refresh token in case of race condition in token rotation. // This grace period may need to be longer if fetching the user data takes longer due to large progress objects session.lastRefreshToken = session.refreshToken - session.lastRefreshTokenExpiresAt = new Date(Date.now() + 10 * 1000) // 10 seconds grace period + session.lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period // Update the session with the new refresh token and expiration session.refreshToken = newRefreshToken - session.expiresAt = newExpiresAt - await session.save() + session.expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) + + // Only update the session if the refresh token hasn't changed since we originally read it + const [numUpdated] = await Database.sessionModel.update(session, { + where: { + id: session.id, + refreshToken: session.lastRefreshToken + } + }) + + if (numUpdated === 0) { + Logger.debug(`[TokenManager] Race condition in rotateTokensForSession for user ${user.id}, getting new token`) + + const updatedSession = await Database.sessionModel.findOne({ where: { id: session.id } }) + + newRefreshToken = updatedSession.refreshToken + } // Set new refresh token cookie this.setRefreshTokenCookie(req, res, newRefreshToken) From b8a2d113f05b180623f6f944f69ea746c9c03a26 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 24 Jan 2026 18:26:11 -0700 Subject: [PATCH 04/19] Allow rotation without grace period for invalidating all user sessions --- server/auth/TokenManager.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index bf987bb67..c36aada08 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -183,17 +183,23 @@ class TokenManager { * @param {import('../models/User')} user * @param {import('express').Request} req * @param {import('express').Response} res - * @returns {Promise<{ accessToken:string, refreshToken:string }>} + * @param {boolean} noGracePeriod - whether to skip the grace period */ - async rotateTokensForSession(session, user, req, res) { + async rotateTokensForSession(session, user, req, res, noGracePeriod = false) { // Generate new tokens const newAccessToken = this.generateTempAccessToken(user) let newRefreshToken = this.generateRefreshToken(user) - // Set grace period of old refresh token in case of race condition in token rotation. - // This grace period may need to be longer if fetching the user data takes longer due to large progress objects - session.lastRefreshToken = session.refreshToken - session.lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period + if (noGracePeriod) { + // Set grace period of old refresh token in case of race condition in token rotation. + // This grace period may need to be longer if fetching the user data takes longer due to large progress objects + session.lastRefreshToken = session.refreshToken + session.lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period + } else { + // Do not set grace period of old refresh token, such as when specifically invalidating sessions for a user + session.lastRefreshToken = null + session.lastRefreshTokenExpiresAt = null + } // Update the session with the new refresh token and expiration session.refreshToken = newRefreshToken @@ -416,7 +422,7 @@ class TokenManager { // So rotate token for current session const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) if (currentSession) { - const newTokens = await this.rotateTokensForSession(currentSession, user, req, res) + const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, true) // Invalidate all sessions for the user except the current one await Database.sessionModel.destroy({ From 077b523bd66fce70639aa8f88d92e2c442115fca Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 24 Jan 2026 18:42:50 -0700 Subject: [PATCH 05/19] Fix JS Doc deletion --- server/auth/TokenManager.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index c36aada08..63463c4b5 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -184,6 +184,7 @@ class TokenManager { * @param {import('express').Request} req * @param {import('express').Response} res * @param {boolean} noGracePeriod - whether to skip the grace period + * @returns {Promise<{ accessToken:string, refreshToken:string }>} */ async rotateTokensForSession(session, user, req, res, noGracePeriod = false) { // Generate new tokens From cfeb6bd502a0f69483db35c37217b8f738c5d029 Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Sat, 24 Jan 2026 18:57:40 -0700 Subject: [PATCH 06/19] Fix: grace period enable statement --- server/auth/TokenManager.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 63463c4b5..12a92903c 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -183,15 +183,15 @@ class TokenManager { * @param {import('../models/User')} user * @param {import('express').Request} req * @param {import('express').Response} res - * @param {boolean} noGracePeriod - whether to skip the grace period + * @param {boolean} gracePeriod - whether to use the grace period * @returns {Promise<{ accessToken:string, refreshToken:string }>} */ - async rotateTokensForSession(session, user, req, res, noGracePeriod = false) { + async rotateTokensForSession(session, user, req, res, gracePeriod = true) { // Generate new tokens const newAccessToken = this.generateTempAccessToken(user) let newRefreshToken = this.generateRefreshToken(user) - if (noGracePeriod) { + if (gracePeriod) { // Set grace period of old refresh token in case of race condition in token rotation. // This grace period may need to be longer if fetching the user data takes longer due to large progress objects session.lastRefreshToken = session.refreshToken @@ -423,7 +423,7 @@ class TokenManager { // So rotate token for current session const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) if (currentSession) { - const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, true) + const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, false) // Invalidate all sessions for the user except the current one await Database.sessionModel.destroy({ From dc446862c1090cc3d22db554d42464f6b4b1c1ec Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 30 Apr 2026 16:08:24 -0500 Subject: [PATCH 07/19] Rename migration to v2.35.0 & merge master --- ...-last-refresh-token.js => v2.35.0-add-last-refresh-token.js} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename server/migrations/{v2.33.0-add-last-refresh-token.js => v2.35.0-add-last-refresh-token.js} (99%) diff --git a/server/migrations/v2.33.0-add-last-refresh-token.js b/server/migrations/v2.35.0-add-last-refresh-token.js similarity index 99% rename from server/migrations/v2.33.0-add-last-refresh-token.js rename to server/migrations/v2.35.0-add-last-refresh-token.js index 5b53c749a..0ad190e9a 100644 --- a/server/migrations/v2.33.0-add-last-refresh-token.js +++ b/server/migrations/v2.35.0-add-last-refresh-token.js @@ -7,7 +7,7 @@ * @property {MigrationContext} context - an object containing the migration context. */ -const migrationVersion = '2.33.0' +const migrationVersion = '2.35.0' const migrationName = `${migrationVersion}-add-last-refresh-token` const loggerPrefix = `[${migrationVersion} migration]` From 394280512941ec83a95e6a1429a8973d13454136 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 30 Apr 2026 16:25:43 -0500 Subject: [PATCH 08/19] Cleanup rotateTokensForSession --- server/auth/TokenManager.js | 46 +++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 7306411ef..57c57cac7 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -187,32 +187,35 @@ class TokenManager { * @returns {Promise<{ accessToken:string, refreshToken:string }>} */ async rotateTokensForSession(session, user, req, res, gracePeriod = true) { - // Generate new tokens + const previousRefreshToken = session.refreshToken const newAccessToken = this.generateTempAccessToken(user) let newRefreshToken = this.generateRefreshToken(user) + const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) + let lastRefreshToken = null + let lastRefreshTokenExpiresAt = null if (gracePeriod) { // Set grace period of old refresh token in case of race condition in token rotation. // This grace period may need to be longer if fetching the user data takes longer due to large progress objects - session.lastRefreshToken = session.refreshToken - session.lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period - } else { - // Do not set grace period of old refresh token, such as when specifically invalidating sessions for a user - session.lastRefreshToken = null - session.lastRefreshTokenExpiresAt = null + lastRefreshToken = previousRefreshToken + lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period } - // Update the session with the new refresh token and expiration - session.refreshToken = newRefreshToken - session.expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) - - // Only update the session if the refresh token hasn't changed since we originally read it - const [numUpdated] = await Database.sessionModel.update(session, { - where: { - id: session.id, - refreshToken: session.lastRefreshToken + // Only update if this session row still has the refresh token we read + const [numUpdated] = await Database.sessionModel.update( + { + refreshToken: newRefreshToken, + expiresAt: newExpiresAt, + lastRefreshToken, + lastRefreshTokenExpiresAt + }, + { + where: { + id: session.id, + refreshToken: previousRefreshToken + } } - }) + ) if (numUpdated === 0) { Logger.debug(`[TokenManager] Race condition in rotateTokensForSession for user ${user.id}, getting new token`) @@ -220,6 +223,15 @@ class TokenManager { const updatedSession = await Database.sessionModel.findOne({ where: { id: session.id } }) newRefreshToken = updatedSession.refreshToken + session.refreshToken = updatedSession.refreshToken + session.expiresAt = updatedSession.expiresAt + session.lastRefreshToken = updatedSession.lastRefreshToken + session.lastRefreshTokenExpiresAt = updatedSession.lastRefreshTokenExpiresAt + } else { + session.refreshToken = newRefreshToken + session.expiresAt = newExpiresAt + session.lastRefreshToken = lastRefreshToken + session.lastRefreshTokenExpiresAt = lastRefreshTokenExpiresAt } // Set new refresh token cookie From 40869bcf39f5894281c501bc059932d15ee6f6ed Mon Sep 17 00:00:00 2001 From: Brandon Date: Fri, 1 May 2026 22:22:09 -0400 Subject: [PATCH 09/19] fix: set correct Content-Type for RSS feed audio files Express's mime package does not recognize .m4b, causing it to fall back to application/octet-stream. This reuses the existing getAudioMimeTypeFromExtname utility (already applied to the download endpoint) to set the correct audio/mp4 header before sendFile. Fixes #5041 --- server/managers/RssFeedManager.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index c4681bdc2..a066a0d32 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -2,6 +2,7 @@ const { Request, Response } = require('express') const Path = require('path') const Logger = require('../Logger') +const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') @@ -216,6 +217,11 @@ class RssFeedManager { res.sendStatus(404) return } + // Express does not set the correct mimetype for m4b files so use our defined mimetypes if available + const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(episodePath)) + if (audioMimeType) { + res.setHeader('Content-Type', audioMimeType) + } res.sendFile(episodePath) } From 47ea6b50922e310acf523dbfaa4abd2f43d61940 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 5 May 2026 17:18:49 -0500 Subject: [PATCH 10/19] Update book/podcast scanner to sanitize description pulled from metadata --- server/scanner/BookScanner.js | 5 +++++ server/scanner/PodcastScanner.js | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index a1e7ff507..ac93c6379 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -7,6 +7,7 @@ const parseNameString = require('../utils/parsers/parseNameString') const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') const globals = require('../utils/globals') const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') +const htmlSanitizer = require('../utils/htmlSanitizer') const AudioFileScanner = require('./AudioFileScanner') const Database = require('../Database') @@ -688,6 +689,10 @@ class BookScanner { bookMetadata.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title) + if (typeof bookMetadata.description === 'string' && bookMetadata.description) { + bookMetadata.description = htmlSanitizer.sanitize(bookMetadata.description) + } + return bookMetadata } diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index c9569c3ad..6ab2d332c 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -11,6 +11,7 @@ const LibraryFile = require('../objects/files/LibraryFile') const fsExtra = require('../libs/fsExtra') const PodcastEpisode = require('../models/PodcastEpisode') const AbsMetadataFileScanner = require('./AbsMetadataFileScanner') +const htmlSanitizer = require('../utils/htmlSanitizer') /** * Metadata for podcasts pulled from files @@ -398,6 +399,10 @@ class PodcastScanner { podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title) + if (typeof podcastMetadata.description === 'string' && podcastMetadata.description) { + podcastMetadata.description = htmlSanitizer.sanitize(podcastMetadata.description) + } + return podcastMetadata } From b0aaa246603df147f2a81b91153c9ed1144e599d Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 12 May 2026 16:57:28 -0500 Subject: [PATCH 11/19] Update socket events to check client is admin & validate log level --- server/SocketAuthority.js | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index ad55f6605..a54231d75 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -3,6 +3,7 @@ const Logger = require('./Logger') const Database = require('./Database') const TokenManager = require('./auth/TokenManager') const CoverSearchManager = require('./managers/CoverSearchManager') +const { LogLevel } = require('./utils/constants') /** * @typedef SocketClient @@ -85,6 +86,14 @@ class SocketAuthority { } } + requireAdminSocket(socket, eventName) { + const client = this.clients[socket.id] + if (client?.user?.isAdminOrUp) return true + + Logger.warn(`[SocketAuthority] Unauthorized ${eventName} socket event from socket ${socket.id}`) + return false + } + /** * Emits event with library item to all clients that can access the library item * Note: Emits toOldJSONExpanded() @@ -179,14 +188,25 @@ class SocketAuthority { socket.on('auth', (token) => this.authenticateSocket(socket, token)) // Scanning - socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId)) + socket.on('cancel_scan', (libraryId) => { + if (!this.requireAdminSocket(socket, 'cancel_scan')) return + this.cancelScan(libraryId) + }) // Cover search streaming socket.on('search_covers', (payload) => this.handleCoverSearch(socket, payload)) socket.on('cancel_cover_search', (requestId) => this.handleCancelCoverSearch(socket, requestId)) // Logs - socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level)) + socket.on('set_log_listener', (level) => { + if (!this.requireAdminSocket(socket, 'set_log_listener')) return + + if (!Number.isInteger(level) || !Object.values(LogLevel).includes(level)) { + Logger.warn(`[SocketAuthority] Invalid set_log_listener level from socket ${socket.id}`) + return + } + Logger.addSocketListener(socket, level) + }) socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id)) // Sent automatically from socket.io clients From eee377e0817d99f66b1b2da08ee7c366bfd606ee Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 13 May 2026 16:23:26 -0500 Subject: [PATCH 12/19] Cleanup TokenManager logs --- server/auth/TokenManager.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 5efeb7a64..fc25140ac 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -299,7 +299,7 @@ class TokenManager { }) if (!session) { - Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`) + Logger.error(`[TokenManager] Failed to refresh token. Session not found`) return { error: 'Invalid refresh token' } @@ -389,7 +389,7 @@ class TokenManager { return newTokens.accessToken } else { - Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`) + Logger.error(`[TokenManager] No session found to rotate tokens`) } } @@ -413,7 +413,7 @@ class TokenManager { try { const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } }) - Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`) + Logger.info(`[TokenManager] Refresh token invalidated, ${numDeleted} sessions deleted`) return true } catch (error) { Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`) From c010f0e1ebb2cba5a743ca935c52160558cc74d5 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 15 May 2026 13:13:14 -0500 Subject: [PATCH 13/19] Fix android device sdkVersion not handling it using number type, causing android session device names to show as iOS --- server/objects/DeviceInfo.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/objects/DeviceInfo.js b/server/objects/DeviceInfo.js index 22ebfbea4..68237f0fb 100644 --- a/server/objects/DeviceInfo.js +++ b/server/objects/DeviceInfo.js @@ -96,7 +96,12 @@ class DeviceInfo { this.clientVersion = stripAllTags(clientDeviceInfo?.clientVersion) || serverVersion this.manufacturer = stripAllTags(clientDeviceInfo?.manufacturer) || null this.model = stripAllTags(clientDeviceInfo?.model) || null - this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null + + if (typeof clientDeviceInfo?.sdkVersion === 'number') { + this.sdkVersion = clientDeviceInfo.sdkVersion.toString() + } else { + this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null + } this.clientName = stripAllTags(clientDeviceInfo?.clientName) || null if (this.sdkVersion) { From 6d5d89429d2e4c796540adcd8ec7ff260235403d Mon Sep 17 00:00:00 2001 From: Mateusz Lesiak Date: Wed, 29 Apr 2026 13:42:37 +0200 Subject: [PATCH 14/19] Translated using Weblate (Polish) Currently translated at 99.8% (1161 of 1163 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/ --- client/strings/pl.json | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/client/strings/pl.json b/client/strings/pl.json index d1bc6c062..f04e61c46 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -951,6 +951,11 @@ "NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.", "NoteUploaderOnlyAudioFiles": "Jeśli przesyłasz tylko pliki audio, każdy plik audio będzie traktowany jako osobny audiobook.", "NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.", + "NotificationOnBackupCompletedDescription": "Wyzwalane po zakończeniu tworzenia kopii zapasowej", + "NotificationOnBackupFailedDescription": "Wyzwalane w przypadku gdy stworzenie kopii zapasowej rzuci błąd", + "NotificationOnEpisodeDownloadedDescription": "Wyzwalane, gdy odcinek podcastu zostanie automatycznie pobrany", + "NotificationOnRSSFeedDisabledDescription": "Wyzwalane, gdy automatyczne pobieranie odcinków jest wyłączone z powodu zbyt wielu nieudanych prób", + "NotificationOnRSSFeedFailedDescription": "Wyzwalane, gdy żądanie kanału RSS dotyczące automatycznego pobrania odcinka nie powiedzie się", "NotificationOnTestDescription": "Zdarzenie używane do testowania systemu powiadomień", "PlaceholderBulkChapterInput": "Wpisz tytuł rozdziału lub użyj numeracji (np. „Odcinek 1”, „Rozdział 10”, „1.”)", "PlaceholderNewCollection": "Nowa nazwa kolekcji", @@ -960,6 +965,7 @@ "PlaceholderSearchEpisode": "Szukanie odcinka..", "StatsAuthorsAdded": "dodano autorów", "StatsBooksAdded": "dodano książki", + "StatsBooksAdditional": "Niektóre dodatki obejmują…", "StatsBooksFinished": "ukończone książki", "StatsBooksFinishedThisYear": "Wybrane książki ukończone w tym roku…", "StatsBooksListenedTo": "książki wysłuchane", @@ -976,6 +982,7 @@ "StatsTotalDuration": "O sumarycznej długości…", "StatsYearInReview": "PRZEGLĄD ROKU", "ToastAccountUpdateSuccess": "Zaktualizowano konto", + "ToastAppriseUrlRequired": "Należy wprowadzić adres URL Apprise", "ToastAsinRequired": "ASIN jest wymagany", "ToastAuthorImageRemoveSuccess": "Zdjęcie autora usunięte", "ToastAuthorNotFound": "Autor \"{0}\" nie został znaleziony", @@ -994,8 +1001,11 @@ "ToastBackupRestoreFailed": "Nie udało się przywrócić kopii zapasowej", "ToastBackupUploadFailed": "Nie udało się przesłać kopii zapasowej", "ToastBackupUploadSuccess": "Kopia zapasowa została przesłana", + "ToastBatchApplyDetailsToItemsSuccess": "Szczegóły zastosowane do elementów", "ToastBatchDeleteFailed": "Usuwanie zbiorcze nie powiodło się", "ToastBatchDeleteSuccess": "Usuwanie zbiorcze powiodło się", + "ToastBatchQuickMatchFailed": "Szybkie dopasowanie partii nie powiodło się!", + "ToastBatchQuickMatchStarted": "Rozpoczęto partię szybkiego dopasowania {0} książek!", "ToastBatchUpdateFailed": "Aktualizacja zbiorcza nie powiodła się", "ToastBatchUpdateSuccess": "Aktualizacja zbiorcza powiodła się", "ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki", @@ -1033,7 +1043,14 @@ "ToastEpisodeDownloadQueueClearSuccess": "Wyczyszczono kolejkę epizodów do ściągnięcia", "ToastEpisodeUpdateSuccess": "Zaktualizowano {0} odcinków", "ToastErrorCannotShare": "Nie można udostępniać natywnie na tym urządzeniu.", + "ToastFailedToCreate": "Nie udało się utworzyć", + "ToastFailedToDelete": "Nie udało się usunąć", + "ToastFailedToLoadData": "Nie udało się załadować danych", + "ToastFailedToMatch": "Nie udało się dopasować", + "ToastFailedToShare": "Nie udało się udostępnić", + "ToastFailedToUpdate": "Nie udało się zaktualizować", "ToastInvalidImageUrl": "Nieprawidłowy URL obrazu", + "ToastInvalidMaxEpisodesToDownload": "Nieprawidłowa maksymalna liczba odcinków do pobrania", "ToastInvalidUrl": "Nieprawidłowy URL", "ToastInvalidUrls": "Jeden lub więcej URL-i są nieprawidłowe", "ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę", @@ -1044,6 +1061,7 @@ "ToastItemMarkedAsFinishedSuccess": "Pozycja oznaczona jako ukończona", "ToastItemMarkedAsNotFinishedFailed": "Oznaczenie pozycji jako ukończonej nie powiodło się", "ToastItemMarkedAsNotFinishedSuccess": "Pozycja oznaczona jako nieukończona", + "ToastItemUpdateSuccess": "Element zaktualizowany", "ToastLibraryCreateFailed": "Nie udało się utworzyć biblioteki", "ToastLibraryCreateSuccess": "Biblioteka \"{0}\" stworzona", "ToastLibraryDeleteFailed": "Nie udało się usunąć biblioteki", @@ -1052,6 +1070,10 @@ "ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki", "ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji", "ToastMatchAllAuthorsFailed": "Nie udało się dopasować wszystkich autorów", + "ToastMetadataFilesRemovedError": "Błąd podczas usuwania metadata.{0} plików", + "ToastMetadataFilesRemovedNoneFound": "Nie znaleziono metadata.{0} plików w bibliotece", + "ToastMetadataFilesRemovedNoneRemoved": "Nie usunięto żadnego metadata.{0} pliku", + "ToastMetadataFilesRemovedSuccess": "{0} metadata.{0} plików usunięto", "ToastMustHaveAtLeastOnePath": "Musi mieć przynajmniej jedną ścieżkę", "ToastNameEmailRequired": "Nazwa i email są wymagane", "ToastNameRequired": "Imię jest wymagane", @@ -1065,7 +1087,15 @@ "ToastNewUserUsernameError": "Wprowadź nazwę użytkownika", "ToastNoNewEpisodesFound": "Nie znaleziono nowych odcinków", "ToastNoRSSFeed": "Podcast nie posiada RSS Feed", + "ToastNoUpdatesNecessary": "Brak konieczności aktualizacji", + "ToastNotificationCreateFailed": "Nie udało się utworzyć powiadomienia", + "ToastNotificationDeleteFailed": "Nie udało się usunąć powiadomienia", "ToastNotificationFailedMaximum": "Maks. ilość nieudanych prób musi być >= 0", + "ToastNotificationQueueMaximum": "Maksymalna liczba powiadomień w kolejce musi być >= 0", + "ToastNotificationSettingsUpdateSuccess": "Zaktualizowano ustawienia powiadomień", + "ToastNotificationTestTriggerFailed": "Nie udało się wywołać powiadomienia testowego", + "ToastNotificationTestTriggerSuccess": "Wyzwolono powiadomienie testowe", + "ToastNotificationUpdateSuccess": "Powiadomienie zaktualizowane", "ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty", "ToastPlaylistCreateSuccess": "Playlista utworzona", "ToastPlaylistRemoveSuccess": "Playlista usunięta", @@ -1073,8 +1103,17 @@ "ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu", "ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony", "ToastPodcastEpisodeUpdated": "Zaktualizowano odcinki", + "ToastPodcastGetFeedFailed": "Nie udało się pobrać kanału podcastu", + "ToastPodcastNoEpisodesInFeed": "Nie znaleziono żadnych odcinków w kanale RSS", + "ToastPodcastNoRssFeed": "Podcast nie ma kanału RSS", + "ToastProgressIsNotBeingSynced": "Postęp nie jest synchronizowany, uruchom ponownie odtwarzanie", + "ToastProviderCreatedFailed": "Nie udało się dodać dostawcy", + "ToastProviderCreatedSuccess": "Dodano nowego dostawcę", + "ToastProviderNameAndUrlRequired": "Wymagane jest podanie nazwy i adresu URL", + "ToastProviderRemoveSuccess": "Dostawca usunięty", "ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się", "ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się", + "ToastRemoveFailed": "Nie udało się usunąć", "ToastRemoveItemFromCollectionFailed": "Nie udało się usunąć elementu z kolekcji", "ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji", "ToastRemoveItemsWithIssuesFailed": "Nie udało się usunąć wadliwych elementów z biblioteki", @@ -1096,16 +1135,25 @@ "ToastSessionDeleteFailed": "Nie udało się usunąć sesji", "ToastSessionDeleteSuccess": "Sesja usunięta", "ToastSleepTimerDone": "Słodkich snów... zZzzZz", + "ToastSlugMustChange": "Slug zawiera nieprawidłowe znaki", + "ToastSlugRequired": "Slug jest wymagany", "ToastSocketConnected": "Nawiązano połączenie z serwerem", "ToastSocketDisconnected": "Połączenie z serwerem zostało zamknięte", "ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się", + "ToastSortingPrefixesEmptyError": "Musi mieć co najmniej 1 prefiks sortowania", + "ToastSortingPrefixesUpdateSuccess": "Zaktualizowano prefiksy sortowania ({0} elementów)", "ToastTitleRequired": "Tytuł jest wymagany", "ToastUnknownError": "Nieznany błąd", "ToastUnlinkOpenIdFailed": "Nie udało się odpiąć użytkownika z OpenID", "ToastUnlinkOpenIdSuccess": "Użytkownik odpięty z OpenID", "ToastUploaderFilepathExistsError": "Ścieżka \"{0}\" już istnieje na serwerze", + "ToastUploaderItemExistsInSubdirectoryError": "Element \"{0}\" używa podkatalogu ścieżki przesyłania.", "ToastUserDeleteFailed": "Nie udało się usunąć użytkownika", "ToastUserDeleteSuccess": "Użytkownik usunięty", + "ToastUserPasswordChangeSuccess": "Hasło zostało pomyślnie zmienione", + "ToastUserPasswordMismatch": "Hasła nie są zgodne", + "ToastUserPasswordMustChange": "Nowe hasło nie może być takie samo jak stare hasło", + "ToastUserRootRequireName": "Należy wprowadzić nazwę użytkownika root", "TooltipAddChapters": "Dodaj rozdział(y)", "TooltipAddOneSecond": "Dodaj sekundę", "TooltipAdjustChapterStart": "Kliknij, aby skorygować czas początkowy", From 3b025076e8c692dda3f9740592b2512be7e6111a Mon Sep 17 00:00:00 2001 From: d0nizam Date: Wed, 29 Apr 2026 17:36:49 +0200 Subject: [PATCH 15/19] Translated using Weblate (Bulgarian) Currently translated at 100.0% (1163 of 1163 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bg/ --- client/strings/bg.json | 105 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/client/strings/bg.json b/client/strings/bg.json index 460f0ff83..c34188a54 100644 --- a/client/strings/bg.json +++ b/client/strings/bg.json @@ -752,7 +752,7 @@ "MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове", "MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"", "MessageBookshelfNoResultsForQuery": "Няма резултати от заявката", - "MessageBookshelfNoSeries": "Нямаш сеЗЙ", + "MessageBookshelfNoSeries": "Нямате поредица", "MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?", "MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига", "MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0", @@ -1018,18 +1018,50 @@ "ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди", "ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.", "ToastChaptersHaveErrors": "Главите имат грешки", + "ToastChaptersInvalidShiftAmountLast": "Невалидно време за преместване. Началният час на последната глава ще превиши общата продължителност на аудиокнигата.", + "ToastChaptersInvalidShiftAmountStart": "Невалидно време за преместване. Първата глава ще има нулева или отрицателна дължина и ще бъде презаписана от втората глава. Увеличете началното време на втората глава.", "ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия", + "ToastChaptersRemoved": "Главите са премахнати", + "ToastChaptersUpdated": "Главите са актуализирани", + "ToastCollectionItemsAddFailed": "Неуспешно добавяне на елемент(и) към колекцията", "ToastCollectionRemoveSuccess": "Колекцията е премахната", "ToastCollectionUpdateSuccess": "Колекцията е обновена", + "ToastConnectionNotAvailable": "Няма връзка. Моля, опитайте отново по-късно", + "ToastCoverSearchFailed": "Търсенето на корица е неуспешно", + "ToastCoverUpdateFailed": "Обновяването на корицата е неуспешно", + "ToastDateTimeInvalidOrIncomplete": "Датата и часът са невалидни или непълни", "ToastDeleteFileFailed": "Неуспешно изтриване на файла", "ToastDeleteFileSuccess": "Успешно изтриване на файла", + "ToastDeviceAddFailed": "Неуспешно добавяне на устройство", + "ToastDeviceNameAlreadyExists": "Вече съществува четец с това име", + "ToastDeviceTestEmailFailed": "Неуспешно изпращане на тестов имейл", + "ToastDeviceTestEmailSuccess": "Тестовият имейл е изпратен", + "ToastEmailSettingsUpdateSuccess": "Имейл настройките са актуализирани", + "ToastEncodeCancelFailed": "Неуспешно отменяне на кодирането", + "ToastEncodeCancelSucces": "Кодирането е отменено", + "ToastEpisodeDownloadQueueClearFailed": "Неуспешно изчистване на опашката", + "ToastEpisodeDownloadQueueClearSuccess": "Опашката за изтегляне на епизоди е изчистена", + "ToastEpisodeUpdateSuccess": "{0} епизода са актуализирани", + "ToastErrorCannotShare": "Не може да се споделя директно от това устройство", + "ToastFailedToCreate": "Неуспешно създаване", + "ToastFailedToDelete": "Неуспешно изтриване", "ToastFailedToLoadData": "Неуспешно зареждане на данни", + "ToastFailedToMatch": "Неуспешно съвпадение", + "ToastFailedToShare": "Неуспешно споделяне", + "ToastFailedToUpdate": "Неуспешно актуализиране", + "ToastInvalidImageUrl": "Невалиден URL адрес на изображение", + "ToastInvalidMaxEpisodesToDownload": "Невалиден максимален брой епизоди за изтегляне", + "ToastInvalidUrl": "Невалиден URL адрес", + "ToastInvalidUrls": "Един или повече URL адреси са невалидни", "ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена", + "ToastItemDeletedFailed": "Неуспешно изтриване на елемента", + "ToastItemDeletedSuccess": "Елементът е изтрит", "ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени", "ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено", "ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен", "ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено", "ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен", + "ToastItemUpdateSuccess": "Елементът е актуализиран", "ToastLibraryCreateFailed": "Неуспешно създаване на библиотека", "ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена", "ToastLibraryDeleteFailed": "Неуспешно изтриване на библиотека", @@ -1037,28 +1069,97 @@ "ToastLibraryScanFailedToStart": "Неуспешно стартиране на сканиране", "ToastLibraryScanStarted": "Сканирането на библиотеката е стартирано", "ToastLibraryUpdateSuccess": "Библиотеката \"{0}\" е обновена", + "ToastMatchAllAuthorsFailed": "Неуспешно съвпадение на всички автори", + "ToastMetadataFilesRemovedError": "Грешка при премахване на metadata.{0} файлове", + "ToastMetadataFilesRemovedNoneFound": "Не са намерени metadata.{0} файлове в библиотеката", + "ToastMetadataFilesRemovedNoneRemoved": "Не са премахнати metadata.{0} файлове", + "ToastMetadataFilesRemovedSuccess": "Премахнати са {0} файла metadata.{1}", + "ToastMustHaveAtLeastOnePath": "Трябва да има поне един път", + "ToastNameEmailRequired": "Изискват се име и имейл", + "ToastNameRequired": "Изисква се име", + "ToastNewApiKeyUserError": "Трябва да изберете потребител", + "ToastNewEpisodesFound": "Намерени са {0} нови епизода", + "ToastNewUserCreatedFailed": "Неуспешно създаване на акаунт: „{0}“", + "ToastNewUserCreatedSuccess": "Създаден е нов акаунт", + "ToastNewUserLibraryError": "Трябва да изберете поне една библиотека", + "ToastNewUserPasswordError": "Трябва да има парола; само root потребителят може да бъде с празна парола", + "ToastNewUserTagError": "Трябва да изберете поне един етикет", + "ToastNewUserUsernameError": "Въведете потребителско име", + "ToastNoNewEpisodesFound": "Не са намерени нови епизоди", + "ToastNoRSSFeed": "Подкастът няма RSS емисия", + "ToastNoUpdatesNecessary": "Не са необходими актуализации", + "ToastNotificationCreateFailed": "Неуспешно създаване на известие", + "ToastNotificationDeleteFailed": "Неуспешно изтриване на известието", + "ToastNotificationFailedMaximum": "Максималният брой неуспешни опити трябва да бъде >= 0", + "ToastNotificationQueueMaximum": "Максималната опашка за известия трябва да бъде >= 0", + "ToastNotificationSettingsUpdateSuccess": "Настройките за известия са актуализирани", + "ToastNotificationTestTriggerFailed": "Неуспешно задействане на тестово известие", + "ToastNotificationTestTriggerSuccess": "Тестовото известие е задействано", + "ToastNotificationUpdateSuccess": "Известието е актуализирано", "ToastPlaylistCreateFailed": "Неуспешно създаване на плейлист", "ToastPlaylistCreateSuccess": "Плейлистът е създаден", "ToastPlaylistRemoveSuccess": "Плейлистът е премахнат", "ToastPlaylistUpdateSuccess": "Плейлистът е обновен", "ToastPodcastCreateFailed": "Неуспешно създаване на подкаст", "ToastPodcastCreateSuccess": "Подкаст успешно създаден", + "ToastPodcastEpisodeUpdated": "Епизодът е актуализиран", + "ToastPodcastGetFeedFailed": "Неуспешно извличане на емисията на подкаста", + "ToastPodcastNoEpisodesInFeed": "Не са намерени епизоди в RSS емисията", + "ToastPodcastNoRssFeed": "Подкастът няма RSS емисия", + "ToastProgressIsNotBeingSynced": "Напредъкът не се синхронизира, рестартирайте възпроизвеждането", + "ToastProviderCreatedFailed": "Неуспешно добавяне на доставчик", + "ToastProviderCreatedSuccess": "Добавен е нов доставчик", + "ToastProviderNameAndUrlRequired": "Изискват се име и URL адрес", + "ToastProviderRemoveSuccess": "Доставчикът е премахнат", "ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията", "ToastRSSFeedCloseSuccess": "RSS емисията е затворена", + "ToastRemoveFailed": "Неуспешно премахване", "ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция", "ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция", + "ToastRemoveItemsWithIssuesFailed": "Неуспешно премахване на елементите от библиотеката с проблеми", + "ToastRemoveItemsWithIssuesSuccess": "Елементите от библиотеката с проблеми са премахнати", + "ToastRenameFailed": "Неуспешно преименуване", + "ToastRescanFailed": "Повторното сканиране е неуспешно за {0}", + "ToastRescanRemoved": "Повторното сканиране завърши: елементът е премахнат", + "ToastRescanUpToDate": "Повторното сканиране завърши: елементът вече е актуален", + "ToastRescanUpdated": "Повторното сканиране завърши: елементът е актуализиран", + "ToastScanFailed": "Неуспешно сканиране на елемент от библиотеката", + "ToastSelectAtLeastOneUser": "Изберете поне един потребител", "ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство", "ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"", + "ToastSeriesSubmitFailedSameName": "Не могат да бъдат добавени два сериала с едно и също име", "ToastSeriesUpdateFailed": "Неуспешно обновяване на серия", "ToastSeriesUpdateSuccess": "Серията е обновена", "ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани", + "ToastSessionCloseFailed": "Неуспешно затваряне на сесията", "ToastSessionDeleteFailed": "Неуспешно изтриване на сесия", "ToastSessionDeleteSuccess": "Сесията е изтрита", + "ToastSleepTimerDone": "Таймерът за заспиване приключи... zZzzZz", + "ToastSlugMustChange": "Краткият URL (slug) съдържа невалидни символи", + "ToastSlugRequired": "Изисква се кратък URL (slug)", "ToastSocketConnected": "Свързан сокет", "ToastSocketDisconnected": "Сокетът е прекъснат", "ToastSocketFailedToConnect": "Неуспешно свързване на сокет", "ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране", "ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)", + "ToastTitleRequired": "Изисква се заглавие", + "ToastUnknownError": "Неизвестна грешка", + "ToastUnlinkOpenIdFailed": "Неуспешно прекъсване на връзката на потребителя с OpenID", + "ToastUnlinkOpenIdSuccess": "Връзката на потребителя с OpenID е прекъсната", + "ToastUploaderFilepathExistsError": "Файловият път „{0}“ вече съществува на сървъра", + "ToastUploaderItemExistsInSubdirectoryError": "Елементът „{0}“ използва поддиректория на пътя за качване.", "ToastUserDeleteFailed": "Неуспешно изтриване на потребител", - "ToastUserDeleteSuccess": "Потребителят е изтрит" + "ToastUserDeleteSuccess": "Потребителят е изтрит", + "ToastUserPasswordChangeSuccess": "Паролата е променена успешно", + "ToastUserPasswordMismatch": "Паролите не съвпадат", + "ToastUserPasswordMustChange": "Новата парола не може да бъде същата като старата", + "ToastUserRootRequireName": "Трябва да въведете root потребителско име", + "TooltipAddChapters": "Добавяне на глава(и)", + "TooltipAddOneSecond": "Добавяне на 1 секунда", + "TooltipAdjustChapterStart": "Кликнете за коригиране на началния час", + "TooltipLockAllChapters": "Заключване на всички глави", + "TooltipLockChapter": "Заключване на глава (Shift+клик за диапазон)", + "TooltipSubtractOneSecond": "Изваждане на 1 секунда", + "TooltipUnlockAllChapters": "Отключване на всички глави", + "TooltipUnlockChapter": "Отключване на глава (Shift+клик за диапазон)" } From 52a485d1359daf79832fa6d3987bea8793c3fe87 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 9 May 2026 16:09:49 +0200 Subject: [PATCH 16/19] Added translation using Weblate (Latvian) --- client/strings/lv.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 client/strings/lv.json diff --git a/client/strings/lv.json b/client/strings/lv.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/client/strings/lv.json @@ -0,0 +1 @@ +{} From 4f21fc023c1c227949955ed6aa76d9f5b1f9f722 Mon Sep 17 00:00:00 2001 From: EteranlK Date: Sun, 10 May 2026 19:36:36 +0200 Subject: [PATCH 17/19] Translated using Weblate (Arabic) Currently translated at 96.3% (1120 of 1163 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/ --- client/strings/ar.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/strings/ar.json b/client/strings/ar.json index a176b9d8b..dc5ef1762 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -244,6 +244,8 @@ "LabelAlreadyInYourLibrary": "موجود بالفعل في مكتبتك", "LabelApiKeyCreated": "تم إنشاء مفتاح API \"{0}\" بنجاح.", "LabelApiKeyCreatedDescription": "تأكد من نسخ مفتاح API الآن، لن تتمكن من رؤيته مرة أخرى.", + "LabelApiKeyUser": "التصرف بالنيابة عن مستخدم", + "LabelApiKeyUserDescription": "مفتاح API سيمتلك نفس صلاحيات المستخدم الذي ينوب عنه ، سيظهر بالسجلات وكأن المستخدم قام بالطلب.", "LabelApiToken": "رمز API", "LabelAppend": "إلحاق", "LabelAudioBitrate": "معدل بت الصوت (على سبيل المثال 128 كيلو بايت)", @@ -293,6 +295,7 @@ "LabelContinueListening": "استمرار الاستماع", "LabelContinueReading": "استمرار القراءة", "LabelContinueSeries": "استمرار المسلسلات", + "LabelCorsAllowed": "CORS Origins مسموح", "LabelCover": "الغلاف", "LabelCoverImageURL": "رابط صورة الغلاف", "LabelCoverProvider": "مزود الغلاف", @@ -426,6 +429,9 @@ "LabelLibraryFilterSublistEmpty": "لا يوجد {0}", "LabelLibraryItem": "عنصر المكتبة", "LabelLibraryName": "اسم المكتبة", + "LabelLibrarySortByProgress": "المرحلة: الأحدث", + "LabelLibrarySortByProgressFinished": "المرحلة: تم الانتهاء", + "LabelLibrarySortByProgressStarted": "المرحلة: تم البدء", "LabelLimit": "حد", "LabelLineSpacing": "تباعد الأسطر", "LabelListenAgain": "الاستماع مجدداً", From 50eeca2e0fcd7136beef1bdd04db368faa2619c3 Mon Sep 17 00:00:00 2001 From: Pavel Miniutka Date: Wed, 13 May 2026 13:47:32 +0200 Subject: [PATCH 18/19] Translated using Weblate (Belarusian) Currently translated at 100.0% (1163 of 1163 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/ --- client/strings/be.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/be.json b/client/strings/be.json index b4e4df86a..d3aa42cf1 100644 --- a/client/strings/be.json +++ b/client/strings/be.json @@ -88,7 +88,7 @@ "ButtonResetToDefault": "Скінуць да прадвызначаных", "ButtonRestore": "Аднавіць", "ButtonSave": "Захаваць", - "ButtonSaveAndClose": "Захаваць і зачыніць", + "ButtonSaveAndClose": "Захаваць і закрыць", "ButtonSaveTracklist": "Захаваць спіс трэкаў", "ButtonScan": "Сканаваць", "ButtonScanLibrary": "Сканіраваць бібліятэку", @@ -284,7 +284,7 @@ "LabelChaptersFound": "раздзелаў знойдзена", "LabelClickForMoreInfo": "Націсніце для больш падрабязнай інфармацыі", "LabelClickToUseCurrentValue": "Націсніце, каб выкарыстоўваць бягучае значэнне", - "LabelClosePlayer": "Зачыніць прайгравальнік", + "LabelClosePlayer": "Закрыць прайгравальнік", "LabelCodec": "Кодэк", "LabelCollapseSeries": "Згарнуць серыі", "LabelCollapseSubSeries": "Згарнуць падсерыі", From 72dc75482f73c766681b5fe20dc1895897bfef44 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 17 May 2026 14:31:41 -0500 Subject: [PATCH 19/19] Version bump v2.35.0 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 79ac53250..ba071852b 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.34.0", + "version": "2.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.34.0", + "version": "2.35.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index dd0f3a0c9..71d64f60a 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.34.0", + "version": "2.35.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 8950b4903..312675d71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.34.0", + "version": "2.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.34.0", + "version": "2.35.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 10ba26f65..a0b340244 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.34.0", + "version": "2.35.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js",