Compare commits

...

26 commits

Author SHA1 Message Date
advplyr
0c7b738b7c
Merge pull request #4730 from weblate/weblate-audiobookshelf-abs-web-client
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Run Component Tests / Run Component Tests (push) Has been cancelled
Build and Push Docker Image / build (push) Has been cancelled
Verify all i18n files are alphabetized / update_translations (push) Has been cancelled
Integration Test / build and test (push) Has been cancelled
Run Unit Tests / Run Unit Tests (push) Has been cancelled
Translations update from Hosted Weblate
2025-10-21 17:26:49 -05:00
burghy86
c3c9e7731d
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/
2025-10-22 00:24:25 +02:00
Petri Hämäläinen
d3b5612fc0
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/
2025-10-22 00:24:24 +02:00
Blubberland
96ef0129ed
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/
2025-10-22 00:24:23 +02:00
nlqog
85546b7dd7
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/
2025-10-22 00:24:22 +02:00
pmangro
d59714d804
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/
2025-10-22 00:24:22 +02:00
Coxe
96693659bf
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/
2025-10-22 00:24:21 +02:00
pryszczoskor
ee2d8d1f71
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/
2025-10-22 00:24:20 +02:00
Hezha
f03b0915eb
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/
2025-10-22 00:24:20 +02:00
advplyr
a92ba564bd
Merge pull request #4750 from mikiher/providers-api
Add metadata providers API and use them on web client
2025-10-21 17:24:11 -05:00
advplyr
e684a8dc43 Update JSDocs & auto-formatting of PodcastFinder 2025-10-21 17:22:10 -05:00
mikiher
6db6b862e6 Upgrade codeql-actions to v3 2025-10-21 17:33:53 +03:00
mikiher
57c7b123f0 Fix codeQL error: Return json error object 2025-10-21 11:00:29 +03:00
mikiher
fd593caafc SearchController: simplify query param validation logic 2025-10-21 09:38:35 +03:00
mikiher
538a5065a4 Update providers users to fetch providers on demand 2025-10-19 18:57:27 +03:00
mikiher
166e0442a0 Remove providers prefetch, refresh on custom provider add/remove 2025-10-19 11:47:17 +03:00
mikiher
816a47a4ba Remove custom providers from library fetch action 2025-10-19 11:40:40 +03:00
mikiher
141211590f Merge provider actions and mutations, add loaded state 2025-10-19 11:39:10 +03:00
mikiher
b01e7570d3 Remove custom providers from library filterdata request 2025-10-19 10:54:26 +03:00
mikiher
0a8662d198 Merge providers API into a single endpoint 2025-10-19 10:53:27 +03:00
mikiher
0a82d6a41b CoverSearchManager: Fix broken podcast cover search 2025-10-17 08:11:03 +03:00
mikiher
3f6162f53c CodeQL fix: limit parameter sizes 2025-10-15 18:54:29 +03:00
mikiher
888190a6be Fix codeQL failures 2025-10-15 18:28:15 +03:00
mikiher
ce4ff4f894 Client: Use new server providers API 2025-10-15 09:52:15 +03:00
mikiher
1da3ab7fdc ApiRouter: New provider API routes 2025-10-14 18:10:12 +03:00
mikiher
4f30cbf2f6 SearchController: New providers API, query param validation 2025-10-14 18:09:32 +03:00
25 changed files with 513 additions and 243 deletions

View file

@ -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}}'

View file

@ -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'
@ -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() {}
}
}
</script>

View file

@ -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
@ -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

View file

@ -2,7 +2,7 @@
<div id="match-wrapper" class="w-full h-full overflow-hidden px-2 md:px-4 py-4 md:py-6 relative">
<form @submit.prevent="submitSearch">
<div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1">
<div class="w-36 px-1">
<div v-if="providersLoaded" class="w-36 px-1">
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
</div>
<div class="grow md:w-72 px-1">
@ -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
@ -319,7 +330,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
@ -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')
}
}
</script>

View file

@ -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: {
@ -156,6 +156,8 @@ export default {
},
mounted() {
this.init()
// Fetch providers if not already loaded
this.$store.dispatch('scanners/fetchProviders')
}
}
</script>

View file

@ -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.providers
},
maskAsFinishedWhenItems() {
return [
{

View file

@ -371,11 +371,13 @@ export default {
},
customMetadataProviderAdded(provider) {
if (!provider?.id) return
this.$store.commit('scanners/addCustomMetadataProvider', provider)
// Refresh providers cache
this.$store.dispatch('scanners/refreshProviders')
},
customMetadataProviderRemoved(provider) {
if (!provider?.id) return
this.$store.commit('scanners/removeCustomMetadataProvider', provider)
// Refresh providers cache
this.$store.dispatch('scanners/refreshProviders')
},
initializeSocket() {
if (this.$root.socket) {

View file

@ -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
@ -416,6 +417,8 @@ export default {
},
mounted() {
this.initServerSettings()
// Fetch providers if not already loaded (for cover provider dropdown)
this.$store.dispatch('scanners/fetchProviders')
}
}
</script>

View file

@ -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
@ -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)

View file

@ -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 })
@ -131,8 +130,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
})

View file

@ -1,126 +1,60 @@
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: [],
providersLoaded: false
})
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)
},
areProvidersLoaded: (state) => state.providersLoaded
}
export const actions = {
async fetchProviders({ commit, state }) {
// Only fetch if not already loaded
if (state.providersLoaded) {
return
}
try {
const response = await this.$axios.$get('/api/search/providers')
if (response?.providers) {
commit('setAllProviders', response.providers)
}
} catch (error) {
console.error('Failed to fetch providers', error)
}
},
async refreshProviders({ commit, state }) {
// if providers are not loaded, do nothing - they will be fetched when required (
if (!state.providersLoaded) {
return
}
try {
const response = await this.$axios.$get('/api/search/providers')
if (response?.providers) {
commit('setAllProviders', response.providers)
}
} catch (error) {
console.error('Failed to refresh providers', error)
}
}
}
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
})
}
},
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)
}
},
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
}
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
}
}
}

View file

@ -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 كيلو بايت)",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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ł",

View file

@ -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,11 +337,24 @@
"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",
"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)",
@ -328,8 +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",
@ -344,7 +400,9 @@
"LabelIntervalEvery6Hours": "A cada 6 horas",
"LabelIntervalEveryDay": "Todo dia",
"LabelIntervalEveryHour": "Toda hora",
"LabelIntervalEveryMinute": "A cada minuto",
"LabelInvert": "Inverter",
"LabelItem": "Item",
"LabelLanguage": "Idioma",
"LabelLanguageDefaultServer": "Idioma Padrão do Servidor",
"LabelLanguages": "Idiomas",
@ -353,16 +411,22 @@
"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",
"LabelLibrarySortByProgress": "Última Atualização",
"LabelLibrarySortByProgressFinished": "Concluído",
"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",
@ -375,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",
@ -390,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",
@ -412,8 +478,10 @@
"LabelOpenIDGroupClaimDescription": "Nome do claim OpenID contendo a lista de grupos do usuário, normalmente chamada de <code>groups</code>. <b>Se configurada</b>, 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",
@ -424,9 +492,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 +506,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 +548,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.",
@ -503,10 +578,14 @@
"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",
"LabelStart": "Iniciar",
"LabelStartTime": "Horário do Início",
"LabelStarted": "Iniciado",
@ -534,12 +613,17 @@
"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",
"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",
@ -559,6 +643,7 @@
"LabelTracksMultiTrack": "Várias trilhas",
"LabelTracksNone": "Sem trilha",
"LabelTracksSingleTrack": "Trilha única",
"LabelTrailer": "Trailer",
"LabelType": "Tipo",
"LabelUnabridged": "Não Abreviada",
"LabelUndo": "Desfazer",
@ -580,15 +665,18 @@
"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",
"LabelXBooks": "{0} livros",
"LabelXItems": "{0} itens",
"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 <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API do Apprise</a> em execução ou uma api que possa tratar esses mesmos chamados. <br />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 <code>http://192.168.1.1:8337</code> você deve utilizar <code>http://192.168.1.1:8337/notify</code>.",
"MessageAppriseDescription": "Para utilizar essa funcionalidade é preciso ter uma instância da <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API do Apprise</a> em execução ou uma API que possa tratar esses mesmos chamados. <br />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 <code>http://192.168.1.1:8337</code> você deve utilizar <code>http://192.168.1.1:8337/notify</code>.",
"MessageBackupsDescription": "Backups incluem usuários, progresso dos usuários, detalhes dos itens da biblioteca, configurações do servidor e imagens armazenadas em <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>não</strong> 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 +731,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 +780,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)",
@ -706,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!",
@ -723,12 +813,19 @@
"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..",
"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",
"ToastAuthorUpdateMerged": "Autor combinado",
"ToastAuthorUpdateSuccess": "Autor atualizado",
@ -745,6 +842,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 +865,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 +889,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"
}

View file

@ -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()
})
}

View file

@ -4,7 +4,29 @@ 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, ValidationError, NotFoundError } = 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
@ -16,6 +38,44 @@ const { isValidASIN } = require('../utils')
class SearchController {
constructor() {}
/**
* Fetches a library item by ID
* @param {string} id - Library item ID
* @param {string} methodName - Name of the calling method for logging
* @returns {Promise<import('../models/LibraryItem').LibraryItemExpanded>}
*/
static async fetchLibraryItem(id) {
const libraryItem = await Database.libraryItemModel.getExpandedById(id)
if (!libraryItem) {
throw new NotFoundError(`library item "${id}" not found`)
}
return 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,19 +83,25 @@ 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 || ''
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)
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')
// Fetch library item
const libraryItem = await SearchController.fetchLibraryItem(id)
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).json({ error: error.message })
}
return res.status(500).json({ error: 'Internal server error' })
}
const results = await BookFinder.search(libraryItem, provider, title, author)
res.json(results)
}
/**
@ -45,20 +111,24 @@ class SearchController {
* @param {Response} res
*/
async findCovers(req, res) {
const query = req.query
const podcast = query.podcast == 1
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')
if (!query.title || typeof query.title !== 'string') {
Logger.error(`[SearchController] findCovers: Invalid title sent in query`)
return res.sendStatus(400)
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).json({ error: error.message })
}
return res.status(500).json({ error: 'Internal server error' })
}
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
})
}
/**
@ -69,34 +139,42 @@ class SearchController {
* @param {Response} res
*/
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')
}
try {
const query = req.query
const term = getQueryParamAsString(query, 'term', '', true)
const country = getQueryParamAsString(query, 'country', 'us')
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).json({ error: error.message })
}
return res.status(500).json({ error: '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 = req.query.q
if (!query || typeof query !== 'string') {
Logger.error(`[SearchController] findAuthor: Invalid query param`)
return res.status(400).send('Invalid query param')
}
try {
const query = getQueryParamAsString(req.query, 'q', '', true)
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).json({ error: error.message })
}
return res.status(500).json({ error: 'Internal server error' })
}
}
/**
@ -106,16 +184,55 @@ class SearchController {
* @param {Response} res
*/
async findChapters(req, res) {
const asin = req.query.asin
if (!isValidASIN(asin.toUpperCase())) {
return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' })
try {
const query = req.query
const asin = getQueryParamAsString(query, 'asin', '', true)
const region = getQueryParamAsString(req.query.region, 'us').toLowerCase()
if (!isValidASIN(asin.toUpperCase())) throw new ValidationError('asin', 'is invalid')
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).json({ error: 'Internal server error' })
}
const region = (req.query.region || 'us').toLowerCase()
const chapterData = await BookFinder.findChapters(asin, region)
if (!chapterData) {
return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' })
}
/**
* GET: /api/search/providers
* Get all available metadata providers
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getAllProviders(req, res) {
const customProviders = await Database.customMetadataProviderModel.findAll()
const customBookProviders = customProviders.filter((p) => p.mediaType === 'book')
const customPodcastProviders = customProviders.filter((p) => p.mediaType === 'podcast')
const bookProviders = BookFinder.providers.filter((p) => p !== 'audiobookcovers')
// 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(chapterData)
res.json({ providers })
}
}
module.exports = new SearchController()

View file

@ -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,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 (input length validated at entry point)
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 +674,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 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
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
cleaned = cleaned.replace(/'/g, '')

View file

@ -7,9 +7,9 @@ class PodcastFinder {
}
/**
*
* @param {string} term
* @param {{country:string}} options
*
* @param {string} term
* @param {{country:string}} options
* @returns {Promise<import('../providers/iTunes').iTunesPodcastSearchResult[]>}
*/
async search(term, options = {}) {
@ -20,12 +20,16 @@ class PodcastFinder {
return results
}
/**
* @param {string} term
* @returns {Promise<string[]>}
*/
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()
module.exports = new PodcastFinder()

View file

@ -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)
}

View file

@ -283,6 +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', SearchController.getAllProviders.bind(this))
//
// Cache Routes (Admin and up)

View file

@ -277,3 +277,57 @@ 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 => throws error
*
* @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} 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 = (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)) {
throw new ValidationError(paramName, 'is an array')
}
// Reject excessively long strings to prevent ReDoS attacks
if (typeof value === 'string' && value.length > maxLength) {
throw new ValidationError(paramName, 'is too long')
}
return String(value)
}