mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-11-24 20:05:41 +01:00
Merge branch 'advplyr:master' into #4584-sort-options-for-narrators
This commit is contained in:
commit
0a963b4abc
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@ -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}}'
|
||||
|
||||
@ -88,7 +88,7 @@ export default {
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
return this.$store.state.scanners.bookProviders
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
@ -96,6 +96,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
// Fetch providers when modal is shown
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
|
||||
// If we don't have a set provider (first open of dialog) or we've switched library, set
|
||||
// the selected provider to the current library default provider
|
||||
if (!this.options.provider || this.lastUsedLibrary != this.currentLibraryId) {
|
||||
@ -127,8 +130,7 @@ export default {
|
||||
this.show = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div id="match-wrapper" class="w-full h-full overflow-hidden px-2 md:px-4 py-4 md:py-6 relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<div class="flex flex-wrap md:flex-nowrap items-center justify-start -mx-1">
|
||||
<div class="w-36 px-1">
|
||||
<div v-if="providersLoaded" class="w-36 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||
</div>
|
||||
<div class="grow md:w-72 px-1">
|
||||
@ -253,6 +253,7 @@ export default {
|
||||
hasSearched: false,
|
||||
selectedMatch: null,
|
||||
selectedMatchOrig: null,
|
||||
waitingForProviders: false,
|
||||
selectedMatchUsage: {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
@ -285,9 +286,19 @@ export default {
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
},
|
||||
providersLoaded(isLoaded) {
|
||||
// Complete initialization once providers are loaded
|
||||
if (isLoaded && this.waitingForProviders) {
|
||||
this.waitingForProviders = false
|
||||
this.initProviderAndSearch()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
providersLoaded() {
|
||||
return this.$store.getters['scanners/areProvidersLoaded']
|
||||
},
|
||||
isProcessing: {
|
||||
get() {
|
||||
return this.processing
|
||||
@ -319,7 +330,7 @@ export default {
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
return this.$store.state.scanners.bookProviders
|
||||
},
|
||||
searchTitleLabel() {
|
||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||
@ -478,6 +489,24 @@ export default {
|
||||
|
||||
this.checkboxToggled()
|
||||
},
|
||||
initProviderAndSearch() {
|
||||
// Set provider based on media type
|
||||
if (this.isPodcast) {
|
||||
this.provider = 'itunes'
|
||||
} else {
|
||||
this.provider = this.getDefaultBookProvider()
|
||||
}
|
||||
|
||||
// Prefer using ASIN if set and using audible provider
|
||||
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
||||
this.searchTitle = this.libraryItem.media.metadata.asin
|
||||
this.searchAuthor = ''
|
||||
}
|
||||
|
||||
if (this.searchTitle) {
|
||||
this.submitSearch()
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.clearSelectedMatch()
|
||||
this.initSelectedMatchUsage()
|
||||
@ -495,19 +524,13 @@ export default {
|
||||
}
|
||||
this.searchTitle = this.libraryItem.media.metadata.title
|
||||
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else {
|
||||
this.provider = this.getDefaultBookProvider()
|
||||
}
|
||||
|
||||
// Prefer using ASIN if set and using audible provider
|
||||
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
||||
this.searchTitle = this.libraryItem.media.metadata.asin
|
||||
this.searchAuthor = ''
|
||||
}
|
||||
|
||||
if (this.searchTitle) {
|
||||
this.submitSearch()
|
||||
// Wait for providers to be loaded before setting provider and searching
|
||||
if (this.providersLoaded || this.isPodcast) {
|
||||
this.waitingForProviders = false
|
||||
this.initProviderAndSearch()
|
||||
} else {
|
||||
this.waitingForProviders = true
|
||||
}
|
||||
},
|
||||
selectMatch(match) {
|
||||
@ -637,6 +660,10 @@ export default {
|
||||
this.selectedMatch = null
|
||||
this.selectedMatchOrig = null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Fetch providers if not already loaded
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
providers() {
|
||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
return this.$store.state.scanners.bookProviders
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -156,6 +156,8 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
// Fetch providers if not already loaded
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -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 [
|
||||
{
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -247,7 +247,8 @@ export default {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
providers() {
|
||||
return this.$store.state.scanners.providers
|
||||
// Use book cover providers for the cover provider dropdown
|
||||
return this.$store.state.scanners.bookCoverProviders || []
|
||||
},
|
||||
dateFormats() {
|
||||
return this.$store.state.globals.dateFormats
|
||||
@ -416,6 +417,8 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.initServerSettings()
|
||||
// Fetch providers if not already loaded (for cover provider dropdown)
|
||||
this.$store.dispatch('scanners/fetchProviders')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 كيلو بايت)",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -588,8 +588,8 @@
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves",
|
||||
"LabelSettingsChromecastSupport": "Chromecast support",
|
||||
"LabelSettingsDateFormat": "Date Format",
|
||||
"LabelSettingsEnableWatcher": "Automatically scan libraries for changes",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Automatically scan library for changes",
|
||||
"LabelSettingsEnableWatcher": "Automatically watch libraries for changes",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Automatically watch library for changes",
|
||||
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.",
|
||||
@ -888,7 +888,7 @@
|
||||
"MessageResetChaptersConfirm": "Are you sure you want to reset chapters and undo the changes you made?",
|
||||
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
|
||||
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
|
||||
"MessageScheduleLibraryScanNote": "For most users, it is recommended to leave this feature disabled and keep the folder watcher setting enabled. The folder watcher will automatically detect changes in your library folders. The folder watcher doesn't work for every file system (like NFS) so scheduled library scans can be used instead.",
|
||||
"MessageScheduleLibraryScanNote": "For most users, it is recommended to leave this feature disabled and keep the \"Automatically watch library for changes\" setting enabled - it will automatically detect changes in your library folders. Enable this feature if \"Automatically watch library for changes\" does not work for your file system (like NFS).",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Run every {0} at {1}",
|
||||
"MessageSearchResultsFor": "Search results for",
|
||||
"MessageSelected": "{0} selected",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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ł",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"ButtonAdd": "Adicionar",
|
||||
"ButtonAddApiKey": "Adicionar Chave API",
|
||||
"ButtonAddApiKey": "Adicionar chave de API",
|
||||
"ButtonAddChapters": "Adicionar Capítulos",
|
||||
"ButtonAddDevice": "Adicionar Dispositivo",
|
||||
"ButtonAddLibrary": "Adicionar Biblioteca",
|
||||
@ -21,6 +21,7 @@
|
||||
"ButtonChooseAFolder": "Escolha uma pasta",
|
||||
"ButtonChooseFiles": "Escolha arquivos",
|
||||
"ButtonClearFilter": "Limpar Filtro",
|
||||
"ButtonClose": "Fechar",
|
||||
"ButtonCloseFeed": "Fechar Feed",
|
||||
"ButtonCloseSession": "Fechar Sessão Aberta",
|
||||
"ButtonCollections": "Coleções",
|
||||
@ -53,7 +54,7 @@
|
||||
"ButtonNevermind": "Cancelar",
|
||||
"ButtonNext": "Próximo",
|
||||
"ButtonNextChapter": "Próximo Capítulo",
|
||||
"ButtonNextItemInQueue": "Próximo Item da Fila",
|
||||
"ButtonNextItemInQueue": "Próximo Item na Fila",
|
||||
"ButtonOk": "Ok",
|
||||
"ButtonOpenFeed": "Abrir Feed",
|
||||
"ButtonOpenManager": "Abrir Gerenciador",
|
||||
@ -120,10 +121,13 @@
|
||||
"HeaderAccount": "Conta",
|
||||
"HeaderAddCustomMetadataProvider": "Adicionar Provedor de Metadados Personalizado",
|
||||
"HeaderAdvanced": "Avançado",
|
||||
"HeaderApiKeys": "Chaves de API",
|
||||
"HeaderAppriseNotificationSettings": "Configuração de notificações Apprise",
|
||||
"HeaderAudioTracks": "Trilhas de áudio",
|
||||
"HeaderAudiobookTools": "Ferramentas de Gerenciamento de Arquivos de Audiobooks",
|
||||
"HeaderAuthentication": "Autenticação",
|
||||
"HeaderBackups": "Backups",
|
||||
"HeaderBulkChapterModal": "Adicionar vários capítulos",
|
||||
"HeaderChangePassword": "Trocar Senha",
|
||||
"HeaderChapters": "Capítulos",
|
||||
"HeaderChooseAFolder": "Escolha uma Pasta",
|
||||
@ -136,6 +140,7 @@
|
||||
"HeaderDetails": "Detalhes",
|
||||
"HeaderDownloadQueue": "Fila de Download",
|
||||
"HeaderEbookFiles": "Arquivos Ebook",
|
||||
"HeaderEmail": "Email",
|
||||
"HeaderEmailSettings": "Configurações de Email",
|
||||
"HeaderEpisodes": "Episódios",
|
||||
"HeaderEreaderDevices": "Dispositivos Ereader",
|
||||
@ -152,6 +157,8 @@
|
||||
"HeaderLibraryStats": "Estatísticas da Biblioteca",
|
||||
"HeaderListeningSessions": "Sessões",
|
||||
"HeaderListeningStats": "Estatísticas",
|
||||
"HeaderLogin": "Login",
|
||||
"HeaderLogs": "Logs",
|
||||
"HeaderManageGenres": "Gerenciar Gêneros",
|
||||
"HeaderManageTags": "Gerenciar Etiquetas",
|
||||
"HeaderMapDetails": "Designar Detalhes",
|
||||
@ -159,17 +166,23 @@
|
||||
"HeaderMetadataOrderOfPrecedence": "Ordem de Prioridade dos Metadados",
|
||||
"HeaderMetadataToEmbed": "Metadados a Serem Incluídos",
|
||||
"HeaderNewAccount": "Nova Conta",
|
||||
"HeaderNewApiKey": "Nova chave de API",
|
||||
"HeaderNewLibrary": "Nova Biblioteca",
|
||||
"HeaderNotificationCreate": "Criar Notificação",
|
||||
"HeaderNotificationUpdate": "Atualizar Notificação",
|
||||
"HeaderNotifications": "Notificações",
|
||||
"HeaderOpenIDConnectAuthentication": "Autenticação via OpenID Connect",
|
||||
"HeaderOpenListeningSessions": "Abrir Sessões de Escuta",
|
||||
"HeaderOpenRSSFeed": "Abrir Feed RSS",
|
||||
"HeaderOtherFiles": "Outros Arquivos",
|
||||
"HeaderPasswordAuthentication": "Autenticação por Senha",
|
||||
"HeaderPermissions": "Permissões",
|
||||
"HeaderPlayerQueue": "Fila do reprodutor",
|
||||
"HeaderPlayerSettings": "Configurações do Reprodutor",
|
||||
"HeaderPlaylist": "Lista de Reprodução",
|
||||
"HeaderPlaylistItems": "Itens da lista de reprodução",
|
||||
"HeaderPodcastsToAdd": "Podcasts para Adicionar",
|
||||
"HeaderPresets": "Valores predefinidos",
|
||||
"HeaderPreviewCover": "Visualização da Capa",
|
||||
"HeaderRSSFeedGeneral": "Detalhes RSS",
|
||||
"HeaderRSSFeedIsOpen": "Feed RSS está Aberto",
|
||||
@ -178,6 +191,7 @@
|
||||
"HeaderRemoveEpisodes": "Remover {0} Episódios",
|
||||
"HeaderSavedMediaProgress": "Progresso da gravação das mídias",
|
||||
"HeaderSchedule": "Programação",
|
||||
"HeaderScheduleEpisodeDownloads": "Programar Download Automático de Episódios",
|
||||
"HeaderScheduleLibraryScans": "Programar Verificação Automática da Biblioteca",
|
||||
"HeaderSession": "Sessão",
|
||||
"HeaderSetBackupSchedule": "Definir Programação de Backup",
|
||||
@ -186,6 +200,8 @@
|
||||
"HeaderSettingsExperimental": "Funcionalidades experimentais",
|
||||
"HeaderSettingsGeneral": "Geral",
|
||||
"HeaderSettingsScanner": "Verificador",
|
||||
"HeaderSettingsSecurity": "Segurança",
|
||||
"HeaderSettingsWebClient": "Cliente Web",
|
||||
"HeaderSleepTimer": "Timer",
|
||||
"HeaderStatsLargestItems": "Maiores Itens",
|
||||
"HeaderStatsLongestItems": "Itens mais longos (hrs)",
|
||||
@ -196,6 +212,7 @@
|
||||
"HeaderTableOfContents": "Sumário",
|
||||
"HeaderTools": "Ferramentas",
|
||||
"HeaderUpdateAccount": "Atualizar Conta",
|
||||
"HeaderUpdateApiKey": "Atualizar Chave de API",
|
||||
"HeaderUpdateAuthor": "Atualizar Autor",
|
||||
"HeaderUpdateDetails": "Atualizar Detalhes",
|
||||
"HeaderUpdateLibrary": "Atualizar Biblioteca",
|
||||
@ -210,6 +227,7 @@
|
||||
"LabelAccountTypeAdmin": "Administrador",
|
||||
"LabelAccountTypeGuest": "Convidado",
|
||||
"LabelAccountTypeUser": "Usuário",
|
||||
"LabelActivities": "Atividades",
|
||||
"LabelActivity": "Atividade",
|
||||
"LabelAddToCollection": "Adicionar à Coleção",
|
||||
"LabelAddToCollectionBatch": "Adicionar {0} Livros à Coleção",
|
||||
@ -219,11 +237,20 @@
|
||||
"LabelAddedDate": "Adicionado {0}",
|
||||
"LabelAdminUsersOnly": "Apenas usuários administradores",
|
||||
"LabelAll": "Todos",
|
||||
"LabelAllEpisodesDownloaded": "Todos os episódios baixados",
|
||||
"LabelAllUsers": "Todos Usuários",
|
||||
"LabelAllUsersExcludingGuests": "Todos usuários exceto convidados",
|
||||
"LabelAllUsersIncludingGuests": "Todos usuários incluindo convidados",
|
||||
"LabelAlreadyInYourLibrary": "Já na sua biblioteca",
|
||||
"LabelApiKeyCreated": "Chave de API \"{0}\" criada com sucesso.",
|
||||
"LabelApiKeyCreatedDescription": "Certifique-se de copiar a chave de API agora pois não será possível vê-la novamente.",
|
||||
"LabelApiKeyUser": "Agir em nome do usuário",
|
||||
"LabelApiKeyUserDescription": "Esta chave de API terá as mesmas permissões que o usuário em nome de quem ela está agindo. Isso aparecerá nos logs como se o usuário estivesse fazendo a solicitação.",
|
||||
"LabelApiToken": "Token de API",
|
||||
"LabelAppend": "Acrescentar",
|
||||
"LabelAudioBitrate": "Bitrate de áudio (por exemplo, 128k)",
|
||||
"LabelAudioChannels": "Canais de áudio (1 ou 2)",
|
||||
"LabelAudioCodec": "Codec de áudio",
|
||||
"LabelAuthor": "Autor",
|
||||
"LabelAuthorFirstLast": "Autor (Nome Sobrenome)",
|
||||
"LabelAuthorLastFirst": "Autor (Sobrenome, Nome)",
|
||||
@ -236,24 +263,31 @@
|
||||
"LabelAutoRegister": "Registrar Automaticamente",
|
||||
"LabelAutoRegisterDescription": "Registra automaticamente novos usuários após login",
|
||||
"LabelBackToUser": "Voltar para Usuário",
|
||||
"LabelBackupAudioFiles": "Backup dos Arquivos de Áudio",
|
||||
"LabelBackupLocation": "Localização do Backup",
|
||||
"LabelBackupsEnableAutomaticBackups": "Ativar backups automáticos",
|
||||
"LabelBackupsEnableAutomaticBackups": "Backups automáticos",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Backups salvos em /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Tamanho máximo do backup (em GB)",
|
||||
"LabelBackupsMaxBackupSize": "Tamanho máximo do backup (em GB) (0 para ilimitado)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "Como proteção contra uma configuração incorreta, backups darão erro se excederem o tamanho configurado.",
|
||||
"LabelBackupsNumberToKeep": "Número de backups para guardar",
|
||||
"LabelBackupsNumberToKeepHelp": "Apenas 1 backup será removido por vez, então, se já existem mais backups, você deve apagá-los manualmente.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBonus": "Bônus",
|
||||
"LabelBooks": "Livros",
|
||||
"LabelButtonText": "Texto do botão",
|
||||
"LabelByAuthor": "por {0}",
|
||||
"LabelChangePassword": "Trocar Senha",
|
||||
"LabelChannels": "Canais",
|
||||
"LabelChapterCount": "{0} Capítulos",
|
||||
"LabelChapterTitle": "Título do Capítulo",
|
||||
"LabelChapters": "Capítulos",
|
||||
"LabelChaptersFound": "capítulos encontrados",
|
||||
"LabelClickForMoreInfo": "Clique para mais informações",
|
||||
"LabelClickToUseCurrentValue": "Clique para usar o valor atual",
|
||||
"LabelClosePlayer": "Fechar Reprodutor",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Fechar Série",
|
||||
"LabelCollapseSubSeries": "Fechar Sub Séries",
|
||||
"LabelCollection": "Coleção",
|
||||
"LabelCollections": "Coleções",
|
||||
"LabelComplete": "Concluído",
|
||||
@ -261,17 +295,21 @@
|
||||
"LabelContinueListening": "Continuar Escutando",
|
||||
"LabelContinueReading": "Continuar Lendo",
|
||||
"LabelContinueSeries": "Continuar Série",
|
||||
"LabelCorsAllowed": "Origens Permitidas para CORS",
|
||||
"LabelCover": "Capa",
|
||||
"LabelCoverImageURL": "URL da Imagem da Capa",
|
||||
"LabelCoverProvider": "Provedor de Capas",
|
||||
"LabelCreatedAt": "Criado em",
|
||||
"LabelCronExpression": "Expressão para o Cron",
|
||||
"LabelCurrent": "Atual",
|
||||
"LabelCurrently": "Atualmente:",
|
||||
"LabelCustomCronExpression": "Expressão personalizada para o Cron:",
|
||||
"LabelDatetime": "Data e Hora",
|
||||
"LabelDays": "Dias",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Apagar do sistema de arquivos (desmarcar para remover apenas da base de dados)",
|
||||
"LabelDescription": "Descrição",
|
||||
"LabelDeselectAll": "Desmarcar tudo",
|
||||
"LabelDetectedPattern": "Padrão detectado:",
|
||||
"LabelDevice": "Dispositivo",
|
||||
"LabelDeviceInfo": "Informação do Dispositivo",
|
||||
"LabelDeviceIsAvailableTo": "Dispositivo está disponível para...",
|
||||
@ -281,6 +319,7 @@
|
||||
"LabelDiscover": "Descobrir",
|
||||
"LabelDownload": "Download",
|
||||
"LabelDownloadNEpisodes": "Download de {0} Episódios",
|
||||
"LabelDownloadable": "Baixável",
|
||||
"LabelDuration": "Duração",
|
||||
"LabelDurationComparisonExactMatch": "(exato)",
|
||||
"LabelDurationComparisonLonger": "({0} maior)",
|
||||
@ -289,6 +328,7 @@
|
||||
"LabelEbook": "Ebook",
|
||||
"LabelEbooks": "Ebooks",
|
||||
"LabelEdit": "Editar",
|
||||
"LabelEmail": "Email",
|
||||
"LabelEmailSettingsFromAddress": "Remetente",
|
||||
"LabelEmailSettingsRejectUnauthorized": "Rejeitar certificados não autorizados",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "Desativar a validação de certificados SSL pode expor sua conexão a riscos de segurança, como ataques \"man-in-the-middle\". Desative essa opção apenas se entender suas consequências e se puder confiar no servidor de email ao qual você está se conectando.",
|
||||
@ -297,11 +337,24 @@
|
||||
"LabelEmailSettingsTestAddress": "Endereço de teste",
|
||||
"LabelEmbeddedCover": "Capa Integrada",
|
||||
"LabelEnable": "Habilitar",
|
||||
"LabelEncodingBackupLocation": "Um backup dos seus arquivos de áudio original será gravado em:",
|
||||
"LabelEncodingChaptersNotEmbedded": "Capítulos não são integrados em audiobooks com várias trilhas.",
|
||||
"LabelEncodingClearItemCache": "Certifique-se de, periodicamente, apagar os itens do cache.",
|
||||
"LabelEncodingFinishedM4B": "O arquivo M4B final será colocado na sua pasta de audiobooks em:",
|
||||
"LabelEncodingInfoEmbedded": "Os metadados serão integrados nas trilhas de áudio dentro da sua pasta de audiobooks.",
|
||||
"LabelEncodingStartedNavigation": "Assim que a tarefa for iniciada você pode sair dessa página.",
|
||||
"LabelEncodingTimeWarning": "A codificação pode durar até 30 minutos.",
|
||||
"LabelEncodingWarningAdvancedSettings": "Aviso: não atualize essas configurações se não estiver familiarizado com as opções de codificação do ffmpeg.",
|
||||
"LabelEncodingWatcherDisabled": "Se você desabilitou o monitoramento, será necessário fazer uma nova verificação deste audiobook depois.",
|
||||
"LabelEnd": "Fim",
|
||||
"LabelEndOfChapter": "Fim do Capítulo",
|
||||
"LabelEpisode": "Episódio",
|
||||
"LabelEpisodeTitle": "Título do Episódio",
|
||||
"LabelEpisodeType": "Tipo do Episódio",
|
||||
"LabelEpisodes": "Episódios",
|
||||
"LabelExample": "Exemplo",
|
||||
"LabelExpired": "Expirado",
|
||||
"LabelExpiresNever": "Nunca",
|
||||
"LabelExplicit": "Explícito",
|
||||
"LabelExplicitChecked": "Explícito (verificado)",
|
||||
"LabelExplicitUnchecked": "Não explícito (não verificado)",
|
||||
@ -328,8 +381,11 @@
|
||||
"LabelHardDeleteFile": "Apagar definitivamente",
|
||||
"LabelHasEbook": "Tem ebook",
|
||||
"LabelHasSupplementaryEbook": "Tem ebook complementar",
|
||||
"LabelHideSubtitles": "Esconder Legendas",
|
||||
"LabelHighestPriority": "Prioridade mais alta",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Hora",
|
||||
"LabelHours": "Horas",
|
||||
"LabelIcon": "Ícone",
|
||||
"LabelImageURLFromTheWeb": "URL da imagem na internet",
|
||||
"LabelInProgress": "Em Andamento",
|
||||
@ -344,7 +400,9 @@
|
||||
"LabelIntervalEvery6Hours": "A cada 6 horas",
|
||||
"LabelIntervalEveryDay": "Todo dia",
|
||||
"LabelIntervalEveryHour": "Toda hora",
|
||||
"LabelIntervalEveryMinute": "A cada minuto",
|
||||
"LabelInvert": "Inverter",
|
||||
"LabelItem": "Item",
|
||||
"LabelLanguage": "Idioma",
|
||||
"LabelLanguageDefaultServer": "Idioma Padrão do Servidor",
|
||||
"LabelLanguages": "Idiomas",
|
||||
@ -353,16 +411,22 @@
|
||||
"LabelLastSeen": "Visto pela Última Vez",
|
||||
"LabelLastTime": "Progresso",
|
||||
"LabelLastUpdate": "Última Atualização",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutSinglePage": "Uma página",
|
||||
"LabelLayoutSplitPage": "Página dividida",
|
||||
"LabelLess": "Menos",
|
||||
"LabelLibrariesAccessibleToUser": "Bibliotecas Acessíveis ao Usuário",
|
||||
"LabelLibrary": "Biblioteca",
|
||||
"LabelLibraryFilterSublistEmpty": "Sem {0}",
|
||||
"LabelLibraryItem": "Item da Biblioteca",
|
||||
"LabelLibraryName": "Nome da Biblioteca",
|
||||
"LabelLibrarySortByProgress": "Última Atualização",
|
||||
"LabelLibrarySortByProgressFinished": "Concluído",
|
||||
"LabelLimit": "Limite",
|
||||
"LabelLineSpacing": "Espaçamento entre linhas",
|
||||
"LabelListenAgain": "Escutar novamente",
|
||||
"LabelLogLevelDebug": "Debug",
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Atenção",
|
||||
"LabelLookForNewEpisodesAfterDate": "Procurar por novos Episódios após essa data",
|
||||
"LabelLowestPriority": "Prioridade mais baixa",
|
||||
@ -375,6 +439,7 @@
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "Fontes de metadados de alta prioridade terão preferência sobre as fontes de metadados de prioridade baixa",
|
||||
"LabelMetadataProvider": "Fonte de Metadados",
|
||||
"LabelMinute": "Minuto",
|
||||
"LabelMinutes": "Minutos",
|
||||
"LabelMissing": "Ausente",
|
||||
"LabelMissingEbook": "Ebook não existe",
|
||||
"LabelMissingSupplementaryEbook": "Ebook complementar não existe",
|
||||
@ -390,6 +455,7 @@
|
||||
"LabelNewestAuthors": "Novos Autores",
|
||||
"LabelNewestEpisodes": "Episódios mais recentes",
|
||||
"LabelNextBackupDate": "Data do próximo backup",
|
||||
"LabelNextChapters": "Próximo capítulo será:",
|
||||
"LabelNextScheduledRun": "Próxima execução programada",
|
||||
"LabelNoCustomMetadataProviders": "Não existem fontes de metadados customizados",
|
||||
"LabelNoEpisodesSelected": "Nenhum episódio selecionado",
|
||||
@ -412,8 +478,10 @@
|
||||
"LabelOpenIDGroupClaimDescription": "Nome do claim OpenID contendo a lista de grupos do usuário, normalmente chamada de <code>groups</code>. <b>Se configurada</b>, a aplicação atribuirá automaticamente os perfis com base na participação do usuário nos grupos, contanto que os nomes desses grupos no claim, sem distinção entre maiúsculas e minúsculas, sejam 'admin', 'user' ou 'guest'. O claim deve conter uma lista e, se o usuário pertencer a múltiplos grupos, a aplicação atribuirá o perfil correspondendo ao maior nível de acesso. Se não houver correspondência a qualquer grupo, o acesso será negado.",
|
||||
"LabelOpenRSSFeed": "Abrir Feed RSS",
|
||||
"LabelOverwrite": "Sobrescrever",
|
||||
"LabelPaginationPageXOfY": "Página {0} de {1}",
|
||||
"LabelPassword": "Senha",
|
||||
"LabelPath": "Caminho",
|
||||
"LabelPermanent": "Permanente",
|
||||
"LabelPermissionsAccessAllLibraries": "Pode Acessar Todas Bibliotecas",
|
||||
"LabelPermissionsAccessAllTags": "Pode Acessar Todas as Etiquetas",
|
||||
"LabelPermissionsAccessExplicitContent": "Pode Acessar Conteúdos Explícitos",
|
||||
@ -424,9 +492,12 @@
|
||||
"LabelPersonalYearReview": "Sua Retrospectiva Anual ({0})",
|
||||
"LabelPhotoPathURL": "Caminho/URL para Foto",
|
||||
"LabelPlayMethod": "Método de Reprodução",
|
||||
"LabelPlayerChapterNumberMarker": "{0} de {1}",
|
||||
"LabelPlaylists": "Listas de Reprodução",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcastSearchRegion": "Região de busca do podcast",
|
||||
"LabelPodcastType": "Tipo de Podcast",
|
||||
"LabelPodcasts": "Podcasts",
|
||||
"LabelPort": "Porta",
|
||||
"LabelPrefixesToIgnore": "Prefixos para Ignorar (sem distinção entre maiúsculas e minúsculas)",
|
||||
"LabelPreventIndexing": "Evitar que o seu feed seja indexado pelos diretórios de podcast do iTunes e Google",
|
||||
@ -435,14 +506,16 @@
|
||||
"LabelProvider": "Fonte",
|
||||
"LabelPubDate": "Data de Publicação",
|
||||
"LabelPublishYear": "Ano de Publicação",
|
||||
"LabelPublishedDate": "Publicado {0}",
|
||||
"LabelPublisher": "Editora",
|
||||
"LabelPublishers": "Editoras",
|
||||
"LabelRSSFeedCustomOwnerEmail": "E-mail do dono personalizado",
|
||||
"LabelRSSFeedCustomOwnerName": "Nome do dono personalizado",
|
||||
"LabelRSSFeedOpen": "Feed RSS Aberto",
|
||||
"LabelRSSFeedOpen": "Feed de RSS Aberto",
|
||||
"LabelRSSFeedPreventIndexing": "Impedir Indexação",
|
||||
"LabelRSSFeedSlug": "Slug do Feed RSS",
|
||||
"LabelRSSFeedURL": "URL do Feed RSS",
|
||||
"LabelRandomly": "Aleatoriamente",
|
||||
"LabelRead": "Lido",
|
||||
"LabelReadAgain": "Ler novamente",
|
||||
"LabelReadEbookWithoutProgress": "Ler ebook sem armazenar progresso",
|
||||
@ -475,6 +548,8 @@
|
||||
"LabelSettingsBookshelfViewHelp": "Aparência esqueomorfa com prateleiras de madeira",
|
||||
"LabelSettingsChromecastSupport": "Suporte ao Chromecast",
|
||||
"LabelSettingsDateFormat": "Formato de data",
|
||||
"LabelSettingsEnableWatcher": "Monitorar automaticamente alterações nas bibliotecas",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Monitorar automaticamente alterações na biblioteca",
|
||||
"LabelSettingsEnableWatcherHelp": "Ativa o acréscimo/atualização de itens quando forem detectadas mudanças no arquivo. *Requer reiniciar o servidor",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Permitir scripts em epubs",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Permitir que arquivos epub executem scripts. É recomendado manter essa configuração desativada, a não ser que confie na fonte dos arquivos epub.",
|
||||
@ -503,10 +578,14 @@
|
||||
"LabelSettingsStoreMetadataWithItem": "Armazenar metadados com o item",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Por padrão os arquivos de metadados são armazenados em /metadata/items. Ao ativar essa configuração os arquivos de metadados serão armazenadas nas pastas dos itens na sua biblioteca",
|
||||
"LabelSettingsTimeFormat": "Formato da Tempo",
|
||||
"LabelShare": "Compartilhar",
|
||||
"LabelShareURL": "Compartilhar URL",
|
||||
"LabelShowAll": "Exibir Todos",
|
||||
"LabelShowSeconds": "Exibir segundos",
|
||||
"LabelShowSubtitles": "Mostrar Legendas",
|
||||
"LabelSize": "Tamanho",
|
||||
"LabelSleepTimer": "Timer",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "Iniciar",
|
||||
"LabelStartTime": "Horário do Início",
|
||||
"LabelStarted": "Iniciado",
|
||||
@ -534,12 +613,17 @@
|
||||
"LabelTagsNotAccessibleToUser": "Etiquetas não Acessíveis Usuário",
|
||||
"LabelTasks": "Tarefas em Execuçào",
|
||||
"LabelTextEditorBulletedList": "Lista com marcadores",
|
||||
"LabelTextEditorLink": "Link",
|
||||
"LabelTextEditorNumberedList": "Lista numerada",
|
||||
"LabelTextEditorUnlink": "Remover link",
|
||||
"LabelTheme": "Tema",
|
||||
"LabelThemeDark": "Escuro",
|
||||
"LabelThemeLight": "Claro",
|
||||
"LabelThemeSepia": "Sépia",
|
||||
"LabelTimeBase": "Base de tempo",
|
||||
"LabelTimeDurationXHours": "{0} horas",
|
||||
"LabelTimeDurationXMinutes": "{0} minutos",
|
||||
"LabelTimeDurationXSeconds": "{0} segundos",
|
||||
"LabelTimeListened": "Tempo de escuta",
|
||||
"LabelTimeListenedToday": "Tempo de escuta hoje",
|
||||
"LabelTimeRemaining": "{0} restantes",
|
||||
@ -559,6 +643,7 @@
|
||||
"LabelTracksMultiTrack": "Várias trilhas",
|
||||
"LabelTracksNone": "Sem trilha",
|
||||
"LabelTracksSingleTrack": "Trilha única",
|
||||
"LabelTrailer": "Trailer",
|
||||
"LabelType": "Tipo",
|
||||
"LabelUnabridged": "Não Abreviada",
|
||||
"LabelUndo": "Desfazer",
|
||||
@ -580,15 +665,18 @@
|
||||
"LabelViewBookmarks": "Ver marcadores",
|
||||
"LabelViewChapters": "Ver capítulos",
|
||||
"LabelViewQueue": "Ver fila do reprodutor",
|
||||
"LabelVolume": "Volume",
|
||||
"LabelWeekdaysToRun": "Dias da semana para executar",
|
||||
"LabelYearReviewHide": "Ocultar Retrospectiva Anual",
|
||||
"LabelYearReviewShow": "Exibir Retrospectiva Anual",
|
||||
"LabelXBooks": "{0} livros",
|
||||
"LabelXItems": "{0} itens",
|
||||
"LabelYearReviewHide": "Ocultar Retrospectiva",
|
||||
"LabelYearReviewShow": "Exibir Retrospectiva",
|
||||
"LabelYourAudiobookDuration": "Duração do seu audiobook",
|
||||
"LabelYourBookmarks": "Seus Marcadores",
|
||||
"LabelYourPlaylists": "Suas Listas de Reprodução",
|
||||
"LabelYourProgress": "Seu Progresso",
|
||||
"MessageAddToPlayerQueue": "Adicionar à lista do reprodutor",
|
||||
"MessageAppriseDescription": "Para utilizar essa funcionalidade é preciso ter uma instância da <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API do Apprise</a> em execução ou uma api que possa tratar esses mesmos chamados. <br />A URL da API do Apprise deve conter o caminho completo da URL para enviar as notificações. Ex: se a sua instância da API estiver em <code>http://192.168.1.1:8337</code> você deve utilizar <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAppriseDescription": "Para utilizar essa funcionalidade é preciso ter uma instância da <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API do Apprise</a> em execução ou uma API que possa tratar esses mesmos chamados. <br />A URL da API do Apprise deve conter o caminho completo da URL para enviar as notificações. Ex: se a sua instância da API estiver em <code>http://192.168.1.1:8337</code> você deve utilizar <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageBackupsDescription": "Backups incluem usuários, progresso dos usuários, detalhes dos itens da biblioteca, configurações do servidor e imagens armazenadas em <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>não</strong> incluem quaisquer arquivos armazenados nas pastas da sua biblioteca.",
|
||||
"MessageBatchQuickMatchDescription": "Consulta Rápida tentará adicionar capas e metadados ausentes para os itens selecionados. Ative as opções abaixo para permitir que a Consulta Rápida sobrescreva capas e/ou metadados existentes.",
|
||||
"MessageBookshelfNoCollections": "Você ainda não criou coleções",
|
||||
@ -643,8 +731,8 @@
|
||||
"MessageForceReScanDescription": "verificará todos os arquivos, como uma verificação nova. Etiquetas ID3 de arquivos de áudio, arquivos OPF e arquivos de texto serão tratados como novos.",
|
||||
"MessageImportantNotice": "Aviso Importante!",
|
||||
"MessageInsertChapterBelow": "Inserir capítulo abaixo",
|
||||
"MessageItemsSelected": "{0} Itens Selecionados",
|
||||
"MessageItemsUpdated": "{0} Itens Atualizados",
|
||||
"MessageItemsSelected": "{0} itens selecionados",
|
||||
"MessageItemsUpdated": "{0} itens atualizados",
|
||||
"MessageJoinUsOn": "Junte-se a nós",
|
||||
"MessageLoading": "Carregando...",
|
||||
"MessageLoadingFolders": "Carregando pastas...",
|
||||
@ -692,6 +780,7 @@
|
||||
"MessagePlayChapter": "Escutar o início do capítulo",
|
||||
"MessagePlaylistCreateFromCollection": "Criar uma lista de reprodução a partir da coleção",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast não tem uma URL do feed RSS para ser usada na consulta",
|
||||
"MessagePodcastSearchField": "Digite um termo para a busca ou a URL de um feed RSS",
|
||||
"MessageQuickMatchDescription": "Preenche detalhes vazios do item & capa com o primeiro resultado de '{0}'. Não sobrescreve detalhes a não ser que a configuração 'Preferir metadados consultados' do servidor esteja ativa.",
|
||||
"MessageRemoveChapter": "Remover capítulo",
|
||||
"MessageRemoveEpisodes": "Remover {0} episódio(s)",
|
||||
@ -706,6 +795,7 @@
|
||||
"MessageServerCouldNotBeReached": "Não foi possível estabelecer conexão com o servidor",
|
||||
"MessageSetChaptersFromTracksDescription": "Definir os capítulos usando cada arquivo de áudio como um capítulo e o nome do arquivo como o título do capítulo",
|
||||
"MessageStartPlaybackAtTime": "Iniciar a reprodução de \"{0}\" em {1}?",
|
||||
"MessageTaskFailed": "Falhou",
|
||||
"MessageThinking": "Pensando...",
|
||||
"MessageUploaderItemFailed": "Falha no upload",
|
||||
"MessageUploaderItemSuccess": "Upload realizado!",
|
||||
@ -723,12 +813,19 @@
|
||||
"NoteUploaderFoldersWithMediaFiles": "Pastas com arquivos de mídia serão tratadas como itens de biblioteca distintos.",
|
||||
"NoteUploaderOnlyAudioFiles": "Ao subir apenas arquivos de áudio, cada arquivo será tratado como um audiobook distinto.",
|
||||
"NoteUploaderUnsupportedFiles": "Arquivos não suportados serão ignorados. Ao escolher ou arrastar uma pasta, outros arquivos que não estão em uma pasta dentro do item serão ignorados.",
|
||||
"PlaceholderBulkChapterInput": "Digite o título de um capítulo ou use uma numeração (por exemplo, 'Episódio 1', 'Capítulo 10', '1.')",
|
||||
"PlaceholderNewCollection": "Novo nome da coleção",
|
||||
"PlaceholderNewFolderPath": "Novo caminho para a pasta",
|
||||
"PlaceholderNewPlaylist": "Novo nome da lista de reprodução",
|
||||
"PlaceholderSearch": "Buscar..",
|
||||
"PlaceholderSearchEpisode": "Buscar Episódio..",
|
||||
"StatsAuthorsAdded": "autores adicionados",
|
||||
"StatsBooksAdded": "livros adicionados",
|
||||
"StatsBooksFinished": "livros concluídos",
|
||||
"StatsTopAuthor": "TOP AUTOR",
|
||||
"StatsTopAuthors": "TOP AUTORES",
|
||||
"ToastAccountUpdateSuccess": "Conta atualizada",
|
||||
"ToastAppriseUrlRequired": "É preciso digitar uma URL Apprise",
|
||||
"ToastAuthorImageRemoveSuccess": "Imagem do autor removida",
|
||||
"ToastAuthorUpdateMerged": "Autor combinado",
|
||||
"ToastAuthorUpdateSuccess": "Autor atualizado",
|
||||
@ -745,6 +842,7 @@
|
||||
"ToastBookmarkCreateFailed": "Falha ao criar marcador",
|
||||
"ToastBookmarkCreateSuccess": "Marcador adicionado",
|
||||
"ToastBookmarkRemoveSuccess": "Marcador removido",
|
||||
"ToastBulkChapterInvalidCount": "Digite um número entre 1 e 150",
|
||||
"ToastCachePurgeFailed": "Falha ao apagar o cache",
|
||||
"ToastCachePurgeSuccess": "Cache apagado com sucesso",
|
||||
"ToastChaptersHaveErrors": "Capítulos com erro",
|
||||
@ -767,6 +865,7 @@
|
||||
"ToastLibraryScanFailedToStart": "Falha ao iniciar verificação",
|
||||
"ToastLibraryScanStarted": "Verificação da biblioteca iniciada",
|
||||
"ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" atualizada",
|
||||
"ToastNewUserUsernameError": "Digite um nome de usuário",
|
||||
"ToastPlaylistCreateFailed": "Falha ao criar lista de reprodução",
|
||||
"ToastPlaylistCreateSuccess": "Lista de reprodução criada",
|
||||
"ToastPlaylistRemoveSuccess": "Lista de reprodução removida",
|
||||
@ -790,5 +889,6 @@
|
||||
"ToastSortingPrefixesEmptyError": "É preciso ter pelo menos um prefixo de ordenação",
|
||||
"ToastSortingPrefixesUpdateSuccess": "Prefixos de ordenação atualizados ({0} item(ns))",
|
||||
"ToastUserDeleteFailed": "Falha ao apagar usuário",
|
||||
"ToastUserDeleteSuccess": "Usuário apagado"
|
||||
"ToastUserDeleteSuccess": "Usuário apagado",
|
||||
"ToastUserRootRequireName": "É preciso entrar com um nome de usuário root"
|
||||
}
|
||||
|
||||
@ -12,9 +12,9 @@ class TokenManager {
|
||||
|
||||
constructor() {
|
||||
/** @type {number} Refresh token expiry in seconds */
|
||||
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days
|
||||
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 30 * 24 * 60 * 60 // 30 days
|
||||
/** @type {number} Access token expiry in seconds */
|
||||
this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours
|
||||
this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 1 * 60 * 60 // 1 hour
|
||||
|
||||
if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
|
||||
Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@ -4,7 +4,29 @@ const BookFinder = require('../finders/BookFinder')
|
||||
const PodcastFinder = require('../finders/PodcastFinder')
|
||||
const AuthorFinder = require('../finders/AuthorFinder')
|
||||
const Database = require('../Database')
|
||||
const { isValidASIN } = require('../utils')
|
||||
const { isValidASIN, getQueryParamAsString, ValidationError, NotFoundError } = require('../utils')
|
||||
|
||||
// Provider name mappings for display purposes
|
||||
const providerMap = {
|
||||
all: 'All',
|
||||
best: 'Best',
|
||||
google: 'Google Books',
|
||||
itunes: 'iTunes',
|
||||
openlibrary: 'Open Library',
|
||||
fantlab: 'FantLab.ru',
|
||||
audiobookcovers: 'AudiobookCovers.com',
|
||||
audible: 'Audible.com',
|
||||
'audible.ca': 'Audible.ca',
|
||||
'audible.uk': 'Audible.co.uk',
|
||||
'audible.au': 'Audible.com.au',
|
||||
'audible.fr': 'Audible.fr',
|
||||
'audible.de': 'Audible.de',
|
||||
'audible.jp': 'Audible.co.jp',
|
||||
'audible.it': 'Audible.it',
|
||||
'audible.in': 'Audible.in',
|
||||
'audible.es': 'Audible.es',
|
||||
audnexus: 'Audnexus'
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
@ -16,6 +38,44 @@ const { isValidASIN } = require('../utils')
|
||||
class SearchController {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Fetches a library item by ID
|
||||
* @param {string} id - Library item ID
|
||||
* @param {string} methodName - Name of the calling method for logging
|
||||
* @returns {Promise<import('../models/LibraryItem').LibraryItemExpanded>}
|
||||
*/
|
||||
static async fetchLibraryItem(id) {
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(id)
|
||||
if (!libraryItem) {
|
||||
throw new NotFoundError(`library item "${id}" not found`)
|
||||
}
|
||||
return libraryItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps custom metadata providers to standardized format
|
||||
* @param {Array} providers - Array of custom provider objects
|
||||
* @returns {Array<{value: string, text: string}>}
|
||||
*/
|
||||
static mapCustomProviders(providers) {
|
||||
return providers.map((provider) => ({
|
||||
value: provider.getSlug(),
|
||||
text: provider.name
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Static helper method to format provider for client (for use in array methods)
|
||||
* @param {string} providerValue - Provider identifier
|
||||
* @returns {{value: string, text: string}}
|
||||
*/
|
||||
static formatProvider(providerValue) {
|
||||
return {
|
||||
value: providerValue,
|
||||
text: providerMap[providerValue] || providerValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/search/books
|
||||
*
|
||||
@ -23,19 +83,25 @@ class SearchController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findBooks(req, res) {
|
||||
const id = req.query.id
|
||||
const libraryItem = await Database.libraryItemModel.getExpandedById(id)
|
||||
const provider = req.query.provider || 'google'
|
||||
const title = req.query.title || ''
|
||||
const author = req.query.author || ''
|
||||
try {
|
||||
const query = req.query
|
||||
const provider = getQueryParamAsString(query, 'provider', 'google')
|
||||
const title = getQueryParamAsString(query, 'title', '')
|
||||
const author = getQueryParamAsString(query, 'author', '')
|
||||
const id = getQueryParamAsString(query, 'id', '', true)
|
||||
|
||||
if (typeof provider !== 'string' || typeof title !== 'string' || typeof author !== 'string') {
|
||||
Logger.error(`[SearchController] findBooks: Invalid request query params`)
|
||||
return res.status(400).send('Invalid request query params')
|
||||
// Fetch library item
|
||||
const libraryItem = await SearchController.fetchLibraryItem(id)
|
||||
|
||||
const results = await BookFinder.search(libraryItem, provider, title, author)
|
||||
res.json(results)
|
||||
} catch (error) {
|
||||
Logger.error(`[SearchController] findBooks: ${error.message}`)
|
||||
if (error instanceof ValidationError || error instanceof NotFoundError) {
|
||||
return res.status(error.status).json({ error: error.message })
|
||||
}
|
||||
return res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
|
||||
const results = await BookFinder.search(libraryItem, provider, title, author)
|
||||
res.json(results)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -45,20 +111,24 @@ class SearchController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findCovers(req, res) {
|
||||
const query = req.query
|
||||
const podcast = query.podcast == 1
|
||||
try {
|
||||
const query = req.query
|
||||
const podcast = query.podcast === '1' || query.podcast === 1
|
||||
const title = getQueryParamAsString(query, 'title', '', true)
|
||||
const author = getQueryParamAsString(query, 'author', '')
|
||||
const provider = getQueryParamAsString(query, 'provider', 'google')
|
||||
|
||||
if (!query.title || typeof query.title !== 'string') {
|
||||
Logger.error(`[SearchController] findCovers: Invalid title sent in query`)
|
||||
return res.sendStatus(400)
|
||||
let results = null
|
||||
if (podcast) results = await PodcastFinder.findCovers(title)
|
||||
else results = await BookFinder.findCovers(provider, title, author)
|
||||
res.json({ results })
|
||||
} catch (error) {
|
||||
Logger.error(`[SearchController] findCovers: ${error.message}`)
|
||||
if (error instanceof ValidationError) {
|
||||
return res.status(error.status).json({ error: error.message })
|
||||
}
|
||||
return res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
|
||||
let results = null
|
||||
if (podcast) results = await PodcastFinder.findCovers(query.title)
|
||||
else results = await BookFinder.findCovers(query.provider || 'google', query.title, query.author || '')
|
||||
res.json({
|
||||
results
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@ -69,34 +139,42 @@ class SearchController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findPodcasts(req, res) {
|
||||
const term = req.query.term
|
||||
const country = req.query.country || 'us'
|
||||
if (!term) {
|
||||
Logger.error('[SearchController] Invalid request query param "term" is required')
|
||||
return res.status(400).send('Invalid request query param "term" is required')
|
||||
}
|
||||
try {
|
||||
const query = req.query
|
||||
const term = getQueryParamAsString(query, 'term', '', true)
|
||||
const country = getQueryParamAsString(query, 'country', 'us')
|
||||
|
||||
const results = await PodcastFinder.search(term, {
|
||||
country
|
||||
})
|
||||
res.json(results)
|
||||
const results = await PodcastFinder.search(term, { country })
|
||||
res.json(results)
|
||||
} catch (error) {
|
||||
Logger.error(`[SearchController] findPodcasts: ${error.message}`)
|
||||
if (error instanceof ValidationError) {
|
||||
return res.status(error.status).json({ error: error.message })
|
||||
}
|
||||
return res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/search/authors
|
||||
* Note: This endpoint is not currently used in the web client.
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findAuthor(req, res) {
|
||||
const query = req.query.q
|
||||
if (!query || typeof query !== 'string') {
|
||||
Logger.error(`[SearchController] findAuthor: Invalid query param`)
|
||||
return res.status(400).send('Invalid query param')
|
||||
}
|
||||
try {
|
||||
const query = getQueryParamAsString(req.query, 'q', '', true)
|
||||
|
||||
const author = await AuthorFinder.findAuthorByName(query)
|
||||
res.json(author)
|
||||
const author = await AuthorFinder.findAuthorByName(query)
|
||||
res.json(author)
|
||||
} catch (error) {
|
||||
Logger.error(`[SearchController] findAuthor: ${error.message}`)
|
||||
if (error instanceof ValidationError) {
|
||||
return res.status(error.status).json({ error: error.message })
|
||||
}
|
||||
return res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -106,16 +184,55 @@ class SearchController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async findChapters(req, res) {
|
||||
const asin = req.query.asin
|
||||
if (!isValidASIN(asin.toUpperCase())) {
|
||||
return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' })
|
||||
try {
|
||||
const query = req.query
|
||||
const asin = getQueryParamAsString(query, 'asin', '', true)
|
||||
const region = getQueryParamAsString(req.query.region, 'us').toLowerCase()
|
||||
|
||||
if (!isValidASIN(asin.toUpperCase())) throw new ValidationError('asin', 'is invalid')
|
||||
|
||||
const chapterData = await BookFinder.findChapters(asin, region)
|
||||
if (!chapterData) {
|
||||
return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' })
|
||||
}
|
||||
res.json(chapterData)
|
||||
} catch (error) {
|
||||
Logger.error(`[SearchController] findChapters: ${error.message}`)
|
||||
if (error instanceof ValidationError) {
|
||||
if (error.paramName === 'asin') {
|
||||
return res.json({ error: 'Invalid ASIN', stringKey: 'MessageInvalidAsin' })
|
||||
}
|
||||
if (error.paramName === 'region') {
|
||||
return res.json({ error: 'Invalid region', stringKey: 'MessageInvalidRegion' })
|
||||
}
|
||||
}
|
||||
return res.status(500).json({ error: 'Internal server error' })
|
||||
}
|
||||
const region = (req.query.region || 'us').toLowerCase()
|
||||
const chapterData = await BookFinder.findChapters(asin, region)
|
||||
if (!chapterData) {
|
||||
return res.json({ error: 'Chapters not found', stringKey: 'MessageChaptersNotFound' })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET: /api/search/providers
|
||||
* Get all available metadata providers
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getAllProviders(req, res) {
|
||||
const customProviders = await Database.customMetadataProviderModel.findAll()
|
||||
|
||||
const customBookProviders = customProviders.filter((p) => p.mediaType === 'book')
|
||||
const customPodcastProviders = customProviders.filter((p) => p.mediaType === 'podcast')
|
||||
|
||||
const bookProviders = BookFinder.providers.filter((p) => p !== 'audiobookcovers')
|
||||
|
||||
// Build minimized payload with custom providers merged in
|
||||
const providers = {
|
||||
books: [...bookProviders.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customBookProviders)],
|
||||
booksCovers: [SearchController.formatProvider('best'), ...BookFinder.providers.map((p) => SearchController.formatProvider(p)), ...SearchController.mapCustomProviders(customBookProviders), SearchController.formatProvider('all')],
|
||||
podcasts: [SearchController.formatProvider('itunes'), ...SearchController.mapCustomProviders(customPodcastProviders)]
|
||||
}
|
||||
res.json(chapterData)
|
||||
|
||||
res.json({ providers })
|
||||
}
|
||||
}
|
||||
module.exports = new SearchController()
|
||||
|
||||
@ -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, '')
|
||||
|
||||
@ -7,9 +7,9 @@ class PodcastFinder {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} term
|
||||
* @param {{country:string}} options
|
||||
*
|
||||
* @param {string} term
|
||||
* @param {{country:string}} options
|
||||
* @returns {Promise<import('../providers/iTunes').iTunesPodcastSearchResult[]>}
|
||||
*/
|
||||
async search(term, options = {}) {
|
||||
@ -20,12 +20,16 @@ class PodcastFinder {
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} term
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async findCovers(term) {
|
||||
if (!term) return null
|
||||
Logger.debug(`[iTunes] Searching for podcast covers with term "${term}"`)
|
||||
var results = await this.iTunesApi.searchPodcasts(term)
|
||||
const results = await this.iTunesApi.searchPodcasts(term)
|
||||
if (!results) return []
|
||||
return results.map(r => r.cover).filter(r => r)
|
||||
return results.map((r) => r.cover).filter((r) => r)
|
||||
}
|
||||
}
|
||||
module.exports = new PodcastFinder()
|
||||
module.exports = new PodcastFinder()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ class Stream extends EventEmitter {
|
||||
return [AudioMimeType.FLAC, AudioMimeType.OPUS, AudioMimeType.WMA, AudioMimeType.AIFF, AudioMimeType.WEBM, AudioMimeType.WEBMA, AudioMimeType.AWB, AudioMimeType.CAF]
|
||||
}
|
||||
get codecsToForceAAC() {
|
||||
return ['alac']
|
||||
return ['alac', 'ac3', 'eac3']
|
||||
}
|
||||
get userToken() {
|
||||
return this.user.token
|
||||
@ -273,7 +273,16 @@ class Stream extends EventEmitter {
|
||||
audioCodec = 'aac'
|
||||
}
|
||||
|
||||
this.ffmpeg.addOption([`-loglevel ${logLevel}`, '-map 0:a', `-c:a ${audioCodec}`])
|
||||
const codecOptions = [`-loglevel ${logLevel}`, '-map 0:a']
|
||||
|
||||
if (['ac3', 'eac3'].includes(this.tracksCodec) && this.tracks.length > 0 && this.tracks[0].bitRate && this.tracks[0].channels) {
|
||||
// In case for ac3/eac3 it needs to be passed the bitrate and channels to avoid ffmpeg errors
|
||||
codecOptions.push(`-c:a ${audioCodec}`, `-b:a ${this.tracks[0].bitRate}`, `-ac ${this.tracks[0].channels}`)
|
||||
} else {
|
||||
codecOptions.push(`-c:a ${audioCodec}`)
|
||||
}
|
||||
|
||||
this.ffmpeg.addOption(codecOptions)
|
||||
const hlsOptions = ['-f hls', '-copyts', '-avoid_negative_ts make_non_negative', '-max_delay 5000000', '-max_muxing_queue_size 2048', `-hls_time 6`, `-hls_segment_type ${this.hlsSegmentType}`, `-start_number ${this.segmentStartNumber}`, '-hls_playlist_type vod', '-hls_list_size 0', '-hls_allow_cache 0']
|
||||
this.ffmpeg.addOption(hlsOptions)
|
||||
if (this.hlsSegmentType === 'fmp4') {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user