@@ -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')
}
}
diff --git a/client/components/modals/libraries/EditLibrary.vue b/client/components/modals/libraries/EditLibrary.vue
index 083fc5766..c805f79b0 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: {
@@ -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 d3b40de95..7cfc22017 100644
--- a/client/components/modals/libraries/LibrarySettings.vue
+++ b/client/components/modals/libraries/LibrarySettings.vue
@@ -104,7 +104,6 @@ export default {
},
data() {
return {
- provider: null,
useSquareBookCovers: false,
enableWatcher: false,
skipMatchingMediaWithAsin: false,
@@ -134,10 +133,6 @@ export default {
isPodcastLibrary() {
return this.mediaType === 'podcast'
},
- providers() {
- if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
- return this.$store.state.scanners.providers
- },
maskAsFinishedWhenItems() {
return [
{
diff --git a/client/layouts/default.vue b/client/layouts/default.vue
index 4b9729248..75753b214 100644
--- a/client/layouts/default.vue
+++ b/client/layouts/default.vue
@@ -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) {
diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue
index 3d030bb32..b8cf3cff2 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
@@ -416,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 eef05b608..73ebef9c6 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
@@ -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)
diff --git a/client/store/libraries.js b/client/store/libraries.js
index 115fb53bf..a6cf1dd39 100644
--- a/client/store/libraries.js
+++ b/client/store/libraries.js
@@ -117,7 +117,6 @@ export const actions = {
const library = data.library
const filterData = data.filterdata
const issues = data.issues || 0
- const customMetadataProviders = data.customMetadataProviders || []
const numUserPlaylists = data.numUserPlaylists
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
@@ -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
})
diff --git a/client/store/scanners.js b/client/store/scanners.js
index 2d3d465ca..ccf7d9249 100644
--- a/client/store/scanners.js
+++ b/client/store/scanners.js
@@ -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
}
-}
\ No newline at end of file
+}
diff --git a/client/strings/ar.json b/client/strings/ar.json
index fb13ad52b..e3e13a859 100644
--- a/client/strings/ar.json
+++ b/client/strings/ar.json
@@ -21,7 +21,8 @@
"ButtonChooseAFolder": "اختر المجلد",
"ButtonChooseFiles": "اختر الملفات",
"ButtonClearFilter": "تصفية الفرز",
- "ButtonCloseFeed": "إغلاق",
+ "ButtonClose": "إغلاق",
+ "ButtonCloseFeed": "إغلاق الموجز",
"ButtonCloseSession": "إغلاق الجلسة المفتوحة",
"ButtonCollections": "المجموعات",
"ButtonConfigureScanner": "إعدادات الماسح الضوئي",
@@ -120,11 +121,13 @@
"HeaderAccount": "الحساب",
"HeaderAddCustomMetadataProvider": "إضافة موفر بيانات تعريفية مخصص",
"HeaderAdvanced": "متقدم",
+ "HeaderApiKeys": "مفاتيح API",
"HeaderAppriseNotificationSettings": "إعدادات الإشعارات",
"HeaderAudioTracks": "المقاطع الصوتية",
"HeaderAudiobookTools": "أدوات إدارة ملفات الكتب الصوتية",
"HeaderAuthentication": "المصادقة",
"HeaderBackups": "النسخ الاحتياطية",
+ "HeaderBulkChapterModal": "أضف فصولاً متعددة",
"HeaderChangePassword": "تغيير كلمة المرور",
"HeaderChapters": "الفصول",
"HeaderChooseAFolder": "اختيار المجلد",
@@ -163,6 +166,7 @@
"HeaderMetadataOrderOfPrecedence": "ترتيب أولوية البيانات الوصفية",
"HeaderMetadataToEmbed": "البيانات الوصفية المراد تضمينها",
"HeaderNewAccount": "حساب جديد",
+ "HeaderNewApiKey": "مفتاح API جديد",
"HeaderNewLibrary": "مكتبة جديدة",
"HeaderNotificationCreate": "إنشاء إشعار",
"HeaderNotificationUpdate": "تحديث إشعار",
@@ -196,6 +200,7 @@
"HeaderSettingsExperimental": "ميزات تجريبية",
"HeaderSettingsGeneral": "عام",
"HeaderSettingsScanner": "إعدادات المسح",
+ "HeaderSettingsSecurity": "الأمان",
"HeaderSettingsWebClient": "عميل الويب",
"HeaderSleepTimer": "مؤقت النوم",
"HeaderStatsLargestItems": "أكبر العناصر حجماً",
@@ -207,6 +212,7 @@
"HeaderTableOfContents": "جدول المحتويات",
"HeaderTools": "أدوات",
"HeaderUpdateAccount": "تحديث الحساب",
+ "HeaderUpdateApiKey": "تحديث مفتاح API",
"HeaderUpdateAuthor": "تحديث المؤلف",
"HeaderUpdateDetails": "تحديث التفاصيل",
"HeaderUpdateLibrary": "تحديث المكتبة",
@@ -236,6 +242,8 @@
"LabelAllUsersExcludingGuests": "جميع المستخدمين باستثناء الضيوف",
"LabelAllUsersIncludingGuests": "جميع المستخدمين بما في ذلك الضيوف",
"LabelAlreadyInYourLibrary": "موجود بالفعل في مكتبتك",
+ "LabelApiKeyCreated": "تم إنشاء مفتاح API \"{0}\" بنجاح.",
+ "LabelApiKeyCreatedDescription": "تأكد من نسخ مفتاح API الآن، لن تتمكن من رؤيته مرة أخرى.",
"LabelApiToken": "رمز API",
"LabelAppend": "إلحاق",
"LabelAudioBitrate": "معدل بت الصوت (على سبيل المثال 128 كيلو بايت)",
diff --git a/client/strings/da.json b/client/strings/da.json
index cb1351450..ab1444970 100644
--- a/client/strings/da.json
+++ b/client/strings/da.json
@@ -1001,13 +1001,14 @@
"ToastCollectionItemsAddFailed": "Genstand(e) tilføjet til kollektion fejlet",
"ToastCollectionRemoveSuccess": "Samling fjernet",
"ToastCollectionUpdateSuccess": "Samling opdateret",
+ "ToastConnectionNotAvailable": "Forbindelse mislykkedes. Prøv igen senere",
"ToastCoverUpdateFailed": "Cover opdatering fejlede",
- "ToastDateTimeInvalidOrIncomplete": "Dato og tid er forkert eller ufærdig",
- "ToastDeleteFileFailed": "Slet fil fejlede",
+ "ToastDateTimeInvalidOrIncomplete": "Dato og tid er ugyldig eller ufærdig",
+ "ToastDeleteFileFailed": "Sletning af fil fejlede",
"ToastDeleteFileSuccess": "Fil slettet",
- "ToastDeviceAddFailed": "Fejlede at tilføje enhed",
- "ToastDeviceNameAlreadyExists": "Elæser enhed med det navn eksistere allerede",
- "ToastDeviceTestEmailFailed": "Fejlede at sende test mail",
+ "ToastDeviceAddFailed": "Tilføjelse af enhed Fejlede",
+ "ToastDeviceNameAlreadyExists": "E-læser enhed med det navn eksistere allerede",
+ "ToastDeviceTestEmailFailed": "Afsendelse af test mail fejlede",
"ToastDeviceTestEmailSuccess": "Test mail sendt",
"ToastEmailSettingsUpdateSuccess": "Mail indstillinger opdateret",
"ToastEncodeCancelFailed": "Fejlede at afbryde indkodning",
@@ -1017,21 +1018,23 @@
"ToastEpisodeUpdateSuccess": "{0} afsnit opdateret",
"ToastErrorCannotShare": "Kan ikke dele på denne enhed",
"ToastFailedToCreate": "Oprettelsen mislykkedes",
- "ToastFailedToLoadData": "Fejlede at indlæse data",
+ "ToastFailedToDelete": "Sletning fejlede",
+ "ToastFailedToLoadData": "Indlæsning af data fejlede",
"ToastFailedToMatch": "Fejlet match",
- "ToastFailedToShare": "Fejlet deling",
+ "ToastFailedToShare": "Deling fejlede",
"ToastFailedToUpdate": "Fejlet opdatering",
- "ToastInvalidImageUrl": "Forkert billede URL",
- "ToastInvalidMaxEpisodesToDownload": "Forkert maks afsnit at hente",
- "ToastInvalidUrl": "Forkert URL",
- "ToastItemCoverUpdateSuccess": "Varens omslag opdateret",
- "ToastItemDeletedFailed": "Fejlede at slette genstand",
+ "ToastInvalidImageUrl": "Ugyldig billede URL",
+ "ToastInvalidMaxEpisodesToDownload": "Ugyldigt maks afsnit at hente",
+ "ToastInvalidUrl": "Ugyldig URL",
+ "ToastInvalidUrls": "En eller flere URLer er ugyldige",
+ "ToastItemCoverUpdateSuccess": "Omslag opdateret",
+ "ToastItemDeletedFailed": "Sletning af genstand fejlede",
"ToastItemDeletedSuccess": "Genstand slettet",
- "ToastItemDetailsUpdateSuccess": "Varedetaljer opdateret",
- "ToastItemMarkedAsFinishedFailed": "Mislykkedes markering som afsluttet",
- "ToastItemMarkedAsFinishedSuccess": "Vare markeret som afsluttet",
- "ToastItemMarkedAsNotFinishedFailed": "Mislykkedes markering som ikke afsluttet",
- "ToastItemMarkedAsNotFinishedSuccess": "Vare markeret som ikke afsluttet",
+ "ToastItemDetailsUpdateSuccess": "Detaljer opdateret",
+ "ToastItemMarkedAsFinishedFailed": "Markering som afsluttet mislykkedes",
+ "ToastItemMarkedAsFinishedSuccess": "Element markeret som afsluttet",
+ "ToastItemMarkedAsNotFinishedFailed": "Markering som ikke afsluttet mislykkedes",
+ "ToastItemMarkedAsNotFinishedSuccess": "Element markeret som ikke afsluttet",
"ToastItemUpdateSuccess": "Genstand opdateret",
"ToastLibraryCreateFailed": "Oprettelse af bibliotek mislykkedes",
"ToastLibraryCreateSuccess": "Bibliotek \"{0}\" oprettet",
diff --git a/client/strings/de.json b/client/strings/de.json
index 2eaebbcc6..5acae6d9b 100644
--- a/client/strings/de.json
+++ b/client/strings/de.json
@@ -1026,6 +1026,8 @@
"ToastCollectionItemsAddFailed": "Das Hinzufügen von Element(en) zur Sammlung ist fehlgeschlagen",
"ToastCollectionRemoveSuccess": "Sammlung entfernt",
"ToastCollectionUpdateSuccess": "Sammlung aktualisiert",
+ "ToastConnectionNotAvailable": "Verbindung nicht möglich. Bitte später erneut versuchen",
+ "ToastCoverSearchFailed": "Cover-Suche fehlgeschlagen",
"ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen",
"ToastDateTimeInvalidOrIncomplete": "Datum und Zeit sind ungültig oder unvollständig",
"ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden",
diff --git a/client/strings/fi.json b/client/strings/fi.json
index d29ce7f95..8d224d81c 100644
--- a/client/strings/fi.json
+++ b/client/strings/fi.json
@@ -355,7 +355,7 @@
"LabelExample": "Esimerkki",
"LabelExpandSeries": "Laajenna sarja",
"LabelExpandSubSeries": "Laajenna alisarja",
- "LabelExplicit": "Yksiselitteinen",
+ "LabelExplicit": "Sopimaton",
"LabelExplicitChecked": "Yksiselitteinen (valittu)",
"LabelExplicitUnchecked": "Ei yksiselitteinen (ei valittu)",
"LabelExportOPML": "Vie OPML",
diff --git a/client/strings/it.json b/client/strings/it.json
index a032a3c95..2354bc0ae 100644
--- a/client/strings/it.json
+++ b/client/strings/it.json
@@ -309,6 +309,7 @@
"LabelDeleteFromFileSystemCheckbox": "Elimina dal file system (togli la spunta per eliminarla solo dal DB)",
"LabelDescription": "Descrizione",
"LabelDeselectAll": "Deseleziona Tutto",
+ "LabelDetectedPattern": "Trovato pattern:",
"LabelDevice": "Dispositivo",
"LabelDeviceInfo": "Info dispositivo",
"LabelDeviceIsAvailableTo": "Il dispositivo e disponibile su…",
@@ -377,6 +378,7 @@
"LabelFilterByUser": "Filtro per Utente",
"LabelFindEpisodes": "Trova Episodi",
"LabelFinished": "Finita",
+ "LabelFinishedDate": "Finito {0}",
"LabelFolder": "Cartella",
"LabelFolders": "Cartelle",
"LabelFontBold": "Grassetto",
@@ -434,8 +436,9 @@
"LabelLibraryFilterSublistEmpty": "Nessuno {0}",
"LabelLibraryItem": "Elementi della biblioteca",
"LabelLibraryName": "Nome della biblioteca",
- "LabelLibrarySortByProgress": "Aggiornamento dei progressi",
- "LabelLibrarySortByProgressStarted": "Data di inizio",
+ "LabelLibrarySortByProgress": "Progressi: Ultimi aggiornamenti",
+ "LabelLibrarySortByProgressFinished": "Progressi: Completati",
+ "LabelLibrarySortByProgressStarted": "Progressi: Iniziati",
"LabelLimit": "Limiti",
"LabelLineSpacing": "Interlinea",
"LabelListenAgain": "Ascolta ancora",
@@ -635,6 +638,7 @@
"LabelStartTime": "Tempo di inizio",
"LabelStarted": "Iniziato",
"LabelStartedAt": "Iniziato al",
+ "LabelStartedDate": "Iniziati {0}",
"LabelStatsAudioTracks": "Tracce Audio",
"LabelStatsAuthors": "Autori",
"LabelStatsBestDay": "Giorno migliore",
@@ -1022,6 +1026,8 @@
"ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito",
"ToastCollectionRemoveSuccess": "Collezione rimossa",
"ToastCollectionUpdateSuccess": "Raccolta aggiornata",
+ "ToastConnectionNotAvailable": "Connessione non disponibile. Provare più tardi",
+ "ToastCoverSearchFailed": "Ricerca Cover fallita",
"ToastCoverUpdateFailed": "Aggiornamento cover fallito",
"ToastDateTimeInvalidOrIncomplete": "Data e ora non sono valide o incomplete",
"ToastDeleteFileFailed": "Impossibile eliminare il file",
diff --git a/client/strings/pl.json b/client/strings/pl.json
index 98335a21d..0b92dc89c 100644
--- a/client/strings/pl.json
+++ b/client/strings/pl.json
@@ -400,7 +400,7 @@
"LabelHours": "Godziny",
"LabelIcon": "Ikona",
"LabelImageURLFromTheWeb": "Link do obrazu w sieci",
- "LabelInProgress": "W trakcie",
+ "LabelInProgress": "W toku",
"LabelIncludeInTracklist": "Dołącz do listy odtwarzania",
"LabelIncomplete": "Nieukończone",
"LabelInterval": "Interwał",
diff --git a/client/strings/pt-br.json b/client/strings/pt-br.json
index 9604daaed..53b9199e2 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,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
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",
@@ -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
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 +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"
}
diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js
index e63441f0b..55ef45690 100644
--- a/server/controllers/LibraryController.js
+++ b/server/controllers/LibraryController.js
@@ -221,13 +221,11 @@ class LibraryController {
const includeArray = (req.query.include || '').split(',')
if (includeArray.includes('filterdata')) {
const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)
- const customMetadataProviders = await Database.customMetadataProviderModel.getForClientByMediaType(req.library.mediaType)
return res.json({
filterdata,
issues: filterdata.numIssues,
numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
- customMetadataProviders,
library: req.library.toOldJSON()
})
}
diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js
index bb3382f71..f6f0ba475 100644
--- a/server/controllers/SearchController.js
+++ b/server/controllers/SearchController.js
@@ -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
}
+ */
+ 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()
diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js
index a6a6b07e6..fe1a61027 100644
--- a/server/finders/BookFinder.js
+++ b/server/finders/BookFinder.js
@@ -385,6 +385,11 @@ class BookFinder {
if (!title) return books
+ // Truncate excessively long inputs to prevent ReDoS attacks
+ const MAX_INPUT_LENGTH = 500
+ title = title.substring(0, MAX_INPUT_LENGTH)
+ author = author?.substring(0, MAX_INPUT_LENGTH) || author
+
const isTitleAsin = isValidASIN(title.toUpperCase())
let actualTitleQuery = title
@@ -402,7 +407,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, '')
diff --git a/server/finders/PodcastFinder.js b/server/finders/PodcastFinder.js
index abaf02ac6..40d6a5a00 100644
--- a/server/finders/PodcastFinder.js
+++ b/server/finders/PodcastFinder.js
@@ -7,9 +7,9 @@ class PodcastFinder {
}
/**
- *
- * @param {string} term
- * @param {{country:string}} options
+ *
+ * @param {string} term
+ * @param {{country:string}} options
* @returns {Promise}
*/
async search(term, options = {}) {
@@ -20,12 +20,16 @@ class PodcastFinder {
return results
}
+ /**
+ * @param {string} term
+ * @returns {Promise}
+ */
async findCovers(term) {
if (!term) return null
Logger.debug(`[iTunes] Searching for podcast covers with term "${term}"`)
- var results = await this.iTunesApi.searchPodcasts(term)
+ const results = await this.iTunesApi.searchPodcasts(term)
if (!results) return []
- return results.map(r => r.cover).filter(r => r)
+ return results.map((r) => r.cover).filter((r) => r)
}
}
-module.exports = new PodcastFinder()
\ No newline at end of file
+module.exports = new PodcastFinder()
diff --git a/server/managers/CoverSearchManager.js b/server/managers/CoverSearchManager.js
index ddcaa23db..193176766 100644
--- a/server/managers/CoverSearchManager.js
+++ b/server/managers/CoverSearchManager.js
@@ -224,6 +224,9 @@ class CoverSearchManager {
if (!Array.isArray(results)) return covers
results.forEach((result) => {
+ if (typeof result === 'string') {
+ covers.push(result)
+ }
if (result.covers && Array.isArray(result.covers)) {
covers.push(...result.covers)
}
diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js
index 6446ecc80..db04bf5ec 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -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)
diff --git a/server/utils/index.js b/server/utils/index.js
index 369620276..c7700a783 100644
--- a/server/utils/index.js
+++ b/server/utils/index.js
@@ -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)
+}