mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-03-20 10:26:40 +01:00
Map english translations and merge with gu
This commit is contained in:
commit
450507a812
3
.gitignore
vendored
3
.gitignore
vendored
@ -7,11 +7,12 @@
|
||||
/podcasts/
|
||||
/media/
|
||||
/metadata/
|
||||
test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/dist/
|
||||
/deploy/
|
||||
/coverage/
|
||||
/.nyc_output/
|
||||
|
||||
sw.*
|
||||
.DS_STORE
|
||||
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -16,5 +16,6 @@
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.detectIndentation": true,
|
||||
"editor.tabSize": 2
|
||||
"editor.tabSize": 2,
|
||||
"javascript.format.semicolons": "remove"
|
||||
}
|
@ -259,3 +259,23 @@ Bookshelf Label
|
||||
.no-bars .Vue-Toastification__container.top-right {
|
||||
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);
|
||||
}
|
@ -320,9 +320,11 @@ export default {
|
||||
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
|
||||
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
|
||||
this.$axios
|
||||
|
@ -338,9 +338,15 @@ export default {
|
||||
libraryItemsAdded(libraryItems) {
|
||||
console.log('libraryItems added', libraryItems)
|
||||
|
||||
const isThisLibrary = !libraryItems.some((li) => li.libraryId !== this.currentLibraryId)
|
||||
if (!this.search && isThisLibrary) {
|
||||
this.fetchCategories()
|
||||
const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added')
|
||||
if (!recentlyAddedShelf) return
|
||||
|
||||
// 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) {
|
||||
|
@ -36,7 +36,7 @@
|
||||
</svg>
|
||||
</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'">
|
||||
<p class="text-sm">{{ $strings.ButtonSearch }}</p>
|
||||
<p class="text-sm">{{ $strings.ButtonAdd }}</p>
|
||||
</nuxt-link>
|
||||
</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">
|
||||
|
@ -104,6 +104,11 @@ export default {
|
||||
id: 'config-rss-feeds',
|
||||
title: this.$strings.HeaderRSSFeeds,
|
||||
path: '/config/rss-feeds'
|
||||
},
|
||||
{
|
||||
id: 'config-authentication',
|
||||
title: this.$strings.HeaderAuthentication,
|
||||
path: '/config/authentication'
|
||||
}
|
||||
]
|
||||
|
||||
|
@ -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'">
|
||||
<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" />
|
||||
</nuxt-link>
|
||||
|
@ -8,7 +8,7 @@
|
||||
<!-- 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">
|
||||
<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>
|
||||
|
||||
<!-- Search icon btn -->
|
||||
|
@ -15,24 +15,33 @@
|
||||
|
||||
<div class="flex my-2 -mx-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 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">
|
||||
<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 v-if="!isPodcast" class="flex my-2 -mx-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 class="w-1/2 px-2">
|
||||
<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>
|
||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
|
||||
<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 h-10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -48,8 +57,8 @@
|
||||
<p class="text-base">{{ $strings.MessageUploaderItemFailed }}</p>
|
||||
</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">
|
||||
<ui-loading-indicator :text="$strings.MessageUploading" />
|
||||
<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="nonInteractionLabel" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -64,7 +73,8 @@ export default {
|
||||
default: () => { }
|
||||
},
|
||||
mediaType: String,
|
||||
processing: Boolean
|
||||
processing: Boolean,
|
||||
provider: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -76,7 +86,8 @@ export default {
|
||||
error: '',
|
||||
isUploading: false,
|
||||
uploadFailed: false,
|
||||
uploadSuccess: false
|
||||
uploadSuccess: false,
|
||||
isFetchingMetadata: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -87,12 +98,19 @@ export default {
|
||||
if (!this.itemData.title) return ''
|
||||
if (this.isPodcast) return this.itemData.title
|
||||
|
||||
if (this.itemData.series && this.itemData.author) {
|
||||
return Path.join(this.itemData.author, this.itemData.series, this.itemData.title)
|
||||
} else if (this.itemData.author) {
|
||||
return Path.join(this.itemData.author, this.itemData.title)
|
||||
} else {
|
||||
return this.itemData.title
|
||||
const outputPathParts = [this.itemData.author, this.itemData.series, this.itemData.title]
|
||||
const cleanedOutputPathParts = outputPathParts.filter(Boolean).map(part => this.$sanitizeFilename(part))
|
||||
|
||||
return Path.join(...cleanedOutputPathParts)
|
||||
},
|
||||
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() {
|
||||
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() {
|
||||
if (!this.itemData.title) {
|
||||
this.error = 'Must have a title'
|
||||
this.error = this.$strings.ErrorUploadLacksTitle
|
||||
return null
|
||||
}
|
||||
this.error = ''
|
||||
|
@ -848,9 +848,11 @@ export default {
|
||||
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
|
||||
|
||||
this.processing = true
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
axios
|
||||
|
@ -14,8 +14,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tracks: [],
|
||||
showFullPath: false
|
||||
tracks: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
@ -127,7 +127,7 @@ export default {
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
autoScanCronExpression: null,
|
||||
hideSingleBookSeries: false,
|
||||
metadataPrecedence: ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata']
|
||||
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -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">
|
||||
<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">
|
||||
{{ 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 class="flex-grow px-4 py-3">{{ source.name }}</div>
|
||||
<div class="px-2 opacity-100">
|
||||
<ui-toggle-switch v-model="source.include" :off-color="'error'" @input="includeToggled(source)" />
|
||||
</div>
|
||||
@ -64,6 +66,11 @@ export default {
|
||||
name: 'Audio file meta tags',
|
||||
include: true
|
||||
},
|
||||
nfoFile: {
|
||||
id: 'nfoFile',
|
||||
name: 'NFO file',
|
||||
include: true
|
||||
},
|
||||
txtFiles: {
|
||||
id: 'txtFiles',
|
||||
name: 'desc.txt & reader.txt files',
|
||||
@ -92,20 +99,34 @@ export default {
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.mediaType === 'book'
|
||||
},
|
||||
firstActiveSourceIndex() {
|
||||
return this.metadataSourceMapped.findIndex((source) => source.include)
|
||||
},
|
||||
lastActiveSourceIndex() {
|
||||
return this.metadataSourceMapped.findLastIndex((source) => source.include)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getSourceIndex(source) {
|
||||
const activeSources = (this.librarySettings.metadataPrecedence || []).map((s) => s).reverse()
|
||||
return activeSources.findIndex((s) => s === source) + 1
|
||||
},
|
||||
resetToDefault() {
|
||||
this.metadataSourceMapped = []
|
||||
for (const key in this.metadataSourceData) {
|
||||
this.metadataSourceMapped.push({ ...this.metadataSourceData[key] })
|
||||
}
|
||||
this.metadataSourceMapped.reverse()
|
||||
|
||||
this.$emit('update', this.getLibraryData())
|
||||
},
|
||||
getLibraryData() {
|
||||
const metadataSourceIds = this.metadataSourceMapped.map((source) => (source.include ? source.id : null)).filter((s) => s)
|
||||
metadataSourceIds.reverse()
|
||||
return {
|
||||
settings: {
|
||||
metadataPrecedence: this.metadataSourceMapped.map((source) => (source.include ? source.id : null)).filter((s) => s)
|
||||
metadataPrecedence: metadataSourceIds
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -120,15 +141,16 @@ export default {
|
||||
},
|
||||
init() {
|
||||
const metadataPrecedence = this.librarySettings.metadataPrecedence || []
|
||||
|
||||
this.metadataSourceMapped = metadataPrecedence.map((source) => this.metadataSourceData[source]).filter((s) => s)
|
||||
|
||||
for (const sourceKey in this.metadataSourceData) {
|
||||
if (!metadataPrecedence.includes(sourceKey)) {
|
||||
const unusedSourceData = { ...this.metadataSourceData[sourceKey], include: false }
|
||||
this.metadataSourceMapped.push(unusedSourceData)
|
||||
this.metadataSourceMapped.unshift(unusedSourceData)
|
||||
}
|
||||
}
|
||||
|
||||
this.metadataSourceMapped.reverse()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
@ -6,7 +6,7 @@
|
||||
<span class="text-sm font-mono">{{ ebookFiles.length }}</span>
|
||||
</div>
|
||||
<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' : ''">
|
||||
<span class="material-icons text-4xl">expand_more</span>
|
||||
</div>
|
||||
@ -75,6 +75,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleFullPath() {
|
||||
this.showFullPath = !this.showFullPath
|
||||
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
|
||||
},
|
||||
readEbook(fileIno) {
|
||||
this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno })
|
||||
},
|
||||
@ -82,6 +86,10 @@ export default {
|
||||
this.showFiles = !this.showFiles
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
if (this.userIsAdmin) {
|
||||
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -6,7 +6,7 @@
|
||||
<span class="text-sm font-mono">{{ files.length }}</span>
|
||||
</div>
|
||||
<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' : ''">
|
||||
<span class="material-icons text-4xl">expand_more</span>
|
||||
</div>
|
||||
@ -84,6 +84,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleFullPath() {
|
||||
this.showFullPath = !this.showFullPath
|
||||
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
|
||||
},
|
||||
clickBar() {
|
||||
this.showFiles = !this.showFiles
|
||||
},
|
||||
@ -93,6 +97,9 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.userIsAdmin) {
|
||||
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
|
||||
}
|
||||
this.showFiles = this.expanded
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,7 @@
|
||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||
</div>
|
||||
<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>
|
||||
<ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
|
||||
</nuxt-link>
|
||||
@ -74,6 +74,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleFullPath() {
|
||||
this.showFullPath = !this.showFullPath
|
||||
localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0)
|
||||
},
|
||||
clickBar() {
|
||||
this.showTracks = !this.showTracks
|
||||
},
|
||||
@ -82,6 +86,10 @@ export default {
|
||||
this.showAudioFileDataModal = true
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
if (this.userIsAdmin) {
|
||||
this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,5 +1,5 @@
|
||||
<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 />
|
||||
<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">
|
||||
@ -7,7 +7,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
</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 />
|
||||
<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">
|
||||
@ -72,23 +72,3 @@ export default {
|
||||
mounted() {}
|
||||
}
|
||||
</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>
|
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
@ -19,8 +19,8 @@
|
||||
|
||||
<div class="w-full h-px bg-white/10 my-4" />
|
||||
|
||||
<p v-if="!isGuest" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p>
|
||||
<form v-if="!isGuest" @submit.prevent="submitChangePassword">
|
||||
<p v-if="showChangePasswordForm" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p>
|
||||
<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="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" />
|
||||
@ -68,6 +68,13 @@ export default {
|
||||
},
|
||||
isGuest() {
|
||||
return this.usertype === 'guest'
|
||||
},
|
||||
isPasswordAuthEnabled() {
|
||||
const activeAuthMethods = this.$store.getters['getServerSetting']('authActiveAuthMethods') || []
|
||||
return activeAuthMethods.includes('local')
|
||||
},
|
||||
showChangePasswordForm() {
|
||||
return !this.isGuest && this.isPasswordAuthEnabled
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -57,6 +57,7 @@ export default {
|
||||
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
||||
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
|
||||
else if (pageName === 'email') return this.$strings.HeaderEmail
|
||||
else if (pageName === 'authentication') return this.$strings.HeaderAuthentication
|
||||
}
|
||||
return this.$strings.HeaderSettings
|
||||
}
|
||||
|
244
client/pages/config/authentication.vue
Normal file
244
client/pages/config/authentication.vue
Normal 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>
|
@ -686,9 +686,11 @@ export default {
|
||||
checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox,
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0),
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1)
|
||||
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
|
||||
.then(() => {
|
||||
|
@ -25,9 +25,12 @@
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<form @submit.prevent="submitForm">
|
||||
|
||||
<form v-show="login_local" @submit.prevent="submitForm">
|
||||
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
|
||||
<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>
|
||||
</div>
|
||||
</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>
|
||||
@ -60,7 +71,10 @@ export default {
|
||||
},
|
||||
confirmPassword: '',
|
||||
ConfigPath: '',
|
||||
MetadataPath: ''
|
||||
MetadataPath: '',
|
||||
login_local: true,
|
||||
login_openid: false,
|
||||
authFormData: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -93,6 +107,12 @@ export default {
|
||||
computed: {
|
||||
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: {
|
||||
@ -162,6 +182,7 @@ export default {
|
||||
else this.error = 'Unknown Error'
|
||||
return false
|
||||
})
|
||||
|
||||
if (authRes?.error) {
|
||||
this.error = authRes.error
|
||||
} else if (authRes) {
|
||||
@ -196,28 +217,62 @@ export default {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$get('/status')
|
||||
.then((res) => {
|
||||
this.processing = false
|
||||
this.isInit = res.isInit
|
||||
this.showInitScreen = !res.isInit
|
||||
this.$setServerLanguageCode(res.language)
|
||||
.then((data) => {
|
||||
this.isInit = data.isInit
|
||||
this.showInitScreen = !data.isInit
|
||||
this.$setServerLanguageCode(data.language)
|
||||
if (this.showInitScreen) {
|
||||
this.ConfigPath = res.ConfigPath || ''
|
||||
this.MetadataPath = res.MetadataPath || ''
|
||||
this.ConfigPath = data.ConfigPath || ''
|
||||
this.MetadataPath = data.MetadataPath || ''
|
||||
} else {
|
||||
this.authFormData = data.authFormData
|
||||
this.updateLoginVisibility(data.authMethods || [])
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Status check failed', error)
|
||||
this.processing = false
|
||||
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() {
|
||||
if (localStorage.getItem('token')) {
|
||||
var userfound = await this.checkAuth()
|
||||
if (userfound) return // if valid user no need to check status
|
||||
if (this.$route.query?.setToken) {
|
||||
localStorage.setItem('token', this.$route.query.setToken)
|
||||
}
|
||||
if (localStorage.getItem('token')) {
|
||||
if (await this.checkAuth()) return // if valid user no need to check status
|
||||
}
|
||||
|
||||
this.checkStatus()
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,20 @@
|
||||
</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">
|
||||
<p class="text-lg">{{ error }}</p>
|
||||
</widgets-alert>
|
||||
@ -61,9 +75,7 @@
|
||||
</widgets-alert>
|
||||
|
||||
<!-- Item Upload cards -->
|
||||
<template v-for="item in items">
|
||||
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="item.index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
|
||||
</template>
|
||||
<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)" />
|
||||
|
||||
<!-- Upload/Reset btns -->
|
||||
<div v-show="items.length" class="flex justify-end pb-8 pt-4">
|
||||
@ -92,13 +104,18 @@ export default {
|
||||
selectedLibraryId: null,
|
||||
selectedFolderId: null,
|
||||
processing: false,
|
||||
uploadFinished: false
|
||||
uploadFinished: false,
|
||||
fetchMetadata: {
|
||||
enabled: false,
|
||||
provider: null
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedLibrary(newVal) {
|
||||
if (newVal && !this.selectedFolderId) {
|
||||
this.setDefaultFolder()
|
||||
this.setMetadataProvider()
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -133,6 +150,13 @@ export default {
|
||||
selectedLibraryIsPodcast() {
|
||||
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() {
|
||||
if (!this.selectedLibrary) return null
|
||||
return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)
|
||||
@ -160,12 +184,16 @@ export default {
|
||||
}
|
||||
}
|
||||
this.setDefaultFolder()
|
||||
this.setMetadataProvider()
|
||||
},
|
||||
setDefaultFolder() {
|
||||
if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) {
|
||||
this.selectedFolderId = this.selectedLibrary.folders[0].id
|
||||
}
|
||||
},
|
||||
setMetadataProvider() {
|
||||
this.fetchMetadata.provider ||= this.$store.getters['libraries/getLibraryProvider'](this.selectedLibraryId)
|
||||
},
|
||||
removeItem(item) {
|
||||
this.items = this.items.filter((b) => b.index !== item.index)
|
||||
if (!this.items.length) {
|
||||
@ -213,27 +241,49 @@ export default {
|
||||
var items = e.dataTransfer.items || []
|
||||
|
||||
var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType)
|
||||
this.setResults(itemResults)
|
||||
this.onItemsSelected(itemResults)
|
||||
},
|
||||
inputChanged(e) {
|
||||
if (!e.target || !e.target.files) return
|
||||
var _files = Array.from(e.target.files)
|
||||
if (_files && _files.length) {
|
||||
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) {
|
||||
this.error = itemResults.error
|
||||
this.items = []
|
||||
this.ignoredFiles = []
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
this.error = ''
|
||||
this.items = itemResults.items
|
||||
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) {
|
||||
var ref = this.$refs[`itemCard-${index}`]
|
||||
@ -248,8 +298,8 @@ export default {
|
||||
var form = new FormData()
|
||||
form.set('title', item.title)
|
||||
if (!this.selectedLibraryIsPodcast) {
|
||||
form.set('author', item.author)
|
||||
form.set('series', item.series)
|
||||
form.set('author', item.author || '')
|
||||
form.set('series', item.series || '')
|
||||
}
|
||||
form.set('library', this.selectedLibraryId)
|
||||
form.set('folder', this.selectedFolderId)
|
||||
@ -346,6 +396,8 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
|
||||
this.setMetadataProvider()
|
||||
|
||||
this.setDefaultFolder()
|
||||
window.addEventListener('dragenter', this.dragenter)
|
||||
window.addEventListener('dragleave', this.dragleave)
|
||||
|
@ -77,6 +77,7 @@ Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
||||
.replace(lineBreaks, replacement)
|
||||
.replace(windowsReservedRe, replacement)
|
||||
.replace(windowsTrailingRe, replacement)
|
||||
.replace(/\s+/g, ' ') // Replace consecutive spaces with a single space
|
||||
|
||||
// Check if basename is too many bytes
|
||||
const ext = Path.extname(sanitized) // separate out file extension
|
||||
|
@ -66,7 +66,7 @@ export const getters = {
|
||||
|
||||
export const actions = {
|
||||
updateServerSettings({ commit }, payload) {
|
||||
var updatePayload = {
|
||||
const updatePayload = {
|
||||
...payload
|
||||
}
|
||||
return this.$axios.$patch('/api/settings', updatePayload).then((result) => {
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "Upravit uživatelské {0}",
|
||||
"ButtonViewAll": "Zobrazit vše",
|
||||
"ButtonYes": "Ano",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "Účet",
|
||||
"HeaderAdvanced": "Pokročilé",
|
||||
"HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise",
|
||||
"HeaderAudiobookTools": "Nástroje pro správu souborů audioknih",
|
||||
"HeaderAudioTracks": "Zvukové stopy",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Zálohy",
|
||||
"HeaderChangePassword": "Změnit heslo",
|
||||
"HeaderChapters": "Kapitoly",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "Nový účet",
|
||||
"HeaderNewLibrary": "Nová knihovna",
|
||||
"HeaderNotifications": "Oznámení",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Otevřít RSS kanál",
|
||||
"HeaderOtherFiles": "Ostatní soubory",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Oprávnění",
|
||||
"HeaderPlayerQueue": "Fronta přehrávače",
|
||||
"HeaderPlaylist": "Seznam skladeb",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Autor (příjmení a jméno)",
|
||||
"LabelAuthors": "Autoři",
|
||||
"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",
|
||||
"LabelBackupLocation": "Umístění zálohy",
|
||||
"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ě.",
|
||||
"LabelBitrate": "Datový tok",
|
||||
"LabelBooks": "Knihy",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Změnit heslo",
|
||||
"LabelChannels": "Kanály",
|
||||
"LabelChapters": "Kapitoly",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Příklad",
|
||||
"LabelExplicit": "Explicitní",
|
||||
"LabelFeedURL": "URL zdroje",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "Soubor",
|
||||
"LabelFileBirthtime": "Čas vzniku souboru",
|
||||
"LabelFileModified": "Soubor změněn",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Trvale smazat soubor",
|
||||
"LabelHasEbook": "Obsahuje elektronickou knihu",
|
||||
"LabelHasSupplementaryEbook": "Obsahuje doplňkovou elektronickou knihu",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Hostitel",
|
||||
"LabelHour": "Hodina",
|
||||
"LabelIcon": "Ikona",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Informace",
|
||||
"LabelLogLevelWarn": "Varovat",
|
||||
"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í",
|
||||
"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",
|
||||
"LabelMetaTag": "Metaznačka",
|
||||
"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",
|
||||
"LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky",
|
||||
"LabelUploaderDropFiles": "Odstranit soubory",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Použít stopu kapitoly",
|
||||
"LabelUseFullTrack": "Použít celou stopu",
|
||||
"LabelUser": "Uživatel",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "Rediger bruger {0}",
|
||||
"ButtonViewAll": "Vis Alle",
|
||||
"ButtonYes": "Ja",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "Konto",
|
||||
"HeaderAdvanced": "Avanceret",
|
||||
"HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger",
|
||||
"HeaderAudiobookTools": "Audiobog Filhåndteringsværktøjer",
|
||||
"HeaderAudioTracks": "Lydspor",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Sikkerhedskopier",
|
||||
"HeaderChangePassword": "Skift Adgangskode",
|
||||
"HeaderChapters": "Kapitler",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "Ny Konto",
|
||||
"HeaderNewLibrary": "Nyt Bibliotek",
|
||||
"HeaderNotifications": "Meddelelser",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Åbn RSS Feed",
|
||||
"HeaderOtherFiles": "Andre Filer",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Tilladelser",
|
||||
"HeaderPlayerQueue": "Afspilningskø",
|
||||
"HeaderPlaylist": "Afspilningsliste",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
|
||||
"LabelAuthors": "Forfattere",
|
||||
"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",
|
||||
"LabelBackupLocation": "Backup Placering",
|
||||
"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.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Bøger",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Ændre Adgangskode",
|
||||
"LabelChannels": "Kanaler",
|
||||
"LabelChapters": "Kapitler",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Eksempel",
|
||||
"LabelExplicit": "Eksplisit",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "Fil",
|
||||
"LabelFileBirthtime": "Fødselstidspunkt for fil",
|
||||
"LabelFileModified": "Fil ændret",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Permanent slet fil",
|
||||
"LabelHasEbook": "Har e-bog",
|
||||
"LabelHasSupplementaryEbook": "Har supplerende e-bog",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Vært",
|
||||
"LabelHour": "Time",
|
||||
"LabelIcon": "Ikon",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Information",
|
||||
"LabelLogLevelWarn": "Advarsel",
|
||||
"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",
|
||||
"LabelMediaType": "Medietype",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
|
||||
"LabelMetadataProvider": "Metadataudbyder",
|
||||
"LabelMetaTag": "Meta-tag",
|
||||
"LabelMetaTags": "Meta-tags",
|
||||
@ -503,6 +521,7 @@
|
||||
"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",
|
||||
"LabelUploaderDropFiles": "Smid filer",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Brug kapitel-spor",
|
||||
"LabelUseFullTrack": "Brug fuldt spor",
|
||||
"LabelUser": "Bruger",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "Benutzer {0} bearbeiten",
|
||||
"ButtonViewAll": "Alles anzeigen",
|
||||
"ButtonYes": "Ja",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "Konto",
|
||||
"HeaderAdvanced": "Erweitert",
|
||||
"HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen",
|
||||
"HeaderAudiobookTools": "Hörbuch-Dateiverwaltungstools",
|
||||
"HeaderAudioTracks": "Audiodateien",
|
||||
"HeaderAuthentication": "Authentifizierung",
|
||||
"HeaderBackups": "Sicherungen",
|
||||
"HeaderChangePassword": "Passwort ändern",
|
||||
"HeaderChapters": "Kapitel",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "Neues Konto",
|
||||
"HeaderNewLibrary": "Neue Bibliothek",
|
||||
"HeaderNotifications": "Benachrichtigungen",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentifizierung",
|
||||
"HeaderOpenRSSFeed": "RSS-Feed öffnen",
|
||||
"HeaderOtherFiles": "Sonstige Dateien",
|
||||
"HeaderPasswordAuthentication": "Password Authentifizierung",
|
||||
"HeaderPermissions": "Berechtigungen",
|
||||
"HeaderPlayerQueue": "Spieler Warteschlange",
|
||||
"HeaderPlaylist": "Wiedergabeliste",
|
||||
@ -181,11 +187,11 @@
|
||||
"LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu",
|
||||
"LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen",
|
||||
"LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu",
|
||||
"LabelAdminUsersOnly": "Admin users only",
|
||||
"LabelAdminUsersOnly": "Nur Admin Benutzer",
|
||||
"LabelAll": "Alle",
|
||||
"LabelAllUsers": "Alle Benutzer",
|
||||
"LabelAllUsersExcludingGuests": "All users excluding guests",
|
||||
"LabelAllUsersIncludingGuests": "All users including guests",
|
||||
"LabelAllUsersExcludingGuests": "Alle Benutzer außer Gästen",
|
||||
"LabelAllUsersIncludingGuests": "All Benutzer und Gäste",
|
||||
"LabelAlreadyInYourLibrary": "In der Bibliothek vorhanden",
|
||||
"LabelAppend": "Anhängen",
|
||||
"LabelAuthor": "Autor",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Autor (Nachname, Vorname)",
|
||||
"LabelAuthors": "Autoren",
|
||||
"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",
|
||||
"LabelBackupLocation": "Backup-Ort",
|
||||
"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.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Bücher",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Passwort ändern",
|
||||
"LabelChannels": "Kanäle",
|
||||
"LabelChapters": "Kapitel",
|
||||
@ -232,7 +245,7 @@
|
||||
"LabelDeselectAll": "Alles abwählen",
|
||||
"LabelDevice": "Gerät",
|
||||
"LabelDeviceInfo": "Geräteinformationen",
|
||||
"LabelDeviceIsAvailableTo": "Device is available to...",
|
||||
"LabelDeviceIsAvailableTo": "Dem Geärt ist es möglich zu ...",
|
||||
"LabelDirectory": "Verzeichnis",
|
||||
"LabelDiscFromFilename": "CD aus dem Dateinamen",
|
||||
"LabelDiscFromMetadata": "CD aus den Metadaten",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Beispiel",
|
||||
"LabelExplicit": "Explizit (Altersbeschränkung)",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "Datei",
|
||||
"LabelFileBirthtime": "Datei erstellt",
|
||||
"LabelFileModified": "Datei geändert",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Datei dauerhaft löschen",
|
||||
"LabelHasEbook": "mit E-Book",
|
||||
"LabelHasSupplementaryEbook": "mit zusätlichem E-Book",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Stunde",
|
||||
"LabelIcon": "Symbol",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Informationen",
|
||||
"LabelLogLevelWarn": "Warnungen",
|
||||
"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",
|
||||
"LabelMediaType": "Medientyp",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
|
||||
"LabelMetadataProvider": "Metadatenanbieter",
|
||||
"LabelMetaTag": "Meta Schlagwort",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
@ -398,7 +416,7 @@
|
||||
"LabelSeason": "Staffel",
|
||||
"LabelSelectAllEpisodes": "Alle Episoden auswählen",
|
||||
"LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt",
|
||||
"LabelSelectUsers": "Select users",
|
||||
"LabelSelectUsers": "Benutzer auswählen",
|
||||
"LabelSendEbookToDevice": "E-Book senden an...",
|
||||
"LabelSequence": "Reihenfolge",
|
||||
"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",
|
||||
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
|
||||
"LabelUploaderDropFiles": "Dateien löschen",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Kapiteldatei verwenden",
|
||||
"LabelUseFullTrack": "Gesamte Datei verwenden",
|
||||
"LabelUser": "Benutzer",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "Edit user {0}",
|
||||
"ButtonViewAll": "View All",
|
||||
"ButtonYes": "Yes",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "Account",
|
||||
"HeaderAdvanced": "Advanced",
|
||||
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
||||
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
||||
"HeaderAudioTracks": "Audio Tracks",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Backups",
|
||||
"HeaderChangePassword": "Change Password",
|
||||
"HeaderChapters": "Chapters",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "New Account",
|
||||
"HeaderNewLibrary": "New Library",
|
||||
"HeaderNotifications": "Notifications",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Open RSS Feed",
|
||||
"HeaderOtherFiles": "Other Files",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Permissions",
|
||||
"HeaderPlayerQueue": "Player Queue",
|
||||
"HeaderPlaylist": "Playlist",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
"LabelAuthors": "Authors",
|
||||
"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",
|
||||
"LabelBackupLocation": "Backup Location",
|
||||
"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.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Books",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Change Password",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "File",
|
||||
"LabelFileBirthtime": "File Birthtime",
|
||||
"LabelFileModified": "File Modified",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Hard delete file",
|
||||
"LabelHasEbook": "Has ebook",
|
||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Hour",
|
||||
"LabelIcon": "Icon",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Warn",
|
||||
"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",
|
||||
"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",
|
||||
"LabelMetaTag": "Meta Tag",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
@ -503,6 +521,7 @@
|
||||
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
|
||||
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
|
||||
"LabelUploaderDropFiles": "Drop files",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Use chapter track",
|
||||
"LabelUseFullTrack": "Use full track",
|
||||
"LabelUser": "User",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "Editar Usuario {0}",
|
||||
"ButtonViewAll": "Ver Todos",
|
||||
"ButtonYes": "Aceptar",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "Cuenta",
|
||||
"HeaderAdvanced": "Avanzado",
|
||||
"HeaderAppriseNotificationSettings": "Ajustes de Notificaciones de Apprise",
|
||||
"HeaderAudiobookTools": "Herramientas de Gestión de Archivos de Audiolibro",
|
||||
"HeaderAudioTracks": "Pistas de Audio",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Respaldos",
|
||||
"HeaderChangePassword": "Cambiar Contraseña",
|
||||
"HeaderChapters": "Capítulos",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "Nueva Cuenta",
|
||||
"HeaderNewLibrary": "Nueva Biblioteca",
|
||||
"HeaderNotifications": "Notificaciones",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Abrir fuente RSS",
|
||||
"HeaderOtherFiles": "Otros Archivos",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Permisos",
|
||||
"HeaderPlayerQueue": "Fila del Reproductor",
|
||||
"HeaderPlaylist": "Lista de Reproducción",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Autor (Apellido, Nombre)",
|
||||
"LabelAuthors": "Autores",
|
||||
"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",
|
||||
"LabelBackupLocation": "Ubicación del Respaldo",
|
||||
"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.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Libros",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Cambiar Contraseña",
|
||||
"LabelChannels": "Canales",
|
||||
"LabelChapters": "Capítulos",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Ejemplo",
|
||||
"LabelExplicit": "Explicito",
|
||||
"LabelFeedURL": "Fuente de URL",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "Archivo",
|
||||
"LabelFileBirthtime": "Archivo Creado en",
|
||||
"LabelFileModified": "Archivo modificado",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Eliminar Definitivamente",
|
||||
"LabelHasEbook": "Tiene Ebook",
|
||||
"LabelHasSupplementaryEbook": "Tiene Ebook Suplementario",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Hora",
|
||||
"LabelIcon": "Icono",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Información",
|
||||
"LabelLogLevelWarn": "Advertencia",
|
||||
"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",
|
||||
"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",
|
||||
"LabelMetaTag": "Meta Tag",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
@ -503,6 +521,7 @@
|
||||
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
|
||||
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
|
||||
"LabelUploaderDropFiles": "Suelte los Archivos",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Usar pista por capitulo",
|
||||
"LabelUseFullTrack": "Usar pista completa",
|
||||
"LabelUser": "Usuario",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "Modifier l’utilisateur {0}",
|
||||
"ButtonViewAll": "Afficher tout",
|
||||
"ButtonYes": "Oui",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "Compte",
|
||||
"HeaderAdvanced": "Avancé",
|
||||
"HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise",
|
||||
"HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook",
|
||||
"HeaderAudioTracks": "Pistes audio",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Sauvegardes",
|
||||
"HeaderChangePassword": "Modifier le mot de passe",
|
||||
"HeaderChapters": "Chapitres",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "Nouveau compte",
|
||||
"HeaderNewLibrary": "Nouvelle bibliothèque",
|
||||
"HeaderNotifications": "Notifications",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Ouvrir Flux RSS",
|
||||
"HeaderOtherFiles": "Autres fichiers",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Permissions",
|
||||
"HeaderPlayerQueue": "Liste d’écoute",
|
||||
"HeaderPlaylist": "Liste de lecture",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Auteur (Nom, Prénom)",
|
||||
"LabelAuthors": "Auteurs",
|
||||
"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 à l’Utilisateur",
|
||||
"LabelBackupLocation": "Backup Location",
|
||||
"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.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Livres",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Modifier le mot de passe",
|
||||
"LabelChannels": "Canaux",
|
||||
"LabelChapters": "Chapitres",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Exemple",
|
||||
"LabelExplicit": "Restriction",
|
||||
"LabelFeedURL": "URL du flux",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "Fichier",
|
||||
"LabelFileBirthtime": "Création du fichier",
|
||||
"LabelFileModified": "Modification du fichier",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Suppression du fichier",
|
||||
"LabelHasEbook": "Dispose d’un livre numérique",
|
||||
"LabelHasSupplementaryEbook": "Dispose d’un livre numérique supplémentaire",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Hôte",
|
||||
"LabelHour": "Heure",
|
||||
"LabelIcon": "Icone",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Warn",
|
||||
"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",
|
||||
"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",
|
||||
"LabelMetaTag": "Etiquette de métadonnée",
|
||||
"LabelMetaTags": "Etiquettes de métadonnée",
|
||||
@ -503,6 +521,7 @@
|
||||
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée",
|
||||
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
|
||||
"LabelUploaderDropFiles": "Déposer des fichiers",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Utiliser la piste du chapitre",
|
||||
"LabelUseFullTrack": "Utiliser la piste Complète",
|
||||
"LabelUser": "Utilisateur",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો",
|
||||
"ButtonViewAll": "બધું જુઓ",
|
||||
"ButtonYes": "હા",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "એકાઉન્ટ",
|
||||
"HeaderAdvanced": "અડ્વાન્સડ",
|
||||
"HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ",
|
||||
"HeaderAudiobookTools": "ઓડિયોબુક ફાઇલ વ્યવસ્થાપન ટૂલ્સ",
|
||||
"HeaderAudioTracks": "ઓડિયો ટ્રેક્સ",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "બેકઅપ્સ",
|
||||
"HeaderChangePassword": "પાસવર્ડ બદલો",
|
||||
"HeaderChapters": "પ્રકરણો",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "નવું એકાઉન્ટ",
|
||||
"HeaderNewLibrary": "નવી પુસ્તકાલય",
|
||||
"HeaderNotifications": "સૂચનાઓ",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "RSS ફીડ ખોલો",
|
||||
"HeaderOtherFiles": "અન્ય ફાઇલો",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "પરવાનગીઓ",
|
||||
"HeaderPlayerQueue": "પ્લેયર કતાર",
|
||||
"HeaderPlaylist": "પ્લેલિસ્ટ",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
"LabelAuthors": "Authors",
|
||||
"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",
|
||||
"LabelBackupLocation": "Backup Location",
|
||||
"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.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Books",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Change Password",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "File",
|
||||
"LabelFileBirthtime": "File Birthtime",
|
||||
"LabelFileModified": "File Modified",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Hard delete file",
|
||||
"LabelHasEbook": "Has ebook",
|
||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Hour",
|
||||
"LabelIcon": "Icon",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Warn",
|
||||
"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",
|
||||
"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",
|
||||
"LabelMetaTag": "Meta Tag",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
@ -503,6 +521,7 @@
|
||||
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
|
||||
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
|
||||
"LabelUploaderDropFiles": "Drop files",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Use chapter track",
|
||||
"LabelUseFullTrack": "Use full track",
|
||||
"LabelUser": "User",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "उपयोगकर्ता {0} को संपादित करें",
|
||||
"ButtonViewAll": "सभी को देखें",
|
||||
"ButtonYes": "हाँ",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "खाता",
|
||||
"HeaderAdvanced": "विकसित",
|
||||
"HeaderAppriseNotificationSettings": "Apprise अधिसूचना सेटिंग्स",
|
||||
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
||||
"HeaderAudioTracks": "Audio Tracks",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Backups",
|
||||
"HeaderChangePassword": "Change Password",
|
||||
"HeaderChapters": "Chapters",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "New Account",
|
||||
"HeaderNewLibrary": "New Library",
|
||||
"HeaderNotifications": "Notifications",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Open RSS Feed",
|
||||
"HeaderOtherFiles": "Other Files",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Permissions",
|
||||
"HeaderPlayerQueue": "Player Queue",
|
||||
"HeaderPlaylist": "Playlist",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
"LabelAuthors": "Authors",
|
||||
"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",
|
||||
"LabelBackupLocation": "Backup Location",
|
||||
"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.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Books",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Change Password",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "File",
|
||||
"LabelFileBirthtime": "File Birthtime",
|
||||
"LabelFileModified": "File Modified",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Hard delete file",
|
||||
"LabelHasEbook": "Has ebook",
|
||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Hour",
|
||||
"LabelIcon": "Icon",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Warn",
|
||||
"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",
|
||||
"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",
|
||||
"LabelMetaTag": "Meta Tag",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
@ -503,6 +521,7 @@
|
||||
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
|
||||
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
|
||||
"LabelUploaderDropFiles": "Drop files",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Use chapter track",
|
||||
"LabelUseFullTrack": "Use full track",
|
||||
"LabelUser": "User",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "Edit user {0}",
|
||||
"ButtonViewAll": "Prikaži sve",
|
||||
"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",
|
||||
"HeaderAdvanced": "Napredno",
|
||||
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
||||
"HeaderAudiobookTools": "Audiobook File Management alati",
|
||||
"HeaderAudioTracks": "Audio Tracks",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Backups",
|
||||
"HeaderChangePassword": "Promijeni lozinku",
|
||||
"HeaderChapters": "Poglavlja",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "Novi korisnički račun",
|
||||
"HeaderNewLibrary": "Nova biblioteka",
|
||||
"HeaderNotifications": "Obavijesti",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Otvori RSS Feed",
|
||||
"HeaderOtherFiles": "Druge datoteke",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Dozvole",
|
||||
"HeaderPlayerQueue": "Player Queue",
|
||||
"HeaderPlaylist": "Playlist",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
"LabelAuthors": "Autori",
|
||||
"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",
|
||||
"LabelBackupLocation": "Backup Location",
|
||||
"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.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Knjige",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Promijeni lozinku",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "Datoteka",
|
||||
"LabelFileBirthtime": "File Birthtime",
|
||||
"LabelFileModified": "File Modified",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Obriši datoteku zauvijek",
|
||||
"LabelHasEbook": "Has ebook",
|
||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Sat",
|
||||
"LabelIcon": "Ikona",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Warn",
|
||||
"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",
|
||||
"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 ",
|
||||
"LabelMetaTag": "Meta Tag",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
@ -503,6 +521,7 @@
|
||||
"LabelUpdateDetailsHelp": "Dozvoli postavljanje novih detalja za odabrane knjige nakon što je match pronađen",
|
||||
"LabelUploaderDragAndDrop": "Drag & Drop datoteke ili foldere",
|
||||
"LabelUploaderDropFiles": "Ubaci datoteke",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Koristi poglavlja track",
|
||||
"LabelUseFullTrack": "Koristi cijeli track",
|
||||
"LabelUser": "Korisnik",
|
||||
|
@ -1,10 +1,10 @@
|
||||
{
|
||||
"ButtonAdd": "Aggiungi",
|
||||
"ButtonAddChapters": "Aggiungi Capitoli",
|
||||
"ButtonAddDevice": "Add Device",
|
||||
"ButtonAddLibrary": "Add Library",
|
||||
"ButtonAddDevice": "Aggiungi Dispositivo",
|
||||
"ButtonAddLibrary": "Aggiungi Libreria",
|
||||
"ButtonAddPodcasts": "Aggiungi Podcast",
|
||||
"ButtonAddUser": "Add User",
|
||||
"ButtonAddUser": "Aggiungi User",
|
||||
"ButtonAddYourFirstLibrary": "Aggiungi la tua prima libreria",
|
||||
"ButtonApply": "Applica",
|
||||
"ButtonApplyChapters": "Applica",
|
||||
@ -62,7 +62,7 @@
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla",
|
||||
"ButtonReScan": "Ri-scansiona",
|
||||
"ButtonReset": "Reset",
|
||||
"ButtonResetToDefault": "Reset to default",
|
||||
"ButtonResetToDefault": "Ripristino di default",
|
||||
"ButtonRestore": "Ripristina",
|
||||
"ButtonSave": "Salva",
|
||||
"ButtonSaveAndClose": "Salva & Chiudi",
|
||||
@ -75,7 +75,7 @@
|
||||
"ButtonSetChaptersFromTracks": "Impostare i capitoli dalle tracce",
|
||||
"ButtonShiftTimes": "Ricerca veloce",
|
||||
"ButtonShow": "Mostra",
|
||||
"ButtonStartM4BEncode": "Inizia L'Encoda del M4B",
|
||||
"ButtonStartM4BEncode": "Inizia L'Encode del M4B",
|
||||
"ButtonStartMetadataEmbed": "Inizia Incorporo Metadata",
|
||||
"ButtonSubmit": "Invia",
|
||||
"ButtonTest": "Test",
|
||||
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "Modifica Utente {0}",
|
||||
"ButtonViewAll": "Mostra Tutto",
|
||||
"ButtonYes": "Si",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "Account",
|
||||
"HeaderAdvanced": "Avanzate",
|
||||
"HeaderAppriseNotificationSettings": "Apprendi le impostazioni di Notifica",
|
||||
"HeaderAudiobookTools": "Utilità Audiobook File Management",
|
||||
"HeaderAudioTracks": "Tracce Audio",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Backup",
|
||||
"HeaderChangePassword": "Cambia Password",
|
||||
"HeaderChapters": "Capitoli",
|
||||
@ -102,7 +106,7 @@
|
||||
"HeaderCurrentDownloads": "Download Correnti",
|
||||
"HeaderDetails": "Dettagli",
|
||||
"HeaderDownloadQueue": "Download Queue",
|
||||
"HeaderEbookFiles": "Ebook Files",
|
||||
"HeaderEbookFiles": "Ebook File",
|
||||
"HeaderEmail": "Email",
|
||||
"HeaderEmailSettings": "Email Settings",
|
||||
"HeaderEpisodes": "Episodi",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "Nuovo Account",
|
||||
"HeaderNewLibrary": "Nuova Libreria",
|
||||
"HeaderNotifications": "Notifiche",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Apri RSS Feed",
|
||||
"HeaderOtherFiles": "Altri File",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Permessi",
|
||||
"HeaderPlayerQueue": "Coda Riproduzione",
|
||||
"HeaderPlaylist": "Playlist",
|
||||
@ -161,7 +167,7 @@
|
||||
"HeaderStatsRecentSessions": "Sessioni Recenti",
|
||||
"HeaderStatsTop10Authors": "Top 10 Autori",
|
||||
"HeaderStatsTop5Genres": "Top 5 Generi",
|
||||
"HeaderTableOfContents": "Tabellla dei Contenuti",
|
||||
"HeaderTableOfContents": "Tabella dei Contenuti",
|
||||
"HeaderTools": "Strumenti",
|
||||
"HeaderUpdateAccount": "Aggiorna Account",
|
||||
"HeaderUpdateAuthor": "Aggiorna Autore",
|
||||
@ -181,11 +187,11 @@
|
||||
"LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta",
|
||||
"LabelAddToPlaylist": "aggiungi alla Playlist",
|
||||
"LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist",
|
||||
"LabelAdminUsersOnly": "Admin users only",
|
||||
"LabelAdminUsersOnly": "Solo utenti Amministratori",
|
||||
"LabelAll": "Tutti",
|
||||
"LabelAllUsers": "Tutti gli Utenti",
|
||||
"LabelAllUsersExcludingGuests": "All users excluding guests",
|
||||
"LabelAllUsersIncludingGuests": "All users including guests",
|
||||
"LabelAllUsersExcludingGuests": "Tutti gli Utenti Esclusi gli ospiti",
|
||||
"LabelAllUsersIncludingGuests": "Tutti gli Utenti Inclusi gli ospiti",
|
||||
"LabelAlreadyInYourLibrary": "Già esistente nella libreria",
|
||||
"LabelAppend": "Appese",
|
||||
"LabelAuthor": "Autore",
|
||||
@ -193,8 +199,14 @@
|
||||
"LabelAuthorLastFirst": "Autori (Per Cognome)",
|
||||
"LabelAuthors": "Autori",
|
||||
"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",
|
||||
"LabelBackupLocation": "Backup Location",
|
||||
"LabelBackupLocation": "Percorso del Backup",
|
||||
"LabelBackupsEnableAutomaticBackups": "Abilita backup Automatico",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "I Backup saranno salvati in /metadata/backups",
|
||||
"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.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Libri",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Cambia Password",
|
||||
"LabelChannels": "Canali",
|
||||
"LabelChapters": "Capitoli",
|
||||
"LabelChaptersFound": "Capitoli Trovati",
|
||||
"LabelChapterTitle": "Titoli dei Capitoli",
|
||||
"LabelClickForMoreInfo": "Click for more info",
|
||||
"LabelClickForMoreInfo": "Click per altre Info",
|
||||
"LabelClosePlayer": "Chiudi player",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Comprimi Serie",
|
||||
"LabelCollection": "Collection",
|
||||
"LabelCollection": "Raccolta",
|
||||
"LabelCollections": "Raccolte",
|
||||
"LabelComplete": "Completo",
|
||||
"LabelConfirmPassword": "Conferma Password",
|
||||
@ -220,23 +233,23 @@
|
||||
"LabelContinueReading": "Continua la Lettura",
|
||||
"LabelContinueSeries": "Continua Serie",
|
||||
"LabelCover": "Cover",
|
||||
"LabelCoverImageURL": "Cover Image URL",
|
||||
"LabelCoverImageURL": "Indirizzo della cover URL",
|
||||
"LabelCreatedAt": "Creato A",
|
||||
"LabelCronExpression": "Espressione Cron",
|
||||
"LabelCurrent": "Attuale",
|
||||
"LabelCurrently": "Attualmente:",
|
||||
"LabelCustomCronExpression": "Custom Cron Expression:",
|
||||
"LabelCustomCronExpression": "Espressione Cron personalizzata:",
|
||||
"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",
|
||||
"LabelDeselectAll": "Deseleziona Tutto",
|
||||
"LabelDevice": "Dispositivo",
|
||||
"LabelDeviceInfo": "Info Dispositivo",
|
||||
"LabelDeviceIsAvailableTo": "Device is available to...",
|
||||
"LabelDeviceIsAvailableTo": "Il dispositivo e disponibile su...",
|
||||
"LabelDirectory": "Elenco",
|
||||
"LabelDiscFromFilename": "Disco dal nome file",
|
||||
"LabelDiscFromMetadata": "Disco dal Metadata",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDiscover": "Scopri",
|
||||
"LabelDownload": "Download",
|
||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
||||
"LabelDuration": "Durata",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Esempio",
|
||||
"LabelExplicit": "Esplicito",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "File",
|
||||
"LabelFileBirthtime": "Data Creazione",
|
||||
"LabelFileModified": "Ultima modifica",
|
||||
@ -275,10 +289,11 @@
|
||||
"LabelHardDeleteFile": "Elimina Definitivamente",
|
||||
"LabelHasEbook": "Un ebook",
|
||||
"LabelHasSupplementaryEbook": "Un ebook Supplementare",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Ora",
|
||||
"LabelIcon": "Icona",
|
||||
"LabelImageURLFromTheWeb": "Image URL from the web",
|
||||
"LabelImageURLFromTheWeb": "Immagine URL da internet",
|
||||
"LabelIncludeInTracklist": "Includi nella Tracklist",
|
||||
"LabelIncomplete": "Incompleta",
|
||||
"LabelInProgress": "In Corso",
|
||||
@ -303,22 +318,25 @@
|
||||
"LabelLastUpdate": "Ultimo Aggiornamento",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutSinglePage": "Pagina Singola",
|
||||
"LabelLayoutSplitPage": "DIvidi Pagina",
|
||||
"LabelLayoutSplitPage": "Dividi Pagina",
|
||||
"LabelLess": "Poco",
|
||||
"LabelLibrariesAccessibleToUser": "Librerie Accessibili agli Utenti",
|
||||
"LabelLibrary": "Libreria",
|
||||
"LabelLibraryItem": "Elementi della Library",
|
||||
"LabelLibraryName": "Nome Libreria",
|
||||
"LabelLimit": "Limiti",
|
||||
"LabelLineSpacing": "Line spacing",
|
||||
"LabelLineSpacing": "Interlinea",
|
||||
"LabelListenAgain": "Ri-ascolta",
|
||||
"LabelLogLevelDebug": "Debug",
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Allarme",
|
||||
"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",
|
||||
"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",
|
||||
"LabelMetaTag": "Meta Tag",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
@ -398,7 +416,7 @@
|
||||
"LabelSeason": "Stagione",
|
||||
"LabelSelectAllEpisodes": "Seleziona tutti gli Episodi",
|
||||
"LabelSelectEpisodesShowing": "Episodi {0} selezionati ",
|
||||
"LabelSelectUsers": "Select users",
|
||||
"LabelSelectUsers": "Selezione Utenti",
|
||||
"LabelSendEbookToDevice": "Invia ebook a...",
|
||||
"LabelSequence": "Sequenza",
|
||||
"LabelSeries": "Serie",
|
||||
@ -414,9 +432,9 @@
|
||||
"LabelSettingsDisableWatcher": "Disattiva Watcher",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Disattiva Watcher per le librerie",
|
||||
"LabelSettingsDisableWatcherHelp": "Disattiva il controllo automatico libri nelle cartelle delle librerie. *Richiede il Riavvio del Server",
|
||||
"LabelSettingsEnableWatcher": "Enable Watcher",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library",
|
||||
"LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart",
|
||||
"LabelSettingsEnableWatcher": "Abilita Watcher",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Abilita il controllo cartelle per la libreria",
|
||||
"LabelSettingsEnableWatcherHelp": "Abilita l'aggiunta/aggiornamento automatico degli elementi quando vengono rilevate modifiche ai file. *Richiede il riavvio del Server",
|
||||
"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.",
|
||||
"LabelSettingsFindCovers": "Trova covers",
|
||||
@ -471,8 +489,8 @@
|
||||
"LabelTagsNotAccessibleToUser": "Tags non accessibile agli Utenti",
|
||||
"LabelTasks": "Processi in esecuzione",
|
||||
"LabelTheme": "Tema",
|
||||
"LabelThemeDark": "Dark",
|
||||
"LabelThemeLight": "Light",
|
||||
"LabelThemeDark": "Scuro",
|
||||
"LabelThemeLight": "Chiaro",
|
||||
"LabelTimeBase": "Time Base",
|
||||
"LabelTimeListened": "Tempo di Ascolto",
|
||||
"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",
|
||||
"LabelUploaderDragAndDrop": "Drag & drop file o Cartelle",
|
||||
"LabelUploaderDropFiles": "Elimina file",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Usa il Capitolo della Traccia",
|
||||
"LabelUseFullTrack": "Usa la traccia totale",
|
||||
"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",
|
||||
"MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro",
|
||||
"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}?",
|
||||
"MessageConfirmDeleteFile": "Questo eliminerà il file dal tuo file system. Sei sicuro?",
|
||||
"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?",
|
||||
"MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?",
|
||||
"MessageConfirmDeleteLibraryItem": " l'elemento della libreria dal database e dal file system. Sei sicuro?",
|
||||
"MessageConfirmDeleteLibraryItems": "Ciò eliminerà {0} elementi della libreria dal database e dal file system. Sei sicuro?",
|
||||
"MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?",
|
||||
"MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?",
|
||||
"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?",
|
||||
"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?",
|
||||
"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}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
|
||||
"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?",
|
||||
"MessageConfirmRenameTagMergeNote": "Nota: Questo tag esiste già e verrà unito nel vecchio.",
|
||||
"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}\"?",
|
||||
"MessageDownloadingEpisode": "Download episodio in corso",
|
||||
"MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto",
|
||||
@ -608,7 +627,7 @@
|
||||
"MessageNoResults": "Nessun Risultato",
|
||||
"MessageNoSearchResultsFor": "Nessun risultato per \"{0}\"",
|
||||
"MessageNoSeries": "Nessuna Serie",
|
||||
"MessageNoTags": "No Tags",
|
||||
"MessageNoTags": "Nessun Tags",
|
||||
"MessageNoTasksRunning": "Nessun processo in esecuzione",
|
||||
"MessageNotYetImplemented": "Non Ancora Implementato",
|
||||
"MessageNoUpdateNecessary": "Nessun aggiornamento necessario",
|
||||
@ -637,7 +656,7 @@
|
||||
"MessageUploaderItemSuccess": "Caricato con successo!",
|
||||
"MessageUploading": "Caricamento...",
|
||||
"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!",
|
||||
"MessageYourAudiobookDurationIsLonger": "La durata dell'audiolibro è più lunga della 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.",
|
||||
"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",
|
||||
"PlaceholderNewFolderPath": "Nuovo percorso Cartella",
|
||||
"PlaceholderNewFolderPath": "Nuovo Percorso Cartella",
|
||||
"PlaceholderNewPlaylist": "Nome nuova playlist",
|
||||
"PlaceholderSearch": "Cerca..",
|
||||
"PlaceholderSearchEpisode": "Cerca Episodio..",
|
||||
@ -717,7 +736,7 @@
|
||||
"ToastRSSFeedCloseSuccess": "RSS feed chiuso",
|
||||
"ToastSendEbookToDeviceFailed": "Impossibile inviare l'ebook al dispositivo",
|
||||
"ToastSendEbookToDeviceSuccess": "Ebook inviato al dispositivo \"{0}\"",
|
||||
"ToastSeriesUpdateFailed": "Aggiornaento Serie Fallito",
|
||||
"ToastSeriesUpdateFailed": "Aggiornamento Serie Fallito",
|
||||
"ToastSeriesUpdateSuccess": "Serie Aggornate",
|
||||
"ToastSessionDeleteFailed": "Errore eliminazione sessione",
|
||||
"ToastSessionDeleteSuccess": "Sessione cancellata",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "Redaguoti naudotoją {0}",
|
||||
"ButtonViewAll": "Peržiūrėti visus",
|
||||
"ButtonYes": "Taip",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "Paskyra",
|
||||
"HeaderAdvanced": "Papildomi",
|
||||
"HeaderAppriseNotificationSettings": "Apprise pranešimo nustatymai",
|
||||
"HeaderAudiobookTools": "Audioknygų failų valdymo įrankiai",
|
||||
"HeaderAudioTracks": "Garso takeliai",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Atsarginės kopijos",
|
||||
"HeaderChangePassword": "Pakeisti slaptažodį",
|
||||
"HeaderChapters": "Skyriai",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "Nauja paskyra",
|
||||
"HeaderNewLibrary": "Nauja biblioteka",
|
||||
"HeaderNotifications": "Pranešimai",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Atidaryti RSS srautą",
|
||||
"HeaderOtherFiles": "Kiti failai",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Leidimai",
|
||||
"HeaderPlayerQueue": "Grotuvo eilė",
|
||||
"HeaderPlaylist": "Grojaraštis",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Autorius (Pavardė, Vardas)",
|
||||
"LabelAuthors": "Autoriai",
|
||||
"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ą",
|
||||
"LabelBackupLocation": "Backup Location",
|
||||
"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.",
|
||||
"LabelBitrate": "Bitų sparta",
|
||||
"LabelBooks": "Knygos",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Pakeisti slaptažodį",
|
||||
"LabelChannels": "Kanalai",
|
||||
"LabelChapters": "Skyriai",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Pavyzdys",
|
||||
"LabelExplicit": "Suaugusiems",
|
||||
"LabelFeedURL": "Srauto URL",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "Failas",
|
||||
"LabelFileBirthtime": "Failo kūrimo laikas",
|
||||
"LabelFileModified": "Failo keitimo laikas",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Galutinai ištrinti failą",
|
||||
"LabelHasEbook": "Turi e-knygą",
|
||||
"LabelHasSupplementaryEbook": "Turi papildomą e-knygą",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Serveris",
|
||||
"LabelHour": "Valanda",
|
||||
"LabelIcon": "Piktograma",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Warn",
|
||||
"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",
|
||||
"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",
|
||||
"LabelMetaTag": "Meta žymė",
|
||||
"LabelMetaTags": "Meta žymos",
|
||||
@ -503,6 +521,7 @@
|
||||
"LabelUpdateDetailsHelp": "Leisti perrašyti esamus duomenis pasirinktoms knygoms, kai yra rasta atitikmenų",
|
||||
"LabelUploaderDragAndDrop": "Tempkite ir paleiskite failus ar aplankus",
|
||||
"LabelUploaderDropFiles": "Nutempti failus",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Naudoti skyrių takelį",
|
||||
"LabelUseFullTrack": "Naudoti visą takelį",
|
||||
"LabelUser": "Vartotojas",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "Wijzig gebruiker {0}",
|
||||
"ButtonViewAll": "Toon alle",
|
||||
"ButtonYes": "Ja",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "Account",
|
||||
"HeaderAdvanced": "Geavanceerd",
|
||||
"HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
|
||||
"HeaderAudiobookTools": "Audioboekbestandbeheer tools",
|
||||
"HeaderAudioTracks": "Audiotracks",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Back-ups",
|
||||
"HeaderChangePassword": "Wachtwoord wijzigen",
|
||||
"HeaderChapters": "Hoofdstukken",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "Nieuwe account",
|
||||
"HeaderNewLibrary": "Nieuwe bibliotheek",
|
||||
"HeaderNotifications": "Notificaties",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Open RSS-feed",
|
||||
"HeaderOtherFiles": "Andere bestanden",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Toestemmingen",
|
||||
"HeaderPlayerQueue": "Afspeelwachtrij",
|
||||
"HeaderPlaylist": "Afspeellijst",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Auteur (Achternaam, Voornaam)",
|
||||
"LabelAuthors": "Auteurs",
|
||||
"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",
|
||||
"LabelBackupLocation": "Back-up locatie",
|
||||
"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.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Boeken",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Wachtwoord wijzigen",
|
||||
"LabelChannels": "Kanalen",
|
||||
"LabelChapters": "Hoofdstukken",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Voorbeeld",
|
||||
"LabelExplicit": "Expliciet",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "Bestand",
|
||||
"LabelFileBirthtime": "Aanmaaktijd bestand",
|
||||
"LabelFileModified": "Bestand gewijzigd",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Hard-delete bestand",
|
||||
"LabelHasEbook": "Heeft ebook",
|
||||
"LabelHasSupplementaryEbook": "Heeft supplementair ebook",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Uur",
|
||||
"LabelIcon": "Icoon",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Waarschuwing",
|
||||
"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",
|
||||
"LabelMediaType": "Mediatype",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
|
||||
"LabelMetadataProvider": "Metadatabron",
|
||||
"LabelMetaTag": "Meta-tag",
|
||||
"LabelMetaTags": "Meta-tags",
|
||||
@ -503,6 +521,7 @@
|
||||
"LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden",
|
||||
"LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen",
|
||||
"LabelUploaderDropFiles": "Bestanden neerzetten",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Gebruik hoofdstuktrack",
|
||||
"LabelUseFullTrack": "Gebruik volledige track",
|
||||
"LabelUser": "Gebruiker",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "Rediger bruker {0}",
|
||||
"ButtonViewAll": "Vis alt",
|
||||
"ButtonYes": "Ja",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "Konto",
|
||||
"HeaderAdvanced": "Avansert",
|
||||
"HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger",
|
||||
"HeaderAudiobookTools": "Lydbok Filbehandlingsverktøy",
|
||||
"HeaderAudioTracks": "Lydspor",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Sikkerhetskopier",
|
||||
"HeaderChangePassword": "Bytt passord",
|
||||
"HeaderChapters": "Kapittel",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "Ny konto",
|
||||
"HeaderNewLibrary": "Ny bibliotek",
|
||||
"HeaderNotifications": "Notifikasjoner",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Åpne RSS Feed",
|
||||
"HeaderOtherFiles": "Andre filer",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Rettigheter",
|
||||
"HeaderPlayerQueue": "Spiller kø",
|
||||
"HeaderPlaylist": "Spilleliste",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)",
|
||||
"LabelAuthors": "Forfattere",
|
||||
"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",
|
||||
"LabelBackupLocation": "Backup Location",
|
||||
"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.",
|
||||
"LabelBitrate": "Bithastighet",
|
||||
"LabelBooks": "Bøker",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Endre passord",
|
||||
"LabelChannels": "Kanaler",
|
||||
"LabelChapters": "Kapitler",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Eksempel",
|
||||
"LabelExplicit": "Eksplisitt",
|
||||
"LabelFeedURL": "Feed Adresse",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "Fil",
|
||||
"LabelFileBirthtime": "Fil Opprettelsesdato",
|
||||
"LabelFileModified": "Fil Endret",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Tving sletting av fil",
|
||||
"LabelHasEbook": "Har ebok",
|
||||
"LabelHasSupplementaryEbook": "Har supplerende ebok",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Tjener",
|
||||
"LabelHour": "Time",
|
||||
"LabelIcon": "Ikon",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Warn",
|
||||
"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",
|
||||
"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",
|
||||
"LabelMetaTag": "Meta Tag",
|
||||
"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",
|
||||
"LabelUploaderDragAndDrop": "Dra og slipp filer eller mapper",
|
||||
"LabelUploaderDropFiles": "Slipp filer",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Bruk kapittelspor",
|
||||
"LabelUseFullTrack": "Bruke hele sporet",
|
||||
"LabelUser": "Bruker",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "Edit user {0}",
|
||||
"ButtonViewAll": "Zobacz wszystko",
|
||||
"ButtonYes": "Tak",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "Konto",
|
||||
"HeaderAdvanced": "Zaawansowane",
|
||||
"HeaderAppriseNotificationSettings": "Ustawienia powiadomień Apprise",
|
||||
"HeaderAudiobookTools": "Narzędzia do zarządzania audiobookami",
|
||||
"HeaderAudioTracks": "Ścieżki audio",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Kopie zapasowe",
|
||||
"HeaderChangePassword": "Zmień hasło",
|
||||
"HeaderChapters": "Rozdziały",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "Nowe konto",
|
||||
"HeaderNewLibrary": "Nowa biblioteka",
|
||||
"HeaderNotifications": "Powiadomienia",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Utwórz kanał RSS",
|
||||
"HeaderOtherFiles": "Inne pliki",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Uprawnienia",
|
||||
"HeaderPlayerQueue": "Player Queue",
|
||||
"HeaderPlaylist": "Playlist",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Author (Malejąco)",
|
||||
"LabelAuthors": "Autorzy",
|
||||
"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",
|
||||
"LabelBackupLocation": "Backup Location",
|
||||
"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ąć.",
|
||||
"LabelBitrate": "Bitrate",
|
||||
"LabelBooks": "Książki",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Zmień hasło",
|
||||
"LabelChannels": "Channels",
|
||||
"LabelChapters": "Chapters",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Example",
|
||||
"LabelExplicit": "Nieprzyzwoite",
|
||||
"LabelFeedURL": "URL kanału",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "Plik",
|
||||
"LabelFileBirthtime": "Data utworzenia pliku",
|
||||
"LabelFileModified": "Data modyfikacji pliku",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Usuń trwale plik",
|
||||
"LabelHasEbook": "Has ebook",
|
||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Godzina",
|
||||
"LabelIcon": "Ikona",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Informacja",
|
||||
"LabelLogLevelWarn": "Ostrzeżenie",
|
||||
"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",
|
||||
"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",
|
||||
"LabelMetaTag": "Tag",
|
||||
"LabelMetaTags": "Meta Tags",
|
||||
@ -503,6 +521,7 @@
|
||||
"LabelUpdateDetailsHelp": "Umożliwienie nadpisania istniejących szczegółów dla wybranych książek w przypadku znalezienia dopasowania",
|
||||
"LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub pliki",
|
||||
"LabelUploaderDropFiles": "Puść pliki",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Użyj ścieżki rozdziału",
|
||||
"LabelUseFullTrack": "Użycie ścieżki rozdziału",
|
||||
"LabelUser": "Użytkownik",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "Редактировать пользователя {0}",
|
||||
"ButtonViewAll": "Посмотреть все",
|
||||
"ButtonYes": "Да",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "Учетная запись",
|
||||
"HeaderAdvanced": "Дополнительно",
|
||||
"HeaderAppriseNotificationSettings": "Настройки оповещений",
|
||||
"HeaderAudiobookTools": "Инструменты файлов аудиокниг",
|
||||
"HeaderAudioTracks": "Аудио треки",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Бэкапы",
|
||||
"HeaderChangePassword": "Изменить пароль",
|
||||
"HeaderChapters": "Главы",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "Новая учетная запись",
|
||||
"HeaderNewLibrary": "Новая библиотека",
|
||||
"HeaderNotifications": "Уведомления",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Открыть RSS-канал",
|
||||
"HeaderOtherFiles": "Другие файлы",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Разрешения",
|
||||
"HeaderPlayerQueue": "Очередь воспроизведения",
|
||||
"HeaderPlaylist": "Плейлист",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Автор (Фамилия, Имя)",
|
||||
"LabelAuthors": "Авторы",
|
||||
"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": "Назад к пользователю",
|
||||
"LabelBackupLocation": "Backup Location",
|
||||
"LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование",
|
||||
@ -203,6 +215,7 @@
|
||||
"LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.",
|
||||
"LabelBitrate": "Битрейт",
|
||||
"LabelBooks": "Книги",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Изменить пароль",
|
||||
"LabelChannels": "Каналы",
|
||||
"LabelChapters": "Главы",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Пример",
|
||||
"LabelExplicit": "Явный",
|
||||
"LabelFeedURL": "URL канала",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "Файл",
|
||||
"LabelFileBirthtime": "Дата создания",
|
||||
"LabelFileModified": "Дата модификации",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Жесткое удаление файла",
|
||||
"LabelHasEbook": "Есть e-книга",
|
||||
"LabelHasSupplementaryEbook": "Есть дополнительная e-книга",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Хост",
|
||||
"LabelHour": "Часы",
|
||||
"LabelIcon": "Иконка",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Info",
|
||||
"LabelLogLevelWarn": "Warn",
|
||||
"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": "Медиа проигрыватель",
|
||||
"LabelMediaType": "Тип медиа",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
|
||||
"LabelMetadataProvider": "Провайдер",
|
||||
"LabelMetaTag": "Мета тег",
|
||||
"LabelMetaTags": "Мета теги",
|
||||
@ -503,6 +521,7 @@
|
||||
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
|
||||
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
|
||||
"LabelUploaderDropFiles": "Перетащите файлы",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Показывать время главы",
|
||||
"LabelUseFullTrack": "Показывать время книги",
|
||||
"LabelUser": "Пользователь",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "Redigera användare {0}",
|
||||
"ButtonViewAll": "Visa alla",
|
||||
"ButtonYes": "Ja",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "Konto",
|
||||
"HeaderAdvanced": "Avancerad",
|
||||
"HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar",
|
||||
"HeaderAudiobookTools": "Ljudbokshantering",
|
||||
"HeaderAudioTracks": "Ljudspår",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Säkerhetskopior",
|
||||
"HeaderChangePassword": "Ändra lösenord",
|
||||
"HeaderChapters": "Kapitel",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "Nytt konto",
|
||||
"HeaderNewLibrary": "Nytt bibliotek",
|
||||
"HeaderNotifications": "Meddelanden",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Öppna RSS-flöde",
|
||||
"HeaderOtherFiles": "Andra filer",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Behörigheter",
|
||||
"HeaderPlayerQueue": "Spelarkö",
|
||||
"HeaderPlaylist": "Spellista",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)",
|
||||
"LabelAuthors": "Författare",
|
||||
"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",
|
||||
"LabelBackupLocation": "Säkerhetskopia Plats",
|
||||
"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.",
|
||||
"LabelBitrate": "Bitfrekvens",
|
||||
"LabelBooks": "Böcker",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Ändra lösenord",
|
||||
"LabelChannels": "Kanaler",
|
||||
"LabelChapters": "Kapitel",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "Exempel",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelFeedURL": "Flödes-URL",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "Fil",
|
||||
"LabelFileBirthtime": "Födelse-tidpunkt för fil",
|
||||
"LabelFileModified": "Fil ändrad",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "Hård radering av fil",
|
||||
"LabelHasEbook": "Har e-bok",
|
||||
"LabelHasSupplementaryEbook": "Har kompletterande e-bok",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Värd",
|
||||
"LabelHour": "Timme",
|
||||
"LabelIcon": "Ikon",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "Felsökningsnivå: Information",
|
||||
"LabelLogLevelWarn": "Felsökningsnivå: Varning",
|
||||
"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",
|
||||
"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",
|
||||
"LabelMetaTag": "Metamärke",
|
||||
"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",
|
||||
"LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar",
|
||||
"LabelUploaderDropFiles": "Släpp filer",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "Använd kapitelspår",
|
||||
"LabelUseFullTrack": "Använd hela spåret",
|
||||
"LabelUser": "Användare",
|
||||
|
@ -87,11 +87,15 @@
|
||||
"ButtonUserEdit": "编辑用户 {0}",
|
||||
"ButtonViewAll": "查看全部",
|
||||
"ButtonYes": "确定",
|
||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||
"ErrorUploadLacksTitle": "Must have a title",
|
||||
"HeaderAccount": "帐户",
|
||||
"HeaderAdvanced": "高级",
|
||||
"HeaderAppriseNotificationSettings": "测试通知设置",
|
||||
"HeaderAudiobookTools": "有声读物文件管理工具",
|
||||
"HeaderAudioTracks": "音轨",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "备份",
|
||||
"HeaderChangePassword": "更改密码",
|
||||
"HeaderChapters": "章节",
|
||||
@ -131,8 +135,10 @@
|
||||
"HeaderNewAccount": "新建帐户",
|
||||
"HeaderNewLibrary": "新建媒体库",
|
||||
"HeaderNotifications": "通知",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "打开 RSS 源",
|
||||
"HeaderOtherFiles": "其他文件",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "权限",
|
||||
"HeaderPlayerQueue": "播放队列",
|
||||
"HeaderPlaylist": "播放列表",
|
||||
@ -193,6 +199,12 @@
|
||||
"LabelAuthorLastFirst": "作者 (名, 姓)",
|
||||
"LabelAuthors": "作者",
|
||||
"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": "返回到用户",
|
||||
"LabelBackupLocation": "备份位置",
|
||||
"LabelBackupsEnableAutomaticBackups": "启用自动备份",
|
||||
@ -203,6 +215,7 @@
|
||||
"LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.",
|
||||
"LabelBitrate": "比特率",
|
||||
"LabelBooks": "图书",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "修改密码",
|
||||
"LabelChannels": "声道",
|
||||
"LabelChapters": "章节",
|
||||
@ -258,6 +271,7 @@
|
||||
"LabelExample": "示例",
|
||||
"LabelExplicit": "信息准确",
|
||||
"LabelFeedURL": "源 URL",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
"LabelFile": "文件",
|
||||
"LabelFileBirthtime": "文件创建时间",
|
||||
"LabelFileModified": "文件修改时间",
|
||||
@ -275,6 +289,7 @@
|
||||
"LabelHardDeleteFile": "完全删除文件",
|
||||
"LabelHasEbook": "有电子书",
|
||||
"LabelHasSupplementaryEbook": "有补充电子书",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "主机",
|
||||
"LabelHour": "小时",
|
||||
"LabelIcon": "图标",
|
||||
@ -316,9 +331,12 @@
|
||||
"LabelLogLevelInfo": "信息",
|
||||
"LabelLogLevelWarn": "警告",
|
||||
"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": "媒体播放器",
|
||||
"LabelMediaType": "媒体类型",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority",
|
||||
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
|
||||
"LabelMetadataProvider": "元数据提供者",
|
||||
"LabelMetaTag": "元数据标签",
|
||||
"LabelMetaTags": "元标签",
|
||||
@ -503,6 +521,7 @@
|
||||
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
|
||||
"LabelUploaderDragAndDrop": "拖放文件或文件夹",
|
||||
"LabelUploaderDropFiles": "删除文件",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||
"LabelUseChapterTrack": "使用章节音轨",
|
||||
"LabelUseFullTrack": "使用完整音轨",
|
||||
"LabelUser": "用户",
|
||||
|
4883
package-lock.json
generated
4883
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
@ -15,7 +15,9 @@
|
||||
"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-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",
|
||||
"pkg": {
|
||||
@ -28,15 +30,24 @@
|
||||
"server/**/*.js"
|
||||
]
|
||||
},
|
||||
"mocha": {
|
||||
"recursive": true
|
||||
},
|
||||
"author": "advplyr",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.17.1",
|
||||
"express-session": "^1.17.3",
|
||||
"graceful-fs": "^4.2.10",
|
||||
"htmlparser2": "^8.0.1",
|
||||
"lru-cache": "^10.0.3",
|
||||
"node-tone": "^1.0.1",
|
||||
"nodemailer": "^6.9.2",
|
||||
"openid-client": "^5.6.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"sequelize": "^6.32.1",
|
||||
"socket.io": "^4.5.4",
|
||||
"sqlite3": "^5.1.6",
|
||||
@ -44,6 +55,10 @@
|
||||
"xml2js": "^0.5.0"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
681
server/Auth.js
681
server/Auth.js
@ -1,32 +1,504 @@
|
||||
const axios = require('axios')
|
||||
const passport = require('passport')
|
||||
const bcrypt = require('./libs/bcryptjs')
|
||||
const jwt = require('./libs/jsonwebtoken')
|
||||
const requestIp = require('./libs/requestIp')
|
||||
const Logger = require('./Logger')
|
||||
const LocalStrategy = require('./libs/passportLocal')
|
||||
const JwtStrategy = require('passport-jwt').Strategy
|
||||
const ExtractJwt = require('passport-jwt').ExtractJwt
|
||||
const OpenIDClient = require('openid-client')
|
||||
const Database = require('./Database')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
/**
|
||||
* @class Class for handling all the authentication related functionality.
|
||||
*/
|
||||
class Auth {
|
||||
constructor() { }
|
||||
|
||||
cors(req, res, next) {
|
||||
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
|
||||
// res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization")
|
||||
res.header('Access-Control-Allow-Credentials', true)
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(200)
|
||||
constructor() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inializes all passportjs strategies and other passportjs ralated initialization.
|
||||
*/
|
||||
async initPassportJs() {
|
||||
// Check if we should load the local strategy (username + password login)
|
||||
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 {
|
||||
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() {
|
||||
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
|
||||
} else {
|
||||
Logger.debug(`[Auth] Setting token secret - using random bytes`)
|
||||
Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
|
||||
}
|
||||
await Database.updateServerSettings()
|
||||
@ -35,47 +507,83 @@ class Auth {
|
||||
const users = await Database.userModel.getOldUsers()
|
||||
if (users.length) {
|
||||
for (const user of users) {
|
||||
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
|
||||
Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`)
|
||||
user.token = await this.generateAccessToken(user)
|
||||
}
|
||||
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 (req.method === 'GET' && req.query && req.query.token) {
|
||||
token = req.query.token
|
||||
} else {
|
||||
const authHeader = req.headers['authorization']
|
||||
token = authHeader && authHeader.split(' ')[1]
|
||||
if (!user?.isActive) {
|
||||
// deny login
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
// approve login
|
||||
done(null, user)
|
||||
return
|
||||
}
|
||||
|
||||
if (token == null) {
|
||||
Logger.error('Api called without a token', req.path)
|
||||
return res.sendStatus(401)
|
||||
/**
|
||||
* Checks if a username and password tuple is valid and the user active.
|
||||
* @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)
|
||||
if (!user) {
|
||||
Logger.error('Verify Token User Not Found', token)
|
||||
return res.sendStatus(404)
|
||||
// Check passwordless root user
|
||||
if (user.type === 'root' && !user.pash) {
|
||||
if (password) {
|
||||
// deny login
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
if (!user.isActive) {
|
||||
Logger.error('Verify Token User is disabled', token, user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
req.user = user
|
||||
next()
|
||||
// approve login
|
||||
done(null, user)
|
||||
return
|
||||
} else if (!user.pash) {
|
||||
Logger.error(`[Auth] User "${user.username}"/"${user.type}" attempted to login without a password set`)
|
||||
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) {
|
||||
return new Promise((resolve) => {
|
||||
bcrypt.hash(password, 8, (err, hash) => {
|
||||
if (err) {
|
||||
Logger.error('Hash failed', err)
|
||||
resolve(null)
|
||||
} else {
|
||||
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
|
||||
* @param {oldUser} user
|
||||
* @returns {object}
|
||||
* Return the login info payload for a user
|
||||
*
|
||||
* @param {Object} user
|
||||
* @returns {Promise<Object>} jsonPayload
|
||||
*/
|
||||
async getUserLoginResponsePayload(user) {
|
||||
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()
|
||||
const password = req.body.password || ''
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} password
|
||||
* @param {*} user
|
||||
* @returns {boolean}
|
||||
*/
|
||||
comparePassword(password, user) {
|
||||
if (user.type === 'root' && !password && !user.pash) return true
|
||||
if (!password || !user.pash) return false
|
||||
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) {
|
||||
var { password, newPassword } = req.body
|
||||
let { password, newPassword } = req.body
|
||||
newPassword = newPassword || ''
|
||||
const matchingUser = await Database.userModel.getUserById(req.user.id)
|
||||
const matchingUser = req.user
|
||||
|
||||
// Only root can have an empty password
|
||||
if (matchingUser.type !== 'root' && !newPassword) {
|
||||
@ -187,6 +639,7 @@ class Auth {
|
||||
})
|
||||
}
|
||||
|
||||
// Check password match
|
||||
const compare = await this.comparePassword(password, matchingUser)
|
||||
if (!compare) {
|
||||
return res.json({
|
||||
@ -208,6 +661,7 @@ class Auth {
|
||||
|
||||
const success = await Database.updateUser(matchingUser)
|
||||
if (success) {
|
||||
Logger.info(`[Auth] User "${matchingUser.username}" changed password`)
|
||||
res.json({
|
||||
success: true
|
||||
})
|
||||
@ -218,4 +672,5 @@ class Auth {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Auth
|
@ -5,13 +5,14 @@ class Logger {
|
||||
constructor() {
|
||||
this.isDev = process.env.NODE_ENV !== 'production'
|
||||
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.logManager = null
|
||||
}
|
||||
|
||||
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() {
|
||||
@ -92,7 +93,7 @@ class Logger {
|
||||
* @param {...any} args
|
||||
*/
|
||||
dev(...args) {
|
||||
if (!this.isDev || process.env.HIDE_DEV_LOGS === '1') return
|
||||
if (this.hideDevLogs) return
|
||||
console.log(`[${this.timestamp}] DEV:`, ...args)
|
||||
}
|
||||
|
||||
|
@ -5,6 +5,7 @@ const http = require('http')
|
||||
const fs = require('./libs/fsExtra')
|
||||
const fileUpload = require('./libs/expressFileupload')
|
||||
const rateLimit = require('./libs/expressRateLimit')
|
||||
const cookieParser = require("cookie-parser")
|
||||
|
||||
const { version } = require('../package.json')
|
||||
|
||||
@ -31,8 +32,13 @@ const PodcastManager = require('./managers/PodcastManager')
|
||||
const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
||||
const RssFeedManager = require('./managers/RssFeedManager')
|
||||
const CronManager = require('./managers/CronManager')
|
||||
const ApiCacheManager = require('./managers/ApiCacheManager')
|
||||
const LibraryScanner = require('./scanner/LibraryScanner')
|
||||
|
||||
//Import the main Passport and Express-Session library
|
||||
const passport = require('passport')
|
||||
const expressSession = require('express-session')
|
||||
|
||||
class Server {
|
||||
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
|
||||
this.Port = PORT
|
||||
@ -67,6 +73,7 @@ class Server {
|
||||
this.audioMetadataManager = new AudioMetadataMangaer()
|
||||
this.rssFeedManager = new RssFeedManager()
|
||||
this.cronManager = new CronManager(this.podcastManager)
|
||||
this.apiCacheManager = new ApiCacheManager()
|
||||
|
||||
// Routers
|
||||
this.apiRouter = new ApiRouter(this)
|
||||
@ -79,7 +86,8 @@ class Server {
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -110,6 +118,7 @@ class Server {
|
||||
|
||||
const libraries = await Database.libraryModel.getAllOldLibraries()
|
||||
await this.cronManager.init(libraries)
|
||||
this.apiCacheManager.init()
|
||||
|
||||
if (Database.serverSettings.scannerDisableWatcher) {
|
||||
Logger.info(`[Server] Watcher is disabled`)
|
||||
@ -124,20 +133,65 @@ class Server {
|
||||
await this.init()
|
||||
|
||||
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()
|
||||
app.use(global.RouterBasePath, router)
|
||||
app.disable('x-powered-by')
|
||||
|
||||
this.server = http.createServer(app)
|
||||
|
||||
router.use(this.auth.cors)
|
||||
router.use(fileUpload({
|
||||
defCharset: 'utf8',
|
||||
defParamCharset: 'utf8',
|
||||
useTempFiles: true,
|
||||
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" }))
|
||||
|
||||
// Static path to generated nuxt
|
||||
@ -163,6 +217,9 @@ class Server {
|
||||
this.rssFeedManager.getFeedItem(req, res)
|
||||
})
|
||||
|
||||
// Auth routes
|
||||
await this.auth.initAuthRoutes(router)
|
||||
|
||||
// Client dynamic routes
|
||||
const dyanimicRoutes = [
|
||||
'/item/:id',
|
||||
@ -174,6 +231,7 @@ class Server {
|
||||
'/library/:library/search',
|
||||
'/library/:library/bookshelf/:id?',
|
||||
'/library/:library/authors',
|
||||
'/library/:library/narrators',
|
||||
'/library/:library/series/:id?',
|
||||
'/library/:library/podcast/search',
|
||||
'/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'))))
|
||||
|
||||
router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
|
||||
router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
||||
// 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('/init', (req, res) => {
|
||||
if (Database.hasRootUser) {
|
||||
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
|
||||
// server has been initialized if a root user exists
|
||||
const payload = {
|
||||
app: 'audiobookshelf',
|
||||
serverVersion: version,
|
||||
isInit: Database.hasRootUser,
|
||||
language: Database.serverSettings.language
|
||||
language: Database.serverSettings.language,
|
||||
authMethods: Database.serverSettings.authActiveAuthMethods,
|
||||
authFormData: Database.serverSettings.authFormData
|
||||
}
|
||||
if (!payload.isInit) {
|
||||
payload.ConfigPath = global.ConfigPath
|
||||
|
@ -1,6 +1,7 @@
|
||||
const SocketIO = require('socket.io')
|
||||
const Logger = require('./Logger')
|
||||
const Database = require('./Database')
|
||||
const Auth = require('./Auth')
|
||||
|
||||
class SocketAuthority {
|
||||
constructor() {
|
||||
@ -81,6 +82,7 @@ class SocketAuthority {
|
||||
methods: ["GET", "POST"]
|
||||
}
|
||||
})
|
||||
|
||||
this.io.on('connection', (socket) => {
|
||||
this.clients[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) {
|
||||
const user = await this.Server.auth.authenticateUser(token)
|
||||
if (!user) {
|
||||
// we don't use passport to authenticate the jwt we get over the socket connection.
|
||||
// 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')
|
||||
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]
|
||||
if (!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))
|
||||
|
||||
// Update user lastSeen
|
||||
// Update user lastSeen without firing sequelize bulk update hooks
|
||||
user.lastSeen = Date.now()
|
||||
await Database.updateUser(user)
|
||||
await Database.userModel.updateFromOld(user, false)
|
||||
|
||||
const initialPayload = {
|
||||
userId: client.user.id,
|
||||
|
@ -8,6 +8,7 @@ const Database = require('../Database')
|
||||
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
||||
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
||||
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
|
||||
const { sanitizeFilename } = require('../utils/fileUtils')
|
||||
|
||||
const TaskManager = require('../managers/TaskManager')
|
||||
|
||||
@ -32,12 +33,9 @@ class MiscController {
|
||||
Logger.error('Invalid request, no files')
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const files = Object.values(req.files)
|
||||
const title = req.body.title
|
||||
const author = req.body.author
|
||||
const series = req.body.series
|
||||
const libraryId = req.body.library
|
||||
const folderId = req.body.folder
|
||||
const { title, author, series, folder: folderId, library: libraryId } = req.body
|
||||
|
||||
const library = await Database.libraryModel.getOldById(libraryId)
|
||||
if (!library) {
|
||||
@ -52,40 +50,26 @@ class MiscController {
|
||||
return res.status(500).send(`Invalid post data`)
|
||||
}
|
||||
|
||||
// For setting permissions recursively
|
||||
let outputDirectory = ''
|
||||
let firstDirPath = ''
|
||||
|
||||
if (library.isPodcast) { // Podcasts only in 1 folder
|
||||
outputDirectory = Path.join(folder.fullPath, title)
|
||||
firstDirPath = outputDirectory
|
||||
} 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`)
|
||||
}
|
||||
// Podcasts should only be one folder deep
|
||||
const outputDirectoryParts = library.isPodcast ? [title] : [author, series, title]
|
||||
// `.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
|
||||
// the base folder path
|
||||
const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map(part => sanitizeFilename(part))
|
||||
const outputDirectory = Path.join(...[folder.fullPath, ...cleanedOutputDirectoryParts])
|
||||
|
||||
await fs.ensureDir(outputDirectory)
|
||||
|
||||
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
var file = files[i]
|
||||
for (const file of files) {
|
||||
const path = Path.join(outputDirectory, sanitizeFilename(file.name))
|
||||
|
||||
var path = Path.join(outputDirectory, file.name)
|
||||
await file.mv(path).then(() => {
|
||||
await file.mv(path)
|
||||
.then(() => {
|
||||
return true
|
||||
}).catch((error) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error('Failed to move file', path, error)
|
||||
return false
|
||||
})
|
||||
@ -119,8 +103,9 @@ class MiscController {
|
||||
/**
|
||||
* PATCH: /api/settings
|
||||
* Update server settings
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async updateServerSettings(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
@ -128,7 +113,7 @@ class MiscController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const settingsUpdate = req.body
|
||||
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
||||
if (!isObject(settingsUpdate)) {
|
||||
return res.status(400).send('Invalid settings update object')
|
||||
}
|
||||
|
||||
@ -248,8 +233,8 @@ class MiscController {
|
||||
* POST: /api/authorize
|
||||
* Used to authorize an API token
|
||||
*
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async authorize(req, res) {
|
||||
if (!req.user) {
|
||||
@ -555,10 +540,10 @@ class MiscController {
|
||||
switch (type) {
|
||||
case 'add':
|
||||
this.watcher.onFileAdded(libraryId, path)
|
||||
break;
|
||||
break
|
||||
case 'unlink':
|
||||
this.watcher.onFileRemoved(libraryId, path)
|
||||
break;
|
||||
break
|
||||
case 'rename':
|
||||
const oldPath = req.body.oldPath
|
||||
if (!oldPath) {
|
||||
@ -566,7 +551,7 @@ class MiscController {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
this.watcher.onFileRename(libraryId, oldPath, path)
|
||||
break;
|
||||
break
|
||||
default:
|
||||
Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`)
|
||||
return res.sendStatus(400)
|
||||
@ -589,5 +574,105 @@ class MiscController {
|
||||
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()
|
@ -6,7 +6,7 @@ class SessionController {
|
||||
constructor() { }
|
||||
|
||||
async findOne(req, res) {
|
||||
return res.json(req.session)
|
||||
return res.json(req.playbackSession)
|
||||
}
|
||||
|
||||
async getAllWithUserData(req, res) {
|
||||
@ -63,32 +63,32 @@ class SessionController {
|
||||
}
|
||||
|
||||
async getOpenSession(req, res) {
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.session.libraryItemId)
|
||||
const sessionForClient = req.session.toJSONForClient(libraryItem)
|
||||
const libraryItem = await Database.libraryItemModel.getOldById(req.playbackSession.libraryItemId)
|
||||
const sessionForClient = req.playbackSession.toJSONForClient(libraryItem)
|
||||
res.json(sessionForClient)
|
||||
}
|
||||
|
||||
// POST: api/session/:id/sync
|
||||
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
|
||||
close(req, res) {
|
||||
let syncData = req.body
|
||||
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
|
||||
async delete(req, res) {
|
||||
// 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) {
|
||||
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)
|
||||
}
|
||||
|
||||
@ -111,7 +111,7 @@ class SessionController {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
req.session = playbackSession
|
||||
req.playbackSession = playbackSession
|
||||
next()
|
||||
}
|
||||
|
||||
@ -130,7 +130,7 @@ class SessionController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
req.session = playbackSession
|
||||
req.playbackSession = playbackSession
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
@ -100,7 +100,7 @@ class UserController {
|
||||
account.id = uuidv4()
|
||||
account.pash = await this.auth.hashPass(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()
|
||||
const newUser = new User(account)
|
||||
|
||||
@ -150,7 +150,7 @@ class UserController {
|
||||
|
||||
if (user.update(account)) {
|
||||
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`)
|
||||
}
|
||||
await Database.updateUser(user)
|
||||
|
@ -31,52 +31,11 @@ class BookFinder {
|
||||
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) {
|
||||
var searchTitle = this.cleanTitleForCompares(title)
|
||||
var searchAuthor = this.cleanAuthorForCompares(author)
|
||||
var searchTitle = cleanTitleForCompares(title)
|
||||
var searchAuthor = cleanAuthorForCompares(author)
|
||||
return books.map(b => {
|
||||
b.cleanedTitle = this.cleanTitleForCompares(b.title)
|
||||
b.cleanedTitle = cleanTitleForCompares(b.title)
|
||||
b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
|
||||
|
||||
// Total length of search (title or both title & author)
|
||||
@ -87,7 +46,7 @@ class BookFinder {
|
||||
b.authorDistance = author.length
|
||||
} else {
|
||||
b.totalPossibleDistance += b.author.length
|
||||
b.cleanedAuthor = this.cleanAuthorForCompares(b.author)
|
||||
b.cleanedAuthor = cleanAuthorForCompares(b.author)
|
||||
|
||||
var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor)
|
||||
var authorDistance = levenshteinDistance(b.author || '', author)
|
||||
@ -190,20 +149,17 @@ class BookFinder {
|
||||
|
||||
static TitleCandidates = class {
|
||||
|
||||
constructor(bookFinder, cleanAuthor) {
|
||||
this.bookFinder = bookFinder
|
||||
constructor(cleanAuthor) {
|
||||
this.candidates = new Set()
|
||||
this.cleanAuthor = cleanAuthor
|
||||
this.priorities = {}
|
||||
this.positions = {}
|
||||
this.currentPosition = 0
|
||||
}
|
||||
|
||||
add(title, position = 0) {
|
||||
add(title) {
|
||||
// if title contains the author, remove it
|
||||
if (this.cleanAuthor) {
|
||||
const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g")
|
||||
title = this.bookFinder.cleanAuthorForCompares(title).replace(authorRe, '').trim()
|
||||
}
|
||||
title = this.#removeAuthorFromTitle(title)
|
||||
|
||||
const titleTransformers = [
|
||||
[/([,:;_]| by ).*/g, ''], // Remove subtitle
|
||||
@ -215,11 +171,11 @@ class BookFinder {
|
||||
]
|
||||
|
||||
// Main variant
|
||||
const cleanTitle = this.bookFinder.cleanTitleForCompares(title).trim()
|
||||
const cleanTitle = cleanTitleForCompares(title).trim()
|
||||
if (!cleanTitle) return
|
||||
this.candidates.add(cleanTitle)
|
||||
this.priorities[cleanTitle] = 0
|
||||
this.positions[cleanTitle] = position
|
||||
this.positions[cleanTitle] = this.currentPosition
|
||||
|
||||
let candidate = cleanTitle
|
||||
|
||||
@ -230,10 +186,11 @@ class BookFinder {
|
||||
if (candidate) {
|
||||
this.candidates.add(candidate)
|
||||
this.priorities[candidate] = 0
|
||||
this.positions[candidate] = position
|
||||
this.positions[candidate] = this.currentPosition
|
||||
}
|
||||
this.priorities[cleanTitle] = 1
|
||||
}
|
||||
this.currentPosition++
|
||||
}
|
||||
|
||||
get size() {
|
||||
@ -243,23 +200,16 @@ class BookFinder {
|
||||
getCandidates() {
|
||||
var candidates = [...this.candidates]
|
||||
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
|
||||
const onlyDigits = /^\d+$/
|
||||
const includesOnlyDigitsDiff = !onlyDigits.test(b) - !onlyDigits.test(a)
|
||||
const includesOnlyDigitsDiff = onlyDigits.test(a) - onlyDigits.test(b)
|
||||
if (includesOnlyDigitsDiff) return includesOnlyDigitsDiff
|
||||
// transformed candidates receive higher priority
|
||||
const priorityDiff = this.priorities[a] - this.priorities[b]
|
||||
if (priorityDiff) return priorityDiff
|
||||
// if same priorirty, prefer candidates that are closer to the beginning (e.g. titles before subtitles)
|
||||
const positionDiff = this.positions[a] - this.positions[b]
|
||||
if (positionDiff) return positionDiff
|
||||
// Start with longer candidaets, as they are likely more specific
|
||||
const lengthDiff = b.length - a.length
|
||||
if (lengthDiff) return lengthDiff
|
||||
return b.localeCompare(a)
|
||||
return positionDiff // candidates with same priority always have different positions
|
||||
})
|
||||
Logger.debug(`[${this.constructor.name}] Found ${candidates.length} fuzzy title candidates`)
|
||||
Logger.debug(candidates)
|
||||
@ -269,21 +219,32 @@ class BookFinder {
|
||||
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 {
|
||||
constructor(bookFinder, cleanAuthor) {
|
||||
this.bookFinder = bookFinder
|
||||
constructor(cleanAuthor, audnexus) {
|
||||
this.audnexus = audnexus
|
||||
this.candidates = new Set()
|
||||
this.cleanAuthor = cleanAuthor
|
||||
if (cleanAuthor) this.candidates.add(cleanAuthor)
|
||||
}
|
||||
|
||||
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()) {
|
||||
if (i > 10) break
|
||||
let cleanName = this.bookFinder.cleanAuthorForCompares(asin.name)
|
||||
let cleanName = cleanAuthorForCompares(asin.name)
|
||||
if (!cleanName) continue
|
||||
if (cleanName.includes(name)) return name
|
||||
if (name.includes(cleanName)) return cleanName
|
||||
@ -294,7 +255,7 @@ class BookFinder {
|
||||
}
|
||||
|
||||
add(author) {
|
||||
const cleanAuthor = this.bookFinder.cleanAuthorForCompares(author).trim()
|
||||
const cleanAuthor = cleanAuthorForCompares(author).trim()
|
||||
if (!cleanAuthor) return
|
||||
this.candidates.add(cleanAuthor)
|
||||
}
|
||||
@ -362,10 +323,10 @@ class BookFinder {
|
||||
title = title.trim().toLowerCase()
|
||||
author = author?.trim().toLowerCase() || ''
|
||||
|
||||
const cleanAuthor = this.cleanAuthorForCompares(author)
|
||||
const cleanAuthor = cleanAuthorForCompares(author)
|
||||
|
||||
// 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
|
||||
const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, " - ")
|
||||
@ -375,9 +336,9 @@ class BookFinder {
|
||||
authorCandidates.add(titlePart)
|
||||
authorCandidates = await authorCandidates.getCandidates()
|
||||
for (const authorCandidate of authorCandidates) {
|
||||
let titleCandidates = new BookFinder.TitleCandidates(this, authorCandidate)
|
||||
for (const [position, titlePart] of titleParts.entries())
|
||||
titleCandidates.add(titlePart, position)
|
||||
let titleCandidates = new BookFinder.TitleCandidates(authorCandidate)
|
||||
for (const titlePart of titleParts)
|
||||
titleCandidates.add(titlePart)
|
||||
titleCandidates = titleCandidates.getCandidates()
|
||||
for (const titleCandidate of titleCandidates) {
|
||||
if (titleCandidate == title && authorCandidate == author) continue // We already tried this
|
||||
@ -457,3 +418,52 @@ class 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()
|
||||
}
|
||||
|
20
server/libs/passportLocal/LICENSE
Normal file
20
server/libs/passportLocal/LICENSE
Normal 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.
|
20
server/libs/passportLocal/index.js
Normal file
20
server/libs/passportLocal/index.js
Normal 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;
|
119
server/libs/passportLocal/strategy.js
Normal file
119
server/libs/passportLocal/strategy.js
Normal 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;
|
54
server/managers/ApiCacheManager.js
Normal file
54
server/managers/ApiCacheManager.js
Normal 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
|
@ -1,7 +1,9 @@
|
||||
const uuidv4 = require("uuid").v4
|
||||
const { DataTypes, Model, Op } = require('sequelize')
|
||||
const sequelize = require('sequelize')
|
||||
const Logger = require('../Logger')
|
||||
const oldUser = require('../objects/user/User')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
const { DataTypes, Model } = sequelize
|
||||
|
||||
class User extends Model {
|
||||
constructor(values, options) {
|
||||
@ -46,6 +48,12 @@ class User extends Model {
|
||||
return users.map(u => this.getOldUser(u))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get old user model from new
|
||||
*
|
||||
* @param {Object} userExpanded
|
||||
* @returns {oldUser}
|
||||
*/
|
||||
static getOldUser(userExpanded) {
|
||||
const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress())
|
||||
|
||||
@ -72,18 +80,32 @@ class User extends Model {
|
||||
createdAt: userExpanded.createdAt.valueOf(),
|
||||
permissions,
|
||||
librariesAccessible,
|
||||
itemTagsSelected
|
||||
itemTagsSelected,
|
||||
authOpenIDSub: userExpanded.extraData?.authOpenIDSub || null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {oldUser} oldUser
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
static createFromOld(oldUser) {
|
||||
const user = this.getFromOld(oldUser)
|
||||
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)
|
||||
return this.update(user, {
|
||||
hooks: !!hooks,
|
||||
where: {
|
||||
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) {
|
||||
const extraData = {
|
||||
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
|
||||
oldUserId: oldUser.oldUserId
|
||||
}
|
||||
if (oldUser.authOpenIDSub) {
|
||||
extraData.authOpenIDSub = oldUser.authOpenIDSub
|
||||
}
|
||||
|
||||
return {
|
||||
id: oldUser.id,
|
||||
username: oldUser.username,
|
||||
@ -103,10 +139,7 @@ class User extends Model {
|
||||
token: oldUser.token || null,
|
||||
isActive: !!oldUser.isActive,
|
||||
lastSeen: oldUser.lastSeen || null,
|
||||
extraData: {
|
||||
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
|
||||
oldUserId: oldUser.oldUserId
|
||||
},
|
||||
extraData,
|
||||
createdAt: oldUser.createdAt || Date.now(),
|
||||
permissions: {
|
||||
...oldUser.permissions,
|
||||
@ -130,12 +163,12 @@ class User extends Model {
|
||||
* @param {string} username
|
||||
* @param {string} pash
|
||||
* @param {Auth} auth
|
||||
* @returns {oldUser}
|
||||
* @returns {Promise<oldUser>}
|
||||
*/
|
||||
static async createRootUser(username, pash, auth) {
|
||||
const userId = uuidv4()
|
||||
|
||||
const token = await auth.generateAccessToken({ userId, username })
|
||||
const token = await auth.generateAccessToken({ id: userId, username })
|
||||
|
||||
const newRoot = new oldUser({
|
||||
id: userId,
|
||||
@ -150,6 +183,38 @@ class User extends Model {
|
||||
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
|
||||
* @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
|
||||
const user = await this.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
[sequelize.Op.or]: [
|
||||
{
|
||||
id: userId
|
||||
},
|
||||
{
|
||||
extraData: {
|
||||
[Op.substring]: userId
|
||||
[sequelize.Op.substring]: userId
|
||||
}
|
||||
}
|
||||
]
|
||||
@ -187,7 +252,26 @@ class User extends Model {
|
||||
const user = await this.findOne({
|
||||
where: {
|
||||
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
|
||||
@ -210,6 +294,21 @@ class User extends Model {
|
||||
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
|
||||
* @returns {object[]} { id, username }
|
||||
|
@ -9,7 +9,7 @@ class LibrarySettings {
|
||||
this.autoScanCronExpression = null
|
||||
this.audiobooksOnly = false
|
||||
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) {
|
||||
this.construct(settings)
|
||||
@ -28,7 +28,7 @@ class LibrarySettings {
|
||||
this.metadataPrecedence = [...settings.metadataPrecedence]
|
||||
} else {
|
||||
// Added in v2.4.5
|
||||
this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata']
|
||||
this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -54,6 +54,24 @@ class ServerSettings {
|
||||
this.version = packageJson.version
|
||||
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) {
|
||||
this.construct(settings)
|
||||
}
|
||||
@ -94,6 +112,36 @@ class ServerSettings {
|
||||
this.version = settings.version || null
|
||||
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
|
||||
if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0
|
||||
this.storeCoverWithItem = !!settings.storeCoverWithBook
|
||||
@ -150,23 +198,96 @@ class ServerSettings {
|
||||
language: this.language,
|
||||
logLevel: this.logLevel,
|
||||
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() {
|
||||
const json = this.toJSON()
|
||||
delete json.tokenSecret
|
||||
delete json.authOpenIDClientID
|
||||
delete json.authOpenIDClientSecret
|
||||
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) {
|
||||
var hasUpdates = false
|
||||
let hasUpdates = false
|
||||
for (const key in payload) {
|
||||
if (key === 'sortingPrefixes' && payload[key] && payload[key].length) {
|
||||
var prefixesCleaned = payload[key].filter(prefix => !!prefix).map(prefix => prefix.toLowerCase())
|
||||
if (prefixesCleaned.join(',') !== this[key].join(',')) {
|
||||
this[key] = [...prefixesCleaned]
|
||||
if (key === 'sortingPrefixes') {
|
||||
// Sorting prefixes are updated with the /api/sorting-prefixes endpoint
|
||||
continue
|
||||
} 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
|
||||
}
|
||||
} else if (this[key] !== payload[key]) {
|
||||
|
@ -24,6 +24,8 @@ class User {
|
||||
this.librariesAccessible = [] // Library IDs (Empty if ALL libraries)
|
||||
this.itemTagsSelected = [] // Empty if ALL item tags accessible
|
||||
|
||||
this.authOpenIDSub = null
|
||||
|
||||
if (user) {
|
||||
this.construct(user)
|
||||
}
|
||||
@ -66,7 +68,7 @@ class User {
|
||||
getDefaultUserPermissions() {
|
||||
return {
|
||||
download: true,
|
||||
update: true,
|
||||
update: this.type === 'root' || this.type === 'admin',
|
||||
delete: this.type === 'root',
|
||||
upload: this.type === 'root' || this.type === 'admin',
|
||||
accessAllLibraries: true,
|
||||
@ -93,7 +95,8 @@ class User {
|
||||
createdAt: this.createdAt,
|
||||
permissions: this.permissions,
|
||||
librariesAccessible: [...this.librariesAccessible],
|
||||
itemTagsSelected: [...this.itemTagsSelected]
|
||||
itemTagsSelected: [...this.itemTagsSelected],
|
||||
authOpenIDSub: this.authOpenIDSub
|
||||
}
|
||||
}
|
||||
|
||||
@ -186,6 +189,8 @@ class User {
|
||||
|
||||
this.librariesAccessible = [...(user.librariesAccessible || [])]
|
||||
this.itemTagsSelected = [...(user.itemTagsSelected || [])]
|
||||
|
||||
this.authOpenIDSub = user.authOpenIDSub || null
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
|
@ -35,6 +35,7 @@ const Series = require('../objects/entities/Series')
|
||||
|
||||
class ApiRouter {
|
||||
constructor(Server) {
|
||||
/** @type {import('../Auth')} */
|
||||
this.auth = Server.auth
|
||||
this.playbackSessionManager = Server.playbackSessionManager
|
||||
this.abMergeManager = Server.abMergeManager
|
||||
@ -47,6 +48,7 @@ class ApiRouter {
|
||||
this.cronManager = Server.cronManager
|
||||
this.notificationManager = Server.notificationManager
|
||||
this.emailManager = Server.emailManager
|
||||
this.apiCacheManager = Server.apiCacheManager
|
||||
|
||||
this.router = express()
|
||||
this.router.disable('x-powered-by')
|
||||
@ -57,6 +59,7 @@ class ApiRouter {
|
||||
//
|
||||
// Library Routes
|
||||
//
|
||||
this.router.get(/^\/libraries/, this.apiCacheManager.middleware)
|
||||
this.router.post('/libraries', LibraryController.create.bind(this))
|
||||
this.router.get('/libraries', LibraryController.findAll.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.delete('/genres/:genre', MiscController.deleteGenre.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))
|
||||
}
|
||||
|
||||
|
@ -18,6 +18,7 @@ const BookFinder = require('../finders/BookFinder')
|
||||
|
||||
const LibraryScan = require("./LibraryScan")
|
||||
const OpfFileScanner = require('./OpfFileScanner')
|
||||
const NfoFileScanner = require('./NfoFileScanner')
|
||||
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
|
||||
|
||||
/**
|
||||
@ -593,7 +594,7 @@ class BookScanner {
|
||||
}
|
||||
|
||||
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(', ')}]`)
|
||||
for (const metadataSource of metadataPrecedence) {
|
||||
if (bookMetadataSourceHandler[metadataSource]) {
|
||||
@ -649,6 +650,14 @@ class BookScanner {
|
||||
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
|
||||
*/
|
||||
|
@ -132,6 +132,11 @@ class LibraryItemScanData {
|
||||
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
|
||||
|
48
server/scanner/NfoFileScanner.js
Normal file
48
server/scanner/NfoFileScanner.js
Normal 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()
|
@ -308,6 +308,7 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
||||
.replace(lineBreaks, replacement)
|
||||
.replace(windowsReservedRe, replacement)
|
||||
.replace(windowsTrailingRe, replacement)
|
||||
.replace(/\s+/g, ' ') // Replace consecutive spaces with a single space
|
||||
|
||||
// Check if basename is too many bytes
|
||||
const ext = Path.extname(sanitized) // separate out file extension
|
||||
|
100
server/utils/parsers/parseNfoMetadata.js
Normal file
100
server/utils/parsers/parseNfoMetadata.js
Normal 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
|
||||
}
|
344
test/server/finders/BookFinder.test.js
Normal file
344
test/server/finders/BookFinder.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
97
test/server/managers/ApiCacheManager.test.js
Normal file
97
test/server/managers/ApiCacheManager.test.js
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
123
test/server/utils/parsers/parseNfoMetadata.test.js
Normal file
123
test/server/utils/parsers/parseNfoMetadata.test.js
Normal 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
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue
Block a user