diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 80956301..2e5f4bce 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@v3 + uses: github/codeql-action/init@v2 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@v3 + uses: github/codeql-action/autobuild@v2 # ℹ️ 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@v3 + uses: github/codeql-action/analyze@v2 with: category: '/language:${{matrix.language}}' diff --git a/client/components/modals/BatchQuickMatchModel.vue b/client/components/modals/BatchQuickMatchModel.vue index f6bcd972..8bea68fa 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.bookProviders + return this.$store.state.scanners.providers }, libraryProvider() { return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google' @@ -96,9 +96,6 @@ 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) { @@ -130,7 +127,8 @@ 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 be17f963..b404a9ab 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.podcastCoverProviders - return this.$store.state.scanners.bookCoverProviders + 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' }] }, searchTitleLabel() { if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN @@ -438,8 +438,6 @@ 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 4b92f6cd..3faa26b2 100644 --- a/client/components/modals/item/tabs/Match.vue +++ b/client/components/modals/item/tabs/Match.vue @@ -2,7 +2,7 @@
-
+
@@ -253,7 +253,6 @@ export default { hasSearched: false, selectedMatch: null, selectedMatchOrig: null, - waitingForProviders: false, selectedMatchUsage: { title: true, subtitle: true, @@ -286,19 +285,9 @@ 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 @@ -330,7 +319,7 @@ export default { }, providers() { if (this.isPodcast) return this.$store.state.scanners.podcastProviders - return this.$store.state.scanners.bookProviders + return this.$store.state.scanners.providers }, searchTitleLabel() { if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN @@ -489,24 +478,6 @@ 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() @@ -524,13 +495,19 @@ 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() + } - // 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 + // 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() } }, selectMatch(match) { @@ -660,10 +637,6 @@ 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 c805f79b..083fc576 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.bookProviders + return this.$store.state.scanners.providers } }, methods: { @@ -156,8 +156,6 @@ 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 7cfc2201..d3b40de9 100644 --- a/client/components/modals/libraries/LibrarySettings.vue +++ b/client/components/modals/libraries/LibrarySettings.vue @@ -104,6 +104,7 @@ export default { }, data() { return { + provider: null, useSquareBookCovers: false, enableWatcher: false, skipMatchingMediaWithAsin: false, @@ -133,6 +134,10 @@ 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 75753b21..4b972924 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -371,13 +371,11 @@ export default { }, customMetadataProviderAdded(provider) { if (!provider?.id) return - // Refresh providers cache - this.$store.dispatch('scanners/refreshProviders') + this.$store.commit('scanners/addCustomMetadataProvider', provider) }, customMetadataProviderRemoved(provider) { if (!provider?.id) return - // Refresh providers cache - this.$store.dispatch('scanners/refreshProviders') + this.$store.commit('scanners/removeCustomMetadataProvider', provider) }, initializeSocket() { if (this.$root.socket) { diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index b8cf3cff..3d030bb3 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -247,8 +247,7 @@ export default { return this.$store.state.serverSettings }, providers() { - // Use book cover providers for the cover provider dropdown - return this.$store.state.scanners.bookCoverProviders || [] + return this.$store.state.scanners.providers }, dateFormats() { return this.$store.state.globals.dateFormats @@ -417,8 +416,6 @@ 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 73ebef9c..eef05b60 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.bookProviders + return this.$store.state.scanners.providers }, canFetchMetadata() { return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled @@ -394,8 +394,6 @@ 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 a6cf1dd3..115fb53b 100644 --- a/client/store/libraries.js +++ b/client/store/libraries.js @@ -117,6 +117,7 @@ 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 }) @@ -130,6 +131,8 @@ 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 ccf7d924..2d3d465c 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -1,60 +1,126 @@ export const state = () => ({ - bookProviders: [], - podcastProviders: [], - bookCoverProviders: [], - podcastCoverProviders: [], - providersLoaded: false + 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' + } + ] }) export const getters = { - checkBookProviderExists: (state) => (providerValue) => { - return state.bookProviders.some((p) => p.value === providerValue) + checkBookProviderExists: state => (providerValue) => { + return state.providers.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) - } + checkPodcastProviderExists: state => (providerValue) => { + return state.podcastProviders.some(p => p.value === providerValue) } } +export const actions = {} + export const mutations = { - 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 + 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 + } } -} +} \ No newline at end of file diff --git a/client/strings/ar.json b/client/strings/ar.json index e3e13a85..fb13ad52 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -21,8 +21,7 @@ "ButtonChooseAFolder": "اختر المجلد", "ButtonChooseFiles": "اختر الملفات", "ButtonClearFilter": "تصفية الفرز", - "ButtonClose": "إغلاق", - "ButtonCloseFeed": "إغلاق الموجز", + "ButtonCloseFeed": "إغلاق", "ButtonCloseSession": "إغلاق الجلسة المفتوحة", "ButtonCollections": "المجموعات", "ButtonConfigureScanner": "إعدادات الماسح الضوئي", @@ -121,13 +120,11 @@ "HeaderAccount": "الحساب", "HeaderAddCustomMetadataProvider": "إضافة موفر بيانات تعريفية مخصص", "HeaderAdvanced": "متقدم", - "HeaderApiKeys": "مفاتيح API", "HeaderAppriseNotificationSettings": "إعدادات الإشعارات", "HeaderAudioTracks": "المقاطع الصوتية", "HeaderAudiobookTools": "أدوات إدارة ملفات الكتب الصوتية", "HeaderAuthentication": "المصادقة", "HeaderBackups": "النسخ الاحتياطية", - "HeaderBulkChapterModal": "أضف فصولاً متعددة", "HeaderChangePassword": "تغيير كلمة المرور", "HeaderChapters": "الفصول", "HeaderChooseAFolder": "اختيار المجلد", @@ -166,7 +163,6 @@ "HeaderMetadataOrderOfPrecedence": "ترتيب أولوية البيانات الوصفية", "HeaderMetadataToEmbed": "البيانات الوصفية المراد تضمينها", "HeaderNewAccount": "حساب جديد", - "HeaderNewApiKey": "مفتاح API جديد", "HeaderNewLibrary": "مكتبة جديدة", "HeaderNotificationCreate": "إنشاء إشعار", "HeaderNotificationUpdate": "تحديث إشعار", @@ -200,7 +196,6 @@ "HeaderSettingsExperimental": "ميزات تجريبية", "HeaderSettingsGeneral": "عام", "HeaderSettingsScanner": "إعدادات المسح", - "HeaderSettingsSecurity": "الأمان", "HeaderSettingsWebClient": "عميل الويب", "HeaderSleepTimer": "مؤقت النوم", "HeaderStatsLargestItems": "أكبر العناصر حجماً", @@ -212,7 +207,6 @@ "HeaderTableOfContents": "جدول المحتويات", "HeaderTools": "أدوات", "HeaderUpdateAccount": "تحديث الحساب", - "HeaderUpdateApiKey": "تحديث مفتاح API", "HeaderUpdateAuthor": "تحديث المؤلف", "HeaderUpdateDetails": "تحديث التفاصيل", "HeaderUpdateLibrary": "تحديث المكتبة", @@ -242,8 +236,6 @@ "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 ab144497..cb135145 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -1001,14 +1001,13 @@ "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 ugyldig eller ufærdig", - "ToastDeleteFileFailed": "Sletning af fil fejlede", + "ToastDateTimeInvalidOrIncomplete": "Dato og tid er forkert eller ufærdig", + "ToastDeleteFileFailed": "Slet fil fejlede", "ToastDeleteFileSuccess": "Fil slettet", - "ToastDeviceAddFailed": "Tilføjelse af enhed Fejlede", - "ToastDeviceNameAlreadyExists": "E-læser enhed med det navn eksistere allerede", - "ToastDeviceTestEmailFailed": "Afsendelse af test mail fejlede", + "ToastDeviceAddFailed": "Fejlede at tilføje enhed", + "ToastDeviceNameAlreadyExists": "Elæser enhed med det navn eksistere allerede", + "ToastDeviceTestEmailFailed": "Fejlede at sende test mail", "ToastDeviceTestEmailSuccess": "Test mail sendt", "ToastEmailSettingsUpdateSuccess": "Mail indstillinger opdateret", "ToastEncodeCancelFailed": "Fejlede at afbryde indkodning", @@ -1018,23 +1017,21 @@ "ToastEpisodeUpdateSuccess": "{0} afsnit opdateret", "ToastErrorCannotShare": "Kan ikke dele på denne enhed", "ToastFailedToCreate": "Oprettelsen mislykkedes", - "ToastFailedToDelete": "Sletning fejlede", - "ToastFailedToLoadData": "Indlæsning af data fejlede", + "ToastFailedToLoadData": "Fejlede at indlæse data", "ToastFailedToMatch": "Fejlet match", - "ToastFailedToShare": "Deling fejlede", + "ToastFailedToShare": "Fejlet deling", "ToastFailedToUpdate": "Fejlet opdatering", - "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", + "ToastInvalidImageUrl": "Forkert billede URL", + "ToastInvalidMaxEpisodesToDownload": "Forkert maks afsnit at hente", + "ToastInvalidUrl": "Forkert URL", + "ToastItemCoverUpdateSuccess": "Varens omslag opdateret", + "ToastItemDeletedFailed": "Fejlede at slette genstand", "ToastItemDeletedSuccess": "Genstand slettet", - "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", + "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", "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 5acae6d9..2eaebbcc 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -1026,8 +1026,6 @@ "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 8d224d81..d29ce7f9 100644 --- a/client/strings/fi.json +++ b/client/strings/fi.json @@ -355,7 +355,7 @@ "LabelExample": "Esimerkki", "LabelExpandSeries": "Laajenna sarja", "LabelExpandSubSeries": "Laajenna alisarja", - "LabelExplicit": "Sopimaton", + "LabelExplicit": "Yksiselitteinen", "LabelExplicitChecked": "Yksiselitteinen (valittu)", "LabelExplicitUnchecked": "Ei yksiselitteinen (ei valittu)", "LabelExportOPML": "Vie OPML", diff --git a/client/strings/it.json b/client/strings/it.json index 2354bc0a..a032a3c9 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -309,7 +309,6 @@ "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…", @@ -378,7 +377,6 @@ "LabelFilterByUser": "Filtro per Utente", "LabelFindEpisodes": "Trova Episodi", "LabelFinished": "Finita", - "LabelFinishedDate": "Finito {0}", "LabelFolder": "Cartella", "LabelFolders": "Cartelle", "LabelFontBold": "Grassetto", @@ -436,9 +434,8 @@ "LabelLibraryFilterSublistEmpty": "Nessuno {0}", "LabelLibraryItem": "Elementi della biblioteca", "LabelLibraryName": "Nome della biblioteca", - "LabelLibrarySortByProgress": "Progressi: Ultimi aggiornamenti", - "LabelLibrarySortByProgressFinished": "Progressi: Completati", - "LabelLibrarySortByProgressStarted": "Progressi: Iniziati", + "LabelLibrarySortByProgress": "Aggiornamento dei progressi", + "LabelLibrarySortByProgressStarted": "Data di inizio", "LabelLimit": "Limiti", "LabelLineSpacing": "Interlinea", "LabelListenAgain": "Ascolta ancora", @@ -638,7 +635,6 @@ "LabelStartTime": "Tempo di inizio", "LabelStarted": "Iniziato", "LabelStartedAt": "Iniziato al", - "LabelStartedDate": "Iniziati {0}", "LabelStatsAudioTracks": "Tracce Audio", "LabelStatsAuthors": "Autori", "LabelStatsBestDay": "Giorno migliore", @@ -1026,8 +1022,6 @@ "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 0b92dc89..98335a21 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 toku", + "LabelInProgress": "W trakcie", "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 53b9199e..9604daae 100644 --- a/client/strings/pt-br.json +++ b/client/strings/pt-br.json @@ -1,6 +1,6 @@ { "ButtonAdd": "Adicionar", - "ButtonAddApiKey": "Adicionar chave de API", + "ButtonAddApiKey": "Adicionar Chave API", "ButtonAddChapters": "Adicionar Capítulos", "ButtonAddDevice": "Adicionar Dispositivo", "ButtonAddLibrary": "Adicionar Biblioteca", @@ -21,7 +21,6 @@ "ButtonChooseAFolder": "Escolha uma pasta", "ButtonChooseFiles": "Escolha arquivos", "ButtonClearFilter": "Limpar Filtro", - "ButtonClose": "Fechar", "ButtonCloseFeed": "Fechar Feed", "ButtonCloseSession": "Fechar Sessão Aberta", "ButtonCollections": "Coleções", @@ -54,7 +53,7 @@ "ButtonNevermind": "Cancelar", "ButtonNext": "Próximo", "ButtonNextChapter": "Próximo Capítulo", - "ButtonNextItemInQueue": "Próximo Item na Fila", + "ButtonNextItemInQueue": "Próximo Item da Fila", "ButtonOk": "Ok", "ButtonOpenFeed": "Abrir Feed", "ButtonOpenManager": "Abrir Gerenciador", @@ -121,13 +120,10 @@ "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", @@ -140,7 +136,6 @@ "HeaderDetails": "Detalhes", "HeaderDownloadQueue": "Fila de Download", "HeaderEbookFiles": "Arquivos Ebook", - "HeaderEmail": "Email", "HeaderEmailSettings": "Configurações de Email", "HeaderEpisodes": "Episódios", "HeaderEreaderDevices": "Dispositivos Ereader", @@ -157,8 +152,6 @@ "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", @@ -166,23 +159,17 @@ "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", @@ -191,7 +178,6 @@ "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", @@ -200,8 +186,6 @@ "HeaderSettingsExperimental": "Funcionalidades experimentais", "HeaderSettingsGeneral": "Geral", "HeaderSettingsScanner": "Verificador", - "HeaderSettingsSecurity": "Segurança", - "HeaderSettingsWebClient": "Cliente Web", "HeaderSleepTimer": "Timer", "HeaderStatsLargestItems": "Maiores Itens", "HeaderStatsLongestItems": "Itens mais longos (hrs)", @@ -212,7 +196,6 @@ "HeaderTableOfContents": "Sumário", "HeaderTools": "Ferramentas", "HeaderUpdateAccount": "Atualizar Conta", - "HeaderUpdateApiKey": "Atualizar Chave de API", "HeaderUpdateAuthor": "Atualizar Autor", "HeaderUpdateDetails": "Atualizar Detalhes", "HeaderUpdateLibrary": "Atualizar Biblioteca", @@ -227,7 +210,6 @@ "LabelAccountTypeAdmin": "Administrador", "LabelAccountTypeGuest": "Convidado", "LabelAccountTypeUser": "Usuário", - "LabelActivities": "Atividades", "LabelActivity": "Atividade", "LabelAddToCollection": "Adicionar à Coleção", "LabelAddToCollectionBatch": "Adicionar {0} Livros à Coleção", @@ -237,20 +219,11 @@ "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)", @@ -263,31 +236,24 @@ "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": "Backups automáticos", + "LabelBackupsEnableAutomaticBackups": "Ativar backups automáticos", "LabelBackupsEnableAutomaticBackupsHelp": "Backups salvos em /metadata/backups", - "LabelBackupsMaxBackupSize": "Tamanho máximo do backup (em GB) (0 para ilimitado)", + "LabelBackupsMaxBackupSize": "Tamanho máximo do backup (em GB)", "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", @@ -295,21 +261,17 @@ "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...", @@ -319,7 +281,6 @@ "LabelDiscover": "Descobrir", "LabelDownload": "Download", "LabelDownloadNEpisodes": "Download de {0} Episódios", - "LabelDownloadable": "Baixável", "LabelDuration": "Duração", "LabelDurationComparisonExactMatch": "(exato)", "LabelDurationComparisonLonger": "({0} maior)", @@ -328,7 +289,6 @@ "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.", @@ -337,24 +297,11 @@ "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)", @@ -381,11 +328,8 @@ "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", @@ -400,9 +344,7 @@ "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", @@ -411,22 +353,16 @@ "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", @@ -439,7 +375,6 @@ "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", @@ -455,7 +390,6 @@ "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", @@ -478,10 +412,8 @@ "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", @@ -492,12 +424,9 @@ "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", @@ -506,16 +435,14 @@ "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 de RSS Aberto", + "LabelRSSFeedOpen": "Feed 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", @@ -548,8 +475,6 @@ "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.", @@ -578,14 +503,10 @@ "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", @@ -613,17 +534,12 @@ "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", @@ -643,7 +559,6 @@ "LabelTracksMultiTrack": "Várias trilhas", "LabelTracksNone": "Sem trilha", "LabelTracksSingleTrack": "Trilha única", - "LabelTrailer": "Trailer", "LabelType": "Tipo", "LabelUnabridged": "Não Abreviada", "LabelUndo": "Desfazer", @@ -665,18 +580,15 @@ "LabelViewBookmarks": "Ver marcadores", "LabelViewChapters": "Ver capítulos", "LabelViewQueue": "Ver fila do reprodutor", - "LabelVolume": "Volume", "LabelWeekdaysToRun": "Dias da semana para executar", - "LabelXBooks": "{0} livros", - "LabelXItems": "{0} itens", - "LabelYearReviewHide": "Ocultar Retrospectiva", - "LabelYearReviewShow": "Exibir Retrospectiva", + "LabelYearReviewHide": "Ocultar Retrospectiva Anual", + "LabelYearReviewShow": "Exibir Retrospectiva Anual", "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", @@ -731,8 +643,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...", @@ -780,7 +692,6 @@ "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)", @@ -795,7 +706,6 @@ "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!", @@ -813,19 +723,12 @@ "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", @@ -842,7 +745,6 @@ "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", @@ -865,7 +767,6 @@ "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", @@ -889,6 +790,5 @@ "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", - "ToastUserRootRequireName": "É preciso entrar com um nome de usuário root" + "ToastUserDeleteSuccess": "Usuário apagado" } diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 55ef4569..e63441f0 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -221,11 +221,13 @@ 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 f6f0ba47..bb3382f7 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -4,29 +4,7 @@ const BookFinder = require('../finders/BookFinder') const PodcastFinder = require('../finders/PodcastFinder') const AuthorFinder = require('../finders/AuthorFinder') const Database = require('../Database') -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' -} +const { isValidASIN } = require('../utils') /** * @typedef RequestUserObject @@ -38,44 +16,6 @@ const providerMap = { 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 * @@ -83,25 +23,19 @@ class SearchController { * @param {Response} res */ async findBooks(req, res) { - 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) + 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 || '' - // 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' }) + 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') } + + const results = await BookFinder.search(libraryItem, provider, title, author) + res.json(results) } /** @@ -111,24 +45,20 @@ class SearchController { * @param {Response} res */ async findCovers(req, res) { - 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') + const query = req.query + const podcast = query.podcast == 1 - 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' }) + 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(query.title) + else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || '') + res.json({ + results + }) } /** @@ -139,42 +69,34 @@ class SearchController { * @param {Response} res */ async findPodcasts(req, res) { - 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) - } 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' }) + const term = req.query.term + const country = req.query.country || 'us' + if (!term) { + Logger.error('[SearchController] Invalid request query param "term" is required') + return res.status(400).send('Invalid request query param "term" is required') } + + const results = await PodcastFinder.search(term, { + country + }) + res.json(results) } /** * 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) { - try { - const query = getQueryParamAsString(req.query, 'q', '', true) - - 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' }) + 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') } + + const author = await AuthorFinder.findAuthorByName(query) + res.json(author) } /** @@ -184,55 +106,16 @@ class SearchController { * @param {Response} res */ async findChapters(req, res) { - 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 asin = req.query.asin + if (!isValidASIN(asin.toUpperCase())) { + return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' }) } - } - - /** - * 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)] + 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' }) } - - res.json({ providers }) + res.json(chapterData) } } module.exports = new SearchController() diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index fe1a6102..a6a6b07e 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -385,11 +385,6 @@ 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 @@ -407,8 +402,7 @@ class BookFinder { let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus) // Remove underscores and parentheses with their contents, and replace with a separator - // Use negated character classes to prevent ReDoS vulnerability (input length validated at entry point) - const cleanTitle = title.replace(/\[[^\]]*\]|\([^)]*\)|{[^}]*}|_/g, ' - ') + const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, ' - ') // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) for (const titlePart of titleParts) authorCandidates.add(titlePart) @@ -674,9 +668,7 @@ function cleanTitleForCompares(title, keepSubtitle = false) { let stripped = keepSubtitle ? title : stripSubtitle(title) // Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game") - // Use 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 + let cleaned = stripped.replace(/ *\([^)]*\) */g, '') // 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 40d6a5a0..abaf02ac 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,16 +20,12 @@ 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}"`) - const results = await this.iTunesApi.searchPodcasts(term) + var results = await this.iTunesApi.searchPodcasts(term) if (!results) return [] - return results.map((r) => r.cover).filter((r) => r) + return results.map(r => r.cover).filter(r => r) } } -module.exports = new PodcastFinder() +module.exports = new PodcastFinder() \ No newline at end of file diff --git a/server/managers/CoverSearchManager.js b/server/managers/CoverSearchManager.js index 19317676..ddcaa23d 100644 --- a/server/managers/CoverSearchManager.js +++ b/server/managers/CoverSearchManager.js @@ -224,9 +224,6 @@ 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 db04bf5e..6446ecc8 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -283,7 +283,6 @@ 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 c7700a78..36962027 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -277,57 +277,3 @@ 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) -}