From 843dd0b1b28ec1e5f36b71eee58af7306e84a4ef Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 29 Nov 2024 04:13:00 +0200 Subject: [PATCH 01/41] Keep original socket.io server for non-subdir clients --- server/Server.js | 18 ++--- server/SocketAuthority.js | 148 ++++++++++++++++++++++---------------- 2 files changed, 90 insertions(+), 76 deletions(-) diff --git a/server/Server.js b/server/Server.js index ae9746d8d..9153ab092 100644 --- a/server/Server.js +++ b/server/Server.js @@ -84,7 +84,6 @@ class Server { Logger.logManager = new LogManager() this.server = null - this.io = null } /** @@ -441,18 +440,11 @@ class Server { async stop() { Logger.info('=== Stopping Server ===') Watcher.close() - Logger.info('Watcher Closed') - - return new Promise((resolve) => { - SocketAuthority.close((err) => { - if (err) { - Logger.error('Failed to close server', err) - } else { - Logger.info('Server successfully closed') - } - resolve() - }) - }) + Logger.info('[Server] Watcher Closed') + await SocketAuthority.close() + Logger.info('[Server] Closing HTTP Server') + await new Promise((resolve) => this.server.close(resolve)) + Logger.info('[Server] HTTP Server Closed') } } module.exports = Server diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index a71829361..19c686d97 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -14,7 +14,7 @@ const Auth = require('./Auth') class SocketAuthority { constructor() { this.Server = null - this.io = null + this.socketIoServers = [] /** @type {Object.} */ this.clients = {} @@ -89,82 +89,104 @@ class SocketAuthority { * * @param {Function} callback */ - close(callback) { - Logger.info('[SocketAuthority] Shutting down') - // This will close all open socket connections, and also close the underlying http server - if (this.io) this.io.close(callback) - else callback() + async close() { + Logger.info('[SocketAuthority] closing...') + const closePromises = this.socketIoServers.map((io) => { + return new Promise((resolve) => { + Logger.info(`[SocketAuthority] Closing Socket.IO server: ${io.path}`) + io.close(() => { + Logger.info(`[SocketAuthority] Socket.IO server closed: ${io.path}`) + resolve() + }) + }) + }) + await Promise.all(closePromises) + Logger.info('[SocketAuthority] closed') + this.socketIoServers = [] } initialize(Server) { this.Server = Server - this.io = new SocketIO.Server(this.Server.server, { + const socketIoOptions = { cors: { origin: '*', methods: ['GET', 'POST'] - }, - path: `${global.RouterBasePath}/socket.io` - }) - - this.io.on('connection', (socket) => { - this.clients[socket.id] = { - id: socket.id, - socket, - connected_at: Date.now() } - socket.sheepClient = this.clients[socket.id] + } - Logger.info('[SocketAuthority] Socket Connected', socket.id) + const ioServer = new SocketIO.Server(Server.server, socketIoOptions) + ioServer.path = '/socket.io' + this.socketIoServers.push(ioServer) - // Required for associating a User with a socket - socket.on('auth', (token) => this.authenticateSocket(socket, token)) + if (global.RouterBasePath) { + // open a separate socket.io server for the router base path, keeping the original server open for legacy clients + const ioBasePath = `${global.RouterBasePath}/socket.io` + const ioBasePathServer = new SocketIO.Server(Server.server, { ...socketIoOptions, path: ioBasePath }) + ioBasePathServer.path = ioBasePath + this.socketIoServers.push(ioBasePathServer) + } - // Scanning - socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId)) - - // Logs - socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level)) - socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id)) - - // Sent automatically from socket.io clients - socket.on('disconnect', (reason) => { - Logger.removeSocketListener(socket.id) - - const _client = this.clients[socket.id] - if (!_client) { - Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`) - } else if (!_client.user) { - Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`) - delete this.clients[socket.id] - } else { - Logger.debug('[SocketAuthority] User Offline ' + _client.user.username) - this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) - - const disconnectTime = Date.now() - _client.connected_at - Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`) - delete this.clients[socket.id] + this.socketIoServers.forEach((io) => { + io.on('connection', (socket) => { + this.clients[socket.id] = { + id: socket.id, + socket, + connected_at: Date.now() } - }) + socket.sheepClient = this.clients[socket.id] - // - // Events for testing - // - socket.on('message_all_users', (payload) => { - // admin user can send a message to all authenticated users - // displays on the web app as a toast - const client = this.clients[socket.id] || {} - if (client.user?.isAdminOrUp) { - this.emitter('admin_message', payload.message || '') - } else { - Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`) - } - }) - socket.on('ping', () => { - const client = this.clients[socket.id] || {} - const user = client.user || {} - Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`) - socket.emit('pong') + Logger.info(`[SocketAuthority] Socket Connected to ${io.path}`, socket.id) + + // Required for associating a User with a socket + socket.on('auth', (token) => this.authenticateSocket(socket, token)) + + // Scanning + socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId)) + + // Logs + socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level)) + socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id)) + + // Sent automatically from socket.io clients + socket.on('disconnect', (reason) => { + Logger.removeSocketListener(socket.id) + + const _client = this.clients[socket.id] + if (!_client) { + Logger.warn(`[SocketAuthority] Socket ${socket.id} disconnect, no client (Reason: ${reason})`) + } else if (!_client.user) { + Logger.info(`[SocketAuthority] Unauth socket ${socket.id} disconnected (Reason: ${reason})`) + delete this.clients[socket.id] + } else { + Logger.debug('[SocketAuthority] User Offline ' + _client.user.username) + this.adminEmitter('user_offline', _client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) + + const disconnectTime = Date.now() - _client.connected_at + Logger.info(`[SocketAuthority] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms (Reason: ${reason})`) + delete this.clients[socket.id] + } + }) + + // + // Events for testing + // + socket.on('message_all_users', (payload) => { + // admin user can send a message to all authenticated users + // displays on the web app as a toast + const client = this.clients[socket.id] || {} + if (client.user?.isAdminOrUp) { + this.emitter('admin_message', payload.message || '') + } else { + Logger.error(`[SocketAuthority] Non-admin user sent the message_all_users event`) + } + }) + socket.on('ping', () => { + const client = this.clients[socket.id] || {} + const user = client.user || {} + Logger.debug(`[SocketAuthority] Received ping from socket ${user.username || 'No User'}`) + socket.emit('pong') + }) }) }) } From 6d8720b404722ba328dfe5de95d43061dc1dffdb Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 29 Nov 2024 04:28:50 +0200 Subject: [PATCH 02/41] Subfolder support for OIDC auth --- client/pages/config/authentication.vue | 38 +++++- client/strings/en-us.json | 2 + server/Auth.js | 8 +- server/controllers/MiscController.js | 4 +- server/migrations/changelog.md | 13 +- ....3-use-subfolder-for-oidc-redirect-uris.js | 84 +++++++++++++ server/objects/settings/ServerSettings.js | 6 +- ...e-subfolder-for-oidc-redirect-uris.test.js | 116 ++++++++++++++++++ 8 files changed, 257 insertions(+), 14 deletions(-) create mode 100644 server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js create mode 100644 test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index 1f934c88e..ba4df4c30 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -64,6 +64,20 @@

+

+
+ +
+
+

{{ $strings.LabelWebRedirectURLsDescription }}

+

+ {{ webCallbackURL }} +
+ {{ mobileAppCallbackURL }} +

+
+
+
@@ -164,6 +178,27 @@ export default { value: 'username' } ] + }, + subfolderOptions() { + const options = [ + { + text: 'None', + value: '' + } + ] + if (this.$config.routerBasePath) { + options.push({ + text: this.$config.routerBasePath, + value: this.$config.routerBasePath + }) + } + return options + }, + webCallbackURL() { + return `https://${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback` + }, + mobileAppCallbackURL() { + return `https://${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect` } }, methods: { @@ -325,7 +360,8 @@ export default { }, init() { this.newAuthSettings = { - ...this.authSettings + ...this.authSettings, + authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs } this.enableLocalAuth = this.authMethods.includes('local') this.enableOpenIDAuth = this.authMethods.includes('openid') diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 0c077ed67..8a91686c1 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "View player settings", "LabelViewQueue": "View player queue", "LabelVolume": "Volume", + "LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs", + "LabelWebRedirectURLsDescription": "Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:", "LabelWeekdaysToRun": "Weekdays to run", "LabelXBooks": "{0} books", "LabelXItems": "{0} items", diff --git a/server/Auth.js b/server/Auth.js index b0046799b..74b767f5b 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -131,7 +131,7 @@ class Auth { { client: openIdClient, params: { - redirect_uri: '/auth/openid/callback', + redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, scope: 'openid profile email' } }, @@ -480,9 +480,9 @@ class Auth { // for the request to mobile-redirect and as such the session is not shared this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri }) - redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString() + redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString() } else { - redirectUri = new URL('/auth/openid/callback', hostUrl).toString() + redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString() if (req.query.state) { Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`) @@ -733,7 +733,7 @@ class Auth { const host = req.get('host') // TODO: ABS does currently not support subfolders for installation // If we want to support it we need to include a config for the serverurl - postLogoutRedirectUri = `${protocol}://${host}/login` + postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login` } // else for openid-mobile we keep postLogoutRedirectUri on null // nice would be to redirect to the app here, but for example Authentik does not implement diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index cf901bea0..2a87f2fef 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -679,9 +679,9 @@ class MiscController { continue } let updatedValue = settingsUpdate[key] - if (updatedValue === '') updatedValue = null + if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null let currentValue = currentAuthenticationSettings[key] - if (currentValue === '') currentValue = null + if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null if (updatedValue !== currentValue) { Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 8960ade2f..8ba4fad00 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -2,9 +2,10 @@ Please add a record of every database migration that you create to this file. This will help us keep track of changes to the database schema over time. -| Server Version | Migration Script Name | Description | -| -------------- | ---------------------------- | ------------------------------------------------------------------------------------ | -| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | -| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | -| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | -| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | +| Server Version | Migration Script Name | Description | +| -------------- | -------------------------------------------- | ------------------------------------------------------------------------------------ | +| v2.15.0 | v2.15.0-series-column-unique | Series must have unique names in the same library | +| v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | +| v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | +| v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | +| v2.17.3 | v2.17.3-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | diff --git a/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js b/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js new file mode 100644 index 000000000..d03783cdd --- /dev/null +++ b/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js @@ -0,0 +1,84 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +/** + * This upward migration adds an subfolder setting for OIDC redirect URIs. + * It updates existing OIDC setups to set this option to None (empty subfolder), so they continue to work as before. + * IF OIDC is not enabled, no action is taken (i.e. the subfolder is left undefined), + * so that future OIDC setups will use the default subfolder. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris') + + const serverSettings = await getServerSettings(queryInterface, logger) + if (serverSettings.authActiveAuthMethods?.includes('openid')) { + logger.info('[2.17.3 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings') + serverSettings.authOpenIDSubfolderForRedirectURLs = '' + await updateServerSettings(queryInterface, logger, serverSettings) + } else { + logger.info('[2.17.3 migration] OIDC is not enabled, no action required') + } + + logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris') +} + +/** + * This downward migration script removes the subfolder setting for OIDC redirect URIs. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ') + + // Remove the OIDC subfolder option from the server settings + const serverSettings = await getServerSettings(queryInterface, logger) + if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) { + logger.info('[2.17.3 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings') + delete serverSettings.authOpenIDSubfolderForRedirectURLs + await updateServerSettings(queryInterface, logger, serverSettings) + } else { + logger.info('[2.17.3 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required') + } + + logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ') +} + +async function getServerSettings(queryInterface, logger) { + const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";') + if (!result[0].length) { + logger.error('[2.17.3 migration] Server settings not found') + throw new Error('Server settings not found') + } + + let serverSettings = null + try { + serverSettings = JSON.parse(result[0][0].value) + } catch (error) { + logger.error('[2.17.3 migration] Error parsing server settings:', error) + throw error + } + + return serverSettings +} + +async function updateServerSettings(queryInterface, logger, serverSettings) { + await queryInterface.sequelize.query('UPDATE settings SET value = :value WHERE key = "server-settings";', { + replacements: { + value: JSON.stringify(serverSettings) + } + }) +} + +module.exports = { up, down } diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 8ecb8ff05..ff28027f5 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -78,6 +78,7 @@ class ServerSettings { this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] this.authOpenIDGroupClaim = '' this.authOpenIDAdvancedPermsClaim = '' + this.authOpenIDSubfolderForRedirectURLs = undefined if (settings) { this.construct(settings) @@ -139,6 +140,7 @@ class ServerSettings { this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth'] this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || '' this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || '' + this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] @@ -240,7 +242,8 @@ class ServerSettings { authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client - authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim // Do not return to client + authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client + authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs } } @@ -286,6 +289,7 @@ class ServerSettings { authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client + authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs, authOpenIDSamplePermissions: User.getSampleAbsPermissions() } diff --git a/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js b/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js new file mode 100644 index 000000000..157b1ed41 --- /dev/null +++ b/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js @@ -0,0 +1,116 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { up, down } = require('../../../server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris') +const { Sequelize } = require('sequelize') +const Logger = require('../../../server/Logger') + +describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { + let queryInterface, logger, context + + beforeEach(() => { + queryInterface = { + sequelize: { + query: sinon.stub() + } + } + logger = { + info: sinon.stub(), + error: sinon.stub() + } + context = { queryInterface, logger } + }) + + describe('up', () => { + it('should add authOpenIDSubfolderForRedirectURLs if OIDC is enabled', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authActiveAuthMethods: ['openid'] }) }]]) + queryInterface.sequelize.query.onSecondCall().resolves() + + await up({ context }) + + expect(logger.info.calledWith('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')).to.be.true + expect(queryInterface.sequelize.query.calledTwice).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect( + queryInterface.sequelize.query.calledWith('UPDATE settings SET value = :value WHERE key = "server-settings";', { + replacements: { + value: JSON.stringify({ authActiveAuthMethods: ['openid'], authOpenIDSubfolderForRedirectURLs: '' }) + } + }) + ).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + }) + + it('should not add authOpenIDSubfolderForRedirectURLs if OIDC is not enabled', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authActiveAuthMethods: [] }) }]]) + + await up({ context }) + + expect(logger.info.calledWith('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] OIDC is not enabled, no action required')).to.be.true + expect(queryInterface.sequelize.query.calledOnce).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + }) + + it('should throw an error if server settings cannot be parsed', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: 'invalid json' }]]) + + try { + await up({ context }) + } catch (error) { + expect(queryInterface.sequelize.query.calledOnce).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect(logger.error.calledWith('[2.17.3 migration] Error parsing server settings:')).to.be.true + expect(error).to.be.instanceOf(Error) + } + }) + + it('should throw an error if server settings are not found', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[]]) + + try { + await up({ context }) + } catch (error) { + expect(queryInterface.sequelize.query.calledOnce).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect(logger.error.calledWith('[2.17.3 migration] Server settings not found')).to.be.true + expect(error).to.be.instanceOf(Error) + } + }) + }) + + describe('down', () => { + it('should remove authOpenIDSubfolderForRedirectURLs if it exists', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({ authOpenIDSubfolderForRedirectURLs: '' }) }]]) + queryInterface.sequelize.query.onSecondCall().resolves() + + await down({ context }) + + expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')).to.be.true + expect(queryInterface.sequelize.query.calledTwice).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect( + queryInterface.sequelize.query.calledWith('UPDATE settings SET value = :value WHERE key = "server-settings";', { + replacements: { + value: JSON.stringify({}) + } + }) + ).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + }) + + it('should not remove authOpenIDSubfolderForRedirectURLs if it does not exist', async () => { + queryInterface.sequelize.query.onFirstCall().resolves([[{ value: JSON.stringify({}) }]]) + + await down({ context }) + + expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')).to.be.true + expect(queryInterface.sequelize.query.calledOnce).to.be.true + expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true + expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + }) + }) +}) From 8c3ba675836c4e5bc916dfe7d60249b02a842468 Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 29 Nov 2024 05:48:04 +0200 Subject: [PATCH 03/41] Fix label order --- client/strings/en-us.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 8a91686c1..75069cd33 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -679,8 +679,8 @@ "LabelViewPlayerSettings": "View player settings", "LabelViewQueue": "View player queue", "LabelVolume": "Volume", - "LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs", "LabelWebRedirectURLsDescription": "Authorize these URLs in your OAuth provider to allow redirection back to the web app after login:", + "LabelWebRedirectURLsSubfolder": "Subfolder for Redirect URLs", "LabelWeekdaysToRun": "Weekdays to run", "LabelXBooks": "{0} books", "LabelXItems": "{0} items", From 9917f2d358c803665cc1bb5750f3f64a1b89577b Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 29 Nov 2024 09:01:03 +0200 Subject: [PATCH 04/41] Change migration to v2.17.4 --- server/migrations/changelog.md | 2 +- ...4-use-subfolder-for-oidc-redirect-uris.js} | 20 ++++++------ ...-subfolder-for-oidc-redirect-uris.test.js} | 32 +++++++++---------- 3 files changed, 27 insertions(+), 27 deletions(-) rename server/migrations/{v2.17.3-use-subfolder-for-oidc-redirect-uris.js => v2.17.4-use-subfolder-for-oidc-redirect-uris.js} (82%) rename test/server/migrations/{v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js => v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js} (73%) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 8ba4fad00..67c09d53c 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -8,4 +8,4 @@ Please add a record of every database migration that you create to this file. Th | v2.15.1 | v2.15.1-reindex-nocase | Fix potential db corruption issues due to bad sqlite extension introduced in v2.12.0 | | v2.15.2 | v2.15.2-index-creation | Creates author, series, and podcast episode indexes | | v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | -| v2.17.3 | v2.17.3-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | +| v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | diff --git a/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js b/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js similarity index 82% rename from server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js rename to server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js index d03783cdd..03797e35e 100644 --- a/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.js +++ b/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.js @@ -18,18 +18,18 @@ */ async function up({ context: { queryInterface, logger } }) { // Upwards migration script - logger.info('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris') + logger.info('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris') const serverSettings = await getServerSettings(queryInterface, logger) if (serverSettings.authActiveAuthMethods?.includes('openid')) { - logger.info('[2.17.3 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings') + logger.info('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings') serverSettings.authOpenIDSubfolderForRedirectURLs = '' await updateServerSettings(queryInterface, logger, serverSettings) } else { - logger.info('[2.17.3 migration] OIDC is not enabled, no action required') + logger.info('[2.17.4 migration] OIDC is not enabled, no action required') } - logger.info('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris') + logger.info('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris') } /** @@ -40,25 +40,25 @@ async function up({ context: { queryInterface, logger } }) { */ async function down({ context: { queryInterface, logger } }) { // Downward migration script - logger.info('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ') + logger.info('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ') // Remove the OIDC subfolder option from the server settings const serverSettings = await getServerSettings(queryInterface, logger) if (serverSettings.authOpenIDSubfolderForRedirectURLs !== undefined) { - logger.info('[2.17.3 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings') + logger.info('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings') delete serverSettings.authOpenIDSubfolderForRedirectURLs await updateServerSettings(queryInterface, logger, serverSettings) } else { - logger.info('[2.17.3 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required') + logger.info('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required') } - logger.info('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ') + logger.info('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ') } async function getServerSettings(queryInterface, logger) { const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";') if (!result[0].length) { - logger.error('[2.17.3 migration] Server settings not found') + logger.error('[2.17.4 migration] Server settings not found') throw new Error('Server settings not found') } @@ -66,7 +66,7 @@ async function getServerSettings(queryInterface, logger) { try { serverSettings = JSON.parse(result[0][0].value) } catch (error) { - logger.error('[2.17.3 migration] Error parsing server settings:', error) + logger.error('[2.17.4 migration] Error parsing server settings:', error) throw error } diff --git a/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js b/test/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js similarity index 73% rename from test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js rename to test/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js index 157b1ed41..1662d5f98 100644 --- a/test/server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris.test.js +++ b/test/server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris.test.js @@ -1,10 +1,10 @@ const { expect } = require('chai') const sinon = require('sinon') -const { up, down } = require('../../../server/migrations/v2.17.3-use-subfolder-for-oidc-redirect-uris') +const { up, down } = require('../../../server/migrations/v2.17.4-use-subfolder-for-oidc-redirect-uris') const { Sequelize } = require('sequelize') const Logger = require('../../../server/Logger') -describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { +describe('Migration v2.17.4-use-subfolder-for-oidc-redirect-uris', () => { let queryInterface, logger, context beforeEach(() => { @@ -27,8 +27,8 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { await up({ context }) - expect(logger.info.calledWith('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] OIDC is enabled, adding authOpenIDSubfolderForRedirectURLs to server settings')).to.be.true expect(queryInterface.sequelize.query.calledTwice).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true expect( @@ -38,7 +38,7 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { } }) ).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true }) it('should not add authOpenIDSubfolderForRedirectURLs if OIDC is not enabled', async () => { @@ -46,11 +46,11 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { await up({ context }) - expect(logger.info.calledWith('[2.17.3 migration] UPGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] OIDC is not enabled, no action required')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] UPGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] OIDC is not enabled, no action required')).to.be.true expect(queryInterface.sequelize.query.calledOnce).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] UPGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] UPGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris')).to.be.true }) it('should throw an error if server settings cannot be parsed', async () => { @@ -61,7 +61,7 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { } catch (error) { expect(queryInterface.sequelize.query.calledOnce).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true - expect(logger.error.calledWith('[2.17.3 migration] Error parsing server settings:')).to.be.true + expect(logger.error.calledWith('[2.17.4 migration] Error parsing server settings:')).to.be.true expect(error).to.be.instanceOf(Error) } }) @@ -74,7 +74,7 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { } catch (error) { expect(queryInterface.sequelize.query.calledOnce).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true - expect(logger.error.calledWith('[2.17.3 migration] Server settings not found')).to.be.true + expect(logger.error.calledWith('[2.17.4 migration] Server settings not found')).to.be.true expect(error).to.be.instanceOf(Error) } }) @@ -87,8 +87,8 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { await down({ context }) - expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] Removing authOpenIDSubfolderForRedirectURLs from server settings')).to.be.true expect(queryInterface.sequelize.query.calledTwice).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true expect( @@ -98,7 +98,7 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { } }) ).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true }) it('should not remove authOpenIDSubfolderForRedirectURLs if it does not exist', async () => { @@ -106,11 +106,11 @@ describe('Migration v2.17.3-use-subfolder-for-oidc-redirect-uris', () => { await down({ context }) - expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE BEGIN: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE BEGIN: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] authOpenIDSubfolderForRedirectURLs not found in server settings, no action required')).to.be.true expect(queryInterface.sequelize.query.calledOnce).to.be.true expect(queryInterface.sequelize.query.calledWith('SELECT value FROM settings WHERE key = "server-settings";')).to.be.true - expect(logger.info.calledWith('[2.17.3 migration] DOWNGRADE END: 2.17.3-use-subfolder-for-oidc-redirect-uris ')).to.be.true + expect(logger.info.calledWith('[2.17.4 migration] DOWNGRADE END: 2.17.4-use-subfolder-for-oidc-redirect-uris ')).to.be.true }) }) }) From a03146e09c7ba04311a0e8e14765809cce151630 Mon Sep 17 00:00:00 2001 From: Techwolf Date: Sun, 1 Dec 2024 18:10:44 -0800 Subject: [PATCH 05/41] Support additional disc folder names --- server/utils/scandir.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index ff21e814f..27cfe003a 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -96,7 +96,7 @@ function groupFilesIntoLibraryItemPaths(mediaType, paths) { // This is the last directory, create group itemGroup[_path] = [Path.basename(path)] return - } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { + } else if (dirparts.length === 1 && /^(cd|dis[ck])\s*\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] return From cc89db059bdd8ed3595f4846a78d5f843f2cdefa Mon Sep 17 00:00:00 2001 From: Techwolf Date: Sun, 1 Dec 2024 18:41:38 -0800 Subject: [PATCH 06/41] Fix second instance of regex --- server/utils/scandir.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 27cfe003a..028a1022d 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -179,7 +179,7 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly // This is the last directory, create group libraryItemGroup[_path] = [item.name] return - } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { + } else if (dirparts.length === 1 && /^(cd|dis[ck])\s*\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)] return From 605bd73c11aa2b79552a1da26f6c29ff904b899a Mon Sep 17 00:00:00 2001 From: Techwolf Date: Sun, 1 Dec 2024 23:57:47 -0800 Subject: [PATCH 07/41] Fix third instance of regex --- server/scanner/AudioFileScanner.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/scanner/AudioFileScanner.js b/server/scanner/AudioFileScanner.js index 3c364c106..6c808aaa1 100644 --- a/server/scanner/AudioFileScanner.js +++ b/server/scanner/AudioFileScanner.js @@ -133,8 +133,8 @@ class AudioFileScanner { // Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3 const pathdir = Path.dirname(path).split('/').pop() - if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) { - const discFromFolder = Number(pathdir.replace(/cd/i, '')) + if (pathdir && /^(cd|dis[ck])\s*\d{1,3}$/i.test(pathdir)) { + const discFromFolder = Number(pathdir.replace(/^(cd|dis[ck])\s*/i, '')) if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder } From 84803cef82226ca3382dc9a76cc5a42292720c76 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 2 Dec 2024 17:23:25 -0600 Subject: [PATCH 08/41] Fix:Load year in review stats for playback sessions with null mediaMetadata --- server/utils/queries/adminStats.js | 57 +++++++++++++++++------------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/server/utils/queries/adminStats.js b/server/utils/queries/adminStats.js index 0c490de42..9d7f572a3 100644 --- a/server/utils/queries/adminStats.js +++ b/server/utils/queries/adminStats.js @@ -5,7 +5,7 @@ const fsExtra = require('../../libs/fsExtra') module.exports = { /** - * + * * @param {number} year YYYY * @returns {Promise} */ @@ -22,7 +22,7 @@ module.exports = { }, /** - * + * * @param {number} year YYYY * @returns {Promise} */ @@ -39,7 +39,7 @@ module.exports = { }, /** - * + * * @param {number} year YYYY * @returns {Promise} */ @@ -63,7 +63,7 @@ module.exports = { }, /** - * + * * @param {number} year YYYY */ async getStatsForYear(year) { @@ -75,7 +75,7 @@ module.exports = { for (const book of booksAdded) { // Grab first 25 that have a cover - if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(book.coverPath)) { + if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && (await fsExtra.pathExists(book.coverPath))) { booksWithCovers.push(book.libraryItem.id) } if (book.duration && !isNaN(book.duration)) { @@ -95,45 +95,54 @@ module.exports = { const listeningSessions = await this.getListeningSessionsForYear(year) let totalListeningTime = 0 for (const ls of listeningSessions) { - totalListeningTime += (ls.timeListening || 0) + totalListeningTime += ls.timeListening || 0 - const authors = ls.mediaMetadata.authors || [] + const authors = ls.mediaMetadata?.authors || [] authors.forEach((au) => { if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0 - authorListeningMap[au.name] += (ls.timeListening || 0) + authorListeningMap[au.name] += ls.timeListening || 0 }) - const narrators = ls.mediaMetadata.narrators || [] + const narrators = ls.mediaMetadata?.narrators || [] narrators.forEach((narrator) => { if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0 - narratorListeningMap[narrator] += (ls.timeListening || 0) + narratorListeningMap[narrator] += ls.timeListening || 0 }) // Filter out bad genres like "audiobook" and "audio book" - const genres = (ls.mediaMetadata.genres || []).filter(g => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) + const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) genres.forEach((genre) => { if (!genreListeningMap[genre]) genreListeningMap[genre] = 0 - genreListeningMap[genre] += (ls.timeListening || 0) + genreListeningMap[genre] += ls.timeListening || 0 }) } let topAuthors = null - topAuthors = Object.keys(authorListeningMap).map(authorName => ({ - name: authorName, - time: Math.round(authorListeningMap[authorName]) - })).sort((a, b) => b.time - a.time).slice(0, 3) + topAuthors = Object.keys(authorListeningMap) + .map((authorName) => ({ + name: authorName, + time: Math.round(authorListeningMap[authorName]) + })) + .sort((a, b) => b.time - a.time) + .slice(0, 3) let topNarrators = null - topNarrators = Object.keys(narratorListeningMap).map(narratorName => ({ - name: narratorName, - time: Math.round(narratorListeningMap[narratorName]) - })).sort((a, b) => b.time - a.time).slice(0, 3) + topNarrators = Object.keys(narratorListeningMap) + .map((narratorName) => ({ + name: narratorName, + time: Math.round(narratorListeningMap[narratorName]) + })) + .sort((a, b) => b.time - a.time) + .slice(0, 3) let topGenres = null - topGenres = Object.keys(genreListeningMap).map(genre => ({ - genre, - time: Math.round(genreListeningMap[genre]) - })).sort((a, b) => b.time - a.time).slice(0, 3) + topGenres = Object.keys(genreListeningMap) + .map((genre) => ({ + genre, + time: Math.round(genreListeningMap[genre]) + })) + .sort((a, b) => b.time - a.time) + .slice(0, 3) // Stats for total books, size and duration for everything added this year or earlier const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, { From 615ed26f0ffb8b2af9517d08a3e57208db99f243 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 2 Dec 2024 17:35:35 -0600 Subject: [PATCH 09/41] Update:Users table show count next to header --- client/components/tables/UsersTable.vue | 1 + client/pages/config/users/index.vue | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/client/components/tables/UsersTable.vue b/client/components/tables/UsersTable.vue index 92fa684ef..09db33418 100644 --- a/client/components/tables/UsersTable.vue +++ b/client/components/tables/UsersTable.vue @@ -120,6 +120,7 @@ export default { this.users = res.users.sort((a, b) => { return a.createdAt - b.createdAt }) + this.$emit('numUsers', this.users.length) }) .catch((error) => { console.error('Failed', error) diff --git a/client/pages/config/users/index.vue b/client/pages/config/users/index.vue index 4dd825910..184529cbe 100644 --- a/client/pages/config/users/index.vue +++ b/client/pages/config/users/index.vue @@ -2,6 +2,10 @@
- +
@@ -29,7 +33,8 @@ export default { data() { return { selectedAccount: null, - showAccountModal: false + showAccountModal: false, + numUsers: 0 } }, computed: {}, From 0f1b64b883479401d09ad3f45c76a976e1af4211 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 3 Dec 2024 17:21:57 -0600 Subject: [PATCH 10/41] Add test for grouping book library items --- test/server/utils/scandir.test.js | 52 +++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 test/server/utils/scandir.test.js diff --git a/test/server/utils/scandir.test.js b/test/server/utils/scandir.test.js new file mode 100644 index 000000000..a5ff6ae0e --- /dev/null +++ b/test/server/utils/scandir.test.js @@ -0,0 +1,52 @@ +const Path = require('path') +const chai = require('chai') +const expect = chai.expect +const scanUtils = require('../../../server/utils/scandir') + +describe('scanUtils', async () => { + it('should properly group files into potential book library items', async () => { + global.isWin = process.platform === 'win32' + global.ServerSettings = { + scannerParseSubtitle: true + } + + const filePaths = [ + 'randomfile.txt', // Should be ignored because it's not a book media file + 'Book1.m4b', // Root single file audiobook + 'Book2/audiofile.m4b', + 'Book2/disk 001/audiofile.m4b', + 'Book2/disk 002/audiofile.m4b', + 'Author/Book3/audiofile.mp3', + 'Author/Book3/Disc 1/audiofile.mp3', + 'Author/Book3/Disc 2/audiofile.mp3', + 'Author/Series/Book4/cover.jpg', + 'Author/Series/Book4/CD1/audiofile.mp3', + 'Author/Series/Book4/CD2/audiofile.mp3', + 'Author/Series2/Book5/deeply/nested/cd 01/audiofile.mp3', + 'Author/Series2/Book5/deeply/nested/cd 02/audiofile.mp3', + 'Author/Series2/Book5/randomfile.js' // Should be ignored because it's not a book media file + ] + + // Create fileItems to match the format of fileUtils.recurseFiles + const fileItems = [] + for (const filePath of filePaths) { + const dirname = Path.dirname(filePath) + fileItems.push({ + name: Path.basename(filePath), + reldirpath: dirname === '.' ? '' : dirname, + extension: Path.extname(filePath), + deep: filePath.split('/').length - 1 + }) + } + + const libraryItemGrouping = scanUtils.groupFileItemsIntoLibraryItemDirs('book', fileItems, false) + + expect(libraryItemGrouping).to.deep.equal({ + 'Book1.m4b': 'Book1.m4b', + Book2: ['audiofile.m4b', 'disk 001/audiofile.m4b', 'disk 002/audiofile.m4b'], + 'Author/Book3': ['audiofile.mp3', 'Disc 1/audiofile.mp3', 'Disc 2/audiofile.mp3'], + 'Author/Series/Book4': ['CD1/audiofile.mp3', 'CD2/audiofile.mp3', 'cover.jpg'], + 'Author/Series2/Book5/deeply/nested': ['cd 01/audiofile.mp3', 'cd 02/audiofile.mp3'] + }) + }) +}) From 344890fb45e9cbf9c3421b97007dc99e6c5b24c0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 4 Dec 2024 16:25:17 -0600 Subject: [PATCH 11/41] Update watcher files changed function to use the same grouping function as other scans --- server/scanner/LibraryScanner.js | 4 +- server/utils/fileUtils.js | 33 +++++++++- server/utils/scandir.js | 101 ------------------------------- 3 files changed, 33 insertions(+), 105 deletions(-) diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index bd0bb310f..a52350f65 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -424,8 +424,8 @@ class LibraryScanner { } const folder = library.libraryFolders[0] - const relFilePaths = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUpdate.relPath) - const fileUpdateGroup = scanUtils.groupFilesIntoLibraryItemPaths(library.mediaType, relFilePaths) + const filePathItems = folderGroups[folderId].fileUpdates.map((fileUpdate) => fileUtils.getFilePathItemFromFileUpdate(fileUpdate)) + const fileUpdateGroup = scanUtils.groupFileItemsIntoLibraryItemDirs(library.mediaType, filePathItems, !!library.settings?.audiobooksOnly) if (!Object.keys(fileUpdateGroup).length) { Logger.info(`[LibraryScanner] No important changes to scan for in folder "${folderId}"`) diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index b0c73d6c6..8b87d3a09 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -131,11 +131,21 @@ async function readTextFile(path) { } module.exports.readTextFile = readTextFile +/** + * @typedef FilePathItem + * @property {string} name - file name e.g. "audiofile.m4b" + * @property {string} path - fullpath excluding folder e.g. "Author/Book/audiofile.m4b" + * @property {string} reldirpath - path excluding file name e.g. "Author/Book" + * @property {string} fullpath - full path e.g. "/audiobooks/Author/Book/audiofile.m4b" + * @property {string} extension - file extension e.g. ".m4b" + * @property {number} deep - depth of file in directory (0 is file in folder root) + */ + /** * Get array of files inside dir * @param {string} path * @param {string} [relPathToReplace] - * @returns {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} + * @returns {FilePathItem[]} */ async function recurseFiles(path, relPathToReplace = null) { path = filePathToPOSIX(path) @@ -213,7 +223,6 @@ async function recurseFiles(path, relPathToReplace = null) { return { name: item.name, path: item.fullname.replace(relPathToReplace, ''), - dirpath: item.path, reldirpath: isInRoot ? '' : item.path.replace(relPathToReplace, ''), fullpath: item.fullname, extension: item.extension, @@ -228,6 +237,26 @@ async function recurseFiles(path, relPathToReplace = null) { } module.exports.recurseFiles = recurseFiles +/** + * + * @param {import('../Watcher').PendingFileUpdate} fileUpdate + * @returns {FilePathItem} + */ +module.exports.getFilePathItemFromFileUpdate = (fileUpdate) => { + let relPath = fileUpdate.relPath + if (relPath.startsWith('/')) relPath = relPath.slice(1) + + const dirname = Path.dirname(relPath) + return { + name: Path.basename(relPath), + path: relPath, + reldirpath: dirname === '.' ? '' : dirname, + fullpath: fileUpdate.path, + extension: Path.extname(relPath), + deep: relPath.split('/').length - 1 + } +} + /** * Download file from web to local file system * Uses SSRF filter to prevent internal URLs diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 028a1022d..a70e09bb0 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -32,107 +32,6 @@ function checkFilepathIsAudioFile(filepath) { } module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile -/** - * TODO: Function needs to be re-done - * @param {string} mediaType - * @param {string[]} paths array of relative file paths - * @returns {Record} map of files grouped into potential libarary item dirs - */ -function groupFilesIntoLibraryItemPaths(mediaType, paths) { - // Step 1: Clean path, Remove leading "/", Filter out non-media files in root dir - var nonMediaFilePaths = [] - var pathsFiltered = paths - .map((path) => { - return path.startsWith('/') ? path.slice(1) : path - }) - .filter((path) => { - let parsedPath = Path.parse(path) - // Is not in root dir OR is a book media file - if (parsedPath.dir) { - if (!isMediaFile(mediaType, parsedPath.ext, false)) { - // Seperate out non-media files - nonMediaFilePaths.push(path) - return false - } - return true - } else if (mediaType === 'book' && isMediaFile(mediaType, parsedPath.ext, false)) { - // (book media type supports single file audiobooks/ebooks in root dir) - return true - } - return false - }) - - // Step 2: Sort by least number of directories - pathsFiltered.sort((a, b) => { - var pathsA = Path.dirname(a).split('/').length - var pathsB = Path.dirname(b).split('/').length - return pathsA - pathsB - }) - - // Step 3: Group files in dirs - var itemGroup = {} - pathsFiltered.forEach((path) => { - var dirparts = Path.dirname(path) - .split('/') - .filter((p) => !!p && p !== '.') // dirname returns . if no directory - var numparts = dirparts.length - var _path = '' - - if (!numparts) { - // Media file in root - itemGroup[path] = path - } else { - // Iterate over directories in path - for (let i = 0; i < numparts; i++) { - var dirpart = dirparts.shift() - _path = Path.posix.join(_path, dirpart) - - if (itemGroup[_path]) { - // Directory already has files, add file - var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path)) - itemGroup[_path].push(relpath) - return - } else if (!dirparts.length) { - // This is the last directory, create group - itemGroup[_path] = [Path.basename(path)] - return - } else if (dirparts.length === 1 && /^(cd|dis[ck])\s*\d{1,3}$/i.test(dirparts[0])) { - // Next directory is the last and is a CD dir, create group - itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] - return - } - } - } - }) - - // Step 4: Add in non-media files if they fit into item group - if (nonMediaFilePaths.length) { - for (const nonMediaFilePath of nonMediaFilePaths) { - const pathDir = Path.dirname(nonMediaFilePath) - const filename = Path.basename(nonMediaFilePath) - const dirparts = pathDir.split('/') - const numparts = dirparts.length - let _path = '' - - // Iterate over directories in path - for (let i = 0; i < numparts; i++) { - const dirpart = dirparts.shift() - _path = Path.posix.join(_path, dirpart) - if (itemGroup[_path]) { - // Directory is a group - const relpath = Path.posix.join(dirparts.join('/'), filename) - itemGroup[_path].push(relpath) - } else if (!dirparts.length) { - itemGroup[_path] = [filename] - } - } - } - } - - return itemGroup -} -module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths - /** * @param {string} mediaType * @param {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} fileItems (see recurseFiles) From 9774b2cfa50b235a17406e0985723d3454f31433 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 4 Dec 2024 16:30:35 -0600 Subject: [PATCH 12/41] Update JSDocs for groupFileItemsIntoLibraryItemDirs --- server/utils/scandir.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/utils/scandir.js b/server/utils/scandir.js index a70e09bb0..f59d0a5bc 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -34,7 +34,7 @@ module.exports.checkFilepathIsAudioFile = checkFilepathIsAudioFile /** * @param {string} mediaType - * @param {{name:string, path:string, dirpath:string, reldirpath:string, fullpath:string, extension:string, deep:number}[]} fileItems (see recurseFiles) + * @param {import('./fileUtils').FilePathItem[]} fileItems * @param {boolean} [audiobooksOnly=false] * @returns {Record} map of files grouped into potential libarary item dirs */ @@ -46,7 +46,9 @@ function groupFileItemsIntoLibraryItemDirs(mediaType, fileItems, audiobooksOnly // Step 2: Seperate media files and other files // - Directories without a media file will not be included + /** @type {import('./fileUtils').FilePathItem[]} */ const mediaFileItems = [] + /** @type {import('./fileUtils').FilePathItem[]} */ const otherFileItems = [] itemsFiltered.forEach((item) => { if (isMediaFile(mediaType, item.extension, audiobooksOnly)) mediaFileItems.push(item) From c35185fff722706d629cd56b806a4e2a735cd791 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 5 Dec 2024 16:15:23 -0600 Subject: [PATCH 13/41] Update prober to accept grp1 as an alternative tag to grouping #3681 --- server/utils/prober.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/prober.js b/server/utils/prober.js index b54b981d2..838899bdc 100644 --- a/server/utils/prober.js +++ b/server/utils/prober.js @@ -189,7 +189,7 @@ function parseTags(format, verbose) { file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'), file_tag_series: tryGrabTags(format, 'series', 'show', 'mvnm'), file_tag_seriespart: tryGrabTags(format, 'series-part', 'episode_id', 'mvin', 'part'), - file_tag_grouping: tryGrabTags(format, 'grouping'), + file_tag_grouping: tryGrabTags(format, 'grouping', 'grp1'), file_tag_isbn: tryGrabTags(format, 'isbn'), // custom file_tag_language: tryGrabTags(format, 'language', 'lang'), file_tag_asin: tryGrabTags(format, 'asin', 'audible_asin'), // custom From 252a233282b5001e38e5c222f02c78ede3e9adc3 Mon Sep 17 00:00:00 2001 From: Henning Date: Mon, 2 Dec 2024 10:46:18 +0000 Subject: [PATCH 14/41] Translated using Weblate (German) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index 030f8f1b3..1ea58b5b0 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -728,7 +728,7 @@ "MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis /metadata/cache löschen.

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

Möchtest du fortfahren?", - "MessageConfirmQuickMatchEpisodes": "Schnelles Zuordnen von Episoden überschreibt die Details, wenn eine Übereinstimmung gefunden wird. Nur nicht zugeordnete Episoden werden aktualisiert. Bist du sicher?", + "MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?", "MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?", "MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", From 68413ae2f62e86d8ffa946877fb6a8fede43c56b Mon Sep 17 00:00:00 2001 From: thehijacker Date: Mon, 2 Dec 2024 06:00:11 +0000 Subject: [PATCH 15/41] Translated using Weblate (Slovenian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index 02c1fb132..e80ac8b27 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -184,7 +184,7 @@ "HeaderScheduleEpisodeDownloads": "Načrtovanje samodejnega prenosa epizod", "HeaderScheduleLibraryScans": "Načrtuj samodejno pregledovanje knjižnice", "HeaderSession": "Seja", - "HeaderSetBackupSchedule": "Nastavite urnik varnostnega kopiranja", + "HeaderSetBackupSchedule": "Nastavi urnik varnostnega kopiranja", "HeaderSettings": "Nastavitve", "HeaderSettingsDisplay": "Zaslon", "HeaderSettingsExperimental": "Eksperimentalne funkcije", @@ -830,7 +830,7 @@ "MessageSearchResultsFor": "Rezultati iskanja za", "MessageSelected": "{0} izbrano", "MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči", - "MessageSetChaptersFromTracksDescription": "Nastavite poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke", + "MessageSetChaptersFromTracksDescription": "Nastavi poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke", "MessageShareExpirationWillBe": "Potečeno bo {0}", "MessageShareExpiresIn": "Poteče čez {0}", "MessageShareURLWillBe": "URL za skupno rabo bo {0}", From cbee6d8f5e74d2518ad27125314c495ec109caba Mon Sep 17 00:00:00 2001 From: Mario Date: Mon, 2 Dec 2024 12:01:08 +0000 Subject: [PATCH 16/41] Translated using Weblate (German) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 1ea58b5b0..7f78360cc 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -728,7 +728,7 @@ "MessageConfirmPurgeCache": "Cache leeren wird das ganze Verzeichnis /metadata/cache löschen.

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

Möchtest du fortfahren?", - "MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?", + "MessageConfirmQuickMatchEpisodes": "Schnellabgleich von Episoden überschreibt deren Details, wenn ein passender Eintrag gefunden wurde, wird aber nur auf bisher unbearbeitete Episoden angewendet. Wirklich fortfahren?", "MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Bist du dir sicher?", "MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", @@ -833,7 +833,7 @@ "MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird", "MessageShareExpirationWillBe": "Läuft am {0} ab", "MessageShareExpiresIn": "Läuft in {0} ab", - "MessageShareURLWillBe": "Der Freigabe Link wird {0} sein.", + "MessageShareURLWillBe": "Der Freigabe Link wird {0} sein", "MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?", "MessageTaskAudioFileNotWritable": "Die Audiodatei \"{0}\" ist schreibgeschützt", "MessageTaskCanceledByUser": "Aufgabe vom Benutzer abgebrochen", @@ -1041,7 +1041,7 @@ "ToastRenameFailed": "Umbenennen fehlgeschlagen", "ToastRescanFailed": "Erneut scannen fehlgeschlagen für {0}", "ToastRescanRemoved": "Erneut scannen erledigt, Artikel wurde entfernt", - "ToastRescanUpToDate": "Erneut scannen erledigt, Artikel wahr auf dem neusten Stand", + "ToastRescanUpToDate": "Erneut scannen erledigt, Artikel war auf dem neusten Stand", "ToastRescanUpdated": "Erneut scannen erledigt, Artikel wurde verändert", "ToastScanFailed": "Fehler beim scannen des Artikels der Bibliothek", "ToastSelectAtLeastOneUser": "Wähle mindestens einen Benutzer aus", From 658ac042685690d18f39678b4667d4b24700781a Mon Sep 17 00:00:00 2001 From: Mario Date: Tue, 3 Dec 2024 14:09:47 +0000 Subject: [PATCH 17/41] Translated using Weblate (German) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index 7f78360cc..865065aa7 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -584,7 +584,7 @@ "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet", "LabelSettingsTimeFormat": "Zeitformat", "LabelShare": "Freigeben", - "LabelShareOpen": "Freigabe", + "LabelShareOpen": "Freigeben", "LabelShareURL": "Freigabe URL", "LabelShowAll": "Alles anzeigen", "LabelShowSeconds": "Zeige Sekunden", From 079a15541c6393f727f3684b32f25dc8f7f3e729 Mon Sep 17 00:00:00 2001 From: Milo Ivir Date: Tue, 3 Dec 2024 16:35:13 +0000 Subject: [PATCH 18/41] Translated using Weblate (Croatian) Currently translated at 100.0% (1072 of 1072 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index a7f2562b7..6ed299fbb 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -532,7 +532,7 @@ "LabelSelectAllEpisodes": "Označi sve nastavke", "LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka", "LabelSelectUsers": "Označi korisnike", - "LabelSendEbookToDevice": "Pošalji e-knjigu", + "LabelSendEbookToDevice": "Pošalji e-knjigu …", "LabelSequence": "Slijed", "LabelSerial": "Serijal", "LabelSeries": "Serijal", From 67952cc57732317cb39d104053c9e829e8155ce3 Mon Sep 17 00:00:00 2001 From: gallegonovato Date: Wed, 4 Dec 2024 10:06:23 +0000 Subject: [PATCH 19/41] Translated using Weblate (Spanish) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/es.json b/client/strings/es.json index 76a62c161..87956e54b 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Ver los ajustes del reproductor", "LabelViewQueue": "Ver Fila del Reproductor", "LabelVolume": "Volumen", + "LabelWebRedirectURLsDescription": "Autorice estas URL en su proveedor OAuth para permitir la redirección a la aplicación web después de iniciar sesión:", + "LabelWebRedirectURLsSubfolder": "Subcarpeta para URL de redireccionamiento", "LabelWeekdaysToRun": "Correr en Días de la Semana", "LabelXBooks": "{0} libros", "LabelXItems": "{0} elementos", From 867354e59d12c5cfa107af1af30f08fd59b8e945 Mon Sep 17 00:00:00 2001 From: Milo Ivir Date: Wed, 4 Dec 2024 20:56:24 +0000 Subject: [PATCH 20/41] Translated using Weblate (Croatian) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index 6ed299fbb..48d9b5a0f 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -271,7 +271,7 @@ "LabelCollapseSubSeries": "Podserijale prikaži sažeto", "LabelCollection": "Zbirka", "LabelCollections": "Zbirke", - "LabelComplete": "Dovršeno", + "LabelComplete": "Potpuno", "LabelConfirmPassword": "Potvrda zaporke", "LabelContinueListening": "Nastavi slušati", "LabelContinueReading": "Nastavi čitati", @@ -567,7 +567,7 @@ "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Preostalo vrijeme je manje od (sekundi)", "LabelSettingsLibraryMarkAsFinishedWhen": "Označi medij dovršenim kada", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Preskoči ranije knjige u funkciji Nastavi serijal", - "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako uključite ovu opciju, serijal će vam se nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.", + "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Na polici početne stranice Nastavi serijal prikazuje se prva nezapočeta knjiga serijala koji imaju barem jednu dovršenu knjigu i nijednu započetu knjigu. Ako se ova opcija uključi serijal će nastaviti od zadnje dovršene knjige umjesto od prve nezapočete knjige.", "LabelSettingsParseSubtitles": "Raščlani podnaslove", "LabelSettingsParseSubtitlesHelp": "Iz naziva mape zvučne knjige raščlanjuje podnaslov.
Podnaslov mora biti odvojen s \" - \"
npr. \"Naslov knjige - Ovo je podnaslov\" imat će podnaslov \"Ovo je podnaslov\"", "LabelSettingsPreferMatchedMetadata": "Daj prednost meta-podatcima prepoznatih stavki", @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Pogledaj postavke reproduktora", "LabelViewQueue": "Pogledaj redoslijed izvođenja reproduktora", "LabelVolume": "Glasnoća", + "LabelWebRedirectURLsDescription": "Autoriziraj ove URL-ove u svom pružatelju OAuth ovjere kako bi omogućio preusmjeravanje natrag na web-aplikaciju nakon prijave:", + "LabelWebRedirectURLsSubfolder": "Podmapa za URL-ove preusmjeravanja", "LabelWeekdaysToRun": "Dani u tjednu za pokretanje", "LabelXBooks": "{0} knjiga", "LabelXItems": "{0} stavki", From f467c44543c6e1a43b688086c778e6df4abb8941 Mon Sep 17 00:00:00 2001 From: Tamanegii Date: Wed, 4 Dec 2024 06:13:20 +0000 Subject: [PATCH 21/41] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.9% (1073 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 072cbd39e..db262448d 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -663,6 +663,7 @@ "LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息", "LabelUpdatedAt": "更新时间", "LabelUploaderDragAndDrop": "拖放文件或文件夹", + "LabelUploaderDragAndDropFilesOnly": "拖放文件", "LabelUploaderDropFiles": "删除文件", "LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列", "LabelUseAdvancedOptions": "使用高级选项", @@ -678,6 +679,7 @@ "LabelViewPlayerSettings": "查看播放器设置", "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", + "LabelWebRedirectURLsDescription": "在您的OAuth提供商中授权这些链接,以允许在登录后重定向回Web应用", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", "LabelXItems": "{0} 项目", From 7334580c8c5221ea82adb01070d82d5c2367af62 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Thu, 5 Dec 2024 13:19:57 +0000 Subject: [PATCH 22/41] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index db262448d..23137053b 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -680,6 +680,7 @@ "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", "LabelWebRedirectURLsDescription": "在您的OAuth提供商中授权这些链接,以允许在登录后重定向回Web应用", + "LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", "LabelXItems": "{0} 项目", From 14f60a593b14c3473140603fc4a3eea4dd446d00 Mon Sep 17 00:00:00 2001 From: Tamanegii Date: Thu, 5 Dec 2024 13:20:37 +0000 Subject: [PATCH 23/41] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 23137053b..a277ecfd1 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -680,7 +680,7 @@ "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", "LabelWebRedirectURLsDescription": "在您的OAuth提供商中授权这些链接,以允许在登录后重定向回Web应用", - "LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹", + "LabelWebRedirectURLsSubfolder": "查了一下GPT,给的回答的{重定向URL的子文件夹},但是不知道这个位置在哪儿,没法确定这个意思是否准确", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", "LabelXItems": "{0} 项目", From 259d93d8827ad2c6dd202ecee77a09378f4006ec Mon Sep 17 00:00:00 2001 From: SunSpring Date: Thu, 5 Dec 2024 13:22:25 +0000 Subject: [PATCH 24/41] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index a277ecfd1..6eea0a603 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -679,7 +679,7 @@ "LabelViewPlayerSettings": "查看播放器设置", "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", - "LabelWebRedirectURLsDescription": "在您的OAuth提供商中授权这些链接,以允许在登录后重定向回Web应用", + "LabelWebRedirectURLsDescription": "在你的 OAuth 提供商中授权这些链接,以允许在登录后重定向回 Web 应用程序:", "LabelWebRedirectURLsSubfolder": "查了一下GPT,给的回答的{重定向URL的子文件夹},但是不知道这个位置在哪儿,没法确定这个意思是否准确", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", From 1ff79520743558569a1b8997e6588ea233c479db Mon Sep 17 00:00:00 2001 From: SunSpring Date: Thu, 5 Dec 2024 13:23:34 +0000 Subject: [PATCH 25/41] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 6eea0a603..e4791aff5 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -680,7 +680,7 @@ "LabelViewQueue": "查看播放列表", "LabelVolume": "音量", "LabelWebRedirectURLsDescription": "在你的 OAuth 提供商中授权这些链接,以允许在登录后重定向回 Web 应用程序:", - "LabelWebRedirectURLsSubfolder": "查了一下GPT,给的回答的{重定向URL的子文件夹},但是不知道这个位置在哪儿,没法确定这个意思是否准确", + "LabelWebRedirectURLsSubfolder": "重定向 URL 的子文件夹", "LabelWeekdaysToRun": "工作日运行", "LabelXBooks": "{0} 本书", "LabelXItems": "{0} 项目", From 890b0b949ee758102fd05ba26c5ed5c3ebbd747f Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 5 Dec 2024 16:50:30 -0600 Subject: [PATCH 26/41] Version bump v2.17.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 588ad79dd..e4e3236ce 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.17.3", + "version": "2.17.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.17.3", + "version": "2.17.4", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index c1a43e525..ea1919017 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.17.3", + "version": "2.17.4", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 062fb0322..10db84ea9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.17.3", + "version": "2.17.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.17.3", + "version": "2.17.4", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index db63261b1..c122240a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.17.3", + "version": "2.17.4", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 9a1c773b7a26f0974824eaa83d135caeb0ebfc58 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 6 Dec 2024 16:59:34 -0600 Subject: [PATCH 27/41] Fix:Server crash on uploadCover temp file mv failed #3685 --- server/managers/CoverManager.js | 76 +++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index 9b4aa32d0..2b3a697d7 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -12,7 +12,7 @@ const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') const CacheManager = require('../managers/CacheManager') class CoverManager { - constructor() { } + constructor() {} getCoverDirectory(libraryItem) { if (global.ServerSettings.storeCoverWithItem && !libraryItem.isFile) { @@ -93,10 +93,13 @@ class CoverManager { const coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`) // Move cover from temp upload dir to destination - const success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => { - Logger.error('[CoverManager] Failed to move cover file', path, error) - return false - }) + const success = await coverFile + .mv(coverFullPath) + .then(() => true) + .catch((error) => { + Logger.error('[CoverManager] Failed to move cover file', coverFullPath, error) + return false + }) if (!success) { return { @@ -124,11 +127,13 @@ class CoverManager { var temppath = Path.posix.join(coverDirPath, 'cover') let errorMsg = '' - let success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { - errorMsg = err.message || 'Unknown error' - Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg) - return false - }) + let success = await downloadImageFile(url, temppath) + .then(() => true) + .catch((err) => { + errorMsg = err.message || 'Unknown error' + Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg) + return false + }) if (!success) { return { error: 'Failed to download image from url: ' + errorMsg @@ -180,7 +185,7 @@ class CoverManager { } // Cover path does not exist - if (!await fs.pathExists(coverPath)) { + if (!(await fs.pathExists(coverPath))) { Logger.error(`[CoverManager] validate cover path does not exist "${coverPath}"`) return { error: 'Cover path does not exist' @@ -188,7 +193,7 @@ class CoverManager { } // Cover path is not a file - if (!await checkPathIsFile(coverPath)) { + if (!(await checkPathIsFile(coverPath))) { Logger.error(`[CoverManager] validate cover path is not a file "${coverPath}"`) return { error: 'Cover path is not a file' @@ -211,10 +216,13 @@ class CoverManager { var newCoverPath = Path.posix.join(coverDirPath, coverFilename) Logger.debug(`[CoverManager] validate cover path copy cover from "${coverPath}" to "${newCoverPath}"`) - var copySuccess = await fs.copy(coverPath, newCoverPath, { overwrite: true }).then(() => true).catch((error) => { - Logger.error(`[CoverManager] validate cover path failed to copy cover`, error) - return false - }) + var copySuccess = await fs + .copy(coverPath, newCoverPath, { overwrite: true }) + .then(() => true) + .catch((error) => { + Logger.error(`[CoverManager] validate cover path failed to copy cover`, error) + return false + }) if (!copySuccess) { return { error: 'Failed to copy cover to dir' @@ -236,14 +244,14 @@ class CoverManager { /** * Extract cover art from audio file and save for library item - * - * @param {import('../models/Book').AudioFileObject[]} audioFiles - * @param {string} libraryItemId - * @param {string} [libraryItemPath] null for isFile library items + * + * @param {import('../models/Book').AudioFileObject[]} audioFiles + * @param {string} libraryItemId + * @param {string} [libraryItemPath] null for isFile library items * @returns {Promise} returns cover path */ async saveEmbeddedCoverArt(audioFiles, libraryItemId, libraryItemPath) { - let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt) + let audioFileWithCover = audioFiles.find((af) => af.embeddedCoverArt) if (!audioFileWithCover) return null let coverDirPath = null @@ -273,10 +281,10 @@ class CoverManager { /** * Extract cover art from ebook and save for library item - * - * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData - * @param {string} libraryItemId - * @param {string} [libraryItemPath] null for isFile library items + * + * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData + * @param {string} libraryItemId + * @param {string} [libraryItemPath] null for isFile library items * @returns {Promise} returns cover path */ async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) { @@ -310,9 +318,9 @@ class CoverManager { } /** - * - * @param {string} url - * @param {string} libraryItemId + * + * @param {string} url + * @param {string} libraryItemId * @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast * @returns {Promise<{error:string}|{cover:string}>} */ @@ -328,10 +336,12 @@ class CoverManager { await fs.ensureDir(coverDirPath) const temppath = Path.posix.join(coverDirPath, 'cover') - const success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { - Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) - return false - }) + const success = await downloadImageFile(url, temppath) + .then(() => true) + .catch((err) => { + Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) + return false + }) if (!success) { return { error: 'Failed to download image from url' @@ -361,4 +371,4 @@ class CoverManager { } } } -module.exports = new CoverManager() \ No newline at end of file +module.exports = new CoverManager() From 3b4a5b8785fff8672abb76fae4325c49b7ffca26 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 6 Dec 2024 17:17:32 -0600 Subject: [PATCH 28/41] Support ALLOW_IFRAME env variable to not include frame-ancestors header #3684 --- index.js | 1 + server/Server.js | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index de1ed5c30..9a0be347c 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,7 @@ if (isDev) { if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath if (devEnv.NunicodePath) process.env.NUSQLITE3_PATH = devEnv.NunicodePath if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1' + if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1' if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath process.env.SOURCE = 'local' process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || '' diff --git a/server/Server.js b/server/Server.js index 9153ab092..cd96733e9 100644 --- a/server/Server.js +++ b/server/Server.js @@ -53,6 +53,7 @@ class Server { global.RouterBasePath = ROUTER_BASE_PATH global.XAccel = process.env.USE_X_ACCEL global.AllowCors = process.env.ALLOW_CORS === '1' + global.AllowIframe = process.env.ALLOW_IFRAME === '1' global.DisableSsrfRequestFilter = process.env.DISABLE_SSRF_REQUEST_FILTER === '1' if (!fs.pathExistsSync(global.ConfigPath)) { @@ -194,8 +195,10 @@ class Server { const app = express() app.use((req, res, next) => { - // Prevent clickjacking by disallowing iframes - res.setHeader('Content-Security-Policy', "frame-ancestors 'self'") + if (!global.AllowIframe) { + // Prevent clickjacking by disallowing iframes + res.setHeader('Content-Security-Policy', "frame-ancestors 'self'") + } /** * @temporary From 835490a9fcecf0ea608179071dad2fc5d2b17b3b Mon Sep 17 00:00:00 2001 From: Jaume Date: Sat, 7 Dec 2024 01:45:41 +0100 Subject: [PATCH 29/41] Catalan translation added new file client/strings/ca.json --- client/strings/ca.json | 1029 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1029 insertions(+) create mode 100644 client/strings/ca.json diff --git a/client/strings/ca.json b/client/strings/ca.json new file mode 100644 index 000000000..8dde850b8 --- /dev/null +++ b/client/strings/ca.json @@ -0,0 +1,1029 @@ +{ + "ButtonAdd": "Afegeix", + "ButtonAddChapters": "Afegeix", + "ButtonAddDevice": "Afegeix Dispositiu", + "ButtonAddLibrary": "Crea Biblioteca", + "ButtonAddPodcasts": "Afegeix Podcasts", + "ButtonAddUser": "Crea Usuari", + "ButtonAddYourFirstLibrary": "Crea la teva Primera Biblioteca", + "ButtonApply": "Aplica", + "ButtonApplyChapters": "Aplica Capítols", + "ButtonAuthors": "Autors", + "ButtonBack": "Enrere", + "ButtonBrowseForFolder": "Cerca Carpeta", + "ButtonCancel": "Cancel·la", + "ButtonCancelEncode": "Cancel·la Codificador", + "ButtonChangeRootPassword": "Canvia Contrasenya Root", + "ButtonCheckAndDownloadNewEpisodes": "Verifica i Descarrega Nous Episodis", + "ButtonChooseAFolder": "Tria una Carpeta", + "ButtonChooseFiles": "Tria un Fitxer", + "ButtonClearFilter": "Elimina Filtres", + "ButtonCloseFeed": "Tanca Font", + "ButtonCloseSession": "Tanca la sessió oberta", + "ButtonCollections": "Col·leccions", + "ButtonConfigureScanner": "Configura Escàner", + "ButtonCreate": "Crea", + "ButtonCreateBackup": "Crea Còpia de Seguretat", + "ButtonDelete": "Elimina", + "ButtonDownloadQueue": "Cua", + "ButtonEdit": "Edita", + "ButtonEditChapters": "Edita Capítol", + "ButtonEditPodcast": "Edita Podcast", + "ButtonEnable": "Habilita", + "ButtonFireAndFail": "Executat i fallat", + "ButtonFireOnTest": "Activa esdeveniment de prova", + "ButtonForceReScan": "Força Re-escaneig", + "ButtonFullPath": "Ruta Completa", + "ButtonHide": "Amaga", + "ButtonHome": "Inici", + "ButtonIssues": "Problemes", + "ButtonJumpBackward": "Retrocedeix", + "ButtonJumpForward": "Avança", + "ButtonLatest": "Últims", + "ButtonLibrary": "Biblioteca", + "ButtonLogout": "Tanca Sessió", + "ButtonLookup": "Cerca", + "ButtonManageTracks": "Gestiona Pistes d'Àudio", + "ButtonMapChapterTitles": "Assigna Títols als Capítols", + "ButtonMatchAllAuthors": "Troba Tots els Autors", + "ButtonMatchBooks": "Troba Llibres", + "ButtonNevermind": "Oblida-ho", + "ButtonNext": "Següent", + "ButtonNextChapter": "Següent Capítol", + "ButtonNextItemInQueue": "Següent element a la cua", + "ButtonOk": "D'acord", + "ButtonOpenFeed": "Obre Font", + "ButtonOpenManager": "Obre Editor", + "ButtonPause": "Pausa", + "ButtonPlay": "Reprodueix", + "ButtonPlayAll": "Reprodueix tot", + "ButtonPlaying": "Reproduint", + "ButtonPlaylists": "Llistes de reproducció", + "ButtonPrevious": "Anterior", + "ButtonPreviousChapter": "Capítol Anterior", + "ButtonProbeAudioFile": "Examina fitxer d'àudio", + "ButtonPurgeAllCache": "Esborra Tot el Cache", + "ButtonPurgeItemsCache": "Esborra Cache d'Elements", + "ButtonQueueAddItem": "Afegeix a la Cua", + "ButtonQueueRemoveItem": "Elimina de la Cua", + "ButtonQuickEmbed": "Inserció Ràpida", + "ButtonQuickEmbedMetadata": "Afegeix Metadades Ràpidament", + "ButtonQuickMatch": "Troba Ràpidament", + "ButtonReScan": "Re-escaneja", + "ButtonRead": "Llegeix", + "ButtonReadLess": "Llegeix menys", + "ButtonReadMore": "Llegeix més", + "ButtonRefresh": "Refresca", + "ButtonRemove": "Elimina", + "ButtonRemoveAll": "Elimina Tot", + "ButtonRemoveAllLibraryItems": "Elimina Tots els Elements de la Biblioteca", + "ButtonRemoveFromContinueListening": "Elimina de Continuar Escoltant", + "ButtonRemoveFromContinueReading": "Elimina de Continuar Llegint", + "ButtonRemoveSeriesFromContinueSeries": "Elimina Sèrie de Continuar Sèries", + "ButtonReset": "Restableix", + "ButtonResetToDefault": "Restaura Valors per Defecte", + "ButtonRestore": "Restaura", + "ButtonSave": "Desa", + "ButtonSaveAndClose": "Desa i Tanca", + "ButtonSaveTracklist": "Desa Pistes", + "ButtonScan": "Escaneja", + "ButtonScanLibrary": "Escaneja Biblioteca", + "ButtonSearch": "Cerca", + "ButtonSelectFolderPath": "Selecciona Ruta de Carpeta", + "ButtonSeries": "Sèries", + "ButtonSetChaptersFromTracks": "Selecciona Capítols Segons les Pistes", + "ButtonShare": "Comparteix", + "ButtonShiftTimes": "Desplaça Temps", + "ButtonShow": "Mostra", + "ButtonStartM4BEncode": "Inicia Codificació M4B", + "ButtonStartMetadataEmbed": "Inicia Inserció de Metadades", + "ButtonStats": "Estadístiques", + "ButtonSubmit": "Envia", + "ButtonTest": "Prova", + "ButtonUnlinkOpenId": "Desvincula OpenID", + "ButtonUpload": "Carrega", + "ButtonUploadBackup": "Carrega Còpia de Seguretat", + "ButtonUploadCover": "Carrega Portada", + "ButtonUploadOPMLFile": "Carrega Fitxer OPML", + "ButtonUserDelete": "Elimina Usuari {0}", + "ButtonUserEdit": "Edita Usuari {0}", + "ButtonViewAll": "Mostra-ho Tot", + "ButtonYes": "Sí", + "ErrorUploadFetchMetadataAPI": "Error obtenint metadades", + "ErrorUploadFetchMetadataNoResults": "No s'han pogut obtenir metadades - Intenta actualitzar el títol i/o autor", + "ErrorUploadLacksTitle": "S'ha de tenir un títol", + "HeaderAccount": "Compte", + "HeaderAddCustomMetadataProvider": "Afegeix un proveïdor de metadades personalitzat", + "HeaderAdvanced": "Avançat", + "HeaderAppriseNotificationSettings": "Configuració de Notificacions Apprise", + "HeaderAudioTracks": "Pistes d'àudio", + "HeaderAudiobookTools": "Eines de Gestió d'Arxius d'Audiollibre", + "HeaderAuthentication": "Autenticació", + "HeaderBackups": "Còpies de Seguretat", + "HeaderChangePassword": "Canvia Contrasenya", + "HeaderChapters": "Capítols", + "HeaderChooseAFolder": "Tria una Carpeta", + "HeaderCollection": "Col·lecció", + "HeaderCollectionItems": "Elements a la Col·lecció", + "HeaderCover": "Portada", + "HeaderCurrentDownloads": "Descàrregues Actuals", + "HeaderCustomMessageOnLogin": "Missatge Personalitzat a l'Iniciar Sessió", + "HeaderCustomMetadataProviders": "Proveïdors de Metadades Personalitzats", + "HeaderDetails": "Detalls", + "HeaderDownloadQueue": "Cua de Descàrregues", + "HeaderEbookFiles": "Fitxers de Llibres Digitals", + "HeaderEmail": "Correu electrònic", + "HeaderEmailSettings": "Configuració de Correu Electrònic", + "HeaderEpisodes": "Episodis", + "HeaderEreaderDevices": "Dispositius Ereader", + "HeaderEreaderSettings": "Configuració del Lector", + "HeaderFiles": "Element", + "HeaderFindChapters": "Cerca Capítol", + "HeaderIgnoredFiles": "Ignora Element", + "HeaderItemFiles": "Carpetes d'Elements", + "HeaderItemMetadataUtils": "Utilitats de Metadades d'Elements", + "HeaderLastListeningSession": "Últimes Sessions", + "HeaderLatestEpisodes": "Últims Episodis", + "HeaderLibraries": "Biblioteques", + "HeaderLibraryFiles": "Fitxers de Biblioteca", + "HeaderLibraryStats": "Estadístiques de Biblioteca", + "HeaderListeningSessions": "Sessió", + "HeaderListeningStats": "Estadístiques de Temps Escoltat", + "HeaderLogin": "Inicia Sessió", + "HeaderLogs": "Registres", + "HeaderManageGenres": "Gestiona Gèneres", + "HeaderManageTags": "Gestiona Etiquetes", + "HeaderMapDetails": "Assigna Detalls", + "HeaderMatch": "Troba", + "HeaderMetadataOrderOfPrecedence": "Ordre de Precedència de Metadades", + "HeaderMetadataToEmbed": "Metadades a Inserir", + "HeaderNewAccount": "Nou Compte", + "HeaderNewLibrary": "Nova Biblioteca", + "HeaderNotificationCreate": "Crea Notificació", + "HeaderNotificationUpdate": "Actualització de Notificació", + "HeaderNotifications": "Notificacions", + "HeaderOpenIDConnectAuthentication": "Autenticació OpenID Connect", + "HeaderOpenListeningSessions": "Sessions públiques d'escolta", + "HeaderOpenRSSFeed": "Obre Font RSS", + "HeaderOtherFiles": "Altres Fitxers", + "HeaderPasswordAuthentication": "Autenticació per Contrasenya", + "HeaderPermissions": "Permisos", + "HeaderPlayerQueue": "Cua del Reproductor", + "HeaderPlayerSettings": "Configuració del Reproductor", + "HeaderPlaylist": "Llista de Reproducció", + "HeaderPlaylistItems": "Elements de la Llista de Reproducció", + "HeaderPodcastsToAdd": "Podcasts a afegir", + "HeaderPreviewCover": "Previsualització de la Portada", + "HeaderRSSFeedGeneral": "Detalls RSS", + "HeaderRSSFeedIsOpen": "La Font RSS està oberta", + "HeaderRSSFeeds": "Fonts RSS", + "HeaderRemoveEpisode": "Elimina Episodi", + "HeaderRemoveEpisodes": "Elimina {0} Episodis", + "HeaderSavedMediaProgress": "Desa el Progrés del Multimèdia", + "HeaderSchedule": "Horari", + "HeaderScheduleEpisodeDownloads": "Programa Descàrregues Automàtiques d'Episodis", + "HeaderScheduleLibraryScans": "Programa Escaneig Automàtic de Biblioteca", + "HeaderSession": "Sessió", + "HeaderSetBackupSchedule": "Programa Còpies de Seguretat", + "HeaderSettings": "Configuració", + "HeaderSettingsDisplay": "Interfície", + "HeaderSettingsExperimental": "Funcions Experimentals", + "HeaderSettingsGeneral": "General", + "HeaderSettingsScanner": "Escàner", + "HeaderSleepTimer": "Temporitzador de son", + "HeaderStatsLargestItems": "Elements més Grans", + "HeaderStatsLongestItems": "Elements més Llargs (h)", + "HeaderStatsMinutesListeningChart": "Minuts Escoltant (Últims 7 dies)", + "HeaderStatsRecentSessions": "Sessions Recents", + "HeaderStatsTop10Authors": "Top 10 Autors", + "HeaderStatsTop5Genres": "Top 5 Gèneres", + "HeaderTableOfContents": "Taula de Continguts", + "HeaderTools": "Eines", + "HeaderUpdateAccount": "Actualitza Compte", + "HeaderUpdateAuthor": "Actualitza Autor", + "HeaderUpdateDetails": "Actualitza Detalls", + "HeaderUpdateLibrary": "Actualitza Biblioteca", + "HeaderUsers": "Usuaris", + "HeaderYearReview": "Revisió de l'Any {0}", + "HeaderYourStats": "Les teves Estadístiques", + "LabelAbridged": "Resumit", + "LabelAbridgedChecked": "Resumit (comprovat)", + "LabelAbridgedUnchecked": "Sense resumir (no comprovat)", + "LabelAccessibleBy": "Accessible per", + "LabelAccountType": "Tipus de Compte", + "LabelAccountTypeAdmin": "Administrador", + "LabelAccountTypeGuest": "Convidat", + "LabelAccountTypeUser": "Usuari", + "LabelActivity": "Activitat", + "LabelAddToCollection": "Afegit a la Col·lecció", + "LabelAddToCollectionBatch": "S'han Afegit {0} Llibres a la Col·lecció", + "LabelAddToPlaylist": "Afegit a la llista de reproducció", + "LabelAddToPlaylistBatch": "S'han Afegit {0} Elements a la Llista de Reproducció", + "LabelAddedAt": "Afegit", + "LabelAddedDate": "{0} Afegit", + "LabelAdminUsersOnly": "Només usuaris administradors", + "LabelAll": "Tots", + "LabelAllUsers": "Tots els Usuaris", + "LabelAllUsersExcludingGuests": "Tots els usuaris excepte convidats", + "LabelAllUsersIncludingGuests": "Tots els usuaris i convidats", + "LabelAlreadyInYourLibrary": "Ja existeix a la Biblioteca", + "LabelApiToken": "Token de l'API", + "LabelAppend": "Adjuntar", + "LabelAudioBitrate": "Taxa de bits d'àudio (per exemple, 128k)", + "LabelAudioChannels": "Canals d'àudio (1 o 2)", + "LabelAudioCodec": "Còdec d'àudio", + "LabelAuthor": "Autor", + "LabelAuthorFirstLast": "Autor (Nom Cognom)", + "LabelAuthorLastFirst": "Autor (Cognom, Nom)", + "LabelAuthors": "Autors", + "LabelAutoDownloadEpisodes": "Descarregar episodis automàticament", + "LabelAutoFetchMetadata": "Actualitzar Metadades Automàticament", + "LabelAutoFetchMetadataHelp": "Obtén metadades de títol, autor i sèrie per agilitzar la càrrega. És possible que calgui revisar metadades addicionals després de la càrrega.", + "LabelAutoLaunch": "Inici automàtic", + "LabelAutoLaunchDescription": "Redirigir automàticament al proveïdor d'autenticació quan s'accedeix a la pàgina d'inici de sessió (ruta d'excepció manual /login?autoLaunch=0)", + "LabelAutoRegister": "Registre automàtic", + "LabelAutoRegisterDescription": "Crear usuaris automàticament en iniciar sessió", + "LabelBackToUser": "Torna a Usuari", + "LabelBackupAudioFiles": "Còpia de seguretat d'arxius d'àudio", + "LabelBackupLocation": "Ubicació de la Còpia de Seguretat", + "LabelBackupsEnableAutomaticBackups": "Habilitar Còpies de Seguretat Automàtiques", + "LabelBackupsEnableAutomaticBackupsHelp": "Còpies de seguretat desades a /metadata/backups", + "LabelBackupsMaxBackupSize": "Mida màxima de la còpia de seguretat (en GB) (0 per il·limitat)", + "LabelBackupsMaxBackupSizeHelp": "Com a protecció contra una configuració incorrecta, les còpies de seguretat fallaran si superen la mida configurada.", + "LabelBackupsNumberToKeep": "Nombre de còpies de seguretat a conservar", + "LabelBackupsNumberToKeepHelp": "Només s'eliminarà una còpia de seguretat alhora. Si té més còpies desades, haurà d'eliminar-les manualment.", + "LabelBitrate": "Taxa de bits", + "LabelBonus": "Bonus", + "LabelBooks": "Llibres", + "LabelButtonText": "Text del botó", + "LabelByAuthor": "per {0}", + "LabelChangePassword": "Canviar Contrasenya", + "LabelChannels": "Canals", + "LabelChapterCount": "{0} capítols", + "LabelChapterTitle": "Títol del Capítol", + "LabelChapters": "Capítols", + "LabelChaptersFound": "Capítol Trobat", + "LabelClickForMoreInfo": "Fes clic per a més informació", + "LabelClickToUseCurrentValue": "Fes clic per utilitzar el valor actual", + "LabelClosePlayer": "Tancar reproductor", + "LabelCodec": "Còdec", + "LabelCollapseSeries": "Contraure sèrie", + "LabelCollapseSubSeries": "Contraure la subsèrie", + "LabelCollection": "Col·lecció", + "LabelCollections": "Col·leccions", + "LabelComplete": "Complet", + "LabelConfirmPassword": "Confirmar Contrasenya", + "LabelContinueListening": "Continuar escoltant", + "LabelContinueReading": "Continuar llegint", + "LabelContinueSeries": "Continuar sèries", + "LabelCover": "Portada", + "LabelCoverImageURL": "URL de la Imatge de Portada", + "LabelCreatedAt": "Creat", + "LabelCronExpression": "Expressió de Cron", + "LabelCurrent": "Actual", + "LabelCurrently": "En aquest moment:", + "LabelCustomCronExpression": "Expressió de Cron Personalitzada:", + "LabelDatetime": "Hora i Data", + "LabelDays": "Dies", + "LabelDeleteFromFileSystemCheckbox": "Eliminar arxius del sistema (desmarcar per eliminar només de la base de dades)", + "LabelDescription": "Descripció", + "LabelDeselectAll": "Desseleccionar Tots", + "LabelDevice": "Dispositiu", + "LabelDeviceInfo": "Informació del Dispositiu", + "LabelDeviceIsAvailableTo": "El dispositiu està disponible per a...", + "LabelDirectory": "Directori", + "LabelDiscFromFilename": "Disc a partir del Nom de l'Arxiu", + "LabelDiscFromMetadata": "Disc a partir de Metadades", + "LabelDiscover": "Descobrir", + "LabelDownload": "Descarregar", + "LabelDownloadNEpisodes": "Descarregar {0} episodis", + "LabelDuration": "Duració", + "LabelDurationComparisonExactMatch": "(coincidència exacta)", + "LabelDurationComparisonLonger": "({0} més llarg)", + "LabelDurationComparisonShorter": "({0} més curt)", + "LabelDurationFound": "Duració Trobada:", + "LabelEbook": "Llibre electrònic", + "LabelEbooks": "Llibres electrònics", + "LabelEdit": "Editar", + "LabelEmail": "Correu electrònic", + "LabelEmailSettingsFromAddress": "Remitent", + "LabelEmailSettingsRejectUnauthorized": "Rebutja certificats no autoritzats", + "LabelEmailSettingsRejectUnauthorizedHelp": "Desactivar la validació de certificats SSL pot exposar la teva connexió a riscos de seguretat, com atacs de tipus man-in-the-middle. Desactiva aquesta opció només si coneixes les implicacions i confies en el servidor de correu al qual et connectes.", + "LabelEmailSettingsSecure": "Seguretat", + "LabelEmailSettingsSecureHelp": "Si està activat, es farà servir TLS per connectar-se al servidor. Si està desactivat, es farà servir TLS si el servidor admet l'extensió STARTTLS. En la majoria dels casos, pots deixar aquesta opció activada si et connectes al port 465. Desactiva-la en el cas d'usar els ports 587 o 25. (de nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsTestAddress": "Provar Adreça", + "LabelEmbeddedCover": "Portada Integrada", + "LabelEnable": "Habilitar", + "LabelEncodingBackupLocation": "Es guardarà una còpia de seguretat dels teus arxius d'àudio originals a:", + "LabelEncodingChaptersNotEmbedded": "Els capítols no s'incrusten en els audiollibres multipista.", + "LabelEncodingClearItemCache": "Assegura't de purgar periòdicament la memòria cau.", + "LabelEncodingFinishedM4B": "El M4B acabat es col·locarà a la teva carpeta d'audiollibres a:", + "LabelEncodingInfoEmbedded": "Les metadades s'integraran a les pistes d'àudio dins de la carpeta d'audiollibres.", + "LabelEncodingStartedNavigation": "Un cop iniciada la tasca, pots sortir d'aquesta pàgina.", + "LabelEncodingTimeWarning": "La codificació pot trigar fins a 30 minuts.", + "LabelEncodingWarningAdvancedSettings": "Advertència: No actualitzis aquesta configuració tret que estiguis familiaritzat amb les opcions de codificació d'ffmpeg.", + "LabelEncodingWatcherDisabled": "Si has desactivat la supervisió dels arxius, hauràs de tornar a escanejar aquest audiollibre més endavant.", + "LabelEnd": "Fi", + "LabelEndOfChapter": "Fi del capítol", + "LabelEpisode": "Episodi", + "LabelEpisodeNotLinkedToRssFeed": "Episodi no enllaçat al feed RSS", + "LabelEpisodeNumber": "Episodi #{0}", + "LabelEpisodeTitle": "Títol de l'Episodi", + "LabelEpisodeType": "Tipus d'Episodi", + "LabelEpisodeUrlFromRssFeed": "URL de l'episodi del feed RSS", + "LabelEpisodes": "Episodis", + "LabelEpisodic": "Episodis", + "LabelExample": "Exemple", + "LabelExpandSeries": "Ampliar sèrie", + "LabelExpandSubSeries": "Expandir la subsèrie", + "LabelExplicit": "Explícit", + "LabelExplicitChecked": "Explícit (marcat)", + "LabelExplicitUnchecked": "No Explícit (sense marcar)", + "LabelExportOPML": "Exportar OPML", + "LabelFeedURL": "Font de URL", + "LabelFetchingMetadata": "Obtenció de metadades", + "LabelFile": "Arxiu", + "LabelFileBirthtime": "Arxiu creat a", + "LabelFileBornDate": "Creat {0}", + "LabelFileModified": "Arxiu modificat", + "LabelFileModifiedDate": "Modificat {0}", + "LabelFilename": "Nom de l'arxiu", + "LabelFilterByUser": "Filtrar per Usuari", + "LabelFindEpisodes": "Cercar Episodi", + "LabelFinished": "Acabat", + "LabelFolder": "Carpeta", + "LabelFolders": "Carpetes", + "LabelFontBold": "Negreta", + "LabelFontBoldness": "Nivell de negreta en font", + "LabelFontFamily": "Família tipogràfica", + "LabelFontItalic": "Cursiva", + "LabelFontScale": "Mida de la font", + "LabelFontStrikethrough": "Ratllat", + "LabelFormat": "Format", + "LabelFull": "Complet", + "LabelGenre": "Gènere", + "LabelGenres": "Gèneres", + "LabelHardDeleteFile": "Eliminar Definitivament", + "LabelHasEbook": "Té un llibre electrònic", + "LabelHasSupplementaryEbook": "Té un llibre electrònic complementari", + "LabelHideSubtitles": "Amagar subtítols", + "LabelHighestPriority": "Prioritat més alta", + "LabelHost": "Amfitrió", + "LabelHour": "Hora", + "LabelHours": "Hores", + "LabelIcon": "Icona", + "LabelImageURLFromTheWeb": "URL de la imatge", + "LabelInProgress": "En procés", + "LabelIncludeInTracklist": "Incloure a la Llista de Pistes", + "LabelIncomplete": "Incomplet", + "LabelInterval": "Interval", + "LabelIntervalCustomDailyWeekly": "Personalitzar diari/setmanal", + "LabelIntervalEvery12Hours": "Cada 12 Hores", + "LabelIntervalEvery15Minutes": "Cada 15 minuts", + "LabelIntervalEvery2Hours": "Cada 2 Hores", + "LabelIntervalEvery30Minutes": "Cada 30 minuts", + "LabelIntervalEvery6Hours": "Cada 6 Hores", + "LabelIntervalEveryDay": "Cada Dia", + "LabelIntervalEveryHour": "Cada Hora", + "LabelInvert": "Invertir", + "LabelItem": "Element", + "LabelJumpBackwardAmount": "Quantitat de salts cap enrere", + "LabelJumpForwardAmount": "Quantitat de salts cap endavant", + "LabelLanguage": "Idioma", + "LabelLanguageDefaultServer": "Idioma Predeterminat del Servidor", + "LabelLanguages": "Idiomes", + "LabelLastBookAdded": "Últim Llibre Afegit", + "LabelLastBookUpdated": "Últim Llibre Actualitzat", + "LabelLastSeen": "Última Vegada Vist", + "LabelLastTime": "Última Vegada", + "LabelLastUpdate": "Última Actualització", + "LabelLayout": "Distribució", + "LabelLayoutSinglePage": "Pàgina única", + "LabelLayoutSplitPage": "Dues Pàgines", + "LabelLess": "Menys", + "LabelLibrariesAccessibleToUser": "Biblioteques Disponibles per a l'Usuari", + "LabelLibrary": "Biblioteca", + "LabelLibraryFilterSublistEmpty": "Sense {0}", + "LabelLibraryItem": "Element de Biblioteca", + "LabelLibraryName": "Nom de Biblioteca", + "LabelLimit": "Límits", + "LabelLineSpacing": "Interlineat", + "LabelListenAgain": "Escoltar de nou", + "LabelLogLevelDebug": "Depurar", + "LabelLogLevelInfo": "Informació", + "LabelLogLevelWarn": "Advertència", + "LabelLookForNewEpisodesAfterDate": "Cercar nous episodis a partir d'aquesta data", + "LabelLowestPriority": "Menor prioritat", + "LabelMatchExistingUsersBy": "Emparellar els usuaris existents per", + "LabelMatchExistingUsersByDescription": "S'utilitza per connectar usuaris existents. Un cop connectats, els usuaris seran emparellats per un identificador únic del seu proveïdor de SSO", + "LabelMaxEpisodesToDownload": "Nombre màxim d'episodis per descarregar. Usa 0 per descarregar una quantitat il·limitada.", + "LabelMaxEpisodesToDownloadPerCheck": "Nombre màxim de nous episodis que es descarregaran per comprovació", + "LabelMaxEpisodesToKeep": "Nombre màxim d'episodis que es mantindran", + "LabelMaxEpisodesToKeepHelp": "El valor 0 no estableix un límit màxim. Després de descarregar automàticament un nou episodi, això eliminarà l'episodi més antic si té més de X episodis. Això només eliminarà 1 episodi per nova descàrrega.", + "LabelMediaPlayer": "Reproductor de Mitjans", + "LabelMediaType": "Tipus de multimèdia", + "LabelMetaTag": "Metaetiqueta", + "LabelMetaTags": "Metaetiquetes", + "LabelMetadataOrderOfPrecedenceDescription": "Les fonts de metadades de major prioritat prevaldran sobre les de menor prioritat", + "LabelMetadataProvider": "Proveïdor de Metadades", + "LabelMinute": "Minut", + "LabelMinutes": "Minuts", + "LabelMissing": "Absent", + "LabelMissingEbook": "No té ebook", + "LabelMissingSupplementaryEbook": "No té ebook complementari", + "LabelMobileRedirectURIs": "URI de redirecció mòbil permeses", + "LabelMobileRedirectURIsDescription": "Aquesta és una llista blanca d'URI de redirecció vàlides per a aplicacions mòbils. El predeterminat és audiobookshelf, que pots eliminar o complementar amb URI addicionals per a la integració d'aplicacions de tercers. Usant un asterisc ( *) com a única entrada que permet qualsevol URI.", + "LabelMore": "Més", + "LabelMoreInfo": "Més informació", + "LabelName": "Nom", + "LabelNarrator": "Narrador", + "LabelNarrators": "Narradors", + "LabelNew": "Nou", + "LabelNewPassword": "Nova Contrasenya", + "LabelNewestAuthors": "Autors més recents", + "LabelNewestEpisodes": "Episodis més recents", + "LabelNextBackupDate": "Data del Següent Respatller", + "LabelNextScheduledRun": "Proper Execució Programada", + "LabelNoCustomMetadataProviders": "Sense proveïdors de metadades personalitzats", + "LabelNoEpisodesSelected": "Cap Episodi Seleccionat", + "LabelNotFinished": "No acabat", + "LabelNotStarted": "Sense iniciar", + "LabelNotes": "Notes", + "LabelNotificationAppriseURL": "URL(s) d'Apprise", + "LabelNotificationAvailableVariables": "Variables Disponibles", + "LabelNotificationBodyTemplate": "Plantilla de Cos", + "LabelNotificationEvent": "Esdeveniment de Notificació", + "LabelNotificationTitleTemplate": "Plantilla de Títol", + "LabelNotificationsMaxFailedAttempts": "Màxim d'Intents Fallits", + "LabelNotificationsMaxFailedAttemptsHelp": "Les notificacions es desactivaran després de fallar aquest nombre de vegades", + "LabelNotificationsMaxQueueSize": "Mida màxima de la cua de notificacions", + "LabelNotificationsMaxQueueSizeHelp": "Les notificacions estan limitades a 1 per segon. Les notificacions seran ignorades si arriben al número màxim de cua per prevenir spam d'esdeveniments.", + "LabelNumberOfBooks": "Nombre de Llibres", + "LabelNumberOfEpisodes": "Nombre d'Episodis", + "LabelOpenIDAdvancedPermsClaimDescription": "Nom de la notificació de OpenID que conté permisos avançats per accions d'usuari dins l'aplicació que s'aplicaran a rols que no siguin d'administrador (si estan configurats). Si el reclam no apareix en la resposta, es denegarà l'accés a ABS. Si manca una sola opció, es tractarà com a falsa. Assegura't que la notificació del proveïdor d'identitats coincideixi amb l'estructura esperada:", + "LabelOpenIDClaims": "Deixa les següents opcions buides per desactivar l'assignació avançada de grups i permisos, el que assignaria automàticament al grup 'Usuari'.", + "LabelOpenIDGroupClaimDescription": "Nom de la declaració OpenID que conté una llista de grups de l'usuari. Comunament coneguts com grups. Si es configura, l'aplicació assignarà automàticament rols basats en la pertinença a grups de l'usuari, sempre que aquests grups es denominen 'admin', 'user' o 'guest' en la notificació. La sol·licitud ha de contenir una llista, i si un usuari pertany a diversos grups, l'aplicació assignarà el rol corresponent al major nivell d'accés. Si cap grup coincideix, es denegarà l'accés.", + "LabelOverwrite": "Sobreescriure", + "LabelPaginationPageXOfY": "Pàgina {0} de {1}", + "LabelPassword": "Contrasenya", + "LabelPath": "Ruta de carpeta", + "LabelPermanent": "Permanent", + "LabelPermissionsAccessAllLibraries": "Pot Accedir a Totes les Biblioteques", + "LabelPermissionsAccessAllTags": "Pot Accedir a Totes les Etiquetes", + "LabelPermissionsAccessExplicitContent": "Pot Accedir a Contingut Explícit", + "LabelPermissionsCreateEreader": "Pot Crear un Gestor de Projectes", + "LabelPermissionsDelete": "Pot Eliminar", + "LabelPermissionsDownload": "Pot Descarregar", + "LabelPermissionsUpdate": "Pot Actualitzar", + "LabelPermissionsUpload": "Pot Pujar", + "LabelPersonalYearReview": "Revisió del teu any ({0})", + "LabelPhotoPathURL": "Ruta/URL de la Foto", + "LabelPlayMethod": "Mètode de Reproducció", + "LabelPlayerChapterNumberMarker": "{0} de {1}", + "LabelPlaylists": "Llistes de Reproducció", + "LabelPodcast": "Podcast", + "LabelPodcastSearchRegion": "Regió de Cerca de Podcasts", + "LabelPodcastType": "Tipus de Podcast", + "LabelPodcasts": "Podcasts", + "LabelPort": "Port", + "LabelPrefixesToIgnore": "Prefixos per Ignorar (no distingeix entre majúscules i minúscules.)", + "LabelPreventIndexing": "Evita que la teva font sigui indexada pels directoris de podcasts d'iTunes i Google", + "LabelPrimaryEbook": "Ebook Principal", + "LabelProgress": "Progrés", + "LabelProvider": "Proveïdor", + "LabelProviderAuthorizationValue": "Valor de l'encapçalament d'autorització", + "LabelPubDate": "Data de Publicació", + "LabelPublishYear": "Any de Publicació", + "LabelPublishedDate": "Publicat {0}", + "LabelPublishedDecade": "Dècada de Publicació", + "LabelPublishedDecades": "Dècades Publicades", + "LabelPublisher": "Editor", + "LabelPublishers": "Editors", + "LabelRSSFeedCustomOwnerEmail": "Correu Electrònic Personalitzat del Propietari", + "LabelRSSFeedCustomOwnerName": "Nom Personalitzat del Propietari", + "LabelRSSFeedOpen": "Font RSS Oberta", + "LabelRSSFeedPreventIndexing": "Evitar l'indexació", + "LabelRSSFeedSlug": "Font RSS Slug", + "LabelRSSFeedURL": "URL de la Font RSS", + "LabelRandomly": "Aleatòriament", + "LabelReAddSeriesToContinueListening": "Reafegir la sèrie per continuar escoltant-la", + "LabelRead": "Llegit", + "LabelReadAgain": "Tornar a llegir", + "LabelReadEbookWithoutProgress": "Llegir Ebook sense guardar progrés", + "LabelRecentSeries": "Sèries Recents", + "LabelRecentlyAdded": "Afegit Recentment", + "LabelRecommended": "Recomanats", + "LabelRedo": "Refer", + "LabelRegion": "Regió", + "LabelReleaseDate": "Data d'Estrena", + "LabelRemoveAllMetadataAbs": "Eliminar tots els fitxers metadata.abs", + "LabelRemoveAllMetadataJson": "Eliminar tots els fitxers metadata.json", + "LabelRemoveCover": "Eliminar Coberta", + "LabelRemoveMetadataFile": "Eliminar fitxers de metadades en carpetes d'elements de biblioteca", + "LabelRemoveMetadataFileHelp": "Elimina tots els fitxers metadata.json i metadata.abs de les teves carpetes {0}.", + "LabelRowsPerPage": "Files per Pàgina", + "LabelSearchTerm": "Cercar Terme", + "LabelSearchTitle": "Cercar Títol", + "LabelSearchTitleOrASIN": "Cercar Títol o ASIN", + "LabelSeason": "Temporada", + "LabelSeasonNumber": "Temporada #{0}", + "LabelSelectAll": "Seleccionar tot", + "LabelSelectAllEpisodes": "Seleccionar tots els episodis", + "LabelSelectEpisodesShowing": "Seleccionar els {0} episodis visibles", + "LabelSelectUsers": "Seleccionar usuaris", + "LabelSendEbookToDevice": "Enviar Ebook a...", + "LabelSequence": "Seqüència", + "LabelSerial": "Serial", + "LabelSeries": "Sèries", + "LabelSeriesName": "Nom de la Sèrie", + "LabelSeriesProgress": "Progrés de la Sèrie", + "LabelServerLogLevel": "Nivell de registre del servidor", + "LabelServerYearReview": "Resum de l'any del servidor ({0})", + "LabelSetEbookAsPrimary": "Establir com a principal", + "LabelSetEbookAsSupplementary": "Establir com a suplementari", + "LabelSettingsAudiobooksOnly": "Només Audiollibres", + "LabelSettingsAudiobooksOnlyHelp": "Activant aquesta opció s'ignoraran els fitxers d'ebook, excepte si estan dins d'una carpeta d'audiollibre, en aquest cas es marcaran com ebooks suplementaris", + "LabelSettingsBookshelfViewHelp": "Disseny esqueomorf amb prestatgeries de fusta", + "LabelSettingsChromecastSupport": "Compatibilitat amb Chromecast", + "LabelSettingsDateFormat": "Format de Data", + "LabelSettingsDisableWatcher": "Desactivar Watcher", + "LabelSettingsDisableWatcherForLibrary": "Desactivar Watcher de Carpetes per a aquesta biblioteca", + "LabelSettingsDisableWatcherHelp": "Desactiva la funció d'afegir/actualitzar elements automàticament quan es detectin canvis en els fitxers. *Requereix reiniciar el servidor", + "LabelSettingsEnableWatcher": "Habilitar Watcher", + "LabelSettingsEnableWatcherForLibrary": "Habilitar Watcher per a la carpeta d'aquesta biblioteca", + "LabelSettingsEnableWatcherHelp": "Permet afegir/actualitzar elements automàticament quan es detectin canvis en els fitxers. *Requereix reiniciar el servidor", + "LabelSettingsEpubsAllowScriptedContent": "Permetre scripts en epubs", + "LabelSettingsEpubsAllowScriptedContentHelp": "Permetre que els fitxers epub executin scripts. Es recomana mantenir aquesta opció desactivada tret que confiïs en l'origen dels fitxers epub.", + "LabelSettingsExperimentalFeatures": "Funcions Experimentals", + "LabelSettingsExperimentalFeaturesHelp": "Funcions en desenvolupament que es beneficiarien dels teus comentaris i experiències de prova. Feu clic aquí per obrir una conversa a Github.", + "LabelShowAll": "Mostra-ho tot", + "LabelShowSeconds": "Mostra segons", + "LabelShowSubtitles": "Mostra subtítols", + "LabelSize": "Mida", + "LabelSleepTimer": "Temporitzador de repòs", + "LabelSlug": "Slug", + "LabelStart": "Inicia", + "LabelStartTime": "Hora d'inici", + "LabelStarted": "Iniciat", + "LabelStartedAt": "Iniciat a", + "LabelStatsAudioTracks": "Pistes d'àudio", + "LabelStatsAuthors": "Autors", + "LabelStatsBestDay": "Millor dia", + "LabelStatsDailyAverage": "Mitjana diària", + "LabelStatsDays": "Dies", + "LabelStatsDaysListened": "Dies escoltats", + "LabelStatsHours": "Hores", + "LabelStatsInARow": "seguits", + "LabelStatsItemsFinished": "Elements acabats", + "LabelStatsItemsInLibrary": "Elements a la biblioteca", + "LabelStatsMinutes": "minuts", + "LabelStatsMinutesListening": "Minuts escoltant", + "LabelStatsOverallDays": "Total de dies", + "LabelStatsOverallHours": "Total d'hores", + "LabelStatsWeekListening": "Temps escoltat aquesta setmana", + "LabelSubtitle": "Subtítol", + "LabelSupportedFileTypes": "Tipus de fitxers compatibles", + "LabelTag": "Etiqueta", + "LabelTags": "Etiquetes", + "LabelTagsAccessibleToUser": "Etiquetes accessibles per a l'usuari", + "LabelTagsNotAccessibleToUser": "Etiquetes no accessibles per a l'usuari", + "LabelTasks": "Tasques en execució", + "LabelTextEditorBulletedList": "Llista amb punts", + "LabelTextEditorLink": "Enllaça", + "LabelTextEditorNumberedList": "Llista numerada", + "LabelTextEditorUnlink": "Desenllaça", + "LabelTheme": "Tema", + "LabelThemeDark": "Fosc", + "LabelThemeLight": "Clar", + "LabelTimeBase": "Temps base", + "LabelTimeDurationXHours": "{0} hores", + "LabelTimeDurationXMinutes": "{0} minuts", + "LabelTimeDurationXSeconds": "{0} segons", + "LabelTimeInMinutes": "Temps en minuts", + "LabelTimeLeft": "Queden {0}", + "LabelTimeListened": "Temps escoltat", + "LabelTimeListenedToday": "Temps escoltat avui", + "LabelTimeRemaining": "{0} restant", + "LabelTimeToShift": "Temps per canviar en segons", + "LabelTitle": "Títol", + "LabelToolsEmbedMetadata": "Incrusta metadades", + "LabelToolsEmbedMetadataDescription": "Incrusta metadades en els fitxers d'àudio, incloent la portada i capítols.", + "LabelToolsM4bEncoder": "Codificador M4B", + "LabelToolsMakeM4b": "Crea fitxer d'audiollibre M4B", + "LabelToolsMakeM4bDescription": "Genera un fitxer d'audiollibre .M4B amb metadades, imatges de portada i capítols incrustats.", + "LabelToolsSplitM4b": "Divideix M4B en fitxers MP3", + "LabelToolsSplitM4bDescription": "Divideix un M4B en fitxers MP3 i incrusta metadades, imatges de portada i capítols.", + "LabelTotalDuration": "Duració total", + "LabelTotalTimeListened": "Temps total escoltat", + "LabelTrackFromFilename": "Pista des del nom del fitxer", + "LabelTrackFromMetadata": "Pista des de metadades", + "LabelTracks": "Pistes", + "LabelTracksMultiTrack": "Diverses pistes", + "LabelTracksNone": "Cap pista", + "LabelTracksSingleTrack": "Una pista", + "LabelTrailer": "Tràiler", + "LabelType": "Tipus", + "LabelUnabridged": "No abreujat", + "LabelUndo": "Desfés", + "LabelUnknown": "Desconegut", + "LabelUnknownPublishDate": "Data de publicació desconeguda", + "LabelUpdateCover": "Actualitza portada", + "LabelUpdateCoverHelp": "Permet sobreescriure les portades existents dels llibres seleccionats quan es trobi una coincidència.", + "LabelUpdateDetails": "Actualitza detalls", + "LabelUpdateDetailsHelp": "Permet sobreescriure els detalls existents dels llibres seleccionats quan es trobin coincidències.", + "LabelUpdatedAt": "Actualitzat a", + "LabelUploaderDragAndDrop": "Arrossega i deixa anar fitxers o carpetes", + "LabelUploaderDragAndDropFilesOnly": "Arrossega i deixa anar fitxers", + "LabelUploaderDropFiles": "Deixa anar els fitxers", + "LabelUploaderItemFetchMetadataHelp": "Cerca títol, autor i sèries automàticament", + "LabelUseAdvancedOptions": "Utilitza opcions avançades", + "LabelUseChapterTrack": "Utilitza pista per capítol", + "LabelUseFullTrack": "Utilitza pista completa", + "LabelUseZeroForUnlimited": "Utilitza 0 per il·limitat", + "LabelUser": "Usuari", + "LabelUsername": "Nom d'usuari", + "LabelValue": "Valor", + "LabelVersion": "Versió", + "LabelViewBookmarks": "Mostra marcadors", + "LabelViewChapters": "Mostra capítols", + "LabelViewPlayerSettings": "Mostra els ajustaments del reproductor", + "LabelViewQueue": "Mostra cua del reproductor", + "LabelVolume": "Volum", + "LabelWebRedirectURLsDescription": "Autoritza aquestes URL al teu proveïdor OAuth per permetre redirecció a l'aplicació web després d'iniciar sessió:", + "LabelWebRedirectURLsSubfolder": "Subcarpeta per a URL de redirecció", + "LabelWeekdaysToRun": "Executar en dies de la setmana", + "LabelXBooks": "{0} llibres", + "LabelXItems": "{0} elements", + "LabelYearReviewHide": "Oculta resum de l'any", + "LabelYearReviewShow": "Mostra resum de l'any", + "LabelYourAudiobookDuration": "Duració del teu audiollibre", + "LabelYourBookmarks": "Els teus marcadors", + "LabelYourPlaylists": "Les teves llistes", + "LabelYourProgress": "El teu progrés", + "MessageAddToPlayerQueue": "Afegeix a la cua del reproductor", + "MessageAppriseDescription": "Per utilitzar aquesta funció, hauràs de tenir l'API d'Apprise en funcionament o una API que gestioni resultats similars.
La URL de l'API d'Apprise ha de tenir la mateixa ruta d'arxius que on s'envien les notificacions. Per exemple: si la teva API és a http://192.168.1.1:8337, llavors posaries http://192.168.1.1:8337/notify.", + "MessageBackupsDescription": "Les còpies de seguretat inclouen: usuaris, progrés dels usuaris, detalls dels elements de la biblioteca, configuració del servidor i imatges a /metadata/items i /metadata/authors. Les còpies de seguretat NO inclouen cap fitxer guardat a la carpeta de la teva biblioteca.", + "MessageBackupsLocationEditNote": "Nota: Actualitzar la ubicació de la còpia de seguretat no mourà ni modificarà les còpies existents", + "MessageBackupsLocationNoEditNote": "Nota: La ubicació de la còpia de seguretat es defineix mitjançant una variable d'entorn i no es pot modificar aquí.", + "MessageBackupsLocationPathEmpty": "La ruta de la còpia de seguretat no pot estar buida", + "MessageBatchQuickMatchDescription": "La funció \"Troba Ràpid\" intentarà afegir portades i metadades que falten als elements seleccionats. Activa l'opció següent perquè \"Troba Ràpid\" pugui sobreescriure portades i/o metadades existents.", + "MessageBookshelfNoCollections": "No tens cap col·lecció", + "MessageBookshelfNoRSSFeeds": "Cap font RSS està oberta", + "MessageBookshelfNoResultsForFilter": "Cap resultat per al filtre \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "Cap resultat per a la consulta", + "MessageBookshelfNoSeries": "No tens cap sèrie", + "MessageChapterEndIsAfter": "El final del capítol és després del final del teu audiollibre", + "MessageChapterErrorFirstNotZero": "El primer capítol ha de començar a 0", + "MessageChapterErrorStartGteDuration": "El temps d'inici no és vàlid: ha de ser inferior a la durada de l'audiollibre", + "MessageChapterErrorStartLtPrev": "El temps d'inici no és vàlid: ha de ser igual o més gran que el temps d'inici del capítol anterior", + "MessageChapterStartIsAfter": "L'inici del capítol és després del final del teu audiollibre", + "MessageCheckingCron": "Comprovant cron...", + "MessageConfirmCloseFeed": "Estàs segur que vols tancar aquesta font?", + "MessageConfirmDeleteBackup": "Estàs segur que vols eliminar la còpia de seguretat {0}?", + "MessageConfirmDeleteDevice": "Estàs segur que vols eliminar el lector electrònic \"{0}\"?", + "MessageConfirmDeleteFile": "Això eliminarà el fitxer del teu sistema. Estàs segur?", + "MessageConfirmDeleteLibrary": "Estàs segur que vols eliminar permanentment la biblioteca \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "Això eliminarà l'element de la base de dades i del sistema. Estàs segur?", + "MessageConfirmDeleteLibraryItems": "Això eliminarà {0} element(s) de la base de dades i del sistema. Estàs segur?", + "MessageConfirmDeleteMetadataProvider": "Estàs segur que vols eliminar el proveïdor de metadades personalitzat \"{0}\"?", + "MessageConfirmDeleteNotification": "Estàs segur que vols eliminar aquesta notificació?", + "MessageConfirmDeleteSession": "Estàs segur que vols eliminar aquesta sessió?", + "MessageConfirmEmbedMetadataInAudioFiles": "Estàs segur que vols incrustar metadades a {0} fitxer(s) d'àudio?", + "MessageConfirmForceReScan": "Estàs segur que vols forçar un reescaneig?", + "MessageConfirmMarkAllEpisodesFinished": "Estàs segur que vols marcar tots els episodis com a acabats?", + "MessageConfirmMarkAllEpisodesNotFinished": "Estàs segur que vols marcar tots els episodis com a no acabats?", + "MessageConfirmMarkItemFinished": "Estàs segur que vols marcar \"{0}\" com a acabat?", + "MessageConfirmMarkItemNotFinished": "Estàs segur que vols marcar \"{0}\" com a no acabat?", + "MessageConfirmMarkSeriesFinished": "Estàs segur que vols marcar tots els llibres d'aquesta sèrie com a acabats?", + "MessageConfirmMarkSeriesNotFinished": "Estàs segur que vols marcar tots els llibres d'aquesta sèrie com a no acabats?", + "MessageConfirmNotificationTestTrigger": "Vols activar aquesta notificació amb dades de prova?", + "MessageConfirmPurgeCache": "Esborrar la memòria cau eliminarà tot el directori localitzat a /metadata/cache.

Estàs segur que vols eliminar-lo?", + "MessageConfirmPurgeItemsCache": "Esborrar la memòria cau dels elements eliminarà el directori /metadata/cache/items.
Estàs segur?", + "MessageConfirmQuickEmbed": "Advertència! La integració ràpida no fa còpies de seguretat dels teus fitxers d'àudio. Assegura't d'haver-ne fet una còpia abans.

Vols continuar?", + "MessageConfirmQuickMatchEpisodes": "El reconeixement ràpid sobreescriurà els detalls si es troba una coincidència. Estàs segur?", + "MessageConfirmReScanLibraryItems": "Estàs segur que vols reescanejar {0} element(s)?", + "MessageConfirmRemoveAllChapters": "Estàs segur que vols eliminar tots els capítols?", + "MessageConfirmRemoveAuthor": "Estàs segur que vols eliminar l'autor \"{0}\"?", + "MessageConfirmRemoveCollection": "Estàs segur que vols eliminar la col·lecció \"{0}\"?", + "MessageConfirmRemoveEpisode": "Estàs segur que vols eliminar l'episodi \"{0}\"?", + "MessageConfirmRemoveEpisodes": "Estàs segur que vols eliminar {0} episodis?", + "MessageConfirmRemoveListeningSessions": "Estàs segur que vols eliminar {0} sessions d'escolta?", + "MessageConfirmRemoveMetadataFiles": "Estàs segur que vols eliminar tots els fitxers de metadades.{0} de les carpetes dels elements de la teva biblioteca?", + "MessageConfirmRemoveNarrator": "Estàs segur que vols eliminar el narrador \"{0}\"?", + "MessageConfirmRemovePlaylist": "Estàs segur que vols eliminar la llista de reproducció \"{0}\"?", + "MessageConfirmRenameGenre": "Estàs segur que vols canviar el gènere \"{0}\" a \"{1}\" per a tots els elements?", + "MessageConfirmRenameGenreMergeNote": "Nota: Aquest gènere ja existeix, i es fusionarà.", + "MessageConfirmRenameGenreWarning": "Advertència! Ja existeix un gènere similar \"{0}\".", + "MessageConfirmRenameTag": "Estàs segur que vols canviar l'etiqueta \"{0}\" a \"{1}\" per a tots els elements?", + "MessageConfirmRenameTagMergeNote": "Nota: Aquesta etiqueta ja existeix, i es fusionarà.", + "MessageConfirmRenameTagWarning": "Advertència! Ja existeix una etiqueta similar \"{0}\".", + "MessageConfirmResetProgress": "Estàs segur que vols reiniciar el teu progrés?", + "MessageConfirmSendEbookToDevice": "Estàs segur que vols enviar {0} ebook(s) \"{1}\" al dispositiu \"{2}\"?", + "MessageConfirmUnlinkOpenId": "Estàs segur que vols desvincular aquest usuari d'OpenID?", + "MessageDownloadingEpisode": "Descarregant capítol", + "MessageDragFilesIntoTrackOrder": "Arrossega els fitxers en l'ordre correcte de les pistes", + "MessageEmbedFailed": "Error en incrustar!", + "MessageEmbedFinished": "Incrustació acabada!", + "MessageEmbedQueue": "En cua per incrustar metadades ({0} en cua)", + "MessageMarkAsFinished": "Marcar com acabat", + "MessageMarkAsNotFinished": "Marcar com no acabat", + "MessageMatchBooksDescription": "S'intentarà fer coincidir els llibres de la biblioteca amb un llibre del proveïdor de cerca seleccionat, i s'ompliran els detalls buits i la portada. No sobreescriu els detalls.", + "MessageNoAudioTracks": "Sense pistes d'àudio", + "MessageNoAuthors": "Sense autors", + "MessageNoBackups": "Sense còpies de seguretat", + "MessageNoBookmarks": "Sense marcadors", + "MessageNoChapters": "Sense capítols", + "MessageNoCollections": "Sense col·leccions", + "MessageNoCoversFound": "Cap portada trobada", + "MessageNoDescription": "Sense descripció", + "MessageNoDevices": "Sense dispositius", + "MessageNoDownloadsInProgress": "No hi ha descàrregues en curs", + "MessageNoDownloadsQueued": "Sense cua de descàrrega", + "MessageNoEpisodeMatchesFound": "No s'han trobat episodis que coincideixin", + "MessageNoEpisodes": "Sense episodis", + "MessageNoFoldersAvailable": "No hi ha carpetes disponibles", + "MessageNoGenres": "Sense gèneres", + "MessageNoIssues": "Sense problemes", + "MessageNoItems": "Sense elements", + "MessageNoItemsFound": "Cap element trobat", + "MessageNoListeningSessions": "Sense sessions escoltades", + "MessageNoLogs": "Sense registres", + "MessageNoMediaProgress": "Sense progrés multimèdia", + "MessageNoNotifications": "Sense notificacions", + "MessageNoPodcastFeed": "Podcast no vàlid: sense font", + "MessageNoPodcastsFound": "Cap podcast trobat", + "MessageNoResults": "Sense resultats", + "MessageNoSearchResultsFor": "No hi ha resultats per a la cerca \"{0}\"", + "MessageNoSeries": "Sense sèries", + "MessageNoTags": "Sense etiquetes", + "MessageNoTasksRunning": "Sense tasques en execució", + "MessageNoUpdatesWereNecessary": "No calien actualitzacions", + "MessageNoUserPlaylists": "No tens cap llista de reproducció", + "MessageNotYetImplemented": "Encara no implementat", + "MessageOpmlPreviewNote": "Nota: Aquesta és una vista prèvia de l'arxiu OPML analitzat. El títol real del podcast s'obtindrà del canal RSS.", + "MessageOr": "o", + "MessagePauseChapter": "Pausar la reproducció del capítol", + "MessagePlayChapter": "Escoltar l'inici del capítol", + "MessagePlaylistCreateFromCollection": "Crear una llista de reproducció a partir d'una col·lecció", + "MessagePleaseWait": "Espera si us plau...", + "MessagePodcastHasNoRSSFeedForMatching": "El podcast no té una URL de font RSS que es pugui utilitzar", + "MessagePodcastSearchField": "Introdueix el terme de cerca o la URL de la font RSS", + "MessageQuickEmbedInProgress": "Integració ràpida en procés", + "MessageQuickEmbedQueue": "En cua per a inserció ràpida ({0} en cua)", + "MessageQuickMatchAllEpisodes": "Combina ràpidament tots els episodis", + "MessageQuickMatchDescription": "Omple detalls d'elements buits i portades amb els primers resultats de '{0}'. No sobreescriu els detalls tret que l'opció \"Preferir metadades trobades\" del servidor estigui habilitada.", + "MessageRemoveChapter": "Eliminar capítols", + "MessageRemoveEpisodes": "Eliminar {0} episodi(s)", + "MessageRemoveFromPlayerQueue": "Eliminar de la cua del reproductor", + "MessageRemoveUserWarning": "Estàs segur que vols eliminar l'usuari \"{0}\"?", + "MessageReportBugsAndContribute": "Informa d'errors, sol·licita funcions i contribueix a", + "MessageResetChaptersConfirm": "Estàs segur que vols desfer els canvis i revertir els capítols al seu estat original?", + "MessageRestoreBackupConfirm": "Estàs segur que vols restaurar la còpia de seguretat creada a", + "MessageRestoreBackupWarning": "Restaurar sobreescriurà tota la base de dades situada a /config i les imatges de portades a /metadata/items i /metadata/authors.

La còpia de seguretat no modifica cap fitxer a les carpetes de la teva biblioteca. Si has activat l'opció del servidor per guardar portades i metadades a les carpetes de la biblioteca, aquests fitxers no es guarden ni sobreescriuen.

Tots els clients que utilitzin el teu servidor s'actualitzaran automàticament.", + "MessageSearchResultsFor": "Resultats de la cerca de", + "MessageSelected": "{0} seleccionat(s)", + "MessageServerCouldNotBeReached": "No es va poder establir la connexió amb el servidor", + "MessageSetChaptersFromTracksDescription": "Establir capítols utilitzant cada fitxer d'àudio com un capítol i el títol del capítol com el nom del fitxer d'àudio", + "MessageShareExpirationWillBe": "La caducitat serà {0}", + "MessageShareExpiresIn": "Caduca en {0}", + "MessageShareURLWillBe": "La URL per compartir serà {0}", + "MessageStartPlaybackAtTime": "Començar la reproducció per a \"{0}\" a {1}?", + "MessageTaskAudioFileNotWritable": "El fitxer d'àudio \"{0}\" no es pot escriure", + "MessageTaskCanceledByUser": "Tasca cancel·lada per l'usuari", + "MessageTaskDownloadingEpisodeDescription": "Descarregant l'episodi \"{0}\"", + "MessageTaskEmbeddingMetadata": "Inserint metadades", + "MessageTaskEmbeddingMetadataDescription": "Inserint metadades en l'audiollibre \"{0}\"", + "MessageTaskEncodingM4b": "Codificant M4B", + "MessageTaskEncodingM4bDescription": "Codificant l'audiollibre \"{0}\" en un únic fitxer M4B", + "MessageTaskFailed": "Fallada", + "MessageTaskFailedToBackupAudioFile": "Error en fer una còpia de seguretat del fitxer d'àudio \"{0}\"", + "MessageTaskFailedToCreateCacheDirectory": "Error en crear el directori de la memòria cau", + "MessageTaskFailedToEmbedMetadataInFile": "Error en incrustar metadades en el fitxer \"{0}\"", + "MessageTaskFailedToMergeAudioFiles": "Error en fusionar fitxers d'àudio", + "MessageTaskFailedToMoveM4bFile": "Error en moure el fitxer M4B", + "MessageTaskFailedToWriteMetadataFile": "Error en escriure el fitxer de metadades", + "MessageTaskMatchingBooksInLibrary": "Coincidint llibres a la biblioteca \"{0}\"", + "MessageTaskNoFilesToScan": "Sense fitxers per escanejar", + "MessageTaskOpmlImport": "Importar OPML", + "MessageTaskOpmlImportDescription": "Creant podcasts a partir de {0} fonts RSS", + "MessageTaskOpmlImportFeed": "Importació de feed OPML", + "MessageTaskOpmlImportFeedDescription": "Importació del feed RSS \"{0}\"", + "MessageTaskOpmlImportFeedFailed": "No es pot obtenir el podcast", + "MessageTaskOpmlImportFeedPodcastDescription": "Creant el podcast \"{0}\"", + "MessageTaskOpmlImportFeedPodcastExists": "El podcast ja existeix a la ruta", + "MessageTaskOpmlImportFeedPodcastFailed": "Error en crear el podcast", + "MessageTaskOpmlImportFinished": "Afegit {0} podcasts", + "MessageTaskOpmlParseFailed": "No s'ha pogut analitzar el fitxer OPML", + "MessageTaskOpmlParseFastFail": "No s'ha trobat l'etiqueta o al fitxer OPML", + "MessageTaskOpmlParseNoneFound": "No s'han trobat fonts al fitxer OPML", + "MessageTaskScanItemsAdded": "{0} afegit", + "MessageTaskScanItemsMissing": "{0} faltant", + "MessageTaskScanItemsUpdated": "{0} actualitzat", + "MessageTaskScanNoChangesNeeded": "No calen canvis", + "MessageTaskScanningFileChanges": "Escanejant canvis al fitxer en \"{0}\"", + "MessageTaskScanningLibrary": "Escanejant la biblioteca \"{0}\"", + "MessageTaskTargetDirectoryNotWritable": "El directori de destinació no es pot escriure", + "MessageThinking": "Pensant...", + "MessageUploaderItemFailed": "Error en pujar", + "MessageUploaderItemSuccess": "Pujada amb èxit!", + "MessageUploading": "Pujant...", + "MessageValidCronExpression": "Expressió de cron vàlida", + "MessageWatcherIsDisabledGlobally": "El watcher està desactivat globalment a la configuració del servidor", + "MessageXLibraryIsEmpty": "La biblioteca {0} està buida!", + "MessageYourAudiobookDurationIsLonger": "La durada del teu audiollibre és més llarga que la durada trobada", + "MessageYourAudiobookDurationIsShorter": "La durada del teu audiollibre és més curta que la durada trobada", + "NoteChangeRootPassword": "L'usuari Root és l'únic usuari que pot no tenir una contrasenya", + "NoteChapterEditorTimes": "Nota: El temps d'inici del primer capítol ha de romandre a 0:00, i el temps d'inici de l'últim capítol no pot superar la durada de l'audiollibre.", + "NoteFolderPicker": "Nota: Les carpetes ja assignades no es mostraran", + "NoteRSSFeedPodcastAppsHttps": "Advertència: La majoria d'aplicacions de podcast requereixen que la URL de la font RSS utilitzi HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "Advertència: Un o més dels teus episodis no tenen data de publicació. Algunes aplicacions de podcast ho requereixen.", + "NoteUploaderFoldersWithMediaFiles": "Les carpetes amb fitxers multimèdia es gestionaran com a elements separats a la biblioteca.", + "NoteUploaderOnlyAudioFiles": "Si només puges fitxers d'àudio, cada fitxer es gestionarà com un audiollibre separat.", + "NoteUploaderUnsupportedFiles": "Els fitxers no compatibles seran ignorats. Si selecciona o arrossega una carpeta, els fitxers que no estiguin dins d'una subcarpeta seran ignorats.", + "NotificationOnBackupCompletedDescription": "S'activa quan es completa una còpia de seguretat", + "NotificationOnBackupFailedDescription": "S'activa quan falla una còpia de seguretat", + "NotificationOnEpisodeDownloadedDescription": "S'activa quan es descarrega automàticament un episodi d'un podcast", + "NotificationOnTestDescription": "Esdeveniment per provar el sistema de notificacions", + "PlaceholderNewCollection": "Nou nom de la col·lecció", + "PlaceholderNewFolderPath": "Nova ruta de carpeta", + "PlaceholderNewPlaylist": "Nou nom de la llista de reproducció", + "PlaceholderSearch": "Cerca...", + "PlaceholderSearchEpisode": "Cerca d'episodis...", + "StatsAuthorsAdded": "autors afegits", + "StatsBooksAdded": "llibres afegits", + "StatsBooksAdditional": "Algunes addicions inclouen…", + "StatsBooksFinished": "llibres acabats", + "StatsBooksFinishedThisYear": "Alguns llibres acabats aquest any…", + "StatsBooksListenedTo": "llibres escoltats", + "StatsCollectionGrewTo": "La teva col·lecció de llibres ha crescut fins a…", + "StatsSessions": "sessions", + "StatsSpentListening": "dedicat a escoltar", + "StatsTopAuthor": "AUTOR DESTACAT", + "StatsTopAuthors": "AUTORS DESTACATS", + "StatsTopGenre": "GÈNERE PRINCIPAL", + "StatsTopGenres": "GÈNERES PRINCIPALS", + "StatsTopMonth": "DESTACAT DEL MES", + "StatsTopNarrator": "NARRADOR DESTACAT", + "StatsTopNarrators": "NARRADORS DESTACATS", + "StatsTotalDuration": "Amb una durada total de…", + "StatsYearInReview": "RESUM DE L'ANY", + "ToastAccountUpdateSuccess": "Compte actualitzat", + "ToastAppriseUrlRequired": "Cal introduir una URL de Apprise", + "ToastAsinRequired": "ASIN requerit", + "ToastAuthorImageRemoveSuccess": "S'ha eliminat la imatge de l'autor", + "ToastAuthorNotFound": "No s'ha trobat l'autor \"{0}\"", + "ToastAuthorRemoveSuccess": "Autor eliminat", + "ToastAuthorSearchNotFound": "No s'ha trobat l'autor", + "ToastAuthorUpdateMerged": "Autor combinat", + "ToastAuthorUpdateSuccess": "Autor actualitzat", + "ToastAuthorUpdateSuccessNoImageFound": "Autor actualitzat (Imatge no trobada)", + "ToastBackupAppliedSuccess": "Còpia de seguretat aplicada", + "ToastBackupCreateFailed": "Error en crear la còpia de seguretat", + "ToastBackupCreateSuccess": "Còpia de seguretat creada", + "ToastBackupDeleteFailed": "Error en eliminar la còpia de seguretat", + "ToastBackupDeleteSuccess": "Còpia de seguretat eliminada", + "ToastBackupInvalidMaxKeep": "Nombre no vàlid de còpies de seguretat a conservar", + "ToastBackupInvalidMaxSize": "Mida màxima de còpia de seguretat no vàlida", + "ToastBackupRestoreFailed": "Error en restaurar la còpia de seguretat", + "ToastBackupUploadFailed": "Error en carregar la còpia de seguretat", + "ToastBackupUploadSuccess": "Còpia de seguretat carregada", + "ToastBatchDeleteFailed": "Error en l'eliminació per lots", + "ToastBatchDeleteSuccess": "Eliminació per lots correcte", + "ToastBatchQuickMatchFailed": "Error en la sincronització ràpida per lots!", + "ToastBatchQuickMatchStarted": "S'ha iniciat la sincronització ràpida per lots de {0} llibres!", + "ToastBatchUpdateFailed": "Error en l'actualització massiva", + "ToastBatchUpdateSuccess": "Actualització massiva completada", + "ToastBookmarkCreateFailed": "Error en crear marcador", + "ToastBookmarkCreateSuccess": "Marcador afegit", + "ToastBookmarkRemoveSuccess": "Marcador eliminat", + "ToastBookmarkUpdateSuccess": "Marcador actualitzat", + "ToastCachePurgeFailed": "Error en purgar la memòria cau", + "ToastCachePurgeSuccess": "Memòria cau purgada amb èxit", + "ToastChaptersHaveErrors": "Els capítols tenen errors", + "ToastChaptersMustHaveTitles": "Els capítols han de tenir un títol", + "ToastChaptersRemoved": "Capítols eliminats", + "ToastChaptersUpdated": "Capítols actualitzats", + "ToastCollectionItemsAddFailed": "Error en afegir elements a la col·lecció", + "ToastCollectionItemsAddSuccess": "Elements afegits a la col·lecció", + "ToastCollectionItemsRemoveSuccess": "Elements eliminats de la col·lecció", + "ToastCollectionRemoveSuccess": "Col·lecció eliminada", + "ToastCollectionUpdateSuccess": "Col·lecció actualitzada", + "ToastCoverUpdateFailed": "Error en actualitzar la portada", + "ToastDeleteFileFailed": "Error en eliminar l'arxiu", + "ToastDeleteFileSuccess": "Arxiu eliminat", + "ToastDeviceAddFailed": "Error en afegir el dispositiu", + "ToastDeviceNameAlreadyExists": "Ja existeix un dispositiu amb aquest nom", + "ToastDeviceTestEmailFailed": "Error en enviar el correu de prova", + "ToastDeviceTestEmailSuccess": "Correu de prova enviat", + "ToastEmailSettingsUpdateSuccess": "Configuració de correu electrònic actualitzada", + "ToastEncodeCancelFailed": "No s'ha pogut cancel·lar la codificació", + "ToastEncodeCancelSucces": "Codificació cancel·lada", + "ToastEpisodeDownloadQueueClearFailed": "No s'ha pogut buidar la cua de descàrregues", + "ToastEpisodeDownloadQueueClearSuccess": "Cua de descàrregues buidada", + "ToastEpisodeUpdateSuccess": "{0} episodi(s) actualitzat(s)", + "ToastErrorCannotShare": "No es pot compartir de manera nativa en aquest dispositiu", + "ToastFailedToLoadData": "Error en carregar les dades", + "ToastFailedToMatch": "Error en emparellar", + "ToastFailedToShare": "Error en compartir", + "ToastFailedToUpdate": "Error en actualitzar", + "ToastInvalidImageUrl": "URL de la imatge no vàlida", + "ToastInvalidMaxEpisodesToDownload": "Nombre màxim d'episodis per descarregar no vàlid", + "ToastInvalidUrl": "URL no vàlida", + "ToastItemCoverUpdateSuccess": "Portada de l'element actualitzada", + "ToastItemDeletedFailed": "Error en eliminar l'element", + "ToastItemDeletedSuccess": "Element eliminat", + "ToastItemDetailsUpdateSuccess": "Detalls de l'element actualitzats", + "ToastItemMarkedAsFinishedFailed": "Error en marcar com a acabat", + "ToastItemMarkedAsFinishedSuccess": "Element marcat com a acabat", + "ToastItemMarkedAsNotFinishedFailed": "Error en marcar com a no acabat", + "ToastItemMarkedAsNotFinishedSuccess": "Element marcat com a no acabat", + "ToastItemUpdateSuccess": "Element actualitzat", + "ToastLibraryCreateFailed": "Error en crear la biblioteca", + "ToastLibraryCreateSuccess": "Biblioteca \"{0}\" creada", + "ToastLibraryDeleteFailed": "Error en eliminar la biblioteca", + "ToastLibraryDeleteSuccess": "Biblioteca eliminada", + "ToastLibraryScanFailedToStart": "Error en iniciar l'escaneig", + "ToastLibraryScanStarted": "S'ha iniciat l'escaneig de la biblioteca", + "ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" actualitzada", + "ToastMatchAllAuthorsFailed": "No coincideix amb tots els autors", + "ToastMetadataFilesRemovedError": "Error en eliminar metadades de {0} arxius", + "ToastMetadataFilesRemovedNoneFound": "No s'han trobat metadades en {0} arxius", + "ToastMetadataFilesRemovedNoneRemoved": "Cap metadada eliminada en {0} arxius", + "ToastMetadataFilesRemovedSuccess": "{0} metadades eliminades en {1} arxius", + "ToastMustHaveAtLeastOnePath": "Ha de tenir almenys una ruta", + "ToastNameEmailRequired": "El nom i el correu electrònic són obligatoris", + "ToastNameRequired": "Nom obligatori", + "ToastNewEpisodesFound": "{0} episodi(s) nou(s) trobat(s)", + "ToastNewUserCreatedFailed": "Error en crear el compte: \"{0}\"", + "ToastNewUserCreatedSuccess": "Nou compte creat", + "ToastNewUserLibraryError": "Ha de seleccionar almenys una biblioteca", + "ToastNewUserPasswordError": "Necessites una contrasenya, només el root pot estar sense contrasenya", + "ToastNewUserTagError": "Selecciona almenys una etiqueta", + "ToastNewUserUsernameError": "Introdueix un nom d'usuari", + "ToastNoNewEpisodesFound": "No s'han trobat nous episodis", + "ToastNoUpdatesNecessary": "No cal actualitzar", + "ToastNotificationCreateFailed": "Error en crear la notificació", + "ToastNotificationDeleteFailed": "Error en eliminar la notificació", + "ToastNotificationFailedMaximum": "El nombre màxim d'intents fallits ha de ser ≥ 0", + "ToastNotificationQueueMaximum": "La cua de notificació màxima ha de ser ≥ 0", + "ToastNotificationSettingsUpdateSuccess": "Configuració de notificació actualitzada", + "ToastNotificationTestTriggerFailed": "No s'ha pogut activar la notificació de prova", + "ToastNotificationTestTriggerSuccess": "Notificació de prova activada", + "ToastNotificationUpdateSuccess": "Notificació actualitzada", + "ToastPlaylistCreateFailed": "Error en crear la llista de reproducció", + "ToastPlaylistCreateSuccess": "Llista de reproducció creada", + "ToastPlaylistRemoveSuccess": "Llista de reproducció eliminada", + "ToastPlaylistUpdateSuccess": "Llista de reproducció actualitzada", + "ToastPodcastCreateFailed": "Error en crear el podcast", + "ToastPodcastCreateSuccess": "Podcast creat", + "ToastPodcastGetFeedFailed": "No s'ha pogut obtenir el podcast", + "ToastPodcastNoEpisodesInFeed": "No s'han trobat episodis en el feed RSS", + "ToastPodcastNoRssFeed": "El podcast no té un feed RSS", + "ToastProgressIsNotBeingSynced": "El progrés no s'està sincronitzant, reinicia la reproducció", + "ToastProviderCreatedFailed": "Error en afegir el proveïdor", + "ToastProviderCreatedSuccess": "Nou proveïdor afegit", + "ToastProviderNameAndUrlRequired": "Nom i URL obligatoris", + "ToastProviderRemoveSuccess": "Proveïdor eliminat", + "ToastRSSFeedCloseFailed": "Error en tancar el feed RSS", + "ToastRSSFeedCloseSuccess": "Feed RSS tancat", + "ToastRemoveFailed": "Error en eliminar", + "ToastRemoveItemFromCollectionFailed": "Error en eliminar l'element de la col·lecció", + "ToastRemoveItemFromCollectionSuccess": "Element eliminat de la col·lecció", + "ToastRemoveItemsWithIssuesFailed": "Error en eliminar elements incorrectes de la biblioteca", + "ToastRemoveItemsWithIssuesSuccess": "S'han eliminat els elements incorrectes de la biblioteca", + "ToastRenameFailed": "Error en canviar el nom", + "ToastRescanFailed": "Error en reescanejar per a {0}", + "ToastRescanRemoved": "Element reescanejat eliminat", + "ToastRescanUpToDate": "Reescaneig completat, l'element ja estava actualitzat", + "ToastRescanUpdated": "Reescaneig completat, l'element ha estat actualitzat", + "ToastScanFailed": "No s'ha pogut escanejar l'element de la biblioteca", + "ToastSelectAtLeastOneUser": "Selecciona almenys un usuari", + "ToastSendEbookToDeviceFailed": "Error en enviar l'ebook al dispositiu", + "ToastSendEbookToDeviceSuccess": "Ebook enviat al dispositiu \"{0}\"", + "ToastSeriesUpdateFailed": "Error en actualitzar la sèrie", + "ToastSeriesUpdateSuccess": "Sèrie actualitzada", + "ToastServerSettingsUpdateSuccess": "Configuració del servidor actualitzada", + "ToastSessionCloseFailed": "Error en tancar la sessió", + "ToastSessionDeleteFailed": "Error en eliminar la sessió", + "ToastSessionDeleteSuccess": "Sessió eliminada", + "ToastSleepTimerDone": "Temporitzador d'apagada activat... zZzzZz", + "ToastSlugMustChange": "L'slug conté caràcters no vàlids", + "ToastSlugRequired": "Slug obligatori", + "ToastSocketConnected": "Socket connectat", + "ToastSocketDisconnected": "Socket desconnectat", + "ToastSocketFailedToConnect": "Error en connectar al Socket", + "ToastSortingPrefixesEmptyError": "Cal tenir almenys 1 prefix per ordenar", + "ToastSortingPrefixesUpdateSuccess": "Prefixos d'ordenació actualitzats ({0} elements)", + "ToastTitleRequired": "Títol obligatori", + "ToastUnknownError": "Error desconegut", + "ToastUnlinkOpenIdFailed": "Error en desvincular l'usuari d'OpenID", + "ToastUnlinkOpenIdSuccess": "Usuari desvinculat d'OpenID", + "ToastUserDeleteFailed": "Error en eliminar l'usuari", + "ToastUserDeleteSuccess": "Usuari eliminat", + "ToastUserPasswordChangeSuccess": "Contrasenya canviada correctament", + "ToastUserPasswordMismatch": "Les contrasenyes no coincideixen", + "ToastUserPasswordMustChange": "La nova contrasenya no pot ser igual a l'anterior", + "ToastUserRootRequireName": "Cal introduir un nom d'usuari root" +} + + From 7486d6345dd03357a6e069bd789cdaad5da785c2 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 7 Dec 2024 09:34:06 +0100 Subject: [PATCH 30/41] Resolved a server crash when a playback session lacked associated media metadata. --- server/utils/queries/userStats.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/utils/queries/userStats.js b/server/utils/queries/userStats.js index 76b69ed78..fbba7129a 100644 --- a/server/utils/queries/userStats.js +++ b/server/utils/queries/userStats.js @@ -127,20 +127,20 @@ module.exports = { bookListeningMap[ls.displayTitle] += listeningSessionListeningTime } - const authors = ls.mediaMetadata.authors || [] + const authors = ls.mediaMetadata?.authors || [] authors.forEach((au) => { if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0 authorListeningMap[au.name] += listeningSessionListeningTime }) - const narrators = ls.mediaMetadata.narrators || [] + const narrators = ls.mediaMetadata?.narrators || [] narrators.forEach((narrator) => { if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0 narratorListeningMap[narrator] += listeningSessionListeningTime }) // Filter out bad genres like "audiobook" and "audio book" - const genres = (ls.mediaMetadata.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) + const genres = (ls.mediaMetadata?.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) genres.forEach((genre) => { if (!genreListeningMap[genre]) genreListeningMap[genre] = 0 genreListeningMap[genre] += listeningSessionListeningTime From 9b8e059efe68bb21500f2b84de36f54d5750ba97 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 7 Dec 2024 19:27:37 +0200 Subject: [PATCH 31/41] Remove serverAddress from Feeds and FeedEpisodes URLs --- .../modals/rssfeed/OpenCloseModal.vue | 9 +- .../modals/rssfeed/ViewFeedModal.vue | 7 +- client/pages/config/rss-feeds.vue | 2 +- server/Server.js | 4 + server/managers/RssFeedManager.js | 2 +- .../v2.17.5-remove-host-from-feed-urls.js | 74 +++++++ server/objects/Feed.js | 30 +-- server/objects/FeedEpisode.js | 16 +- server/objects/FeedMeta.js | 32 ++- ...v2.17.5-remove-host-from-feed-urls.test.js | 202 ++++++++++++++++++ 10 files changed, 331 insertions(+), 47 deletions(-) create mode 100644 server/migrations/v2.17.5-remove-host-from-feed-urls.js create mode 100644 test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js diff --git a/client/components/modals/rssfeed/OpenCloseModal.vue b/client/components/modals/rssfeed/OpenCloseModal.vue index 53542cf55..4eff94013 100644 --- a/client/components/modals/rssfeed/OpenCloseModal.vue +++ b/client/components/modals/rssfeed/OpenCloseModal.vue @@ -10,9 +10,9 @@

{{ $strings.HeaderRSSFeedIsOpen }}

- + - content_copy + content_copy
@@ -111,8 +111,11 @@ export default { userIsAdminOrUp() { return this.$store.getters['user/getIsAdminOrUp'] }, + feedUrl() { + return this.currentFeed ? `${window.origin}${this.$config.routerBasePath}${this.currentFeed.feedUrl}` : '' + }, demoFeedUrl() { - return `${window.origin}/feed/${this.newFeedSlug}` + return `${window.origin}${this.$config.routerBasePath}/feed/${this.newFeedSlug}` }, isHttp() { return window.origin.startsWith('http://') diff --git a/client/components/modals/rssfeed/ViewFeedModal.vue b/client/components/modals/rssfeed/ViewFeedModal.vue index cd06350bb..704125179 100644 --- a/client/components/modals/rssfeed/ViewFeedModal.vue +++ b/client/components/modals/rssfeed/ViewFeedModal.vue @@ -5,8 +5,8 @@

{{ $strings.HeaderRSSFeedGeneral }}

- - content_copy + + content_copy
@@ -70,6 +70,9 @@ export default { }, _feed() { return this.feed || {} + }, + feedUrl() { + return this.feed ? `${window.origin}${this.$config.routerBasePath}${this.feed.feedUrl}` : '' } }, methods: { diff --git a/client/pages/config/rss-feeds.vue b/client/pages/config/rss-feeds.vue index 68117a859..039e9a0df 100644 --- a/client/pages/config/rss-feeds.vue +++ b/client/pages/config/rss-feeds.vue @@ -126,7 +126,7 @@ export default { }, coverUrl(feed) { if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png` - return `${feed.feedUrl}/cover` + return `${this.$config.routerBasePath}${feed.feedUrl}/cover` }, async loadFeeds() { const data = await this.$axios.$get(`/api/feeds`).catch((err) => { diff --git a/server/Server.js b/server/Server.js index cd96733e9..dfcb474a4 100644 --- a/server/Server.js +++ b/server/Server.js @@ -253,6 +253,10 @@ class Server { // if RouterBasePath is set, modify all requests to include the base path if (global.RouterBasePath) { app.use((req, res, next) => { + const host = req.get('host') + const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' + const prefix = req.url.startsWith(global.RouterBasePath) ? global.RouterBasePath : '' + req.originalHostPrefix = `${protocol}://${host}${prefix}` if (!req.url.startsWith(global.RouterBasePath)) { req.url = `${global.RouterBasePath}${req.url}` } diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index 7716440df..8984a39b5 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -162,7 +162,7 @@ class RssFeedManager { } } - const xml = feed.buildXml() + const xml = feed.buildXml(req.originalHostPrefix) res.set('Content-Type', 'text/xml') res.send(xml) } diff --git a/server/migrations/v2.17.5-remove-host-from-feed-urls.js b/server/migrations/v2.17.5-remove-host-from-feed-urls.js new file mode 100644 index 000000000..e08877f23 --- /dev/null +++ b/server/migrations/v2.17.5-remove-host-from-feed-urls.js @@ -0,0 +1,74 @@ +/** + * @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.17.5' +const migrationName = `${migrationVersion}-remove-host-from-feed-urls` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + logger.info(`${loggerPrefix} Removing serverAddress from Feeds table URLs`) + await queryInterface.sequelize.query(` + UPDATE Feeds + SET feedUrl = REPLACE(feedUrl, COALESCE(serverAddress, ''), ''), + imageUrl = REPLACE(imageUrl, COALESCE(serverAddress, ''), ''), + siteUrl = REPLACE(siteUrl, COALESCE(serverAddress, ''), ''); + `) + logger.info(`${loggerPrefix} Removed serverAddress from Feeds table URLs`) + + logger.info(`${loggerPrefix} Removing serverAddress from FeedEpisodes table URLs`) + await queryInterface.sequelize.query(` + UPDATE FeedEpisodes + SET siteUrl = REPLACE(siteUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''), + enclosureUrl = REPLACE(enclosureUrl, (SELECT COALESCE(serverAddress, '') FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), ''); + `) + logger.info(`${loggerPrefix} Removed serverAddress from FeedEpisodes table URLs`) + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script adds the host (serverAddress) back to URL columns in the feeds and feedEpisodes tables. + * + * @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}`) + + logger.info(`${loggerPrefix} Adding serverAddress back to Feeds table URLs`) + await queryInterface.sequelize.query(` + UPDATE Feeds + SET feedUrl = COALESCE(serverAddress, '') || feedUrl, + imageUrl = COALESCE(serverAddress, '') || imageUrl, + siteUrl = COALESCE(serverAddress, '') || siteUrl; + `) + logger.info(`${loggerPrefix} Added serverAddress back to Feeds table URLs`) + + logger.info(`${loggerPrefix} Adding serverAddress back to FeedEpisodes table URLs`) + await queryInterface.sequelize.query(` + UPDATE FeedEpisodes + SET siteUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.siteUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId), + enclosureUrl = (SELECT COALESCE(serverAddress, '') || FeedEpisodes.enclosureUrl FROM Feeds WHERE Feeds.id = FeedEpisodes.feedId); + `) + logger.info(`${loggerPrefix} Added serverAddress back to FeedEpisodes table URLs`) + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/objects/Feed.js b/server/objects/Feed.js index 74a220e35..da76067d4 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -109,7 +109,7 @@ class Feed { const mediaMetadata = media.metadata const isPodcast = libraryItem.mediaType === 'podcast' - const feedUrl = `${serverAddress}/feed/${slug}` + const feedUrl = `/feed/${slug}` const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName this.id = uuidv4() @@ -128,9 +128,9 @@ class Feed { this.meta.title = mediaMetadata.title this.meta.description = mediaMetadata.description this.meta.author = author - this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` + this.meta.imageUrl = media.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.feedUrl = feedUrl - this.meta.link = `${serverAddress}/item/${libraryItem.id}` + this.meta.link = `/item/${libraryItem.id}` this.meta.explicit = !!mediaMetadata.explicit this.meta.type = mediaMetadata.type this.meta.language = mediaMetadata.language @@ -176,7 +176,7 @@ class Feed { this.meta.title = mediaMetadata.title this.meta.description = mediaMetadata.description this.meta.author = author - this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = media.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.explicit = !!mediaMetadata.explicit this.meta.type = mediaMetadata.type this.meta.language = mediaMetadata.language @@ -206,7 +206,7 @@ class Feed { } setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { - const feedUrl = `${serverAddress}/feed/${slug}` + const feedUrl = `/feed/${slug}` const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length) const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath) @@ -227,9 +227,9 @@ class Feed { this.meta.title = collectionExpanded.name this.meta.description = collectionExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.feedUrl = feedUrl - this.meta.link = `${serverAddress}/collection/${collectionExpanded.id}` + this.meta.link = `/collection/${collectionExpanded.id}` this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit this.meta.preventIndexing = preventIndexing this.meta.ownerName = ownerName @@ -272,7 +272,7 @@ class Feed { this.meta.title = collectionExpanded.name this.meta.description = collectionExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit this.episodes = [] @@ -301,7 +301,7 @@ class Feed { } setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { - const feedUrl = `${serverAddress}/feed/${slug}` + const feedUrl = `/feed/${slug}` let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length) // Sort series items by series sequence @@ -326,9 +326,9 @@ class Feed { this.meta.title = seriesExpanded.name this.meta.description = seriesExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.feedUrl = feedUrl - this.meta.link = `${serverAddress}/library/${libraryId}/series/${seriesExpanded.id}` + this.meta.link = `/library/${libraryId}/series/${seriesExpanded.id}` this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit this.meta.preventIndexing = preventIndexing this.meta.ownerName = ownerName @@ -374,7 +374,7 @@ class Feed { this.meta.title = seriesExpanded.name this.meta.description = seriesExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png` this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit this.episodes = [] @@ -402,12 +402,12 @@ class Feed { this.xml = null } - buildXml() { + buildXml(originalHostPrefix) { if (this.xml) return this.xml - var rssfeed = new RSS(this.meta.getRSSData()) + var rssfeed = new RSS(this.meta.getRSSData(originalHostPrefix)) this.episodes.forEach((ep) => { - rssfeed.item(ep.getRSSData()) + rssfeed.item(ep.getRSSData(originalHostPrefix)) }) this.xml = rssfeed.xml() return this.xml diff --git a/server/objects/FeedEpisode.js b/server/objects/FeedEpisode.js index 6d9f36a08..13d590ff7 100644 --- a/server/objects/FeedEpisode.js +++ b/server/objects/FeedEpisode.js @@ -79,7 +79,7 @@ class FeedEpisode { this.title = episode.title this.description = episode.description || '' this.enclosure = { - url: `${serverAddress}${contentUrl}`, + url: `${contentUrl}`, type: episode.audioTrack.mimeType, size: episode.size } @@ -136,7 +136,7 @@ class FeedEpisode { this.title = title this.description = mediaMetadata.description || '' this.enclosure = { - url: `${serverAddress}${contentUrl}`, + url: `${contentUrl}`, type: audioTrack.mimeType, size: audioTrack.metadata.size } @@ -151,15 +151,19 @@ class FeedEpisode { this.fullPath = audioTrack.metadata.path } - getRSSData() { + getRSSData(hostPrefix) { return { title: this.title, description: this.description || '', - url: this.link, - guid: this.enclosure.url, + url: `${hostPrefix}${this.link}`, + guid: `${hostPrefix}${this.enclosure.url}`, author: this.author, date: this.pubDate, - enclosure: this.enclosure, + enclosure: { + url: `${hostPrefix}${this.enclosure.url}`, + type: this.enclosure.type, + size: this.enclosure.size + }, custom_elements: [ { 'itunes:author': this.author }, { 'itunes:duration': secondsToTimestamp(this.duration) }, diff --git a/server/objects/FeedMeta.js b/server/objects/FeedMeta.js index 307e12bc1..e439fe8f7 100644 --- a/server/objects/FeedMeta.js +++ b/server/objects/FeedMeta.js @@ -60,42 +60,36 @@ class FeedMeta { } } - getRSSData() { - const blockTags = [ - { 'itunes:block': 'yes' }, - { 'googleplay:block': 'yes' } - ] + getRSSData(hostPrefix) { + const blockTags = [{ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' }] return { title: this.title, description: this.description || '', generator: 'Audiobookshelf', - feed_url: this.feedUrl, - site_url: this.link, - image_url: this.imageUrl, + feed_url: `${hostPrefix}${this.feedUrl}`, + site_url: `${hostPrefix}${this.link}`, + image_url: `${hostPrefix}${this.imageUrl}`, custom_namespaces: { - 'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd', - 'psc': 'http://podlove.org/simple-chapters', - 'podcast': 'https://podcastindex.org/namespace/1.0', - 'googleplay': 'http://www.google.com/schemas/play-podcasts/1.0' + itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd', + psc: 'http://podlove.org/simple-chapters', + podcast: 'https://podcastindex.org/namespace/1.0', + googleplay: 'http://www.google.com/schemas/play-podcasts/1.0' }, custom_elements: [ - { 'language': this.language || 'en' }, - { 'author': this.author || 'advplyr' }, + { language: this.language || 'en' }, + { author: this.author || 'advplyr' }, { 'itunes:author': this.author || 'advplyr' }, { 'itunes:summary': this.description || '' }, { 'itunes:type': this.type }, { 'itunes:image': { _attr: { - href: this.imageUrl + href: `${hostPrefix}${this.imageUrl}` } } }, { - 'itunes:owner': [ - { 'itunes:name': this.ownerName || this.author || '' }, - { 'itunes:email': this.ownerEmail || '' } - ] + 'itunes:owner': [{ 'itunes:name': this.ownerName || this.author || '' }, { 'itunes:email': this.ownerEmail || '' }] }, { 'itunes:explicit': !!this.explicit }, ...(this.preventIndexing ? blockTags : []) diff --git a/test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js b/test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js new file mode 100644 index 000000000..786ed6ae6 --- /dev/null +++ b/test/server/migrations/v2.17.5-remove-host-from-feed-urls.test.js @@ -0,0 +1,202 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { up, down } = require('../../../server/migrations/v2.17.5-remove-host-from-feed-urls') +const { Sequelize, DataTypes } = require('sequelize') +const Logger = require('../../../server/Logger') + +const defineModels = (sequelize) => { + const Feeds = sequelize.define('Feeds', { + id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 }, + feedUrl: { type: DataTypes.STRING }, + imageUrl: { type: DataTypes.STRING }, + siteUrl: { type: DataTypes.STRING }, + serverAddress: { type: DataTypes.STRING } + }) + + const FeedEpisodes = sequelize.define('FeedEpisodes', { + id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 }, + feedId: { type: DataTypes.UUID }, + siteUrl: { type: DataTypes.STRING }, + enclosureUrl: { type: DataTypes.STRING } + }) + + return { Feeds, FeedEpisodes } +} + +describe('Migration v2.17.4-use-subfolder-for-oidc-redirect-uris', () => { + let queryInterface, logger, context + let sequelize + let Feeds, FeedEpisodes + const feed1Id = '00000000-0000-4000-a000-000000000001' + const feed2Id = '00000000-0000-4000-a000-000000000002' + const feedEpisode1Id = '00000000-4000-a000-0000-000000000011' + const feedEpisode2Id = '00000000-4000-a000-0000-000000000012' + const feedEpisode3Id = '00000000-4000-a000-0000-000000000021' + + before(async () => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + ;({ Feeds, FeedEpisodes } = defineModels(sequelize)) + await sequelize.sync() + }) + + after(async () => { + await sequelize.close() + }) + + beforeEach(async () => { + // Reset tables before each test + await Feeds.destroy({ where: {}, truncate: true }) + await FeedEpisodes.destroy({ where: {}, truncate: true }) + + logger = { + info: sinon.stub(), + error: sinon.stub() + } + context = { queryInterface, logger } + }) + + describe('up', () => { + it('should remove serverAddress from URLs in Feeds and FeedEpisodes tables', async () => { + await Feeds.bulkCreate([ + { id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: 'http://server1.com/img1', siteUrl: 'http://server1.com/site1', serverAddress: 'http://server1.com' }, + { id: feed2Id, feedUrl: 'http://server2.com/feed2', imageUrl: 'http://server2.com/img2', siteUrl: 'http://server2.com/site2', serverAddress: 'http://server2.com' } + ]) + + await FeedEpisodes.bulkCreate([ + { id: feedEpisode1Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode11', enclosureUrl: 'http://server1.com/enclosure11' }, + { id: feedEpisode2Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode12', enclosureUrl: 'http://server1.com/enclosure12' }, + { id: feedEpisode3Id, feedId: feed2Id, siteUrl: 'http://server2.com/episode21', enclosureUrl: 'http://server2.com/enclosure21' } + ]) + + await up({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(logger.info.calledWith('[2.17.5 migration] UPGRADE BEGIN: 2.17.5-remove-host-from-feed-urls')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] Removing serverAddress from Feeds table URLs')).to.be.true + + expect(feeds[0].feedUrl).to.equal('/feed1') + expect(feeds[0].imageUrl).to.equal('/img1') + expect(feeds[0].siteUrl).to.equal('/site1') + expect(feeds[1].feedUrl).to.equal('/feed2') + expect(feeds[1].imageUrl).to.equal('/img2') + expect(feeds[1].siteUrl).to.equal('/site2') + + expect(logger.info.calledWith('[2.17.5 migration] Removed serverAddress from Feeds table URLs')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] Removing serverAddress from FeedEpisodes table URLs')).to.be.true + + expect(feedEpisodes[0].siteUrl).to.equal('/episode11') + expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11') + expect(feedEpisodes[1].siteUrl).to.equal('/episode12') + expect(feedEpisodes[1].enclosureUrl).to.equal('/enclosure12') + expect(feedEpisodes[2].siteUrl).to.equal('/episode21') + expect(feedEpisodes[2].enclosureUrl).to.equal('/enclosure21') + + expect(logger.info.calledWith('[2.17.5 migration] Removed serverAddress from FeedEpisodes table URLs')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] UPGRADE END: 2.17.5-remove-host-from-feed-urls')).to.be.true + }) + + it('should handle null URLs in Feeds and FeedEpisodes tables', async () => { + await Feeds.bulkCreate([{ id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: null, siteUrl: 'http://server1.com/site1', serverAddress: 'http://server1.com' }]) + + await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: null, enclosureUrl: 'http://server1.com/enclosure11' }]) + + await up({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(feeds[0].feedUrl).to.equal('/feed1') + expect(feeds[0].imageUrl).to.be.null + expect(feeds[0].siteUrl).to.equal('/site1') + expect(feedEpisodes[0].siteUrl).to.be.null + expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11') + }) + + it('should handle null serverAddress in Feeds table', async () => { + await Feeds.bulkCreate([{ id: feed1Id, feedUrl: 'http://server1.com/feed1', imageUrl: 'http://server1.com/img1', siteUrl: 'http://server1.com/site1', serverAddress: null }]) + await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: 'http://server1.com/episode11', enclosureUrl: 'http://server1.com/enclosure11' }]) + + await up({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1') + expect(feeds[0].imageUrl).to.equal('http://server1.com/img1') + expect(feeds[0].siteUrl).to.equal('http://server1.com/site1') + expect(feedEpisodes[0].siteUrl).to.equal('http://server1.com/episode11') + expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11') + }) + }) + + describe('down', () => { + it('should add serverAddress back to URLs in Feeds and FeedEpisodes tables', async () => { + await Feeds.bulkCreate([ + { id: feed1Id, feedUrl: '/feed1', imageUrl: '/img1', siteUrl: '/site1', serverAddress: 'http://server1.com' }, + { id: feed2Id, feedUrl: '/feed2', imageUrl: '/img2', siteUrl: '/site2', serverAddress: 'http://server2.com' } + ]) + + await FeedEpisodes.bulkCreate([ + { id: feedEpisode1Id, feedId: feed1Id, siteUrl: '/episode11', enclosureUrl: '/enclosure11' }, + { id: feedEpisode2Id, feedId: feed1Id, siteUrl: '/episode12', enclosureUrl: '/enclosure12' }, + { id: feedEpisode3Id, feedId: feed2Id, siteUrl: '/episode21', enclosureUrl: '/enclosure21' } + ]) + + await down({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(logger.info.calledWith('[2.17.5 migration] DOWNGRADE BEGIN: 2.17.5-remove-host-from-feed-urls')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] Adding serverAddress back to Feeds table URLs')).to.be.true + + expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1') + expect(feeds[0].imageUrl).to.equal('http://server1.com/img1') + expect(feeds[0].siteUrl).to.equal('http://server1.com/site1') + expect(feeds[1].feedUrl).to.equal('http://server2.com/feed2') + expect(feeds[1].imageUrl).to.equal('http://server2.com/img2') + expect(feeds[1].siteUrl).to.equal('http://server2.com/site2') + + expect(logger.info.calledWith('[2.17.5 migration] Added serverAddress back to Feeds table URLs')).to.be.true + expect(logger.info.calledWith('[2.17.5 migration] Adding serverAddress back to FeedEpisodes table URLs')).to.be.true + + expect(feedEpisodes[0].siteUrl).to.equal('http://server1.com/episode11') + expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11') + expect(feedEpisodes[1].siteUrl).to.equal('http://server1.com/episode12') + expect(feedEpisodes[1].enclosureUrl).to.equal('http://server1.com/enclosure12') + expect(feedEpisodes[2].siteUrl).to.equal('http://server2.com/episode21') + expect(feedEpisodes[2].enclosureUrl).to.equal('http://server2.com/enclosure21') + + expect(logger.info.calledWith('[2.17.5 migration] DOWNGRADE END: 2.17.5-remove-host-from-feed-urls')).to.be.true + }) + + it('should handle null URLs in Feeds and FeedEpisodes tables', async () => { + await Feeds.bulkCreate([{ id: feed1Id, feedUrl: '/feed1', imageUrl: null, siteUrl: '/site1', serverAddress: 'http://server1.com' }]) + await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: null, enclosureUrl: '/enclosure11' }]) + + await down({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(feeds[0].feedUrl).to.equal('http://server1.com/feed1') + expect(feeds[0].imageUrl).to.be.null + expect(feeds[0].siteUrl).to.equal('http://server1.com/site1') + expect(feedEpisodes[0].siteUrl).to.be.null + expect(feedEpisodes[0].enclosureUrl).to.equal('http://server1.com/enclosure11') + }) + + it('should handle null serverAddress in Feeds table', async () => { + await Feeds.bulkCreate([{ id: feed1Id, feedUrl: '/feed1', imageUrl: '/img1', siteUrl: '/site1', serverAddress: null }]) + await FeedEpisodes.bulkCreate([{ id: feedEpisode1Id, feedId: feed1Id, siteUrl: '/episode11', enclosureUrl: '/enclosure11' }]) + + await down({ context }) + const feeds = await Feeds.findAll({ raw: true }) + const feedEpisodes = await FeedEpisodes.findAll({ raw: true }) + + expect(feeds[0].feedUrl).to.equal('/feed1') + expect(feeds[0].imageUrl).to.equal('/img1') + expect(feeds[0].siteUrl).to.equal('/site1') + expect(feedEpisodes[0].siteUrl).to.equal('/episode11') + expect(feedEpisodes[0].enclosureUrl).to.equal('/enclosure11') + }) + }) +}) From 6fa11934be0e7c5b28c423f495377980a7e9fb63 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 7 Dec 2024 15:15:47 -0600 Subject: [PATCH 32/41] Add:Catalan language option --- client/plugins/i18n.js | 1 + client/strings/ca.json | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/client/plugins/i18n.js b/client/plugins/i18n.js index 0ec5cccee..12d2b44bc 100644 --- a/client/plugins/i18n.js +++ b/client/plugins/i18n.js @@ -7,6 +7,7 @@ const defaultCode = 'en-us' const languageCodeMap = { bg: { label: 'Български', dateFnsLocale: 'bg' }, bn: { label: 'বাংলা', dateFnsLocale: 'bn' }, + ca: { label: 'Català', dateFnsLocale: 'ca' }, cs: { label: 'Čeština', dateFnsLocale: 'cs' }, da: { label: 'Dansk', dateFnsLocale: 'da' }, de: { label: 'Deutsch', dateFnsLocale: 'de' }, diff --git a/client/strings/ca.json b/client/strings/ca.json index 8dde850b8..f7e85ae25 100644 --- a/client/strings/ca.json +++ b/client/strings/ca.json @@ -1025,5 +1025,3 @@ "ToastUserPasswordMustChange": "La nova contrasenya no pot ser igual a l'anterior", "ToastUserRootRequireName": "Cal introduir un nom d'usuari root" } - - From a8ab8badd5c42e1794715a370b6a8ae60c6b8652 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 8 Dec 2024 09:23:39 +0200 Subject: [PATCH 33/41] always set req.originalHostPrefix --- server/Server.js | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/server/Server.js b/server/Server.js index dfcb474a4..795982750 100644 --- a/server/Server.js +++ b/server/Server.js @@ -251,18 +251,17 @@ class Server { const router = express.Router() // if RouterBasePath is set, modify all requests to include the base path - if (global.RouterBasePath) { - app.use((req, res, next) => { - const host = req.get('host') - const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' - const prefix = req.url.startsWith(global.RouterBasePath) ? global.RouterBasePath : '' - req.originalHostPrefix = `${protocol}://${host}${prefix}` - if (!req.url.startsWith(global.RouterBasePath)) { - req.url = `${global.RouterBasePath}${req.url}` - } - next() - }) - } + app.use((req, res, next) => { + const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath) + const host = req.get('host') + const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' + const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : '' + req.originalHostPrefix = `${protocol}://${host}${prefix}` + if (!urlStartsWithRouterBasePath) { + req.url = `${global.RouterBasePath}${req.url}` + } + next() + }) app.use(global.RouterBasePath, router) app.disable('x-powered-by') From b38ce4173144a9d33330ac3b59fbf7faf8320292 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 8 Dec 2024 09:48:58 +0200 Subject: [PATCH 34/41] Remove xml cache from Feed object --- server/objects/Feed.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/server/objects/Feed.js b/server/objects/Feed.js index da76067d4..ac50b899f 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -29,9 +29,6 @@ class Feed { this.createdAt = null this.updatedAt = null - // Cached xml - this.xml = null - if (feed) { this.construct(feed) } @@ -202,7 +199,6 @@ class Feed { } this.updatedAt = Date.now() - this.xml = null } setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { @@ -297,7 +293,6 @@ class Feed { }) this.updatedAt = Date.now() - this.xml = null } setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { @@ -399,18 +394,14 @@ class Feed { }) this.updatedAt = Date.now() - this.xml = null } buildXml(originalHostPrefix) { - if (this.xml) return this.xml - var rssfeed = new RSS(this.meta.getRSSData(originalHostPrefix)) this.episodes.forEach((ep) => { rssfeed.item(ep.getRSSData(originalHostPrefix)) }) - this.xml = rssfeed.xml() - return this.xml + return rssfeed.xml() } getAuthorsStringFromLibraryItems(libraryItems) { From 5646466aa371cc03f12496cd0a1d28de34839734 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 8 Dec 2024 08:05:33 -0600 Subject: [PATCH 35/41] Update JSDocs for feeds endpoints --- server/managers/RssFeedManager.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index 8984a39b5..583f0bb67 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -1,3 +1,4 @@ +const { Request, Response } = require('express') const Path = require('path') const Logger = require('../Logger') @@ -77,6 +78,12 @@ class RssFeedManager { return Database.feedModel.findByPkOld(id) } + /** + * GET: /feed/:slug + * + * @param {Request} req + * @param {Response} res + */ async getFeed(req, res) { const feed = await this.findFeedBySlug(req.params.slug) if (!feed) { @@ -167,6 +174,12 @@ class RssFeedManager { res.send(xml) } + /** + * GET: /feed/:slug/item/:episodeId/* + * + * @param {Request} req + * @param {Response} res + */ async getFeedItem(req, res) { const feed = await this.findFeedBySlug(req.params.slug) if (!feed) { @@ -183,6 +196,12 @@ class RssFeedManager { res.sendFile(episodePath) } + /** + * GET: /feed/:slug/cover* + * + * @param {Request} req + * @param {Response} res + */ async getFeedCover(req, res) { const feed = await this.findFeedBySlug(req.params.slug) if (!feed) { From f7b7b85673fb8a5ac1a9b9c09e1bb686aa7d2f90 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 8 Dec 2024 08:19:23 -0600 Subject: [PATCH 36/41] Add v2.17.5 migration to changelog --- server/migrations/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index f46cd4ae7..f49924327 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -10,3 +10,4 @@ Please add a record of every database migration that you create to this file. Th | v2.17.0 | v2.17.0-uuid-replacement | Changes the data type of columns with UUIDv4 to UUID matching the associated model | | v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration | | v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | +| v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables | From 57906540fef30b2b8801e4abbf38ca12d7307f9f Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 8 Dec 2024 08:57:45 -0600 Subject: [PATCH 37/41] Add:Server setting to allow iframe & update UI to differentiate web client settings #3684 --- client/pages/config/index.vue | 47 ++++++++++++++--------- client/store/index.js | 11 +++--- client/strings/en-us.json | 2 + server/Server.js | 3 +- server/controllers/MiscController.js | 5 ++- server/objects/settings/ServerSettings.js | 8 ++++ 6 files changed, 49 insertions(+), 27 deletions(-) diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 1f0d61ebc..bbb75b934 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -42,11 +42,6 @@
-
- -

{{ $strings.LabelSettingsChromecastSupport }}

-
-

{{ $strings.HeaderSettingsScanner }}

@@ -94,6 +89,20 @@

+ +
+

{{ $strings.HeaderSettingsWebClient }}

+
+ +
+ +

{{ $strings.LabelSettingsChromecastSupport }}

+
+ +
+ +

{{ $strings.LabelSettingsAllowIframe }}

+
@@ -324,21 +333,21 @@ export default { }, updateServerSettings(payload) { this.updatingServerSettings = true - this.$store - .dispatch('updateServerSettings', payload) - .then(() => { - this.updatingServerSettings = false + this.$store.dispatch('updateServerSettings', payload).then((response) => { + this.updatingServerSettings = false - if (payload.language) { - // Updating language after save allows for re-rendering - this.$setLanguageCode(payload.language) - } - }) - .catch((error) => { - console.error('Failed to update server settings', error) - this.updatingServerSettings = false - this.$toast.error(this.$strings.ToastFailedToUpdate) - }) + if (response.error) { + console.error('Failed to update server settins', response.error) + this.$toast.error(response.error) + this.initServerSettings() + return + } + + if (payload.language) { + // Updating language after save allows for re-rendering + this.$setLanguageCode(payload.language) + } + }) }, initServerSettings() { this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {} diff --git a/client/store/index.js b/client/store/index.js index acd03eb46..2f2201b66 100644 --- a/client/store/index.js +++ b/client/store/index.js @@ -72,16 +72,17 @@ export const actions = { return this.$axios .$patch('/api/settings', updatePayload) .then((result) => { - if (result.success) { + if (result.serverSettings) { commit('setServerSettings', result.serverSettings) - return true - } else { - return false } + return result }) .catch((error) => { console.error('Failed to update server settings', error) - return false + const errorMsg = error.response?.data || 'Unknown error' + return { + error: errorMsg + } }) }, checkForUpdate({ commit }) { diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 75069cd33..805e8f48b 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -190,6 +190,7 @@ "HeaderSettingsExperimental": "Experimental Features", "HeaderSettingsGeneral": "General", "HeaderSettingsScanner": "Scanner", + "HeaderSettingsWebClient": "Web Client", "HeaderSleepTimer": "Sleep Timer", "HeaderStatsLargestItems": "Largest Items", "HeaderStatsLongestItems": "Longest Items (hrs)", @@ -542,6 +543,7 @@ "LabelServerYearReview": "Server Year in Review ({0})", "LabelSetEbookAsPrimary": "Set as primary", "LabelSetEbookAsSupplementary": "Set as supplementary", + "LabelSettingsAllowIframe": "Allow embedding in an iframe", "LabelSettingsAudiobooksOnly": "Audiobooks only", "LabelSettingsAudiobooksOnlyHelp": "Enabling this setting will ignore ebook files unless they are inside an audiobook folder in which case they will be set as supplementary ebooks", "LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves", diff --git a/server/Server.js b/server/Server.js index 795982750..2f1220d87 100644 --- a/server/Server.js +++ b/server/Server.js @@ -53,7 +53,6 @@ class Server { global.RouterBasePath = ROUTER_BASE_PATH global.XAccel = process.env.USE_X_ACCEL global.AllowCors = process.env.ALLOW_CORS === '1' - global.AllowIframe = process.env.ALLOW_IFRAME === '1' global.DisableSsrfRequestFilter = process.env.DISABLE_SSRF_REQUEST_FILTER === '1' if (!fs.pathExistsSync(global.ConfigPath)) { @@ -195,7 +194,7 @@ class Server { const app = express() app.use((req, res, next) => { - if (!global.AllowIframe) { + if (!global.ServerSettings.allowIframe) { // Prevent clickjacking by disallowing iframes res.setHeader('Content-Security-Policy', "frame-ancestors 'self'") } diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 2a87f2fef..b35619b70 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -126,6 +126,10 @@ class MiscController { if (!isObject(settingsUpdate)) { return res.status(400).send('Invalid settings update object') } + if (settingsUpdate.allowIframe == false && process.env.ALLOW_IFRAME === '1') { + Logger.warn('Cannot disable iframe when ALLOW_IFRAME is enabled in environment') + return res.status(400).send('Cannot disable iframe when ALLOW_IFRAME is enabled in environment') + } const madeUpdates = Database.serverSettings.update(settingsUpdate) if (madeUpdates) { @@ -137,7 +141,6 @@ class MiscController { } } return res.json({ - success: true, serverSettings: Database.serverSettings.toJSONForBrowser() }) } diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index ff28027f5..29913e449 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -24,6 +24,7 @@ class ServerSettings { // Security/Rate limits this.rateLimitLoginRequests = 10 this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes + this.allowIframe = false // Backups this.backupPath = Path.join(global.MetadataPath, 'backups') @@ -99,6 +100,7 @@ class ServerSettings { this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10 this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes + this.allowIframe = !!settings.allowIframe this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups') this.backupSchedule = settings.backupSchedule || false @@ -190,6 +192,11 @@ class ServerSettings { Logger.info(`[ServerSettings] Using backup path from environment variable ${process.env.BACKUP_PATH}`) this.backupPath = process.env.BACKUP_PATH } + + if (process.env.ALLOW_IFRAME === '1' && !this.allowIframe) { + Logger.info(`[ServerSettings] Using allowIframe from environment variable`) + this.allowIframe = true + } } toJSON() { @@ -207,6 +214,7 @@ class ServerSettings { metadataFileFormat: this.metadataFileFormat, rateLimitLoginRequests: this.rateLimitLoginRequests, rateLimitLoginWindow: this.rateLimitLoginWindow, + allowIframe: this.allowIframe, backupPath: this.backupPath, backupSchedule: this.backupSchedule, backupsToKeep: this.backupsToKeep, From 5f72e30e63884c731e520849be60f224d30a2278 Mon Sep 17 00:00:00 2001 From: Clara Papke Date: Fri, 6 Dec 2024 16:56:14 +0000 Subject: [PATCH 38/41] Translated using Weblate (German) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/de.json b/client/strings/de.json index 865065aa7..d3a10eadc 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Zeige player Einstellungen", "LabelViewQueue": "Player-Warteschlange anzeigen", "LabelVolume": "Lautstärke", + "LabelWebRedirectURLsDescription": "Autorisieren Sie diese URLs bei ihrem OAuth-Anbieter, um die Weiterleitung zurück zur Webanwendung nach dem Login zu ermöglichen:", + "LabelWebRedirectURLsSubfolder": "Unterordner für Weiterleitung-URLs", "LabelWeekdaysToRun": "Wochentage für die Ausführung", "LabelXBooks": "{0} Bücher", "LabelXItems": "{0} Medien", From e6d754113e95f780a3b18dd5be555da164048c76 Mon Sep 17 00:00:00 2001 From: Bezruchenko Simon Date: Fri, 6 Dec 2024 10:34:22 +0000 Subject: [PATCH 39/41] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/uk.json b/client/strings/uk.json index 448bbf4c8..f2342636b 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Переглянути налаштування програвача", "LabelViewQueue": "Переглянути чергу відтворення", "LabelVolume": "Гучність", + "LabelWebRedirectURLsDescription": "Авторизуйте ці URL у вашому OAuth постачальнику, щоб дозволити редирекцію назад до веб-додатку після входу:", + "LabelWebRedirectURLsSubfolder": "Підпапка для Redirect URL", "LabelWeekdaysToRun": "Виконувати у дні", "LabelXBooks": "{0} книг", "LabelXItems": "{0} елементів", From 8aaf62f2433aca7689e675a9c63b556ee19728e5 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Fri, 6 Dec 2024 10:03:47 +0000 Subject: [PATCH 40/41] Translated using Weblate (Slovenian) Currently translated at 100.0% (1074 of 1074 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index e80ac8b27..58500f9fb 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -679,6 +679,8 @@ "LabelViewPlayerSettings": "Ogled nastavitev predvajalnika", "LabelViewQueue": "Ogled čakalno vrsto predvajalnika", "LabelVolume": "Glasnost", + "LabelWebRedirectURLsDescription": "Avtorizirajte URL-je pri svojem ponudniku OAuth ter s tem omogočite preusmeritev nazaj v spletno aplikacijo po prijavi:", + "LabelWebRedirectURLsSubfolder": "Podmapa za URL-je preusmeritve", "LabelWeekdaysToRun": "Delovni dnevi predvajanja", "LabelXBooks": "{0} knjig", "LabelXItems": "{0} elementov", From 190a1000d9b5909b5bcd953f32f39fa8f261ecb9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 8 Dec 2024 09:03:05 -0600 Subject: [PATCH 41/41] Version bump v2.17.5 --- 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 e4e3236ce..807976bde 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.17.4", + "version": "2.17.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.17.4", + "version": "2.17.5", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index ea1919017..6f9d9d446 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.17.4", + "version": "2.17.5", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 10db84ea9..efa917dcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.17.4", + "version": "2.17.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.17.4", + "version": "2.17.5", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index c122240a7..2e9c97090 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.17.4", + "version": "2.17.5", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js",