mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-03 13:09:27 -05:00
Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
086954fb9c | ||
|
|
f243ad14e0 | ||
|
|
2e5822b7c8 | ||
|
|
3d468339b3 | ||
|
|
b4c14fc78d | ||
|
|
d9584174ff | ||
|
|
36e00e8d6a | ||
|
|
5e69b54eb0 | ||
|
|
5a8c60a8bc | ||
|
|
3ff41f2b43 | ||
|
|
17cab0d3a8 | ||
|
|
0fac9e367d | ||
|
|
bf0bcf8967 | ||
|
|
2e06ae01a1 | ||
|
|
288a32cc1e | ||
|
|
26fc3a1966 | ||
|
|
9d257ebecd | ||
|
|
1a046a9bcb | ||
|
|
7a9c869ac5 | ||
|
|
572fb0993c | ||
|
|
9beee3ed65 | ||
|
|
ab19e25586 | ||
|
|
07d7d16418 | ||
|
|
5e1e748c71 | ||
|
|
6651ad0d45 | ||
|
|
288beae874 | ||
|
|
32ce771911 | ||
|
|
d944ecaa21 | ||
|
|
5aeb6ade72 | ||
|
|
107b4b83c1 | ||
|
|
0d61e29ecf | ||
|
|
781d4f570f | ||
|
|
a4d4f1bc2e | ||
|
|
048e27f03f | ||
|
|
7b6aa3ba5a | ||
|
|
aa933df525 | ||
|
|
a0f137936d | ||
|
|
dcbfc963c1 | ||
|
|
91fa78d740 | ||
|
|
89eb857c14 | ||
|
|
e07d17c472 | ||
|
|
4c2c320b9d | ||
|
|
56c574c928 | ||
|
|
d2aea86957 | ||
|
|
80e061115f | ||
|
|
4299627f5f | ||
|
|
6a722102c5 | ||
|
|
f22f3361d5 | ||
|
|
4dec8c265d | ||
|
|
d990e5b909 | ||
|
|
fb48636510 | ||
|
|
1ad6722e6d | ||
|
|
557ef2ef79 | ||
|
|
cff2caa07a | ||
|
|
237fe84c54 | ||
|
|
078cb0855f | ||
|
|
ecba67da6d | ||
|
|
ea05e1f559 | ||
|
|
d3a55c8b1a | ||
|
|
d6b17678ec | ||
|
|
33e287a543 | ||
|
|
08f045a02b | ||
|
|
e8c14dbb58 | ||
|
|
bf48eee705 | ||
|
|
8f4c75ff2b | ||
|
|
ee75d672e6 | ||
|
|
e140897313 | ||
|
|
d1671f0ddc | ||
|
|
2730486ba5 | ||
|
|
49e4515785 | ||
|
|
819c524f51 | ||
|
|
70c213ad22 | ||
|
|
aad6402fdb | ||
|
|
5ce1cda2d0 | ||
|
|
ba60fc7581 | ||
|
|
0344e8cf1b | ||
|
|
f840aa80f8 | ||
|
|
c17540e191 | ||
|
|
309ef807ab | ||
|
|
61e05e92a8 | ||
|
|
1e5d6a5d52 | ||
|
|
ff831678e8 | ||
|
|
910be21e93 | ||
|
|
89055f8655 | ||
|
|
b9ccc28baa | ||
|
|
5a3d450482 | ||
|
|
047e7a72f2 | ||
|
|
3a9d09ea63 | ||
|
|
ee3d3808ef | ||
|
|
8f5a6b7c95 | ||
|
|
840811b464 | ||
|
|
567e1c46db | ||
|
|
cfe0c2a986 | ||
|
|
68546acf2a | ||
|
|
5220361151 | ||
|
|
076e01dbfe | ||
|
|
f15ed08b6a | ||
|
|
828b96b2d9 | ||
|
|
3100437651 | ||
|
|
20880a6bf6 | ||
|
|
2eff69fe9f | ||
|
|
5f035db0a9 | ||
|
|
e4a7e9d6b5 | ||
|
|
ab14b561f5 | ||
|
|
5ce4734a70 | ||
|
|
1ae2089253 | ||
|
|
3c21e9d413 | ||
|
|
9616d99640 | ||
|
|
2662e8f715 | ||
|
|
0d5a30b214 | ||
|
|
e282142d3f | ||
|
|
7ba10db7d4 | ||
|
|
f6de373388 | ||
|
|
9922294507 | ||
|
|
f42ab45e1b | ||
|
|
7a131880e5 | ||
|
|
0e75c80627 | ||
|
|
2c25f64652 | ||
|
|
45cf00bd04 | ||
|
|
f6113e85c7 | ||
|
|
2c90bba774 | ||
|
|
51b0750a3f | ||
|
|
0a6cd89090 | ||
|
|
942aa93f57 | ||
|
|
763c0f4a3d | ||
|
|
7af3033f8d | ||
|
|
91d8451ab3 | ||
|
|
6aaf3f0f02 | ||
|
|
226a774ab9 | ||
|
|
af4c35069b | ||
|
|
405c954b65 | ||
|
|
f0f03efe17 | ||
|
|
dd9a3858d7 | ||
|
|
95e6fef3d1 | ||
|
|
4359ca28df | ||
|
|
8b685436de | ||
|
|
8d0064763c | ||
|
|
7010a13648 | ||
|
|
812395b21b | ||
|
|
62b0940766 | ||
|
|
08676a675a | ||
|
|
be53b31712 | ||
|
|
e1ddb95250 |
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"
|
||||
}
|
||||
@@ -258,4 +258,24 @@ 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 -->
|
||||
|
||||
@@ -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>
|
||||
@@ -30,7 +30,7 @@
|
||||
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs md:text-sm text-gray-400">{{ bookDuration }}</p>
|
||||
<p v-if="media.duration" class="text-xs md:text-sm text-gray-400">{{ bookDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { supplant } from './utils'
|
||||
const defaultCode = 'en-us'
|
||||
|
||||
const languageCodeMap = {
|
||||
'cs': { label: 'Čeština', dateFnsLocale: 'cs' },
|
||||
'da': { label: 'Dansk', dateFnsLocale: 'da' },
|
||||
'de': { label: 'Deutsch', dateFnsLocale: 'de' },
|
||||
'en-us': { label: 'English', dateFnsLocale: 'enUS' },
|
||||
@@ -17,6 +18,7 @@ const languageCodeMap = {
|
||||
'no': { label: 'Norsk', dateFnsLocale: 'no' },
|
||||
'pl': { label: 'Polski', dateFnsLocale: 'pl' },
|
||||
'ru': { label: 'Русский', dateFnsLocale: 'ru' },
|
||||
'sv': { label: 'Svenska', dateFnsLocale: 'sv' },
|
||||
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' },
|
||||
}
|
||||
Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map(code => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
741
client/strings/cs.json
Normal file
741
client/strings/cs.json
Normal file
@@ -0,0 +1,741 @@
|
||||
{
|
||||
"ButtonAdd": "Přidat",
|
||||
"ButtonAddChapters": "Přidat kapitoly",
|
||||
"ButtonAddDevice": "Přidat zařízení",
|
||||
"ButtonAddLibrary": "Přidat knihovnu",
|
||||
"ButtonAddPodcasts": "Přidat podcasty",
|
||||
"ButtonAddUser": "Přidat uživatele",
|
||||
"ButtonAddYourFirstLibrary": "Vytvořte svou první knihovnu",
|
||||
"ButtonApply": "Aplikovat",
|
||||
"ButtonApplyChapters": "Aplikovat kapitoly",
|
||||
"ButtonAuthors": "Autoři",
|
||||
"ButtonBrowseForFolder": "Vyhledat složku",
|
||||
"ButtonCancel": "Zrušit",
|
||||
"ButtonCancelEncode": "Zrušit kódování",
|
||||
"ButtonChangeRootPassword": "Změnit 'Root' heslo",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Zkontrolovat & stáhnout nové epizody",
|
||||
"ButtonChooseAFolder": "Vybrat složku",
|
||||
"ButtonChooseFiles": "Vybrat soubory",
|
||||
"ButtonClearFilter": "Vymazat filtr",
|
||||
"ButtonCloseFeed": "Zavřít kanál",
|
||||
"ButtonCollections": "Kolekce",
|
||||
"ButtonConfigureScanner": "Konfigurovat Prohledávání",
|
||||
"ButtonCreate": "Vytvořit",
|
||||
"ButtonCreateBackup": "Vytvořit zálohu",
|
||||
"ButtonDelete": "Smazat",
|
||||
"ButtonDownloadQueue": "Fronta",
|
||||
"ButtonEdit": "Upravit",
|
||||
"ButtonEditChapters": "Upravit kapitoly",
|
||||
"ButtonEditPodcast": "Upravit podcast",
|
||||
"ButtonForceReScan": "Vynutit opětovné prohledání",
|
||||
"ButtonFullPath": "Úplná cesta",
|
||||
"ButtonHide": "Skrýt",
|
||||
"ButtonHome": "Domů",
|
||||
"ButtonIssues": "Problémy",
|
||||
"ButtonLatest": "Nejnovější",
|
||||
"ButtonLibrary": "Knihovna",
|
||||
"ButtonLogout": "Odhlásit",
|
||||
"ButtonLookup": "Vyhledat",
|
||||
"ButtonManageTracks": "Správa stop",
|
||||
"ButtonMapChapterTitles": "Mapovat názvy kapitol",
|
||||
"ButtonMatchAllAuthors": "Spárovat všechny autory",
|
||||
"ButtonMatchBooks": "Spárovat Knihy",
|
||||
"ButtonNevermind": "Nevadí",
|
||||
"ButtonOk": "Ok",
|
||||
"ButtonOpenFeed": "Otevřít kanál",
|
||||
"ButtonOpenManager": "Otevřít správce",
|
||||
"ButtonPlay": "Přehrát",
|
||||
"ButtonPlaying": "Hraje",
|
||||
"ButtonPlaylists": "Seznamy skladeb",
|
||||
"ButtonPurgeAllCache": "Vyčistit veškerou mezipaměť",
|
||||
"ButtonPurgeItemsCache": "Vyčistit mezipaměť položek",
|
||||
"ButtonPurgeMediaProgress": "Vyčistit průběh médií",
|
||||
"ButtonQueueAddItem": "Přidat do fronty",
|
||||
"ButtonQueueRemoveItem": "Odstranit z fronty",
|
||||
"ButtonQuickMatch": "Rychlé přiřazení",
|
||||
"ButtonRead": "Číst",
|
||||
"ButtonRemove": "Odstranit",
|
||||
"ButtonRemoveAll": "Odstranit vše",
|
||||
"ButtonRemoveAllLibraryItems": "Odstranit všechny položky knihovny",
|
||||
"ButtonRemoveFromContinueListening": "Odstranit z Pokračovat v poslechu",
|
||||
"ButtonRemoveFromContinueReading": "Odstranit z Pokračovat ve čtení",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Odstranit sérii z Pokračovat v sérii",
|
||||
"ButtonReScan": "Znovu prohledat",
|
||||
"ButtonReset": "Resetovat",
|
||||
"ButtonResetToDefault": "Obnovit výchozí",
|
||||
"ButtonRestore": "Obnovit",
|
||||
"ButtonSave": "Uložit",
|
||||
"ButtonSaveAndClose": "Uložit a zavřít",
|
||||
"ButtonSaveTracklist": "Uložit seznam skladeb",
|
||||
"ButtonScan": "Prohledat",
|
||||
"ButtonScanLibrary": "Prohledat Knihovnu",
|
||||
"ButtonSearch": "Hledat",
|
||||
"ButtonSelectFolderPath": "Vybrat cestu ke složce",
|
||||
"ButtonSeries": "Série",
|
||||
"ButtonSetChaptersFromTracks": "Nastavit kapitoly ze stop",
|
||||
"ButtonShiftTimes": "Časy posunu",
|
||||
"ButtonShow": "Zobrazit",
|
||||
"ButtonStartM4BEncode": "Spustit kódování M4B",
|
||||
"ButtonStartMetadataEmbed": "Spustit vkládání metadat",
|
||||
"ButtonSubmit": "Odeslat",
|
||||
"ButtonTest": "Test",
|
||||
"ButtonUpload": "Nahrát",
|
||||
"ButtonUploadBackup": "Nahrát zálohu",
|
||||
"ButtonUploadCover": "Nahrát obálku",
|
||||
"ButtonUploadOPMLFile": "Nahrát soubor OPML",
|
||||
"ButtonUserDelete": "Smazat uživatelský {0}",
|
||||
"ButtonUserEdit": "Upravit uživatelské {0}",
|
||||
"ButtonViewAll": "Zobrazit vše",
|
||||
"ButtonYes": "Ano",
|
||||
"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",
|
||||
"HeaderChooseAFolder": "Zvolte složku",
|
||||
"HeaderCollection": "Kolekce",
|
||||
"HeaderCollectionItems": "Položky kolekce",
|
||||
"HeaderCover": "Obálka",
|
||||
"HeaderCurrentDownloads": "Aktuální stahování",
|
||||
"HeaderDetails": "Podrobnosti",
|
||||
"HeaderDownloadQueue": "Fronta stahování",
|
||||
"HeaderEbookFiles": "Soubory elektronických knih",
|
||||
"HeaderEmail": "E-mail",
|
||||
"HeaderEmailSettings": "Nastavení e-mailu",
|
||||
"HeaderEpisodes": "Epizody",
|
||||
"HeaderEreaderDevices": "Čtečky elektronických knih",
|
||||
"HeaderEreaderSettings": "Nastavení čtečky elektronických knih",
|
||||
"HeaderFiles": "Soubory",
|
||||
"HeaderFindChapters": "Najít kapitoly",
|
||||
"HeaderIgnoredFiles": "Ignorované soubory",
|
||||
"HeaderItemFiles": "Soubory položek",
|
||||
"HeaderItemMetadataUtils": "Nástroje metadat položek",
|
||||
"HeaderLastListeningSession": "Poslední poslechová relace",
|
||||
"HeaderLatestEpisodes": "Poslední epizody",
|
||||
"HeaderLibraries": "Knihovny",
|
||||
"HeaderLibraryFiles": "Soubory knihovny",
|
||||
"HeaderLibraryStats": "Statistiky knihovny",
|
||||
"HeaderListeningSessions": "Poslechové relace",
|
||||
"HeaderListeningStats": "Statistiky poslechu",
|
||||
"HeaderLogin": "Přihlásit",
|
||||
"HeaderLogs": "Záznamy",
|
||||
"HeaderManageGenres": "Spravovat žánry",
|
||||
"HeaderManageTags": "Spravovat štítky",
|
||||
"HeaderMapDetails": "Podrobnosti mapování",
|
||||
"HeaderMatch": "Shoda",
|
||||
"HeaderMetadataOrderOfPrecedence": "Pořadí priorit metadat",
|
||||
"HeaderMetadataToEmbed": "Metadata k vložení",
|
||||
"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",
|
||||
"HeaderPlaylistItems": "Položky seznamu přehrávání",
|
||||
"HeaderPodcastsToAdd": "Podcasty k přidání",
|
||||
"HeaderPreviewCover": "Náhled obálky",
|
||||
"HeaderRemoveEpisode": "Odstranit epizodu",
|
||||
"HeaderRemoveEpisodes": "Odstranit {0} epizody",
|
||||
"HeaderRSSFeedGeneral": "Podrobnosti o RSS",
|
||||
"HeaderRSSFeedIsOpen": "Informační kanál RSS je otevřený",
|
||||
"HeaderRSSFeeds": "RSS kanály",
|
||||
"HeaderSavedMediaProgress": "Průběh uložených médií",
|
||||
"HeaderSchedule": "Plán",
|
||||
"HeaderScheduleLibraryScans": "Naplánovat automatické prohledávání knihoven",
|
||||
"HeaderSession": "Relace",
|
||||
"HeaderSetBackupSchedule": "Nastavit plán zálohování",
|
||||
"HeaderSettings": "Nastavení",
|
||||
"HeaderSettingsDisplay": "Zobrazit",
|
||||
"HeaderSettingsExperimental": "Experimentální funkce",
|
||||
"HeaderSettingsGeneral": "Obecné",
|
||||
"HeaderSettingsScanner": "Skener",
|
||||
"HeaderSleepTimer": "Časovač vypnutí",
|
||||
"HeaderStatsLargestItems": "Největší položky",
|
||||
"HeaderStatsLongestItems": "Nejdelší položky (hod.)",
|
||||
"HeaderStatsMinutesListeningChart": "Počet minut poslechu (posledních 7 dní)",
|
||||
"HeaderStatsRecentSessions": "Poslední relace",
|
||||
"HeaderStatsTop10Authors": "Top 10 autorů",
|
||||
"HeaderStatsTop5Genres": "Top 5 žánrů",
|
||||
"HeaderTableOfContents": "Obsah",
|
||||
"HeaderTools": "Nástroje",
|
||||
"HeaderUpdateAccount": "Aktualizovat účet",
|
||||
"HeaderUpdateAuthor": "Aktualizovat autora",
|
||||
"HeaderUpdateDetails": "Aktualizovat podrobnosti",
|
||||
"HeaderUpdateLibrary": "Aktualizovat knihovnu",
|
||||
"HeaderUsers": "Uživatelé",
|
||||
"HeaderYourStats": "Vaše statistiky",
|
||||
"LabelAbridged": "Zkráceno",
|
||||
"LabelAccountType": "Typ účtu",
|
||||
"LabelAccountTypeAdmin": "Správce",
|
||||
"LabelAccountTypeGuest": "Host",
|
||||
"LabelAccountTypeUser": "Uživatel",
|
||||
"LabelActivity": "Aktivita",
|
||||
"LabelAdded": "Přidáno",
|
||||
"LabelAddedAt": "Přidáno v",
|
||||
"LabelAddToCollection": "Přidat do kolekce",
|
||||
"LabelAddToCollectionBatch": "Přidat {0} knihy do kolekce",
|
||||
"LabelAddToPlaylist": "Přidat do seznamu přehrávání",
|
||||
"LabelAddToPlaylistBatch": "Přidat {0} položky do seznamu přehrávání",
|
||||
"LabelAdminUsersOnly": "Pouze administrátoři",
|
||||
"LabelAll": "Vše",
|
||||
"LabelAllUsers": "Všichni uživatelé",
|
||||
"LabelAllUsersExcludingGuests": "Všichni uživatelé kromě hostů",
|
||||
"LabelAllUsersIncludingGuests": "Všichni uživatelé včetně hostů",
|
||||
"LabelAlreadyInYourLibrary": "Již ve vaší knihovně",
|
||||
"LabelAppend": "Připojit",
|
||||
"LabelAuthor": "Autor",
|
||||
"LabelAuthorFirstLast": "Autor (jméno a příjmení)",
|
||||
"LabelAuthorLastFirst": "Autor (příjmení a jméno)",
|
||||
"LabelAuthors": "Autoři",
|
||||
"LabelAutoDownloadEpisodes": "Automaticky stahovat epizody",
|
||||
"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í",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Zálohy uložené v /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Maximální velikost zálohy (v GB)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "Ochrana proti chybné konfiguraci: Zálohování se nezdaří, pokud překročí nastavenou velikost.",
|
||||
"LabelBackupsNumberToKeep": "Počet záloh, které se mají uchovat",
|
||||
"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",
|
||||
"LabelChaptersFound": "Kapitoly nalezeny",
|
||||
"LabelChapterTitle": "Název kapitoly",
|
||||
"LabelClickForMoreInfo": "Klikněte pro více informací",
|
||||
"LabelClosePlayer": "Zavřít přehrávač",
|
||||
"LabelCodec": "Kodek",
|
||||
"LabelCollapseSeries": "Sbalit sérii",
|
||||
"LabelCollection": "Kolekce",
|
||||
"LabelCollections": "Kolekce",
|
||||
"LabelComplete": "Dokončeno",
|
||||
"LabelConfirmPassword": "Potvrdit heslo",
|
||||
"LabelContinueListening": "Pokračovat v poslechu",
|
||||
"LabelContinueReading": "Pokračovat ve čtení",
|
||||
"LabelContinueSeries": "Pokračovat v sérii",
|
||||
"LabelCover": "Obálka",
|
||||
"LabelCoverImageURL": "URL obrázku obálky",
|
||||
"LabelCreatedAt": "Vytvořeno v",
|
||||
"LabelCronExpression": "Výraz Cronu",
|
||||
"LabelCurrent": "Aktuální",
|
||||
"LabelCurrently": "Aktuálně:",
|
||||
"LabelCustomCronExpression": "Vlastní výraz cronu:",
|
||||
"LabelDatetime": "Datum a čas",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Smazat ze souborového systému (zrušte zaškrtnutí pro odstranění pouze z databáze)",
|
||||
"LabelDescription": "Popis",
|
||||
"LabelDeselectAll": "Odznačit vše",
|
||||
"LabelDevice": "Zařízení",
|
||||
"LabelDeviceInfo": "Informace o zařízení",
|
||||
"LabelDeviceIsAvailableTo": "Zařízení je dostupné pro...",
|
||||
"LabelDirectory": "Adresář",
|
||||
"LabelDiscFromFilename": "Disk z názvu souboru",
|
||||
"LabelDiscFromMetadata": "Disk z metadat",
|
||||
"LabelDiscover": "Objevit",
|
||||
"LabelDownload": "Stáhnout",
|
||||
"LabelDownloadNEpisodes": "Stáhnout {0} epizody",
|
||||
"LabelDuration": "Doba trvání",
|
||||
"LabelDurationFound": "Doba trvání nalezena:",
|
||||
"LabelEbook": "Elektronická kniha",
|
||||
"LabelEbooks": "Elektronické knihy",
|
||||
"LabelEdit": "Upravit",
|
||||
"LabelEmail": "E-mail",
|
||||
"LabelEmailSettingsFromAddress": "Z adresy",
|
||||
"LabelEmailSettingsSecure": "Zabezpečené",
|
||||
"LabelEmailSettingsSecureHelp": "Pokud je true, připojení bude při připojování k serveru používat TLS. Pokud je false, použije se protokol TLS, pokud server podporuje rozšíření STARTTLS. Ve většině případů nastavte tuto hodnotu na true, pokud se připojujete k portu 465. Pro port 587 nebo 25 ponechte hodnotu false. (z nodemailer.com/smtp/#authentication)",
|
||||
"LabelEmailSettingsTestAddress": "Testovací adresa",
|
||||
"LabelEmbeddedCover": "Vložená obálka",
|
||||
"LabelEnable": "Povolit",
|
||||
"LabelEnd": "Konec",
|
||||
"LabelEpisode": "Epizoda",
|
||||
"LabelEpisodeTitle": "Název epizody",
|
||||
"LabelEpisodeType": "Typ epizody",
|
||||
"LabelExample": "Příklad",
|
||||
"LabelExplicit": "Explicitní",
|
||||
"LabelFeedURL": "URL zdroje",
|
||||
"LabelFile": "Soubor",
|
||||
"LabelFileBirthtime": "Čas vzniku souboru",
|
||||
"LabelFileModified": "Soubor změněn",
|
||||
"LabelFilename": "Název souboru",
|
||||
"LabelFilterByUser": "Filtrovat podle uživatele",
|
||||
"LabelFindEpisodes": "Najít epizody",
|
||||
"LabelFinished": "Dokončeno",
|
||||
"LabelFolder": "Složka",
|
||||
"LabelFolders": "Složky",
|
||||
"LabelFontFamily": "Rodina písem",
|
||||
"LabelFontScale": "Měřítko písma",
|
||||
"LabelFormat": "Formát",
|
||||
"LabelGenre": "Žánr",
|
||||
"LabelGenres": "Žánry",
|
||||
"LabelHardDeleteFile": "Trvale smazat soubor",
|
||||
"LabelHasEbook": "Obsahuje elektronickou knihu",
|
||||
"LabelHasSupplementaryEbook": "Obsahuje doplňkovou elektronickou knihu",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Hostitel",
|
||||
"LabelHour": "Hodina",
|
||||
"LabelIcon": "Ikona",
|
||||
"LabelImageURLFromTheWeb": "URL obrázku z webu",
|
||||
"LabelIncludeInTracklist": "Zahrnout do seznamu stop",
|
||||
"LabelIncomplete": "Neúplné",
|
||||
"LabelInProgress": "Probíhá",
|
||||
"LabelInterval": "Interval",
|
||||
"LabelIntervalCustomDailyWeekly": "Vlastní denně/týdně",
|
||||
"LabelIntervalEvery12Hours": "Každých 12 hodin",
|
||||
"LabelIntervalEvery15Minutes": "Každých 15 minut",
|
||||
"LabelIntervalEvery2Hours": "Každé 2 hodiny",
|
||||
"LabelIntervalEvery30Minutes": "Každých 30 minut",
|
||||
"LabelIntervalEvery6Hours": "Každých 6 hodin",
|
||||
"LabelIntervalEveryDay": "Každý den",
|
||||
"LabelIntervalEveryHour": "Každou hodinu",
|
||||
"LabelInvalidParts": "Neplatné části",
|
||||
"LabelInvert": "Invertovat",
|
||||
"LabelItem": "Položka",
|
||||
"LabelLanguage": "Jazyk",
|
||||
"LabelLanguageDefaultServer": "Výchozí jazyk serveru",
|
||||
"LabelLastBookAdded": "Poslední kniha přidána",
|
||||
"LabelLastBookUpdated": "Poslední kniha aktualizována",
|
||||
"LabelLastSeen": "Naposledy viděno",
|
||||
"LabelLastTime": "Naposledy",
|
||||
"LabelLastUpdate": "Poslední aktualizace",
|
||||
"LabelLayout": "Rozvržení",
|
||||
"LabelLayoutSinglePage": "Jedna stránka",
|
||||
"LabelLayoutSplitPage": "Rozdělit stránku",
|
||||
"LabelLess": "Méně",
|
||||
"LabelLibrariesAccessibleToUser": "Knihovny přístupné uživateli",
|
||||
"LabelLibrary": "Knihovna",
|
||||
"LabelLibraryItem": "Položka knihovny",
|
||||
"LabelLibraryName": "Název knihovny",
|
||||
"LabelLimit": "Omezit",
|
||||
"LabelLineSpacing": "Řádkování",
|
||||
"LabelListenAgain": "Poslouchat znovu",
|
||||
"LabelLogLevelDebug": "Ladit",
|
||||
"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": "Higher priority metadata sources will override lower priority metadata sources",
|
||||
"LabelMetadataProvider": "Poskytovatel metadat",
|
||||
"LabelMetaTag": "Metaznačka",
|
||||
"LabelMetaTags": "Metaznačky",
|
||||
"LabelMinute": "Minuta",
|
||||
"LabelMissing": "Chybějící",
|
||||
"LabelMissingParts": "Chybějící díly",
|
||||
"LabelMore": "Více",
|
||||
"LabelMoreInfo": "Více informací",
|
||||
"LabelName": "Jméno",
|
||||
"LabelNarrator": "Interpret",
|
||||
"LabelNarrators": "Interpreti",
|
||||
"LabelNew": "Nový",
|
||||
"LabelNewestAuthors": "Nejnovější autoři",
|
||||
"LabelNewestEpisodes": "Nejnovější epizody",
|
||||
"LabelNewPassword": "Nové heslo",
|
||||
"LabelNextBackupDate": "Datum příští zálohy",
|
||||
"LabelNextScheduledRun": "Další naplánované spuštění",
|
||||
"LabelNoEpisodesSelected": "Nebyly vybrány žádné epizody",
|
||||
"LabelNotes": "Poznámky",
|
||||
"LabelNotFinished": "Nedokončeno",
|
||||
"LabelNotificationAppriseURL": "URL adresy Apprise",
|
||||
"LabelNotificationAvailableVariables": "Dostupné proměnné",
|
||||
"LabelNotificationBodyTemplate": "Šablona těla",
|
||||
"LabelNotificationEvent": "Událost oznámení",
|
||||
"LabelNotificationsMaxFailedAttempts": "Maximální počet neúspěšných pokusů",
|
||||
"LabelNotificationsMaxFailedAttemptsHelp": "Oznámení jsou vypnuta, pokud se jim to nepodaří odeslat",
|
||||
"LabelNotificationsMaxQueueSize": "Maximální velikost fronty pro oznamovací události",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "Události jsou omezeny na 1 za sekundu. Události budou ignorovány, pokud je fronta v maximální velikosti. Tím se zabrání spamování oznámení.",
|
||||
"LabelNotificationTitleTemplate": "Šablona názvu",
|
||||
"LabelNotStarted": "Nezahájeno",
|
||||
"LabelNumberOfBooks": "Počet knih",
|
||||
"LabelNumberOfEpisodes": "Počet epizod",
|
||||
"LabelOpenRSSFeed": "Otevřít RSS kanál",
|
||||
"LabelOverwrite": "Přepsat",
|
||||
"LabelPassword": "Heslo",
|
||||
"LabelPath": "Cesta",
|
||||
"LabelPermissionsAccessAllLibraries": "Má přístup ke všem knihovnám",
|
||||
"LabelPermissionsAccessAllTags": "Má přístup ke všem značkám",
|
||||
"LabelPermissionsAccessExplicitContent": "Má přístup k explicitnímu obsahu",
|
||||
"LabelPermissionsDelete": "Může mazat",
|
||||
"LabelPermissionsDownload": "Může stahovat",
|
||||
"LabelPermissionsUpdate": "Může aktualizovat",
|
||||
"LabelPermissionsUpload": "Může nahrávat",
|
||||
"LabelPhotoPathURL": "Cesta k fotografii/URL",
|
||||
"LabelPlaylists": "Seznamy skladeb",
|
||||
"LabelPlayMethod": "Metoda přehrávání",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasty",
|
||||
"LabelPodcastType": "Typ podcastu",
|
||||
"LabelPort": "Port",
|
||||
"LabelPrefixesToIgnore": "Předpony, které se mají ignorovat (nerozlišují se malá a velká písmena)",
|
||||
"LabelPreventIndexing": "Zabránit indexování vašeho kanálu v adresářích podcastů iTunes a Google",
|
||||
"LabelPrimaryEbook": "Hlavní e-kniha",
|
||||
"LabelProgress": "Průběh",
|
||||
"LabelProvider": "Poskytovatel",
|
||||
"LabelPubDate": "Datum vydání",
|
||||
"LabelPublisher": "Vydavatel",
|
||||
"LabelPublishYear": "Rok vydání",
|
||||
"LabelRead": "Číst",
|
||||
"LabelReadAgain": "Číst znovu",
|
||||
"LabelReadEbookWithoutProgress": "Číst e-knihu bez zachování průběhu",
|
||||
"LabelRecentlyAdded": "Nedávno přidané",
|
||||
"LabelRecentSeries": "Nedávné série",
|
||||
"LabelRecommended": "Doporučeno",
|
||||
"LabelRegion": "Region",
|
||||
"LabelReleaseDate": "Datum vydání",
|
||||
"LabelRemoveCover": "Odstranit obálku",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
||||
"LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka",
|
||||
"LabelRSSFeedOpen": "Otevření RSS kanálu",
|
||||
"LabelRSSFeedPreventIndexing": "Zabránit indexování",
|
||||
"LabelRSSFeedSlug": "RSS kanál Slug",
|
||||
"LabelRSSFeedURL": "URL RSS kanálu",
|
||||
"LabelSearchTerm": "Vyhledat termín",
|
||||
"LabelSearchTitle": "Vyhledat název",
|
||||
"LabelSearchTitleOrASIN": "Vyhledat název nebo ASIN",
|
||||
"LabelSeason": "Sezóna",
|
||||
"LabelSelectAllEpisodes": "Vybrat všechny epizody",
|
||||
"LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují",
|
||||
"LabelSelectUsers": "Vybrat uživatele",
|
||||
"LabelSendEbookToDevice": "Odeslat e-knihu do...",
|
||||
"LabelSequence": "Sekvence",
|
||||
"LabelSeries": "Série",
|
||||
"LabelSeriesName": "Název série",
|
||||
"LabelSeriesProgress": "Průběh série",
|
||||
"LabelSetEbookAsPrimary": "Nastavit jako primární",
|
||||
"LabelSetEbookAsSupplementary": "Nastavit jako doplňkové",
|
||||
"LabelSettingsAudiobooksOnly": "Pouze audioknihy",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "Povolením tohoto nastavení budou soubory e-knih ignorovány, pokud nejsou ve složce audioknih, v takovém případě budou nastaveny jako doplňkové e-knihy",
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorfní design s dřevěnými policemi",
|
||||
"LabelSettingsChromecastSupport": "Podpora Chromecastu",
|
||||
"LabelSettingsDateFormat": "Formát data",
|
||||
"LabelSettingsDisableWatcher": "Zakázat sledování",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Zakázat sledování složky pro knihovnu",
|
||||
"LabelSettingsDisableWatcherHelp": "Zakáže automatické přidávání/aktualizaci položek při zjištění změn v souboru. *Vyžaduje restart serveru",
|
||||
"LabelSettingsEnableWatcher": "Povolit sledování",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Povolit sledování složky pro knihovnu",
|
||||
"LabelSettingsEnableWatcherHelp": "Povoluje automatické přidávání/aktualizaci položek, když jsou zjištěny změny souborů. *Vyžaduje restart serveru",
|
||||
"LabelSettingsExperimentalFeatures": "Experimentální funkce",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Funkce ve vývoji, které by mohly využít vaši zpětnou vazbu a pomoc s testováním. Kliknutím otevřete diskuzi na githubu.",
|
||||
"LabelSettingsFindCovers": "Najít obálky",
|
||||
"LabelSettingsFindCoversHelp": "Pokud vaše audiokniha nemá vloženou obálku nebo obrázek obálky uvnitř složky, skener se pokusí obálku najít.<br>Poznámka: Tím se prodlouží doba prohledávání",
|
||||
"LabelSettingsHideSingleBookSeries": "Skrýt sérii s jedinou knihou",
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Série, které mají jedinou knihu, budou skryty na stránce série a na domovské stránce.",
|
||||
"LabelSettingsHomePageBookshelfView": "Domovská stránka používá zobrazení police s knihami",
|
||||
"LabelSettingsLibraryBookshelfView": "Knihovna používá zobrazení police s knihami",
|
||||
"LabelSettingsParseSubtitles": "Analzyovat podtitul",
|
||||
"LabelSettingsParseSubtitlesHelp": "Rozparsovat podtitul z názvů složek audioknih.<br>Podtiul musí být oddělen znakem \" - \"<br>tj. \"Název knihy - Zde Podtitul\" má podtitul \"Zde podtitul\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "Preferovat spárovaná metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Spárovaná data budou mít při použití funkce Rychlé párování přednost před údaji o položce. Ve výchozím nastavení funkce Rychlé párování pouze doplní chybějící údaje.",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Přeskočit párování knih, které již mají ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Přeskočit párování knih, které již mají ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignorovat předpony při třídění",
|
||||
"LabelSettingsSortingIgnorePrefixesHelp": "tj. pro předponu \"the\" název knihy \"Název knihy\" by se třídil jako \"Název knihy, The\"",
|
||||
"LabelSettingsSquareBookCovers": "Použít čtvercové obálky knih",
|
||||
"LabelSettingsSquareBookCoversHelp": "Preferovat použití čtvercových obálek před standardními obálkami 1.6:1",
|
||||
"LabelSettingsStoreCoversWithItem": "Uložit obálky s položkou",
|
||||
"LabelSettingsStoreCoversWithItemHelp": "Ve výchozím nastavení jsou obálky uloženy v adresáři /metadata/items, povolením tohoto nastavení se obálky uloží do složky položek knihovny. Zůstane zachován pouze jeden soubor s názvem \"cover\"",
|
||||
"LabelSettingsStoreMetadataWithItem": "Uložit metadata s položkou",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Ve výchozím nastavení jsou soubory metadat uloženy v adresáři /metadata/items, povolením tohoto nastavení budou soubory metadat uloženy ve složkách položek knihovny",
|
||||
"LabelSettingsTimeFormat": "Formát času",
|
||||
"LabelShowAll": "Zobrazit vše",
|
||||
"LabelSize": "Velikost",
|
||||
"LabelSleepTimer": "Časovač vypnutí",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "Spustit",
|
||||
"LabelStarted": "Spuštěno",
|
||||
"LabelStartedAt": "Spuštěno v",
|
||||
"LabelStartTime": "Čas Spuštění",
|
||||
"LabelStatsAudioTracks": "Zvukové stopy",
|
||||
"LabelStatsAuthors": "Autoři",
|
||||
"LabelStatsBestDay": "Nejlepší den",
|
||||
"LabelStatsDailyAverage": "Denní průměr",
|
||||
"LabelStatsDays": "Dny",
|
||||
"LabelStatsDaysListened": "Dny poslechu",
|
||||
"LabelStatsHours": "Hodiny",
|
||||
"LabelStatsInARow": "v řadě",
|
||||
"LabelStatsItemsFinished": "Dokončené Položky",
|
||||
"LabelStatsItemsInLibrary": "Položky v knihovně",
|
||||
"LabelStatsMinutes": "minut",
|
||||
"LabelStatsMinutesListening": "Minuty poslechu",
|
||||
"LabelStatsOverallDays": "Celkový počet dní",
|
||||
"LabelStatsOverallHours": "Celkový počet hodin",
|
||||
"LabelStatsWeekListening": "Týdenní poslech",
|
||||
"LabelSubtitle": "Podtitul",
|
||||
"LabelSupportedFileTypes": "Podporované typy souborů",
|
||||
"LabelTag": "Značka",
|
||||
"LabelTags": "Značky",
|
||||
"LabelTagsAccessibleToUser": "Značky přístupné uživateli",
|
||||
"LabelTagsNotAccessibleToUser": "Značky nepřístupné uživateli",
|
||||
"LabelTasks": "Spuštěné Úlohy",
|
||||
"LabelTheme": "Téma",
|
||||
"LabelThemeDark": "Tmavé",
|
||||
"LabelThemeLight": "Světlé",
|
||||
"LabelTimeBase": "Časová základna",
|
||||
"LabelTimeListened": "Čas poslechu",
|
||||
"LabelTimeListenedToday": "Čas poslechu dnes",
|
||||
"LabelTimeRemaining": "{0} zbývá",
|
||||
"LabelTimeToShift": "Čas posunu v sekundách",
|
||||
"LabelTitle": "Název",
|
||||
"LabelToolsEmbedMetadata": "Vložit metadata",
|
||||
"LabelToolsEmbedMetadataDescription": "Vložit metadata do zvukových souborů včetně obálky a kapitol.",
|
||||
"LabelToolsMakeM4b": "Vytvořit soubor audioknihy M4B",
|
||||
"LabelToolsMakeM4bDescription": "Vygenerovat soubor audioknihy M4B s vloženými metadaty, obálkou a kapitolami.",
|
||||
"LabelToolsSplitM4b": "Rozdělit M4B na MP3",
|
||||
"LabelToolsSplitM4bDescription": "Vytvořit soubory MP3 z M4B rozděleného podle kapitol s vloženými metadaty, obrázku obálky a kapitol.",
|
||||
"LabelTotalDuration": "Celková doba trvání",
|
||||
"LabelTotalTimeListened": "Celkový čas poslechu",
|
||||
"LabelTrackFromFilename": "Stopa z názvu souboru",
|
||||
"LabelTrackFromMetadata": "Stopa z metadat",
|
||||
"LabelTracks": "Stopy",
|
||||
"LabelTracksMultiTrack": "Více stop",
|
||||
"LabelTracksNone": "Žádné stopy",
|
||||
"LabelTracksSingleTrack": "Jedna stopa",
|
||||
"LabelType": "Typ",
|
||||
"LabelUnabridged": "Nezkráceno",
|
||||
"LabelUnknown": "Neznámý",
|
||||
"LabelUpdateCover": "Aktualizovat obálku",
|
||||
"LabelUpdateCoverHelp": "Povolit přepsání existujících obálek pro vybrané knihy, pokud je nalezena shoda",
|
||||
"LabelUpdatedAt": "Aktualizováno v",
|
||||
"LabelUpdateDetails": "Aktualizovat podrobnosti",
|
||||
"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",
|
||||
"LabelUseChapterTrack": "Použít stopu kapitoly",
|
||||
"LabelUseFullTrack": "Použít celou stopu",
|
||||
"LabelUser": "Uživatel",
|
||||
"LabelUsername": "Uživatelské jméno",
|
||||
"LabelValue": "Hodnota",
|
||||
"LabelVersion": "Verze",
|
||||
"LabelViewBookmarks": "Zobrazit záložky",
|
||||
"LabelViewChapters": "Zobrazit kapitoly",
|
||||
"LabelViewQueue": "Zobrazit frontu přehrávače",
|
||||
"LabelVolume": "Hlasitost",
|
||||
"LabelWeekdaysToRun": "Dny v týdnu ke spuštění",
|
||||
"LabelYourAudiobookDuration": "Doba trvání vaší audioknihy",
|
||||
"LabelYourBookmarks": "Vaše záložky",
|
||||
"LabelYourPlaylists": "Vaše seznamy přehrávání",
|
||||
"LabelYourProgress": "Váš pokrok",
|
||||
"MessageAddToPlayerQueue": "Přidat do fronty přehrávače",
|
||||
"MessageAppriseDescription": "Abyste mohli používat tuto funkci, musíte mít spuštěnou instanci <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> nebo API, které bude zpracovávat stejné požadavky. <br />Adresa URL API Apprise by měla být úplná URL cesta pro odeslání oznámení, např. pokud je vaše instance API obsluhována na adrese <code>http://192.168.1.1:8337</code> pak byste měli zadat <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageBackupsDescription": "Zálohy zahrnují uživatele, průběh uživatele, podrobnosti o položkách knihovny, nastavení serveru a obrázky uložené v <code>/metadata/items</code> a <code>/metadata/authors</code>. Zálohy <strong>ne</strong> zahrnují všechny soubory uložené ve složkách knihovny.",
|
||||
"MessageBatchQuickMatchDescription": "Rychlá párování se pokusí přidat chybějící obálky a metadata pro vybrané položky. Povolením níže uvedených možností umožníte funkci Rychlé párování přepsat stávající obálky a/nebo metadata.",
|
||||
"MessageBookshelfNoCollections": "Ještě jste nevytvořili žádnou sbírku",
|
||||
"MessageBookshelfNoResultsForFilter": "Filtr \"{0}: {1}\"",
|
||||
"MessageBookshelfNoRSSFeeds": "Nejsou otevřeny žádné RSS kanály",
|
||||
"MessageBookshelfNoSeries": "Nemáte žádnou sérii",
|
||||
"MessageChapterEndIsAfter": "Konec kapitoly přesahuje konec audioknihy",
|
||||
"MessageChapterErrorFirstNotZero": "První kapitola musí začínat na 0",
|
||||
"MessageChapterErrorStartGteDuration": "Neplatný čas začátku, musí být kratší než doba trvání audioknihy",
|
||||
"MessageChapterErrorStartLtPrev": "Neplatný čas začátku, musí být větší nebo roven času začátku předchozí kapitoly",
|
||||
"MessageChapterStartIsAfter": "Začátek kapitoly přesahuje konec audioknihy",
|
||||
"MessageCheckingCron": "Kontrola cronu...",
|
||||
"MessageConfirmCloseFeed": "Opravdu chcete zavřít tento kanál?",
|
||||
"MessageConfirmDeleteBackup": "Opravdu chcete smazat zálohu pro {0}?",
|
||||
"MessageConfirmDeleteFile": "Tento krok smaže soubor ze souborového systému. Jsi si jisti?",
|
||||
"MessageConfirmDeleteLibrary": "Opravdu chcete trvale smazat knihovnu \"{0}\"?",
|
||||
"MessageConfirmDeleteLibraryItem": "Tento krok odstraní položku knihovny z databáze a vašeho souborového systému. Jste si jisti?",
|
||||
"MessageConfirmDeleteLibraryItems": "Tímto smažete {0} položkek knihovny z databáze a vašeho souborového systému. Jsi si jisti?",
|
||||
"MessageConfirmDeleteSession": "Opravdu chcete smazat tuto relaci?",
|
||||
"MessageConfirmForceReScan": "Opravdu chcete vynutit opětovné prohledání?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokončené?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Opravdu chcete označit všechny epizody jako nedokončené?",
|
||||
"MessageConfirmMarkSeriesFinished": "Opravdu chcete označit všechny knihy z této série jako dokončené?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Opravdu chcete označit všechny knihy z této série jako nedokončené?",
|
||||
"MessageConfirmQuickEmbed": "Varování! Rychlé vložení nezálohuje vaše zvukové soubory. Ujistěte se, že máte zálohu zvukových souborů. <br><br>Chcete pokračovat?",
|
||||
"MessageConfirmRemoveAllChapters": "Opravdu chcete odstranit všechny kapitoly?",
|
||||
"MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?",
|
||||
"MessageConfirmRemoveNarrator": "Opravdu chcete odebrat předčítání \"{0}\"?",
|
||||
"MessageConfirmRemovePlaylist": "Opravdu chcete odstranit svůj playlist \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Opravdu chcete přejmenovat žánr \"{0}\" na \"{1}\" pro všechny položky?",
|
||||
"MessageConfirmRenameGenreMergeNote": "Poznámka: Tento žánr již existuje, takže budou sloučeny.",
|
||||
"MessageConfirmRenameGenreWarning": "Varování! Podobný žánr s jiným obalem již existuje \"{0}\".",
|
||||
"MessageConfirmRenameTag": "Opravdu chcete přejmenovat tag \"{0}\" na \"{1}\" pro všechny položky?",
|
||||
"MessageConfirmRenameTagMergeNote": "Poznámka: Tato značka již existuje, takže budou sloučeny.",
|
||||
"MessageConfirmRenameTagWarning": "Varování! Podobná značka s jinými velkými a malými písmeny již existuje \"{0}\".",
|
||||
"MessageConfirmReScanLibraryItems": "Opravdu chcete znovu prohledat {0} položky?",
|
||||
"MessageConfirmSendEbookToDevice": "Opravdu chcete odeslat e-knihu {0} {1}\" do zařízení \"{2}\"?",
|
||||
"MessageDownloadingEpisode": "Stahuji epizodu",
|
||||
"MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop",
|
||||
"MessageEmbedFinished": "Vložení dokončeno!",
|
||||
"MessageEpisodesQueuedForDownload": "{0} epizody zařazené do fronty ke stažení",
|
||||
"MessageFeedURLWillBe": "URL zdroje bude {0}",
|
||||
"MessageFetching": "Stahování...",
|
||||
"MessageForceReScanDescription": "znovu prohledá všechny soubory jako při novém skenování. ID3 tagy zvukových souborů OPF soubory a textové soubory budou skenovány jako nové.",
|
||||
"MessageImportantNotice": "Důležité upozornění!",
|
||||
"MessageInsertChapterBelow": "Vložit kapitolu níže",
|
||||
"MessageItemsSelected": "{0} vybraných položek",
|
||||
"MessageItemsUpdated": "{0} položky byly aktualizovány",
|
||||
"MessageJoinUsOn": "Přidejte se k nám",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} poslechových relací za poslední rok",
|
||||
"MessageLoading": "Načítá se...",
|
||||
"MessageLoadingFolders": "Načítám složky...",
|
||||
"MessageM4BFailed": "M4B se nezdařil!",
|
||||
"MessageM4BFinished": "M4B dokončen!",
|
||||
"MessageMapChapterTitles": "Mapování názvů kapitol ke stávajícím kapitolám audioknihy bez úpravy časových razítek",
|
||||
"MessageMarkAllEpisodesFinished": "Označit všechny epizody za dokončené",
|
||||
"MessageMarkAllEpisodesNotFinished": "Označit všechny epizody jako nedokončené",
|
||||
"MessageMarkAsFinished": "Označit jako dokončené",
|
||||
"MessageMarkAsNotFinished": "Označit jako nedokončené",
|
||||
"MessageMatchBooksDescription": "pokusí se spárovat knihy v knihovně s knihou od vybraného vyhledávače a vyplnit prázdné údaje a obálku. Nepřepisuje detaily.",
|
||||
"MessageNoAudioTracks": "Žádné zvukové stopy",
|
||||
"MessageNoAuthors": "Žádní autoři",
|
||||
"MessageNoBackups": "Žádné zálohy",
|
||||
"MessageNoBookmarks": "Žádné záložky",
|
||||
"MessageNoChapters": "Žádné kapitoly",
|
||||
"MessageNoCollections": "Žádné kolekce",
|
||||
"MessageNoCoversFound": "Nebyly nalezeny žádné obálky",
|
||||
"MessageNoDescription": "Bez popisu",
|
||||
"MessageNoDownloadsInProgress": "Momentálně neprobíhá žádné stahování",
|
||||
"MessageNoDownloadsQueued": "Žádné stahování ve frontě",
|
||||
"MessageNoEpisodeMatchesFound": "Nebyly nalezeny žádné odpovídající epizody",
|
||||
"MessageNoEpisodes": "Žádné epizody",
|
||||
"MessageNoFoldersAvailable": "Nejsou k dispozici žádné složky",
|
||||
"MessageNoGenres": "Žádné žánry",
|
||||
"MessageNoIssues": "Žádné výtisk",
|
||||
"MessageNoItems": "Žádné položky",
|
||||
"MessageNoItemsFound": "Nebyly nalezeny žádné položky",
|
||||
"MessageNoListeningSessions": "Žádné poslechové relace",
|
||||
"MessageNoLogs": "Žádné protokoly",
|
||||
"MessageNoMediaProgress": "Žádný průběh médií",
|
||||
"MessageNoNotifications": "Žádná oznámení",
|
||||
"MessageNoPodcastsFound": "Nebyly nalezeny žádné podcasty",
|
||||
"MessageNoResults": "Žádné výsledky",
|
||||
"MessageNoSearchResultsFor": "Nebyly nalezeny žádné výsledky hledání pro \"{0}\"",
|
||||
"MessageNoSeries": "Žádné série",
|
||||
"MessageNoTags": "Žádné značky",
|
||||
"MessageNoTasksRunning": "Nejsou spuštěny žádné úlohy",
|
||||
"MessageNotYetImplemented": "Ještě není implementováno",
|
||||
"MessageNoUpdateNecessary": "Není nutná žádná aktualizace",
|
||||
"MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace",
|
||||
"MessageNoUserPlaylists": "Nemáte žádné seznamy skladeb",
|
||||
"MessageOr": "nebo",
|
||||
"MessagePauseChapter": "Pozastavit přehrávání kapitoly",
|
||||
"MessagePlayChapter": "Poslechnout si začátek kapitoly",
|
||||
"MessagePlaylistCreateFromCollection": "Vytvořit seznam skladeb z kolekce",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcast nemá žádnou adresu URL kanálu RSS, kterou by mohl použít pro porovnávání",
|
||||
"MessageQuickMatchDescription": "Vyplňte prázdné detaily položky a obálku prvním výsledkem shody z '{0}'. Nepřepisuje podrobnosti, pokud není povoleno nastavení serveru \"Preferovat párování metadata\".",
|
||||
"MessageRemoveChapter": "Odstranit kapitolu",
|
||||
"MessageRemoveEpisodes": "Odstranit {0} epizodu",
|
||||
"MessageRemoveFromPlayerQueue": "Odstranit z fronty přehrávače",
|
||||
"MessageRemoveUserWarning": "Opravdu chcete trvale smazat uživatele \"{0}\"?",
|
||||
"MessageReportBugsAndContribute": "Hlásit chyby, žádat o funkce a přispívat",
|
||||
"MessageResetChaptersConfirm": "Opravdu chcete resetovat kapitoly a vrátit zpět provedené změny?",
|
||||
"MessageRestoreBackupConfirm": "Opravdu chcete obnovit zálohu vytvořenou dne?",
|
||||
"MessageRestoreBackupWarning": "Obnovení zálohy přepíše celou databázi umístěnou v /config a obálku obrázků v /metadata/items & /metadata/authors.<br /><br />Backups nezmění žádné soubory ve složkách knihovny. Pokud jste povolili nastavení serveru pro ukládání obrázků obalu a metadat do složek knihovny, nebudou zálohovány ani přepsány.<br /><br />Všichni klienti používající váš server budou automaticky obnoveni.",
|
||||
"MessageSearchResultsFor": "Výsledky hledání pro",
|
||||
"MessageServerCouldNotBeReached": "Server je nedostupný",
|
||||
"MessageSetChaptersFromTracksDescription": "Nastavit kapitoly jako kapitolu a název kapitoly jako název zvukového souboru",
|
||||
"MessageStartPlaybackAtTime": "Spustit přehrávání pro \"{0}\" v {1}?",
|
||||
"MessageThinking": "Přemýšlení...",
|
||||
"MessageUploaderItemFailed": "Nahrávání se nezdařilo",
|
||||
"MessageUploaderItemSuccess": "Nahráno bylo úspěšně!",
|
||||
"MessageUploading": "Odesílám...",
|
||||
"MessageValidCronExpression": "Platný výraz cronu",
|
||||
"MessageWatcherIsDisabledGlobally": "Hlídač je globálně zakázán v nastavení serveru",
|
||||
"MessageXLibraryIsEmpty": "{0} knihovna je prázdná!",
|
||||
"MessageYourAudiobookDurationIsLonger": "Doba trvání audioknihy je delší než nalezená délka",
|
||||
"MessageYourAudiobookDurationIsShorter": "Délka audioknihy je kratší, než byla nalezena.",
|
||||
"NoteChangeRootPassword": "Uživatel root je jediný uživatel, který může mít prázdné heslo",
|
||||
"NoteChapterEditorTimes": "Poznámka: Čas začátku první kapitoly musí zůstat v 0:00 a čas začátku poslední kapitoly nesmí překročit tuto dobu trvání audioknihy.",
|
||||
"NoteFolderPicker": "Poznámka: složky, které jsou již namapovány, nebudou zobrazeny",
|
||||
"NoteFolderPickerDebian": "Poznámka: Výběr složek pro instalaci debianu není plně implementován. Cestu ke své knihovně byste měli zadat přímo.",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Upozornění: Většina aplikací pro podcasty bude vyžadovat, aby adresa URL kanálu RSS používala protokol HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Upozornění: 1 nebo více epizod nemá datum vydání. Některé podcastové aplikace to vyžadují.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Se složkami s multimediálními soubory bude zacházeno jako se samostatnými položkami knihovny.",
|
||||
"NoteUploaderOnlyAudioFiles": "Pokud nahráváte pouze zvukové soubory, bude s každým zvukovým souborem zacházeno jako se samostatnou audioknihou.",
|
||||
"NoteUploaderUnsupportedFiles": "Nepodporované soubory jsou ignorovány. Při výběru nebo přetažení složky jsou ostatní soubory, které nejsou ve složce položek, ignorovány.",
|
||||
"PlaceholderNewCollection": "Nový název kolekce",
|
||||
"PlaceholderNewFolderPath": "Nová cesta ke složce",
|
||||
"PlaceholderNewPlaylist": "Nový název seznamu přehrávání",
|
||||
"PlaceholderSearch": "Hledat..",
|
||||
"PlaceholderSearchEpisode": "Hledat epizodu..",
|
||||
"ToastAccountUpdateFailed": "Aktualizace účtu se nezdařila",
|
||||
"ToastAccountUpdateSuccess": "Účet aktualizován",
|
||||
"ToastAuthorImageRemoveFailed": "Nepodařilo se odstranit obrázek",
|
||||
"ToastAuthorImageRemoveSuccess": "Obrázek autora odstraněn",
|
||||
"ToastAuthorUpdateFailed": "Aktualizace autora se nezdařila",
|
||||
"ToastAuthorUpdateMerged": "Autor sloučen",
|
||||
"ToastAuthorUpdateSuccess": "Autor aktualizován",
|
||||
"ToastAuthorUpdateSuccessNoImageFound": "Autor aktualizován (nebyl nalezen žádný obrázek)",
|
||||
"ToastBackupCreateFailed": "Vytvoření zálohy se nezdařilo",
|
||||
"ToastBackupCreateSuccess": "Záloha vytvořena",
|
||||
"ToastBackupDeleteFailed": "Nepodařilo se smazat zálohu",
|
||||
"ToastBackupDeleteSuccess": "Záloha smazána",
|
||||
"ToastBackupRestoreFailed": "Nepodařilo se obnovit zálohu",
|
||||
"ToastBackupUploadFailed": "Nepodařilo se nahrát zálohu",
|
||||
"ToastBackupUploadSuccess": "Záloha nahrána",
|
||||
"ToastBatchUpdateFailed": "Dávková aktualizace se nezdařila",
|
||||
"ToastBatchUpdateSuccess": "Dávková aktualizace proběhla úspěšně",
|
||||
"ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo",
|
||||
"ToastBookmarkCreateSuccess": "Přidána záložka",
|
||||
"ToastBookmarkRemoveFailed": "Nepodařilo se odstranit záložku",
|
||||
"ToastBookmarkRemoveSuccess": "Záložka odstraněna",
|
||||
"ToastBookmarkUpdateFailed": "Aktualizace záložky se nezdařila",
|
||||
"ToastBookmarkUpdateSuccess": "Záložka aktualizována",
|
||||
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
|
||||
"ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy",
|
||||
"ToastCollectionItemsRemoveFailed": "Nepodařilo se odstranit položky z kolekce",
|
||||
"ToastCollectionItemsRemoveSuccess": "Položky odstraněny z kolekce",
|
||||
"ToastCollectionRemoveFailed": "Nepodařilo se odstranit kolekci",
|
||||
"ToastCollectionRemoveSuccess": "Kolekce odstraněna",
|
||||
"ToastCollectionUpdateFailed": "Aktualizace kolekce se nezdařila",
|
||||
"ToastCollectionUpdateSuccess": "Kolekce aktualizována",
|
||||
"ToastItemCoverUpdateFailed": "Aktualizace obálky se nezdařila",
|
||||
"ToastItemCoverUpdateSuccess": "Obálka předmětu byl aktualizována",
|
||||
"ToastItemDetailsUpdateFailed": "Nepodařilo se aktualizovat podrobnosti o položce",
|
||||
"ToastItemDetailsUpdateSuccess": "Podrobnosti o položce byly aktualizovány",
|
||||
"ToastItemDetailsUpdateUnneeded": "Podrobnosti o položce nejsou potřeba aktualizovat",
|
||||
"ToastItemMarkedAsFinishedFailed": "Nepodařilo se označit jako dokončené",
|
||||
"ToastItemMarkedAsFinishedSuccess": "Položka označena jako dokončená",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Nepodařilo se označit jako nedokončené",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Položka označena jako nedokončená",
|
||||
"ToastLibraryCreateFailed": "Vytvoření knihovny se nezdařilo",
|
||||
"ToastLibraryCreateSuccess": "Knihovna \"{0}\" vytvořena",
|
||||
"ToastLibraryDeleteFailed": "Nepodařilo se smazat knihovnu",
|
||||
"ToastLibraryDeleteSuccess": "Knihovna smazána",
|
||||
"ToastLibraryScanFailedToStart": "Nepodařilo se spustit kontrolu",
|
||||
"ToastLibraryScanStarted": "Kontrola knihovny spuštěna",
|
||||
"ToastLibraryUpdateFailed": "Aktualizace knihovny se nezdařila",
|
||||
"ToastLibraryUpdateSuccess": "Knihovna \"{0}\" aktualizována",
|
||||
"ToastPlaylistCreateFailed": "Vytvoření seznamu přehrávání se nezdařilo",
|
||||
"ToastPlaylistCreateSuccess": "Seznam přehrávání vytvořen",
|
||||
"ToastPlaylistRemoveFailed": "Nepodařilo se odstranit seznamu přehrávání",
|
||||
"ToastPlaylistRemoveSuccess": "Seznam přehrávání odstraněn",
|
||||
"ToastPlaylistUpdateFailed": "Aktualizace seznamu přehrávání se nezdařila",
|
||||
"ToastPlaylistUpdateSuccess": "Seznam přehrávání aktualizován",
|
||||
"ToastPodcastCreateFailed": "Vytvoření podcastu se nezdařilo",
|
||||
"ToastPodcastCreateSuccess": "Podcast byl úspěšně vytvořen",
|
||||
"ToastRemoveItemFromCollectionFailed": "Nepodařilo se odebrat položku z kolekce",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Položka odstraněna z kolekce",
|
||||
"ToastRSSFeedCloseFailed": "Nepodařilo se zavřít RSS kanál",
|
||||
"ToastRSSFeedCloseSuccess": "RSS kanál uzavřen",
|
||||
"ToastSendEbookToDeviceFailed": "Odeslání e-knihy do zařízení se nezdařilo",
|
||||
"ToastSendEbookToDeviceSuccess": "E-kniha odeslána do zařízení \"{0}\"",
|
||||
"ToastSeriesUpdateFailed": "Aktualizace série se nezdařila",
|
||||
"ToastSeriesUpdateSuccess": "Aktualizace série byla úspěšná",
|
||||
"ToastSessionDeleteFailed": "Nepodařilo se smazat relaci",
|
||||
"ToastSessionDeleteSuccess": "Relace smazána",
|
||||
"ToastSocketConnected": "Socket připojen",
|
||||
"ToastSocketDisconnected": "Socket odpojen",
|
||||
"ToastSocketFailedToConnect": "Socket se nepodařilo připojit",
|
||||
"ToastUserDeleteFailed": "Nepodařilo se smazat uživatele",
|
||||
"ToastUserDeleteSuccess": "Uživatel smazán"
|
||||
}
|
||||
@@ -92,6 +92,7 @@
|
||||
"HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger",
|
||||
"HeaderAudiobookTools": "Audiobog Filhåndteringsværktøjer",
|
||||
"HeaderAudioTracks": "Lydspor",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Sikkerhedskopier",
|
||||
"HeaderChangePassword": "Skift Adgangskode",
|
||||
"HeaderChapters": "Kapitler",
|
||||
@@ -131,8 +132,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 +196,10 @@
|
||||
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
|
||||
"LabelAuthors": "Forfattere",
|
||||
"LabelAutoDownloadEpisodes": "Auto Download Episoder",
|
||||
"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 +210,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",
|
||||
@@ -275,6 +283,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 +325,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",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen",
|
||||
"HeaderAudiobookTools": "Hörbuch-Dateiverwaltungstools",
|
||||
"HeaderAudioTracks": "Audiodateien",
|
||||
"HeaderAuthentication": "Authentifizierung",
|
||||
"HeaderBackups": "Sicherungen",
|
||||
"HeaderChangePassword": "Passwort ändern",
|
||||
"HeaderChapters": "Kapitel",
|
||||
@@ -131,8 +132,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 +184,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 +196,10 @@
|
||||
"LabelAuthorLastFirst": "Autor (Nachname, Vorname)",
|
||||
"LabelAuthors": "Autoren",
|
||||
"LabelAutoDownloadEpisodes": "Episoden automatisch herunterladen",
|
||||
"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 +210,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 +240,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",
|
||||
@@ -275,6 +283,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 +325,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 +410,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",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
||||
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
||||
"HeaderAudioTracks": "Audio Tracks",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Backups",
|
||||
"HeaderChangePassword": "Change Password",
|
||||
"HeaderChapters": "Chapters",
|
||||
@@ -131,8 +132,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 +196,10 @@
|
||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
"LabelAuthors": "Authors",
|
||||
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
|
||||
"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 +210,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",
|
||||
@@ -275,6 +283,7 @@
|
||||
"LabelHardDeleteFile": "Hard delete file",
|
||||
"LabelHasEbook": "Has ebook",
|
||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Hour",
|
||||
"LabelIcon": "Icon",
|
||||
@@ -316,9 +325,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",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"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 +132,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 +196,10 @@
|
||||
"LabelAuthorLastFirst": "Autor (Apellido, Nombre)",
|
||||
"LabelAuthors": "Autores",
|
||||
"LabelAutoDownloadEpisodes": "Descargar Episodios Automáticamente",
|
||||
"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 +210,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",
|
||||
@@ -275,6 +283,7 @@
|
||||
"LabelHardDeleteFile": "Eliminar Definitivamente",
|
||||
"LabelHasEbook": "Tiene Ebook",
|
||||
"LabelHasSupplementaryEbook": "Tiene Ebook Suplementario",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Hora",
|
||||
"LabelIcon": "Icono",
|
||||
@@ -316,9 +325,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",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"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 +132,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 +196,10 @@
|
||||
"LabelAuthorLastFirst": "Auteur (Nom, Prénom)",
|
||||
"LabelAuthors": "Auteurs",
|
||||
"LabelAutoDownloadEpisodes": "Téléchargement automatique d’épisode",
|
||||
"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 +210,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",
|
||||
@@ -275,6 +283,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 +325,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",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ",
|
||||
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
||||
"HeaderAudioTracks": "Audio Tracks",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Backups",
|
||||
"HeaderChangePassword": "Change Password",
|
||||
"HeaderChapters": "Chapters",
|
||||
@@ -131,8 +132,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 +196,10 @@
|
||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
"LabelAuthors": "Authors",
|
||||
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
|
||||
"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 +210,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",
|
||||
@@ -275,6 +283,7 @@
|
||||
"LabelHardDeleteFile": "Hard delete file",
|
||||
"LabelHasEbook": "Has ebook",
|
||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Hour",
|
||||
"LabelIcon": "Icon",
|
||||
@@ -316,9 +325,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",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"HeaderAppriseNotificationSettings": "Apprise अधिसूचना सेटिंग्स",
|
||||
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
||||
"HeaderAudioTracks": "Audio Tracks",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Backups",
|
||||
"HeaderChangePassword": "Change Password",
|
||||
"HeaderChapters": "Chapters",
|
||||
@@ -131,8 +132,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 +196,10 @@
|
||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
"LabelAuthors": "Authors",
|
||||
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
|
||||
"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 +210,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",
|
||||
@@ -275,6 +283,7 @@
|
||||
"LabelHardDeleteFile": "Hard delete file",
|
||||
"LabelHasEbook": "Has ebook",
|
||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Hour",
|
||||
"LabelIcon": "Icon",
|
||||
@@ -316,9 +325,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",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
||||
"HeaderAudiobookTools": "Audiobook File Management alati",
|
||||
"HeaderAudioTracks": "Audio Tracks",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Backups",
|
||||
"HeaderChangePassword": "Promijeni lozinku",
|
||||
"HeaderChapters": "Poglavlja",
|
||||
@@ -131,8 +132,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 +196,10 @@
|
||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||
"LabelAuthors": "Autori",
|
||||
"LabelAutoDownloadEpisodes": "Automatski preuzmi epizode",
|
||||
"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 +210,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",
|
||||
@@ -275,6 +283,7 @@
|
||||
"LabelHardDeleteFile": "Obriši datoteku zauvijek",
|
||||
"LabelHasEbook": "Has ebook",
|
||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Sat",
|
||||
"LabelIcon": "Ikona",
|
||||
@@ -316,9 +325,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -92,6 +92,7 @@
|
||||
"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 +103,7 @@
|
||||
"HeaderCurrentDownloads": "Download Correnti",
|
||||
"HeaderDetails": "Dettagli",
|
||||
"HeaderDownloadQueue": "Download Queue",
|
||||
"HeaderEbookFiles": "Ebook Files",
|
||||
"HeaderEbookFiles": "Ebook File",
|
||||
"HeaderEmail": "Email",
|
||||
"HeaderEmailSettings": "Email Settings",
|
||||
"HeaderEpisodes": "Episodi",
|
||||
@@ -131,8 +132,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 +164,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 +184,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 +196,12 @@
|
||||
"LabelAuthorLastFirst": "Autori (Per Cognome)",
|
||||
"LabelAuthors": "Autori",
|
||||
"LabelAutoDownloadEpisodes": "Auto Download Episodi",
|
||||
"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 +210,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 +228,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",
|
||||
@@ -275,10 +283,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 +312,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 +410,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 +426,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 +483,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",
|
||||
@@ -532,21 +544,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 +570,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 +620,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 +649,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 +663,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 +729,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",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"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 +132,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 +196,10 @@
|
||||
"LabelAuthorLastFirst": "Autorius (Pavardė, Vardas)",
|
||||
"LabelAuthors": "Autoriai",
|
||||
"LabelAutoDownloadEpisodes": "Automatiškai atsisiųsti epizodus",
|
||||
"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 +210,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",
|
||||
@@ -275,6 +283,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 +325,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",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
|
||||
"HeaderAudiobookTools": "Audioboekbestandbeheer tools",
|
||||
"HeaderAudioTracks": "Audiotracks",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Back-ups",
|
||||
"HeaderChangePassword": "Wachtwoord wijzigen",
|
||||
"HeaderChapters": "Hoofdstukken",
|
||||
@@ -131,8 +132,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 +196,10 @@
|
||||
"LabelAuthorLastFirst": "Auteur (Achternaam, Voornaam)",
|
||||
"LabelAuthors": "Auteurs",
|
||||
"LabelAutoDownloadEpisodes": "Afleveringen automatisch downloaden",
|
||||
"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 +210,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",
|
||||
@@ -275,6 +283,7 @@
|
||||
"LabelHardDeleteFile": "Hard-delete bestand",
|
||||
"LabelHasEbook": "Heeft ebook",
|
||||
"LabelHasSupplementaryEbook": "Heeft supplementair ebook",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Uur",
|
||||
"LabelIcon": "Icoon",
|
||||
@@ -316,9 +325,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",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger",
|
||||
"HeaderAudiobookTools": "Lydbok Filbehandlingsverktøy",
|
||||
"HeaderAudioTracks": "Lydspor",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Sikkerhetskopier",
|
||||
"HeaderChangePassword": "Bytt passord",
|
||||
"HeaderChapters": "Kapittel",
|
||||
@@ -131,8 +132,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 +196,10 @@
|
||||
"LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)",
|
||||
"LabelAuthors": "Forfattere",
|
||||
"LabelAutoDownloadEpisodes": "Last ned episoder automatisk",
|
||||
"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 +210,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",
|
||||
@@ -275,6 +283,7 @@
|
||||
"LabelHardDeleteFile": "Tving sletting av fil",
|
||||
"LabelHasEbook": "Har ebok",
|
||||
"LabelHasSupplementaryEbook": "Har supplerende ebok",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Tjener",
|
||||
"LabelHour": "Time",
|
||||
"LabelIcon": "Ikon",
|
||||
@@ -316,9 +325,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",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"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 +132,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 +196,10 @@
|
||||
"LabelAuthorLastFirst": "Author (Malejąco)",
|
||||
"LabelAuthors": "Autorzy",
|
||||
"LabelAutoDownloadEpisodes": "Automatyczne pobieranie odcinków",
|
||||
"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 +210,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",
|
||||
@@ -275,6 +283,7 @@
|
||||
"LabelHardDeleteFile": "Usuń trwale plik",
|
||||
"LabelHasEbook": "Has ebook",
|
||||
"LabelHasSupplementaryEbook": "Has supplementary ebook",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Host",
|
||||
"LabelHour": "Godzina",
|
||||
"LabelIcon": "Ikona",
|
||||
@@ -316,9 +325,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",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"HeaderAppriseNotificationSettings": "Настройки оповещений",
|
||||
"HeaderAudiobookTools": "Инструменты файлов аудиокниг",
|
||||
"HeaderAudioTracks": "Аудио треки",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Бэкапы",
|
||||
"HeaderChangePassword": "Изменить пароль",
|
||||
"HeaderChapters": "Главы",
|
||||
@@ -131,8 +132,10 @@
|
||||
"HeaderNewAccount": "Новая учетная запись",
|
||||
"HeaderNewLibrary": "Новая библиотека",
|
||||
"HeaderNotifications": "Уведомления",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "Открыть RSS-канал",
|
||||
"HeaderOtherFiles": "Другие файлы",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "Разрешения",
|
||||
"HeaderPlayerQueue": "Очередь воспроизведения",
|
||||
"HeaderPlaylist": "Плейлист",
|
||||
@@ -193,6 +196,10 @@
|
||||
"LabelAuthorLastFirst": "Автор (Фамилия, Имя)",
|
||||
"LabelAuthors": "Авторы",
|
||||
"LabelAutoDownloadEpisodes": "Скачивать эпизоды автоматически",
|
||||
"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 +210,7 @@
|
||||
"LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.",
|
||||
"LabelBitrate": "Битрейт",
|
||||
"LabelBooks": "Книги",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "Изменить пароль",
|
||||
"LabelChannels": "Каналы",
|
||||
"LabelChapters": "Главы",
|
||||
@@ -275,6 +283,7 @@
|
||||
"LabelHardDeleteFile": "Жесткое удаление файла",
|
||||
"LabelHasEbook": "Есть e-книга",
|
||||
"LabelHasSupplementaryEbook": "Есть дополнительная e-книга",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "Хост",
|
||||
"LabelHour": "Часы",
|
||||
"LabelIcon": "Иконка",
|
||||
@@ -316,9 +325,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": "Мета теги",
|
||||
|
||||
741
client/strings/sv.json
Normal file
741
client/strings/sv.json
Normal file
@@ -0,0 +1,741 @@
|
||||
{
|
||||
"ButtonAdd": "Lägg till",
|
||||
"ButtonAddChapters": "Lägg till kapitel",
|
||||
"ButtonAddDevice": "Lägg till enhet",
|
||||
"ButtonAddLibrary": "Lägg till bibliotek",
|
||||
"ButtonAddPodcasts": "Lägg till podcasts",
|
||||
"ButtonAddUser": "Lägg till användare",
|
||||
"ButtonAddYourFirstLibrary": "Lägg till ditt första bibliotek",
|
||||
"ButtonApply": "Tillämpa",
|
||||
"ButtonApplyChapters": "Tillämpa kapitel",
|
||||
"ButtonAuthors": "Författare",
|
||||
"ButtonBrowseForFolder": "Bläddra efter mapp",
|
||||
"ButtonCancel": "Avbryt",
|
||||
"ButtonCancelEncode": "Avbryt kodning",
|
||||
"ButtonChangeRootPassword": "Ändra rootlösenord",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Kontrollera och ladda ner nya avsnitt",
|
||||
"ButtonChooseAFolder": "Välj en mapp",
|
||||
"ButtonChooseFiles": "Välj filer",
|
||||
"ButtonClearFilter": "Rensa filter",
|
||||
"ButtonCloseFeed": "Stäng flöde",
|
||||
"ButtonCollections": "Samlingar",
|
||||
"ButtonConfigureScanner": "Konfigurera skanner",
|
||||
"ButtonCreate": "Skapa",
|
||||
"ButtonCreateBackup": "Skapa säkerhetskopia",
|
||||
"ButtonDelete": "Radera",
|
||||
"ButtonDownloadQueue": "Kö",
|
||||
"ButtonEdit": "Redigera",
|
||||
"ButtonEditChapters": "Redigera kapitel",
|
||||
"ButtonEditPodcast": "Redigera podcast",
|
||||
"ButtonForceReScan": "Tvinga omstart",
|
||||
"ButtonFullPath": "Full sökväg",
|
||||
"ButtonHide": "Dölj",
|
||||
"ButtonHome": "Hem",
|
||||
"ButtonIssues": "Problem",
|
||||
"ButtonLatest": "Senaste",
|
||||
"ButtonLibrary": "Bibliotek",
|
||||
"ButtonLogout": "Logga ut",
|
||||
"ButtonLookup": "Sök",
|
||||
"ButtonManageTracks": "Hantera spår",
|
||||
"ButtonMapChapterTitles": "Karta kapitelrubriker",
|
||||
"ButtonMatchAllAuthors": "Matcha alla författare",
|
||||
"ButtonMatchBooks": "Matcha böcker",
|
||||
"ButtonNevermind": "Glöm det",
|
||||
"ButtonOk": "Okej",
|
||||
"ButtonOpenFeed": "Öppna flöde",
|
||||
"ButtonOpenManager": "Öppna Manager",
|
||||
"ButtonPlay": "Spela",
|
||||
"ButtonPlaying": "Spelar",
|
||||
"ButtonPlaylists": "Spellistor",
|
||||
"ButtonPurgeAllCache": "Rensa all cache",
|
||||
"ButtonPurgeItemsCache": "Rensa föremåls-cache",
|
||||
"ButtonPurgeMediaProgress": "Rensa medieförlopp",
|
||||
"ButtonQueueAddItem": "Lägg till i kön",
|
||||
"ButtonQueueRemoveItem": "Ta bort från kön",
|
||||
"ButtonQuickMatch": "Snabb matchning",
|
||||
"ButtonRead": "Läs",
|
||||
"ButtonRemove": "Ta bort",
|
||||
"ButtonRemoveAll": "Ta bort alla",
|
||||
"ButtonRemoveAllLibraryItems": "Ta bort alla biblioteksobjekt",
|
||||
"ButtonRemoveFromContinueListening": "Ta bort från Fortsätt lyssna",
|
||||
"ButtonRemoveFromContinueReading": "Ta bort från Fortsätt läsa",
|
||||
"ButtonRemoveSeriesFromContinueSeries": "Ta bort serie från Fortsätt serie",
|
||||
"ButtonReScan": "Omstart",
|
||||
"ButtonReset": "Återställ",
|
||||
"ButtonResetToDefault": "Återställ till standard",
|
||||
"ButtonRestore": "Återställ",
|
||||
"ButtonSave": "Spara",
|
||||
"ButtonSaveAndClose": "Spara och stäng",
|
||||
"ButtonSaveTracklist": "Spara spårlista",
|
||||
"ButtonScan": "Skanna",
|
||||
"ButtonScanLibrary": "Skanna bibliotek",
|
||||
"ButtonSearch": "Sök",
|
||||
"ButtonSelectFolderPath": "Välj mappens sökväg",
|
||||
"ButtonSeries": "Serie",
|
||||
"ButtonSetChaptersFromTracks": "Ställ in kapitel från spår",
|
||||
"ButtonShiftTimes": "Förskjut tider",
|
||||
"ButtonShow": "Visa",
|
||||
"ButtonStartM4BEncode": "Starta M4B-kodning",
|
||||
"ButtonStartMetadataEmbed": "Starta inbäddning av metadata",
|
||||
"ButtonSubmit": "Skicka",
|
||||
"ButtonTest": "Testa",
|
||||
"ButtonUpload": "Ladda upp",
|
||||
"ButtonUploadBackup": "Ladda upp säkerhetskopia",
|
||||
"ButtonUploadCover": "Ladda upp omslag",
|
||||
"ButtonUploadOPMLFile": "Ladda upp OPML-fil",
|
||||
"ButtonUserDelete": "Radera användare {0}",
|
||||
"ButtonUserEdit": "Redigera användare {0}",
|
||||
"ButtonViewAll": "Visa alla",
|
||||
"ButtonYes": "Ja",
|
||||
"HeaderAccount": "Konto",
|
||||
"HeaderAdvanced": "Avancerad",
|
||||
"HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar",
|
||||
"HeaderAudiobookTools": "Ljudbokshantering",
|
||||
"HeaderAudioTracks": "Ljudspår",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "Säkerhetskopior",
|
||||
"HeaderChangePassword": "Ändra lösenord",
|
||||
"HeaderChapters": "Kapitel",
|
||||
"HeaderChooseAFolder": "Välj en mapp",
|
||||
"HeaderCollection": "Samling",
|
||||
"HeaderCollectionItems": "Samlingselement",
|
||||
"HeaderCover": "Omslag",
|
||||
"HeaderCurrentDownloads": "Aktuella nedladdningar",
|
||||
"HeaderDetails": "Detaljer",
|
||||
"HeaderDownloadQueue": "Nedladdningskö",
|
||||
"HeaderEbookFiles": "E-boksfiler",
|
||||
"HeaderEmail": "E-post",
|
||||
"HeaderEmailSettings": "E-postinställningar",
|
||||
"HeaderEpisodes": "Avsnitt",
|
||||
"HeaderEreaderDevices": "E-boksläsarenheter",
|
||||
"HeaderEreaderSettings": "E-boksinställningar",
|
||||
"HeaderFiles": "Filer",
|
||||
"HeaderFindChapters": "Hitta kapitel",
|
||||
"HeaderIgnoredFiles": "Ignorerade filer",
|
||||
"HeaderItemFiles": "Föremålsfiler",
|
||||
"HeaderItemMetadataUtils": "Metadataverktyg för föremål",
|
||||
"HeaderLastListeningSession": "Senaste lyssningssession",
|
||||
"HeaderLatestEpisodes": "Senaste avsnitt",
|
||||
"HeaderLibraries": "Bibliotek",
|
||||
"HeaderLibraryFiles": "Biblioteksfiler",
|
||||
"HeaderLibraryStats": "Biblioteksstatistik",
|
||||
"HeaderListeningSessions": "Lyssningssessioner",
|
||||
"HeaderListeningStats": "Lyssningsstatistik",
|
||||
"HeaderLogin": "Logga in",
|
||||
"HeaderLogs": "Loggar",
|
||||
"HeaderManageGenres": "Hantera genrer",
|
||||
"HeaderManageTags": "Hantera taggar",
|
||||
"HeaderMapDetails": "Karta detaljer",
|
||||
"HeaderMatch": "Matcha",
|
||||
"HeaderMetadataOrderOfPrecedence": "Metadataordning av företräde",
|
||||
"HeaderMetadataToEmbed": "Metadata att bädda in",
|
||||
"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",
|
||||
"HeaderPlaylistItems": "Spellistobjekt",
|
||||
"HeaderPodcastsToAdd": "Podcaster att lägga till",
|
||||
"HeaderPreviewCover": "Förhandsgranska omslag",
|
||||
"HeaderRemoveEpisode": "Ta bort avsnitt",
|
||||
"HeaderRemoveEpisodes": "Ta bort {0} avsnitt",
|
||||
"HeaderRSSFeedGeneral": "RSS-information",
|
||||
"HeaderRSSFeedIsOpen": "RSS-flödet är öppet",
|
||||
"HeaderRSSFeeds": "RSS-flöden",
|
||||
"HeaderSavedMediaProgress": "Sparad medieförlopp",
|
||||
"HeaderSchedule": "Schema",
|
||||
"HeaderScheduleLibraryScans": "Schemalagda biblioteksskanningar",
|
||||
"HeaderSession": "Session",
|
||||
"HeaderSetBackupSchedule": "Ange schemaläggning för säkerhetskopia",
|
||||
"HeaderSettings": "Inställningar",
|
||||
"HeaderSettingsDisplay": "Visning",
|
||||
"HeaderSettingsExperimental": "Experimentella funktioner",
|
||||
"HeaderSettingsGeneral": "Allmänt",
|
||||
"HeaderSettingsScanner": "Skanner",
|
||||
"HeaderSleepTimer": "Sovtidtagare",
|
||||
"HeaderStatsLargestItems": "Största föremål",
|
||||
"HeaderStatsLongestItems": "Längsta föremål (tim)",
|
||||
"HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagar)",
|
||||
"HeaderStatsRecentSessions": "Senaste sessioner",
|
||||
"HeaderStatsTop10Authors": "Topp 10 författare",
|
||||
"HeaderStatsTop5Genres": "Topp 5 genrer",
|
||||
"HeaderTableOfContents": "Innehållsförteckning",
|
||||
"HeaderTools": "Verktyg",
|
||||
"HeaderUpdateAccount": "Uppdatera konto",
|
||||
"HeaderUpdateAuthor": "Uppdatera författare",
|
||||
"HeaderUpdateDetails": "Uppdatera detaljer",
|
||||
"HeaderUpdateLibrary": "Uppdatera bibliotek",
|
||||
"HeaderUsers": "Användare",
|
||||
"HeaderYourStats": "Dina statistik",
|
||||
"LabelAbridged": "Förkortad",
|
||||
"LabelAccountType": "Kontotyp",
|
||||
"LabelAccountTypeAdmin": "Admin",
|
||||
"LabelAccountTypeGuest": "Gäst",
|
||||
"LabelAccountTypeUser": "Användare",
|
||||
"LabelActivity": "Aktivitet",
|
||||
"LabelAdded": "Tillagd",
|
||||
"LabelAddedAt": "Tillagd vid",
|
||||
"LabelAddToCollection": "Lägg till i Samling",
|
||||
"LabelAddToCollectionBatch": "Lägg till {0} böcker i Samlingen",
|
||||
"LabelAddToPlaylist": "Lägg till i Spellista",
|
||||
"LabelAddToPlaylistBatch": "Lägg till {0} objekt i Spellistan",
|
||||
"LabelAdminUsersOnly": "Endast administratörer",
|
||||
"LabelAll": "Alla",
|
||||
"LabelAllUsers": "Alla användare",
|
||||
"LabelAllUsersExcludingGuests": "Alla användare utom gäster",
|
||||
"LabelAllUsersIncludingGuests": "Alla användare inklusive gäster",
|
||||
"LabelAlreadyInYourLibrary": "Redan i din samling",
|
||||
"LabelAppend": "Lägg till",
|
||||
"LabelAuthor": "Författare",
|
||||
"LabelAuthorFirstLast": "Författare (Förnamn Efternamn)",
|
||||
"LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)",
|
||||
"LabelAuthors": "Författare",
|
||||
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
|
||||
"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",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i /metadata/säkerhetskopior",
|
||||
"LabelBackupsMaxBackupSize": "Maximal säkerhetskopiostorlek (i GB)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot felkonfiguration kommer säkerhetskopior att misslyckas om de överskrider den konfigurerade storleken.",
|
||||
"LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla",
|
||||
"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",
|
||||
"LabelChaptersFound": "hittade kapitel",
|
||||
"LabelChapterTitle": "Kapitelrubrik",
|
||||
"LabelClickForMoreInfo": "Klicka för mer information",
|
||||
"LabelClosePlayer": "Stäng spelaren",
|
||||
"LabelCodec": "Codec",
|
||||
"LabelCollapseSeries": "Fäll ihop serie",
|
||||
"LabelCollection": "Samling",
|
||||
"LabelCollections": "Samlingar",
|
||||
"LabelComplete": "Komplett",
|
||||
"LabelConfirmPassword": "Bekräfta lösenord",
|
||||
"LabelContinueListening": "Fortsätt lyssna",
|
||||
"LabelContinueReading": "Fortsätt läsa",
|
||||
"LabelContinueSeries": "Fortsätt serie",
|
||||
"LabelCover": "Omslag",
|
||||
"LabelCoverImageURL": "URL till omslagsbild",
|
||||
"LabelCreatedAt": "Skapad vid",
|
||||
"LabelCronExpression": "Cron-uttryck",
|
||||
"LabelCurrent": "Nuvarande",
|
||||
"LabelCurrently": "För närvarande:",
|
||||
"LabelCustomCronExpression": "Anpassat Cron-uttryck:",
|
||||
"LabelDatetime": "Datum och tid",
|
||||
"LabelDeleteFromFileSystemCheckbox": "Ta bort från filsystem (avmarkera för att endast ta bort från databasen)",
|
||||
"LabelDescription": "Beskrivning",
|
||||
"LabelDeselectAll": "Avmarkera alla",
|
||||
"LabelDevice": "Enhet",
|
||||
"LabelDeviceInfo": "Enhetsinformation",
|
||||
"LabelDeviceIsAvailableTo": "Enhet är tillgänglig för...",
|
||||
"LabelDirectory": "Katalog",
|
||||
"LabelDiscFromFilename": "Skiva från filnamn",
|
||||
"LabelDiscFromMetadata": "Skiva från metadata",
|
||||
"LabelDiscover": "Upptäck",
|
||||
"LabelDownload": "Ladda ner",
|
||||
"LabelDownloadNEpisodes": "Ladda ner {0} avsnitt",
|
||||
"LabelDuration": "Varaktighet",
|
||||
"LabelDurationFound": "Varaktighet hittad:",
|
||||
"LabelEbook": "E-bok",
|
||||
"LabelEbooks": "E-böcker",
|
||||
"LabelEdit": "Redigera",
|
||||
"LabelEmail": "E-post",
|
||||
"LabelEmailSettingsFromAddress": "Från adress",
|
||||
"LabelEmailSettingsSecure": "Säker",
|
||||
"LabelEmailSettingsSecureHelp": "Om sant kommer anslutningen att använda TLS vid anslutning till servern. Om falskt används TLS om servern stöder STARTTLS-tillägget. I de flesta fall, om du ansluter till port 465, bör du ställa in detta värde till sant. För port 587 eller 25, låt det vara falskt. (från nodemailer.com/smtp/#authentication)",
|
||||
"LabelEmailSettingsTestAddress": "Testadress",
|
||||
"LabelEmbeddedCover": "Inbäddat omslag",
|
||||
"LabelEnable": "Aktivera",
|
||||
"LabelEnd": "Slut",
|
||||
"LabelEpisode": "Avsnitt",
|
||||
"LabelEpisodeTitle": "Avsnittsrubrik",
|
||||
"LabelEpisodeType": "Avsnittstyp",
|
||||
"LabelExample": "Exempel",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelFeedURL": "Flödes-URL",
|
||||
"LabelFile": "Fil",
|
||||
"LabelFileBirthtime": "Födelse-tidpunkt för fil",
|
||||
"LabelFileModified": "Fil ändrad",
|
||||
"LabelFilename": "Filnamn",
|
||||
"LabelFilterByUser": "Filtrera efter användare",
|
||||
"LabelFindEpisodes": "Hitta avsnitt",
|
||||
"LabelFinished": "Avslutad",
|
||||
"LabelFolder": "Mapp",
|
||||
"LabelFolders": "Mappar",
|
||||
"LabelFontFamily": "Teckensnittsfamilj",
|
||||
"LabelFontScale": "Teckensnittsskala",
|
||||
"LabelFormat": "Format",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genrer",
|
||||
"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",
|
||||
"LabelImageURLFromTheWeb": "Bild-URL från webben",
|
||||
"LabelIncludeInTracklist": "Inkludera i spårlista",
|
||||
"LabelIncomplete": "Ofullständig",
|
||||
"LabelInProgress": "Pågående",
|
||||
"LabelInterval": "Intervall",
|
||||
"LabelIntervalCustomDailyWeekly": "Anpassat dagligt/veckovis",
|
||||
"LabelIntervalEvery12Hours": "Var 12:e timme",
|
||||
"LabelIntervalEvery15Minutes": "Var 15:e minut",
|
||||
"LabelIntervalEvery2Hours": "Var 2:e timme",
|
||||
"LabelIntervalEvery30Minutes": "Var 30:e minut",
|
||||
"LabelIntervalEvery6Hours": "Var 6:e timme",
|
||||
"LabelIntervalEveryDay": "Varje dag",
|
||||
"LabelIntervalEveryHour": "Varje timme",
|
||||
"LabelInvalidParts": "Ogiltiga delar",
|
||||
"LabelInvert": "Invertera",
|
||||
"LabelItem": "Objekt",
|
||||
"LabelLanguage": "Språk",
|
||||
"LabelLanguageDefaultServer": "Standardspråk för server",
|
||||
"LabelLastBookAdded": "Senaste bok tillagd",
|
||||
"LabelLastBookUpdated": "Senaste bok uppdaterad",
|
||||
"LabelLastSeen": "Senast sedd",
|
||||
"LabelLastTime": "Senaste gången",
|
||||
"LabelLastUpdate": "Senaste uppdatering",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutSinglePage": "En sida",
|
||||
"LabelLayoutSplitPage": "Dela sida",
|
||||
"LabelLess": "Mindre",
|
||||
"LabelLibrariesAccessibleToUser": "Åtkomliga bibliotek för användare",
|
||||
"LabelLibrary": "Bibliotek",
|
||||
"LabelLibraryItem": "Biblioteksobjekt",
|
||||
"LabelLibraryName": "Biblioteksnamn",
|
||||
"LabelLimit": "Begränsning",
|
||||
"LabelLineSpacing": "Radavstånd",
|
||||
"LabelListenAgain": "Lyssna igen",
|
||||
"LabelLogLevelDebug": "Felsökningsnivå: Felsökning",
|
||||
"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": "Higher priority metadata sources will override lower priority metadata sources",
|
||||
"LabelMetadataProvider": "Metadataleverantör",
|
||||
"LabelMetaTag": "Metamärke",
|
||||
"LabelMetaTags": "Metamärken",
|
||||
"LabelMinute": "Minut",
|
||||
"LabelMissing": "Saknad",
|
||||
"LabelMissingParts": "Saknade delar",
|
||||
"LabelMore": "Mer",
|
||||
"LabelMoreInfo": "Mer information",
|
||||
"LabelName": "Namn",
|
||||
"LabelNarrator": "Berättare",
|
||||
"LabelNarrators": "Berättare",
|
||||
"LabelNew": "Ny",
|
||||
"LabelNewestAuthors": "Nyaste författare",
|
||||
"LabelNewestEpisodes": "Nyaste avsnitt",
|
||||
"LabelNewPassword": "Nytt lösenord",
|
||||
"LabelNextBackupDate": "Nästa säkerhetskopia datum",
|
||||
"LabelNextScheduledRun": "Nästa schemalagda körning",
|
||||
"LabelNoEpisodesSelected": "Inga avsnitt valda",
|
||||
"LabelNotes": "Anteckningar",
|
||||
"LabelNotFinished": "Ej avslutad",
|
||||
"LabelNotificationAppriseURL": "Apprise URL(er)",
|
||||
"LabelNotificationAvailableVariables": "Tillgängliga variabler",
|
||||
"LabelNotificationBodyTemplate": "Kroppsmall",
|
||||
"LabelNotificationEvent": "Aviseringshändelse",
|
||||
"LabelNotificationsMaxFailedAttempts": "Max antal misslyckade försök",
|
||||
"LabelNotificationsMaxFailedAttemptsHelp": "Aviseringar inaktiveras när de misslyckas med att skickas så många gånger",
|
||||
"LabelNotificationsMaxQueueSize": "Max köstorlek för aviseringsevenemang",
|
||||
"LabelNotificationsMaxQueueSizeHelp": "Evenemang är begränsade till att utlösa ett per sekund. Evenemang kommer att ignoreras om kön är full. Detta förhindrar aviseringsspam.",
|
||||
"LabelNotificationTitleTemplate": "Titelsmall",
|
||||
"LabelNotStarted": "Inte påbörjad",
|
||||
"LabelNumberOfBooks": "Antal böcker",
|
||||
"LabelNumberOfEpisodes": "Antal avsnitt",
|
||||
"LabelOpenRSSFeed": "Öppna RSS-flöde",
|
||||
"LabelOverwrite": "Skriv över",
|
||||
"LabelPassword": "Lösenord",
|
||||
"LabelPath": "Sökväg",
|
||||
"LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek",
|
||||
"LabelPermissionsAccessAllTags": "Kan komma åt alla taggar",
|
||||
"LabelPermissionsAccessExplicitContent": "Kan komma åt explicit innehåll",
|
||||
"LabelPermissionsDelete": "Kan radera",
|
||||
"LabelPermissionsDownload": "Kan ladda ner",
|
||||
"LabelPermissionsUpdate": "Kan uppdatera",
|
||||
"LabelPermissionsUpload": "Kan ladda upp",
|
||||
"LabelPhotoPathURL": "Bildsökväg/URL",
|
||||
"LabelPlaylists": "Spellistor",
|
||||
"LabelPlayMethod": "Spelläge",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcasts": "Podcasts",
|
||||
"LabelPodcastType": "Podcasttyp",
|
||||
"LabelPort": "Port",
|
||||
"LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)",
|
||||
"LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer",
|
||||
"LabelPrimaryEbook": "Primär e-bok",
|
||||
"LabelProgress": "Framsteg",
|
||||
"LabelProvider": "Leverantör",
|
||||
"LabelPubDate": "Publiceringsdatum",
|
||||
"LabelPublisher": "Utgivare",
|
||||
"LabelPublishYear": "Publiceringsår",
|
||||
"LabelRead": "Läst",
|
||||
"LabelReadAgain": "Läs igen",
|
||||
"LabelReadEbookWithoutProgress": "Läs e-bok utan att behålla framsteg",
|
||||
"LabelRecentlyAdded": "Nyligen tillagd",
|
||||
"LabelRecentSeries": "Senaste serier",
|
||||
"LabelRecommended": "Rekommenderad",
|
||||
"LabelRegion": "Region",
|
||||
"LabelReleaseDate": "Utgivningsdatum",
|
||||
"LabelRemoveCover": "Ta bort omslag",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post",
|
||||
"LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn",
|
||||
"LabelRSSFeedOpen": "Öppna RSS-flöde",
|
||||
"LabelRSSFeedPreventIndexing": "Förhindra indexering",
|
||||
"LabelRSSFeedSlug": "RSS-flödesslag",
|
||||
"LabelRSSFeedURL": "RSS-flöde URL",
|
||||
"LabelSearchTerm": "Sökterm",
|
||||
"LabelSearchTitle": "Sök titel",
|
||||
"LabelSearchTitleOrASIN": "Sök titel eller ASIN",
|
||||
"LabelSeason": "Säsong",
|
||||
"LabelSelectAllEpisodes": "Välj alla avsnitt",
|
||||
"LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas",
|
||||
"LabelSelectUsers": "Välj användare",
|
||||
"LabelSendEbookToDevice": "Skicka e-bok till...",
|
||||
"LabelSequence": "Sekvens",
|
||||
"LabelSeries": "Serie",
|
||||
"LabelSeriesName": "Serienamn",
|
||||
"LabelSeriesProgress": "Serieframsteg",
|
||||
"LabelSetEbookAsPrimary": "Ange som primär",
|
||||
"LabelSetEbookAsSupplementary": "Ange som kompletterande",
|
||||
"LabelSettingsAudiobooksOnly": "Endast ljudböcker",
|
||||
"LabelSettingsAudiobooksOnlyHelp": "Aktivera detta alternativ kommer att ignorera e-boksfiler om de inte finns inom en ljudboksmapp, i vilket fall de kommer att anges som kompletterande e-böcker",
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorfisk design med trähyllor",
|
||||
"LabelSettingsChromecastSupport": "Chromecast-stöd",
|
||||
"LabelSettingsDateFormat": "Datumformat",
|
||||
"LabelSettingsDisableWatcher": "Inaktivera Watcher",
|
||||
"LabelSettingsDisableWatcherForLibrary": "Inaktivera mappbevakning för bibliotek",
|
||||
"LabelSettingsDisableWatcherHelp": "Inaktiverar automatiskt lägga till/uppdatera objekt när filändringar upptäcks. *Kräver omstart av servern",
|
||||
"LabelSettingsEnableWatcher": "Aktivera Watcher",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Aktivera mappbevakning för bibliotek",
|
||||
"LabelSettingsEnableWatcherHelp": "Aktiverar automatiskt lägga till/uppdatera objekt när filändringar upptäcks. *Kräver omstart av servern",
|
||||
"LabelSettingsExperimentalFeatures": "Experimentella funktioner",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.",
|
||||
"LabelSettingsFindCovers": "Hitta omslag",
|
||||
"LabelSettingsFindCoversHelp": "Om din ljudbok inte har ett inbäddat omslag eller en omslagsbild i mappen kommer skannern att försöka hitta ett omslag.<br>Observera: Detta kommer att förlänga skannningstiden",
|
||||
"LabelSettingsHideSingleBookSeries": "Dölj enboksserier",
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Serier som har en enda bok kommer att döljas från seriesidan och hyllsidan på startsidan.",
|
||||
"LabelSettingsHomePageBookshelfView": "Startsida använd bokhyllvy",
|
||||
"LabelSettingsLibraryBookshelfView": "Bibliotek använd bokhyllvy",
|
||||
"LabelSettingsParseSubtitles": "Analysera undertexter",
|
||||
"LabelSettingsParseSubtitlesHelp": "Extrahera undertexter från mappnamn för ljudböcker.<br>Undertext måste vara åtskilda av \" - \"<br>t.ex. \"Boktitel - En undertitel här\" har undertiteln \"En undertitel här\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "Föredra matchad metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att åsidosätta objektdetaljer vid snabbmatchning. Som standard kommer snabbmatchning endast att fylla i saknade detaljer.",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker med ASIN",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker med ISBN",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering",
|
||||
"LabelSettingsSortingIgnorePrefixesHelp": "t.ex. för prefixet \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"",
|
||||
"LabelSettingsSquareBookCovers": "Använd fyrkantiga bokomslag",
|
||||
"LabelSettingsSquareBookCoversHelp": "Föredrar att använda fyrkantiga omslag över standard 1.6:1 bokomslag",
|
||||
"LabelSettingsStoreCoversWithItem": "Lagra omslag med objekt",
|
||||
"LabelSettingsStoreCoversWithItemHelp": "Som standard lagras omslag i /metadata/items, att aktivera detta alternativ kommer att lagra omslag i din biblioteksmapp. Endast en fil med namnet \"cover\" kommer att behållas",
|
||||
"LabelSettingsStoreMetadataWithItem": "Lagra metadata med objekt",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i /metadata/items, att aktivera detta alternativ kommer att lagra metadatafiler i dina biblioteksmappar",
|
||||
"LabelSettingsTimeFormat": "Tidsformat",
|
||||
"LabelShowAll": "Visa alla",
|
||||
"LabelSize": "Storlek",
|
||||
"LabelSleepTimer": "Sleeptimer",
|
||||
"LabelSlug": "Slug",
|
||||
"LabelStart": "Start",
|
||||
"LabelStarted": "Startad",
|
||||
"LabelStartedAt": "Startad vid",
|
||||
"LabelStartTime": "Starttid",
|
||||
"LabelStatsAudioTracks": "Ljudspår",
|
||||
"LabelStatsAuthors": "Författare",
|
||||
"LabelStatsBestDay": "Bästa dag",
|
||||
"LabelStatsDailyAverage": "Dagligt genomsnitt",
|
||||
"LabelStatsDays": "Dagar",
|
||||
"LabelStatsDaysListened": "Dagar lyssnade",
|
||||
"LabelStatsHours": "Timmar",
|
||||
"LabelStatsInARow": "i rad",
|
||||
"LabelStatsItemsFinished": "Objekt avslutade",
|
||||
"LabelStatsItemsInLibrary": "Objekt i biblioteket",
|
||||
"LabelStatsMinutes": "minuter",
|
||||
"LabelStatsMinutesListening": "Minuter av lyssnande",
|
||||
"LabelStatsOverallDays": "Totalt antal dagar",
|
||||
"LabelStatsOverallHours": "Totalt antal timmar",
|
||||
"LabelStatsWeekListening": "Veckans lyssnande",
|
||||
"LabelSubtitle": "Underrubrik",
|
||||
"LabelSupportedFileTypes": "Stödda filtyper",
|
||||
"LabelTag": "Tagg",
|
||||
"LabelTags": "Taggar",
|
||||
"LabelTagsAccessibleToUser": "Taggar tillgängliga för användaren",
|
||||
"LabelTagsNotAccessibleToUser": "Taggar inte tillgängliga för användaren",
|
||||
"LabelTasks": "Körande uppgifter",
|
||||
"LabelTheme": "Tema",
|
||||
"LabelThemeDark": "Mörkt",
|
||||
"LabelThemeLight": "Ljust",
|
||||
"LabelTimeBase": "Tidsbas",
|
||||
"LabelTimeListened": "Tid lyssnad",
|
||||
"LabelTimeListenedToday": "Tid lyssnad idag",
|
||||
"LabelTimeRemaining": "{0} kvar",
|
||||
"LabelTimeToShift": "Tid att skifta i sekunder",
|
||||
"LabelTitle": "Titel",
|
||||
"LabelToolsEmbedMetadata": "Bädda in metadata",
|
||||
"LabelToolsEmbedMetadataDescription": "Bädda in metadata i ljudfiler, inklusive omslagsbild och kapitel.",
|
||||
"LabelToolsMakeM4b": "Skapa M4B ljudbok",
|
||||
"LabelToolsMakeM4bDescription": "Skapa en .M4B ljudboksfil med inbäddad metadata, omslagsbild och kapitel.",
|
||||
"LabelToolsSplitM4b": "Dela M4B till MP3-filer",
|
||||
"LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.",
|
||||
"LabelTotalDuration": "Total varaktighet",
|
||||
"LabelTotalTimeListened": "Total tid lyssnad",
|
||||
"LabelTrackFromFilename": "Spår från filnamn",
|
||||
"LabelTrackFromMetadata": "Spår från metadata",
|
||||
"LabelTracks": "Spår",
|
||||
"LabelTracksMultiTrack": "Flerspårigt",
|
||||
"LabelTracksNone": "Inga spår",
|
||||
"LabelTracksSingleTrack": "Enspårigt",
|
||||
"LabelType": "Typ",
|
||||
"LabelUnabridged": "Oavkortad",
|
||||
"LabelUnknown": "Okänd",
|
||||
"LabelUpdateCover": "Uppdatera omslag",
|
||||
"LabelUpdateCoverHelp": "Tillåt överskrivning av befintliga omslag för de valda böckerna när en matchning hittas",
|
||||
"LabelUpdatedAt": "Uppdaterad vid",
|
||||
"LabelUpdateDetails": "Uppdatera detaljer",
|
||||
"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",
|
||||
"LabelUseChapterTrack": "Använd kapitelspår",
|
||||
"LabelUseFullTrack": "Använd hela spåret",
|
||||
"LabelUser": "Användare",
|
||||
"LabelUsername": "Användarnamn",
|
||||
"LabelValue": "Värde",
|
||||
"LabelVersion": "Version",
|
||||
"LabelViewBookmarks": "Visa bokmärken",
|
||||
"LabelViewChapters": "Visa kapitel",
|
||||
"LabelViewQueue": "Visa spellista",
|
||||
"LabelVolume": "Volym",
|
||||
"LabelWeekdaysToRun": "Vardagar att köra",
|
||||
"LabelYourAudiobookDuration": "Din ljudboks varaktighet",
|
||||
"LabelYourBookmarks": "Dina bokmärken",
|
||||
"LabelYourPlaylists": "Dina spellistor",
|
||||
"LabelYourProgress": "Din framsteg",
|
||||
"MessageAddToPlayerQueue": "Lägg till i spellistan",
|
||||
"MessageAppriseDescription": "För att använda den här funktionen behöver du ha en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> igång eller en API som hanterar dessa begäranden. <br />Apprise API-urlen bör vara hela URL-sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på <code>http://192.168.1.1:8337</code>, bör du ange <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageBackupsDescription": "Säkerhetskopieringar inkluderar användare, användares framsteg, biblioteksföremål, serverinställningar och bilder lagrade i <code>/metadata/items</code> & <code>/metadata/authors</code>. Säkerhetskopieringar inkluderar <strong>inte</strong> några filer lagrade i dina biblioteksmappar.",
|
||||
"MessageBatchQuickMatchDescription": "Quick Match kommer försöka lägga till saknade omslag och metadata för de valda föremålen. Aktivera alternativen nedan för att tillåta Quick Match att överskriva befintliga omslag och/eller metadata.",
|
||||
"MessageBookshelfNoCollections": "Du har ännu inte skapat några samlingar",
|
||||
"MessageBookshelfNoResultsForFilter": "Inga resultat för filter \"{0}: {1}\"",
|
||||
"MessageBookshelfNoRSSFeeds": "Inga RSS-flöden är öppna",
|
||||
"MessageBookshelfNoSeries": "Du har inga serier",
|
||||
"MessageChapterEndIsAfter": "Kapitelns slut är efter din ljudboks slut",
|
||||
"MessageChapterErrorFirstNotZero": "Första kapitlet måste börja vid 0",
|
||||
"MessageChapterErrorStartGteDuration": "Ogiltig starttid måste vara mindre än ljudbokens varaktighet",
|
||||
"MessageChapterErrorStartLtPrev": "Ogiltig starttid måste vara större än eller lika med tidigare kapitels starttid",
|
||||
"MessageChapterStartIsAfter": "Kapitlets start är efter din ljudboks slut",
|
||||
"MessageCheckingCron": "Kontrollerar cron...",
|
||||
"MessageConfirmCloseFeed": "Är du säker på att du vill stänga detta flöde?",
|
||||
"MessageConfirmDeleteBackup": "Är du säker på att du vill radera säkerhetskopian för {0}?",
|
||||
"MessageConfirmDeleteFile": "Detta kommer att radera filen från ditt filsystem. Är du säker?",
|
||||
"MessageConfirmDeleteLibrary": "Är du säker på att du vill radera biblioteket \"{0}\"?",
|
||||
"MessageConfirmDeleteLibraryItem": "Detta kommer att radera biblioteksföremålet från databasen och ditt filsystem. Är du säker?",
|
||||
"MessageConfirmDeleteLibraryItems": "Detta kommer att radera {0} biblioteksföremål från databasen och ditt filsystem. Är du säker?",
|
||||
"MessageConfirmDeleteSession": "Är du säker på att du vill radera denna session?",
|
||||
"MessageConfirmForceReScan": "Är du säker på att du vill tvinga omgenomsökning?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Är du säker på att du vill markera alla avsnitt som avslutade?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Är du säker på att du vill markera alla avsnitt som inte avslutade?",
|
||||
"MessageConfirmMarkSeriesFinished": "Är du säker på att du vill markera alla böcker i denna serie som avslutade?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Är du säker på att du vill markera alla böcker i denna serie som inte avslutade?",
|
||||
"MessageConfirmQuickEmbed": "Varning! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?",
|
||||
"MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?",
|
||||
"MessageConfirmRemoveAuthor": "Är du säker på att du vill ta bort författaren \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?",
|
||||
"MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort berättaren \"{0}\"?",
|
||||
"MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på genren \"{0}\" till \"{1}\" för alla objekt?",
|
||||
"MessageConfirmRenameGenreMergeNote": "Observera: Den här genren finns redan, så de kommer att slås samman.",
|
||||
"MessageConfirmRenameGenreWarning": "Varning! En liknande genre med annat skrivsätt finns redan \"{0}\".",
|
||||
"MessageConfirmRenameTag": "Är du säker på att du vill byta namn på taggen \"{0}\" till \"{1}\" för alla objekt?",
|
||||
"MessageConfirmRenameTagMergeNote": "Observera: Den här taggen finns redan, så de kommer att slås samman.",
|
||||
"MessageConfirmRenameTagWarning": "Varning! En liknande tagg med annat skrivsätt finns redan \"{0}\".",
|
||||
"MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra omgenomsökning för {0} objekt?",
|
||||
"MessageConfirmSendEbookToDevice": "Är du säker på att du vill skicka {0} e-bok \"{1}\" till enheten \"{2}\"?",
|
||||
"MessageDownloadingEpisode": "Laddar ner avsnitt",
|
||||
"MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning",
|
||||
"MessageEmbedFinished": "Inbäddning klar!",
|
||||
"MessageEpisodesQueuedForDownload": "{0} avsnitt i kö för nedladdning",
|
||||
"MessageFeedURLWillBe": "Flödes-URL kommer att vara {0}",
|
||||
"MessageFetching": "Hämtar...",
|
||||
"MessageForceReScanDescription": "kommer att göra en omgångssökning av alla filer som en färsk sökning. ID3-taggar för ljudfiler, OPF-filer och textfiler kommer att sökas som nya.",
|
||||
"MessageImportantNotice": "Viktig meddelande!",
|
||||
"MessageInsertChapterBelow": "Infoga kapitel nedanför",
|
||||
"MessageItemsSelected": "{0} Objekt markerade",
|
||||
"MessageItemsUpdated": "{0} Objekt uppdaterade",
|
||||
"MessageJoinUsOn": "Anslut dig till oss på",
|
||||
"MessageListeningSessionsInTheLastYear": "{0} lyssningssessioner det senaste året",
|
||||
"MessageLoading": "Laddar...",
|
||||
"MessageLoadingFolders": "Laddar mappar...",
|
||||
"MessageM4BFailed": "M4B misslyckades!",
|
||||
"MessageM4BFinished": "M4B klar!",
|
||||
"MessageMapChapterTitles": "Kartlägg kapitelrubriker till dina befintliga ljudbokskapitel utan att justera tidstämplar",
|
||||
"MessageMarkAllEpisodesFinished": "Markera alla avsnitt som avslutade",
|
||||
"MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som inte avslutade",
|
||||
"MessageMarkAsFinished": "Markera som avslutad",
|
||||
"MessageMarkAsNotFinished": "Markera som inte avslutad",
|
||||
"MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda sökleverantören och fylla i tomma detaljer och omslagskonst. Överskriver inte detaljer.",
|
||||
"MessageNoAudioTracks": "Inga ljudspår",
|
||||
"MessageNoAuthors": "Inga författare",
|
||||
"MessageNoBackups": "Inga säkerhetskopior",
|
||||
"MessageNoBookmarks": "Inga bokmärken",
|
||||
"MessageNoChapters": "Inga kapitel",
|
||||
"MessageNoCollections": "Inga samlingar",
|
||||
"MessageNoCoversFound": "Inga omslag hittade",
|
||||
"MessageNoDescription": "Ingen beskrivning",
|
||||
"MessageNoDownloadsInProgress": "Inga nedladdningar pågår för närvarande",
|
||||
"MessageNoDownloadsQueued": "Inga nedladdningar i kö",
|
||||
"MessageNoEpisodeMatchesFound": "Inga matchande avsnitt hittades",
|
||||
"MessageNoEpisodes": "Inga avsnitt",
|
||||
"MessageNoFoldersAvailable": "Inga mappar tillgängliga",
|
||||
"MessageNoGenres": "Inga genrer",
|
||||
"MessageNoIssues": "Inga problem",
|
||||
"MessageNoItems": "Inga objekt",
|
||||
"MessageNoItemsFound": "Inga objekt hittades",
|
||||
"MessageNoListeningSessions": "Inga lyssningssessioner",
|
||||
"MessageNoLogs": "Inga loggar",
|
||||
"MessageNoMediaProgress": "Ingen medieförlopp",
|
||||
"MessageNoNotifications": "Inga aviseringar",
|
||||
"MessageNoPodcastsFound": "Inga podcasts hittade",
|
||||
"MessageNoResults": "Inga resultat",
|
||||
"MessageNoSearchResultsFor": "Inga sökresultat för \"{0}\"",
|
||||
"MessageNoSeries": "Inga serier",
|
||||
"MessageNoTags": "Inga taggar",
|
||||
"MessageNoTasksRunning": "Inga pågående uppgifter",
|
||||
"MessageNotYetImplemented": "Ännu inte implementerad",
|
||||
"MessageNoUpdateNecessary": "Ingen uppdatering krävs",
|
||||
"MessageNoUpdatesWereNecessary": "Inga uppdateringar var nödvändiga",
|
||||
"MessageNoUserPlaylists": "Du har inga spellistor",
|
||||
"MessageOr": "eller",
|
||||
"MessagePauseChapter": "Pausa kapiteluppspelning",
|
||||
"MessagePlayChapter": "Lyssna på kapitlets början",
|
||||
"MessagePlaylistCreateFromCollection": "Skapa spellista från samling",
|
||||
"MessagePodcastHasNoRSSFeedForMatching": "Podcasten har ingen RSS-flödes-URL att använda för matchning",
|
||||
"MessageQuickMatchDescription": "Fyll tomma objektdetaljer och omslag med första matchningsresultat från '{0}'. Överskriver inte detaljer om inte serverinställningen 'Föredra matchad metadata' är aktiverad.",
|
||||
"MessageRemoveChapter": "Ta bort kapitel",
|
||||
"MessageRemoveEpisodes": "Ta bort {0} avsnitt",
|
||||
"MessageRemoveFromPlayerQueue": "Ta bort från spellistan",
|
||||
"MessageRemoveUserWarning": "Är du säker på att du vill radera användaren \"{0}\" permanent?",
|
||||
"MessageReportBugsAndContribute": "Rapportera buggar, begär funktioner och bidra på",
|
||||
"MessageResetChaptersConfirm": "Är du säker på att du vill återställa kapitel och ångra ändringarna du gjort?",
|
||||
"MessageRestoreBackupConfirm": "Är du säker på att du vill återställa säkerhetskopian som skapades den",
|
||||
"MessageRestoreBackupWarning": "Att återställa en säkerhetskopia kommer att skriva över hela databasen som finns i /config och omslagsbilder i /metadata/items & /metadata/authors.<br /><br />Säkerhetskopior ändrar inte några filer i dina biblioteksmappar. Om du har aktiverat serverinställningar för att lagra omslagskonst och metadata i dina biblioteksmappar säkerhetskopieras eller skrivs de inte över.<br /><br />Alla klienter som använder din server kommer att uppdateras automatiskt.",
|
||||
"MessageSearchResultsFor": "Sökresultat för",
|
||||
"MessageServerCouldNotBeReached": "Servern kunde inte nås",
|
||||
"MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn",
|
||||
"MessageStartPlaybackAtTime": "Starta uppspelning för \"{0}\" kl. {1}?",
|
||||
"MessageThinking": "Tänker...",
|
||||
"MessageUploaderItemFailed": "Misslyckades med att ladda upp",
|
||||
"MessageUploaderItemSuccess": "Uppladdning lyckades!",
|
||||
"MessageUploading": "Laddar upp...",
|
||||
"MessageValidCronExpression": "Giltigt cron-uttryck",
|
||||
"MessageWatcherIsDisabledGlobally": "Vakten är inaktiverad globalt i serverinställningarna",
|
||||
"MessageXLibraryIsEmpty": "{0} biblioteket är tomt!",
|
||||
"MessageYourAudiobookDurationIsLonger": "Varaktigheten på din ljudbok är längre än den hittade varaktigheten",
|
||||
"MessageYourAudiobookDurationIsShorter": "Varaktigheten på din ljudbok är kortare än den hittade varaktigheten",
|
||||
"NoteChangeRootPassword": "Rotanvändaren är den enda användaren som kan ha ett tomt lösenord",
|
||||
"NoteChapterEditorTimes": "Obs: Starttiden för första kapitlet måste förbli 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens varaktighet.",
|
||||
"NoteFolderPicker": "Obs: Mappar som redan är kartlagda kommer inte att visas",
|
||||
"NoteFolderPickerDebian": "Obs: Mappväljaren för Debian-installationen är inte fullständigt implementerad. Du bör ange sökvägen till ditt bibliotek direkt.",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Varning: De flesta podcastappar kräver att RSS-flödets URL används med HTTPS",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Varning: 1 eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa podcastappar kräver detta.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Mappar med mediefiler hanteras som separata biblioteksobjekt.",
|
||||
"NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.",
|
||||
"NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.",
|
||||
"PlaceholderNewCollection": "Nytt samlingsnamn",
|
||||
"PlaceholderNewFolderPath": "Nytt mappväg",
|
||||
"PlaceholderNewPlaylist": "Nytt spellistanamn",
|
||||
"PlaceholderSearch": "Sök...",
|
||||
"PlaceholderSearchEpisode": "Sök avsnitt...",
|
||||
"ToastAccountUpdateFailed": "Det gick inte att uppdatera kontot",
|
||||
"ToastAccountUpdateSuccess": "Kontot uppdaterat",
|
||||
"ToastAuthorImageRemoveFailed": "Det gick inte att ta bort författarens bild",
|
||||
"ToastAuthorImageRemoveSuccess": "Författarens bild borttagen",
|
||||
"ToastAuthorUpdateFailed": "Det gick inte att uppdatera författaren",
|
||||
"ToastAuthorUpdateMerged": "Författaren sammanslagen",
|
||||
"ToastAuthorUpdateSuccess": "Författaren uppdaterad",
|
||||
"ToastAuthorUpdateSuccessNoImageFound": "Författaren uppdaterad (ingen bild hittad)",
|
||||
"ToastBackupCreateFailed": "Det gick inte att skapa en säkerhetskopia",
|
||||
"ToastBackupCreateSuccess": "Säkerhetskopia skapad",
|
||||
"ToastBackupDeleteFailed": "Det gick inte att ta bort säkerhetskopian",
|
||||
"ToastBackupDeleteSuccess": "Säkerhetskopan borttagen",
|
||||
"ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopan",
|
||||
"ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopan",
|
||||
"ToastBackupUploadSuccess": "Säkerhetskopan uppladdad",
|
||||
"ToastBatchUpdateFailed": "Batchuppdateringen misslyckades",
|
||||
"ToastBatchUpdateSuccess": "Batchuppdateringen lyckades",
|
||||
"ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket",
|
||||
"ToastBookmarkCreateSuccess": "Bokmärket tillagt",
|
||||
"ToastBookmarkRemoveFailed": "Det gick inte att ta bort bokmärket",
|
||||
"ToastBookmarkRemoveSuccess": "Bokmärket borttaget",
|
||||
"ToastBookmarkUpdateFailed": "Det gick inte att uppdatera bokmärket",
|
||||
"ToastBookmarkUpdateSuccess": "Bokmärket uppdaterat",
|
||||
"ToastChaptersHaveErrors": "Kapitlen har fel",
|
||||
"ToastChaptersMustHaveTitles": "Kapitel måste ha titlar",
|
||||
"ToastCollectionItemsRemoveFailed": "Det gick inte att ta bort objekt från samlingen",
|
||||
"ToastCollectionItemsRemoveSuccess": "Objekt borttagna från samlingen",
|
||||
"ToastCollectionRemoveFailed": "Det gick inte att ta bort samlingen",
|
||||
"ToastCollectionRemoveSuccess": "Samlingen borttagen",
|
||||
"ToastCollectionUpdateFailed": "Det gick inte att uppdatera samlingen",
|
||||
"ToastCollectionUpdateSuccess": "Samlingen uppdaterad",
|
||||
"ToastItemCoverUpdateFailed": "Det gick inte att uppdatera objektets omslag",
|
||||
"ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat",
|
||||
"ToastItemDetailsUpdateFailed": "Det gick inte att uppdatera objektdetaljerna",
|
||||
"ToastItemDetailsUpdateSuccess": "Objektdetaljer uppdaterade",
|
||||
"ToastItemDetailsUpdateUnneeded": "Inga uppdateringar behövs för objektdetaljerna",
|
||||
"ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera som färdig",
|
||||
"ToastItemMarkedAsFinishedSuccess": "Objekt markerat som färdig",
|
||||
"ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera som ej färdig",
|
||||
"ToastItemMarkedAsNotFinishedSuccess": "Objekt markerat som ej färdig",
|
||||
"ToastLibraryCreateFailed": "Det gick inte att skapa biblioteket",
|
||||
"ToastLibraryCreateSuccess": "Biblioteket \"{0}\" skapat",
|
||||
"ToastLibraryDeleteFailed": "Det gick inte att ta bort biblioteket",
|
||||
"ToastLibraryDeleteSuccess": "Biblioteket borttaget",
|
||||
"ToastLibraryScanFailedToStart": "Misslyckades med att starta skanningen",
|
||||
"ToastLibraryScanStarted": "Skanning av biblioteket påbörjad",
|
||||
"ToastLibraryUpdateFailed": "Det gick inte att uppdatera biblioteket",
|
||||
"ToastLibraryUpdateSuccess": "Biblioteket \"{0}\" uppdaterat",
|
||||
"ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan",
|
||||
"ToastPlaylistCreateSuccess": "Spellistan skapad",
|
||||
"ToastPlaylistRemoveFailed": "Det gick inte att ta bort spellistan",
|
||||
"ToastPlaylistRemoveSuccess": "Spellistan borttagen",
|
||||
"ToastPlaylistUpdateFailed": "Det gick inte att uppdatera spellistan",
|
||||
"ToastPlaylistUpdateSuccess": "Spellistan uppdaterad",
|
||||
"ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten",
|
||||
"ToastPodcastCreateSuccess": "Podcasten skapad framgångsrikt",
|
||||
"ToastRemoveItemFromCollectionFailed": "Misslyckades med att ta bort objektet från samlingen",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen",
|
||||
"ToastRSSFeedCloseFailed": "Misslyckades med att stänga RSS-flödet",
|
||||
"ToastRSSFeedCloseSuccess": "RSS-flödet stängt",
|
||||
"ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten",
|
||||
"ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"",
|
||||
"ToastSeriesUpdateFailed": "Serieuppdateringen misslyckades",
|
||||
"ToastSeriesUpdateSuccess": "Serieuppdateringen lyckades",
|
||||
"ToastSessionDeleteFailed": "Misslyckades med att ta bort sessionen",
|
||||
"ToastSessionDeleteSuccess": "Sessionen borttagen",
|
||||
"ToastSocketConnected": "Socket ansluten",
|
||||
"ToastSocketDisconnected": "Socket frånkopplad",
|
||||
"ToastSocketFailedToConnect": "Socket misslyckades med att ansluta",
|
||||
"ToastUserDeleteFailed": "Misslyckades med att ta bort användaren",
|
||||
"ToastUserDeleteSuccess": "Användaren borttagen"
|
||||
}
|
||||
@@ -92,6 +92,7 @@
|
||||
"HeaderAppriseNotificationSettings": "测试通知设置",
|
||||
"HeaderAudiobookTools": "有声读物文件管理工具",
|
||||
"HeaderAudioTracks": "音轨",
|
||||
"HeaderAuthentication": "Authentication",
|
||||
"HeaderBackups": "备份",
|
||||
"HeaderChangePassword": "更改密码",
|
||||
"HeaderChapters": "章节",
|
||||
@@ -131,8 +132,10 @@
|
||||
"HeaderNewAccount": "新建帐户",
|
||||
"HeaderNewLibrary": "新建媒体库",
|
||||
"HeaderNotifications": "通知",
|
||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||
"HeaderOpenRSSFeed": "打开 RSS 源",
|
||||
"HeaderOtherFiles": "其他文件",
|
||||
"HeaderPasswordAuthentication": "Password Authentication",
|
||||
"HeaderPermissions": "权限",
|
||||
"HeaderPlayerQueue": "播放队列",
|
||||
"HeaderPlaylist": "播放列表",
|
||||
@@ -193,6 +196,10 @@
|
||||
"LabelAuthorLastFirst": "作者 (名, 姓)",
|
||||
"LabelAuthors": "作者",
|
||||
"LabelAutoDownloadEpisodes": "自动下载剧集",
|
||||
"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 +210,7 @@
|
||||
"LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.",
|
||||
"LabelBitrate": "比特率",
|
||||
"LabelBooks": "图书",
|
||||
"LabelButtonText": "Button Text",
|
||||
"LabelChangePassword": "修改密码",
|
||||
"LabelChannels": "声道",
|
||||
"LabelChapters": "章节",
|
||||
@@ -275,6 +283,7 @@
|
||||
"LabelHardDeleteFile": "完全删除文件",
|
||||
"LabelHasEbook": "有电子书",
|
||||
"LabelHasSupplementaryEbook": "有补充电子书",
|
||||
"LabelHighestPriority": "Highest priority",
|
||||
"LabelHost": "主机",
|
||||
"LabelHour": "小时",
|
||||
"LabelIcon": "图标",
|
||||
@@ -316,9 +325,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": "元标签",
|
||||
|
||||
4887
package-lock.json
generated
4887
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"
|
||||
}
|
||||
}
|
||||
647
server/Auth.js
647
server/Auth.js
@@ -1,32 +1,466 @@
|
||||
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
|
||||
}
|
||||
|
||||
// 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' })(req, res, next)
|
||||
} else {
|
||||
return passport.authenticate('openid-client', { failureRedirect: '/login?error=Unauthorized&autoLaunch=0' })(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 +469,79 @@ 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
|
||||
}
|
||||
|
||||
if (token == null) {
|
||||
Logger.error('Api called without a token', req.path)
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
const user = await this.verifyToken(token)
|
||||
if (!user) {
|
||||
Logger.error('Verify Token User Not Found', token)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 || !user.isActive) {
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check passwordless root user
|
||||
if (user.type === 'root' && (!user.pash || user.pash === '')) {
|
||||
if (password) {
|
||||
// deny login
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
// approve login
|
||||
done(null, user)
|
||||
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 +550,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 +567,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 +597,7 @@ class Auth {
|
||||
})
|
||||
}
|
||||
|
||||
// Check password match
|
||||
const compare = await this.comparePassword(password, matchingUser)
|
||||
if (!compare) {
|
||||
return res.json({
|
||||
@@ -208,6 +619,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 +630,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
|
||||
@@ -155,7 +209,7 @@ class Server {
|
||||
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
|
||||
this.rssFeedManager.getFeed(req, res)
|
||||
})
|
||||
router.get('/feed/:slug/cover', (req, res) => {
|
||||
router.get('/feed/:slug/cover*', (req, res) => {
|
||||
this.rssFeedManager.getFeedCover(req, res)
|
||||
})
|
||||
router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
|
||||
@@ -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',
|
||||
@@ -186,8 +243,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 +256,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,
|
||||
|
||||
@@ -119,8 +119,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 +129,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 +249,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 +556,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 +567,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 +590,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)
|
||||
|
||||
@@ -6,7 +6,7 @@ const Audnexus = require('../providers/Audnexus')
|
||||
const FantLab = require('../providers/FantLab')
|
||||
const AudiobookCovers = require('../providers/AudiobookCovers')
|
||||
const Logger = require('../Logger')
|
||||
const { levenshteinDistance } = require('../utils/index')
|
||||
const { levenshteinDistance, escapeRegExp } = require('../utils/index')
|
||||
|
||||
class BookFinder {
|
||||
constructor() {
|
||||
@@ -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 |)${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
|
||||
@@ -127,8 +127,7 @@ class CronManager {
|
||||
}
|
||||
}
|
||||
|
||||
async executePodcastCron(expression, libraryItemIds) {
|
||||
Logger.debug(`[CronManager] Start executing podcast cron ${expression} for ${libraryItemIds.length} item(s)`)
|
||||
async executePodcastCron(expression) {
|
||||
const podcastCron = this.podcastCrons.find(cron => cron.expression === expression)
|
||||
if (!podcastCron) {
|
||||
Logger.error(`[CronManager] Podcast cron not found for expression ${expression}`)
|
||||
@@ -136,6 +135,9 @@ class CronManager {
|
||||
}
|
||||
this.podcastCronExpressionsExecuting.push(expression)
|
||||
|
||||
const libraryItemIds = podcastCron.libraryItemIds
|
||||
Logger.debug(`[CronManager] Start executing podcast cron ${expression} for ${libraryItemIds.length} item(s)`)
|
||||
|
||||
// Get podcast library items to check
|
||||
const libraryItems = []
|
||||
for (const libraryItemId of libraryItemIds) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const Path = require('path')
|
||||
const uuidv4 = require("uuid").v4
|
||||
const FeedMeta = require('./FeedMeta')
|
||||
const FeedEpisode = require('./FeedEpisode')
|
||||
@@ -101,11 +102,13 @@ class Feed {
|
||||
this.serverAddress = serverAddress
|
||||
this.feedUrl = feedUrl
|
||||
|
||||
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
|
||||
|
||||
this.meta = new FeedMeta()
|
||||
this.meta.title = mediaMetadata.title
|
||||
this.meta.description = mediaMetadata.description
|
||||
this.meta.author = author
|
||||
this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png`
|
||||
this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
|
||||
this.meta.feedUrl = feedUrl
|
||||
this.meta.link = `${serverAddress}/item/${libraryItem.id}`
|
||||
this.meta.explicit = !!mediaMetadata.explicit
|
||||
@@ -145,10 +148,12 @@ class Feed {
|
||||
this.entityUpdatedAt = libraryItem.updatedAt
|
||||
this.coverPath = media.coverPath || null
|
||||
|
||||
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
|
||||
|
||||
this.meta.title = mediaMetadata.title
|
||||
this.meta.description = mediaMetadata.description
|
||||
this.meta.author = author
|
||||
this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png`
|
||||
this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
|
||||
this.meta.explicit = !!mediaMetadata.explicit
|
||||
this.meta.type = mediaMetadata.type
|
||||
this.meta.language = mediaMetadata.language
|
||||
@@ -190,11 +195,13 @@ class Feed {
|
||||
this.serverAddress = serverAddress
|
||||
this.feedUrl = feedUrl
|
||||
|
||||
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
|
||||
|
||||
this.meta = new FeedMeta()
|
||||
this.meta.title = collectionExpanded.name
|
||||
this.meta.description = collectionExpanded.description || ''
|
||||
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
|
||||
this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png`
|
||||
this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
|
||||
this.meta.feedUrl = feedUrl
|
||||
this.meta.link = `${serverAddress}/collection/${collectionExpanded.id}`
|
||||
this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit
|
||||
@@ -225,10 +232,12 @@ class Feed {
|
||||
this.entityUpdatedAt = collectionExpanded.lastUpdate
|
||||
this.coverPath = firstItemWithCover?.coverPath || null
|
||||
|
||||
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
|
||||
|
||||
this.meta.title = collectionExpanded.name
|
||||
this.meta.description = collectionExpanded.description || ''
|
||||
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
|
||||
this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png`
|
||||
this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
|
||||
this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit
|
||||
|
||||
this.episodes = []
|
||||
@@ -267,11 +276,13 @@ class Feed {
|
||||
this.serverAddress = serverAddress
|
||||
this.feedUrl = feedUrl
|
||||
|
||||
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
|
||||
|
||||
this.meta = new FeedMeta()
|
||||
this.meta.title = seriesExpanded.name
|
||||
this.meta.description = seriesExpanded.description || ''
|
||||
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
|
||||
this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png`
|
||||
this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png`
|
||||
this.meta.feedUrl = feedUrl
|
||||
this.meta.link = `${serverAddress}/library/${libraryId}/series/${seriesExpanded.id}`
|
||||
this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit
|
||||
@@ -305,10 +316,12 @@ class Feed {
|
||||
this.entityUpdatedAt = seriesExpanded.updatedAt
|
||||
this.coverPath = firstItemWithCover?.coverPath || null
|
||||
|
||||
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
|
||||
|
||||
this.meta.title = seriesExpanded.name
|
||||
this.meta.description = seriesExpanded.description || ''
|
||||
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
|
||||
this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png`
|
||||
this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png`
|
||||
this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit
|
||||
|
||||
this.episodes = []
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const Path = require('path')
|
||||
const uuidv4 = require("uuid").v4
|
||||
const date = require('../libs/dateAndTime')
|
||||
const { secondsToTimestamp } = require('../utils/index')
|
||||
@@ -69,7 +70,8 @@ class FeedEpisode {
|
||||
}
|
||||
|
||||
setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, meta) {
|
||||
const contentUrl = `/feed/${slug}/item/${episode.id}/${episode.audioFile.metadata.filename}`
|
||||
const contentFileExtension = Path.extname(episode.audioFile.metadata.filename)
|
||||
const contentUrl = `/feed/${slug}/item/${episode.id}/media${contentFileExtension}`
|
||||
const media = libraryItem.media
|
||||
const mediaMetadata = media.metadata
|
||||
|
||||
@@ -108,7 +110,8 @@ class FeedEpisode {
|
||||
// e.g. Track 1 will have a pub date before Track 2
|
||||
const audiobookPubDate = date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
|
||||
|
||||
const contentUrl = `/feed/${slug}/item/${episodeId}/${audioTrack.metadata.filename}`
|
||||
const contentFileExtension = Path.extname(audioTrack.metadata.filename)
|
||||
const contentUrl = `/feed/${slug}/item/${episodeId}/media${contentFileExtension}`
|
||||
const media = libraryItem.media
|
||||
const mediaMetadata = media.metadata
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -463,7 +463,7 @@ class LibraryScanner {
|
||||
// Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item
|
||||
const updateGroup = { ...fileUpdateGroup }
|
||||
for (const itemDir in updateGroup) {
|
||||
if (itemDir == fileUpdateGroup[itemDir]) continue // Media in root path
|
||||
if (isSingleMediaFile(fileUpdateGroup, itemDir)) continue // Media in root path
|
||||
|
||||
const itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/'))
|
||||
if (!itemDirNestedFiles.length) continue
|
||||
@@ -559,7 +559,7 @@ class LibraryScanner {
|
||||
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" - scan for updates`)
|
||||
itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, renamedPaths)
|
||||
continue
|
||||
} else if (library.settings.audiobooksOnly && !fileUpdateGroup[itemDir].some?.(scanUtils.checkFilepathIsAudioFile)) {
|
||||
} else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) {
|
||||
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" has no audio files`)
|
||||
continue
|
||||
}
|
||||
@@ -580,7 +580,7 @@ class LibraryScanner {
|
||||
}
|
||||
|
||||
Logger.debug(`[LibraryScanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`)
|
||||
const isSingleMediaItem = itemDir === fileUpdateGroup[itemDir]
|
||||
const isSingleMediaItem = isSingleMediaFile(fileUpdateGroup, itemDir)
|
||||
const newLibraryItem = await LibraryItemScanner.scanPotentialNewLibraryItem(fullPath, library, folder, isSingleMediaItem)
|
||||
if (newLibraryItem) {
|
||||
const oldNewLibraryItem = Database.libraryItemModel.getOldLibraryItem(newLibraryItem)
|
||||
@@ -592,4 +592,14 @@ class LibraryScanner {
|
||||
return itemGroupingResults
|
||||
}
|
||||
}
|
||||
module.exports = new LibraryScanner()
|
||||
module.exports = new LibraryScanner()
|
||||
|
||||
function hasAudioFiles(fileUpdateGroup, itemDir) {
|
||||
return isSingleMediaFile(fileUpdateGroup, itemDir) ?
|
||||
scanUtils.checkFilepathIsAudioFile(fileUpdateGroup[itemDir]) :
|
||||
fileUpdateGroup[itemDir].some(scanUtils.checkFilepathIsAudioFile)
|
||||
}
|
||||
|
||||
function isSingleMediaFile(fileUpdateGroup, itemDir) {
|
||||
return itemDir === fileUpdateGroup[itemDir]
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -192,4 +192,16 @@ module.exports.asciiOnlyToLowerCase = (str) => {
|
||||
}
|
||||
}
|
||||
return temp
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape string used in RegExp
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
|
||||
*
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
module.exports.escapeRegExp = (str) => {
|
||||
if (typeof str !== 'string') return ''
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user