diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2e5f4bced..809563018 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -60,7 +60,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -73,6 +73,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: '/language:${{matrix.language}}' diff --git a/client/components/modals/BatchQuickMatchModel.vue b/client/components/modals/BatchQuickMatchModel.vue index 8bea68faf..f6bcd9728 100644 --- a/client/components/modals/BatchQuickMatchModel.vue +++ b/client/components/modals/BatchQuickMatchModel.vue @@ -88,7 +88,7 @@ export default { }, providers() { if (this.isPodcast) return this.$store.state.scanners.podcastProviders - return this.$store.state.scanners.providers + return this.$store.state.scanners.bookProviders }, libraryProvider() { return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google' @@ -96,6 +96,9 @@ export default { }, methods: { init() { + // Fetch providers when modal is shown + this.$store.dispatch('scanners/fetchProviders') + // If we don't have a set provider (first open of dialog) or we've switched library, set // the selected provider to the current library default provider if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) { @@ -127,8 +130,7 @@ export default { this.show = false }) } - }, - mounted() {} + } } diff --git a/client/components/modals/item/tabs/Cover.vue b/client/components/modals/item/tabs/Cover.vue index b404a9abb..be17f9636 100644 --- a/client/components/modals/item/tabs/Cover.vue +++ b/client/components/modals/item/tabs/Cover.vue @@ -133,8 +133,8 @@ export default { } }, providers() { - if (this.isPodcast) return this.$store.state.scanners.podcastProviders - return [{ text: 'Best', value: 'best' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders, { text: 'All', value: 'all' }] + if (this.isPodcast) return this.$store.state.scanners.podcastCoverProviders + return this.$store.state.scanners.bookCoverProviders }, searchTitleLabel() { if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN @@ -438,6 +438,8 @@ export default { mounted() { // Setup socket listeners when component is mounted this.addSocketListeners() + // Fetch providers if not already loaded + this.$store.dispatch('scanners/fetchProviders') }, beforeDestroy() { // Cancel any ongoing search when component is destroyed diff --git a/client/components/modals/item/tabs/Match.vue b/client/components/modals/item/tabs/Match.vue index 3faa26b27..4b92f6cd8 100644 --- a/client/components/modals/item/tabs/Match.vue +++ b/client/components/modals/item/tabs/Match.vue @@ -2,7 +2,7 @@
-
+
@@ -253,6 +253,7 @@ export default { hasSearched: false, selectedMatch: null, selectedMatchOrig: null, + waitingForProviders: false, selectedMatchUsage: { title: true, subtitle: true, @@ -285,9 +286,19 @@ export default { handler(newVal) { if (newVal) this.init() } + }, + providersLoaded(isLoaded) { + // Complete initialization once providers are loaded + if (isLoaded && this.waitingForProviders) { + this.waitingForProviders = false + this.initProviderAndSearch() + } } }, computed: { + providersLoaded() { + return this.$store.getters['scanners/areProvidersLoaded'] + }, isProcessing: { get() { return this.processing @@ -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) +}