From 4f30cbf2f652362f2fcb4ac1baa1af3104ad9fb5 Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 14 Oct 2025 18:09:32 +0300 Subject: [PATCH 01/24] SearchController: New providers API, query param validation --- server/controllers/SearchController.js | 298 ++++++++++++++++++++++--- 1 file changed, 267 insertions(+), 31 deletions(-) diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index bb3382f71..72f602d2a 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -6,6 +6,28 @@ const AuthorFinder = require('../finders/AuthorFinder') const Database = require('../Database') const { isValidASIN } = require('../utils') +// Provider name mappings for display purposes +const providerMap = { + all: 'All', + best: 'Best', + google: 'Google Books', + itunes: 'iTunes', + openlibrary: 'Open Library', + fantlab: 'FantLab.ru', + audiobookcovers: 'AudiobookCovers.com', + audible: 'Audible.com', + 'audible.ca': 'Audible.ca', + 'audible.uk': 'Audible.co.uk', + 'audible.au': 'Audible.com.au', + 'audible.fr': 'Audible.fr', + 'audible.de': 'Audible.de', + 'audible.jp': 'Audible.co.jp', + 'audible.it': 'Audible.it', + 'audible.in': 'Audible.in', + 'audible.es': 'Audible.es', + audnexus: 'Audnexus' +} + /** * @typedef RequestUserObject * @property {import('../models/User')} user @@ -16,6 +38,100 @@ const { isValidASIN } = require('../utils') class SearchController { constructor() {} + /** + * Validates that multiple parameters are strings + * @param {Object} params - Object with param names as keys and values to validate + * @param {string} methodName - Name of the calling method for logging + * @returns {{valid: boolean, error?: {status: number, message: string}}} + */ + static validateStringParams(params, methodName) { + for (const [key, value] of Object.entries(params)) { + if (typeof value !== 'string') { + Logger.error(`[SearchController] ${methodName}: Invalid ${key} parameter`) + return { + valid: false, + error: { + status: 400, + message: 'Invalid request query params' + } + } + } + } + return { valid: true } + } + + /** + * Validates that a required string parameter exists and is a string + * @param {any} value - Value to validate + * @param {string} paramName - Parameter name for logging + * @param {string} methodName - Name of the calling method for logging + * @returns {{valid: boolean, error?: {status: number, message: string}}} + */ + static validateRequiredString(value, paramName, methodName) { + if (!value || typeof value !== 'string') { + Logger.error(`[SearchController] ${methodName}: Invalid or missing ${paramName}`) + return { + valid: false, + error: { + status: 400, + message: `Invalid or missing ${paramName}` + } + } + } + return { valid: true } + } + + /** + * Validates and fetches a library item by ID + * @param {string} id - Library item ID + * @param {string} methodName - Name of the calling method for logging + * @returns {Promise<{valid: boolean, libraryItem?: any, error?: {status: number, message: string}}>} + */ + static async fetchAndValidateLibraryItem(id, methodName) { + const validation = SearchController.validateRequiredString(id, 'library item id', methodName) + if (!validation.valid) { + return validation + } + + const libraryItem = await Database.libraryItemModel.getExpandedById(id) + if (!libraryItem) { + Logger.error(`[SearchController] ${methodName}: Library item not found with id "${id}"`) + return { + valid: false, + error: { + status: 404, + message: 'Library item not found' + } + } + } + + return { valid: true, libraryItem } + } + + /** + * Maps custom metadata providers to standardized format + * @param {Array} providers - Array of custom provider objects + * @returns {Array<{value: string, text: string}>} + */ + static mapCustomProviders(providers) { + return providers.map((provider) => ({ + value: provider.getSlug(), + text: provider.name + })) + } + + /** + * Static helper method to format provider for client (for use in array methods) + * @param {string} providerValue - Provider identifier + * @returns {{value: string, text: string}} + */ + static formatProvider(providerValue) { + return { + value: providerValue, + text: providerMap[providerValue] || providerValue + } + } + /** * GET: /api/search/books * @@ -23,18 +139,19 @@ class SearchController { * @param {Response} res */ async findBooks(req, res) { - const id = req.query.id - const libraryItem = await Database.libraryItemModel.getExpandedById(id) const provider = req.query.provider || 'google' const title = req.query.title || '' const author = req.query.author || '' - if (typeof provider !== 'string' || typeof title !== 'string' || typeof author !== 'string') { - Logger.error(`[SearchController] findBooks: Invalid request query params`) - return res.status(400).send('Invalid request query params') - } + // Validate string parameters + const validation = SearchController.validateStringParams({ provider, title, author }, 'findBooks') + if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) - const results = await BookFinder.search(libraryItem, provider, title, author) + // Fetch and validate library item + const itemValidation = await SearchController.fetchAndValidateLibraryItem(req.query.id, 'findBooks') + if (!itemValidation.valid) return res.status(itemValidation.error.status).send(itemValidation.error.message) + + const results = await BookFinder.search(itemValidation.libraryItem, provider, title, author) res.json(results) } @@ -46,19 +163,23 @@ class SearchController { */ async findCovers(req, res) { const query = req.query - const podcast = query.podcast == 1 + const podcast = query.podcast === '1' || query.podcast === 1 + const title = query.title || '' + const author = query.author || '' + const provider = query.provider || 'google' - if (!query.title || typeof query.title !== 'string') { - Logger.error(`[SearchController] findCovers: Invalid title sent in query`) - return res.sendStatus(400) - } + // Validate required title + const titleValidation = SearchController.validateRequiredString(title, 'title', 'findCovers') + if (!titleValidation.valid) return res.status(titleValidation.error.status).send(titleValidation.error.message) + + // Validate other string parameters + const validation = SearchController.validateStringParams({ author, provider }, 'findCovers') + if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) let results = null - if (podcast) results = await PodcastFinder.findCovers(query.title) - else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || '') - res.json({ - results - }) + if (podcast) results = await PodcastFinder.findCovers(title) + else results = await BookFinder.findCovers(provider, title, author) + res.json({ results }) } /** @@ -71,14 +192,16 @@ class SearchController { async findPodcasts(req, res) { const term = req.query.term const country = req.query.country || 'us' - if (!term) { - Logger.error('[SearchController] Invalid request query param "term" is required') - return res.status(400).send('Invalid request query param "term" is required') - } - const results = await PodcastFinder.search(term, { - country - }) + // Validate required term + const termValidation = SearchController.validateRequiredString(term, 'term', 'findPodcasts') + if (!termValidation.valid) return res.status(termValidation.error.status).send(termValidation.error.message) + + // Validate country parameter + const validation = SearchController.validateStringParams({ country }, 'findPodcasts') + if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) + + const results = await PodcastFinder.search(term, { country }) res.json(results) } @@ -90,10 +213,10 @@ class SearchController { */ async findAuthor(req, res) { const query = req.query.q - if (!query || typeof query !== 'string') { - Logger.error(`[SearchController] findAuthor: Invalid query param`) - return res.status(400).send('Invalid query param') - } + + // Validate query parameter + const validation = SearchController.validateRequiredString(query, 'query', 'findAuthor') + if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) const author = await AuthorFinder.findAuthorByName(query) res.json(author) @@ -107,15 +230,128 @@ class SearchController { */ async findChapters(req, res) { const asin = req.query.asin - if (!isValidASIN(asin.toUpperCase())) { - return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' }) - } const region = (req.query.region || 'us').toLowerCase() + + // Validate ASIN parameter + const asinValidation = SearchController.validateRequiredString(asin, 'asin', 'findChapters') + if (!asinValidation.valid) return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' }) + + if (!isValidASIN(asin.toUpperCase())) return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' }) + + // Validate region parameter + const validation = SearchController.validateStringParams({ region }, 'findChapters') + if (!validation.valid) res.json({ error: 'Invalid region', stringKey: 'MessageInvalidRegion' }) + const chapterData = await BookFinder.findChapters(asin, region) if (!chapterData) { return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' }) } res.json(chapterData) } + + /** + * GET: /api/search/providers/podcasts/covers + * Get available podcast cover metadata providers + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getPodcastCoverProviders(req, res) { + // Podcast covers only use iTunes + const customProviders = await Database.customMetadataProviderModel.findAll({ + where: { + mediaType: 'podcast' + } + }) + + const providers = [SearchController.formatProvider('itunes'), ...SearchController.mapCustomProviders(customProviders)] + + res.json({ providers }) + } + + /** + * GET: /api/search/providers/books/covers + * Get available book cover metadata providers + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getBookCoverProviders(req, res) { + // Book covers use all book providers + const customProviders = await Database.customMetadataProviderModel.findAll({ + where: { + mediaType: 'book' + } + }) + + const providers = [SearchController.formatProvider('best'), ...BookFinder.providers.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customProviders), SearchController.formatProvider('all')] + + res.json({ providers }) + } + + /** + * GET: /api/search/providers/books + * Get available book metadata providers + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getBookProviders(req, res) { + const customProviders = await Database.customMetadataProviderModel.findAll({ + where: { + mediaType: 'book' + } + }) + + // Filter out cover-only providers + const bookProviders = BookFinder.providers.filter((p) => p !== 'audiobookcovers') + + const providers = [...bookProviders.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customProviders)] + + res.json({ providers }) + } + + /** + * GET: /api/search/providers/podcasts + * Get available podcast metadata providers + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getPodcastProviders(req, res) { + const customProviders = await Database.customMetadataProviderModel.findAll({ + where: { + mediaType: 'podcast' + } + }) + + const providers = [SearchController.formatProvider('itunes'), ...SearchController.mapCustomProviders(customProviders)] + + res.json({ providers }) + } + + /** + * GET: /api/search/providers/authors + * Get available author metadata providers + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getAuthorProviders(req, res) { + const providers = [SearchController.formatProvider('audnexus')] + res.json({ providers }) + } + + /** + * GET: /api/search/providers/chapters + * Get available chapter metadata providers + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getChapterProviders(req, res) { + const providers = [SearchController.formatProvider('audnexus')] + res.json({ providers }) + } } module.exports = new SearchController() From 1da3ab7fdc819ad71271098bba1f186de058fc7a Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 14 Oct 2025 18:10:12 +0300 Subject: [PATCH 02/24] ApiRouter: New provider API routes --- server/routers/ApiRouter.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 6446ecc80..c72aa143f 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -283,6 +283,12 @@ class ApiRouter { this.router.get('/search/podcast', SearchController.findPodcasts.bind(this)) this.router.get('/search/authors', SearchController.findAuthor.bind(this)) this.router.get('/search/chapters', SearchController.findChapters.bind(this)) + this.router.get('/search/providers/books', SearchController.getBookProviders.bind(this)) + this.router.get('/search/providers/books/covers', SearchController.getBookCoverProviders.bind(this)) + this.router.get('/search/providers/podcasts', SearchController.getPodcastProviders.bind(this)) + this.router.get('/search/providers/podcasts/covers', SearchController.getPodcastCoverProviders.bind(this)) + this.router.get('/search/providers/authors', SearchController.getAuthorProviders.bind(this)) + this.router.get('/search/providers/chapters', SearchController.getChapterProviders.bind(this)) // // Cache Routes (Admin and up) From ce4ff4f894ea5e924ac64429c3fa8fa772f210d0 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 15 Oct 2025 09:52:15 +0300 Subject: [PATCH 03/24] Client: Use new server providers API --- .../modals/BatchQuickMatchModel.vue | 2 +- client/components/modals/item/tabs/Cover.vue | 4 +- client/components/modals/item/tabs/Match.vue | 2 +- .../modals/libraries/EditLibrary.vue | 2 +- .../modals/libraries/LibrarySettings.vue | 2 +- client/layouts/default.vue | 24 ++- client/pages/config/index.vue | 3 +- client/pages/upload/index.vue | 2 +- client/store/libraries.js | 2 - client/store/scanners.js | 171 ++++++------------ 10 files changed, 90 insertions(+), 124 deletions(-) diff --git a/client/components/modals/BatchQuickMatchModel.vue b/client/components/modals/BatchQuickMatchModel.vue index 8bea68faf..138684b20 100644 --- a/client/components/modals/BatchQuickMatchModel.vue +++ b/client/components/modals/BatchQuickMatchModel.vue @@ -88,7 +88,7 @@ export default { }, providers() { if (this.isPodcast) return this.$store.state.scanners.podcastProviders - return this.$store.state.scanners.providers + return this.$store.state.scanners.bookProviders }, libraryProvider() { return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google' diff --git a/client/components/modals/item/tabs/Cover.vue b/client/components/modals/item/tabs/Cover.vue index b404a9abb..4ed734b1b 100644 --- a/client/components/modals/item/tabs/Cover.vue +++ b/client/components/modals/item/tabs/Cover.vue @@ -133,8 +133,8 @@ export default { } }, providers() { - if (this.isPodcast) return this.$store.state.scanners.podcastProviders - return [{ text: 'Best', value: 'best' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders, { text: 'All', value: 'all' }] + if (this.isPodcast) return this.$store.state.scanners.podcastCoverProviders + return this.$store.state.scanners.bookCoverProviders }, searchTitleLabel() { if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN diff --git a/client/components/modals/item/tabs/Match.vue b/client/components/modals/item/tabs/Match.vue index 3faa26b27..36cdd7a8e 100644 --- a/client/components/modals/item/tabs/Match.vue +++ b/client/components/modals/item/tabs/Match.vue @@ -319,7 +319,7 @@ export default { }, providers() { if (this.isPodcast) return this.$store.state.scanners.podcastProviders - return this.$store.state.scanners.providers + return this.$store.state.scanners.bookProviders }, searchTitleLabel() { if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN diff --git a/client/components/modals/libraries/EditLibrary.vue b/client/components/modals/libraries/EditLibrary.vue index 083fc5766..1e4d3990a 100644 --- a/client/components/modals/libraries/EditLibrary.vue +++ b/client/components/modals/libraries/EditLibrary.vue @@ -74,7 +74,7 @@ export default { }, providers() { if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders - return this.$store.state.scanners.providers + return this.$store.state.scanners.bookProviders } }, methods: { diff --git a/client/components/modals/libraries/LibrarySettings.vue b/client/components/modals/libraries/LibrarySettings.vue index d3b40de95..231cb5ec4 100644 --- a/client/components/modals/libraries/LibrarySettings.vue +++ b/client/components/modals/libraries/LibrarySettings.vue @@ -136,7 +136,7 @@ export default { }, providers() { if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders - return this.$store.state.scanners.providers + return this.$store.state.scanners.bookProviders }, maskAsFinishedWhenItems() { return [ diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 4b9729248..21c256bd3 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -371,11 +371,25 @@ export default { }, customMetadataProviderAdded(provider) { if (!provider?.id) return - this.$store.commit('scanners/addCustomMetadataProvider', provider) + // Refetch the appropriate provider types + if (provider.mediaType === 'book') { + this.$store.dispatch('scanners/fetchBookProviders') + this.$store.dispatch('scanners/fetchBookCoverProviders') + } else if (provider.mediaType === 'podcast') { + this.$store.dispatch('scanners/fetchPodcastProviders') + this.$store.dispatch('scanners/fetchPodcastCoverProviders') + } }, customMetadataProviderRemoved(provider) { if (!provider?.id) return - this.$store.commit('scanners/removeCustomMetadataProvider', provider) + // Refetch the appropriate provider types + if (provider.mediaType === 'book') { + this.$store.dispatch('scanners/fetchBookProviders') + this.$store.dispatch('scanners/fetchBookCoverProviders') + } else if (provider.mediaType === 'podcast') { + this.$store.dispatch('scanners/fetchPodcastProviders') + this.$store.dispatch('scanners/fetchPodcastCoverProviders') + } }, initializeSocket() { if (this.$root.socket) { @@ -612,6 +626,12 @@ export default { this.$store.dispatch('libraries/load') + // Fetch metadata providers + this.$store.dispatch('scanners/fetchBookProviders') + this.$store.dispatch('scanners/fetchBookCoverProviders') + this.$store.dispatch('scanners/fetchPodcastProviders') + this.$store.dispatch('scanners/fetchPodcastCoverProviders') + this.initLocalStorage() this.checkVersionUpdate() diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 3d030bb32..099ae9c4e 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -247,7 +247,8 @@ export default { return this.$store.state.serverSettings }, providers() { - return this.$store.state.scanners.providers + // Use book cover providers for the cover provider dropdown + return this.$store.state.scanners.bookCoverProviders || [] }, dateFormats() { return this.$store.state.globals.dateFormats diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index eef05b608..84d4d1478 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -155,7 +155,7 @@ export default { }, providers() { if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders - return this.$store.state.scanners.providers + return this.$store.state.scanners.bookProviders }, canFetchMetadata() { return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled diff --git a/client/store/libraries.js b/client/store/libraries.js index 115fb53bf..a824b6474 100644 --- a/client/store/libraries.js +++ b/client/store/libraries.js @@ -131,8 +131,6 @@ export const actions = { commit('setLibraryIssues', issues) commit('setLibraryFilterData', filterData) commit('setNumUserPlaylists', numUserPlaylists) - commit('scanners/setCustomMetadataProviders', customMetadataProviders, { root: true }) - commit('setCurrentLibrary', { id: libraryId }) return data }) diff --git a/client/store/scanners.js b/client/store/scanners.js index 2d3d465ca..40a8d864b 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -1,126 +1,73 @@ export const state = () => ({ - providers: [ - { - text: 'Google Books', - value: 'google' - }, - { - text: 'Open Library', - value: 'openlibrary' - }, - { - text: 'iTunes', - value: 'itunes' - }, - { - text: 'Audible.com', - value: 'audible' - }, - { - text: 'Audible.ca', - value: 'audible.ca' - }, - { - text: 'Audible.co.uk', - value: 'audible.uk' - }, - { - text: 'Audible.com.au', - value: 'audible.au' - }, - { - text: 'Audible.fr', - value: 'audible.fr' - }, - { - text: 'Audible.de', - value: 'audible.de' - }, - { - text: 'Audible.co.jp', - value: 'audible.jp' - }, - { - text: 'Audible.it', - value: 'audible.it' - }, - { - text: 'Audible.co.in', - value: 'audible.in' - }, - { - text: 'Audible.es', - value: 'audible.es' - }, - { - text: 'FantLab.ru', - value: 'fantlab' - } - ], - podcastProviders: [ - { - text: 'iTunes', - value: 'itunes' - } - ], - coverOnlyProviders: [ - { - text: 'AudiobookCovers.com', - value: 'audiobookcovers' - } - ] + bookProviders: [], + podcastProviders: [], + bookCoverProviders: [], + podcastCoverProviders: [] }) export const getters = { - checkBookProviderExists: state => (providerValue) => { - return state.providers.some(p => p.value === providerValue) + checkBookProviderExists: (state) => (providerValue) => { + return state.bookProviders.some((p) => p.value === providerValue) }, - checkPodcastProviderExists: state => (providerValue) => { - return state.podcastProviders.some(p => p.value === providerValue) + checkPodcastProviderExists: (state) => (providerValue) => { + return state.podcastProviders.some((p) => p.value === providerValue) } } -export const actions = {} - -export const mutations = { - addCustomMetadataProvider(state, provider) { - if (provider.mediaType === 'book') { - if (state.providers.some(p => p.value === provider.slug)) return - state.providers.push({ - text: provider.name, - value: provider.slug - }) - } else { - if (state.podcastProviders.some(p => p.value === provider.slug)) return - state.podcastProviders.push({ - text: provider.name, - value: provider.slug - }) +export const actions = { + async fetchBookProviders({ commit }) { + try { + const response = await this.$axios.$get('/api/search/providers/books') + if (response?.providers) { + commit('setBookProviders', response.providers) + } + } catch (error) { + console.error('Failed to fetch book providers', error) } }, - removeCustomMetadataProvider(state, provider) { - if (provider.mediaType === 'book') { - state.providers = state.providers.filter(p => p.value !== provider.slug) - } else { - state.podcastProviders = state.podcastProviders.filter(p => p.value !== provider.slug) + async fetchPodcastProviders({ commit }) { + try { + const response = await this.$axios.$get('/api/search/providers/podcasts') + if (response?.providers) { + commit('setPodcastProviders', response.providers) + } + } catch (error) { + console.error('Failed to fetch podcast providers', error) } }, - setCustomMetadataProviders(state, providers) { - if (!providers?.length) return - - const mediaType = providers[0].mediaType - if (mediaType === 'book') { - // clear previous values, and add new values to the end - state.providers = state.providers.filter((p) => !p.value.startsWith('custom-')) - state.providers = [ - ...state.providers, - ...providers.map((p) => ({ - text: p.name, - value: p.slug - })) - ] - } else { - // Podcast providers not supported yet + async fetchBookCoverProviders({ commit }) { + try { + const response = await this.$axios.$get('/api/search/providers/books/covers') + if (response?.providers) { + commit('setBookCoverProviders', response.providers) + } + } catch (error) { + console.error('Failed to fetch book cover providers', error) + } + }, + async fetchPodcastCoverProviders({ commit }) { + try { + const response = await this.$axios.$get('/api/search/providers/podcasts/covers') + if (response?.providers) { + commit('setPodcastCoverProviders', response.providers) + } + } catch (error) { + console.error('Failed to fetch podcast cover providers', error) } } -} \ No newline at end of file +} + +export const mutations = { + setBookProviders(state, providers) { + state.bookProviders = providers + }, + setPodcastProviders(state, providers) { + state.podcastProviders = providers + }, + setBookCoverProviders(state, providers) { + state.bookCoverProviders = providers + }, + setPodcastCoverProviders(state, providers) { + state.podcastCoverProviders = providers + } +} From 888190a6be4c103b6eec3e5c0f97f678eda87900 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 15 Oct 2025 18:28:15 +0300 Subject: [PATCH 04/24] Fix codeQL failures --- server/controllers/SearchController.js | 25 +++++++++++++------------ server/finders/BookFinder.js | 7 +++++-- server/utils/index.js | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index 72f602d2a..57538d2c0 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -4,7 +4,7 @@ const BookFinder = require('../finders/BookFinder') const PodcastFinder = require('../finders/PodcastFinder') const AuthorFinder = require('../finders/AuthorFinder') const Database = require('../Database') -const { isValidASIN } = require('../utils') +const { isValidASIN, getQueryParamAsString } = require('../utils') // Provider name mappings for display purposes const providerMap = { @@ -139,9 +139,10 @@ class SearchController { * @param {Response} res */ async findBooks(req, res) { - const provider = req.query.provider || 'google' - const title = req.query.title || '' - const author = req.query.author || '' + // Safely extract query parameters, rejecting arrays to prevent type confusion + const provider = getQueryParamAsString(req.query.provider, 'google') + const title = getQueryParamAsString(req.query.title, '') + const author = getQueryParamAsString(req.query.author, '') // Validate string parameters const validation = SearchController.validateStringParams({ provider, title, author }, 'findBooks') @@ -164,9 +165,9 @@ class SearchController { async findCovers(req, res) { const query = req.query const podcast = query.podcast === '1' || query.podcast === 1 - const title = query.title || '' - const author = query.author || '' - const provider = query.provider || 'google' + const title = getQueryParamAsString(query.title, '') + const author = getQueryParamAsString(query.author, '') + const provider = getQueryParamAsString(query.provider, 'google') // Validate required title const titleValidation = SearchController.validateRequiredString(title, 'title', 'findCovers') @@ -190,8 +191,8 @@ class SearchController { * @param {Response} res */ async findPodcasts(req, res) { - const term = req.query.term - const country = req.query.country || 'us' + const term = getQueryParamAsString(req.query.term) + const country = getQueryParamAsString(req.query.country, 'us') // Validate required term const termValidation = SearchController.validateRequiredString(term, 'term', 'findPodcasts') @@ -212,7 +213,7 @@ class SearchController { * @param {Response} res */ async findAuthor(req, res) { - const query = req.query.q + const query = getQueryParamAsString(req.query.q) // Validate query parameter const validation = SearchController.validateRequiredString(query, 'query', 'findAuthor') @@ -229,8 +230,8 @@ class SearchController { * @param {Response} res */ async findChapters(req, res) { - const asin = req.query.asin - const region = (req.query.region || 'us').toLowerCase() + const asin = getQueryParamAsString(req.query.asin) + const region = getQueryParamAsString(req.query.region, 'us').toLowerCase() // Validate ASIN parameter const asinValidation = SearchController.validateRequiredString(asin, 'asin', 'findChapters') diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index a6a6b07e6..6dc90c44f 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -402,7 +402,8 @@ class BookFinder { let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus) // Remove underscores and parentheses with their contents, and replace with a separator - const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, ' - ') + // Use negated character classes to prevent ReDoS vulnerability + const cleanTitle = title.replace(/\[[^\]]*\]|\([^)]*\)|{[^}]*}|_/g, ' - ') // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) for (const titlePart of titleParts) authorCandidates.add(titlePart) @@ -668,7 +669,9 @@ function cleanTitleForCompares(title, keepSubtitle = false) { let stripped = keepSubtitle ? title : stripSubtitle(title) // Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game") - let cleaned = stripped.replace(/ *\([^)]*\) */g, '') + // Use a safe two-pass approach to prevent ReDoS vulnerability + let cleaned = stripped.replace(/\([^)]*\)/g, '') // Remove parenthetical content + cleaned = cleaned.replace(/\s+/g, ' ').trim() // Clean up any resulting multiple spaces // Remove single quotes (i.e. "Ender's Game" becomes "Enders Game") cleaned = cleaned.replace(/'/g, '') diff --git a/server/utils/index.js b/server/utils/index.js index 369620276..4421fbade 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -277,3 +277,22 @@ module.exports.timestampToSeconds = (timestamp) => { } return null } + +/** + * Safely extracts a query parameter as a string, rejecting arrays to prevent type confusion + * Express query parameters can be arrays if the same parameter appears multiple times + * @example ?author=Smith => "Smith" + * @example ?author=Smith&author=Jones => null (array detected) + * + * @param {any} value - Query parameter value + * @param {string} defaultValue - Default value if undefined/null + * @returns {string|null} String value or null if invalid (array) + */ +module.exports.getQueryParamAsString = (value, defaultValue = '') => { + // Explicitly reject arrays to prevent type confusion + if (Array.isArray(value)) { + return null + } + // Return default for undefined/null, otherwise return the value + return value == null ? defaultValue : value +} From 3f6162f53c3246963cbbaaa1697043a3119cfd89 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 15 Oct 2025 18:54:29 +0300 Subject: [PATCH 05/24] CodeQL fix: limit parameter sizes --- server/finders/BookFinder.js | 9 +++++++-- server/utils/index.js | 16 ++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 6dc90c44f..fe1a61027 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -385,6 +385,11 @@ class BookFinder { if (!title) return books + // Truncate excessively long inputs to prevent ReDoS attacks + const MAX_INPUT_LENGTH = 500 + title = title.substring(0, MAX_INPUT_LENGTH) + author = author?.substring(0, MAX_INPUT_LENGTH) || author + const isTitleAsin = isValidASIN(title.toUpperCase()) let actualTitleQuery = title @@ -402,7 +407,7 @@ class BookFinder { let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus) // Remove underscores and parentheses with their contents, and replace with a separator - // Use negated character classes to prevent ReDoS vulnerability + // Use negated character classes to prevent ReDoS vulnerability (input length validated at entry point) const cleanTitle = title.replace(/\[[^\]]*\]|\([^)]*\)|{[^}]*}|_/g, ' - ') // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) @@ -669,7 +674,7 @@ function cleanTitleForCompares(title, keepSubtitle = false) { let stripped = keepSubtitle ? title : stripSubtitle(title) // Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game") - // Use a safe two-pass approach to prevent ReDoS vulnerability + // Use negated character class to prevent ReDoS vulnerability (input length validated at entry point) let cleaned = stripped.replace(/\([^)]*\)/g, '') // Remove parenthetical content cleaned = cleaned.replace(/\s+/g, ' ').trim() // Clean up any resulting multiple spaces diff --git a/server/utils/index.js b/server/utils/index.js index 4421fbade..0661d14f8 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -286,13 +286,21 @@ module.exports.timestampToSeconds = (timestamp) => { * * @param {any} value - Query parameter value * @param {string} defaultValue - Default value if undefined/null - * @returns {string|null} String value or null if invalid (array) + * @param {number} maxLength - Optional maximum length (defaults to 10000 to prevent ReDoS attacks) + * @returns {string|null} String value or null if invalid (array or too long) */ -module.exports.getQueryParamAsString = (value, defaultValue = '') => { +module.exports.getQueryParamAsString = (value, defaultValue = '', maxLength = 1000) => { // Explicitly reject arrays to prevent type confusion if (Array.isArray(value)) { return null } - // Return default for undefined/null, otherwise return the value - return value == null ? defaultValue : value + // Return default for undefined/null + if (value == null) { + return defaultValue + } + // Reject excessively long strings to prevent ReDoS attacks + if (typeof value === 'string' && value.length > maxLength) { + return null + } + return value } From 0a82d6a41b5662ee681bdecf1e922824f427fda5 Mon Sep 17 00:00:00 2001 From: mikiher Date: Fri, 17 Oct 2025 08:11:03 +0300 Subject: [PATCH 06/24] CoverSearchManager: Fix broken podcast cover search --- server/managers/CoverSearchManager.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/managers/CoverSearchManager.js b/server/managers/CoverSearchManager.js index ddcaa23db..193176766 100644 --- a/server/managers/CoverSearchManager.js +++ b/server/managers/CoverSearchManager.js @@ -224,6 +224,9 @@ class CoverSearchManager { if (!Array.isArray(results)) return covers results.forEach((result) => { + if (typeof result === 'string') { + covers.push(result) + } if (result.covers && Array.isArray(result.covers)) { covers.push(...result.covers) } From 0a8662d1983736ea36fb76a3bfd028f6e2586106 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 19 Oct 2025 10:53:27 +0300 Subject: [PATCH 07/24] Merge providers API into a single endpoint --- server/controllers/SearchController.js | 104 +++---------------------- server/routers/ApiRouter.js | 7 +- 2 files changed, 13 insertions(+), 98 deletions(-) diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index 57538d2c0..72d215f34 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -251,108 +251,28 @@ class SearchController { } /** - * GET: /api/search/providers/podcasts/covers - * Get available podcast cover metadata providers + * GET: /api/search/providers + * Get all available metadata providers * * @param {RequestWithUser} req * @param {Response} res */ - async getPodcastCoverProviders(req, res) { - // Podcast covers only use iTunes - const customProviders = await Database.customMetadataProviderModel.findAll({ - where: { - mediaType: 'podcast' - } - }) + async getAllProviders(req, res) { + const customProviders = await Database.customMetadataProviderModel.findAll() - const providers = [SearchController.formatProvider('itunes'), ...SearchController.mapCustomProviders(customProviders)] + const customBookProviders = customProviders.filter((p) => p.mediaType === 'book') + const customPodcastProviders = customProviders.filter((p) => p.mediaType === 'podcast') - res.json({ providers }) - } - - /** - * GET: /api/search/providers/books/covers - * Get available book cover metadata providers - * - * @param {RequestWithUser} req - * @param {Response} res - */ - async getBookCoverProviders(req, res) { - // Book covers use all book providers - const customProviders = await Database.customMetadataProviderModel.findAll({ - where: { - mediaType: 'book' - } - }) - - const providers = [SearchController.formatProvider('best'), ...BookFinder.providers.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customProviders), SearchController.formatProvider('all')] - - res.json({ providers }) - } - - /** - * GET: /api/search/providers/books - * Get available book metadata providers - * - * @param {RequestWithUser} req - * @param {Response} res - */ - async getBookProviders(req, res) { - const customProviders = await Database.customMetadataProviderModel.findAll({ - where: { - mediaType: 'book' - } - }) - - // Filter out cover-only providers const bookProviders = BookFinder.providers.filter((p) => p !== 'audiobookcovers') - const providers = [...bookProviders.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customProviders)] + // Build minimized payload with custom providers merged in + const providers = { + books: [...bookProviders.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customBookProviders)], + booksCovers: [SearchController.formatProvider('best'), ...BookFinder.providers.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customBookProviders), SearchController.formatProvider('all')], + podcasts: [SearchController.formatProvider('itunes'), ...SearchController.mapCustomProviders(customPodcastProviders)] + } res.json({ providers }) } - - /** - * GET: /api/search/providers/podcasts - * Get available podcast metadata providers - * - * @param {RequestWithUser} req - * @param {Response} res - */ - async getPodcastProviders(req, res) { - const customProviders = await Database.customMetadataProviderModel.findAll({ - where: { - mediaType: 'podcast' - } - }) - - const providers = [SearchController.formatProvider('itunes'), ...SearchController.mapCustomProviders(customProviders)] - - res.json({ providers }) - } - - /** - * GET: /api/search/providers/authors - * Get available author metadata providers - * - * @param {RequestWithUser} req - * @param {Response} res - */ - async getAuthorProviders(req, res) { - const providers = [SearchController.formatProvider('audnexus')] - res.json({ providers }) - } - - /** - * GET: /api/search/providers/chapters - * Get available chapter metadata providers - * - * @param {RequestWithUser} req - * @param {Response} res - */ - async getChapterProviders(req, res) { - const providers = [SearchController.formatProvider('audnexus')] - res.json({ providers }) - } } module.exports = new SearchController() diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index c72aa143f..db04bf5ec 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -283,12 +283,7 @@ class ApiRouter { this.router.get('/search/podcast', SearchController.findPodcasts.bind(this)) this.router.get('/search/authors', SearchController.findAuthor.bind(this)) this.router.get('/search/chapters', SearchController.findChapters.bind(this)) - this.router.get('/search/providers/books', SearchController.getBookProviders.bind(this)) - this.router.get('/search/providers/books/covers', SearchController.getBookCoverProviders.bind(this)) - this.router.get('/search/providers/podcasts', SearchController.getPodcastProviders.bind(this)) - this.router.get('/search/providers/podcasts/covers', SearchController.getPodcastCoverProviders.bind(this)) - this.router.get('/search/providers/authors', SearchController.getAuthorProviders.bind(this)) - this.router.get('/search/providers/chapters', SearchController.getChapterProviders.bind(this)) + this.router.get('/search/providers', SearchController.getAllProviders.bind(this)) // // Cache Routes (Admin and up) From b01e7570d398b20e300c87a80d61a82aa903041b Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 19 Oct 2025 10:54:26 +0300 Subject: [PATCH 08/24] Remove custom providers from library filterdata request --- server/controllers/LibraryController.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index e63441f0b..55ef45690 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -221,13 +221,11 @@ class LibraryController { const includeArray = (req.query.include || '').split(',') if (includeArray.includes('filterdata')) { const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id) - const customMetadataProviders = await Database.customMetadataProviderModel.getForClientByMediaType(req.library.mediaType) return res.json({ filterdata, issues: filterdata.numIssues, numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id), - customMetadataProviders, library: req.library.toOldJSON() }) } From 141211590f15c2ac6485e6ed09e8fe0f57a048f8 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 19 Oct 2025 11:39:10 +0300 Subject: [PATCH 09/24] Merge provider actions and mutations, add loaded state --- client/store/scanners.js | 67 ++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 40 deletions(-) diff --git a/client/store/scanners.js b/client/store/scanners.js index 40a8d864b..ccf7d9249 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -2,7 +2,8 @@ export const state = () => ({ bookProviders: [], podcastProviders: [], bookCoverProviders: [], - podcastCoverProviders: [] + podcastCoverProviders: [], + providersLoaded: false }) export const getters = { @@ -11,63 +12,49 @@ export const getters = { }, checkPodcastProviderExists: (state) => (providerValue) => { return state.podcastProviders.some((p) => p.value === providerValue) - } + }, + areProvidersLoaded: (state) => state.providersLoaded } export const actions = { - async fetchBookProviders({ commit }) { + async fetchProviders({ commit, state }) { + // Only fetch if not already loaded + if (state.providersLoaded) { + return + } + try { - const response = await this.$axios.$get('/api/search/providers/books') + const response = await this.$axios.$get('/api/search/providers') if (response?.providers) { - commit('setBookProviders', response.providers) + commit('setAllProviders', response.providers) } } catch (error) { - console.error('Failed to fetch book providers', error) + console.error('Failed to fetch providers', error) } }, - async fetchPodcastProviders({ commit }) { - try { - const response = await this.$axios.$get('/api/search/providers/podcasts') - if (response?.providers) { - commit('setPodcastProviders', response.providers) - } - } catch (error) { - console.error('Failed to fetch podcast providers', error) + async refreshProviders({ commit, state }) { + // if providers are not loaded, do nothing - they will be fetched when required ( + if (!state.providersLoaded) { + return } - }, - async fetchBookCoverProviders({ commit }) { + try { - const response = await this.$axios.$get('/api/search/providers/books/covers') + const response = await this.$axios.$get('/api/search/providers') if (response?.providers) { - commit('setBookCoverProviders', response.providers) + commit('setAllProviders', response.providers) } } catch (error) { - console.error('Failed to fetch book cover providers', error) - } - }, - async fetchPodcastCoverProviders({ commit }) { - try { - const response = await this.$axios.$get('/api/search/providers/podcasts/covers') - if (response?.providers) { - commit('setPodcastCoverProviders', response.providers) - } - } catch (error) { - console.error('Failed to fetch podcast cover providers', error) + console.error('Failed to refresh providers', error) } } } export const mutations = { - setBookProviders(state, providers) { - state.bookProviders = providers - }, - setPodcastProviders(state, providers) { - state.podcastProviders = providers - }, - setBookCoverProviders(state, providers) { - state.bookCoverProviders = providers - }, - setPodcastCoverProviders(state, providers) { - state.podcastCoverProviders = providers + setAllProviders(state, providers) { + state.bookProviders = providers.books || [] + state.podcastProviders = providers.podcasts || [] + state.bookCoverProviders = providers.booksCovers || [] + state.podcastCoverProviders = providers.podcasts || [] // Use same as bookCovers since podcasts use iTunes only + state.providersLoaded = true } } From 816a47a4bac71ed4415e4923e99980a0150ce049 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 19 Oct 2025 11:40:40 +0300 Subject: [PATCH 10/24] Remove custom providers from library fetch action --- client/store/libraries.js | 1 - 1 file changed, 1 deletion(-) diff --git a/client/store/libraries.js b/client/store/libraries.js index a824b6474..a6cf1dd39 100644 --- a/client/store/libraries.js +++ b/client/store/libraries.js @@ -117,7 +117,6 @@ export const actions = { const library = data.library const filterData = data.filterdata const issues = data.issues || 0 - const customMetadataProviders = data.customMetadataProviders || [] const numUserPlaylists = data.numUserPlaylists dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true }) From 166e0442a0e5763f62d5f82d76543fd13ec50510 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 19 Oct 2025 11:47:17 +0300 Subject: [PATCH 11/24] Remove providers prefetch, refresh on custom provider add/remove --- client/layouts/default.vue | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 21c256bd3..75753b214 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -371,25 +371,13 @@ export default { }, customMetadataProviderAdded(provider) { if (!provider?.id) return - // Refetch the appropriate provider types - if (provider.mediaType === 'book') { - this.$store.dispatch('scanners/fetchBookProviders') - this.$store.dispatch('scanners/fetchBookCoverProviders') - } else if (provider.mediaType === 'podcast') { - this.$store.dispatch('scanners/fetchPodcastProviders') - this.$store.dispatch('scanners/fetchPodcastCoverProviders') - } + // Refresh providers cache + this.$store.dispatch('scanners/refreshProviders') }, customMetadataProviderRemoved(provider) { if (!provider?.id) return - // Refetch the appropriate provider types - if (provider.mediaType === 'book') { - this.$store.dispatch('scanners/fetchBookProviders') - this.$store.dispatch('scanners/fetchBookCoverProviders') - } else if (provider.mediaType === 'podcast') { - this.$store.dispatch('scanners/fetchPodcastProviders') - this.$store.dispatch('scanners/fetchPodcastCoverProviders') - } + // Refresh providers cache + this.$store.dispatch('scanners/refreshProviders') }, initializeSocket() { if (this.$root.socket) { @@ -626,12 +614,6 @@ export default { this.$store.dispatch('libraries/load') - // Fetch metadata providers - this.$store.dispatch('scanners/fetchBookProviders') - this.$store.dispatch('scanners/fetchBookCoverProviders') - this.$store.dispatch('scanners/fetchPodcastProviders') - this.$store.dispatch('scanners/fetchPodcastCoverProviders') - this.initLocalStorage() this.checkVersionUpdate() From 538a5065a448336bbf29eccf65101728c1f9c53a Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 19 Oct 2025 18:57:27 +0300 Subject: [PATCH 12/24] Update providers users to fetch providers on demand --- .../modals/BatchQuickMatchModel.vue | 6 ++- client/components/modals/item/tabs/Cover.vue | 2 + client/components/modals/item/tabs/Match.vue | 53 ++++++++++++++----- .../modals/libraries/EditLibrary.vue | 2 + .../modals/libraries/LibrarySettings.vue | 5 -- client/pages/config/index.vue | 2 + client/pages/upload/index.vue | 2 + 7 files changed, 52 insertions(+), 20 deletions(-) diff --git a/client/components/modals/BatchQuickMatchModel.vue b/client/components/modals/BatchQuickMatchModel.vue index 138684b20..f6bcd9728 100644 --- a/client/components/modals/BatchQuickMatchModel.vue +++ b/client/components/modals/BatchQuickMatchModel.vue @@ -96,6 +96,9 @@ export default { }, methods: { init() { + // Fetch providers when modal is shown + this.$store.dispatch('scanners/fetchProviders') + // If we don't have a set provider (first open of dialog) or we've switched library, set // the selected provider to the current library default provider if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) { @@ -127,8 +130,7 @@ export default { this.show = false }) } - }, - mounted() {} + } } diff --git a/client/components/modals/item/tabs/Cover.vue b/client/components/modals/item/tabs/Cover.vue index 4ed734b1b..be17f9636 100644 --- a/client/components/modals/item/tabs/Cover.vue +++ b/client/components/modals/item/tabs/Cover.vue @@ -438,6 +438,8 @@ export default { mounted() { // Setup socket listeners when component is mounted this.addSocketListeners() + // Fetch providers if not already loaded + this.$store.dispatch('scanners/fetchProviders') }, beforeDestroy() { // Cancel any ongoing search when component is destroyed diff --git a/client/components/modals/item/tabs/Match.vue b/client/components/modals/item/tabs/Match.vue index 36cdd7a8e..4b92f6cd8 100644 --- a/client/components/modals/item/tabs/Match.vue +++ b/client/components/modals/item/tabs/Match.vue @@ -2,7 +2,7 @@
-
+
@@ -253,6 +253,7 @@ export default { hasSearched: false, selectedMatch: null, selectedMatchOrig: null, + waitingForProviders: false, selectedMatchUsage: { title: true, subtitle: true, @@ -285,9 +286,19 @@ export default { handler(newVal) { if (newVal) this.init() } + }, + providersLoaded(isLoaded) { + // Complete initialization once providers are loaded + if (isLoaded && this.waitingForProviders) { + this.waitingForProviders = false + this.initProviderAndSearch() + } } }, computed: { + providersLoaded() { + return this.$store.getters['scanners/areProvidersLoaded'] + }, isProcessing: { get() { return this.processing @@ -478,6 +489,24 @@ export default { this.checkboxToggled() }, + initProviderAndSearch() { + // Set provider based on media type + if (this.isPodcast) { + this.provider = 'itunes' + } else { + this.provider = this.getDefaultBookProvider() + } + + // Prefer using ASIN if set and using audible provider + if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) { + this.searchTitle = this.libraryItem.media.metadata.asin + this.searchAuthor = '' + } + + if (this.searchTitle) { + this.submitSearch() + } + }, init() { this.clearSelectedMatch() this.initSelectedMatchUsage() @@ -495,19 +524,13 @@ export default { } this.searchTitle = this.libraryItem.media.metadata.title this.searchAuthor = this.libraryItem.media.metadata.authorName || '' - if (this.isPodcast) this.provider = 'itunes' - else { - this.provider = this.getDefaultBookProvider() - } - // Prefer using ASIN if set and using audible provider - if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) { - this.searchTitle = this.libraryItem.media.metadata.asin - this.searchAuthor = '' - } - - if (this.searchTitle) { - this.submitSearch() + // Wait for providers to be loaded before setting provider and searching + if (this.providersLoaded || this.isPodcast) { + this.waitingForProviders = false + this.initProviderAndSearch() + } else { + this.waitingForProviders = true } }, selectMatch(match) { @@ -637,6 +660,10 @@ export default { this.selectedMatch = null this.selectedMatchOrig = null } + }, + mounted() { + // Fetch providers if not already loaded + this.$store.dispatch('scanners/fetchProviders') } } diff --git a/client/components/modals/libraries/EditLibrary.vue b/client/components/modals/libraries/EditLibrary.vue index 1e4d3990a..c805f79b0 100644 --- a/client/components/modals/libraries/EditLibrary.vue +++ b/client/components/modals/libraries/EditLibrary.vue @@ -156,6 +156,8 @@ export default { }, mounted() { this.init() + // Fetch providers if not already loaded + this.$store.dispatch('scanners/fetchProviders') } } diff --git a/client/components/modals/libraries/LibrarySettings.vue b/client/components/modals/libraries/LibrarySettings.vue index 231cb5ec4..7cfc22017 100644 --- a/client/components/modals/libraries/LibrarySettings.vue +++ b/client/components/modals/libraries/LibrarySettings.vue @@ -104,7 +104,6 @@ export default { }, data() { return { - provider: null, useSquareBookCovers: false, enableWatcher: false, skipMatchingMediaWithAsin: false, @@ -134,10 +133,6 @@ export default { isPodcastLibrary() { return this.mediaType === 'podcast' }, - providers() { - if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders - return this.$store.state.scanners.bookProviders - }, maskAsFinishedWhenItems() { return [ { diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 099ae9c4e..b8cf3cff2 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -417,6 +417,8 @@ export default { }, mounted() { this.initServerSettings() + // Fetch providers if not already loaded (for cover provider dropdown) + this.$store.dispatch('scanners/fetchProviders') } } diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 84d4d1478..73ebef9c6 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -394,6 +394,8 @@ export default { this.setMetadataProvider() this.setDefaultFolder() + // Fetch providers if not already loaded + this.$store.dispatch('scanners/fetchProviders') window.addEventListener('dragenter', this.dragenter) window.addEventListener('dragleave', this.dragleave) window.addEventListener('dragover', this.dragover) From fd593caafc0a7ffc2ce27ae2d649b6163c0c3f67 Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 21 Oct 2025 09:38:35 +0300 Subject: [PATCH 13/24] SearchController: simplify query param validation logic --- server/controllers/SearchController.js | 212 ++++++++++--------------- server/utils/index.js | 49 ++++-- 2 files changed, 124 insertions(+), 137 deletions(-) diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index 72d215f34..9bb6e397d 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -4,7 +4,7 @@ const BookFinder = require('../finders/BookFinder') const PodcastFinder = require('../finders/PodcastFinder') const AuthorFinder = require('../finders/AuthorFinder') const Database = require('../Database') -const { isValidASIN, getQueryParamAsString } = require('../utils') +const { isValidASIN, getQueryParamAsString, ValidationError, NotFoundError } = require('../utils') // Provider name mappings for display purposes const providerMap = { @@ -39,73 +39,17 @@ class SearchController { constructor() {} /** - * Validates that multiple parameters are strings - * @param {Object} params - Object with param names as keys and values to validate - * @param {string} methodName - Name of the calling method for logging - * @returns {{valid: boolean, error?: {status: number, message: string}}} - */ - static validateStringParams(params, methodName) { - for (const [key, value] of Object.entries(params)) { - if (typeof value !== 'string') { - Logger.error(`[SearchController] ${methodName}: Invalid ${key} parameter`) - return { - valid: false, - error: { - status: 400, - message: 'Invalid request query params' - } - } - } - } - return { valid: true } - } - - /** - * Validates that a required string parameter exists and is a string - * @param {any} value - Value to validate - * @param {string} paramName - Parameter name for logging - * @param {string} methodName - Name of the calling method for logging - * @returns {{valid: boolean, error?: {status: number, message: string}}} - */ - static validateRequiredString(value, paramName, methodName) { - if (!value || typeof value !== 'string') { - Logger.error(`[SearchController] ${methodName}: Invalid or missing ${paramName}`) - return { - valid: false, - error: { - status: 400, - message: `Invalid or missing ${paramName}` - } - } - } - return { valid: true } - } - - /** - * Validates and fetches a library item by ID + * Fetches a library item by ID * @param {string} id - Library item ID * @param {string} methodName - Name of the calling method for logging - * @returns {Promise<{valid: boolean, libraryItem?: any, error?: {status: number, message: string}}>} + * @returns {Promise} */ - static async fetchAndValidateLibraryItem(id, methodName) { - const validation = SearchController.validateRequiredString(id, 'library item id', methodName) - if (!validation.valid) { - return validation - } - + static async fetchLibraryItem(id) { const libraryItem = await Database.libraryItemModel.getExpandedById(id) if (!libraryItem) { - Logger.error(`[SearchController] ${methodName}: Library item not found with id "${id}"`) - return { - valid: false, - error: { - status: 404, - message: 'Library item not found' - } - } + throw new NotFoundError(`library item "${id}" not found`) } - - return { valid: true, libraryItem } + return libraryItem } /** @@ -139,21 +83,25 @@ class SearchController { * @param {Response} res */ async findBooks(req, res) { - // Safely extract query parameters, rejecting arrays to prevent type confusion - const provider = getQueryParamAsString(req.query.provider, 'google') - const title = getQueryParamAsString(req.query.title, '') - const author = getQueryParamAsString(req.query.author, '') + try { + const query = req.query + const provider = getQueryParamAsString(query, 'provider', 'google') + const title = getQueryParamAsString(query, 'title', '') + const author = getQueryParamAsString(query, 'author', '') + const id = getQueryParamAsString(query, 'id', '', true) - // Validate string parameters - const validation = SearchController.validateStringParams({ provider, title, author }, 'findBooks') - if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) + // Fetch library item + const libraryItem = await SearchController.fetchLibraryItem(id) - // Fetch and validate library item - const itemValidation = await SearchController.fetchAndValidateLibraryItem(req.query.id, 'findBooks') - if (!itemValidation.valid) return res.status(itemValidation.error.status).send(itemValidation.error.message) - - const results = await BookFinder.search(itemValidation.libraryItem, provider, title, author) - res.json(results) + const results = await BookFinder.search(libraryItem, provider, title, author) + res.json(results) + } catch (error) { + Logger.error(`[SearchController] findBooks: ${error.message}`) + if (error instanceof ValidationError || error instanceof NotFoundError) { + return res.status(error.status).send(error.message) + } + return res.status(500).send('Internal server error') + } } /** @@ -163,24 +111,24 @@ class SearchController { * @param {Response} res */ async findCovers(req, res) { - const query = req.query - const podcast = query.podcast === '1' || query.podcast === 1 - const title = getQueryParamAsString(query.title, '') - const author = getQueryParamAsString(query.author, '') - const provider = getQueryParamAsString(query.provider, 'google') + try { + const query = req.query + const podcast = query.podcast === '1' || query.podcast === 1 + const title = getQueryParamAsString(query, 'title', '', true) + const author = getQueryParamAsString(query, 'author', '') + const provider = getQueryParamAsString(query, 'provider', 'google') - // Validate required title - const titleValidation = SearchController.validateRequiredString(title, 'title', 'findCovers') - if (!titleValidation.valid) return res.status(titleValidation.error.status).send(titleValidation.error.message) - - // Validate other string parameters - const validation = SearchController.validateStringParams({ author, provider }, 'findCovers') - if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) - - let results = null - if (podcast) results = await PodcastFinder.findCovers(title) - else results = await BookFinder.findCovers(provider, title, author) - res.json({ results }) + let results = null + if (podcast) results = await PodcastFinder.findCovers(title) + else results = await BookFinder.findCovers(provider, title, author) + res.json({ results }) + } catch (error) { + Logger.error(`[SearchController] findCovers: ${error.message}`) + if (error instanceof ValidationError) { + return res.status(error.status).send(error.message) + } + return res.status(500).send('Internal server error') + } } /** @@ -191,36 +139,42 @@ class SearchController { * @param {Response} res */ async findPodcasts(req, res) { - const term = getQueryParamAsString(req.query.term) - const country = getQueryParamAsString(req.query.country, 'us') + try { + const query = req.query + const term = getQueryParamAsString(query, 'term', '', true) + const country = getQueryParamAsString(query, 'country', 'us') - // Validate required term - const termValidation = SearchController.validateRequiredString(term, 'term', 'findPodcasts') - if (!termValidation.valid) return res.status(termValidation.error.status).send(termValidation.error.message) - - // Validate country parameter - const validation = SearchController.validateStringParams({ country }, 'findPodcasts') - if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) - - const results = await PodcastFinder.search(term, { country }) - res.json(results) + const results = await PodcastFinder.search(term, { country }) + res.json(results) + } catch (error) { + Logger.error(`[SearchController] findPodcasts: ${error.message}`) + if (error instanceof ValidationError) { + return res.status(error.status).send(error.message) + } + return res.status(500).send('Internal server error') + } } /** * GET: /api/search/authors + * Note: This endpoint is not currently used in the web client. * * @param {RequestWithUser} req * @param {Response} res */ async findAuthor(req, res) { - const query = getQueryParamAsString(req.query.q) + try { + const query = getQueryParamAsString(req.query, 'q', '', true) - // Validate query parameter - const validation = SearchController.validateRequiredString(query, 'query', 'findAuthor') - if (!validation.valid) return res.status(validation.error.status).send(validation.error.message) - - const author = await AuthorFinder.findAuthorByName(query) - res.json(author) + const author = await AuthorFinder.findAuthorByName(query) + res.json(author) + } catch (error) { + Logger.error(`[SearchController] findAuthor: ${error.message}`) + if (error instanceof ValidationError) { + return res.status(error.status).send(error.message) + } + return res.status(500).send('Internal server error') + } } /** @@ -230,24 +184,30 @@ class SearchController { * @param {Response} res */ async findChapters(req, res) { - const asin = getQueryParamAsString(req.query.asin) - const region = getQueryParamAsString(req.query.region, 'us').toLowerCase() + try { + const query = req.query + const asin = getQueryParamAsString(query, 'asin', '', true) + const region = getQueryParamAsString(req.query.region, 'us').toLowerCase() - // Validate ASIN parameter - const asinValidation = SearchController.validateRequiredString(asin, 'asin', 'findChapters') - if (!asinValidation.valid) return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' }) + if (!isValidASIN(asin.toUpperCase())) throw new ValidationError('asin', 'is invalid') - if (!isValidASIN(asin.toUpperCase())) return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' }) - - // Validate region parameter - const validation = SearchController.validateStringParams({ region }, 'findChapters') - if (!validation.valid) res.json({ error: 'Invalid region', stringKey: 'MessageInvalidRegion' }) - - const chapterData = await BookFinder.findChapters(asin, region) - if (!chapterData) { - return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' }) + const chapterData = await BookFinder.findChapters(asin, region) + if (!chapterData) { + return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' }) + } + res.json(chapterData) + } catch (error) { + Logger.error(`[SearchController] findChapters: ${error.message}`) + if (error instanceof ValidationError) { + if (error.paramName === 'asin') { + return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' }) + } + if (error.paramName === 'region') { + return res.json({ error: 'Invalid region', stringKey: 'MessageInvalidRegion' }) + } + } + return res.status(500).send('Internal server error') } - res.json(chapterData) } /** diff --git a/server/utils/index.js b/server/utils/index.js index 0661d14f8..c7700a783 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -278,29 +278,56 @@ module.exports.timestampToSeconds = (timestamp) => { return null } +class ValidationError extends Error { + constructor(paramName, message, status = 400) { + super(`Query parameter "${paramName}" ${message}`) + this.name = 'ValidationError' + this.paramName = paramName + this.status = status + } +} +module.exports.ValidationError = ValidationError + +class NotFoundError extends Error { + constructor(message, status = 404) { + super(message) + this.name = 'NotFoundError' + this.status = status + } +} +module.exports.NotFoundError = NotFoundError + /** * Safely extracts a query parameter as a string, rejecting arrays to prevent type confusion * Express query parameters can be arrays if the same parameter appears multiple times * @example ?author=Smith => "Smith" - * @example ?author=Smith&author=Jones => null (array detected) + * @example ?author=Smith&author=Jones => throws error * - * @param {any} value - Query parameter value + * @param {Object} query - Query object + * @param {string} paramName - Parameter name * @param {string} defaultValue - Default value if undefined/null + * @param {boolean} required - Whether the parameter is required * @param {number} maxLength - Optional maximum length (defaults to 10000 to prevent ReDoS attacks) - * @returns {string|null} String value or null if invalid (array or too long) + * @returns {string} String value + * @throws {ValidationError} If value is an array + * @throws {ValidationError} If value is too long + * @throws {ValidationError} If value is required but not provided */ -module.exports.getQueryParamAsString = (value, defaultValue = '', maxLength = 1000) => { +module.exports.getQueryParamAsString = (query, paramName, defaultValue = '', required = false, maxLength = 1000) => { + const value = query[paramName] + if (value === undefined || value === null) { + if (required) { + throw new ValidationError(paramName, 'is required') + } + return defaultValue + } // Explicitly reject arrays to prevent type confusion if (Array.isArray(value)) { - return null - } - // Return default for undefined/null - if (value == null) { - return defaultValue + throw new ValidationError(paramName, 'is an array') } // Reject excessively long strings to prevent ReDoS attacks if (typeof value === 'string' && value.length > maxLength) { - return null + throw new ValidationError(paramName, 'is too long') } - return value + return String(value) } From 57c7b123f05d416347829edf13ef10a798bd0fd5 Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 21 Oct 2025 11:00:29 +0300 Subject: [PATCH 14/24] Fix codeQL error: Return json error object --- server/controllers/SearchController.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index 9bb6e397d..5a16229cd 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -98,9 +98,9 @@ class SearchController { } catch (error) { Logger.error(`[SearchController] findBooks: ${error.message}`) if (error instanceof ValidationError || error instanceof NotFoundError) { - return res.status(error.status).send(error.message) + return res.status(error.status).json({ error: error.message }) } - return res.status(500).send('Internal server error') + return res.status(500).json({ error: 'Internal server error' }) } } @@ -125,9 +125,9 @@ class SearchController { } catch (error) { Logger.error(`[SearchController] findCovers: ${error.message}`) if (error instanceof ValidationError) { - return res.status(error.status).send(error.message) + return res.status(error.status).json({ error: error.message }) } - return res.status(500).send('Internal server error') + return res.status(500).json({ error: 'Internal server error' }) } } @@ -149,9 +149,9 @@ class SearchController { } catch (error) { Logger.error(`[SearchController] findPodcasts: ${error.message}`) if (error instanceof ValidationError) { - return res.status(error.status).send(error.message) + return res.status(error.status).json({ error: error.message }) } - return res.status(500).send('Internal server error') + return res.status(500).json({ error: 'Internal server error' }) } } @@ -171,9 +171,9 @@ class SearchController { } catch (error) { Logger.error(`[SearchController] findAuthor: ${error.message}`) if (error instanceof ValidationError) { - return res.status(error.status).send(error.message) + return res.status(error.status).json({ error: error.message }) } - return res.status(500).send('Internal server error') + return res.status(500).json({ error: 'Internal server error' }) } } @@ -206,7 +206,7 @@ class SearchController { return res.json({ error: 'Invalid region', stringKey: 'MessageInvalidRegion' }) } } - return res.status(500).send('Internal server error') + return res.status(500).json({ error: 'Internal server error' }) } } From 6db6b862e6c5342564639e741ec01ae32deed8fa Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 21 Oct 2025 17:33:53 +0300 Subject: [PATCH 15/24] Upgrade codeql-actions to v3 --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2e5f4bced..809563018 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -60,7 +60,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -73,6 +73,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: '/language:${{matrix.language}}' From e684a8dc43cedcb5f7af003b54c09bfa2bffc3d4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 21 Oct 2025 17:22:10 -0500 Subject: [PATCH 16/24] Update JSDocs & auto-formatting of PodcastFinder --- server/controllers/SearchController.js | 2 +- server/finders/PodcastFinder.js | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index 5a16229cd..f6f0ba475 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -42,7 +42,7 @@ class SearchController { * Fetches a library item by ID * @param {string} id - Library item ID * @param {string} methodName - Name of the calling method for logging - * @returns {Promise} + * @returns {Promise} */ static async fetchLibraryItem(id) { const libraryItem = await Database.libraryItemModel.getExpandedById(id) diff --git a/server/finders/PodcastFinder.js b/server/finders/PodcastFinder.js index abaf02ac6..40d6a5a00 100644 --- a/server/finders/PodcastFinder.js +++ b/server/finders/PodcastFinder.js @@ -7,9 +7,9 @@ class PodcastFinder { } /** - * - * @param {string} term - * @param {{country:string}} options + * + * @param {string} term + * @param {{country:string}} options * @returns {Promise} */ async search(term, options = {}) { @@ -20,12 +20,16 @@ class PodcastFinder { return results } + /** + * @param {string} term + * @returns {Promise} + */ async findCovers(term) { if (!term) return null Logger.debug(`[iTunes] Searching for podcast covers with term "${term}"`) - var results = await this.iTunesApi.searchPodcasts(term) + const results = await this.iTunesApi.searchPodcasts(term) if (!results) return [] - return results.map(r => r.cover).filter(r => r) + return results.map((r) => r.cover).filter((r) => r) } } -module.exports = new PodcastFinder() \ No newline at end of file +module.exports = new PodcastFinder() From f03b0915ebb5df1d642b6874ca28949be52a14b2 Mon Sep 17 00:00:00 2001 From: Hezha Date: Wed, 8 Oct 2025 22:54:01 +0200 Subject: [PATCH 17/24] Translated using Weblate (Arabic) Currently translated at 95.9% (1116 of 1163 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/ --- client/strings/ar.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/strings/ar.json b/client/strings/ar.json index fb13ad52b..e3e13a859 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -21,7 +21,8 @@ "ButtonChooseAFolder": "اختر المجلد", "ButtonChooseFiles": "اختر الملفات", "ButtonClearFilter": "تصفية الفرز", - "ButtonCloseFeed": "إغلاق", + "ButtonClose": "إغلاق", + "ButtonCloseFeed": "إغلاق الموجز", "ButtonCloseSession": "إغلاق الجلسة المفتوحة", "ButtonCollections": "المجموعات", "ButtonConfigureScanner": "إعدادات الماسح الضوئي", @@ -120,11 +121,13 @@ "HeaderAccount": "الحساب", "HeaderAddCustomMetadataProvider": "إضافة موفر بيانات تعريفية مخصص", "HeaderAdvanced": "متقدم", + "HeaderApiKeys": "مفاتيح API", "HeaderAppriseNotificationSettings": "إعدادات الإشعارات", "HeaderAudioTracks": "المقاطع الصوتية", "HeaderAudiobookTools": "أدوات إدارة ملفات الكتب الصوتية", "HeaderAuthentication": "المصادقة", "HeaderBackups": "النسخ الاحتياطية", + "HeaderBulkChapterModal": "أضف فصولاً متعددة", "HeaderChangePassword": "تغيير كلمة المرور", "HeaderChapters": "الفصول", "HeaderChooseAFolder": "اختيار المجلد", @@ -163,6 +166,7 @@ "HeaderMetadataOrderOfPrecedence": "ترتيب أولوية البيانات الوصفية", "HeaderMetadataToEmbed": "البيانات الوصفية المراد تضمينها", "HeaderNewAccount": "حساب جديد", + "HeaderNewApiKey": "مفتاح API جديد", "HeaderNewLibrary": "مكتبة جديدة", "HeaderNotificationCreate": "إنشاء إشعار", "HeaderNotificationUpdate": "تحديث إشعار", @@ -196,6 +200,7 @@ "HeaderSettingsExperimental": "ميزات تجريبية", "HeaderSettingsGeneral": "عام", "HeaderSettingsScanner": "إعدادات المسح", + "HeaderSettingsSecurity": "الأمان", "HeaderSettingsWebClient": "عميل الويب", "HeaderSleepTimer": "مؤقت النوم", "HeaderStatsLargestItems": "أكبر العناصر حجماً", @@ -207,6 +212,7 @@ "HeaderTableOfContents": "جدول المحتويات", "HeaderTools": "أدوات", "HeaderUpdateAccount": "تحديث الحساب", + "HeaderUpdateApiKey": "تحديث مفتاح API", "HeaderUpdateAuthor": "تحديث المؤلف", "HeaderUpdateDetails": "تحديث التفاصيل", "HeaderUpdateLibrary": "تحديث المكتبة", @@ -236,6 +242,8 @@ "LabelAllUsersExcludingGuests": "جميع المستخدمين باستثناء الضيوف", "LabelAllUsersIncludingGuests": "جميع المستخدمين بما في ذلك الضيوف", "LabelAlreadyInYourLibrary": "موجود بالفعل في مكتبتك", + "LabelApiKeyCreated": "تم إنشاء مفتاح API \"{0}\" بنجاح.", + "LabelApiKeyCreatedDescription": "تأكد من نسخ مفتاح API الآن، لن تتمكن من رؤيته مرة أخرى.", "LabelApiToken": "رمز API", "LabelAppend": "إلحاق", "LabelAudioBitrate": "معدل بت الصوت (على سبيل المثال 128 كيلو بايت)", From ee2d8d1f719178a50a70b6ecc2f3bd6c53dc329c Mon Sep 17 00:00:00 2001 From: pryszczoskor Date: Wed, 8 Oct 2025 23:02:06 +0200 Subject: [PATCH 18/24] Translated using Weblate (Polish) Currently translated at 88.2% (1026 of 1163 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/ --- client/strings/pl.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/pl.json b/client/strings/pl.json index 98335a21d..0b92dc89c 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -400,7 +400,7 @@ "LabelHours": "Godziny", "LabelIcon": "Ikona", "LabelImageURLFromTheWeb": "Link do obrazu w sieci", - "LabelInProgress": "W trakcie", + "LabelInProgress": "W toku", "LabelIncludeInTracklist": "Dołącz do listy odtwarzania", "LabelIncomplete": "Nieukończone", "LabelInterval": "Interwał", From 96693659bf3e0642be509ab8d6a27ba6868febf7 Mon Sep 17 00:00:00 2001 From: Coxe Date: Fri, 10 Oct 2025 21:11:49 +0200 Subject: [PATCH 19/24] Translated using Weblate (Danish) Currently translated at 96.9% (1127 of 1163 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/da/ --- client/strings/da.json | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/client/strings/da.json b/client/strings/da.json index cb1351450..ab1444970 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -1001,13 +1001,14 @@ "ToastCollectionItemsAddFailed": "Genstand(e) tilføjet til kollektion fejlet", "ToastCollectionRemoveSuccess": "Samling fjernet", "ToastCollectionUpdateSuccess": "Samling opdateret", + "ToastConnectionNotAvailable": "Forbindelse mislykkedes. Prøv igen senere", "ToastCoverUpdateFailed": "Cover opdatering fejlede", - "ToastDateTimeInvalidOrIncomplete": "Dato og tid er forkert eller ufærdig", - "ToastDeleteFileFailed": "Slet fil fejlede", + "ToastDateTimeInvalidOrIncomplete": "Dato og tid er ugyldig eller ufærdig", + "ToastDeleteFileFailed": "Sletning af fil fejlede", "ToastDeleteFileSuccess": "Fil slettet", - "ToastDeviceAddFailed": "Fejlede at tilføje enhed", - "ToastDeviceNameAlreadyExists": "Elæser enhed med det navn eksistere allerede", - "ToastDeviceTestEmailFailed": "Fejlede at sende test mail", + "ToastDeviceAddFailed": "Tilføjelse af enhed Fejlede", + "ToastDeviceNameAlreadyExists": "E-læser enhed med det navn eksistere allerede", + "ToastDeviceTestEmailFailed": "Afsendelse af test mail fejlede", "ToastDeviceTestEmailSuccess": "Test mail sendt", "ToastEmailSettingsUpdateSuccess": "Mail indstillinger opdateret", "ToastEncodeCancelFailed": "Fejlede at afbryde indkodning", @@ -1017,21 +1018,23 @@ "ToastEpisodeUpdateSuccess": "{0} afsnit opdateret", "ToastErrorCannotShare": "Kan ikke dele på denne enhed", "ToastFailedToCreate": "Oprettelsen mislykkedes", - "ToastFailedToLoadData": "Fejlede at indlæse data", + "ToastFailedToDelete": "Sletning fejlede", + "ToastFailedToLoadData": "Indlæsning af data fejlede", "ToastFailedToMatch": "Fejlet match", - "ToastFailedToShare": "Fejlet deling", + "ToastFailedToShare": "Deling fejlede", "ToastFailedToUpdate": "Fejlet opdatering", - "ToastInvalidImageUrl": "Forkert billede URL", - "ToastInvalidMaxEpisodesToDownload": "Forkert maks afsnit at hente", - "ToastInvalidUrl": "Forkert URL", - "ToastItemCoverUpdateSuccess": "Varens omslag opdateret", - "ToastItemDeletedFailed": "Fejlede at slette genstand", + "ToastInvalidImageUrl": "Ugyldig billede URL", + "ToastInvalidMaxEpisodesToDownload": "Ugyldigt maks afsnit at hente", + "ToastInvalidUrl": "Ugyldig URL", + "ToastInvalidUrls": "En eller flere URLer er ugyldige", + "ToastItemCoverUpdateSuccess": "Omslag opdateret", + "ToastItemDeletedFailed": "Sletning af genstand fejlede", "ToastItemDeletedSuccess": "Genstand slettet", - "ToastItemDetailsUpdateSuccess": "Varedetaljer opdateret", - "ToastItemMarkedAsFinishedFailed": "Mislykkedes markering som afsluttet", - "ToastItemMarkedAsFinishedSuccess": "Vare markeret som afsluttet", - "ToastItemMarkedAsNotFinishedFailed": "Mislykkedes markering som ikke afsluttet", - "ToastItemMarkedAsNotFinishedSuccess": "Vare markeret som ikke afsluttet", + "ToastItemDetailsUpdateSuccess": "Detaljer opdateret", + "ToastItemMarkedAsFinishedFailed": "Markering som afsluttet mislykkedes", + "ToastItemMarkedAsFinishedSuccess": "Element markeret som afsluttet", + "ToastItemMarkedAsNotFinishedFailed": "Markering som ikke afsluttet mislykkedes", + "ToastItemMarkedAsNotFinishedSuccess": "Element markeret som ikke afsluttet", "ToastItemUpdateSuccess": "Genstand opdateret", "ToastLibraryCreateFailed": "Oprettelse af bibliotek mislykkedes", "ToastLibraryCreateSuccess": "Bibliotek \"{0}\" oprettet", From d59714d804c46f0027235394914f44879eb463d3 Mon Sep 17 00:00:00 2001 From: pmangro Date: Sat, 11 Oct 2025 00:07:00 +0200 Subject: [PATCH 20/24] Translated using Weblate (Portuguese (Brazil)) Currently translated at 74.2% (863 of 1163 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pt_BR/ --- client/strings/pt-br.json | 93 ++++++++++++++++++++++++++++++++++----- 1 file changed, 82 insertions(+), 11 deletions(-) diff --git a/client/strings/pt-br.json b/client/strings/pt-br.json index 9604daaed..e31aa3f03 100644 --- a/client/strings/pt-br.json +++ b/client/strings/pt-br.json @@ -1,6 +1,6 @@ { "ButtonAdd": "Adicionar", - "ButtonAddApiKey": "Adicionar Chave API", + "ButtonAddApiKey": "Adicionar chave de API", "ButtonAddChapters": "Adicionar Capítulos", "ButtonAddDevice": "Adicionar Dispositivo", "ButtonAddLibrary": "Adicionar Biblioteca", @@ -21,6 +21,7 @@ "ButtonChooseAFolder": "Escolha uma pasta", "ButtonChooseFiles": "Escolha arquivos", "ButtonClearFilter": "Limpar Filtro", + "ButtonClose": "Fechar", "ButtonCloseFeed": "Fechar Feed", "ButtonCloseSession": "Fechar Sessão Aberta", "ButtonCollections": "Coleções", @@ -53,7 +54,7 @@ "ButtonNevermind": "Cancelar", "ButtonNext": "Próximo", "ButtonNextChapter": "Próximo Capítulo", - "ButtonNextItemInQueue": "Próximo Item da Fila", + "ButtonNextItemInQueue": "Próximo Item na Fila", "ButtonOk": "Ok", "ButtonOpenFeed": "Abrir Feed", "ButtonOpenManager": "Abrir Gerenciador", @@ -120,10 +121,13 @@ "HeaderAccount": "Conta", "HeaderAddCustomMetadataProvider": "Adicionar Provedor de Metadados Personalizado", "HeaderAdvanced": "Avançado", + "HeaderApiKeys": "Chaves de API", "HeaderAppriseNotificationSettings": "Configuração de notificações Apprise", "HeaderAudioTracks": "Trilhas de áudio", "HeaderAudiobookTools": "Ferramentas de Gerenciamento de Arquivos de Audiobooks", "HeaderAuthentication": "Autenticação", + "HeaderBackups": "Backups", + "HeaderBulkChapterModal": "Adicionar vários capítulos", "HeaderChangePassword": "Trocar Senha", "HeaderChapters": "Capítulos", "HeaderChooseAFolder": "Escolha uma Pasta", @@ -136,6 +140,7 @@ "HeaderDetails": "Detalhes", "HeaderDownloadQueue": "Fila de Download", "HeaderEbookFiles": "Arquivos Ebook", + "HeaderEmail": "Email", "HeaderEmailSettings": "Configurações de Email", "HeaderEpisodes": "Episódios", "HeaderEreaderDevices": "Dispositivos Ereader", @@ -152,6 +157,8 @@ "HeaderLibraryStats": "Estatísticas da Biblioteca", "HeaderListeningSessions": "Sessões", "HeaderListeningStats": "Estatísticas", + "HeaderLogin": "Login", + "HeaderLogs": "Logs", "HeaderManageGenres": "Gerenciar Gêneros", "HeaderManageTags": "Gerenciar Etiquetas", "HeaderMapDetails": "Designar Detalhes", @@ -159,17 +166,23 @@ "HeaderMetadataOrderOfPrecedence": "Ordem de Prioridade dos Metadados", "HeaderMetadataToEmbed": "Metadados a Serem Incluídos", "HeaderNewAccount": "Nova Conta", + "HeaderNewApiKey": "Nova chave de API", "HeaderNewLibrary": "Nova Biblioteca", + "HeaderNotificationCreate": "Criar Notificação", + "HeaderNotificationUpdate": "Atualizar Notificação", "HeaderNotifications": "Notificações", "HeaderOpenIDConnectAuthentication": "Autenticação via OpenID Connect", + "HeaderOpenListeningSessions": "Abrir Sessões de Escuta", "HeaderOpenRSSFeed": "Abrir Feed RSS", "HeaderOtherFiles": "Outros Arquivos", "HeaderPasswordAuthentication": "Autenticação por Senha", "HeaderPermissions": "Permissões", "HeaderPlayerQueue": "Fila do reprodutor", + "HeaderPlayerSettings": "Configurações do Reprodutor", "HeaderPlaylist": "Lista de Reprodução", "HeaderPlaylistItems": "Itens da lista de reprodução", "HeaderPodcastsToAdd": "Podcasts para Adicionar", + "HeaderPresets": "Valores predefinidos", "HeaderPreviewCover": "Visualização da Capa", "HeaderRSSFeedGeneral": "Detalhes RSS", "HeaderRSSFeedIsOpen": "Feed RSS está Aberto", @@ -178,6 +191,7 @@ "HeaderRemoveEpisodes": "Remover {0} Episódios", "HeaderSavedMediaProgress": "Progresso da gravação das mídias", "HeaderSchedule": "Programação", + "HeaderScheduleEpisodeDownloads": "Programar Download Automático de Episódios", "HeaderScheduleLibraryScans": "Programar Verificação Automática da Biblioteca", "HeaderSession": "Sessão", "HeaderSetBackupSchedule": "Definir Programação de Backup", @@ -186,6 +200,8 @@ "HeaderSettingsExperimental": "Funcionalidades experimentais", "HeaderSettingsGeneral": "Geral", "HeaderSettingsScanner": "Verificador", + "HeaderSettingsSecurity": "Segurança", + "HeaderSettingsWebClient": "Cliente Web", "HeaderSleepTimer": "Timer", "HeaderStatsLargestItems": "Maiores Itens", "HeaderStatsLongestItems": "Itens mais longos (hrs)", @@ -196,6 +212,7 @@ "HeaderTableOfContents": "Sumário", "HeaderTools": "Ferramentas", "HeaderUpdateAccount": "Atualizar Conta", + "HeaderUpdateApiKey": "Atualizar Chave de API", "HeaderUpdateAuthor": "Atualizar Autor", "HeaderUpdateDetails": "Atualizar Detalhes", "HeaderUpdateLibrary": "Atualizar Biblioteca", @@ -210,6 +227,7 @@ "LabelAccountTypeAdmin": "Administrador", "LabelAccountTypeGuest": "Convidado", "LabelAccountTypeUser": "Usuário", + "LabelActivities": "Atividades", "LabelActivity": "Atividade", "LabelAddToCollection": "Adicionar à Coleção", "LabelAddToCollectionBatch": "Adicionar {0} Livros à Coleção", @@ -219,11 +237,20 @@ "LabelAddedDate": "Adicionado {0}", "LabelAdminUsersOnly": "Apenas usuários administradores", "LabelAll": "Todos", + "LabelAllEpisodesDownloaded": "Todos os episódios baixados", "LabelAllUsers": "Todos Usuários", "LabelAllUsersExcludingGuests": "Todos usuários exceto convidados", "LabelAllUsersIncludingGuests": "Todos usuários incluindo convidados", "LabelAlreadyInYourLibrary": "Já na sua biblioteca", + "LabelApiKeyCreated": "Chave de API \"{0}\" criada com sucesso.", + "LabelApiKeyCreatedDescription": "Certifique-se de copiar a chave de API agora pois não será possível vê-la novamente.", + "LabelApiKeyUser": "Agir em nome do usuário", + "LabelApiKeyUserDescription": "Esta chave de API terá as mesmas permissões que o usuário em nome de quem ela está agindo. Isso aparecerá nos logs como se o usuário estivesse fazendo a solicitação.", + "LabelApiToken": "Token de API", "LabelAppend": "Acrescentar", + "LabelAudioBitrate": "Bitrate de áudio (por exemplo, 128k)", + "LabelAudioChannels": "Canais de áudio (1 ou 2)", + "LabelAudioCodec": "Codec de áudio", "LabelAuthor": "Autor", "LabelAuthorFirstLast": "Autor (Nome Sobrenome)", "LabelAuthorLastFirst": "Autor (Sobrenome, Nome)", @@ -236,24 +263,31 @@ "LabelAutoRegister": "Registrar Automaticamente", "LabelAutoRegisterDescription": "Registra automaticamente novos usuários após login", "LabelBackToUser": "Voltar para Usuário", + "LabelBackupAudioFiles": "Backup dos Arquivos de Áudio", "LabelBackupLocation": "Localização do Backup", - "LabelBackupsEnableAutomaticBackups": "Ativar backups automáticos", + "LabelBackupsEnableAutomaticBackups": "Backups automáticos", "LabelBackupsEnableAutomaticBackupsHelp": "Backups salvos em /metadata/backups", - "LabelBackupsMaxBackupSize": "Tamanho máximo do backup (em GB)", + "LabelBackupsMaxBackupSize": "Tamanho máximo do backup (em GB) (0 para ilimitado)", "LabelBackupsMaxBackupSizeHelp": "Como proteção contra uma configuração incorreta, backups darão erro se excederem o tamanho configurado.", "LabelBackupsNumberToKeep": "Número de backups para guardar", "LabelBackupsNumberToKeepHelp": "Apenas 1 backup será removido por vez, então, se já existem mais backups, você deve apagá-los manualmente.", + "LabelBitrate": "Bitrate", + "LabelBonus": "Bônus", "LabelBooks": "Livros", "LabelButtonText": "Texto do botão", "LabelByAuthor": "por {0}", "LabelChangePassword": "Trocar Senha", "LabelChannels": "Canais", + "LabelChapterCount": "{0} Capítulos", "LabelChapterTitle": "Título do Capítulo", "LabelChapters": "Capítulos", "LabelChaptersFound": "capítulos encontrados", "LabelClickForMoreInfo": "Clique para mais informações", + "LabelClickToUseCurrentValue": "Clique para usar o valor atual", "LabelClosePlayer": "Fechar Reprodutor", + "LabelCodec": "Codec", "LabelCollapseSeries": "Fechar Série", + "LabelCollapseSubSeries": "Fechar Sub Séries", "LabelCollection": "Coleção", "LabelCollections": "Coleções", "LabelComplete": "Concluído", @@ -261,17 +295,21 @@ "LabelContinueListening": "Continuar Escutando", "LabelContinueReading": "Continuar Lendo", "LabelContinueSeries": "Continuar Série", + "LabelCorsAllowed": "Origens Permitidas para CORS", "LabelCover": "Capa", "LabelCoverImageURL": "URL da Imagem da Capa", + "LabelCoverProvider": "Provedor de Capas", "LabelCreatedAt": "Criado em", "LabelCronExpression": "Expressão para o Cron", "LabelCurrent": "Atual", "LabelCurrently": "Atualmente:", "LabelCustomCronExpression": "Expressão personalizada para o Cron:", "LabelDatetime": "Data e Hora", + "LabelDays": "Dias", "LabelDeleteFromFileSystemCheckbox": "Apagar do sistema de arquivos (desmarcar para remover apenas da base de dados)", "LabelDescription": "Descrição", "LabelDeselectAll": "Desmarcar tudo", + "LabelDetectedPattern": "Padrão detectado:", "LabelDevice": "Dispositivo", "LabelDeviceInfo": "Informação do Dispositivo", "LabelDeviceIsAvailableTo": "Dispositivo está disponível para...", @@ -281,6 +319,7 @@ "LabelDiscover": "Descobrir", "LabelDownload": "Download", "LabelDownloadNEpisodes": "Download de {0} Episódios", + "LabelDownloadable": "Baixável", "LabelDuration": "Duração", "LabelDurationComparisonExactMatch": "(exato)", "LabelDurationComparisonLonger": "({0} maior)", @@ -289,6 +328,7 @@ "LabelEbook": "Ebook", "LabelEbooks": "Ebooks", "LabelEdit": "Editar", + "LabelEmail": "Email", "LabelEmailSettingsFromAddress": "Remetente", "LabelEmailSettingsRejectUnauthorized": "Rejeitar certificados não autorizados", "LabelEmailSettingsRejectUnauthorizedHelp": "Desativar a validação de certificados SSL pode expor sua conexão a riscos de segurança, como ataques \"man-in-the-middle\". Desative essa opção apenas se entender suas consequências e se puder confiar no servidor de email ao qual você está se conectando.", @@ -297,6 +337,15 @@ "LabelEmailSettingsTestAddress": "Endereço de teste", "LabelEmbeddedCover": "Capa Integrada", "LabelEnable": "Habilitar", + "LabelEncodingBackupLocation": "Um backup dos seus arquivos de áudio original será gravado em:", + "LabelEncodingChaptersNotEmbedded": "Capítulos não são integrados em audiobooks com várias trilhas.", + "LabelEncodingClearItemCache": "Certifique-se de, periodicamente, apagar os itens do cache.", + "LabelEncodingFinishedM4B": "O arquivo M4B final será colocado na sua pasta de audiobooks em:", + "LabelEncodingInfoEmbedded": "Os metadados serão integrados nas trilhas de áudio dentro da sua pasta de audiobooks.", + "LabelEncodingStartedNavigation": "Assim que a tarefa for iniciada você pode sair dessa página.", + "LabelEncodingTimeWarning": "A codificação pode durar até 30 minutos.", + "LabelEncodingWarningAdvancedSettings": "Aviso: não atualize essas configurações se não estiver familiarizado com as opções de codificação do ffmpeg.", + "LabelEncodingWatcherDisabled": "Se você desabilitou o monitoramento, será necessário fazer uma nova verificação deste audiobook depois.", "LabelEnd": "Fim", "LabelEpisode": "Episódio", "LabelEpisodeTitle": "Título do Episódio", @@ -329,6 +378,7 @@ "LabelHasEbook": "Tem ebook", "LabelHasSupplementaryEbook": "Tem ebook complementar", "LabelHighestPriority": "Prioridade mais alta", + "LabelHost": "Host", "LabelHour": "Hora", "LabelIcon": "Ícone", "LabelImageURLFromTheWeb": "URL da imagem na internet", @@ -345,6 +395,7 @@ "LabelIntervalEveryDay": "Todo dia", "LabelIntervalEveryHour": "Toda hora", "LabelInvert": "Inverter", + "LabelItem": "Item", "LabelLanguage": "Idioma", "LabelLanguageDefaultServer": "Idioma Padrão do Servidor", "LabelLanguages": "Idiomas", @@ -353,16 +404,20 @@ "LabelLastSeen": "Visto pela Última Vez", "LabelLastTime": "Progresso", "LabelLastUpdate": "Última Atualização", + "LabelLayout": "Layout", "LabelLayoutSinglePage": "Uma página", "LabelLayoutSplitPage": "Página dividida", "LabelLess": "Menos", "LabelLibrariesAccessibleToUser": "Bibliotecas Acessíveis ao Usuário", "LabelLibrary": "Biblioteca", + "LabelLibraryFilterSublistEmpty": "Sem {0}", "LabelLibraryItem": "Item da Biblioteca", "LabelLibraryName": "Nome da Biblioteca", "LabelLimit": "Limite", "LabelLineSpacing": "Espaçamento entre linhas", "LabelListenAgain": "Escutar novamente", + "LabelLogLevelDebug": "Debug", + "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Atenção", "LabelLookForNewEpisodesAfterDate": "Procurar por novos Episódios após essa data", "LabelLowestPriority": "Prioridade mais baixa", @@ -424,9 +479,12 @@ "LabelPersonalYearReview": "Sua Retrospectiva Anual ({0})", "LabelPhotoPathURL": "Caminho/URL para Foto", "LabelPlayMethod": "Método de Reprodução", + "LabelPlayerChapterNumberMarker": "{0} de {1}", "LabelPlaylists": "Listas de Reprodução", + "LabelPodcast": "Podcast", "LabelPodcastSearchRegion": "Região de busca do podcast", "LabelPodcastType": "Tipo de Podcast", + "LabelPodcasts": "Podcasts", "LabelPort": "Porta", "LabelPrefixesToIgnore": "Prefixos para Ignorar (sem distinção entre maiúsculas e minúsculas)", "LabelPreventIndexing": "Evitar que o seu feed seja indexado pelos diretórios de podcast do iTunes e Google", @@ -435,14 +493,16 @@ "LabelProvider": "Fonte", "LabelPubDate": "Data de Publicação", "LabelPublishYear": "Ano de Publicação", + "LabelPublishedDate": "Publicado {0}", "LabelPublisher": "Editora", "LabelPublishers": "Editoras", "LabelRSSFeedCustomOwnerEmail": "E-mail do dono personalizado", "LabelRSSFeedCustomOwnerName": "Nome do dono personalizado", - "LabelRSSFeedOpen": "Feed RSS Aberto", + "LabelRSSFeedOpen": "Feed de RSS Aberto", "LabelRSSFeedPreventIndexing": "Impedir Indexação", "LabelRSSFeedSlug": "Slug do Feed RSS", "LabelRSSFeedURL": "URL do Feed RSS", + "LabelRandomly": "Aleatoriamente", "LabelRead": "Lido", "LabelReadAgain": "Ler novamente", "LabelReadEbookWithoutProgress": "Ler ebook sem armazenar progresso", @@ -475,6 +535,8 @@ "LabelSettingsBookshelfViewHelp": "Aparência esqueomorfa com prateleiras de madeira", "LabelSettingsChromecastSupport": "Suporte ao Chromecast", "LabelSettingsDateFormat": "Formato de data", + "LabelSettingsEnableWatcher": "Monitorar automaticamente alterações nas bibliotecas", + "LabelSettingsEnableWatcherForLibrary": "Monitorar automaticamente alterações na biblioteca", "LabelSettingsEnableWatcherHelp": "Ativa o acréscimo/atualização de itens quando forem detectadas mudanças no arquivo. *Requer reiniciar o servidor", "LabelSettingsEpubsAllowScriptedContent": "Permitir scripts em epubs", "LabelSettingsEpubsAllowScriptedContentHelp": "Permitir que arquivos epub executem scripts. É recomendado manter essa configuração desativada, a não ser que confie na fonte dos arquivos epub.", @@ -507,6 +569,7 @@ "LabelShowSeconds": "Exibir segundos", "LabelSize": "Tamanho", "LabelSleepTimer": "Timer", + "LabelSlug": "Slug", "LabelStart": "Iniciar", "LabelStartTime": "Horário do Início", "LabelStarted": "Iniciado", @@ -534,6 +597,7 @@ "LabelTagsNotAccessibleToUser": "Etiquetas não Acessíveis Usuário", "LabelTasks": "Tarefas em Execuçào", "LabelTextEditorBulletedList": "Lista com marcadores", + "LabelTextEditorLink": "Link", "LabelTextEditorNumberedList": "Lista numerada", "LabelTextEditorUnlink": "Remover link", "LabelTheme": "Tema", @@ -580,15 +644,16 @@ "LabelViewBookmarks": "Ver marcadores", "LabelViewChapters": "Ver capítulos", "LabelViewQueue": "Ver fila do reprodutor", + "LabelVolume": "Volume", "LabelWeekdaysToRun": "Dias da semana para executar", - "LabelYearReviewHide": "Ocultar Retrospectiva Anual", - "LabelYearReviewShow": "Exibir Retrospectiva Anual", + "LabelYearReviewHide": "Ocultar Retrospectiva", + "LabelYearReviewShow": "Exibir Retrospectiva", "LabelYourAudiobookDuration": "Duração do seu audiobook", "LabelYourBookmarks": "Seus Marcadores", "LabelYourPlaylists": "Suas Listas de Reprodução", "LabelYourProgress": "Seu Progresso", "MessageAddToPlayerQueue": "Adicionar à lista do reprodutor", - "MessageAppriseDescription": "Para utilizar essa funcionalidade é preciso ter uma instância da API do Apprise em execução ou uma api que possa tratar esses mesmos chamados.
A URL da API do Apprise deve conter o caminho completo da URL para enviar as notificações. Ex: se a sua instância da API estiver em http://192.168.1.1:8337 você deve utilizar http://192.168.1.1:8337/notify.", + "MessageAppriseDescription": "Para utilizar essa funcionalidade é preciso ter uma instância da API do Apprise em execução ou uma API que possa tratar esses mesmos chamados.
A URL da API do Apprise deve conter o caminho completo da URL para enviar as notificações. Ex: se a sua instância da API estiver em http://192.168.1.1:8337 você deve utilizar http://192.168.1.1:8337/notify.", "MessageBackupsDescription": "Backups incluem usuários, progresso dos usuários, detalhes dos itens da biblioteca, configurações do servidor e imagens armazenadas em /metadata/items & /metadata/authors. Backups não incluem quaisquer arquivos armazenados nas pastas da sua biblioteca.", "MessageBatchQuickMatchDescription": "Consulta Rápida tentará adicionar capas e metadados ausentes para os itens selecionados. Ative as opções abaixo para permitir que a Consulta Rápida sobrescreva capas e/ou metadados existentes.", "MessageBookshelfNoCollections": "Você ainda não criou coleções", @@ -643,8 +708,8 @@ "MessageForceReScanDescription": "verificará todos os arquivos, como uma verificação nova. Etiquetas ID3 de arquivos de áudio, arquivos OPF e arquivos de texto serão tratados como novos.", "MessageImportantNotice": "Aviso Importante!", "MessageInsertChapterBelow": "Inserir capítulo abaixo", - "MessageItemsSelected": "{0} Itens Selecionados", - "MessageItemsUpdated": "{0} Itens Atualizados", + "MessageItemsSelected": "{0} itens selecionados", + "MessageItemsUpdated": "{0} itens atualizados", "MessageJoinUsOn": "Junte-se a nós", "MessageLoading": "Carregando...", "MessageLoadingFolders": "Carregando pastas...", @@ -692,6 +757,7 @@ "MessagePlayChapter": "Escutar o início do capítulo", "MessagePlaylistCreateFromCollection": "Criar uma lista de reprodução a partir da coleção", "MessagePodcastHasNoRSSFeedForMatching": "Podcast não tem uma URL do feed RSS para ser usada na consulta", + "MessagePodcastSearchField": "Digite um termo para a busca ou a URL de um feed RSS", "MessageQuickMatchDescription": "Preenche detalhes vazios do item & capa com o primeiro resultado de '{0}'. Não sobrescreve detalhes a não ser que a configuração 'Preferir metadados consultados' do servidor esteja ativa.", "MessageRemoveChapter": "Remover capítulo", "MessageRemoveEpisodes": "Remover {0} episódio(s)", @@ -723,12 +789,14 @@ "NoteUploaderFoldersWithMediaFiles": "Pastas com arquivos de mídia serão tratadas como itens de biblioteca distintos.", "NoteUploaderOnlyAudioFiles": "Ao subir apenas arquivos de áudio, cada arquivo será tratado como um audiobook distinto.", "NoteUploaderUnsupportedFiles": "Arquivos não suportados serão ignorados. Ao escolher ou arrastar uma pasta, outros arquivos que não estão em uma pasta dentro do item serão ignorados.", + "PlaceholderBulkChapterInput": "Digite o título de um capítulo ou use uma numeração (por exemplo, 'Episódio 1', 'Capítulo 10', '1.')", "PlaceholderNewCollection": "Novo nome da coleção", "PlaceholderNewFolderPath": "Novo caminho para a pasta", "PlaceholderNewPlaylist": "Novo nome da lista de reprodução", "PlaceholderSearch": "Buscar..", "PlaceholderSearchEpisode": "Buscar Episódio..", "ToastAccountUpdateSuccess": "Conta atualizada", + "ToastAppriseUrlRequired": "É preciso digitar uma URL Apprise", "ToastAuthorImageRemoveSuccess": "Imagem do autor removida", "ToastAuthorUpdateMerged": "Autor combinado", "ToastAuthorUpdateSuccess": "Autor atualizado", @@ -745,6 +813,7 @@ "ToastBookmarkCreateFailed": "Falha ao criar marcador", "ToastBookmarkCreateSuccess": "Marcador adicionado", "ToastBookmarkRemoveSuccess": "Marcador removido", + "ToastBulkChapterInvalidCount": "Digite um número entre 1 e 150", "ToastCachePurgeFailed": "Falha ao apagar o cache", "ToastCachePurgeSuccess": "Cache apagado com sucesso", "ToastChaptersHaveErrors": "Capítulos com erro", @@ -767,6 +836,7 @@ "ToastLibraryScanFailedToStart": "Falha ao iniciar verificação", "ToastLibraryScanStarted": "Verificação da biblioteca iniciada", "ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" atualizada", + "ToastNewUserUsernameError": "Digite um nome de usuário", "ToastPlaylistCreateFailed": "Falha ao criar lista de reprodução", "ToastPlaylistCreateSuccess": "Lista de reprodução criada", "ToastPlaylistRemoveSuccess": "Lista de reprodução removida", @@ -790,5 +860,6 @@ "ToastSortingPrefixesEmptyError": "É preciso ter pelo menos um prefixo de ordenação", "ToastSortingPrefixesUpdateSuccess": "Prefixos de ordenação atualizados ({0} item(ns))", "ToastUserDeleteFailed": "Falha ao apagar usuário", - "ToastUserDeleteSuccess": "Usuário apagado" + "ToastUserDeleteSuccess": "Usuário apagado", + "ToastUserRootRequireName": "É preciso entrar com um nome de usuário root" } From 85546b7dd790b1ef7fb8277b02bd08963053cc05 Mon Sep 17 00:00:00 2001 From: nlqog Date: Sun, 12 Oct 2025 19:40:49 +0200 Subject: [PATCH 21/24] Translated using Weblate (Portuguese (Brazil)) Currently translated at 76.6% (892 of 1163 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pt_BR/ --- client/strings/pt-br.json | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/client/strings/pt-br.json b/client/strings/pt-br.json index e31aa3f03..53b9199e2 100644 --- a/client/strings/pt-br.json +++ b/client/strings/pt-br.json @@ -347,10 +347,14 @@ "LabelEncodingWarningAdvancedSettings": "Aviso: não atualize essas configurações se não estiver familiarizado com as opções de codificação do ffmpeg.", "LabelEncodingWatcherDisabled": "Se você desabilitou o monitoramento, será necessário fazer uma nova verificação deste audiobook depois.", "LabelEnd": "Fim", + "LabelEndOfChapter": "Fim do Capítulo", "LabelEpisode": "Episódio", "LabelEpisodeTitle": "Título do Episódio", "LabelEpisodeType": "Tipo do Episódio", + "LabelEpisodes": "Episódios", "LabelExample": "Exemplo", + "LabelExpired": "Expirado", + "LabelExpiresNever": "Nunca", "LabelExplicit": "Explícito", "LabelExplicitChecked": "Explícito (verificado)", "LabelExplicitUnchecked": "Não explícito (não verificado)", @@ -377,9 +381,11 @@ "LabelHardDeleteFile": "Apagar definitivamente", "LabelHasEbook": "Tem ebook", "LabelHasSupplementaryEbook": "Tem ebook complementar", + "LabelHideSubtitles": "Esconder Legendas", "LabelHighestPriority": "Prioridade mais alta", "LabelHost": "Host", "LabelHour": "Hora", + "LabelHours": "Horas", "LabelIcon": "Ícone", "LabelImageURLFromTheWeb": "URL da imagem na internet", "LabelInProgress": "Em Andamento", @@ -394,6 +400,7 @@ "LabelIntervalEvery6Hours": "A cada 6 horas", "LabelIntervalEveryDay": "Todo dia", "LabelIntervalEveryHour": "Toda hora", + "LabelIntervalEveryMinute": "A cada minuto", "LabelInvert": "Inverter", "LabelItem": "Item", "LabelLanguage": "Idioma", @@ -413,6 +420,8 @@ "LabelLibraryFilterSublistEmpty": "Sem {0}", "LabelLibraryItem": "Item da Biblioteca", "LabelLibraryName": "Nome da Biblioteca", + "LabelLibrarySortByProgress": "Última Atualização", + "LabelLibrarySortByProgressFinished": "Concluído", "LabelLimit": "Limite", "LabelLineSpacing": "Espaçamento entre linhas", "LabelListenAgain": "Escutar novamente", @@ -430,6 +439,7 @@ "LabelMetadataOrderOfPrecedenceDescription": "Fontes de metadados de alta prioridade terão preferência sobre as fontes de metadados de prioridade baixa", "LabelMetadataProvider": "Fonte de Metadados", "LabelMinute": "Minuto", + "LabelMinutes": "Minutos", "LabelMissing": "Ausente", "LabelMissingEbook": "Ebook não existe", "LabelMissingSupplementaryEbook": "Ebook complementar não existe", @@ -445,6 +455,7 @@ "LabelNewestAuthors": "Novos Autores", "LabelNewestEpisodes": "Episódios mais recentes", "LabelNextBackupDate": "Data do próximo backup", + "LabelNextChapters": "Próximo capítulo será:", "LabelNextScheduledRun": "Próxima execução programada", "LabelNoCustomMetadataProviders": "Não existem fontes de metadados customizados", "LabelNoEpisodesSelected": "Nenhum episódio selecionado", @@ -467,8 +478,10 @@ "LabelOpenIDGroupClaimDescription": "Nome do claim OpenID contendo a lista de grupos do usuário, normalmente chamada de groups. Se configurada, a aplicação atribuirá automaticamente os perfis com base na participação do usuário nos grupos, contanto que os nomes desses grupos no claim, sem distinção entre maiúsculas e minúsculas, sejam 'admin', 'user' ou 'guest'. O claim deve conter uma lista e, se o usuário pertencer a múltiplos grupos, a aplicação atribuirá o perfil correspondendo ao maior nível de acesso. Se não houver correspondência a qualquer grupo, o acesso será negado.", "LabelOpenRSSFeed": "Abrir Feed RSS", "LabelOverwrite": "Sobrescrever", + "LabelPaginationPageXOfY": "Página {0} de {1}", "LabelPassword": "Senha", "LabelPath": "Caminho", + "LabelPermanent": "Permanente", "LabelPermissionsAccessAllLibraries": "Pode Acessar Todas Bibliotecas", "LabelPermissionsAccessAllTags": "Pode Acessar Todas as Etiquetas", "LabelPermissionsAccessExplicitContent": "Pode Acessar Conteúdos Explícitos", @@ -565,8 +578,11 @@ "LabelSettingsStoreMetadataWithItem": "Armazenar metadados com o item", "LabelSettingsStoreMetadataWithItemHelp": "Por padrão os arquivos de metadados são armazenados em /metadata/items. Ao ativar essa configuração os arquivos de metadados serão armazenadas nas pastas dos itens na sua biblioteca", "LabelSettingsTimeFormat": "Formato da Tempo", + "LabelShare": "Compartilhar", + "LabelShareURL": "Compartilhar URL", "LabelShowAll": "Exibir Todos", "LabelShowSeconds": "Exibir segundos", + "LabelShowSubtitles": "Mostrar Legendas", "LabelSize": "Tamanho", "LabelSleepTimer": "Timer", "LabelSlug": "Slug", @@ -603,7 +619,11 @@ "LabelTheme": "Tema", "LabelThemeDark": "Escuro", "LabelThemeLight": "Claro", + "LabelThemeSepia": "Sépia", "LabelTimeBase": "Base de tempo", + "LabelTimeDurationXHours": "{0} horas", + "LabelTimeDurationXMinutes": "{0} minutos", + "LabelTimeDurationXSeconds": "{0} segundos", "LabelTimeListened": "Tempo de escuta", "LabelTimeListenedToday": "Tempo de escuta hoje", "LabelTimeRemaining": "{0} restantes", @@ -623,6 +643,7 @@ "LabelTracksMultiTrack": "Várias trilhas", "LabelTracksNone": "Sem trilha", "LabelTracksSingleTrack": "Trilha única", + "LabelTrailer": "Trailer", "LabelType": "Tipo", "LabelUnabridged": "Não Abreviada", "LabelUndo": "Desfazer", @@ -646,6 +667,8 @@ "LabelViewQueue": "Ver fila do reprodutor", "LabelVolume": "Volume", "LabelWeekdaysToRun": "Dias da semana para executar", + "LabelXBooks": "{0} livros", + "LabelXItems": "{0} itens", "LabelYearReviewHide": "Ocultar Retrospectiva", "LabelYearReviewShow": "Exibir Retrospectiva", "LabelYourAudiobookDuration": "Duração do seu audiobook", @@ -772,6 +795,7 @@ "MessageServerCouldNotBeReached": "Não foi possível estabelecer conexão com o servidor", "MessageSetChaptersFromTracksDescription": "Definir os capítulos usando cada arquivo de áudio como um capítulo e o nome do arquivo como o título do capítulo", "MessageStartPlaybackAtTime": "Iniciar a reprodução de \"{0}\" em {1}?", + "MessageTaskFailed": "Falhou", "MessageThinking": "Pensando...", "MessageUploaderItemFailed": "Falha no upload", "MessageUploaderItemSuccess": "Upload realizado!", @@ -795,6 +819,11 @@ "PlaceholderNewPlaylist": "Novo nome da lista de reprodução", "PlaceholderSearch": "Buscar..", "PlaceholderSearchEpisode": "Buscar Episódio..", + "StatsAuthorsAdded": "autores adicionados", + "StatsBooksAdded": "livros adicionados", + "StatsBooksFinished": "livros concluídos", + "StatsTopAuthor": "TOP AUTOR", + "StatsTopAuthors": "TOP AUTORES", "ToastAccountUpdateSuccess": "Conta atualizada", "ToastAppriseUrlRequired": "É preciso digitar uma URL Apprise", "ToastAuthorImageRemoveSuccess": "Imagem do autor removida", From 96ef0129edf11cc08048084d0a7bff3a8f02e500 Mon Sep 17 00:00:00 2001 From: Blubberland Date: Tue, 14 Oct 2025 09:25:56 +0200 Subject: [PATCH 22/24] Translated using Weblate (German) Currently translated at 100.0% (1163 of 1163 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/de.json b/client/strings/de.json index 2eaebbcc6..5acae6d9b 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -1026,6 +1026,8 @@ "ToastCollectionItemsAddFailed": "Das Hinzufügen von Element(en) zur Sammlung ist fehlgeschlagen", "ToastCollectionRemoveSuccess": "Sammlung entfernt", "ToastCollectionUpdateSuccess": "Sammlung aktualisiert", + "ToastConnectionNotAvailable": "Verbindung nicht möglich. Bitte später erneut versuchen", + "ToastCoverSearchFailed": "Cover-Suche fehlgeschlagen", "ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen", "ToastDateTimeInvalidOrIncomplete": "Datum und Zeit sind ungültig oder unvollständig", "ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden", From d3b5612fc01310741ab01f0250964093e0223d92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petri=20H=C3=A4m=C3=A4l=C3=A4inen?= Date: Wed, 15 Oct 2025 16:08:22 +0200 Subject: [PATCH 23/24] Translated using Weblate (Finnish) Currently translated at 95.3% (1109 of 1163 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/ --- client/strings/fi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/fi.json b/client/strings/fi.json index d29ce7f95..8d224d81c 100644 --- a/client/strings/fi.json +++ b/client/strings/fi.json @@ -355,7 +355,7 @@ "LabelExample": "Esimerkki", "LabelExpandSeries": "Laajenna sarja", "LabelExpandSubSeries": "Laajenna alisarja", - "LabelExplicit": "Yksiselitteinen", + "LabelExplicit": "Sopimaton", "LabelExplicitChecked": "Yksiselitteinen (valittu)", "LabelExplicitUnchecked": "Ei yksiselitteinen (ei valittu)", "LabelExportOPML": "Vie OPML", From c3c9e7731d04079b0b90a11123e1b3f9282c3ff9 Mon Sep 17 00:00:00 2001 From: burghy86 Date: Thu, 16 Oct 2025 23:24:19 +0200 Subject: [PATCH 24/24] Translated using Weblate (Italian) Currently translated at 100.0% (1163 of 1163 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/ --- client/strings/it.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/client/strings/it.json b/client/strings/it.json index a032a3c95..2354bc0ae 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -309,6 +309,7 @@ "LabelDeleteFromFileSystemCheckbox": "Elimina dal file system (togli la spunta per eliminarla solo dal DB)", "LabelDescription": "Descrizione", "LabelDeselectAll": "Deseleziona Tutto", + "LabelDetectedPattern": "Trovato pattern:", "LabelDevice": "Dispositivo", "LabelDeviceInfo": "Info dispositivo", "LabelDeviceIsAvailableTo": "Il dispositivo e disponibile su…", @@ -377,6 +378,7 @@ "LabelFilterByUser": "Filtro per Utente", "LabelFindEpisodes": "Trova Episodi", "LabelFinished": "Finita", + "LabelFinishedDate": "Finito {0}", "LabelFolder": "Cartella", "LabelFolders": "Cartelle", "LabelFontBold": "Grassetto", @@ -434,8 +436,9 @@ "LabelLibraryFilterSublistEmpty": "Nessuno {0}", "LabelLibraryItem": "Elementi della biblioteca", "LabelLibraryName": "Nome della biblioteca", - "LabelLibrarySortByProgress": "Aggiornamento dei progressi", - "LabelLibrarySortByProgressStarted": "Data di inizio", + "LabelLibrarySortByProgress": "Progressi: Ultimi aggiornamenti", + "LabelLibrarySortByProgressFinished": "Progressi: Completati", + "LabelLibrarySortByProgressStarted": "Progressi: Iniziati", "LabelLimit": "Limiti", "LabelLineSpacing": "Interlinea", "LabelListenAgain": "Ascolta ancora", @@ -635,6 +638,7 @@ "LabelStartTime": "Tempo di inizio", "LabelStarted": "Iniziato", "LabelStartedAt": "Iniziato al", + "LabelStartedDate": "Iniziati {0}", "LabelStatsAudioTracks": "Tracce Audio", "LabelStatsAuthors": "Autori", "LabelStatsBestDay": "Giorno migliore", @@ -1022,6 +1026,8 @@ "ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito", "ToastCollectionRemoveSuccess": "Collezione rimossa", "ToastCollectionUpdateSuccess": "Raccolta aggiornata", + "ToastConnectionNotAvailable": "Connessione non disponibile. Provare più tardi", + "ToastCoverSearchFailed": "Ricerca Cover fallita", "ToastCoverUpdateFailed": "Aggiornamento cover fallito", "ToastDateTimeInvalidOrIncomplete": "Data e ora non sono valide o incomplete", "ToastDeleteFileFailed": "Impossibile eliminare il file",