mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-04 01:40:40 +02:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2db81bf7d | |||
| b376f89ce5 | |||
| 5633113f25 | |||
| 669415cfbf | |||
| 9f366863a9 | |||
| 0d644fe0c9 | |||
| 72fa6b8200 | |||
| 6d3f1d263a | |||
| 47bf9f7836 | |||
| 2738402aac | |||
| 68d36522b1 | |||
| 24a587b944 | |||
| 76119445a3 | |||
| 46ec59c74e | |||
| 2b7122c744 | |||
| 52f0a5432b | |||
| 7391b4d0ec | |||
| aa7ee3e8ff | |||
| bef0f3709f | |||
| f33b011847 | |||
| 2d8d11d4da | |||
| 10b1784f6d | |||
| f2f2ea161c | |||
| dc67a52000 | |||
| 05820aa820 | |||
| 8966dbbcd1 | |||
| cf32819c01 | |||
| 728496010c | |||
| 0a08f47942 | |||
| 39ceb02500 | |||
| 4336714248 | |||
| 1d41904fc3 | |||
| fae383a045 | |||
| 9720ba3eed | |||
| d3256d59d5 | |||
| fa5f7ab7a5 | |||
| 6f26fd7238 | |||
| 6abc0819d9 | |||
| b580a23e7e | |||
| f659c3f11c | |||
| 0282a0521b | |||
| 75637e4b94 | |||
| b6c789dee6 | |||
| 8d3d636329 | |||
| b8c8d2a02e | |||
| 98104a3c03 | |||
| 8f4c65ec8c | |||
| 341a0452da | |||
| b5e255a384 | |||
| 34156af403 | |||
| 7c9c278cc4 | |||
| 450507a812 | |||
| cf00650c6d | |||
| e6ab28365f | |||
| 80fd2a1a18 | |||
| 84160b2f07 | |||
| fbc2c2b481 | |||
| 57a5005197 | |||
| 9350c5513e | |||
| f59516cc6e | |||
| 88078ff813 | |||
| 281de48ed4 | |||
| 3c6d6bf688 | |||
| 8ac0ce399f | |||
| 80458e24bd | |||
| 6ab966ee2f | |||
| 166477ae27 | |||
| a719065b8d | |||
| 36599a2984 | |||
| d9c9289d65 | |||
| e5579b2c33 | |||
| 618028503b | |||
| 2f6756eddf | |||
| ad53894ea1 | |||
| 8c434703fb | |||
| 3cc900ffbf | |||
| 6d968f9044 | |||
| 23fa9e8d7f | |||
| 59a428d549 |
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
||||||
<!-- Cover size widget -->
|
<!-- Cover size widget -->
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
|
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
|
||||||
|
|
||||||
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||||
<p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
<p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||||
@@ -94,6 +94,9 @@ export default {
|
|||||||
},
|
},
|
||||||
selectedMediaItems() {
|
selectedMediaItems() {
|
||||||
return this.$store.state.globals.selectedMediaItems || []
|
return this.$store.state.globals.selectedMediaItems || []
|
||||||
|
},
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
|
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -205,6 +205,9 @@ export default {
|
|||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||||
return this.entityWidth / baseSize
|
return this.entityWidth / baseSize
|
||||||
|
},
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -15,24 +15,33 @@
|
|||||||
|
|
||||||
<div class="flex my-2 -mx-2">
|
<div class="flex my-2 -mx-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
|
<ui-text-input-with-label v-model.trim="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
|
<div v-if="!isPodcast" class="flex items-end">
|
||||||
|
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
|
||||||
|
<ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp">
|
||||||
|
<div
|
||||||
|
class="ml-2 mb-1 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer"
|
||||||
|
@click="fetchMetadata">
|
||||||
|
<span class="text-base text-white text-opacity-80 font-mono material-icons">sync</span>
|
||||||
|
</div>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
|
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
|
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isPodcast" class="flex my-2 -mx-2">
|
<div v-if="!isPodcast" class="flex my-2 -mx-2">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" />
|
<ui-text-input-with-label v-model.trim="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" inputClass="h-10" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
|
<label class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></label>
|
||||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" />
|
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs h-10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,8 +57,8 @@
|
|||||||
<p class="text-base">{{ $strings.MessageUploaderItemFailed }}</p>
|
<p class="text-base">{{ $strings.MessageUploaderItemFailed }}</p>
|
||||||
</widgets-alert>
|
</widgets-alert>
|
||||||
|
|
||||||
<div v-if="isUploading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
|
<div v-if="isNonInteractable" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||||
<ui-loading-indicator :text="$strings.MessageUploading" />
|
<ui-loading-indicator :text="nonInteractionLabel" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,10 +70,11 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
item: {
|
item: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => { }
|
||||||
},
|
},
|
||||||
mediaType: String,
|
mediaType: String,
|
||||||
processing: Boolean
|
processing: Boolean,
|
||||||
|
provider: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -76,7 +86,8 @@ export default {
|
|||||||
error: '',
|
error: '',
|
||||||
isUploading: false,
|
isUploading: false,
|
||||||
uploadFailed: false,
|
uploadFailed: false,
|
||||||
uploadSuccess: false
|
uploadSuccess: false,
|
||||||
|
isFetchingMetadata: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -87,12 +98,19 @@ export default {
|
|||||||
if (!this.itemData.title) return ''
|
if (!this.itemData.title) return ''
|
||||||
if (this.isPodcast) return this.itemData.title
|
if (this.isPodcast) return this.itemData.title
|
||||||
|
|
||||||
if (this.itemData.series && this.itemData.author) {
|
const outputPathParts = [this.itemData.author, this.itemData.series, this.itemData.title]
|
||||||
return Path.join(this.itemData.author, this.itemData.series, this.itemData.title)
|
const cleanedOutputPathParts = outputPathParts.filter(Boolean).map(part => this.$sanitizeFilename(part))
|
||||||
} else if (this.itemData.author) {
|
|
||||||
return Path.join(this.itemData.author, this.itemData.title)
|
return Path.join(...cleanedOutputPathParts)
|
||||||
} else {
|
},
|
||||||
return this.itemData.title
|
isNonInteractable() {
|
||||||
|
return this.isUploading || this.isFetchingMetadata
|
||||||
|
},
|
||||||
|
nonInteractionLabel() {
|
||||||
|
if (this.isUploading) {
|
||||||
|
return this.$strings.MessageUploading
|
||||||
|
} else if (this.isFetchingMetadata) {
|
||||||
|
return this.$strings.LabelFetchingMetadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -105,9 +123,42 @@ export default {
|
|||||||
titleUpdated() {
|
titleUpdated() {
|
||||||
this.error = ''
|
this.error = ''
|
||||||
},
|
},
|
||||||
|
async fetchMetadata() {
|
||||||
|
if (!this.itemData.title.trim().length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isFetchingMetadata = true
|
||||||
|
this.error = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const searchQueryString = new URLSearchParams({
|
||||||
|
title: this.itemData.title,
|
||||||
|
author: this.itemData.author,
|
||||||
|
provider: this.provider
|
||||||
|
})
|
||||||
|
const [bestCandidate, ..._rest] = await this.$axios.$get(`/api/search/books?${searchQueryString}`)
|
||||||
|
|
||||||
|
if (bestCandidate) {
|
||||||
|
this.itemData = {
|
||||||
|
...this.itemData,
|
||||||
|
title: bestCandidate.title,
|
||||||
|
author: bestCandidate.author,
|
||||||
|
series: (bestCandidate.series || [])[0]?.series
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.error = this.$strings.ErrorUploadFetchMetadataNoResults
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed', e)
|
||||||
|
this.error = this.$strings.ErrorUploadFetchMetadataAPI
|
||||||
|
} finally {
|
||||||
|
this.isFetchingMetadata = false
|
||||||
|
}
|
||||||
|
},
|
||||||
getData() {
|
getData() {
|
||||||
if (!this.itemData.title) {
|
if (!this.itemData.title) {
|
||||||
this.error = 'Must have a title'
|
this.error = this.$strings.ErrorUploadLacksTitle
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
this.error = ''
|
this.error = ''
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ export default {
|
|||||||
if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}`
|
if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}`
|
||||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}`
|
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}`
|
||||||
if (this.searchAuthor) searchQuery += `&author=${encodeURIComponent(this.searchAuthor)}`
|
if (this.searchAuthor) searchQuery += `&author=${encodeURIComponent(this.searchAuthor)}`
|
||||||
|
if (this.libraryItemId) searchQuery += `&id=${this.libraryItemId}`
|
||||||
return searchQuery
|
return searchQuery
|
||||||
},
|
},
|
||||||
submitSearch() {
|
submitSearch() {
|
||||||
|
|||||||
@@ -14,34 +14,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" :class="comicMetadata ? 'left-32' : 'left-20'">
|
<div v-if="numPages" class="absolute top-0 left-4 sm:left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowPageMenu">
|
||||||
<span class="material-icons text-xl">download</span>
|
|
||||||
</a>
|
|
||||||
<div v-if="comicMetadata" class="absolute top-0 left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
|
|
||||||
<span class="material-icons text-xl">more</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="numPages" class="absolute top-0 left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowPageMenu">
|
|
||||||
<span class="material-icons text-xl">menu</span>
|
<span class="material-icons text-xl">menu</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="numPages" class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
|
<div v-if="comicMetadata" class="absolute top-0 left-16 sm:left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
|
||||||
|
<span class="material-icons text-xl">more</span>
|
||||||
|
</div>
|
||||||
|
<a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" :class="comicMetadata ? 'left-28 sm:left-32' : 'left-16 sm:left-20'">
|
||||||
|
<span class="material-icons text-xl">download</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div v-if="numPages" class="absolute top-0 right-14 sm:right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
|
||||||
<p class="font-mono">{{ page }} / {{ numPages }}</p>
|
<p class="font-mono">{{ page }} / {{ numPages }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="mainImg" class="absolute top-0 right-36 sm:right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
|
||||||
|
<ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" />
|
||||||
|
<ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="overflow-hidden w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
<div v-show="canGoPrev" ref="prevButton" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
||||||
<div class="flex items-center justify-center h-full w-1/2">
|
<div class="flex items-center justify-center h-full w-1/2">
|
||||||
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
|
<div v-show="canGoNext" ref="nextButton" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
|
||||||
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
|
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
|
||||||
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
|
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-full flex justify-center">
|
<div ref="imageContainer" class="w-full h-full relative overflow-auto">
|
||||||
<img v-if="mainImg" :src="mainImg" class="object-contain h-full m-auto" />
|
<div class="h-full flex" :class="scale > 100 ? '' : 'justify-center'">
|
||||||
|
<img v-if="mainImg" :style="{ minWidth: scale + '%', width: scale + '%' }" :src="mainImg" class="object-contain m-auto" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
|
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
|
||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
</div>
|
</div>
|
||||||
@@ -54,6 +60,10 @@ import Path from 'path'
|
|||||||
import { Archive } from 'libarchive.js/main.js'
|
import { Archive } from 'libarchive.js/main.js'
|
||||||
import { CompressedFile } from 'libarchive.js/src/compressed-file'
|
import { CompressedFile } from 'libarchive.js/src/compressed-file'
|
||||||
|
|
||||||
|
// This is % with respect to the screen width
|
||||||
|
const MAX_SCALE = 400
|
||||||
|
const MIN_SCALE = 10
|
||||||
|
|
||||||
Archive.init({
|
Archive.init({
|
||||||
workerUrl: '/libarchive/worker-bundle.js'
|
workerUrl: '/libarchive/worker-bundle.js'
|
||||||
})
|
})
|
||||||
@@ -81,7 +91,8 @@ export default {
|
|||||||
showInfoMenu: false,
|
showInfoMenu: false,
|
||||||
loadTimeout: null,
|
loadTimeout: null,
|
||||||
loadedFirstPage: false,
|
loadedFirstPage: false,
|
||||||
comicMetadata: null
|
comicMetadata: null,
|
||||||
|
scale: 80
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -136,6 +147,12 @@ export default {
|
|||||||
return p
|
return p
|
||||||
}) || []
|
}) || []
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
canScaleUp() {
|
||||||
|
return this.scale < MAX_SCALE
|
||||||
|
},
|
||||||
|
canScaleDown() {
|
||||||
|
return this.scale > MIN_SCALE
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -331,10 +348,37 @@ export default {
|
|||||||
orderedImages = orderedImages.concat(noNumImages.map((i) => i.filename))
|
orderedImages = orderedImages.concat(noNumImages.map((i) => i.filename))
|
||||||
|
|
||||||
this.pages = orderedImages
|
this.pages = orderedImages
|
||||||
|
},
|
||||||
|
zoomIn() {
|
||||||
|
this.scale += 10
|
||||||
|
},
|
||||||
|
zoomOut() {
|
||||||
|
this.scale -= 10
|
||||||
|
},
|
||||||
|
scroll(event) {
|
||||||
|
const imageContainer = this.$refs.imageContainer
|
||||||
|
|
||||||
|
imageContainer.scrollBy({
|
||||||
|
top: event.deltaY,
|
||||||
|
left: event.deltaX,
|
||||||
|
behavior: 'auto'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {},
|
mounted() {
|
||||||
beforeDestroy() {}
|
const prevButton = this.$refs.prevButton
|
||||||
|
const nextButton = this.$refs.nextButton
|
||||||
|
|
||||||
|
prevButton.addEventListener('wheel', this.scroll, { passive: false })
|
||||||
|
nextButton.addEventListener('wheel', this.scroll, { passive: false })
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
const prevButton = this.$refs.prevButton
|
||||||
|
const nextButton = this.$refs.nextButton
|
||||||
|
|
||||||
|
prevButton.removeEventListener('wheel', this.scroll, { passive: false })
|
||||||
|
nextButton.removeEventListener('wheel', this.scroll, { passive: false })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||||
|
<widgets-loading-spinner />
|
||||||
|
</div>
|
||||||
|
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
variant: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
year: Number,
|
||||||
|
processing: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
canvas: null,
|
||||||
|
dataUrl: null,
|
||||||
|
yearStats: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
variant() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async initCanvas() {
|
||||||
|
if (!this.yearStats) return
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 800
|
||||||
|
canvas.height = 800
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
const createRoundedRect = (x, y, w, h) => {
|
||||||
|
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||||
|
grd1.addColorStop(0, '#44444455')
|
||||||
|
grd1.addColorStop(1, '#ffffff11')
|
||||||
|
ctx.fillStyle = grd1
|
||||||
|
ctx.strokeStyle = '#C0C0C088'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.roundRect(x, y, w, h, [20])
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||||
|
ctx.letterSpacing = letterSpacing
|
||||||
|
|
||||||
|
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||||
|
if (maxWidth) {
|
||||||
|
let txtWidth = ctx.measureText(text).width
|
||||||
|
while (txtWidth > maxWidth) {
|
||||||
|
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||||
|
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||||
|
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||||
|
text += '...'
|
||||||
|
txtWidth = ctx.measureText(text).width
|
||||||
|
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillText(text, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addIcon = (icon, color, fontSize, x, y) => {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = `${fontSize} Material Icons Outlined`
|
||||||
|
ctx.fillText(icon, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bg color
|
||||||
|
ctx.fillStyle = '#232323'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Cover image tiles
|
||||||
|
const bookCovers = this.yearStats.finishedBooksWithCovers
|
||||||
|
bookCovers.push(...this.yearStats.booksWithCovers)
|
||||||
|
|
||||||
|
let finishedBookCoverImgs = {}
|
||||||
|
|
||||||
|
if (bookCovers.length) {
|
||||||
|
let index = 0
|
||||||
|
ctx.globalAlpha = 0.25
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||||
|
ctx.rotate((-Math.PI / 180) * 25)
|
||||||
|
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||||
|
ctx.translate(-130, -120)
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
const coverIndex = index % bookCovers.length
|
||||||
|
let libraryItemId = bookCovers[coverIndex]
|
||||||
|
index++
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
img.addEventListener('load', () => {
|
||||||
|
let sw = img.width
|
||||||
|
if (img.width > img.height) {
|
||||||
|
sw = img.height
|
||||||
|
}
|
||||||
|
let sx = -(sw - img.width) / 2
|
||||||
|
let sy = -(sw - img.height) / 2
|
||||||
|
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
|
||||||
|
resolve()
|
||||||
|
if (this.yearStats.finishedBooksWithCovers.includes(libraryItemId) && !finishedBookCoverImgs[libraryItemId]) {
|
||||||
|
finishedBookCoverImgs[libraryItemId] = {
|
||||||
|
img,
|
||||||
|
sx,
|
||||||
|
sy,
|
||||||
|
sw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
img.addEventListener('error', () => {
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
|
||||||
|
// Create gradient
|
||||||
|
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||||
|
grd1.addColorStop(0, '#000000aa')
|
||||||
|
grd1.addColorStop(1, '#cd9d49aa')
|
||||||
|
ctx.fillStyle = grd1
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Top Abs icon
|
||||||
|
let tanColor = '#ffdb70'
|
||||||
|
ctx.fillStyle = tanColor
|
||||||
|
ctx.font = '42px absicons'
|
||||||
|
ctx.fillText('\ue900', 15, 36)
|
||||||
|
|
||||||
|
// Top text
|
||||||
|
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||||
|
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||||
|
|
||||||
|
// Top left box
|
||||||
|
createRoundedRect(50, 100, 340, 160)
|
||||||
|
addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165)
|
||||||
|
addText('books finished', '28px', 'normal', tanColor, '0px', 160, 210)
|
||||||
|
const readIconPath = new Path2D()
|
||||||
|
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 2, d: 2, e: 100, f: 160 })
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fill(readIconPath)
|
||||||
|
|
||||||
|
// Box top right
|
||||||
|
createRoundedRect(410, 100, 340, 160)
|
||||||
|
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
|
||||||
|
addText('spent listening', '28px', 'normal', tanColor, '0px', 500, 205)
|
||||||
|
addIcon('watch_later', 'white', '52px', 440, 180)
|
||||||
|
|
||||||
|
// Box bottom left
|
||||||
|
createRoundedRect(50, 280, 340, 160)
|
||||||
|
addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345)
|
||||||
|
addText('sessions', '28px', 'normal', tanColor, '1px', 160, 390)
|
||||||
|
addIcon('headphones', 'white', '52px', 95, 360)
|
||||||
|
|
||||||
|
// Box bottom right
|
||||||
|
createRoundedRect(410, 280, 340, 160)
|
||||||
|
addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
|
||||||
|
addText('books listened to', '28px', 'normal', tanColor, '0px', 500, 390)
|
||||||
|
addIcon('local_library', 'white', '52px', 440, 360)
|
||||||
|
|
||||||
|
if (!this.variant) {
|
||||||
|
// Text stats
|
||||||
|
const topNarrator = this.yearStats.mostListenedNarrator
|
||||||
|
if (topNarrator) {
|
||||||
|
addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520)
|
||||||
|
addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)
|
||||||
|
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)
|
||||||
|
}
|
||||||
|
|
||||||
|
const topGenre = this.yearStats.topGenres[0]
|
||||||
|
if (topGenre) {
|
||||||
|
addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520)
|
||||||
|
addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)
|
||||||
|
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)
|
||||||
|
}
|
||||||
|
|
||||||
|
const topAuthor = this.yearStats.topAuthors[0]
|
||||||
|
if (topAuthor) {
|
||||||
|
addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670)
|
||||||
|
addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)
|
||||||
|
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.yearStats.mostListenedMonth?.time) {
|
||||||
|
const jsdate = new Date(this.year, this.yearStats.mostListenedMonth.month, 1)
|
||||||
|
const monthName = this.$formatJsDate(jsdate, 'LLLL')
|
||||||
|
addText('TOP MONTH', '24px', 'normal', tanColor, '1px', 430, 670)
|
||||||
|
addText(monthName, '36px', 'bolder', 'white', '0px', 430, 714, 330)
|
||||||
|
addText(this.$elapsedPrettyExtended(this.yearStats.mostListenedMonth.time, true, false), '24px', 'lighter', 'white', '1px', 430, 749)
|
||||||
|
}
|
||||||
|
} else if (this.variant === 1) {
|
||||||
|
// Bottom images
|
||||||
|
finishedBookCoverImgs = Object.values(finishedBookCoverImgs)
|
||||||
|
if (finishedBookCoverImgs.length > 0) {
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
addText('Some books finished this year...', '28px', 'normal', tanColor, '0px', canvas.width / 2, 530)
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(5, finishedBookCoverImgs.length); i++) {
|
||||||
|
let imgToAdd = finishedBookCoverImgs[i]
|
||||||
|
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 570, 140, 140)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.variant === 2) {
|
||||||
|
// Text stats
|
||||||
|
if (this.yearStats.topAuthors.length) {
|
||||||
|
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 524)
|
||||||
|
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||||
|
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 584 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.yearStats.topGenres.length) {
|
||||||
|
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 524)
|
||||||
|
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
|
||||||
|
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 584 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas = canvas
|
||||||
|
this.dataUrl = canvas.toDataURL('png')
|
||||||
|
},
|
||||||
|
refresh() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
share() {
|
||||||
|
this.canvas.toBlob((blob) => {
|
||||||
|
const file = new File([blob], 'yearinreview.png', { type: blob.type })
|
||||||
|
const shareData = {
|
||||||
|
files: [file]
|
||||||
|
}
|
||||||
|
if (navigator.canShare(shareData)) {
|
||||||
|
navigator
|
||||||
|
.share(shareData)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Share success')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to share', error)
|
||||||
|
if (error.name !== 'AbortError') {
|
||||||
|
this.$toast.error('Failed to share: ' + error.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Cannot share natively on this device')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async init() {
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
|
||||||
|
console.error('Failed to load stats for year', err)
|
||||||
|
this.$toast.error('Failed to load year stats')
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
await this.initCanvas()
|
||||||
|
this.$emit('update:processing', false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-1 sm:p-4 mb-4">
|
||||||
|
<!-- hack to get icon fonts loaded on init -->
|
||||||
|
<div class="h-0 w-0 overflow-hidden opacity-0">
|
||||||
|
<span class="material-icons-outlined">close</span>
|
||||||
|
<span class="abs-icons icon-audiobookshelf" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="hidden md:block text-xl font-semibold">{{ yearInReviewYear }} Year in Review</p>
|
||||||
|
<div class="hidden md:block flex-grow" />
|
||||||
|
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Hide Year in Review' : 'See Year in Review' }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- your year in review -->
|
||||||
|
<div v-if="showYearInReview">
|
||||||
|
<div class="w-full h-px bg-slate-200/10 my-4" />
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
|
||||||
|
<!-- previous button -->
|
||||||
|
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
||||||
|
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||||
|
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||||
|
</ui-btn>
|
||||||
|
<!-- share button -->
|
||||||
|
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview"> Share </ui-btn>
|
||||||
|
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="hidden sm:block text-lg font-semibold">Your Year in Review ({{ yearInReviewVariant + 1 }})</p>
|
||||||
|
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<!-- refresh button -->
|
||||||
|
<ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview">
|
||||||
|
<span class="hidden sm:inline-block">Refresh</span>
|
||||||
|
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||||
|
</ui-btn>
|
||||||
|
<!-- next button -->
|
||||||
|
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
|
||||||
|
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||||
|
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||||
|
</ui-btn>
|
||||||
|
</div>
|
||||||
|
<stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" />
|
||||||
|
|
||||||
|
<!-- your year in review short -->
|
||||||
|
<div class="w-full max-w-[800px] mx-auto my-4">
|
||||||
|
<!-- share button -->
|
||||||
|
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort"> Share </ui-btn>
|
||||||
|
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- your server in review -->
|
||||||
|
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
|
||||||
|
<div class="flex items-center justify-center mb-2">
|
||||||
|
<!-- previous button -->
|
||||||
|
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
||||||
|
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||||
|
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||||
|
</ui-btn>
|
||||||
|
<!-- share button -->
|
||||||
|
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer"> Share </ui-btn>
|
||||||
|
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="hidden sm:block text-lg font-semibold">Server Year in Review ({{ yearInReviewServerVariant + 1 }})</p>
|
||||||
|
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<!-- refresh button -->
|
||||||
|
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer">
|
||||||
|
<span class="hidden sm:inline-block">Refresh</span>
|
||||||
|
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||||
|
</ui-btn>
|
||||||
|
<!-- next button -->
|
||||||
|
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
|
||||||
|
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||||
|
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||||
|
</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewServer" :year="yearInReviewYear" :variant="yearInReviewServerVariant" :processing.sync="processingYearInReviewServer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showYearInReview: false,
|
||||||
|
yearInReviewYear: 0,
|
||||||
|
yearInReviewVariant: 0,
|
||||||
|
yearInReviewServerVariant: 0,
|
||||||
|
processingYearInReview: false,
|
||||||
|
processingYearInReviewShort: false,
|
||||||
|
processingYearInReviewServer: false,
|
||||||
|
showShareButton: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
shareYearInReviewServer() {
|
||||||
|
this.$refs.yearInReviewServer.share()
|
||||||
|
},
|
||||||
|
shareYearInReview() {
|
||||||
|
this.$refs.yearInReview.share()
|
||||||
|
},
|
||||||
|
shareYearInReviewShort() {
|
||||||
|
this.$refs.yearInReviewShort.share()
|
||||||
|
},
|
||||||
|
refreshYearInReviewServer() {
|
||||||
|
this.$refs.yearInReviewServer.refresh()
|
||||||
|
},
|
||||||
|
refreshYearInReview() {
|
||||||
|
this.$refs.yearInReview.refresh()
|
||||||
|
this.$refs.yearInReviewShort.refresh()
|
||||||
|
},
|
||||||
|
clickShowYearInReview() {
|
||||||
|
this.showYearInReview = !this.showYearInReview
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeMount() {
|
||||||
|
this.yearInReviewYear = new Date().getFullYear()
|
||||||
|
// When not December show previous year
|
||||||
|
if (new Date().getMonth() < 11) {
|
||||||
|
this.yearInReviewYear--
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (typeof navigator.share !== 'undefined' && navigator.share) {
|
||||||
|
this.showShareButton = true
|
||||||
|
} else {
|
||||||
|
console.warn('Navigator.share not supported')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||||
|
<widgets-loading-spinner />
|
||||||
|
</div>
|
||||||
|
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
variant: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
processing: Boolean,
|
||||||
|
year: Number
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
canvas: null,
|
||||||
|
dataUrl: null,
|
||||||
|
yearStats: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
variant() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async initCanvas() {
|
||||||
|
if (!this.yearStats) return
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 800
|
||||||
|
canvas.height = 800
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
const createRoundedRect = (x, y, w, h) => {
|
||||||
|
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||||
|
grd1.addColorStop(0, '#44444455')
|
||||||
|
grd1.addColorStop(1, '#ffffff11')
|
||||||
|
ctx.fillStyle = grd1
|
||||||
|
ctx.strokeStyle = '#C0C0C088'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.roundRect(x, y, w, h, [20])
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||||
|
ctx.letterSpacing = letterSpacing
|
||||||
|
|
||||||
|
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||||
|
if (maxWidth) {
|
||||||
|
let txtWidth = ctx.measureText(text).width
|
||||||
|
while (txtWidth > maxWidth) {
|
||||||
|
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||||
|
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||||
|
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||||
|
text += '...'
|
||||||
|
txtWidth = ctx.measureText(text).width
|
||||||
|
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillText(text, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bg color
|
||||||
|
ctx.fillStyle = '#232323'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Cover image tiles
|
||||||
|
let imgsToAdd = {}
|
||||||
|
|
||||||
|
if (this.yearStats.booksAddedWithCovers.length) {
|
||||||
|
let index = 0
|
||||||
|
ctx.globalAlpha = 0.25
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||||
|
ctx.rotate((-Math.PI / 180) * 25)
|
||||||
|
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||||
|
ctx.translate(-130, -120)
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
const coverIndex = index % this.yearStats.booksAddedWithCovers.length
|
||||||
|
let libraryItemId = this.yearStats.booksAddedWithCovers[coverIndex]
|
||||||
|
index++
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
img.addEventListener('load', () => {
|
||||||
|
let sw = img.width
|
||||||
|
if (img.width > img.height) {
|
||||||
|
sw = img.height
|
||||||
|
}
|
||||||
|
let sx = -(sw - img.width) / 2
|
||||||
|
let sy = -(sw - img.height) / 2
|
||||||
|
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
|
||||||
|
if (!imgsToAdd[libraryItemId]) {
|
||||||
|
imgsToAdd[libraryItemId] = {
|
||||||
|
img,
|
||||||
|
sx,
|
||||||
|
sy,
|
||||||
|
sw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
img.addEventListener('error', () => {
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
|
||||||
|
// Create gradient
|
||||||
|
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||||
|
grd1.addColorStop(0, '#000000aa')
|
||||||
|
grd1.addColorStop(1, '#cd9d49aa')
|
||||||
|
ctx.fillStyle = grd1
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Top Abs icon
|
||||||
|
let tanColor = '#ffdb70'
|
||||||
|
ctx.fillStyle = tanColor
|
||||||
|
ctx.font = '42px absicons'
|
||||||
|
ctx.fillText('\ue900', 15, 36)
|
||||||
|
|
||||||
|
// Top text
|
||||||
|
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||||
|
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||||
|
|
||||||
|
// Top left box
|
||||||
|
createRoundedRect(40, 100, 230, 100)
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140)
|
||||||
|
addText('books added', '18px', 'normal', tanColor, '0px', 155, 170)
|
||||||
|
|
||||||
|
// Box top right
|
||||||
|
createRoundedRect(285, 100, 230, 100)
|
||||||
|
addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140)
|
||||||
|
addText('authors added', '18px', 'normal', tanColor, '0px', 400, 170)
|
||||||
|
|
||||||
|
// Box bottom left
|
||||||
|
createRoundedRect(530, 100, 230, 100)
|
||||||
|
addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140)
|
||||||
|
addText('sessions', '18px', 'normal', tanColor, '1px', 645, 170)
|
||||||
|
|
||||||
|
// Text stats
|
||||||
|
if (this.yearStats.totalBooksAddedSize) {
|
||||||
|
addText('Your book collection grew to...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 260)
|
||||||
|
addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300)
|
||||||
|
addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.yearStats.totalBooksAddedDuration) {
|
||||||
|
addText('With a total duration of...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 400)
|
||||||
|
addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440)
|
||||||
|
addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.variant) {
|
||||||
|
// Bottom images
|
||||||
|
imgsToAdd = Object.values(imgsToAdd)
|
||||||
|
if (imgsToAdd.length > 0) {
|
||||||
|
addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) {
|
||||||
|
let imgToAdd = imgsToAdd[i]
|
||||||
|
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.variant === 1) {
|
||||||
|
// Text stats
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
if (this.yearStats.topAuthors.length) {
|
||||||
|
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
|
||||||
|
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||||
|
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.yearStats.topNarrators.length) {
|
||||||
|
addText('TOP NARRATORS', '24px', 'normal', tanColor, '1px', 430, 549)
|
||||||
|
for (let i = 0; i < this.yearStats.topNarrators.length; i++) {
|
||||||
|
addText(this.yearStats.topNarrators[i].name, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.variant === 2) {
|
||||||
|
// Text stats
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
if (this.yearStats.topAuthors.length) {
|
||||||
|
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
|
||||||
|
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||||
|
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.yearStats.topGenres.length) {
|
||||||
|
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 549)
|
||||||
|
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
|
||||||
|
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas = canvas
|
||||||
|
this.dataUrl = canvas.toDataURL('png')
|
||||||
|
},
|
||||||
|
share() {
|
||||||
|
this.canvas.toBlob((blob) => {
|
||||||
|
const file = new File([blob], 'yearinreviewserver.png', { type: blob.type })
|
||||||
|
const shareData = {
|
||||||
|
files: [file]
|
||||||
|
}
|
||||||
|
if (navigator.canShare(shareData)) {
|
||||||
|
navigator
|
||||||
|
.share(shareData)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Share success')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to share', error)
|
||||||
|
if (error.name !== 'AbortError') {
|
||||||
|
this.$toast.error('Failed to share: ' + error.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Cannot share natively on this device')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
refresh() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
async init() {
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
this.yearStats = await this.$axios.$get(`/api/stats/year/${this.year}`).catch((err) => {
|
||||||
|
console.error('Failed to load stats for year', err)
|
||||||
|
this.$toast.error('Failed to load year stats')
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
await this.initCanvas()
|
||||||
|
this.$emit('update:processing', false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="processing" class="max-w-[600px] h-32 sm:h-[200px] flex items-center justify-center">
|
||||||
|
<widgets-loading-spinner />
|
||||||
|
</div>
|
||||||
|
<img v-else-if="dataUrl" :src="dataUrl" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
processing: Boolean,
|
||||||
|
year: Number
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
canvas: null,
|
||||||
|
dataUrl: null,
|
||||||
|
yearStats: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async initCanvas() {
|
||||||
|
if (!this.yearStats) return
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 600
|
||||||
|
canvas.height = 200
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
const createRoundedRect = (x, y, w, h) => {
|
||||||
|
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||||
|
grd1.addColorStop(0, '#44444455')
|
||||||
|
grd1.addColorStop(1, '#ffffff11')
|
||||||
|
ctx.fillStyle = grd1
|
||||||
|
ctx.strokeStyle = '#C0C0C088'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.roundRect(x, y, w, h, [20])
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||||
|
ctx.letterSpacing = letterSpacing
|
||||||
|
|
||||||
|
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||||
|
if (maxWidth) {
|
||||||
|
let txtWidth = ctx.measureText(text).width
|
||||||
|
while (txtWidth > maxWidth) {
|
||||||
|
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||||
|
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||||
|
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||||
|
text += '...'
|
||||||
|
txtWidth = ctx.measureText(text).width
|
||||||
|
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillText(text, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addIcon = (icon, color, fontSize, x, y) => {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = `${fontSize} Material Icons Outlined`
|
||||||
|
ctx.fillText(icon, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bg color
|
||||||
|
ctx.fillStyle = '#232323'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Cover image tiles
|
||||||
|
const bookCovers = this.yearStats.finishedBooksWithCovers
|
||||||
|
bookCovers.push(...this.yearStats.booksWithCovers)
|
||||||
|
|
||||||
|
if (bookCovers.length) {
|
||||||
|
let index = 0
|
||||||
|
ctx.globalAlpha = 0.25
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||||
|
ctx.rotate((-Math.PI / 180) * 25)
|
||||||
|
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||||
|
ctx.translate(-10, -90)
|
||||||
|
for (let x = 0; x < 4; x++) {
|
||||||
|
for (let y = 0; y < 3; y++) {
|
||||||
|
const coverIndex = index % bookCovers.length
|
||||||
|
let libraryItemId = bookCovers[coverIndex]
|
||||||
|
index++
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
img.addEventListener('load', () => {
|
||||||
|
let sw = img.width
|
||||||
|
if (img.width > img.height) {
|
||||||
|
sw = img.height
|
||||||
|
}
|
||||||
|
let sx = -(sw - img.width) / 2
|
||||||
|
let sy = -(sw - img.height) / 2
|
||||||
|
ctx.drawImage(img, sx, sy, sw, sw, 155 * x, 155 * y, 155, 155)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
img.addEventListener('error', () => {
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
|
||||||
|
// Create gradient
|
||||||
|
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||||
|
grd1.addColorStop(0, '#000000aa')
|
||||||
|
grd1.addColorStop(1, '#cd9d49aa')
|
||||||
|
ctx.fillStyle = grd1
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Top Abs icon
|
||||||
|
let tanColor = '#ffdb70'
|
||||||
|
ctx.fillStyle = tanColor
|
||||||
|
ctx.font = '42px absicons'
|
||||||
|
ctx.fillText('\ue900', 15, 36)
|
||||||
|
|
||||||
|
// Top text
|
||||||
|
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||||
|
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||||
|
|
||||||
|
// Top left box
|
||||||
|
createRoundedRect(15, 75, 280, 110)
|
||||||
|
addText(this.yearStats.numBooksFinished, '48px', 'bold', 'white', '0px', 105, 120)
|
||||||
|
addText('books finished', '20px', 'normal', tanColor, '0px', 105, 155)
|
||||||
|
const readIconPath = new Path2D()
|
||||||
|
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 1.5, d: 1.5, e: 55, f: 115 })
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fill(readIconPath)
|
||||||
|
|
||||||
|
createRoundedRect(305, 75, 280, 110)
|
||||||
|
addText(this.yearStats.numBooksListened, '48px', 'bold', 'white', '0px', 400, 120)
|
||||||
|
addText('books listened to', '20px', 'normal', tanColor, '0px', 400, 155)
|
||||||
|
addIcon('local_library', 'white', '42px', 345, 130)
|
||||||
|
|
||||||
|
this.canvas = canvas
|
||||||
|
this.dataUrl = canvas.toDataURL('png')
|
||||||
|
},
|
||||||
|
share() {
|
||||||
|
this.canvas.toBlob((blob) => {
|
||||||
|
const file = new File([blob], 'yearinreviewshort.png', { type: blob.type })
|
||||||
|
const shareData = {
|
||||||
|
files: [file]
|
||||||
|
}
|
||||||
|
if (navigator.canShare(shareData)) {
|
||||||
|
navigator
|
||||||
|
.share(shareData)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Share success')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to share', error)
|
||||||
|
if (error.name !== 'AbortError') {
|
||||||
|
this.$toast.error('Failed to share: ' + error.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Cannot share natively on this device')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
refresh() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
async init() {
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
|
||||||
|
console.error('Failed to load stats for year', err)
|
||||||
|
this.$toast.error('Failed to load year stats')
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
await this.initCanvas()
|
||||||
|
this.$emit('update:processing', false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
|
<label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''">
|
||||||
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
|
<div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass">
|
||||||
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
|
<input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" />
|
||||||
<svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
<span v-if="partial" class="material-icons text-base leading-none text-gray-400">remove</span>
|
||||||
|
<svg v-else-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="label" class="select-none" :class="[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']">{{ label }}</div>
|
<div v-if="label" class="select-none" :class="[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']">{{ label }}</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -31,7 +32,8 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
disabled: Boolean
|
disabled: Boolean,
|
||||||
|
partial: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
||||||
<p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||||
<button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
<button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
||||||
@@ -64,7 +64,7 @@ export default {
|
|||||||
},
|
},
|
||||||
itemsToShow() {
|
itemsToShow() {
|
||||||
return this.items.map((i) => {
|
return this.items.map((i) => {
|
||||||
if (typeof i === 'string') {
|
if (typeof i === 'string' || typeof i === 'number') {
|
||||||
return {
|
return {
|
||||||
text: i,
|
text: i,
|
||||||
value: i
|
value: i
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<label class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
|
<label v-if="label" class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label>
|
||||||
<div ref="wrapper" class="relative">
|
<div ref="wrapper" class="relative">
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="submitForm">
|
||||||
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
|
<div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'">
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ export default {
|
|||||||
label: String,
|
label: String,
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
readonly: Boolean,
|
readonly: Boolean,
|
||||||
showEdit: Boolean
|
showEdit: Boolean,
|
||||||
|
menuDisabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -77,7 +81,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
showMenu() {
|
showMenu() {
|
||||||
return this.isFocused
|
return this.isFocused && !this.menuDisabled
|
||||||
},
|
},
|
||||||
wrapperClass() {
|
wrapperClass() {
|
||||||
var classes = []
|
var classes = []
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.6.0",
|
"version": "2.7.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.6.0",
|
"version": "2.7.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.6.0",
|
"version": "2.7.0",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
|
|
||||||
<div class="w-full h-px bg-white/10 my-4" />
|
<div class="w-full h-px bg-white/10 my-4" />
|
||||||
|
|
||||||
<p v-if="!isGuest" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p>
|
<p v-if="showChangePasswordForm" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p>
|
||||||
<form v-if="!isGuest" @submit.prevent="submitChangePassword">
|
<form v-if="showChangePasswordForm" @submit.prevent="submitChangePassword">
|
||||||
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" :label="$strings.LabelPassword" class="my-2" />
|
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" :label="$strings.LabelPassword" class="my-2" />
|
||||||
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" :label="$strings.LabelNewPassword" class="my-2" />
|
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" :label="$strings.LabelNewPassword" class="my-2" />
|
||||||
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" :label="$strings.LabelConfirmPassword" class="my-2" />
|
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" :label="$strings.LabelConfirmPassword" class="my-2" />
|
||||||
@@ -68,6 +68,13 @@ export default {
|
|||||||
},
|
},
|
||||||
isGuest() {
|
isGuest() {
|
||||||
return this.usertype === 'guest'
|
return this.usertype === 'guest'
|
||||||
|
},
|
||||||
|
isPasswordAuthEnabled() {
|
||||||
|
const activeAuthMethods = this.$store.getters['getServerSetting']('authActiveAuthMethods') || []
|
||||||
|
return activeAuthMethods.includes('local')
|
||||||
|
},
|
||||||
|
showChangePasswordForm() {
|
||||||
|
return !this.isGuest && this.isPasswordAuthEnabled
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
|
|
||||||
<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="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" />
|
||||||
|
|
||||||
|
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
|
||||||
|
<p class="pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
|
||||||
|
|
||||||
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" 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="flex items-center pt-1 mb-2">
|
||||||
@@ -187,6 +190,25 @@ export default {
|
|||||||
this.$toast.error('Client Secret required')
|
this.$toast.error('Client Secret required')
|
||||||
isValid = false
|
isValid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidRedirectURI(uri) {
|
||||||
|
// Check for somestring://someother/string
|
||||||
|
const pattern = new RegExp('^\\w+://[\\w\\.-]+$', 'i')
|
||||||
|
return pattern.test(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uris = this.newAuthSettings.authOpenIDMobileRedirectURIs
|
||||||
|
if (uris.includes('*') && uris.length > 1) {
|
||||||
|
this.$toast.error('Mobile Redirect URIs: Asterisk (*) must be the only entry if used')
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
uris.forEach((uri) => {
|
||||||
|
if (uri !== '*' && !isValidRedirectURI(uri)) {
|
||||||
|
this.$toast.error(`Mobile Redirect URIs: Invalid URI ${uri}`)
|
||||||
|
isValid = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
return isValid
|
return isValid
|
||||||
},
|
},
|
||||||
async saveSettings() {
|
async saveSettings() {
|
||||||
@@ -208,7 +230,11 @@ export default {
|
|||||||
.$patch('/api/auth-settings', this.newAuthSettings)
|
.$patch('/api/auth-settings', this.newAuthSettings)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
this.$store.commit('setServerSettings', data.serverSettings)
|
this.$store.commit('setServerSettings', data.serverSettings)
|
||||||
this.$toast.success('Server settings updated')
|
if (data.updated) {
|
||||||
|
this.$toast.success('Server settings updated')
|
||||||
|
} else {
|
||||||
|
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Failed to update server settings', error)
|
console.error('Failed to update server settings', error)
|
||||||
|
|||||||
@@ -5,37 +5,72 @@
|
|||||||
<ui-dropdown v-model="selectedUser" :items="userItems" :label="$strings.LabelFilterByUser" small class="max-w-48" @input="updateUserFilter" />
|
<ui-dropdown v-model="selectedUser" :items="userItems" :label="$strings.LabelFilterByUser" small class="max-w-48" @input="updateUserFilter" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="listeningSessions.length" class="block max-w-full">
|
<div v-if="listeningSessions.length" class="block max-w-full relative">
|
||||||
<table class="userSessionsTable">
|
<table class="userSessionsTable">
|
||||||
<tr class="bg-primary bg-opacity-40">
|
<tr class="bg-primary bg-opacity-40">
|
||||||
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
|
<th class="w-6 min-w-6 text-left hidden md:table-cell h-11">
|
||||||
<th class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
|
<ui-checkbox v-model="isAllSelected" :partial="numSelected > 0 && !isAllSelected" small checkbox-bg="bg" />
|
||||||
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
|
</th>
|
||||||
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
<th v-if="numSelected" class="flex-grow text-left" :colspan="7">
|
||||||
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
|
<div class="flex items-center">
|
||||||
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
|
<p>{{ $getString('MessageSelected', [numSelected]) }}</p>
|
||||||
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
<div class="flex-grow" />
|
||||||
|
<ui-btn small color="error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th v-if="!numSelected" class="flex-grow sm:flex-grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')">
|
||||||
|
<div class="inline-flex items-center">
|
||||||
|
{{ $strings.LabelItem }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th v-if="!numSelected" class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
|
||||||
|
<th v-if="!numSelected" class="w-26 min-w-26 text-left hidden md:table-cell group cursor-pointer" @click.stop="sortColumn('playMethod')">
|
||||||
|
<div class="inline-flex items-center">
|
||||||
|
{{ $strings.LabelPlayMethod }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('playMethod') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th v-if="!numSelected" class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||||
|
<th v-if="!numSelected" class="w-24 min-w-24 sm:w-32 sm:min-w-32 group cursor-pointer" @click.stop="sortColumn('timeListening')">
|
||||||
|
<div class="inline-flex items-center">
|
||||||
|
{{ $strings.LabelTimeListened }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }" class="material-icons text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th v-if="!numSelected" class="w-24 min-w-24 group cursor-pointer" @click.stop="sortColumn('currentTime')">
|
||||||
|
<div class="inline-flex items-center">
|
||||||
|
{{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-icons text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th v-if="!numSelected" class="flex-grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')">
|
||||||
|
<div class="inline-flex items-center">
|
||||||
|
{{ $strings.LabelLastUpdate }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('updatedAt') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
|
<tr v-for="session in listeningSessions" :key="session.id" :class="{ selected: session.selected }" class="cursor-pointer" @click="clickSessionRow(session)">
|
||||||
<td class="py-1 max-w-48">
|
<td class="hidden md:table-cell py-1 max-w-6 relative">
|
||||||
|
<ui-checkbox v-model="session.selected" small checkbox-bg="bg" />
|
||||||
|
<!-- overlay of the checkbox so that the entire box is clickable -->
|
||||||
|
<div class="absolute inset-0 w-full h-full" @click.stop="session.selected = !session.selected" />
|
||||||
|
</td>
|
||||||
|
<td class="py-1 flex-grow sm:flex-grow-0 sm:w-48 sm:max-w-48">
|
||||||
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||||
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden md:table-cell">
|
<td class="hidden md:table-cell w-20 min-w-20">
|
||||||
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
|
||||||
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden md:table-cell">
|
<td class="hidden md:table-cell w-26 min-w-26">
|
||||||
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="hidden sm:table-cell">
|
<td class="hidden sm:table-cell w-32 min-w-32">
|
||||||
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
<p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
<td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)">
|
||||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center hidden sm:table-cell">
|
<td class="text-center hidden sm:table-cell">
|
||||||
@@ -45,10 +80,22 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<div class="flex items-center justify-end my-2">
|
<!-- table bottom options -->
|
||||||
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
|
<div class="flex items-center my-2">
|
||||||
<p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p>
|
<div class="flex-grow" />
|
||||||
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
|
<div class="hidden sm:inline-flex items-center">
|
||||||
|
<p class="text-sm">{{ $strings.LabelRowsPerPage }}</p>
|
||||||
|
<ui-dropdown v-model="itemsPerPage" :items="itemsPerPageOptions" small class="w-24 mx-2" @input="updatedItemsPerPage" />
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center">
|
||||||
|
<p class="text-sm mx-2">Page {{ currentPage + 1 }} of {{ numPages }}</p>
|
||||||
|
<ui-icon-btn icon="arrow_back_ios_new" :size="9" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
|
||||||
|
<ui-icon-btn icon="arrow_forward_ios" :size="9" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="deletingSessions || loading" class="absolute inset-0 w-full h-full flex items-center justify-center">
|
||||||
|
<ui-loading-indicator />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
|
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
|
||||||
@@ -128,6 +175,7 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
loading: false,
|
||||||
showSessionModal: false,
|
showSessionModal: false,
|
||||||
selectedSession: null,
|
selectedSession: null,
|
||||||
listeningSessions: [],
|
listeningSessions: [],
|
||||||
@@ -138,7 +186,11 @@ export default {
|
|||||||
itemsPerPage: 10,
|
itemsPerPage: 10,
|
||||||
userFilter: null,
|
userFilter: null,
|
||||||
selectedUser: '',
|
selectedUser: '',
|
||||||
processingGoToTimestamp: false
|
sortBy: 'updatedAt',
|
||||||
|
sortDesc: true,
|
||||||
|
processingGoToTimestamp: false,
|
||||||
|
deletingSessions: false,
|
||||||
|
itemsPerPageOptions: [10, 25, 50, 100]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -162,9 +214,85 @@ export default {
|
|||||||
},
|
},
|
||||||
timeFormat() {
|
timeFormat() {
|
||||||
return this.$store.state.serverSettings.timeFormat
|
return this.$store.state.serverSettings.timeFormat
|
||||||
|
},
|
||||||
|
numSelected() {
|
||||||
|
return this.listeningSessions.filter((s) => s.selected).length
|
||||||
|
},
|
||||||
|
isAllSelected: {
|
||||||
|
get() {
|
||||||
|
return this.numSelected === this.listeningSessions.length
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.setSelectionForAll(val)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
isSortSelected(column) {
|
||||||
|
return this.sortBy === column
|
||||||
|
},
|
||||||
|
sortColumn(column) {
|
||||||
|
if (this.sortBy === column) {
|
||||||
|
this.sortDesc = !this.sortDesc
|
||||||
|
} else {
|
||||||
|
this.sortBy = column
|
||||||
|
}
|
||||||
|
this.loadSessions(this.currentPage)
|
||||||
|
},
|
||||||
|
removeSelectedSessions() {
|
||||||
|
if (!this.numSelected) return
|
||||||
|
this.deletingSessions = true
|
||||||
|
|
||||||
|
let isAllSessions = this.isAllSelected
|
||||||
|
const payload = {
|
||||||
|
sessions: this.listeningSessions.filter((s) => s.selected).map((s) => s.id)
|
||||||
|
}
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/sessions/batch/delete`, payload)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Sessions removed')
|
||||||
|
if (isAllSessions) {
|
||||||
|
// If all sessions were removed from the current page then go to the previous page
|
||||||
|
if (this.currentPage > 0) {
|
||||||
|
this.currentPage--
|
||||||
|
}
|
||||||
|
this.loadSessions(this.currentPage)
|
||||||
|
} else {
|
||||||
|
// Filter out the deleted sessions
|
||||||
|
this.listeningSessions = this.listeningSessions.filter((ls) => !payload.sessions.includes(ls.id))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMsg = error.response?.data || 'Failed to remove sessions'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.deletingSessions = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removeSessionsClick() {
|
||||||
|
if (!this.numSelected) return
|
||||||
|
const payload = {
|
||||||
|
message: this.$getString('MessageConfirmRemoveListeningSessions', [this.numSelected]),
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.removeSelectedSessions()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
|
setSelectionForAll(val) {
|
||||||
|
this.listeningSessions = this.listeningSessions.map((s) => {
|
||||||
|
s.selected = val
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updatedItemsPerPage() {
|
||||||
|
this.currentPage = 0
|
||||||
|
this.loadSessions(this.currentPage)
|
||||||
|
},
|
||||||
closedSession() {
|
closedSession() {
|
||||||
this.loadOpenSessions()
|
this.loadOpenSessions()
|
||||||
},
|
},
|
||||||
@@ -252,6 +380,13 @@ export default {
|
|||||||
nextPage() {
|
nextPage() {
|
||||||
this.loadSessions(this.currentPage + 1)
|
this.loadSessions(this.currentPage + 1)
|
||||||
},
|
},
|
||||||
|
clickSessionRow(session) {
|
||||||
|
if (this.numSelected > 0) {
|
||||||
|
session.selected = !session.selected
|
||||||
|
} else {
|
||||||
|
this.showSession(session)
|
||||||
|
}
|
||||||
|
},
|
||||||
showSession(session) {
|
showSession(session) {
|
||||||
this.selectedSession = session
|
this.selectedSession = session
|
||||||
this.showSessionModal = true
|
this.showSessionModal = true
|
||||||
@@ -274,11 +409,21 @@ export default {
|
|||||||
return 'Unknown'
|
return 'Unknown'
|
||||||
},
|
},
|
||||||
async loadSessions(page) {
|
async loadSessions(page) {
|
||||||
const userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : ''
|
this.loading = true
|
||||||
const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=${this.itemsPerPage}${userFilterQuery}`).catch((err) => {
|
const urlSearchParams = new URLSearchParams()
|
||||||
|
urlSearchParams.set('page', page)
|
||||||
|
urlSearchParams.set('itemsPerPage', this.itemsPerPage)
|
||||||
|
urlSearchParams.set('sort', this.sortBy)
|
||||||
|
urlSearchParams.set('desc', this.sortDesc ? '1' : '0')
|
||||||
|
if (this.selectedUser) {
|
||||||
|
urlSearchParams.set('user', this.selectedUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await this.$axios.$get(`/api/sessions?${urlSearchParams.toString()}`).catch((err) => {
|
||||||
console.error('Failed to load listening sessions', err)
|
console.error('Failed to load listening sessions', err)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
this.loading = false
|
||||||
if (!data) {
|
if (!data) {
|
||||||
this.$toast.error('Failed to load listening sessions')
|
this.$toast.error('Failed to load listening sessions')
|
||||||
return
|
return
|
||||||
@@ -287,8 +432,13 @@ export default {
|
|||||||
this.numPages = data.numPages
|
this.numPages = data.numPages
|
||||||
this.total = data.total
|
this.total = data.total
|
||||||
this.currentPage = data.page
|
this.currentPage = data.page
|
||||||
this.listeningSessions = data.sessions
|
this.listeningSessions = data.sessions.map((ls) => {
|
||||||
this.userFilter = data.userFilter
|
return {
|
||||||
|
...ls,
|
||||||
|
selected: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.userFilter = data.userId
|
||||||
},
|
},
|
||||||
async loadOpenSessions() {
|
async loadOpenSessions() {
|
||||||
const data = await this.$axios.$get('/api/sessions/open').catch((err) => {
|
const data = await this.$axios.$get('/api/sessions/open').catch((err) => {
|
||||||
@@ -326,15 +476,18 @@ export default {
|
|||||||
.userSessionsTable tr:first-child {
|
.userSessionsTable tr:first-child {
|
||||||
background-color: #272727;
|
background-color: #272727;
|
||||||
}
|
}
|
||||||
.userSessionsTable tr:not(:first-child) {
|
.userSessionsTable tr:not(:first-child):not(.selected) {
|
||||||
background-color: #373838;
|
background-color: #373838;
|
||||||
}
|
}
|
||||||
.userSessionsTable tr:not(:first-child):nth-child(odd) {
|
.userSessionsTable tr:not(:first-child):nth-child(odd):not(.selected):not(:hover) {
|
||||||
background-color: #2f2f2f;
|
background-color: #2f2f2f;
|
||||||
}
|
}
|
||||||
.userSessionsTable tr:hover:not(:first-child) {
|
.userSessionsTable tr:hover:not(:first-child) {
|
||||||
background-color: #474747;
|
background-color: #474747;
|
||||||
}
|
}
|
||||||
|
.userSessionsTable tr.selected {
|
||||||
|
background-color: #474747;
|
||||||
|
}
|
||||||
.userSessionsTable td {
|
.userSessionsTable td {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<app-settings-content :header-text="$strings.HeaderYourStats">
|
<!-- Year in review banner shown at the top in December and January -->
|
||||||
|
<stats-year-in-review-banner v-if="showYearInReviewBanner" />
|
||||||
|
|
||||||
|
<app-settings-content :header-text="$strings.HeaderYourStats" class="!mb-4">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<div class="flex p-2">
|
<div class="flex p-2">
|
||||||
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
|
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
|
||||||
@@ -63,6 +66,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
|
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
|
||||||
</app-settings-content>
|
</app-settings-content>
|
||||||
|
|
||||||
|
<!-- Year in review banner shown at the bottom Feb - Nov -->
|
||||||
|
<stats-year-in-review-banner v-if="!showYearInReviewBanner" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -71,7 +77,8 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
listeningStats: null,
|
listeningStats: null,
|
||||||
windowWidth: 0
|
windowWidth: 0,
|
||||||
|
showYearInReviewBanner: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -119,6 +126,12 @@ export default {
|
|||||||
console.error('Failed to load listening sesions', err)
|
console.error('Failed to load listening sesions', err)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let month = new Date().getMonth()
|
||||||
|
// January and December show year in review banner
|
||||||
|
if (month === 11 || month === 0) {
|
||||||
|
this.showYearInReviewBanner = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -45,6 +45,11 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
async asyncData({ params, query, store, app, redirect }) {
|
async asyncData({ params, query, store, app, redirect }) {
|
||||||
|
// Podcast search/add page is restricted to admins
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
return redirect(`/library/${params.library}`)
|
||||||
|
}
|
||||||
|
|
||||||
var libraryId = params.library
|
var libraryId = params.library
|
||||||
var libraryData = await store.dispatch('libraries/fetch', libraryId)
|
var libraryData = await store.dispatch('libraries/fetch', libraryId)
|
||||||
if (!libraryData) {
|
if (!libraryData) {
|
||||||
|
|||||||
@@ -14,6 +14,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6">
|
||||||
|
<label class="flex cursor-pointer pt-4">
|
||||||
|
<ui-toggle-switch v-model="fetchMetadata.enabled" class="inline-flex" />
|
||||||
|
<span class="pl-2 text-base">{{ $strings.LabelAutoFetchMetadata }}</span>
|
||||||
|
</label>
|
||||||
|
<ui-tooltip :text="$strings.LabelAutoFetchMetadataHelp" class="inline-flex pt-4">
|
||||||
|
<span class="pl-1 material-icons icon-text text-sm cursor-pointer">info_outlined</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<div class="flex-grow ml-4">
|
||||||
|
<ui-dropdown v-model="fetchMetadata.provider" :items="providers" :label="$strings.LabelProvider" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<widgets-alert v-if="error" type="error">
|
<widgets-alert v-if="error" type="error">
|
||||||
<p class="text-lg">{{ error }}</p>
|
<p class="text-lg">{{ error }}</p>
|
||||||
</widgets-alert>
|
</widgets-alert>
|
||||||
@@ -61,9 +75,7 @@
|
|||||||
</widgets-alert>
|
</widgets-alert>
|
||||||
|
|
||||||
<!-- Item Upload cards -->
|
<!-- Item Upload cards -->
|
||||||
<template v-for="item in items">
|
<cards-item-upload-card v-for="item in items" :key="item.index" :ref="`itemCard-${item.index}`" :media-type="selectedLibraryMediaType" :item="item" :provider="fetchMetadata.provider" :processing="processing" @remove="removeItem(item)" />
|
||||||
<cards-item-upload-card :ref="`itemCard-${item.index}`" :key="item.index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Upload/Reset btns -->
|
<!-- Upload/Reset btns -->
|
||||||
<div v-show="items.length" class="flex justify-end pb-8 pt-4">
|
<div v-show="items.length" class="flex justify-end pb-8 pt-4">
|
||||||
@@ -92,13 +104,18 @@ export default {
|
|||||||
selectedLibraryId: null,
|
selectedLibraryId: null,
|
||||||
selectedFolderId: null,
|
selectedFolderId: null,
|
||||||
processing: false,
|
processing: false,
|
||||||
uploadFinished: false
|
uploadFinished: false,
|
||||||
|
fetchMetadata: {
|
||||||
|
enabled: false,
|
||||||
|
provider: null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
selectedLibrary(newVal) {
|
selectedLibrary(newVal) {
|
||||||
if (newVal && !this.selectedFolderId) {
|
if (newVal && !this.selectedFolderId) {
|
||||||
this.setDefaultFolder()
|
this.setDefaultFolder()
|
||||||
|
this.setMetadataProvider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -133,6 +150,13 @@ export default {
|
|||||||
selectedLibraryIsPodcast() {
|
selectedLibraryIsPodcast() {
|
||||||
return this.selectedLibraryMediaType === 'podcast'
|
return this.selectedLibraryMediaType === 'podcast'
|
||||||
},
|
},
|
||||||
|
providers() {
|
||||||
|
if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders
|
||||||
|
return this.$store.state.scanners.providers
|
||||||
|
},
|
||||||
|
canFetchMetadata() {
|
||||||
|
return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled
|
||||||
|
},
|
||||||
selectedFolder() {
|
selectedFolder() {
|
||||||
if (!this.selectedLibrary) return null
|
if (!this.selectedLibrary) return null
|
||||||
return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)
|
return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)
|
||||||
@@ -160,12 +184,16 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.setDefaultFolder()
|
this.setDefaultFolder()
|
||||||
|
this.setMetadataProvider()
|
||||||
},
|
},
|
||||||
setDefaultFolder() {
|
setDefaultFolder() {
|
||||||
if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) {
|
if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) {
|
||||||
this.selectedFolderId = this.selectedLibrary.folders[0].id
|
this.selectedFolderId = this.selectedLibrary.folders[0].id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setMetadataProvider() {
|
||||||
|
this.fetchMetadata.provider ||= this.$store.getters['libraries/getLibraryProvider'](this.selectedLibraryId)
|
||||||
|
},
|
||||||
removeItem(item) {
|
removeItem(item) {
|
||||||
this.items = this.items.filter((b) => b.index !== item.index)
|
this.items = this.items.filter((b) => b.index !== item.index)
|
||||||
if (!this.items.length) {
|
if (!this.items.length) {
|
||||||
@@ -213,27 +241,49 @@ export default {
|
|||||||
var items = e.dataTransfer.items || []
|
var items = e.dataTransfer.items || []
|
||||||
|
|
||||||
var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType)
|
var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType)
|
||||||
this.setResults(itemResults)
|
this.onItemsSelected(itemResults)
|
||||||
},
|
},
|
||||||
inputChanged(e) {
|
inputChanged(e) {
|
||||||
if (!e.target || !e.target.files) return
|
if (!e.target || !e.target.files) return
|
||||||
var _files = Array.from(e.target.files)
|
var _files = Array.from(e.target.files)
|
||||||
if (_files && _files.length) {
|
if (_files && _files.length) {
|
||||||
var itemResults = this.uploadHelpers.getItemsFromPicker(_files, this.selectedLibraryMediaType)
|
var itemResults = this.uploadHelpers.getItemsFromPicker(_files, this.selectedLibraryMediaType)
|
||||||
this.setResults(itemResults)
|
this.onItemsSelected(itemResults)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setResults(itemResults) {
|
onItemsSelected(itemResults) {
|
||||||
|
if (this.itemSelectionSuccessful(itemResults)) {
|
||||||
|
// setTimeout ensures the new item ref is attached before this method is called
|
||||||
|
setTimeout(this.attemptMetadataFetch, 0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemSelectionSuccessful(itemResults) {
|
||||||
|
console.log('Upload results', itemResults)
|
||||||
|
|
||||||
if (itemResults.error) {
|
if (itemResults.error) {
|
||||||
this.error = itemResults.error
|
this.error = itemResults.error
|
||||||
this.items = []
|
this.items = []
|
||||||
this.ignoredFiles = []
|
this.ignoredFiles = []
|
||||||
} else {
|
return false
|
||||||
this.error = ''
|
|
||||||
this.items = itemResults.items
|
|
||||||
this.ignoredFiles = itemResults.ignoredFiles
|
|
||||||
}
|
}
|
||||||
console.log('Upload results', itemResults)
|
|
||||||
|
this.error = ''
|
||||||
|
this.items = itemResults.items
|
||||||
|
this.ignoredFiles = itemResults.ignoredFiles
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
attemptMetadataFetch() {
|
||||||
|
if (!this.canFetchMetadata) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.items.forEach((item) => {
|
||||||
|
let itemRef = this.$refs[`itemCard-${item.index}`]
|
||||||
|
|
||||||
|
if (itemRef?.length) {
|
||||||
|
itemRef[0].fetchMetadata(this.fetchMetadata.provider)
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
updateItemCardStatus(index, status) {
|
updateItemCardStatus(index, status) {
|
||||||
var ref = this.$refs[`itemCard-${index}`]
|
var ref = this.$refs[`itemCard-${index}`]
|
||||||
@@ -248,8 +298,8 @@ export default {
|
|||||||
var form = new FormData()
|
var form = new FormData()
|
||||||
form.set('title', item.title)
|
form.set('title', item.title)
|
||||||
if (!this.selectedLibraryIsPodcast) {
|
if (!this.selectedLibraryIsPodcast) {
|
||||||
form.set('author', item.author)
|
form.set('author', item.author || '')
|
||||||
form.set('series', item.series)
|
form.set('series', item.series || '')
|
||||||
}
|
}
|
||||||
form.set('library', this.selectedLibraryId)
|
form.set('library', this.selectedLibraryId)
|
||||||
form.set('folder', this.selectedFolderId)
|
form.set('folder', this.selectedFolderId)
|
||||||
@@ -346,6 +396,8 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
|
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
|
||||||
|
this.setMetadataProvider()
|
||||||
|
|
||||||
this.setDefaultFolder()
|
this.setDefaultFolder()
|
||||||
window.addEventListener('dragenter', this.dragenter)
|
window.addEventListener('dragenter', this.dragenter)
|
||||||
window.addEventListener('dragleave', this.dragleave)
|
window.addEventListener('dragleave', this.dragleave)
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
|||||||
.replace(lineBreaks, replacement)
|
.replace(lineBreaks, replacement)
|
||||||
.replace(windowsReservedRe, replacement)
|
.replace(windowsReservedRe, replacement)
|
||||||
.replace(windowsTrailingRe, replacement)
|
.replace(windowsTrailingRe, replacement)
|
||||||
|
.replace(/\s+/g, ' ') // Replace consecutive spaces with a single space
|
||||||
|
|
||||||
// Check if basename is too many bytes
|
// Check if basename is too many bytes
|
||||||
const ext = Path.extname(sanitized) // separate out file extension
|
const ext = Path.extname(sanitized) // separate out file extension
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
"ButtonUserEdit": "Upravit uživatelské {0}",
|
"ButtonUserEdit": "Upravit uživatelské {0}",
|
||||||
"ButtonViewAll": "Zobrazit vše",
|
"ButtonViewAll": "Zobrazit vše",
|
||||||
"ButtonYes": "Ano",
|
"ButtonYes": "Ano",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||||
|
"ErrorUploadLacksTitle": "Must have a title",
|
||||||
"HeaderAccount": "Účet",
|
"HeaderAccount": "Účet",
|
||||||
"HeaderAdvanced": "Pokročilé",
|
"HeaderAdvanced": "Pokročilé",
|
||||||
"HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise",
|
"HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Autor (příjmení a jméno)",
|
"LabelAuthorLastFirst": "Autor (příjmení a jméno)",
|
||||||
"LabelAuthors": "Autoři",
|
"LabelAuthors": "Autoři",
|
||||||
"LabelAutoDownloadEpisodes": "Automaticky stahovat epizody",
|
"LabelAutoDownloadEpisodes": "Automaticky stahovat epizody",
|
||||||
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Auto Launch",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Příklad",
|
"LabelExample": "Příklad",
|
||||||
"LabelExplicit": "Explicitní",
|
"LabelExplicit": "Explicitní",
|
||||||
"LabelFeedURL": "URL zdroje",
|
"LabelFeedURL": "URL zdroje",
|
||||||
|
"LabelFetchingMetadata": "Fetching Metadata",
|
||||||
"LabelFile": "Soubor",
|
"LabelFile": "Soubor",
|
||||||
"LabelFileBirthtime": "Čas vzniku souboru",
|
"LabelFileBirthtime": "Čas vzniku souboru",
|
||||||
"LabelFileModified": "Soubor změněn",
|
"LabelFileModified": "Soubor změněn",
|
||||||
@@ -337,6 +343,8 @@
|
|||||||
"LabelMinute": "Minuta",
|
"LabelMinute": "Minuta",
|
||||||
"LabelMissing": "Chybějící",
|
"LabelMissing": "Chybějící",
|
||||||
"LabelMissingParts": "Chybějící díly",
|
"LabelMissingParts": "Chybějící díly",
|
||||||
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "Více",
|
"LabelMore": "Více",
|
||||||
"LabelMoreInfo": "Více informací",
|
"LabelMoreInfo": "Více informací",
|
||||||
"LabelName": "Jméno",
|
"LabelName": "Jméno",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Region",
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Datum vydání",
|
"LabelReleaseDate": "Datum vydání",
|
||||||
"LabelRemoveCover": "Odstranit obálku",
|
"LabelRemoveCover": "Odstranit obálku",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
||||||
"LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka",
|
"LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka",
|
||||||
"LabelRSSFeedOpen": "Otevření RSS kanálu",
|
"LabelRSSFeedOpen": "Otevření RSS kanálu",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda",
|
"LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda",
|
||||||
"LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky",
|
"LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky",
|
||||||
"LabelUploaderDropFiles": "Odstranit soubory",
|
"LabelUploaderDropFiles": "Odstranit soubory",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||||
"LabelUseChapterTrack": "Použít stopu kapitoly",
|
"LabelUseChapterTrack": "Použít stopu kapitoly",
|
||||||
"LabelUseFullTrack": "Použít celou stopu",
|
"LabelUseFullTrack": "Použít celou stopu",
|
||||||
"LabelUser": "Uživatel",
|
"LabelUser": "Uživatel",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?",
|
"MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "Opravdu chcete odebrat předčítání \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Opravdu chcete odebrat předčítání \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Opravdu chcete odstranit svůj playlist \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Opravdu chcete odstranit svůj playlist \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Opravdu chcete přejmenovat žánr \"{0}\" na \"{1}\" pro všechny položky?",
|
"MessageConfirmRenameGenre": "Opravdu chcete přejmenovat žánr \"{0}\" na \"{1}\" pro všechny položky?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Opravdu chcete obnovit zálohu vytvořenou dne?",
|
"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.",
|
"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",
|
"MessageSearchResultsFor": "Výsledky hledání pro",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Server je nedostupný",
|
"MessageServerCouldNotBeReached": "Server je nedostupný",
|
||||||
"MessageSetChaptersFromTracksDescription": "Nastavit kapitoly jako kapitolu a název kapitoly jako název zvukového souboru",
|
"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}?",
|
"MessageStartPlaybackAtTime": "Spustit přehrávání pro \"{0}\" v {1}?",
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
"ButtonUserEdit": "Rediger bruger {0}",
|
"ButtonUserEdit": "Rediger bruger {0}",
|
||||||
"ButtonViewAll": "Vis Alle",
|
"ButtonViewAll": "Vis Alle",
|
||||||
"ButtonYes": "Ja",
|
"ButtonYes": "Ja",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||||
|
"ErrorUploadLacksTitle": "Must have a title",
|
||||||
"HeaderAccount": "Konto",
|
"HeaderAccount": "Konto",
|
||||||
"HeaderAdvanced": "Avanceret",
|
"HeaderAdvanced": "Avanceret",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger",
|
"HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
|
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
|
||||||
"LabelAuthors": "Forfattere",
|
"LabelAuthors": "Forfattere",
|
||||||
"LabelAutoDownloadEpisodes": "Auto Download Episoder",
|
"LabelAutoDownloadEpisodes": "Auto Download Episoder",
|
||||||
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Auto Launch",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Eksempel",
|
"LabelExample": "Eksempel",
|
||||||
"LabelExplicit": "Eksplisit",
|
"LabelExplicit": "Eksplisit",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
|
"LabelFetchingMetadata": "Fetching Metadata",
|
||||||
"LabelFile": "Fil",
|
"LabelFile": "Fil",
|
||||||
"LabelFileBirthtime": "Fødselstidspunkt for fil",
|
"LabelFileBirthtime": "Fødselstidspunkt for fil",
|
||||||
"LabelFileModified": "Fil ændret",
|
"LabelFileModified": "Fil ændret",
|
||||||
@@ -337,6 +343,8 @@
|
|||||||
"LabelMinute": "Minut",
|
"LabelMinute": "Minut",
|
||||||
"LabelMissing": "Mangler",
|
"LabelMissing": "Mangler",
|
||||||
"LabelMissingParts": "Manglende dele",
|
"LabelMissingParts": "Manglende dele",
|
||||||
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "Mere",
|
"LabelMore": "Mere",
|
||||||
"LabelMoreInfo": "Mere info",
|
"LabelMoreInfo": "Mere info",
|
||||||
"LabelName": "Navn",
|
"LabelName": "Navn",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Region",
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Udgivelsesdato",
|
"LabelReleaseDate": "Udgivelsesdato",
|
||||||
"LabelRemoveCover": "Fjern omslag",
|
"LabelRemoveCover": "Fjern omslag",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
|
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
|
||||||
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
|
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
|
||||||
"LabelRSSFeedOpen": "Åben RSS-feed",
|
"LabelRSSFeedOpen": "Åben RSS-feed",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Tillad overskrivning af eksisterende detaljer for de valgte bøger, når der findes en match",
|
"LabelUpdateDetailsHelp": "Tillad overskrivning af eksisterende detaljer for de valgte bøger, når der findes en match",
|
||||||
"LabelUploaderDragAndDrop": "Træk og slip filer eller mapper",
|
"LabelUploaderDragAndDrop": "Træk og slip filer eller mapper",
|
||||||
"LabelUploaderDropFiles": "Smid filer",
|
"LabelUploaderDropFiles": "Smid filer",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||||
"LabelUseChapterTrack": "Brug kapitel-spor",
|
"LabelUseChapterTrack": "Brug kapitel-spor",
|
||||||
"LabelUseFullTrack": "Brug fuldt spor",
|
"LabelUseFullTrack": "Brug fuldt spor",
|
||||||
"LabelUser": "Bruger",
|
"LabelUser": "Bruger",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "Er du sikker på, at du vil fjerne samlingen \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Er du sikker på, at du vil fjerne samlingen \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Er du sikker på, at du vil fjerne episoden \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Er du sikker på, at du vil fjerne episoden \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "Er du sikker på, at du vil fjerne {0} episoder?",
|
"MessageConfirmRemoveEpisodes": "Er du sikker på, at du vil fjerne {0} episoder?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "Er du sikker på, at du vil fjerne fortælleren \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Er du sikker på, at du vil fjerne fortælleren \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Er du sikker på, at du vil fjerne din spilleliste \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Er du sikker på, at du vil fjerne din spilleliste \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Er du sikker på, at du vil omdøbe genre \"{0}\" til \"{1}\" for alle elementer?",
|
"MessageConfirmRenameGenre": "Er du sikker på, at du vil omdøbe genre \"{0}\" til \"{1}\" for alle elementer?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Er du sikker på, at du vil gendanne sikkerhedskopien oprettet den",
|
"MessageRestoreBackupConfirm": "Er du sikker på, at du vil gendanne sikkerhedskopien oprettet den",
|
||||||
"MessageRestoreBackupWarning": "Gendannelse af en sikkerhedskopi vil overskrive hele databasen, som er placeret på /config, og omslagsbilleder i /metadata/items & /metadata/authors.<br /><br />Sikkerhedskopier ændrer ikke nogen filer i dine biblioteksmapper. Hvis du har aktiveret serverindstillinger for at gemme omslagskunst og metadata i dine biblioteksmapper, sikkerhedskopieres eller overskrives disse ikke.<br /><br />Alle klienter, der bruger din server, opdateres automatisk.",
|
"MessageRestoreBackupWarning": "Gendannelse af en sikkerhedskopi vil overskrive hele databasen, som er placeret på /config, og omslagsbilleder i /metadata/items & /metadata/authors.<br /><br />Sikkerhedskopier ændrer ikke nogen filer i dine biblioteksmapper. Hvis du har aktiveret serverindstillinger for at gemme omslagskunst og metadata i dine biblioteksmapper, sikkerhedskopieres eller overskrives disse ikke.<br /><br />Alle klienter, der bruger din server, opdateres automatisk.",
|
||||||
"MessageSearchResultsFor": "Søgeresultater for",
|
"MessageSearchResultsFor": "Søgeresultater for",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Serveren kunne ikke nås",
|
"MessageServerCouldNotBeReached": "Serveren kunne ikke nås",
|
||||||
"MessageSetChaptersFromTracksDescription": "Indstil kapitler ved at bruge hver lydfil som et kapitel og kapiteloverskrift som lydfilnavn",
|
"MessageSetChaptersFromTracksDescription": "Indstil kapitler ved at bruge hver lydfil som et kapitel og kapiteloverskrift som lydfilnavn",
|
||||||
"MessageStartPlaybackAtTime": "Start afspilning for \"{0}\" kl. {1}?",
|
"MessageStartPlaybackAtTime": "Start afspilning for \"{0}\" kl. {1}?",
|
||||||
|
|||||||
+22
-10
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"ButtonAdd": "Hinzufügen",
|
"ButtonAdd": "Hinzufügen",
|
||||||
"ButtonAddChapters": "Kapitel hinzufügen",
|
"ButtonAddChapters": "Kapitel hinzufügen",
|
||||||
"ButtonAddDevice": "Add Device",
|
"ButtonAddDevice": "Gerät hinzufügen",
|
||||||
"ButtonAddLibrary": "Add Library",
|
"ButtonAddLibrary": "Bibliothek hinzufügen",
|
||||||
"ButtonAddPodcasts": "Podcasts hinzufügen",
|
"ButtonAddPodcasts": "Podcasts hinzufügen",
|
||||||
"ButtonAddUser": "Add User",
|
"ButtonAddUser": "Benutzer hinzufügen",
|
||||||
"ButtonAddYourFirstLibrary": "Erstelle deine erste Bibliothek",
|
"ButtonAddYourFirstLibrary": "Erstelle deine erste Bibliothek",
|
||||||
"ButtonApply": "Übernehmen",
|
"ButtonApply": "Übernehmen",
|
||||||
"ButtonApplyChapters": "Kapitel anwenden",
|
"ButtonApplyChapters": "Kapitel anwenden",
|
||||||
@@ -58,11 +58,11 @@
|
|||||||
"ButtonRemoveAll": "Alles löschen",
|
"ButtonRemoveAll": "Alles löschen",
|
||||||
"ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge",
|
"ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge",
|
||||||
"ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste",
|
"ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste",
|
||||||
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
|
"ButtonRemoveFromContinueReading": "Lösche die Serie aus der Lesefortsetzungsliste",
|
||||||
"ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste",
|
"ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste",
|
||||||
"ButtonReScan": "Neu scannen",
|
"ButtonReScan": "Neu scannen",
|
||||||
"ButtonReset": "Zurücksetzen",
|
"ButtonReset": "Zurücksetzen",
|
||||||
"ButtonResetToDefault": "Reset to default",
|
"ButtonResetToDefault": "Zurücksetzen auf Standard",
|
||||||
"ButtonRestore": "Wiederherstellen",
|
"ButtonRestore": "Wiederherstellen",
|
||||||
"ButtonSave": "Speichern",
|
"ButtonSave": "Speichern",
|
||||||
"ButtonSaveAndClose": "Speichern & Schließen",
|
"ButtonSaveAndClose": "Speichern & Schließen",
|
||||||
@@ -87,6 +87,9 @@
|
|||||||
"ButtonUserEdit": "Benutzer {0} bearbeiten",
|
"ButtonUserEdit": "Benutzer {0} bearbeiten",
|
||||||
"ButtonViewAll": "Alles anzeigen",
|
"ButtonViewAll": "Alles anzeigen",
|
||||||
"ButtonYes": "Ja",
|
"ButtonYes": "Ja",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Fehler beim Abrufen der Metadaten",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Metadaten konnten nicht abgerufen werden. Versuchen Sie den Titel und oder den Autor zu updaten",
|
||||||
|
"ErrorUploadLacksTitle": "Es muss ein Titel eingegeben werden",
|
||||||
"HeaderAccount": "Konto",
|
"HeaderAccount": "Konto",
|
||||||
"HeaderAdvanced": "Erweitert",
|
"HeaderAdvanced": "Erweitert",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen",
|
"HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Autor (Nachname, Vorname)",
|
"LabelAuthorLastFirst": "Autor (Nachname, Vorname)",
|
||||||
"LabelAuthors": "Autoren",
|
"LabelAuthors": "Autoren",
|
||||||
"LabelAutoDownloadEpisodes": "Episoden automatisch herunterladen",
|
"LabelAutoDownloadEpisodes": "Episoden automatisch herunterladen",
|
||||||
|
"LabelAutoFetchMetadata": "Automatisches Abholen der Metadaten",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Abholen der Metadaten von Titel, Autor und Serien, um das Hochladen zu optimieren. Möglicherweise müssen zusätzliche Metadaten nach dem Hochladen abgeglichen werden.",
|
||||||
"LabelAutoLaunch": "Automatischer Start",
|
"LabelAutoLaunch": "Automatischer Start",
|
||||||
"LabelAutoLaunchDescription": "Automatische Weiterleitung zum Authentifizierungsanbieter beim Navigieren zur Anmeldeseite (manueller Überschreibungspfad <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Automatische Weiterleitung zum Authentifizierungsanbieter beim Navigieren zur Anmeldeseite (manueller Überschreibungspfad <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Automatische Registrierung",
|
"LabelAutoRegister": "Automatische Registrierung",
|
||||||
@@ -216,7 +221,7 @@
|
|||||||
"LabelChapters": "Kapitel",
|
"LabelChapters": "Kapitel",
|
||||||
"LabelChaptersFound": "gefundene Kapitel",
|
"LabelChaptersFound": "gefundene Kapitel",
|
||||||
"LabelChapterTitle": "Kapitelüberschrift",
|
"LabelChapterTitle": "Kapitelüberschrift",
|
||||||
"LabelClickForMoreInfo": "Click for more info",
|
"LabelClickForMoreInfo": "Klicken für mehr Informationen",
|
||||||
"LabelClosePlayer": "Player schließen",
|
"LabelClosePlayer": "Player schließen",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Serien zusammenfassen",
|
"LabelCollapseSeries": "Serien zusammenfassen",
|
||||||
@@ -246,7 +251,7 @@
|
|||||||
"LabelDiscFromMetadata": "CD aus den Metadaten",
|
"LabelDiscFromMetadata": "CD aus den Metadaten",
|
||||||
"LabelDiscover": "Entdecken",
|
"LabelDiscover": "Entdecken",
|
||||||
"LabelDownload": "Herunterladen",
|
"LabelDownload": "Herunterladen",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episodes",
|
"LabelDownloadNEpisodes": "Download {0} Episoden",
|
||||||
"LabelDuration": "Laufzeit",
|
"LabelDuration": "Laufzeit",
|
||||||
"LabelDurationFound": "Gefundene Laufzeit:",
|
"LabelDurationFound": "Gefundene Laufzeit:",
|
||||||
"LabelEbook": "E-Book",
|
"LabelEbook": "E-Book",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Beispiel",
|
"LabelExample": "Beispiel",
|
||||||
"LabelExplicit": "Explizit (Altersbeschränkung)",
|
"LabelExplicit": "Explizit (Altersbeschränkung)",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
|
"LabelFetchingMetadata": "Abholen der Metadaten",
|
||||||
"LabelFile": "Datei",
|
"LabelFile": "Datei",
|
||||||
"LabelFileBirthtime": "Datei erstellt",
|
"LabelFileBirthtime": "Datei erstellt",
|
||||||
"LabelFileModified": "Datei geändert",
|
"LabelFileModified": "Datei geändert",
|
||||||
@@ -283,7 +289,7 @@
|
|||||||
"LabelHardDeleteFile": "Datei dauerhaft löschen",
|
"LabelHardDeleteFile": "Datei dauerhaft löschen",
|
||||||
"LabelHasEbook": "mit E-Book",
|
"LabelHasEbook": "mit E-Book",
|
||||||
"LabelHasSupplementaryEbook": "mit zusätlichem E-Book",
|
"LabelHasSupplementaryEbook": "mit zusätlichem E-Book",
|
||||||
"LabelHighestPriority": "Highest priority",
|
"LabelHighestPriority": "Höchste Priorität",
|
||||||
"LabelHost": "Host",
|
"LabelHost": "Host",
|
||||||
"LabelHour": "Stunde",
|
"LabelHour": "Stunde",
|
||||||
"LabelIcon": "Symbol",
|
"LabelIcon": "Symbol",
|
||||||
@@ -325,18 +331,20 @@
|
|||||||
"LabelLogLevelInfo": "Informationen",
|
"LabelLogLevelInfo": "Informationen",
|
||||||
"LabelLogLevelWarn": "Warnungen",
|
"LabelLogLevelWarn": "Warnungen",
|
||||||
"LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum",
|
"LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum",
|
||||||
"LabelLowestPriority": "Lowest Priority",
|
"LabelLowestPriority": "Niedrigste Priorität",
|
||||||
"LabelMatchExistingUsersBy": "Zuordnen existierender Benutzer mit",
|
"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",
|
"LabelMatchExistingUsersByDescription": "Wird zum Verbinden vorhandener Benutzer verwendet. Sobald die Verbindung hergestellt ist, wird den Benutzern eine eindeutige ID von Ihrem SSO-Anbieter zugeordnet",
|
||||||
"LabelMediaPlayer": "Mediaplayer",
|
"LabelMediaPlayer": "Mediaplayer",
|
||||||
"LabelMediaType": "Medientyp",
|
"LabelMediaType": "Medientyp",
|
||||||
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
|
"LabelMetadataOrderOfPrecedenceDescription": "Eine Höhere Priorität Quelle für Metadaten wird die Metadaten aus eine Quelle mit niedrigerer Priorität überschreiben.",
|
||||||
"LabelMetadataProvider": "Metadatenanbieter",
|
"LabelMetadataProvider": "Metadatenanbieter",
|
||||||
"LabelMetaTag": "Meta Schlagwort",
|
"LabelMetaTag": "Meta Schlagwort",
|
||||||
"LabelMetaTags": "Meta Tags",
|
"LabelMetaTags": "Meta Tags",
|
||||||
"LabelMinute": "Minute",
|
"LabelMinute": "Minute",
|
||||||
"LabelMissing": "Fehlend",
|
"LabelMissing": "Fehlend",
|
||||||
"LabelMissingParts": "Fehlende Teile",
|
"LabelMissingParts": "Fehlende Teile",
|
||||||
|
"LabelMobileRedirectURIs": "Erlaubte Weiterleitungs-URIs für die mobile App",
|
||||||
|
"LabelMobileRedirectURIsDescription": "Dies ist eine Whitelist gültiger Umleitungs-URIs für mobile Apps. Der Standardwert ist <code>audiobookshelf://oauth</code>, den Sie entfernen oder durch zusätzliche URIs für die Integration von Drittanbieter-Apps ergänzen können. Die Verwendung eines Sternchens (<code>*</code>) als alleiniger Eintrag erlaubt jede URI.",
|
||||||
"LabelMore": "Mehr",
|
"LabelMore": "Mehr",
|
||||||
"LabelMoreInfo": "Mehr Info",
|
"LabelMoreInfo": "Mehr Info",
|
||||||
"LabelName": "Name",
|
"LabelName": "Name",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Region",
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Veröffentlichungsdatum",
|
"LabelReleaseDate": "Veröffentlichungsdatum",
|
||||||
"LabelRemoveCover": "Lösche Titelbild",
|
"LabelRemoveCover": "Lösche Titelbild",
|
||||||
|
"LabelRowsPerPage": "Zeilen pro Seite",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail",
|
"LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail",
|
||||||
"LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers",
|
"LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers",
|
||||||
"LabelRSSFeedOpen": "RSS Feed Offen",
|
"LabelRSSFeedOpen": "RSS Feed Offen",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird",
|
"LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird",
|
||||||
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
|
"LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern",
|
||||||
"LabelUploaderDropFiles": "Dateien löschen",
|
"LabelUploaderDropFiles": "Dateien löschen",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatisches Abholden von Titel, Author und Serien",
|
||||||
"LabelUseChapterTrack": "Kapiteldatei verwenden",
|
"LabelUseChapterTrack": "Kapiteldatei verwenden",
|
||||||
"LabelUseFullTrack": "Gesamte Datei verwenden",
|
"LabelUseFullTrack": "Gesamte Datei verwenden",
|
||||||
"LabelUser": "Benutzer",
|
"LabelUser": "Benutzer",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird gelöscht! Sind Sie sicher?",
|
"MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird gelöscht! Sind Sie sicher?",
|
||||||
"MessageConfirmRemoveEpisode": "Episode \"{0}\" wird geloscht! Sind Sie sicher?",
|
"MessageConfirmRemoveEpisode": "Episode \"{0}\" wird geloscht! Sind Sie sicher?",
|
||||||
"MessageConfirmRemoveEpisodes": "{0} Episoden werden gelöscht! Sind Sie sicher?",
|
"MessageConfirmRemoveEpisodes": "{0} Episoden werden gelöscht! Sind Sie sicher?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Sind Sie sicher, dass sie {0} Hörsitzungen enfernen möchten?",
|
||||||
"MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird gelöscht! Sind Sie sicher?",
|
"MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird gelöscht! Sind Sie sicher?",
|
||||||
"MessageConfirmRemovePlaylist": "Wiedergabeliste \"{0}\" wird entfernt! Sind Sie sicher?",
|
"MessageConfirmRemovePlaylist": "Wiedergabeliste \"{0}\" wird entfernt! Sind Sie sicher?",
|
||||||
"MessageConfirmRenameGenre": "Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Sind Sie sicher?",
|
"MessageConfirmRenameGenre": "Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Sind Sie sicher?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Sind Sie sicher, dass Sie die Sicherung wiederherstellen wollen, welche am",
|
"MessageRestoreBackupConfirm": "Sind Sie sicher, dass Sie die Sicherung wiederherstellen wollen, welche am",
|
||||||
"MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in Ihren Bibliotheksordnern verändert. Wenn Sie die Servereinstellungen aktiviert haben, um Cover und Metadaten in Ihren Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.",
|
"MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in Ihren Bibliotheksordnern verändert. Wenn Sie die Servereinstellungen aktiviert haben, um Cover und Metadaten in Ihren Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.",
|
||||||
"MessageSearchResultsFor": "Suchergebnisse für",
|
"MessageSearchResultsFor": "Suchergebnisse für",
|
||||||
|
"MessageSelected": "{0} ausgewählt",
|
||||||
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
|
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
|
||||||
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
|
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
|
||||||
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
|
"MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?",
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
"ButtonUserEdit": "Edit user {0}",
|
"ButtonUserEdit": "Edit user {0}",
|
||||||
"ButtonViewAll": "View All",
|
"ButtonViewAll": "View All",
|
||||||
"ButtonYes": "Yes",
|
"ButtonYes": "Yes",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||||
|
"ErrorUploadLacksTitle": "Must have a title",
|
||||||
"HeaderAccount": "Account",
|
"HeaderAccount": "Account",
|
||||||
"HeaderAdvanced": "Advanced",
|
"HeaderAdvanced": "Advanced",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||||
"LabelAuthors": "Authors",
|
"LabelAuthors": "Authors",
|
||||||
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
|
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
|
||||||
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Auto Launch",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Example",
|
"LabelExample": "Example",
|
||||||
"LabelExplicit": "Explicit",
|
"LabelExplicit": "Explicit",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
|
"LabelFetchingMetadata": "Fetching Metadata",
|
||||||
"LabelFile": "File",
|
"LabelFile": "File",
|
||||||
"LabelFileBirthtime": "File Birthtime",
|
"LabelFileBirthtime": "File Birthtime",
|
||||||
"LabelFileModified": "File Modified",
|
"LabelFileModified": "File Modified",
|
||||||
@@ -337,6 +343,8 @@
|
|||||||
"LabelMinute": "Minute",
|
"LabelMinute": "Minute",
|
||||||
"LabelMissing": "Missing",
|
"LabelMissing": "Missing",
|
||||||
"LabelMissingParts": "Missing Parts",
|
"LabelMissingParts": "Missing Parts",
|
||||||
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "More",
|
"LabelMore": "More",
|
||||||
"LabelMoreInfo": "More Info",
|
"LabelMoreInfo": "More Info",
|
||||||
"LabelName": "Name",
|
"LabelName": "Name",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Region",
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Release Date",
|
"LabelReleaseDate": "Release Date",
|
||||||
"LabelRemoveCover": "Remove cover",
|
"LabelRemoveCover": "Remove cover",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||||
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
||||||
"LabelRSSFeedOpen": "RSS Feed Open",
|
"LabelRSSFeedOpen": "RSS Feed Open",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
|
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
|
||||||
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
|
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
|
||||||
"LabelUploaderDropFiles": "Drop files",
|
"LabelUploaderDropFiles": "Drop files",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||||
"LabelUseChapterTrack": "Use chapter track",
|
"LabelUseChapterTrack": "Use chapter track",
|
||||||
"LabelUseFullTrack": "Use full track",
|
"LabelUseFullTrack": "Use full track",
|
||||||
"LabelUser": "User",
|
"LabelUser": "User",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
|
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
|
||||||
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
|
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
|
||||||
"MessageSearchResultsFor": "Search results for",
|
"MessageSearchResultsFor": "Search results for",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Server could not be reached",
|
"MessageServerCouldNotBeReached": "Server could not be reached",
|
||||||
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
||||||
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
|
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
"ButtonUserEdit": "Editar Usuario {0}",
|
"ButtonUserEdit": "Editar Usuario {0}",
|
||||||
"ButtonViewAll": "Ver Todos",
|
"ButtonViewAll": "Ver Todos",
|
||||||
"ButtonYes": "Aceptar",
|
"ButtonYes": "Aceptar",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||||
|
"ErrorUploadLacksTitle": "Must have a title",
|
||||||
"HeaderAccount": "Cuenta",
|
"HeaderAccount": "Cuenta",
|
||||||
"HeaderAdvanced": "Avanzado",
|
"HeaderAdvanced": "Avanzado",
|
||||||
"HeaderAppriseNotificationSettings": "Ajustes de Notificaciones de Apprise",
|
"HeaderAppriseNotificationSettings": "Ajustes de Notificaciones de Apprise",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Autor (Apellido, Nombre)",
|
"LabelAuthorLastFirst": "Autor (Apellido, Nombre)",
|
||||||
"LabelAuthors": "Autores",
|
"LabelAuthors": "Autores",
|
||||||
"LabelAutoDownloadEpisodes": "Descargar Episodios Automáticamente",
|
"LabelAutoDownloadEpisodes": "Descargar Episodios Automáticamente",
|
||||||
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Auto Launch",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Ejemplo",
|
"LabelExample": "Ejemplo",
|
||||||
"LabelExplicit": "Explicito",
|
"LabelExplicit": "Explicito",
|
||||||
"LabelFeedURL": "Fuente de URL",
|
"LabelFeedURL": "Fuente de URL",
|
||||||
|
"LabelFetchingMetadata": "Fetching Metadata",
|
||||||
"LabelFile": "Archivo",
|
"LabelFile": "Archivo",
|
||||||
"LabelFileBirthtime": "Archivo Creado en",
|
"LabelFileBirthtime": "Archivo Creado en",
|
||||||
"LabelFileModified": "Archivo modificado",
|
"LabelFileModified": "Archivo modificado",
|
||||||
@@ -337,6 +343,8 @@
|
|||||||
"LabelMinute": "Minuto",
|
"LabelMinute": "Minuto",
|
||||||
"LabelMissing": "Ausente",
|
"LabelMissing": "Ausente",
|
||||||
"LabelMissingParts": "Partes Ausentes",
|
"LabelMissingParts": "Partes Ausentes",
|
||||||
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "Más",
|
"LabelMore": "Más",
|
||||||
"LabelMoreInfo": "Más Información",
|
"LabelMoreInfo": "Más Información",
|
||||||
"LabelName": "Nombre",
|
"LabelName": "Nombre",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Región",
|
"LabelRegion": "Región",
|
||||||
"LabelReleaseDate": "Fecha de Estreno",
|
"LabelReleaseDate": "Fecha de Estreno",
|
||||||
"LabelRemoveCover": "Remover Portada",
|
"LabelRemoveCover": "Remover Portada",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Email de dueño personalizado",
|
"LabelRSSFeedCustomOwnerEmail": "Email de dueño personalizado",
|
||||||
"LabelRSSFeedCustomOwnerName": "Nombre de dueño personalizado",
|
"LabelRSSFeedCustomOwnerName": "Nombre de dueño personalizado",
|
||||||
"LabelRSSFeedOpen": "Fuente RSS Abierta",
|
"LabelRSSFeedOpen": "Fuente RSS Abierta",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
|
"LabelUpdateDetailsHelp": "Permitir sobrescribir detalles existentes de los libros seleccionados cuando sean encontrados",
|
||||||
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
|
"LabelUploaderDragAndDrop": "Arrastre y suelte archivos o carpetas",
|
||||||
"LabelUploaderDropFiles": "Suelte los Archivos",
|
"LabelUploaderDropFiles": "Suelte los Archivos",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||||
"LabelUseChapterTrack": "Usar pista por capitulo",
|
"LabelUseChapterTrack": "Usar pista por capitulo",
|
||||||
"LabelUseFullTrack": "Usar pista completa",
|
"LabelUseFullTrack": "Usar pista completa",
|
||||||
"LabelUser": "Usuario",
|
"LabelUser": "Usuario",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "¿Está seguro de que desea remover la colección \"{0}\"?",
|
"MessageConfirmRemoveCollection": "¿Está seguro de que desea remover la colección \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "¿Está seguro de que desea remover el episodio \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "¿Está seguro de que desea remover el episodio \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "¿Está seguro de que desea remover {0} episodios?",
|
"MessageConfirmRemoveEpisodes": "¿Está seguro de que desea remover {0} episodios?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "¿Está seguro de que desea remover el narrador \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "¿Está seguro de que desea remover el narrador \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "¿Está seguro de que desea remover la lista de reproducción \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "¿Está seguro de que desea remover la lista de reproducción \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "¿Está seguro de que desea renombrar el genero \"{0}\" a \"{1}\" de todos los elementos?",
|
"MessageConfirmRenameGenre": "¿Está seguro de que desea renombrar el genero \"{0}\" a \"{1}\" de todos los elementos?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "¿Está seguro de que desea para restaurar del respaldo creado en",
|
"MessageRestoreBackupConfirm": "¿Está seguro de que desea para restaurar del respaldo creado en",
|
||||||
"MessageRestoreBackupWarning": "Restaurar sobrescribirá toda la base de datos localizada en /config y las imágenes de portadas en /metadata/items y /metadata/authors.<br /><br />El respaldo no modifica ningún archivo en las carpetas de su biblioteca. Si ha habilitado la opción del servidor para almacenar portadas y metadata en las carpetas de su biblioteca, esos archivos no se respaldan o sobrescriben.<br /><br />Todos los clientes que usen su servidor se actualizarán automáticamente.",
|
"MessageRestoreBackupWarning": "Restaurar sobrescribirá toda la base de datos localizada en /config y las imágenes de portadas en /metadata/items y /metadata/authors.<br /><br />El respaldo no modifica ningún archivo en las carpetas de su biblioteca. Si ha habilitado la opción del servidor para almacenar portadas y metadata en las carpetas de su biblioteca, esos archivos no se respaldan o sobrescriben.<br /><br />Todos los clientes que usen su servidor se actualizarán automáticamente.",
|
||||||
"MessageSearchResultsFor": "Resultados de la búsqueda de",
|
"MessageSearchResultsFor": "Resultados de la búsqueda de",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "No se pudo establecer la conexión con el servidor",
|
"MessageServerCouldNotBeReached": "No se pudo establecer la conexión con el servidor",
|
||||||
"MessageSetChaptersFromTracksDescription": "Establecer capítulos usando cada archivo de audio como un capítulo y el título del capítulo como el nombre del archivo de audio",
|
"MessageSetChaptersFromTracksDescription": "Establecer capítulos usando cada archivo de audio como un capítulo y el título del capítulo como el nombre del archivo de audio",
|
||||||
"MessageStartPlaybackAtTime": "Iniciar reproducción para \"{0}\" en {1}?",
|
"MessageStartPlaybackAtTime": "Iniciar reproducción para \"{0}\" en {1}?",
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
"ButtonUserEdit": "Modifier l’utilisateur {0}",
|
"ButtonUserEdit": "Modifier l’utilisateur {0}",
|
||||||
"ButtonViewAll": "Afficher tout",
|
"ButtonViewAll": "Afficher tout",
|
||||||
"ButtonYes": "Oui",
|
"ButtonYes": "Oui",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||||
|
"ErrorUploadLacksTitle": "Must have a title",
|
||||||
"HeaderAccount": "Compte",
|
"HeaderAccount": "Compte",
|
||||||
"HeaderAdvanced": "Avancé",
|
"HeaderAdvanced": "Avancé",
|
||||||
"HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise",
|
"HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Auteur (Nom, Prénom)",
|
"LabelAuthorLastFirst": "Auteur (Nom, Prénom)",
|
||||||
"LabelAuthors": "Auteurs",
|
"LabelAuthors": "Auteurs",
|
||||||
"LabelAutoDownloadEpisodes": "Téléchargement automatique d’épisode",
|
"LabelAutoDownloadEpisodes": "Téléchargement automatique d’épisode",
|
||||||
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Auto Launch",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Exemple",
|
"LabelExample": "Exemple",
|
||||||
"LabelExplicit": "Restriction",
|
"LabelExplicit": "Restriction",
|
||||||
"LabelFeedURL": "URL du flux",
|
"LabelFeedURL": "URL du flux",
|
||||||
|
"LabelFetchingMetadata": "Fetching Metadata",
|
||||||
"LabelFile": "Fichier",
|
"LabelFile": "Fichier",
|
||||||
"LabelFileBirthtime": "Création du fichier",
|
"LabelFileBirthtime": "Création du fichier",
|
||||||
"LabelFileModified": "Modification du fichier",
|
"LabelFileModified": "Modification du fichier",
|
||||||
@@ -337,6 +343,8 @@
|
|||||||
"LabelMinute": "Minute",
|
"LabelMinute": "Minute",
|
||||||
"LabelMissing": "Manquant",
|
"LabelMissing": "Manquant",
|
||||||
"LabelMissingParts": "Parties manquantes",
|
"LabelMissingParts": "Parties manquantes",
|
||||||
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "Plus",
|
"LabelMore": "Plus",
|
||||||
"LabelMoreInfo": "Plus d’info",
|
"LabelMoreInfo": "Plus d’info",
|
||||||
"LabelName": "Nom",
|
"LabelName": "Nom",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Région",
|
"LabelRegion": "Région",
|
||||||
"LabelReleaseDate": "Date de parution",
|
"LabelReleaseDate": "Date de parution",
|
||||||
"LabelRemoveCover": "Supprimer la couverture",
|
"LabelRemoveCover": "Supprimer la couverture",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Courriel du propriétaire personnalisé",
|
"LabelRSSFeedCustomOwnerEmail": "Courriel du propriétaire personnalisé",
|
||||||
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
|
"LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé",
|
||||||
"LabelRSSFeedOpen": "Flux RSS ouvert",
|
"LabelRSSFeedOpen": "Flux RSS ouvert",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée",
|
"LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée",
|
||||||
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
|
"LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers",
|
||||||
"LabelUploaderDropFiles": "Déposer des fichiers",
|
"LabelUploaderDropFiles": "Déposer des fichiers",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||||
"LabelUseChapterTrack": "Utiliser la piste du chapitre",
|
"LabelUseChapterTrack": "Utiliser la piste du chapitre",
|
||||||
"LabelUseFullTrack": "Utiliser la piste Complète",
|
"LabelUseFullTrack": "Utiliser la piste Complète",
|
||||||
"LabelUser": "Utilisateur",
|
"LabelUser": "Utilisateur",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?",
|
"MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?",
|
||||||
"MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l’épisode « {0} » ?",
|
"MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l’épisode « {0} » ?",
|
||||||
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?",
|
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "Êtes-vous sûr de vouloir supprimer le narrateur « {0} » ?",
|
"MessageConfirmRemoveNarrator": "Êtes-vous sûr de vouloir supprimer le narrateur « {0} » ?",
|
||||||
"MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture « {0} » ?",
|
"MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture « {0} » ?",
|
||||||
"MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » en « {1} » pour tous les articles ?",
|
"MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » en « {1} » pour tous les articles ?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Êtes-vous certain de vouloir restaurer la sauvegarde créée le",
|
"MessageRestoreBackupConfirm": "Êtes-vous certain de vouloir restaurer la sauvegarde créée le",
|
||||||
"MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.<br /><br />Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br /><br />Tous les clients utilisant votre serveur seront automatiquement mis à jour.",
|
"MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.<br /><br />Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br /><br />Tous les clients utilisant votre serveur seront automatiquement mis à jour.",
|
||||||
"MessageSearchResultsFor": "Résultats de recherche pour",
|
"MessageSearchResultsFor": "Résultats de recherche pour",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Serveur inaccessible",
|
"MessageServerCouldNotBeReached": "Serveur inaccessible",
|
||||||
"MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre",
|
"MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre",
|
||||||
"MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?",
|
"MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?",
|
||||||
|
|||||||
+87
-75
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"ButtonAdd": "ઉમેરો",
|
"ButtonAdd": "ઉમેરો",
|
||||||
"ButtonAddChapters": "પ્રકરણો ઉમેરો",
|
"ButtonAddChapters": "પ્રકરણો ઉમેરો",
|
||||||
"ButtonAddDevice": "Add Device",
|
"ButtonAddDevice": "ઉપકરણ ઉમેરો",
|
||||||
"ButtonAddLibrary": "Add Library",
|
"ButtonAddLibrary": "પુસ્તકાલય ઉમેરો",
|
||||||
"ButtonAddPodcasts": "પોડકાસ્ટ ઉમેરો",
|
"ButtonAddPodcasts": "પોડકાસ્ટ ઉમેરો",
|
||||||
"ButtonAddUser": "Add User",
|
"ButtonAddUser": "વપરાશકર્તા ઉમેરો",
|
||||||
"ButtonAddYourFirstLibrary": "તમારી પ્રથમ પુસ્તકાલય ઉમેરો",
|
"ButtonAddYourFirstLibrary": "તમારી પ્રથમ પુસ્તકાલય ઉમેરો",
|
||||||
"ButtonApply": "લાગુ કરો",
|
"ButtonApply": "લાગુ કરો",
|
||||||
"ButtonApplyChapters": "પ્રકરણો લાગુ કરો",
|
"ButtonApplyChapters": "પ્રકરણો લાગુ કરો",
|
||||||
@@ -58,11 +58,11 @@
|
|||||||
"ButtonRemoveAll": "બધું કાઢી નાખો",
|
"ButtonRemoveAll": "બધું કાઢી નાખો",
|
||||||
"ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો",
|
"ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો",
|
||||||
"ButtonRemoveFromContinueListening": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો",
|
"ButtonRemoveFromContinueListening": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો",
|
||||||
"ButtonRemoveFromContinueReading": "Remove from Continue Reading",
|
"ButtonRemoveFromContinueReading": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો",
|
||||||
"ButtonRemoveSeriesFromContinueSeries": "સાંભળતી સિરીઝ માંથી કાઢી નાખો",
|
"ButtonRemoveSeriesFromContinueSeries": "સાંભળતી સિરીઝ માંથી કાઢી નાખો",
|
||||||
"ButtonReScan": "ફરીથી સ્કેન કરો",
|
"ButtonReScan": "ફરીથી સ્કેન કરો",
|
||||||
"ButtonReset": "રીસેટ કરો",
|
"ButtonReset": "રીસેટ કરો",
|
||||||
"ButtonResetToDefault": "Reset to default",
|
"ButtonResetToDefault": "ડિફોલ્ટ પર રીસેટ કરો",
|
||||||
"ButtonRestore": "પુનઃસ્થાપિત કરો",
|
"ButtonRestore": "પુનઃસ્થાપિત કરો",
|
||||||
"ButtonSave": "સાચવો",
|
"ButtonSave": "સાચવો",
|
||||||
"ButtonSaveAndClose": "સાચવો અને બંધ કરો",
|
"ButtonSaveAndClose": "સાચવો અને બંધ કરો",
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો",
|
"ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો",
|
||||||
"ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો",
|
"ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો",
|
||||||
"ButtonSubmit": "સબમિટ કરો",
|
"ButtonSubmit": "સબમિટ કરો",
|
||||||
"ButtonTest": "Test",
|
"ButtonTest": "પરખ કરો",
|
||||||
"ButtonUpload": "અપલોડ કરો",
|
"ButtonUpload": "અપલોડ કરો",
|
||||||
"ButtonUploadBackup": "બેકઅપ અપલોડ કરો",
|
"ButtonUploadBackup": "બેકઅપ અપલોડ કરો",
|
||||||
"ButtonUploadCover": "કવર અપલોડ કરો",
|
"ButtonUploadCover": "કવર અપલોડ કરો",
|
||||||
@@ -87,81 +87,84 @@
|
|||||||
"ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો",
|
"ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો",
|
||||||
"ButtonViewAll": "બધું જુઓ",
|
"ButtonViewAll": "બધું જુઓ",
|
||||||
"ButtonYes": "હા",
|
"ButtonYes": "હા",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||||
|
"ErrorUploadLacksTitle": "Must have a title",
|
||||||
"HeaderAccount": "એકાઉન્ટ",
|
"HeaderAccount": "એકાઉન્ટ",
|
||||||
"HeaderAdvanced": "અડ્વાન્સડ",
|
"HeaderAdvanced": "અડ્વાન્સડ",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ",
|
"HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ",
|
||||||
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
"HeaderAudiobookTools": "ઓડિયોબુક ફાઇલ વ્યવસ્થાપન ટૂલ્સ",
|
||||||
"HeaderAudioTracks": "Audio Tracks",
|
"HeaderAudioTracks": "ઓડિયો ટ્રેક્સ",
|
||||||
"HeaderAuthentication": "Authentication",
|
"HeaderAuthentication": "Authentication",
|
||||||
"HeaderBackups": "Backups",
|
"HeaderBackups": "બેકઅપ્સ",
|
||||||
"HeaderChangePassword": "Change Password",
|
"HeaderChangePassword": "પાસવર્ડ બદલો",
|
||||||
"HeaderChapters": "Chapters",
|
"HeaderChapters": "પ્રકરણો",
|
||||||
"HeaderChooseAFolder": "Choose a Folder",
|
"HeaderChooseAFolder": "ફોલ્ડર પસંદ કરો",
|
||||||
"HeaderCollection": "Collection",
|
"HeaderCollection": "સંગ્રહ",
|
||||||
"HeaderCollectionItems": "Collection Items",
|
"HeaderCollectionItems": "સંગ્રહ વસ્તુઓ",
|
||||||
"HeaderCover": "Cover",
|
"HeaderCover": "આવરણ",
|
||||||
"HeaderCurrentDownloads": "Current Downloads",
|
"HeaderCurrentDownloads": "વર્તમાન ડાઉનલોડ્સ",
|
||||||
"HeaderDetails": "Details",
|
"HeaderDetails": "વિગતો",
|
||||||
"HeaderDownloadQueue": "Download Queue",
|
"HeaderDownloadQueue": "ડાઉનલોડ કતાર",
|
||||||
"HeaderEbookFiles": "Ebook Files",
|
"HeaderEbookFiles": "ઇબુક ફાઇલો",
|
||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "ઈમેલ",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "ઈમેલ સેટિંગ્સ",
|
||||||
"HeaderEpisodes": "Episodes",
|
"HeaderEpisodes": "એપિસોડ્સ",
|
||||||
"HeaderEreaderDevices": "Ereader Devices",
|
"HeaderEreaderDevices": "ઇરીડર ઉપકરણો",
|
||||||
"HeaderEreaderSettings": "Ereader Settings",
|
"HeaderEreaderSettings": "ઇરીડર સેટિંગ્સ",
|
||||||
"HeaderFiles": "Files",
|
"HeaderFiles": "ફાઇલો",
|
||||||
"HeaderFindChapters": "Find Chapters",
|
"HeaderFindChapters": "પ્રકરણો શોધો",
|
||||||
"HeaderIgnoredFiles": "Ignored Files",
|
"HeaderIgnoredFiles": "અવગણેલી ફાઇલો",
|
||||||
"HeaderItemFiles": "Item Files",
|
"HeaderItemFiles": "વાસ્તુ ની ફાઈલો",
|
||||||
"HeaderItemMetadataUtils": "Item Metadata Utils",
|
"HeaderItemMetadataUtils": "વસ્તુ મેટાડેટા સાધનો",
|
||||||
"HeaderLastListeningSession": "Last Listening Session",
|
"HeaderLastListeningSession": "છેલ્લી સાંભળતી સેશન",
|
||||||
"HeaderLatestEpisodes": "Latest episodes",
|
"HeaderLatestEpisodes": "નવીનતમ એપિસોડ્સ",
|
||||||
"HeaderLibraries": "Libraries",
|
"HeaderLibraries": "પુસ્તકાલયો",
|
||||||
"HeaderLibraryFiles": "Library Files",
|
"HeaderLibraryFiles": "પુસ્તકાલય ફાઇલો",
|
||||||
"HeaderLibraryStats": "Library Stats",
|
"HeaderLibraryStats": "પુસ્તકાલય આંકડા",
|
||||||
"HeaderListeningSessions": "Listening Sessions",
|
"HeaderListeningSessions": "સાંભળતી સેશન્સ",
|
||||||
"HeaderListeningStats": "Listening Stats",
|
"HeaderListeningStats": "સાંભળતી આંકડા",
|
||||||
"HeaderLogin": "Login",
|
"HeaderLogin": "લોગિન",
|
||||||
"HeaderLogs": "Logs",
|
"HeaderLogs": "લોગ્સ",
|
||||||
"HeaderManageGenres": "Manage Genres",
|
"HeaderManageGenres": "જાતિઓ મેનેજ કરો",
|
||||||
"HeaderManageTags": "Manage Tags",
|
"HeaderManageTags": "ટેગ્સ મેનેજ કરો",
|
||||||
"HeaderMapDetails": "Map details",
|
"HeaderMapDetails": "વિગતો મેપ કરો",
|
||||||
"HeaderMatch": "Match",
|
"HeaderMatch": "મેળ ખાતી શોધો",
|
||||||
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
|
"HeaderMetadataOrderOfPrecedence": "મેટાડેટા પ્રાધાન્યતાનો ક્રમ",
|
||||||
"HeaderMetadataToEmbed": "Metadata to embed",
|
"HeaderMetadataToEmbed": "એમ્બેડ કરવા માટે મેટાડેટા",
|
||||||
"HeaderNewAccount": "New Account",
|
"HeaderNewAccount": "નવું એકાઉન્ટ",
|
||||||
"HeaderNewLibrary": "New Library",
|
"HeaderNewLibrary": "નવી પુસ્તકાલય",
|
||||||
"HeaderNotifications": "Notifications",
|
"HeaderNotifications": "સૂચનાઓ",
|
||||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
||||||
"HeaderOpenRSSFeed": "Open RSS Feed",
|
"HeaderOpenRSSFeed": "RSS ફીડ ખોલો",
|
||||||
"HeaderOtherFiles": "Other Files",
|
"HeaderOtherFiles": "અન્ય ફાઇલો",
|
||||||
"HeaderPasswordAuthentication": "Password Authentication",
|
"HeaderPasswordAuthentication": "Password Authentication",
|
||||||
"HeaderPermissions": "Permissions",
|
"HeaderPermissions": "પરવાનગીઓ",
|
||||||
"HeaderPlayerQueue": "Player Queue",
|
"HeaderPlayerQueue": "પ્લેયર કતાર",
|
||||||
"HeaderPlaylist": "Playlist",
|
"HeaderPlaylist": "પ્લેલિસ્ટ",
|
||||||
"HeaderPlaylistItems": "Playlist Items",
|
"HeaderPlaylistItems": "પ્લેલિસ્ટ ની વસ્તુઓ",
|
||||||
"HeaderPodcastsToAdd": "Podcasts to Add",
|
"HeaderPodcastsToAdd": "ઉમેરવા માટે પોડકાસ્ટ્સ",
|
||||||
"HeaderPreviewCover": "Preview Cover",
|
"HeaderPreviewCover": "પૂર્વાવલોકન કવર",
|
||||||
"HeaderRemoveEpisode": "Remove Episode",
|
"HeaderRemoveEpisode": "એપિસોડ કાઢી નાખો",
|
||||||
"HeaderRemoveEpisodes": "Remove {0} Episodes",
|
"HeaderRemoveEpisodes": "{0} એપિસોડ્સ કાઢી નાખો",
|
||||||
"HeaderRSSFeedGeneral": "RSS Details",
|
"HeaderRSSFeedGeneral": "સામાન્ય RSS ફીડ",
|
||||||
"HeaderRSSFeedIsOpen": "RSS Feed is Open",
|
"HeaderRSSFeedIsOpen": "RSS ફીડ ખોલેલી છે",
|
||||||
"HeaderRSSFeeds": "RSS Feeds",
|
"HeaderRSSFeeds": "RSS ફીડ્સ",
|
||||||
"HeaderSavedMediaProgress": "Saved Media Progress",
|
"HeaderSavedMediaProgress": "સાચવેલ મીડિયા પ્રગતિ",
|
||||||
"HeaderSchedule": "Schedule",
|
"HeaderSchedule": "સમયપત્રક",
|
||||||
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
"HeaderScheduleLibraryScans": "પુસ્તકાલય સ્કેન સમયપત્રક",
|
||||||
"HeaderSession": "Session",
|
"HeaderSession": "સેશન",
|
||||||
"HeaderSetBackupSchedule": "Set Backup Schedule",
|
"HeaderSetBackupSchedule": "બેકઅપ સમયપત્રક સેટ કરો",
|
||||||
"HeaderSettings": "Settings",
|
"HeaderSettings": "સેટિંગ્સ",
|
||||||
"HeaderSettingsDisplay": "Display",
|
"HeaderSettingsDisplay": "ડિસ્પ્લે સેટિંગ્સ",
|
||||||
"HeaderSettingsExperimental": "Experimental Features",
|
"HeaderSettingsExperimental": "પ્રયોગશીલ સેટિંગ્સ",
|
||||||
"HeaderSettingsGeneral": "General",
|
"HeaderSettingsGeneral": "સામાન્ય સેટિંગ્સ",
|
||||||
"HeaderSettingsScanner": "Scanner",
|
"HeaderSettingsScanner": "સ્કેનર સેટિંગ્સ",
|
||||||
"HeaderSleepTimer": "Sleep Timer",
|
"HeaderSleepTimer": "સ્લીપ ટાઈમર",
|
||||||
"HeaderStatsLargestItems": "Largest Items",
|
"HeaderStatsLargestItems": "સૌથી મોટી વસ્તુઓ",
|
||||||
"HeaderStatsLongestItems": "Longest Items (hrs)",
|
"HeaderStatsLongestItems": "સૌથી લાંબી વસ્તુઓ (કલાક)",
|
||||||
"HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)",
|
"HeaderStatsMinutesListeningChart": "સાંભળવાની મિનિટ (છેલ્લા ૭ દિવસ)",
|
||||||
"HeaderStatsRecentSessions": "Recent Sessions",
|
"HeaderStatsRecentSessions": "છેલ્લી સાંભળતી સેશન્સ",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Authors",
|
"HeaderStatsTop10Authors": "Top 10 Authors",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Genres",
|
"HeaderStatsTop5Genres": "Top 5 Genres",
|
||||||
"HeaderTableOfContents": "Table of Contents",
|
"HeaderTableOfContents": "Table of Contents",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||||
"LabelAuthors": "Authors",
|
"LabelAuthors": "Authors",
|
||||||
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
|
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
|
||||||
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Auto Launch",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Example",
|
"LabelExample": "Example",
|
||||||
"LabelExplicit": "Explicit",
|
"LabelExplicit": "Explicit",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
|
"LabelFetchingMetadata": "Fetching Metadata",
|
||||||
"LabelFile": "File",
|
"LabelFile": "File",
|
||||||
"LabelFileBirthtime": "File Birthtime",
|
"LabelFileBirthtime": "File Birthtime",
|
||||||
"LabelFileModified": "File Modified",
|
"LabelFileModified": "File Modified",
|
||||||
@@ -337,6 +343,8 @@
|
|||||||
"LabelMinute": "Minute",
|
"LabelMinute": "Minute",
|
||||||
"LabelMissing": "Missing",
|
"LabelMissing": "Missing",
|
||||||
"LabelMissingParts": "Missing Parts",
|
"LabelMissingParts": "Missing Parts",
|
||||||
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "More",
|
"LabelMore": "More",
|
||||||
"LabelMoreInfo": "More Info",
|
"LabelMoreInfo": "More Info",
|
||||||
"LabelName": "Name",
|
"LabelName": "Name",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Region",
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Release Date",
|
"LabelReleaseDate": "Release Date",
|
||||||
"LabelRemoveCover": "Remove cover",
|
"LabelRemoveCover": "Remove cover",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||||
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
||||||
"LabelRSSFeedOpen": "RSS Feed Open",
|
"LabelRSSFeedOpen": "RSS Feed Open",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
|
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
|
||||||
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
|
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
|
||||||
"LabelUploaderDropFiles": "Drop files",
|
"LabelUploaderDropFiles": "Drop files",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||||
"LabelUseChapterTrack": "Use chapter track",
|
"LabelUseChapterTrack": "Use chapter track",
|
||||||
"LabelUseFullTrack": "Use full track",
|
"LabelUseFullTrack": "Use full track",
|
||||||
"LabelUser": "User",
|
"LabelUser": "User",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
|
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
|
||||||
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
|
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
|
||||||
"MessageSearchResultsFor": "Search results for",
|
"MessageSearchResultsFor": "Search results for",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Server could not be reached",
|
"MessageServerCouldNotBeReached": "Server could not be reached",
|
||||||
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
||||||
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
|
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
"ButtonUserEdit": "उपयोगकर्ता {0} को संपादित करें",
|
"ButtonUserEdit": "उपयोगकर्ता {0} को संपादित करें",
|
||||||
"ButtonViewAll": "सभी को देखें",
|
"ButtonViewAll": "सभी को देखें",
|
||||||
"ButtonYes": "हाँ",
|
"ButtonYes": "हाँ",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||||
|
"ErrorUploadLacksTitle": "Must have a title",
|
||||||
"HeaderAccount": "खाता",
|
"HeaderAccount": "खाता",
|
||||||
"HeaderAdvanced": "विकसित",
|
"HeaderAdvanced": "विकसित",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise अधिसूचना सेटिंग्स",
|
"HeaderAppriseNotificationSettings": "Apprise अधिसूचना सेटिंग्स",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||||
"LabelAuthors": "Authors",
|
"LabelAuthors": "Authors",
|
||||||
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
|
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
|
||||||
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Auto Launch",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Example",
|
"LabelExample": "Example",
|
||||||
"LabelExplicit": "Explicit",
|
"LabelExplicit": "Explicit",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
|
"LabelFetchingMetadata": "Fetching Metadata",
|
||||||
"LabelFile": "File",
|
"LabelFile": "File",
|
||||||
"LabelFileBirthtime": "File Birthtime",
|
"LabelFileBirthtime": "File Birthtime",
|
||||||
"LabelFileModified": "File Modified",
|
"LabelFileModified": "File Modified",
|
||||||
@@ -337,6 +343,8 @@
|
|||||||
"LabelMinute": "Minute",
|
"LabelMinute": "Minute",
|
||||||
"LabelMissing": "Missing",
|
"LabelMissing": "Missing",
|
||||||
"LabelMissingParts": "Missing Parts",
|
"LabelMissingParts": "Missing Parts",
|
||||||
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "More",
|
"LabelMore": "More",
|
||||||
"LabelMoreInfo": "More Info",
|
"LabelMoreInfo": "More Info",
|
||||||
"LabelName": "Name",
|
"LabelName": "Name",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Region",
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Release Date",
|
"LabelReleaseDate": "Release Date",
|
||||||
"LabelRemoveCover": "Remove cover",
|
"LabelRemoveCover": "Remove cover",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||||
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
||||||
"LabelRSSFeedOpen": "RSS Feed Open",
|
"LabelRSSFeedOpen": "RSS Feed Open",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
|
"LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located",
|
||||||
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
|
"LabelUploaderDragAndDrop": "Drag & drop files or folders",
|
||||||
"LabelUploaderDropFiles": "Drop files",
|
"LabelUploaderDropFiles": "Drop files",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||||
"LabelUseChapterTrack": "Use chapter track",
|
"LabelUseChapterTrack": "Use chapter track",
|
||||||
"LabelUseFullTrack": "Use full track",
|
"LabelUseFullTrack": "Use full track",
|
||||||
"LabelUser": "User",
|
"LabelUser": "User",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
|
"MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on",
|
||||||
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
|
"MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.",
|
||||||
"MessageSearchResultsFor": "Search results for",
|
"MessageSearchResultsFor": "Search results for",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Server could not be reached",
|
"MessageServerCouldNotBeReached": "Server could not be reached",
|
||||||
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
||||||
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
|
"MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?",
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
"ButtonUserEdit": "Edit user {0}",
|
"ButtonUserEdit": "Edit user {0}",
|
||||||
"ButtonViewAll": "Prikaži sve",
|
"ButtonViewAll": "Prikaži sve",
|
||||||
"ButtonYes": "Da",
|
"ButtonYes": "Da",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||||
|
"ErrorUploadLacksTitle": "Must have a title",
|
||||||
"HeaderAccount": "Korisnički račun",
|
"HeaderAccount": "Korisnički račun",
|
||||||
"HeaderAdvanced": "Napredno",
|
"HeaderAdvanced": "Napredno",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Author (Last, First)",
|
"LabelAuthorLastFirst": "Author (Last, First)",
|
||||||
"LabelAuthors": "Autori",
|
"LabelAuthors": "Autori",
|
||||||
"LabelAutoDownloadEpisodes": "Automatski preuzmi epizode",
|
"LabelAutoDownloadEpisodes": "Automatski preuzmi epizode",
|
||||||
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Auto Launch",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Example",
|
"LabelExample": "Example",
|
||||||
"LabelExplicit": "Explicit",
|
"LabelExplicit": "Explicit",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
|
"LabelFetchingMetadata": "Fetching Metadata",
|
||||||
"LabelFile": "Datoteka",
|
"LabelFile": "Datoteka",
|
||||||
"LabelFileBirthtime": "File Birthtime",
|
"LabelFileBirthtime": "File Birthtime",
|
||||||
"LabelFileModified": "File Modified",
|
"LabelFileModified": "File Modified",
|
||||||
@@ -337,6 +343,8 @@
|
|||||||
"LabelMinute": "Minuta",
|
"LabelMinute": "Minuta",
|
||||||
"LabelMissing": "Nedostaje",
|
"LabelMissing": "Nedostaje",
|
||||||
"LabelMissingParts": "Nedostajali dijelovi",
|
"LabelMissingParts": "Nedostajali dijelovi",
|
||||||
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "Više",
|
"LabelMore": "Više",
|
||||||
"LabelMoreInfo": "More Info",
|
"LabelMoreInfo": "More Info",
|
||||||
"LabelName": "Ime",
|
"LabelName": "Ime",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Regija",
|
"LabelRegion": "Regija",
|
||||||
"LabelReleaseDate": "Datum izlaska",
|
"LabelReleaseDate": "Datum izlaska",
|
||||||
"LabelRemoveCover": "Remove cover",
|
"LabelRemoveCover": "Remove cover",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||||
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
||||||
"LabelRSSFeedOpen": "RSS Feed Open",
|
"LabelRSSFeedOpen": "RSS Feed Open",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Dozvoli postavljanje novih detalja za odabrane knjige nakon što je match pronađen",
|
"LabelUpdateDetailsHelp": "Dozvoli postavljanje novih detalja za odabrane knjige nakon što je match pronađen",
|
||||||
"LabelUploaderDragAndDrop": "Drag & Drop datoteke ili foldere",
|
"LabelUploaderDragAndDrop": "Drag & Drop datoteke ili foldere",
|
||||||
"LabelUploaderDropFiles": "Ubaci datoteke",
|
"LabelUploaderDropFiles": "Ubaci datoteke",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||||
"LabelUseChapterTrack": "Koristi poglavlja track",
|
"LabelUseChapterTrack": "Koristi poglavlja track",
|
||||||
"LabelUseFullTrack": "Koristi cijeli track",
|
"LabelUseFullTrack": "Koristi cijeli track",
|
||||||
"LabelUser": "Korisnik",
|
"LabelUser": "Korisnik",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?",
|
"MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?",
|
"MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Jeste li sigurni da želite povratiti backup kreiran",
|
"MessageRestoreBackupConfirm": "Jeste li sigurni da želite povratiti backup kreiran",
|
||||||
"MessageRestoreBackupWarning": "Povračanje backupa će zamijeniti postoječu bazu podataka u /config i slike covera u /metadata/items i /metadata/authors.<br /><br />Backups ne modificiraju nikakve datoteke u folderu od biblioteke. Ako imate uključene server postavke da spremate cover i metapodtake u folderu od biblioteke, onda oni neće biti backupani ili overwritten.<br /><br />Svi klijenti koji koriste tvoj server će biti automatski osvježeni.",
|
"MessageRestoreBackupWarning": "Povračanje backupa će zamijeniti postoječu bazu podataka u /config i slike covera u /metadata/items i /metadata/authors.<br /><br />Backups ne modificiraju nikakve datoteke u folderu od biblioteke. Ako imate uključene server postavke da spremate cover i metapodtake u folderu od biblioteke, onda oni neće biti backupani ili overwritten.<br /><br />Svi klijenti koji koriste tvoj server će biti automatski osvježeni.",
|
||||||
"MessageSearchResultsFor": "Traži rezultate za",
|
"MessageSearchResultsFor": "Traži rezultate za",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Server ne može biti kontaktiran",
|
"MessageServerCouldNotBeReached": "Server ne može biti kontaktiran",
|
||||||
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
||||||
"MessageStartPlaybackAtTime": "Pokreni reprodukciju za \"{0}\" na {1}?",
|
"MessageStartPlaybackAtTime": "Pokreni reprodukciju za \"{0}\" na {1}?",
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
"ButtonUserEdit": "Modifica Utente {0}",
|
"ButtonUserEdit": "Modifica Utente {0}",
|
||||||
"ButtonViewAll": "Mostra Tutto",
|
"ButtonViewAll": "Mostra Tutto",
|
||||||
"ButtonYes": "Si",
|
"ButtonYes": "Si",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||||
|
"ErrorUploadLacksTitle": "Must have a title",
|
||||||
"HeaderAccount": "Account",
|
"HeaderAccount": "Account",
|
||||||
"HeaderAdvanced": "Avanzate",
|
"HeaderAdvanced": "Avanzate",
|
||||||
"HeaderAppriseNotificationSettings": "Apprendi le impostazioni di Notifica",
|
"HeaderAppriseNotificationSettings": "Apprendi le impostazioni di Notifica",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Autori (Per Cognome)",
|
"LabelAuthorLastFirst": "Autori (Per Cognome)",
|
||||||
"LabelAuthors": "Autori",
|
"LabelAuthors": "Autori",
|
||||||
"LabelAutoDownloadEpisodes": "Auto Download Episodi",
|
"LabelAutoDownloadEpisodes": "Auto Download Episodi",
|
||||||
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Auto Launch",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Esempio",
|
"LabelExample": "Esempio",
|
||||||
"LabelExplicit": "Esplicito",
|
"LabelExplicit": "Esplicito",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
|
"LabelFetchingMetadata": "Fetching Metadata",
|
||||||
"LabelFile": "File",
|
"LabelFile": "File",
|
||||||
"LabelFileBirthtime": "Data Creazione",
|
"LabelFileBirthtime": "Data Creazione",
|
||||||
"LabelFileModified": "Ultima modifica",
|
"LabelFileModified": "Ultima modifica",
|
||||||
@@ -337,6 +343,8 @@
|
|||||||
"LabelMinute": "Minuto",
|
"LabelMinute": "Minuto",
|
||||||
"LabelMissing": "Altro",
|
"LabelMissing": "Altro",
|
||||||
"LabelMissingParts": "Parti rimantenti",
|
"LabelMissingParts": "Parti rimantenti",
|
||||||
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "Molto",
|
"LabelMore": "Molto",
|
||||||
"LabelMoreInfo": "Più Info",
|
"LabelMoreInfo": "Più Info",
|
||||||
"LabelName": "Nome",
|
"LabelName": "Nome",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Regione",
|
"LabelRegion": "Regione",
|
||||||
"LabelReleaseDate": "Data Release",
|
"LabelReleaseDate": "Data Release",
|
||||||
"LabelRemoveCover": "Rimuovi cover",
|
"LabelRemoveCover": "Rimuovi cover",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Email del proprietario personalizzato",
|
"LabelRSSFeedCustomOwnerEmail": "Email del proprietario personalizzato",
|
||||||
"LabelRSSFeedCustomOwnerName": "Nome del proprietario personalizzato",
|
"LabelRSSFeedCustomOwnerName": "Nome del proprietario personalizzato",
|
||||||
"LabelRSSFeedOpen": "RSS Feed Aperto",
|
"LabelRSSFeedOpen": "RSS Feed Aperto",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Consenti la sovrascrittura dei dettagli esistenti per i libri selezionati quando viene individuata una corrispondenza",
|
"LabelUpdateDetailsHelp": "Consenti la sovrascrittura dei dettagli esistenti per i libri selezionati quando viene individuata una corrispondenza",
|
||||||
"LabelUploaderDragAndDrop": "Drag & drop file o Cartelle",
|
"LabelUploaderDragAndDrop": "Drag & drop file o Cartelle",
|
||||||
"LabelUploaderDropFiles": "Elimina file",
|
"LabelUploaderDropFiles": "Elimina file",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||||
"LabelUseChapterTrack": "Usa il Capitolo della Traccia",
|
"LabelUseChapterTrack": "Usa il Capitolo della Traccia",
|
||||||
"LabelUseFullTrack": "Usa la traccia totale",
|
"LabelUseFullTrack": "Usa la traccia totale",
|
||||||
"LabelUser": "Utente",
|
"LabelUser": "Utente",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
|
"MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "Sei sicuro di voler rimuovere il narratore \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Sei sicuro di voler rimuovere il narratore \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Sei sicuro di voler rimuovere la tua playlist \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Sei sicuro di voler rimuovere la tua playlist \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Sei sicuro di voler rinominare il genere \"{0}\" in \"{1}\" per tutti gli oggetti?",
|
"MessageConfirmRenameGenre": "Sei sicuro di voler rinominare il genere \"{0}\" in \"{1}\" per tutti gli oggetti?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Sei sicuro di voler ripristinare il backup creato su",
|
"MessageRestoreBackupConfirm": "Sei sicuro di voler ripristinare il backup creato su",
|
||||||
"MessageRestoreBackupWarning": "Il ripristino di un backup sovrascriverà l'intero database situato in /config e sovrascrive le immagini in /metadata/items & /metadata/authors.<br /><br />I backup non modificano alcun file nelle cartelle della libreria. Se hai abilitato le impostazioni del server per archiviare copertine e metadati nelle cartelle della libreria, questi non vengono sottoposti a backup o sovrascritti.<br /><br />Tutti i client che utilizzano il tuo server verranno aggiornati automaticamente.",
|
"MessageRestoreBackupWarning": "Il ripristino di un backup sovrascriverà l'intero database situato in /config e sovrascrive le immagini in /metadata/items & /metadata/authors.<br /><br />I backup non modificano alcun file nelle cartelle della libreria. Se hai abilitato le impostazioni del server per archiviare copertine e metadati nelle cartelle della libreria, questi non vengono sottoposti a backup o sovrascritti.<br /><br />Tutti i client che utilizzano il tuo server verranno aggiornati automaticamente.",
|
||||||
"MessageSearchResultsFor": "cerca risultati per",
|
"MessageSearchResultsFor": "cerca risultati per",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Impossibile raggiungere il server",
|
"MessageServerCouldNotBeReached": "Impossibile raggiungere il server",
|
||||||
"MessageSetChaptersFromTracksDescription": "Impostare i capitoli utilizzando ciascun file audio come capitolo e il titolo del capitolo come nome del file audio",
|
"MessageSetChaptersFromTracksDescription": "Impostare i capitoli utilizzando ciascun file audio come capitolo e il titolo del capitolo come nome del file audio",
|
||||||
"MessageStartPlaybackAtTime": "Avvia la riproduzione per \"{0}\" a {1}?",
|
"MessageStartPlaybackAtTime": "Avvia la riproduzione per \"{0}\" a {1}?",
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
"ButtonUserEdit": "Redaguoti naudotoją {0}",
|
"ButtonUserEdit": "Redaguoti naudotoją {0}",
|
||||||
"ButtonViewAll": "Peržiūrėti visus",
|
"ButtonViewAll": "Peržiūrėti visus",
|
||||||
"ButtonYes": "Taip",
|
"ButtonYes": "Taip",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||||
|
"ErrorUploadLacksTitle": "Must have a title",
|
||||||
"HeaderAccount": "Paskyra",
|
"HeaderAccount": "Paskyra",
|
||||||
"HeaderAdvanced": "Papildomi",
|
"HeaderAdvanced": "Papildomi",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise pranešimo nustatymai",
|
"HeaderAppriseNotificationSettings": "Apprise pranešimo nustatymai",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Autorius (Pavardė, Vardas)",
|
"LabelAuthorLastFirst": "Autorius (Pavardė, Vardas)",
|
||||||
"LabelAuthors": "Autoriai",
|
"LabelAuthors": "Autoriai",
|
||||||
"LabelAutoDownloadEpisodes": "Automatiškai atsisiųsti epizodus",
|
"LabelAutoDownloadEpisodes": "Automatiškai atsisiųsti epizodus",
|
||||||
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Auto Launch",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Pavyzdys",
|
"LabelExample": "Pavyzdys",
|
||||||
"LabelExplicit": "Suaugusiems",
|
"LabelExplicit": "Suaugusiems",
|
||||||
"LabelFeedURL": "Srauto URL",
|
"LabelFeedURL": "Srauto URL",
|
||||||
|
"LabelFetchingMetadata": "Fetching Metadata",
|
||||||
"LabelFile": "Failas",
|
"LabelFile": "Failas",
|
||||||
"LabelFileBirthtime": "Failo kūrimo laikas",
|
"LabelFileBirthtime": "Failo kūrimo laikas",
|
||||||
"LabelFileModified": "Failo keitimo laikas",
|
"LabelFileModified": "Failo keitimo laikas",
|
||||||
@@ -337,6 +343,8 @@
|
|||||||
"LabelMinute": "Minutė",
|
"LabelMinute": "Minutė",
|
||||||
"LabelMissing": "Trūksta",
|
"LabelMissing": "Trūksta",
|
||||||
"LabelMissingParts": "Trūkstamos dalys",
|
"LabelMissingParts": "Trūkstamos dalys",
|
||||||
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "Daugiau",
|
"LabelMore": "Daugiau",
|
||||||
"LabelMoreInfo": "Daugiau informacijos",
|
"LabelMoreInfo": "Daugiau informacijos",
|
||||||
"LabelName": "Pavadinimas",
|
"LabelName": "Pavadinimas",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Regionas",
|
"LabelRegion": "Regionas",
|
||||||
"LabelReleaseDate": "Išleidimo data",
|
"LabelReleaseDate": "Išleidimo data",
|
||||||
"LabelRemoveCover": "Pašalinti viršelį",
|
"LabelRemoveCover": "Pašalinti viršelį",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Pasirinktinis savininko el. paštas",
|
"LabelRSSFeedCustomOwnerEmail": "Pasirinktinis savininko el. paštas",
|
||||||
"LabelRSSFeedCustomOwnerName": "Pasirinktinis savininko vardas",
|
"LabelRSSFeedCustomOwnerName": "Pasirinktinis savininko vardas",
|
||||||
"LabelRSSFeedOpen": "Atidarytas RSS srautas",
|
"LabelRSSFeedOpen": "Atidarytas RSS srautas",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Leisti perrašyti esamus duomenis pasirinktoms knygoms, kai yra rasta atitikmenų",
|
"LabelUpdateDetailsHelp": "Leisti perrašyti esamus duomenis pasirinktoms knygoms, kai yra rasta atitikmenų",
|
||||||
"LabelUploaderDragAndDrop": "Tempkite ir paleiskite failus ar aplankus",
|
"LabelUploaderDragAndDrop": "Tempkite ir paleiskite failus ar aplankus",
|
||||||
"LabelUploaderDropFiles": "Nutempti failus",
|
"LabelUploaderDropFiles": "Nutempti failus",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||||
"LabelUseChapterTrack": "Naudoti skyrių takelį",
|
"LabelUseChapterTrack": "Naudoti skyrių takelį",
|
||||||
"LabelUseFullTrack": "Naudoti visą takelį",
|
"LabelUseFullTrack": "Naudoti visą takelį",
|
||||||
"LabelUser": "Vartotojas",
|
"LabelUser": "Vartotojas",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "Ar tikrai norite pašalinti kolekciją \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Ar tikrai norite pašalinti kolekciją \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Ar tikrai norite pašalinti epizodą \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Ar tikrai norite pašalinti epizodą \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "Ar tikrai norite pašalinti {0} epizodus?",
|
"MessageConfirmRemoveEpisodes": "Ar tikrai norite pašalinti {0} epizodus?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "Ar tikrai norite pašalinti skaitytoją \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Ar tikrai norite pašalinti skaitytoją \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Ar tikrai norite pašalinti savo grojaraštį \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Ar tikrai norite pašalinti savo grojaraštį \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Ar tikrai norite pervadinti žanrą \"{0}\" į \"{1}\" visiems elementams?",
|
"MessageConfirmRenameGenre": "Ar tikrai norite pervadinti žanrą \"{0}\" į \"{1}\" visiems elementams?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Ar tikrai norite atkurti atsarginę kopiją, sukurtą",
|
"MessageRestoreBackupConfirm": "Ar tikrai norite atkurti atsarginę kopiją, sukurtą",
|
||||||
"MessageRestoreBackupWarning": "Atkurdami atsarginę kopiją perrašysite visą duomenų bazę, esančią /config ir viršelių vaizdus /metadata/items ir /metadata/authors.<br /><br />Atsarginės kopijos nekeičia jokių failų jūsų bibliotekos aplankuose. Jei esate įgalinę serverio nustatymus, kad viršelio meną ir metaduomenis saugotumėte savo bibliotekos aplankuose, šie neperrašomi ar atkuriami.<br /><br />Visi klientai, naudojantys jūsų serverį, bus automatiškai atnaujinti.",
|
"MessageRestoreBackupWarning": "Atkurdami atsarginę kopiją perrašysite visą duomenų bazę, esančią /config ir viršelių vaizdus /metadata/items ir /metadata/authors.<br /><br />Atsarginės kopijos nekeičia jokių failų jūsų bibliotekos aplankuose. Jei esate įgalinę serverio nustatymus, kad viršelio meną ir metaduomenis saugotumėte savo bibliotekos aplankuose, šie neperrašomi ar atkuriami.<br /><br />Visi klientai, naudojantys jūsų serverį, bus automatiškai atnaujinti.",
|
||||||
"MessageSearchResultsFor": "Paieškos rezultatai „{0}“",
|
"MessageSearchResultsFor": "Paieškos rezultatai „{0}“",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Nepavyko pasiekti serverio",
|
"MessageServerCouldNotBeReached": "Nepavyko pasiekti serverio",
|
||||||
"MessageSetChaptersFromTracksDescription": "Nustatyti skyrius, naudojant kiekvieną garso failą kaip skyrių ir skyriaus pavadinimą kaip garso failo pavadinimą",
|
"MessageSetChaptersFromTracksDescription": "Nustatyti skyrius, naudojant kiekvieną garso failą kaip skyrių ir skyriaus pavadinimą kaip garso failo pavadinimą",
|
||||||
"MessageStartPlaybackAtTime": "Paleisti klausymą „{0}“ nuo {1}?",
|
"MessageStartPlaybackAtTime": "Paleisti klausymą „{0}“ nuo {1}?",
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
"ButtonUserEdit": "Wijzig gebruiker {0}",
|
"ButtonUserEdit": "Wijzig gebruiker {0}",
|
||||||
"ButtonViewAll": "Toon alle",
|
"ButtonViewAll": "Toon alle",
|
||||||
"ButtonYes": "Ja",
|
"ButtonYes": "Ja",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||||
|
"ErrorUploadLacksTitle": "Must have a title",
|
||||||
"HeaderAccount": "Account",
|
"HeaderAccount": "Account",
|
||||||
"HeaderAdvanced": "Geavanceerd",
|
"HeaderAdvanced": "Geavanceerd",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
|
"HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Auteur (Achternaam, Voornaam)",
|
"LabelAuthorLastFirst": "Auteur (Achternaam, Voornaam)",
|
||||||
"LabelAuthors": "Auteurs",
|
"LabelAuthors": "Auteurs",
|
||||||
"LabelAutoDownloadEpisodes": "Afleveringen automatisch downloaden",
|
"LabelAutoDownloadEpisodes": "Afleveringen automatisch downloaden",
|
||||||
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Auto Launch",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Voorbeeld",
|
"LabelExample": "Voorbeeld",
|
||||||
"LabelExplicit": "Expliciet",
|
"LabelExplicit": "Expliciet",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
|
"LabelFetchingMetadata": "Fetching Metadata",
|
||||||
"LabelFile": "Bestand",
|
"LabelFile": "Bestand",
|
||||||
"LabelFileBirthtime": "Aanmaaktijd bestand",
|
"LabelFileBirthtime": "Aanmaaktijd bestand",
|
||||||
"LabelFileModified": "Bestand gewijzigd",
|
"LabelFileModified": "Bestand gewijzigd",
|
||||||
@@ -337,6 +343,8 @@
|
|||||||
"LabelMinute": "Minuut",
|
"LabelMinute": "Minuut",
|
||||||
"LabelMissing": "Ontbrekend",
|
"LabelMissing": "Ontbrekend",
|
||||||
"LabelMissingParts": "Ontbrekende delen",
|
"LabelMissingParts": "Ontbrekende delen",
|
||||||
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "Meer",
|
"LabelMore": "Meer",
|
||||||
"LabelMoreInfo": "Meer info",
|
"LabelMoreInfo": "Meer info",
|
||||||
"LabelName": "Naam",
|
"LabelName": "Naam",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Regio",
|
"LabelRegion": "Regio",
|
||||||
"LabelReleaseDate": "Verschijningsdatum",
|
"LabelReleaseDate": "Verschijningsdatum",
|
||||||
"LabelRemoveCover": "Verwijder cover",
|
"LabelRemoveCover": "Verwijder cover",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Aangepast e-mailadres eigenaar",
|
"LabelRSSFeedCustomOwnerEmail": "Aangepast e-mailadres eigenaar",
|
||||||
"LabelRSSFeedCustomOwnerName": "Aangepaste naam eigenaar",
|
"LabelRSSFeedCustomOwnerName": "Aangepaste naam eigenaar",
|
||||||
"LabelRSSFeedOpen": "RSS-feed open",
|
"LabelRSSFeedOpen": "RSS-feed open",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden",
|
"LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden",
|
||||||
"LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen",
|
"LabelUploaderDragAndDrop": "Slepen & neerzeten van bestanden of mappen",
|
||||||
"LabelUploaderDropFiles": "Bestanden neerzetten",
|
"LabelUploaderDropFiles": "Bestanden neerzetten",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||||
"LabelUseChapterTrack": "Gebruik hoofdstuktrack",
|
"LabelUseChapterTrack": "Gebruik hoofdstuktrack",
|
||||||
"LabelUseFullTrack": "Gebruik volledige track",
|
"LabelUseFullTrack": "Gebruik volledige track",
|
||||||
"LabelUser": "Gebruiker",
|
"LabelUser": "Gebruiker",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?",
|
"MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?",
|
||||||
"MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?",
|
"MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?",
|
||||||
"MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?",
|
"MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "Weet je zeker dat je verteller \"{0}\" wil verwijderen?",
|
"MessageConfirmRemoveNarrator": "Weet je zeker dat je verteller \"{0}\" wil verwijderen?",
|
||||||
"MessageConfirmRemovePlaylist": "Weet je zeker dat je je afspeellijst \"{0}\" wil verwijderen?",
|
"MessageConfirmRemovePlaylist": "Weet je zeker dat je je afspeellijst \"{0}\" wil verwijderen?",
|
||||||
"MessageConfirmRenameGenre": "Weet je zeker dat je genre \"{0}\" wil hernoemen naar \"{1}\" voor alle onderdelen?",
|
"MessageConfirmRenameGenre": "Weet je zeker dat je genre \"{0}\" wil hernoemen naar \"{1}\" voor alle onderdelen?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op",
|
"MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op",
|
||||||
"MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle clients die van je server gebruik maken zullen automatisch worden ververst.",
|
"MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle clients die van je server gebruik maken zullen automatisch worden ververst.",
|
||||||
"MessageSearchResultsFor": "Zoekresultaten voor",
|
"MessageSearchResultsFor": "Zoekresultaten voor",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Server niet bereikbaar",
|
"MessageServerCouldNotBeReached": "Server niet bereikbaar",
|
||||||
"MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel",
|
"MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel",
|
||||||
"MessageStartPlaybackAtTime": "Afspelen van \"{0}\" beginnen op {1}?",
|
"MessageStartPlaybackAtTime": "Afspelen van \"{0}\" beginnen op {1}?",
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
"ButtonUserEdit": "Rediger bruker {0}",
|
"ButtonUserEdit": "Rediger bruker {0}",
|
||||||
"ButtonViewAll": "Vis alt",
|
"ButtonViewAll": "Vis alt",
|
||||||
"ButtonYes": "Ja",
|
"ButtonYes": "Ja",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||||
|
"ErrorUploadLacksTitle": "Must have a title",
|
||||||
"HeaderAccount": "Konto",
|
"HeaderAccount": "Konto",
|
||||||
"HeaderAdvanced": "Avansert",
|
"HeaderAdvanced": "Avansert",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger",
|
"HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)",
|
"LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)",
|
||||||
"LabelAuthors": "Forfattere",
|
"LabelAuthors": "Forfattere",
|
||||||
"LabelAutoDownloadEpisodes": "Last ned episoder automatisk",
|
"LabelAutoDownloadEpisodes": "Last ned episoder automatisk",
|
||||||
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Auto Launch",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Eksempel",
|
"LabelExample": "Eksempel",
|
||||||
"LabelExplicit": "Eksplisitt",
|
"LabelExplicit": "Eksplisitt",
|
||||||
"LabelFeedURL": "Feed Adresse",
|
"LabelFeedURL": "Feed Adresse",
|
||||||
|
"LabelFetchingMetadata": "Fetching Metadata",
|
||||||
"LabelFile": "Fil",
|
"LabelFile": "Fil",
|
||||||
"LabelFileBirthtime": "Fil Opprettelsesdato",
|
"LabelFileBirthtime": "Fil Opprettelsesdato",
|
||||||
"LabelFileModified": "Fil Endret",
|
"LabelFileModified": "Fil Endret",
|
||||||
@@ -337,6 +343,8 @@
|
|||||||
"LabelMinute": "Minutt",
|
"LabelMinute": "Minutt",
|
||||||
"LabelMissing": "Mangler",
|
"LabelMissing": "Mangler",
|
||||||
"LabelMissingParts": "Manglende deler",
|
"LabelMissingParts": "Manglende deler",
|
||||||
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "Mer",
|
"LabelMore": "Mer",
|
||||||
"LabelMoreInfo": "Mer info",
|
"LabelMoreInfo": "Mer info",
|
||||||
"LabelName": "Navn",
|
"LabelName": "Navn",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Region",
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Utgivelsesdato",
|
"LabelReleaseDate": "Utgivelsesdato",
|
||||||
"LabelRemoveCover": "Fjern omslag",
|
"LabelRemoveCover": "Fjern omslag",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Tilpasset eier Epost",
|
"LabelRSSFeedCustomOwnerEmail": "Tilpasset eier Epost",
|
||||||
"LabelRSSFeedCustomOwnerName": "Tilpasset eier Navn",
|
"LabelRSSFeedCustomOwnerName": "Tilpasset eier Navn",
|
||||||
"LabelRSSFeedOpen": "RSS Feed åpne",
|
"LabelRSSFeedOpen": "RSS Feed åpne",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Tillat overskriving av eksisterende detaljer for de valgte bøkene når en lik bok er funnet",
|
"LabelUpdateDetailsHelp": "Tillat overskriving av eksisterende detaljer for de valgte bøkene når en lik bok er funnet",
|
||||||
"LabelUploaderDragAndDrop": "Dra og slipp filer eller mapper",
|
"LabelUploaderDragAndDrop": "Dra og slipp filer eller mapper",
|
||||||
"LabelUploaderDropFiles": "Slipp filer",
|
"LabelUploaderDropFiles": "Slipp filer",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||||
"LabelUseChapterTrack": "Bruk kapittelspor",
|
"LabelUseChapterTrack": "Bruk kapittelspor",
|
||||||
"LabelUseFullTrack": "Bruke hele sporet",
|
"LabelUseFullTrack": "Bruke hele sporet",
|
||||||
"LabelUser": "Bruker",
|
"LabelUser": "Bruker",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "Er du sikker på at du vil fjerne samling\"{0}\"?",
|
"MessageConfirmRemoveCollection": "Er du sikker på at du vil fjerne samling\"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Er du sikker på at du vil fjerne episode \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Er du sikker på at du vil fjerne episode \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "Er du sikker på at du vil fjerne {0} episoder?",
|
"MessageConfirmRemoveEpisodes": "Er du sikker på at du vil fjerne {0} episoder?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "Er du sikker på at du vil fjerne forteller \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Er du sikker på at du vil fjerne forteller \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Er du sikker på at du vil fjerne spillelisten \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Er du sikker på at du vil fjerne spillelisten \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Er du sikker på at du vil endre sjanger \"{0}\" til \"{1}\" for alle gjenstandene?",
|
"MessageConfirmRenameGenre": "Er du sikker på at du vil endre sjanger \"{0}\" til \"{1}\" for alle gjenstandene?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Er du sikker på at du vil gjenopprette sikkerhetskopien som var laget",
|
"MessageRestoreBackupConfirm": "Er du sikker på at du vil gjenopprette sikkerhetskopien som var laget",
|
||||||
"MessageRestoreBackupWarning": "gjenoppretting av sikkerhetskopi vil overskrive hele databasen under /config og omslagsbilde under /metadata/items og /metadata/authors.<br /><br />Sikkerhetskopier endrer ikke noen filer under dine bibliotekmapper. Hvis du har aktivert tjenerinstillingen for å lagre omslagsbilder og metadata i bibliotekmapper så vil ikke de filene bli tatt sikkerhetskopi eller overskrevet.<br /><br />Alle klientene som bruker din tjener vil bli fornyet automatisk.",
|
"MessageRestoreBackupWarning": "gjenoppretting av sikkerhetskopi vil overskrive hele databasen under /config og omslagsbilde under /metadata/items og /metadata/authors.<br /><br />Sikkerhetskopier endrer ikke noen filer under dine bibliotekmapper. Hvis du har aktivert tjenerinstillingen for å lagre omslagsbilder og metadata i bibliotekmapper så vil ikke de filene bli tatt sikkerhetskopi eller overskrevet.<br /><br />Alle klientene som bruker din tjener vil bli fornyet automatisk.",
|
||||||
"MessageSearchResultsFor": "Søk resultat for",
|
"MessageSearchResultsFor": "Søk resultat for",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Tjener kunne ikke bli nådd",
|
"MessageServerCouldNotBeReached": "Tjener kunne ikke bli nådd",
|
||||||
"MessageSetChaptersFromTracksDescription": "Sett kapitler ved å bruke hver lydfil som kapittel og kapitteltittel som lydfilnavnet",
|
"MessageSetChaptersFromTracksDescription": "Sett kapitler ved å bruke hver lydfil som kapittel og kapitteltittel som lydfilnavnet",
|
||||||
"MessageStartPlaybackAtTime": "Start avspilling av \"{0}\" ved {1}?",
|
"MessageStartPlaybackAtTime": "Start avspilling av \"{0}\" ved {1}?",
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
"ButtonUserEdit": "Edit user {0}",
|
"ButtonUserEdit": "Edit user {0}",
|
||||||
"ButtonViewAll": "Zobacz wszystko",
|
"ButtonViewAll": "Zobacz wszystko",
|
||||||
"ButtonYes": "Tak",
|
"ButtonYes": "Tak",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||||
|
"ErrorUploadLacksTitle": "Must have a title",
|
||||||
"HeaderAccount": "Konto",
|
"HeaderAccount": "Konto",
|
||||||
"HeaderAdvanced": "Zaawansowane",
|
"HeaderAdvanced": "Zaawansowane",
|
||||||
"HeaderAppriseNotificationSettings": "Ustawienia powiadomień Apprise",
|
"HeaderAppriseNotificationSettings": "Ustawienia powiadomień Apprise",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Author (Malejąco)",
|
"LabelAuthorLastFirst": "Author (Malejąco)",
|
||||||
"LabelAuthors": "Autorzy",
|
"LabelAuthors": "Autorzy",
|
||||||
"LabelAutoDownloadEpisodes": "Automatyczne pobieranie odcinków",
|
"LabelAutoDownloadEpisodes": "Automatyczne pobieranie odcinków",
|
||||||
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Auto Launch",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Example",
|
"LabelExample": "Example",
|
||||||
"LabelExplicit": "Nieprzyzwoite",
|
"LabelExplicit": "Nieprzyzwoite",
|
||||||
"LabelFeedURL": "URL kanału",
|
"LabelFeedURL": "URL kanału",
|
||||||
|
"LabelFetchingMetadata": "Fetching Metadata",
|
||||||
"LabelFile": "Plik",
|
"LabelFile": "Plik",
|
||||||
"LabelFileBirthtime": "Data utworzenia pliku",
|
"LabelFileBirthtime": "Data utworzenia pliku",
|
||||||
"LabelFileModified": "Data modyfikacji pliku",
|
"LabelFileModified": "Data modyfikacji pliku",
|
||||||
@@ -337,6 +343,8 @@
|
|||||||
"LabelMinute": "Minuta",
|
"LabelMinute": "Minuta",
|
||||||
"LabelMissing": "Brakujący",
|
"LabelMissing": "Brakujący",
|
||||||
"LabelMissingParts": "Brakujące cześci",
|
"LabelMissingParts": "Brakujące cześci",
|
||||||
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "Więcej",
|
"LabelMore": "Więcej",
|
||||||
"LabelMoreInfo": "More Info",
|
"LabelMoreInfo": "More Info",
|
||||||
"LabelName": "Nazwa",
|
"LabelName": "Nazwa",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Region",
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Data wydania",
|
"LabelReleaseDate": "Data wydania",
|
||||||
"LabelRemoveCover": "Remove cover",
|
"LabelRemoveCover": "Remove cover",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||||
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
"LabelRSSFeedCustomOwnerName": "Custom owner Name",
|
||||||
"LabelRSSFeedOpen": "RSS Feed otwarty",
|
"LabelRSSFeedOpen": "RSS Feed otwarty",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Umożliwienie nadpisania istniejących szczegółów dla wybranych książek w przypadku znalezienia dopasowania",
|
"LabelUpdateDetailsHelp": "Umożliwienie nadpisania istniejących szczegółów dla wybranych książek w przypadku znalezienia dopasowania",
|
||||||
"LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub pliki",
|
"LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub pliki",
|
||||||
"LabelUploaderDropFiles": "Puść pliki",
|
"LabelUploaderDropFiles": "Puść pliki",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||||
"LabelUseChapterTrack": "Użyj ścieżki rozdziału",
|
"LabelUseChapterTrack": "Użyj ścieżki rozdziału",
|
||||||
"LabelUseFullTrack": "Użycie ścieżki rozdziału",
|
"LabelUseFullTrack": "Użycie ścieżki rozdziału",
|
||||||
"LabelUser": "Użytkownik",
|
"LabelUser": "Użytkownik",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
|
"MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
"MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Czy na pewno chcesz przywrócić kopię zapasową utworzoną w dniu",
|
"MessageRestoreBackupConfirm": "Czy na pewno chcesz przywrócić kopię zapasową utworzoną w dniu",
|
||||||
"MessageRestoreBackupWarning": "Przywrócenie kopii zapasowej spowoduje nadpisane bazy danych w folderze /config oraz okładke w folderze /metadata/items & /metadata/authors.<br /><br />Kopie zapasowe nie modyfikują żadnego pliku w folderach z plikami audio. Jeśli włączyłeś ustawienia serwera, aby przechowywać okładki i metadane w folderach biblioteki, to nie są one zapisywane w kopii zapasowej lub nadpisywane<br /><br />Wszyscy klienci korzystający z Twojego serwera będą automatycznie odświeżani",
|
"MessageRestoreBackupWarning": "Przywrócenie kopii zapasowej spowoduje nadpisane bazy danych w folderze /config oraz okładke w folderze /metadata/items & /metadata/authors.<br /><br />Kopie zapasowe nie modyfikują żadnego pliku w folderach z plikami audio. Jeśli włączyłeś ustawienia serwera, aby przechowywać okładki i metadane w folderach biblioteki, to nie są one zapisywane w kopii zapasowej lub nadpisywane<br /><br />Wszyscy klienci korzystający z Twojego serwera będą automatycznie odświeżani",
|
||||||
"MessageSearchResultsFor": "Wyniki wyszukiwania dla",
|
"MessageSearchResultsFor": "Wyniki wyszukiwania dla",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem",
|
"MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem",
|
||||||
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
"MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name",
|
||||||
"MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?",
|
"MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?",
|
||||||
|
|||||||
+44
-32
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"ButtonAdd": "Добавить",
|
"ButtonAdd": "Добавить",
|
||||||
"ButtonAddChapters": "Добавить главы",
|
"ButtonAddChapters": "Добавить главы",
|
||||||
"ButtonAddDevice": "Add Device",
|
"ButtonAddDevice": "Добавить устройство",
|
||||||
"ButtonAddLibrary": "Add Library",
|
"ButtonAddLibrary": "Добавить библиотеку",
|
||||||
"ButtonAddPodcasts": "Добавить подкасты",
|
"ButtonAddPodcasts": "Добавить подкасты",
|
||||||
"ButtonAddUser": "Add User",
|
"ButtonAddUser": "Добавить пользователя",
|
||||||
"ButtonAddYourFirstLibrary": "Добавьте Вашу первую библиотеку",
|
"ButtonAddYourFirstLibrary": "Добавьте Вашу первую библиотеку",
|
||||||
"ButtonApply": "Применить",
|
"ButtonApply": "Применить",
|
||||||
"ButtonApplyChapters": "Применить главы",
|
"ButtonApplyChapters": "Применить главы",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"ButtonRemoveSeriesFromContinueSeries": "Удалить серию из Продолжить серию",
|
"ButtonRemoveSeriesFromContinueSeries": "Удалить серию из Продолжить серию",
|
||||||
"ButtonReScan": "Пересканировать",
|
"ButtonReScan": "Пересканировать",
|
||||||
"ButtonReset": "Сбросить",
|
"ButtonReset": "Сбросить",
|
||||||
"ButtonResetToDefault": "Reset to default",
|
"ButtonResetToDefault": "Сборосить по умолчанию",
|
||||||
"ButtonRestore": "Восстановить",
|
"ButtonRestore": "Восстановить",
|
||||||
"ButtonSave": "Сохранить",
|
"ButtonSave": "Сохранить",
|
||||||
"ButtonSaveAndClose": "Сохранить и закрыть",
|
"ButtonSaveAndClose": "Сохранить и закрыть",
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"ButtonStartM4BEncode": "Начать кодирование M4B",
|
"ButtonStartM4BEncode": "Начать кодирование M4B",
|
||||||
"ButtonStartMetadataEmbed": "Начать встраивание метаданных",
|
"ButtonStartMetadataEmbed": "Начать встраивание метаданных",
|
||||||
"ButtonSubmit": "Применить",
|
"ButtonSubmit": "Применить",
|
||||||
"ButtonTest": "Test",
|
"ButtonTest": "Тест",
|
||||||
"ButtonUpload": "Загрузить",
|
"ButtonUpload": "Загрузить",
|
||||||
"ButtonUploadBackup": "Загрузить бэкап",
|
"ButtonUploadBackup": "Загрузить бэкап",
|
||||||
"ButtonUploadCover": "Загрузить обложку",
|
"ButtonUploadCover": "Загрузить обложку",
|
||||||
@@ -87,12 +87,15 @@
|
|||||||
"ButtonUserEdit": "Редактировать пользователя {0}",
|
"ButtonUserEdit": "Редактировать пользователя {0}",
|
||||||
"ButtonViewAll": "Посмотреть все",
|
"ButtonViewAll": "Посмотреть все",
|
||||||
"ButtonYes": "Да",
|
"ButtonYes": "Да",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Ошибка при получении метаданных",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Не удалось получить метаданные - попробуйте обновить название и/или автора",
|
||||||
|
"ErrorUploadLacksTitle": "Название должно быть заполнено",
|
||||||
"HeaderAccount": "Учетная запись",
|
"HeaderAccount": "Учетная запись",
|
||||||
"HeaderAdvanced": "Дополнительно",
|
"HeaderAdvanced": "Дополнительно",
|
||||||
"HeaderAppriseNotificationSettings": "Настройки оповещений",
|
"HeaderAppriseNotificationSettings": "Настройки оповещений",
|
||||||
"HeaderAudiobookTools": "Инструменты файлов аудиокниг",
|
"HeaderAudiobookTools": "Инструменты файлов аудиокниг",
|
||||||
"HeaderAudioTracks": "Аудио треки",
|
"HeaderAudioTracks": "Аудио треки",
|
||||||
"HeaderAuthentication": "Authentication",
|
"HeaderAuthentication": "Аутентификация",
|
||||||
"HeaderBackups": "Бэкапы",
|
"HeaderBackups": "Бэкапы",
|
||||||
"HeaderChangePassword": "Изменить пароль",
|
"HeaderChangePassword": "Изменить пароль",
|
||||||
"HeaderChapters": "Главы",
|
"HeaderChapters": "Главы",
|
||||||
@@ -127,15 +130,15 @@
|
|||||||
"HeaderManageTags": "Редактировать теги",
|
"HeaderManageTags": "Редактировать теги",
|
||||||
"HeaderMapDetails": "Найти подробности",
|
"HeaderMapDetails": "Найти подробности",
|
||||||
"HeaderMatch": "Поиск",
|
"HeaderMatch": "Поиск",
|
||||||
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
|
"HeaderMetadataOrderOfPrecedence": "Порядок приоритета метаданных",
|
||||||
"HeaderMetadataToEmbed": "Метаинформация для встраивания",
|
"HeaderMetadataToEmbed": "Метаинформация для встраивания",
|
||||||
"HeaderNewAccount": "Новая учетная запись",
|
"HeaderNewAccount": "Новая учетная запись",
|
||||||
"HeaderNewLibrary": "Новая библиотека",
|
"HeaderNewLibrary": "Новая библиотека",
|
||||||
"HeaderNotifications": "Уведомления",
|
"HeaderNotifications": "Уведомления",
|
||||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
"HeaderOpenIDConnectAuthentication": "Аутентификация OpenID Connect",
|
||||||
"HeaderOpenRSSFeed": "Открыть RSS-канал",
|
"HeaderOpenRSSFeed": "Открыть RSS-канал",
|
||||||
"HeaderOtherFiles": "Другие файлы",
|
"HeaderOtherFiles": "Другие файлы",
|
||||||
"HeaderPasswordAuthentication": "Password Authentication",
|
"HeaderPasswordAuthentication": "Аутентификация по паролю",
|
||||||
"HeaderPermissions": "Разрешения",
|
"HeaderPermissions": "Разрешения",
|
||||||
"HeaderPlayerQueue": "Очередь воспроизведения",
|
"HeaderPlayerQueue": "Очередь воспроизведения",
|
||||||
"HeaderPlaylist": "Плейлист",
|
"HeaderPlaylist": "Плейлист",
|
||||||
@@ -184,11 +187,11 @@
|
|||||||
"LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию",
|
"LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию",
|
||||||
"LabelAddToPlaylist": "Добавить в плейлист",
|
"LabelAddToPlaylist": "Добавить в плейлист",
|
||||||
"LabelAddToPlaylistBatch": "Добавить {0} элементов в плейлист",
|
"LabelAddToPlaylistBatch": "Добавить {0} элементов в плейлист",
|
||||||
"LabelAdminUsersOnly": "Admin users only",
|
"LabelAdminUsersOnly": "Только для пользователей с правами администратора",
|
||||||
"LabelAll": "Все",
|
"LabelAll": "Все",
|
||||||
"LabelAllUsers": "Все пользователи",
|
"LabelAllUsers": "Все пользователи",
|
||||||
"LabelAllUsersExcludingGuests": "All users excluding guests",
|
"LabelAllUsersExcludingGuests": "Все пользователи, кроме гостей",
|
||||||
"LabelAllUsersIncludingGuests": "All users including guests",
|
"LabelAllUsersIncludingGuests": "Все пользователи, включая гостей",
|
||||||
"LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке",
|
"LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке",
|
||||||
"LabelAppend": "Добавить",
|
"LabelAppend": "Добавить",
|
||||||
"LabelAuthor": "Автор",
|
"LabelAuthor": "Автор",
|
||||||
@@ -196,12 +199,14 @@
|
|||||||
"LabelAuthorLastFirst": "Автор (Фамилия, Имя)",
|
"LabelAuthorLastFirst": "Автор (Фамилия, Имя)",
|
||||||
"LabelAuthors": "Авторы",
|
"LabelAuthors": "Авторы",
|
||||||
"LabelAutoDownloadEpisodes": "Скачивать эпизоды автоматически",
|
"LabelAutoDownloadEpisodes": "Скачивать эпизоды автоматически",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoFetchMetadata": "Автоматическое извлечение метаданных",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoFetchMetadataHelp": "Извлекает метаданные для названия, автора и серии для упрощения загрузки. После загрузки может потребоваться сопоставление дополнительных метаданных.",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoLaunch": "Автозапуск",
|
||||||
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
|
"LabelAutoLaunchDescription": "Редирект на провайдера аутентификации автоматически при переходе на страницу входа (путь ручного переопределения <code>/login?autoLaunch=0</code>)",
|
||||||
|
"LabelAutoRegister": "Автоматическая регистрация",
|
||||||
|
"LabelAutoRegisterDescription": "Автоматическое создание новых пользователей после входа в систему",
|
||||||
"LabelBackToUser": "Назад к пользователю",
|
"LabelBackToUser": "Назад к пользователю",
|
||||||
"LabelBackupLocation": "Backup Location",
|
"LabelBackupLocation": "Путь для бэкапов",
|
||||||
"LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование",
|
"LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование",
|
||||||
"LabelBackupsEnableAutomaticBackupsHelp": "Бэкапы сохраняются в /metadata/backups",
|
"LabelBackupsEnableAutomaticBackupsHelp": "Бэкапы сохраняются в /metadata/backups",
|
||||||
"LabelBackupsMaxBackupSize": "Максимальный размер бэкапа (в GB)",
|
"LabelBackupsMaxBackupSize": "Максимальный размер бэкапа (в GB)",
|
||||||
@@ -210,13 +215,13 @@
|
|||||||
"LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.",
|
"LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.",
|
||||||
"LabelBitrate": "Битрейт",
|
"LabelBitrate": "Битрейт",
|
||||||
"LabelBooks": "Книги",
|
"LabelBooks": "Книги",
|
||||||
"LabelButtonText": "Button Text",
|
"LabelButtonText": "Текст кнопки",
|
||||||
"LabelChangePassword": "Изменить пароль",
|
"LabelChangePassword": "Изменить пароль",
|
||||||
"LabelChannels": "Каналы",
|
"LabelChannels": "Каналы",
|
||||||
"LabelChapters": "Главы",
|
"LabelChapters": "Главы",
|
||||||
"LabelChaptersFound": "глав найдено",
|
"LabelChaptersFound": "глав найдено",
|
||||||
"LabelChapterTitle": "Название главы",
|
"LabelChapterTitle": "Название главы",
|
||||||
"LabelClickForMoreInfo": "Click for more info",
|
"LabelClickForMoreInfo": "Нажмите, чтобы узнать больше",
|
||||||
"LabelClosePlayer": "Закрыть проигрыватель",
|
"LabelClosePlayer": "Закрыть проигрыватель",
|
||||||
"LabelCodec": "Кодек",
|
"LabelCodec": "Кодек",
|
||||||
"LabelCollapseSeries": "Свернуть серии",
|
"LabelCollapseSeries": "Свернуть серии",
|
||||||
@@ -235,12 +240,12 @@
|
|||||||
"LabelCurrently": "Текущее:",
|
"LabelCurrently": "Текущее:",
|
||||||
"LabelCustomCronExpression": "Пользовательское выражение Cron:",
|
"LabelCustomCronExpression": "Пользовательское выражение Cron:",
|
||||||
"LabelDatetime": "Дата и время",
|
"LabelDatetime": "Дата и время",
|
||||||
"LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)",
|
"LabelDeleteFromFileSystemCheckbox": "Удалить из файловой системы (снимите флажок, чтобы удалить только из базы данных)",
|
||||||
"LabelDescription": "Описание",
|
"LabelDescription": "Описание",
|
||||||
"LabelDeselectAll": "Снять выделение",
|
"LabelDeselectAll": "Снять выделение",
|
||||||
"LabelDevice": "Устройство",
|
"LabelDevice": "Устройство",
|
||||||
"LabelDeviceInfo": "Информация об устройстве",
|
"LabelDeviceInfo": "Информация об устройстве",
|
||||||
"LabelDeviceIsAvailableTo": "Device is available to...",
|
"LabelDeviceIsAvailableTo": "Устройство доступно для...",
|
||||||
"LabelDirectory": "Каталог",
|
"LabelDirectory": "Каталог",
|
||||||
"LabelDiscFromFilename": "Диск из Имени файла",
|
"LabelDiscFromFilename": "Диск из Имени файла",
|
||||||
"LabelDiscFromMetadata": "Диск из Метаданных",
|
"LabelDiscFromMetadata": "Диск из Метаданных",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Пример",
|
"LabelExample": "Пример",
|
||||||
"LabelExplicit": "Явный",
|
"LabelExplicit": "Явный",
|
||||||
"LabelFeedURL": "URL канала",
|
"LabelFeedURL": "URL канала",
|
||||||
|
"LabelFetchingMetadata": "Извлечение метаданных",
|
||||||
"LabelFile": "Файл",
|
"LabelFile": "Файл",
|
||||||
"LabelFileBirthtime": "Дата создания",
|
"LabelFileBirthtime": "Дата создания",
|
||||||
"LabelFileModified": "Дата модификации",
|
"LabelFileModified": "Дата модификации",
|
||||||
@@ -283,11 +289,11 @@
|
|||||||
"LabelHardDeleteFile": "Жесткое удаление файла",
|
"LabelHardDeleteFile": "Жесткое удаление файла",
|
||||||
"LabelHasEbook": "Есть e-книга",
|
"LabelHasEbook": "Есть e-книга",
|
||||||
"LabelHasSupplementaryEbook": "Есть дополнительная e-книга",
|
"LabelHasSupplementaryEbook": "Есть дополнительная e-книга",
|
||||||
"LabelHighestPriority": "Highest priority",
|
"LabelHighestPriority": "Наивысший приоритет",
|
||||||
"LabelHost": "Хост",
|
"LabelHost": "Хост",
|
||||||
"LabelHour": "Часы",
|
"LabelHour": "Часы",
|
||||||
"LabelIcon": "Иконка",
|
"LabelIcon": "Иконка",
|
||||||
"LabelImageURLFromTheWeb": "Image URL from the web",
|
"LabelImageURLFromTheWeb": "URL-адрес изображения из Интернета",
|
||||||
"LabelIncludeInTracklist": "Включать в список воспроизведения",
|
"LabelIncludeInTracklist": "Включать в список воспроизведения",
|
||||||
"LabelIncomplete": "Не завершен",
|
"LabelIncomplete": "Не завершен",
|
||||||
"LabelInProgress": "В процессе",
|
"LabelInProgress": "В процессе",
|
||||||
@@ -325,18 +331,20 @@
|
|||||||
"LabelLogLevelInfo": "Info",
|
"LabelLogLevelInfo": "Info",
|
||||||
"LabelLogLevelWarn": "Warn",
|
"LabelLogLevelWarn": "Warn",
|
||||||
"LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты",
|
"LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты",
|
||||||
"LabelLowestPriority": "Lowest Priority",
|
"LabelLowestPriority": "Самый низкий приоритет",
|
||||||
"LabelMatchExistingUsersBy": "Match existing users by",
|
"LabelMatchExistingUsersBy": "Сопоставление существующих пользователей по",
|
||||||
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
|
"LabelMatchExistingUsersByDescription": "Используется для подключения существующих пользователей. После подключения пользователям будет присвоен уникальный идентификатор от поставщика единого входа",
|
||||||
"LabelMediaPlayer": "Медиа проигрыватель",
|
"LabelMediaPlayer": "Медиа проигрыватель",
|
||||||
"LabelMediaType": "Тип медиа",
|
"LabelMediaType": "Тип медиа",
|
||||||
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
|
"LabelMetadataOrderOfPrecedenceDescription": "Источники метаданных с более высоким приоритетом будут переопределять источники метаданных с более низким приоритетом",
|
||||||
"LabelMetadataProvider": "Провайдер",
|
"LabelMetadataProvider": "Провайдер",
|
||||||
"LabelMetaTag": "Мета тег",
|
"LabelMetaTag": "Мета тег",
|
||||||
"LabelMetaTags": "Мета теги",
|
"LabelMetaTags": "Мета теги",
|
||||||
"LabelMinute": "Минуты",
|
"LabelMinute": "Минуты",
|
||||||
"LabelMissing": "Потеряно",
|
"LabelMissing": "Потеряно",
|
||||||
"LabelMissingParts": "Потерянные части",
|
"LabelMissingParts": "Потерянные части",
|
||||||
|
"LabelMobileRedirectURIs": "Разрешенные URI перенаправления с мобильных устройств",
|
||||||
|
"LabelMobileRedirectURIsDescription": "Это белый список допустимых URI перенаправления для мобильных приложений. По умолчанию используется <code>audiobookshelf://oauth</code>, который можно удалить или дополнить дополнительными URI для интеграции со сторонними приложениями. Использование звездочки (<code>*</code>) в качестве единственной записи разрешает любой URI.",
|
||||||
"LabelMore": "Еще",
|
"LabelMore": "Еще",
|
||||||
"LabelMoreInfo": "Больше информации",
|
"LabelMoreInfo": "Больше информации",
|
||||||
"LabelName": "Имя",
|
"LabelName": "Имя",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Регион",
|
"LabelRegion": "Регион",
|
||||||
"LabelReleaseDate": "Дата выхода",
|
"LabelReleaseDate": "Дата выхода",
|
||||||
"LabelRemoveCover": "Удалить обложку",
|
"LabelRemoveCover": "Удалить обложку",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца",
|
"LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца",
|
||||||
"LabelRSSFeedCustomOwnerName": "Пользовательское Имя владельца",
|
"LabelRSSFeedCustomOwnerName": "Пользовательское Имя владельца",
|
||||||
"LabelRSSFeedOpen": "Открыть RSS-канал",
|
"LabelRSSFeedOpen": "Открыть RSS-канал",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
|
"LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены",
|
||||||
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
|
"LabelUploaderDragAndDrop": "Перетащите файлы или каталоги",
|
||||||
"LabelUploaderDropFiles": "Перетащите файлы",
|
"LabelUploaderDropFiles": "Перетащите файлы",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии",
|
||||||
"LabelUseChapterTrack": "Показывать время главы",
|
"LabelUseChapterTrack": "Показывать время главы",
|
||||||
"LabelUseFullTrack": "Показывать время книги",
|
"LabelUseFullTrack": "Показывать время книги",
|
||||||
"LabelUser": "Пользователь",
|
"LabelUser": "Пользователь",
|
||||||
@@ -548,20 +558,21 @@
|
|||||||
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
|
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
|
||||||
"MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?",
|
"MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?",
|
||||||
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?",
|
||||||
"MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?",
|
"MessageConfirmDeleteLibraryItem": "Это приведет к удалению элемента библиотеки из базы данных и файловой системы. Вы уверены?",
|
||||||
"MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?",
|
"MessageConfirmDeleteLibraryItems": "Это приведет к удалению {0} элементов библиотеки из базы данных и файловой системы. Вы уверены?",
|
||||||
"MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?",
|
"MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?",
|
||||||
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
|
"MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?",
|
||||||
"MessageConfirmMarkAllEpisodesFinished": "Вы уверены, что хотите отметить все эпизоды как завершенные?",
|
"MessageConfirmMarkAllEpisodesFinished": "Вы уверены, что хотите отметить все эпизоды как завершенные?",
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "Вы уверены, что хотите отметить все эпизоды как не завершенные?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "Вы уверены, что хотите отметить все эпизоды как не завершенные?",
|
||||||
"MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как завершенные?",
|
"MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как завершенные?",
|
||||||
"MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как не завершенные?",
|
"MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как не завершенные?",
|
||||||
"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": "Предупреждение! Быстрое встраивание не позволяет создавать резервные копии аудиофайлов. Убедитесь, что у вас есть резервная копия аудиофайлов. <br><br>Хотите продолжить?",
|
||||||
"MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?",
|
"MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?",
|
||||||
"MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?",
|
"MessageConfirmRemoveAuthor": "Вы уверены, что хотите удалить автора \"{0}\"?",
|
||||||
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
|
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?",
|
"MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "Вы уверены, что хотите удалить чтеца \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "Вы уверены, что хотите удалить чтеца \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "Вы уверены, что хотите удалить плейлист \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "Вы уверены, что хотите удалить плейлист \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "Вы уверены, что хотите переименовать жанр \"{0}\" в \"{1}\" для всех элементов?",
|
"MessageConfirmRenameGenre": "Вы уверены, что хотите переименовать жанр \"{0}\" в \"{1}\" для всех элементов?",
|
||||||
@@ -570,7 +581,7 @@
|
|||||||
"MessageConfirmRenameTag": "Вы уверены, что хотите переименовать тег \"{0}\" в \"{1}\" для всех элементов?",
|
"MessageConfirmRenameTag": "Вы уверены, что хотите переименовать тег \"{0}\" в \"{1}\" для всех элементов?",
|
||||||
"MessageConfirmRenameTagMergeNote": "Примечание: Этот тег уже существует, поэтому они будут объединены.",
|
"MessageConfirmRenameTagMergeNote": "Примечание: Этот тег уже существует, поэтому они будут объединены.",
|
||||||
"MessageConfirmRenameTagWarning": "Предупреждение! Похожий тег с другими начальными буквами уже существует \"{0}\".",
|
"MessageConfirmRenameTagWarning": "Предупреждение! Похожий тег с другими начальными буквами уже существует \"{0}\".",
|
||||||
"MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?",
|
"MessageConfirmReScanLibraryItems": "Вы уверены, что хотите пересканировать {0} элементов?",
|
||||||
"MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?",
|
||||||
"MessageDownloadingEpisode": "Эпизод скачивается",
|
"MessageDownloadingEpisode": "Эпизод скачивается",
|
||||||
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
|
"MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Вы уверены, что хотите восстановить резервную копию, созданную",
|
"MessageRestoreBackupConfirm": "Вы уверены, что хотите восстановить резервную копию, созданную",
|
||||||
"MessageRestoreBackupWarning": "Восстановление резервной копии перезапишет всю базу данных, расположенную в /config, и обложки изображений в /metadata/items и /metadata/authors.<br/><br/>Бэкапы не изменяют файлы в папках библиотеки. Если вы включили параметры сервера для хранения обложек и метаданных в папках библиотеки, то они не резервируются и не перезаписываются.<br/><br/>Все клиенты, использующие ваш сервер, будут автоматически обновлены.",
|
"MessageRestoreBackupWarning": "Восстановление резервной копии перезапишет всю базу данных, расположенную в /config, и обложки изображений в /metadata/items и /metadata/authors.<br/><br/>Бэкапы не изменяют файлы в папках библиотеки. Если вы включили параметры сервера для хранения обложек и метаданных в папках библиотеки, то они не резервируются и не перезаписываются.<br/><br/>Все клиенты, использующие ваш сервер, будут автоматически обновлены.",
|
||||||
"MessageSearchResultsFor": "Результаты поиска для",
|
"MessageSearchResultsFor": "Результаты поиска для",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Не удалось связаться с сервером",
|
"MessageServerCouldNotBeReached": "Не удалось связаться с сервером",
|
||||||
"MessageSetChaptersFromTracksDescription": "Установка глав с использованием каждого аудиофайла в качестве главы и заголовка главы в качестве имени аудиофайла",
|
"MessageSetChaptersFromTracksDescription": "Установка глав с использованием каждого аудиофайла в качестве главы и заголовка главы в качестве имени аудиофайла",
|
||||||
"MessageStartPlaybackAtTime": "Начать воспроизведение для \"{0}\" с {1}?",
|
"MessageStartPlaybackAtTime": "Начать воспроизведение для \"{0}\" с {1}?",
|
||||||
|
|||||||
@@ -87,6 +87,9 @@
|
|||||||
"ButtonUserEdit": "Redigera användare {0}",
|
"ButtonUserEdit": "Redigera användare {0}",
|
||||||
"ButtonViewAll": "Visa alla",
|
"ButtonViewAll": "Visa alla",
|
||||||
"ButtonYes": "Ja",
|
"ButtonYes": "Ja",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
||||||
|
"ErrorUploadLacksTitle": "Must have a title",
|
||||||
"HeaderAccount": "Konto",
|
"HeaderAccount": "Konto",
|
||||||
"HeaderAdvanced": "Avancerad",
|
"HeaderAdvanced": "Avancerad",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar",
|
"HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar",
|
||||||
@@ -196,6 +199,8 @@
|
|||||||
"LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)",
|
"LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)",
|
||||||
"LabelAuthors": "Författare",
|
"LabelAuthors": "Författare",
|
||||||
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
|
"LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt",
|
||||||
|
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Auto Launch",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Register",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "Exempel",
|
"LabelExample": "Exempel",
|
||||||
"LabelExplicit": "Explicit",
|
"LabelExplicit": "Explicit",
|
||||||
"LabelFeedURL": "Flödes-URL",
|
"LabelFeedURL": "Flödes-URL",
|
||||||
|
"LabelFetchingMetadata": "Fetching Metadata",
|
||||||
"LabelFile": "Fil",
|
"LabelFile": "Fil",
|
||||||
"LabelFileBirthtime": "Födelse-tidpunkt för fil",
|
"LabelFileBirthtime": "Födelse-tidpunkt för fil",
|
||||||
"LabelFileModified": "Fil ändrad",
|
"LabelFileModified": "Fil ändrad",
|
||||||
@@ -337,6 +343,8 @@
|
|||||||
"LabelMinute": "Minut",
|
"LabelMinute": "Minut",
|
||||||
"LabelMissing": "Saknad",
|
"LabelMissing": "Saknad",
|
||||||
"LabelMissingParts": "Saknade delar",
|
"LabelMissingParts": "Saknade delar",
|
||||||
|
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
||||||
|
"LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.",
|
||||||
"LabelMore": "Mer",
|
"LabelMore": "Mer",
|
||||||
"LabelMoreInfo": "Mer information",
|
"LabelMoreInfo": "Mer information",
|
||||||
"LabelName": "Namn",
|
"LabelName": "Namn",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "Region",
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Utgivningsdatum",
|
"LabelReleaseDate": "Utgivningsdatum",
|
||||||
"LabelRemoveCover": "Ta bort omslag",
|
"LabelRemoveCover": "Ta bort omslag",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post",
|
"LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post",
|
||||||
"LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn",
|
"LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn",
|
||||||
"LabelRSSFeedOpen": "Öppna RSS-flöde",
|
"LabelRSSFeedOpen": "Öppna RSS-flöde",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "Tillåt överskrivning av befintliga detaljer för de valda böckerna när en matchning hittas",
|
"LabelUpdateDetailsHelp": "Tillåt överskrivning av befintliga detaljer för de valda böckerna när en matchning hittas",
|
||||||
"LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar",
|
"LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar",
|
||||||
"LabelUploaderDropFiles": "Släpp filer",
|
"LabelUploaderDropFiles": "Släpp filer",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series",
|
||||||
"LabelUseChapterTrack": "Använd kapitelspår",
|
"LabelUseChapterTrack": "Använd kapitelspår",
|
||||||
"LabelUseFullTrack": "Använd hela spåret",
|
"LabelUseFullTrack": "Använd hela spåret",
|
||||||
"LabelUser": "Användare",
|
"LabelUser": "Användare",
|
||||||
@@ -562,6 +572,7 @@
|
|||||||
"MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{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}\"?",
|
"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?",
|
"MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort berättaren \"{0}\"?",
|
"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}\"?",
|
"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?",
|
"MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på genren \"{0}\" till \"{1}\" för alla objekt?",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "Är du säker på att du vill återställa säkerhetskopian som skapades den",
|
"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.",
|
"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",
|
"MessageSearchResultsFor": "Sökresultat för",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "Servern kunde inte nås",
|
"MessageServerCouldNotBeReached": "Servern kunde inte nås",
|
||||||
"MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn",
|
"MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn",
|
||||||
"MessageStartPlaybackAtTime": "Starta uppspelning för \"{0}\" kl. {1}?",
|
"MessageStartPlaybackAtTime": "Starta uppspelning för \"{0}\" kl. {1}?",
|
||||||
|
|||||||
+40
-28
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"ButtonAdd": "增加",
|
"ButtonAdd": "增加",
|
||||||
"ButtonAddChapters": "添加章节",
|
"ButtonAddChapters": "添加章节",
|
||||||
"ButtonAddDevice": "Add Device",
|
"ButtonAddDevice": "添加设备",
|
||||||
"ButtonAddLibrary": "Add Library",
|
"ButtonAddLibrary": "添加库",
|
||||||
"ButtonAddPodcasts": "添加播客",
|
"ButtonAddPodcasts": "添加播客",
|
||||||
"ButtonAddUser": "Add User",
|
"ButtonAddUser": "添加用户",
|
||||||
"ButtonAddYourFirstLibrary": "添加第一个媒体库",
|
"ButtonAddYourFirstLibrary": "添加第一个媒体库",
|
||||||
"ButtonApply": "应用",
|
"ButtonApply": "应用",
|
||||||
"ButtonApplyChapters": "应用到章节",
|
"ButtonApplyChapters": "应用到章节",
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
"ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除",
|
"ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除",
|
||||||
"ButtonReScan": "重新扫描",
|
"ButtonReScan": "重新扫描",
|
||||||
"ButtonReset": "重置",
|
"ButtonReset": "重置",
|
||||||
"ButtonResetToDefault": "Reset to default",
|
"ButtonResetToDefault": "重置为默认",
|
||||||
"ButtonRestore": "恢复",
|
"ButtonRestore": "恢复",
|
||||||
"ButtonSave": "保存",
|
"ButtonSave": "保存",
|
||||||
"ButtonSaveAndClose": "保存并关闭",
|
"ButtonSaveAndClose": "保存并关闭",
|
||||||
@@ -87,12 +87,15 @@
|
|||||||
"ButtonUserEdit": "编辑用户 {0}",
|
"ButtonUserEdit": "编辑用户 {0}",
|
||||||
"ButtonViewAll": "查看全部",
|
"ButtonViewAll": "查看全部",
|
||||||
"ButtonYes": "确定",
|
"ButtonYes": "确定",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "获取元数据时出错",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "无法获取元数据 - 尝试更新标题和/或作者",
|
||||||
|
"ErrorUploadLacksTitle": "必须有标题",
|
||||||
"HeaderAccount": "帐户",
|
"HeaderAccount": "帐户",
|
||||||
"HeaderAdvanced": "高级",
|
"HeaderAdvanced": "高级",
|
||||||
"HeaderAppriseNotificationSettings": "测试通知设置",
|
"HeaderAppriseNotificationSettings": "测试通知设置",
|
||||||
"HeaderAudiobookTools": "有声读物文件管理工具",
|
"HeaderAudiobookTools": "有声读物文件管理工具",
|
||||||
"HeaderAudioTracks": "音轨",
|
"HeaderAudioTracks": "音轨",
|
||||||
"HeaderAuthentication": "Authentication",
|
"HeaderAuthentication": "身份验证",
|
||||||
"HeaderBackups": "备份",
|
"HeaderBackups": "备份",
|
||||||
"HeaderChangePassword": "更改密码",
|
"HeaderChangePassword": "更改密码",
|
||||||
"HeaderChapters": "章节",
|
"HeaderChapters": "章节",
|
||||||
@@ -127,15 +130,15 @@
|
|||||||
"HeaderManageTags": "管理标签",
|
"HeaderManageTags": "管理标签",
|
||||||
"HeaderMapDetails": "编辑详情",
|
"HeaderMapDetails": "编辑详情",
|
||||||
"HeaderMatch": "匹配",
|
"HeaderMatch": "匹配",
|
||||||
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
|
"HeaderMetadataOrderOfPrecedence": "元数据优先级",
|
||||||
"HeaderMetadataToEmbed": "嵌入元数据",
|
"HeaderMetadataToEmbed": "嵌入元数据",
|
||||||
"HeaderNewAccount": "新建帐户",
|
"HeaderNewAccount": "新建帐户",
|
||||||
"HeaderNewLibrary": "新建媒体库",
|
"HeaderNewLibrary": "新建媒体库",
|
||||||
"HeaderNotifications": "通知",
|
"HeaderNotifications": "通知",
|
||||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
"HeaderOpenIDConnectAuthentication": "OpenID 连接身份验证",
|
||||||
"HeaderOpenRSSFeed": "打开 RSS 源",
|
"HeaderOpenRSSFeed": "打开 RSS 源",
|
||||||
"HeaderOtherFiles": "其他文件",
|
"HeaderOtherFiles": "其他文件",
|
||||||
"HeaderPasswordAuthentication": "Password Authentication",
|
"HeaderPasswordAuthentication": "密码认证",
|
||||||
"HeaderPermissions": "权限",
|
"HeaderPermissions": "权限",
|
||||||
"HeaderPlayerQueue": "播放队列",
|
"HeaderPlayerQueue": "播放队列",
|
||||||
"HeaderPlaylist": "播放列表",
|
"HeaderPlaylist": "播放列表",
|
||||||
@@ -184,11 +187,11 @@
|
|||||||
"LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏",
|
"LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏",
|
||||||
"LabelAddToPlaylist": "添加到播放列表",
|
"LabelAddToPlaylist": "添加到播放列表",
|
||||||
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
|
"LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表",
|
||||||
"LabelAdminUsersOnly": "Admin users only",
|
"LabelAdminUsersOnly": "仅限管理员用户",
|
||||||
"LabelAll": "全部",
|
"LabelAll": "全部",
|
||||||
"LabelAllUsers": "所有用户",
|
"LabelAllUsers": "所有用户",
|
||||||
"LabelAllUsersExcludingGuests": "All users excluding guests",
|
"LabelAllUsersExcludingGuests": "除访客外的所有用户",
|
||||||
"LabelAllUsersIncludingGuests": "All users including guests",
|
"LabelAllUsersIncludingGuests": "包括访客的所有用户",
|
||||||
"LabelAlreadyInYourLibrary": "已存在你的库中",
|
"LabelAlreadyInYourLibrary": "已存在你的库中",
|
||||||
"LabelAppend": "附加",
|
"LabelAppend": "附加",
|
||||||
"LabelAuthor": "作者",
|
"LabelAuthor": "作者",
|
||||||
@@ -196,10 +199,12 @@
|
|||||||
"LabelAuthorLastFirst": "作者 (名, 姓)",
|
"LabelAuthorLastFirst": "作者 (名, 姓)",
|
||||||
"LabelAuthors": "作者",
|
"LabelAuthors": "作者",
|
||||||
"LabelAutoDownloadEpisodes": "自动下载剧集",
|
"LabelAutoDownloadEpisodes": "自动下载剧集",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoFetchMetadata": "自动获取元数据",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoFetchMetadataHelp": "获取标题, 作者和系列的元数据以简化上传. 上传后可能需要匹配其他元数据.",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoLaunch": "自动启动",
|
||||||
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
|
"LabelAutoLaunchDescription": "导航到登录页面时自动重定向到身份验证提供程序 (手动覆盖路径 <code>/login?autoLaunch=0</code>)",
|
||||||
|
"LabelAutoRegister": "自动注册",
|
||||||
|
"LabelAutoRegisterDescription": "登录后自动创建新用户",
|
||||||
"LabelBackToUser": "返回到用户",
|
"LabelBackToUser": "返回到用户",
|
||||||
"LabelBackupLocation": "备份位置",
|
"LabelBackupLocation": "备份位置",
|
||||||
"LabelBackupsEnableAutomaticBackups": "启用自动备份",
|
"LabelBackupsEnableAutomaticBackups": "启用自动备份",
|
||||||
@@ -210,13 +215,13 @@
|
|||||||
"LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.",
|
"LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.",
|
||||||
"LabelBitrate": "比特率",
|
"LabelBitrate": "比特率",
|
||||||
"LabelBooks": "图书",
|
"LabelBooks": "图书",
|
||||||
"LabelButtonText": "Button Text",
|
"LabelButtonText": "按钮文本",
|
||||||
"LabelChangePassword": "修改密码",
|
"LabelChangePassword": "修改密码",
|
||||||
"LabelChannels": "声道",
|
"LabelChannels": "声道",
|
||||||
"LabelChapters": "章节",
|
"LabelChapters": "章节",
|
||||||
"LabelChaptersFound": "找到的章节",
|
"LabelChaptersFound": "找到的章节",
|
||||||
"LabelChapterTitle": "章节标题",
|
"LabelChapterTitle": "章节标题",
|
||||||
"LabelClickForMoreInfo": "Click for more info",
|
"LabelClickForMoreInfo": "点击了解更多信息",
|
||||||
"LabelClosePlayer": "关闭播放器",
|
"LabelClosePlayer": "关闭播放器",
|
||||||
"LabelCodec": "编解码",
|
"LabelCodec": "编解码",
|
||||||
"LabelCollapseSeries": "折叠系列",
|
"LabelCollapseSeries": "折叠系列",
|
||||||
@@ -235,12 +240,12 @@
|
|||||||
"LabelCurrently": "当前:",
|
"LabelCurrently": "当前:",
|
||||||
"LabelCustomCronExpression": "自定义计划任务表达式:",
|
"LabelCustomCronExpression": "自定义计划任务表达式:",
|
||||||
"LabelDatetime": "日期时间",
|
"LabelDatetime": "日期时间",
|
||||||
"LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)",
|
"LabelDeleteFromFileSystemCheckbox": "从文件系统删除 (取消选中仅从数据库中删除)",
|
||||||
"LabelDescription": "描述",
|
"LabelDescription": "描述",
|
||||||
"LabelDeselectAll": "全部取消选择",
|
"LabelDeselectAll": "全部取消选择",
|
||||||
"LabelDevice": "设备",
|
"LabelDevice": "设备",
|
||||||
"LabelDeviceInfo": "设备信息",
|
"LabelDeviceInfo": "设备信息",
|
||||||
"LabelDeviceIsAvailableTo": "Device is available to...",
|
"LabelDeviceIsAvailableTo": "设备可用于...",
|
||||||
"LabelDirectory": "目录",
|
"LabelDirectory": "目录",
|
||||||
"LabelDiscFromFilename": "从文件名获取光盘",
|
"LabelDiscFromFilename": "从文件名获取光盘",
|
||||||
"LabelDiscFromMetadata": "从元数据获取光盘",
|
"LabelDiscFromMetadata": "从元数据获取光盘",
|
||||||
@@ -266,6 +271,7 @@
|
|||||||
"LabelExample": "示例",
|
"LabelExample": "示例",
|
||||||
"LabelExplicit": "信息准确",
|
"LabelExplicit": "信息准确",
|
||||||
"LabelFeedURL": "源 URL",
|
"LabelFeedURL": "源 URL",
|
||||||
|
"LabelFetchingMetadata": "正在获取元数据",
|
||||||
"LabelFile": "文件",
|
"LabelFile": "文件",
|
||||||
"LabelFileBirthtime": "文件创建时间",
|
"LabelFileBirthtime": "文件创建时间",
|
||||||
"LabelFileModified": "文件修改时间",
|
"LabelFileModified": "文件修改时间",
|
||||||
@@ -283,7 +289,7 @@
|
|||||||
"LabelHardDeleteFile": "完全删除文件",
|
"LabelHardDeleteFile": "完全删除文件",
|
||||||
"LabelHasEbook": "有电子书",
|
"LabelHasEbook": "有电子书",
|
||||||
"LabelHasSupplementaryEbook": "有补充电子书",
|
"LabelHasSupplementaryEbook": "有补充电子书",
|
||||||
"LabelHighestPriority": "Highest priority",
|
"LabelHighestPriority": "最高优先级",
|
||||||
"LabelHost": "主机",
|
"LabelHost": "主机",
|
||||||
"LabelHour": "小时",
|
"LabelHour": "小时",
|
||||||
"LabelIcon": "图标",
|
"LabelIcon": "图标",
|
||||||
@@ -325,18 +331,20 @@
|
|||||||
"LabelLogLevelInfo": "信息",
|
"LabelLogLevelInfo": "信息",
|
||||||
"LabelLogLevelWarn": "警告",
|
"LabelLogLevelWarn": "警告",
|
||||||
"LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集",
|
"LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集",
|
||||||
"LabelLowestPriority": "Lowest Priority",
|
"LabelLowestPriority": "最低优先级",
|
||||||
"LabelMatchExistingUsersBy": "Match existing users by",
|
"LabelMatchExistingUsersBy": "匹配现有用户",
|
||||||
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
|
"LabelMatchExistingUsersByDescription": "用于连接现有用户. 连接后, 用户将通过SSO提供商提供的唯一 id 进行匹配",
|
||||||
"LabelMediaPlayer": "媒体播放器",
|
"LabelMediaPlayer": "媒体播放器",
|
||||||
"LabelMediaType": "媒体类型",
|
"LabelMediaType": "媒体类型",
|
||||||
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
|
"LabelMetadataOrderOfPrecedenceDescription": "较高优先级的元数据源将覆盖较低优先级的元数据源",
|
||||||
"LabelMetadataProvider": "元数据提供者",
|
"LabelMetadataProvider": "元数据提供者",
|
||||||
"LabelMetaTag": "元数据标签",
|
"LabelMetaTag": "元数据标签",
|
||||||
"LabelMetaTags": "元标签",
|
"LabelMetaTags": "元标签",
|
||||||
"LabelMinute": "分钟",
|
"LabelMinute": "分钟",
|
||||||
"LabelMissing": "丢失",
|
"LabelMissing": "丢失",
|
||||||
"LabelMissingParts": "丢失的部分",
|
"LabelMissingParts": "丢失的部分",
|
||||||
|
"LabelMobileRedirectURIs": "允许移动应用重定向 URI",
|
||||||
|
"LabelMobileRedirectURIsDescription": "这是移动应用程序的有效重定向 URI 白名单. 默认值为 <code>audiobookshelf://oauth</code>,您可以删除它或添加其他 URI 以进行第三方应用集成. 使用星号 (<code>*</code>) 作为唯一条目允许任何 URI.",
|
||||||
"LabelMore": "更多",
|
"LabelMore": "更多",
|
||||||
"LabelMoreInfo": "更多..",
|
"LabelMoreInfo": "更多..",
|
||||||
"LabelName": "名称",
|
"LabelName": "名称",
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
"LabelRegion": "区域",
|
"LabelRegion": "区域",
|
||||||
"LabelReleaseDate": "发布日期",
|
"LabelReleaseDate": "发布日期",
|
||||||
"LabelRemoveCover": "移除封面",
|
"LabelRemoveCover": "移除封面",
|
||||||
|
"LabelRowsPerPage": "Rows per page",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "自定义所有者电子邮件",
|
"LabelRSSFeedCustomOwnerEmail": "自定义所有者电子邮件",
|
||||||
"LabelRSSFeedCustomOwnerName": "自定义所有者名称",
|
"LabelRSSFeedCustomOwnerName": "自定义所有者名称",
|
||||||
"LabelRSSFeedOpen": "打开 RSS 源",
|
"LabelRSSFeedOpen": "打开 RSS 源",
|
||||||
@@ -515,6 +524,7 @@
|
|||||||
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
|
"LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息",
|
||||||
"LabelUploaderDragAndDrop": "拖放文件或文件夹",
|
"LabelUploaderDragAndDrop": "拖放文件或文件夹",
|
||||||
"LabelUploaderDropFiles": "删除文件",
|
"LabelUploaderDropFiles": "删除文件",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列",
|
||||||
"LabelUseChapterTrack": "使用章节音轨",
|
"LabelUseChapterTrack": "使用章节音轨",
|
||||||
"LabelUseFullTrack": "使用完整音轨",
|
"LabelUseFullTrack": "使用完整音轨",
|
||||||
"LabelUser": "用户",
|
"LabelUser": "用户",
|
||||||
@@ -548,20 +558,21 @@
|
|||||||
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
|
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
|
||||||
"MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?",
|
"MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?",
|
||||||
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?",
|
||||||
"MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?",
|
"MessageConfirmDeleteLibraryItem": "这将从数据库和文件系统中删除库项目. 你确定吗?",
|
||||||
"MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?",
|
"MessageConfirmDeleteLibraryItems": "这将从数据库和文件系统中删除 {0} 个库项目. 你确定吗?",
|
||||||
"MessageConfirmDeleteSession": "你确定要删除此会话吗?",
|
"MessageConfirmDeleteSession": "你确定要删除此会话吗?",
|
||||||
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
|
"MessageConfirmForceReScan": "你确定要强制重新扫描吗?",
|
||||||
"MessageConfirmMarkAllEpisodesFinished": "你确定要将所有剧集都标记为已完成吗?",
|
"MessageConfirmMarkAllEpisodesFinished": "你确定要将所有剧集都标记为已完成吗?",
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "你确定要将所有剧集都标记为未完成吗?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "你确定要将所有剧集都标记为未完成吗?",
|
||||||
"MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?",
|
"MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?",
|
||||||
"MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?",
|
"MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?",
|
||||||
"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": "警告! 快速嵌入不会备份你的音频文件. 确保你有音频文件的备份. <br><br>你是否想继续吗?",
|
||||||
"MessageConfirmRemoveAllChapters": "你确定要移除所有章节吗?",
|
"MessageConfirmRemoveAllChapters": "你确定要移除所有章节吗?",
|
||||||
"MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?",
|
"MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?",
|
||||||
"MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?",
|
"MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?",
|
"MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?",
|
||||||
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
|
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||||
"MessageConfirmRemoveNarrator": "你确定要删除演播者 \"{0}\"?",
|
"MessageConfirmRemoveNarrator": "你确定要删除演播者 \"{0}\"?",
|
||||||
"MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?",
|
"MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?",
|
||||||
"MessageConfirmRenameGenre": "你确定要将所有项目流派 \"{0}\" 重命名到 \"{1}\"?",
|
"MessageConfirmRenameGenre": "你确定要将所有项目流派 \"{0}\" 重命名到 \"{1}\"?",
|
||||||
@@ -570,7 +581,7 @@
|
|||||||
"MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?",
|
"MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?",
|
||||||
"MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.",
|
"MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.",
|
||||||
"MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".",
|
"MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".",
|
||||||
"MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?",
|
"MessageConfirmReScanLibraryItems": "你确定要重新扫描 {0} 个项目吗?",
|
||||||
"MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?",
|
||||||
"MessageDownloadingEpisode": "正在下载剧集",
|
"MessageDownloadingEpisode": "正在下载剧集",
|
||||||
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
|
"MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序",
|
||||||
@@ -641,6 +652,7 @@
|
|||||||
"MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份",
|
"MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份",
|
||||||
"MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果您已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.",
|
"MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果您已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.",
|
||||||
"MessageSearchResultsFor": "搜索结果",
|
"MessageSearchResultsFor": "搜索结果",
|
||||||
|
"MessageSelected": "{0} selected",
|
||||||
"MessageServerCouldNotBeReached": "无法访问服务器",
|
"MessageServerCouldNotBeReached": "无法访问服务器",
|
||||||
"MessageSetChaptersFromTracksDescription": "把每个音频文件设置为章节并将章节标题设置为音频文件名",
|
"MessageSetChaptersFromTracksDescription": "把每个音频文件设置为章节并将章节标题设置为音频文件名",
|
||||||
"MessageStartPlaybackAtTime": "开始播放 \"{0}\" 在 {1}?",
|
"MessageStartPlaybackAtTime": "开始播放 \"{0}\" 在 {1}?",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ module.exports = {
|
|||||||
'16': '4rem',
|
'16': '4rem',
|
||||||
'20': '5rem',
|
'20': '5rem',
|
||||||
'24': '6rem',
|
'24': '6rem',
|
||||||
|
'26': '6.5rem',
|
||||||
'32': '8rem',
|
'32': '8rem',
|
||||||
'48': '12rem',
|
'48': '12rem',
|
||||||
'64': '16rem',
|
'64': '16rem',
|
||||||
|
|||||||
+17
-1
@@ -3,12 +3,28 @@ version: "3.7"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
audiobookshelf:
|
audiobookshelf:
|
||||||
image: ghcr.io/advplyr/audiobookshelf
|
image: ghcr.io/advplyr/audiobookshelf:latest
|
||||||
|
# ABS runs on port 13378 by default. If you want to change
|
||||||
|
# the port, only change the external port, not the internal port
|
||||||
ports:
|
ports:
|
||||||
- 13378:80
|
- 13378:80
|
||||||
volumes:
|
volumes:
|
||||||
|
# These volumes are needed to keep your library persistent
|
||||||
|
# and allow media to be accessed by the ABS server.
|
||||||
|
# The path to the left of the colon is the path on your computer,
|
||||||
|
# and the path to the right of the colon is where the data is
|
||||||
|
# available to ABS in Docker.
|
||||||
|
# You can change these media directories or add as many as you want
|
||||||
- ./audiobooks:/audiobooks
|
- ./audiobooks:/audiobooks
|
||||||
- ./podcasts:/podcasts
|
- ./podcasts:/podcasts
|
||||||
|
# The metadata directory can be stored anywhere on your computer
|
||||||
- ./metadata:/metadata
|
- ./metadata:/metadata
|
||||||
|
# The config directory needs to be on the same physical machine
|
||||||
|
# you are running ABS on
|
||||||
- ./config:/config
|
- ./config:/config
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# You can use the following environment variable to run the ABS
|
||||||
|
# docker container as a specific user. You will need to change
|
||||||
|
# the UID and GID to the correct values for your user.
|
||||||
|
#environment:
|
||||||
|
# - user=1000:1000
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.6.0",
|
"version": "2.7.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.6.0",
|
"version": "2.7.0",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.6.0",
|
"version": "2.7.0",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|||||||
@@ -174,16 +174,49 @@ serve that directly:
|
|||||||
|
|
||||||
[See LinuxServer.io config sample](https://github.com/linuxserver/reverse-proxy-confs/blob/master/audiobookshelf.subdomain.conf.sample)
|
[See LinuxServer.io config sample](https://github.com/linuxserver/reverse-proxy-confs/blob/master/audiobookshelf.subdomain.conf.sample)
|
||||||
|
|
||||||
### Synology Reverse Proxy
|
### Synology NAS Reverse Proxy Setup (DSM 7+/Quickconnect)
|
||||||
|
|
||||||
1. Open Control Panel > Application Portal
|
1. **Open Control Panel**
|
||||||
2. Change to the Reverse Proxy tab
|
- Navigate to `Login Portal > Advanced`.
|
||||||
3. Select the proxy rule for which you want to enable Websockets and click on Edit
|
|
||||||
4. Change to the "Custom Header" tab
|
2. **General Tab**
|
||||||
5. Click Create > WebSocket
|
- Click `Reverse Proxy` > `Create`.
|
||||||
6. Click Save
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|----------------|
|
||||||
|
| Reverse Proxy Name | audiobookshelf |
|
||||||
|
|
||||||
|
3. **Source Configuration**
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|-------------------------|-------------------------------------|
|
||||||
|
| Protocol | HTTPS |
|
||||||
|
| Hostname | `<sub>.<quickconnectdomain>.synology.me` |
|
||||||
|
| Port | 443 |
|
||||||
|
| Access Control Profile | Leave as is |
|
||||||
|
|
||||||
|
- Example Hostname: `audiobookshelf.mydomain.synology.me`
|
||||||
|
|
||||||
|
4. **Destination Configuration**
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|-----------|------------------|
|
||||||
|
| Protocol | HTTP |
|
||||||
|
| Hostname | Your NAS IP |
|
||||||
|
| Port | 13378 |
|
||||||
|
|
||||||
|
5. **Custom Header Tab**
|
||||||
|
- Go to `Create > Websocket`.
|
||||||
|
- Configure Headers (leave as is):
|
||||||
|
|
||||||
|
| Header Name | Value |
|
||||||
|
|-------------|------------------|
|
||||||
|
| Upgrade | `$http_upgrade` |
|
||||||
|
| Connection | `$connection_upgrade` |
|
||||||
|
|
||||||
|
6. **Advanced Settings Tab**
|
||||||
|
- Leave as is.
|
||||||
|
|
||||||
[from @silentArtifact](https://github.com/advplyr/audiobookshelf/issues/241#issuecomment-1036732329)
|
|
||||||
|
|
||||||
### [Traefik Reverse Proxy](https://doc.traefik.io/traefik/)
|
### [Traefik Reverse Proxy](https://doc.traefik.io/traefik/)
|
||||||
|
|
||||||
|
|||||||
+136
-17
@@ -8,6 +8,7 @@ const ExtractJwt = require('passport-jwt').ExtractJwt
|
|||||||
const OpenIDClient = require('openid-client')
|
const OpenIDClient = require('openid-client')
|
||||||
const Database = require('./Database')
|
const Database = require('./Database')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
const e = require('express')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @class Class for handling all the authentication related functionality.
|
* @class Class for handling all the authentication related functionality.
|
||||||
@@ -15,6 +16,8 @@ const Logger = require('./Logger')
|
|||||||
class Auth {
|
class Auth {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
// Map of openId sessions indexed by oauth2 state-variable
|
||||||
|
this.openIdAuthSession = new Map()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,9 +190,10 @@ class Auth {
|
|||||||
* @param {import('express').Response} res
|
* @param {import('express').Response} res
|
||||||
*/
|
*/
|
||||||
paramsToCookies(req, res) {
|
paramsToCookies(req, res) {
|
||||||
if (req.query.isRest?.toLowerCase() == 'true') {
|
// Set if isRest flag is set or if mobile oauth flow is used
|
||||||
|
if (req.query.isRest?.toLowerCase() == 'true' || req.query.redirect_uri) {
|
||||||
// store the isRest flag to the is_rest cookie
|
// store the isRest flag to the is_rest cookie
|
||||||
res.cookie('is_rest', req.query.isRest.toLowerCase(), {
|
res.cookie('is_rest', 'true', {
|
||||||
maxAge: 120000, // 2 min
|
maxAge: 120000, // 2 min
|
||||||
httpOnly: true
|
httpOnly: true
|
||||||
})
|
})
|
||||||
@@ -283,8 +287,27 @@ class Auth {
|
|||||||
// for API or mobile clients
|
// for API or mobile clients
|
||||||
const oidcStrategy = passport._strategy('openid-client')
|
const oidcStrategy = passport._strategy('openid-client')
|
||||||
const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http'
|
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}`)
|
let mobile_redirect_uri = null
|
||||||
|
|
||||||
|
// The client wishes a different redirect_uri
|
||||||
|
// We will allow if it is in the whitelist, by saving it into this.openIdAuthSession and setting the redirect uri to /auth/openid/mobile-redirect
|
||||||
|
// where we will handle the redirect to it
|
||||||
|
if (req.query.redirect_uri) {
|
||||||
|
// Check if the redirect_uri is in the whitelist
|
||||||
|
if (Database.serverSettings.authOpenIDMobileRedirectURIs.includes(req.query.redirect_uri) ||
|
||||||
|
(Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')) {
|
||||||
|
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/mobile-redirect`).toString()
|
||||||
|
mobile_redirect_uri = req.query.redirect_uri
|
||||||
|
} else {
|
||||||
|
Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri} - not in whitelist`)
|
||||||
|
return res.status(400).send('Invalid redirect_uri')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.debug(`[Auth] Oidc redirect_uri=${oidcStrategy._params.redirect_uri}`)
|
||||||
const client = oidcStrategy._client
|
const client = oidcStrategy._client
|
||||||
const sessionKey = oidcStrategy._key
|
const sessionKey = oidcStrategy._key
|
||||||
|
|
||||||
@@ -324,16 +347,21 @@ class Auth {
|
|||||||
req.session[sessionKey] = {
|
req.session[sessionKey] = {
|
||||||
...req.session[sessionKey],
|
...req.session[sessionKey],
|
||||||
...pick(params, 'nonce', 'state', 'max_age', 'response_type'),
|
...pick(params, 'nonce', 'state', 'max_age', 'response_type'),
|
||||||
mobile: req.query.isRest?.toLowerCase() === 'true' // Used in the abs callback later
|
mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out
|
||||||
|
sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We cannot save redirect_uri in the session, because it the mobile client uses browser instead of the API
|
||||||
|
// for the request to mobile-redirect and as such the session is not shared
|
||||||
|
this.openIdAuthSession.set(params.state, { mobile_redirect_uri: mobile_redirect_uri })
|
||||||
|
|
||||||
// Now get the URL to direct to
|
// Now get the URL to direct to
|
||||||
const authorizationUrl = client.authorizationUrl({
|
const authorizationUrl = client.authorizationUrl({
|
||||||
...params,
|
...params,
|
||||||
scope: 'openid profile email',
|
scope: 'openid profile email',
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
code_challenge,
|
code_challenge,
|
||||||
code_challenge_method,
|
code_challenge_method
|
||||||
})
|
})
|
||||||
|
|
||||||
// params (isRest, callback) to a cookie that will be send to the client
|
// params (isRest, callback) to a cookie that will be send to the client
|
||||||
@@ -347,6 +375,37 @@ class Auth {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// This will be the oauth2 callback route for mobile clients
|
||||||
|
// It will redirect to an app-link like audiobookshelf://oauth
|
||||||
|
router.get('/auth/openid/mobile-redirect', (req, res) => {
|
||||||
|
try {
|
||||||
|
// Extract the state parameter from the request
|
||||||
|
const { state, code } = req.query
|
||||||
|
|
||||||
|
// Check if the state provided is in our list
|
||||||
|
if (!state || !this.openIdAuthSession.has(state)) {
|
||||||
|
Logger.error('[Auth] /auth/openid/mobile-redirect route: State parameter mismatch')
|
||||||
|
return res.status(400).send('State parameter mismatch')
|
||||||
|
}
|
||||||
|
|
||||||
|
let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri
|
||||||
|
|
||||||
|
if (!mobile_redirect_uri) {
|
||||||
|
Logger.error('[Auth] No redirect URI')
|
||||||
|
return res.status(400).send('No redirect URI')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.openIdAuthSession.delete(state)
|
||||||
|
|
||||||
|
const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
||||||
|
// Redirect to the overwrite URI saved in the map
|
||||||
|
res.redirect(redirectUri)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}`)
|
||||||
|
res.status(500).send('Internal Server Error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// openid strategy callback route (this receives the token from the configured openid login provider)
|
// openid strategy callback route (this receives the token from the configured openid login provider)
|
||||||
router.get('/auth/openid/callback', (req, res, next) => {
|
router.get('/auth/openid/callback', (req, res, next) => {
|
||||||
const oidcStrategy = passport._strategy('openid-client')
|
const oidcStrategy = passport._strategy('openid-client')
|
||||||
@@ -363,29 +422,85 @@ class Auth {
|
|||||||
req.session[sessionKey].code_verifier = req.query.code_verifier
|
req.session[sessionKey].code_verifier = req.query.code_verifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {
|
||||||
|
Logger.error(logMessage)
|
||||||
|
if (response) {
|
||||||
|
// Depending on the error, it can also have a body
|
||||||
|
// We also log the request header the passport plugin sents for the URL
|
||||||
|
const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED')
|
||||||
|
Logger.debug(header + '\n' + response.body?.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return res.status(errorCode).send(errorMessage)
|
||||||
|
} else {
|
||||||
|
return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function passportCallback(req, res, next) {
|
||||||
|
return (err, user, info) => {
|
||||||
|
const isMobile = req.session[sessionKey]?.mobile === true
|
||||||
|
if (err) {
|
||||||
|
return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Info usually contains the error message from the SSO provider
|
||||||
|
return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.logIn(user, (loginError) => {
|
||||||
|
if (loginError) {
|
||||||
|
return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`)
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request
|
// 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
|
// 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) {
|
// We set it here again because the passport param can change between requests
|
||||||
return passport.authenticate('openid-client', { redirect_uri: 'audiobookshelf://oauth' })(req, res, next)
|
return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(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)
|
// on a successfull login: read the cookies and react like the client requested (callback or json)
|
||||||
this.handleLoginSuccessBasedOnCookie.bind(this))
|
this.handleLoginSuccessBasedOnCookie.bind(this))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used to auto-populate the openid URLs in config/authentication
|
* Helper route used to auto-populate the openid URLs in config/authentication
|
||||||
|
* Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration"
|
||||||
|
*
|
||||||
|
* @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/
|
||||||
*/
|
*/
|
||||||
router.get('/auth/openid/config', async (req, res) => {
|
router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[Auth] Non-admin user "${req.user.username}" attempted to get issuer config`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
if (!req.query.issuer) {
|
if (!req.query.issuer) {
|
||||||
return res.status(400).send('Invalid request. Query param \'issuer\' is required')
|
return res.status(400).send('Invalid request. Query param \'issuer\' is required')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip trailing slash
|
||||||
let issuerUrl = req.query.issuer
|
let issuerUrl = req.query.issuer
|
||||||
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
||||||
|
|
||||||
const configUrl = `${issuerUrl}/.well-known/openid-configuration`
|
// Append config pathname and validate URL
|
||||||
axios.get(configUrl).then(({ data }) => {
|
let configUrl = null
|
||||||
|
try {
|
||||||
|
configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`)
|
||||||
|
if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) {
|
||||||
|
throw new Error('Invalid pathname')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
|
||||||
|
return res.status(400).send('Invalid request. Query param \'issuer\' is invalid')
|
||||||
|
}
|
||||||
|
|
||||||
|
axios.get(configUrl.toString()).then(({ data }) => {
|
||||||
res.json({
|
res.json({
|
||||||
issuer: data.issuer,
|
issuer: data.issuer,
|
||||||
authorization_endpoint: data.authorization_endpoint,
|
authorization_endpoint: data.authorization_endpoint,
|
||||||
@@ -504,13 +619,13 @@ class Auth {
|
|||||||
// Load the user given it's username
|
// Load the user given it's username
|
||||||
const user = await Database.userModel.getUserByUsername(username.toLowerCase())
|
const user = await Database.userModel.getUserByUsername(username.toLowerCase())
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user?.isActive) {
|
||||||
done(null, null)
|
done(null, null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check passwordless root user
|
// Check passwordless root user
|
||||||
if (user.type === 'root' && (!user.pash || user.pash === '')) {
|
if (user.type === 'root' && !user.pash) {
|
||||||
if (password) {
|
if (password) {
|
||||||
// deny login
|
// deny login
|
||||||
done(null, null)
|
done(null, null)
|
||||||
@@ -519,6 +634,10 @@ class Auth {
|
|||||||
// approve login
|
// approve login
|
||||||
done(null, user)
|
done(null, user)
|
||||||
return
|
return
|
||||||
|
} else if (!user.pash) {
|
||||||
|
Logger.error(`[Auth] User "${user.username}"/"${user.type}" attempted to login without a password set`)
|
||||||
|
done(null, null)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check password match
|
// Check password match
|
||||||
|
|||||||
+20
-2
@@ -1,5 +1,5 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const { Sequelize } = require('sequelize')
|
const { Sequelize, Op } = require('sequelize')
|
||||||
|
|
||||||
const packageJson = require('../package.json')
|
const packageJson = require('../package.json')
|
||||||
const fs = require('./libs/fsExtra')
|
const fs = require('./libs/fsExtra')
|
||||||
@@ -122,11 +122,16 @@ class Database {
|
|||||||
return this.models.feed
|
return this.models.feed
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {typeof import('./models/Feed')} */
|
/** @type {typeof import('./models/FeedEpisode')} */
|
||||||
get feedEpisodeModel() {
|
get feedEpisodeModel() {
|
||||||
return this.models.feedEpisode
|
return this.models.feedEpisode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/PlaybackSession')} */
|
||||||
|
get playbackSessionModel() {
|
||||||
|
return this.models.playbackSession
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if db file exists
|
* Check if db file exists
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
@@ -693,6 +698,7 @@ class Database {
|
|||||||
* Clean invalid records in database
|
* Clean invalid records in database
|
||||||
* Series should have atleast one Book
|
* Series should have atleast one Book
|
||||||
* Book and Podcast must have an associated LibraryItem
|
* Book and Podcast must have an associated LibraryItem
|
||||||
|
* Remove playback sessions that are 3 seconds or less
|
||||||
*/
|
*/
|
||||||
async cleanDatabase() {
|
async cleanDatabase() {
|
||||||
// Remove invalid Podcast records
|
// Remove invalid Podcast records
|
||||||
@@ -733,6 +739,18 @@ class Database {
|
|||||||
Logger.warn(`Found series "${series.name}" with no books - removing it`)
|
Logger.warn(`Found series "${series.name}" with no books - removing it`)
|
||||||
await series.destroy()
|
await series.destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove playback sessions that were 3 seconds or less
|
||||||
|
const badSessionsRemoved = await this.playbackSessionModel.destroy({
|
||||||
|
where: {
|
||||||
|
timeListening: {
|
||||||
|
[Op.lte]: 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (badSessionsRemoved > 0) {
|
||||||
|
Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ class Server {
|
|||||||
'/library/:library/search',
|
'/library/:library/search',
|
||||||
'/library/:library/bookshelf/:id?',
|
'/library/:library/bookshelf/:id?',
|
||||||
'/library/:library/authors',
|
'/library/:library/authors',
|
||||||
|
'/library/:library/narrators',
|
||||||
'/library/:library/series/:id?',
|
'/library/:library/series/:id?',
|
||||||
'/library/:library/podcast/search',
|
'/library/:library/podcast/search',
|
||||||
'/library/:library/podcast/latest',
|
'/library/:library/podcast/latest',
|
||||||
|
|||||||
@@ -552,8 +552,8 @@ class LibraryController {
|
|||||||
* @param {import('express').Response} res
|
* @param {import('express').Response} res
|
||||||
*/
|
*/
|
||||||
async search(req, res) {
|
async search(req, res) {
|
||||||
if (!req.query.q) {
|
if (!req.query.q || typeof req.query.q !== 'string') {
|
||||||
return res.status(400).send('No query string')
|
return res.status(400).send('Invalid request. Query param "q" must be a string')
|
||||||
}
|
}
|
||||||
const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
|
const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
|
||||||
const query = asciiOnlyToLowerCase(req.query.q.trim())
|
const query = asciiOnlyToLowerCase(req.query.q.trim())
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const SocketAuthority = require('../SocketAuthority')
|
|||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const { sort } = require('../libs/fastSort')
|
const { sort } = require('../libs/fastSort')
|
||||||
const { toNumber } = require('../utils/index')
|
const { toNumber } = require('../utils/index')
|
||||||
|
const userStats = require('../utils/queries/userStats')
|
||||||
|
|
||||||
class MeController {
|
class MeController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
@@ -333,5 +334,21 @@ class MeController {
|
|||||||
}
|
}
|
||||||
res.json(req.user.toJSONForBrowser())
|
res.json(req.user.toJSONForBrowser())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/stats/year/:year
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
|
async getStatsForYear(req, res) {
|
||||||
|
const year = Number(req.params.year)
|
||||||
|
if (isNaN(year) || year < 2000 || year > 9999) {
|
||||||
|
Logger.error(`[MeController] Invalid year "${year}"`)
|
||||||
|
return res.status(400).send('Invalid year')
|
||||||
|
}
|
||||||
|
const data = await userStats.getStatsForYear(req.user, year)
|
||||||
|
res.json(data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = new MeController()
|
module.exports = new MeController()
|
||||||
@@ -8,8 +8,10 @@ const Database = require('../Database')
|
|||||||
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
||||||
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
||||||
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
|
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
|
||||||
|
const { sanitizeFilename } = require('../utils/fileUtils')
|
||||||
|
|
||||||
const TaskManager = require('../managers/TaskManager')
|
const TaskManager = require('../managers/TaskManager')
|
||||||
|
const adminStats = require('../utils/queries/adminStats')
|
||||||
|
|
||||||
//
|
//
|
||||||
// This is a controller for routes that don't have a home yet :(
|
// This is a controller for routes that don't have a home yet :(
|
||||||
@@ -32,12 +34,9 @@ class MiscController {
|
|||||||
Logger.error('Invalid request, no files')
|
Logger.error('Invalid request, no files')
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = Object.values(req.files)
|
const files = Object.values(req.files)
|
||||||
const title = req.body.title
|
const { title, author, series, folder: folderId, library: libraryId } = req.body
|
||||||
const author = req.body.author
|
|
||||||
const series = req.body.series
|
|
||||||
const libraryId = req.body.library
|
|
||||||
const folderId = req.body.folder
|
|
||||||
|
|
||||||
const library = await Database.libraryModel.getOldById(libraryId)
|
const library = await Database.libraryModel.getOldById(libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
@@ -52,43 +51,29 @@ class MiscController {
|
|||||||
return res.status(500).send(`Invalid post data`)
|
return res.status(500).send(`Invalid post data`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// For setting permissions recursively
|
// Podcasts should only be one folder deep
|
||||||
let outputDirectory = ''
|
const outputDirectoryParts = library.isPodcast ? [title] : [author, series, title]
|
||||||
let firstDirPath = ''
|
// `.filter(Boolean)` to strip out all the potentially missing details (eg: `author`)
|
||||||
|
// before sanitizing all the directory parts to remove illegal chars and finally prepending
|
||||||
if (library.isPodcast) { // Podcasts only in 1 folder
|
// the base folder path
|
||||||
outputDirectory = Path.join(folder.fullPath, title)
|
const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map(part => sanitizeFilename(part))
|
||||||
firstDirPath = outputDirectory
|
const outputDirectory = Path.join(...[folder.fullPath, ...cleanedOutputDirectoryParts])
|
||||||
} else {
|
|
||||||
firstDirPath = Path.join(folder.fullPath, author)
|
|
||||||
if (series && author) {
|
|
||||||
outputDirectory = Path.join(folder.fullPath, author, series, title)
|
|
||||||
} else if (author) {
|
|
||||||
outputDirectory = Path.join(folder.fullPath, author, title)
|
|
||||||
} else {
|
|
||||||
outputDirectory = Path.join(folder.fullPath, title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await fs.pathExists(outputDirectory)) {
|
|
||||||
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
|
|
||||||
return res.status(500).send(`Directory "${outputDirectory}" already exists`)
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.ensureDir(outputDirectory)
|
await fs.ensureDir(outputDirectory)
|
||||||
|
|
||||||
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (const file of files) {
|
||||||
var file = files[i]
|
const path = Path.join(outputDirectory, sanitizeFilename(file.name))
|
||||||
|
|
||||||
var path = Path.join(outputDirectory, file.name)
|
await file.mv(path)
|
||||||
await file.mv(path).then(() => {
|
.then(() => {
|
||||||
return true
|
return true
|
||||||
}).catch((error) => {
|
})
|
||||||
Logger.error('Failed to move file', path, error)
|
.catch((error) => {
|
||||||
return false
|
Logger.error('Failed to move file', path, error)
|
||||||
})
|
return false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
@@ -645,6 +630,27 @@ class MiscController {
|
|||||||
} else {
|
} else {
|
||||||
Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`)
|
Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`)
|
||||||
}
|
}
|
||||||
|
} else if (key === 'authOpenIDMobileRedirectURIs') {
|
||||||
|
function isValidRedirectURI(uri) {
|
||||||
|
if (typeof uri !== 'string') return false
|
||||||
|
const pattern = new RegExp('^\\w+://[\\w.-]+$', 'i')
|
||||||
|
return pattern.test(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uris = settingsUpdate[key]
|
||||||
|
if (!Array.isArray(uris) ||
|
||||||
|
(uris.includes('*') && uris.length > 1) ||
|
||||||
|
uris.some(uri => uri !== '*' && !isValidRedirectURI(uri))) {
|
||||||
|
Logger.warn(`[MiscController] Invalid value for authOpenIDMobileRedirectURIs`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the URIs
|
||||||
|
if (Database.serverSettings[key].some(uri => !uris.includes(uri)) || uris.some(uri => !Database.serverSettings[key].includes(uri))) {
|
||||||
|
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${Database.serverSettings[key]}" to "${uris}"`)
|
||||||
|
Database.serverSettings[key] = uris
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const updatedValueType = typeof settingsUpdate[key]
|
const updatedValueType = typeof settingsUpdate[key]
|
||||||
if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) {
|
if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) {
|
||||||
@@ -687,8 +693,29 @@ class MiscController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
updated: hasUpdates,
|
||||||
serverSettings: Database.serverSettings.toJSONForBrowser()
|
serverSettings: Database.serverSettings.toJSONForBrowser()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/me/stats/year/:year
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
|
async getAdminStatsForYear(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin stats for year`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
const year = Number(req.params.year)
|
||||||
|
if (isNaN(year) || year < 2000 || year > 9999) {
|
||||||
|
Logger.error(`[MiscController] Invalid year "${year}"`)
|
||||||
|
return res.status(400).send('Invalid year')
|
||||||
|
}
|
||||||
|
const stats = await adminStats.getStatsForYear(year)
|
||||||
|
res.json(stats)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = new MiscController()
|
module.exports = new MiscController()
|
||||||
@@ -6,6 +6,7 @@ const fs = require('../libs/fsExtra')
|
|||||||
|
|
||||||
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
||||||
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
|
const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils')
|
||||||
|
const { validateUrl } = require('../utils/index')
|
||||||
|
|
||||||
const Scanner = require('../scanner/Scanner')
|
const Scanner = require('../scanner/Scanner')
|
||||||
const CoverManager = require('../managers/CoverManager')
|
const CoverManager = require('../managers/CoverManager')
|
||||||
@@ -16,7 +17,7 @@ class PodcastController {
|
|||||||
|
|
||||||
async create(req, res) {
|
async create(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[PodcastController] Non-admin user attempted to create podcast`, req.user)
|
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to create podcast`)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
const payload = req.body
|
const payload = req.body
|
||||||
@@ -102,10 +103,24 @@ class PodcastController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: /api/podcasts/feed
|
||||||
|
*
|
||||||
|
* @typedef getPodcastFeedReqBody
|
||||||
|
* @property {string} rssFeed
|
||||||
|
*
|
||||||
|
* @param {import('express').Request<{}, {}, getPodcastFeedReqBody, {}} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
async getPodcastFeed(req, res) {
|
async getPodcastFeed(req, res) {
|
||||||
var url = req.body.rssFeed
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get podcast feed`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = validateUrl(req.body.rssFeed)
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return res.status(400).send('Bad request')
|
return res.status(400).send('Invalid request body. "rssFeed" must be a valid URL')
|
||||||
}
|
}
|
||||||
|
|
||||||
const podcast = await getPodcastFeed(url)
|
const podcast = await getPodcastFeed(url)
|
||||||
@@ -116,6 +131,11 @@ class PodcastController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getFeedsFromOPMLText(req, res) {
|
async getFeedsFromOPMLText(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get feeds from opml`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
if (!req.body.opmlText) {
|
if (!req.body.opmlText) {
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ const BookFinder = require('../finders/BookFinder')
|
|||||||
const PodcastFinder = require('../finders/PodcastFinder')
|
const PodcastFinder = require('../finders/PodcastFinder')
|
||||||
const AuthorFinder = require('../finders/AuthorFinder')
|
const AuthorFinder = require('../finders/AuthorFinder')
|
||||||
const MusicFinder = require('../finders/MusicFinder')
|
const MusicFinder = require('../finders/MusicFinder')
|
||||||
|
const Database = require("../Database")
|
||||||
|
|
||||||
class SearchController {
|
class SearchController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
async findBooks(req, res) {
|
async findBooks(req, res) {
|
||||||
|
const id = req.query.id
|
||||||
|
const libraryItem = await Database.libraryItemModel.getOldById(id)
|
||||||
const provider = req.query.provider || 'google'
|
const provider = req.query.provider || 'google'
|
||||||
const title = req.query.title || ''
|
const title = req.query.title || ''
|
||||||
const author = req.query.author || ''
|
const author = req.query.author || ''
|
||||||
const results = await BookFinder.search(provider, title, author)
|
const results = await BookFinder.search(libraryItem, provider, title, author)
|
||||||
res.json(results)
|
res.json(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,8 +35,19 @@ class SearchController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find podcast RSS feeds given a term
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
async findPodcasts(req, res) {
|
async findPodcasts(req, res) {
|
||||||
const term = req.query.term
|
const term = req.query.term
|
||||||
|
if (!term) {
|
||||||
|
Logger.error('[SearchController] Invalid request query param "term" is required')
|
||||||
|
return res.status(400).send('Invalid request query param "term" is required')
|
||||||
|
}
|
||||||
|
|
||||||
const results = await PodcastFinder.search(term)
|
const results = await PodcastFinder.search(term)
|
||||||
res.json(results)
|
res.json(results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const { toNumber } = require('../utils/index')
|
const { toNumber, isUUID } = require('../utils/index')
|
||||||
|
|
||||||
class SessionController {
|
class SessionController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
@@ -9,35 +9,97 @@ class SessionController {
|
|||||||
return res.json(req.playbackSession)
|
return res.json(req.playbackSession)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/sessions
|
||||||
|
* @this import('../routers/ApiRouter')
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
async getAllWithUserData(req, res) {
|
async getAllWithUserData(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[SessionController] getAllWithUserData: Non-admin user requested all session data ${req.user.id}/"${req.user.username}"`)
|
Logger.error(`[SessionController] getAllWithUserData: Non-admin user requested all session data ${req.user.id}/"${req.user.username}"`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
// Validate "user" query
|
||||||
let listeningSessions = []
|
let userId = req.query.user
|
||||||
if (req.query.user) {
|
if (userId && !isUUID(userId)) {
|
||||||
listeningSessions = await this.getUserListeningSessionsHelper(req.query.user)
|
Logger.warn(`[SessionController] Invalid "user" query string "${userId}"`)
|
||||||
} else {
|
userId = null
|
||||||
listeningSessions = await this.getAllSessionsWithUserData()
|
}
|
||||||
|
// Validate "sort" query
|
||||||
|
const validSortOrders = ['displayTitle', 'duration', 'playMethod', 'startTime', 'currentTime', 'timeListening', 'updatedAt', 'createdAt']
|
||||||
|
let orderKey = req.query.sort || 'updatedAt'
|
||||||
|
if (!validSortOrders.includes(orderKey)) {
|
||||||
|
Logger.warn(`[SessionController] Invalid "sort" query string "${orderKey}" (Must be one of "${validSortOrders.join('|')}")`)
|
||||||
|
orderKey = 'updatedAt'
|
||||||
|
}
|
||||||
|
let orderDesc = req.query.desc === '1' ? 'DESC' : 'ASC'
|
||||||
|
// Validate "itemsPerPage" and "page" query
|
||||||
|
let itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
|
||||||
|
if (itemsPerPage < 1) {
|
||||||
|
Logger.warn(`[SessionController] Invalid "itemsPerPage" query string "${itemsPerPage}"`)
|
||||||
|
itemsPerPage = 10
|
||||||
|
}
|
||||||
|
let page = toNumber(req.query.page, 0)
|
||||||
|
if (page < 0) {
|
||||||
|
Logger.warn(`[SessionController] Invalid "page" query string "${page}"`)
|
||||||
|
page = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
|
let where = null
|
||||||
const page = toNumber(req.query.page, 0)
|
const include = [
|
||||||
|
{
|
||||||
|
model: Database.models.device
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
const start = page * itemsPerPage
|
if (userId) {
|
||||||
const sessions = listeningSessions.slice(start, start + itemsPerPage)
|
where = {
|
||||||
|
userId
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
include.push({
|
||||||
|
model: Database.userModel,
|
||||||
|
attributes: ['id', 'username']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows, count } = await Database.playbackSessionModel.findAndCountAll({
|
||||||
|
where,
|
||||||
|
include,
|
||||||
|
order: [
|
||||||
|
[orderKey, orderDesc]
|
||||||
|
],
|
||||||
|
limit: itemsPerPage,
|
||||||
|
offset: itemsPerPage * page
|
||||||
|
})
|
||||||
|
|
||||||
|
// Map playback sessions to old playback sessions
|
||||||
|
const sessions = rows.map(session => {
|
||||||
|
const oldPlaybackSession = Database.playbackSessionModel.getOldPlaybackSession(session)
|
||||||
|
if (session.user) {
|
||||||
|
return {
|
||||||
|
...oldPlaybackSession,
|
||||||
|
user: {
|
||||||
|
id: session.user.id,
|
||||||
|
username: session.user.username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return oldPlaybackSession.toJSON()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
total: listeningSessions.length,
|
total: count,
|
||||||
numPages: Math.ceil(listeningSessions.length / itemsPerPage),
|
numPages: Math.ceil(count / itemsPerPage),
|
||||||
page,
|
page,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
sessions
|
sessions
|
||||||
}
|
}
|
||||||
|
if (userId) {
|
||||||
if (req.query.user) {
|
payload.userId = userId
|
||||||
payload.userFilter = req.query.user
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(payload)
|
res.json(payload)
|
||||||
@@ -92,6 +154,49 @@ class SessionController {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: /api/sessions/batch/delete
|
||||||
|
* @this import('../routers/ApiRouter')
|
||||||
|
*
|
||||||
|
* @typedef batchDeleteReqBody
|
||||||
|
* @property {string[]} sessions
|
||||||
|
*
|
||||||
|
* @param {import('express').Request<{}, {}, batchDeleteReqBody, {}} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
|
async batchDelete(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[SessionController] Non-admin user attempted to batch delete sessions "${req.user.username}"`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
// Validate session ids
|
||||||
|
if (!req.body.sessions?.length || !Array.isArray(req.body.sessions) || req.body.sessions.some(s => !isUUID(s))) {
|
||||||
|
Logger.error(`[SessionController] Invalid request body. "sessions" array is required`, req.body)
|
||||||
|
return res.status(400).send('Invalid request body. "sessions" array of session id strings is required.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any of these sessions are open and close it
|
||||||
|
for (const sessionId of req.body.sessions) {
|
||||||
|
const openSession = this.playbackSessionManager.getSession(sessionId)
|
||||||
|
if (openSession) {
|
||||||
|
await this.playbackSessionManager.removeSession(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionsRemoved = await Database.playbackSessionModel.destroy({
|
||||||
|
where: {
|
||||||
|
id: req.body.sessions
|
||||||
|
}
|
||||||
|
})
|
||||||
|
Logger.info(`[SessionController] ${sessionsRemoved} playback sessions removed by "${req.user.username}"`)
|
||||||
|
res.sendStatus(200)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[SessionController] Failed to remove playback sessions`, error)
|
||||||
|
res.status(500).send('Failed to remove sessions')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// POST: api/session/local
|
// POST: api/session/local
|
||||||
syncLocal(req, res) {
|
syncLocal(req, res) {
|
||||||
this.playbackSessionManager.syncLocalSessionRequest(req, res)
|
this.playbackSessionManager.syncLocalSessionRequest(req, res)
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ class BookFinder {
|
|||||||
[/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''], // Remove edition
|
[/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''], // Remove edition
|
||||||
[/(^| |\.)(m4b|m4a|mp3)( |$)/g, ''], // Remove file-type
|
[/(^| |\.)(m4b|m4a|mp3)( |$)/g, ''], // Remove file-type
|
||||||
[/ a novel.*$/g, ''], // Remove "a novel"
|
[/ a novel.*$/g, ''], // Remove "a novel"
|
||||||
|
[/(^| )(un)?abridged( |$)/g, ' '], // Remove "unabridged/abridged"
|
||||||
[/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers
|
[/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -298,6 +299,7 @@ class BookFinder {
|
|||||||
/**
|
/**
|
||||||
* Search for books including fuzzy searches
|
* Search for books including fuzzy searches
|
||||||
*
|
*
|
||||||
|
* @param {Object} libraryItem
|
||||||
* @param {string} provider
|
* @param {string} provider
|
||||||
* @param {string} title
|
* @param {string} title
|
||||||
* @param {string} author
|
* @param {string} author
|
||||||
@@ -306,7 +308,7 @@ class BookFinder {
|
|||||||
* @param {{titleDistance:number, authorDistance:number, maxFuzzySearches:number}} options
|
* @param {{titleDistance:number, authorDistance:number, maxFuzzySearches:number}} options
|
||||||
* @returns {Promise<Object[]>}
|
* @returns {Promise<Object[]>}
|
||||||
*/
|
*/
|
||||||
async search(provider, title, author, isbn, asin, options = {}) {
|
async search(libraryItem, provider, title, author, isbn, asin, options = {}) {
|
||||||
let books = []
|
let books = []
|
||||||
const maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
|
const maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
|
||||||
const maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
|
const maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
|
||||||
@@ -335,6 +337,7 @@ class BookFinder {
|
|||||||
for (const titlePart of titleParts)
|
for (const titlePart of titleParts)
|
||||||
authorCandidates.add(titlePart)
|
authorCandidates.add(titlePart)
|
||||||
authorCandidates = await authorCandidates.getCandidates()
|
authorCandidates = await authorCandidates.getCandidates()
|
||||||
|
loop_author:
|
||||||
for (const authorCandidate of authorCandidates) {
|
for (const authorCandidate of authorCandidates) {
|
||||||
let titleCandidates = new BookFinder.TitleCandidates(authorCandidate)
|
let titleCandidates = new BookFinder.TitleCandidates(authorCandidate)
|
||||||
for (const titlePart of titleParts)
|
for (const titlePart of titleParts)
|
||||||
@@ -342,13 +345,27 @@ class BookFinder {
|
|||||||
titleCandidates = titleCandidates.getCandidates()
|
titleCandidates = titleCandidates.getCandidates()
|
||||||
for (const titleCandidate of titleCandidates) {
|
for (const titleCandidate of titleCandidates) {
|
||||||
if (titleCandidate == title && authorCandidate == author) continue // We already tried this
|
if (titleCandidate == title && authorCandidate == author) continue // We already tried this
|
||||||
if (++numFuzzySearches > maxFuzzySearches) return books
|
if (++numFuzzySearches > maxFuzzySearches) break loop_author
|
||||||
books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance)
|
books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance)
|
||||||
if (books.length) return books
|
if (books.length) break loop_author
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (books.length) {
|
||||||
|
const resultsHaveDuration = provider.startsWith('audible')
|
||||||
|
if (resultsHaveDuration && libraryItem?.media?.duration) {
|
||||||
|
const libraryItemDurationMinutes = libraryItem.media.duration / 60
|
||||||
|
// If provider results have duration, sort by ascendinge duration difference from libraryItem
|
||||||
|
books.sort((a, b) => {
|
||||||
|
const aDuration = a.duration || Number.POSITIVE_INFINITY
|
||||||
|
const bDuration = b.duration || Number.POSITIVE_INFINITY
|
||||||
|
const aDurationDiff = Math.abs(aDuration - libraryItemDurationMinutes)
|
||||||
|
const bDurationDiff = Math.abs(bDuration - libraryItemDurationMinutes)
|
||||||
|
return aDurationDiff - bDurationDiff
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
return books
|
return books
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,12 +409,12 @@ class BookFinder {
|
|||||||
|
|
||||||
if (provider === 'all') {
|
if (provider === 'all') {
|
||||||
for (const providerString of this.providers) {
|
for (const providerString of this.providers) {
|
||||||
const providerResults = await this.search(providerString, title, author, options)
|
const providerResults = await this.search(null, providerString, title, author, options)
|
||||||
Logger.debug(`[BookFinder] Found ${providerResults.length} covers from ${providerString}`)
|
Logger.debug(`[BookFinder] Found ${providerResults.length} covers from ${providerString}`)
|
||||||
searchResults.push(...providerResults)
|
searchResults.push(...providerResults)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
searchResults = await this.search(provider, title, author, options)
|
searchResults = await this.search(null, provider, title, author, options)
|
||||||
}
|
}
|
||||||
Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`)
|
Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`)
|
||||||
|
|
||||||
@@ -461,6 +478,8 @@ function cleanAuthorForCompares(author) {
|
|||||||
cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2')
|
cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2')
|
||||||
// remove middle initials
|
// remove middle initials
|
||||||
cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '')
|
cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '')
|
||||||
|
// remove et al.
|
||||||
|
cleanAuthor = cleanAuthor.replace(/ et al\.?(?= |$)/g, '')
|
||||||
return cleanAuthor
|
return cleanAuthor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class ApiCacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(database = Database) {
|
init(database = Database) {
|
||||||
let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy']
|
let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy', 'afterUpsert']
|
||||||
hooks.forEach(hook => database.sequelize.addHook(hook, (model) => this.clear(model, hook)))
|
hooks.forEach(hook => database.sequelize.addHook(hook, (model) => this.clear(model, hook)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,19 +103,29 @@ class RssFeedManager {
|
|||||||
await Database.updateFeed(feed)
|
await Database.updateFeed(feed)
|
||||||
}
|
}
|
||||||
} else if (feed.entityType === 'collection') {
|
} else if (feed.entityType === 'collection') {
|
||||||
const collection = await Database.collectionModel.findByPk(feed.entityId)
|
const collection = await Database.collectionModel.findByPk(feed.entityId, {
|
||||||
|
include: Database.collectionBookModel
|
||||||
|
})
|
||||||
if (collection) {
|
if (collection) {
|
||||||
const collectionExpanded = await collection.getOldJsonExpanded()
|
const collectionExpanded = await collection.getOldJsonExpanded()
|
||||||
|
|
||||||
// Find most recently updated item in collection
|
// Find most recently updated item in collection
|
||||||
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
|
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
|
||||||
|
// Check for most recently updated book
|
||||||
collectionExpanded.books.forEach((libraryItem) => {
|
collectionExpanded.books.forEach((libraryItem) => {
|
||||||
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
|
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
|
||||||
mostRecentlyUpdatedAt = libraryItem.updatedAt
|
mostRecentlyUpdatedAt = libraryItem.updatedAt
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// Check for most recently added collection book
|
||||||
|
collection.collectionBooks.forEach((collectionBook) => {
|
||||||
|
if (collectionBook.createdAt.valueOf() > mostRecentlyUpdatedAt) {
|
||||||
|
mostRecentlyUpdatedAt = collectionBook.createdAt.valueOf()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const hasBooksRemoved = collection.collectionBooks.length < feed.episodes.length
|
||||||
|
|
||||||
if (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
|
if (!feed.entityUpdatedAt || hasBooksRemoved || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
|
||||||
Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`)
|
Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`)
|
||||||
|
|
||||||
feed.updateFromCollection(collectionExpanded)
|
feed.updateFromCollection(collectionExpanded)
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ class Feed extends Model {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find all library item ids that have an open feed (used in library filter)
|
* Find all library item ids that have an open feed (used in library filter)
|
||||||
* @returns {Promise<Array<String>>} array of library item ids
|
* @returns {Promise<string[]>} array of library item ids
|
||||||
*/
|
*/
|
||||||
static async findAllLibraryItemIds() {
|
static async findAllLibraryItemIds() {
|
||||||
const feeds = await this.findAll({
|
const feeds = await this.findAll({
|
||||||
@@ -122,8 +122,8 @@ class Feed extends Model {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find feed where and return oldFeed
|
* Find feed where and return oldFeed
|
||||||
* @param {object} where sequelize where object
|
* @param {Object} where sequelize where object
|
||||||
* @returns {Promise<objects.Feed>} oldFeed
|
* @returns {Promise<oldFeed>} oldFeed
|
||||||
*/
|
*/
|
||||||
static async findOneOld(where) {
|
static async findOneOld(where) {
|
||||||
if (!where) return null
|
if (!where) return null
|
||||||
@@ -140,7 +140,7 @@ class Feed extends Model {
|
|||||||
/**
|
/**
|
||||||
* Find feed and return oldFeed
|
* Find feed and return oldFeed
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @returns {Promise<objects.Feed>} oldFeed
|
* @returns {Promise<oldFeed>} oldFeed
|
||||||
*/
|
*/
|
||||||
static async findByPkOld(id) {
|
static async findByPkOld(id) {
|
||||||
if (!id) return null
|
if (!id) return null
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ class ServerSettings {
|
|||||||
this.authOpenIDAutoLaunch = false
|
this.authOpenIDAutoLaunch = false
|
||||||
this.authOpenIDAutoRegister = false
|
this.authOpenIDAutoRegister = false
|
||||||
this.authOpenIDMatchExistingBy = null
|
this.authOpenIDMatchExistingBy = null
|
||||||
|
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
|
||||||
|
|
||||||
if (settings) {
|
if (settings) {
|
||||||
this.construct(settings)
|
this.construct(settings)
|
||||||
@@ -126,6 +127,7 @@ class ServerSettings {
|
|||||||
this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch
|
this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch
|
||||||
this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister
|
this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister
|
||||||
this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null
|
this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null
|
||||||
|
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
|
||||||
|
|
||||||
if (!Array.isArray(this.authActiveAuthMethods)) {
|
if (!Array.isArray(this.authActiveAuthMethods)) {
|
||||||
this.authActiveAuthMethods = ['local']
|
this.authActiveAuthMethods = ['local']
|
||||||
@@ -211,7 +213,8 @@ class ServerSettings {
|
|||||||
authOpenIDButtonText: this.authOpenIDButtonText,
|
authOpenIDButtonText: this.authOpenIDButtonText,
|
||||||
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
||||||
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
||||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy
|
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
||||||
|
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +223,7 @@ class ServerSettings {
|
|||||||
delete json.tokenSecret
|
delete json.tokenSecret
|
||||||
delete json.authOpenIDClientID
|
delete json.authOpenIDClientID
|
||||||
delete json.authOpenIDClientSecret
|
delete json.authOpenIDClientSecret
|
||||||
|
delete json.authOpenIDMobileRedirectURIs
|
||||||
return json
|
return json
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +258,8 @@ class ServerSettings {
|
|||||||
authOpenIDButtonText: this.authOpenIDButtonText,
|
authOpenIDButtonText: this.authOpenIDButtonText,
|
||||||
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
||||||
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
||||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy
|
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
||||||
|
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,27 @@ class Audible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audible will sometimes send sequences with "Book 1" or "2, Dramatized Adaptation"
|
||||||
|
* @see https://github.com/advplyr/audiobookshelf/issues/2380
|
||||||
|
* @see https://github.com/advplyr/audiobookshelf/issues/1339
|
||||||
|
*
|
||||||
|
* @param {string} seriesName
|
||||||
|
* @param {string} sequence
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
cleanSeriesSequence(seriesName, sequence) {
|
||||||
|
if (!sequence) return ''
|
||||||
|
let updatedSequence = sequence.replace(/Book /, '').trim()
|
||||||
|
if (updatedSequence.includes(' ')) {
|
||||||
|
updatedSequence = updatedSequence.split(' ').shift().replace(/,$/, '')
|
||||||
|
}
|
||||||
|
if (sequence !== updatedSequence) {
|
||||||
|
Logger.debug(`[Audible] Series "${seriesName}" sequence was cleaned from "${sequence}" to "${updatedSequence}"`)
|
||||||
|
}
|
||||||
|
return updatedSequence
|
||||||
|
}
|
||||||
|
|
||||||
cleanResult(item) {
|
cleanResult(item) {
|
||||||
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item
|
const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item
|
||||||
|
|
||||||
@@ -25,13 +46,13 @@ class Audible {
|
|||||||
if (seriesPrimary) {
|
if (seriesPrimary) {
|
||||||
series.push({
|
series.push({
|
||||||
series: seriesPrimary.name,
|
series: seriesPrimary.name,
|
||||||
sequence: (seriesPrimary.position || '').replace(/Book /, '') // Can be badly formatted see #1339
|
sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || '')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (seriesSecondary) {
|
if (seriesSecondary) {
|
||||||
series.push({
|
series.push({
|
||||||
series: seriesSecondary.name,
|
series: seriesSecondary.name,
|
||||||
sequence: (seriesSecondary.position || '').replace(/Book /, '')
|
sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || '')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +85,7 @@ class Audible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
asinSearch(asin, region) {
|
asinSearch(asin, region) {
|
||||||
asin = encodeURIComponent(asin);
|
asin = encodeURIComponent(asin)
|
||||||
var regionQuery = region ? `?region=${region}` : ''
|
var regionQuery = region ? `?region=${region}` : ''
|
||||||
var url = `https://api.audnex.us/books/${asin}${regionQuery}`
|
var url = `https://api.audnex.us/books/${asin}${regionQuery}`
|
||||||
Logger.debug(`[Audible] ASIN url: ${url}`)
|
Logger.debug(`[Audible] ASIN url: ${url}`)
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ class ApiRouter {
|
|||||||
this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
|
this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
|
||||||
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
|
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
|
||||||
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
|
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
|
||||||
|
this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this))
|
||||||
|
|
||||||
//
|
//
|
||||||
// Backup Routes
|
// Backup Routes
|
||||||
@@ -220,6 +221,7 @@ class ApiRouter {
|
|||||||
this.router.get('/sessions', SessionController.getAllWithUserData.bind(this))
|
this.router.get('/sessions', SessionController.getAllWithUserData.bind(this))
|
||||||
this.router.delete('/sessions/:id', SessionController.middleware.bind(this), SessionController.delete.bind(this))
|
this.router.delete('/sessions/:id', SessionController.middleware.bind(this), SessionController.delete.bind(this))
|
||||||
this.router.get('/sessions/open', SessionController.getOpenSessions.bind(this))
|
this.router.get('/sessions/open', SessionController.getOpenSessions.bind(this))
|
||||||
|
this.router.post('/sessions/batch/delete', SessionController.batchDelete.bind(this))
|
||||||
this.router.post('/session/local', SessionController.syncLocal.bind(this))
|
this.router.post('/session/local', SessionController.syncLocal.bind(this))
|
||||||
this.router.post('/session/local-all', SessionController.syncLocalSessions.bind(this))
|
this.router.post('/session/local-all', SessionController.syncLocalSessions.bind(this))
|
||||||
// TODO: Update these endpoints because they are only for open playback sessions
|
// TODO: Update these endpoints because they are only for open playback sessions
|
||||||
@@ -315,6 +317,7 @@ class ApiRouter {
|
|||||||
this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
|
this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
|
||||||
this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this))
|
this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this))
|
||||||
this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this))
|
this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this))
|
||||||
|
this.router.get('/stats/year/:year', MiscController.getAdminStatsForYear.bind(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDirectories(dir, relpath, excludedDirs, level = 0) {
|
async getDirectories(dir, relpath, excludedDirs, level = 0) {
|
||||||
@@ -490,18 +493,6 @@ class ApiRouter {
|
|||||||
return userSessions.sort((a, b) => b.updatedAt - a.updatedAt)
|
return userSessions.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllSessionsWithUserData() {
|
|
||||||
const sessions = await Database.getPlaybackSessions()
|
|
||||||
sessions.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
||||||
const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
|
|
||||||
return sessions.map(se => {
|
|
||||||
return {
|
|
||||||
...se,
|
|
||||||
user: minifiedUserObjects.find(u => u.id === se.userId) || null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserListeningStatsHelpers(userId) {
|
async getUserListeningStatsHelpers(userId) {
|
||||||
const today = date.format(new Date(), 'YYYY-MM-DD')
|
const today = date.format(new Date(), 'YYYY-MM-DD')
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class Scanner {
|
|||||||
var searchISBN = options.isbn || libraryItem.media.metadata.isbn
|
var searchISBN = options.isbn || libraryItem.media.metadata.isbn
|
||||||
var searchASIN = options.asin || libraryItem.media.metadata.asin
|
var searchASIN = options.asin || libraryItem.media.metadata.asin
|
||||||
|
|
||||||
var results = await BookFinder.search(provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 })
|
var results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 })
|
||||||
if (!results.length) {
|
if (!results.length) {
|
||||||
return {
|
return {
|
||||||
warning: `No ${provider} match found`
|
warning: `No ${provider} match found`
|
||||||
|
|||||||
@@ -308,6 +308,7 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => {
|
|||||||
.replace(lineBreaks, replacement)
|
.replace(lineBreaks, replacement)
|
||||||
.replace(windowsReservedRe, replacement)
|
.replace(windowsReservedRe, replacement)
|
||||||
.replace(windowsTrailingRe, replacement)
|
.replace(windowsTrailingRe, replacement)
|
||||||
|
.replace(/\s+/g, ' ') // Replace consecutive spaces with a single space
|
||||||
|
|
||||||
// Check if basename is too many bytes
|
// Check if basename is too many bytes
|
||||||
const ext = Path.extname(sanitized) // separate out file extension
|
const ext = Path.extname(sanitized) // separate out file extension
|
||||||
|
|||||||
+34
-6
@@ -1,4 +1,5 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
|
const uuid = require('uuid')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { parseString } = require("xml2js")
|
const { parseString } = require("xml2js")
|
||||||
const areEquivalent = require('./areEquivalent')
|
const areEquivalent = require('./areEquivalent')
|
||||||
@@ -11,24 +12,24 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
|
|||||||
str2 = str2.toLowerCase()
|
str2 = str2.toLowerCase()
|
||||||
}
|
}
|
||||||
const track = Array(str2.length + 1).fill(null).map(() =>
|
const track = Array(str2.length + 1).fill(null).map(() =>
|
||||||
Array(str1.length + 1).fill(null));
|
Array(str1.length + 1).fill(null))
|
||||||
for (let i = 0; i <= str1.length; i += 1) {
|
for (let i = 0; i <= str1.length; i += 1) {
|
||||||
track[0][i] = i;
|
track[0][i] = i
|
||||||
}
|
}
|
||||||
for (let j = 0; j <= str2.length; j += 1) {
|
for (let j = 0; j <= str2.length; j += 1) {
|
||||||
track[j][0] = j;
|
track[j][0] = j
|
||||||
}
|
}
|
||||||
for (let j = 1; j <= str2.length; j += 1) {
|
for (let j = 1; j <= str2.length; j += 1) {
|
||||||
for (let i = 1; i <= str1.length; i += 1) {
|
for (let i = 1; i <= str1.length; i += 1) {
|
||||||
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1
|
||||||
track[j][i] = Math.min(
|
track[j][i] = Math.min(
|
||||||
track[j][i - 1] + 1, // deletion
|
track[j][i - 1] + 1, // deletion
|
||||||
track[j - 1][i] + 1, // insertion
|
track[j - 1][i] + 1, // insertion
|
||||||
track[j - 1][i - 1] + indicator, // substitution
|
track[j - 1][i - 1] + indicator, // substitution
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return track[str2.length][str1.length];
|
return track[str2.length][str1.length]
|
||||||
}
|
}
|
||||||
module.exports.levenshteinDistance = levenshteinDistance
|
module.exports.levenshteinDistance = levenshteinDistance
|
||||||
|
|
||||||
@@ -205,3 +206,30 @@ module.exports.escapeRegExp = (str) => {
|
|||||||
if (typeof str !== 'string') return ''
|
if (typeof str !== 'string') return ''
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate url string with URL class
|
||||||
|
*
|
||||||
|
* @param {string} rawUrl
|
||||||
|
* @returns {string} null if invalid
|
||||||
|
*/
|
||||||
|
module.exports.validateUrl = (rawUrl) => {
|
||||||
|
if (!rawUrl || typeof rawUrl !== 'string') return null
|
||||||
|
try {
|
||||||
|
return new URL(rawUrl).toString()
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`Invalid URL "${rawUrl}"`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string is a valid UUID
|
||||||
|
*
|
||||||
|
* @param {string} str
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
module.exports.isUUID = (str) => {
|
||||||
|
if (!str || typeof str !== 'string') return false
|
||||||
|
return uuid.validate(str)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const Logger = require('../Logger')
|
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
|
const ssrfFilter = require('ssrf-req-filter')
|
||||||
|
const Logger = require('../Logger')
|
||||||
const { xmlToJSON, levenshteinDistance } = require('./index')
|
const { xmlToJSON, levenshteinDistance } = require('./index')
|
||||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||||
|
|
||||||
@@ -216,9 +217,26 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get podcast RSS feed as JSON
|
||||||
|
* Uses SSRF filter to prevent internal URLs
|
||||||
|
*
|
||||||
|
* @param {string} feedUrl
|
||||||
|
* @param {boolean} [excludeEpisodeMetadata=false]
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
||||||
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`)
|
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`)
|
||||||
return axios.get(feedUrl, { timeout: 12000, responseType: 'arraybuffer' }).then(async (data) => {
|
|
||||||
|
return axios({
|
||||||
|
url: feedUrl,
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 12000,
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
headers: { Accept: 'application/rss+xml' },
|
||||||
|
httpAgent: ssrfFilter(feedUrl),
|
||||||
|
httpsAgent: ssrfFilter(feedUrl)
|
||||||
|
}).then(async (data) => {
|
||||||
|
|
||||||
// Adding support for ios-8859-1 encoded RSS feeds.
|
// Adding support for ios-8859-1 encoded RSS feeds.
|
||||||
// See: https://github.com/advplyr/audiobookshelf/issues/1489
|
// See: https://github.com/advplyr/audiobookshelf/issues/1489
|
||||||
@@ -231,12 +249,12 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
|||||||
|
|
||||||
if (!data?.data) {
|
if (!data?.data) {
|
||||||
Logger.error(`[podcastUtils] getPodcastFeed: Invalid podcast feed request response (${feedUrl})`)
|
Logger.error(`[podcastUtils] getPodcastFeed: Invalid podcast feed request response (${feedUrl})`)
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}" success - parsing xml`)
|
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}" success - parsing xml`)
|
||||||
const payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata)
|
const payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata)
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// RSS feed may be a private RSS feed
|
// RSS feed may be a private RSS feed
|
||||||
@@ -245,7 +263,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
|||||||
return payload.podcast
|
return payload.podcast
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
Logger.error('[podcastUtils] getPodcastFeed Error', error)
|
Logger.error('[podcastUtils] getPodcastFeed Error', error)
|
||||||
return false
|
return null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
const Sequelize = require('sequelize')
|
||||||
|
const Database = require('../../Database')
|
||||||
|
const PlaybackSession = require('../../models/PlaybackSession')
|
||||||
|
const fsExtra = require('../../libs/fsExtra')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} year YYYY
|
||||||
|
* @returns {Promise<PlaybackSession[]>}
|
||||||
|
*/
|
||||||
|
async getListeningSessionsForYear(year) {
|
||||||
|
const sessions = await Database.playbackSessionModel.findAll({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
[Sequelize.Op.gte]: `${year}-01-01`,
|
||||||
|
[Sequelize.Op.lt]: `${year + 1}-01-01`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return sessions
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} year YYYY
|
||||||
|
* @returns {Promise<number>}
|
||||||
|
*/
|
||||||
|
async getNumAuthorsAddedForYear(year) {
|
||||||
|
const count = await Database.authorModel.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
[Sequelize.Op.gte]: `${year}-01-01`,
|
||||||
|
[Sequelize.Op.lt]: `${year + 1}-01-01`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return count
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} year YYYY
|
||||||
|
* @returns {Promise<import('../../models/Book')[]>}
|
||||||
|
*/
|
||||||
|
async getBooksAddedForYear(year) {
|
||||||
|
const books = await Database.bookModel.findAll({
|
||||||
|
attributes: ['id', 'title', 'coverPath', 'duration', 'createdAt'],
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
[Sequelize.Op.gte]: `${year}-01-01`,
|
||||||
|
[Sequelize.Op.lt]: `${year + 1}-01-01`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
model: Database.libraryItemModel,
|
||||||
|
attributes: ['id', 'mediaId', 'mediaType', 'size'],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
order: Database.sequelize.random()
|
||||||
|
})
|
||||||
|
return books
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} year YYYY
|
||||||
|
*/
|
||||||
|
async getStatsForYear(year) {
|
||||||
|
const booksAdded = await this.getBooksAddedForYear(year)
|
||||||
|
|
||||||
|
let totalBooksAddedSize = 0
|
||||||
|
let totalBooksAddedDuration = 0
|
||||||
|
const booksWithCovers = []
|
||||||
|
|
||||||
|
for (const book of booksAdded) {
|
||||||
|
// Grab first 25 that have a cover
|
||||||
|
if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(book.coverPath)) {
|
||||||
|
booksWithCovers.push(book.libraryItem.id)
|
||||||
|
}
|
||||||
|
if (book.duration && !isNaN(book.duration)) {
|
||||||
|
totalBooksAddedDuration += book.duration
|
||||||
|
}
|
||||||
|
if (book.libraryItem.size && !isNaN(book.libraryItem.size)) {
|
||||||
|
totalBooksAddedSize += book.libraryItem.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const numAuthorsAdded = await this.getNumAuthorsAddedForYear(year)
|
||||||
|
|
||||||
|
let authorListeningMap = {}
|
||||||
|
let narratorListeningMap = {}
|
||||||
|
let genreListeningMap = {}
|
||||||
|
|
||||||
|
const listeningSessions = await this.getListeningSessionsForYear(year)
|
||||||
|
let totalListeningTime = 0
|
||||||
|
for (const ls of listeningSessions) {
|
||||||
|
totalListeningTime += (ls.timeListening || 0)
|
||||||
|
|
||||||
|
const authors = ls.mediaMetadata.authors || []
|
||||||
|
authors.forEach((au) => {
|
||||||
|
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
|
||||||
|
authorListeningMap[au.name] += (ls.timeListening || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const narrators = ls.mediaMetadata.narrators || []
|
||||||
|
narrators.forEach((narrator) => {
|
||||||
|
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
|
||||||
|
narratorListeningMap[narrator] += (ls.timeListening || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter out bad genres like "audiobook" and "audio book"
|
||||||
|
const genres = (ls.mediaMetadata.genres || []).filter(g => !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
|
||||||
|
genres.forEach((genre) => {
|
||||||
|
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
|
||||||
|
genreListeningMap[genre] += (ls.timeListening || 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let topAuthors = null
|
||||||
|
topAuthors = Object.keys(authorListeningMap).map(authorName => ({
|
||||||
|
name: authorName,
|
||||||
|
time: Math.round(authorListeningMap[authorName])
|
||||||
|
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
||||||
|
|
||||||
|
let topNarrators = null
|
||||||
|
topNarrators = Object.keys(narratorListeningMap).map(narratorName => ({
|
||||||
|
name: narratorName,
|
||||||
|
time: Math.round(narratorListeningMap[narratorName])
|
||||||
|
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
||||||
|
|
||||||
|
let topGenres = null
|
||||||
|
topGenres = Object.keys(genreListeningMap).map(genre => ({
|
||||||
|
genre,
|
||||||
|
time: Math.round(genreListeningMap[genre])
|
||||||
|
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
||||||
|
|
||||||
|
// Stats for total books, size and duration for everything added this year or earlier
|
||||||
|
const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, {
|
||||||
|
replacements: {
|
||||||
|
nextYear: year + 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const totalStatResults = totalStatResultsRow[0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
numListeningSessions: listeningSessions.length,
|
||||||
|
numBooksAdded: booksAdded.length,
|
||||||
|
numAuthorsAdded,
|
||||||
|
totalBooksAddedSize,
|
||||||
|
totalBooksAddedDuration: Math.round(totalBooksAddedDuration),
|
||||||
|
booksAddedWithCovers: booksWithCovers,
|
||||||
|
totalBooksSize: totalStatResults?.totalSize || 0,
|
||||||
|
totalBooksDuration: totalStatResults?.totalDuration || 0,
|
||||||
|
totalListeningTime,
|
||||||
|
numBooks: totalStatResults?.totalItems || 0,
|
||||||
|
topAuthors,
|
||||||
|
topNarrators,
|
||||||
|
topGenres
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
const Sequelize = require('sequelize')
|
||||||
|
const Database = require('../../Database')
|
||||||
|
const PlaybackSession = require('../../models/PlaybackSession')
|
||||||
|
const MediaProgress = require('../../models/MediaProgress')
|
||||||
|
const fsExtra = require('../../libs/fsExtra')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {number} year YYYY
|
||||||
|
* @returns {Promise<PlaybackSession[]>}
|
||||||
|
*/
|
||||||
|
async getUserListeningSessionsForYear(userId, year) {
|
||||||
|
const sessions = await Database.playbackSessionModel.findAll({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
createdAt: {
|
||||||
|
[Sequelize.Op.gte]: `${year}-01-01`,
|
||||||
|
[Sequelize.Op.lt]: `${year + 1}-01-01`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
model: Database.bookModel,
|
||||||
|
attributes: ['id', 'coverPath'],
|
||||||
|
include: {
|
||||||
|
model: Database.libraryItemModel,
|
||||||
|
attributes: ['id', 'mediaId', 'mediaType']
|
||||||
|
},
|
||||||
|
required: false
|
||||||
|
},
|
||||||
|
order: Database.sequelize.random()
|
||||||
|
})
|
||||||
|
return sessions
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {number} year YYYY
|
||||||
|
* @returns {Promise<MediaProgress[]>}
|
||||||
|
*/
|
||||||
|
async getBookMediaProgressFinishedForYear(userId, year) {
|
||||||
|
const progresses = await Database.mediaProgressModel.findAll({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
mediaItemType: 'book',
|
||||||
|
finishedAt: {
|
||||||
|
[Sequelize.Op.gte]: `${year}-01-01`,
|
||||||
|
[Sequelize.Op.lt]: `${year + 1}-01-01`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
model: Database.bookModel,
|
||||||
|
attributes: ['id', 'title', 'coverPath'],
|
||||||
|
include: {
|
||||||
|
model: Database.libraryItemModel,
|
||||||
|
attributes: ['id', 'mediaId', 'mediaType']
|
||||||
|
},
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
order: Database.sequelize.random()
|
||||||
|
})
|
||||||
|
return progresses
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('../../objects/user/User')} user
|
||||||
|
* @param {number} year YYYY
|
||||||
|
*/
|
||||||
|
async getStatsForYear(user, year) {
|
||||||
|
const userId = user.id
|
||||||
|
const listeningSessions = await this.getUserListeningSessionsForYear(userId, year)
|
||||||
|
const bookProgressesFinished = await this.getBookMediaProgressFinishedForYear(userId, year)
|
||||||
|
|
||||||
|
let totalBookListeningTime = 0
|
||||||
|
let totalPodcastListeningTime = 0
|
||||||
|
let totalListeningTime = 0
|
||||||
|
|
||||||
|
let authorListeningMap = {}
|
||||||
|
let genreListeningMap = {}
|
||||||
|
let narratorListeningMap = {}
|
||||||
|
let monthListeningMap = {}
|
||||||
|
let bookListeningMap = {}
|
||||||
|
|
||||||
|
const booksWithCovers = []
|
||||||
|
const finishedBooksWithCovers = []
|
||||||
|
|
||||||
|
// Get finished book stats
|
||||||
|
const numBooksFinished = bookProgressesFinished.length
|
||||||
|
let longestAudiobookFinished = null
|
||||||
|
for (const mediaProgress of bookProgressesFinished) {
|
||||||
|
// Grab first 5 that have a cover
|
||||||
|
if (mediaProgress.mediaItem?.coverPath && !finishedBooksWithCovers.includes(mediaProgress.mediaItem.libraryItem.id) && finishedBooksWithCovers.length < 5 && await fsExtra.pathExists(mediaProgress.mediaItem.coverPath)) {
|
||||||
|
finishedBooksWithCovers.push(mediaProgress.mediaItem.libraryItem.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaProgress.duration && (!longestAudiobookFinished?.duration || mediaProgress.duration > longestAudiobookFinished.duration)) {
|
||||||
|
longestAudiobookFinished = {
|
||||||
|
id: mediaProgress.mediaItem.id,
|
||||||
|
title: mediaProgress.mediaItem.title,
|
||||||
|
duration: Math.round(mediaProgress.duration),
|
||||||
|
finishedAt: mediaProgress.finishedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get listening session stats
|
||||||
|
for (const ls of listeningSessions) {
|
||||||
|
// Grab first 25 that have a cover
|
||||||
|
if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && !finishedBooksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(ls.mediaItem.coverPath)) {
|
||||||
|
booksWithCovers.push(ls.mediaItem.libraryItem.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeningSessionListeningTime = ls.timeListening || 0
|
||||||
|
|
||||||
|
const lsMonth = ls.createdAt.getMonth()
|
||||||
|
if (!monthListeningMap[lsMonth]) monthListeningMap[lsMonth] = 0
|
||||||
|
monthListeningMap[lsMonth] += listeningSessionListeningTime
|
||||||
|
|
||||||
|
totalListeningTime += listeningSessionListeningTime
|
||||||
|
if (ls.mediaItemType === 'book') {
|
||||||
|
totalBookListeningTime += listeningSessionListeningTime
|
||||||
|
|
||||||
|
if (ls.displayTitle && !bookListeningMap[ls.displayTitle]) {
|
||||||
|
bookListeningMap[ls.displayTitle] = listeningSessionListeningTime
|
||||||
|
} else if (ls.displayTitle) {
|
||||||
|
bookListeningMap[ls.displayTitle] += listeningSessionListeningTime
|
||||||
|
}
|
||||||
|
|
||||||
|
const authors = ls.mediaMetadata.authors || []
|
||||||
|
authors.forEach((au) => {
|
||||||
|
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
|
||||||
|
authorListeningMap[au.name] += listeningSessionListeningTime
|
||||||
|
})
|
||||||
|
|
||||||
|
const narrators = ls.mediaMetadata.narrators || []
|
||||||
|
narrators.forEach((narrator) => {
|
||||||
|
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
|
||||||
|
narratorListeningMap[narrator] += listeningSessionListeningTime
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter out bad genres like "audiobook" and "audio book"
|
||||||
|
const genres = (ls.mediaMetadata.genres || []).filter(g => !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
|
||||||
|
genres.forEach((genre) => {
|
||||||
|
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
|
||||||
|
genreListeningMap[genre] += listeningSessionListeningTime
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
totalPodcastListeningTime += listeningSessionListeningTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalListeningTime = Math.round(totalListeningTime)
|
||||||
|
totalBookListeningTime = Math.round(totalBookListeningTime)
|
||||||
|
totalPodcastListeningTime = Math.round(totalPodcastListeningTime)
|
||||||
|
|
||||||
|
let topAuthors = null
|
||||||
|
topAuthors = Object.keys(authorListeningMap).map(authorName => ({
|
||||||
|
name: authorName,
|
||||||
|
time: Math.round(authorListeningMap[authorName])
|
||||||
|
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
||||||
|
|
||||||
|
let mostListenedNarrator = null
|
||||||
|
for (const narrator in narratorListeningMap) {
|
||||||
|
if (!mostListenedNarrator?.time || narratorListeningMap[narrator] > mostListenedNarrator.time) {
|
||||||
|
mostListenedNarrator = {
|
||||||
|
time: Math.round(narratorListeningMap[narrator]),
|
||||||
|
name: narrator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let topGenres = null
|
||||||
|
topGenres = Object.keys(genreListeningMap).map(genre => ({
|
||||||
|
genre,
|
||||||
|
time: Math.round(genreListeningMap[genre])
|
||||||
|
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
||||||
|
|
||||||
|
let mostListenedMonth = null
|
||||||
|
for (const month in monthListeningMap) {
|
||||||
|
if (!mostListenedMonth?.time || monthListeningMap[month] > mostListenedMonth.time) {
|
||||||
|
mostListenedMonth = {
|
||||||
|
month: Number(month),
|
||||||
|
time: Math.round(monthListeningMap[month])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalListeningSessions: listeningSessions.length,
|
||||||
|
totalListeningTime,
|
||||||
|
totalBookListeningTime,
|
||||||
|
totalPodcastListeningTime,
|
||||||
|
topAuthors,
|
||||||
|
topGenres,
|
||||||
|
mostListenedNarrator,
|
||||||
|
mostListenedMonth,
|
||||||
|
numBooksFinished,
|
||||||
|
numBooksListened: Object.keys(bookListeningMap).length,
|
||||||
|
longestAudiobookFinished,
|
||||||
|
booksWithCovers,
|
||||||
|
finishedBooksWithCovers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ describe('TitleCandidates', () => {
|
|||||||
['adds candidate + variant, removing edition 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']],
|
['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 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 "a novel"', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']],
|
||||||
|
['adds candidate + variant, removing "abridged"', 'abridged anna karenina', ['anna karenina', 'abridged anna karenina']],
|
||||||
|
['adds candidate + variant, removing "unabridged"', 'anna karenina unabridged', ['anna karenina', 'anna karenina unabridged']],
|
||||||
['adds candidate + variant, removing preceding/trailing numbers', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']],
|
['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 empty candidate', '', []],
|
||||||
['does not add spaces-only candidate', ' ', []],
|
['does not add spaces-only candidate', ' ', []],
|
||||||
@@ -109,6 +111,7 @@ describe('AuthorCandidates', () => {
|
|||||||
['adds recognized author if edit distance from candidate is small', 'nicolai gogol', ['nikolai 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', []],
|
['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 (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']],
|
||||||
|
['adds normalized recognized candidate (et al removed)', 'nikolai gogol et al.', ['nikolai gogol']],
|
||||||
['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']],
|
['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']],
|
||||||
].forEach(([name, author, expected]) => it(name, async () => {
|
].forEach(([name, author, expected]) => it(name, async () => {
|
||||||
authorCandidates.add(author)
|
authorCandidates.add(author)
|
||||||
@@ -222,14 +225,14 @@ describe('search', () => {
|
|||||||
|
|
||||||
describe('search title is empty', () => {
|
describe('search title is empty', () => {
|
||||||
it('returns empty result', async () => {
|
it('returns empty result', async () => {
|
||||||
expect(await bookFinder.search('', '', a)).to.deep.equal([])
|
expect(await bookFinder.search(null, '', '', a)).to.deep.equal([])
|
||||||
sinon.assert.callCount(bookFinder.runSearch, 0)
|
sinon.assert.callCount(bookFinder.runSearch, 0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('search title is a recognized title and search author is a recognized author', () => {
|
describe('search title is a recognized title and search author is a recognized author', () => {
|
||||||
it('returns non-empty result (no fuzzy searches)', async () => {
|
it('returns non-empty result (no fuzzy searches)', async () => {
|
||||||
expect(await bookFinder.search('', t, a)).to.deep.equal(r)
|
expect(await bookFinder.search(null, '', t, a)).to.deep.equal(r)
|
||||||
sinon.assert.callCount(bookFinder.runSearch, 1)
|
sinon.assert.callCount(bookFinder.runSearch, 1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -251,7 +254,7 @@ describe('search', () => {
|
|||||||
[`2022_${t}_HQ`],
|
[`2022_${t}_HQ`],
|
||||||
].forEach(([searchTitle]) => {
|
].forEach(([searchTitle]) => {
|
||||||
it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => {
|
it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => {
|
||||||
expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r)
|
expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal(r)
|
||||||
sinon.assert.callCount(bookFinder.runSearch, 2)
|
sinon.assert.callCount(bookFinder.runSearch, 2)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -261,7 +264,7 @@ describe('search', () => {
|
|||||||
[`${a} - series 01 - ${t}`],
|
[`${a} - series 01 - ${t}`],
|
||||||
].forEach(([searchTitle]) => {
|
].forEach(([searchTitle]) => {
|
||||||
it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => {
|
it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => {
|
||||||
expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r)
|
expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal(r)
|
||||||
sinon.assert.callCount(bookFinder.runSearch, 3)
|
sinon.assert.callCount(bookFinder.runSearch, 3)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -271,7 +274,7 @@ describe('search', () => {
|
|||||||
[`${t} junk`],
|
[`${t} junk`],
|
||||||
].forEach(([searchTitle]) => {
|
].forEach(([searchTitle]) => {
|
||||||
it(`search('${searchTitle}', '${a}') returns an empty result`, async () => {
|
it(`search('${searchTitle}', '${a}') returns an empty result`, async () => {
|
||||||
expect(await bookFinder.search('', searchTitle, a)).to.deep.equal([])
|
expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -280,7 +283,7 @@ describe('search', () => {
|
|||||||
[`${t} - ${a}`],
|
[`${t} - ${a}`],
|
||||||
].forEach(([searchTitle]) => {
|
].forEach(([searchTitle]) => {
|
||||||
it(`search('${searchTitle}', '${a}') returns an empty result (with no fuzzy searches)`, async () => {
|
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([])
|
expect(await bookFinder.search(null, '', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([])
|
||||||
sinon.assert.callCount(bookFinder.runSearch, 1)
|
sinon.assert.callCount(bookFinder.runSearch, 1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -292,7 +295,7 @@ describe('search', () => {
|
|||||||
[`${a} - series 01 - ${t}`],
|
[`${a} - series 01 - ${t}`],
|
||||||
].forEach(([searchTitle]) => {
|
].forEach(([searchTitle]) => {
|
||||||
it(`search('${searchTitle}', '${a}') returns an empty result (1 fuzzy search)`, async () => {
|
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([])
|
expect(await bookFinder.search(null, '', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([])
|
||||||
sinon.assert.callCount(bookFinder.runSearch, 2)
|
sinon.assert.callCount(bookFinder.runSearch, 2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -305,7 +308,7 @@ describe('search', () => {
|
|||||||
[`${a} - ${t}`],
|
[`${a} - ${t}`],
|
||||||
].forEach(([searchTitle]) => {
|
].forEach(([searchTitle]) => {
|
||||||
it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => {
|
it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => {
|
||||||
expect(await bookFinder.search('', searchTitle, '')).to.deep.equal(r)
|
expect(await bookFinder.search(null, '', searchTitle, '')).to.deep.equal(r)
|
||||||
sinon.assert.callCount(bookFinder.runSearch, 2)
|
sinon.assert.callCount(bookFinder.runSearch, 2)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -316,7 +319,7 @@ describe('search', () => {
|
|||||||
[`${u} - ${t}`]
|
[`${u} - ${t}`]
|
||||||
].forEach(([searchTitle]) => {
|
].forEach(([searchTitle]) => {
|
||||||
it(`search('${searchTitle}', '') returns an empty result`, async () => {
|
it(`search('${searchTitle}', '') returns an empty result`, async () => {
|
||||||
expect(await bookFinder.search('', searchTitle, '')).to.deep.equal([])
|
expect(await bookFinder.search(null, '', searchTitle, '')).to.deep.equal([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -327,7 +330,7 @@ describe('search', () => {
|
|||||||
[`${u} - ${t}`]
|
[`${u} - ${t}`]
|
||||||
].forEach(([searchTitle]) => {
|
].forEach(([searchTitle]) => {
|
||||||
it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => {
|
it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => {
|
||||||
expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r)
|
expect(await bookFinder.search(null, '', searchTitle, u)).to.deep.equal(r)
|
||||||
sinon.assert.callCount(bookFinder.runSearch, 2)
|
sinon.assert.callCount(bookFinder.runSearch, 2)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -336,9 +339,41 @@ describe('search', () => {
|
|||||||
[`${t}`]
|
[`${t}`]
|
||||||
].forEach(([searchTitle]) => {
|
].forEach(([searchTitle]) => {
|
||||||
it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => {
|
it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => {
|
||||||
expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r)
|
expect(await bookFinder.search(null, '', searchTitle, u)).to.deep.equal(r)
|
||||||
sinon.assert.callCount(bookFinder.runSearch, 1)
|
sinon.assert.callCount(bookFinder.runSearch, 1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('search provider results have duration', () => {
|
||||||
|
const libraryItem = { media: { duration: 60 * 1000 } }
|
||||||
|
const provider = 'audible'
|
||||||
|
const unsorted = [{ duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }]
|
||||||
|
const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }]
|
||||||
|
runSearchStub.withArgs(t, a, provider).resolves(unsorted)
|
||||||
|
|
||||||
|
it('returns results sorted by library item duration diff', async () => {
|
||||||
|
expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns unsorted results if library item is null', async () => {
|
||||||
|
expect(await bookFinder.search(null, provider, t, a)).to.deep.equal(unsorted)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns unsorted results if library item duration is undefined', async () => {
|
||||||
|
expect(await bookFinder.search({ media: {} }, provider, t, a)).to.deep.equal(unsorted)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns unsorted results if library item media is undefined', async () => {
|
||||||
|
expect(await bookFinder.search({ }, provider, t, a)).to.deep.equal(unsorted)
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('should return a result last if it has no duration', async () => {
|
||||||
|
const unsorted = [{}, { duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }]
|
||||||
|
const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }, {}]
|
||||||
|
runSearchStub.withArgs(t, a, provider).resolves(unsorted)
|
||||||
|
|
||||||
|
expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user