Map english translations and merge with gu

This commit is contained in:
advplyr 2023-12-05 15:41:12 -06:00
commit 450507a812
72 changed files with 7899 additions and 502 deletions

3
.gitignore vendored
View File

@ -7,11 +7,12 @@
/podcasts/ /podcasts/
/media/ /media/
/metadata/ /metadata/
test/
/client/.nuxt/ /client/.nuxt/
/client/dist/ /client/dist/
/dist/ /dist/
/deploy/ /deploy/
/coverage/
/.nyc_output/
sw.* sw.*
.DS_STORE .DS_STORE

View File

@ -16,5 +16,6 @@
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.detectIndentation": true, "editor.detectIndentation": true,
"editor.tabSize": 2 "editor.tabSize": 2,
"javascript.format.semicolons": "remove"
} }

View File

@ -259,3 +259,23 @@ Bookshelf Label
.no-bars .Vue-Toastification__container.top-right { .no-bars .Vue-Toastification__container.top-right {
padding-top: 8px; padding-top: 8px;
} }
.abs-btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
.abs-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
.abs-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}

View File

@ -320,9 +320,11 @@ export default {
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox, checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
yesButtonText: this.$strings.ButtonDelete, yesButtonText: this.$strings.ButtonDelete,
yesButtonColor: 'error', yesButtonColor: 'error',
checkboxDefaultValue: true, checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
callback: (confirmed, hardDelete) => { callback: (confirmed, hardDelete) => {
if (confirmed) { if (confirmed) {
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
this.$store.commit('setProcessingBatch', true) this.$store.commit('setProcessingBatch', true)
this.$axios this.$axios

View File

@ -338,9 +338,15 @@ export default {
libraryItemsAdded(libraryItems) { libraryItemsAdded(libraryItems) {
console.log('libraryItems added', libraryItems) console.log('libraryItems added', libraryItems)
const isThisLibrary = !libraryItems.some((li) => li.libraryId !== this.currentLibraryId) const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added')
if (!this.search && isThisLibrary) { if (!recentlyAddedShelf) return
this.fetchCategories()
// Add new library item to the recently added shelf
for (const libraryItem of libraryItems) {
if (libraryItem.libraryId === this.currentLibraryId && !recentlyAddedShelf.entities.some((ent) => ent.id === libraryItem.id)) {
// Add to front of array
recentlyAddedShelf.entities.unshift(libraryItem)
}
} }
}, },
libraryItemsUpdated(items) { libraryItemsUpdated(items) {

View File

@ -36,7 +36,7 @@
</svg> </svg>
</nuxt-link> </nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
<p class="text-sm">{{ $strings.ButtonSearch }}</p> <p class="text-sm">{{ $strings.ButtonAdd }}</p>
</nuxt-link> </nuxt-link>
</div> </div>
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8"> <div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">

View File

@ -104,6 +104,11 @@ export default {
id: 'config-rss-feeds', id: 'config-rss-feeds',
title: this.$strings.HeaderRSSFeeds, title: this.$strings.HeaderRSSFeeds,
path: '/config/rss-feeds' path: '/config/rss-feeds'
},
{
id: 'config-authentication',
title: this.$strings.HeaderAuthentication,
path: '/config/authentication'
} }
] ]

View File

@ -82,7 +82,7 @@
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="abs-icons icon-podcast text-xl"></span> <span class="abs-icons icon-podcast text-xl"></span>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p> <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAdd }}</p>
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </nuxt-link>

View File

@ -8,7 +8,7 @@
<!-- Author name & num books overlay --> <!-- Author name & num books overlay -->
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2"> <div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p> <p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p> <p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
</div> </div>
<!-- Search icon btn --> <!-- Search icon btn -->

View File

@ -15,24 +15,33 @@
<div class="flex my-2 -mx-2"> <div class="flex my-2 -mx-2">
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<ui-text-input-with-label v-model="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" /> <ui-text-input-with-label v-model.trim="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
</div> </div>
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" /> <div v-if="!isPodcast" class="flex items-end">
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
<ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp">
<div
class="ml-2 mb-1 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer"
@click="fetchMetadata">
<span class="text-base text-white text-opacity-80 font-mono material-icons">sync</span>
</div>
</ui-tooltip>
</div>
<div v-else class="w-full"> <div v-else class="w-full">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p> <p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" /> <ui-text-input :value="directory" disabled class="w-full font-mono text-xs" />
</div> </div>
</div> </div>
</div> </div>
<div v-if="!isPodcast" class="flex my-2 -mx-2"> <div v-if="!isPodcast" class="flex my-2 -mx-2">
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" /> <ui-text-input-with-label v-model.trim="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" inputClass="h-10" />
</div> </div>
<div class="w-1/2 px-2"> <div class="w-1/2 px-2">
<div class="w-full"> <div class="w-full">
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p> <label class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></label>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" /> <ui-text-input :value="directory" disabled class="w-full font-mono text-xs h-10" />
</div> </div>
</div> </div>
</div> </div>
@ -48,8 +57,8 @@
<p class="text-base">{{ $strings.MessageUploaderItemFailed }}</p> <p class="text-base">{{ $strings.MessageUploaderItemFailed }}</p>
</widgets-alert> </widgets-alert>
<div v-if="isUploading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20"> <div v-if="isNonInteractable" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
<ui-loading-indicator :text="$strings.MessageUploading" /> <ui-loading-indicator :text="nonInteractionLabel" />
</div> </div>
</div> </div>
</template> </template>
@ -64,7 +73,8 @@ export default {
default: () => { } default: () => { }
}, },
mediaType: String, mediaType: String,
processing: Boolean processing: Boolean,
provider: String
}, },
data() { data() {
return { return {
@ -76,7 +86,8 @@ export default {
error: '', error: '',
isUploading: false, isUploading: false,
uploadFailed: false, uploadFailed: false,
uploadSuccess: false uploadSuccess: false,
isFetchingMetadata: false
} }
}, },
computed: { computed: {
@ -87,12 +98,19 @@ export default {
if (!this.itemData.title) return '' if (!this.itemData.title) return ''
if (this.isPodcast) return this.itemData.title if (this.isPodcast) return this.itemData.title
if (this.itemData.series && this.itemData.author) { const outputPathParts = [this.itemData.author, this.itemData.series, this.itemData.title]
return Path.join(this.itemData.author, this.itemData.series, this.itemData.title) const cleanedOutputPathParts = outputPathParts.filter(Boolean).map(part => this.$sanitizeFilename(part))
} else if (this.itemData.author) {
return Path.join(this.itemData.author, this.itemData.title) return Path.join(...cleanedOutputPathParts)
} else { },
return this.itemData.title isNonInteractable() {
return this.isUploading || this.isFetchingMetadata
},
nonInteractionLabel() {
if (this.isUploading) {
return this.$strings.MessageUploading
} else if (this.isFetchingMetadata) {
return this.$strings.LabelFetchingMetadata
} }
} }
}, },
@ -105,9 +123,42 @@ export default {
titleUpdated() { titleUpdated() {
this.error = '' this.error = ''
}, },
async fetchMetadata() {
if (!this.itemData.title.trim().length) {
return
}
this.isFetchingMetadata = true
this.error = ''
try {
const searchQueryString = new URLSearchParams({
title: this.itemData.title,
author: this.itemData.author,
provider: this.provider
})
const [bestCandidate, ..._rest] = await this.$axios.$get(`/api/search/books?${searchQueryString}`)
if (bestCandidate) {
this.itemData = {
...this.itemData,
title: bestCandidate.title,
author: bestCandidate.author,
series: (bestCandidate.series || [])[0]?.series
}
} else {
this.error = this.$strings.ErrorUploadFetchMetadataNoResults
}
} catch (e) {
console.error('Failed', e)
this.error = this.$strings.ErrorUploadFetchMetadataAPI
} finally {
this.isFetchingMetadata = false
}
},
getData() { getData() {
if (!this.itemData.title) { if (!this.itemData.title) {
this.error = 'Must have a title' this.error = this.$strings.ErrorUploadLacksTitle
return null return null
} }
this.error = '' this.error = ''

View File

@ -848,9 +848,11 @@ export default {
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox, checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
yesButtonText: this.$strings.ButtonDelete, yesButtonText: this.$strings.ButtonDelete,
yesButtonColor: 'error', yesButtonColor: 'error',
checkboxDefaultValue: true, checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
callback: (confirmed, hardDelete) => { callback: (confirmed, hardDelete) => {
if (confirmed) { if (confirmed) {
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
this.processing = true this.processing = true
const axios = this.$axios || this.$nuxt.$axios const axios = this.$axios || this.$nuxt.$axios
axios axios

View File

@ -14,8 +14,7 @@ export default {
}, },
data() { data() {
return { return {
tracks: [], tracks: []
showFullPath: false
} }
}, },
watch: { watch: {

View File

@ -127,7 +127,7 @@ export default {
skipMatchingMediaWithIsbn: false, skipMatchingMediaWithIsbn: false,
autoScanCronExpression: null, autoScanCronExpression: null,
hideSingleBookSeries: false, hideSingleBookSeries: false,
metadataPrecedence: ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata'] metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
} }
} }
}, },

View File

@ -19,9 +19,11 @@
<li v-for="(source, index) in metadataSourceMapped" :key="source.id" :class="source.include ? 'item' : 'opacity-50'" class="w-full px-2 flex items-center relative border border-white/10"> <li v-for="(source, index) in metadataSourceMapped" :key="source.id" :class="source.include ? 'item' : 'opacity-50'" class="w-full px-2 flex items-center relative border border-white/10">
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 mr-2 md:mr-4">reorder</span> <span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 mr-2 md:mr-4">reorder</span>
<div class="text-center py-1 w-8 min-w-8"> <div class="text-center py-1 w-8 min-w-8">
{{ source.include ? index + 1 : '' }} {{ source.include ? getSourceIndex(source.id) : '' }}
</div>
<div class="flex-grow inline-flex justify-between px-4 py-3">
{{ source.name }} <span v-if="source.include && (index === firstActiveSourceIndex || index === lastActiveSourceIndex)" class="px-2 italic font-semibold text-xs text-gray-400">{{ index === firstActiveSourceIndex ? $strings.LabelHighestPriority : $strings.LabelLowestPriority }}</span>
</div> </div>
<div class="flex-grow px-4 py-3">{{ source.name }}</div>
<div class="px-2 opacity-100"> <div class="px-2 opacity-100">
<ui-toggle-switch v-model="source.include" :off-color="'error'" @input="includeToggled(source)" /> <ui-toggle-switch v-model="source.include" :off-color="'error'" @input="includeToggled(source)" />
</div> </div>
@ -64,6 +66,11 @@ export default {
name: 'Audio file meta tags', name: 'Audio file meta tags',
include: true include: true
}, },
nfoFile: {
id: 'nfoFile',
name: 'NFO file',
include: true
},
txtFiles: { txtFiles: {
id: 'txtFiles', id: 'txtFiles',
name: 'desc.txt & reader.txt files', name: 'desc.txt & reader.txt files',
@ -92,20 +99,34 @@ export default {
}, },
isBookLibrary() { isBookLibrary() {
return this.mediaType === 'book' return this.mediaType === 'book'
},
firstActiveSourceIndex() {
return this.metadataSourceMapped.findIndex((source) => source.include)
},
lastActiveSourceIndex() {
return this.metadataSourceMapped.findLastIndex((source) => source.include)
} }
}, },
methods: { methods: {
getSourceIndex(source) {
const activeSources = (this.librarySettings.metadataPrecedence || []).map((s) => s).reverse()
return activeSources.findIndex((s) => s === source) + 1
},
resetToDefault() { resetToDefault() {
this.metadataSourceMapped = [] this.metadataSourceMapped = []
for (const key in this.metadataSourceData) { for (const key in this.metadataSourceData) {
this.metadataSourceMapped.push({ ...this.metadataSourceData[key] }) this.metadataSourceMapped.push({ ...this.metadataSourceData[key] })
} }
this.metadataSourceMapped.reverse()
this.$emit('update', this.getLibraryData()) this.$emit('update', this.getLibraryData())
}, },
getLibraryData() { getLibraryData() {
const metadataSourceIds = this.metadataSourceMapped.map((source) => (source.include ? source.id : null)).filter((s) => s)
metadataSourceIds.reverse()
return { return {
settings: { settings: {
metadataPrecedence: this.metadataSourceMapped.map((source) => (source.include ? source.id : null)).filter((s) => s) metadataPrecedence: metadataSourceIds
} }
} }
}, },
@ -120,15 +141,16 @@ export default {
}, },
init() { init() {
const metadataPrecedence = this.librarySettings.metadataPrecedence || [] const metadataPrecedence = this.librarySettings.metadataPrecedence || []
this.metadataSourceMapped = metadataPrecedence.map((source) => this.metadataSourceData[source]).filter((s) => s) this.metadataSourceMapped = metadataPrecedence.map((source) => this.metadataSourceData[source]).filter((s) => s)
for (const sourceKey in this.metadataSourceData) { for (const sourceKey in this.metadataSourceData) {
if (!metadataPrecedence.includes(sourceKey)) { if (!metadataPrecedence.includes(sourceKey)) {
const unusedSourceData = { ...this.metadataSourceData[sourceKey], include: false } const unusedSourceData = { ...this.metadataSourceData[sourceKey], include: false }
this.metadataSourceMapped.push(unusedSourceData) this.metadataSourceMapped.unshift(unusedSourceData)
} }
} }
this.metadataSourceMapped.reverse()
} }
}, },
mounted() { mounted() {

View File

@ -6,7 +6,7 @@
<span class="text-sm font-mono">{{ ebookFiles.length }}</span> <span class="text-sm font-mono">{{ ebookFiles.length }}</span>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn> <ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''"> <div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span> <span class="material-icons text-4xl">expand_more</span>
</div> </div>
@ -75,6 +75,10 @@ export default {
} }
}, },
methods: { methods: {
toggleFullPath() {
this.showFullPath = !this.showFullPath
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
},
readEbook(fileIno) { readEbook(fileIno) {
this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno }) this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno })
}, },
@ -82,6 +86,10 @@ export default {
this.showFiles = !this.showFiles this.showFiles = !this.showFiles
} }
}, },
mounted() {} mounted() {
if (this.userIsAdmin) {
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
}
}
} }
</script> </script>

View File

@ -6,7 +6,7 @@
<span class="text-sm font-mono">{{ files.length }}</span> <span class="text-sm font-mono">{{ files.length }}</span>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn> <ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''"> <div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span> <span class="material-icons text-4xl">expand_more</span>
</div> </div>
@ -84,6 +84,10 @@ export default {
} }
}, },
methods: { methods: {
toggleFullPath() {
this.showFullPath = !this.showFullPath
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
},
clickBar() { clickBar() {
this.showFiles = !this.showFiles this.showFiles = !this.showFiles
}, },
@ -93,6 +97,9 @@ export default {
} }
}, },
mounted() { mounted() {
if (this.userIsAdmin) {
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
}
this.showFiles = this.expanded this.showFiles = this.expanded
} }
} }

View File

@ -6,7 +6,7 @@
<span class="text-sm font-mono">{{ tracks.length }}</span> <span class="text-sm font-mono">{{ tracks.length }}</span>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn> <ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
<nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent> <nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
<ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn> <ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
</nuxt-link> </nuxt-link>
@ -74,6 +74,10 @@ export default {
} }
}, },
methods: { methods: {
toggleFullPath() {
this.showFullPath = !this.showFullPath
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
},
clickBar() { clickBar() {
this.showTracks = !this.showTracks this.showTracks = !this.showTracks
}, },
@ -82,6 +86,10 @@ export default {
this.showAudioFileDataModal = true this.showAudioFileDataModal = true
} }
}, },
mounted() {} mounted() {
if (this.userIsAdmin) {
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
}
}
} }
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList"> <nuxt-link v-if="to" :to="to" class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
<slot /> <slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100"> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24"> <svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
@ -7,7 +7,7 @@
</svg> </svg>
</div> </div>
</nuxt-link> </nuxt-link>
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click"> <button v-else class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
<slot /> <slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100"> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24"> <svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
@ -72,23 +72,3 @@ export default {
mounted() {} mounted() {}
} }
</script> </script>
<style scoped>
.btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
.btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>

View File

@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.5.0", "version": "2.6.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.5.0", "version": "2.6.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.5.0", "version": "2.6.0",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",

View File

@ -19,8 +19,8 @@
<div class="w-full h-px bg-white/10 my-4" /> <div class="w-full h-px bg-white/10 my-4" />
<p v-if="!isGuest" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p> <p v-if="showChangePasswordForm" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p>
<form v-if="!isGuest" @submit.prevent="submitChangePassword"> <form v-if="showChangePasswordForm" @submit.prevent="submitChangePassword">
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" :label="$strings.LabelPassword" class="my-2" /> <ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" :label="$strings.LabelPassword" class="my-2" />
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" :label="$strings.LabelNewPassword" class="my-2" /> <ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" :label="$strings.LabelNewPassword" class="my-2" />
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" :label="$strings.LabelConfirmPassword" class="my-2" /> <ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" :label="$strings.LabelConfirmPassword" class="my-2" />
@ -68,6 +68,13 @@ export default {
}, },
isGuest() { isGuest() {
return this.usertype === 'guest' return this.usertype === 'guest'
},
isPasswordAuthEnabled() {
const activeAuthMethods = this.$store.getters['getServerSetting']('authActiveAuthMethods') || []
return activeAuthMethods.includes('local')
},
showChangePasswordForm() {
return !this.isGuest && this.isPasswordAuthEnabled
} }
}, },
methods: { methods: {

View File

@ -57,6 +57,7 @@ export default {
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
else if (pageName === 'email') return this.$strings.HeaderEmail else if (pageName === 'email') return this.$strings.HeaderEmail
else if (pageName === 'authentication') return this.$strings.HeaderAuthentication
} }
return this.$strings.HeaderSettings return this.$strings.HeaderSettings
} }

View File

@ -0,0 +1,244 @@
<template>
<div id="authentication-settings">
<app-settings-content :header-text="$strings.HeaderAuthentication">
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
<div class="flex items-center">
<ui-checkbox v-model="enableLocalAuth" checkbox-bg="bg" />
<p class="text-lg pl-4">{{ $strings.HeaderPasswordAuthentication }}</p>
</div>
</div>
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
<div class="flex items-center">
<ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" />
<p class="text-lg pl-4">{{ $strings.HeaderOpenIDConnectAuthentication }}</p>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/oidc_authentication" target="_blank" class="inline-flex">
<span class="material-icons text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
</div>
<transition name="slide">
<div v-if="enableOpenIDAuth" class="flex flex-wrap pt-4">
<div class="w-full flex items-center mb-2">
<div class="flex-grow">
<ui-text-input-with-label ref="issuerUrl" v-model="newAuthSettings.authOpenIDIssuerURL" :disabled="savingSettings" :label="'Issuer URL'" />
</div>
<div class="w-36 mx-1 mt-[1.375rem]">
<ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" @click.stop="autoPopulateOIDCClick">
<span class="material-icons text-base">auto_fix_high</span>
<span class="whitespace-nowrap break-keep pl-1">Auto-populate</span></ui-btn
>
</div>
</div>
<ui-text-input-with-label ref="authorizationUrl" v-model="newAuthSettings.authOpenIDAuthorizationURL" :disabled="savingSettings" :label="'Authorize URL'" class="mb-2" />
<ui-text-input-with-label ref="tokenUrl" v-model="newAuthSettings.authOpenIDTokenURL" :disabled="savingSettings" :label="'Token URL'" class="mb-2" />
<ui-text-input-with-label ref="userInfoUrl" v-model="newAuthSettings.authOpenIDUserInfoURL" :disabled="savingSettings" :label="'Userinfo URL'" class="mb-2" />
<ui-text-input-with-label ref="jwksUrl" v-model="newAuthSettings.authOpenIDJwksURL" :disabled="savingSettings" :label="'JWKS URL'" class="mb-2" />
<ui-text-input-with-label ref="logoutUrl" v-model="newAuthSettings.authOpenIDLogoutURL" :disabled="savingSettings" :label="'Logout URL'" class="mb-2" />
<ui-text-input-with-label ref="openidClientId" v-model="newAuthSettings.authOpenIDClientID" :disabled="savingSettings" :label="'Client ID'" class="mb-2" />
<ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" />
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
<div class="flex items-center pt-1 mb-2">
<div class="w-44">
<ui-dropdown v-model="newAuthSettings.authOpenIDMatchExistingBy" small :items="matchingExistingOptions" :label="$strings.LabelMatchExistingUsersBy" :disabled="savingSettings" />
</div>
<p class="pl-4 text-sm text-gray-300 mt-5">{{ $strings.LabelMatchExistingUsersByDescription }}</p>
</div>
<div class="flex items-center py-4 px-1">
<ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" />
<p id="auto-redirect-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoLaunch }}</p>
<p class="pl-4 text-sm text-gray-300" v-html="$strings.LabelAutoLaunchDescription" />
</div>
<div class="flex items-center py-4 px-1">
<ui-toggle-switch labeledBy="auto-register-toggle" v-model="newAuthSettings.authOpenIDAutoRegister" :disabled="savingSettings" />
<p id="auto-register-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoRegister }}</p>
<p class="pl-4 text-sm text-gray-300">{{ $strings.LabelAutoRegisterDescription }}</p>
</div>
</div>
</transition>
</div>
<div class="w-full flex items-center justify-end p-4">
<ui-btn color="success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
</div>
</app-settings-content>
</div>
</template>
<script>
export default {
async asyncData({ store, redirect, app }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
return
}
const authSettings = await app.$axios.$get('/api/auth-settings').catch((error) => {
console.error('Failed', error)
return null
})
if (!authSettings) {
redirect('/config')
return
}
return {
authSettings
}
},
data() {
return {
enableLocalAuth: false,
enableOpenIDAuth: false,
savingSettings: false,
newAuthSettings: {}
}
},
computed: {
authMethods() {
return this.authSettings.authActiveAuthMethods || []
},
matchingExistingOptions() {
return [
{
text: 'Do not match',
value: null
},
{
text: 'Match by email',
value: 'email'
},
{
text: 'Match by username',
value: 'username'
}
]
}
},
methods: {
autoPopulateOIDCClick() {
if (!this.newAuthSettings.authOpenIDIssuerURL) {
this.$toast.error('Issuer URL required')
return
}
// Remove trailing slash
let issuerUrl = this.newAuthSettings.authOpenIDIssuerURL
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
// If the full config path is on the issuer url then remove it
if (issuerUrl.endsWith('/.well-known/openid-configuration')) {
issuerUrl = issuerUrl.replace('/.well-known/openid-configuration', '')
this.newAuthSettings.authOpenIDIssuerURL = this.newAuthSettings.authOpenIDIssuerURL.replace('/.well-known/openid-configuration', '')
}
this.$axios
.$get(`/auth/openid/config?issuer=${issuerUrl}`)
.then((data) => {
if (data.issuer) this.newAuthSettings.authOpenIDIssuerURL = data.issuer
if (data.authorization_endpoint) this.newAuthSettings.authOpenIDAuthorizationURL = data.authorization_endpoint
if (data.token_endpoint) this.newAuthSettings.authOpenIDTokenURL = data.token_endpoint
if (data.userinfo_endpoint) this.newAuthSettings.authOpenIDUserInfoURL = data.userinfo_endpoint
if (data.end_session_endpoint) this.newAuthSettings.authOpenIDLogoutURL = data.end_session_endpoint
if (data.jwks_uri) this.newAuthSettings.authOpenIDJwksURL = data.jwks_uri
})
.catch((error) => {
console.error('Failed to receive data', error)
const errorMsg = error.response?.data || 'Unknown error'
this.$toast.error(errorMsg)
})
},
validateOpenID() {
let isValid = true
if (!this.newAuthSettings.authOpenIDIssuerURL) {
this.$toast.error('Issuer URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDAuthorizationURL) {
this.$toast.error('Authorize URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDTokenURL) {
this.$toast.error('Token URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDUserInfoURL) {
this.$toast.error('Userinfo URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDJwksURL) {
this.$toast.error('JWKS URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDClientID) {
this.$toast.error('Client ID required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDClientSecret) {
this.$toast.error('Client Secret required')
isValid = false
}
return isValid
},
async saveSettings() {
if (!this.enableLocalAuth && !this.enableOpenIDAuth) {
this.$toast.error('Must have at least one authentication method enabled')
return
}
if (this.enableOpenIDAuth && !this.validateOpenID()) {
return
}
this.newAuthSettings.authActiveAuthMethods = []
if (this.enableLocalAuth) this.newAuthSettings.authActiveAuthMethods.push('local')
if (this.enableOpenIDAuth) this.newAuthSettings.authActiveAuthMethods.push('openid')
this.savingSettings = true
this.$axios
.$patch('/api/auth-settings', this.newAuthSettings)
.then((data) => {
this.$store.commit('setServerSettings', data.serverSettings)
this.$toast.success('Server settings updated')
})
.catch((error) => {
console.error('Failed to update server settings', error)
this.$toast.error('Failed to update server settings')
})
.finally(() => {
this.savingSettings = false
})
},
init() {
this.newAuthSettings = {
...this.authSettings
}
this.enableLocalAuth = this.authMethods.includes('local')
this.enableOpenIDAuth = this.authMethods.includes('openid')
}
},
mounted() {
this.init()
}
}
</script>
<style>
#authentication-settings code {
font-size: 0.8rem;
border-radius: 6px;
background-color: rgb(82, 82, 82);
color: white;
padding: 2px 4px;
white-space: nowrap;
}
</style>

View File

@ -686,9 +686,11 @@ export default {
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox, checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
yesButtonText: this.$strings.ButtonDelete, yesButtonText: this.$strings.ButtonDelete,
yesButtonColor: 'error', yesButtonColor: 'error',
checkboxDefaultValue: true, checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
callback: (confirmed, hardDelete) => { callback: (confirmed, hardDelete) => {
if (confirmed) { if (confirmed) {
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
this.$axios this.$axios
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`) .$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
.then(() => { .then(() => {

View File

@ -25,9 +25,12 @@
</div> </div>
<div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 -mt-40"> <div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 -mt-40">
<p class="text-3xl text-white text-center mb-4">{{ $strings.HeaderLogin }}</p> <p class="text-3xl text-white text-center mb-4">{{ $strings.HeaderLogin }}</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" /> <div class="w-full h-px bg-white bg-opacity-10 my-4" />
<p v-if="error" class="text-error text-center py-2">{{ error }}</p> <p v-if="error" class="text-error text-center py-2">{{ error }}</p>
<form @submit.prevent="submitForm">
<form v-show="login_local" @submit.prevent="submitForm">
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label> <label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
<ui-text-input v-model="username" :disabled="processing" class="mb-3 w-full" /> <ui-text-input v-model="username" :disabled="processing" class="mb-3 w-full" />
@ -37,6 +40,14 @@
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn> <ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
</div> </div>
</form> </form>
<div v-if="login_local && login_openid" class="w-full h-px bg-white bg-opacity-10 my-4" />
<div class="w-full flex py-3">
<a v-if="login_openid" :href="openidAuthUri" class="w-full abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-8 py-2 leading-none">
{{ openIDButtonText }}
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -60,7 +71,10 @@ export default {
}, },
confirmPassword: '', confirmPassword: '',
ConfigPath: '', ConfigPath: '',
MetadataPath: '' MetadataPath: '',
login_local: true,
login_openid: false,
authFormData: null
} }
}, },
watch: { watch: {
@ -93,6 +107,12 @@ export default {
computed: { computed: {
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
},
openidAuthUri() {
return `${process.env.serverUrl}/auth/openid?callback=${location.href.split('?').shift()}`
},
openIDButtonText() {
return this.authFormData?.authOpenIDButtonText || 'Login with OpenId'
} }
}, },
methods: { methods: {
@ -162,6 +182,7 @@ export default {
else this.error = 'Unknown Error' else this.error = 'Unknown Error'
return false return false
}) })
if (authRes?.error) { if (authRes?.error) {
this.error = authRes.error this.error = authRes.error
} else if (authRes) { } else if (authRes) {
@ -196,28 +217,62 @@ export default {
this.processing = true this.processing = true
this.$axios this.$axios
.$get('/status') .$get('/status')
.then((res) => { .then((data) => {
this.processing = false this.isInit = data.isInit
this.isInit = res.isInit this.showInitScreen = !data.isInit
this.showInitScreen = !res.isInit this.$setServerLanguageCode(data.language)
this.$setServerLanguageCode(res.language)
if (this.showInitScreen) { if (this.showInitScreen) {
this.ConfigPath = res.ConfigPath || '' this.ConfigPath = data.ConfigPath || ''
this.MetadataPath = res.MetadataPath || '' this.MetadataPath = data.MetadataPath || ''
} else {
this.authFormData = data.authFormData
this.updateLoginVisibility(data.authMethods || [])
} }
}) })
.catch((error) => { .catch((error) => {
console.error('Status check failed', error) console.error('Status check failed', error)
this.processing = false
this.criticalError = 'Status check failed' this.criticalError = 'Status check failed'
}) })
.finally(() => {
this.processing = false
})
},
updateLoginVisibility(authMethods) {
if (this.$route.query?.error) {
this.error = this.$route.query.error
// Remove error query string
const newurl = new URL(location.href)
newurl.searchParams.delete('error')
window.history.replaceState({ path: newurl.href }, '', newurl.href)
}
if (authMethods.includes('local') || !authMethods.length) {
this.login_local = true
} else {
this.login_local = false
}
if (authMethods.includes('openid')) {
// Auto redirect unless query string ?autoLaunch=0
if (this.authFormData?.authOpenIDAutoLaunch && this.$route.query?.autoLaunch !== '0') {
window.location.href = this.openidAuthUri
}
this.login_openid = true
} else {
this.login_openid = false
}
} }
}, },
async mounted() { async mounted() {
if (localStorage.getItem('token')) { if (this.$route.query?.setToken) {
var userfound = await this.checkAuth() localStorage.setItem('token', this.$route.query.setToken)
if (userfound) return // if valid user no need to check status
} }
if (localStorage.getItem('token')) {
if (await this.checkAuth()) return // if valid user no need to check status
}
this.checkStatus() this.checkStatus()
} }
} }

View File

@ -14,6 +14,20 @@
</div> </div>
</div> </div>
<div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6">
<label class="flex cursor-pointer pt-4">
<ui-toggle-switch v-model="fetchMetadata.enabled" class="inline-flex" />
<span class="pl-2 text-base">{{ $strings.LabelAutoFetchMetadata }}</span>
</label>
<ui-tooltip :text="$strings.LabelAutoFetchMetadataHelp" class="inline-flex pt-4">
<span class="pl-1 material-icons icon-text text-sm cursor-pointer">info_outlined</span>
</ui-tooltip>
<div class="flex-grow ml-4">
<ui-dropdown v-model="fetchMetadata.provider" :items="providers" :label="$strings.LabelProvider" />
</div>
</div>
<widgets-alert v-if="error" type="error"> <widgets-alert v-if="error" type="error">
<p class="text-lg">{{ error }}</p> <p class="text-lg">{{ error }}</p>
</widgets-alert> </widgets-alert>
@ -61,9 +75,7 @@
</widgets-alert> </widgets-alert>
<!-- Item Upload cards --> <!-- Item Upload cards -->
<template v-for="item in items"> <cards-item-upload-card v-for="item in items" :key="item.index" :ref="`itemCard-${item.index}`" :media-type="selectedLibraryMediaType" :item="item" :provider="fetchMetadata.provider" :processing="processing" @remove="removeItem(item)" />
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="item.index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
</template>
<!-- Upload/Reset btns --> <!-- Upload/Reset btns -->
<div v-show="items.length" class="flex justify-end pb-8 pt-4"> <div v-show="items.length" class="flex justify-end pb-8 pt-4">
@ -92,13 +104,18 @@ export default {
selectedLibraryId: null, selectedLibraryId: null,
selectedFolderId: null, selectedFolderId: null,
processing: false, processing: false,
uploadFinished: false uploadFinished: false,
fetchMetadata: {
enabled: false,
provider: null
}
} }
}, },
watch: { watch: {
selectedLibrary(newVal) { selectedLibrary(newVal) {
if (newVal && !this.selectedFolderId) { if (newVal && !this.selectedFolderId) {
this.setDefaultFolder() this.setDefaultFolder()
this.setMetadataProvider()
} }
} }
}, },
@ -133,6 +150,13 @@ export default {
selectedLibraryIsPodcast() { selectedLibraryIsPodcast() {
return this.selectedLibraryMediaType === 'podcast' return this.selectedLibraryMediaType === 'podcast'
}, },
providers() {
if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
},
canFetchMetadata() {
return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled
},
selectedFolder() { selectedFolder() {
if (!this.selectedLibrary) return null if (!this.selectedLibrary) return null
return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId) return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)
@ -160,12 +184,16 @@ export default {
} }
} }
this.setDefaultFolder() this.setDefaultFolder()
this.setMetadataProvider()
}, },
setDefaultFolder() { setDefaultFolder() {
if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) { if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) {
this.selectedFolderId = this.selectedLibrary.folders[0].id this.selectedFolderId = this.selectedLibrary.folders[0].id
} }
}, },
setMetadataProvider() {
this.fetchMetadata.provider ||= this.$store.getters['libraries/getLibraryProvider'](this.selectedLibraryId)
},
removeItem(item) { removeItem(item) {
this.items = this.items.filter((b) => b.index !== item.index) this.items = this.items.filter((b) => b.index !== item.index)
if (!this.items.length) { if (!this.items.length) {
@ -213,27 +241,49 @@ export default {
var items = e.dataTransfer.items || [] var items = e.dataTransfer.items || []
var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType) var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType)
this.setResults(itemResults) this.onItemsSelected(itemResults)
}, },
inputChanged(e) { inputChanged(e) {
if (!e.target || !e.target.files) return if (!e.target || !e.target.files) return
var _files = Array.from(e.target.files) var _files = Array.from(e.target.files)
if (_files && _files.length) { if (_files && _files.length) {
var itemResults = this.uploadHelpers.getItemsFromPicker(_files, this.selectedLibraryMediaType) var itemResults = this.uploadHelpers.getItemsFromPicker(_files, this.selectedLibraryMediaType)
this.setResults(itemResults) this.onItemsSelected(itemResults)
} }
}, },
setResults(itemResults) { onItemsSelected(itemResults) {
if (this.itemSelectionSuccessful(itemResults)) {
// setTimeout ensures the new item ref is attached before this method is called
setTimeout(this.attemptMetadataFetch, 0)
}
},
itemSelectionSuccessful(itemResults) {
console.log('Upload results', itemResults)
if (itemResults.error) { if (itemResults.error) {
this.error = itemResults.error this.error = itemResults.error
this.items = [] this.items = []
this.ignoredFiles = [] this.ignoredFiles = []
} else { return false
}
this.error = '' this.error = ''
this.items = itemResults.items this.items = itemResults.items
this.ignoredFiles = itemResults.ignoredFiles this.ignoredFiles = itemResults.ignoredFiles
return true
},
attemptMetadataFetch() {
if (!this.canFetchMetadata) {
return false
} }
console.log('Upload results', itemResults)
this.items.forEach((item) => {
let itemRef = this.$refs[`itemCard-${item.index}`]
if (itemRef?.length) {
itemRef[0].fetchMetadata(this.fetchMetadata.provider)
}
})
}, },
updateItemCardStatus(index, status) { updateItemCardStatus(index, status) {
var ref = this.$refs[`itemCard-${index}`] var ref = this.$refs[`itemCard-${index}`]
@ -248,8 +298,8 @@ export default {
var form = new FormData() var form = new FormData()
form.set('title', item.title) form.set('title', item.title)
if (!this.selectedLibraryIsPodcast) { if (!this.selectedLibraryIsPodcast) {
form.set('author', item.author) form.set('author', item.author || '')
form.set('series', item.series) form.set('series', item.series || '')
} }
form.set('library', this.selectedLibraryId) form.set('library', this.selectedLibraryId)
form.set('folder', this.selectedFolderId) form.set('folder', this.selectedFolderId)
@ -346,6 +396,8 @@ export default {
}, },
mounted() { mounted() {
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
this.setMetadataProvider()
this.setDefaultFolder() this.setDefaultFolder()
window.addEventListener('dragenter', this.dragenter) window.addEventListener('dragenter', this.dragenter)
window.addEventListener('dragleave', this.dragleave) window.addEventListener('dragleave', this.dragleave)

View File

@ -77,6 +77,7 @@ Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => {
.replace(lineBreaks, replacement) .replace(lineBreaks, replacement)
.replace(windowsReservedRe, replacement) .replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement) .replace(windowsTrailingRe, replacement)
.replace(/\s+/g, ' ') // Replace consecutive spaces with a single space
// Check if basename is too many bytes // Check if basename is too many bytes
const ext = Path.extname(sanitized) // separate out file extension const ext = Path.extname(sanitized) // separate out file extension

View File

@ -66,7 +66,7 @@ export const getters = {
export const actions = { export const actions = {
updateServerSettings({ commit }, payload) { updateServerSettings({ commit }, payload) {
var updatePayload = { const updatePayload = {
...payload ...payload
} }
return this.$axios.$patch('/api/settings', updatePayload).then((result) => { return this.$axios.$patch('/api/settings', updatePayload).then((result) => {

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "Upravit uživatelské {0}", "ButtonUserEdit": "Upravit uživatelské {0}",
"ButtonViewAll": "Zobrazit vše", "ButtonViewAll": "Zobrazit vše",
"ButtonYes": "Ano", "ButtonYes": "Ano",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Účet", "HeaderAccount": "Účet",
"HeaderAdvanced": "Pokročilé", "HeaderAdvanced": "Pokročilé",
"HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise", "HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise",
"HeaderAudiobookTools": "Nástroje pro správu souborů audioknih", "HeaderAudiobookTools": "Nástroje pro správu souborů audioknih",
"HeaderAudioTracks": "Zvukové stopy", "HeaderAudioTracks": "Zvukové stopy",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Zálohy", "HeaderBackups": "Zálohy",
"HeaderChangePassword": "Změnit heslo", "HeaderChangePassword": "Změnit heslo",
"HeaderChapters": "Kapitoly", "HeaderChapters": "Kapitoly",
@ -131,8 +135,10 @@
"HeaderNewAccount": "Nový účet", "HeaderNewAccount": "Nový účet",
"HeaderNewLibrary": "Nová knihovna", "HeaderNewLibrary": "Nová knihovna",
"HeaderNotifications": "Oznámení", "HeaderNotifications": "Oznámení",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Otevřít RSS kanál", "HeaderOpenRSSFeed": "Otevřít RSS kanál",
"HeaderOtherFiles": "Ostatní soubory", "HeaderOtherFiles": "Ostatní soubory",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Oprávnění", "HeaderPermissions": "Oprávnění",
"HeaderPlayerQueue": "Fronta přehrávače", "HeaderPlayerQueue": "Fronta přehrávače",
"HeaderPlaylist": "Seznam skladeb", "HeaderPlaylist": "Seznam skladeb",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Autor (příjmení a jméno)", "LabelAuthorLastFirst": "Autor (příjmení a jméno)",
"LabelAuthors": "Autoři", "LabelAuthors": "Autoři",
"LabelAutoDownloadEpisodes": "Automaticky stahovat epizody", "LabelAutoDownloadEpisodes": "Automaticky stahovat epizody",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Zpět k uživateli", "LabelBackToUser": "Zpět k uživateli",
"LabelBackupLocation": "Umístění zálohy", "LabelBackupLocation": "Umístění zálohy",
"LabelBackupsEnableAutomaticBackups": "Povolit automatické zálohování", "LabelBackupsEnableAutomaticBackups": "Povolit automatické zálohování",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "Najednou bude odstraněna pouze 1 záloha, takže pokud již máte více záloh, měli byste je odstranit ručně.", "LabelBackupsNumberToKeepHelp": "Najednou bude odstraněna pouze 1 záloha, takže pokud již máte více záloh, měli byste je odstranit ručně.",
"LabelBitrate": "Datový tok", "LabelBitrate": "Datový tok",
"LabelBooks": "Knihy", "LabelBooks": "Knihy",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Změnit heslo", "LabelChangePassword": "Změnit heslo",
"LabelChannels": "Kanály", "LabelChannels": "Kanály",
"LabelChapters": "Kapitoly", "LabelChapters": "Kapitoly",
@ -258,6 +271,7 @@
"LabelExample": "Příklad", "LabelExample": "Příklad",
"LabelExplicit": "Explicitní", "LabelExplicit": "Explicitní",
"LabelFeedURL": "URL zdroje", "LabelFeedURL": "URL zdroje",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Soubor", "LabelFile": "Soubor",
"LabelFileBirthtime": "Čas vzniku souboru", "LabelFileBirthtime": "Čas vzniku souboru",
"LabelFileModified": "Soubor změněn", "LabelFileModified": "Soubor změněn",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Trvale smazat soubor", "LabelHardDeleteFile": "Trvale smazat soubor",
"LabelHasEbook": "Obsahuje elektronickou knihu", "LabelHasEbook": "Obsahuje elektronickou knihu",
"LabelHasSupplementaryEbook": "Obsahuje doplňkovou elektronickou knihu", "LabelHasSupplementaryEbook": "Obsahuje doplňkovou elektronickou knihu",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Hostitel", "LabelHost": "Hostitel",
"LabelHour": "Hodina", "LabelHour": "Hodina",
"LabelIcon": "Ikona", "LabelIcon": "Ikona",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Informace", "LabelLogLevelInfo": "Informace",
"LabelLogLevelWarn": "Varovat", "LabelLogLevelWarn": "Varovat",
"LabelLookForNewEpisodesAfterDate": "Hledat nové epizody po tomto datu", "LabelLookForNewEpisodesAfterDate": "Hledat nové epizody po tomto datu",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Přehrávač médií", "LabelMediaPlayer": "Přehrávač médií",
"LabelMediaType": "Typ média", "LabelMediaType": "Typ média",
"LabelMetadataOrderOfPrecedenceDescription": "1 je nejnižší priorita, 5 je nejvyšší priorita", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Poskytovatel metadat", "LabelMetadataProvider": "Poskytovatel metadat",
"LabelMetaTag": "Metaznačka", "LabelMetaTag": "Metaznačka",
"LabelMetaTags": "Metaznačky", "LabelMetaTags": "Metaznačky",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda", "LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda",
"LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky", "LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky",
"LabelUploaderDropFiles": "Odstranit soubory", "LabelUploaderDropFiles": "Odstranit soubory",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Použít stopu kapitoly", "LabelUseChapterTrack": "Použít stopu kapitoly",
"LabelUseFullTrack": "Použít celou stopu", "LabelUseFullTrack": "Použít celou stopu",
"LabelUser": "Uživatel", "LabelUser": "Uživatel",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "Rediger bruger {0}", "ButtonUserEdit": "Rediger bruger {0}",
"ButtonViewAll": "Vis Alle", "ButtonViewAll": "Vis Alle",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAdvanced": "Avanceret", "HeaderAdvanced": "Avanceret",
"HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger", "HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger",
"HeaderAudiobookTools": "Audiobog Filhåndteringsværktøjer", "HeaderAudiobookTools": "Audiobog Filhåndteringsværktøjer",
"HeaderAudioTracks": "Lydspor", "HeaderAudioTracks": "Lydspor",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Sikkerhedskopier", "HeaderBackups": "Sikkerhedskopier",
"HeaderChangePassword": "Skift Adgangskode", "HeaderChangePassword": "Skift Adgangskode",
"HeaderChapters": "Kapitler", "HeaderChapters": "Kapitler",
@ -131,8 +135,10 @@
"HeaderNewAccount": "Ny Konto", "HeaderNewAccount": "Ny Konto",
"HeaderNewLibrary": "Nyt Bibliotek", "HeaderNewLibrary": "Nyt Bibliotek",
"HeaderNotifications": "Meddelelser", "HeaderNotifications": "Meddelelser",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Åbn RSS Feed", "HeaderOpenRSSFeed": "Åbn RSS Feed",
"HeaderOtherFiles": "Andre Filer", "HeaderOtherFiles": "Andre Filer",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Tilladelser", "HeaderPermissions": "Tilladelser",
"HeaderPlayerQueue": "Afspilningskø", "HeaderPlayerQueue": "Afspilningskø",
"HeaderPlaylist": "Afspilningsliste", "HeaderPlaylist": "Afspilningsliste",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)", "LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
"LabelAuthors": "Forfattere", "LabelAuthors": "Forfattere",
"LabelAutoDownloadEpisodes": "Auto Download Episoder", "LabelAutoDownloadEpisodes": "Auto Download Episoder",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Tilbage til Bruger", "LabelBackToUser": "Tilbage til Bruger",
"LabelBackupLocation": "Backup Placering", "LabelBackupLocation": "Backup Placering",
"LabelBackupsEnableAutomaticBackups": "Aktivér automatisk sikkerhedskopiering", "LabelBackupsEnableAutomaticBackups": "Aktivér automatisk sikkerhedskopiering",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.", "LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.",
"LabelBitrate": "Bitrate", "LabelBitrate": "Bitrate",
"LabelBooks": "Bøger", "LabelBooks": "Bøger",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Ændre Adgangskode", "LabelChangePassword": "Ændre Adgangskode",
"LabelChannels": "Kanaler", "LabelChannels": "Kanaler",
"LabelChapters": "Kapitler", "LabelChapters": "Kapitler",
@ -258,6 +271,7 @@
"LabelExample": "Eksempel", "LabelExample": "Eksempel",
"LabelExplicit": "Eksplisit", "LabelExplicit": "Eksplisit",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Fil", "LabelFile": "Fil",
"LabelFileBirthtime": "Fødselstidspunkt for fil", "LabelFileBirthtime": "Fødselstidspunkt for fil",
"LabelFileModified": "Fil ændret", "LabelFileModified": "Fil ændret",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Permanent slet fil", "LabelHardDeleteFile": "Permanent slet fil",
"LabelHasEbook": "Har e-bog", "LabelHasEbook": "Har e-bog",
"LabelHasSupplementaryEbook": "Har supplerende e-bog", "LabelHasSupplementaryEbook": "Har supplerende e-bog",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Vært", "LabelHost": "Vært",
"LabelHour": "Time", "LabelHour": "Time",
"LabelIcon": "Ikon", "LabelIcon": "Ikon",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Information", "LabelLogLevelInfo": "Information",
"LabelLogLevelWarn": "Advarsel", "LabelLogLevelWarn": "Advarsel",
"LabelLookForNewEpisodesAfterDate": "Søg efter nye episoder efter denne dato", "LabelLookForNewEpisodesAfterDate": "Søg efter nye episoder efter denne dato",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Medieafspiller", "LabelMediaPlayer": "Medieafspiller",
"LabelMediaType": "Medietype", "LabelMediaType": "Medietype",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Metadataudbyder", "LabelMetadataProvider": "Metadataudbyder",
"LabelMetaTag": "Meta-tag", "LabelMetaTag": "Meta-tag",
"LabelMetaTags": "Meta-tags", "LabelMetaTags": "Meta-tags",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Tillad overskrivning af eksisterende detaljer for de valgte bøger, når der findes en match", "LabelUpdateDetailsHelp": "Tillad overskrivning af eksisterende detaljer for de valgte bøger, når der findes en match",
"LabelUploaderDragAndDrop": "Træk og slip filer eller mapper", "LabelUploaderDragAndDrop": "Træk og slip filer eller mapper",
"LabelUploaderDropFiles": "Smid filer", "LabelUploaderDropFiles": "Smid filer",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Brug kapitel-spor", "LabelUseChapterTrack": "Brug kapitel-spor",
"LabelUseFullTrack": "Brug fuldt spor", "LabelUseFullTrack": "Brug fuldt spor",
"LabelUser": "Bruger", "LabelUser": "Bruger",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "Benutzer {0} bearbeiten", "ButtonUserEdit": "Benutzer {0} bearbeiten",
"ButtonViewAll": "Alles anzeigen", "ButtonViewAll": "Alles anzeigen",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAdvanced": "Erweitert", "HeaderAdvanced": "Erweitert",
"HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen", "HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen",
"HeaderAudiobookTools": "Hörbuch-Dateiverwaltungstools", "HeaderAudiobookTools": "Hörbuch-Dateiverwaltungstools",
"HeaderAudioTracks": "Audiodateien", "HeaderAudioTracks": "Audiodateien",
"HeaderAuthentication": "Authentifizierung",
"HeaderBackups": "Sicherungen", "HeaderBackups": "Sicherungen",
"HeaderChangePassword": "Passwort ändern", "HeaderChangePassword": "Passwort ändern",
"HeaderChapters": "Kapitel", "HeaderChapters": "Kapitel",
@ -131,8 +135,10 @@
"HeaderNewAccount": "Neues Konto", "HeaderNewAccount": "Neues Konto",
"HeaderNewLibrary": "Neue Bibliothek", "HeaderNewLibrary": "Neue Bibliothek",
"HeaderNotifications": "Benachrichtigungen", "HeaderNotifications": "Benachrichtigungen",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentifizierung",
"HeaderOpenRSSFeed": "RSS-Feed öffnen", "HeaderOpenRSSFeed": "RSS-Feed öffnen",
"HeaderOtherFiles": "Sonstige Dateien", "HeaderOtherFiles": "Sonstige Dateien",
"HeaderPasswordAuthentication": "Password Authentifizierung",
"HeaderPermissions": "Berechtigungen", "HeaderPermissions": "Berechtigungen",
"HeaderPlayerQueue": "Spieler Warteschlange", "HeaderPlayerQueue": "Spieler Warteschlange",
"HeaderPlaylist": "Wiedergabeliste", "HeaderPlaylist": "Wiedergabeliste",
@ -181,11 +187,11 @@
"LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu", "LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
"LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen", "LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
"LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu", "LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
"LabelAdminUsersOnly": "Admin users only", "LabelAdminUsersOnly": "Nur Admin Benutzer",
"LabelAll": "Alle", "LabelAll": "Alle",
"LabelAllUsers": "Alle Benutzer", "LabelAllUsers": "Alle Benutzer",
"LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersExcludingGuests": "Alle Benutzer außer Gästen",
"LabelAllUsersIncludingGuests": "All users including guests", "LabelAllUsersIncludingGuests": "All Benutzer und Gäste",
"LabelAlreadyInYourLibrary": "In der Bibliothek vorhanden", "LabelAlreadyInYourLibrary": "In der Bibliothek vorhanden",
"LabelAppend": "Anhängen", "LabelAppend": "Anhängen",
"LabelAuthor": "Autor", "LabelAuthor": "Autor",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Autor (Nachname, Vorname)", "LabelAuthorLastFirst": "Autor (Nachname, Vorname)",
"LabelAuthors": "Autoren", "LabelAuthors": "Autoren",
"LabelAutoDownloadEpisodes": "Episoden automatisch herunterladen", "LabelAutoDownloadEpisodes": "Episoden automatisch herunterladen",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Automatischer Start",
"LabelAutoLaunchDescription": "Automatische Weiterleitung zum Authentifizierungsanbieter beim Navigieren zur Anmeldeseite (manueller Überschreibungspfad <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Automatische Registrierung",
"LabelAutoRegisterDescription": "Automatische neue Neutzer anlegen nach dem Einloggen",
"LabelBackToUser": "Zurück zum Benutzer", "LabelBackToUser": "Zurück zum Benutzer",
"LabelBackupLocation": "Backup-Ort", "LabelBackupLocation": "Backup-Ort",
"LabelBackupsEnableAutomaticBackups": "Automatische Sicherung aktivieren", "LabelBackupsEnableAutomaticBackups": "Automatische Sicherung aktivieren",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn Sie bereits mehrere Sicherungen als die definierte max. Anzahl haben, sollten Sie diese manuell entfernen.", "LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn Sie bereits mehrere Sicherungen als die definierte max. Anzahl haben, sollten Sie diese manuell entfernen.",
"LabelBitrate": "Bitrate", "LabelBitrate": "Bitrate",
"LabelBooks": "Bücher", "LabelBooks": "Bücher",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Passwort ändern", "LabelChangePassword": "Passwort ändern",
"LabelChannels": "Kanäle", "LabelChannels": "Kanäle",
"LabelChapters": "Kapitel", "LabelChapters": "Kapitel",
@ -232,7 +245,7 @@
"LabelDeselectAll": "Alles abwählen", "LabelDeselectAll": "Alles abwählen",
"LabelDevice": "Gerät", "LabelDevice": "Gerät",
"LabelDeviceInfo": "Geräteinformationen", "LabelDeviceInfo": "Geräteinformationen",
"LabelDeviceIsAvailableTo": "Device is available to...", "LabelDeviceIsAvailableTo": "Dem Geärt ist es möglich zu ...",
"LabelDirectory": "Verzeichnis", "LabelDirectory": "Verzeichnis",
"LabelDiscFromFilename": "CD aus dem Dateinamen", "LabelDiscFromFilename": "CD aus dem Dateinamen",
"LabelDiscFromMetadata": "CD aus den Metadaten", "LabelDiscFromMetadata": "CD aus den Metadaten",
@ -258,6 +271,7 @@
"LabelExample": "Beispiel", "LabelExample": "Beispiel",
"LabelExplicit": "Explizit (Altersbeschränkung)", "LabelExplicit": "Explizit (Altersbeschränkung)",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Datei", "LabelFile": "Datei",
"LabelFileBirthtime": "Datei erstellt", "LabelFileBirthtime": "Datei erstellt",
"LabelFileModified": "Datei geändert", "LabelFileModified": "Datei geändert",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Datei dauerhaft löschen", "LabelHardDeleteFile": "Datei dauerhaft löschen",
"LabelHasEbook": "mit E-Book", "LabelHasEbook": "mit E-Book",
"LabelHasSupplementaryEbook": "mit zusätlichem E-Book", "LabelHasSupplementaryEbook": "mit zusätlichem E-Book",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Stunde", "LabelHour": "Stunde",
"LabelIcon": "Symbol", "LabelIcon": "Symbol",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Informationen", "LabelLogLevelInfo": "Informationen",
"LabelLogLevelWarn": "Warnungen", "LabelLogLevelWarn": "Warnungen",
"LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum", "LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Zuordnen existierender Benutzer mit",
"LabelMatchExistingUsersByDescription": "Wird zum Verbinden vorhandener Benutzer verwendet. Sobald die Verbindung hergestellt ist, wird den Benutzern eine eindeutige ID von Ihrem SSO-Anbieter zugeordnet",
"LabelMediaPlayer": "Mediaplayer", "LabelMediaPlayer": "Mediaplayer",
"LabelMediaType": "Medientyp", "LabelMediaType": "Medientyp",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Metadatenanbieter", "LabelMetadataProvider": "Metadatenanbieter",
"LabelMetaTag": "Meta Schlagwort", "LabelMetaTag": "Meta Schlagwort",
"LabelMetaTags": "Meta Tags", "LabelMetaTags": "Meta Tags",
@ -398,7 +416,7 @@
"LabelSeason": "Staffel", "LabelSeason": "Staffel",
"LabelSelectAllEpisodes": "Alle Episoden auswählen", "LabelSelectAllEpisodes": "Alle Episoden auswählen",
"LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt", "LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt",
"LabelSelectUsers": "Select users", "LabelSelectUsers": "Benutzer auswählen",
"LabelSendEbookToDevice": "E-Book senden an...", "LabelSendEbookToDevice": "E-Book senden an...",
"LabelSequence": "Reihenfolge", "LabelSequence": "Reihenfolge",
"LabelSeries": "Serien", "LabelSeries": "Serien",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird", "LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird",
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern", "LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
"LabelUploaderDropFiles": "Dateien löschen", "LabelUploaderDropFiles": "Dateien löschen",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Kapiteldatei verwenden", "LabelUseChapterTrack": "Kapiteldatei verwenden",
"LabelUseFullTrack": "Gesamte Datei verwenden", "LabelUseFullTrack": "Gesamte Datei verwenden",
"LabelUser": "Benutzer", "LabelUser": "Benutzer",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "Edit user {0}", "ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "View All", "ButtonViewAll": "View All",
"ButtonYes": "Yes", "ButtonYes": "Yes",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Account", "HeaderAccount": "Account",
"HeaderAdvanced": "Advanced", "HeaderAdvanced": "Advanced",
"HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAppriseNotificationSettings": "Apprise Notification Settings",
"HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudiobookTools": "Audiobook File Management Tools",
"HeaderAudioTracks": "Audio Tracks", "HeaderAudioTracks": "Audio Tracks",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Backups", "HeaderBackups": "Backups",
"HeaderChangePassword": "Change Password", "HeaderChangePassword": "Change Password",
"HeaderChapters": "Chapters", "HeaderChapters": "Chapters",
@ -131,8 +135,10 @@
"HeaderNewAccount": "New Account", "HeaderNewAccount": "New Account",
"HeaderNewLibrary": "New Library", "HeaderNewLibrary": "New Library",
"HeaderNotifications": "Notifications", "HeaderNotifications": "Notifications",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Open RSS Feed", "HeaderOpenRSSFeed": "Open RSS Feed",
"HeaderOtherFiles": "Other Files", "HeaderOtherFiles": "Other Files",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Permissions", "HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Player Queue", "HeaderPlayerQueue": "Player Queue",
"HeaderPlaylist": "Playlist", "HeaderPlaylist": "Playlist",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Last, First)",
"LabelAuthors": "Authors", "LabelAuthors": "Authors",
"LabelAutoDownloadEpisodes": "Auto Download Episodes", "LabelAutoDownloadEpisodes": "Auto Download Episodes",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Back to User", "LabelBackToUser": "Back to User",
"LabelBackupLocation": "Backup Location", "LabelBackupLocation": "Backup Location",
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups", "LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.", "LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
"LabelBitrate": "Bitrate", "LabelBitrate": "Bitrate",
"LabelBooks": "Books", "LabelBooks": "Books",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Change Password", "LabelChangePassword": "Change Password",
"LabelChannels": "Channels", "LabelChannels": "Channels",
"LabelChapters": "Chapters", "LabelChapters": "Chapters",
@ -258,6 +271,7 @@
"LabelExample": "Example", "LabelExample": "Example",
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "File", "LabelFile": "File",
"LabelFileBirthtime": "File Birthtime", "LabelFileBirthtime": "File Birthtime",
"LabelFileModified": "File Modified", "LabelFileModified": "File Modified",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Hard delete file", "LabelHardDeleteFile": "Hard delete file",
"LabelHasEbook": "Has ebook", "LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook", "LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Hour", "LabelHour": "Hour",
"LabelIcon": "Icon", "LabelIcon": "Icon",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Media Player", "LabelMediaPlayer": "Media Player",
"LabelMediaType": "Media Type", "LabelMediaType": "Media Type",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Metadata Provider", "LabelMetadataProvider": "Metadata Provider",
"LabelMetaTag": "Meta Tag", "LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags", "LabelMetaTags": "Meta Tags",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located", "LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
"LabelUploaderDragAndDrop": "Drag & drop files or folders", "LabelUploaderDragAndDrop": "Drag & drop files or folders",
"LabelUploaderDropFiles": "Drop files", "LabelUploaderDropFiles": "Drop files",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Use chapter track", "LabelUseChapterTrack": "Use chapter track",
"LabelUseFullTrack": "Use full track", "LabelUseFullTrack": "Use full track",
"LabelUser": "User", "LabelUser": "User",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "Editar Usuario {0}", "ButtonUserEdit": "Editar Usuario {0}",
"ButtonViewAll": "Ver Todos", "ButtonViewAll": "Ver Todos",
"ButtonYes": "Aceptar", "ButtonYes": "Aceptar",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Cuenta", "HeaderAccount": "Cuenta",
"HeaderAdvanced": "Avanzado", "HeaderAdvanced": "Avanzado",
"HeaderAppriseNotificationSettings": "Ajustes de Notificaciones de Apprise", "HeaderAppriseNotificationSettings": "Ajustes de Notificaciones de Apprise",
"HeaderAudiobookTools": "Herramientas de Gestión de Archivos de Audiolibro", "HeaderAudiobookTools": "Herramientas de Gestión de Archivos de Audiolibro",
"HeaderAudioTracks": "Pistas de Audio", "HeaderAudioTracks": "Pistas de Audio",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Respaldos", "HeaderBackups": "Respaldos",
"HeaderChangePassword": "Cambiar Contraseña", "HeaderChangePassword": "Cambiar Contraseña",
"HeaderChapters": "Capítulos", "HeaderChapters": "Capítulos",
@ -131,8 +135,10 @@
"HeaderNewAccount": "Nueva Cuenta", "HeaderNewAccount": "Nueva Cuenta",
"HeaderNewLibrary": "Nueva Biblioteca", "HeaderNewLibrary": "Nueva Biblioteca",
"HeaderNotifications": "Notificaciones", "HeaderNotifications": "Notificaciones",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Abrir fuente RSS", "HeaderOpenRSSFeed": "Abrir fuente RSS",
"HeaderOtherFiles": "Otros Archivos", "HeaderOtherFiles": "Otros Archivos",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Permisos", "HeaderPermissions": "Permisos",
"HeaderPlayerQueue": "Fila del Reproductor", "HeaderPlayerQueue": "Fila del Reproductor",
"HeaderPlaylist": "Lista de Reproducción", "HeaderPlaylist": "Lista de Reproducción",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Autor (Apellido, Nombre)", "LabelAuthorLastFirst": "Autor (Apellido, Nombre)",
"LabelAuthors": "Autores", "LabelAuthors": "Autores",
"LabelAutoDownloadEpisodes": "Descargar Episodios Automáticamente", "LabelAutoDownloadEpisodes": "Descargar Episodios Automáticamente",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Regresar a Usuario", "LabelBackToUser": "Regresar a Usuario",
"LabelBackupLocation": "Ubicación del Respaldo", "LabelBackupLocation": "Ubicación del Respaldo",
"LabelBackupsEnableAutomaticBackups": "Habilitar Respaldo Automático", "LabelBackupsEnableAutomaticBackups": "Habilitar Respaldo Automático",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "Solamente 1 respaldo se removerá a la vez. Si tiene mas respaldos guardados, debe removerlos manualmente.", "LabelBackupsNumberToKeepHelp": "Solamente 1 respaldo se removerá a la vez. Si tiene mas respaldos guardados, debe removerlos manualmente.",
"LabelBitrate": "Bitrate", "LabelBitrate": "Bitrate",
"LabelBooks": "Libros", "LabelBooks": "Libros",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Cambiar Contraseña", "LabelChangePassword": "Cambiar Contraseña",
"LabelChannels": "Canales", "LabelChannels": "Canales",
"LabelChapters": "Capítulos", "LabelChapters": "Capítulos",
@ -258,6 +271,7 @@
"LabelExample": "Ejemplo", "LabelExample": "Ejemplo",
"LabelExplicit": "Explicito", "LabelExplicit": "Explicito",
"LabelFeedURL": "Fuente de URL", "LabelFeedURL": "Fuente de URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Archivo", "LabelFile": "Archivo",
"LabelFileBirthtime": "Archivo Creado en", "LabelFileBirthtime": "Archivo Creado en",
"LabelFileModified": "Archivo modificado", "LabelFileModified": "Archivo modificado",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Eliminar Definitivamente", "LabelHardDeleteFile": "Eliminar Definitivamente",
"LabelHasEbook": "Tiene Ebook", "LabelHasEbook": "Tiene Ebook",
"LabelHasSupplementaryEbook": "Tiene Ebook Suplementario", "LabelHasSupplementaryEbook": "Tiene Ebook Suplementario",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Hora", "LabelHour": "Hora",
"LabelIcon": "Icono", "LabelIcon": "Icono",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Información", "LabelLogLevelInfo": "Información",
"LabelLogLevelWarn": "Advertencia", "LabelLogLevelWarn": "Advertencia",
"LabelLookForNewEpisodesAfterDate": "Buscar Nuevos Episodios a partir de esta Fecha", "LabelLookForNewEpisodesAfterDate": "Buscar Nuevos Episodios a partir de esta Fecha",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Reproductor de Medios", "LabelMediaPlayer": "Reproductor de Medios",
"LabelMediaType": "Tipo de Multimedia", "LabelMediaType": "Tipo de Multimedia",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Proveedor de Metadata", "LabelMetadataProvider": "Proveedor de Metadata",
"LabelMetaTag": "Meta Tag", "LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags", "LabelMetaTags": "Meta Tags",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados", "LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas", "LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
"LabelUploaderDropFiles": "Suelte los Archivos", "LabelUploaderDropFiles": "Suelte los Archivos",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Usar pista por capitulo", "LabelUseChapterTrack": "Usar pista por capitulo",
"LabelUseFullTrack": "Usar pista completa", "LabelUseFullTrack": "Usar pista completa",
"LabelUser": "Usuario", "LabelUser": "Usuario",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "Modifier lutilisateur {0}", "ButtonUserEdit": "Modifier lutilisateur {0}",
"ButtonViewAll": "Afficher tout", "ButtonViewAll": "Afficher tout",
"ButtonYes": "Oui", "ButtonYes": "Oui",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Compte", "HeaderAccount": "Compte",
"HeaderAdvanced": "Avancé", "HeaderAdvanced": "Avancé",
"HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise", "HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise",
"HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook", "HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook",
"HeaderAudioTracks": "Pistes audio", "HeaderAudioTracks": "Pistes audio",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Sauvegardes", "HeaderBackups": "Sauvegardes",
"HeaderChangePassword": "Modifier le mot de passe", "HeaderChangePassword": "Modifier le mot de passe",
"HeaderChapters": "Chapitres", "HeaderChapters": "Chapitres",
@ -131,8 +135,10 @@
"HeaderNewAccount": "Nouveau compte", "HeaderNewAccount": "Nouveau compte",
"HeaderNewLibrary": "Nouvelle bibliothèque", "HeaderNewLibrary": "Nouvelle bibliothèque",
"HeaderNotifications": "Notifications", "HeaderNotifications": "Notifications",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Ouvrir Flux RSS", "HeaderOpenRSSFeed": "Ouvrir Flux RSS",
"HeaderOtherFiles": "Autres fichiers", "HeaderOtherFiles": "Autres fichiers",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Permissions", "HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Liste découte", "HeaderPlayerQueue": "Liste découte",
"HeaderPlaylist": "Liste de lecture", "HeaderPlaylist": "Liste de lecture",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Auteur (Nom, Prénom)", "LabelAuthorLastFirst": "Auteur (Nom, Prénom)",
"LabelAuthors": "Auteurs", "LabelAuthors": "Auteurs",
"LabelAutoDownloadEpisodes": "Téléchargement automatique dépisode", "LabelAutoDownloadEpisodes": "Téléchargement automatique dépisode",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Revenir à lUtilisateur", "LabelBackToUser": "Revenir à lUtilisateur",
"LabelBackupLocation": "Backup Location", "LabelBackupLocation": "Backup Location",
"LabelBackupsEnableAutomaticBackups": "Activer les sauvegardes automatiques", "LabelBackupsEnableAutomaticBackups": "Activer les sauvegardes automatiques",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "Une seule sauvegarde sera effacée à la fois. Si vous avez plus de sauvegardes à effacer, vous devrez le faire manuellement.", "LabelBackupsNumberToKeepHelp": "Une seule sauvegarde sera effacée à la fois. Si vous avez plus de sauvegardes à effacer, vous devrez le faire manuellement.",
"LabelBitrate": "Bitrate", "LabelBitrate": "Bitrate",
"LabelBooks": "Livres", "LabelBooks": "Livres",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Modifier le mot de passe", "LabelChangePassword": "Modifier le mot de passe",
"LabelChannels": "Canaux", "LabelChannels": "Canaux",
"LabelChapters": "Chapitres", "LabelChapters": "Chapitres",
@ -258,6 +271,7 @@
"LabelExample": "Exemple", "LabelExample": "Exemple",
"LabelExplicit": "Restriction", "LabelExplicit": "Restriction",
"LabelFeedURL": "URL du flux", "LabelFeedURL": "URL du flux",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Fichier", "LabelFile": "Fichier",
"LabelFileBirthtime": "Création du fichier", "LabelFileBirthtime": "Création du fichier",
"LabelFileModified": "Modification du fichier", "LabelFileModified": "Modification du fichier",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Suppression du fichier", "LabelHardDeleteFile": "Suppression du fichier",
"LabelHasEbook": "Dispose dun livre numérique", "LabelHasEbook": "Dispose dun livre numérique",
"LabelHasSupplementaryEbook": "Dispose dun livre numérique supplémentaire", "LabelHasSupplementaryEbook": "Dispose dun livre numérique supplémentaire",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Hôte", "LabelHost": "Hôte",
"LabelHour": "Heure", "LabelHour": "Heure",
"LabelIcon": "Icone", "LabelIcon": "Icone",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date", "LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Lecteur multimédia", "LabelMediaPlayer": "Lecteur multimédia",
"LabelMediaType": "Type de média", "LabelMediaType": "Type de média",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Fournisseur de métadonnées", "LabelMetadataProvider": "Fournisseur de métadonnées",
"LabelMetaTag": "Etiquette de métadonnée", "LabelMetaTag": "Etiquette de métadonnée",
"LabelMetaTags": "Etiquettes de métadonnée", "LabelMetaTags": "Etiquettes de métadonnée",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée", "LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsquune correspondance est trouvée",
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers", "LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
"LabelUploaderDropFiles": "Déposer des fichiers", "LabelUploaderDropFiles": "Déposer des fichiers",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Utiliser la piste du chapitre", "LabelUseChapterTrack": "Utiliser la piste du chapitre",
"LabelUseFullTrack": "Utiliser la piste Complète", "LabelUseFullTrack": "Utiliser la piste Complète",
"LabelUser": "Utilisateur", "LabelUser": "Utilisateur",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો", "ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો",
"ButtonViewAll": "બધું જુઓ", "ButtonViewAll": "બધું જુઓ",
"ButtonYes": "હા", "ButtonYes": "હા",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "એકાઉન્ટ", "HeaderAccount": "એકાઉન્ટ",
"HeaderAdvanced": "અડ્વાન્સડ", "HeaderAdvanced": "અડ્વાન્સડ",
"HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ", "HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ",
"HeaderAudiobookTools": "ઓડિયોબુક ફાઇલ વ્યવસ્થાપન ટૂલ્સ", "HeaderAudiobookTools": "ઓડિયોબુક ફાઇલ વ્યવસ્થાપન ટૂલ્સ",
"HeaderAudioTracks": "ઓડિયો ટ્રેક્સ", "HeaderAudioTracks": "ઓડિયો ટ્રેક્સ",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "બેકઅપ્સ", "HeaderBackups": "બેકઅપ્સ",
"HeaderChangePassword": "પાસવર્ડ બદલો", "HeaderChangePassword": "પાસવર્ડ બદલો",
"HeaderChapters": "પ્રકરણો", "HeaderChapters": "પ્રકરણો",
@ -131,8 +135,10 @@
"HeaderNewAccount": "નવું એકાઉન્ટ", "HeaderNewAccount": "નવું એકાઉન્ટ",
"HeaderNewLibrary": "નવી પુસ્તકાલય", "HeaderNewLibrary": "નવી પુસ્તકાલય",
"HeaderNotifications": "સૂચનાઓ", "HeaderNotifications": "સૂચનાઓ",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "RSS ફીડ ખોલો", "HeaderOpenRSSFeed": "RSS ફીડ ખોલો",
"HeaderOtherFiles": "અન્ય ફાઇલો", "HeaderOtherFiles": "અન્ય ફાઇલો",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "પરવાનગીઓ", "HeaderPermissions": "પરવાનગીઓ",
"HeaderPlayerQueue": "પ્લેયર કતાર", "HeaderPlayerQueue": "પ્લેયર કતાર",
"HeaderPlaylist": "પ્લેલિસ્ટ", "HeaderPlaylist": "પ્લેલિસ્ટ",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Last, First)",
"LabelAuthors": "Authors", "LabelAuthors": "Authors",
"LabelAutoDownloadEpisodes": "Auto Download Episodes", "LabelAutoDownloadEpisodes": "Auto Download Episodes",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Back to User", "LabelBackToUser": "Back to User",
"LabelBackupLocation": "Backup Location", "LabelBackupLocation": "Backup Location",
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups", "LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.", "LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
"LabelBitrate": "Bitrate", "LabelBitrate": "Bitrate",
"LabelBooks": "Books", "LabelBooks": "Books",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Change Password", "LabelChangePassword": "Change Password",
"LabelChannels": "Channels", "LabelChannels": "Channels",
"LabelChapters": "Chapters", "LabelChapters": "Chapters",
@ -258,6 +271,7 @@
"LabelExample": "Example", "LabelExample": "Example",
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "File", "LabelFile": "File",
"LabelFileBirthtime": "File Birthtime", "LabelFileBirthtime": "File Birthtime",
"LabelFileModified": "File Modified", "LabelFileModified": "File Modified",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Hard delete file", "LabelHardDeleteFile": "Hard delete file",
"LabelHasEbook": "Has ebook", "LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook", "LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Hour", "LabelHour": "Hour",
"LabelIcon": "Icon", "LabelIcon": "Icon",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Media Player", "LabelMediaPlayer": "Media Player",
"LabelMediaType": "Media Type", "LabelMediaType": "Media Type",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Metadata Provider", "LabelMetadataProvider": "Metadata Provider",
"LabelMetaTag": "Meta Tag", "LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags", "LabelMetaTags": "Meta Tags",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located", "LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
"LabelUploaderDragAndDrop": "Drag & drop files or folders", "LabelUploaderDragAndDrop": "Drag & drop files or folders",
"LabelUploaderDropFiles": "Drop files", "LabelUploaderDropFiles": "Drop files",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Use chapter track", "LabelUseChapterTrack": "Use chapter track",
"LabelUseFullTrack": "Use full track", "LabelUseFullTrack": "Use full track",
"LabelUser": "User", "LabelUser": "User",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "उपयोगकर्ता {0} को संपादित करें", "ButtonUserEdit": "उपयोगकर्ता {0} को संपादित करें",
"ButtonViewAll": "सभी को देखें", "ButtonViewAll": "सभी को देखें",
"ButtonYes": "हाँ", "ButtonYes": "हाँ",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "खाता", "HeaderAccount": "खाता",
"HeaderAdvanced": "विकसित", "HeaderAdvanced": "विकसित",
"HeaderAppriseNotificationSettings": "Apprise अधिसूचना सेटिंग्स", "HeaderAppriseNotificationSettings": "Apprise अधिसूचना सेटिंग्स",
"HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudiobookTools": "Audiobook File Management Tools",
"HeaderAudioTracks": "Audio Tracks", "HeaderAudioTracks": "Audio Tracks",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Backups", "HeaderBackups": "Backups",
"HeaderChangePassword": "Change Password", "HeaderChangePassword": "Change Password",
"HeaderChapters": "Chapters", "HeaderChapters": "Chapters",
@ -131,8 +135,10 @@
"HeaderNewAccount": "New Account", "HeaderNewAccount": "New Account",
"HeaderNewLibrary": "New Library", "HeaderNewLibrary": "New Library",
"HeaderNotifications": "Notifications", "HeaderNotifications": "Notifications",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Open RSS Feed", "HeaderOpenRSSFeed": "Open RSS Feed",
"HeaderOtherFiles": "Other Files", "HeaderOtherFiles": "Other Files",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Permissions", "HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Player Queue", "HeaderPlayerQueue": "Player Queue",
"HeaderPlaylist": "Playlist", "HeaderPlaylist": "Playlist",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Last, First)",
"LabelAuthors": "Authors", "LabelAuthors": "Authors",
"LabelAutoDownloadEpisodes": "Auto Download Episodes", "LabelAutoDownloadEpisodes": "Auto Download Episodes",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Back to User", "LabelBackToUser": "Back to User",
"LabelBackupLocation": "Backup Location", "LabelBackupLocation": "Backup Location",
"LabelBackupsEnableAutomaticBackups": "Enable automatic backups", "LabelBackupsEnableAutomaticBackups": "Enable automatic backups",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.", "LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.",
"LabelBitrate": "Bitrate", "LabelBitrate": "Bitrate",
"LabelBooks": "Books", "LabelBooks": "Books",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Change Password", "LabelChangePassword": "Change Password",
"LabelChannels": "Channels", "LabelChannels": "Channels",
"LabelChapters": "Chapters", "LabelChapters": "Chapters",
@ -258,6 +271,7 @@
"LabelExample": "Example", "LabelExample": "Example",
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "File", "LabelFile": "File",
"LabelFileBirthtime": "File Birthtime", "LabelFileBirthtime": "File Birthtime",
"LabelFileModified": "File Modified", "LabelFileModified": "File Modified",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Hard delete file", "LabelHardDeleteFile": "Hard delete file",
"LabelHasEbook": "Has ebook", "LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook", "LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Hour", "LabelHour": "Hour",
"LabelIcon": "Icon", "LabelIcon": "Icon",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Media Player", "LabelMediaPlayer": "Media Player",
"LabelMediaType": "Media Type", "LabelMediaType": "Media Type",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Metadata Provider", "LabelMetadataProvider": "Metadata Provider",
"LabelMetaTag": "Meta Tag", "LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags", "LabelMetaTags": "Meta Tags",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located", "LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
"LabelUploaderDragAndDrop": "Drag & drop files or folders", "LabelUploaderDragAndDrop": "Drag & drop files or folders",
"LabelUploaderDropFiles": "Drop files", "LabelUploaderDropFiles": "Drop files",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Use chapter track", "LabelUseChapterTrack": "Use chapter track",
"LabelUseFullTrack": "Use full track", "LabelUseFullTrack": "Use full track",
"LabelUser": "User", "LabelUser": "User",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "Edit user {0}", "ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "Prikaži sve", "ButtonViewAll": "Prikaži sve",
"ButtonYes": "Da", "ButtonYes": "Da",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Korisnički račun", "HeaderAccount": "Korisnički račun",
"HeaderAdvanced": "Napredno", "HeaderAdvanced": "Napredno",
"HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAppriseNotificationSettings": "Apprise Notification Settings",
"HeaderAudiobookTools": "Audiobook File Management alati", "HeaderAudiobookTools": "Audiobook File Management alati",
"HeaderAudioTracks": "Audio Tracks", "HeaderAudioTracks": "Audio Tracks",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Backups", "HeaderBackups": "Backups",
"HeaderChangePassword": "Promijeni lozinku", "HeaderChangePassword": "Promijeni lozinku",
"HeaderChapters": "Poglavlja", "HeaderChapters": "Poglavlja",
@ -131,8 +135,10 @@
"HeaderNewAccount": "Novi korisnički račun", "HeaderNewAccount": "Novi korisnički račun",
"HeaderNewLibrary": "Nova biblioteka", "HeaderNewLibrary": "Nova biblioteka",
"HeaderNotifications": "Obavijesti", "HeaderNotifications": "Obavijesti",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Otvori RSS Feed", "HeaderOpenRSSFeed": "Otvori RSS Feed",
"HeaderOtherFiles": "Druge datoteke", "HeaderOtherFiles": "Druge datoteke",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Dozvole", "HeaderPermissions": "Dozvole",
"HeaderPlayerQueue": "Player Queue", "HeaderPlayerQueue": "Player Queue",
"HeaderPlaylist": "Playlist", "HeaderPlaylist": "Playlist",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthorLastFirst": "Author (Last, First)",
"LabelAuthors": "Autori", "LabelAuthors": "Autori",
"LabelAutoDownloadEpisodes": "Automatski preuzmi epizode", "LabelAutoDownloadEpisodes": "Automatski preuzmi epizode",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Nazad k korisniku", "LabelBackToUser": "Nazad k korisniku",
"LabelBackupLocation": "Backup Location", "LabelBackupLocation": "Backup Location",
"LabelBackupsEnableAutomaticBackups": "Uključi automatski backup", "LabelBackupsEnableAutomaticBackups": "Uključi automatski backup",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "Samo 1 backup će biti odjednom obrisan. Ako koristite više njih, morati ćete ih ručno ukloniti.", "LabelBackupsNumberToKeepHelp": "Samo 1 backup će biti odjednom obrisan. Ako koristite više njih, morati ćete ih ručno ukloniti.",
"LabelBitrate": "Bitrate", "LabelBitrate": "Bitrate",
"LabelBooks": "Knjige", "LabelBooks": "Knjige",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Promijeni lozinku", "LabelChangePassword": "Promijeni lozinku",
"LabelChannels": "Channels", "LabelChannels": "Channels",
"LabelChapters": "Chapters", "LabelChapters": "Chapters",
@ -258,6 +271,7 @@
"LabelExample": "Example", "LabelExample": "Example",
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Datoteka", "LabelFile": "Datoteka",
"LabelFileBirthtime": "File Birthtime", "LabelFileBirthtime": "File Birthtime",
"LabelFileModified": "File Modified", "LabelFileModified": "File Modified",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Obriši datoteku zauvijek", "LabelHardDeleteFile": "Obriši datoteku zauvijek",
"LabelHasEbook": "Has ebook", "LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook", "LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Sat", "LabelHour": "Sat",
"LabelIcon": "Ikona", "LabelIcon": "Ikona",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma", "LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Media Player", "LabelMediaPlayer": "Media Player",
"LabelMediaType": "Media Type", "LabelMediaType": "Media Type",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Poslužitelj metapodataka ", "LabelMetadataProvider": "Poslužitelj metapodataka ",
"LabelMetaTag": "Meta Tag", "LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags", "LabelMetaTags": "Meta Tags",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Dozvoli postavljanje novih detalja za odabrane knjige nakon što je match pronađen", "LabelUpdateDetailsHelp": "Dozvoli postavljanje novih detalja za odabrane knjige nakon što je match pronađen",
"LabelUploaderDragAndDrop": "Drag & Drop datoteke ili foldere", "LabelUploaderDragAndDrop": "Drag & Drop datoteke ili foldere",
"LabelUploaderDropFiles": "Ubaci datoteke", "LabelUploaderDropFiles": "Ubaci datoteke",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Koristi poglavlja track", "LabelUseChapterTrack": "Koristi poglavlja track",
"LabelUseFullTrack": "Koristi cijeli track", "LabelUseFullTrack": "Koristi cijeli track",
"LabelUser": "Korisnik", "LabelUser": "Korisnik",

View File

@ -1,10 +1,10 @@
{ {
"ButtonAdd": "Aggiungi", "ButtonAdd": "Aggiungi",
"ButtonAddChapters": "Aggiungi Capitoli", "ButtonAddChapters": "Aggiungi Capitoli",
"ButtonAddDevice": "Add Device", "ButtonAddDevice": "Aggiungi Dispositivo",
"ButtonAddLibrary": "Add Library", "ButtonAddLibrary": "Aggiungi Libreria",
"ButtonAddPodcasts": "Aggiungi Podcast", "ButtonAddPodcasts": "Aggiungi Podcast",
"ButtonAddUser": "Add User", "ButtonAddUser": "Aggiungi User",
"ButtonAddYourFirstLibrary": "Aggiungi la tua prima libreria", "ButtonAddYourFirstLibrary": "Aggiungi la tua prima libreria",
"ButtonApply": "Applica", "ButtonApply": "Applica",
"ButtonApplyChapters": "Applica", "ButtonApplyChapters": "Applica",
@ -62,7 +62,7 @@
"ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla", "ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla",
"ButtonReScan": "Ri-scansiona", "ButtonReScan": "Ri-scansiona",
"ButtonReset": "Reset", "ButtonReset": "Reset",
"ButtonResetToDefault": "Reset to default", "ButtonResetToDefault": "Ripristino di default",
"ButtonRestore": "Ripristina", "ButtonRestore": "Ripristina",
"ButtonSave": "Salva", "ButtonSave": "Salva",
"ButtonSaveAndClose": "Salva & Chiudi", "ButtonSaveAndClose": "Salva & Chiudi",
@ -75,7 +75,7 @@
"ButtonSetChaptersFromTracks": "Impostare i capitoli dalle tracce", "ButtonSetChaptersFromTracks": "Impostare i capitoli dalle tracce",
"ButtonShiftTimes": "Ricerca veloce", "ButtonShiftTimes": "Ricerca veloce",
"ButtonShow": "Mostra", "ButtonShow": "Mostra",
"ButtonStartM4BEncode": "Inizia L'Encoda del M4B", "ButtonStartM4BEncode": "Inizia L'Encode del M4B",
"ButtonStartMetadataEmbed": "Inizia Incorporo Metadata", "ButtonStartMetadataEmbed": "Inizia Incorporo Metadata",
"ButtonSubmit": "Invia", "ButtonSubmit": "Invia",
"ButtonTest": "Test", "ButtonTest": "Test",
@ -87,11 +87,15 @@
"ButtonUserEdit": "Modifica Utente {0}", "ButtonUserEdit": "Modifica Utente {0}",
"ButtonViewAll": "Mostra Tutto", "ButtonViewAll": "Mostra Tutto",
"ButtonYes": "Si", "ButtonYes": "Si",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Account", "HeaderAccount": "Account",
"HeaderAdvanced": "Avanzate", "HeaderAdvanced": "Avanzate",
"HeaderAppriseNotificationSettings": "Apprendi le impostazioni di Notifica", "HeaderAppriseNotificationSettings": "Apprendi le impostazioni di Notifica",
"HeaderAudiobookTools": "Utilità Audiobook File Management", "HeaderAudiobookTools": "Utilità Audiobook File Management",
"HeaderAudioTracks": "Tracce Audio", "HeaderAudioTracks": "Tracce Audio",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Backup", "HeaderBackups": "Backup",
"HeaderChangePassword": "Cambia Password", "HeaderChangePassword": "Cambia Password",
"HeaderChapters": "Capitoli", "HeaderChapters": "Capitoli",
@ -102,7 +106,7 @@
"HeaderCurrentDownloads": "Download Correnti", "HeaderCurrentDownloads": "Download Correnti",
"HeaderDetails": "Dettagli", "HeaderDetails": "Dettagli",
"HeaderDownloadQueue": "Download Queue", "HeaderDownloadQueue": "Download Queue",
"HeaderEbookFiles": "Ebook Files", "HeaderEbookFiles": "Ebook File",
"HeaderEmail": "Email", "HeaderEmail": "Email",
"HeaderEmailSettings": "Email Settings", "HeaderEmailSettings": "Email Settings",
"HeaderEpisodes": "Episodi", "HeaderEpisodes": "Episodi",
@ -131,8 +135,10 @@
"HeaderNewAccount": "Nuovo Account", "HeaderNewAccount": "Nuovo Account",
"HeaderNewLibrary": "Nuova Libreria", "HeaderNewLibrary": "Nuova Libreria",
"HeaderNotifications": "Notifiche", "HeaderNotifications": "Notifiche",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Apri RSS Feed", "HeaderOpenRSSFeed": "Apri RSS Feed",
"HeaderOtherFiles": "Altri File", "HeaderOtherFiles": "Altri File",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Permessi", "HeaderPermissions": "Permessi",
"HeaderPlayerQueue": "Coda Riproduzione", "HeaderPlayerQueue": "Coda Riproduzione",
"HeaderPlaylist": "Playlist", "HeaderPlaylist": "Playlist",
@ -161,7 +167,7 @@
"HeaderStatsRecentSessions": "Sessioni Recenti", "HeaderStatsRecentSessions": "Sessioni Recenti",
"HeaderStatsTop10Authors": "Top 10 Autori", "HeaderStatsTop10Authors": "Top 10 Autori",
"HeaderStatsTop5Genres": "Top 5 Generi", "HeaderStatsTop5Genres": "Top 5 Generi",
"HeaderTableOfContents": "Tabellla dei Contenuti", "HeaderTableOfContents": "Tabella dei Contenuti",
"HeaderTools": "Strumenti", "HeaderTools": "Strumenti",
"HeaderUpdateAccount": "Aggiorna Account", "HeaderUpdateAccount": "Aggiorna Account",
"HeaderUpdateAuthor": "Aggiorna Autore", "HeaderUpdateAuthor": "Aggiorna Autore",
@ -181,11 +187,11 @@
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta", "LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
"LabelAddToPlaylist": "aggiungi alla Playlist", "LabelAddToPlaylist": "aggiungi alla Playlist",
"LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist", "LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist",
"LabelAdminUsersOnly": "Admin users only", "LabelAdminUsersOnly": "Solo utenti Amministratori",
"LabelAll": "Tutti", "LabelAll": "Tutti",
"LabelAllUsers": "Tutti gli Utenti", "LabelAllUsers": "Tutti gli Utenti",
"LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersExcludingGuests": "Tutti gli Utenti Esclusi gli ospiti",
"LabelAllUsersIncludingGuests": "All users including guests", "LabelAllUsersIncludingGuests": "Tutti gli Utenti Inclusi gli ospiti",
"LabelAlreadyInYourLibrary": "Già esistente nella libreria", "LabelAlreadyInYourLibrary": "Già esistente nella libreria",
"LabelAppend": "Appese", "LabelAppend": "Appese",
"LabelAuthor": "Autore", "LabelAuthor": "Autore",
@ -193,8 +199,14 @@
"LabelAuthorLastFirst": "Autori (Per Cognome)", "LabelAuthorLastFirst": "Autori (Per Cognome)",
"LabelAuthors": "Autori", "LabelAuthors": "Autori",
"LabelAutoDownloadEpisodes": "Auto Download Episodi", "LabelAutoDownloadEpisodes": "Auto Download Episodi",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Torna a Utenti", "LabelBackToUser": "Torna a Utenti",
"LabelBackupLocation": "Backup Location", "LabelBackupLocation": "Percorso del Backup",
"LabelBackupsEnableAutomaticBackups": "Abilita backup Automatico", "LabelBackupsEnableAutomaticBackups": "Abilita backup Automatico",
"LabelBackupsEnableAutomaticBackupsHelp": "I Backup saranno salvati in /metadata/backups", "LabelBackupsEnableAutomaticBackupsHelp": "I Backup saranno salvati in /metadata/backups",
"LabelBackupsMaxBackupSize": "Dimensione massima backup (in GB)", "LabelBackupsMaxBackupSize": "Dimensione massima backup (in GB)",
@ -203,16 +215,17 @@
"LabelBackupsNumberToKeepHelp": "Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.", "LabelBackupsNumberToKeepHelp": "Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.",
"LabelBitrate": "Bitrate", "LabelBitrate": "Bitrate",
"LabelBooks": "Libri", "LabelBooks": "Libri",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Cambia Password", "LabelChangePassword": "Cambia Password",
"LabelChannels": "Canali", "LabelChannels": "Canali",
"LabelChapters": "Capitoli", "LabelChapters": "Capitoli",
"LabelChaptersFound": "Capitoli Trovati", "LabelChaptersFound": "Capitoli Trovati",
"LabelChapterTitle": "Titoli dei Capitoli", "LabelChapterTitle": "Titoli dei Capitoli",
"LabelClickForMoreInfo": "Click for more info", "LabelClickForMoreInfo": "Click per altre Info",
"LabelClosePlayer": "Chiudi player", "LabelClosePlayer": "Chiudi player",
"LabelCodec": "Codec", "LabelCodec": "Codec",
"LabelCollapseSeries": "Comprimi Serie", "LabelCollapseSeries": "Comprimi Serie",
"LabelCollection": "Collection", "LabelCollection": "Raccolta",
"LabelCollections": "Raccolte", "LabelCollections": "Raccolte",
"LabelComplete": "Completo", "LabelComplete": "Completo",
"LabelConfirmPassword": "Conferma Password", "LabelConfirmPassword": "Conferma Password",
@ -220,23 +233,23 @@
"LabelContinueReading": "Continua la Lettura", "LabelContinueReading": "Continua la Lettura",
"LabelContinueSeries": "Continua Serie", "LabelContinueSeries": "Continua Serie",
"LabelCover": "Cover", "LabelCover": "Cover",
"LabelCoverImageURL": "Cover Image URL", "LabelCoverImageURL": "Indirizzo della cover URL",
"LabelCreatedAt": "Creato A", "LabelCreatedAt": "Creato A",
"LabelCronExpression": "Espressione Cron", "LabelCronExpression": "Espressione Cron",
"LabelCurrent": "Attuale", "LabelCurrent": "Attuale",
"LabelCurrently": "Attualmente:", "LabelCurrently": "Attualmente:",
"LabelCustomCronExpression": "Custom Cron Expression:", "LabelCustomCronExpression": "Espressione Cron personalizzata:",
"LabelDatetime": "Data & Ora", "LabelDatetime": "Data & Ora",
"LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDeleteFromFileSystemCheckbox": "Elimina dal file system (togli la spunta per eliminarla solo dal DB)",
"LabelDescription": "Descrizione", "LabelDescription": "Descrizione",
"LabelDeselectAll": "Deseleziona Tutto", "LabelDeselectAll": "Deseleziona Tutto",
"LabelDevice": "Dispositivo", "LabelDevice": "Dispositivo",
"LabelDeviceInfo": "Info Dispositivo", "LabelDeviceInfo": "Info Dispositivo",
"LabelDeviceIsAvailableTo": "Device is available to...", "LabelDeviceIsAvailableTo": "Il dispositivo e disponibile su...",
"LabelDirectory": "Elenco", "LabelDirectory": "Elenco",
"LabelDiscFromFilename": "Disco dal nome file", "LabelDiscFromFilename": "Disco dal nome file",
"LabelDiscFromMetadata": "Disco dal Metadata", "LabelDiscFromMetadata": "Disco dal Metadata",
"LabelDiscover": "Discover", "LabelDiscover": "Scopri",
"LabelDownload": "Download", "LabelDownload": "Download",
"LabelDownloadNEpisodes": "Download {0} episodes", "LabelDownloadNEpisodes": "Download {0} episodes",
"LabelDuration": "Durata", "LabelDuration": "Durata",
@ -258,6 +271,7 @@
"LabelExample": "Esempio", "LabelExample": "Esempio",
"LabelExplicit": "Esplicito", "LabelExplicit": "Esplicito",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "File", "LabelFile": "File",
"LabelFileBirthtime": "Data Creazione", "LabelFileBirthtime": "Data Creazione",
"LabelFileModified": "Ultima modifica", "LabelFileModified": "Ultima modifica",
@ -275,10 +289,11 @@
"LabelHardDeleteFile": "Elimina Definitivamente", "LabelHardDeleteFile": "Elimina Definitivamente",
"LabelHasEbook": "Un ebook", "LabelHasEbook": "Un ebook",
"LabelHasSupplementaryEbook": "Un ebook Supplementare", "LabelHasSupplementaryEbook": "Un ebook Supplementare",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Ora", "LabelHour": "Ora",
"LabelIcon": "Icona", "LabelIcon": "Icona",
"LabelImageURLFromTheWeb": "Image URL from the web", "LabelImageURLFromTheWeb": "Immagine URL da internet",
"LabelIncludeInTracklist": "Includi nella Tracklist", "LabelIncludeInTracklist": "Includi nella Tracklist",
"LabelIncomplete": "Incompleta", "LabelIncomplete": "Incompleta",
"LabelInProgress": "In Corso", "LabelInProgress": "In Corso",
@ -303,22 +318,25 @@
"LabelLastUpdate": "Ultimo Aggiornamento", "LabelLastUpdate": "Ultimo Aggiornamento",
"LabelLayout": "Layout", "LabelLayout": "Layout",
"LabelLayoutSinglePage": "Pagina Singola", "LabelLayoutSinglePage": "Pagina Singola",
"LabelLayoutSplitPage": "DIvidi Pagina", "LabelLayoutSplitPage": "Dividi Pagina",
"LabelLess": "Poco", "LabelLess": "Poco",
"LabelLibrariesAccessibleToUser": "Librerie Accessibili agli Utenti", "LabelLibrariesAccessibleToUser": "Librerie Accessibili agli Utenti",
"LabelLibrary": "Libreria", "LabelLibrary": "Libreria",
"LabelLibraryItem": "Elementi della Library", "LabelLibraryItem": "Elementi della Library",
"LabelLibraryName": "Nome Libreria", "LabelLibraryName": "Nome Libreria",
"LabelLimit": "Limiti", "LabelLimit": "Limiti",
"LabelLineSpacing": "Line spacing", "LabelLineSpacing": "Interlinea",
"LabelListenAgain": "Ri-ascolta", "LabelListenAgain": "Ri-ascolta",
"LabelLogLevelDebug": "Debug", "LabelLogLevelDebug": "Debug",
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Allarme", "LabelLogLevelWarn": "Allarme",
"LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data", "LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Media Player", "LabelMediaPlayer": "Media Player",
"LabelMediaType": "Tipo Media", "LabelMediaType": "Tipo Media",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Metadata Provider", "LabelMetadataProvider": "Metadata Provider",
"LabelMetaTag": "Meta Tag", "LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags", "LabelMetaTags": "Meta Tags",
@ -398,7 +416,7 @@
"LabelSeason": "Stagione", "LabelSeason": "Stagione",
"LabelSelectAllEpisodes": "Seleziona tutti gli Episodi", "LabelSelectAllEpisodes": "Seleziona tutti gli Episodi",
"LabelSelectEpisodesShowing": "Episodi {0} selezionati ", "LabelSelectEpisodesShowing": "Episodi {0} selezionati ",
"LabelSelectUsers": "Select users", "LabelSelectUsers": "Selezione Utenti",
"LabelSendEbookToDevice": "Invia ebook a...", "LabelSendEbookToDevice": "Invia ebook a...",
"LabelSequence": "Sequenza", "LabelSequence": "Sequenza",
"LabelSeries": "Serie", "LabelSeries": "Serie",
@ -414,9 +432,9 @@
"LabelSettingsDisableWatcher": "Disattiva Watcher", "LabelSettingsDisableWatcher": "Disattiva Watcher",
"LabelSettingsDisableWatcherForLibrary": "Disattiva Watcher per le librerie", "LabelSettingsDisableWatcherForLibrary": "Disattiva Watcher per le librerie",
"LabelSettingsDisableWatcherHelp": "Disattiva il controllo automatico libri nelle cartelle delle librerie. *Richiede il Riavvio del Server", "LabelSettingsDisableWatcherHelp": "Disattiva il controllo automatico libri nelle cartelle delle librerie. *Richiede il Riavvio del Server",
"LabelSettingsEnableWatcher": "Enable Watcher", "LabelSettingsEnableWatcher": "Abilita Watcher",
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library", "LabelSettingsEnableWatcherForLibrary": "Abilita il controllo cartelle per la libreria",
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart", "LabelSettingsEnableWatcherHelp": "Abilita l'aggiunta/aggiornamento automatico degli elementi quando vengono rilevate modifiche ai file. *Richiede il riavvio del Server",
"LabelSettingsExperimentalFeatures": "Opzioni Sperimentali", "LabelSettingsExperimentalFeatures": "Opzioni Sperimentali",
"LabelSettingsExperimentalFeaturesHelp": "Funzionalità in fase di sviluppo che potrebbero utilizzare i tuoi feedback e aiutare i test. Fare clic per aprire la discussione github.", "LabelSettingsExperimentalFeaturesHelp": "Funzionalità in fase di sviluppo che potrebbero utilizzare i tuoi feedback e aiutare i test. Fare clic per aprire la discussione github.",
"LabelSettingsFindCovers": "Trova covers", "LabelSettingsFindCovers": "Trova covers",
@ -471,8 +489,8 @@
"LabelTagsNotAccessibleToUser": "Tags non accessibile agli Utenti", "LabelTagsNotAccessibleToUser": "Tags non accessibile agli Utenti",
"LabelTasks": "Processi in esecuzione", "LabelTasks": "Processi in esecuzione",
"LabelTheme": "Tema", "LabelTheme": "Tema",
"LabelThemeDark": "Dark", "LabelThemeDark": "Scuro",
"LabelThemeLight": "Light", "LabelThemeLight": "Chiaro",
"LabelTimeBase": "Time Base", "LabelTimeBase": "Time Base",
"LabelTimeListened": "Tempo di Ascolto", "LabelTimeListened": "Tempo di Ascolto",
"LabelTimeListenedToday": "Tempo di Ascolto Oggi", "LabelTimeListenedToday": "Tempo di Ascolto Oggi",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Consenti la sovrascrittura dei dettagli esistenti per i libri selezionati quando viene individuata una corrispondenza", "LabelUpdateDetailsHelp": "Consenti la sovrascrittura dei dettagli esistenti per i libri selezionati quando viene individuata una corrispondenza",
"LabelUploaderDragAndDrop": "Drag & drop file o Cartelle", "LabelUploaderDragAndDrop": "Drag & drop file o Cartelle",
"LabelUploaderDropFiles": "Elimina file", "LabelUploaderDropFiles": "Elimina file",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Usa il Capitolo della Traccia", "LabelUseChapterTrack": "Usa il Capitolo della Traccia",
"LabelUseFullTrack": "Usa la traccia totale", "LabelUseFullTrack": "Usa la traccia totale",
"LabelUser": "Utente", "LabelUser": "Utente",
@ -532,21 +551,21 @@
"MessageChapterErrorStartLtPrev": "L'ora di inizio non valida deve essere maggiore o uguale all'ora di inizio del capitolo precedente", "MessageChapterErrorStartLtPrev": "L'ora di inizio non valida deve essere maggiore o uguale all'ora di inizio del capitolo precedente",
"MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro", "MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro",
"MessageCheckingCron": "Controllo cron...", "MessageCheckingCron": "Controllo cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?", "MessageConfirmCloseFeed": "Sei sicuro di voler chiudere questo feed?",
"MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?", "MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?",
"MessageConfirmDeleteFile": "Questo eliminerà il file dal tuo file system. Sei sicuro?", "MessageConfirmDeleteFile": "Questo eliminerà il file dal tuo file system. Sei sicuro?",
"MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?", "MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?",
"MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", "MessageConfirmDeleteLibraryItem": " l'elemento della libreria dal database e dal file system. Sei sicuro?",
"MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteLibraryItems": "Ciò eliminerà {0} elementi della libreria dal database e dal file system. Sei sicuro?",
"MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?", "MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?",
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?", "MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
"MessageConfirmMarkAllEpisodesFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come finiti?", "MessageConfirmMarkAllEpisodesFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come finiti?",
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", "MessageConfirmMarkAllEpisodesNotFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come non completati?",
"MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?", "MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?",
"MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?", "MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?",
"MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmQuickEmbed": "Attenzione! L'incorporamento rapido non eseguirà il backup dei file audio. Assicurati di avere un backup dei tuoi file audio. <br><br>Vuoi Continuare?",
"MessageConfirmRemoveAllChapters": "Sei sicuro di voler rimuovere tutti i capitoli?", "MessageConfirmRemoveAllChapters": "Sei sicuro di voler rimuovere tutti i capitoli?",
"MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveAuthor": "Sei sicuro di voler rimuovere l'autore? \"{0}\"?",
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?", "MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?", "MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?", "MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
@ -558,7 +577,7 @@
"MessageConfirmRenameTag": "Sei sicuro di voler rinominare il tag \"{0}\" in \"{1}\" per tutti gli oggetti?", "MessageConfirmRenameTag": "Sei sicuro di voler rinominare il tag \"{0}\" in \"{1}\" per tutti gli oggetti?",
"MessageConfirmRenameTagMergeNote": "Nota: Questo tag esiste già e verrà unito nel vecchio.", "MessageConfirmRenameTagMergeNote": "Nota: Questo tag esiste già e verrà unito nel vecchio.",
"MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".", "MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".",
"MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmReScanLibraryItems": "Sei sicuro di voler ripetere la scansione? {0} oggetti?",
"MessageConfirmSendEbookToDevice": "Sei sicuro di voler inviare {0} ebook \"{1}\" al Device \"{2}\"?", "MessageConfirmSendEbookToDevice": "Sei sicuro di voler inviare {0} ebook \"{1}\" al Device \"{2}\"?",
"MessageDownloadingEpisode": "Download episodio in corso", "MessageDownloadingEpisode": "Download episodio in corso",
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto", "MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
@ -608,7 +627,7 @@
"MessageNoResults": "Nessun Risultato", "MessageNoResults": "Nessun Risultato",
"MessageNoSearchResultsFor": "Nessun risultato per \"{0}\"", "MessageNoSearchResultsFor": "Nessun risultato per \"{0}\"",
"MessageNoSeries": "Nessuna Serie", "MessageNoSeries": "Nessuna Serie",
"MessageNoTags": "No Tags", "MessageNoTags": "Nessun Tags",
"MessageNoTasksRunning": "Nessun processo in esecuzione", "MessageNoTasksRunning": "Nessun processo in esecuzione",
"MessageNotYetImplemented": "Non Ancora Implementato", "MessageNotYetImplemented": "Non Ancora Implementato",
"MessageNoUpdateNecessary": "Nessun aggiornamento necessario", "MessageNoUpdateNecessary": "Nessun aggiornamento necessario",
@ -637,7 +656,7 @@
"MessageUploaderItemSuccess": "Caricato con successo!", "MessageUploaderItemSuccess": "Caricato con successo!",
"MessageUploading": "Caricamento...", "MessageUploading": "Caricamento...",
"MessageValidCronExpression": "Espressione Cron Valida", "MessageValidCronExpression": "Espressione Cron Valida",
"MessageWatcherIsDisabledGlobally": "Watcher è disabilitato a livello globale nelle impostazioni del server", "MessageWatcherIsDisabledGlobally": "Controllo file automatico è disabilitato a livello globale nelle impostazioni del server",
"MessageXLibraryIsEmpty": "{0} libreria vuota!", "MessageXLibraryIsEmpty": "{0} libreria vuota!",
"MessageYourAudiobookDurationIsLonger": "La durata dell'audiolibro è più lunga della durata trovata", "MessageYourAudiobookDurationIsLonger": "La durata dell'audiolibro è più lunga della durata trovata",
"MessageYourAudiobookDurationIsShorter": "La durata dell'audiolibro è inferiore alla durata trovata", "MessageYourAudiobookDurationIsShorter": "La durata dell'audiolibro è inferiore alla durata trovata",
@ -651,7 +670,7 @@
"NoteUploaderOnlyAudioFiles": "Se carichi solo file audio, ogni file audio verrà gestito come un audiolibro separato.", "NoteUploaderOnlyAudioFiles": "Se carichi solo file audio, ogni file audio verrà gestito come un audiolibro separato.",
"NoteUploaderUnsupportedFiles": "I file non supportati vengono ignorati. Quando si sceglie o si elimina una cartella, gli altri file che non si trovano in una cartella di elementi vengono ignorati.", "NoteUploaderUnsupportedFiles": "I file non supportati vengono ignorati. Quando si sceglie o si elimina una cartella, gli altri file che non si trovano in una cartella di elementi vengono ignorati.",
"PlaceholderNewCollection": "Nome Nuova Raccolta", "PlaceholderNewCollection": "Nome Nuova Raccolta",
"PlaceholderNewFolderPath": "Nuovo percorso Cartella", "PlaceholderNewFolderPath": "Nuovo Percorso Cartella",
"PlaceholderNewPlaylist": "Nome nuova playlist", "PlaceholderNewPlaylist": "Nome nuova playlist",
"PlaceholderSearch": "Cerca..", "PlaceholderSearch": "Cerca..",
"PlaceholderSearchEpisode": "Cerca Episodio..", "PlaceholderSearchEpisode": "Cerca Episodio..",
@ -717,7 +736,7 @@
"ToastRSSFeedCloseSuccess": "RSS feed chiuso", "ToastRSSFeedCloseSuccess": "RSS feed chiuso",
"ToastSendEbookToDeviceFailed": "Impossibile inviare l'ebook al dispositivo", "ToastSendEbookToDeviceFailed": "Impossibile inviare l'ebook al dispositivo",
"ToastSendEbookToDeviceSuccess": "Ebook inviato al dispositivo \"{0}\"", "ToastSendEbookToDeviceSuccess": "Ebook inviato al dispositivo \"{0}\"",
"ToastSeriesUpdateFailed": "Aggiornaento Serie Fallito", "ToastSeriesUpdateFailed": "Aggiornamento Serie Fallito",
"ToastSeriesUpdateSuccess": "Serie Aggornate", "ToastSeriesUpdateSuccess": "Serie Aggornate",
"ToastSessionDeleteFailed": "Errore eliminazione sessione", "ToastSessionDeleteFailed": "Errore eliminazione sessione",
"ToastSessionDeleteSuccess": "Sessione cancellata", "ToastSessionDeleteSuccess": "Sessione cancellata",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "Redaguoti naudotoją {0}", "ButtonUserEdit": "Redaguoti naudotoją {0}",
"ButtonViewAll": "Peržiūrėti visus", "ButtonViewAll": "Peržiūrėti visus",
"ButtonYes": "Taip", "ButtonYes": "Taip",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Paskyra", "HeaderAccount": "Paskyra",
"HeaderAdvanced": "Papildomi", "HeaderAdvanced": "Papildomi",
"HeaderAppriseNotificationSettings": "Apprise pranešimo nustatymai", "HeaderAppriseNotificationSettings": "Apprise pranešimo nustatymai",
"HeaderAudiobookTools": "Audioknygų failų valdymo įrankiai", "HeaderAudiobookTools": "Audioknygų failų valdymo įrankiai",
"HeaderAudioTracks": "Garso takeliai", "HeaderAudioTracks": "Garso takeliai",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Atsarginės kopijos", "HeaderBackups": "Atsarginės kopijos",
"HeaderChangePassword": "Pakeisti slaptažodį", "HeaderChangePassword": "Pakeisti slaptažodį",
"HeaderChapters": "Skyriai", "HeaderChapters": "Skyriai",
@ -131,8 +135,10 @@
"HeaderNewAccount": "Nauja paskyra", "HeaderNewAccount": "Nauja paskyra",
"HeaderNewLibrary": "Nauja biblioteka", "HeaderNewLibrary": "Nauja biblioteka",
"HeaderNotifications": "Pranešimai", "HeaderNotifications": "Pranešimai",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Atidaryti RSS srautą", "HeaderOpenRSSFeed": "Atidaryti RSS srautą",
"HeaderOtherFiles": "Kiti failai", "HeaderOtherFiles": "Kiti failai",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Leidimai", "HeaderPermissions": "Leidimai",
"HeaderPlayerQueue": "Grotuvo eilė", "HeaderPlayerQueue": "Grotuvo eilė",
"HeaderPlaylist": "Grojaraštis", "HeaderPlaylist": "Grojaraštis",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Autorius (Pavardė, Vardas)", "LabelAuthorLastFirst": "Autorius (Pavardė, Vardas)",
"LabelAuthors": "Autoriai", "LabelAuthors": "Autoriai",
"LabelAutoDownloadEpisodes": "Automatiškai atsisiųsti epizodus", "LabelAutoDownloadEpisodes": "Automatiškai atsisiųsti epizodus",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Grįžti į naudotoją", "LabelBackToUser": "Grįžti į naudotoją",
"LabelBackupLocation": "Backup Location", "LabelBackupLocation": "Backup Location",
"LabelBackupsEnableAutomaticBackups": "Įjungti automatinį atsarginių kopijų kūrimą", "LabelBackupsEnableAutomaticBackups": "Įjungti automatinį atsarginių kopijų kūrimą",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "Tik viena atsarginė kopija bus pašalinta vienu metu, todėl jei jau turite daugiau atsarginių kopijų nei nurodyta, turite jas pašalinti rankiniu būdu.", "LabelBackupsNumberToKeepHelp": "Tik viena atsarginė kopija bus pašalinta vienu metu, todėl jei jau turite daugiau atsarginių kopijų nei nurodyta, turite jas pašalinti rankiniu būdu.",
"LabelBitrate": "Bitų sparta", "LabelBitrate": "Bitų sparta",
"LabelBooks": "Knygos", "LabelBooks": "Knygos",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Pakeisti slaptažodį", "LabelChangePassword": "Pakeisti slaptažodį",
"LabelChannels": "Kanalai", "LabelChannels": "Kanalai",
"LabelChapters": "Skyriai", "LabelChapters": "Skyriai",
@ -258,6 +271,7 @@
"LabelExample": "Pavyzdys", "LabelExample": "Pavyzdys",
"LabelExplicit": "Suaugusiems", "LabelExplicit": "Suaugusiems",
"LabelFeedURL": "Srauto URL", "LabelFeedURL": "Srauto URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Failas", "LabelFile": "Failas",
"LabelFileBirthtime": "Failo kūrimo laikas", "LabelFileBirthtime": "Failo kūrimo laikas",
"LabelFileModified": "Failo keitimo laikas", "LabelFileModified": "Failo keitimo laikas",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Galutinai ištrinti failą", "LabelHardDeleteFile": "Galutinai ištrinti failą",
"LabelHasEbook": "Turi e-knygą", "LabelHasEbook": "Turi e-knygą",
"LabelHasSupplementaryEbook": "Turi papildomą e-knygą", "LabelHasSupplementaryEbook": "Turi papildomą e-knygą",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Serveris", "LabelHost": "Serveris",
"LabelHour": "Valanda", "LabelHour": "Valanda",
"LabelIcon": "Piktograma", "LabelIcon": "Piktograma",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Ieškoti naujų epizodų po šios datos", "LabelLookForNewEpisodesAfterDate": "Ieškoti naujų epizodų po šios datos",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Grotuvas", "LabelMediaPlayer": "Grotuvas",
"LabelMediaType": "Medijos tipas", "LabelMediaType": "Medijos tipas",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Metaduomenų tiekėjas", "LabelMetadataProvider": "Metaduomenų tiekėjas",
"LabelMetaTag": "Meta žymė", "LabelMetaTag": "Meta žymė",
"LabelMetaTags": "Meta žymos", "LabelMetaTags": "Meta žymos",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Leisti perrašyti esamus duomenis pasirinktoms knygoms, kai yra rasta atitikmenų", "LabelUpdateDetailsHelp": "Leisti perrašyti esamus duomenis pasirinktoms knygoms, kai yra rasta atitikmenų",
"LabelUploaderDragAndDrop": "Tempkite ir paleiskite failus ar aplankus", "LabelUploaderDragAndDrop": "Tempkite ir paleiskite failus ar aplankus",
"LabelUploaderDropFiles": "Nutempti failus", "LabelUploaderDropFiles": "Nutempti failus",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Naudoti skyrių takelį", "LabelUseChapterTrack": "Naudoti skyrių takelį",
"LabelUseFullTrack": "Naudoti visą takelį", "LabelUseFullTrack": "Naudoti visą takelį",
"LabelUser": "Vartotojas", "LabelUser": "Vartotojas",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "Wijzig gebruiker {0}", "ButtonUserEdit": "Wijzig gebruiker {0}",
"ButtonViewAll": "Toon alle", "ButtonViewAll": "Toon alle",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Account", "HeaderAccount": "Account",
"HeaderAdvanced": "Geavanceerd", "HeaderAdvanced": "Geavanceerd",
"HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen", "HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
"HeaderAudiobookTools": "Audioboekbestandbeheer tools", "HeaderAudiobookTools": "Audioboekbestandbeheer tools",
"HeaderAudioTracks": "Audiotracks", "HeaderAudioTracks": "Audiotracks",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Back-ups", "HeaderBackups": "Back-ups",
"HeaderChangePassword": "Wachtwoord wijzigen", "HeaderChangePassword": "Wachtwoord wijzigen",
"HeaderChapters": "Hoofdstukken", "HeaderChapters": "Hoofdstukken",
@ -131,8 +135,10 @@
"HeaderNewAccount": "Nieuwe account", "HeaderNewAccount": "Nieuwe account",
"HeaderNewLibrary": "Nieuwe bibliotheek", "HeaderNewLibrary": "Nieuwe bibliotheek",
"HeaderNotifications": "Notificaties", "HeaderNotifications": "Notificaties",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Open RSS-feed", "HeaderOpenRSSFeed": "Open RSS-feed",
"HeaderOtherFiles": "Andere bestanden", "HeaderOtherFiles": "Andere bestanden",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Toestemmingen", "HeaderPermissions": "Toestemmingen",
"HeaderPlayerQueue": "Afspeelwachtrij", "HeaderPlayerQueue": "Afspeelwachtrij",
"HeaderPlaylist": "Afspeellijst", "HeaderPlaylist": "Afspeellijst",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Auteur (Achternaam, Voornaam)", "LabelAuthorLastFirst": "Auteur (Achternaam, Voornaam)",
"LabelAuthors": "Auteurs", "LabelAuthors": "Auteurs",
"LabelAutoDownloadEpisodes": "Afleveringen automatisch downloaden", "LabelAutoDownloadEpisodes": "Afleveringen automatisch downloaden",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Terug naar gebruiker", "LabelBackToUser": "Terug naar gebruiker",
"LabelBackupLocation": "Back-up locatie", "LabelBackupLocation": "Back-up locatie",
"LabelBackupsEnableAutomaticBackups": "Automatische back-ups inschakelen", "LabelBackupsEnableAutomaticBackups": "Automatische back-ups inschakelen",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "Er wordt slechts 1 back-up per keer verwijderd, dus als je reeds meer back-ups dan dit hebt moet je ze handmatig verwijderen.", "LabelBackupsNumberToKeepHelp": "Er wordt slechts 1 back-up per keer verwijderd, dus als je reeds meer back-ups dan dit hebt moet je ze handmatig verwijderen.",
"LabelBitrate": "Bitrate", "LabelBitrate": "Bitrate",
"LabelBooks": "Boeken", "LabelBooks": "Boeken",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Wachtwoord wijzigen", "LabelChangePassword": "Wachtwoord wijzigen",
"LabelChannels": "Kanalen", "LabelChannels": "Kanalen",
"LabelChapters": "Hoofdstukken", "LabelChapters": "Hoofdstukken",
@ -258,6 +271,7 @@
"LabelExample": "Voorbeeld", "LabelExample": "Voorbeeld",
"LabelExplicit": "Expliciet", "LabelExplicit": "Expliciet",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Bestand", "LabelFile": "Bestand",
"LabelFileBirthtime": "Aanmaaktijd bestand", "LabelFileBirthtime": "Aanmaaktijd bestand",
"LabelFileModified": "Bestand gewijzigd", "LabelFileModified": "Bestand gewijzigd",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Hard-delete bestand", "LabelHardDeleteFile": "Hard-delete bestand",
"LabelHasEbook": "Heeft ebook", "LabelHasEbook": "Heeft ebook",
"LabelHasSupplementaryEbook": "Heeft supplementair ebook", "LabelHasSupplementaryEbook": "Heeft supplementair ebook",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Uur", "LabelHour": "Uur",
"LabelIcon": "Icoon", "LabelIcon": "Icoon",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Waarschuwing", "LabelLogLevelWarn": "Waarschuwing",
"LabelLookForNewEpisodesAfterDate": "Zoek naar nieuwe afleveringen na deze datum", "LabelLookForNewEpisodesAfterDate": "Zoek naar nieuwe afleveringen na deze datum",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Mediaspeler", "LabelMediaPlayer": "Mediaspeler",
"LabelMediaType": "Mediatype", "LabelMediaType": "Mediatype",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Metadatabron", "LabelMetadataProvider": "Metadatabron",
"LabelMetaTag": "Meta-tag", "LabelMetaTag": "Meta-tag",
"LabelMetaTags": "Meta-tags", "LabelMetaTags": "Meta-tags",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden", "LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden",
"LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen", "LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen",
"LabelUploaderDropFiles": "Bestanden neerzetten", "LabelUploaderDropFiles": "Bestanden neerzetten",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Gebruik hoofdstuktrack", "LabelUseChapterTrack": "Gebruik hoofdstuktrack",
"LabelUseFullTrack": "Gebruik volledige track", "LabelUseFullTrack": "Gebruik volledige track",
"LabelUser": "Gebruiker", "LabelUser": "Gebruiker",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "Rediger bruker {0}", "ButtonUserEdit": "Rediger bruker {0}",
"ButtonViewAll": "Vis alt", "ButtonViewAll": "Vis alt",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAdvanced": "Avansert", "HeaderAdvanced": "Avansert",
"HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger", "HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger",
"HeaderAudiobookTools": "Lydbok Filbehandlingsverktøy", "HeaderAudiobookTools": "Lydbok Filbehandlingsverktøy",
"HeaderAudioTracks": "Lydspor", "HeaderAudioTracks": "Lydspor",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Sikkerhetskopier", "HeaderBackups": "Sikkerhetskopier",
"HeaderChangePassword": "Bytt passord", "HeaderChangePassword": "Bytt passord",
"HeaderChapters": "Kapittel", "HeaderChapters": "Kapittel",
@ -131,8 +135,10 @@
"HeaderNewAccount": "Ny konto", "HeaderNewAccount": "Ny konto",
"HeaderNewLibrary": "Ny bibliotek", "HeaderNewLibrary": "Ny bibliotek",
"HeaderNotifications": "Notifikasjoner", "HeaderNotifications": "Notifikasjoner",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Åpne RSS Feed", "HeaderOpenRSSFeed": "Åpne RSS Feed",
"HeaderOtherFiles": "Andre filer", "HeaderOtherFiles": "Andre filer",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Rettigheter", "HeaderPermissions": "Rettigheter",
"HeaderPlayerQueue": "Spiller kø", "HeaderPlayerQueue": "Spiller kø",
"HeaderPlaylist": "Spilleliste", "HeaderPlaylist": "Spilleliste",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)", "LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)",
"LabelAuthors": "Forfattere", "LabelAuthors": "Forfattere",
"LabelAutoDownloadEpisodes": "Last ned episoder automatisk", "LabelAutoDownloadEpisodes": "Last ned episoder automatisk",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Tilbake til bruker", "LabelBackToUser": "Tilbake til bruker",
"LabelBackupLocation": "Backup Location", "LabelBackupLocation": "Backup Location",
"LabelBackupsEnableAutomaticBackups": "Aktiver automatisk sikkerhetskopi", "LabelBackupsEnableAutomaticBackups": "Aktiver automatisk sikkerhetskopi",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhetskopi vil bli fjernet om gangen, hvis du allerede har flere sikkerhetskopier enn dette bør du fjerne de manuelt.", "LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhetskopi vil bli fjernet om gangen, hvis du allerede har flere sikkerhetskopier enn dette bør du fjerne de manuelt.",
"LabelBitrate": "Bithastighet", "LabelBitrate": "Bithastighet",
"LabelBooks": "Bøker", "LabelBooks": "Bøker",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Endre passord", "LabelChangePassword": "Endre passord",
"LabelChannels": "Kanaler", "LabelChannels": "Kanaler",
"LabelChapters": "Kapitler", "LabelChapters": "Kapitler",
@ -258,6 +271,7 @@
"LabelExample": "Eksempel", "LabelExample": "Eksempel",
"LabelExplicit": "Eksplisitt", "LabelExplicit": "Eksplisitt",
"LabelFeedURL": "Feed Adresse", "LabelFeedURL": "Feed Adresse",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Fil", "LabelFile": "Fil",
"LabelFileBirthtime": "Fil Opprettelsesdato", "LabelFileBirthtime": "Fil Opprettelsesdato",
"LabelFileModified": "Fil Endret", "LabelFileModified": "Fil Endret",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Tving sletting av fil", "LabelHardDeleteFile": "Tving sletting av fil",
"LabelHasEbook": "Har ebok", "LabelHasEbook": "Har ebok",
"LabelHasSupplementaryEbook": "Har supplerende ebok", "LabelHasSupplementaryEbook": "Har supplerende ebok",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Tjener", "LabelHost": "Tjener",
"LabelHour": "Time", "LabelHour": "Time",
"LabelIcon": "Ikon", "LabelIcon": "Ikon",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Se etter nye episoder etter denne datoen", "LabelLookForNewEpisodesAfterDate": "Se etter nye episoder etter denne datoen",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Mediespiller", "LabelMediaPlayer": "Mediespiller",
"LabelMediaType": "Medie type", "LabelMediaType": "Medie type",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Metadata Leverandør", "LabelMetadataProvider": "Metadata Leverandør",
"LabelMetaTag": "Meta Tag", "LabelMetaTag": "Meta Tag",
"LabelMetaTags": "Meta Tags", "LabelMetaTags": "Meta Tags",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Tillat overskriving av eksisterende detaljer for de valgte bøkene når en lik bok er funnet", "LabelUpdateDetailsHelp": "Tillat overskriving av eksisterende detaljer for de valgte bøkene når en lik bok er funnet",
"LabelUploaderDragAndDrop": "Dra og slipp filer eller mapper", "LabelUploaderDragAndDrop": "Dra og slipp filer eller mapper",
"LabelUploaderDropFiles": "Slipp filer", "LabelUploaderDropFiles": "Slipp filer",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Bruk kapittelspor", "LabelUseChapterTrack": "Bruk kapittelspor",
"LabelUseFullTrack": "Bruke hele sporet", "LabelUseFullTrack": "Bruke hele sporet",
"LabelUser": "Bruker", "LabelUser": "Bruker",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "Edit user {0}", "ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "Zobacz wszystko", "ButtonViewAll": "Zobacz wszystko",
"ButtonYes": "Tak", "ButtonYes": "Tak",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAdvanced": "Zaawansowane", "HeaderAdvanced": "Zaawansowane",
"HeaderAppriseNotificationSettings": "Ustawienia powiadomień Apprise", "HeaderAppriseNotificationSettings": "Ustawienia powiadomień Apprise",
"HeaderAudiobookTools": "Narzędzia do zarządzania audiobookami", "HeaderAudiobookTools": "Narzędzia do zarządzania audiobookami",
"HeaderAudioTracks": "Ścieżki audio", "HeaderAudioTracks": "Ścieżki audio",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Kopie zapasowe", "HeaderBackups": "Kopie zapasowe",
"HeaderChangePassword": "Zmień hasło", "HeaderChangePassword": "Zmień hasło",
"HeaderChapters": "Rozdziały", "HeaderChapters": "Rozdziały",
@ -131,8 +135,10 @@
"HeaderNewAccount": "Nowe konto", "HeaderNewAccount": "Nowe konto",
"HeaderNewLibrary": "Nowa biblioteka", "HeaderNewLibrary": "Nowa biblioteka",
"HeaderNotifications": "Powiadomienia", "HeaderNotifications": "Powiadomienia",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Utwórz kanał RSS", "HeaderOpenRSSFeed": "Utwórz kanał RSS",
"HeaderOtherFiles": "Inne pliki", "HeaderOtherFiles": "Inne pliki",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Uprawnienia", "HeaderPermissions": "Uprawnienia",
"HeaderPlayerQueue": "Player Queue", "HeaderPlayerQueue": "Player Queue",
"HeaderPlaylist": "Playlist", "HeaderPlaylist": "Playlist",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Author (Malejąco)", "LabelAuthorLastFirst": "Author (Malejąco)",
"LabelAuthors": "Autorzy", "LabelAuthors": "Autorzy",
"LabelAutoDownloadEpisodes": "Automatyczne pobieranie odcinków", "LabelAutoDownloadEpisodes": "Automatyczne pobieranie odcinków",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Powrót", "LabelBackToUser": "Powrót",
"LabelBackupLocation": "Backup Location", "LabelBackupLocation": "Backup Location",
"LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe", "LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "Tylko 1 kopia zapasowa zostanie usunięta, więc jeśli masz już więcej kopii zapasowych, powinieneś je ręcznie usunąć.", "LabelBackupsNumberToKeepHelp": "Tylko 1 kopia zapasowa zostanie usunięta, więc jeśli masz już więcej kopii zapasowych, powinieneś je ręcznie usunąć.",
"LabelBitrate": "Bitrate", "LabelBitrate": "Bitrate",
"LabelBooks": "Książki", "LabelBooks": "Książki",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Zmień hasło", "LabelChangePassword": "Zmień hasło",
"LabelChannels": "Channels", "LabelChannels": "Channels",
"LabelChapters": "Chapters", "LabelChapters": "Chapters",
@ -258,6 +271,7 @@
"LabelExample": "Example", "LabelExample": "Example",
"LabelExplicit": "Nieprzyzwoite", "LabelExplicit": "Nieprzyzwoite",
"LabelFeedURL": "URL kanału", "LabelFeedURL": "URL kanału",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Plik", "LabelFile": "Plik",
"LabelFileBirthtime": "Data utworzenia pliku", "LabelFileBirthtime": "Data utworzenia pliku",
"LabelFileModified": "Data modyfikacji pliku", "LabelFileModified": "Data modyfikacji pliku",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Usuń trwale plik", "LabelHardDeleteFile": "Usuń trwale plik",
"LabelHasEbook": "Has ebook", "LabelHasEbook": "Has ebook",
"LabelHasSupplementaryEbook": "Has supplementary ebook", "LabelHasSupplementaryEbook": "Has supplementary ebook",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Host", "LabelHost": "Host",
"LabelHour": "Godzina", "LabelHour": "Godzina",
"LabelIcon": "Ikona", "LabelIcon": "Ikona",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Informacja", "LabelLogLevelInfo": "Informacja",
"LabelLogLevelWarn": "Ostrzeżenie", "LabelLogLevelWarn": "Ostrzeżenie",
"LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie", "LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Odtwarzacz", "LabelMediaPlayer": "Odtwarzacz",
"LabelMediaType": "Typ mediów", "LabelMediaType": "Typ mediów",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Dostawca metadanych", "LabelMetadataProvider": "Dostawca metadanych",
"LabelMetaTag": "Tag", "LabelMetaTag": "Tag",
"LabelMetaTags": "Meta Tags", "LabelMetaTags": "Meta Tags",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Umożliwienie nadpisania istniejących szczegółów dla wybranych książek w przypadku znalezienia dopasowania", "LabelUpdateDetailsHelp": "Umożliwienie nadpisania istniejących szczegółów dla wybranych książek w przypadku znalezienia dopasowania",
"LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub pliki", "LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub pliki",
"LabelUploaderDropFiles": "Puść pliki", "LabelUploaderDropFiles": "Puść pliki",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Użyj ścieżki rozdziału", "LabelUseChapterTrack": "Użyj ścieżki rozdziału",
"LabelUseFullTrack": "Użycie ścieżki rozdziału", "LabelUseFullTrack": "Użycie ścieżki rozdziału",
"LabelUser": "Użytkownik", "LabelUser": "Użytkownik",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "Редактировать пользователя {0}", "ButtonUserEdit": "Редактировать пользователя {0}",
"ButtonViewAll": "Посмотреть все", "ButtonViewAll": "Посмотреть все",
"ButtonYes": "Да", "ButtonYes": "Да",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Учетная запись", "HeaderAccount": "Учетная запись",
"HeaderAdvanced": "Дополнительно", "HeaderAdvanced": "Дополнительно",
"HeaderAppriseNotificationSettings": "Настройки оповещений", "HeaderAppriseNotificationSettings": "Настройки оповещений",
"HeaderAudiobookTools": "Инструменты файлов аудиокниг", "HeaderAudiobookTools": "Инструменты файлов аудиокниг",
"HeaderAudioTracks": "Аудио треки", "HeaderAudioTracks": "Аудио треки",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Бэкапы", "HeaderBackups": "Бэкапы",
"HeaderChangePassword": "Изменить пароль", "HeaderChangePassword": "Изменить пароль",
"HeaderChapters": "Главы", "HeaderChapters": "Главы",
@ -131,8 +135,10 @@
"HeaderNewAccount": "Новая учетная запись", "HeaderNewAccount": "Новая учетная запись",
"HeaderNewLibrary": "Новая библиотека", "HeaderNewLibrary": "Новая библиотека",
"HeaderNotifications": "Уведомления", "HeaderNotifications": "Уведомления",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Открыть RSS-канал", "HeaderOpenRSSFeed": "Открыть RSS-канал",
"HeaderOtherFiles": "Другие файлы", "HeaderOtherFiles": "Другие файлы",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Разрешения", "HeaderPermissions": "Разрешения",
"HeaderPlayerQueue": "Очередь воспроизведения", "HeaderPlayerQueue": "Очередь воспроизведения",
"HeaderPlaylist": "Плейлист", "HeaderPlaylist": "Плейлист",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Автор (Фамилия, Имя)", "LabelAuthorLastFirst": "Автор (Фамилия, Имя)",
"LabelAuthors": "Авторы", "LabelAuthors": "Авторы",
"LabelAutoDownloadEpisodes": "Скачивать эпизоды автоматически", "LabelAutoDownloadEpisodes": "Скачивать эпизоды автоматически",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Назад к пользователю", "LabelBackToUser": "Назад к пользователю",
"LabelBackupLocation": "Backup Location", "LabelBackupLocation": "Backup Location",
"LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование", "LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.", "LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.",
"LabelBitrate": "Битрейт", "LabelBitrate": "Битрейт",
"LabelBooks": "Книги", "LabelBooks": "Книги",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Изменить пароль", "LabelChangePassword": "Изменить пароль",
"LabelChannels": "Каналы", "LabelChannels": "Каналы",
"LabelChapters": "Главы", "LabelChapters": "Главы",
@ -258,6 +271,7 @@
"LabelExample": "Пример", "LabelExample": "Пример",
"LabelExplicit": "Явный", "LabelExplicit": "Явный",
"LabelFeedURL": "URL канала", "LabelFeedURL": "URL канала",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Файл", "LabelFile": "Файл",
"LabelFileBirthtime": "Дата создания", "LabelFileBirthtime": "Дата создания",
"LabelFileModified": "Дата модификации", "LabelFileModified": "Дата модификации",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Жесткое удаление файла", "LabelHardDeleteFile": "Жесткое удаление файла",
"LabelHasEbook": "Есть e-книга", "LabelHasEbook": "Есть e-книга",
"LabelHasSupplementaryEbook": "Есть дополнительная e-книга", "LabelHasSupplementaryEbook": "Есть дополнительная e-книга",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Хост", "LabelHost": "Хост",
"LabelHour": "Часы", "LabelHour": "Часы",
"LabelIcon": "Иконка", "LabelIcon": "Иконка",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Info", "LabelLogLevelInfo": "Info",
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты", "LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Медиа проигрыватель", "LabelMediaPlayer": "Медиа проигрыватель",
"LabelMediaType": "Тип медиа", "LabelMediaType": "Тип медиа",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Провайдер", "LabelMetadataProvider": "Провайдер",
"LabelMetaTag": "Мета тег", "LabelMetaTag": "Мета тег",
"LabelMetaTags": "Мета теги", "LabelMetaTags": "Мета теги",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены", "LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги", "LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
"LabelUploaderDropFiles": "Перетащите файлы", "LabelUploaderDropFiles": "Перетащите файлы",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Показывать время главы", "LabelUseChapterTrack": "Показывать время главы",
"LabelUseFullTrack": "Показывать время книги", "LabelUseFullTrack": "Показывать время книги",
"LabelUser": "Пользователь", "LabelUser": "Пользователь",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "Redigera användare {0}", "ButtonUserEdit": "Redigera användare {0}",
"ButtonViewAll": "Visa alla", "ButtonViewAll": "Visa alla",
"ButtonYes": "Ja", "ButtonYes": "Ja",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAdvanced": "Avancerad", "HeaderAdvanced": "Avancerad",
"HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar", "HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar",
"HeaderAudiobookTools": "Ljudbokshantering", "HeaderAudiobookTools": "Ljudbokshantering",
"HeaderAudioTracks": "Ljudspår", "HeaderAudioTracks": "Ljudspår",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Säkerhetskopior", "HeaderBackups": "Säkerhetskopior",
"HeaderChangePassword": "Ändra lösenord", "HeaderChangePassword": "Ändra lösenord",
"HeaderChapters": "Kapitel", "HeaderChapters": "Kapitel",
@ -131,8 +135,10 @@
"HeaderNewAccount": "Nytt konto", "HeaderNewAccount": "Nytt konto",
"HeaderNewLibrary": "Nytt bibliotek", "HeaderNewLibrary": "Nytt bibliotek",
"HeaderNotifications": "Meddelanden", "HeaderNotifications": "Meddelanden",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "Öppna RSS-flöde", "HeaderOpenRSSFeed": "Öppna RSS-flöde",
"HeaderOtherFiles": "Andra filer", "HeaderOtherFiles": "Andra filer",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "Behörigheter", "HeaderPermissions": "Behörigheter",
"HeaderPlayerQueue": "Spelarkö", "HeaderPlayerQueue": "Spelarkö",
"HeaderPlaylist": "Spellista", "HeaderPlaylist": "Spellista",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)", "LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)",
"LabelAuthors": "Författare", "LabelAuthors": "Författare",
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt", "LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "Tillbaka till användaren", "LabelBackToUser": "Tillbaka till användaren",
"LabelBackupLocation": "Säkerhetskopia Plats", "LabelBackupLocation": "Säkerhetskopia Plats",
"LabelBackupsEnableAutomaticBackups": "Aktivera automatiska säkerhetskopior", "LabelBackupsEnableAutomaticBackups": "Aktivera automatiska säkerhetskopior",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "Endast en säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än detta bör du ta bort dem manuellt.", "LabelBackupsNumberToKeepHelp": "Endast en säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än detta bör du ta bort dem manuellt.",
"LabelBitrate": "Bitfrekvens", "LabelBitrate": "Bitfrekvens",
"LabelBooks": "Böcker", "LabelBooks": "Böcker",
"LabelButtonText": "Button Text",
"LabelChangePassword": "Ändra lösenord", "LabelChangePassword": "Ändra lösenord",
"LabelChannels": "Kanaler", "LabelChannels": "Kanaler",
"LabelChapters": "Kapitel", "LabelChapters": "Kapitel",
@ -258,6 +271,7 @@
"LabelExample": "Exempel", "LabelExample": "Exempel",
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelFeedURL": "Flödes-URL", "LabelFeedURL": "Flödes-URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "Fil", "LabelFile": "Fil",
"LabelFileBirthtime": "Födelse-tidpunkt för fil", "LabelFileBirthtime": "Födelse-tidpunkt för fil",
"LabelFileModified": "Fil ändrad", "LabelFileModified": "Fil ändrad",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "Hård radering av fil", "LabelHardDeleteFile": "Hård radering av fil",
"LabelHasEbook": "Har e-bok", "LabelHasEbook": "Har e-bok",
"LabelHasSupplementaryEbook": "Har kompletterande e-bok", "LabelHasSupplementaryEbook": "Har kompletterande e-bok",
"LabelHighestPriority": "Highest priority",
"LabelHost": "Värd", "LabelHost": "Värd",
"LabelHour": "Timme", "LabelHour": "Timme",
"LabelIcon": "Ikon", "LabelIcon": "Ikon",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "Felsökningsnivå: Information", "LabelLogLevelInfo": "Felsökningsnivå: Information",
"LabelLogLevelWarn": "Felsökningsnivå: Varning", "LabelLogLevelWarn": "Felsökningsnivå: Varning",
"LabelLookForNewEpisodesAfterDate": "Sök efter nya avsnitt efter detta datum", "LabelLookForNewEpisodesAfterDate": "Sök efter nya avsnitt efter detta datum",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "Mediaspelare", "LabelMediaPlayer": "Mediaspelare",
"LabelMediaType": "Mediatyp", "LabelMediaType": "Mediatyp",
"LabelMetadataOrderOfPrecedenceDescription": "1 är lägsta prioritet, 5 är högsta prioritet", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "Metadataleverantör", "LabelMetadataProvider": "Metadataleverantör",
"LabelMetaTag": "Metamärke", "LabelMetaTag": "Metamärke",
"LabelMetaTags": "Metamärken", "LabelMetaTags": "Metamärken",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "Tillåt överskrivning av befintliga detaljer för de valda böckerna när en matchning hittas", "LabelUpdateDetailsHelp": "Tillåt överskrivning av befintliga detaljer för de valda böckerna när en matchning hittas",
"LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar", "LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar",
"LabelUploaderDropFiles": "Släpp filer", "LabelUploaderDropFiles": "Släpp filer",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "Använd kapitelspår", "LabelUseChapterTrack": "Använd kapitelspår",
"LabelUseFullTrack": "Använd hela spåret", "LabelUseFullTrack": "Använd hela spåret",
"LabelUser": "Användare", "LabelUser": "Användare",

View File

@ -87,11 +87,15 @@
"ButtonUserEdit": "编辑用户 {0}", "ButtonUserEdit": "编辑用户 {0}",
"ButtonViewAll": "查看全部", "ButtonViewAll": "查看全部",
"ButtonYes": "确定", "ButtonYes": "确定",
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "帐户", "HeaderAccount": "帐户",
"HeaderAdvanced": "高级", "HeaderAdvanced": "高级",
"HeaderAppriseNotificationSettings": "测试通知设置", "HeaderAppriseNotificationSettings": "测试通知设置",
"HeaderAudiobookTools": "有声读物文件管理工具", "HeaderAudiobookTools": "有声读物文件管理工具",
"HeaderAudioTracks": "音轨", "HeaderAudioTracks": "音轨",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "备份", "HeaderBackups": "备份",
"HeaderChangePassword": "更改密码", "HeaderChangePassword": "更改密码",
"HeaderChapters": "章节", "HeaderChapters": "章节",
@ -131,8 +135,10 @@
"HeaderNewAccount": "新建帐户", "HeaderNewAccount": "新建帐户",
"HeaderNewLibrary": "新建媒体库", "HeaderNewLibrary": "新建媒体库",
"HeaderNotifications": "通知", "HeaderNotifications": "通知",
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
"HeaderOpenRSSFeed": "打开 RSS 源", "HeaderOpenRSSFeed": "打开 RSS 源",
"HeaderOtherFiles": "其他文件", "HeaderOtherFiles": "其他文件",
"HeaderPasswordAuthentication": "Password Authentication",
"HeaderPermissions": "权限", "HeaderPermissions": "权限",
"HeaderPlayerQueue": "播放队列", "HeaderPlayerQueue": "播放队列",
"HeaderPlaylist": "播放列表", "HeaderPlaylist": "播放列表",
@ -193,6 +199,12 @@
"LabelAuthorLastFirst": "作者 (名, 姓)", "LabelAuthorLastFirst": "作者 (名, 姓)",
"LabelAuthors": "作者", "LabelAuthors": "作者",
"LabelAutoDownloadEpisodes": "自动下载剧集", "LabelAutoDownloadEpisodes": "自动下载剧集",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
"LabelAutoRegister": "Auto Register",
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
"LabelBackToUser": "返回到用户", "LabelBackToUser": "返回到用户",
"LabelBackupLocation": "备份位置", "LabelBackupLocation": "备份位置",
"LabelBackupsEnableAutomaticBackups": "启用自动备份", "LabelBackupsEnableAutomaticBackups": "启用自动备份",
@ -203,6 +215,7 @@
"LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.", "LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.",
"LabelBitrate": "比特率", "LabelBitrate": "比特率",
"LabelBooks": "图书", "LabelBooks": "图书",
"LabelButtonText": "Button Text",
"LabelChangePassword": "修改密码", "LabelChangePassword": "修改密码",
"LabelChannels": "声道", "LabelChannels": "声道",
"LabelChapters": "章节", "LabelChapters": "章节",
@ -258,6 +271,7 @@
"LabelExample": "示例", "LabelExample": "示例",
"LabelExplicit": "信息准确", "LabelExplicit": "信息准确",
"LabelFeedURL": "源 URL", "LabelFeedURL": "源 URL",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "文件", "LabelFile": "文件",
"LabelFileBirthtime": "文件创建时间", "LabelFileBirthtime": "文件创建时间",
"LabelFileModified": "文件修改时间", "LabelFileModified": "文件修改时间",
@ -275,6 +289,7 @@
"LabelHardDeleteFile": "完全删除文件", "LabelHardDeleteFile": "完全删除文件",
"LabelHasEbook": "有电子书", "LabelHasEbook": "有电子书",
"LabelHasSupplementaryEbook": "有补充电子书", "LabelHasSupplementaryEbook": "有补充电子书",
"LabelHighestPriority": "Highest priority",
"LabelHost": "主机", "LabelHost": "主机",
"LabelHour": "小时", "LabelHour": "小时",
"LabelIcon": "图标", "LabelIcon": "图标",
@ -316,9 +331,12 @@
"LabelLogLevelInfo": "信息", "LabelLogLevelInfo": "信息",
"LabelLogLevelWarn": "警告", "LabelLogLevelWarn": "警告",
"LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集", "LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集",
"LabelLowestPriority": "Lowest Priority",
"LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMediaPlayer": "媒体播放器", "LabelMediaPlayer": "媒体播放器",
"LabelMediaType": "媒体类型", "LabelMediaType": "媒体类型",
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
"LabelMetadataProvider": "元数据提供者", "LabelMetadataProvider": "元数据提供者",
"LabelMetaTag": "元数据标签", "LabelMetaTag": "元数据标签",
"LabelMetaTags": "元标签", "LabelMetaTags": "元标签",
@ -503,6 +521,7 @@
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息", "LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
"LabelUploaderDragAndDrop": "拖放文件或文件夹", "LabelUploaderDragAndDrop": "拖放文件或文件夹",
"LabelUploaderDropFiles": "删除文件", "LabelUploaderDropFiles": "删除文件",
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
"LabelUseChapterTrack": "使用章节音轨", "LabelUseChapterTrack": "使用章节音轨",
"LabelUseFullTrack": "使用完整音轨", "LabelUseFullTrack": "使用完整音轨",
"LabelUser": "用户", "LabelUser": "用户",

4883
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.5.0", "version": "2.6.0",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
@ -15,7 +15,9 @@
"docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local", "docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local",
"docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local", "docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local",
"docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local", "docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local",
"deploy-linux": "node deploy/linux" "deploy-linux": "node deploy/linux",
"test": "mocha",
"coverage": "nyc mocha"
}, },
"bin": "prod.js", "bin": "prod.js",
"pkg": { "pkg": {
@ -28,15 +30,24 @@
"server/**/*.js" "server/**/*.js"
] ]
}, },
"mocha": {
"recursive": true
},
"author": "advplyr", "author": "advplyr",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
"cookie-parser": "^1.4.6",
"express": "^4.17.1", "express": "^4.17.1",
"express-session": "^1.17.3",
"graceful-fs": "^4.2.10", "graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1", "htmlparser2": "^8.0.1",
"lru-cache": "^10.0.3",
"node-tone": "^1.0.1", "node-tone": "^1.0.1",
"nodemailer": "^6.9.2", "nodemailer": "^6.9.2",
"openid-client": "^5.6.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"sequelize": "^6.32.1", "sequelize": "^6.32.1",
"socket.io": "^4.5.4", "socket.io": "^4.5.4",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
@ -44,6 +55,10 @@
"xml2js": "^0.5.0" "xml2js": "^0.5.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^2.0.20" "chai": "^4.3.10",
"mocha": "^10.2.0",
"nodemon": "^2.0.20",
"nyc": "^15.1.0",
"sinon": "^17.0.1"
} }
} }

View File

@ -1,32 +1,504 @@
const axios = require('axios')
const passport = require('passport')
const bcrypt = require('./libs/bcryptjs') const bcrypt = require('./libs/bcryptjs')
const jwt = require('./libs/jsonwebtoken') const jwt = require('./libs/jsonwebtoken')
const requestIp = require('./libs/requestIp') const LocalStrategy = require('./libs/passportLocal')
const Logger = require('./Logger') const JwtStrategy = require('passport-jwt').Strategy
const ExtractJwt = require('passport-jwt').ExtractJwt
const OpenIDClient = require('openid-client')
const Database = require('./Database') const Database = require('./Database')
const Logger = require('./Logger')
/**
* @class Class for handling all the authentication related functionality.
*/
class Auth { class Auth {
constructor() { }
cors(req, res, next) { constructor() {
res.header('Access-Control-Allow-Origin', '*') }
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
res.header('Access-Control-Allow-Headers', '*') /**
// TODO: Make sure allowing all headers is not a security concern. It is required for adding custom headers for SSO * Inializes all passportjs strategies and other passportjs ralated initialization.
// res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization") */
res.header('Access-Control-Allow-Credentials', true) async initPassportJs() {
if (req.method === 'OPTIONS') { // Check if we should load the local strategy (username + password login)
res.sendStatus(200) if (global.ServerSettings.authActiveAuthMethods.includes("local")) {
this.initAuthStrategyPassword()
}
// Check if we should load the openid strategy
if (global.ServerSettings.authActiveAuthMethods.includes("openid")) {
this.initAuthStrategyOpenID()
}
// Load the JwtStrategy (always) -> for bearer token auth
passport.use(new JwtStrategy({
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
secretOrKey: Database.serverSettings.tokenSecret
}, this.jwtAuthCheck.bind(this)))
// define how to seralize a user (to be put into the session)
passport.serializeUser(function (user, cb) {
process.nextTick(function () {
// only store id to session
return cb(null, JSON.stringify({
id: user.id,
}))
})
})
// define how to deseralize a user (use the ID to get it from the database)
passport.deserializeUser((function (user, cb) {
process.nextTick((async function () {
const parsedUserInfo = JSON.parse(user)
// load the user by ID that is stored in the session
const dbUser = await Database.userModel.getUserById(parsedUserInfo.id)
return cb(null, dbUser)
}).bind(this))
}).bind(this))
}
/**
* Passport use LocalStrategy
*/
initAuthStrategyPassword() {
passport.use(new LocalStrategy(this.localAuthCheckUserPw.bind(this)))
}
/**
* Passport use OpenIDClient.Strategy
*/
initAuthStrategyOpenID() {
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
Logger.error(`[Auth] Cannot init openid auth strategy - invalid settings`)
return
}
const openIdIssuerClient = new OpenIDClient.Issuer({
issuer: global.ServerSettings.authOpenIDIssuerURL,
authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL,
token_endpoint: global.ServerSettings.authOpenIDTokenURL,
userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL,
jwks_uri: global.ServerSettings.authOpenIDJwksURL
}).Client
const openIdClient = new openIdIssuerClient({
client_id: global.ServerSettings.authOpenIDClientID,
client_secret: global.ServerSettings.authOpenIDClientSecret
})
passport.use('openid-client', new OpenIDClient.Strategy({
client: openIdClient,
params: {
redirect_uri: '/auth/openid/callback',
scope: 'openid profile email'
}
}, async (tokenset, userinfo, done) => {
Logger.debug(`[Auth] openid callback userinfo=`, userinfo)
let failureMessage = 'Unauthorized'
if (!userinfo.sub) {
Logger.error(`[Auth] openid callback invalid userinfo, no sub`)
return done(null, null, failureMessage)
}
// First check for matching user by sub
let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub)
if (!user) {
// Optionally match existing by email or username based on server setting "authOpenIDMatchExistingBy"
if (Database.serverSettings.authOpenIDMatchExistingBy === 'email' && userinfo.email && userinfo.email_verified) {
Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`)
user = await Database.userModel.getUserByEmail(userinfo.email)
// Check that user is not already matched
if (user?.authOpenIDSub) {
Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`)
// TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback
failureMessage = 'A matching user was found but is already matched with another user from your auth provider'
user = null
}
} else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username' && userinfo.preferred_username) {
Logger.info(`[Auth] openid: User not found, checking existing with username "${userinfo.preferred_username}"`)
user = await Database.userModel.getUserByUsername(userinfo.preferred_username)
// Check that user is not already matched
if (user?.authOpenIDSub) {
Logger.warn(`[Auth] openid: User found with username "${userinfo.preferred_username}" but is already matched with sub "${user.authOpenIDSub}"`)
// TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback
failureMessage = 'A matching user was found but is already matched with another user from your auth provider'
user = null
}
}
// If existing user was matched and isActive then save sub to user
if (user?.isActive) {
Logger.info(`[Auth] openid: New user found matching existing user "${user.username}"`)
user.authOpenIDSub = userinfo.sub
await Database.userModel.updateFromOld(user)
} else if (user && !user.isActive) {
Logger.warn(`[Auth] openid: New user found matching existing user "${user.username}" but that user is deactivated`)
}
// Optionally auto register the user
if (!user && Database.serverSettings.authOpenIDAutoRegister) {
Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo)
user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this)
}
}
if (!user?.isActive) {
if (user && !user.isActive) {
failureMessage = 'Unauthorized'
}
// deny login
done(null, null, failureMessage)
return
}
// permit login
return done(null, user)
}))
}
/**
* Unuse strategy
*
* @param {string} name
*/
unuseAuthStrategy(name) {
passport.unuse(name)
}
/**
* Use strategy
*
* @param {string} name
*/
useAuthStrategy(name) {
if (name === 'openid') {
this.initAuthStrategyOpenID()
} else if (name === 'local') {
this.initAuthStrategyPassword()
} else { } else {
next() Logger.error('[Auth] Invalid auth strategy ' + name)
} }
} }
/**
* Stores the client's choice how the login callback should happen in temp cookies
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
paramsToCookies(req, res) {
if (req.query.isRest?.toLowerCase() == 'true') {
// store the isRest flag to the is_rest cookie
res.cookie('is_rest', req.query.isRest.toLowerCase(), {
maxAge: 120000, // 2 min
httpOnly: true
})
} else {
// no isRest-flag set -> set is_rest cookie to false
res.cookie('is_rest', 'false', {
maxAge: 120000, // 2 min
httpOnly: true
})
// persist state if passed in
if (req.query.state) {
res.cookie('auth_state', req.query.state, {
maxAge: 120000, // 2 min
httpOnly: true
})
}
const callback = req.query.redirect_uri || req.query.callback
// check if we are missing a callback parameter - we need one if isRest=false
if (!callback) {
res.status(400).send({
message: 'No callback parameter'
})
return
}
// store the callback url to the auth_cb cookie
res.cookie('auth_cb', callback, {
maxAge: 120000, // 2 min
httpOnly: true
})
}
}
/**
* Informs the client in the right mode about a successfull login and the token
* (clients choise is restored from cookies).
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async handleLoginSuccessBasedOnCookie(req, res) {
// get userLogin json (information about the user, server and the session)
const data_json = await this.getUserLoginResponsePayload(req.user)
if (req.cookies.is_rest === 'true') {
// REST request - send data
res.json(data_json)
} else {
// UI request -> check if we have a callback url
// TODO: do we want to somehow limit the values for auth_cb?
if (req.cookies.auth_cb) {
let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : ''
// UI request -> redirect to auth_cb url and send the jwt token as parameter
res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}${stateQuery}`)
} else {
res.status(400).send('No callback or already expired')
}
}
}
/**
* Creates all (express) routes required for authentication.
*
* @param {import('express').Router} router
*/
async initAuthRoutes(router) {
// Local strategy login route (takes username and password)
router.post('/login', passport.authenticate('local'), async (req, res) => {
// return the user login response json if the login was successfull
res.json(await this.getUserLoginResponsePayload(req.user))
})
// openid strategy login route (this redirects to the configured openid login provider)
router.get('/auth/openid', (req, res, next) => {
try {
// helper function from openid-client
function pick(object, ...paths) {
const obj = {}
for (const path of paths) {
if (object[path] !== undefined) {
obj[path] = object[path]
}
}
return obj
}
// Get the OIDC client from the strategy
// We need to call the client manually, because the strategy does not support forwarding the code challenge
// for API or mobile clients
const oidcStrategy = passport._strategy('openid-client')
const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http'
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString()
Logger.debug(`[Auth] Set oidc redirect_uri=${oidcStrategy._params.redirect_uri}`)
const client = oidcStrategy._client
const sessionKey = oidcStrategy._key
let code_challenge
let code_challenge_method
// If code_challenge is provided, expect that code_verifier will be handled by the client (mobile app)
// The web frontend of ABS does not need to do a PKCE itself, because it never handles the "code" of the oauth flow
// and as such will not send a code challenge, we will generate then one
if (req.query.code_challenge) {
code_challenge = req.query.code_challenge
code_challenge_method = req.query.code_challenge_method || 'S256'
if (!['S256', 'plain'].includes(code_challenge_method)) {
return res.status(400).send('Invalid code_challenge_method')
}
} else {
// If no code_challenge is provided, assume a web application flow and generate one
const code_verifier = OpenIDClient.generators.codeVerifier()
code_challenge = OpenIDClient.generators.codeChallenge(code_verifier)
code_challenge_method = 'S256'
// Store the code_verifier in the session for later use in the token exchange
req.session[sessionKey] = { ...req.session[sessionKey], code_verifier }
}
const params = {
state: OpenIDClient.generators.random(),
// Other params by the passport strategy
...oidcStrategy._params
}
if (!params.nonce && params.response_type.includes('id_token')) {
params.nonce = OpenIDClient.generators.random()
}
req.session[sessionKey] = {
...req.session[sessionKey],
...pick(params, 'nonce', 'state', 'max_age', 'response_type'),
mobile: req.query.isRest?.toLowerCase() === 'true' // Used in the abs callback later
}
// Now get the URL to direct to
const authorizationUrl = client.authorizationUrl({
...params,
scope: 'openid profile email',
response_type: 'code',
code_challenge,
code_challenge_method,
})
// params (isRest, callback) to a cookie that will be send to the client
this.paramsToCookies(req, res)
// Redirect the user agent (browser) to the authorization URL
res.redirect(authorizationUrl)
} catch (error) {
Logger.error(`[Auth] Error in /auth/openid route: ${error}`)
res.status(500).send('Internal Server Error')
}
})
// openid strategy callback route (this receives the token from the configured openid login provider)
router.get('/auth/openid/callback', (req, res, next) => {
const oidcStrategy = passport._strategy('openid-client')
const sessionKey = oidcStrategy._key
if (!req.session[sessionKey]) {
return res.status(400).send('No session')
}
// If the client sends us a code_verifier, we will tell passport to use this to send this in the token request
// The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request
// Crucial for API/Mobile clients
if (req.query.code_verifier) {
req.session[sessionKey].code_verifier = req.query.code_verifier
}
function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {
Logger.error(logMessage)
if (response) {
// Depending on the error, it can also have a body
// We also log the request header the passport plugin sents for the URL
const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED')
Logger.debug(header + '\n' + response.body?.toString())
}
if (isMobile) {
return res.status(errorCode).send(errorMessage)
} else {
return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`)
}
}
function passportCallback(req, res, next) {
return (err, user, info) => {
const isMobile = req.session[sessionKey]?.mobile === true
if (err) {
return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response)
}
if (!user) {
// Info usually contains the error message from the SSO provider
return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response)
}
req.logIn(user, (loginError) => {
if (loginError) {
return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`)
}
next()
})
}
}
// While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request
// We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided
if (req.session[sessionKey].mobile) {
return passport.authenticate('openid-client', { redirect_uri: 'audiobookshelf://oauth' }, passportCallback(req, res, next))(req, res, next)
} else {
return passport.authenticate('openid-client', passportCallback(req, res, next))(req, res, next)
}
},
// on a successfull login: read the cookies and react like the client requested (callback or json)
this.handleLoginSuccessBasedOnCookie.bind(this))
/**
* Used to auto-populate the openid URLs in config/authentication
*/
router.get('/auth/openid/config', async (req, res) => {
if (!req.query.issuer) {
return res.status(400).send('Invalid request. Query param \'issuer\' is required')
}
let issuerUrl = req.query.issuer
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
const configUrl = `${issuerUrl}/.well-known/openid-configuration`
axios.get(configUrl).then(({ data }) => {
res.json({
issuer: data.issuer,
authorization_endpoint: data.authorization_endpoint,
token_endpoint: data.token_endpoint,
userinfo_endpoint: data.userinfo_endpoint,
end_session_endpoint: data.end_session_endpoint,
jwks_uri: data.jwks_uri
})
}).catch((error) => {
Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error)
res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`)
})
})
// Logout route
router.post('/logout', (req, res) => {
// TODO: invalidate possible JWTs
req.logout((err) => {
if (err) {
res.sendStatus(500)
} else {
res.sendStatus(200)
}
})
})
}
/**
* middleware to use in express to only allow authenticated users.
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
isAuthenticated(req, res, next) {
// check if session cookie says that we are authenticated
if (req.isAuthenticated()) {
next()
} else {
// try JWT to authenticate
passport.authenticate("jwt")(req, res, next)
}
}
/**
* Function to generate a jwt token for a given user
*
* @param {{ id:string, username:string }} user
* @returns {string} token
*/
generateAccessToken(user) {
return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret)
}
/**
* Function to validate a jwt token for a given user
*
* @param {string} token
* @returns {Object} tokens data
*/
static validateAccessToken(token) {
try {
return jwt.verify(token, global.ServerSettings.tokenSecret)
}
catch (err) {
return null
}
}
/**
* Generate a token which is used to encrpt/protect the jwts.
*/
async initTokenSecret() { async initTokenSecret() {
if (process.env.TOKEN_SECRET) { // User can supply their own token secret if (process.env.TOKEN_SECRET) { // User can supply their own token secret
Logger.debug(`[Auth] Setting token secret - using user passed in TOKEN_SECRET env var`)
Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET
} else { } else {
Logger.debug(`[Auth] Setting token secret - using random bytes`)
Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
} }
await Database.updateServerSettings() await Database.updateServerSettings()
@ -35,47 +507,83 @@ class Auth {
const users = await Database.userModel.getOldUsers() const users = await Database.userModel.getOldUsers()
if (users.length) { if (users.length) {
for (const user of users) { for (const user of users) {
user.token = await this.generateAccessToken({ userId: user.id, username: user.username }) user.token = await this.generateAccessToken(user)
Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`)
} }
await Database.updateBulkUsers(users) await Database.updateBulkUsers(users)
} }
} }
async authMiddleware(req, res, next) { /**
var token = null * Checks if the user in the validated jwt_payload really exists and is active.
* @param {Object} jwt_payload
* @param {function} done
*/
async jwtAuthCheck(jwt_payload, done) {
// load user by id from the jwt token
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)
// If using a get request, the token can be passed as a query string if (!user?.isActive) {
if (req.method === 'GET' && req.query && req.query.token) { // deny login
token = req.query.token done(null, null)
} else { return
const authHeader = req.headers['authorization'] }
token = authHeader && authHeader.split(' ')[1] // approve login
done(null, user)
return
} }
if (token == null) { /**
Logger.error('Api called without a token', req.path) * Checks if a username and password tuple is valid and the user active.
return res.sendStatus(401) * @param {string} username
* @param {string} password
* @param {function} done
*/
async localAuthCheckUserPw(username, password, done) {
// Load the user given it's username
const user = await Database.userModel.getUserByUsername(username.toLowerCase())
if (!user?.isActive) {
done(null, null)
return
} }
const user = await this.verifyToken(token) // Check passwordless root user
if (!user) { if (user.type === 'root' && !user.pash) {
Logger.error('Verify Token User Not Found', token) if (password) {
return res.sendStatus(404) // deny login
done(null, null)
return
} }
if (!user.isActive) { // approve login
Logger.error('Verify Token User is disabled', token, user.username) done(null, user)
return res.sendStatus(403) return
} } else if (!user.pash) {
req.user = user Logger.error(`[Auth] User "${user.username}"/"${user.type}" attempted to login without a password set`)
next() done(null, null)
return
} }
// Check password match
const compare = await bcrypt.compare(password, user.pash)
if (compare) {
// approve login
done(null, user)
return
}
// deny login
done(null, null)
return
}
/**
* Hashes a password with bcrypt.
* @param {string} password
* @returns {string} hash
*/
hashPass(password) { hashPass(password) {
return new Promise((resolve) => { return new Promise((resolve) => {
bcrypt.hash(password, 8, (err, hash) => { bcrypt.hash(password, 8, (err, hash) => {
if (err) { if (err) {
Logger.error('Hash failed', err)
resolve(null) resolve(null)
} else { } else {
resolve(hash) resolve(hash)
@ -84,36 +592,11 @@ class Auth {
}) })
} }
generateAccessToken(payload) {
return jwt.sign(payload, Database.serverSettings.tokenSecret)
}
authenticateUser(token) {
return this.verifyToken(token)
}
verifyToken(token) {
return new Promise((resolve) => {
jwt.verify(token, Database.serverSettings.tokenSecret, async (err, payload) => {
if (!payload || err) {
Logger.error('JWT Verify Token Failed', err)
return resolve(null)
}
const user = await Database.userModel.getUserByIdOrOldId(payload.userId)
if (user && user.username === payload.username) {
resolve(user)
} else {
resolve(null)
}
})
})
}
/** /**
* Payload returned to a user after successful login * Return the login info payload for a user
* @param {oldUser} user *
* @returns {object} * @param {Object} user
* @returns {Promise<Object>} jsonPayload
*/ */
async getUserLoginResponsePayload(user) { async getUserLoginResponsePayload(user) {
const libraryIds = await Database.libraryModel.getAllLibraryIds() const libraryIds = await Database.libraryModel.getAllLibraryIds()
@ -126,59 +609,28 @@ class Auth {
} }
} }
async login(req, res) { /**
const ipAddress = requestIp.getClientIp(req) *
const username = (req.body.username || '').toLowerCase() * @param {string} password
const password = req.body.password || '' * @param {*} user
* @returns {boolean}
const user = await Database.userModel.getUserByUsername(username) */
if (!user?.isActive) {
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
if (req.rateLimit.remaining <= 2) {
Logger.error(`[Auth] Failed login attempt for username ${username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`)
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
}
return res.status(401).send('Invalid user or password')
}
// Check passwordless root user
if (user.type === 'root' && (!user.pash || user.pash === '')) {
if (password) {
return res.status(401).send('Invalid root password (hint: there is none)')
} else {
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
const userLoginResponsePayload = await this.getUserLoginResponsePayload(user)
return res.json(userLoginResponsePayload)
}
}
// Check password match
const compare = await bcrypt.compare(password, user.pash)
if (compare) {
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
const userLoginResponsePayload = await this.getUserLoginResponsePayload(user)
res.json(userLoginResponsePayload)
} else {
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
if (req.rateLimit.remaining <= 2) {
Logger.error(`[Auth] Failed login attempt for user ${user.username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`)
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
}
return res.status(401).send('Invalid user or password')
}
}
comparePassword(password, user) { comparePassword(password, user) {
if (user.type === 'root' && !password && !user.pash) return true if (user.type === 'root' && !password && !user.pash) return true
if (!password || !user.pash) return false if (!password || !user.pash) return false
return bcrypt.compare(password, user.pash) return bcrypt.compare(password, user.pash)
} }
/**
* User changes their password from request
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async userChangePassword(req, res) { async userChangePassword(req, res) {
var { password, newPassword } = req.body let { password, newPassword } = req.body
newPassword = newPassword || '' newPassword = newPassword || ''
const matchingUser = await Database.userModel.getUserById(req.user.id) const matchingUser = req.user
// Only root can have an empty password // Only root can have an empty password
if (matchingUser.type !== 'root' && !newPassword) { if (matchingUser.type !== 'root' && !newPassword) {
@ -187,6 +639,7 @@ class Auth {
}) })
} }
// Check password match
const compare = await this.comparePassword(password, matchingUser) const compare = await this.comparePassword(password, matchingUser)
if (!compare) { if (!compare) {
return res.json({ return res.json({
@ -208,6 +661,7 @@ class Auth {
const success = await Database.updateUser(matchingUser) const success = await Database.updateUser(matchingUser)
if (success) { if (success) {
Logger.info(`[Auth] User "${matchingUser.username}" changed password`)
res.json({ res.json({
success: true success: true
}) })
@ -218,4 +672,5 @@ class Auth {
} }
} }
} }
module.exports = Auth module.exports = Auth

View File

@ -5,13 +5,14 @@ class Logger {
constructor() { constructor() {
this.isDev = process.env.NODE_ENV !== 'production' this.isDev = process.env.NODE_ENV !== 'production'
this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE
this.hideDevLogs = process.env.HIDE_DEV_LOGS === undefined ? !this.isDev : process.env.HIDE_DEV_LOGS === '1'
this.socketListeners = [] this.socketListeners = []
this.logManager = null this.logManager = null
} }
get timestamp() { get timestamp() {
return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss') return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss.SSS')
} }
get levelString() { get levelString() {
@ -92,7 +93,7 @@ class Logger {
* @param {...any} args * @param {...any} args
*/ */
dev(...args) { dev(...args) {
if (!this.isDev || process.env.HIDE_DEV_LOGS === '1') return if (this.hideDevLogs) return
console.log(`[${this.timestamp}] DEV:`, ...args) console.log(`[${this.timestamp}] DEV:`, ...args)
} }

View File

@ -5,6 +5,7 @@ const http = require('http')
const fs = require('./libs/fsExtra') const fs = require('./libs/fsExtra')
const fileUpload = require('./libs/expressFileupload') const fileUpload = require('./libs/expressFileupload')
const rateLimit = require('./libs/expressRateLimit') const rateLimit = require('./libs/expressRateLimit')
const cookieParser = require("cookie-parser")
const { version } = require('../package.json') const { version } = require('../package.json')
@ -31,8 +32,13 @@ const PodcastManager = require('./managers/PodcastManager')
const AudioMetadataMangaer = require('./managers/AudioMetadataManager') const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
const RssFeedManager = require('./managers/RssFeedManager') const RssFeedManager = require('./managers/RssFeedManager')
const CronManager = require('./managers/CronManager') const CronManager = require('./managers/CronManager')
const ApiCacheManager = require('./managers/ApiCacheManager')
const LibraryScanner = require('./scanner/LibraryScanner') const LibraryScanner = require('./scanner/LibraryScanner')
//Import the main Passport and Express-Session library
const passport = require('passport')
const expressSession = require('express-session')
class Server { class Server {
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) { constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
this.Port = PORT this.Port = PORT
@ -67,6 +73,7 @@ class Server {
this.audioMetadataManager = new AudioMetadataMangaer() this.audioMetadataManager = new AudioMetadataMangaer()
this.rssFeedManager = new RssFeedManager() this.rssFeedManager = new RssFeedManager()
this.cronManager = new CronManager(this.podcastManager) this.cronManager = new CronManager(this.podcastManager)
this.apiCacheManager = new ApiCacheManager()
// Routers // Routers
this.apiRouter = new ApiRouter(this) this.apiRouter = new ApiRouter(this)
@ -79,7 +86,8 @@ class Server {
} }
authMiddleware(req, res, next) { authMiddleware(req, res, next) {
this.auth.authMiddleware(req, res, next) // ask passportjs if the current request is authenticated
this.auth.isAuthenticated(req, res, next)
} }
cancelLibraryScan(libraryId) { cancelLibraryScan(libraryId) {
@ -110,6 +118,7 @@ class Server {
const libraries = await Database.libraryModel.getAllOldLibraries() const libraries = await Database.libraryModel.getAllOldLibraries()
await this.cronManager.init(libraries) await this.cronManager.init(libraries)
this.apiCacheManager.init()
if (Database.serverSettings.scannerDisableWatcher) { if (Database.serverSettings.scannerDisableWatcher) {
Logger.info(`[Server] Watcher is disabled`) Logger.info(`[Server] Watcher is disabled`)
@ -124,20 +133,65 @@ class Server {
await this.init() await this.init()
const app = express() const app = express()
/**
* @temporary
* This is necessary for the ebook API endpoint in the mobile apps
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
* @see https://ionicframework.com/docs/troubleshooting/cors
*
* Running in development allows cors to allow testing the mobile apps in the browser
*/
app.use((req, res, next) => {
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/ebook(\/[0-9]+)?/)) {
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
if (Logger.isDev || allowedOrigins.some(o => o === req.get('origin'))) {
res.header('Access-Control-Allow-Origin', req.get('origin'))
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
res.header('Access-Control-Allow-Headers', '*')
res.header('Access-Control-Allow-Credentials', true)
if (req.method === 'OPTIONS') {
return res.sendStatus(200)
}
}
}
next()
})
// parse cookies in requests
app.use(cookieParser())
// enable express-session
app.use(expressSession({
secret: global.ServerSettings.tokenSecret,
resave: false,
saveUninitialized: false,
cookie: {
// also send the cookie if were are not on https (not every use has https)
secure: false
},
}))
// init passport.js
app.use(passport.initialize())
// register passport in express-session
app.use(passport.session())
// config passport.js
await this.auth.initPassportJs()
const router = express.Router() const router = express.Router()
app.use(global.RouterBasePath, router) app.use(global.RouterBasePath, router)
app.disable('x-powered-by') app.disable('x-powered-by')
this.server = http.createServer(app) this.server = http.createServer(app)
router.use(this.auth.cors)
router.use(fileUpload({ router.use(fileUpload({
defCharset: 'utf8', defCharset: 'utf8',
defParamCharset: 'utf8', defParamCharset: 'utf8',
useTempFiles: true, useTempFiles: true,
tempFileDir: Path.join(global.MetadataPath, 'tmp') tempFileDir: Path.join(global.MetadataPath, 'tmp')
})) }))
router.use(express.urlencoded({ extended: true, limit: "5mb" })); router.use(express.urlencoded({ extended: true, limit: "5mb" }))
router.use(express.json({ limit: "5mb" })) router.use(express.json({ limit: "5mb" }))
// Static path to generated nuxt // Static path to generated nuxt
@ -163,6 +217,9 @@ class Server {
this.rssFeedManager.getFeedItem(req, res) this.rssFeedManager.getFeedItem(req, res)
}) })
// Auth routes
await this.auth.initAuthRoutes(router)
// Client dynamic routes // Client dynamic routes
const dyanimicRoutes = [ const dyanimicRoutes = [
'/item/:id', '/item/:id',
@ -174,6 +231,7 @@ class Server {
'/library/:library/search', '/library/:library/search',
'/library/:library/bookshelf/:id?', '/library/:library/bookshelf/:id?',
'/library/:library/authors', '/library/:library/authors',
'/library/:library/narrators',
'/library/:library/series/:id?', '/library/:library/series/:id?',
'/library/:library/podcast/search', '/library/:library/podcast/search',
'/library/:library/podcast/latest', '/library/:library/podcast/latest',
@ -186,8 +244,8 @@ class Server {
] ]
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res)) // router.post('/login', passport.authenticate('local', this.auth.login), this.auth.loginResult.bind(this))
router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this)) // router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
router.post('/init', (req, res) => { router.post('/init', (req, res) => {
if (Database.hasRootUser) { if (Database.hasRootUser) {
Logger.error(`[Server] attempt to init server when server already has a root user`) Logger.error(`[Server] attempt to init server when server already has a root user`)
@ -199,8 +257,12 @@ class Server {
// status check for client to see if server has been initialized // status check for client to see if server has been initialized
// server has been initialized if a root user exists // server has been initialized if a root user exists
const payload = { const payload = {
app: 'audiobookshelf',
serverVersion: version,
isInit: Database.hasRootUser, isInit: Database.hasRootUser,
language: Database.serverSettings.language language: Database.serverSettings.language,
authMethods: Database.serverSettings.authActiveAuthMethods,
authFormData: Database.serverSettings.authFormData
} }
if (!payload.isInit) { if (!payload.isInit) {
payload.ConfigPath = global.ConfigPath payload.ConfigPath = global.ConfigPath

View File

@ -1,6 +1,7 @@
const SocketIO = require('socket.io') const SocketIO = require('socket.io')
const Logger = require('./Logger') const Logger = require('./Logger')
const Database = require('./Database') const Database = require('./Database')
const Auth = require('./Auth')
class SocketAuthority { class SocketAuthority {
constructor() { constructor() {
@ -81,6 +82,7 @@ class SocketAuthority {
methods: ["GET", "POST"] methods: ["GET", "POST"]
} }
}) })
this.io.on('connection', (socket) => { this.io.on('connection', (socket) => {
this.clients[socket.id] = { this.clients[socket.id] = {
id: socket.id, id: socket.id,
@ -144,14 +146,31 @@ class SocketAuthority {
}) })
} }
// When setting up a socket connection the user needs to be associated with a socket id /**
// for this the client will send a 'auth' event that includes the users API token * When setting up a socket connection the user needs to be associated with a socket id
* for this the client will send a 'auth' event that includes the users API token
*
* @param {SocketIO.Socket} socket
* @param {string} token JWT
*/
async authenticateSocket(socket, token) { async authenticateSocket(socket, token) {
const user = await this.Server.auth.authenticateUser(token) // we don't use passport to authenticate the jwt we get over the socket connection.
if (!user) { // it's easier to directly verify/decode it.
const token_data = Auth.validateAccessToken(token)
if (!token_data?.userId) {
// Token invalid
Logger.error('Cannot validate socket - invalid token') Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token') return socket.emit('invalid_token')
} }
// get the user via the id from the decoded jwt.
const user = await Database.userModel.getUserByIdOrOldId(token_data.userId)
if (!user) {
// user not found
Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token')
}
const client = this.clients[socket.id] const client = this.clients[socket.id]
if (!client) { if (!client) {
Logger.error(`[SocketAuthority] Socket for user ${user.username} has no client`) Logger.error(`[SocketAuthority] Socket for user ${user.username} has no client`)
@ -173,9 +192,9 @@ class SocketAuthority {
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
// Update user lastSeen // Update user lastSeen without firing sequelize bulk update hooks
user.lastSeen = Date.now() user.lastSeen = Date.now()
await Database.updateUser(user) await Database.userModel.updateFromOld(user, false)
const initialPayload = { const initialPayload = {
userId: client.user.id, userId: client.user.id,

View File

@ -8,6 +8,7 @@ const Database = require('../Database')
const libraryItemFilters = require('../utils/queries/libraryItemFilters') const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const patternValidation = require('../libs/nodeCron/pattern-validation') const patternValidation = require('../libs/nodeCron/pattern-validation')
const { isObject, getTitleIgnorePrefix } = require('../utils/index') const { isObject, getTitleIgnorePrefix } = require('../utils/index')
const { sanitizeFilename } = require('../utils/fileUtils')
const TaskManager = require('../managers/TaskManager') const TaskManager = require('../managers/TaskManager')
@ -32,12 +33,9 @@ class MiscController {
Logger.error('Invalid request, no files') Logger.error('Invalid request, no files')
return res.sendStatus(400) return res.sendStatus(400)
} }
const files = Object.values(req.files) const files = Object.values(req.files)
const title = req.body.title const { title, author, series, folder: folderId, library: libraryId } = req.body
const author = req.body.author
const series = req.body.series
const libraryId = req.body.library
const folderId = req.body.folder
const library = await Database.libraryModel.getOldById(libraryId) const library = await Database.libraryModel.getOldById(libraryId)
if (!library) { if (!library) {
@ -52,40 +50,26 @@ class MiscController {
return res.status(500).send(`Invalid post data`) return res.status(500).send(`Invalid post data`)
} }
// For setting permissions recursively // Podcasts should only be one folder deep
let outputDirectory = '' const outputDirectoryParts = library.isPodcast ? [title] : [author, series, title]
let firstDirPath = '' // `.filter(Boolean)` to strip out all the potentially missing details (eg: `author`)
// before sanitizing all the directory parts to remove illegal chars and finally prepending
if (library.isPodcast) { // Podcasts only in 1 folder // the base folder path
outputDirectory = Path.join(folder.fullPath, title) const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map(part => sanitizeFilename(part))
firstDirPath = outputDirectory const outputDirectory = Path.join(...[folder.fullPath, ...cleanedOutputDirectoryParts])
} else {
firstDirPath = Path.join(folder.fullPath, author)
if (series && author) {
outputDirectory = Path.join(folder.fullPath, author, series, title)
} else if (author) {
outputDirectory = Path.join(folder.fullPath, author, title)
} else {
outputDirectory = Path.join(folder.fullPath, title)
}
}
if (await fs.pathExists(outputDirectory)) {
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
return res.status(500).send(`Directory "${outputDirectory}" already exists`)
}
await fs.ensureDir(outputDirectory) await fs.ensureDir(outputDirectory)
Logger.info(`Uploading ${files.length} files to`, outputDirectory) Logger.info(`Uploading ${files.length} files to`, outputDirectory)
for (let i = 0; i < files.length; i++) { for (const file of files) {
var file = files[i] const path = Path.join(outputDirectory, sanitizeFilename(file.name))
var path = Path.join(outputDirectory, file.name) await file.mv(path)
await file.mv(path).then(() => { .then(() => {
return true return true
}).catch((error) => { })
.catch((error) => {
Logger.error('Failed to move file', path, error) Logger.error('Failed to move file', path, error)
return false return false
}) })
@ -119,8 +103,9 @@ class MiscController {
/** /**
* PATCH: /api/settings * PATCH: /api/settings
* Update server settings * Update server settings
* @param {*} req *
* @param {*} res * @param {import('express').Request} req
* @param {import('express').Response} res
*/ */
async updateServerSettings(req, res) { async updateServerSettings(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
@ -128,7 +113,7 @@ class MiscController {
return res.sendStatus(403) return res.sendStatus(403)
} }
const settingsUpdate = req.body const settingsUpdate = req.body
if (!settingsUpdate || !isObject(settingsUpdate)) { if (!isObject(settingsUpdate)) {
return res.status(400).send('Invalid settings update object') return res.status(400).send('Invalid settings update object')
} }
@ -248,8 +233,8 @@ class MiscController {
* POST: /api/authorize * POST: /api/authorize
* Used to authorize an API token * Used to authorize an API token
* *
* @param {*} req * @param {import('express').Request} req
* @param {*} res * @param {import('express').Response} res
*/ */
async authorize(req, res) { async authorize(req, res) {
if (!req.user) { if (!req.user) {
@ -555,10 +540,10 @@ class MiscController {
switch (type) { switch (type) {
case 'add': case 'add':
this.watcher.onFileAdded(libraryId, path) this.watcher.onFileAdded(libraryId, path)
break; break
case 'unlink': case 'unlink':
this.watcher.onFileRemoved(libraryId, path) this.watcher.onFileRemoved(libraryId, path)
break; break
case 'rename': case 'rename':
const oldPath = req.body.oldPath const oldPath = req.body.oldPath
if (!oldPath) { if (!oldPath) {
@ -566,7 +551,7 @@ class MiscController {
return res.sendStatus(400) return res.sendStatus(400)
} }
this.watcher.onFileRename(libraryId, oldPath, path) this.watcher.onFileRename(libraryId, oldPath, path)
break; break
default: default:
Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`) Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`)
return res.sendStatus(400) return res.sendStatus(400)
@ -589,5 +574,105 @@ class MiscController {
res.status(400).send(error.message) res.status(400).send(error.message)
} }
} }
/**
* GET: api/auth-settings (admin only)
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
getAuthSettings(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get auth settings`)
return res.sendStatus(403)
}
return res.json(Database.serverSettings.authenticationSettings)
}
/**
* PATCH: api/auth-settings
* @this import('../routers/ApiRouter')
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async updateAuthSettings(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to update auth settings`)
return res.sendStatus(403)
}
const settingsUpdate = req.body
if (!isObject(settingsUpdate)) {
return res.status(400).send('Invalid auth settings update object')
}
let hasUpdates = false
const currentAuthenticationSettings = Database.serverSettings.authenticationSettings
const originalAuthMethods = [...currentAuthenticationSettings.authActiveAuthMethods]
// TODO: Better validation of auth settings once auth settings are separated from server settings
for (const key in currentAuthenticationSettings) {
if (settingsUpdate[key] === undefined) continue
if (key === 'authActiveAuthMethods') {
let updatedAuthMethods = settingsUpdate[key]?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth))
if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) {
updatedAuthMethods.sort()
currentAuthenticationSettings[key].sort()
if (updatedAuthMethods.join() !== currentAuthenticationSettings[key].join()) {
Logger.debug(`[MiscController] Updating auth settings key "authActiveAuthMethods" from "${currentAuthenticationSettings[key].join()}" to "${updatedAuthMethods.join()}"`)
Database.serverSettings[key] = updatedAuthMethods
hasUpdates = true
}
} else {
Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`)
}
} else {
const updatedValueType = typeof settingsUpdate[key]
if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) {
if (updatedValueType !== 'boolean') {
Logger.warn(`[MiscController] Invalid value for ${key}. Expected boolean`)
continue
}
} else if (settingsUpdate[key] !== null && updatedValueType !== 'string') {
Logger.warn(`[MiscController] Invalid value for ${key}. Expected string or null`)
continue
}
let updatedValue = settingsUpdate[key]
if (updatedValue === '') updatedValue = null
let currentValue = currentAuthenticationSettings[key]
if (currentValue === '') currentValue = null
if (updatedValue !== currentValue) {
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
Database.serverSettings[key] = updatedValue
hasUpdates = true
}
}
}
if (hasUpdates) {
await Database.updateServerSettings()
// Use/unuse auth methods
Database.serverSettings.supportedAuthMethods.forEach((authMethod) => {
if (originalAuthMethods.includes(authMethod) && !Database.serverSettings.authActiveAuthMethods.includes(authMethod)) {
// Auth method has been removed
Logger.info(`[MiscController] Disabling active auth method "${authMethod}"`)
this.auth.unuseAuthStrategy(authMethod)
} else if (!originalAuthMethods.includes(authMethod) && Database.serverSettings.authActiveAuthMethods.includes(authMethod)) {
// Auth method has been added
Logger.info(`[MiscController] Enabling active auth method "${authMethod}"`)
this.auth.useAuthStrategy(authMethod)
}
})
}
res.json({
serverSettings: Database.serverSettings.toJSONForBrowser()
})
}
} }
module.exports = new MiscController() module.exports = new MiscController()

View File

@ -6,7 +6,7 @@ class SessionController {
constructor() { } constructor() { }
async findOne(req, res) { async findOne(req, res) {
return res.json(req.session) return res.json(req.playbackSession)
} }
async getAllWithUserData(req, res) { async getAllWithUserData(req, res) {
@ -63,32 +63,32 @@ class SessionController {
} }
async getOpenSession(req, res) { async getOpenSession(req, res) {
const libraryItem = await Database.libraryItemModel.getOldById(req.session.libraryItemId) const libraryItem = await Database.libraryItemModel.getOldById(req.playbackSession.libraryItemId)
const sessionForClient = req.session.toJSONForClient(libraryItem) const sessionForClient = req.playbackSession.toJSONForClient(libraryItem)
res.json(sessionForClient) res.json(sessionForClient)
} }
// POST: api/session/:id/sync // POST: api/session/:id/sync
sync(req, res) { sync(req, res) {
this.playbackSessionManager.syncSessionRequest(req.user, req.session, req.body, res) this.playbackSessionManager.syncSessionRequest(req.user, req.playbackSession, req.body, res)
} }
// POST: api/session/:id/close // POST: api/session/:id/close
close(req, res) { close(req, res) {
let syncData = req.body let syncData = req.body
if (syncData && !Object.keys(syncData).length) syncData = null if (syncData && !Object.keys(syncData).length) syncData = null
this.playbackSessionManager.closeSessionRequest(req.user, req.session, syncData, res) this.playbackSessionManager.closeSessionRequest(req.user, req.playbackSession, syncData, res)
} }
// DELETE: api/session/:id // DELETE: api/session/:id
async delete(req, res) { async delete(req, res) {
// if session is open then remove it // if session is open then remove it
const openSession = this.playbackSessionManager.getSession(req.session.id) const openSession = this.playbackSessionManager.getSession(req.playbackSession.id)
if (openSession) { if (openSession) {
await this.playbackSessionManager.removeSession(req.session.id) await this.playbackSessionManager.removeSession(req.playbackSession.id)
} }
await Database.removePlaybackSession(req.session.id) await Database.removePlaybackSession(req.playbackSession.id)
res.sendStatus(200) res.sendStatus(200)
} }
@ -111,7 +111,7 @@ class SessionController {
return res.sendStatus(404) return res.sendStatus(404)
} }
req.session = playbackSession req.playbackSession = playbackSession
next() next()
} }
@ -130,7 +130,7 @@ class SessionController {
return res.sendStatus(403) return res.sendStatus(403)
} }
req.session = playbackSession req.playbackSession = playbackSession
next() next()
} }
} }

View File

@ -100,7 +100,7 @@ class UserController {
account.id = uuidv4() account.id = uuidv4()
account.pash = await this.auth.hashPass(account.password) account.pash = await this.auth.hashPass(account.password)
delete account.password delete account.password
account.token = await this.auth.generateAccessToken({ userId: account.id, username }) account.token = await this.auth.generateAccessToken(account)
account.createdAt = Date.now() account.createdAt = Date.now()
const newUser = new User(account) const newUser = new User(account)
@ -150,7 +150,7 @@ class UserController {
if (user.update(account)) { if (user.update(account)) {
if (shouldUpdateToken) { if (shouldUpdateToken) {
user.token = await this.auth.generateAccessToken({ userId: user.id, username: user.username }) user.token = await this.auth.generateAccessToken(user)
Logger.info(`[UserController] User ${user.username} was generated a new api token`) Logger.info(`[UserController] User ${user.username} was generated a new api token`)
} }
await Database.updateUser(user) await Database.updateUser(user)

View File

@ -31,52 +31,11 @@ class BookFinder {
return book return book
} }
stripSubtitle(title) {
if (title.includes(':')) {
return title.split(':')[0].trim()
} else if (title.includes(' - ')) {
return title.split(' - ')[0].trim()
}
return title
}
replaceAccentedChars(str) {
try {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
} catch (error) {
Logger.error('[BookFinder] str normalize error', error)
return str
}
}
cleanTitleForCompares(title) {
if (!title) return ''
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
let stripped = this.stripSubtitle(title)
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
let cleaned = stripped.replace(/ *\([^)]*\) */g, "")
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
cleaned = cleaned.replace(/'/g, '')
return this.replaceAccentedChars(cleaned).toLowerCase()
}
cleanAuthorForCompares(author) {
if (!author) return ''
let cleanAuthor = this.replaceAccentedChars(author).toLowerCase()
// separate initials
cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2')
// remove middle initials
cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '')
return cleanAuthor
}
filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) { filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) {
var searchTitle = this.cleanTitleForCompares(title) var searchTitle = cleanTitleForCompares(title)
var searchAuthor = this.cleanAuthorForCompares(author) var searchAuthor = cleanAuthorForCompares(author)
return books.map(b => { return books.map(b => {
b.cleanedTitle = this.cleanTitleForCompares(b.title) b.cleanedTitle = cleanTitleForCompares(b.title)
b.titleDistance = levenshteinDistance(b.cleanedTitle, title) b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
// Total length of search (title or both title & author) // Total length of search (title or both title & author)
@ -87,7 +46,7 @@ class BookFinder {
b.authorDistance = author.length b.authorDistance = author.length
} else { } else {
b.totalPossibleDistance += b.author.length b.totalPossibleDistance += b.author.length
b.cleanedAuthor = this.cleanAuthorForCompares(b.author) b.cleanedAuthor = cleanAuthorForCompares(b.author)
var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor) var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor)
var authorDistance = levenshteinDistance(b.author || '', author) var authorDistance = levenshteinDistance(b.author || '', author)
@ -190,20 +149,17 @@ class BookFinder {
static TitleCandidates = class { static TitleCandidates = class {
constructor(bookFinder, cleanAuthor) { constructor(cleanAuthor) {
this.bookFinder = bookFinder
this.candidates = new Set() this.candidates = new Set()
this.cleanAuthor = cleanAuthor this.cleanAuthor = cleanAuthor
this.priorities = {} this.priorities = {}
this.positions = {} this.positions = {}
this.currentPosition = 0
} }
add(title, position = 0) { add(title) {
// if title contains the author, remove it // if title contains the author, remove it
if (this.cleanAuthor) { title = this.#removeAuthorFromTitle(title)
const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g")
title = this.bookFinder.cleanAuthorForCompares(title).replace(authorRe, '').trim()
}
const titleTransformers = [ const titleTransformers = [
[/([,:;_]| by ).*/g, ''], // Remove subtitle [/([,:;_]| by ).*/g, ''], // Remove subtitle
@ -215,11 +171,11 @@ class BookFinder {
] ]
// Main variant // Main variant
const cleanTitle = this.bookFinder.cleanTitleForCompares(title).trim() const cleanTitle = cleanTitleForCompares(title).trim()
if (!cleanTitle) return if (!cleanTitle) return
this.candidates.add(cleanTitle) this.candidates.add(cleanTitle)
this.priorities[cleanTitle] = 0 this.priorities[cleanTitle] = 0
this.positions[cleanTitle] = position this.positions[cleanTitle] = this.currentPosition
let candidate = cleanTitle let candidate = cleanTitle
@ -230,10 +186,11 @@ class BookFinder {
if (candidate) { if (candidate) {
this.candidates.add(candidate) this.candidates.add(candidate)
this.priorities[candidate] = 0 this.priorities[candidate] = 0
this.positions[candidate] = position this.positions[candidate] = this.currentPosition
} }
this.priorities[cleanTitle] = 1 this.priorities[cleanTitle] = 1
} }
this.currentPosition++
} }
get size() { get size() {
@ -243,23 +200,16 @@ class BookFinder {
getCandidates() { getCandidates() {
var candidates = [...this.candidates] var candidates = [...this.candidates]
candidates.sort((a, b) => { candidates.sort((a, b) => {
// Candidates that include the author are likely low quality
const includesAuthorDiff = !b.includes(this.cleanAuthor) - !a.includes(this.cleanAuthor)
if (includesAuthorDiff) return includesAuthorDiff
// Candidates that include only digits are also likely low quality // Candidates that include only digits are also likely low quality
const onlyDigits = /^\d+$/ const onlyDigits = /^\d+$/
const includesOnlyDigitsDiff = !onlyDigits.test(b) - !onlyDigits.test(a) const includesOnlyDigitsDiff = onlyDigits.test(a) - onlyDigits.test(b)
if (includesOnlyDigitsDiff) return includesOnlyDigitsDiff if (includesOnlyDigitsDiff) return includesOnlyDigitsDiff
// transformed candidates receive higher priority // transformed candidates receive higher priority
const priorityDiff = this.priorities[a] - this.priorities[b] const priorityDiff = this.priorities[a] - this.priorities[b]
if (priorityDiff) return priorityDiff if (priorityDiff) return priorityDiff
// if same priorirty, prefer candidates that are closer to the beginning (e.g. titles before subtitles) // if same priorirty, prefer candidates that are closer to the beginning (e.g. titles before subtitles)
const positionDiff = this.positions[a] - this.positions[b] const positionDiff = this.positions[a] - this.positions[b]
if (positionDiff) return positionDiff return positionDiff // candidates with same priority always have different positions
// Start with longer candidaets, as they are likely more specific
const lengthDiff = b.length - a.length
if (lengthDiff) return lengthDiff
return b.localeCompare(a)
}) })
Logger.debug(`[${this.constructor.name}] Found ${candidates.length} fuzzy title candidates`) Logger.debug(`[${this.constructor.name}] Found ${candidates.length} fuzzy title candidates`)
Logger.debug(candidates) Logger.debug(candidates)
@ -269,21 +219,32 @@ class BookFinder {
delete(title) { delete(title) {
return this.candidates.delete(title) return this.candidates.delete(title)
} }
#removeAuthorFromTitle(title) {
if (!this.cleanAuthor) return title
const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g")
const authorCleanedTitle = cleanAuthorForCompares(title)
const authorCleanedTitleWithoutAuthor = authorCleanedTitle.replace(authorRe, '')
if (authorCleanedTitleWithoutAuthor !== authorCleanedTitle) {
return authorCleanedTitleWithoutAuthor.trim()
}
return title
}
} }
static AuthorCandidates = class { static AuthorCandidates = class {
constructor(bookFinder, cleanAuthor) { constructor(cleanAuthor, audnexus) {
this.bookFinder = bookFinder this.audnexus = audnexus
this.candidates = new Set() this.candidates = new Set()
this.cleanAuthor = cleanAuthor this.cleanAuthor = cleanAuthor
if (cleanAuthor) this.candidates.add(cleanAuthor) if (cleanAuthor) this.candidates.add(cleanAuthor)
} }
validateAuthor(name, region = '', maxLevenshtein = 2) { validateAuthor(name, region = '', maxLevenshtein = 2) {
return this.bookFinder.audnexus.authorASINsRequest(name, region).then((asins) => { return this.audnexus.authorASINsRequest(name, region).then((asins) => {
for (const [i, asin] of asins.entries()) { for (const [i, asin] of asins.entries()) {
if (i > 10) break if (i > 10) break
let cleanName = this.bookFinder.cleanAuthorForCompares(asin.name) let cleanName = cleanAuthorForCompares(asin.name)
if (!cleanName) continue if (!cleanName) continue
if (cleanName.includes(name)) return name if (cleanName.includes(name)) return name
if (name.includes(cleanName)) return cleanName if (name.includes(cleanName)) return cleanName
@ -294,7 +255,7 @@ class BookFinder {
} }
add(author) { add(author) {
const cleanAuthor = this.bookFinder.cleanAuthorForCompares(author).trim() const cleanAuthor = cleanAuthorForCompares(author).trim()
if (!cleanAuthor) return if (!cleanAuthor) return
this.candidates.add(cleanAuthor) this.candidates.add(cleanAuthor)
} }
@ -362,10 +323,10 @@ class BookFinder {
title = title.trim().toLowerCase() title = title.trim().toLowerCase()
author = author?.trim().toLowerCase() || '' author = author?.trim().toLowerCase() || ''
const cleanAuthor = this.cleanAuthorForCompares(author) const cleanAuthor = cleanAuthorForCompares(author)
// Now run up to maxFuzzySearches fuzzy searches // Now run up to maxFuzzySearches fuzzy searches
let authorCandidates = new BookFinder.AuthorCandidates(this, cleanAuthor) let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus)
// Remove underscores and parentheses with their contents, and replace with a separator // Remove underscores and parentheses with their contents, and replace with a separator
const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, " - ") const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, " - ")
@ -375,9 +336,9 @@ class BookFinder {
authorCandidates.add(titlePart) authorCandidates.add(titlePart)
authorCandidates = await authorCandidates.getCandidates() authorCandidates = await authorCandidates.getCandidates()
for (const authorCandidate of authorCandidates) { for (const authorCandidate of authorCandidates) {
let titleCandidates = new BookFinder.TitleCandidates(this, authorCandidate) let titleCandidates = new BookFinder.TitleCandidates(authorCandidate)
for (const [position, titlePart] of titleParts.entries()) for (const titlePart of titleParts)
titleCandidates.add(titlePart, position) titleCandidates.add(titlePart)
titleCandidates = titleCandidates.getCandidates() titleCandidates = titleCandidates.getCandidates()
for (const titleCandidate of titleCandidates) { for (const titleCandidate of titleCandidates) {
if (titleCandidate == title && authorCandidate == author) continue // We already tried this if (titleCandidate == title && authorCandidate == author) continue // We already tried this
@ -457,3 +418,52 @@ class BookFinder {
} }
} }
module.exports = new BookFinder() module.exports = new BookFinder()
function stripSubtitle(title) {
if (title.includes(':')) {
return title.split(':')[0].trim()
} else if (title.includes(' - ')) {
return title.split(' - ')[0].trim()
}
return title
}
function replaceAccentedChars(str) {
try {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
} catch (error) {
Logger.error('[BookFinder] str normalize error', error)
return str
}
}
function cleanTitleForCompares(title) {
if (!title) return ''
title = stripRedundantSpaces(title)
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
let stripped = stripSubtitle(title)
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
let cleaned = stripped.replace(/ *\([^)]*\) */g, "")
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
cleaned = cleaned.replace(/'/g, '')
return replaceAccentedChars(cleaned).toLowerCase()
}
function cleanAuthorForCompares(author) {
if (!author) return ''
author = stripRedundantSpaces(author)
let cleanAuthor = replaceAccentedChars(author).toLowerCase()
// separate initials
cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2')
// remove middle initials
cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '')
return cleanAuthor
}
function stripRedundantSpaces(str) {
return str.replace(/\s+/g, ' ').trim()
}

View File

@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2011-2014 Jared Hanson
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,20 @@
//
// modified for audiobookshelf
// Source: https://github.com/jaredhanson/passport-local
//
/**
* Module dependencies.
*/
var Strategy = require('./strategy');
/**
* Expose `Strategy` directly from package.
*/
exports = module.exports = Strategy;
/**
* Export constructors.
*/
exports.Strategy = Strategy;

View File

@ -0,0 +1,119 @@
/**
* Module dependencies.
*/
const passport = require('passport-strategy')
const util = require('util')
function lookup(obj, field) {
if (!obj) { return null; }
var chain = field.split(']').join('').split('[');
for (var i = 0, len = chain.length; i < len; i++) {
var prop = obj[chain[i]];
if (typeof (prop) === 'undefined') { return null; }
if (typeof (prop) !== 'object') { return prop; }
obj = prop;
}
return null;
}
/**
* `Strategy` constructor.
*
* The local authentication strategy authenticates requests based on the
* credentials submitted through an HTML-based login form.
*
* Applications must supply a `verify` callback which accepts `username` and
* `password` credentials, and then calls the `done` callback supplying a
* `user`, which should be set to `false` if the credentials are not valid.
* If an exception occured, `err` should be set.
*
* Optionally, `options` can be used to change the fields in which the
* credentials are found.
*
* Options:
* - `usernameField` field name where the username is found, defaults to _username_
* - `passwordField` field name where the password is found, defaults to _password_
* - `passReqToCallback` when `true`, `req` is the first argument to the verify callback (default: `false`)
*
* Examples:
*
* passport.use(new LocalStrategy(
* function(username, password, done) {
* User.findOne({ username: username, password: password }, function (err, user) {
* done(err, user);
* });
* }
* ));
*
* @param {Object} options
* @param {Function} verify
* @api public
*/
function Strategy(options, verify) {
if (typeof options == 'function') {
verify = options;
options = {};
}
if (!verify) { throw new TypeError('LocalStrategy requires a verify callback'); }
this._usernameField = options.usernameField || 'username';
this._passwordField = options.passwordField || 'password';
passport.Strategy.call(this);
this.name = 'local';
this._verify = verify;
this._passReqToCallback = options.passReqToCallback;
}
/**
* Inherit from `passport.Strategy`.
*/
util.inherits(Strategy, passport.Strategy);
/**
* Authenticate request based on the contents of a form submission.
*
* @param {Object} req
* @api protected
*/
Strategy.prototype.authenticate = function (req, options) {
options = options || {};
var username = lookup(req.body, this._usernameField)
if (username === null) {
lookup(req.query, this._usernameField);
}
var password = lookup(req.body, this._passwordField)
if (password === null) {
password = lookup(req.query, this._passwordField);
}
if (username === null || password === null) {
return this.fail({ message: options.badRequestMessage || 'Missing credentials' }, 400);
}
var self = this;
function verified(err, user, info) {
if (err) { return self.error(err); }
if (!user) { return self.fail(info); }
self.success(user, info);
}
try {
if (self._passReqToCallback) {
this._verify(req, username, password, verified);
} else {
this._verify(username, password, verified);
}
} catch (ex) {
return self.error(ex);
}
};
/**
* Expose `Strategy`.
*/
module.exports = Strategy;

View File

@ -0,0 +1,54 @@
const { LRUCache } = require('lru-cache')
const Logger = require('../Logger')
const Database = require('../Database')
class ApiCacheManager {
defaultCacheOptions = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: item => (item.body.length + JSON.stringify(item.headers).length) }
defaultTtlOptions = { ttl: 30 * 60 * 1000 }
constructor(cache = new LRUCache(this.defaultCacheOptions), ttlOptions = this.defaultTtlOptions) {
this.cache = cache
this.ttlOptions = ttlOptions
}
init(database = Database) {
let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy']
hooks.forEach(hook => database.sequelize.addHook(hook, (model) => this.clear(model, hook)))
}
clear(model, hook) {
Logger.debug(`[ApiCacheManager] ${model.constructor.name}.${hook}: Clearing cache`)
this.cache.clear()
}
get middleware() {
return (req, res, next) => {
const key = { user: req.user.username, url: req.url }
const stringifiedKey = JSON.stringify(key)
Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`)
const cached = this.cache.get(stringifiedKey)
if (cached) {
Logger.debug(`[ApiCacheManager] Cache hit: ${stringifiedKey}`)
res.set(cached.headers)
res.status(cached.statusCode)
res.send(cached.body)
return
}
res.originalSend = res.send
res.send = (body) => {
Logger.debug(`[ApiCacheManager] Cache miss: ${stringifiedKey}`)
const cached = { body, headers: res.getHeaders(), statusCode: res.statusCode }
if (key.url.search(/^\/libraries\/.*?\/personalized/) !== -1) {
Logger.debug(`[ApiCacheManager] Caching with ${this.ttlOptions.ttl} ms TTL`)
this.cache.set(stringifiedKey, cached, this.ttlOptions)
} else {
this.cache.set(stringifiedKey, cached)
}
res.originalSend(body)
}
next()
}
}
}
module.exports = ApiCacheManager

View File

@ -1,7 +1,9 @@
const uuidv4 = require("uuid").v4 const uuidv4 = require("uuid").v4
const { DataTypes, Model, Op } = require('sequelize') const sequelize = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const oldUser = require('../objects/user/User') const oldUser = require('../objects/user/User')
const SocketAuthority = require('../SocketAuthority')
const { DataTypes, Model } = sequelize
class User extends Model { class User extends Model {
constructor(values, options) { constructor(values, options) {
@ -46,6 +48,12 @@ class User extends Model {
return users.map(u => this.getOldUser(u)) return users.map(u => this.getOldUser(u))
} }
/**
* Get old user model from new
*
* @param {Object} userExpanded
* @returns {oldUser}
*/
static getOldUser(userExpanded) { static getOldUser(userExpanded) {
const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress()) const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress())
@ -72,18 +80,32 @@ class User extends Model {
createdAt: userExpanded.createdAt.valueOf(), createdAt: userExpanded.createdAt.valueOf(),
permissions, permissions,
librariesAccessible, librariesAccessible,
itemTagsSelected itemTagsSelected,
authOpenIDSub: userExpanded.extraData?.authOpenIDSub || null
}) })
} }
/**
*
* @param {oldUser} oldUser
* @returns {Promise<User>}
*/
static createFromOld(oldUser) { static createFromOld(oldUser) {
const user = this.getFromOld(oldUser) const user = this.getFromOld(oldUser)
return this.create(user) return this.create(user)
} }
static updateFromOld(oldUser) { /**
* Update User from old user model
*
* @param {oldUser} oldUser
* @param {boolean} [hooks=true] Run before / after bulk update hooks?
* @returns {Promise<boolean>}
*/
static updateFromOld(oldUser, hooks = true) {
const user = this.getFromOld(oldUser) const user = this.getFromOld(oldUser)
return this.update(user, { return this.update(user, {
hooks: !!hooks,
where: { where: {
id: user.id id: user.id
} }
@ -93,7 +115,21 @@ class User extends Model {
}) })
} }
/**
* Get new User model from old
*
* @param {oldUser} oldUser
* @returns {Object}
*/
static getFromOld(oldUser) { static getFromOld(oldUser) {
const extraData = {
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
oldUserId: oldUser.oldUserId
}
if (oldUser.authOpenIDSub) {
extraData.authOpenIDSub = oldUser.authOpenIDSub
}
return { return {
id: oldUser.id, id: oldUser.id,
username: oldUser.username, username: oldUser.username,
@ -103,10 +139,7 @@ class User extends Model {
token: oldUser.token || null, token: oldUser.token || null,
isActive: !!oldUser.isActive, isActive: !!oldUser.isActive,
lastSeen: oldUser.lastSeen || null, lastSeen: oldUser.lastSeen || null,
extraData: { extraData,
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
oldUserId: oldUser.oldUserId
},
createdAt: oldUser.createdAt || Date.now(), createdAt: oldUser.createdAt || Date.now(),
permissions: { permissions: {
...oldUser.permissions, ...oldUser.permissions,
@ -130,12 +163,12 @@ class User extends Model {
* @param {string} username * @param {string} username
* @param {string} pash * @param {string} pash
* @param {Auth} auth * @param {Auth} auth
* @returns {oldUser} * @returns {Promise<oldUser>}
*/ */
static async createRootUser(username, pash, auth) { static async createRootUser(username, pash, auth) {
const userId = uuidv4() const userId = uuidv4()
const token = await auth.generateAccessToken({ userId, username }) const token = await auth.generateAccessToken({ id: userId, username })
const newRoot = new oldUser({ const newRoot = new oldUser({
id: userId, id: userId,
@ -150,6 +183,38 @@ class User extends Model {
return newRoot return newRoot
} }
/**
* Create user from openid userinfo
* @param {Object} userinfo
* @param {Auth} auth
* @returns {Promise<oldUser>}
*/
static async createUserFromOpenIdUserInfo(userinfo, auth) {
const userId = uuidv4()
// TODO: Ensure username is unique?
const username = userinfo.preferred_username || userinfo.name || userinfo.sub
const email = (userinfo.email && userinfo.email_verified) ? userinfo.email : null
const token = await auth.generateAccessToken({ id: userId, username })
const newUser = new oldUser({
id: userId,
type: 'user',
username,
email,
pash: null,
token,
isActive: true,
authOpenIDSub: userinfo.sub,
createdAt: Date.now()
})
if (await this.createFromOld(newUser)) {
SocketAuthority.adminEmitter('user_added', newUser.toJSONForBrowser())
return newUser
}
return null
}
/** /**
* Get a user by id or by the old database id * Get a user by id or by the old database id
* @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id * @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id
@ -160,13 +225,13 @@ class User extends Model {
if (!userId) return null if (!userId) return null
const user = await this.findOne({ const user = await this.findOne({
where: { where: {
[Op.or]: [ [sequelize.Op.or]: [
{ {
id: userId id: userId
}, },
{ {
extraData: { extraData: {
[Op.substring]: userId [sequelize.Op.substring]: userId
} }
} }
] ]
@ -187,7 +252,26 @@ class User extends Model {
const user = await this.findOne({ const user = await this.findOne({
where: { where: {
username: { username: {
[Op.like]: username [sequelize.Op.like]: username
}
},
include: this.sequelize.models.mediaProgress
})
if (!user) return null
return this.getOldUser(user)
}
/**
* Get user by email case insensitive
* @param {string} username
* @returns {Promise<oldUser|null>} returns null if not found
*/
static async getUserByEmail(email) {
if (!email) return null
const user = await this.findOne({
where: {
email: {
[sequelize.Op.like]: email
} }
}, },
include: this.sequelize.models.mediaProgress include: this.sequelize.models.mediaProgress
@ -210,6 +294,21 @@ class User extends Model {
return this.getOldUser(user) return this.getOldUser(user)
} }
/**
* Get user by openid sub
* @param {string} sub
* @returns {Promise<oldUser|null>} returns null if not found
*/
static async getUserByOpenIDSub(sub) {
if (!sub) return null
const user = await this.findOne({
where: sequelize.where(sequelize.literal(`extraData->>"authOpenIDSub"`), sub),
include: this.sequelize.models.mediaProgress
})
if (!user) return null
return this.getOldUser(user)
}
/** /**
* Get array of user id and username * Get array of user id and username
* @returns {object[]} { id, username } * @returns {object[]} { id, username }

View File

@ -9,7 +9,7 @@ class LibrarySettings {
this.autoScanCronExpression = null this.autoScanCronExpression = null
this.audiobooksOnly = false this.audiobooksOnly = false
this.hideSingleBookSeries = false // Do not show series that only have 1 book this.hideSingleBookSeries = false // Do not show series that only have 1 book
this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata'] this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
if (settings) { if (settings) {
this.construct(settings) this.construct(settings)
@ -28,7 +28,7 @@ class LibrarySettings {
this.metadataPrecedence = [...settings.metadataPrecedence] this.metadataPrecedence = [...settings.metadataPrecedence]
} else { } else {
// Added in v2.4.5 // Added in v2.4.5
this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata'] this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
} }
} }

View File

@ -54,6 +54,24 @@ class ServerSettings {
this.version = packageJson.version this.version = packageJson.version
this.buildNumber = packageJson.buildNumber this.buildNumber = packageJson.buildNumber
// Auth settings
// Active auth methodes
this.authActiveAuthMethods = ['local']
// openid settings
this.authOpenIDIssuerURL = null
this.authOpenIDAuthorizationURL = null
this.authOpenIDTokenURL = null
this.authOpenIDUserInfoURL = null
this.authOpenIDJwksURL = null
this.authOpenIDLogoutURL = null
this.authOpenIDClientID = null
this.authOpenIDClientSecret = null
this.authOpenIDButtonText = 'Login with OpenId'
this.authOpenIDAutoLaunch = false
this.authOpenIDAutoRegister = false
this.authOpenIDMatchExistingBy = null
if (settings) { if (settings) {
this.construct(settings) this.construct(settings)
} }
@ -94,6 +112,36 @@ class ServerSettings {
this.version = settings.version || null this.version = settings.version || null
this.buildNumber = settings.buildNumber || 0 // Added v2.4.5 this.buildNumber = settings.buildNumber || 0 // Added v2.4.5
this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local']
this.authOpenIDIssuerURL = settings.authOpenIDIssuerURL || null
this.authOpenIDAuthorizationURL = settings.authOpenIDAuthorizationURL || null
this.authOpenIDTokenURL = settings.authOpenIDTokenURL || null
this.authOpenIDUserInfoURL = settings.authOpenIDUserInfoURL || null
this.authOpenIDJwksURL = settings.authOpenIDJwksURL || null
this.authOpenIDLogoutURL = settings.authOpenIDLogoutURL || null
this.authOpenIDClientID = settings.authOpenIDClientID || null
this.authOpenIDClientSecret = settings.authOpenIDClientSecret || null
this.authOpenIDButtonText = settings.authOpenIDButtonText || 'Login with OpenId'
this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch
this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister
this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null
if (!Array.isArray(this.authActiveAuthMethods)) {
this.authActiveAuthMethods = ['local']
}
// remove uninitialized methods
// OpenID
if (this.authActiveAuthMethods.includes('openid') && !this.isOpenIDAuthSettingsValid) {
this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('openid', 0), 1)
}
// fallback to local
if (!Array.isArray(this.authActiveAuthMethods) || this.authActiveAuthMethods.length == 0) {
this.authActiveAuthMethods = ['local']
}
// Migrations // Migrations
if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0 if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0
this.storeCoverWithItem = !!settings.storeCoverWithBook this.storeCoverWithItem = !!settings.storeCoverWithBook
@ -150,23 +198,96 @@ class ServerSettings {
language: this.language, language: this.language,
logLevel: this.logLevel, logLevel: this.logLevel,
version: this.version, version: this.version,
buildNumber: this.buildNumber buildNumber: this.buildNumber,
authActiveAuthMethods: this.authActiveAuthMethods,
authOpenIDIssuerURL: this.authOpenIDIssuerURL,
authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL,
authOpenIDTokenURL: this.authOpenIDTokenURL,
authOpenIDUserInfoURL: this.authOpenIDUserInfoURL,
authOpenIDJwksURL: this.authOpenIDJwksURL,
authOpenIDLogoutURL: this.authOpenIDLogoutURL,
authOpenIDClientID: this.authOpenIDClientID, // Do not return to client
authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client
authOpenIDButtonText: this.authOpenIDButtonText,
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy
} }
} }
toJSONForBrowser() { toJSONForBrowser() {
const json = this.toJSON() const json = this.toJSON()
delete json.tokenSecret delete json.tokenSecret
delete json.authOpenIDClientID
delete json.authOpenIDClientSecret
return json return json
} }
get supportedAuthMethods() {
return ['local', 'openid']
}
/**
* Auth settings required for openid to be valid
*/
get isOpenIDAuthSettingsValid() {
return this.authOpenIDIssuerURL &&
this.authOpenIDAuthorizationURL &&
this.authOpenIDTokenURL &&
this.authOpenIDUserInfoURL &&
this.authOpenIDJwksURL &&
this.authOpenIDClientID &&
this.authOpenIDClientSecret
}
get authenticationSettings() {
return {
authActiveAuthMethods: this.authActiveAuthMethods,
authOpenIDIssuerURL: this.authOpenIDIssuerURL,
authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL,
authOpenIDTokenURL: this.authOpenIDTokenURL,
authOpenIDUserInfoURL: this.authOpenIDUserInfoURL,
authOpenIDJwksURL: this.authOpenIDJwksURL,
authOpenIDLogoutURL: this.authOpenIDLogoutURL,
authOpenIDClientID: this.authOpenIDClientID, // Do not return to client
authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client
authOpenIDButtonText: this.authOpenIDButtonText,
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy
}
}
get authFormData() {
const clientFormData = {}
if (this.authActiveAuthMethods.includes('openid')) {
clientFormData.authOpenIDButtonText = this.authOpenIDButtonText
clientFormData.authOpenIDAutoLaunch = this.authOpenIDAutoLaunch
}
return clientFormData
}
/**
* Update server settings
*
* @param {Object} payload
* @returns {boolean} true if updates were made
*/
update(payload) { update(payload) {
var hasUpdates = false let hasUpdates = false
for (const key in payload) { for (const key in payload) {
if (key === 'sortingPrefixes' && payload[key] && payload[key].length) { if (key === 'sortingPrefixes') {
var prefixesCleaned = payload[key].filter(prefix => !!prefix).map(prefix => prefix.toLowerCase()) // Sorting prefixes are updated with the /api/sorting-prefixes endpoint
if (prefixesCleaned.join(',') !== this[key].join(',')) { continue
this[key] = [...prefixesCleaned] } else if (key === 'authActiveAuthMethods') {
if (!payload[key]?.length) {
Logger.error(`[ServerSettings] Invalid authActiveAuthMethods`, payload[key])
continue
}
this.authActiveAuthMethods.sort()
payload[key].sort()
if (payload[key].join() !== this.authActiveAuthMethods.join()) {
this.authActiveAuthMethods = payload[key]
hasUpdates = true hasUpdates = true
} }
} else if (this[key] !== payload[key]) { } else if (this[key] !== payload[key]) {

View File

@ -24,6 +24,8 @@ class User {
this.librariesAccessible = [] // Library IDs (Empty if ALL libraries) this.librariesAccessible = [] // Library IDs (Empty if ALL libraries)
this.itemTagsSelected = [] // Empty if ALL item tags accessible this.itemTagsSelected = [] // Empty if ALL item tags accessible
this.authOpenIDSub = null
if (user) { if (user) {
this.construct(user) this.construct(user)
} }
@ -66,7 +68,7 @@ class User {
getDefaultUserPermissions() { getDefaultUserPermissions() {
return { return {
download: true, download: true,
update: true, update: this.type === 'root' || this.type === 'admin',
delete: this.type === 'root', delete: this.type === 'root',
upload: this.type === 'root' || this.type === 'admin', upload: this.type === 'root' || this.type === 'admin',
accessAllLibraries: true, accessAllLibraries: true,
@ -93,7 +95,8 @@ class User {
createdAt: this.createdAt, createdAt: this.createdAt,
permissions: this.permissions, permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible], librariesAccessible: [...this.librariesAccessible],
itemTagsSelected: [...this.itemTagsSelected] itemTagsSelected: [...this.itemTagsSelected],
authOpenIDSub: this.authOpenIDSub
} }
} }
@ -186,6 +189,8 @@ class User {
this.librariesAccessible = [...(user.librariesAccessible || [])] this.librariesAccessible = [...(user.librariesAccessible || [])]
this.itemTagsSelected = [...(user.itemTagsSelected || [])] this.itemTagsSelected = [...(user.itemTagsSelected || [])]
this.authOpenIDSub = user.authOpenIDSub || null
} }
update(payload) { update(payload) {

View File

@ -35,6 +35,7 @@ const Series = require('../objects/entities/Series')
class ApiRouter { class ApiRouter {
constructor(Server) { constructor(Server) {
/** @type {import('../Auth')} */
this.auth = Server.auth this.auth = Server.auth
this.playbackSessionManager = Server.playbackSessionManager this.playbackSessionManager = Server.playbackSessionManager
this.abMergeManager = Server.abMergeManager this.abMergeManager = Server.abMergeManager
@ -47,6 +48,7 @@ class ApiRouter {
this.cronManager = Server.cronManager this.cronManager = Server.cronManager
this.notificationManager = Server.notificationManager this.notificationManager = Server.notificationManager
this.emailManager = Server.emailManager this.emailManager = Server.emailManager
this.apiCacheManager = Server.apiCacheManager
this.router = express() this.router = express()
this.router.disable('x-powered-by') this.router.disable('x-powered-by')
@ -57,6 +59,7 @@ class ApiRouter {
// //
// Library Routes // Library Routes
// //
this.router.get(/^\/libraries/, this.apiCacheManager.middleware)
this.router.post('/libraries', LibraryController.create.bind(this)) this.router.post('/libraries', LibraryController.create.bind(this))
this.router.get('/libraries', LibraryController.findAll.bind(this)) this.router.get('/libraries', LibraryController.findAll.bind(this))
this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this)) this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this))
@ -309,6 +312,8 @@ class ApiRouter {
this.router.post('/genres/rename', MiscController.renameGenre.bind(this)) this.router.post('/genres/rename', MiscController.renameGenre.bind(this))
this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this)) this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this))
this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this)) this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this))
this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this)) this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this))
} }

View File

@ -18,6 +18,7 @@ const BookFinder = require('../finders/BookFinder')
const LibraryScan = require("./LibraryScan") const LibraryScan = require("./LibraryScan")
const OpfFileScanner = require('./OpfFileScanner') const OpfFileScanner = require('./OpfFileScanner')
const NfoFileScanner = require('./NfoFileScanner')
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner') const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
/** /**
@ -593,7 +594,7 @@ class BookScanner {
} }
const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId) const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId)
const metadataPrecedence = librarySettings.metadataPrecedence || ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata'] const metadataPrecedence = librarySettings.metadataPrecedence || ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
libraryScan.addLog(LogLevel.DEBUG, `"${bookMetadata.title}" Getting metadata with precedence [${metadataPrecedence.join(', ')}]`) libraryScan.addLog(LogLevel.DEBUG, `"${bookMetadata.title}" Getting metadata with precedence [${metadataPrecedence.join(', ')}]`)
for (const metadataSource of metadataPrecedence) { for (const metadataSource of metadataPrecedence) {
if (bookMetadataSourceHandler[metadataSource]) { if (bookMetadataSourceHandler[metadataSource]) {
@ -649,6 +650,14 @@ class BookScanner {
AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan) AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan)
} }
/**
* Metadata from .nfo file
*/
async nfoFile() {
if (!this.libraryItemData.metadataNfoLibraryFile) return
await NfoFileScanner.scanBookNfoFile(this.libraryItemData.metadataNfoLibraryFile, this.bookMetadata)
}
/** /**
* Description from desc.txt and narrator from reader.txt * Description from desc.txt and narrator from reader.txt
*/ */

View File

@ -132,6 +132,11 @@ class LibraryItemScanData {
return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.opf') return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.opf')
} }
/** @type {LibraryItem.LibraryFileObject} */
get metadataNfoLibraryFile() {
return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.nfo')
}
/** /**
* *
* @param {LibraryItem} existingLibraryItem * @param {LibraryItem} existingLibraryItem

View File

@ -0,0 +1,48 @@
const { parseNfoMetadata } = require('../utils/parsers/parseNfoMetadata')
const { readTextFile } = require('../utils/fileUtils')
class NfoFileScanner {
constructor() { }
/**
* Parse metadata from .nfo file found in library scan and update bookMetadata
*
* @param {import('../models/LibraryItem').LibraryFileObject} nfoLibraryFileObj
* @param {Object} bookMetadata
*/
async scanBookNfoFile(nfoLibraryFileObj, bookMetadata) {
const nfoText = await readTextFile(nfoLibraryFileObj.metadata.path)
const nfoMetadata = nfoText ? await parseNfoMetadata(nfoText) : null
if (nfoMetadata) {
for (const key in nfoMetadata) {
if (key === 'tags') { // Add tags only if tags are empty
if (nfoMetadata.tags.length) {
bookMetadata.tags = nfoMetadata.tags
}
} else if (key === 'genres') { // Add genres only if genres are empty
if (nfoMetadata.genres.length) {
bookMetadata.genres = nfoMetadata.genres
}
} else if (key === 'authors') {
if (nfoMetadata.authors?.length) {
bookMetadata.authors = nfoMetadata.authors
}
} else if (key === 'narrators') {
if (nfoMetadata.narrators?.length) {
bookMetadata.narrators = nfoMetadata.narrators
}
} else if (key === 'series') {
if (nfoMetadata.series) {
bookMetadata.series = [{
name: nfoMetadata.series,
sequence: nfoMetadata.sequence || null
}]
}
} else if (nfoMetadata[key] && key !== 'sequence') {
bookMetadata[key] = nfoMetadata[key]
}
}
}
}
}
module.exports = new NfoFileScanner()

View File

@ -308,6 +308,7 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
.replace(lineBreaks, replacement) .replace(lineBreaks, replacement)
.replace(windowsReservedRe, replacement) .replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement) .replace(windowsTrailingRe, replacement)
.replace(/\s+/g, ' ') // Replace consecutive spaces with a single space
// Check if basename is too many bytes // Check if basename is too many bytes
const ext = Path.extname(sanitized) // separate out file extension const ext = Path.extname(sanitized) // separate out file extension

View File

@ -0,0 +1,100 @@
function parseNfoMetadata(nfoText) {
if (!nfoText) return null
const lines = nfoText.split(/\r?\n/)
const metadata = {}
let insideBookDescription = false
lines.forEach(line => {
if (line.search(/^\s*book description\s*$/i) !== -1) {
insideBookDescription = true
return
}
if (insideBookDescription) {
if (line.search(/^\s*=+\s*$/i) !== -1) return
metadata.description = metadata.description || ''
metadata.description += line + '\n'
return
}
const match = line.match(/^(.*?):(.*)$/)
if (match) {
const key = match[1].toLowerCase().trim()
const value = match[2].trim()
if (!value) return
switch (key) {
case 'title':
{
const titleMatch = value.match(/^(.*?):(.*)$/)
if (titleMatch) {
metadata.title = titleMatch[1].trim()
metadata.subtitle = titleMatch[2].trim()
} else {
metadata.title = value
}
}
break
case 'author':
metadata.authors = value.split(/\s*,\s*/).filter(v => v)
break
case 'narrator':
case 'read by':
metadata.narrators = value.split(/\s*,\s*/).filter(v => v)
break
case 'series name':
metadata.series = value
break
case 'genre':
metadata.genres = value.split(/\s*,\s*/).filter(v => v)
break
case 'tags':
metadata.tags = value.split(/\s*,\s*/).filter(v => v)
break
case 'copyright':
case 'audible.com release':
case 'audiobook copyright':
case 'book copyright':
case 'recording copyright':
case 'release date':
case 'date':
{
const year = extractYear(value)
if (year) {
metadata.publishedYear = year
}
}
break
case 'position in series':
metadata.sequence = value
break
case 'unabridged':
metadata.abridged = value.toLowerCase() === 'yes' ? false : true
break
case 'abridged':
metadata.abridged = value.toLowerCase() === 'no' ? false : true
break
case 'publisher':
metadata.publisher = value
break
case 'asin':
metadata.asin = value
break
case 'isbn':
case 'isbn-10':
case 'isbn-13':
metadata.isbn = value
break
}
}
})
// Trim leading/trailing whitespace for description
if (metadata.description) {
metadata.description = metadata.description.trim()
}
return metadata
}
module.exports = { parseNfoMetadata }
function extractYear(str) {
const match = str.match(/\d{4}/g)
return match ? match[match.length - 1] : null
}

View File

@ -0,0 +1,344 @@
const sinon = require('sinon')
const chai = require('chai')
const expect = chai.expect
const bookFinder = require('../../../server/finders/BookFinder')
const { LogLevel } = require('../../../server/utils/constants')
const Logger = require('../../../server/Logger')
Logger.setLogLevel(LogLevel.INFO)
describe('TitleCandidates', () => {
describe('cleanAuthor non-empty', () => {
let titleCandidates
const cleanAuthor = 'leo tolstoy'
beforeEach(() => {
titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor)
})
describe('no adds', () => {
it('returns no candidates', () => {
expect(titleCandidates.getCandidates()).to.deep.equal([])
})
})
describe('single add', () => {
[
['adds candidate', 'anna karenina', ['anna karenina']],
['adds lowercased candidate', 'ANNA KARENINA', ['anna karenina']],
['adds candidate, removing redundant spaces', 'anna karenina', ['anna karenina']],
['adds candidate, removing author', `anna karenina by ${cleanAuthor}`, ['anna karenina']],
['does not add empty candidate after removing author', cleanAuthor, []],
['adds candidate, removing subtitle', 'anna karenina: subtitle', ['anna karenina']],
['adds candidate + variant, removing "by ..."', 'anna karenina by arnold schwarzenegger', ['anna karenina', 'anna karenina by arnold schwarzenegger']],
['adds candidate + variant, removing bitrate', 'anna karenina 64kbps', ['anna karenina', 'anna karenina 64kbps']],
['adds candidate + variant, removing edition 1', 'anna karenina 2nd edition', ['anna karenina', 'anna karenina 2nd edition']],
['adds candidate + variant, removing edition 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']],
['adds candidate + variant, removing fie type', 'anna karenina.mp3', ['anna karenina', 'anna karenina.mp3']],
['adds candidate + variant, removing "a novel"', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']],
['adds candidate + variant, removing preceding/trailing numbers', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']],
['does not add empty candidate', '', []],
['does not add spaces-only candidate', ' ', []],
['does not add empty variant', '1984', ['1984']],
].forEach(([name, title, expected]) => it(name, () => {
titleCandidates.add(title)
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
}))
})
describe('multiple adds', () => {
[
['demotes digits-only candidates', ['01', 'anna karenina'], ['anna karenina', '01']],
['promotes transformed variants', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']],
['orders by position', ['title2', 'title1'], ['title2', 'title1']],
['dedupes candidates', ['title1', 'title1'], ['title1']],
].forEach(([name, titles, expected]) => it(name, () => {
for (const title of titles) titleCandidates.add(title)
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
}))
})
})
describe('cleanAuthor empty', () => {
let titleCandidates
let cleanAuthor = ''
beforeEach(() => {
titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor)
})
describe('single add', () => {
[
['adds a candidate', 'leo tolstoy', ['leo tolstoy']],
].forEach(([name, title, expected]) => it(name, () => {
titleCandidates.add(title)
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
}))
})
})
})
describe('AuthorCandidates', () => {
let authorCandidates
const audnexus = {
authorASINsRequest: sinon.stub().resolves([
{ name: 'Leo Tolstoy' },
{ name: 'Nikolai Gogol' },
{ name: 'J. K. Rowling' },
]),
}
describe('cleanAuthor is null', () => {
beforeEach(() => {
authorCandidates = new bookFinder.constructor.AuthorCandidates(null, audnexus)
})
describe('no adds', () => {
[
['returns empty author candidate', []],
].forEach(([name, expected]) => it(name, async () => {
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
describe('single add', () => {
[
['adds recognized candidate', 'nikolai gogol', ['nikolai gogol']],
['does not add unrecognized candidate', 'fyodor dostoevsky', []],
['adds recognized author if candidate is a superstring', 'dr. nikolai gogol', ['nikolai gogol']],
['adds candidate if it is a substring of recognized author', 'gogol', ['gogol']],
['adds recognized author if edit distance from candidate is small', 'nicolai gogol', ['nikolai gogol']],
['does not add candidate if edit distance from any recognized author is large', 'nikolai google', []],
['adds normalized recognized candidate (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']],
['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']],
].forEach(([name, author, expected]) => it(name, async () => {
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
describe('multi add', () => {
[
['adds recognized author candidates', ['nikolai gogol', 'leo tolstoy'], ['nikolai gogol', 'leo tolstoy']],
['dedupes author candidates', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']],
].forEach(([name, authors, expected]) => it(name, async () => {
for (const author of authors) authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
})
describe('cleanAuthor is a recognized author', () => {
const cleanAuthor = 'leo tolstoy'
beforeEach(() => {
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
})
describe('no adds', () => {
[
['adds cleanAuthor as candidate', [cleanAuthor]],
].forEach(([name, expected]) => it(name, async () => {
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
describe('single add', () => {
[
['adds recognized candidate', 'nikolai gogol', [cleanAuthor, 'nikolai gogol']],
['does not add candidate if it is a dupe of cleanAuthor', cleanAuthor, [cleanAuthor]],
].forEach(([name, author, expected]) => it(name, async () => {
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
})
describe('cleanAuthor is an unrecognized author', () => {
const cleanAuthor = 'Fyodor Dostoevsky'
beforeEach(() => {
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
})
describe('no adds', () => {
[
['adds cleanAuthor as candidate', [cleanAuthor]],
].forEach(([name, expected]) => it(name, async () => {
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
describe('single add', () => {
[
['adds recognized candidate and removes cleanAuthor', 'nikolai gogol', ['nikolai gogol']],
['does not add unrecognized candidate', 'jackie chan', [cleanAuthor]],
].forEach(([name, author, expected]) => it(name, async () => {
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
})
describe('cleanAuthor is unrecognized and dirty', () => {
describe('no adds', () => {
[
['adds aggressively cleaned cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', ['fyodor dostoevsky']],
['adds cleanAuthor if aggresively cleaned cleanAuthor is empty', ', jackie chan', [', jackie chan']],
].forEach(([name, cleanAuthor, expected]) => it(name, async () => {
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
describe('single add', () => {
[
['adds recognized candidate and removes cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', 'nikolai gogol', ['nikolai gogol']],
].forEach(([name, cleanAuthor, author, expected]) => it(name, async () => {
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
})
})
describe('search', () => {
const t = 'title'
const a = 'author'
const u = 'unrecognized'
const r = ['book']
const runSearchStub = sinon.stub(bookFinder, 'runSearch')
runSearchStub.resolves([])
runSearchStub.withArgs(t, a).resolves(r)
runSearchStub.withArgs(t, u).resolves(r)
const audnexusStub = sinon.stub(bookFinder.audnexus, 'authorASINsRequest')
audnexusStub.resolves([{ name: a }])
beforeEach(() => {
bookFinder.runSearch.resetHistory()
})
describe('search title is empty', () => {
it('returns empty result', async () => {
expect(await bookFinder.search('', '', a)).to.deep.equal([])
sinon.assert.callCount(bookFinder.runSearch, 0)
})
})
describe('search title is a recognized title and search author is a recognized author', () => {
it('returns non-empty result (no fuzzy searches)', async () => {
expect(await bookFinder.search('', t, a)).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 1)
})
})
describe('search title contains recognized title and search author is a recognized author', () => {
[
[`${t} -`],
[`${t} - ${a}`],
[`${a} - ${t}`],
[`${t}- ${a}`],
[`${t} -${a}`],
[`${t} ${a}`],
[`${a} - ${t} (unabridged)`],
[`${a} - ${t} (subtitle) - mp3`],
[`${t} {narrator} - series-01 64kbps 10:00:00`],
[`${a} - ${t} (2006) narrated by narrator [unabridged]`],
[`${t} - ${a} 2022 mp3`],
[`01 ${t}`],
[`2022_${t}_HQ`],
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => {
expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 2)
})
});
[
[`s-01 - ${t} (narrator) 64kbps 10:00:00`],
[`${a} - series 01 - ${t}`],
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => {
expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 3)
})
});
[
[`${t}-${a}`],
[`${t} junk`],
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns an empty result`, async () => {
expect(await bookFinder.search('', searchTitle, a)).to.deep.equal([])
})
})
describe('maxFuzzySearches = 0', () => {
[
[`${t} - ${a}`],
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns an empty result (with no fuzzy searches)`, async () => {
expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([])
sinon.assert.callCount(bookFinder.runSearch, 1)
})
})
})
describe('maxFuzzySearches = 1', () => {
[
[`s-01 - ${t} (narrator) 64kbps 10:00:00`],
[`${a} - series 01 - ${t}`],
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns an empty result (1 fuzzy search)`, async () => {
expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([])
sinon.assert.callCount(bookFinder.runSearch, 2)
})
})
})
})
describe('search title contains recognized title and search author is empty', () => {
[
[`${t} - ${a}`],
[`${a} - ${t}`],
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => {
expect(await bookFinder.search('', searchTitle, '')).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 2)
})
});
[
[`${t}`],
[`${t} - ${u}`],
[`${u} - ${t}`]
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '') returns an empty result`, async () => {
expect(await bookFinder.search('', searchTitle, '')).to.deep.equal([])
})
})
})
describe('search title contains recognized title and search author is an unrecognized author', () => {
[
[`${t} - ${u}`],
[`${u} - ${t}`]
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => {
expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 2)
})
});
[
[`${t}`]
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => {
expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 1)
})
})
})
})

View File

@ -0,0 +1,97 @@
// Import dependencies and modules for testing
const { expect } = require('chai')
const sinon = require('sinon')
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
describe('ApiCacheManager', () => {
let cache
let req
let res
let next
let manager
beforeEach(() => {
cache = { get: sinon.stub(), set: sinon.spy() }
req = { user: { username: 'testUser' }, url: '/test-url' }
res = { send: sinon.spy(), getHeaders: sinon.stub(), statusCode: 200, status: sinon.spy(), set: sinon.spy() }
next = sinon.spy()
})
describe('middleware', () => {
it('should send cached data if available', () => {
// Arrange
const cachedData = { body: 'cached data', headers: { 'content-type': 'application/json' }, statusCode: 200 }
cache.get.returns(cachedData)
const key = JSON.stringify({ user: req.user.username, url: req.url })
manager = new ApiCacheManager(cache)
// Act
manager.middleware(req, res, next)
// Assert
expect(cache.get.calledOnce).to.be.true
expect(cache.get.calledWith(key)).to.be.true
expect(res.set.calledOnce).to.be.true
expect(res.set.calledWith(cachedData.headers)).to.be.true
expect(res.status.calledOnce).to.be.true
expect(res.status.calledWith(cachedData.statusCode)).to.be.true
expect(res.send.calledOnce).to.be.true
expect(res.send.calledWith(cachedData.body)).to.be.true
expect(res.originalSend).to.be.undefined
expect(next.called).to.be.false
expect(cache.set.called).to.be.false
})
it('should cache and send response if data is not cached', () => {
// Arrange
cache.get.returns(null)
const headers = { 'content-type': 'application/json' }
res.getHeaders.returns(headers)
const body = 'response data'
const statusCode = 200
const responseData = { body, headers, statusCode }
const key = JSON.stringify({ user: req.user.username, url: req.url })
manager = new ApiCacheManager(cache)
// Act
manager.middleware(req, res, next)
res.send(body)
// Assert
expect(cache.get.calledOnce).to.be.true
expect(cache.get.calledWith(key)).to.be.true
expect(next.calledOnce).to.be.true
expect(cache.set.calledOnce).to.be.true
expect(cache.set.calledWith(key, responseData)).to.be.true
expect(res.originalSend.calledOnce).to.be.true
expect(res.originalSend.calledWith(body)).to.be.true
})
it('should cache personalized response with 30 minutes TTL', () => {
// Arrange
cache.get.returns(null)
const headers = { 'content-type': 'application/json' }
res.getHeaders.returns(headers)
const body = 'personalized data'
const statusCode = 200
const responseData = { body, headers, statusCode }
req.url = '/libraries/id/personalized'
const key = JSON.stringify({ user: req.user.username, url: req.url })
const ttlOptions = { ttl: 30 * 60 * 1000 }
manager = new ApiCacheManager(cache, ttlOptions)
// Act
manager.middleware(req, res, next)
res.send(body)
// Assert
expect(cache.get.calledOnce).to.be.true
expect(cache.get.calledWith(key)).to.be.true
expect(next.calledOnce).to.be.true
expect(cache.set.calledOnce).to.be.true
expect(cache.set.calledWith(key, responseData, ttlOptions)).to.be.true
expect(res.originalSend.calledOnce).to.be.true
expect(res.originalSend.calledWith(body)).to.be.true
})
})
})

View File

@ -0,0 +1,123 @@
const chai = require('chai')
const expect = chai.expect
const { parseNfoMetadata } = require('../../../../server/utils/parsers/parseNfoMetadata')
describe('parseNfoMetadata', () => {
it('returns null if nfoText is empty', () => {
const result = parseNfoMetadata('')
expect(result).to.be.null
})
it('parses title', () => {
const nfoText = 'Title: The Great Gatsby'
const result = parseNfoMetadata(nfoText)
expect(result.title).to.equal('The Great Gatsby')
})
it('parses title with subtitle', () => {
const nfoText = 'Title: The Great Gatsby: A Novel'
const result = parseNfoMetadata(nfoText)
expect(result.title).to.equal('The Great Gatsby')
expect(result.subtitle).to.equal('A Novel')
})
it('parses authors', () => {
const nfoText = 'Author: F. Scott Fitzgerald'
const result = parseNfoMetadata(nfoText)
expect(result.authors).to.deep.equal(['F. Scott Fitzgerald'])
})
it('parses multiple authors', () => {
const nfoText = 'Author: John Steinbeck, Ernest Hemingway'
const result = parseNfoMetadata(nfoText)
expect(result.authors).to.deep.equal(['John Steinbeck', 'Ernest Hemingway'])
})
it('parses narrators', () => {
const nfoText = 'Read by: Jake Gyllenhaal'
const result = parseNfoMetadata(nfoText)
expect(result.narrators).to.deep.equal(['Jake Gyllenhaal'])
})
it('parses multiple narrators', () => {
const nfoText = 'Read by: Jake Gyllenhaal, Kate Winslet'
const result = parseNfoMetadata(nfoText)
expect(result.narrators).to.deep.equal(['Jake Gyllenhaal', 'Kate Winslet'])
})
it('parses series name', () => {
const nfoText = 'Series Name: Harry Potter'
const result = parseNfoMetadata(nfoText)
expect(result.series).to.equal('Harry Potter')
})
it('parses genre', () => {
const nfoText = 'Genre: Fiction'
const result = parseNfoMetadata(nfoText)
expect(result.genres).to.deep.equal(['Fiction'])
})
it('parses multiple genres', () => {
const nfoText = 'Genre: Fiction, Historical'
const result = parseNfoMetadata(nfoText)
expect(result.genres).to.deep.equal(['Fiction', 'Historical'])
})
it('parses tags', () => {
const nfoText = 'Tags: mystery, thriller'
const result = parseNfoMetadata(nfoText)
expect(result.tags).to.deep.equal(['mystery', 'thriller'])
})
it('parses year from various date fields', () => {
const nfoText = 'Release Date: 2021-05-01\nBook Copyright: 2021\nRecording Copyright: 2021'
const result = parseNfoMetadata(nfoText)
expect(result.publishedYear).to.equal('2021')
})
it('parses position in series', () => {
const nfoText = 'Position in Series: 2'
const result = parseNfoMetadata(nfoText)
expect(result.sequence).to.equal('2')
})
it('parses abridged flag', () => {
const nfoText = 'Abridged: No'
const result = parseNfoMetadata(nfoText)
expect(result.abridged).to.be.false
const nfoText2 = 'Unabridged: Yes'
const result2 = parseNfoMetadata(nfoText2)
expect(result2.abridged).to.be.false
})
it('parses publisher', () => {
const nfoText = 'Publisher: Penguin Random House'
const result = parseNfoMetadata(nfoText)
expect(result.publisher).to.equal('Penguin Random House')
})
it('parses ASIN', () => {
const nfoText = 'ASIN: B08X5JZJLH'
const result = parseNfoMetadata(nfoText)
expect(result.asin).to.equal('B08X5JZJLH')
})
it('parses description', () => {
const nfoText = 'Book Description\n=========\nThis is a book.\n It\'s good'
const result = parseNfoMetadata(nfoText)
expect(result.description).to.equal('This is a book.\n It\'s good')
})
it('no value', () => {
const nfoText = 'Title:'
const result = parseNfoMetadata(nfoText)
expect(result.title).to.be.undefined
})
it('no year value', () => {
const nfoText = "Date:0"
const result = parseNfoMetadata(nfoText)
expect(result.publishedYear).to.be.undefined
})
})