From 4f30cbf2f652362f2fcb4ac1baa1af3104ad9fb5 Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 14 Oct 2025 18:09:32 +0300 Subject: [PATCH 01/29] 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 bb3382f7..72f602d2 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/29] 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 6446ecc8..c72aa143 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/29] 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 8bea68fa..138684b2 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 b404a9ab..4ed734b1 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 3faa26b2..36cdd7a8 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 083fc576..1e4d3990 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 d3b40de9..231cb5ec 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 4b972924..21c256bd 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 3d030bb3..099ae9c4 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 eef05b60..84d4d147 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 115fb53b..a824b647 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 2d3d465c..40a8d864 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/29] 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 72f602d2..57538d2c 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 a6a6b07e..6dc90c44 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 36962027..4421fbad 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/29] 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 6dc90c44..fe1a6102 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 4421fbad..0661d14f 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/29] 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 ddcaa23d..19317676 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 0a4de61eff00ae1f4ba102acf4cf2a744192e208 Mon Sep 17 00:00:00 2001 From: Finn Dittmar Date: Sun, 19 Oct 2025 09:22:12 +0200 Subject: [PATCH 07/29] Chnage Auth Expiry --- server/auth/TokenManager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index d972b534..faa6774a 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -12,9 +12,9 @@ class TokenManager { constructor() { /** @type {number} Refresh token expiry in seconds */ - this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days + this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 30 * 24 * 60 * 60 // 30 days /** @type {number} Access token expiry in seconds */ - this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours + this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 1 * 60 * 60 // 1 hour if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) { Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`) From 0a8662d1983736ea36fb76a3bfd028f6e2586106 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 19 Oct 2025 10:53:27 +0300 Subject: [PATCH 08/29] 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 57538d2c..72d215f3 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 c72aa143..db04bf5e 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 09/29] 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 e63441f0..55ef4569 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 10/29] 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 40a8d864..ccf7d924 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 11/29] 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 a824b647..a6cf1dd3 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 12/29] 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 21c256bd..75753b21 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 13/29] 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 138684b2..f6bcd972 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 4ed734b1..be17f963 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 36cdd7a8..4b92f6cd 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 1e4d3990..c805f79b 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 231cb5ec..7cfc2201 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 099ae9c4..b8cf3cff 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 84d4d147..73ebef9c 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 14/29] 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 72d215f3..9bb6e397 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 0661d14f..c7700a78 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 15/29] 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 9bb6e397..5a16229c 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 16/29] 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 2e5f4bce..80956301 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 17/29] 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 5a16229c..f6f0ba47 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 abaf02ac..40d6a5a0 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 18/29] 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 fb13ad52..e3e13a85 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 19/29] 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 98335a21..0b92dc89 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 20/29] 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 cb135145..ab144497 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 21/29] 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 9604daae..e31aa3f0 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 22/29] 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 e31aa3f0..53b9199e 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 23/29] 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 2eaebbcc..5acae6d9 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 24/29] 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 d29ce7f9..8d224d81 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 25/29] 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 a032a3c9..2354bc0a 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", From 2cf6e8a5fe9040b89e280146505c1241885f4788 Mon Sep 17 00:00:00 2001 From: Finn Dittmar Date: Fri, 7 Nov 2025 19:02:32 +0100 Subject: [PATCH 26/29] Support eac3 --- server/objects/Stream.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/server/objects/Stream.js b/server/objects/Stream.js index d9bdd583..8150c43c 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -73,7 +73,7 @@ class Stream extends EventEmitter { return [AudioMimeType.FLAC, AudioMimeType.OPUS, AudioMimeType.WMA, AudioMimeType.AIFF, AudioMimeType.WEBM, AudioMimeType.WEBMA, AudioMimeType.AWB, AudioMimeType.CAF] } get codecsToForceAAC() { - return ['alac'] + return ['alac', 'ac3', 'eac3'] } get userToken() { return this.user.token @@ -273,7 +273,16 @@ class Stream extends EventEmitter { audioCodec = 'aac' } - this.ffmpeg.addOption([`-loglevel ${logLevel}`, '-map 0:a', `-c:a ${audioCodec}`]) + const codecOptions = [`-loglevel ${logLevel}`, '-map 0:a'] + + if (this.codecsToForceAAC.slice(1, 3).includes(this.tracksCodec)) { + // In case for ac3/eac3 it needs to be passed the bitrate and channels to avoid ffmpeg errors + codecOptions.push(`-c:a ${audioCodec}`, `-b:a ${this.tracks[0].bitRate}`, `-ac ${this.tracks[0].channels}`) + } else { + codecOptions.push(`-c:a ${audioCodec}`) + } + + this.ffmpeg.addOption(codecOptions) const hlsOptions = ['-f hls', '-copyts', '-avoid_negative_ts make_non_negative', '-max_delay 5000000', '-max_muxing_queue_size 2048', `-hls_time 6`, `-hls_segment_type ${this.hlsSegmentType}`, `-start_number ${this.segmentStartNumber}`, '-hls_playlist_type vod', '-hls_list_size 0', '-hls_allow_cache 0'] this.ffmpeg.addOption(hlsOptions) if (this.hlsSegmentType === 'fmp4') { From 3316505d1c9b8f9af2606ef628aeb765d309cf11 Mon Sep 17 00:00:00 2001 From: Finn Dittmar Date: Fri, 7 Nov 2025 19:12:38 +0100 Subject: [PATCH 27/29] Really makes sure nothing can break --- server/objects/Stream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/objects/Stream.js b/server/objects/Stream.js index 8150c43c..b3582329 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -275,7 +275,7 @@ class Stream extends EventEmitter { const codecOptions = [`-loglevel ${logLevel}`, '-map 0:a'] - if (this.codecsToForceAAC.slice(1, 3).includes(this.tracksCodec)) { + if (this.codecsToForceAAC.slice(1, 3).includes(this.tracksCodec) && this.tracks.length > 0 && this.tracks[0].bitRate && this.tracks[0].channels) { // In case for ac3/eac3 it needs to be passed the bitrate and channels to avoid ffmpeg errors codecOptions.push(`-c:a ${audioCodec}`, `-b:a ${this.tracks[0].bitRate}`, `-ac ${this.tracks[0].channels}`) } else { From 763d8810e3fcff9b8e16ff4a6769654a9e0541d3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 8 Nov 2025 17:08:43 -0600 Subject: [PATCH 28/29] Update Stream ac3/eac3 check --- server/objects/Stream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/objects/Stream.js b/server/objects/Stream.js index b3582329..5aa013e8 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -275,7 +275,7 @@ class Stream extends EventEmitter { const codecOptions = [`-loglevel ${logLevel}`, '-map 0:a'] - if (this.codecsToForceAAC.slice(1, 3).includes(this.tracksCodec) && this.tracks.length > 0 && this.tracks[0].bitRate && this.tracks[0].channels) { + if (['ac3', 'eac3'].includes(this.tracksCodec) && this.tracks.length > 0 && this.tracks[0].bitRate && this.tracks[0].channels) { // In case for ac3/eac3 it needs to be passed the bitrate and channels to avoid ffmpeg errors codecOptions.push(`-c:a ${audioCodec}`, `-b:a ${this.tracks[0].bitRate}`, `-ac ${this.tracks[0].channels}`) } else { From 5e68936c20ba0fc3a21ccab7fb7c9bbc870500c7 Mon Sep 17 00:00:00 2001 From: mikiher Date: Mon, 10 Nov 2025 09:14:46 +0200 Subject: [PATCH 29/29] Update English strings for library watcher settings and scan notes #4095 --- client/strings/en-us.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 83acb5a6..fb2bcb28 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -588,8 +588,8 @@ "LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves", "LabelSettingsChromecastSupport": "Chromecast support", "LabelSettingsDateFormat": "Date Format", - "LabelSettingsEnableWatcher": "Automatically scan libraries for changes", - "LabelSettingsEnableWatcherForLibrary": "Automatically scan library for changes", + "LabelSettingsEnableWatcher": "Automatically watch libraries for changes", + "LabelSettingsEnableWatcherForLibrary": "Automatically watch library for changes", "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart", "LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs", "LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.", @@ -888,7 +888,7 @@ "MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?", "MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on", "MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.

Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.

All clients using your server will be automatically refreshed.", - "MessageScheduleLibraryScanNote": "For most users, it is recommended to leave this feature disabled and keep the folder watcher setting enabled. The folder watcher will automatically detect changes in your library folders. The folder watcher doesn't work for every file system (like NFS) so scheduled library scans can be used instead.", + "MessageScheduleLibraryScanNote": "For most users, it is recommended to leave this feature disabled and keep the \"Automatically watch library for changes\" setting enabled - it will automatically detect changes in your library folders. Enable this feature if \"Automatically watch library for changes\" does not work for your file system (like NFS).", "MessageScheduleRunEveryWeekdayAtTime": "Run every {0} at {1}", "MessageSearchResultsFor": "Search results for", "MessageSelected": "{0} selected",