mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-10-06 02:01:58 +02:00
Merge branch 'advplyr:master' into fix-match-update
This commit is contained in:
commit
0f4b11494e
@ -217,36 +217,6 @@ Bookshelf Label
|
|||||||
filter: blur(20px);
|
filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.episode-subtitle {
|
|
||||||
word-break: break-word;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: -webkit-box;
|
|
||||||
line-height: 16px;
|
|
||||||
/* fallback */
|
|
||||||
max-height: 32px;
|
|
||||||
/* fallback */
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
/* number of lines to show */
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.episode-subtitle-long {
|
|
||||||
word-break: break-word;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: -webkit-box;
|
|
||||||
line-height: 16px;
|
|
||||||
/* fallback */
|
|
||||||
max-height: 72px;
|
|
||||||
/* fallback */
|
|
||||||
-webkit-line-clamp: 6;
|
|
||||||
/* number of lines to show */
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
||||||
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
||||||
padding-top: 104px;
|
padding-top: 104px;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
|
<div v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
|
||||||
<div id="videoDock" />
|
<div id="videoDock" />
|
||||||
<div class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
<div class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
||||||
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||||
@ -29,7 +29,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-tooltip direction="top" :text="$strings.LabelClosePlayer">
|
<ui-tooltip direction="top" :text="$strings.LabelClosePlayer">
|
||||||
<span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
|
<button :aria-label="$strings.LabelClosePlayer" class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</button>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<player-ui
|
<player-ui
|
||||||
@ -380,7 +380,7 @@ export default {
|
|||||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
|
||||||
if (!data.numSegments) return
|
if (!data.numSegments) return
|
||||||
var chunks = data.chunks
|
var chunks = data.chunks
|
||||||
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
|
console.log(`[MediaPlayerContainer] Stream Progress ${data.percent}`)
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||||
} else {
|
} else {
|
||||||
@ -397,17 +397,17 @@ export default {
|
|||||||
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
|
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
|
||||||
},
|
},
|
||||||
streamOpen(session) {
|
streamOpen(session) {
|
||||||
console.log(`[StreamContainer] Stream session open`, session)
|
console.log(`[MediaPlayerContainer] Stream session open`, session)
|
||||||
},
|
},
|
||||||
streamClosed(streamId) {
|
streamClosed(streamId) {
|
||||||
// Stream was closed from the server
|
// Stream was closed from the server
|
||||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||||
console.warn('[StreamContainer] Closing stream due to request from server')
|
console.warn('[MediaPlayerContainer] Closing stream due to request from server')
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
streamReady() {
|
streamReady() {
|
||||||
console.log(`[StreamContainer] Stream Ready`)
|
console.log(`[MediaPlayerContainer] Stream Ready`)
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.setStreamReady()
|
this.$refs.audioPlayer.setStreamReady()
|
||||||
} else {
|
} else {
|
||||||
@ -417,7 +417,7 @@ export default {
|
|||||||
streamError(streamId) {
|
streamError(streamId) {
|
||||||
// Stream had critical error from the server
|
// Stream had critical error from the server
|
||||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||||
console.warn('[StreamContainer] Closing stream due to stream error from server')
|
console.warn('[MediaPlayerContainer] Closing stream due to stream error from server')
|
||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -496,7 +496,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#streamContainer {
|
#mediaPlayerContainer {
|
||||||
box-shadow: 0px -6px 8px #1111113f;
|
box-shadow: 0px -6px 8px #1111113f;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
|
<slot name="header-prefix"></slot>
|
||||||
<h1 class="text-xl">{{ headerText }}</h1>
|
<h1 class="text-xl">{{ headerText }}</h1>
|
||||||
|
|
||||||
<slot name="header-items"></slot>
|
<slot name="header-items"></slot>
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="sm:w-80 w-full relative">
|
<div class="">
|
||||||
<form @submit.prevent="submitSearch">
|
<div class="w-full relative sm:w-80">
|
||||||
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
<form @submit.prevent="submitSearch">
|
||||||
</form>
|
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||||
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
</form>
|
||||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||||
|
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<li v-if="isTyping" class="py-2 px-2">
|
<li v-if="isTyping" class="py-2 px-2">
|
||||||
<p>{{ $strings.MessageThinking }}</p>
|
<p>{{ $strings.MessageThinking }}</p>
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
<button :aria-label="$strings.LabelVolume" class="text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||||
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
||||||
</div>
|
</button>
|
||||||
<transition name="menux">
|
<transition name="menux">
|
||||||
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
|
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
|
||||||
<div ref="volumeTrack" class="h-1 w-full bg-gray-500 my-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack">
|
<div ref="volumeTrack" class="h-1 w-full bg-gray-500 my-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack">
|
||||||
@ -38,8 +38,8 @@ export default {
|
|||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("volume", val);
|
localStorage.setItem('volume', val)
|
||||||
} catch(error) {
|
} catch (error) {
|
||||||
console.error('Failed to store volume', err)
|
console.error('Failed to store volume', err)
|
||||||
}
|
}
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
@ -146,7 +146,7 @@ export default {
|
|||||||
if (this.value === 0) {
|
if (this.value === 0) {
|
||||||
this.isMute = true
|
this.isMute = true
|
||||||
}
|
}
|
||||||
const storageVolume = localStorage.getItem("volume")
|
const storageVolume = localStorage.getItem('volume')
|
||||||
if (storageVolume) {
|
if (storageVolume) {
|
||||||
this.volume = parseFloat(storageVolume)
|
this.volume = parseFloat(storageVolume)
|
||||||
}
|
}
|
||||||
|
@ -111,7 +111,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex pt-4 px-2">
|
<div class="flex pt-4 px-2">
|
||||||
<ui-btn v-if="isEditingRoot" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
|
<ui-btn v-if="hasOpenIDLink" small :loading="unlinkingFromOpenID" color="primary" type="button" class="mr-2" @click.stop="unlinkOpenID">Unlink OpenID</ui-btn>
|
||||||
|
<ui-btn v-if="isEditingRoot" small class="flex items-center" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
@ -136,7 +137,8 @@ export default {
|
|||||||
newUser: {},
|
newUser: {},
|
||||||
isNew: true,
|
isNew: true,
|
||||||
tags: [],
|
tags: [],
|
||||||
loadingTags: false
|
loadingTags: false,
|
||||||
|
unlinkingFromOpenID: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -180,7 +182,7 @@ export default {
|
|||||||
return this.isNew ? this.$strings.HeaderNewAccount : this.$strings.HeaderUpdateAccount
|
return this.isNew ? this.$strings.HeaderNewAccount : this.$strings.HeaderUpdateAccount
|
||||||
},
|
},
|
||||||
isEditingRoot() {
|
isEditingRoot() {
|
||||||
return this.account && this.account.type === 'root'
|
return this.account?.type === 'root'
|
||||||
},
|
},
|
||||||
libraries() {
|
libraries() {
|
||||||
return this.$store.state.libraries.libraries
|
return this.$store.state.libraries.libraries
|
||||||
@ -198,6 +200,9 @@ export default {
|
|||||||
},
|
},
|
||||||
tagsSelectionText() {
|
tagsSelectionText() {
|
||||||
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
||||||
|
},
|
||||||
|
hasOpenIDLink() {
|
||||||
|
return !!this.account?.hasOpenIDLink
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -205,6 +210,31 @@ export default {
|
|||||||
// Force close when navigating - used in UsersTable
|
// Force close when navigating - used in UsersTable
|
||||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||||
},
|
},
|
||||||
|
unlinkOpenID() {
|
||||||
|
const payload = {
|
||||||
|
message: 'Are you sure you want to unlink this user from OpenID?',
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.unlinkingFromOpenID = true
|
||||||
|
this.$axios
|
||||||
|
.$patch(`/api/users/${this.account.id}/openid-unlink`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('User unlinked from OpenID')
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to unlink user from OpenID', error)
|
||||||
|
this.$toast.error('Failed to unlink user from OpenID')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.unlinkingFromOpenID = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
},
|
||||||
accessAllTagsToggled(val) {
|
accessAllTagsToggled(val) {
|
||||||
if (val) {
|
if (val) {
|
||||||
if (this.newUser.itemTagsSelected?.length) {
|
if (this.newUser.itemTagsSelected?.length) {
|
||||||
|
105
client/components/modals/AddCustomMetadataProviderModal.vue
Normal file
105
client/components/modals/AddCustomMetadataProviderModal.vue
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal ref="modal" v-model="show" name="custom-metadata-provider" :width="600" :height="'unset'" :processing="processing">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||||
|
<p class="text-3xl text-white truncate">Add custom metadata provider</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<form @submit.prevent="submitForm">
|
||||||
|
<div class="px-4 w-full flex items-center text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
|
<div class="w-full p-8">
|
||||||
|
<div class="flex mb-2">
|
||||||
|
<div class="w-3/4 p-1">
|
||||||
|
<ui-text-input-with-label v-model="newName" :label="$strings.LabelName" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/4 p-1">
|
||||||
|
<ui-text-input-with-label value="Book" readonly :label="$strings.LabelMediaType" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full mb-2 p-1">
|
||||||
|
<ui-text-input-with-label v-model="newUrl" label="URL" />
|
||||||
|
</div>
|
||||||
|
<div class="w-full mb-2 p-1">
|
||||||
|
<ui-text-input-with-label v-model="newAuthHeaderValue" :label="'Authorization Header Value'" type="password" />
|
||||||
|
</div>
|
||||||
|
<div class="flex px-1 pt-4">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn color="success" type="submit">{{ $strings.ButtonAdd }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
processing: false,
|
||||||
|
newName: '',
|
||||||
|
newUrl: '',
|
||||||
|
newAuthHeaderValue: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
submitForm() {
|
||||||
|
if (!this.newName || !this.newUrl) {
|
||||||
|
this.$toast.error('Must add name and url')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$post('/api/custom-metadata-providers', {
|
||||||
|
name: this.newName,
|
||||||
|
url: this.newUrl,
|
||||||
|
mediaType: 'book', // Currently only supporting book mediaType
|
||||||
|
authHeaderValue: this.newAuthHeaderValue
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
this.$emit('added', data.provider)
|
||||||
|
this.$toast.success('New provider added')
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMsg = error.response?.data || 'Unknown error'
|
||||||
|
console.error('Failed to add provider', error)
|
||||||
|
this.$toast.error('Failed to add provider: ' + errorMsg)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.processing = false
|
||||||
|
this.newName = ''
|
||||||
|
this.newUrl = ''
|
||||||
|
this.newAuthHeaderValue = ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
@ -328,6 +328,17 @@ export default {
|
|||||||
console.error('PersistProvider', error)
|
console.error('PersistProvider', error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
getDefaultBookProvider() {
|
||||||
|
let provider = localStorage.getItem('book-provider')
|
||||||
|
if (!provider) return 'google'
|
||||||
|
// Validate book provider
|
||||||
|
if (!this.$store.getters['scanners/checkBookProviderExists'](provider)) {
|
||||||
|
console.error('Stored book provider does not exist', provider)
|
||||||
|
localStorage.removeItem('book-provider')
|
||||||
|
return 'google'
|
||||||
|
}
|
||||||
|
return provider
|
||||||
|
},
|
||||||
getSearchQuery() {
|
getSearchQuery() {
|
||||||
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)}`
|
||||||
@ -434,7 +445,9 @@ export default {
|
|||||||
this.searchTitle = this.libraryItem.media.metadata.title
|
this.searchTitle = this.libraryItem.media.metadata.title
|
||||||
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
||||||
if (this.isPodcast) this.provider = 'itunes'
|
if (this.isPodcast) this.provider = 'itunes'
|
||||||
else this.provider = localStorage.getItem('book-provider') || 'google'
|
else {
|
||||||
|
this.provider = this.getDefaultBookProvider()
|
||||||
|
}
|
||||||
|
|
||||||
// Prefer using ASIN if set and using audible provider
|
// Prefer using ASIN if set and using audible provider
|
||||||
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
||||||
|
@ -49,6 +49,9 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isPodcastLibrary" class="py-3">
|
||||||
|
<ui-dropdown :label="$strings.LabelPodcastSearchRegion" v-model="podcastSearchRegion" :items="$podcastSearchRegionOptions" small class="max-w-52" @input="formUpdated" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -69,7 +72,8 @@ export default {
|
|||||||
skipMatchingMediaWithAsin: false,
|
skipMatchingMediaWithAsin: false,
|
||||||
skipMatchingMediaWithIsbn: false,
|
skipMatchingMediaWithIsbn: false,
|
||||||
audiobooksOnly: false,
|
audiobooksOnly: false,
|
||||||
hideSingleBookSeries: false
|
hideSingleBookSeries: false,
|
||||||
|
podcastSearchRegion: 'us'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -85,6 +89,9 @@ export default {
|
|||||||
isBookLibrary() {
|
isBookLibrary() {
|
||||||
return this.mediaType === 'book'
|
return this.mediaType === 'book'
|
||||||
},
|
},
|
||||||
|
isPodcastLibrary() {
|
||||||
|
return this.mediaType === 'podcast'
|
||||||
|
},
|
||||||
providers() {
|
providers() {
|
||||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||||
return this.$store.state.scanners.providers
|
return this.$store.state.scanners.providers
|
||||||
@ -99,7 +106,8 @@ export default {
|
|||||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
|
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
|
||||||
audiobooksOnly: !!this.audiobooksOnly,
|
audiobooksOnly: !!this.audiobooksOnly,
|
||||||
hideSingleBookSeries: !!this.hideSingleBookSeries
|
hideSingleBookSeries: !!this.hideSingleBookSeries,
|
||||||
|
podcastSearchRegion: this.podcastSearchRegion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -113,6 +121,7 @@ export default {
|
|||||||
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||||
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
|
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
|
||||||
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
|
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
|
||||||
|
this.podcastSearchRegion = this.librarySettings.podcastSearchRegion || 'us'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
<div class="break-words">{{ episode.title }}</div>
|
<div class="break-words">{{ episode.title }}</div>
|
||||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
|
||||||
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
<div v-for="(episode, index) in episodesFound" :key="index" class="w-full py-4 border-b border-white border-opacity-5 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer px-2" @click.stop="selectEpisode(episode)">
|
<div v-for="(episode, index) in episodesFound" :key="index" class="w-full py-4 border-b border-white border-opacity-5 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer px-2" @click.stop="selectEpisode(episode)">
|
||||||
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
|
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
|
||||||
<p class="break-words mb-1">{{ episode.title }}</p>
|
<p class="break-words mb-1">{{ episode.title }}</p>
|
||||||
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
|
||||||
<p class="text-xs text-gray-400">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
<p class="text-xs text-gray-400">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -2,21 +2,21 @@
|
|||||||
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
<div class="flex pt-4 pb-2 md:pt-0 md:pb-2">
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<template v-if="!loading">
|
<template v-if="!loading">
|
||||||
<div class="cursor-pointer flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
|
<button :aria-label="$strings.ButtonPreviousChapter" class="flex items-center justify-center text-gray-300 mr-4 md:mr-8" @mousedown.prevent @mouseup.prevent @click.stop="prevChapter">
|
||||||
<span class="material-icons text-2xl sm:text-3xl">first_page</span>
|
<span class="material-icons text-2xl sm:text-3xl">first_page</span>
|
||||||
</div>
|
</button>
|
||||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
<button :aria-label="$strings.ButtonJumpBackward" class="flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpBackward">
|
||||||
<span class="material-icons text-2xl sm:text-3xl">replay_10</span>
|
<span class="material-icons text-2xl sm:text-3xl">replay_10</span>
|
||||||
</div>
|
</button>
|
||||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
<button :aria-label="paused ? $strings.ButtonPlay : $strings.ButtonPause" class="p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4 md:mx-8" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPause">
|
||||||
<span class="material-icons text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
<span class="material-icons text-2xl">{{ seekLoading ? 'autorenew' : paused ? 'play_arrow' : 'pause' }}</span>
|
||||||
</div>
|
</button>
|
||||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
<button :aria-label="$strings.ButtonJumpForward" class="flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="jumpForward">
|
||||||
<span class="material-icons text-2xl sm:text-3xl">forward_10</span>
|
<span class="material-icons text-2xl sm:text-3xl">forward_10</span>
|
||||||
</div>
|
</button>
|
||||||
<div class="flex items-center justify-center ml-4 md:ml-8" :class="hasNextChapter ? 'text-gray-300 cursor-pointer' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
|
<button :aria-label="$strings.ButtonNextChapter" class="flex items-center justify-center ml-4 md:ml-8" :disabled="!hasNextChapter" :class="hasNextChapter ? 'text-gray-300' : 'text-gray-500'" @mousedown.prevent @mouseup.prevent @click.stop="nextChapter">
|
||||||
<span class="material-icons text-2xl sm:text-3xl">last_page</span>
|
<span class="material-icons text-2xl sm:text-3xl">last_page</span>
|
||||||
</div>
|
</button>
|
||||||
<controls-playback-speed-control v-model="playbackRateInput" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
<controls-playback-speed-control v-model="playbackRateInput" @input="playbackRateUpdated" @change="playbackRateChanged" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
@ -9,37 +9,37 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip direction="top" :text="$strings.LabelSleepTimer">
|
<ui-tooltip direction="top" :text="$strings.LabelSleepTimer">
|
||||||
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
<button :aria-label="$strings.LabelSleepTimer" class="text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showSleepTimer')">
|
||||||
<span v-if="!sleepTimerSet" class="material-icons text-2xl">snooze</span>
|
<span v-if="!sleepTimerSet" class="material-icons text-2xl">snooze</span>
|
||||||
<div v-else class="flex items-center">
|
<div v-else class="flex items-center">
|
||||||
<span class="material-icons text-lg text-warning">snooze</span>
|
<span class="material-icons text-lg text-warning">snooze</span>
|
||||||
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
|
<p class="text-xl text-warning font-mono font-semibold text-center px-0.5 pb-0.5" style="min-width: 30px">{{ sleepTimerRemainingString }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip v-if="!isPodcast" direction="top" :text="$strings.LabelViewBookmarks">
|
<ui-tooltip v-if="!isPodcast" direction="top" :text="$strings.LabelViewBookmarks">
|
||||||
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
<button :aria-label="$strings.LabelViewBookmarks" class="text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showBookmarks')">
|
||||||
<span class="material-icons text-2xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
<span class="material-icons text-2xl">{{ bookmarks.length ? 'bookmarks' : 'bookmark_border' }}</span>
|
||||||
</div>
|
</button>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip v-if="chapters.length" direction="top" :text="$strings.LabelViewChapters">
|
<ui-tooltip v-if="chapters.length" direction="top" :text="$strings.LabelViewChapters">
|
||||||
<div class="cursor-pointer text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
<button :aria-label="$strings.LabelViewChapters" class="text-gray-300 hover:text-white mx-1 lg:mx-2" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||||
<span class="material-icons text-2xl">format_list_bulleted</span>
|
<span class="material-icons text-2xl">format_list_bulleted</span>
|
||||||
</div>
|
</button>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip v-if="playerQueueItems.length" direction="top" :text="$strings.LabelViewQueue">
|
<ui-tooltip v-if="playerQueueItems.length" direction="top" :text="$strings.LabelViewQueue">
|
||||||
<button class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')">
|
<button :aria-label="$strings.LabelViewQueue" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')">
|
||||||
<span class="material-icons text-2.5xl sm:text-3xl">playlist_play</span>
|
<span class="material-icons text-2.5xl sm:text-3xl">playlist_play</span>
|
||||||
</button>
|
</button>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack">
|
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack">
|
||||||
<div class="cursor-pointer text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
|
<button :aria-label="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack" class="text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
|
||||||
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
|
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
|
||||||
</div>
|
</button>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
127
client/components/tables/CustomMetadataProviderTable.vue
Normal file
127
client/components/tables/CustomMetadataProviderTable.vue
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-40">
|
||||||
|
<table v-if="providers.length" id="providers">
|
||||||
|
<tr>
|
||||||
|
<th>{{ $strings.LabelName }}</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Authorization Header Value</th>
|
||||||
|
<th class="w-12"></th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="provider in providers" :key="provider.id">
|
||||||
|
<td class="text-sm">{{ provider.name }}</td>
|
||||||
|
<td class="text-sm">{{ provider.url }}</td>
|
||||||
|
<td class="text-sm">
|
||||||
|
<span v-if="provider.authHeaderValue" class="custom-provider-api-key">{{ provider.authHeaderValue }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-0">
|
||||||
|
<div class="h-8 w-8 flex items-center justify-center text-white text-opacity-50 hover:text-error cursor-pointer" @click.stop="removeProvider(provider)">
|
||||||
|
<button type="button" :aria-label="$strings.ButtonDelete" class="material-icons text-base">delete</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div v-else-if="!processing" class="text-center py-8">
|
||||||
|
<p class="text-lg">No custom metadata providers</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="processing" class="absolute inset-0 h-full flex items-center justify-center bg-black/40 rounded-md">
|
||||||
|
<ui-loading-indicator />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
providers: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
processing: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
removeProvider(provider) {
|
||||||
|
const payload = {
|
||||||
|
message: `Are you sure you want remove custom metadata provider "${provider.name}"?`,
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) {
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/custom-metadata-providers/${provider.id}`)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Provider removed')
|
||||||
|
this.$emit('removed', provider.id)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to remove provider', error)
|
||||||
|
this.$toast.error('Failed to remove provider')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.$emit('update:processing', false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
}
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#providers {
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border: 1px solid #474747;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#providers td,
|
||||||
|
#providers th {
|
||||||
|
/* border: 1px solid #2e2e2e; */
|
||||||
|
padding: 8px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
#providers td.py-0 {
|
||||||
|
padding: 0px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#providers tr:nth-child(even) {
|
||||||
|
background-color: #373838;
|
||||||
|
}
|
||||||
|
|
||||||
|
#providers tr:nth-child(odd) {
|
||||||
|
background-color: #2f2f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
#providers tr:hover {
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
#providers th {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-top: 5px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
background-color: #272727;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-provider-api-key {
|
||||||
|
padding: 1px;
|
||||||
|
background-color: #272727;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: transparent;
|
||||||
|
transition: color, background-color 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-provider-api-key:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
@ -7,8 +7,8 @@
|
|||||||
<widgets-podcast-type-indicator :type="episodeType" />
|
<widgets-podcast-type-indicator :type="episodeType" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-10 flex items-center mt-1.5 mb-0.5">
|
<div class="h-10 flex items-center mt-1.5 mb-0.5 overflow-hidden">
|
||||||
<p class="text-sm text-gray-200 episode-subtitle" v-html="episodeSubtitle"></p>
|
<p class="text-sm text-gray-200 line-clamp-2" v-html="episodeSubtitle"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-8 flex items-center">
|
<div class="h-8 flex items-center">
|
||||||
<div class="w-full inline-flex justify-between max-w-xl">
|
<div class="w-full inline-flex justify-between max-w-xl">
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
<Nuxt :key="currentLang" />
|
<Nuxt :key="currentLang" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-stream-container ref="streamContainer" />
|
<app-media-player-container ref="mediaPlayerContainer" />
|
||||||
|
|
||||||
<modals-item-edit-modal />
|
<modals-item-edit-modal />
|
||||||
<modals-collections-add-create-modal />
|
<modals-collections-add-create-modal />
|
||||||
@ -129,23 +129,23 @@ export default {
|
|||||||
this.$eventBus.$emit('socket_init')
|
this.$eventBus.$emit('socket_init')
|
||||||
},
|
},
|
||||||
streamOpen(stream) {
|
streamOpen(stream) {
|
||||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
|
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream)
|
||||||
},
|
},
|
||||||
streamClosed(streamId) {
|
streamClosed(streamId) {
|
||||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamClosed(streamId)
|
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamClosed(streamId)
|
||||||
},
|
},
|
||||||
streamProgress(data) {
|
streamProgress(data) {
|
||||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamProgress(data)
|
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamProgress(data)
|
||||||
},
|
},
|
||||||
streamReady() {
|
streamReady() {
|
||||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamReady()
|
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamReady()
|
||||||
},
|
},
|
||||||
streamReset(payload) {
|
streamReset(payload) {
|
||||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamReset(payload)
|
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamReset(payload)
|
||||||
},
|
},
|
||||||
streamError({ id, errorMessage }) {
|
streamError({ id, errorMessage }) {
|
||||||
this.$toast.error(`Stream Failed: ${errorMessage}`)
|
this.$toast.error(`Stream Failed: ${errorMessage}`)
|
||||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamError(id)
|
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamError(id)
|
||||||
},
|
},
|
||||||
libraryAdded(library) {
|
libraryAdded(library) {
|
||||||
this.$store.commit('libraries/addUpdate', library)
|
this.$store.commit('libraries/addUpdate', library)
|
||||||
@ -247,7 +247,7 @@ export default {
|
|||||||
this.multiSessionCurrentSessionId = null
|
this.multiSessionCurrentSessionId = null
|
||||||
this.$toast.dismiss('multiple-sessions')
|
this.$toast.dismiss('multiple-sessions')
|
||||||
}
|
}
|
||||||
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
|
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.sessionClosedEvent(sessionId)
|
||||||
},
|
},
|
||||||
userMediaProgressUpdate(payload) {
|
userMediaProgressUpdate(payload) {
|
||||||
this.$store.commit('user/updateMediaProgress', payload)
|
this.$store.commit('user/updateMediaProgress', payload)
|
||||||
@ -328,6 +328,14 @@ export default {
|
|||||||
|
|
||||||
this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices)
|
this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices)
|
||||||
},
|
},
|
||||||
|
customMetadataProviderAdded(provider) {
|
||||||
|
if (!provider?.id) return
|
||||||
|
this.$store.commit('scanners/addCustomMetadataProvider', provider)
|
||||||
|
},
|
||||||
|
customMetadataProviderRemoved(provider) {
|
||||||
|
if (!provider?.id) return
|
||||||
|
this.$store.commit('scanners/removeCustomMetadataProvider', provider)
|
||||||
|
},
|
||||||
initializeSocket() {
|
initializeSocket() {
|
||||||
this.socket = this.$nuxtSocket({
|
this.socket = this.$nuxtSocket({
|
||||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||||
@ -406,6 +414,10 @@ export default {
|
|||||||
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)
|
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)
|
||||||
|
|
||||||
this.socket.on('admin_message', this.adminMessageEvt)
|
this.socket.on('admin_message', this.adminMessageEvt)
|
||||||
|
|
||||||
|
// Custom metadata provider Listeners
|
||||||
|
this.socket.on('custom_metadata_provider_added', this.customMetadataProviderAdded)
|
||||||
|
this.socket.on('custom_metadata_provider_removed', this.customMetadataProviderRemoved)
|
||||||
},
|
},
|
||||||
showUpdateToast(versionData) {
|
showUpdateToast(versionData) {
|
||||||
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
||||||
|
@ -29,7 +29,8 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
script: [],
|
script: [],
|
||||||
link: [
|
link: [
|
||||||
{ rel: 'icon', type: 'image/x-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/favicon.ico' }
|
{ rel: 'icon', type: 'image/x-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/favicon.ico' },
|
||||||
|
{ rel: 'apple-touch-icon', href: (process.env.ROUTER_BASE_PATH || '') + '/ios_icon.png' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -95,7 +96,7 @@ module.exports = {
|
|||||||
meta: {
|
meta: {
|
||||||
appleStatusBarStyle: 'black',
|
appleStatusBarStyle: 'black',
|
||||||
name: 'Audiobookshelf',
|
name: 'Audiobookshelf',
|
||||||
theme_color: '#373838',
|
theme_color: '#232323',
|
||||||
mobileAppIOS: true,
|
mobileAppIOS: true,
|
||||||
nativeUI: true
|
nativeUI: true
|
||||||
},
|
},
|
||||||
@ -103,7 +104,7 @@ module.exports = {
|
|||||||
name: 'Audiobookshelf',
|
name: 'Audiobookshelf',
|
||||||
short_name: 'Audiobookshelf',
|
short_name: 'Audiobookshelf',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
background_color: '#373838',
|
background_color: '#232323',
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg',
|
src: (process.env.ROUTER_BASE_PATH || '') + '/icon.svg',
|
||||||
|
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.7.2",
|
"version": "2.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.7.2",
|
"version": "2.8.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.7.2",
|
"version": "2.8.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",
|
||||||
|
@ -82,19 +82,33 @@ export default {
|
|||||||
this.$setLanguageCode(lang)
|
this.$setLanguageCode(lang)
|
||||||
},
|
},
|
||||||
logout() {
|
logout() {
|
||||||
var rootSocket = this.$root.socket || {}
|
// Disconnect from socket
|
||||||
const logoutPayload = {
|
if (this.$root.socket) {
|
||||||
socketId: rootSocket.id
|
console.log('Disconnecting from socket', this.$root.socket.id)
|
||||||
|
this.$root.socket.removeAllListeners()
|
||||||
|
this.$root.socket.disconnect()
|
||||||
}
|
}
|
||||||
this.$axios.$post('/logout', logoutPayload).catch((error) => {
|
|
||||||
console.error(error)
|
|
||||||
})
|
|
||||||
if (localStorage.getItem('token')) {
|
if (localStorage.getItem('token')) {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
}
|
}
|
||||||
this.$store.commit('libraries/setUserPlaylists', [])
|
this.$store.commit('libraries/setUserPlaylists', [])
|
||||||
this.$store.commit('libraries/setCollections', [])
|
this.$store.commit('libraries/setCollections', [])
|
||||||
this.$router.push('/login')
|
|
||||||
|
this.$axios
|
||||||
|
.$post('/logout')
|
||||||
|
.then((logoutPayload) => {
|
||||||
|
const redirect_url = logoutPayload.redirect_url
|
||||||
|
|
||||||
|
if (redirect_url) {
|
||||||
|
window.location.href = redirect_url
|
||||||
|
} else {
|
||||||
|
this.$router.push('/login')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
resetForm() {
|
resetForm() {
|
||||||
this.password = null
|
this.password = null
|
||||||
|
@ -142,7 +142,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
|
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
|
||||||
<div v-if="!chapterData" class="flex p-20">
|
<div v-if="!chapterData" class="flex p-20">
|
||||||
<ui-text-input-with-label v-model="asinInput" label="ASIN" />
|
<ui-text-input-with-label v-model.trim="asinInput" label="ASIN" />
|
||||||
<ui-dropdown v-model="regionInput" :label="$strings.LabelRegion" small :items="audibleRegions" class="w-32 mx-1" />
|
<ui-dropdown v-model="regionInput" :label="$strings.LabelRegion" small :items="audibleRegions" class="w-32 mx-1" />
|
||||||
<ui-btn small color="primary" class="mt-5" @click="findChapters">{{ $strings.ButtonSearch }}</ui-btn>
|
<ui-btn small color="primary" class="mt-5" @click="findChapters">{{ $strings.ButtonSearch }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
@ -17,7 +17,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">{{ $strings.LabelDescription }}</p>
|
<p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">{{ $strings.LabelDescription }}</p>
|
||||||
<p class="text-white max-w-3xl text-sm leading-5 whitespace-pre-wrap">{{ author.description }}</p>
|
<p ref="description" id="author-description" class="text-white max-w-3xl text-base whitespace-pre-wrap" :class="{ 'show-full': showFullDescription }">{{ author.description }}</p>
|
||||||
|
<button v-if="isDescriptionClamped" class="py-0.5 flex items-center text-slate-300 hover:text-white" @click="showFullDescription = !showFullDescription">
|
||||||
|
{{ showFullDescription ? 'Read less' : 'Read more' }} <span class="material-icons text-xl pl-1">{{ showFullDescription ? 'expand_less' : 'expand_more' }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -62,7 +65,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
isDescriptionClamped: false,
|
||||||
|
showFullDescription: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
@ -82,6 +88,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
checkDescriptionClamped() {
|
||||||
|
if (!this.$refs.description) return
|
||||||
|
this.isDescriptionClamped = this.$refs.description.scrollHeight > this.$refs.description.clientHeight
|
||||||
|
},
|
||||||
editAuthor() {
|
editAuthor() {
|
||||||
this.$store.commit('globals/showEditAuthorModal', this.author)
|
this.$store.commit('globals/showEditAuthorModal', this.author)
|
||||||
},
|
},
|
||||||
@ -93,6 +103,7 @@ export default {
|
|||||||
series: this.authorSeries,
|
series: this.authorSeries,
|
||||||
libraryItems: this.libraryItems
|
libraryItems: this.libraryItems
|
||||||
}
|
}
|
||||||
|
this.$nextTick(this.checkDescriptionClamped)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
authorRemoved(author) {
|
authorRemoved(author) {
|
||||||
@ -104,6 +115,7 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
if (!this.author) this.$router.replace('/')
|
if (!this.author) this.$router.replace('/')
|
||||||
|
this.checkDescriptionClamped()
|
||||||
|
|
||||||
this.$root.socket.on('author_updated', this.authorUpdated)
|
this.$root.socket.on('author_updated', this.authorUpdated)
|
||||||
this.$root.socket.on('author_removed', this.authorRemoved)
|
this.$root.socket.on('author_removed', this.authorRemoved)
|
||||||
@ -114,3 +126,18 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#author-description {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
max-height: 6.25rem;
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
#author-description.show-full {
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
max-height: 999rem;
|
||||||
|
}
|
||||||
|
</style>
|
@ -206,7 +206,7 @@ export default {
|
|||||||
|
|
||||||
function isValidRedirectURI(uri) {
|
function isValidRedirectURI(uri) {
|
||||||
// Check for somestring://someother/string
|
// Check for somestring://someother/string
|
||||||
const pattern = new RegExp('^\\w+://[\\w\\.-]+$', 'i')
|
const pattern = new RegExp('^\\w+://[\\w\\.-]+(/[\\w\\./-]*)*$', 'i')
|
||||||
return pattern.test(uri)
|
return pattern.test(uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<app-settings-content :header-text="$strings.HeaderCustomMetadataProviders">
|
||||||
|
<template #header-prefix>
|
||||||
|
<nuxt-link to="/config/item-metadata-utils" class="w-8 h-8 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center mr-2">
|
||||||
|
<span class="material-icons text-2xl">arrow_back</span>
|
||||||
|
</nuxt-link>
|
||||||
|
</template>
|
||||||
|
<template #header-items>
|
||||||
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
|
<a href="https://www.audiobookshelf.org/guides/custom-metadata-providers" target="_blank" class="inline-flex">
|
||||||
|
<span class="material-icons text-xl w-5 text-gray-200">help_outline</span>
|
||||||
|
</a>
|
||||||
|
</ui-tooltip>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<ui-btn color="primary" small @click="setShowAddModal">{{ $strings.ButtonAdd }}</ui-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<tables-custom-metadata-provider-table :providers="providers" :processing.sync="processing" class="pt-2" @removed="providerRemoved" />
|
||||||
|
<modals-add-custom-metadata-provider-modal ref="addModal" v-model="showAddModal" @added="providerAdded" />
|
||||||
|
</app-settings-content>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ store, redirect }) {
|
||||||
|
if (!store.getters['user/getIsAdminOrUp']) {
|
||||||
|
redirect('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showAddModal: false,
|
||||||
|
processing: false,
|
||||||
|
providers: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
providerRemoved(providerId) {
|
||||||
|
this.providers = this.providers.filter((p) => p.id !== providerId)
|
||||||
|
},
|
||||||
|
providerAdded(provider) {
|
||||||
|
this.providers.push(provider)
|
||||||
|
},
|
||||||
|
setShowAddModal() {
|
||||||
|
this.showAddModal = true
|
||||||
|
},
|
||||||
|
loadProviders() {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$get('/api/custom-metadata-providers')
|
||||||
|
.then((res) => {
|
||||||
|
this.providers = res.providers
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Failed to load custom metadata providers')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.processing = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadProviders()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style></style>
|
@ -13,6 +13,12 @@
|
|||||||
<span class="material-icons">arrow_forward</span>
|
<span class="material-icons">arrow_forward</span>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
<nuxt-link to="/config/item-metadata-utils/custom-metadata-providers" class="block w-full rounded bg-primary/40 hover:bg-primary/60 text-gray-300 hover:text-white p-4 my-2">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<p>{{ $strings.HeaderCustomMetadataProviders }}</p>
|
||||||
|
<span class="material-icons">arrow_forward</span>
|
||||||
|
</div>
|
||||||
|
</nuxt-link>
|
||||||
</app-settings-content>
|
</app-settings-content>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 800px; min-height: 550px">
|
<div ref="container" id="log-container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="min-height: 550px">
|
||||||
<template v-for="(log, index) in logs">
|
<template v-for="(log, index) in logs">
|
||||||
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
|
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
|
||||||
<p class="text-gray-400 w-36 font-mono text-xs">{{ log.timestamp }}</p>
|
<p class="text-gray-400 w-36 font-mono text-xs">{{ log.timestamp }}</p>
|
||||||
@ -136,7 +136,15 @@ export default {
|
|||||||
this.loadedLogs = this.loadedLogs.slice(-5000)
|
this.loadedLogs = this.loadedLogs.slice(-5000)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
init(attempts = 0) {
|
async loadLoggerData() {
|
||||||
|
const loggerData = await this.$axios.$get('/api/logger-data').catch((error) => {
|
||||||
|
console.error('Failed to load logger data', error)
|
||||||
|
this.$toast.error('Failed to load logger data')
|
||||||
|
})
|
||||||
|
|
||||||
|
this.loadedLogs = loggerData?.currentDailyLogs || []
|
||||||
|
},
|
||||||
|
async init(attempts = 0) {
|
||||||
if (!this.$root.socket) {
|
if (!this.$root.socket) {
|
||||||
if (attempts > 10) {
|
if (attempts > 10) {
|
||||||
return console.error('Failed to setup socket listeners')
|
return console.error('Failed to setup socket listeners')
|
||||||
@ -147,14 +155,11 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.loadLoggerData()
|
||||||
|
|
||||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||||
this.$root.socket.on('daily_logs', this.dailyLogsLoaded)
|
|
||||||
this.$root.socket.on('log', this.logEvtReceived)
|
this.$root.socket.on('log', this.logEvtReceived)
|
||||||
this.$root.socket.emit('set_log_listener', this.newServerSettings.logLevel)
|
this.$root.socket.emit('set_log_listener', this.newServerSettings.logLevel)
|
||||||
this.$root.socket.emit('fetch_daily_logs')
|
|
||||||
},
|
|
||||||
dailyLogsLoaded(lines) {
|
|
||||||
this.loadedLogs = lines
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updated() {
|
updated() {
|
||||||
@ -166,13 +171,15 @@ export default {
|
|||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (!this.$root.socket) return
|
if (!this.$root.socket) return
|
||||||
this.$root.socket.emit('remove_log_listener')
|
this.$root.socket.emit('remove_log_listener')
|
||||||
this.$root.socket.off('daily_logs', this.dailyLogsLoaded)
|
|
||||||
this.$root.socket.off('log', this.logEvtReceived)
|
this.$root.socket.off('log', this.logEvtReceived)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
#log-container {
|
||||||
|
height: calc(100vh - 285px);
|
||||||
|
}
|
||||||
.logmessage {
|
.logmessage {
|
||||||
width: calc(100% - 208px);
|
width: calc(100% - 208px);
|
||||||
}
|
}
|
||||||
|
@ -84,7 +84,7 @@
|
|||||||
<div class="flex items-center my-2">
|
<div class="flex items-center my-2">
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<div class="hidden sm:inline-flex items-center">
|
<div class="hidden sm:inline-flex items-center">
|
||||||
<p class="text-sm">{{ $strings.LabelRowsPerPage }}</p>
|
<p class="text-sm whitespace-nowrap">{{ $strings.LabelRowsPerPage }}</p>
|
||||||
<ui-dropdown v-model="itemsPerPage" :items="itemsPerPageOptions" small class="w-24 mx-2" @input="updatedItemsPerPage" />
|
<ui-dropdown v-model="itemsPerPage" :items="itemsPerPageOptions" small class="w-24 mx-2" @input="updatedItemsPerPage" />
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-flex items-center">
|
<div class="inline-flex items-center">
|
||||||
|
@ -125,7 +125,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-4 w-full">
|
<div class="my-4 w-full">
|
||||||
<p class="text-base text-gray-100 whitespace-pre-line">{{ description }}</p>
|
<p ref="description" id="item-description" class="text-base text-gray-100 whitespace-pre-line mb-1" :class="{ 'show-full': showFullDescription }">{{ description }}</p>
|
||||||
|
<button v-if="isDescriptionClamped" class="py-0.5 flex items-center text-slate-300 hover:text-white" @click="showFullDescription = !showFullDescription">
|
||||||
|
{{ showFullDescription ? 'Read less' : 'Read more' }} <span class="material-icons text-xl pl-1">{{ showFullDescription ? 'expand_less' : 'expand_more' }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="invalidAudioFiles.length" class="bg-error border-red-800 shadow-md p-4">
|
<div v-if="invalidAudioFiles.length" class="bg-error border-red-800 shadow-md p-4">
|
||||||
@ -182,7 +185,9 @@ export default {
|
|||||||
podcastFeedEpisodes: [],
|
podcastFeedEpisodes: [],
|
||||||
episodesDownloading: [],
|
episodesDownloading: [],
|
||||||
episodeDownloadsQueued: [],
|
episodeDownloadsQueued: [],
|
||||||
showBookmarksModal: false
|
showBookmarksModal: false,
|
||||||
|
isDescriptionClamped: false,
|
||||||
|
showFullDescription: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@ -596,10 +601,15 @@ export default {
|
|||||||
this.$store.commit('setBookshelfBookIds', [])
|
this.$store.commit('setBookshelfBookIds', [])
|
||||||
this.$store.commit('showEditModal', this.libraryItem)
|
this.$store.commit('showEditModal', this.libraryItem)
|
||||||
},
|
},
|
||||||
|
checkDescriptionClamped() {
|
||||||
|
if (!this.$refs.description) return
|
||||||
|
this.isDescriptionClamped = this.$refs.description.scrollHeight > this.$refs.description.clientHeight
|
||||||
|
},
|
||||||
libraryItemUpdated(libraryItem) {
|
libraryItemUpdated(libraryItem) {
|
||||||
if (libraryItem.id === this.libraryItemId) {
|
if (libraryItem.id === this.libraryItemId) {
|
||||||
console.log('Item was updated', libraryItem)
|
console.log('Item was updated', libraryItem)
|
||||||
this.libraryItem = libraryItem
|
this.libraryItem = libraryItem
|
||||||
|
this.$nextTick(this.checkDescriptionClamped)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clearProgressClick() {
|
clearProgressClick() {
|
||||||
@ -756,6 +766,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.checkDescriptionClamped()
|
||||||
|
|
||||||
this.episodeDownloadsQueued = this.libraryItem.episodeDownloadsQueued || []
|
this.episodeDownloadsQueued = this.libraryItem.episodeDownloadsQueued || []
|
||||||
this.episodesDownloading = this.libraryItem.episodesDownloading || []
|
this.episodesDownloading = this.libraryItem.episodesDownloading || []
|
||||||
|
|
||||||
@ -782,3 +794,18 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#item-description {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
max-height: 6.25rem;
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
#item-description.show-full {
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
max-height: 999rem;
|
||||||
|
}
|
||||||
|
</style>
|
@ -45,7 +45,7 @@
|
|||||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-gray-200 mb-4 episode-subtitle-long" v-html="episode.subtitle || episode.description" />
|
<p class="text-sm text-gray-200 mb-4 line-clamp-4" v-html="episode.subtitle || episode.description" />
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress && episode.progress.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
|
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress && episode.progress.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
|
||||||
|
@ -86,6 +86,9 @@ export default {
|
|||||||
},
|
},
|
||||||
streamLibraryItem() {
|
streamLibraryItem() {
|
||||||
return this.$store.state.streamLibraryItem
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
librarySettings() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibrarySettings']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -151,7 +154,12 @@ export default {
|
|||||||
async submitSearch(term) {
|
async submitSearch(term) {
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.termSearched = ''
|
this.termSearched = ''
|
||||||
let results = await this.$axios.$get(`/api/search/podcast?term=${encodeURIComponent(term)}`).catch((error) => {
|
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
term,
|
||||||
|
country: this.librarySettings?.podcastSearchRegion || 'us'
|
||||||
|
})
|
||||||
|
let results = await this.$axios.$get(`/api/search/podcast?${searchParams.toString()}`).catch((error) => {
|
||||||
console.error('Search request failed', error)
|
console.error('Search request failed', error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
@ -14,9 +14,11 @@ const languageCodeMap = {
|
|||||||
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
|
'hr': { label: 'Hrvatski', dateFnsLocale: 'hr' },
|
||||||
'it': { label: 'Italiano', dateFnsLocale: 'it' },
|
'it': { label: 'Italiano', dateFnsLocale: 'it' },
|
||||||
'lt': { label: 'Lietuvių', dateFnsLocale: 'lt' },
|
'lt': { label: 'Lietuvių', dateFnsLocale: 'lt' },
|
||||||
|
'hu': { label: 'Magyar', dateFnsLocale: 'hu' },
|
||||||
'nl': { label: 'Nederlands', dateFnsLocale: 'nl' },
|
'nl': { label: 'Nederlands', dateFnsLocale: 'nl' },
|
||||||
'no': { label: 'Norsk', dateFnsLocale: 'no' },
|
'no': { label: 'Norsk', dateFnsLocale: 'no' },
|
||||||
'pl': { label: 'Polski', dateFnsLocale: 'pl' },
|
'pl': { label: 'Polski', dateFnsLocale: 'pl' },
|
||||||
|
'pt-br': { label: 'Português (Brasil)', dateFnsLocale: 'ptBR' },
|
||||||
'ru': { label: 'Русский', dateFnsLocale: 'ru' },
|
'ru': { label: 'Русский', dateFnsLocale: 'ru' },
|
||||||
'sv': { label: 'Svenska', dateFnsLocale: 'sv' },
|
'sv': { label: 'Svenska', dateFnsLocale: 'sv' },
|
||||||
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' },
|
'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' },
|
||||||
@ -28,6 +30,18 @@ Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map(code => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// iTunes search API uses ISO 3166 country codes: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
|
||||||
|
const podcastSearchRegionMap = {
|
||||||
|
'us': { label: 'United States' },
|
||||||
|
'cn': { label: '中国' }
|
||||||
|
}
|
||||||
|
Vue.prototype.$podcastSearchRegionOptions = Object.keys(podcastSearchRegionMap).map(code => {
|
||||||
|
return {
|
||||||
|
text: podcastSearchRegionMap[code].label,
|
||||||
|
value: code
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
Vue.prototype.$languageCodes = {
|
Vue.prototype.$languageCodes = {
|
||||||
default: defaultCode,
|
default: defaultCode,
|
||||||
current: defaultCode,
|
current: defaultCode,
|
||||||
@ -83,7 +97,7 @@ async function loadi18n(code) {
|
|||||||
|
|
||||||
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
|
Vue.prototype.$setDateFnsLocale(languageCodeMap[code].dateFnsLocale)
|
||||||
|
|
||||||
this.$eventBus.$emit('change-lang', code)
|
this?.$eventBus?.$emit('change-lang', code)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -156,14 +156,14 @@ Vue.prototype.$copyToClipboard = (str, ctx) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function xmlToJson(xml) {
|
function xmlToJson(xml) {
|
||||||
const json = {};
|
const json = {}
|
||||||
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
|
for (const res of xml.matchAll(/(?:<(\w*)(?:\s[^>]*)*>)((?:(?!<\1).)*)(?:<\/\1>)|<(\w*)(?:\s*)*\/>/gm)) {
|
||||||
const key = res[1] || res[3];
|
const key = res[1] || res[3]
|
||||||
const value = res[2] && xmlToJson(res[2]);
|
const value = res[2] && xmlToJson(res[2])
|
||||||
json[key] = ((value && Object.keys(value).length) ? value : res[2]) || null;
|
json[key] = ((value && Object.keys(value).length) ? value : res[2]) || null
|
||||||
|
|
||||||
}
|
}
|
||||||
return json;
|
return json
|
||||||
}
|
}
|
||||||
Vue.prototype.$xmlToJson = xmlToJson
|
Vue.prototype.$xmlToJson = xmlToJson
|
||||||
|
|
||||||
|
BIN
client/static/ios_icon.png
Normal file
BIN
client/static/ios_icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 33 KiB |
@ -113,6 +113,7 @@ export const actions = {
|
|||||||
const library = data.library
|
const library = data.library
|
||||||
const filterData = data.filterdata
|
const filterData = data.filterdata
|
||||||
const issues = data.issues || 0
|
const issues = data.issues || 0
|
||||||
|
const customMetadataProviders = data.customMetadataProviders || []
|
||||||
const numUserPlaylists = data.numUserPlaylists
|
const numUserPlaylists = data.numUserPlaylists
|
||||||
|
|
||||||
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
|
dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true })
|
||||||
@ -126,6 +127,8 @@ export const actions = {
|
|||||||
commit('setLibraryIssues', issues)
|
commit('setLibraryIssues', issues)
|
||||||
commit('setLibraryFilterData', filterData)
|
commit('setLibraryFilterData', filterData)
|
||||||
commit('setNumUserPlaylists', numUserPlaylists)
|
commit('setNumUserPlaylists', numUserPlaylists)
|
||||||
|
commit('scanners/setCustomMetadataProviders', customMetadataProviders, { root: true })
|
||||||
|
|
||||||
commit('setCurrentLibrary', libraryId)
|
commit('setCurrentLibrary', libraryId)
|
||||||
return data
|
return data
|
||||||
})
|
})
|
||||||
|
@ -71,8 +71,56 @@ export const state = () => ({
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {}
|
export const getters = {
|
||||||
|
checkBookProviderExists: state => (providerValue) => {
|
||||||
|
return state.providers.some(p => p.value === providerValue)
|
||||||
|
},
|
||||||
|
checkPodcastProviderExists: state => (providerValue) => {
|
||||||
|
return state.podcastProviders.some(p => p.value === providerValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const actions = {}
|
export const actions = {}
|
||||||
|
|
||||||
export const mutations = {}
|
export const mutations = {
|
||||||
|
addCustomMetadataProvider(state, provider) {
|
||||||
|
if (provider.mediaType === 'book') {
|
||||||
|
if (state.providers.some(p => p.value === provider.slug)) return
|
||||||
|
state.providers.push({
|
||||||
|
text: provider.name,
|
||||||
|
value: provider.slug
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
if (state.podcastProviders.some(p => p.value === provider.slug)) return
|
||||||
|
state.podcastProviders.push({
|
||||||
|
text: provider.name,
|
||||||
|
value: provider.slug
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeCustomMetadataProvider(state, provider) {
|
||||||
|
if (provider.mediaType === 'book') {
|
||||||
|
state.providers = state.providers.filter(p => p.value !== provider.slug)
|
||||||
|
} else {
|
||||||
|
state.podcastProviders = state.podcastProviders.filter(p => p.value !== provider.slug)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setCustomMetadataProviders(state, providers) {
|
||||||
|
if (!providers?.length) return
|
||||||
|
|
||||||
|
const mediaType = providers[0].mediaType
|
||||||
|
if (mediaType === 'book') {
|
||||||
|
// clear previous values, and add new values to the end
|
||||||
|
state.providers = state.providers.filter((p) => !p.value.startsWith('custom-'))
|
||||||
|
state.providers = [
|
||||||
|
...state.providers,
|
||||||
|
...providers.map((p) => ({
|
||||||
|
text: p.name,
|
||||||
|
value: p.slug
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
// Podcast providers not supported yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "Skrýt",
|
"ButtonHide": "Skrýt",
|
||||||
"ButtonHome": "Domů",
|
"ButtonHome": "Domů",
|
||||||
"ButtonIssues": "Problémy",
|
"ButtonIssues": "Problémy",
|
||||||
|
"ButtonJumpBackward": "Jump Backward",
|
||||||
|
"ButtonJumpForward": "Jump Forward",
|
||||||
"ButtonLatest": "Nejnovější",
|
"ButtonLatest": "Nejnovější",
|
||||||
"ButtonLibrary": "Knihovna",
|
"ButtonLibrary": "Knihovna",
|
||||||
"ButtonLogout": "Odhlásit",
|
"ButtonLogout": "Odhlásit",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "Spárovat všechny autory",
|
"ButtonMatchAllAuthors": "Spárovat všechny autory",
|
||||||
"ButtonMatchBooks": "Spárovat Knihy",
|
"ButtonMatchBooks": "Spárovat Knihy",
|
||||||
"ButtonNevermind": "Nevadí",
|
"ButtonNevermind": "Nevadí",
|
||||||
|
"ButtonNextChapter": "Next Chapter",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Otevřít kanál",
|
"ButtonOpenFeed": "Otevřít kanál",
|
||||||
"ButtonOpenManager": "Otevřít správce",
|
"ButtonOpenManager": "Otevřít správce",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "Přehrát",
|
"ButtonPlay": "Přehrát",
|
||||||
"ButtonPlaying": "Hraje",
|
"ButtonPlaying": "Hraje",
|
||||||
"ButtonPlaylists": "Seznamy skladeb",
|
"ButtonPlaylists": "Seznamy skladeb",
|
||||||
|
"ButtonPreviousChapter": "Previous Chapter",
|
||||||
"ButtonPurgeAllCache": "Vyčistit veškerou mezipaměť",
|
"ButtonPurgeAllCache": "Vyčistit veškerou mezipaměť",
|
||||||
"ButtonPurgeItemsCache": "Vyčistit mezipaměť položek",
|
"ButtonPurgeItemsCache": "Vyčistit mezipaměť položek",
|
||||||
"ButtonPurgeMediaProgress": "Vyčistit průběh médií",
|
"ButtonPurgeMediaProgress": "Vyčistit průběh médií",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "Položky kolekce",
|
"HeaderCollectionItems": "Položky kolekce",
|
||||||
"HeaderCover": "Obálka",
|
"HeaderCover": "Obálka",
|
||||||
"HeaderCurrentDownloads": "Aktuální stahování",
|
"HeaderCurrentDownloads": "Aktuální stahování",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Podrobnosti",
|
"HeaderDetails": "Podrobnosti",
|
||||||
"HeaderDownloadQueue": "Fronta stahování",
|
"HeaderDownloadQueue": "Fronta stahování",
|
||||||
"HeaderEbookFiles": "Soubory elektronických knih",
|
"HeaderEbookFiles": "Soubory elektronických knih",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Metoda přehrávání",
|
"LabelPlayMethod": "Metoda přehrávání",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasty",
|
"LabelPodcasts": "Podcasty",
|
||||||
|
"LabelPodcastSearchRegion": "Oblast vyhledávání podcastu",
|
||||||
"LabelPodcastType": "Typ podcastu",
|
"LabelPodcastType": "Typ podcastu",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Předpony, které se mají ignorovat (nerozlišují se malá a velká písmena)",
|
"LabelPrefixesToIgnore": "Předpony, které se mají ignorovat (nerozlišují se malá a velká písmena)",
|
||||||
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "Skjul",
|
"ButtonHide": "Skjul",
|
||||||
"ButtonHome": "Hjem",
|
"ButtonHome": "Hjem",
|
||||||
"ButtonIssues": "Problemer",
|
"ButtonIssues": "Problemer",
|
||||||
|
"ButtonJumpBackward": "Jump Backward",
|
||||||
|
"ButtonJumpForward": "Jump Forward",
|
||||||
"ButtonLatest": "Seneste",
|
"ButtonLatest": "Seneste",
|
||||||
"ButtonLibrary": "Bibliotek",
|
"ButtonLibrary": "Bibliotek",
|
||||||
"ButtonLogout": "Log ud",
|
"ButtonLogout": "Log ud",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "Match alle forfattere",
|
"ButtonMatchAllAuthors": "Match alle forfattere",
|
||||||
"ButtonMatchBooks": "Match bøger",
|
"ButtonMatchBooks": "Match bøger",
|
||||||
"ButtonNevermind": "Glem det",
|
"ButtonNevermind": "Glem det",
|
||||||
|
"ButtonNextChapter": "Next Chapter",
|
||||||
"ButtonOk": "OK",
|
"ButtonOk": "OK",
|
||||||
"ButtonOpenFeed": "Åbn feed",
|
"ButtonOpenFeed": "Åbn feed",
|
||||||
"ButtonOpenManager": "Åbn manager",
|
"ButtonOpenManager": "Åbn manager",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "Afspil",
|
"ButtonPlay": "Afspil",
|
||||||
"ButtonPlaying": "Afspiller",
|
"ButtonPlaying": "Afspiller",
|
||||||
"ButtonPlaylists": "Afspilningslister",
|
"ButtonPlaylists": "Afspilningslister",
|
||||||
|
"ButtonPreviousChapter": "Previous Chapter",
|
||||||
"ButtonPurgeAllCache": "Ryd al cache",
|
"ButtonPurgeAllCache": "Ryd al cache",
|
||||||
"ButtonPurgeItemsCache": "Ryd elementcache",
|
"ButtonPurgeItemsCache": "Ryd elementcache",
|
||||||
"ButtonPurgeMediaProgress": "Ryd Medieforløb",
|
"ButtonPurgeMediaProgress": "Ryd Medieforløb",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "Samlingselementer",
|
"HeaderCollectionItems": "Samlingselementer",
|
||||||
"HeaderCover": "Omslag",
|
"HeaderCover": "Omslag",
|
||||||
"HeaderCurrentDownloads": "Nuværende Downloads",
|
"HeaderCurrentDownloads": "Nuværende Downloads",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Detaljer",
|
"HeaderDetails": "Detaljer",
|
||||||
"HeaderDownloadQueue": "Download Kø",
|
"HeaderDownloadQueue": "Download Kø",
|
||||||
"HeaderEbookFiles": "E-bogsfiler",
|
"HeaderEbookFiles": "E-bogsfiler",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Afspilningsmetode",
|
"LabelPlayMethod": "Afspilningsmetode",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
|
"LabelPodcastSearchRegion": "Podcast søgeområde",
|
||||||
"LabelPodcastType": "Podcast type",
|
"LabelPodcastType": "Podcast type",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Præfikser der skal ignoreres (skal ikke skelne mellem store og små bogstaver)",
|
"LabelPrefixesToIgnore": "Præfikser der skal ignoreres (skal ikke skelne mellem store og små bogstaver)",
|
||||||
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "Ausblenden",
|
"ButtonHide": "Ausblenden",
|
||||||
"ButtonHome": "Startseite",
|
"ButtonHome": "Startseite",
|
||||||
"ButtonIssues": "Probleme",
|
"ButtonIssues": "Probleme",
|
||||||
|
"ButtonJumpBackward": "Zurück springen",
|
||||||
|
"ButtonJumpForward": "Vorwärts springen",
|
||||||
"ButtonLatest": "Neuste",
|
"ButtonLatest": "Neuste",
|
||||||
"ButtonLibrary": "Bibliothek",
|
"ButtonLibrary": "Bibliothek",
|
||||||
"ButtonLogout": "Abmelden",
|
"ButtonLogout": "Abmelden",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "Online Metadaten-Abgleich (alle Autoren)",
|
"ButtonMatchAllAuthors": "Online Metadaten-Abgleich (alle Autoren)",
|
||||||
"ButtonMatchBooks": "Online Metadaten-Abgleich (alle Medien)",
|
"ButtonMatchBooks": "Online Metadaten-Abgleich (alle Medien)",
|
||||||
"ButtonNevermind": "Abbrechen",
|
"ButtonNevermind": "Abbrechen",
|
||||||
|
"ButtonNextChapter": "Nächstes Kapitel",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Feed öffnen",
|
"ButtonOpenFeed": "Feed öffnen",
|
||||||
"ButtonOpenManager": "Manager öffnen",
|
"ButtonOpenManager": "Manager öffnen",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "Abspielen",
|
"ButtonPlay": "Abspielen",
|
||||||
"ButtonPlaying": "Spielt",
|
"ButtonPlaying": "Spielt",
|
||||||
"ButtonPlaylists": "Wiedergabelisten",
|
"ButtonPlaylists": "Wiedergabelisten",
|
||||||
|
"ButtonPreviousChapter": "Vorheriges Kapitel",
|
||||||
"ButtonPurgeAllCache": "Cache leeren",
|
"ButtonPurgeAllCache": "Cache leeren",
|
||||||
"ButtonPurgeItemsCache": "Lösche Medien-Cache",
|
"ButtonPurgeItemsCache": "Lösche Medien-Cache",
|
||||||
"ButtonPurgeMediaProgress": "Lösche Hörfortschritte",
|
"ButtonPurgeMediaProgress": "Lösche Hörfortschritte",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "Sammlungseinträge",
|
"HeaderCollectionItems": "Sammlungseinträge",
|
||||||
"HeaderCover": "Titelbild",
|
"HeaderCover": "Titelbild",
|
||||||
"HeaderCurrentDownloads": "Aktuelle Downloads",
|
"HeaderCurrentDownloads": "Aktuelle Downloads",
|
||||||
|
"HeaderCustomMetadataProviders": "Benutzerdefinierte Metadata Anbieter",
|
||||||
"HeaderDetails": "Details",
|
"HeaderDetails": "Details",
|
||||||
"HeaderDownloadQueue": "Download Warteschlange",
|
"HeaderDownloadQueue": "Download Warteschlange",
|
||||||
"HeaderEbookFiles": "E-Book Dateien",
|
"HeaderEbookFiles": "E-Book Dateien",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Abspielmethode",
|
"LabelPlayMethod": "Abspielmethode",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
|
"LabelPodcastSearchRegion": "Podcast-Suchregion",
|
||||||
"LabelPodcastType": "Podcast Typ",
|
"LabelPodcastType": "Podcast Typ",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
|
"LabelPrefixesToIgnore": "Zu ignorierende(s) Vorwort(e) (Groß- und Kleinschreibung wird nicht berücksichtigt)",
|
||||||
@ -495,10 +502,10 @@
|
|||||||
"LabelTagsAccessibleToUser": "Für Benutzer zugängliche Schlagwörter",
|
"LabelTagsAccessibleToUser": "Für Benutzer zugängliche Schlagwörter",
|
||||||
"LabelTagsNotAccessibleToUser": "Für Benutzer nicht zugängliche Schlagwörter",
|
"LabelTagsNotAccessibleToUser": "Für Benutzer nicht zugängliche Schlagwörter",
|
||||||
"LabelTasks": "Laufende Aufgaben",
|
"LabelTasks": "Laufende Aufgaben",
|
||||||
"LabelTextEditorBulletedList": "Bulleted list",
|
"LabelTextEditorBulletedList": "Aufzählungsliste",
|
||||||
"LabelTextEditorLink": "Link",
|
"LabelTextEditorLink": "Link",
|
||||||
"LabelTextEditorNumberedList": "Numbered list",
|
"LabelTextEditorNumberedList": "nummerierte Liste",
|
||||||
"LabelTextEditorUnlink": "Unlink",
|
"LabelTextEditorUnlink": "entkoppeln",
|
||||||
"LabelTheme": "Theme",
|
"LabelTheme": "Theme",
|
||||||
"LabelThemeDark": "Dunkel",
|
"LabelThemeDark": "Dunkel",
|
||||||
"LabelThemeLight": "Hell",
|
"LabelThemeLight": "Hell",
|
||||||
@ -524,7 +531,7 @@
|
|||||||
"LabelTracksSingleTrack": "Einzeldatei",
|
"LabelTracksSingleTrack": "Einzeldatei",
|
||||||
"LabelType": "Typ",
|
"LabelType": "Typ",
|
||||||
"LabelUnabridged": "Ungekürzt",
|
"LabelUnabridged": "Ungekürzt",
|
||||||
"LabelUndo": "Undo",
|
"LabelUndo": "Rückgängig machen",
|
||||||
"LabelUnknown": "Unbekannt",
|
"LabelUnknown": "Unbekannt",
|
||||||
"LabelUpdateCover": "Titelbild aktualisieren",
|
"LabelUpdateCover": "Titelbild aktualisieren",
|
||||||
"LabelUpdateCoverHelp": "Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird",
|
"LabelUpdateCoverHelp": "Erlaube das Überschreiben bestehender Titelbilder für die ausgewählten Hörbücher, wenn eine Übereinstimmung gefunden wird",
|
||||||
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "Hide",
|
"ButtonHide": "Hide",
|
||||||
"ButtonHome": "Home",
|
"ButtonHome": "Home",
|
||||||
"ButtonIssues": "Issues",
|
"ButtonIssues": "Issues",
|
||||||
|
"ButtonJumpBackward": "Jump Backward",
|
||||||
|
"ButtonJumpForward": "Jump Forward",
|
||||||
"ButtonLatest": "Latest",
|
"ButtonLatest": "Latest",
|
||||||
"ButtonLibrary": "Library",
|
"ButtonLibrary": "Library",
|
||||||
"ButtonLogout": "Logout",
|
"ButtonLogout": "Logout",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "Match All Authors",
|
"ButtonMatchAllAuthors": "Match All Authors",
|
||||||
"ButtonMatchBooks": "Match Books",
|
"ButtonMatchBooks": "Match Books",
|
||||||
"ButtonNevermind": "Nevermind",
|
"ButtonNevermind": "Nevermind",
|
||||||
|
"ButtonNextChapter": "Next Chapter",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Open Feed",
|
"ButtonOpenFeed": "Open Feed",
|
||||||
"ButtonOpenManager": "Open Manager",
|
"ButtonOpenManager": "Open Manager",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "Play",
|
"ButtonPlay": "Play",
|
||||||
"ButtonPlaying": "Playing",
|
"ButtonPlaying": "Playing",
|
||||||
"ButtonPlaylists": "Playlists",
|
"ButtonPlaylists": "Playlists",
|
||||||
|
"ButtonPreviousChapter": "Previous Chapter",
|
||||||
"ButtonPurgeAllCache": "Purge All Cache",
|
"ButtonPurgeAllCache": "Purge All Cache",
|
||||||
"ButtonPurgeItemsCache": "Purge Items Cache",
|
"ButtonPurgeItemsCache": "Purge Items Cache",
|
||||||
"ButtonPurgeMediaProgress": "Purge Media Progress",
|
"ButtonPurgeMediaProgress": "Purge Media Progress",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "Collection Items",
|
"HeaderCollectionItems": "Collection Items",
|
||||||
"HeaderCover": "Cover",
|
"HeaderCover": "Cover",
|
||||||
"HeaderCurrentDownloads": "Current Downloads",
|
"HeaderCurrentDownloads": "Current Downloads",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Details",
|
"HeaderDetails": "Details",
|
||||||
"HeaderDownloadQueue": "Download Queue",
|
"HeaderDownloadQueue": "Download Queue",
|
||||||
"HeaderEbookFiles": "Ebook Files",
|
"HeaderEbookFiles": "Ebook Files",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Play Method",
|
"LabelPlayMethod": "Play Method",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
|
"LabelPodcastSearchRegion": "Podcast search region",
|
||||||
"LabelPodcastType": "Podcast Type",
|
"LabelPodcastType": "Podcast Type",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
|
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"ButtonAdd": "Agregar",
|
"ButtonAdd": "Agregar",
|
||||||
"ButtonAddChapters": "Agregar Capitulo",
|
"ButtonAddChapters": "Agregar Capitulo",
|
||||||
"ButtonAddDevice": "Add Device",
|
"ButtonAddDevice": "Agregar Dispositivo",
|
||||||
"ButtonAddLibrary": "Add Library",
|
"ButtonAddLibrary": "Crear Biblioteca",
|
||||||
"ButtonAddPodcasts": "Agregar Podcasts",
|
"ButtonAddPodcasts": "Agregar Podcasts",
|
||||||
"ButtonAddUser": "Add User",
|
"ButtonAddUser": "Crear Usuario",
|
||||||
"ButtonAddYourFirstLibrary": "Agrega tu Primera Biblioteca",
|
"ButtonAddYourFirstLibrary": "Crea tu Primera Biblioteca",
|
||||||
"ButtonApply": "Aplicar",
|
"ButtonApply": "Aplicar",
|
||||||
"ButtonApplyChapters": "Aplicar Capítulos",
|
"ButtonApplyChapters": "Aplicar Capítulos",
|
||||||
"ButtonAuthors": "Autores",
|
"ButtonAuthors": "Autores",
|
||||||
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "Esconder",
|
"ButtonHide": "Esconder",
|
||||||
"ButtonHome": "Inicio",
|
"ButtonHome": "Inicio",
|
||||||
"ButtonIssues": "Problemas",
|
"ButtonIssues": "Problemas",
|
||||||
|
"ButtonJumpBackward": "Retroceder",
|
||||||
|
"ButtonJumpForward": "Adelantar",
|
||||||
"ButtonLatest": "Últimos",
|
"ButtonLatest": "Últimos",
|
||||||
"ButtonLibrary": "Biblioteca",
|
"ButtonLibrary": "Biblioteca",
|
||||||
"ButtonLogout": "Cerrar Sesión",
|
"ButtonLogout": "Cerrar Sesión",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "Encontrar Todos los Autores",
|
"ButtonMatchAllAuthors": "Encontrar Todos los Autores",
|
||||||
"ButtonMatchBooks": "Encontrar Libros",
|
"ButtonMatchBooks": "Encontrar Libros",
|
||||||
"ButtonNevermind": "Olvidar",
|
"ButtonNevermind": "Olvidar",
|
||||||
|
"ButtonNextChapter": "Siguiente Capítulo",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Abrir Fuente",
|
"ButtonOpenFeed": "Abrir Fuente",
|
||||||
"ButtonOpenManager": "Abrir Editor",
|
"ButtonOpenManager": "Abrir Editor",
|
||||||
|
"ButtonPause": "Pausar",
|
||||||
"ButtonPlay": "Reproducir",
|
"ButtonPlay": "Reproducir",
|
||||||
"ButtonPlaying": "Reproduciendo",
|
"ButtonPlaying": "Reproduciendo",
|
||||||
"ButtonPlaylists": "Listas de Reproducción",
|
"ButtonPlaylists": "Listas de Reproducción",
|
||||||
|
"ButtonPreviousChapter": "Capítulo Anterior",
|
||||||
"ButtonPurgeAllCache": "Purgar Todo el Cache",
|
"ButtonPurgeAllCache": "Purgar Todo el Cache",
|
||||||
"ButtonPurgeItemsCache": "Purgar Elementos de Cache",
|
"ButtonPurgeItemsCache": "Purgar Elementos de Cache",
|
||||||
"ButtonPurgeMediaProgress": "Purgar Progreso de Multimedia",
|
"ButtonPurgeMediaProgress": "Purgar Progreso de Multimedia",
|
||||||
@ -87,15 +92,15 @@
|
|||||||
"ButtonUserEdit": "Editar Usuario {0}",
|
"ButtonUserEdit": "Editar Usuario {0}",
|
||||||
"ButtonViewAll": "Ver Todos",
|
"ButtonViewAll": "Ver Todos",
|
||||||
"ButtonYes": "Aceptar",
|
"ButtonYes": "Aceptar",
|
||||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
"ErrorUploadFetchMetadataAPI": "Error obteniendo metadatos",
|
||||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
"ErrorUploadFetchMetadataNoResults": "No se pudo obtener metadatos - Intenta actualizar el título y/o autor",
|
||||||
"ErrorUploadLacksTitle": "Must have a title",
|
"ErrorUploadLacksTitle": "Se debe tener título",
|
||||||
"HeaderAccount": "Cuenta",
|
"HeaderAccount": "Cuenta",
|
||||||
"HeaderAdvanced": "Avanzado",
|
"HeaderAdvanced": "Avanzado",
|
||||||
"HeaderAppriseNotificationSettings": "Ajustes de Notificaciones de Apprise",
|
"HeaderAppriseNotificationSettings": "Ajustes de Notificaciones de Apprise",
|
||||||
"HeaderAudiobookTools": "Herramientas de Gestión de Archivos de Audiolibro",
|
"HeaderAudiobookTools": "Herramientas de Gestión de Archivos de Audiolibro",
|
||||||
"HeaderAudioTracks": "Pistas de Audio",
|
"HeaderAudioTracks": "Pistas de Audio",
|
||||||
"HeaderAuthentication": "Authentication",
|
"HeaderAuthentication": "Autenticación",
|
||||||
"HeaderBackups": "Respaldos",
|
"HeaderBackups": "Respaldos",
|
||||||
"HeaderChangePassword": "Cambiar Contraseña",
|
"HeaderChangePassword": "Cambiar Contraseña",
|
||||||
"HeaderChapters": "Capítulos",
|
"HeaderChapters": "Capítulos",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "Elementos en la Colección",
|
"HeaderCollectionItems": "Elementos en la Colección",
|
||||||
"HeaderCover": "Portada",
|
"HeaderCover": "Portada",
|
||||||
"HeaderCurrentDownloads": "Descargando Actualmente",
|
"HeaderCurrentDownloads": "Descargando Actualmente",
|
||||||
|
"HeaderCustomMetadataProviders": "Proveedores de metadatos personalizados",
|
||||||
"HeaderDetails": "Detalles",
|
"HeaderDetails": "Detalles",
|
||||||
"HeaderDownloadQueue": "Lista de Descarga",
|
"HeaderDownloadQueue": "Lista de Descarga",
|
||||||
"HeaderEbookFiles": "Archivos de Ebook",
|
"HeaderEbookFiles": "Archivos de Ebook",
|
||||||
@ -130,15 +136,15 @@
|
|||||||
"HeaderManageTags": "Administrar Etiquetas",
|
"HeaderManageTags": "Administrar Etiquetas",
|
||||||
"HeaderMapDetails": "Asignar Detalles",
|
"HeaderMapDetails": "Asignar Detalles",
|
||||||
"HeaderMatch": "Encontrar",
|
"HeaderMatch": "Encontrar",
|
||||||
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
|
"HeaderMetadataOrderOfPrecedence": "Orden de precedencia de metadatos",
|
||||||
"HeaderMetadataToEmbed": "Metadatos para Insertar",
|
"HeaderMetadataToEmbed": "Metadatos para Insertar",
|
||||||
"HeaderNewAccount": "Nueva Cuenta",
|
"HeaderNewAccount": "Nueva Cuenta",
|
||||||
"HeaderNewLibrary": "Nueva Biblioteca",
|
"HeaderNewLibrary": "Nueva Biblioteca",
|
||||||
"HeaderNotifications": "Notificaciones",
|
"HeaderNotifications": "Notificaciones",
|
||||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication",
|
"HeaderOpenIDConnectAuthentication": "Autenticación OpenID Connect",
|
||||||
"HeaderOpenRSSFeed": "Abrir fuente RSS",
|
"HeaderOpenRSSFeed": "Abrir fuente RSS",
|
||||||
"HeaderOtherFiles": "Otros Archivos",
|
"HeaderOtherFiles": "Otros Archivos",
|
||||||
"HeaderPasswordAuthentication": "Password Authentication",
|
"HeaderPasswordAuthentication": "Autenticación por contraseña",
|
||||||
"HeaderPermissions": "Permisos",
|
"HeaderPermissions": "Permisos",
|
||||||
"HeaderPlayerQueue": "Fila del Reproductor",
|
"HeaderPlayerQueue": "Fila del Reproductor",
|
||||||
"HeaderPlaylist": "Lista de Reproducción",
|
"HeaderPlaylist": "Lista de Reproducción",
|
||||||
@ -187,11 +193,11 @@
|
|||||||
"LabelAddToCollectionBatch": "Se Añadieron {0} Libros a la Colección",
|
"LabelAddToCollectionBatch": "Se Añadieron {0} Libros a la Colección",
|
||||||
"LabelAddToPlaylist": "Añadido a la Lista de Reproducción",
|
"LabelAddToPlaylist": "Añadido a la Lista de Reproducción",
|
||||||
"LabelAddToPlaylistBatch": "Se Añadieron {0} Artículos a la Lista de Reproducción",
|
"LabelAddToPlaylistBatch": "Se Añadieron {0} Artículos a la Lista de Reproducción",
|
||||||
"LabelAdminUsersOnly": "Admin users only",
|
"LabelAdminUsersOnly": "Solamente usuarios administradores",
|
||||||
"LabelAll": "Todos",
|
"LabelAll": "Todos",
|
||||||
"LabelAllUsers": "Todos los Usuarios",
|
"LabelAllUsers": "Todos los Usuarios",
|
||||||
"LabelAllUsersExcludingGuests": "All users excluding guests",
|
"LabelAllUsersExcludingGuests": "Todos los usuarios excepto invitados",
|
||||||
"LabelAllUsersIncludingGuests": "All users including guests",
|
"LabelAllUsersIncludingGuests": "Todos los usuarios e invitados",
|
||||||
"LabelAlreadyInYourLibrary": "Ya en la Biblioteca",
|
"LabelAlreadyInYourLibrary": "Ya en la Biblioteca",
|
||||||
"LabelAppend": "Adjuntar",
|
"LabelAppend": "Adjuntar",
|
||||||
"LabelAuthor": "Autor",
|
"LabelAuthor": "Autor",
|
||||||
@ -199,12 +205,12 @@
|
|||||||
"LabelAuthorLastFirst": "Autor (Apellido, Nombre)",
|
"LabelAuthorLastFirst": "Autor (Apellido, Nombre)",
|
||||||
"LabelAuthors": "Autores",
|
"LabelAuthors": "Autores",
|
||||||
"LabelAutoDownloadEpisodes": "Descargar Episodios Automáticamente",
|
"LabelAutoDownloadEpisodes": "Descargar Episodios Automáticamente",
|
||||||
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
"LabelAutoFetchMetadata": "Actualizar Metadatos Automáticamente",
|
||||||
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
"LabelAutoFetchMetadataHelp": "Obtiene metadatos de título, autor y serie para agilizar la carga. Es posible que haya que cotejar metadatos adicionales después de la carga.",
|
||||||
"LabelAutoLaunch": "Auto Launch",
|
"LabelAutoLaunch": "Lanzamiento automático",
|
||||||
"LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Redirigir al proveedor de autenticación automáticamente al navegar a la página de inicio de sesión (ruta de sobreescritura manual <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Registro automático",
|
||||||
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
|
"LabelAutoRegisterDescription": "Crear usuarios automáticamente tras iniciar sesión",
|
||||||
"LabelBackToUser": "Regresar a Usuario",
|
"LabelBackToUser": "Regresar a Usuario",
|
||||||
"LabelBackupLocation": "Ubicación del Respaldo",
|
"LabelBackupLocation": "Ubicación del Respaldo",
|
||||||
"LabelBackupsEnableAutomaticBackups": "Habilitar Respaldo Automático",
|
"LabelBackupsEnableAutomaticBackups": "Habilitar Respaldo Automático",
|
||||||
@ -215,13 +221,13 @@
|
|||||||
"LabelBackupsNumberToKeepHelp": "Solamente 1 respaldo se removerá a la vez. Si tiene mas respaldos guardados, debe removerlos manualmente.",
|
"LabelBackupsNumberToKeepHelp": "Solamente 1 respaldo se removerá a la vez. Si tiene mas respaldos guardados, debe removerlos manualmente.",
|
||||||
"LabelBitrate": "Bitrate",
|
"LabelBitrate": "Bitrate",
|
||||||
"LabelBooks": "Libros",
|
"LabelBooks": "Libros",
|
||||||
"LabelButtonText": "Button Text",
|
"LabelButtonText": "Texto del botón",
|
||||||
"LabelChangePassword": "Cambiar Contraseña",
|
"LabelChangePassword": "Cambiar Contraseña",
|
||||||
"LabelChannels": "Canales",
|
"LabelChannels": "Canales",
|
||||||
"LabelChapters": "Capítulos",
|
"LabelChapters": "Capítulos",
|
||||||
"LabelChaptersFound": "Capítulo Encontrado",
|
"LabelChaptersFound": "Capítulo Encontrado",
|
||||||
"LabelChapterTitle": "Titulo del Capítulo",
|
"LabelChapterTitle": "Titulo del Capítulo",
|
||||||
"LabelClickForMoreInfo": "Click for more info",
|
"LabelClickForMoreInfo": "Click para más información",
|
||||||
"LabelClosePlayer": "Cerrar Reproductor",
|
"LabelClosePlayer": "Cerrar Reproductor",
|
||||||
"LabelCodec": "Codec",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Colapsar Serie",
|
"LabelCollapseSeries": "Colapsar Serie",
|
||||||
@ -240,12 +246,12 @@
|
|||||||
"LabelCurrently": "En este momento:",
|
"LabelCurrently": "En este momento:",
|
||||||
"LabelCustomCronExpression": "Expresión de Cron Personalizada:",
|
"LabelCustomCronExpression": "Expresión de Cron Personalizada:",
|
||||||
"LabelDatetime": "Hora y Fecha",
|
"LabelDatetime": "Hora y Fecha",
|
||||||
"LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)",
|
"LabelDeleteFromFileSystemCheckbox": "Eliminar archivos del sistema (desmarcar para eliminar sólo de la base de datos)",
|
||||||
"LabelDescription": "Descripción",
|
"LabelDescription": "Descripción",
|
||||||
"LabelDeselectAll": "Deseleccionar Todos",
|
"LabelDeselectAll": "Deseleccionar Todos",
|
||||||
"LabelDevice": "Dispositivo",
|
"LabelDevice": "Dispositivo",
|
||||||
"LabelDeviceInfo": "Información de Dispositivo",
|
"LabelDeviceInfo": "Información de Dispositivo",
|
||||||
"LabelDeviceIsAvailableTo": "Device is available to...",
|
"LabelDeviceIsAvailableTo": "El dispositivo está disponible para...",
|
||||||
"LabelDirectory": "Directorio",
|
"LabelDirectory": "Directorio",
|
||||||
"LabelDiscFromFilename": "Disco a partir del Nombre del Archivo",
|
"LabelDiscFromFilename": "Disco a partir del Nombre del Archivo",
|
||||||
"LabelDiscFromMetadata": "Disco a partir de Metadata",
|
"LabelDiscFromMetadata": "Disco a partir de Metadata",
|
||||||
@ -271,7 +277,7 @@
|
|||||||
"LabelExample": "Ejemplo",
|
"LabelExample": "Ejemplo",
|
||||||
"LabelExplicit": "Explicito",
|
"LabelExplicit": "Explicito",
|
||||||
"LabelFeedURL": "Fuente de URL",
|
"LabelFeedURL": "Fuente de URL",
|
||||||
"LabelFetchingMetadata": "Fetching Metadata",
|
"LabelFetchingMetadata": "Obteniendo metadatos",
|
||||||
"LabelFile": "Archivo",
|
"LabelFile": "Archivo",
|
||||||
"LabelFileBirthtime": "Archivo Creado en",
|
"LabelFileBirthtime": "Archivo Creado en",
|
||||||
"LabelFileModified": "Archivo modificado",
|
"LabelFileModified": "Archivo modificado",
|
||||||
@ -281,23 +287,23 @@
|
|||||||
"LabelFinished": "Terminado",
|
"LabelFinished": "Terminado",
|
||||||
"LabelFolder": "Carpeta",
|
"LabelFolder": "Carpeta",
|
||||||
"LabelFolders": "Carpetas",
|
"LabelFolders": "Carpetas",
|
||||||
"LabelFontBold": "Bold",
|
"LabelFontBold": "Negrilla",
|
||||||
"LabelFontFamily": "Familia tipográfica",
|
"LabelFontFamily": "Familia tipográfica",
|
||||||
"LabelFontItalic": "Italic",
|
"LabelFontItalic": "Itálica",
|
||||||
"LabelFontScale": "Tamaño de Fuente",
|
"LabelFontScale": "Tamaño de Fuente",
|
||||||
"LabelFontStrikethrough": "Strikethrough",
|
"LabelFontStrikethrough": "Tachado",
|
||||||
"LabelFormat": "Formato",
|
"LabelFormat": "Formato",
|
||||||
"LabelGenre": "Genero",
|
"LabelGenre": "Genero",
|
||||||
"LabelGenres": "Géneros",
|
"LabelGenres": "Géneros",
|
||||||
"LabelHardDeleteFile": "Eliminar Definitivamente",
|
"LabelHardDeleteFile": "Eliminar Definitivamente",
|
||||||
"LabelHasEbook": "Tiene Ebook",
|
"LabelHasEbook": "Tiene Ebook",
|
||||||
"LabelHasSupplementaryEbook": "Tiene Ebook Suplementario",
|
"LabelHasSupplementaryEbook": "Tiene Ebook Suplementario",
|
||||||
"LabelHighestPriority": "Highest priority",
|
"LabelHighestPriority": "Mayor prioridad",
|
||||||
"LabelHost": "Host",
|
"LabelHost": "Host",
|
||||||
"LabelHour": "Hora",
|
"LabelHour": "Hora",
|
||||||
"LabelIcon": "Icono",
|
"LabelIcon": "Icono",
|
||||||
"LabelImageURLFromTheWeb": "Image URL from the web",
|
"LabelImageURLFromTheWeb": "URL de la imagen",
|
||||||
"LabelIncludeInTracklist": "Incluir en Tracklist",
|
"LabelIncludeInTracklist": "Incluir en la Tracklist",
|
||||||
"LabelIncomplete": "Incompleto",
|
"LabelIncomplete": "Incompleto",
|
||||||
"LabelInProgress": "En Proceso",
|
"LabelInProgress": "En Proceso",
|
||||||
"LabelInterval": "Intervalo",
|
"LabelInterval": "Intervalo",
|
||||||
@ -334,20 +340,20 @@
|
|||||||
"LabelLogLevelInfo": "Información",
|
"LabelLogLevelInfo": "Información",
|
||||||
"LabelLogLevelWarn": "Advertencia",
|
"LabelLogLevelWarn": "Advertencia",
|
||||||
"LabelLookForNewEpisodesAfterDate": "Buscar Nuevos Episodios a partir de esta Fecha",
|
"LabelLookForNewEpisodesAfterDate": "Buscar Nuevos Episodios a partir de esta Fecha",
|
||||||
"LabelLowestPriority": "Lowest Priority",
|
"LabelLowestPriority": "Menor prioridad",
|
||||||
"LabelMatchExistingUsersBy": "Match existing users by",
|
"LabelMatchExistingUsersBy": "Emparejar a los usuarios existentes por",
|
||||||
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
|
"LabelMatchExistingUsersByDescription": "Se utiliza para conectar usuarios existentes. Una vez conectados, los usuarios serán emparejados por un identificador único de su proveedor de SSO",
|
||||||
"LabelMediaPlayer": "Reproductor de Medios",
|
"LabelMediaPlayer": "Reproductor de Medios",
|
||||||
"LabelMediaType": "Tipo de Multimedia",
|
"LabelMediaType": "Tipo de Multimedia",
|
||||||
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
|
"LabelMetadataOrderOfPrecedenceDescription": "Las fuentes de metadatos de mayor prioridad prevalecerán sobre las de menor prioridad",
|
||||||
"LabelMetadataProvider": "Proveedor de Metadata",
|
"LabelMetadataProvider": "Proveedor de Metadatos",
|
||||||
"LabelMetaTag": "Meta Tag",
|
"LabelMetaTag": "Metaetiqueta",
|
||||||
"LabelMetaTags": "Meta Tags",
|
"LabelMetaTags": "Metaetiquetas",
|
||||||
"LabelMinute": "Minuto",
|
"LabelMinute": "Minuto",
|
||||||
"LabelMissing": "Ausente",
|
"LabelMissing": "Ausente",
|
||||||
"LabelMissingParts": "Partes Ausentes",
|
"LabelMissingParts": "Partes Ausentes",
|
||||||
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
"LabelMobileRedirectURIs": "URIs de redirección a móviles permitidos",
|
||||||
"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.",
|
"LabelMobileRedirectURIsDescription": "Esta es una lista de URIs válidos para redireccionamiento de apps móviles. La URI por defecto es <code>audiobookshelf://oauth</code>, la cual puedes remover or corroborar con URIs adicionales para la integración con apps de terceros. Utilizando un asterisco (<code>*</code>) como el único punto de entrada permite cualquier URI.",
|
||||||
"LabelMore": "Más",
|
"LabelMore": "Más",
|
||||||
"LabelMoreInfo": "Más Información",
|
"LabelMoreInfo": "Más Información",
|
||||||
"LabelName": "Nombre",
|
"LabelName": "Nombre",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Método de Reproducción",
|
"LabelPlayMethod": "Método de Reproducción",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
|
"LabelPodcastSearchRegion": "Región de búsqueda de podcasts",
|
||||||
"LabelPodcastType": "Tipo Podcast",
|
"LabelPodcastType": "Tipo Podcast",
|
||||||
"LabelPort": "Puerto",
|
"LabelPort": "Puerto",
|
||||||
"LabelPrefixesToIgnore": "Prefijos para Ignorar (no distingue entre mayúsculas y minúsculas.)",
|
"LabelPrefixesToIgnore": "Prefijos para Ignorar (no distingue entre mayúsculas y minúsculas.)",
|
||||||
@ -406,11 +413,11 @@
|
|||||||
"LabelRecentlyAdded": "Agregado Recientemente",
|
"LabelRecentlyAdded": "Agregado Recientemente",
|
||||||
"LabelRecentSeries": "Series Recientes",
|
"LabelRecentSeries": "Series Recientes",
|
||||||
"LabelRecommended": "Recomendados",
|
"LabelRecommended": "Recomendados",
|
||||||
"LabelRedo": "Redo",
|
"LabelRedo": "Rehacer",
|
||||||
"LabelRegion": "Región",
|
"LabelRegion": "Región",
|
||||||
"LabelReleaseDate": "Fecha de Estreno",
|
"LabelReleaseDate": "Fecha de Estreno",
|
||||||
"LabelRemoveCover": "Remover Portada",
|
"LabelRemoveCover": "Remover Portada",
|
||||||
"LabelRowsPerPage": "Rows per page",
|
"LabelRowsPerPage": "Filas por página",
|
||||||
"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",
|
||||||
@ -423,7 +430,7 @@
|
|||||||
"LabelSeason": "Temporada",
|
"LabelSeason": "Temporada",
|
||||||
"LabelSelectAllEpisodes": "Seleccionar todos los episodios",
|
"LabelSelectAllEpisodes": "Seleccionar todos los episodios",
|
||||||
"LabelSelectEpisodesShowing": "Seleccionar los {0} episodios visibles",
|
"LabelSelectEpisodesShowing": "Seleccionar los {0} episodios visibles",
|
||||||
"LabelSelectUsers": "Select users",
|
"LabelSelectUsers": "Seleccionar usuarios",
|
||||||
"LabelSendEbookToDevice": "Enviar Ebook a...",
|
"LabelSendEbookToDevice": "Enviar Ebook a...",
|
||||||
"LabelSequence": "Secuencia",
|
"LabelSequence": "Secuencia",
|
||||||
"LabelSeries": "Series",
|
"LabelSeries": "Series",
|
||||||
@ -495,14 +502,14 @@
|
|||||||
"LabelTagsAccessibleToUser": "Etiquetas Accessibles al Usuario",
|
"LabelTagsAccessibleToUser": "Etiquetas Accessibles al Usuario",
|
||||||
"LabelTagsNotAccessibleToUser": "Etiquetas no Accesibles al Usuario",
|
"LabelTagsNotAccessibleToUser": "Etiquetas no Accesibles al Usuario",
|
||||||
"LabelTasks": "Tareas Corriendo",
|
"LabelTasks": "Tareas Corriendo",
|
||||||
"LabelTextEditorBulletedList": "Bulleted list",
|
"LabelTextEditorBulletedList": "Lista con viñetas",
|
||||||
"LabelTextEditorLink": "Link",
|
"LabelTextEditorLink": "Enlazar",
|
||||||
"LabelTextEditorNumberedList": "Numbered list",
|
"LabelTextEditorNumberedList": "Lista numerada",
|
||||||
"LabelTextEditorUnlink": "Unlink",
|
"LabelTextEditorUnlink": "Desenlazar",
|
||||||
"LabelTheme": "Tema",
|
"LabelTheme": "Tema",
|
||||||
"LabelThemeDark": "Oscuro",
|
"LabelThemeDark": "Oscuro",
|
||||||
"LabelThemeLight": "Claro",
|
"LabelThemeLight": "Claro",
|
||||||
"LabelTimeBase": "Time Base",
|
"LabelTimeBase": "Tiempo Base",
|
||||||
"LabelTimeListened": "Tiempo Escuchando",
|
"LabelTimeListened": "Tiempo Escuchando",
|
||||||
"LabelTimeListenedToday": "Tiempo Escuchando Hoy",
|
"LabelTimeListenedToday": "Tiempo Escuchando Hoy",
|
||||||
"LabelTimeRemaining": "{0} restante",
|
"LabelTimeRemaining": "{0} restante",
|
||||||
@ -524,7 +531,7 @@
|
|||||||
"LabelTracksSingleTrack": "Una pista",
|
"LabelTracksSingleTrack": "Una pista",
|
||||||
"LabelType": "Tipo",
|
"LabelType": "Tipo",
|
||||||
"LabelUnabridged": "No Abreviado",
|
"LabelUnabridged": "No Abreviado",
|
||||||
"LabelUndo": "Undo",
|
"LabelUndo": "Deshacer",
|
||||||
"LabelUnknown": "Desconocido",
|
"LabelUnknown": "Desconocido",
|
||||||
"LabelUpdateCover": "Actualizar Portada",
|
"LabelUpdateCover": "Actualizar Portada",
|
||||||
"LabelUpdateCoverHelp": "Permitir sobrescribir las portadas existentes de los libros seleccionados cuando sean encontradas.",
|
"LabelUpdateCoverHelp": "Permitir sobrescribir las portadas existentes de los libros seleccionados cuando sean encontradas.",
|
||||||
@ -533,7 +540,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",
|
"LabelUploaderItemFetchMetadataHelp": "Buscar título, autor y series automáticamente",
|
||||||
"LabelUseChapterTrack": "Usar pista por capitulo",
|
"LabelUseChapterTrack": "Usar pista por capitulo",
|
||||||
"LabelUseFullTrack": "Usar pista completa",
|
"LabelUseFullTrack": "Usar pista completa",
|
||||||
"LabelUser": "Usuario",
|
"LabelUser": "Usuario",
|
||||||
@ -567,15 +574,15 @@
|
|||||||
"MessageConfirmDeleteBackup": "¿Está seguro de que desea eliminar el respaldo {0}?",
|
"MessageConfirmDeleteBackup": "¿Está seguro de que desea eliminar el respaldo {0}?",
|
||||||
"MessageConfirmDeleteFile": "Esto eliminará el archivo de su sistema de archivos. ¿Está seguro?",
|
"MessageConfirmDeleteFile": "Esto eliminará el archivo de su sistema de archivos. ¿Está seguro?",
|
||||||
"MessageConfirmDeleteLibrary": "¿Está seguro de que desea eliminar permanentemente la biblioteca \"{0}\"?",
|
"MessageConfirmDeleteLibrary": "¿Está seguro de que desea eliminar permanentemente la biblioteca \"{0}\"?",
|
||||||
"MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?",
|
"MessageConfirmDeleteLibraryItem": "Esto removerá la librería de la base de datos y archivos en tu sistema. ¿Estás seguro?",
|
||||||
"MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?",
|
"MessageConfirmDeleteLibraryItems": "Esto removerá {0} elemento(s) de la librería en base de datos y archivos en tu sistema. ¿Estás seguro?",
|
||||||
"MessageConfirmDeleteSession": "¿Está seguro de que desea eliminar esta sesión?",
|
"MessageConfirmDeleteSession": "¿Está seguro de que desea eliminar esta sesión?",
|
||||||
"MessageConfirmForceReScan": "¿Está seguro de que desea forzar un re-escaneo?",
|
"MessageConfirmForceReScan": "¿Está seguro de que desea forzar un re-escaneo?",
|
||||||
"MessageConfirmMarkAllEpisodesFinished": "¿Está seguro de que desea marcar todos los episodios como terminados?",
|
"MessageConfirmMarkAllEpisodesFinished": "¿Está seguro de que desea marcar todos los episodios como terminados?",
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "¿Está seguro de que desea marcar todos los episodios como no terminados?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "¿Está seguro de que desea marcar todos los episodios como no terminados?",
|
||||||
"MessageConfirmMarkSeriesFinished": "¿Está seguro de que desea marcar todos los libros en esta serie como terminados?",
|
"MessageConfirmMarkSeriesFinished": "¿Está seguro de que desea marcar todos los libros en esta serie como terminados?",
|
||||||
"MessageConfirmMarkSeriesNotFinished": "¿Está seguro de que desea marcar todos los libros en esta serie como no terminados?",
|
"MessageConfirmMarkSeriesNotFinished": "¿Está seguro de que desea marcar todos los libros en esta serie como no terminados?",
|
||||||
"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": "¡Advertencia! La integración rápida no realiza copias de seguridad a ninguno de tus archivos de audio. Asegúrate de haber realizado una copia de los mismos previamente. <br><br>¿Deseas continuar?",
|
||||||
"MessageConfirmRemoveAllChapters": "¿Está seguro de que desea remover todos los capitulos?",
|
"MessageConfirmRemoveAllChapters": "¿Está seguro de que desea remover todos los capitulos?",
|
||||||
"MessageConfirmRemoveAuthor": "¿Está seguro de que desea remover el autor \"{0}\"?",
|
"MessageConfirmRemoveAuthor": "¿Está seguro de que desea remover el autor \"{0}\"?",
|
||||||
"MessageConfirmRemoveCollection": "¿Está seguro de que desea remover la colección \"{0}\"?",
|
"MessageConfirmRemoveCollection": "¿Está seguro de que desea remover la colección \"{0}\"?",
|
||||||
@ -590,7 +597,7 @@
|
|||||||
"MessageConfirmRenameTag": "¿Está seguro de que desea renombrar la etiqueta \"{0}\" a \"{1}\" de todos los elementos?",
|
"MessageConfirmRenameTag": "¿Está seguro de que desea renombrar la etiqueta \"{0}\" a \"{1}\" de todos los elementos?",
|
||||||
"MessageConfirmRenameTagMergeNote": "Nota: Esta etiqueta ya existe, por lo que se fusionarán.",
|
"MessageConfirmRenameTagMergeNote": "Nota: Esta etiqueta ya existe, por lo que se fusionarán.",
|
||||||
"MessageConfirmRenameTagWarning": "Advertencia! Una etiqueta similar ya existe \"{0}\".",
|
"MessageConfirmRenameTagWarning": "Advertencia! Una etiqueta similar ya existe \"{0}\".",
|
||||||
"MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?",
|
"MessageConfirmReScanLibraryItems": "¿Estás seguro de querer re escanear {0} elemento(s)?",
|
||||||
"MessageConfirmSendEbookToDevice": "¿Está seguro de que enviar {0} ebook(s) \"{1}\" al dispositivo \"{2}\"?",
|
"MessageConfirmSendEbookToDevice": "¿Está seguro de que enviar {0} ebook(s) \"{1}\" al dispositivo \"{2}\"?",
|
||||||
"MessageDownloadingEpisode": "Descargando Capitulo",
|
"MessageDownloadingEpisode": "Descargando Capitulo",
|
||||||
"MessageDragFilesIntoTrackOrder": "Arrastra los archivos al orden correcto de las pistas.",
|
"MessageDragFilesIntoTrackOrder": "Arrastra los archivos al orden correcto de las pistas.",
|
||||||
@ -661,7 +668,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",
|
"MessageSelected": "{0} seleccionado(s)",
|
||||||
"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}?",
|
||||||
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "Cacher",
|
"ButtonHide": "Cacher",
|
||||||
"ButtonHome": "Accueil",
|
"ButtonHome": "Accueil",
|
||||||
"ButtonIssues": "Parutions",
|
"ButtonIssues": "Parutions",
|
||||||
|
"ButtonJumpBackward": "Jump Backward",
|
||||||
|
"ButtonJumpForward": "Jump Forward",
|
||||||
"ButtonLatest": "Dernière version",
|
"ButtonLatest": "Dernière version",
|
||||||
"ButtonLibrary": "Bibliothèque",
|
"ButtonLibrary": "Bibliothèque",
|
||||||
"ButtonLogout": "Me déconnecter",
|
"ButtonLogout": "Me déconnecter",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "Chercher tous les auteurs",
|
"ButtonMatchAllAuthors": "Chercher tous les auteurs",
|
||||||
"ButtonMatchBooks": "Chercher les livres",
|
"ButtonMatchBooks": "Chercher les livres",
|
||||||
"ButtonNevermind": "Non merci",
|
"ButtonNevermind": "Non merci",
|
||||||
|
"ButtonNextChapter": "Next Chapter",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Ouvrir le flux",
|
"ButtonOpenFeed": "Ouvrir le flux",
|
||||||
"ButtonOpenManager": "Ouvrir le gestionnaire",
|
"ButtonOpenManager": "Ouvrir le gestionnaire",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "Écouter",
|
"ButtonPlay": "Écouter",
|
||||||
"ButtonPlaying": "En lecture",
|
"ButtonPlaying": "En lecture",
|
||||||
"ButtonPlaylists": "Listes de lecture",
|
"ButtonPlaylists": "Listes de lecture",
|
||||||
|
"ButtonPreviousChapter": "Previous Chapter",
|
||||||
"ButtonPurgeAllCache": "Purger le cache",
|
"ButtonPurgeAllCache": "Purger le cache",
|
||||||
"ButtonPurgeItemsCache": "Purger le cache des articles",
|
"ButtonPurgeItemsCache": "Purger le cache des articles",
|
||||||
"ButtonPurgeMediaProgress": "Purger la progression des médias",
|
"ButtonPurgeMediaProgress": "Purger la progression des médias",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "Entrées de la collection",
|
"HeaderCollectionItems": "Entrées de la collection",
|
||||||
"HeaderCover": "Couverture",
|
"HeaderCover": "Couverture",
|
||||||
"HeaderCurrentDownloads": "Téléchargements en cours",
|
"HeaderCurrentDownloads": "Téléchargements en cours",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Détails",
|
"HeaderDetails": "Détails",
|
||||||
"HeaderDownloadQueue": "File d’attente de téléchargements",
|
"HeaderDownloadQueue": "File d’attente de téléchargements",
|
||||||
"HeaderEbookFiles": "Fichier des livres numériques",
|
"HeaderEbookFiles": "Fichier des livres numériques",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Méthode d’écoute",
|
"LabelPlayMethod": "Méthode d’écoute",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
|
"LabelPodcastSearchRegion": "Région de recherche de podcasts",
|
||||||
"LabelPodcastType": "Type de Podcast",
|
"LabelPodcastType": "Type de Podcast",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)",
|
"LabelPrefixesToIgnore": "Préfixes à Ignorer (Insensible à la Casse)",
|
||||||
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "છુપાવો",
|
"ButtonHide": "છુપાવો",
|
||||||
"ButtonHome": "ઘર",
|
"ButtonHome": "ઘર",
|
||||||
"ButtonIssues": "સમસ્યાઓ",
|
"ButtonIssues": "સમસ્યાઓ",
|
||||||
|
"ButtonJumpBackward": "Jump Backward",
|
||||||
|
"ButtonJumpForward": "Jump Forward",
|
||||||
"ButtonLatest": "નવીનતમ",
|
"ButtonLatest": "નવીનતમ",
|
||||||
"ButtonLibrary": "પુસ્તકાલય",
|
"ButtonLibrary": "પુસ્તકાલય",
|
||||||
"ButtonLogout": "લૉગ આઉટ",
|
"ButtonLogout": "લૉગ આઉટ",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "બધા મેળ ખાતા લેખકો શોધો",
|
"ButtonMatchAllAuthors": "બધા મેળ ખાતા લેખકો શોધો",
|
||||||
"ButtonMatchBooks": "મેળ ખાતી પુસ્તકો શોધો",
|
"ButtonMatchBooks": "મેળ ખાતી પુસ્તકો શોધો",
|
||||||
"ButtonNevermind": "કંઈ વાંધો નહીં",
|
"ButtonNevermind": "કંઈ વાંધો નહીં",
|
||||||
|
"ButtonNextChapter": "Next Chapter",
|
||||||
"ButtonOk": "ઓકે",
|
"ButtonOk": "ઓકે",
|
||||||
"ButtonOpenFeed": "ફીડ ખોલો",
|
"ButtonOpenFeed": "ફીડ ખોલો",
|
||||||
"ButtonOpenManager": "મેનેજર ખોલો",
|
"ButtonOpenManager": "મેનેજર ખોલો",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "ચલાવો",
|
"ButtonPlay": "ચલાવો",
|
||||||
"ButtonPlaying": "ચલાવી રહ્યું છે",
|
"ButtonPlaying": "ચલાવી રહ્યું છે",
|
||||||
"ButtonPlaylists": "પ્લેલિસ્ટ",
|
"ButtonPlaylists": "પ્લેલિસ્ટ",
|
||||||
|
"ButtonPreviousChapter": "Previous Chapter",
|
||||||
"ButtonPurgeAllCache": "બધો Cache કાઢી નાખો",
|
"ButtonPurgeAllCache": "બધો Cache કાઢી નાખો",
|
||||||
"ButtonPurgeItemsCache": "વસ્તુઓનો Cache કાઢી નાખો",
|
"ButtonPurgeItemsCache": "વસ્તુઓનો Cache કાઢી નાખો",
|
||||||
"ButtonPurgeMediaProgress": "બધું સાંભળ્યું કાઢી નાખો",
|
"ButtonPurgeMediaProgress": "બધું સાંભળ્યું કાઢી નાખો",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "સંગ્રહ વસ્તુઓ",
|
"HeaderCollectionItems": "સંગ્રહ વસ્તુઓ",
|
||||||
"HeaderCover": "આવરણ",
|
"HeaderCover": "આવરણ",
|
||||||
"HeaderCurrentDownloads": "વર્તમાન ડાઉનલોડ્સ",
|
"HeaderCurrentDownloads": "વર્તમાન ડાઉનલોડ્સ",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "વિગતો",
|
"HeaderDetails": "વિગતો",
|
||||||
"HeaderDownloadQueue": "ડાઉનલોડ કતાર",
|
"HeaderDownloadQueue": "ડાઉનલોડ કતાર",
|
||||||
"HeaderEbookFiles": "ઇબુક ફાઇલો",
|
"HeaderEbookFiles": "ઇબુક ફાઇલો",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Play Method",
|
"LabelPlayMethod": "Play Method",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
|
"LabelPodcastSearchRegion": "પોડકાસ્ટ શોધ પ્રદેશ",
|
||||||
"LabelPodcastType": "Podcast Type",
|
"LabelPodcastType": "Podcast Type",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
|
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
|
||||||
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "छुपाएं",
|
"ButtonHide": "छुपाएं",
|
||||||
"ButtonHome": "घर",
|
"ButtonHome": "घर",
|
||||||
"ButtonIssues": "समस्याएं",
|
"ButtonIssues": "समस्याएं",
|
||||||
|
"ButtonJumpBackward": "Jump Backward",
|
||||||
|
"ButtonJumpForward": "Jump Forward",
|
||||||
"ButtonLatest": "नवीनतम",
|
"ButtonLatest": "नवीनतम",
|
||||||
"ButtonLibrary": "पुस्तकालय",
|
"ButtonLibrary": "पुस्तकालय",
|
||||||
"ButtonLogout": "लॉग आउट",
|
"ButtonLogout": "लॉग आउट",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "सभी लेखकों को तलाश करें",
|
"ButtonMatchAllAuthors": "सभी लेखकों को तलाश करें",
|
||||||
"ButtonMatchBooks": "संबंधित पुस्तकों का मिलान करें",
|
"ButtonMatchBooks": "संबंधित पुस्तकों का मिलान करें",
|
||||||
"ButtonNevermind": "कोई बात नहीं",
|
"ButtonNevermind": "कोई बात नहीं",
|
||||||
|
"ButtonNextChapter": "Next Chapter",
|
||||||
"ButtonOk": "ठीक है",
|
"ButtonOk": "ठीक है",
|
||||||
"ButtonOpenFeed": "फ़ीड खोलें",
|
"ButtonOpenFeed": "फ़ीड खोलें",
|
||||||
"ButtonOpenManager": "मैनेजर खोलें",
|
"ButtonOpenManager": "मैनेजर खोलें",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "चलाएँ",
|
"ButtonPlay": "चलाएँ",
|
||||||
"ButtonPlaying": "चल रही है",
|
"ButtonPlaying": "चल रही है",
|
||||||
"ButtonPlaylists": "प्लेलिस्ट्स",
|
"ButtonPlaylists": "प्लेलिस्ट्स",
|
||||||
|
"ButtonPreviousChapter": "Previous Chapter",
|
||||||
"ButtonPurgeAllCache": "सभी Cache मिटाएं",
|
"ButtonPurgeAllCache": "सभी Cache मिटाएं",
|
||||||
"ButtonPurgeItemsCache": "आइटम Cache मिटाएं",
|
"ButtonPurgeItemsCache": "आइटम Cache मिटाएं",
|
||||||
"ButtonPurgeMediaProgress": "अभी तक सुना हुआ सब हटा दे",
|
"ButtonPurgeMediaProgress": "अभी तक सुना हुआ सब हटा दे",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "Collection Items",
|
"HeaderCollectionItems": "Collection Items",
|
||||||
"HeaderCover": "Cover",
|
"HeaderCover": "Cover",
|
||||||
"HeaderCurrentDownloads": "Current Downloads",
|
"HeaderCurrentDownloads": "Current Downloads",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Details",
|
"HeaderDetails": "Details",
|
||||||
"HeaderDownloadQueue": "Download Queue",
|
"HeaderDownloadQueue": "Download Queue",
|
||||||
"HeaderEbookFiles": "Ebook Files",
|
"HeaderEbookFiles": "Ebook Files",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Play Method",
|
"LabelPlayMethod": "Play Method",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
|
"LabelPodcastSearchRegion": "पॉडकास्ट खोज क्षेत्र",
|
||||||
"LabelPodcastType": "Podcast Type",
|
"LabelPodcastType": "Podcast Type",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
|
"LabelPrefixesToIgnore": "Prefixes to Ignore (case insensitive)",
|
||||||
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "Sakrij",
|
"ButtonHide": "Sakrij",
|
||||||
"ButtonHome": "Početna stranica",
|
"ButtonHome": "Početna stranica",
|
||||||
"ButtonIssues": "Problemi",
|
"ButtonIssues": "Problemi",
|
||||||
|
"ButtonJumpBackward": "Jump Backward",
|
||||||
|
"ButtonJumpForward": "Jump Forward",
|
||||||
"ButtonLatest": "Najnovije",
|
"ButtonLatest": "Najnovije",
|
||||||
"ButtonLibrary": "Biblioteka",
|
"ButtonLibrary": "Biblioteka",
|
||||||
"ButtonLogout": "Odjavi se",
|
"ButtonLogout": "Odjavi se",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "Matchaj sve autore",
|
"ButtonMatchAllAuthors": "Matchaj sve autore",
|
||||||
"ButtonMatchBooks": "Matchaj knjige",
|
"ButtonMatchBooks": "Matchaj knjige",
|
||||||
"ButtonNevermind": "Nije bitno",
|
"ButtonNevermind": "Nije bitno",
|
||||||
|
"ButtonNextChapter": "Next Chapter",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Otvori feed",
|
"ButtonOpenFeed": "Otvori feed",
|
||||||
"ButtonOpenManager": "Otvori menadžera",
|
"ButtonOpenManager": "Otvori menadžera",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "Pokreni",
|
"ButtonPlay": "Pokreni",
|
||||||
"ButtonPlaying": "Playing",
|
"ButtonPlaying": "Playing",
|
||||||
"ButtonPlaylists": "Playlists",
|
"ButtonPlaylists": "Playlists",
|
||||||
|
"ButtonPreviousChapter": "Previous Chapter",
|
||||||
"ButtonPurgeAllCache": "Isprazni sav cache",
|
"ButtonPurgeAllCache": "Isprazni sav cache",
|
||||||
"ButtonPurgeItemsCache": "Isprazni Items Cache",
|
"ButtonPurgeItemsCache": "Isprazni Items Cache",
|
||||||
"ButtonPurgeMediaProgress": "Purge Media Progress",
|
"ButtonPurgeMediaProgress": "Purge Media Progress",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "Stvari u kolekciji",
|
"HeaderCollectionItems": "Stvari u kolekciji",
|
||||||
"HeaderCover": "Cover",
|
"HeaderCover": "Cover",
|
||||||
"HeaderCurrentDownloads": "Current Downloads",
|
"HeaderCurrentDownloads": "Current Downloads",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Detalji",
|
"HeaderDetails": "Detalji",
|
||||||
"HeaderDownloadQueue": "Download Queue",
|
"HeaderDownloadQueue": "Download Queue",
|
||||||
"HeaderEbookFiles": "Ebook Files",
|
"HeaderEbookFiles": "Ebook Files",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Vrsta reprodukcije",
|
"LabelPlayMethod": "Vrsta reprodukcije",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
|
"LabelPodcastSearchRegion": "Područje pretrage podcasta",
|
||||||
"LabelPodcastType": "Podcast Type",
|
"LabelPodcastType": "Podcast Type",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Prefiksi za ignorirati (mala i velika slova nisu bitna)",
|
"LabelPrefixesToIgnore": "Prefiksi za ignorirati (mala i velika slova nisu bitna)",
|
||||||
|
768
client/strings/hu.json
Normal file
768
client/strings/hu.json
Normal file
@ -0,0 +1,768 @@
|
|||||||
|
{
|
||||||
|
"ButtonAdd": "Hozzáadás",
|
||||||
|
"ButtonAddChapters": "Fejezetek hozzáadása",
|
||||||
|
"ButtonAddDevice": "Eszköz hozzáadása",
|
||||||
|
"ButtonAddLibrary": "Könyvtár hozzáadása",
|
||||||
|
"ButtonAddPodcasts": "Podcastok hozzáadása",
|
||||||
|
"ButtonAddUser": "Felhasználó hozzáadása",
|
||||||
|
"ButtonAddYourFirstLibrary": "Az első könyvtár hozzáadása",
|
||||||
|
"ButtonApply": "Alkalmaz",
|
||||||
|
"ButtonApplyChapters": "Fejezetek alkalmazása",
|
||||||
|
"ButtonAuthors": "Szerzők",
|
||||||
|
"ButtonBrowseForFolder": "Mappa keresése",
|
||||||
|
"ButtonCancel": "Mégse",
|
||||||
|
"ButtonCancelEncode": "Kódolás megszakítása",
|
||||||
|
"ButtonChangeRootPassword": "Gyökérjelszó megváltoztatása",
|
||||||
|
"ButtonCheckAndDownloadNewEpisodes": "Új epizódok ellenőrzése és letöltése",
|
||||||
|
"ButtonChooseAFolder": "Válassz egy mappát",
|
||||||
|
"ButtonChooseFiles": "Fájlok kiválasztása",
|
||||||
|
"ButtonClearFilter": "Szűrő törlése",
|
||||||
|
"ButtonCloseFeed": "Hírcsatorna bezárása",
|
||||||
|
"ButtonCollections": "Gyűjtemény",
|
||||||
|
"ButtonConfigureScanner": "Szkenner konfigurálása",
|
||||||
|
"ButtonCreate": "Létrehozás",
|
||||||
|
"ButtonCreateBackup": "Biztonsági másolat készítése",
|
||||||
|
"ButtonDelete": "Törlés",
|
||||||
|
"ButtonDownloadQueue": "Sor",
|
||||||
|
"ButtonEdit": "Szerkesztés",
|
||||||
|
"ButtonEditChapters": "Fejezetek szerkesztése",
|
||||||
|
"ButtonEditPodcast": "Podcast szerkesztése",
|
||||||
|
"ButtonForceReScan": "Újraszkennelés kényszerítése",
|
||||||
|
"ButtonFullPath": "Teljes útvonal",
|
||||||
|
"ButtonHide": "Elrejtés",
|
||||||
|
"ButtonHome": "Kezdőlap",
|
||||||
|
"ButtonIssues": "Problémák",
|
||||||
|
"ButtonJumpBackward": "Ugrás vissza",
|
||||||
|
"ButtonJumpForward": "Ugrás előre",
|
||||||
|
"ButtonLatest": "Legújabb",
|
||||||
|
"ButtonLibrary": "Könyvtár",
|
||||||
|
"ButtonLogout": "Kijelentkezés",
|
||||||
|
"ButtonLookup": "Keresés",
|
||||||
|
"ButtonManageTracks": "Sávok kezelése",
|
||||||
|
"ButtonMapChapterTitles": "Fejezetcímek hozzárendelése",
|
||||||
|
"ButtonMatchAllAuthors": "Minden szerző egyeztetése",
|
||||||
|
"ButtonMatchBooks": "Könyvek egyeztetése",
|
||||||
|
"ButtonNevermind": "Mindegy",
|
||||||
|
"ButtonNextChapter": "Következő fejezet",
|
||||||
|
"ButtonOk": "Oké",
|
||||||
|
"ButtonOpenFeed": "Hírcsatorna megnyitása",
|
||||||
|
"ButtonOpenManager": "Kezelő megnyitása",
|
||||||
|
"ButtonPause": "Szünet",
|
||||||
|
"ButtonPlay": "Lejátszás",
|
||||||
|
"ButtonPlaying": "Lejátszás folyamatban",
|
||||||
|
"ButtonPlaylists": "Lejátszási listák",
|
||||||
|
"ButtonPreviousChapter": "Előző fejezet",
|
||||||
|
"ButtonPurgeAllCache": "Összes gyorsítótár törlése",
|
||||||
|
"ButtonPurgeItemsCache": "Elemek gyorsítótárának törlése",
|
||||||
|
"ButtonPurgeMediaProgress": "Médialejátszás állapotának törlése",
|
||||||
|
"ButtonQueueAddItem": "Hozzáadás a sorhoz",
|
||||||
|
"ButtonQueueRemoveItem": "Eltávolítás a sorból",
|
||||||
|
"ButtonQuickMatch": "Gyors egyeztetés",
|
||||||
|
"ButtonRead": "Olvasás",
|
||||||
|
"ButtonRemove": "Eltávolítás",
|
||||||
|
"ButtonRemoveAll": "Összes eltávolítása",
|
||||||
|
"ButtonRemoveAllLibraryItems": "Összes könyvtárelem eltávolítása",
|
||||||
|
"ButtonRemoveFromContinueListening": "Eltávolítás a Folytatás hallgatásából",
|
||||||
|
"ButtonRemoveFromContinueReading": "Eltávolítás a Folytatás olvasásából",
|
||||||
|
"ButtonRemoveSeriesFromContinueSeries": "Sorozat eltávolítása a Folytatás sorozatokból",
|
||||||
|
"ButtonReScan": "Újraszkennelés",
|
||||||
|
"ButtonReset": "Visszaállítás",
|
||||||
|
"ButtonResetToDefault": "Alapértelmezésre állítás",
|
||||||
|
"ButtonRestore": "Visszaállítás",
|
||||||
|
"ButtonSave": "Mentés",
|
||||||
|
"ButtonSaveAndClose": "Mentés és bezárás",
|
||||||
|
"ButtonSaveTracklist": "Sávlista mentése",
|
||||||
|
"ButtonScan": "Szkennelés",
|
||||||
|
"ButtonScanLibrary": "Könyvtár szkennelése",
|
||||||
|
"ButtonSearch": "Keresés",
|
||||||
|
"ButtonSelectFolderPath": "Mappa útvonalának kiválasztása",
|
||||||
|
"ButtonSeries": "Sorozatok",
|
||||||
|
"ButtonSetChaptersFromTracks": "Fejezetek beállítása sávokból",
|
||||||
|
"ButtonShiftTimes": "Idők eltolása",
|
||||||
|
"ButtonShow": "Megjelenítés",
|
||||||
|
"ButtonStartM4BEncode": "M4B kódolás indítása",
|
||||||
|
"ButtonStartMetadataEmbed": "Metaadatok beágyazásának indítása",
|
||||||
|
"ButtonSubmit": "Beküldés",
|
||||||
|
"ButtonTest": "Teszt",
|
||||||
|
"ButtonUpload": "Feltöltés",
|
||||||
|
"ButtonUploadBackup": "Biztonsági másolat feltöltése",
|
||||||
|
"ButtonUploadCover": "Borító feltöltése",
|
||||||
|
"ButtonUploadOPMLFile": "OPML fájl feltöltése",
|
||||||
|
"ButtonUserDelete": "Felhasználó törlése {0}",
|
||||||
|
"ButtonUserEdit": "Felhasználó szerkesztése {0}",
|
||||||
|
"ButtonViewAll": "Összes megtekintése",
|
||||||
|
"ButtonYes": "Igen",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Hiba a metaadatok lekérésekor",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Nem sikerült a metaadatok lekérése - próbálja meg frissíteni a címet és/vagy a szerzőt",
|
||||||
|
"ErrorUploadLacksTitle": "Cím szükséges",
|
||||||
|
"HeaderAccount": "Fiók",
|
||||||
|
"HeaderAdvanced": "Haladó",
|
||||||
|
"HeaderAppriseNotificationSettings": "Apprise értesítési beállítások",
|
||||||
|
"HeaderAudiobookTools": "Hangoskönyv fájlkezelő eszközök",
|
||||||
|
"HeaderAudioTracks": "Audiósávok",
|
||||||
|
"HeaderAuthentication": "Hitelesítés",
|
||||||
|
"HeaderBackups": "Biztonsági másolatok",
|
||||||
|
"HeaderChangePassword": "Jelszó megváltoztatása",
|
||||||
|
"HeaderChapters": "Fejezetek",
|
||||||
|
"HeaderChooseAFolder": "Válasszon egy mappát",
|
||||||
|
"HeaderCollection": "Gyűjtemény",
|
||||||
|
"HeaderCollectionItems": "Gyűjtemény elemek",
|
||||||
|
"HeaderCover": "Borító",
|
||||||
|
"HeaderCurrentDownloads": "Jelenlegi letöltések",
|
||||||
|
"HeaderCustomMetadataProviders": "Egyéni metaadat-szolgáltatók",
|
||||||
|
"HeaderDetails": "Részletek",
|
||||||
|
"HeaderDownloadQueue": "Letöltési sor",
|
||||||
|
"HeaderEbookFiles": "E-könyv fájlok",
|
||||||
|
"HeaderEmail": "E-mail",
|
||||||
|
"HeaderEmailSettings": "E-mail beállítások",
|
||||||
|
"HeaderEpisodes": "Epizódok",
|
||||||
|
"HeaderEreaderDevices": "E-olvasó eszközök",
|
||||||
|
"HeaderEreaderSettings": "E-olvasó beállítások",
|
||||||
|
"HeaderFiles": "Fájlok",
|
||||||
|
"HeaderFindChapters": "Fejezetek keresése",
|
||||||
|
"HeaderIgnoredFiles": "Figyelmen kívül hagyott fájlok",
|
||||||
|
"HeaderItemFiles": "Elemfájlok",
|
||||||
|
"HeaderItemMetadataUtils": "Elem metaadat eszközök",
|
||||||
|
"HeaderLastListeningSession": "Utolsó hallgatási munkamenet",
|
||||||
|
"HeaderLatestEpisodes": "Legújabb epizódok",
|
||||||
|
"HeaderLibraries": "Könyvtárak",
|
||||||
|
"HeaderLibraryFiles": "Könyvtárfájlok",
|
||||||
|
"HeaderLibraryStats": "Könyvtár statisztikák",
|
||||||
|
"HeaderListeningSessions": "Hallgatási munkamenetek",
|
||||||
|
"HeaderListeningStats": "Hallgatási statisztikák",
|
||||||
|
"HeaderLogin": "Bejelentkezés",
|
||||||
|
"HeaderLogs": "Naplók",
|
||||||
|
"HeaderManageGenres": "Műfajok kezelése",
|
||||||
|
"HeaderManageTags": "Címkék kezelése",
|
||||||
|
"HeaderMapDetails": "Részletek hozzárendelése",
|
||||||
|
"HeaderMatch": "Egyeztetés",
|
||||||
|
"HeaderMetadataOrderOfPrecedence": "Metaadatok előnyben részesítési sorrendje",
|
||||||
|
"HeaderMetadataToEmbed": "Beágyazandó metaadatok",
|
||||||
|
"HeaderNewAccount": "Új fiók",
|
||||||
|
"HeaderNewLibrary": "Új könyvtár",
|
||||||
|
"HeaderNotifications": "Értesítések",
|
||||||
|
"HeaderOpenIDConnectAuthentication": "OpenID Connect hitelesítés",
|
||||||
|
"HeaderOpenRSSFeed": "RSS hírcsatorna megnyitása",
|
||||||
|
"HeaderOtherFiles": "Egyéb fájlok",
|
||||||
|
"HeaderPasswordAuthentication": "Jelszó hitelesítés",
|
||||||
|
"HeaderPermissions": "Engedélyek",
|
||||||
|
"HeaderPlayerQueue": "Lejátszó sor",
|
||||||
|
"HeaderPlaylist": "Lejátszási lista",
|
||||||
|
"HeaderPlaylistItems": "Lejátszási lista elemek",
|
||||||
|
"HeaderPodcastsToAdd": "Hozzáadandó podcastok",
|
||||||
|
"HeaderPreviewCover": "Borító előnézete",
|
||||||
|
"HeaderRemoveEpisode": "Epizód eltávolítása",
|
||||||
|
"HeaderRemoveEpisodes": "{0} epizód eltávolítása",
|
||||||
|
"HeaderRSSFeedGeneral": "RSS részletek",
|
||||||
|
"HeaderRSSFeedIsOpen": "RSS hírcsatorna nyitva",
|
||||||
|
"HeaderRSSFeeds": "RSS hírcsatornák",
|
||||||
|
"HeaderSavedMediaProgress": "Mentett médialejátszási állapot",
|
||||||
|
"HeaderSchedule": "Ütemezés",
|
||||||
|
"HeaderScheduleLibraryScans": "Könyvtárak automatikus szkennelésének ütemezése",
|
||||||
|
"HeaderSession": "Munkamenet",
|
||||||
|
"HeaderSetBackupSchedule": "Biztonsági másolatok ütemezésének beállítása",
|
||||||
|
"HeaderSettings": "Beállítások",
|
||||||
|
"HeaderSettingsDisplay": "Kijelző",
|
||||||
|
"HeaderSettingsExperimental": "Kísérleti funkciók",
|
||||||
|
"HeaderSettingsGeneral": "Általános",
|
||||||
|
"HeaderSettingsScanner": "Szkenner",
|
||||||
|
"HeaderSleepTimer": "Alvásidőzítő",
|
||||||
|
"HeaderStatsLargestItems": "Legnagyobb elemek",
|
||||||
|
"HeaderStatsLongestItems": "Leghosszabb elemek (órákban)",
|
||||||
|
"HeaderStatsMinutesListeningChart": "Hallgatási percek (az utolsó 7 napban)",
|
||||||
|
"HeaderStatsRecentSessions": "Legutóbbi munkamenetek",
|
||||||
|
"HeaderStatsTop10Authors": "Top 10 szerzők",
|
||||||
|
"HeaderStatsTop5Genres": "Top 5 műfajok",
|
||||||
|
"HeaderTableOfContents": "Tartalomjegyzék",
|
||||||
|
"HeaderTools": "Eszközök",
|
||||||
|
"HeaderUpdateAccount": "Fiók frissítése",
|
||||||
|
"HeaderUpdateAuthor": "Szerző frissítése",
|
||||||
|
"HeaderUpdateDetails": "Részletek frissítése",
|
||||||
|
"HeaderUpdateLibrary": "Könyvtár frissítése",
|
||||||
|
"HeaderUsers": "Felhasználók",
|
||||||
|
"HeaderYourStats": "Saját statisztikák",
|
||||||
|
"LabelAbridged": "Tömörített",
|
||||||
|
"LabelAccountType": "Fióktípus",
|
||||||
|
"LabelAccountTypeAdmin": "Admin",
|
||||||
|
"LabelAccountTypeGuest": "Vendég",
|
||||||
|
"LabelAccountTypeUser": "Felhasználó",
|
||||||
|
"LabelActivity": "Tevékenység",
|
||||||
|
"LabelAdded": "Hozzáadva",
|
||||||
|
"LabelAddedAt": "Hozzáadás ideje",
|
||||||
|
"LabelAddToCollection": "Hozzáadás a gyűjteményhez",
|
||||||
|
"LabelAddToCollectionBatch": "{0} könyv hozzáadása a gyűjteményhez",
|
||||||
|
"LabelAddToPlaylist": "Hozzáadás a lejátszási listához",
|
||||||
|
"LabelAddToPlaylistBatch": "{0} elem hozzáadása a lejátszási listához",
|
||||||
|
"LabelAdminUsersOnly": "Csak admin felhasználók",
|
||||||
|
"LabelAll": "Minden",
|
||||||
|
"LabelAllUsers": "Minden felhasználó",
|
||||||
|
"LabelAllUsersExcludingGuests": "Minden felhasználó, vendégek kivételével",
|
||||||
|
"LabelAllUsersIncludingGuests": "Minden felhasználó, beleértve a vendégeket is",
|
||||||
|
"LabelAlreadyInYourLibrary": "Már a könyvtárában van",
|
||||||
|
"LabelAppend": "Hozzáfűzés",
|
||||||
|
"LabelAuthor": "Szerző",
|
||||||
|
"LabelAuthorFirstLast": "Szerző (Keresztnév Vezetéknév)",
|
||||||
|
"LabelAuthorLastFirst": "Szerző (Vezetéknév, Keresztnév)",
|
||||||
|
"LabelAuthors": "Szerzők",
|
||||||
|
"LabelAutoDownloadEpisodes": "Epizódok automatikus letöltése",
|
||||||
|
"LabelAutoFetchMetadata": "Metaadatok automatikus lekérése",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Cím, szerző és sorozat metaadatok automatikus lekérése a feltöltés megkönnyítése érdekében. További metaadatok egyeztetése szükséges lehet a feltöltés után.",
|
||||||
|
"LabelAutoLaunch": "Automatikus indítás",
|
||||||
|
"LabelAutoLaunchDescription": "Automatikus átirányítás az hitelesítő szolgáltatóhoz a bejelentkezési oldalra navigáláskor (kézi felülbírálás útvonala <code>/login?autoLaunch=0</code>)",
|
||||||
|
"LabelAutoRegister": "Automatikus regisztráció",
|
||||||
|
"LabelAutoRegisterDescription": "Új felhasználók automatikus létrehozása bejelentkezés után",
|
||||||
|
"LabelBackToUser": "Vissza a felhasználóhoz",
|
||||||
|
"LabelBackupLocation": "Biztonsági másolat helye",
|
||||||
|
"LabelBackupsEnableAutomaticBackups": "Automatikus biztonsági másolatok engedélyezése",
|
||||||
|
"LabelBackupsEnableAutomaticBackupsHelp": "Biztonsági másolatok mentése a /metadata/backups mappába",
|
||||||
|
"LabelBackupsMaxBackupSize": "Maximális biztonsági másolat méret (GB-ban)",
|
||||||
|
"LabelBackupsMaxBackupSizeHelp": "A rossz konfiguráció elleni védelem érdekében a biztonsági másolatok meghiúsulnak, ha meghaladják a beállított méretet.",
|
||||||
|
"LabelBackupsNumberToKeep": "Megtartandó biztonsági másolatok száma",
|
||||||
|
"LabelBackupsNumberToKeepHelp": "Egyszerre csak 1 biztonsági másolat kerül eltávolításra, tehát ha már több biztonsági másolat van, mint ez a szám, akkor manuálisan kell eltávolítani őket.",
|
||||||
|
"LabelBitrate": "Bitráta",
|
||||||
|
"LabelBooks": "Könyvek",
|
||||||
|
"LabelButtonText": "Gomb szövege",
|
||||||
|
"LabelChangePassword": "Jelszó megváltoztatása",
|
||||||
|
"LabelChannels": "Csatornák",
|
||||||
|
"LabelChapters": "Fejezetek",
|
||||||
|
"LabelChaptersFound": "fejezet található",
|
||||||
|
"LabelChapterTitle": "Fejezet címe",
|
||||||
|
"LabelClickForMoreInfo": "További információkért kattintson",
|
||||||
|
"LabelClosePlayer": "Lejátszó bezárása",
|
||||||
|
"LabelCodec": "Kodek",
|
||||||
|
"LabelCollapseSeries": "Sorozat összecsukása",
|
||||||
|
"LabelCollection": "Gyűjtemény",
|
||||||
|
"LabelCollections": "Gyűjtemények",
|
||||||
|
"LabelComplete": "Teljes",
|
||||||
|
"LabelConfirmPassword": "Jelszó megerősítése",
|
||||||
|
"LabelContinueListening": "Hallgatás folytatása",
|
||||||
|
"LabelContinueReading": "Olvasás folytatása",
|
||||||
|
"LabelContinueSeries": "Sorozat folytatása",
|
||||||
|
"LabelCover": "Borító",
|
||||||
|
"LabelCoverImageURL": "Borítókép URL",
|
||||||
|
"LabelCreatedAt": "Létrehozás ideje",
|
||||||
|
"LabelCronExpression": "Cron kifejezés",
|
||||||
|
"LabelCurrent": "Jelenlegi",
|
||||||
|
"LabelCurrently": "Jelenleg:",
|
||||||
|
"LabelCustomCronExpression": "Egyéni Cron kifejezés:",
|
||||||
|
"LabelDatetime": "Dátumidő",
|
||||||
|
"LabelDeleteFromFileSystemCheckbox": "Törlés a fájlrendszerről (ne jelölje be, ha csak az adatbázisból szeretné eltávolítani)",
|
||||||
|
"LabelDescription": "Leírás",
|
||||||
|
"LabelDeselectAll": "Minden kijelölés megszüntetése",
|
||||||
|
"LabelDevice": "Eszköz",
|
||||||
|
"LabelDeviceInfo": "Eszköz információ",
|
||||||
|
"LabelDeviceIsAvailableTo": "Eszköz elérhető a következő számára...",
|
||||||
|
"LabelDirectory": "Könyvtár",
|
||||||
|
"LabelDiscFromFilename": "Lemez a fájlnévből",
|
||||||
|
"LabelDiscFromMetadata": "Lemez a metaadatokból",
|
||||||
|
"LabelDiscover": "Felfedezés",
|
||||||
|
"LabelDownload": "Letöltés",
|
||||||
|
"LabelDownloadNEpisodes": "{0} epizód letöltése",
|
||||||
|
"LabelDuration": "Időtartam",
|
||||||
|
"LabelDurationFound": "Megtalált időtartam:",
|
||||||
|
"LabelEbook": "E-könyv",
|
||||||
|
"LabelEbooks": "E-könyvek",
|
||||||
|
"LabelEdit": "Szerkesztés",
|
||||||
|
"LabelEmail": "E-mail",
|
||||||
|
"LabelEmailSettingsFromAddress": "Feladó címe",
|
||||||
|
"LabelEmailSettingsSecure": "Biztonságos",
|
||||||
|
"LabelEmailSettingsSecureHelp": "Ha igaz, a kapcsolat TLS-t használ a szerverhez való csatlakozáskor. Ha hamis, akkor TLS-t használ, ha a szerver támogatja a STARTTLS kiterjesztést. A legtöbb esetben állítsa ezt az értéket igazra, ha a 465-ös portra csatlakozik. A 587-es vagy 25-ös port esetében tartsa hamis értéken. (a nodemailer.com/smtp/#authentication oldalról)",
|
||||||
|
"LabelEmailSettingsTestAddress": "Teszt cím",
|
||||||
|
"LabelEmbeddedCover": "Beágyazott borító",
|
||||||
|
"LabelEnable": "Engedélyezés",
|
||||||
|
"LabelEnd": "Vége",
|
||||||
|
"LabelEpisode": "Epizód",
|
||||||
|
"LabelEpisodeTitle": "Epizód címe",
|
||||||
|
"LabelEpisodeType": "Epizód típusa",
|
||||||
|
"LabelExample": "Példa",
|
||||||
|
"LabelExplicit": "Explicit",
|
||||||
|
"LabelFeedURL": "Hírcsatorna URL",
|
||||||
|
"LabelFetchingMetadata": "Metaadatok lekérése",
|
||||||
|
"LabelFile": "Fájl",
|
||||||
|
"LabelFileBirthtime": "Fájl létrehozásának ideje",
|
||||||
|
"LabelFileModified": "Fájl módosításának ideje",
|
||||||
|
"LabelFilename": "Fájlnév",
|
||||||
|
"LabelFilterByUser": "Szűrés felhasználó szerint",
|
||||||
|
"LabelFindEpisodes": "Epizódok keresése",
|
||||||
|
"LabelFinished": "Befejezett",
|
||||||
|
"LabelFolder": "Mappa",
|
||||||
|
"LabelFolders": "Mappák",
|
||||||
|
"LabelFontBold": "Félkövér",
|
||||||
|
"LabelFontFamily": "Betűtípus család",
|
||||||
|
"LabelFontItalic": "Dőlt",
|
||||||
|
"LabelFontScale": "Betűméret skála",
|
||||||
|
"LabelFontStrikethrough": "Áthúzott",
|
||||||
|
"LabelFormat": "Formátum",
|
||||||
|
"LabelGenre": "Műfaj",
|
||||||
|
"LabelGenres": "Műfajok",
|
||||||
|
"LabelHardDeleteFile": "Fájl végleges törlése",
|
||||||
|
"LabelHasEbook": "Van e-könyve",
|
||||||
|
"LabelHasSupplementaryEbook": "Van kiegészítő e-könyve",
|
||||||
|
"LabelHighestPriority": "Legmagasabb prioritás",
|
||||||
|
"LabelHost": "Hoszt",
|
||||||
|
"LabelHour": "Óra",
|
||||||
|
"LabelIcon": "Ikon",
|
||||||
|
"LabelImageURLFromTheWeb": "Kép URL a weben",
|
||||||
|
"LabelIncludeInTracklist": "Beleértve a sávlistába",
|
||||||
|
"LabelIncomplete": "Befejezetlen",
|
||||||
|
"LabelInProgress": "Folyamatban",
|
||||||
|
"LabelInterval": "Intervallum",
|
||||||
|
"LabelIntervalCustomDailyWeekly": "Egyéni napi/heti",
|
||||||
|
"LabelIntervalEvery12Hours": "Minden 12 órában",
|
||||||
|
"LabelIntervalEvery15Minutes": "Minden 15 percben",
|
||||||
|
"LabelIntervalEvery2Hours": "Minden 2 órában",
|
||||||
|
"LabelIntervalEvery30Minutes": "Minden 30 percben",
|
||||||
|
"LabelIntervalEvery6Hours": "Minden 6 órában",
|
||||||
|
"LabelIntervalEveryDay": "Minden nap",
|
||||||
|
"LabelIntervalEveryHour": "Minden órában",
|
||||||
|
"LabelInvalidParts": "Érvénytelen részek",
|
||||||
|
"LabelInvert": "Megfordítás",
|
||||||
|
"LabelItem": "Elem",
|
||||||
|
"LabelLanguage": "Nyelv",
|
||||||
|
"LabelLanguageDefaultServer": "Szerver alapértelmezett nyelve",
|
||||||
|
"LabelLastBookAdded": "Utolsó hozzáadott könyv",
|
||||||
|
"LabelLastBookUpdated": "Utolsó frissített könyv",
|
||||||
|
"LabelLastSeen": "Utolsó látogatás",
|
||||||
|
"LabelLastTime": "Utolsó alkalom",
|
||||||
|
"LabelLastUpdate": "Utolsó frissítés",
|
||||||
|
"LabelLayout": "Elrendezés",
|
||||||
|
"LabelLayoutSinglePage": "Egyoldalas",
|
||||||
|
"LabelLayoutSplitPage": "Kétoldalas",
|
||||||
|
"LabelLess": "Kevesebb",
|
||||||
|
"LabelLibrariesAccessibleToUser": "A felhasználó számára elérhető könyvtárak",
|
||||||
|
"LabelLibrary": "Könyvtár",
|
||||||
|
"LabelLibraryItem": "Könyvtári elem",
|
||||||
|
"LabelLibraryName": "Könyvtár neve",
|
||||||
|
"LabelLimit": "Korlát",
|
||||||
|
"LabelLineSpacing": "Sorköz",
|
||||||
|
"LabelListenAgain": "Újrahallgatás",
|
||||||
|
"LabelLogLevelDebug": "Debug",
|
||||||
|
"LabelLogLevelInfo": "Információ",
|
||||||
|
"LabelLogLevelWarn": "Figyelmeztetés",
|
||||||
|
"LabelLookForNewEpisodesAfterDate": "Új epizódok keresése ezen a dátum után",
|
||||||
|
"LabelLowestPriority": "Legalacsonyabb prioritás",
|
||||||
|
"LabelMatchExistingUsersBy": "Meglévő felhasználók egyeztetése",
|
||||||
|
"LabelMatchExistingUsersByDescription": "Meglévő felhasználók összekapcsolására használt. Egyszer összekapcsolva, a felhasználók egyedülálló azonosítóval lesznek egyeztetve az Ön SSO szolgáltatójától",
|
||||||
|
"LabelMediaPlayer": "Médialejátszó",
|
||||||
|
"LabelMediaType": "Média típus",
|
||||||
|
"LabelMetadataOrderOfPrecedenceDescription": "A magasabb prioritású metaadat-források felülírják az alacsonyabb prioritásúakat",
|
||||||
|
"LabelMetadataProvider": "Metaadat-szolgáltató",
|
||||||
|
"LabelMetaTag": "Meta címke",
|
||||||
|
"LabelMetaTags": "Meta címkék",
|
||||||
|
"LabelMinute": "Perc",
|
||||||
|
"LabelMissing": "Hiányzó",
|
||||||
|
"LabelMissingParts": "Hiányzó részek",
|
||||||
|
"LabelMobileRedirectURIs": "Engedélyezett mobil átirányítási URI-k",
|
||||||
|
"LabelMobileRedirectURIsDescription": "Ez egy fehérlista az érvényes mobilalkalmazás-átirányítási URI-k számára. Az alapértelmezett <code>audiobookshelf://oauth</code>, amely eltávolítható vagy kiegészíthető további URI-kkal harmadik féltől származó alkalmazásintegráció érdekében. Ha az egyetlen bejegyzés egy csillag (<code>*</code>), akkor bármely URI engedélyezett.",
|
||||||
|
"LabelMore": "Több",
|
||||||
|
"LabelMoreInfo": "További információ",
|
||||||
|
"LabelName": "Név",
|
||||||
|
"LabelNarrator": "Előadó",
|
||||||
|
"LabelNarrators": "Előadók",
|
||||||
|
"LabelNew": "Új",
|
||||||
|
"LabelNewestAuthors": "Legújabb szerzők",
|
||||||
|
"LabelNewestEpisodes": "Legújabb epizódok",
|
||||||
|
"LabelNewPassword": "Új jelszó",
|
||||||
|
"LabelNextBackupDate": "Következő biztonsági másolat dátuma",
|
||||||
|
"LabelNextScheduledRun": "Következő ütemezett futtatás",
|
||||||
|
"LabelNoEpisodesSelected": "Nincsenek kiválasztott epizódok",
|
||||||
|
"LabelNotes": "Megjegyzések",
|
||||||
|
"LabelNotFinished": "Nem befejezett",
|
||||||
|
"LabelNotificationAppriseURL": "Apprise URL(ek)",
|
||||||
|
"LabelNotificationAvailableVariables": "Elérhető változók",
|
||||||
|
"LabelNotificationBodyTemplate": "Törzs sablon",
|
||||||
|
"LabelNotificationEvent": "Értesítési esemény",
|
||||||
|
"LabelNotificationsMaxFailedAttempts": "Maximális sikertelen próbálkozások",
|
||||||
|
"LabelNotificationsMaxFailedAttemptsHelp": "Az értesítések akkor kerülnek letiltásra, ha ennyiszer nem sikerül elküldeni őket",
|
||||||
|
"LabelNotificationsMaxQueueSize": "Maximális értesítési események sorának mérete",
|
||||||
|
"LabelNotificationsMaxQueueSizeHelp": "Az események korlátozva vannak, hogy másodpercenként 1-szer történjenek. Ha a sor maximális méretű, akkor az események figyelmen kívül lesznek hagyva. Ez megakadályozza az értesítések spamelését.",
|
||||||
|
"LabelNotificationTitleTemplate": "Cím sablon",
|
||||||
|
"LabelNotStarted": "Nem indult el",
|
||||||
|
"LabelNumberOfBooks": "Könyvek száma",
|
||||||
|
"LabelNumberOfEpisodes": "Epizódok száma",
|
||||||
|
"LabelOpenRSSFeed": "RSS hírcsatorna megnyitása",
|
||||||
|
"LabelOverwrite": "Felülírás",
|
||||||
|
"LabelPassword": "Jelszó",
|
||||||
|
"LabelPath": "Útvonal",
|
||||||
|
"LabelPermissionsAccessAllLibraries": "Hozzáférhet az összes könyvtárhoz",
|
||||||
|
"LabelPermissionsAccessAllTags": "Hozzáférhet az összes címkéhez",
|
||||||
|
"LabelPermissionsAccessExplicitContent": "Hozzáférhet explicit tartalomhoz",
|
||||||
|
"LabelPermissionsDelete": "Törölhet",
|
||||||
|
"LabelPermissionsDownload": "Letölthet",
|
||||||
|
"LabelPermissionsUpdate": "Frissíthet",
|
||||||
|
"LabelPermissionsUpload": "Feltölthet",
|
||||||
|
"LabelPhotoPathURL": "Fénykép útvonal/URL",
|
||||||
|
"LabelPlaylists": "Lejátszási listák",
|
||||||
|
"LabelPlayMethod": "Lejátszási módszer",
|
||||||
|
"LabelPodcast": "Podcast",
|
||||||
|
"LabelPodcasts": "Podcastok",
|
||||||
|
"LabelPodcastSearchRegion": "Podcast keresési régió",
|
||||||
|
"LabelPodcastType": "Podcast típus",
|
||||||
|
"LabelPort": "Port",
|
||||||
|
"LabelPrefixesToIgnore": "Figyelmen kívül hagyandó előtagok (nem érzékeny a kis- és nagybetűkre)",
|
||||||
|
"LabelPreventIndexing": "A hírcsatorna indexelésének megakadályozása az iTunes és a Google podcast könyvtáraiban",
|
||||||
|
"LabelPrimaryEbook": "Elsődleges e-könyv",
|
||||||
|
"LabelProgress": "Haladás",
|
||||||
|
"LabelProvider": "Szolgáltató",
|
||||||
|
"LabelPubDate": "Kiadás dátuma",
|
||||||
|
"LabelPublisher": "Kiadó",
|
||||||
|
"LabelPublishYear": "Kiadás éve",
|
||||||
|
"LabelRead": "Olvasás",
|
||||||
|
"LabelReadAgain": "Újraolvasás",
|
||||||
|
"LabelReadEbookWithoutProgress": "E-könyv olvasása haladás nélkül",
|
||||||
|
"LabelRecentlyAdded": "Nemrég hozzáadva",
|
||||||
|
"LabelRecentSeries": "Legutóbbi sorozatok",
|
||||||
|
"LabelRecommended": "Ajánlott",
|
||||||
|
"LabelRedo": "Újra",
|
||||||
|
"LabelRegion": "Régió",
|
||||||
|
"LabelReleaseDate": "Megjelenés dátuma",
|
||||||
|
"LabelRemoveCover": "Borító eltávolítása",
|
||||||
|
"LabelRowsPerPage": "Sorok száma oldalanként",
|
||||||
|
"LabelRSSFeedCustomOwnerEmail": "Egyéni tulajdonos e-mail",
|
||||||
|
"LabelRSSFeedCustomOwnerName": "Egyéni tulajdonos neve",
|
||||||
|
"LabelRSSFeedOpen": "RSS hírcsatorna nyitva",
|
||||||
|
"LabelRSSFeedPreventIndexing": "Indexelés megakadályozása",
|
||||||
|
"LabelRSSFeedSlug": "RSS hírcsatorna slug",
|
||||||
|
"LabelRSSFeedURL": "RSS hírcsatorna URL",
|
||||||
|
"LabelSearchTerm": "Keresési kifejezés",
|
||||||
|
"LabelSearchTitle": "Cím keresése",
|
||||||
|
"LabelSearchTitleOrASIN": "Cím vagy ASIN keresése",
|
||||||
|
"LabelSeason": "Évad",
|
||||||
|
"LabelSelectAllEpisodes": "Összes epizód kiválasztása",
|
||||||
|
"LabelSelectEpisodesShowing": "Kiválasztás {0} megjelenített epizód",
|
||||||
|
"LabelSelectUsers": "Felhasználók kiválasztása",
|
||||||
|
"LabelSendEbookToDevice": "E-könyv küldése...",
|
||||||
|
"LabelSequence": "Sorozat",
|
||||||
|
"LabelSeries": "Sorozat",
|
||||||
|
"LabelSeriesName": "Sorozat neve",
|
||||||
|
"LabelSeriesProgress": "Sorozat haladása",
|
||||||
|
"LabelSetEbookAsPrimary": "Beállítás elsődlegesként",
|
||||||
|
"LabelSetEbookAsSupplementary": "Beállítás kiegészítőként",
|
||||||
|
"LabelSettingsAudiobooksOnly": "Csak hangoskönyvek",
|
||||||
|
"LabelSettingsAudiobooksOnlyHelp": "Ennek a beállításnak az engedélyezése figyelmen kívül hagyja az e-könyv fájlokat, kivéve, ha azok egy hangoskönyv mappában vannak, ebben az esetben kiegészítő e-könyvként lesznek beállítva",
|
||||||
|
"LabelSettingsBookshelfViewHelp": "Skeuomorfikus dizájn fa polcokkal",
|
||||||
|
"LabelSettingsChromecastSupport": "Chromecast támogatás",
|
||||||
|
"LabelSettingsDateFormat": "Dátumformátum",
|
||||||
|
"LabelSettingsDisableWatcher": "Figyelő letiltása",
|
||||||
|
"LabelSettingsDisableWatcherForLibrary": "Mappafigyelő letiltása a könyvtárban",
|
||||||
|
"LabelSettingsDisableWatcherHelp": "Letiltja az automatikus elem hozzáadás/frissítés funkciót, amikor fájlváltozásokat észlel. *Szerver újraindítása szükséges",
|
||||||
|
"LabelSettingsEnableWatcher": "Figyelő engedélyezése",
|
||||||
|
"LabelSettingsEnableWatcherForLibrary": "Mappafigyelő engedélyezése a könyvtárban",
|
||||||
|
"LabelSettingsEnableWatcherHelp": "Engedélyezi az automatikus elem hozzáadás/frissítés funkciót, amikor fájlváltozásokat észlel. *Szerver újraindítása szükséges",
|
||||||
|
"LabelSettingsExperimentalFeatures": "Kísérleti funkciók",
|
||||||
|
"LabelSettingsExperimentalFeaturesHelp": "Fejlesztés alatt álló funkciók, amelyek visszajelzésre és tesztelésre szorulnak. Kattintson a github megbeszélés megnyitásához.",
|
||||||
|
"LabelSettingsFindCovers": "Borítók keresése",
|
||||||
|
"LabelSettingsFindCoversHelp": "Ha a hangoskönyvnek nincs beágyazott borítója vagy borítóképe a mappában, a szkenner megpróbálja megtalálni a borítót.<br>Megjegyzés: Ez meghosszabbítja a szkennelési időt",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Egykönyves sorozatok elrejtése",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "A csak egy könyvet tartalmazó sorozatok el lesznek rejtve a sorozatok oldalról és a kezdőlap polcairól.",
|
||||||
|
"LabelSettingsHomePageBookshelfView": "Kezdőlap használja a könyvespolc nézetet",
|
||||||
|
"LabelSettingsLibraryBookshelfView": "Könyvtár használja a könyvespolc nézetet",
|
||||||
|
"LabelSettingsParseSubtitles": "Feliratok elemzése",
|
||||||
|
"LabelSettingsParseSubtitlesHelp": "Feliratok kinyerése a hangoskönyv mappaneveiből.<br>A feliratnak el kell különülnie egy \" - \" jellel<br>például: \"Könyv címe - Egy felirat itt\" esetén a felirat \"Egy felirat itt\"",
|
||||||
|
"LabelSettingsPreferMatchedMetadata": "Preferált egyeztetett metaadatok",
|
||||||
|
"LabelSettingsPreferMatchedMetadataHelp": "Az egyeztetett adatok felülírják az elem részleteit a Gyors egyeztetés használatakor. Alapértelmezés szerint a Gyors egyeztetés csak a hiányzó részleteket tölti ki.",
|
||||||
|
"LabelSettingsSkipMatchingBooksWithASIN": "Már ASIN-nel rendelkező könyvek egyeztetésének kihagyása",
|
||||||
|
"LabelSettingsSkipMatchingBooksWithISBN": "Már ISBN-nel rendelkező könyvek egyeztetésének kihagyása",
|
||||||
|
"LabelSettingsSortingIgnorePrefixes": "Előtagok figyelmen kívül hagyása rendezéskor",
|
||||||
|
"LabelSettingsSortingIgnorePrefixesHelp": "például az \"a\" előtag esetén a \"A könyv címe\" könyv címe \"Könyv címe, A\" szerint rendeződik",
|
||||||
|
"LabelSettingsSquareBookCovers": "Négyzet alakú könyvborítók használata",
|
||||||
|
"LabelSettingsSquareBookCoversHelp": "Négyzet alakú borítók használata az 1,6:1 arányú standard könyvborítók helyett",
|
||||||
|
"LabelSettingsStoreCoversWithItem": "Borítók tárolása az elemmel",
|
||||||
|
"LabelSettingsStoreCoversWithItemHelp": "Alapértelmezés szerint a borítók a /metadata/items mappában vannak tárolva, ennek a beállításnak az engedélyezése a borítókat a könyvtári elem mappájában tárolja. Csak egy \"cover\" nevű fájl lesz megtartva",
|
||||||
|
"LabelSettingsStoreMetadataWithItem": "Metaadatok tárolása az elemmel",
|
||||||
|
"LabelSettingsStoreMetadataWithItemHelp": "Alapértelmezés szerint a metaadatfájlok a /metadata/items mappában vannak tárolva, ennek a beállításnak az engedélyezése a metaadatfájlokat a könyvtári elem mappáiban tárolja",
|
||||||
|
"LabelSettingsTimeFormat": "Időformátum",
|
||||||
|
"LabelShowAll": "Mindent mutat",
|
||||||
|
"LabelSize": "Méret",
|
||||||
|
"LabelSleepTimer": "Alvásidőzítő",
|
||||||
|
"LabelSlug": "Rövid cím",
|
||||||
|
"LabelStart": "Kezdés",
|
||||||
|
"LabelStarted": "Elkezdődött",
|
||||||
|
"LabelStartedAt": "Kezdés ideje",
|
||||||
|
"LabelStartTime": "Kezdési idő",
|
||||||
|
"LabelStatsAudioTracks": "Audiósávok",
|
||||||
|
"LabelStatsAuthors": "Szerzők",
|
||||||
|
"LabelStatsBestDay": "Legjobb nap",
|
||||||
|
"LabelStatsDailyAverage": "Napi átlag",
|
||||||
|
"LabelStatsDays": "Napok",
|
||||||
|
"LabelStatsDaysListened": "Hallgatott napok",
|
||||||
|
"LabelStatsHours": "Órák",
|
||||||
|
"LabelStatsInARow": "egymás után",
|
||||||
|
"LabelStatsItemsFinished": "Befejezett elemek",
|
||||||
|
"LabelStatsItemsInLibrary": "Elemek a könyvtárban",
|
||||||
|
"LabelStatsMinutes": "percek",
|
||||||
|
"LabelStatsMinutesListening": "Hallgatási percek",
|
||||||
|
"LabelStatsOverallDays": "Összes nap",
|
||||||
|
"LabelStatsOverallHours": "Összes óra",
|
||||||
|
"LabelStatsWeekListening": "Heti hallgatás",
|
||||||
|
"LabelSubtitle": "Felirat",
|
||||||
|
"LabelSupportedFileTypes": "Támogatott fájltípusok",
|
||||||
|
"LabelTag": "Címke",
|
||||||
|
"LabelTags": "Címkék",
|
||||||
|
"LabelTagsAccessibleToUser": "A felhasználó számára elérhető címkék",
|
||||||
|
"LabelTagsNotAccessibleToUser": "A felhasználó számára nem elérhető címkék",
|
||||||
|
"LabelTasks": "Futó feladatok",
|
||||||
|
"LabelTextEditorBulletedList": "Pontozott lista",
|
||||||
|
"LabelTextEditorLink": "Link",
|
||||||
|
"LabelTextEditorNumberedList": "Számozott lista",
|
||||||
|
"LabelTextEditorUnlink": "Link eltávolítása",
|
||||||
|
"LabelTheme": "Téma",
|
||||||
|
"LabelThemeDark": "Sötét",
|
||||||
|
"LabelThemeLight": "Világos",
|
||||||
|
"LabelTimeBase": "Időalap",
|
||||||
|
"LabelTimeListened": "Hallgatott idő",
|
||||||
|
"LabelTimeListenedToday": "Ma hallgatott idő",
|
||||||
|
"LabelTimeRemaining": "{0} maradt",
|
||||||
|
"LabelTimeToShift": "Eltolás ideje másodpercben",
|
||||||
|
"LabelTitle": "Cím",
|
||||||
|
"LabelToolsEmbedMetadata": "Metaadatok beágyazása",
|
||||||
|
"LabelToolsEmbedMetadataDescription": "Metaadatok beágyazása az audiofájlokba, beleértve a borítóképet és a fejezeteket.",
|
||||||
|
"LabelToolsMakeM4b": "M4B Hangoskönyv fájl készítése",
|
||||||
|
"LabelToolsMakeM4bDescription": ".M4B hangoskönyv fájl generálása beágyazott metaadatokkal, borítóképpel és fejezetekkel.",
|
||||||
|
"LabelToolsSplitM4b": "M4B felosztása MP3-ra",
|
||||||
|
"LabelToolsSplitM4bDescription": "MP3 fájlok létrehozása egy M4B-ből, fejezetenként felosztva, beágyazott metaadatokkal, borítóképpel és fejezetekkel.",
|
||||||
|
"LabelTotalDuration": "Teljes időtartam",
|
||||||
|
"LabelTotalTimeListened": "Teljes hallgatási idő",
|
||||||
|
"LabelTrackFromFilename": "Sáv a fájlnévből",
|
||||||
|
"LabelTrackFromMetadata": "Sáv a metaadatokból",
|
||||||
|
"LabelTracks": "Sávok",
|
||||||
|
"LabelTracksMultiTrack": "Többsávos",
|
||||||
|
"LabelTracksNone": "Nincsenek sávok",
|
||||||
|
"LabelTracksSingleTrack": "Egysávos",
|
||||||
|
"LabelType": "Típus",
|
||||||
|
"LabelUnabridged": "Nem tömörített",
|
||||||
|
"LabelUndo": "Visszavonás",
|
||||||
|
"LabelUnknown": "Ismeretlen",
|
||||||
|
"LabelUpdateCover": "Borító frissítése",
|
||||||
|
"LabelUpdateCoverHelp": "Lehetővé teszi a meglévő borítók felülírását a kiválasztott könyveknél, amikor találatot talál",
|
||||||
|
"LabelUpdatedAt": "Frissítve",
|
||||||
|
"LabelUpdateDetails": "Részletek frissítése",
|
||||||
|
"LabelUpdateDetailsHelp": "Lehetővé teszi a meglévő részletek felülírását a kiválasztott könyveknél, amikor találatot talál",
|
||||||
|
"LabelUploaderDragAndDrop": "Fájlok vagy mappák húzása és elengedése",
|
||||||
|
"LabelUploaderDropFiles": "Fájlok elengedése",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Cím, szerző és sorozat automatikus lekérése",
|
||||||
|
"LabelUseChapterTrack": "Fejezetsáv használata",
|
||||||
|
"LabelUseFullTrack": "Teljes sáv használata",
|
||||||
|
"LabelUser": "Felhasználó",
|
||||||
|
"LabelUsername": "Felhasználónév",
|
||||||
|
"LabelValue": "Érték",
|
||||||
|
"LabelVersion": "Verzió",
|
||||||
|
"LabelViewBookmarks": "Könyvjelzők megtekintése",
|
||||||
|
"LabelViewChapters": "Fejezetek megtekintése",
|
||||||
|
"LabelViewQueue": "Lejátszó sor megtekintése",
|
||||||
|
"LabelVolume": "Hangerő",
|
||||||
|
"LabelWeekdaysToRun": "Futás napjai",
|
||||||
|
"LabelYourAudiobookDuration": "Hangoskönyv időtartama",
|
||||||
|
"LabelYourBookmarks": "Könyvjelzőid",
|
||||||
|
"LabelYourPlaylists": "Lejátszási listáid",
|
||||||
|
"LabelYourProgress": "Haladásod",
|
||||||
|
"MessageAddToPlayerQueue": "Hozzáadás a lejátszó sorhoz",
|
||||||
|
"MessageAppriseDescription": "Ennek a funkció használatához futtatnia kell egy <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> példányt vagy egy olyan API-t, amely kezeli ezeket a kéréseket. <br />Az Apprise API URL-nek a teljes URL útvonalat kell tartalmaznia az értesítés elküldéséhez, például, ha az API példánya a <code>http://192.168.1.1:8337</code> címen szolgáltatva, akkor <code>http://192.168.1.1:8337/notify</code> értéket kell megadnia.",
|
||||||
|
"MessageBackupsDescription": "A biztonsági másolatok tartalmazzák a felhasználókat, a felhasználói haladást, a könyvtári elem részleteit, a szerver beállításait és a képeket, amelyek a <code>/metadata/items</code> és <code>/metadata/authors</code> mappákban vannak tárolva. A biztonsági másolatok <strong>nem</strong> tartalmazzák a könyvtári mappákban tárolt fájlokat.",
|
||||||
|
"MessageBatchQuickMatchDescription": "A Gyors egyeztetés megpróbálja hozzáadni a hiányzó borítókat és metaadatokat a kiválasztott elemekhez. Engedélyezze az alábbi opciókat, hogy a Gyors egyeztetés felülírhassa a meglévő borítókat és/vagy metaadatokat.",
|
||||||
|
"MessageBookshelfNoCollections": "Még nem készített gyűjteményeket",
|
||||||
|
"MessageBookshelfNoResultsForFilter": "Nincs eredmény a \"{0}: {1}\" szűrőre",
|
||||||
|
"MessageBookshelfNoRSSFeeds": "Nincsenek nyitott RSS hírcsatornák",
|
||||||
|
"MessageBookshelfNoSeries": "Nincsenek sorozatai",
|
||||||
|
"MessageChapterEndIsAfter": "A fejezet vége a hangoskönyv végét követi",
|
||||||
|
"MessageChapterErrorFirstNotZero": "Az első fejezetnek 0:00-kor kell kezdődnie",
|
||||||
|
"MessageChapterErrorStartGteDuration": "Érvénytelen kezdési idő, kevesebbnek kell lennie, mint a hangoskönyv időtartama",
|
||||||
|
"MessageChapterErrorStartLtPrev": "Érvénytelen kezdési idő, nagyobbnak kell lennie, mint az előző fejezet kezdési ideje",
|
||||||
|
"MessageChapterStartIsAfter": "A fejezet kezdete a hangoskönyv végét követi",
|
||||||
|
"MessageCheckingCron": "Cron ellenőrzése...",
|
||||||
|
"MessageConfirmCloseFeed": "Biztosan be szeretné zárni ezt a hírcsatornát?",
|
||||||
|
"MessageConfirmDeleteBackup": "Biztosan törölni szeretné a(z) {0} biztonsági másolatot?",
|
||||||
|
"MessageConfirmDeleteFile": "Ez törölni fogja a fájlt a fájlrendszerből. Biztos benne?",
|
||||||
|
"MessageConfirmDeleteLibrary": "Biztosan véglegesen törölni szeretné a(z) \"{0}\" könyvtárat?",
|
||||||
|
"MessageConfirmDeleteLibraryItem": "Ez eltávolítja a könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?",
|
||||||
|
"MessageConfirmDeleteLibraryItems": "Ez eltávolítja a(z) {0} könyvtári elemet az adatbázisból és a fájlrendszerből. Biztos benne?",
|
||||||
|
"MessageConfirmDeleteSession": "Biztosan törölni szeretné ezt a munkamenetet?",
|
||||||
|
"MessageConfirmForceReScan": "Biztosan kényszeríteni szeretné az újraszkennelést?",
|
||||||
|
"MessageConfirmMarkAllEpisodesFinished": "Biztosan meg szeretné jelölni az összes epizódot befejezettnek?",
|
||||||
|
"MessageConfirmMarkAllEpisodesNotFinished": "Biztosan meg szeretné jelölni az összes epizódot nem befejezettnek?",
|
||||||
|
"MessageConfirmMarkSeriesFinished": "Biztosan meg szeretné jelölni a sorozat összes könyvét befejezettnek?",
|
||||||
|
"MessageConfirmMarkSeriesNotFinished": "Biztosan meg szeretné jelölni a sorozat összes könyvét nem befejezettnek?",
|
||||||
|
"MessageConfirmQuickEmbed": "Figyelem! A Gyors beágyazás nem készít biztonsági másolatot az audiofájlokról. Győződjön meg arról, hogy van biztonsági másolata az audiofájlokról. <br><br>Szeretné folytatni?",
|
||||||
|
"MessageConfirmRemoveAllChapters": "Biztosan eltávolítja az összes fejezetet?",
|
||||||
|
"MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?",
|
||||||
|
"MessageConfirmRemoveCollection": "Biztosan eltávolítja a(z) \"{0}\" gyűjteményt?",
|
||||||
|
"MessageConfirmRemoveEpisode": "Biztosan eltávolítja a(z) \"{0}\" epizódot?",
|
||||||
|
"MessageConfirmRemoveEpisodes": "Biztosan eltávolítja a(z) {0} epizódot?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?",
|
||||||
|
"MessageConfirmRemoveNarrator": "Biztosan eltávolítja a(z) \"{0}\" előadót?",
|
||||||
|
"MessageConfirmRemovePlaylist": "Biztosan eltávolítja a(z) \"{0}\" lejátszási listáját?",
|
||||||
|
"MessageConfirmRenameGenre": "Biztosan át szeretné nevezni a(z) \"{0}\" műfajt \"{1}\"-re az összes elemnél?",
|
||||||
|
"MessageConfirmRenameGenreMergeNote": "Megjegyzés: Ez a műfaj már létezik, így össze lesznek vonva.",
|
||||||
|
"MessageConfirmRenameGenreWarning": "Figyelem! Egy hasonló, de eltérő nagybetűkkel rendelkező műfaj már létezik \"{0}\".",
|
||||||
|
"MessageConfirmRenameTag": "Biztosan át szeretné nevezni a(z) \"{0}\" címkét \"{1}\"-re az összes elemnél?",
|
||||||
|
"MessageConfirmRenameTagMergeNote": "Megjegyzés: Ez a címke már létezik, így össze lesznek vonva.",
|
||||||
|
"MessageConfirmRenameTagWarning": "Figyelem! Egy hasonló, de eltérő nagybetűkkel rendelkező címke már létezik \"{0}\".",
|
||||||
|
"MessageConfirmReScanLibraryItems": "Biztosan újra szeretné szkennelni a(z) {0} elemet?",
|
||||||
|
"MessageConfirmSendEbookToDevice": "Biztosan el szeretné küldeni a(z) {0} e-könyvet a(z) \"{1}\" eszközre?",
|
||||||
|
"MessageDownloadingEpisode": "Epizód letöltése",
|
||||||
|
"MessageDragFilesIntoTrackOrder": "Húzza a fájlokat a helyes sávrendbe",
|
||||||
|
"MessageEmbedFinished": "Beágyazás befejeződött!",
|
||||||
|
"MessageEpisodesQueuedForDownload": "{0} Epizód letöltésre várakozik",
|
||||||
|
"MessageFeedURLWillBe": "A hírcsatorna URL-je {0} lesz",
|
||||||
|
"MessageFetching": "Lekérés...",
|
||||||
|
"MessageForceReScanDescription": "minden fájlt újra szkennel, mint egy friss szkennelés. Az audiofájlok ID3 címkéi, OPF fájlok és szövegfájlok újként lesznek szkennelve.",
|
||||||
|
"MessageImportantNotice": "Fontos közlemény!",
|
||||||
|
"MessageInsertChapterBelow": "Fejezet beszúrása alulra",
|
||||||
|
"MessageItemsSelected": "{0} kiválasztott elem",
|
||||||
|
"MessageItemsUpdated": "{0} frissített elem",
|
||||||
|
"MessageJoinUsOn": "Csatlakozzon hozzánk: ",
|
||||||
|
"MessageListeningSessionsInTheLastYear": "{0} hallgatási munkamenet az elmúlt évben",
|
||||||
|
"MessageLoading": "Betöltés...",
|
||||||
|
"MessageLoadingFolders": "Mappák betöltése...",
|
||||||
|
"MessageM4BFailed": "M4B sikertelen!",
|
||||||
|
"MessageM4BFinished": "M4B befejeződött!",
|
||||||
|
"MessageMapChapterTitles": "Fejezetcímek hozzárendelése a meglévő hangoskönyv fejezeteihez anélkül, hogy az időbélyegeket módosítaná",
|
||||||
|
"MessageMarkAllEpisodesFinished": "Az összes epizód megjelölése befejezettnek",
|
||||||
|
"MessageMarkAllEpisodesNotFinished": "Az összes epizód megjelölése nem befejezettnek",
|
||||||
|
"MessageMarkAsFinished": "Megjelölés befejezettnek",
|
||||||
|
"MessageMarkAsNotFinished": "Megjelölés nem befejezettnek",
|
||||||
|
"MessageMatchBooksDescription": "megpróbálja egyeztetni a könyvtár könyveit egy kiválasztott keresési szolgáltató könyvével, és kitölti az üres részleteket és a borítót. Nem írja felül a részleteket.",
|
||||||
|
"MessageNoAudioTracks": "Nincsenek audiósávok",
|
||||||
|
"MessageNoAuthors": "Nincsenek szerzők",
|
||||||
|
"MessageNoBackups": "Nincsenek biztonsági másolatok",
|
||||||
|
"MessageNoBookmarks": "Nincsenek könyvjelzők",
|
||||||
|
"MessageNoChapters": "Nincsenek fejezetek",
|
||||||
|
"MessageNoCollections": "Nincsenek gyűjtemények",
|
||||||
|
"MessageNoCoversFound": "Nem találhatóak borítók",
|
||||||
|
"MessageNoDescription": "Nincs leírás",
|
||||||
|
"MessageNoDownloadsInProgress": "Jelenleg nincsenek folyamatban lévő letöltések",
|
||||||
|
"MessageNoDownloadsQueued": "Nincsenek várakozó letöltések",
|
||||||
|
"MessageNoEpisodeMatchesFound": "Nincs találat az epizódokra",
|
||||||
|
"MessageNoEpisodes": "Nincsenek epizódok",
|
||||||
|
"MessageNoFoldersAvailable": "Nincsenek elérhető mappák",
|
||||||
|
"MessageNoGenres": "Nincsenek műfajok",
|
||||||
|
"MessageNoIssues": "Nincsenek problémák",
|
||||||
|
"MessageNoItems": "Nincsenek elemek",
|
||||||
|
"MessageNoItemsFound": "Nem találhatóak elemek",
|
||||||
|
"MessageNoListeningSessions": "Nincsenek hallgatási munkamenetek",
|
||||||
|
"MessageNoLogs": "Nincsenek naplók",
|
||||||
|
"MessageNoMediaProgress": "Nincs előrehaladás a médialejátszásban",
|
||||||
|
"MessageNoNotifications": "Nincsenek értesítések",
|
||||||
|
"MessageNoPodcastsFound": "Nem találhatóak podcastok",
|
||||||
|
"MessageNoResults": "Nincsenek eredmények",
|
||||||
|
"MessageNoSearchResultsFor": "Nincs keresési eredmény erre: \"{0}\"",
|
||||||
|
"MessageNoSeries": "Nincsenek sorozatok",
|
||||||
|
"MessageNoTags": "Nincsenek címkék",
|
||||||
|
"MessageNoTasksRunning": "Nincsenek futó feladatok",
|
||||||
|
"MessageNotYetImplemented": "Még nem implementált",
|
||||||
|
"MessageNoUpdateNecessary": "Nincs szükség frissítésre",
|
||||||
|
"MessageNoUpdatesWereNecessary": "Nem volt szükség frissítésekre",
|
||||||
|
"MessageNoUserPlaylists": "Nincsenek felhasználói lejátszási listák",
|
||||||
|
"MessageOr": "vagy",
|
||||||
|
"MessagePauseChapter": "Fejezet lejátszásának szüneteltetése",
|
||||||
|
"MessagePlayChapter": "Fejezet elejének meghallgatása",
|
||||||
|
"MessagePlaylistCreateFromCollection": "Lejátszási lista létrehozása gyűjteményből",
|
||||||
|
"MessagePodcastHasNoRSSFeedForMatching": "A podcastnak nincs RSS hírcsatorna URL-je az egyeztetéshez",
|
||||||
|
"MessageQuickMatchDescription": "Üres elem részletek és borító feltöltése az első találati eredménnyel a(z) '{0}'-ból. Nem írja felül a részleteket, kivéve, ha a 'Preferált egyeztetett metaadatok' szerverbeállítás engedélyezve van.",
|
||||||
|
"MessageRemoveChapter": "Fejezet eltávolítása",
|
||||||
|
"MessageRemoveEpisodes": "Epizód(ok) eltávolítása: {0}",
|
||||||
|
"MessageRemoveFromPlayerQueue": "Eltávolítás a lejátszási sorból",
|
||||||
|
"MessageRemoveUserWarning": "Biztosan véglegesen törölni szeretné a(z) \"{0}\" felhasználót?",
|
||||||
|
"MessageReportBugsAndContribute": "Hibák jelentése, funkciók kérése és hozzájárulás itt:",
|
||||||
|
"MessageResetChaptersConfirm": "Biztosan alaphelyzetbe szeretné állítani a fejezeteket és visszavonni a módosításokat?",
|
||||||
|
"MessageRestoreBackupConfirm": "Biztosan vissza szeretné állítani a biztonsági másolatot, amely ekkor készült:",
|
||||||
|
"MessageRestoreBackupWarning": "A biztonsági mentés visszaállítása felülírja az egész adatbázist, amely a /config mappában található, valamint a borítóképeket a /metadata/items és /metadata/authors mappákban.<br /><br />A biztonsági mentések nem módosítják a könyvtár mappáiban található fájlokat. Ha engedélyezte a szerverbeállításokat a borítóképek és a metaadatok könyvtármappákban való tárolására, akkor ezek nem kerülnek biztonsági mentésre vagy felülírásra.<br /><br />A szerver használó összes kliens automatikusan frissül.",
|
||||||
|
"MessageSearchResultsFor": "Keresési eredmények",
|
||||||
|
"MessageSelected": "{0} kiválasztva",
|
||||||
|
"MessageServerCouldNotBeReached": "A szervert nem lehet elérni",
|
||||||
|
"MessageSetChaptersFromTracksDescription": "Fejezetek beállítása minden egyes hangfájlt egy fejezetként használva, és a fejezet címét a hangfájl neveként",
|
||||||
|
"MessageStartPlaybackAtTime": "\"{0}\" lejátszásának kezdése {1} -tól?",
|
||||||
|
"MessageThinking": "Gondolkodás...",
|
||||||
|
"MessageUploaderItemFailed": "A feltöltés sikertelen",
|
||||||
|
"MessageUploaderItemSuccess": "Sikeresen feltöltve!",
|
||||||
|
"MessageUploading": "Feltöltés...",
|
||||||
|
"MessageValidCronExpression": "Érvényes cron kifejezés",
|
||||||
|
"MessageWatcherIsDisabledGlobally": "A megfigyelő globálisan le van tiltva a szerver beállításokban",
|
||||||
|
"MessageXLibraryIsEmpty": "{0} könyvtár üres!",
|
||||||
|
"MessageYourAudiobookDurationIsLonger": "Az Ön hangoskönyvének hossza hosszabb, mint a talált időtartam",
|
||||||
|
"MessageYourAudiobookDurationIsShorter": "Az Ön hangoskönyvének hossza rövidebb, mint a talált időtartam",
|
||||||
|
"NoteChangeRootPassword": "A Root felhasználó az egyetlen felhasználó, akinek lehet üres jelszava",
|
||||||
|
"NoteChapterEditorTimes": "Megjegyzés: Az első fejezet kezdőidejének 0:00 kell lennie, és az utolsó fejezet kezdőideje nem haladhatja meg a hangoskönyv időtartamát.",
|
||||||
|
"NoteFolderPicker": "Megjegyzés: azok a mappák, amelyek már hozzá vannak rendelve, nem jelennek meg",
|
||||||
|
"NoteRSSFeedPodcastAppsHttps": "Figyelem: A legtöbb podcast alkalmazás megköveteli, hogy az RSS feed URL HTTPS-t használjon",
|
||||||
|
"NoteRSSFeedPodcastAppsPubDate": "Figyelem: Az egy vagy több epizódnak nincs Közzétételi dátuma. Néhány podcast alkalmazás ezt megköveteli.",
|
||||||
|
"NoteUploaderFoldersWithMediaFiles": "A médiafájlokat tartalmazó mappák külön könyvtári tételekként lesznek kezelve.",
|
||||||
|
"NoteUploaderOnlyAudioFiles": "Ha csak hangfájlokat tölt fel, akkor minden egyes hangfájl külön hangoskönyvként lesz kezelve.",
|
||||||
|
"NoteUploaderUnsupportedFiles": "A nem támogatott fájlok figyelmen kívül hagyásra kerülnek. Mappa kiválasztása vagy elengedésekor az elem mappáján kívüli egyéb fájlok figyelmen kívül lesznek hagyva.",
|
||||||
|
"PlaceholderNewCollection": "Új gyűjtemény neve",
|
||||||
|
"PlaceholderNewFolderPath": "Új mappa útvonala",
|
||||||
|
"PlaceholderNewPlaylist": "Új lejátszási lista neve",
|
||||||
|
"PlaceholderSearch": "Keresés..",
|
||||||
|
"PlaceholderSearchEpisode": "Epizód keresése..",
|
||||||
|
"ToastAccountUpdateFailed": "A fiók frissítése sikertelen",
|
||||||
|
"ToastAccountUpdateSuccess": "Fiók frissítve",
|
||||||
|
"ToastAuthorImageRemoveFailed": "A kép eltávolítása sikertelen",
|
||||||
|
"ToastAuthorImageRemoveSuccess": "Szerző képe eltávolítva",
|
||||||
|
"ToastAuthorUpdateFailed": "A szerző frissítése sikertelen",
|
||||||
|
"ToastAuthorUpdateMerged": "Szerző összevonva",
|
||||||
|
"ToastAuthorUpdateSuccess": "Szerző frissítve",
|
||||||
|
"ToastAuthorUpdateSuccessNoImageFound": "Szerző frissítve (nem található kép)",
|
||||||
|
"ToastBackupCreateFailed": "A biztonsági mentés létrehozása sikertelen",
|
||||||
|
"ToastBackupCreateSuccess": "Biztonsági mentés létrehozva",
|
||||||
|
"ToastBackupDeleteFailed": "A biztonsági mentés törlése sikertelen",
|
||||||
|
"ToastBackupDeleteSuccess": "Biztonsági mentés törölve",
|
||||||
|
"ToastBackupRestoreFailed": "A biztonsági mentés visszaállítása sikertelen",
|
||||||
|
"ToastBackupUploadFailed": "A biztonsági mentés feltöltése sikertelen",
|
||||||
|
"ToastBackupUploadSuccess": "Biztonsági mentés feltöltve",
|
||||||
|
"ToastBatchUpdateFailed": "Kötegelt frissítés sikertelen",
|
||||||
|
"ToastBatchUpdateSuccess": "Kötegelt frissítés sikeres",
|
||||||
|
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
|
||||||
|
"ToastBookmarkCreateSuccess": "Könyvjelző hozzáadva",
|
||||||
|
"ToastBookmarkRemoveFailed": "Könyvjelző eltávolítása sikertelen",
|
||||||
|
"ToastBookmarkRemoveSuccess": "Könyvjelző eltávolítva",
|
||||||
|
"ToastBookmarkUpdateFailed": "Könyvjelző frissítése sikertelen",
|
||||||
|
"ToastBookmarkUpdateSuccess": "Könyvjelző frissítve",
|
||||||
|
"ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak",
|
||||||
|
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük",
|
||||||
|
"ToastCollectionItemsRemoveFailed": "Elem(ek) eltávolítása a gyűjteményből sikertelen",
|
||||||
|
"ToastCollectionItemsRemoveSuccess": "Elem(ek) eltávolítva a gyűjteményből",
|
||||||
|
"ToastCollectionRemoveFailed": "Gyűjtemény eltávolítása sikertelen",
|
||||||
|
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
|
||||||
|
"ToastCollectionUpdateFailed": "Gyűjtemény frissítése sikertelen",
|
||||||
|
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
|
||||||
|
"ToastItemCoverUpdateFailed": "Elem borítójának frissítése sikertelen",
|
||||||
|
"ToastItemCoverUpdateSuccess": "Elem borítója frissítve",
|
||||||
|
"ToastItemDetailsUpdateFailed": "Elem részleteinek frissítése sikertelen",
|
||||||
|
"ToastItemDetailsUpdateSuccess": "Elem részletei frissítve",
|
||||||
|
"ToastItemDetailsUpdateUnneeded": "Nincsenek szükséges frissítések a tétel részletein",
|
||||||
|
"ToastItemMarkedAsFinishedFailed": "Megjelölés Befejezettként sikertelen",
|
||||||
|
"ToastItemMarkedAsFinishedSuccess": "Elem megjelölve Befejezettként",
|
||||||
|
"ToastItemMarkedAsNotFinishedFailed": "Nem sikerült Nem Befejezettként megjelölni az elemet",
|
||||||
|
"ToastItemMarkedAsNotFinishedSuccess": "Elem megjelölve Nem Befejezettként",
|
||||||
|
"ToastLibraryCreateFailed": "Könyvtár létrehozása sikertelen",
|
||||||
|
"ToastLibraryCreateSuccess": "\"{0}\" könyvtár létrehozva",
|
||||||
|
"ToastLibraryDeleteFailed": "Könyvtár törlése sikertelen",
|
||||||
|
"ToastLibraryDeleteSuccess": "Könyvtár törölve",
|
||||||
|
"ToastLibraryScanFailedToStart": "A beolvasás elindítása sikertelen",
|
||||||
|
"ToastLibraryScanStarted": "Könyvtár beolvasása elindítva",
|
||||||
|
"ToastLibraryUpdateFailed": "Könyvtár frissítése sikertelen",
|
||||||
|
"ToastLibraryUpdateSuccess": "\"{0}\" könyvtár frissítve",
|
||||||
|
"ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen",
|
||||||
|
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
|
||||||
|
"ToastPlaylistRemoveFailed": "Lejátszási lista eltávolítása sikertelen",
|
||||||
|
"ToastPlaylistRemoveSuccess": "Lejátszási lista eltávolítva",
|
||||||
|
"ToastPlaylistUpdateFailed": "Lejátszási lista frissítése sikertelen",
|
||||||
|
"ToastPlaylistUpdateSuccess": "Lejátszási lista frissítve",
|
||||||
|
"ToastPodcastCreateFailed": "Podcast létrehozása sikertelen",
|
||||||
|
"ToastPodcastCreateSuccess": "Podcast sikeresen létrehozva",
|
||||||
|
"ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen",
|
||||||
|
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
|
||||||
|
"ToastRSSFeedCloseFailed": "RSS feed bezárása sikertelen",
|
||||||
|
"ToastRSSFeedCloseSuccess": "RSS feed bezárva",
|
||||||
|
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
|
||||||
|
"ToastSendEbookToDeviceSuccess": "E-könyv elküldve az eszközre \"{0}\"",
|
||||||
|
"ToastSeriesUpdateFailed": "Sorozat frissítése sikertelen",
|
||||||
|
"ToastSeriesUpdateSuccess": "Sorozat frissítése sikeres",
|
||||||
|
"ToastSessionDeleteFailed": "Munkamenet törlése sikertelen",
|
||||||
|
"ToastSessionDeleteSuccess": "Munkamenet törölve",
|
||||||
|
"ToastSocketConnected": "Socket csatlakoztatva",
|
||||||
|
"ToastSocketDisconnected": "Socket lecsatlakoztatva",
|
||||||
|
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
|
||||||
|
"ToastUserDeleteFailed": "Felhasználó törlése sikertelen",
|
||||||
|
"ToastUserDeleteSuccess": "Felhasználó törölve"
|
||||||
|
}
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "Nascondi",
|
"ButtonHide": "Nascondi",
|
||||||
"ButtonHome": "Home",
|
"ButtonHome": "Home",
|
||||||
"ButtonIssues": "Errori",
|
"ButtonIssues": "Errori",
|
||||||
|
"ButtonJumpBackward": "Salta indietro",
|
||||||
|
"ButtonJumpForward": "Salta Avanti",
|
||||||
"ButtonLatest": "Ultimi",
|
"ButtonLatest": "Ultimi",
|
||||||
"ButtonLibrary": "Libreria",
|
"ButtonLibrary": "Libreria",
|
||||||
"ButtonLogout": "Disconnetti",
|
"ButtonLogout": "Disconnetti",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "Aggiungi metadata agli Autori",
|
"ButtonMatchAllAuthors": "Aggiungi metadata agli Autori",
|
||||||
"ButtonMatchBooks": "Aggiungi metadata della Libreria",
|
"ButtonMatchBooks": "Aggiungi metadata della Libreria",
|
||||||
"ButtonNevermind": "Nevermind",
|
"ButtonNevermind": "Nevermind",
|
||||||
|
"ButtonNextChapter": "Prossimo Capitolo",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Apri Feed",
|
"ButtonOpenFeed": "Apri Feed",
|
||||||
"ButtonOpenManager": "Apri Manager",
|
"ButtonOpenManager": "Apri Manager",
|
||||||
|
"ButtonPause": "Pausa",
|
||||||
"ButtonPlay": "Play",
|
"ButtonPlay": "Play",
|
||||||
"ButtonPlaying": "In Riproduzione",
|
"ButtonPlaying": "In Riproduzione",
|
||||||
"ButtonPlaylists": "Playlists",
|
"ButtonPlaylists": "Playlists",
|
||||||
|
"ButtonPreviousChapter": "Capitolo Precendente",
|
||||||
"ButtonPurgeAllCache": "Elimina tutta la Cache",
|
"ButtonPurgeAllCache": "Elimina tutta la Cache",
|
||||||
"ButtonPurgeItemsCache": "Elimina la Cache selezionata",
|
"ButtonPurgeItemsCache": "Elimina la Cache selezionata",
|
||||||
"ButtonPurgeMediaProgress": "Elimina info dei media ascoltati",
|
"ButtonPurgeMediaProgress": "Elimina info dei media ascoltati",
|
||||||
@ -87,15 +92,15 @@
|
|||||||
"ButtonUserEdit": "Modifica Utente {0}",
|
"ButtonUserEdit": "Modifica Utente {0}",
|
||||||
"ButtonViewAll": "Mostra Tutto",
|
"ButtonViewAll": "Mostra Tutto",
|
||||||
"ButtonYes": "Si",
|
"ButtonYes": "Si",
|
||||||
"ErrorUploadFetchMetadataAPI": "Error fetching metadata",
|
"ErrorUploadFetchMetadataAPI": "Errore Recupero metadati",
|
||||||
"ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author",
|
"ErrorUploadFetchMetadataNoResults": "Impossibile recuperare i metadati: prova a modificate il titolo e/o l'autore",
|
||||||
"ErrorUploadLacksTitle": "Must have a title",
|
"ErrorUploadLacksTitle": "Deve avere un titolo",
|
||||||
"HeaderAccount": "Account",
|
"HeaderAccount": "Account",
|
||||||
"HeaderAdvanced": "Avanzate",
|
"HeaderAdvanced": "Avanzate",
|
||||||
"HeaderAppriseNotificationSettings": "Apprendi le impostazioni di Notifica",
|
"HeaderAppriseNotificationSettings": "Apprendi le impostazioni di Notifica",
|
||||||
"HeaderAudiobookTools": "Utilità Audiobook File Management",
|
"HeaderAudiobookTools": "Utilità Audiobook File Management",
|
||||||
"HeaderAudioTracks": "Tracce Audio",
|
"HeaderAudioTracks": "Tracce Audio",
|
||||||
"HeaderAuthentication": "Authentication",
|
"HeaderAuthentication": "Authenticazione",
|
||||||
"HeaderBackups": "Backup",
|
"HeaderBackups": "Backup",
|
||||||
"HeaderChangePassword": "Cambia Password",
|
"HeaderChangePassword": "Cambia Password",
|
||||||
"HeaderChapters": "Capitoli",
|
"HeaderChapters": "Capitoli",
|
||||||
@ -104,8 +109,9 @@
|
|||||||
"HeaderCollectionItems": "Elementi della Raccolta",
|
"HeaderCollectionItems": "Elementi della Raccolta",
|
||||||
"HeaderCover": "Cover",
|
"HeaderCover": "Cover",
|
||||||
"HeaderCurrentDownloads": "Download Correnti",
|
"HeaderCurrentDownloads": "Download Correnti",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Dettagli",
|
"HeaderDetails": "Dettagli",
|
||||||
"HeaderDownloadQueue": "Download Queue",
|
"HeaderDownloadQueue": "Download coda",
|
||||||
"HeaderEbookFiles": "Ebook File",
|
"HeaderEbookFiles": "Ebook File",
|
||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Settings",
|
"HeaderEmailSettings": "Email Settings",
|
||||||
@ -130,7 +136,7 @@
|
|||||||
"HeaderManageTags": "Gestisci Tags",
|
"HeaderManageTags": "Gestisci Tags",
|
||||||
"HeaderMapDetails": "Mappa Dettagli",
|
"HeaderMapDetails": "Mappa Dettagli",
|
||||||
"HeaderMatch": "Trova Corrispondenza",
|
"HeaderMatch": "Trova Corrispondenza",
|
||||||
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
|
"HeaderMetadataOrderOfPrecedence": "Priorità ordine Metadata",
|
||||||
"HeaderMetadataToEmbed": "Metadata da incorporare",
|
"HeaderMetadataToEmbed": "Metadata da incorporare",
|
||||||
"HeaderNewAccount": "Nuovo Account",
|
"HeaderNewAccount": "Nuovo Account",
|
||||||
"HeaderNewLibrary": "Nuova Libreria",
|
"HeaderNewLibrary": "Nuova Libreria",
|
||||||
@ -199,12 +205,12 @@
|
|||||||
"LabelAuthorLastFirst": "Autori (Per Cognome)",
|
"LabelAuthorLastFirst": "Autori (Per Cognome)",
|
||||||
"LabelAuthors": "Autori",
|
"LabelAuthors": "Autori",
|
||||||
"LabelAutoDownloadEpisodes": "Auto Download Episodi",
|
"LabelAutoDownloadEpisodes": "Auto Download Episodi",
|
||||||
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
|
"LabelAutoFetchMetadata": "Auto controllo Metadata",
|
||||||
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
|
"LabelAutoFetchMetadataHelp": "Recupera i metadati per titolo, autore e serie per semplificare il caricamento. Potrebbe essere necessario abbinare metadati aggiuntivi dopo il caricamento.",
|
||||||
"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": "Reindirizzamento automatico al provider di autenticazione quando si accede alla pagina di accesso (percorso di sostituzione manuale <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Auto Register",
|
"LabelAutoRegister": "Auto Registrazione",
|
||||||
"LabelAutoRegisterDescription": "Automatically create new users after logging in",
|
"LabelAutoRegisterDescription": "Crea automaticamente nuovi utenti dopo aver effettuato l'accesso",
|
||||||
"LabelBackToUser": "Torna a Utenti",
|
"LabelBackToUser": "Torna a Utenti",
|
||||||
"LabelBackupLocation": "Percorso del Backup",
|
"LabelBackupLocation": "Percorso del Backup",
|
||||||
"LabelBackupsEnableAutomaticBackups": "Abilita backup Automatico",
|
"LabelBackupsEnableAutomaticBackups": "Abilita backup Automatico",
|
||||||
@ -215,7 +221,7 @@
|
|||||||
"LabelBackupsNumberToKeepHelp": "Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.",
|
"LabelBackupsNumberToKeepHelp": "Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.",
|
||||||
"LabelBitrate": "Bitrate",
|
"LabelBitrate": "Bitrate",
|
||||||
"LabelBooks": "Libri",
|
"LabelBooks": "Libri",
|
||||||
"LabelButtonText": "Button Text",
|
"LabelButtonText": "Buttone Testo",
|
||||||
"LabelChangePassword": "Cambia Password",
|
"LabelChangePassword": "Cambia Password",
|
||||||
"LabelChannels": "Canali",
|
"LabelChannels": "Canali",
|
||||||
"LabelChapters": "Capitoli",
|
"LabelChapters": "Capitoli",
|
||||||
@ -271,7 +277,7 @@
|
|||||||
"LabelExample": "Esempio",
|
"LabelExample": "Esempio",
|
||||||
"LabelExplicit": "Esplicito",
|
"LabelExplicit": "Esplicito",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed URL",
|
||||||
"LabelFetchingMetadata": "Fetching Metadata",
|
"LabelFetchingMetadata": "Recupero dei metadati",
|
||||||
"LabelFile": "File",
|
"LabelFile": "File",
|
||||||
"LabelFileBirthtime": "Data Creazione",
|
"LabelFileBirthtime": "Data Creazione",
|
||||||
"LabelFileModified": "Ultima modifica",
|
"LabelFileModified": "Ultima modifica",
|
||||||
@ -292,7 +298,7 @@
|
|||||||
"LabelHardDeleteFile": "Elimina Definitivamente",
|
"LabelHardDeleteFile": "Elimina Definitivamente",
|
||||||
"LabelHasEbook": "Un ebook",
|
"LabelHasEbook": "Un ebook",
|
||||||
"LabelHasSupplementaryEbook": "Un ebook Supplementare",
|
"LabelHasSupplementaryEbook": "Un ebook Supplementare",
|
||||||
"LabelHighestPriority": "Highest priority",
|
"LabelHighestPriority": "Priorità Massima",
|
||||||
"LabelHost": "Host",
|
"LabelHost": "Host",
|
||||||
"LabelHour": "Ora",
|
"LabelHour": "Ora",
|
||||||
"LabelIcon": "Icona",
|
"LabelIcon": "Icona",
|
||||||
@ -334,20 +340,20 @@
|
|||||||
"LabelLogLevelInfo": "Info",
|
"LabelLogLevelInfo": "Info",
|
||||||
"LabelLogLevelWarn": "Allarme",
|
"LabelLogLevelWarn": "Allarme",
|
||||||
"LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data",
|
"LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data",
|
||||||
"LabelLowestPriority": "Lowest Priority",
|
"LabelLowestPriority": "Priorità Minima",
|
||||||
"LabelMatchExistingUsersBy": "Match existing users by",
|
"LabelMatchExistingUsersBy": "Abbina gli utenti esistenti per",
|
||||||
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
|
"LabelMatchExistingUsersByDescription": "Utilizzato per connettere gli utenti esistenti. Una volta connessi, gli utenti verranno abbinati a un ID univoco dal tuo provider SSO",
|
||||||
"LabelMediaPlayer": "Media Player",
|
"LabelMediaPlayer": "Media Player",
|
||||||
"LabelMediaType": "Tipo Media",
|
"LabelMediaType": "Tipo Media",
|
||||||
"LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources",
|
"LabelMetadataOrderOfPrecedenceDescription": "Le origini di metadati con priorità più alta sovrascriveranno le origini di metadati con priorità inferiore",
|
||||||
"LabelMetadataProvider": "Metadata Provider",
|
"LabelMetadataProvider": "Metadata Provider",
|
||||||
"LabelMetaTag": "Meta Tag",
|
"LabelMetaTag": "Meta Tag",
|
||||||
"LabelMetaTags": "Meta Tags",
|
"LabelMetaTags": "Meta Tags",
|
||||||
"LabelMinute": "Minuto",
|
"LabelMinute": "Minuto",
|
||||||
"LabelMissing": "Altro",
|
"LabelMissing": "Altro",
|
||||||
"LabelMissingParts": "Parti rimantenti",
|
"LabelMissingParts": "Parti rimanenti",
|
||||||
"LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs",
|
"LabelMobileRedirectURIs": "URI di reindirizzamento mobile consentiti",
|
||||||
"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.",
|
"LabelMobileRedirectURIsDescription": "Questa è una lista bianca di URI di reindirizzamento validi per le app mobili. Quello predefinito è <code>audiobookshelf://oauth</code>, che puoi rimuovere o integrare con URI aggiuntivi per l'integrazione di app di terze parti. Utilizzando un asterisco (<code>*</code>) poiché l'unica voce consente qualsiasi URI.",
|
||||||
"LabelMore": "Molto",
|
"LabelMore": "Molto",
|
||||||
"LabelMoreInfo": "Più Info",
|
"LabelMoreInfo": "Più Info",
|
||||||
"LabelName": "Nome",
|
"LabelName": "Nome",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Metodo di riproduzione",
|
"LabelPlayMethod": "Metodo di riproduzione",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
|
"LabelPodcastSearchRegion": "Area di ricerca podcast",
|
||||||
"LabelPodcastType": "Tipo di Podcast",
|
"LabelPodcastType": "Tipo di Podcast",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)",
|
"LabelPrefixesToIgnore": "Suffissi da ignorare (specificando maiuscole e minuscole)",
|
||||||
@ -406,7 +413,7 @@
|
|||||||
"LabelRecentlyAdded": "Aggiunti Recentemente",
|
"LabelRecentlyAdded": "Aggiunti Recentemente",
|
||||||
"LabelRecentSeries": "Serie Recenti",
|
"LabelRecentSeries": "Serie Recenti",
|
||||||
"LabelRecommended": "Raccomandati",
|
"LabelRecommended": "Raccomandati",
|
||||||
"LabelRedo": "Redo",
|
"LabelRedo": "Rifai",
|
||||||
"LabelRegion": "Regione",
|
"LabelRegion": "Regione",
|
||||||
"LabelReleaseDate": "Data Release",
|
"LabelReleaseDate": "Data Release",
|
||||||
"LabelRemoveCover": "Rimuovi cover",
|
"LabelRemoveCover": "Rimuovi cover",
|
||||||
@ -468,7 +475,7 @@
|
|||||||
"LabelShowAll": "Mostra Tutto",
|
"LabelShowAll": "Mostra Tutto",
|
||||||
"LabelSize": "Dimensione",
|
"LabelSize": "Dimensione",
|
||||||
"LabelSleepTimer": "Sleep timer",
|
"LabelSleepTimer": "Sleep timer",
|
||||||
"LabelSlug": "Slug",
|
"LabelSlug": "Lento",
|
||||||
"LabelStart": "Inizo",
|
"LabelStart": "Inizo",
|
||||||
"LabelStarted": "Iniziato",
|
"LabelStarted": "Iniziato",
|
||||||
"LabelStartedAt": "Iniziato al",
|
"LabelStartedAt": "Iniziato al",
|
||||||
@ -495,9 +502,9 @@
|
|||||||
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
|
"LabelTagsAccessibleToUser": "Tags permessi agli Utenti",
|
||||||
"LabelTagsNotAccessibleToUser": "Tags non accessibile agli Utenti",
|
"LabelTagsNotAccessibleToUser": "Tags non accessibile agli Utenti",
|
||||||
"LabelTasks": "Processi in esecuzione",
|
"LabelTasks": "Processi in esecuzione",
|
||||||
"LabelTextEditorBulletedList": "Bulleted list",
|
"LabelTextEditorBulletedList": "Elenco puntato",
|
||||||
"LabelTextEditorLink": "Link",
|
"LabelTextEditorLink": "Link",
|
||||||
"LabelTextEditorNumberedList": "Numbered list",
|
"LabelTextEditorNumberedList": "Elenco Numerato",
|
||||||
"LabelTextEditorUnlink": "Unlink",
|
"LabelTextEditorUnlink": "Unlink",
|
||||||
"LabelTheme": "Tema",
|
"LabelTheme": "Tema",
|
||||||
"LabelThemeDark": "Scuro",
|
"LabelThemeDark": "Scuro",
|
||||||
@ -520,7 +527,7 @@
|
|||||||
"LabelTrackFromMetadata": "Traccia da Metadata",
|
"LabelTrackFromMetadata": "Traccia da Metadata",
|
||||||
"LabelTracks": "Traccia",
|
"LabelTracks": "Traccia",
|
||||||
"LabelTracksMultiTrack": "Multi-traccia",
|
"LabelTracksMultiTrack": "Multi-traccia",
|
||||||
"LabelTracksNone": "No tracks",
|
"LabelTracksNone": "Nessuna traccia",
|
||||||
"LabelTracksSingleTrack": "Traccia-singola",
|
"LabelTracksSingleTrack": "Traccia-singola",
|
||||||
"LabelType": "Tipo",
|
"LabelType": "Tipo",
|
||||||
"LabelUnabridged": "Integrale",
|
"LabelUnabridged": "Integrale",
|
||||||
@ -533,7 +540,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",
|
"LabelUploaderItemFetchMetadataHelp": "Recupera automaticamente titolo, autore e serie",
|
||||||
"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",
|
||||||
@ -581,7 +588,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?",
|
"MessageConfirmRemoveListeningSessions": "Sei sicuro di voler rimuovere {0} sessioni di Ascolto?",
|
||||||
"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?",
|
||||||
@ -661,7 +668,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",
|
"MessageSelected": "{0} selezionati",
|
||||||
"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}?",
|
||||||
@ -750,7 +757,7 @@
|
|||||||
"ToastSendEbookToDeviceFailed": "Impossibile inviare l'ebook al dispositivo",
|
"ToastSendEbookToDeviceFailed": "Impossibile inviare l'ebook al dispositivo",
|
||||||
"ToastSendEbookToDeviceSuccess": "Ebook inviato al dispositivo \"{0}\"",
|
"ToastSendEbookToDeviceSuccess": "Ebook inviato al dispositivo \"{0}\"",
|
||||||
"ToastSeriesUpdateFailed": "Aggiornamento Serie Fallito",
|
"ToastSeriesUpdateFailed": "Aggiornamento Serie Fallito",
|
||||||
"ToastSeriesUpdateSuccess": "Serie Aggornate",
|
"ToastSeriesUpdateSuccess": "Serie Aggiornate",
|
||||||
"ToastSessionDeleteFailed": "Errore eliminazione sessione",
|
"ToastSessionDeleteFailed": "Errore eliminazione sessione",
|
||||||
"ToastSessionDeleteSuccess": "Sessione cancellata",
|
"ToastSessionDeleteSuccess": "Sessione cancellata",
|
||||||
"ToastSocketConnected": "Socket connesso",
|
"ToastSocketConnected": "Socket connesso",
|
||||||
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "Slėpti",
|
"ButtonHide": "Slėpti",
|
||||||
"ButtonHome": "Pradžia",
|
"ButtonHome": "Pradžia",
|
||||||
"ButtonIssues": "Problemos",
|
"ButtonIssues": "Problemos",
|
||||||
|
"ButtonJumpBackward": "Jump Backward",
|
||||||
|
"ButtonJumpForward": "Jump Forward",
|
||||||
"ButtonLatest": "Naujausias",
|
"ButtonLatest": "Naujausias",
|
||||||
"ButtonLibrary": "Biblioteka",
|
"ButtonLibrary": "Biblioteka",
|
||||||
"ButtonLogout": "Atsijungti",
|
"ButtonLogout": "Atsijungti",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "Pritaikyti visus autorius",
|
"ButtonMatchAllAuthors": "Pritaikyti visus autorius",
|
||||||
"ButtonMatchBooks": "Pritaikyti knygas",
|
"ButtonMatchBooks": "Pritaikyti knygas",
|
||||||
"ButtonNevermind": "Nesvarbu",
|
"ButtonNevermind": "Nesvarbu",
|
||||||
|
"ButtonNextChapter": "Next Chapter",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Atidaryti srautą",
|
"ButtonOpenFeed": "Atidaryti srautą",
|
||||||
"ButtonOpenManager": "Atidaryti tvarkyklę",
|
"ButtonOpenManager": "Atidaryti tvarkyklę",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "Groti",
|
"ButtonPlay": "Groti",
|
||||||
"ButtonPlaying": "Grojama",
|
"ButtonPlaying": "Grojama",
|
||||||
"ButtonPlaylists": "Grojaraščiai",
|
"ButtonPlaylists": "Grojaraščiai",
|
||||||
|
"ButtonPreviousChapter": "Previous Chapter",
|
||||||
"ButtonPurgeAllCache": "Valyti visą saugyklą",
|
"ButtonPurgeAllCache": "Valyti visą saugyklą",
|
||||||
"ButtonPurgeItemsCache": "Valyti elementų saugyklą",
|
"ButtonPurgeItemsCache": "Valyti elementų saugyklą",
|
||||||
"ButtonPurgeMediaProgress": "Valyti medijos progresą",
|
"ButtonPurgeMediaProgress": "Valyti medijos progresą",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "Kolekcijos elementai",
|
"HeaderCollectionItems": "Kolekcijos elementai",
|
||||||
"HeaderCover": "Viršelis",
|
"HeaderCover": "Viršelis",
|
||||||
"HeaderCurrentDownloads": "Dabartiniai parsisiuntimai",
|
"HeaderCurrentDownloads": "Dabartiniai parsisiuntimai",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Detalės",
|
"HeaderDetails": "Detalės",
|
||||||
"HeaderDownloadQueue": "Parsisiuntimo eilė",
|
"HeaderDownloadQueue": "Parsisiuntimo eilė",
|
||||||
"HeaderEbookFiles": "Eknygos failai",
|
"HeaderEbookFiles": "Eknygos failai",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Grojimo metodas",
|
"LabelPlayMethod": "Grojimo metodas",
|
||||||
"LabelPodcast": "Tinklalaidė",
|
"LabelPodcast": "Tinklalaidė",
|
||||||
"LabelPodcasts": "Tinklalaidės",
|
"LabelPodcasts": "Tinklalaidės",
|
||||||
|
"LabelPodcastSearchRegion": "Podcast paieškos regionas",
|
||||||
"LabelPodcastType": "Tinklalaidės tipas",
|
"LabelPodcastType": "Tinklalaidės tipas",
|
||||||
"LabelPort": "Prievadas",
|
"LabelPort": "Prievadas",
|
||||||
"LabelPrefixesToIgnore": "Ignoruojami priešdėliai (didžiosios/mažosios nesvarbu)",
|
"LabelPrefixesToIgnore": "Ignoruojami priešdėliai (didžiosios/mažosios nesvarbu)",
|
||||||
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "Verberg",
|
"ButtonHide": "Verberg",
|
||||||
"ButtonHome": "Home",
|
"ButtonHome": "Home",
|
||||||
"ButtonIssues": "Issues",
|
"ButtonIssues": "Issues",
|
||||||
|
"ButtonJumpBackward": "Jump Backward",
|
||||||
|
"ButtonJumpForward": "Jump Forward",
|
||||||
"ButtonLatest": "Meest recent",
|
"ButtonLatest": "Meest recent",
|
||||||
"ButtonLibrary": "Bibliotheek",
|
"ButtonLibrary": "Bibliotheek",
|
||||||
"ButtonLogout": "Log uit",
|
"ButtonLogout": "Log uit",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "Alle auteurs matchen",
|
"ButtonMatchAllAuthors": "Alle auteurs matchen",
|
||||||
"ButtonMatchBooks": "Alle boeken matchen",
|
"ButtonMatchBooks": "Alle boeken matchen",
|
||||||
"ButtonNevermind": "Laat maar",
|
"ButtonNevermind": "Laat maar",
|
||||||
|
"ButtonNextChapter": "Next Chapter",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Feed openen",
|
"ButtonOpenFeed": "Feed openen",
|
||||||
"ButtonOpenManager": "Manager openen",
|
"ButtonOpenManager": "Manager openen",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "Afspelen",
|
"ButtonPlay": "Afspelen",
|
||||||
"ButtonPlaying": "Speelt",
|
"ButtonPlaying": "Speelt",
|
||||||
"ButtonPlaylists": "Afspeellijsten",
|
"ButtonPlaylists": "Afspeellijsten",
|
||||||
|
"ButtonPreviousChapter": "Previous Chapter",
|
||||||
"ButtonPurgeAllCache": "Volledige cache legen",
|
"ButtonPurgeAllCache": "Volledige cache legen",
|
||||||
"ButtonPurgeItemsCache": "Onderdelen-cache legen",
|
"ButtonPurgeItemsCache": "Onderdelen-cache legen",
|
||||||
"ButtonPurgeMediaProgress": "Mediavoortgang legen",
|
"ButtonPurgeMediaProgress": "Mediavoortgang legen",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "Collectie-objecten",
|
"HeaderCollectionItems": "Collectie-objecten",
|
||||||
"HeaderCover": "Cover",
|
"HeaderCover": "Cover",
|
||||||
"HeaderCurrentDownloads": "Huidige downloads",
|
"HeaderCurrentDownloads": "Huidige downloads",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Details",
|
"HeaderDetails": "Details",
|
||||||
"HeaderDownloadQueue": "Download-wachtrij",
|
"HeaderDownloadQueue": "Download-wachtrij",
|
||||||
"HeaderEbookFiles": "Ebook Files",
|
"HeaderEbookFiles": "Ebook Files",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Afspeelwijze",
|
"LabelPlayMethod": "Afspeelwijze",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
|
"LabelPodcastSearchRegion": "Podcast zoekregio",
|
||||||
"LabelPodcastType": "Podcasttype",
|
"LabelPodcastType": "Podcasttype",
|
||||||
"LabelPort": "Poort",
|
"LabelPort": "Poort",
|
||||||
"LabelPrefixesToIgnore": "Te negeren voorzetsels (ongeacht hoofdlettergebruik)",
|
"LabelPrefixesToIgnore": "Te negeren voorzetsels (ongeacht hoofdlettergebruik)",
|
||||||
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "Gjøm",
|
"ButtonHide": "Gjøm",
|
||||||
"ButtonHome": "Hjem",
|
"ButtonHome": "Hjem",
|
||||||
"ButtonIssues": "Problemer",
|
"ButtonIssues": "Problemer",
|
||||||
|
"ButtonJumpBackward": "Jump Backward",
|
||||||
|
"ButtonJumpForward": "Jump Forward",
|
||||||
"ButtonLatest": "Siste",
|
"ButtonLatest": "Siste",
|
||||||
"ButtonLibrary": "Bibliotek",
|
"ButtonLibrary": "Bibliotek",
|
||||||
"ButtonLogout": "Logg ut",
|
"ButtonLogout": "Logg ut",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "Søk opp alle forfattere",
|
"ButtonMatchAllAuthors": "Søk opp alle forfattere",
|
||||||
"ButtonMatchBooks": "Søk opp bøker",
|
"ButtonMatchBooks": "Søk opp bøker",
|
||||||
"ButtonNevermind": "Avbryt",
|
"ButtonNevermind": "Avbryt",
|
||||||
|
"ButtonNextChapter": "Next Chapter",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Åpne Feed",
|
"ButtonOpenFeed": "Åpne Feed",
|
||||||
"ButtonOpenManager": "Åpne behandler",
|
"ButtonOpenManager": "Åpne behandler",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "Spill av",
|
"ButtonPlay": "Spill av",
|
||||||
"ButtonPlaying": "Spiller av",
|
"ButtonPlaying": "Spiller av",
|
||||||
"ButtonPlaylists": "Spilleliste",
|
"ButtonPlaylists": "Spilleliste",
|
||||||
|
"ButtonPreviousChapter": "Previous Chapter",
|
||||||
"ButtonPurgeAllCache": "Tøm alle mellomlager",
|
"ButtonPurgeAllCache": "Tøm alle mellomlager",
|
||||||
"ButtonPurgeItemsCache": "Tøm mellomlager",
|
"ButtonPurgeItemsCache": "Tøm mellomlager",
|
||||||
"ButtonPurgeMediaProgress": "Slett medie fremgang",
|
"ButtonPurgeMediaProgress": "Slett medie fremgang",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "Samlingsgjenstander",
|
"HeaderCollectionItems": "Samlingsgjenstander",
|
||||||
"HeaderCover": "Omslag",
|
"HeaderCover": "Omslag",
|
||||||
"HeaderCurrentDownloads": "Aktive nedlastinger",
|
"HeaderCurrentDownloads": "Aktive nedlastinger",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Detaljer",
|
"HeaderDetails": "Detaljer",
|
||||||
"HeaderDownloadQueue": "Last ned kø",
|
"HeaderDownloadQueue": "Last ned kø",
|
||||||
"HeaderEbookFiles": "Ebook filer",
|
"HeaderEbookFiles": "Ebook filer",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Avspillingsmetode",
|
"LabelPlayMethod": "Avspillingsmetode",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcaster",
|
"LabelPodcasts": "Podcaster",
|
||||||
|
"LabelPodcastSearchRegion": "Podcast-søkeområde",
|
||||||
"LabelPodcastType": "Podcast type",
|
"LabelPodcastType": "Podcast type",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Prefiks som skal ignoreres (skiller ikke mellom store og små bokstaver)",
|
"LabelPrefixesToIgnore": "Prefiks som skal ignoreres (skiller ikke mellom store og små bokstaver)",
|
||||||
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "Ukryj",
|
"ButtonHide": "Ukryj",
|
||||||
"ButtonHome": "Strona główna",
|
"ButtonHome": "Strona główna",
|
||||||
"ButtonIssues": "Błędy",
|
"ButtonIssues": "Błędy",
|
||||||
|
"ButtonJumpBackward": "Jump Backward",
|
||||||
|
"ButtonJumpForward": "Jump Forward",
|
||||||
"ButtonLatest": "Aktualna wersja:",
|
"ButtonLatest": "Aktualna wersja:",
|
||||||
"ButtonLibrary": "Biblioteka",
|
"ButtonLibrary": "Biblioteka",
|
||||||
"ButtonLogout": "Wyloguj",
|
"ButtonLogout": "Wyloguj",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "Dopasuj wszystkich autorów",
|
"ButtonMatchAllAuthors": "Dopasuj wszystkich autorów",
|
||||||
"ButtonMatchBooks": "Dopasuj książki",
|
"ButtonMatchBooks": "Dopasuj książki",
|
||||||
"ButtonNevermind": "Anuluj",
|
"ButtonNevermind": "Anuluj",
|
||||||
|
"ButtonNextChapter": "Next Chapter",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Otwórz feed",
|
"ButtonOpenFeed": "Otwórz feed",
|
||||||
"ButtonOpenManager": "Otwórz menadżera",
|
"ButtonOpenManager": "Otwórz menadżera",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "Odtwarzaj",
|
"ButtonPlay": "Odtwarzaj",
|
||||||
"ButtonPlaying": "Odtwarzane",
|
"ButtonPlaying": "Odtwarzane",
|
||||||
"ButtonPlaylists": "Playlists",
|
"ButtonPlaylists": "Playlists",
|
||||||
|
"ButtonPreviousChapter": "Previous Chapter",
|
||||||
"ButtonPurgeAllCache": "Wyczyść dane tymczasowe",
|
"ButtonPurgeAllCache": "Wyczyść dane tymczasowe",
|
||||||
"ButtonPurgeItemsCache": "Wyczyść dane tymczasowe pozycji",
|
"ButtonPurgeItemsCache": "Wyczyść dane tymczasowe pozycji",
|
||||||
"ButtonPurgeMediaProgress": "Wyczyść postęp",
|
"ButtonPurgeMediaProgress": "Wyczyść postęp",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "Elementy kolekcji",
|
"HeaderCollectionItems": "Elementy kolekcji",
|
||||||
"HeaderCover": "Okładka",
|
"HeaderCover": "Okładka",
|
||||||
"HeaderCurrentDownloads": "Current Downloads",
|
"HeaderCurrentDownloads": "Current Downloads",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Szczegóły",
|
"HeaderDetails": "Szczegóły",
|
||||||
"HeaderDownloadQueue": "Download Queue",
|
"HeaderDownloadQueue": "Download Queue",
|
||||||
"HeaderEbookFiles": "Ebook Files",
|
"HeaderEbookFiles": "Ebook Files",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Metoda odtwarzania",
|
"LabelPlayMethod": "Metoda odtwarzania",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasty",
|
"LabelPodcasts": "Podcasty",
|
||||||
|
"LabelPodcastSearchRegion": "Obszar wyszukiwania podcastów",
|
||||||
"LabelPodcastType": "Podcast Type",
|
"LabelPodcastType": "Podcast Type",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Ignorowane prefiksy (wielkość liter nie ma znaczenia)",
|
"LabelPrefixesToIgnore": "Ignorowane prefiksy (wielkość liter nie ma znaczenia)",
|
||||||
|
768
client/strings/pt-br.json
Normal file
768
client/strings/pt-br.json
Normal file
@ -0,0 +1,768 @@
|
|||||||
|
{
|
||||||
|
"ButtonAdd": "Adicionar",
|
||||||
|
"ButtonAddChapters": "Adicionar Capítulos",
|
||||||
|
"ButtonAddDevice": "Adicionar Dispositivo",
|
||||||
|
"ButtonAddLibrary": "Adicionar Biblioteca",
|
||||||
|
"ButtonAddPodcasts": "Adicionar Podcasts",
|
||||||
|
"ButtonAddUser": "Adicionar Usuário",
|
||||||
|
"ButtonAddYourFirstLibrary": "Adicionar sua primeira biblioteca",
|
||||||
|
"ButtonApply": "Aplicar",
|
||||||
|
"ButtonApplyChapters": "Aplicar Capítulos",
|
||||||
|
"ButtonAuthors": "Autores",
|
||||||
|
"ButtonBrowseForFolder": "Procurar por Pasta",
|
||||||
|
"ButtonCancel": "Cancelar",
|
||||||
|
"ButtonCancelEncode": "Cancelar Codificação",
|
||||||
|
"ButtonChangeRootPassword": "Alterar senha do administrador",
|
||||||
|
"ButtonCheckAndDownloadNewEpisodes": "Verificar & Baixar Novos Episódios",
|
||||||
|
"ButtonChooseAFolder": "Escolha uma pasta",
|
||||||
|
"ButtonChooseFiles": "Escolha arquivos",
|
||||||
|
"ButtonClearFilter": "Limpar Filtro",
|
||||||
|
"ButtonCloseFeed": "Fechar Feed",
|
||||||
|
"ButtonCollections": "Coleções",
|
||||||
|
"ButtonConfigureScanner": "Configurar Verificador",
|
||||||
|
"ButtonCreate": "Criar",
|
||||||
|
"ButtonCreateBackup": "Criar Backup",
|
||||||
|
"ButtonDelete": "Apagar",
|
||||||
|
"ButtonDownloadQueue": "Fila de download",
|
||||||
|
"ButtonEdit": "Editar",
|
||||||
|
"ButtonEditChapters": "Editar Capítulos",
|
||||||
|
"ButtonEditPodcast": "Editar Podcast",
|
||||||
|
"ButtonForceReScan": "Forcar Nova Verificação",
|
||||||
|
"ButtonFullPath": "Caminho Completo",
|
||||||
|
"ButtonHide": "Ocultar",
|
||||||
|
"ButtonHome": "Principal",
|
||||||
|
"ButtonIssues": "Problemas",
|
||||||
|
"ButtonJumpBackward": "Retroceder",
|
||||||
|
"ButtonJumpForward": "Adiantar",
|
||||||
|
"ButtonLatest": "Mais Recentes",
|
||||||
|
"ButtonLibrary": "Biblioteca",
|
||||||
|
"ButtonLogout": "Logout",
|
||||||
|
"ButtonLookup": "Procurar",
|
||||||
|
"ButtonManageTracks": "Gerenciar Faixas",
|
||||||
|
"ButtonMapChapterTitles": "Designar Títulos de Capítulos",
|
||||||
|
"ButtonMatchAllAuthors": "Consultar Todos os Autores",
|
||||||
|
"ButtonMatchBooks": "Consultar Livros",
|
||||||
|
"ButtonNevermind": "Cancelar",
|
||||||
|
"ButtonNextChapter": "Próximo Capítulo",
|
||||||
|
"ButtonOk": "Ok",
|
||||||
|
"ButtonOpenFeed": "Abrir Feed",
|
||||||
|
"ButtonOpenManager": "Abrir Gerenciador",
|
||||||
|
"ButtonPause": "Pausar",
|
||||||
|
"ButtonPlay": "Reproduzir",
|
||||||
|
"ButtonPlaying": "Reproduzindo",
|
||||||
|
"ButtonPlaylists": "Lista de Reprodução",
|
||||||
|
"ButtonPreviousChapter": "Capítulo Anterior",
|
||||||
|
"ButtonPurgeAllCache": "Apagar Todo o Cache",
|
||||||
|
"ButtonPurgeItemsCache": "Apagar o Cache de Itens",
|
||||||
|
"ButtonPurgeMediaProgress": "Apagar o Progresso nas Mídias",
|
||||||
|
"ButtonQueueAddItem": "Adicionar à Lista",
|
||||||
|
"ButtonQueueRemoveItem": "Remover da Lista",
|
||||||
|
"ButtonQuickMatch": "Consulta rápida",
|
||||||
|
"ButtonRead": "Ler",
|
||||||
|
"ButtonRemove": "Remover",
|
||||||
|
"ButtonRemoveAll": "Remover Todos",
|
||||||
|
"ButtonRemoveAllLibraryItems": "Remover Todos os Itens da Biblioteca",
|
||||||
|
"ButtonRemoveFromContinueListening": "Remover de Continuar Escutando",
|
||||||
|
"ButtonRemoveFromContinueReading": "Remover de Continuar Lendo",
|
||||||
|
"ButtonRemoveSeriesFromContinueSeries": "Remover Série de Continuar Série",
|
||||||
|
"ButtonReScan": "Nova Verificação",
|
||||||
|
"ButtonReset": "Resetar",
|
||||||
|
"ButtonResetToDefault": "Resetar para valores padrão",
|
||||||
|
"ButtonRestore": "Restaurar",
|
||||||
|
"ButtonSave": "Salvar",
|
||||||
|
"ButtonSaveAndClose": "Salvar & Fechar",
|
||||||
|
"ButtonSaveTracklist": "Salvar Lista de Faixas",
|
||||||
|
"ButtonScan": "Verificar",
|
||||||
|
"ButtonScanLibrary": "Verificar Biblioteca",
|
||||||
|
"ButtonSearch": "Pesquisar",
|
||||||
|
"ButtonSelectFolderPath": "Selecionar Caminho da Pasta",
|
||||||
|
"ButtonSeries": "Séries",
|
||||||
|
"ButtonSetChaptersFromTracks": "Definir Capítulos Segundo Faixas",
|
||||||
|
"ButtonShiftTimes": "Deslocar tempos",
|
||||||
|
"ButtonShow": "Exibir",
|
||||||
|
"ButtonStartM4BEncode": "Iniciar Codificação M4B",
|
||||||
|
"ButtonStartMetadataEmbed": "Iniciar Inclusão de Metadados",
|
||||||
|
"ButtonSubmit": "Enviar",
|
||||||
|
"ButtonTest": "Testar",
|
||||||
|
"ButtonUpload": "Upload",
|
||||||
|
"ButtonUploadBackup": "Upload de Backup",
|
||||||
|
"ButtonUploadCover": "Upload de Capa",
|
||||||
|
"ButtonUploadOPMLFile": "Upload Arquivo OPML",
|
||||||
|
"ButtonUserDelete": "Apagar usuário {0}",
|
||||||
|
"ButtonUserEdit": "Editar usuário {0}",
|
||||||
|
"ButtonViewAll": "Ver tudo",
|
||||||
|
"ButtonYes": "Sim",
|
||||||
|
"ErrorUploadFetchMetadataAPI": "Erro buscando metadados",
|
||||||
|
"ErrorUploadFetchMetadataNoResults": "Não foi possível buscar metadados - tente atualizar o título e/ou autor",
|
||||||
|
"ErrorUploadLacksTitle": "É preciso ter um título",
|
||||||
|
"HeaderAccount": "Conta",
|
||||||
|
"HeaderAdvanced": "Avançado",
|
||||||
|
"HeaderAppriseNotificationSettings": "Configuração de notificações Apprise",
|
||||||
|
"HeaderAudiobookTools": "Ferramentas de Gerenciamento de Arquivos de Audiobooks",
|
||||||
|
"HeaderAudioTracks": "Trilhas de áudio",
|
||||||
|
"HeaderAuthentication": "Autenticação",
|
||||||
|
"HeaderBackups": "Backups",
|
||||||
|
"HeaderChangePassword": "Trocar Senha",
|
||||||
|
"HeaderChapters": "Capítulos",
|
||||||
|
"HeaderChooseAFolder": "Escolha uma Pasta",
|
||||||
|
"HeaderCollection": "Coleção",
|
||||||
|
"HeaderCollectionItems": "Itens da Coleção",
|
||||||
|
"HeaderCover": "Capas",
|
||||||
|
"HeaderCurrentDownloads": "Downloads em andamento",
|
||||||
|
"HeaderCustomMetadataProviders": "Fontes de Metadados Customizados",
|
||||||
|
"HeaderDetails": "Detalhes",
|
||||||
|
"HeaderDownloadQueue": "Fila de Download",
|
||||||
|
"HeaderEbookFiles": "Arquivos Ebook",
|
||||||
|
"HeaderEmail": "Email",
|
||||||
|
"HeaderEmailSettings": "Configurações de Email",
|
||||||
|
"HeaderEpisodes": "Episódios",
|
||||||
|
"HeaderEreaderDevices": "Dispositivos Ereader",
|
||||||
|
"HeaderEreaderSettings": "Configurações Ereader",
|
||||||
|
"HeaderFiles": "Arquivos",
|
||||||
|
"HeaderFindChapters": "Localizar Capítulos",
|
||||||
|
"HeaderIgnoredFiles": "Arquivos Ignorados",
|
||||||
|
"HeaderItemFiles": "Arquivos de Itens",
|
||||||
|
"HeaderItemMetadataUtils": "Utilidades para Metadados dos Itens",
|
||||||
|
"HeaderLastListeningSession": "Última sessão",
|
||||||
|
"HeaderLatestEpisodes": "Últimos episódios",
|
||||||
|
"HeaderLibraries": "Bibliotecas",
|
||||||
|
"HeaderLibraryFiles": "Arquivos da Biblioteca",
|
||||||
|
"HeaderLibraryStats": "Estatísticas da Biblioteca",
|
||||||
|
"HeaderListeningSessions": "Sessões",
|
||||||
|
"HeaderListeningStats": "Estatísticas",
|
||||||
|
"HeaderLogin": "Login",
|
||||||
|
"HeaderLogs": "Logs",
|
||||||
|
"HeaderManageGenres": "Gerenciar Gêneros",
|
||||||
|
"HeaderManageTags": "Gerenciar Etiquetas",
|
||||||
|
"HeaderMapDetails": "Designar Detalhes",
|
||||||
|
"HeaderMatch": "Consultar",
|
||||||
|
"HeaderMetadataOrderOfPrecedence": "Ordem de Prioridade dos Metadados",
|
||||||
|
"HeaderMetadataToEmbed": "Metadados a Serem Incluídos",
|
||||||
|
"HeaderNewAccount": "Nova Conta",
|
||||||
|
"HeaderNewLibrary": "Nova Biblioteca",
|
||||||
|
"HeaderNotifications": "Notificações",
|
||||||
|
"HeaderOpenIDConnectAuthentication": "Autenticação via OpenID Connect",
|
||||||
|
"HeaderOpenRSSFeed": "Abrir Feed RSS",
|
||||||
|
"HeaderOtherFiles": "Outros Arquivos",
|
||||||
|
"HeaderPasswordAuthentication": "Autenticação por Senha",
|
||||||
|
"HeaderPermissions": "Permissões",
|
||||||
|
"HeaderPlayerQueue": "Fila do reprodutor",
|
||||||
|
"HeaderPlaylist": "Lista de Reprodução",
|
||||||
|
"HeaderPlaylistItems": "Itens da lista de reprodução",
|
||||||
|
"HeaderPodcastsToAdd": "Podcasts para Adicionar",
|
||||||
|
"HeaderPreviewCover": "Visualização da Capa",
|
||||||
|
"HeaderRemoveEpisode": "Remover Episódio",
|
||||||
|
"HeaderRemoveEpisodes": "Remover {0} Episódios",
|
||||||
|
"HeaderRSSFeedGeneral": "Detalhes RSS",
|
||||||
|
"HeaderRSSFeedIsOpen": "Feed RSS está aberto",
|
||||||
|
"HeaderRSSFeeds": "Feeds RSS",
|
||||||
|
"HeaderSavedMediaProgress": "Progresso da gravação das mídias",
|
||||||
|
"HeaderSchedule": "Programação",
|
||||||
|
"HeaderScheduleLibraryScans": "Programar Verificação Automática da Biblioteca",
|
||||||
|
"HeaderSession": "Sessão",
|
||||||
|
"HeaderSetBackupSchedule": "Definir Programação de Backup",
|
||||||
|
"HeaderSettings": "Configurações",
|
||||||
|
"HeaderSettingsDisplay": "Exibição",
|
||||||
|
"HeaderSettingsExperimental": "Funcionalidades experimentais",
|
||||||
|
"HeaderSettingsGeneral": "Geral",
|
||||||
|
"HeaderSettingsScanner": "Verificador",
|
||||||
|
"HeaderSleepTimer": "Timer",
|
||||||
|
"HeaderStatsLargestItems": "Maiores Itens",
|
||||||
|
"HeaderStatsLongestItems": "Itens mais longos (hrs)",
|
||||||
|
"HeaderStatsMinutesListeningChart": "Minutos Escutados (últimos 7 dias)",
|
||||||
|
"HeaderStatsRecentSessions": "Sessões Recentes",
|
||||||
|
"HeaderStatsTop10Authors": "Top 10 Autores",
|
||||||
|
"HeaderStatsTop5Genres": "Top 5 Gêneros",
|
||||||
|
"HeaderTableOfContents": "Sumário",
|
||||||
|
"HeaderTools": "Ferramentas",
|
||||||
|
"HeaderUpdateAccount": "Atualizar Conta",
|
||||||
|
"HeaderUpdateAuthor": "Atualizar Autor",
|
||||||
|
"HeaderUpdateDetails": "Atualizar Detalhes",
|
||||||
|
"HeaderUpdateLibrary": "Atualizar Biblioteca",
|
||||||
|
"HeaderUsers": "Usuários",
|
||||||
|
"HeaderYourStats": "Suas Estatísticas",
|
||||||
|
"LabelAbridged": "Versão Abreviada",
|
||||||
|
"LabelAccountType": "Tipo de Conta",
|
||||||
|
"LabelAccountTypeAdmin": "Administrador",
|
||||||
|
"LabelAccountTypeGuest": "Convidado",
|
||||||
|
"LabelAccountTypeUser": "Usuário",
|
||||||
|
"LabelActivity": "Atividade",
|
||||||
|
"LabelAdded": "Acrescentado",
|
||||||
|
"LabelAddedAt": "Acrescentado em",
|
||||||
|
"LabelAddToCollection": "Adicionar à Coleção",
|
||||||
|
"LabelAddToCollectionBatch": "Adicionar {0} Livros à Coleção",
|
||||||
|
"LabelAddToPlaylist": "Adicionar à Lista de Reprodução",
|
||||||
|
"LabelAddToPlaylistBatch": "Adicionar {0} itens à Lista de Reprodução",
|
||||||
|
"LabelAdminUsersOnly": "Apenas usuários administradores",
|
||||||
|
"LabelAll": "Todos",
|
||||||
|
"LabelAllUsers": "Todos Usuários",
|
||||||
|
"LabelAllUsersExcludingGuests": "Todos usuários exceto convidados",
|
||||||
|
"LabelAllUsersIncludingGuests": "Todos usuários incluindo convidados",
|
||||||
|
"LabelAlreadyInYourLibrary": "Já na sua biblioteca",
|
||||||
|
"LabelAppend": "Acrescentar",
|
||||||
|
"LabelAuthor": "Autor",
|
||||||
|
"LabelAuthorFirstLast": "Autor (Nome Sobrenome)",
|
||||||
|
"LabelAuthorLastFirst": "Autor (Sobrenome, Nome)",
|
||||||
|
"LabelAuthors": "Autores",
|
||||||
|
"LabelAutoDownloadEpisodes": "Download Automático de Episódios",
|
||||||
|
"LabelAutoFetchMetadata": "Buscar Metadados Automaticamente",
|
||||||
|
"LabelAutoFetchMetadataHelp": "Busca metadados de título, autor e série para otimizar o upload. Pode ser necessário buscas metadados adicionais após o upload.",
|
||||||
|
"LabelAutoLaunch": "Iniciar Automaticamente",
|
||||||
|
"LabelAutoLaunchDescription": "Redireciona para o fornecedor de autenticação automaticamente ao navegar para a tela de login (caminho para substituição manual <code>/login?autoLaunch=0</code>)",
|
||||||
|
"LabelAutoRegister": "Registrar Automaticamente",
|
||||||
|
"LabelAutoRegisterDescription": "Registra automaticamente novos usuários após login",
|
||||||
|
"LabelBackToUser": "Voltar para Usuário",
|
||||||
|
"LabelBackupLocation": "Localização do Backup",
|
||||||
|
"LabelBackupsEnableAutomaticBackups": "Ativar backups automáticos",
|
||||||
|
"LabelBackupsEnableAutomaticBackupsHelp": "Backups salvos em /metadata/backups",
|
||||||
|
"LabelBackupsMaxBackupSize": "Tamanho máximo do backup (em GB)",
|
||||||
|
"LabelBackupsMaxBackupSizeHelp": "Como proteção contra uma configuração incorreta, backups darão erro se excederem o tamanho configurado.",
|
||||||
|
"LabelBackupsNumberToKeep": "Número de backups para guardar",
|
||||||
|
"LabelBackupsNumberToKeepHelp": "Apenas 1 backup será removido por vez, então, se já existem mais backups, você deve apagá-los manualmente.",
|
||||||
|
"LabelBitrate": "Bitrate",
|
||||||
|
"LabelBooks": "Livros",
|
||||||
|
"LabelButtonText": "Texto do botão",
|
||||||
|
"LabelChangePassword": "Trocar Senha",
|
||||||
|
"LabelChannels": "Canais",
|
||||||
|
"LabelChapters": "Capítulos",
|
||||||
|
"LabelChaptersFound": "capítulos encontrados",
|
||||||
|
"LabelChapterTitle": "Título do Capítulo",
|
||||||
|
"LabelClickForMoreInfo": "Clique para mais informações",
|
||||||
|
"LabelClosePlayer": "Fechar Reprodutor",
|
||||||
|
"LabelCodec": "Codec",
|
||||||
|
"LabelCollapseSeries": "Fechar Série",
|
||||||
|
"LabelCollection": "Coleção",
|
||||||
|
"LabelCollections": "Coleções",
|
||||||
|
"LabelComplete": "Concluído",
|
||||||
|
"LabelConfirmPassword": "Confirmar Senha",
|
||||||
|
"LabelContinueListening": "Continuar Escutando",
|
||||||
|
"LabelContinueReading": "Continuar Lendo",
|
||||||
|
"LabelContinueSeries": "Continuar Série",
|
||||||
|
"LabelCover": "Capa",
|
||||||
|
"LabelCoverImageURL": "URL da Imagem da Capa",
|
||||||
|
"LabelCreatedAt": "Criado em",
|
||||||
|
"LabelCronExpression": "Expressão para o Cron",
|
||||||
|
"LabelCurrent": "Atual",
|
||||||
|
"LabelCurrently": "Atualmente:",
|
||||||
|
"LabelCustomCronExpression": "Expressão personalizada para o Cron:",
|
||||||
|
"LabelDatetime": "Data e Hora",
|
||||||
|
"LabelDeleteFromFileSystemCheckbox": "Apagar do sistema de arquivos (desmarcar para remover apenas da base de dados)",
|
||||||
|
"LabelDescription": "Descrição",
|
||||||
|
"LabelDeselectAll": "Desmarcar tudo",
|
||||||
|
"LabelDevice": "Dispositivo",
|
||||||
|
"LabelDeviceInfo": "Informação do Dispositivo",
|
||||||
|
"LabelDeviceIsAvailableTo": "Dispositivo está disponível para...",
|
||||||
|
"LabelDirectory": "Diretório",
|
||||||
|
"LabelDiscFromFilename": "Disco a partir do nome do arquivo",
|
||||||
|
"LabelDiscFromMetadata": "Disco a partir dos metadados",
|
||||||
|
"LabelDiscover": "Descobrir",
|
||||||
|
"LabelDownload": "Download",
|
||||||
|
"LabelDownloadNEpisodes": "Download de {0} Episódios",
|
||||||
|
"LabelDuration": "Duração",
|
||||||
|
"LabelDurationFound": "Duração comprovada:",
|
||||||
|
"LabelEbook": "Ebook",
|
||||||
|
"LabelEbooks": "Ebooks",
|
||||||
|
"LabelEdit": "Editar",
|
||||||
|
"LabelEmail": "Email",
|
||||||
|
"LabelEmailSettingsFromAddress": "Remetente",
|
||||||
|
"LabelEmailSettingsSecure": "Seguro",
|
||||||
|
"LabelEmailSettingsSecureHelp": "Se ativado, a conexão utilizará TLS para a conexão ao servidor. Se desativado TLS será usado se o servidor suportar a extensão STARTTLS. Na maioria dos casos ative esse valor se estiver conectando pela porta 465. Para portas 587 ou 25, mantenha inativo. (de nodemailer.com/smtp/#authentication)",
|
||||||
|
"LabelEmailSettingsTestAddress": "Endereço de teste",
|
||||||
|
"LabelEmbeddedCover": "Capa Integrada",
|
||||||
|
"LabelEnable": "Habilitar",
|
||||||
|
"LabelEnd": "Fim",
|
||||||
|
"LabelEpisode": "Episódio",
|
||||||
|
"LabelEpisodeTitle": "Título do Episódio",
|
||||||
|
"LabelEpisodeType": "Tipo do Episódio",
|
||||||
|
"LabelExample": "Exemplo",
|
||||||
|
"LabelExplicit": "Explícito",
|
||||||
|
"LabelFeedURL": "URL do Feed",
|
||||||
|
"LabelFetchingMetadata": "Buscando Metadados",
|
||||||
|
"LabelFile": "Arquivo",
|
||||||
|
"LabelFileBirthtime": "Criação do Arquivo",
|
||||||
|
"LabelFileModified": "Modificação do Arquivo",
|
||||||
|
"LabelFilename": "Nome do Arquivo",
|
||||||
|
"LabelFilterByUser": "Filtrar por Usuário",
|
||||||
|
"LabelFindEpisodes": "Localizar Episódios",
|
||||||
|
"LabelFinished": "Concluído",
|
||||||
|
"LabelFolder": "Pasta",
|
||||||
|
"LabelFolders": "Pastas",
|
||||||
|
"LabelFontBold": "Negrito",
|
||||||
|
"LabelFontFamily": "Família de fonte",
|
||||||
|
"LabelFontItalic": "Itálico",
|
||||||
|
"LabelFontScale": "Escala de fonte",
|
||||||
|
"LabelFontStrikethrough": "Tachado",
|
||||||
|
"LabelFormat": "Formato",
|
||||||
|
"LabelGenre": "Gênero",
|
||||||
|
"LabelGenres": "Gêneros",
|
||||||
|
"LabelHardDeleteFile": "Apagar definitivamente",
|
||||||
|
"LabelHasEbook": "Tem ebook",
|
||||||
|
"LabelHasSupplementaryEbook": "Tem ebook complementar",
|
||||||
|
"LabelHighestPriority": "Prioridade mais alta",
|
||||||
|
"LabelHost": "Host",
|
||||||
|
"LabelHour": "Hora",
|
||||||
|
"LabelIcon": "Ícone",
|
||||||
|
"LabelImageURLFromTheWeb": "URL da imagem na internet",
|
||||||
|
"LabelIncludeInTracklist": "Incluir na Lista de Faixas",
|
||||||
|
"LabelIncomplete": "Incompleto",
|
||||||
|
"LabelInProgress": "Em Andamento",
|
||||||
|
"LabelInterval": "Intervalo",
|
||||||
|
"LabelIntervalCustomDailyWeekly": "Personalizar diário/semanal",
|
||||||
|
"LabelIntervalEvery12Hours": "A cada 12 horas",
|
||||||
|
"LabelIntervalEvery15Minutes": "A cada 15 minutos",
|
||||||
|
"LabelIntervalEvery2Hours": "A cada 2 horas",
|
||||||
|
"LabelIntervalEvery30Minutes": "A cada 30 minutos",
|
||||||
|
"LabelIntervalEvery6Hours": "A cada 6 horas",
|
||||||
|
"LabelIntervalEveryDay": "Todo dia",
|
||||||
|
"LabelIntervalEveryHour": "Toda hora",
|
||||||
|
"LabelInvalidParts": "Partes Inválidas",
|
||||||
|
"LabelInvert": "Inverter",
|
||||||
|
"LabelItem": "Item",
|
||||||
|
"LabelLanguage": "Idioma",
|
||||||
|
"LabelLanguageDefaultServer": "Idioma Padrão do Servidor",
|
||||||
|
"LabelLastBookAdded": "Último Livro Acrescentado",
|
||||||
|
"LabelLastBookUpdated": "Último Livro Atualizado",
|
||||||
|
"LabelLastSeen": "Visto pela Última Vez",
|
||||||
|
"LabelLastTime": "Progresso",
|
||||||
|
"LabelLastUpdate": "Última Atualização",
|
||||||
|
"LabelLayout": "Layout",
|
||||||
|
"LabelLayoutSinglePage": "Uma página",
|
||||||
|
"LabelLayoutSplitPage": "Página dividida",
|
||||||
|
"LabelLess": "Menos",
|
||||||
|
"LabelLibrariesAccessibleToUser": "Bibliotecas Acessíveis ao Usuário",
|
||||||
|
"LabelLibrary": "Biblioteca",
|
||||||
|
"LabelLibraryItem": "Item da Biblioteca",
|
||||||
|
"LabelLibraryName": "Nome da Biblioteca",
|
||||||
|
"LabelLimit": "Limite",
|
||||||
|
"LabelLineSpacing": "Espaçamento entre linhas",
|
||||||
|
"LabelListenAgain": "Escutar novamente",
|
||||||
|
"LabelLogLevelDebug": "Debug",
|
||||||
|
"LabelLogLevelInfo": "Info",
|
||||||
|
"LabelLogLevelWarn": "Atenção",
|
||||||
|
"LabelLookForNewEpisodesAfterDate": "Procurar por novos Episódios após essa data",
|
||||||
|
"LabelLowestPriority": "Prioridade mais baixa",
|
||||||
|
"LabelMatchExistingUsersBy": "Consultar usuários existentes usando",
|
||||||
|
"LabelMatchExistingUsersByDescription": "Utilizado para conectar usuários já existentes. Uma vez conectados, usuários serão consultados utilizando uma identificação única do seu provedor de SSO",
|
||||||
|
"LabelMediaPlayer": "Reprodutor de mídia",
|
||||||
|
"LabelMediaType": "Tipo de Mídia",
|
||||||
|
"LabelMetadataOrderOfPrecedenceDescription": "Fontes de metadados de alta prioridade terão preferência sobre as fontes de metadados de prioridade baixa",
|
||||||
|
"LabelMetadataProvider": "Fonte de Metadados",
|
||||||
|
"LabelMetaTag": "Etiqueta Meta",
|
||||||
|
"LabelMetaTags": "Etiquetas Meta",
|
||||||
|
"LabelMinute": "Minuto",
|
||||||
|
"LabelMissing": "Ausente",
|
||||||
|
"LabelMissingParts": "Partes Ausentes",
|
||||||
|
"LabelMobileRedirectURIs": "URIs de redirecionamento móveis permitidas",
|
||||||
|
"LabelMobileRedirectURIsDescription": "Essa é uma lista de permissionamento para URIs válidas para o redirecionamento de aplicativos móveis. A padrão é <code>audiobookshelf://oauth</code>, que pode ser removida ou acrescentada com novas URIs para integração com apps de terceiros. Usando um asterisco (<code>*</code>) como um item único dará permissão para qualquer URI.",
|
||||||
|
"LabelMore": "Mais",
|
||||||
|
"LabelMoreInfo": "Mais Informações",
|
||||||
|
"LabelName": "Nome",
|
||||||
|
"LabelNarrator": "Narrador",
|
||||||
|
"LabelNarrators": "Narradores",
|
||||||
|
"LabelNew": "Novo",
|
||||||
|
"LabelNewestAuthors": "Novos Autores",
|
||||||
|
"LabelNewestEpisodes": "Episódios mais recentes",
|
||||||
|
"LabelNewPassword": "Nova Senha",
|
||||||
|
"LabelNextBackupDate": "Data do próximo backup",
|
||||||
|
"LabelNextScheduledRun": "Próxima execução programada",
|
||||||
|
"LabelNoEpisodesSelected": "Nenhum episódio selecionado",
|
||||||
|
"LabelNotes": "Notas",
|
||||||
|
"LabelNotFinished": "Não concluído",
|
||||||
|
"LabelNotificationAppriseURL": "URL(s) Apprise",
|
||||||
|
"LabelNotificationAvailableVariables": "Variáveis disponíveis",
|
||||||
|
"LabelNotificationBodyTemplate": "Modelo de Corpo",
|
||||||
|
"LabelNotificationEvent": "Evento de Notificação",
|
||||||
|
"LabelNotificationsMaxFailedAttempts": "Máximo de tentativas com falhas",
|
||||||
|
"LabelNotificationsMaxFailedAttemptsHelp": "Notificações serão desabilitadas após falharem este número de vezes",
|
||||||
|
"LabelNotificationsMaxQueueSize": "Tamanho máximo da fila de eventos de notificação",
|
||||||
|
"LabelNotificationsMaxQueueSizeHelp": "Eventos estão limitados a um disparo por segundo. Eventos serão ignorados se a fila estiver no tamanho máximo. Isso evita o excesso de notificações.",
|
||||||
|
"LabelNotificationTitleTemplate": "Modelo de Título",
|
||||||
|
"LabelNotStarted": "Não iniciado",
|
||||||
|
"LabelNumberOfBooks": "Número de Livros",
|
||||||
|
"LabelNumberOfEpisodes": "# de Episódios",
|
||||||
|
"LabelOpenRSSFeed": "Abrir Feed RSS",
|
||||||
|
"LabelOverwrite": "Sobrescrever",
|
||||||
|
"LabelPassword": "Senha",
|
||||||
|
"LabelPath": "Caminho",
|
||||||
|
"LabelPermissionsAccessAllLibraries": "Pode Acessar Todas Bibliotecas",
|
||||||
|
"LabelPermissionsAccessAllTags": "Pode Acessar Todas as Etiquetas",
|
||||||
|
"LabelPermissionsAccessExplicitContent": "Pode Acessar Conteúdos Explícitos",
|
||||||
|
"LabelPermissionsDelete": "Pode Apagar",
|
||||||
|
"LabelPermissionsDownload": "Pode Fazer Download",
|
||||||
|
"LabelPermissionsUpdate": "Pode Atualizar",
|
||||||
|
"LabelPermissionsUpload": "Pode Fazer Upload",
|
||||||
|
"LabelPhotoPathURL": "Caminho/URL para Foto",
|
||||||
|
"LabelPlaylists": "Listas de Reprodução",
|
||||||
|
"LabelPlayMethod": "Método de Reprodução",
|
||||||
|
"LabelPodcast": "Podcast",
|
||||||
|
"LabelPodcasts": "Podcasts",
|
||||||
|
"LabelPodcastSearchRegion": "Podcast search region",
|
||||||
|
"LabelPodcastType": "Tipo de Podcast",
|
||||||
|
"LabelPort": "Porta",
|
||||||
|
"LabelPrefixesToIgnore": "Prefixos para Ignorar (sem distinção entre maiúsculas e minúsculas)",
|
||||||
|
"LabelPreventIndexing": "Evitar que o seu feed seja indexado pelos diretórios de podcast do iTunes e Google",
|
||||||
|
"LabelPrimaryEbook": "Ebook principal",
|
||||||
|
"LabelProgress": "Progresso",
|
||||||
|
"LabelProvider": "Fonte",
|
||||||
|
"LabelPubDate": "Data de Publicação",
|
||||||
|
"LabelPublisher": "Editora",
|
||||||
|
"LabelPublishYear": "Ano de Publicação",
|
||||||
|
"LabelRead": "Lido",
|
||||||
|
"LabelReadAgain": "Ler novamente",
|
||||||
|
"LabelReadEbookWithoutProgress": "Ler ebook sem armazenar progresso",
|
||||||
|
"LabelRecentlyAdded": "Novidades",
|
||||||
|
"LabelRecentSeries": "Séries Recentes",
|
||||||
|
"LabelRecommended": "Recomendado",
|
||||||
|
"LabelRedo": "Refazer",
|
||||||
|
"LabelRegion": "Região",
|
||||||
|
"LabelReleaseDate": "Data de Lançamento",
|
||||||
|
"LabelRemoveCover": "Remover capa",
|
||||||
|
"LabelRowsPerPage": "Linhas por Página",
|
||||||
|
"LabelRSSFeedCustomOwnerEmail": "Email do dono personalizado",
|
||||||
|
"LabelRSSFeedCustomOwnerName": "Nome do dono personalizado",
|
||||||
|
"LabelRSSFeedOpen": "Feed RSS Aberto",
|
||||||
|
"LabelRSSFeedPreventIndexing": "Impedir Indexação",
|
||||||
|
"LabelRSSFeedSlug": "Slug do Feed RSS",
|
||||||
|
"LabelRSSFeedURL": "URL do Feed RSS",
|
||||||
|
"LabelSearchTerm": "Busca por Termo",
|
||||||
|
"LabelSearchTitle": "Busca por Título",
|
||||||
|
"LabelSearchTitleOrASIN": "Busca por Título ou ASIN",
|
||||||
|
"LabelSeason": "Temporada",
|
||||||
|
"LabelSelectAllEpisodes": "Selecionar todos os Episódios",
|
||||||
|
"LabelSelectEpisodesShowing": "Selecionar os {0} Episódios Visíveis",
|
||||||
|
"LabelSelectUsers": "Selecionar usuários",
|
||||||
|
"LabelSendEbookToDevice": "Enviar Ebook para...",
|
||||||
|
"LabelSequence": "Sequência",
|
||||||
|
"LabelSeries": "Série",
|
||||||
|
"LabelSeriesName": "Nome da Série",
|
||||||
|
"LabelSeriesProgress": "Progresso da Série",
|
||||||
|
"LabelSetEbookAsPrimary": "Definir como principal",
|
||||||
|
"LabelSetEbookAsSupplementary": "Definir como complementar",
|
||||||
|
"LabelSettingsAudiobooksOnly": "Apenas Audiobooks",
|
||||||
|
"LabelSettingsAudiobooksOnlyHelp": "Ao ativar essa configuração os arquivos de ebooks serão ignorados a não ser que estejam dentro de uma pasta com um audiobook. Nesse caso eles serão definidos como ebooks complementares",
|
||||||
|
"LabelSettingsBookshelfViewHelp": "Aparência esqueomorfa com prateleiras de madeira",
|
||||||
|
"LabelSettingsChromecastSupport": "Suporte ao Chromecast",
|
||||||
|
"LabelSettingsDateFormat": "Formato de data",
|
||||||
|
"LabelSettingsDisableWatcher": "Desativar Monitoramento",
|
||||||
|
"LabelSettingsDisableWatcherForLibrary": "Desativa o monitoramento de pastas para a biblioteca",
|
||||||
|
"LabelSettingsDisableWatcherHelp": "Desativa o acréscimo/atualização de itens quando forem detectadas mudanças no arquivo. *Requer reiniciar o servidor",
|
||||||
|
"LabelSettingsEnableWatcher": "Ativar Monitoramento",
|
||||||
|
"LabelSettingsEnableWatcherForLibrary": "Ativa o monitoramento de pastas para a biblioteca",
|
||||||
|
"LabelSettingsEnableWatcherHelp": "Ativa o acréscimo/atualização de itens quando forem detectadas mudanças no arquivo. *Requer reiniciar o servidor",
|
||||||
|
"LabelSettingsExperimentalFeatures": "Funcionalidade experimentais",
|
||||||
|
"LabelSettingsExperimentalFeaturesHelp": "Funcionalidade em desenvolvimento que se beneficiairam dos seus comentários e da sua ajuda para testar. Clique para abrir a discussão no github.",
|
||||||
|
"LabelSettingsFindCovers": "Localizar capas",
|
||||||
|
"LabelSettingsFindCoversHelp": "Se o seu audiobook não tiver uma capa incluída ou uma imagem de capa na pasta, o verificador tentará localizar uma capa.<br>Atenção: Isso irá estender o tempo de análise",
|
||||||
|
"LabelSettingsHideSingleBookSeries": "Ocultar séries com um só livro",
|
||||||
|
"LabelSettingsHideSingleBookSeriesHelp": "Séries com um só livro serão ocultadas na página de séries e na prateleira de séries na página principal.",
|
||||||
|
"LabelSettingsHomePageBookshelfView": "Usar visão estante na página principal",
|
||||||
|
"LabelSettingsLibraryBookshelfView": "Usar visão estante na página da biblioteca",
|
||||||
|
"LabelSettingsParseSubtitles": "Analisar subtítulos",
|
||||||
|
"LabelSettingsParseSubtitlesHelp": "Extrair subtítulos do nome da pasta do audiobook.<br>Subtítulo deve estar separado por \" - \"<br>ex: \"Título do Livro - Um Subtítulo Aqui\" tem o subtítulo \"Um Subtítulo Aqui\"",
|
||||||
|
"LabelSettingsPreferMatchedMetadata": "Preferir metadados consultados",
|
||||||
|
"LabelSettingsPreferMatchedMetadataHelp": "Dados consultados serão priorizados sobre os detalhes do item quando usada a Consulta Rápida. Por padrão, Consulta Rápida só preencherá os detalhes ausentes.",
|
||||||
|
"LabelSettingsSkipMatchingBooksWithASIN": "Pular consulta de livros que já têm um ASIN",
|
||||||
|
"LabelSettingsSkipMatchingBooksWithISBN": "Pular consulta de livros que já têm um ISBN",
|
||||||
|
"LabelSettingsSortingIgnorePrefixes": "Ignorar prefixos ao ordenar",
|
||||||
|
"LabelSettingsSortingIgnorePrefixesHelp": "ex: o prefixo \"o\" do título \"O Título do Livro\" seria ordenado como \"Título do Livro, O\"",
|
||||||
|
"LabelSettingsSquareBookCovers": "Usar capas de livro quadradas",
|
||||||
|
"LabelSettingsSquareBookCoversHelp": "Preferir capas quadradas ao invés das capas 1.6:1 padrão",
|
||||||
|
"LabelSettingsStoreCoversWithItem": "Armazenar capas com o item",
|
||||||
|
"LabelSettingsStoreCoversWithItemHelp": "Por padrão as capas são armazenadas em /metadata/items. Ao ativar essa configuração as capas serão armazenadas na pasta do item na sua biblioteca. Apenas um arquivo chamado \"cover\" será mantido",
|
||||||
|
"LabelSettingsStoreMetadataWithItem": "Armazenar metadados com o item",
|
||||||
|
"LabelSettingsStoreMetadataWithItemHelp": "Por padrão os arquivos de metadados são armazenados em /metadata/items. Ao ativar essa configuração os arquivos de metadados serão armazenadas nas pastas dos itens na sua biblioteca",
|
||||||
|
"LabelSettingsTimeFormat": "Formato da Tempo",
|
||||||
|
"LabelShowAll": "Exibir Todos",
|
||||||
|
"LabelSize": "Tamanho",
|
||||||
|
"LabelSleepTimer": "Timer",
|
||||||
|
"LabelSlug": "Slug",
|
||||||
|
"LabelStart": "Iniciar",
|
||||||
|
"LabelStarted": "Iniciado",
|
||||||
|
"LabelStartedAt": "Iniciado Em",
|
||||||
|
"LabelStartTime": "Horário do Início",
|
||||||
|
"LabelStatsAudioTracks": "Trilhas de Áudio",
|
||||||
|
"LabelStatsAuthors": "Autores",
|
||||||
|
"LabelStatsBestDay": "Melhor Dia",
|
||||||
|
"LabelStatsDailyAverage": "Média Diária",
|
||||||
|
"LabelStatsDays": "Dias",
|
||||||
|
"LabelStatsDaysListened": "Dias Escutando",
|
||||||
|
"LabelStatsHours": "Horas",
|
||||||
|
"LabelStatsInARow": "seguidos",
|
||||||
|
"LabelStatsItemsFinished": "itens Concluídos",
|
||||||
|
"LabelStatsItemsInLibrary": "itens na biblioteca",
|
||||||
|
"LabelStatsMinutes": "minutos",
|
||||||
|
"LabelStatsMinutesListening": "Minutos Escutando",
|
||||||
|
"LabelStatsOverallDays": "Total de Dias",
|
||||||
|
"LabelStatsOverallHours": "Total de Horas",
|
||||||
|
"LabelStatsWeekListening": "Tempo escutando na semana",
|
||||||
|
"LabelSubtitle": "Subtítulo",
|
||||||
|
"LabelSupportedFileTypes": "Tipos de arquivos suportados",
|
||||||
|
"LabelTag": "Etiqueta",
|
||||||
|
"LabelTags": "Etiquetas",
|
||||||
|
"LabelTagsAccessibleToUser": "Etiquetas Acessíveis ao Usuário",
|
||||||
|
"LabelTagsNotAccessibleToUser": "Etiquetas não Acessíveis Usuário",
|
||||||
|
"LabelTasks": "Tarefas em Execuçào",
|
||||||
|
"LabelTextEditorBulletedList": "Lista com marcadores",
|
||||||
|
"LabelTextEditorLink": "Link",
|
||||||
|
"LabelTextEditorNumberedList": "Lista numerada",
|
||||||
|
"LabelTextEditorUnlink": "Remover link",
|
||||||
|
"LabelTheme": "Tema",
|
||||||
|
"LabelThemeDark": "Escuro",
|
||||||
|
"LabelThemeLight": "Claro",
|
||||||
|
"LabelTimeBase": "Base de tempo",
|
||||||
|
"LabelTimeListened": "Tempo de escuta",
|
||||||
|
"LabelTimeListenedToday": "Tempo de escuta hoje",
|
||||||
|
"LabelTimeRemaining": "{0} restantes",
|
||||||
|
"LabelTimeToShift": "Deslocamento de tempo em segundos",
|
||||||
|
"LabelTitle": "Título",
|
||||||
|
"LabelToolsEmbedMetadata": "Incluir Metadados",
|
||||||
|
"LabelToolsEmbedMetadataDescription": "Incluir metadados no arquivo de áudio, com imagem da capa e capítulos.",
|
||||||
|
"LabelToolsMakeM4b": "Gerar audiobook no formato M4B",
|
||||||
|
"LabelToolsMakeM4bDescription": "Gerar um arquivo de audiobook no formato .M4B com metadados, imagem da capa e capítulos.",
|
||||||
|
"LabelToolsSplitM4b": "Dividir um M4B em MP3s",
|
||||||
|
"LabelToolsSplitM4bDescription": "Criar arquivos MP3s a partir da divisão de um M4B em capítulos, com metadados e imagem de capa.",
|
||||||
|
"LabelTotalDuration": "Duração Total",
|
||||||
|
"LabelTotalTimeListened": "Tempo Total Escutado",
|
||||||
|
"LabelTrackFromFilename": "Trilha a partir do nome do arquivo",
|
||||||
|
"LabelTrackFromMetadata": "Trilha a partir dos Metadados",
|
||||||
|
"LabelTracks": "Trilhas",
|
||||||
|
"LabelTracksMultiTrack": "Várias trilhas",
|
||||||
|
"LabelTracksNone": "Sem trilha",
|
||||||
|
"LabelTracksSingleTrack": "Trilha única",
|
||||||
|
"LabelType": "Tipo",
|
||||||
|
"LabelUnabridged": "Não Abreviada",
|
||||||
|
"LabelUndo": "Undo",
|
||||||
|
"LabelUnknown": "Desconhecido",
|
||||||
|
"LabelUpdateCover": "Atualizar Capa",
|
||||||
|
"LabelUpdateCoverHelp": "Permite sobrescrever capas existentes para os livros selecionados quando uma consulta for localizada",
|
||||||
|
"LabelUpdatedAt": "Atualizado em",
|
||||||
|
"LabelUpdateDetails": "Atualizar Detalhes",
|
||||||
|
"LabelUpdateDetailsHelp": "Permite sobrescrever detalhes existentes para os livros selecionados quando uma consulta for localizada",
|
||||||
|
"LabelUploaderDragAndDrop": "Arraste e solte arquivos ou pastas",
|
||||||
|
"LabelUploaderDropFiles": "Solte os arquivos",
|
||||||
|
"LabelUploaderItemFetchMetadataHelp": "Busca título, autor e série automaticamente",
|
||||||
|
"LabelUseChapterTrack": "Usar a trilha do capítulo",
|
||||||
|
"LabelUseFullTrack": "Usar a trilha toda",
|
||||||
|
"LabelUser": "Usuário",
|
||||||
|
"LabelUsername": "Nome do usuário",
|
||||||
|
"LabelValue": "Valor",
|
||||||
|
"LabelVersion": "Versão",
|
||||||
|
"LabelViewBookmarks": "Ver marcadores",
|
||||||
|
"LabelViewChapters": "Ver capítulos",
|
||||||
|
"LabelViewQueue": "Ver fila do reprodutor",
|
||||||
|
"LabelVolume": "Volume",
|
||||||
|
"LabelWeekdaysToRun": "Dias da semana para executar",
|
||||||
|
"LabelYourAudiobookDuration": "Duração do seu audiobook",
|
||||||
|
"LabelYourBookmarks": "Seus Marcadores",
|
||||||
|
"LabelYourPlaylists": "Suas Listas de Reprodução",
|
||||||
|
"LabelYourProgress": "Seu Progresso",
|
||||||
|
"MessageAddToPlayerQueue": "Adicionar à lista do reprodutor",
|
||||||
|
"MessageAppriseDescription": "Para utilizar essa funcionalidade é preciso ter uma instância da <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API do Apprise</a> em execução ou uma api que possa tratar esses mesmos chamados. <br />A URL da API do Apprise deve conter o caminho completo da URL para enviar as notificações. Ex: se a sua instância da API estiver em <code>http://192.168.1.1:8337</code> você deve utilizar <code>http://192.168.1.1:8337/notify</code>.",
|
||||||
|
"MessageBackupsDescription": "Backups incluem usuários, progresso dos usuários, detalhes dos itens da biblioteca, configurações do servidor e imagens armazenadas em <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>não</strong> incluem quaisquer arquivos armazenados nas pastas da sua biblioteca.",
|
||||||
|
"MessageBatchQuickMatchDescription": "Consulta Rápida tentará adicionar capas e metadados ausentes para os itens selecionados. Ative as opções abaixo para permitir que a Consulta Rápida sobrescreva capas e/ou metadados existentes.",
|
||||||
|
"MessageBookshelfNoCollections": "Você ainda não criou coleções",
|
||||||
|
"MessageBookshelfNoResultsForFilter": "Sem Resultados para o filtro \"{0}: {1}\"",
|
||||||
|
"MessageBookshelfNoRSSFeeds": "Não existem feeds RSS abertos",
|
||||||
|
"MessageBookshelfNoSeries": "Você não tem séries",
|
||||||
|
"MessageChapterEndIsAfter": "O final do capítulo está além do final do seu audiobook",
|
||||||
|
"MessageChapterErrorFirstNotZero": "O primeiro capítulo precisa começar no 0",
|
||||||
|
"MessageChapterErrorStartGteDuration": "Tempo de início não é válido pois precisa ser menor do que a duração do audioboook",
|
||||||
|
"MessageChapterErrorStartLtPrev": "Tempo de início não é válido pois precisa ser igual ou maior que o tempo de início do capítulo anterior",
|
||||||
|
"MessageChapterStartIsAfter": "Início do capítulo está além do final do seu audiobook",
|
||||||
|
"MessageCheckingCron": "Verificando o cron...",
|
||||||
|
"MessageConfirmCloseFeed": "Tem certeza de que deseja fechar esse feed?",
|
||||||
|
"MessageConfirmDeleteBackup": "Tem certeza de que deseja apagar o backup {0}?",
|
||||||
|
"MessageConfirmDeleteFile": "Essa ação apagará o arquivo do seu sistema de arquivos. Tem certeza?",
|
||||||
|
"MessageConfirmDeleteLibrary": "Tem certeza de que deseja apagar a biblioteca \"{0}\" definitivamente?",
|
||||||
|
"MessageConfirmDeleteLibraryItem": "Essa ação apagará o item da biblioteca do banco de dados e do seu sistema de arquivos. Tem certeza?",
|
||||||
|
"MessageConfirmDeleteLibraryItems": "Essa ação apagará {0} itens da biblioteca do banco de dados e do seu sistema de arquivos. Tem certeza?",
|
||||||
|
"MessageConfirmDeleteSession": "Tem certeza de que deseja apagar essa sessão?",
|
||||||
|
"MessageConfirmForceReScan": "Tem certeza de que deseja forçar a nova verificação?",
|
||||||
|
"MessageConfirmMarkAllEpisodesFinished": "Tem certeza de que deseja marcar todos os episódios como concluídos?",
|
||||||
|
"MessageConfirmMarkAllEpisodesNotFinished": "Tem certeza de que deseja marcar todos os episódios como não concluídos?",
|
||||||
|
"MessageConfirmMarkSeriesFinished": "Tem certeza de que deseja marcar todos os livros nesta série como concluídos?",
|
||||||
|
"MessageConfirmMarkSeriesNotFinished": "Tem certeza de que deseja marcar todos os livros nesta série como não concluídos?",
|
||||||
|
"MessageConfirmQuickEmbed": "Aviso! Inclusão rápida não fará backup dos seus arquivos de áudio. Verifique se tem um backup dos seus arquivos de áudio. <br><br>Quer continuar?",
|
||||||
|
"MessageConfirmRemoveAllChapters": "Tem certeza de que deseja remover todos os capítulos?",
|
||||||
|
"MessageConfirmRemoveAuthor": "Tem certeza de que deseja remover o autor \"{0}\"?",
|
||||||
|
"MessageConfirmRemoveCollection": "Tem certeza de que deseja remover a coleção \"{0}\"?",
|
||||||
|
"MessageConfirmRemoveEpisode": "Tem certeza de que deseja remover o episódio \"{0}\"?",
|
||||||
|
"MessageConfirmRemoveEpisodes": "Tem certeza de que deseja remover os {0} episódios?",
|
||||||
|
"MessageConfirmRemoveListeningSessions": "Tem certeza de que deseja remover as {0} sessões de escuta?",
|
||||||
|
"MessageConfirmRemoveNarrator": "Tem certeza de que deseja remover o narrador \"{0}\"?",
|
||||||
|
"MessageConfirmRemovePlaylist": "Tem certeza de que deseja remover a sua lista de reprodução \"{0}\"?",
|
||||||
|
"MessageConfirmRenameGenre": "Tem certeza de que deseja renomear o gênero \"{0}\" para \"{1}\" em todos os itens?",
|
||||||
|
"MessageConfirmRenameGenreMergeNote": "Aviso: Este gênero já existe então eles serão combinados.",
|
||||||
|
"MessageConfirmRenameGenreWarning": "Atenção! Um gênero com um nome semelhante já existe \"{0}\".",
|
||||||
|
"MessageConfirmRenameTag": "Tem certeza de que deseja renomear a etiqueta \"{0}\" para \"{1}\" em todos os itens?",
|
||||||
|
"MessageConfirmRenameTagMergeNote": "Aviso: Esta etiqueta já existe então elas serão combinadas.",
|
||||||
|
"MessageConfirmRenameTagWarning": "Atenção! Uma etiqueta com um nome semelhante já existe \"{0}\".",
|
||||||
|
"MessageConfirmReScanLibraryItems": "Tem certeza de que deseja uma nova verificação de {0} itens?",
|
||||||
|
"MessageConfirmSendEbookToDevice": "Tem certeza de que deseja enviar {0} ebook(s) \"{1}\" para o dispositivo \"{2}\"?",
|
||||||
|
"MessageDownloadingEpisode": "Realizando o download do episódio",
|
||||||
|
"MessageDragFilesIntoTrackOrder": "Arraste os arquivos para ordenar as trilhas corretamente",
|
||||||
|
"MessageEmbedFinished": "Inclusão Concluída!",
|
||||||
|
"MessageEpisodesQueuedForDownload": "{0} Episódio(s) na fila de download",
|
||||||
|
"MessageFeedURLWillBe": "URL do Feed será {0}",
|
||||||
|
"MessageFetching": "Buscando...",
|
||||||
|
"MessageForceReScanDescription": "verificará todos os arquivos, como uma verificação nova. Etiquetas ID3 de arquivos de áudio, arquivos OPF e arquivos de texto serão tratados como novos.",
|
||||||
|
"MessageImportantNotice": "Aviso Importante!",
|
||||||
|
"MessageInsertChapterBelow": "Inserir capítulo abaixo",
|
||||||
|
"MessageItemsSelected": "{0} Itens Selecionados",
|
||||||
|
"MessageItemsUpdated": "{0} Itens Atualizados",
|
||||||
|
"MessageJoinUsOn": "Junte-se a nós",
|
||||||
|
"MessageListeningSessionsInTheLastYear": "{0} sessões de escuta no ano anterior",
|
||||||
|
"MessageLoading": "Carregando...",
|
||||||
|
"MessageLoadingFolders": "Carregando pastas...",
|
||||||
|
"MessageM4BFailed": "Falha no M4B!",
|
||||||
|
"MessageM4BFinished": "M4B Concluído!",
|
||||||
|
"MessageMapChapterTitles": "Designar títulos de capítulos a partir dos capítulos existentes no audiobook sem ajustar seus tempos",
|
||||||
|
"MessageMarkAllEpisodesFinished": "Marcar todos os episódios como concluídos",
|
||||||
|
"MessageMarkAllEpisodesNotFinished": "Marcar todos os episódios como não concluídos",
|
||||||
|
"MessageMarkAsFinished": "Marcar como Concluído",
|
||||||
|
"MessageMarkAsNotFinished": "Marcar como Não Concluído",
|
||||||
|
"MessageMatchBooksDescription": "tentará consultar os livros da biblioteca no fornecedor de busca selecionado e preencher os detalhes ausentes e a capa. Não sobrescreve os detalhes.",
|
||||||
|
"MessageNoAudioTracks": "Sem trilhas de áudio",
|
||||||
|
"MessageNoAuthors": "Sem Autores",
|
||||||
|
"MessageNoBackups": "Sem Backups",
|
||||||
|
"MessageNoBookmarks": "Sem Marcadores",
|
||||||
|
"MessageNoChapters": "Sem Capítulos",
|
||||||
|
"MessageNoCollections": "Sem Coleções",
|
||||||
|
"MessageNoCoversFound": "Nenhuma Capa Encontrada",
|
||||||
|
"MessageNoDescription": "Sem Descrições",
|
||||||
|
"MessageNoDownloadsInProgress": "Não existem downloads em andamento",
|
||||||
|
"MessageNoDownloadsQueued": "Não existem itens na fila de download",
|
||||||
|
"MessageNoEpisodeMatchesFound": "Não existem episódios correspondentes",
|
||||||
|
"MessageNoEpisodes": "Sem Episódios",
|
||||||
|
"MessageNoFoldersAvailable": "Nenhuma Pasta Disponível",
|
||||||
|
"MessageNoGenres": "Sem Gêneros",
|
||||||
|
"MessageNoIssues": "Sem Problemas",
|
||||||
|
"MessageNoItems": "Sem Itens",
|
||||||
|
"MessageNoItemsFound": "Nenhum item encontrado",
|
||||||
|
"MessageNoListeningSessions": "Sem Sessões de Escuta",
|
||||||
|
"MessageNoLogs": "Sem Logs",
|
||||||
|
"MessageNoMediaProgress": "Sem Progresso de Mídia",
|
||||||
|
"MessageNoNotifications": "Sem Notificações",
|
||||||
|
"MessageNoPodcastsFound": "Nenhum podcast encontrado",
|
||||||
|
"MessageNoResults": "Sem resultados",
|
||||||
|
"MessageNoSearchResultsFor": "Sem resultados para \"{0}\"",
|
||||||
|
"MessageNoSeries": "Sem Séries",
|
||||||
|
"MessageNoTags": "Sem etiquetas",
|
||||||
|
"MessageNoTasksRunning": "Sem Tarefas em Execução",
|
||||||
|
"MessageNotYetImplemented": "Ainda não implementado",
|
||||||
|
"MessageNoUpdateNecessary": "Não é necessária a atualização",
|
||||||
|
"MessageNoUpdatesWereNecessary": "Nenhuma atualização é necessária",
|
||||||
|
"MessageNoUserPlaylists": "Você não tem listas de reprodução",
|
||||||
|
"MessageOr": "ou",
|
||||||
|
"MessagePauseChapter": "Pausar reprodução do capítulo",
|
||||||
|
"MessagePlayChapter": "Escutar o início do capítulo",
|
||||||
|
"MessagePlaylistCreateFromCollection": "Criar uma lista de reprodução a partir da coleção",
|
||||||
|
"MessagePodcastHasNoRSSFeedForMatching": "Podcast não tem uma URL do feed RSS para ser usada na consulta",
|
||||||
|
"MessageQuickMatchDescription": "Preenche detalhes vazios do item & capa com o primeiro resultado de '{0}'. Não sobrescreve detalhes a não ser que a configuração 'Preferir metadados consultados' do servidor esteja ativa.",
|
||||||
|
"MessageRemoveChapter": "Remover capítulo",
|
||||||
|
"MessageRemoveEpisodes": "Remover {0} episódio(s)",
|
||||||
|
"MessageRemoveFromPlayerQueue": "Remover da lista do reprodutor",
|
||||||
|
"MessageRemoveUserWarning": "Tem certeza de que deseja apagar definitivamente o usuário \"{0}\"?",
|
||||||
|
"MessageReportBugsAndContribute": "Reporte bugs, peça funcionalidades e contribua em",
|
||||||
|
"MessageResetChaptersConfirm": "Tem certeza de que deseja resetar os capítulos e desfazer as alterações realizadas?",
|
||||||
|
"MessageRestoreBackupConfirm": "Tem certeza de que deseja restaurar o backup criado em",
|
||||||
|
"MessageRestoreBackupWarning": "Restaurar um backup sobrescreverá totalmente o banco de dados localizado em /config e as imagens de capa em /metadata/items & /metadata/authors.<br /><br />Backups não alteram quaisquer arquivos nas pastas da sua biblioteca. Se a configuração do servidor de armazenar a arte da capa e os metadados nas pastas da sua biblioteca estiver ativa, esses itens não estão no backup e não serão sobrescritos.<br /><br />Todos os clientes usando o seu servidor serão atualizados automaticamente.",
|
||||||
|
"MessageSearchResultsFor": "Resultado da busca por",
|
||||||
|
"MessageSelected": "{0} selecionado(s)",
|
||||||
|
"MessageServerCouldNotBeReached": "Não foi possível estabelecer conexão com o servidor",
|
||||||
|
"MessageSetChaptersFromTracksDescription": "Definir os capítulos usando cada arquivo de áudio como um capítulo e o nome do arquivo como o título do capítulo",
|
||||||
|
"MessageStartPlaybackAtTime": "Iniciar a reprodução de \"{0}\" em {1}?",
|
||||||
|
"MessageThinking": "Pensando...",
|
||||||
|
"MessageUploaderItemFailed": "Falha no upload",
|
||||||
|
"MessageUploaderItemSuccess": "Upload realizado!",
|
||||||
|
"MessageUploading": "Realizando o upload...",
|
||||||
|
"MessageValidCronExpression": "Expressão do cron válida",
|
||||||
|
"MessageWatcherIsDisabledGlobally": "Monitoramento está desativado nas configurações do servidor",
|
||||||
|
"MessageXLibraryIsEmpty": "Biblioteca {0} está vazia!",
|
||||||
|
"MessageYourAudiobookDurationIsLonger": "A duração do seu audiobook é maior do que a duração encontrada",
|
||||||
|
"MessageYourAudiobookDurationIsShorter": "A duração do seu audiobook é menor do que a duração encontrada",
|
||||||
|
"NoteChangeRootPassword": "O usuário Admiistrador é o único usuário que pode não ter uma senha",
|
||||||
|
"NoteChapterEditorTimes": "Aviso: O tempo de início do primeiro capítulo precisa ficar em 0:00 e o tempo de início do último capítulo não pode exceder a duração deste audiobook.",
|
||||||
|
"NoteFolderPicker": "Aviso: pastas já designadas não serão exibidas",
|
||||||
|
"NoteRSSFeedPodcastAppsHttps": "Atenção: A maioria dos aplicativos de podcasts requer que a URL do feed RSS use HTTPS",
|
||||||
|
"NoteRSSFeedPodcastAppsPubDate": "Atenção: Um ou mais dos seus episódios não tem uma data de publicação. Alguns aplicativos de podcasts requerem isto.",
|
||||||
|
"NoteUploaderFoldersWithMediaFiles": "Pastas com arquivos de mídia serão tratadas como itens de biblioteca distintos.",
|
||||||
|
"NoteUploaderOnlyAudioFiles": "Ao subir apenas arquivos de áudio, cada arquivo será tratado como um audiobook distinto.",
|
||||||
|
"NoteUploaderUnsupportedFiles": "Arquivos não suportados serão ignorados. Ao escolher ou arrastar uma pasta, outros arquivos que não estão em uma pasta dentro do item serão ignorados.",
|
||||||
|
"PlaceholderNewCollection": "Novo nome da coleção",
|
||||||
|
"PlaceholderNewFolderPath": "Novo caminho para a pasta",
|
||||||
|
"PlaceholderNewPlaylist": "Novo nome da lista de reprodução",
|
||||||
|
"PlaceholderSearch": "Buscar..",
|
||||||
|
"PlaceholderSearchEpisode": "Buscar Episódio..",
|
||||||
|
"ToastAccountUpdateFailed": "Falha ao atualizar a conta",
|
||||||
|
"ToastAccountUpdateSuccess": "Conta atualizada",
|
||||||
|
"ToastAuthorImageRemoveFailed": "Falha ao remover imagem",
|
||||||
|
"ToastAuthorImageRemoveSuccess": "Imagem do autor removida",
|
||||||
|
"ToastAuthorUpdateFailed": "Falha ao atualizar o autor",
|
||||||
|
"ToastAuthorUpdateMerged": "Autor combinado",
|
||||||
|
"ToastAuthorUpdateSuccess": "Autor atualizado",
|
||||||
|
"ToastAuthorUpdateSuccessNoImageFound": "Autor atualizado (nenhuma imagem encontrada)",
|
||||||
|
"ToastBackupCreateFailed": "Falha ao criar backup",
|
||||||
|
"ToastBackupCreateSuccess": "Backup criado",
|
||||||
|
"ToastBackupDeleteFailed": "Falha ao apagar backup",
|
||||||
|
"ToastBackupDeleteSuccess": "Backup apagado",
|
||||||
|
"ToastBackupRestoreFailed": "Falha ao restaurar backup",
|
||||||
|
"ToastBackupUploadFailed": "Falha no upload do backup",
|
||||||
|
"ToastBackupUploadSuccess": "Upload do backup realizado",
|
||||||
|
"ToastBatchUpdateFailed": "Falha na atualização em lote",
|
||||||
|
"ToastBatchUpdateSuccess": "Atualização em lote realizada",
|
||||||
|
"ToastBookmarkCreateFailed": "Falha ao criar marcador",
|
||||||
|
"ToastBookmarkCreateSuccess": "Marcador adicionado",
|
||||||
|
"ToastBookmarkRemoveFailed": "Falha ao remover marcador",
|
||||||
|
"ToastBookmarkRemoveSuccess": "Marcador removido",
|
||||||
|
"ToastBookmarkUpdateFailed": "Falha ao atualizar o marcador",
|
||||||
|
"ToastBookmarkUpdateSuccess": "Marcador atualizado",
|
||||||
|
"ToastChaptersHaveErrors": "Capítulos com erro",
|
||||||
|
"ToastChaptersMustHaveTitles": "Capítulos precisam ter títulos",
|
||||||
|
"ToastCollectionItemsRemoveFailed": "Falha ao remover item(ns) da coleção",
|
||||||
|
"ToastCollectionItemsRemoveSuccess": "Item(ns) removidos da coleção",
|
||||||
|
"ToastCollectionRemoveFailed": "Falha ao remover coleção",
|
||||||
|
"ToastCollectionRemoveSuccess": "Coleção removida",
|
||||||
|
"ToastCollectionUpdateFailed": "Falha ao atualizar coleção",
|
||||||
|
"ToastCollectionUpdateSuccess": "Coleção atualizada",
|
||||||
|
"ToastItemCoverUpdateFailed": "Falha ao atualizar capa do item",
|
||||||
|
"ToastItemCoverUpdateSuccess": "Capa do item atualizada",
|
||||||
|
"ToastItemDetailsUpdateFailed": "Falha ao atualizar detalhes do item",
|
||||||
|
"ToastItemDetailsUpdateSuccess": "Detalhes do item atualizados",
|
||||||
|
"ToastItemDetailsUpdateUnneeded": "Nenhuma atualização necessária para os detalhes do item",
|
||||||
|
"ToastItemMarkedAsFinishedFailed": "Falha ao marcar como Concluído",
|
||||||
|
"ToastItemMarkedAsFinishedSuccess": "Item marcado como Concluído",
|
||||||
|
"ToastItemMarkedAsNotFinishedFailed": "Falha ao marcar como Não Concluído",
|
||||||
|
"ToastItemMarkedAsNotFinishedSuccess": "Item marcado como Não Concluído",
|
||||||
|
"ToastLibraryCreateFailed": "Falha ao criar biblioteca",
|
||||||
|
"ToastLibraryCreateSuccess": "Biblioteca \"{0}\" criada",
|
||||||
|
"ToastLibraryDeleteFailed": "Falha ao apagar biblioteca",
|
||||||
|
"ToastLibraryDeleteSuccess": "Biblioteca apagada",
|
||||||
|
"ToastLibraryScanFailedToStart": "Falha ao iniciar verificação",
|
||||||
|
"ToastLibraryScanStarted": "Verificação da biblioteca iniciada",
|
||||||
|
"ToastLibraryUpdateFailed": "Falha ao atualizar a biblioteca",
|
||||||
|
"ToastLibraryUpdateSuccess": "Biblioteca \"{0}\" atualizada",
|
||||||
|
"ToastPlaylistCreateFailed": "Falha ao criar lista de reprodução",
|
||||||
|
"ToastPlaylistCreateSuccess": "Lista de reprodução criada",
|
||||||
|
"ToastPlaylistRemoveFailed": "Falha ao remover lista de reprodução",
|
||||||
|
"ToastPlaylistRemoveSuccess": "Lista de reprodução removida",
|
||||||
|
"ToastPlaylistUpdateFailed": "Falha ao atualizar lista de reprodução",
|
||||||
|
"ToastPlaylistUpdateSuccess": "Lista de reprodução atualizada",
|
||||||
|
"ToastPodcastCreateFailed": "Falha ao criar podcast",
|
||||||
|
"ToastPodcastCreateSuccess": "Podcast criado",
|
||||||
|
"ToastRemoveItemFromCollectionFailed": "Falha ao remover item da coleção",
|
||||||
|
"ToastRemoveItemFromCollectionSuccess": "Item removido da coleção",
|
||||||
|
"ToastRSSFeedCloseFailed": "Falha ao fechar feed RSS",
|
||||||
|
"ToastRSSFeedCloseSuccess": "Feed RSS fechado",
|
||||||
|
"ToastSendEbookToDeviceFailed": "Falha ao enviar ebook para dispositivo",
|
||||||
|
"ToastSendEbookToDeviceSuccess": "Ebook enviado para o dispositivo \"{0}\"",
|
||||||
|
"ToastSeriesUpdateFailed": "Falha ao atualizar série",
|
||||||
|
"ToastSeriesUpdateSuccess": "Série atualizada",
|
||||||
|
"ToastSessionDeleteFailed": "Falha ao apagar sessão",
|
||||||
|
"ToastSessionDeleteSuccess": "Sessão apagada",
|
||||||
|
"ToastSocketConnected": "Socket conectado",
|
||||||
|
"ToastSocketDisconnected": "Socket desconectado",
|
||||||
|
"ToastSocketFailedToConnect": "Falha na conexão do socket",
|
||||||
|
"ToastUserDeleteFailed": "Falha ao apagar usuário",
|
||||||
|
"ToastUserDeleteSuccess": "Usuário apagado"
|
||||||
|
}
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "Скрыть",
|
"ButtonHide": "Скрыть",
|
||||||
"ButtonHome": "Домой",
|
"ButtonHome": "Домой",
|
||||||
"ButtonIssues": "Проблемы",
|
"ButtonIssues": "Проблемы",
|
||||||
|
"ButtonJumpBackward": "Jump Backward",
|
||||||
|
"ButtonJumpForward": "Jump Forward",
|
||||||
"ButtonLatest": "Последнее",
|
"ButtonLatest": "Последнее",
|
||||||
"ButtonLibrary": "Библиотека",
|
"ButtonLibrary": "Библиотека",
|
||||||
"ButtonLogout": "Выход",
|
"ButtonLogout": "Выход",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "Найти всех авторов",
|
"ButtonMatchAllAuthors": "Найти всех авторов",
|
||||||
"ButtonMatchBooks": "Найти книги",
|
"ButtonMatchBooks": "Найти книги",
|
||||||
"ButtonNevermind": "Не важно",
|
"ButtonNevermind": "Не важно",
|
||||||
|
"ButtonNextChapter": "Next Chapter",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Открыть канал",
|
"ButtonOpenFeed": "Открыть канал",
|
||||||
"ButtonOpenManager": "Открыть менеджер",
|
"ButtonOpenManager": "Открыть менеджер",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "Слушать",
|
"ButtonPlay": "Слушать",
|
||||||
"ButtonPlaying": "Проигрывается",
|
"ButtonPlaying": "Проигрывается",
|
||||||
"ButtonPlaylists": "Плейлисты",
|
"ButtonPlaylists": "Плейлисты",
|
||||||
|
"ButtonPreviousChapter": "Previous Chapter",
|
||||||
"ButtonPurgeAllCache": "Очистить весь кэш",
|
"ButtonPurgeAllCache": "Очистить весь кэш",
|
||||||
"ButtonPurgeItemsCache": "Очистить кэш элементов",
|
"ButtonPurgeItemsCache": "Очистить кэш элементов",
|
||||||
"ButtonPurgeMediaProgress": "Очистить прогресс медиа",
|
"ButtonPurgeMediaProgress": "Очистить прогресс медиа",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "Элементы коллекции",
|
"HeaderCollectionItems": "Элементы коллекции",
|
||||||
"HeaderCover": "Обложка",
|
"HeaderCover": "Обложка",
|
||||||
"HeaderCurrentDownloads": "Текущие закачки",
|
"HeaderCurrentDownloads": "Текущие закачки",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Подробности",
|
"HeaderDetails": "Подробности",
|
||||||
"HeaderDownloadQueue": "Очередь скачивания",
|
"HeaderDownloadQueue": "Очередь скачивания",
|
||||||
"HeaderEbookFiles": "Файлы e-книг",
|
"HeaderEbookFiles": "Файлы e-книг",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Метод воспроизведения",
|
"LabelPlayMethod": "Метод воспроизведения",
|
||||||
"LabelPodcast": "Подкаст",
|
"LabelPodcast": "Подкаст",
|
||||||
"LabelPodcasts": "Подкасты",
|
"LabelPodcasts": "Подкасты",
|
||||||
|
"LabelPodcastSearchRegion": "Регион поиска подкастов",
|
||||||
"LabelPodcastType": "Тип подкаста",
|
"LabelPodcastType": "Тип подкаста",
|
||||||
"LabelPort": "Порт",
|
"LabelPort": "Порт",
|
||||||
"LabelPrefixesToIgnore": "Игнорируемые префиксы (без учета регистра)",
|
"LabelPrefixesToIgnore": "Игнорируемые префиксы (без учета регистра)",
|
||||||
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "Dölj",
|
"ButtonHide": "Dölj",
|
||||||
"ButtonHome": "Hem",
|
"ButtonHome": "Hem",
|
||||||
"ButtonIssues": "Problem",
|
"ButtonIssues": "Problem",
|
||||||
|
"ButtonJumpBackward": "Jump Backward",
|
||||||
|
"ButtonJumpForward": "Jump Forward",
|
||||||
"ButtonLatest": "Senaste",
|
"ButtonLatest": "Senaste",
|
||||||
"ButtonLibrary": "Bibliotek",
|
"ButtonLibrary": "Bibliotek",
|
||||||
"ButtonLogout": "Logga ut",
|
"ButtonLogout": "Logga ut",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "Matcha alla författare",
|
"ButtonMatchAllAuthors": "Matcha alla författare",
|
||||||
"ButtonMatchBooks": "Matcha böcker",
|
"ButtonMatchBooks": "Matcha böcker",
|
||||||
"ButtonNevermind": "Glöm det",
|
"ButtonNevermind": "Glöm det",
|
||||||
|
"ButtonNextChapter": "Next Chapter",
|
||||||
"ButtonOk": "Okej",
|
"ButtonOk": "Okej",
|
||||||
"ButtonOpenFeed": "Öppna flöde",
|
"ButtonOpenFeed": "Öppna flöde",
|
||||||
"ButtonOpenManager": "Öppna Manager",
|
"ButtonOpenManager": "Öppna Manager",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "Spela",
|
"ButtonPlay": "Spela",
|
||||||
"ButtonPlaying": "Spelar",
|
"ButtonPlaying": "Spelar",
|
||||||
"ButtonPlaylists": "Spellistor",
|
"ButtonPlaylists": "Spellistor",
|
||||||
|
"ButtonPreviousChapter": "Previous Chapter",
|
||||||
"ButtonPurgeAllCache": "Rensa all cache",
|
"ButtonPurgeAllCache": "Rensa all cache",
|
||||||
"ButtonPurgeItemsCache": "Rensa föremåls-cache",
|
"ButtonPurgeItemsCache": "Rensa föremåls-cache",
|
||||||
"ButtonPurgeMediaProgress": "Rensa medieförlopp",
|
"ButtonPurgeMediaProgress": "Rensa medieförlopp",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "Samlingselement",
|
"HeaderCollectionItems": "Samlingselement",
|
||||||
"HeaderCover": "Omslag",
|
"HeaderCover": "Omslag",
|
||||||
"HeaderCurrentDownloads": "Aktuella nedladdningar",
|
"HeaderCurrentDownloads": "Aktuella nedladdningar",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "Detaljer",
|
"HeaderDetails": "Detaljer",
|
||||||
"HeaderDownloadQueue": "Nedladdningskö",
|
"HeaderDownloadQueue": "Nedladdningskö",
|
||||||
"HeaderEbookFiles": "E-boksfiler",
|
"HeaderEbookFiles": "E-boksfiler",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "Spelläge",
|
"LabelPlayMethod": "Spelläge",
|
||||||
"LabelPodcast": "Podcast",
|
"LabelPodcast": "Podcast",
|
||||||
"LabelPodcasts": "Podcasts",
|
"LabelPodcasts": "Podcasts",
|
||||||
|
"LabelPodcastSearchRegion": "Podcast-sökområde",
|
||||||
"LabelPodcastType": "Podcasttyp",
|
"LabelPodcastType": "Podcasttyp",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)",
|
"LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)",
|
||||||
|
@ -32,6 +32,8 @@
|
|||||||
"ButtonHide": "隐藏",
|
"ButtonHide": "隐藏",
|
||||||
"ButtonHome": "首页",
|
"ButtonHome": "首页",
|
||||||
"ButtonIssues": "问题",
|
"ButtonIssues": "问题",
|
||||||
|
"ButtonJumpBackward": "Jump Backward",
|
||||||
|
"ButtonJumpForward": "Jump Forward",
|
||||||
"ButtonLatest": "最新",
|
"ButtonLatest": "最新",
|
||||||
"ButtonLibrary": "媒体库",
|
"ButtonLibrary": "媒体库",
|
||||||
"ButtonLogout": "注销",
|
"ButtonLogout": "注销",
|
||||||
@ -41,12 +43,15 @@
|
|||||||
"ButtonMatchAllAuthors": "匹配所有作者",
|
"ButtonMatchAllAuthors": "匹配所有作者",
|
||||||
"ButtonMatchBooks": "匹配图书",
|
"ButtonMatchBooks": "匹配图书",
|
||||||
"ButtonNevermind": "没有关系",
|
"ButtonNevermind": "没有关系",
|
||||||
|
"ButtonNextChapter": "Next Chapter",
|
||||||
"ButtonOk": "确定",
|
"ButtonOk": "确定",
|
||||||
"ButtonOpenFeed": "打开源",
|
"ButtonOpenFeed": "打开源",
|
||||||
"ButtonOpenManager": "打开管理器",
|
"ButtonOpenManager": "打开管理器",
|
||||||
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "播放",
|
"ButtonPlay": "播放",
|
||||||
"ButtonPlaying": "正在播放",
|
"ButtonPlaying": "正在播放",
|
||||||
"ButtonPlaylists": "播放列表",
|
"ButtonPlaylists": "播放列表",
|
||||||
|
"ButtonPreviousChapter": "Previous Chapter",
|
||||||
"ButtonPurgeAllCache": "清理所有缓存",
|
"ButtonPurgeAllCache": "清理所有缓存",
|
||||||
"ButtonPurgeItemsCache": "清理项目缓存",
|
"ButtonPurgeItemsCache": "清理项目缓存",
|
||||||
"ButtonPurgeMediaProgress": "清理媒体进度",
|
"ButtonPurgeMediaProgress": "清理媒体进度",
|
||||||
@ -104,6 +109,7 @@
|
|||||||
"HeaderCollectionItems": "收藏项目",
|
"HeaderCollectionItems": "收藏项目",
|
||||||
"HeaderCover": "封面",
|
"HeaderCover": "封面",
|
||||||
"HeaderCurrentDownloads": "当前下载",
|
"HeaderCurrentDownloads": "当前下载",
|
||||||
|
"HeaderCustomMetadataProviders": "Custom Metadata Providers",
|
||||||
"HeaderDetails": "详情",
|
"HeaderDetails": "详情",
|
||||||
"HeaderDownloadQueue": "下载队列",
|
"HeaderDownloadQueue": "下载队列",
|
||||||
"HeaderEbookFiles": "电子书文件",
|
"HeaderEbookFiles": "电子书文件",
|
||||||
@ -390,6 +396,7 @@
|
|||||||
"LabelPlayMethod": "播放方法",
|
"LabelPlayMethod": "播放方法",
|
||||||
"LabelPodcast": "播客",
|
"LabelPodcast": "播客",
|
||||||
"LabelPodcasts": "播客",
|
"LabelPodcasts": "播客",
|
||||||
|
"LabelPodcastSearchRegion": "播客搜索地区",
|
||||||
"LabelPodcastType": "播客类型",
|
"LabelPodcastType": "播客类型",
|
||||||
"LabelPort": "端口",
|
"LabelPort": "端口",
|
||||||
"LabelPrefixesToIgnore": "忽略的前缀 (不区分大小写)",
|
"LabelPrefixesToIgnore": "忽略的前缀 (不区分大小写)",
|
||||||
|
135
custom-metadata-provider-specification.yaml
Normal file
135
custom-metadata-provider-specification.yaml
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
servers:
|
||||||
|
- url: https://example.com
|
||||||
|
description: Local server
|
||||||
|
info:
|
||||||
|
license:
|
||||||
|
name: MIT
|
||||||
|
url: https://opensource.org/licenses/MIT
|
||||||
|
|
||||||
|
|
||||||
|
title: Custom Metadata Provider
|
||||||
|
version: 0.1.0
|
||||||
|
security:
|
||||||
|
- api_key: []
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/search:
|
||||||
|
get:
|
||||||
|
description: Search for books
|
||||||
|
operationId: search
|
||||||
|
summary: Search for books
|
||||||
|
security:
|
||||||
|
- api_key: []
|
||||||
|
parameters:
|
||||||
|
- name: query
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: author
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
matches:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/BookMetadata"
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
BookMetadata:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
subtitle:
|
||||||
|
type: string
|
||||||
|
author:
|
||||||
|
type: string
|
||||||
|
narrator:
|
||||||
|
type: string
|
||||||
|
publisher:
|
||||||
|
type: string
|
||||||
|
publishedYear:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
cover:
|
||||||
|
type: string
|
||||||
|
description: URL to the cover image
|
||||||
|
isbn:
|
||||||
|
type: string
|
||||||
|
format: isbn
|
||||||
|
asin:
|
||||||
|
type: string
|
||||||
|
format: asin
|
||||||
|
genres:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
tags:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
series:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
series:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
sequence:
|
||||||
|
type: number
|
||||||
|
format: int64
|
||||||
|
language:
|
||||||
|
type: string
|
||||||
|
duration:
|
||||||
|
type: number
|
||||||
|
format: int64
|
||||||
|
description: Duration in seconds
|
||||||
|
required:
|
||||||
|
- title
|
||||||
|
securitySchemes:
|
||||||
|
api_key:
|
||||||
|
type: apiKey
|
||||||
|
name: AUTHORIZATION
|
||||||
|
in: header
|
||||||
|
|
||||||
|
|
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.7.2",
|
"version": "2.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.7.2",
|
"version": "2.8.0",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.7.2",
|
"version": "2.8.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",
|
||||||
|
268
server/Auth.js
268
server/Auth.js
@ -81,7 +81,8 @@ class Auth {
|
|||||||
authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL,
|
authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL,
|
||||||
token_endpoint: global.ServerSettings.authOpenIDTokenURL,
|
token_endpoint: global.ServerSettings.authOpenIDTokenURL,
|
||||||
userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL,
|
userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL,
|
||||||
jwks_uri: global.ServerSettings.authOpenIDJwksURL
|
jwks_uri: global.ServerSettings.authOpenIDJwksURL,
|
||||||
|
end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL
|
||||||
}).Client
|
}).Client
|
||||||
const openIdClient = new openIdIssuerClient({
|
const openIdClient = new openIdIssuerClient({
|
||||||
client_id: global.ServerSettings.authOpenIDClientID,
|
client_id: global.ServerSettings.authOpenIDClientID,
|
||||||
@ -153,6 +154,9 @@ class Auth {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
|
||||||
|
user.openid_id_token = tokenset.id_token
|
||||||
|
|
||||||
// permit login
|
// permit login
|
||||||
return done(null, user)
|
return done(null, user)
|
||||||
}))
|
}))
|
||||||
@ -183,49 +187,48 @@ class Auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores the client's choice how the login callback should happen in temp cookies
|
* Returns if the given auth method is API based.
|
||||||
|
*
|
||||||
|
* @param {string} authMethod
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isAuthMethodAPIBased(authMethod) {
|
||||||
|
return ['api', 'openid-mobile'].includes(authMethod)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the client's choice of login callback method in temporary cookies.
|
||||||
|
*
|
||||||
|
* The `authMethod` parameter specifies the authentication strategy and can have the following values:
|
||||||
|
* - 'local': Standard authentication,
|
||||||
|
* - 'api': Authentication for API use
|
||||||
|
* - 'openid': OpenID authentication directly over web
|
||||||
|
* - 'openid-mobile': OpenID authentication, but done via an mobile device
|
||||||
*
|
*
|
||||||
* @param {import('express').Request} req
|
* @param {import('express').Request} req
|
||||||
* @param {import('express').Response} res
|
* @param {import('express').Response} res
|
||||||
|
* @param {string} authMethod - The authentication method, default is 'local'.
|
||||||
*/
|
*/
|
||||||
paramsToCookies(req, res) {
|
paramsToCookies(req, res, authMethod = 'local') {
|
||||||
// Set if isRest flag is set or if mobile oauth flow is used
|
const TWO_MINUTES = 120000 // 2 minutes in milliseconds
|
||||||
if (req.query.isRest?.toLowerCase() == 'true' || req.query.redirect_uri) {
|
const callback = req.query.redirect_uri || req.query.callback
|
||||||
// store the isRest flag to the is_rest cookie
|
|
||||||
res.cookie('is_rest', 'true', {
|
|
||||||
maxAge: 120000, // 2 min
|
|
||||||
httpOnly: true
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// no isRest-flag set -> set is_rest cookie to false
|
|
||||||
res.cookie('is_rest', 'false', {
|
|
||||||
maxAge: 120000, // 2 min
|
|
||||||
httpOnly: true
|
|
||||||
})
|
|
||||||
|
|
||||||
// persist state if passed in
|
// Additional handling for non-API based authMethod
|
||||||
|
if (!this.isAuthMethodAPIBased(authMethod)) {
|
||||||
|
// Store 'auth_state' if present in the request
|
||||||
if (req.query.state) {
|
if (req.query.state) {
|
||||||
res.cookie('auth_state', req.query.state, {
|
res.cookie('auth_state', req.query.state, { maxAge: TWO_MINUTES, httpOnly: true })
|
||||||
maxAge: 120000, // 2 min
|
|
||||||
httpOnly: true
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const callback = req.query.redirect_uri || req.query.callback
|
// Validate and store the callback URL
|
||||||
|
|
||||||
// check if we are missing a callback parameter - we need one if isRest=false
|
|
||||||
if (!callback) {
|
if (!callback) {
|
||||||
res.status(400).send({
|
return res.status(400).send({ message: 'No callback parameter' })
|
||||||
message: 'No callback parameter'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// store the callback url to the auth_cb cookie
|
res.cookie('auth_cb', callback, { maxAge: TWO_MINUTES, httpOnly: true })
|
||||||
res.cookie('auth_cb', callback, {
|
|
||||||
maxAge: 120000, // 2 min
|
|
||||||
httpOnly: true
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the authentication method for long
|
||||||
|
res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -239,7 +242,7 @@ class Auth {
|
|||||||
// get userLogin json (information about the user, server and the session)
|
// get userLogin json (information about the user, server and the session)
|
||||||
const data_json = await this.getUserLoginResponsePayload(req.user)
|
const data_json = await this.getUserLoginResponsePayload(req.user)
|
||||||
|
|
||||||
if (req.cookies.is_rest === 'true') {
|
if (this.isAuthMethodAPIBased(req.cookies.auth_method)) {
|
||||||
// REST request - send data
|
// REST request - send data
|
||||||
res.json(data_json)
|
res.json(data_json)
|
||||||
} else {
|
} else {
|
||||||
@ -269,109 +272,105 @@ class Auth {
|
|||||||
|
|
||||||
// openid strategy login route (this redirects to the configured openid login provider)
|
// openid strategy login route (this redirects to the configured openid login provider)
|
||||||
router.get('/auth/openid', (req, res, next) => {
|
router.get('/auth/openid', (req, res, next) => {
|
||||||
|
// Get the OIDC client from the strategy
|
||||||
|
// We need to call the client manually, because the strategy does not support forwarding the code challenge
|
||||||
|
// for API or mobile clients
|
||||||
|
const oidcStrategy = passport._strategy('openid-client')
|
||||||
|
const client = oidcStrategy._client
|
||||||
|
const sessionKey = oidcStrategy._key
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// helper function from openid-client
|
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||||
function pick(object, ...paths) {
|
const hostUrl = new URL(`${protocol}://${req.get('host')}`)
|
||||||
const obj = {}
|
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
|
||||||
for (const path of paths) {
|
|
||||||
if (object[path] !== undefined) {
|
// Only allow code flow (for mobile clients)
|
||||||
obj[path] = object[path]
|
if (req.query.response_type && req.query.response_type !== 'code') {
|
||||||
}
|
Logger.debug(`[Auth] OIDC Invalid response_type=${req.query.response_type}`)
|
||||||
}
|
return res.status(400).send('Invalid response_type, only code supported')
|
||||||
return obj
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the OIDC client from the strategy
|
// Generate a state on web flow or if no state supplied
|
||||||
// We need to call the client manually, because the strategy does not support forwarding the code challenge
|
const state = (!isMobileFlow || !req.query.state) ? OpenIDClient.generators.random() : req.query.state
|
||||||
// for API or mobile clients
|
|
||||||
const oidcStrategy = passport._strategy('openid-client')
|
|
||||||
const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http'
|
|
||||||
|
|
||||||
let mobile_redirect_uri = null
|
// Redirect URL for the SSO provider
|
||||||
|
let redirectUri
|
||||||
// The client wishes a different redirect_uri
|
if (isMobileFlow) {
|
||||||
// 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
|
// Mobile required redirect uri
|
||||||
// where we will handle the redirect to it
|
// If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect
|
||||||
if (req.query.redirect_uri) {
|
// where we will handle the redirect to it
|
||||||
// Check if the redirect_uri is in the whitelist
|
if (!req.query.redirect_uri || !isValidRedirectUri(req.query.redirect_uri)) {
|
||||||
if (Database.serverSettings.authOpenIDMobileRedirectURIs.includes(req.query.redirect_uri) ||
|
Logger.debug(`[Auth] Invalid redirect_uri=${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')
|
return res.status(400).send('Invalid redirect_uri')
|
||||||
}
|
}
|
||||||
|
// We cannot save the supplied 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(state, { mobile_redirect_uri: req.query.redirect_uri })
|
||||||
|
|
||||||
|
redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString()
|
||||||
} else {
|
} else {
|
||||||
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString()
|
redirectUri = new URL('/auth/openid/callback', hostUrl).toString()
|
||||||
}
|
|
||||||
|
|
||||||
Logger.debug(`[Auth] Oidc redirect_uri=${oidcStrategy._params.redirect_uri}`)
|
if (req.query.state) {
|
||||||
const client = oidcStrategy._client
|
Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`)
|
||||||
const sessionKey = oidcStrategy._key
|
return res.status(400).send('Invalid state, not allowed on web flow')
|
||||||
|
|
||||||
let code_challenge
|
|
||||||
let code_challenge_method
|
|
||||||
|
|
||||||
// If code_challenge is provided, expect that code_verifier will be handled by the client (mobile app)
|
|
||||||
// The web frontend of ABS does not need to do a PKCE itself, because it never handles the "code" of the oauth flow
|
|
||||||
// and as such will not send a code challenge, we will generate then one
|
|
||||||
if (req.query.code_challenge) {
|
|
||||||
code_challenge = req.query.code_challenge
|
|
||||||
code_challenge_method = req.query.code_challenge_method || 'S256'
|
|
||||||
|
|
||||||
if (!['S256', 'plain'].includes(code_challenge_method)) {
|
|
||||||
return res.status(400).send('Invalid code_challenge_method')
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// If no code_challenge is provided, assume a web application flow and generate one
|
|
||||||
const code_verifier = OpenIDClient.generators.codeVerifier()
|
|
||||||
code_challenge = OpenIDClient.generators.codeChallenge(code_verifier)
|
|
||||||
code_challenge_method = 'S256'
|
|
||||||
|
|
||||||
// Store the code_verifier in the session for later use in the token exchange
|
|
||||||
req.session[sessionKey] = { ...req.session[sessionKey], code_verifier }
|
|
||||||
}
|
}
|
||||||
|
oidcStrategy._params.redirect_uri = redirectUri
|
||||||
|
Logger.debug(`[Auth] OIDC redirect_uri=${redirectUri}`)
|
||||||
|
|
||||||
const params = {
|
let { code_challenge, code_challenge_method, code_verifier } = generatePkce(req, isMobileFlow)
|
||||||
state: OpenIDClient.generators.random(),
|
|
||||||
// Other params by the passport strategy
|
|
||||||
...oidcStrategy._params
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!params.nonce && params.response_type.includes('id_token')) {
|
|
||||||
params.nonce = OpenIDClient.generators.random()
|
|
||||||
}
|
|
||||||
|
|
||||||
req.session[sessionKey] = {
|
req.session[sessionKey] = {
|
||||||
...req.session[sessionKey],
|
...req.session[sessionKey],
|
||||||
...pick(params, 'nonce', 'state', 'max_age', 'response_type'),
|
state: state,
|
||||||
|
max_age: oidcStrategy._params.max_age,
|
||||||
|
response_type: 'code',
|
||||||
|
code_verifier: code_verifier, // not null if web flow
|
||||||
mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out
|
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
|
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
|
|
||||||
const authorizationUrl = client.authorizationUrl({
|
const authorizationUrl = client.authorizationUrl({
|
||||||
...params,
|
...oidcStrategy._params,
|
||||||
scope: 'openid profile email',
|
state: state,
|
||||||
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
|
this.paramsToCookies(req, res, isMobileFlow ? 'openid-mobile' : 'openid')
|
||||||
this.paramsToCookies(req, res)
|
|
||||||
|
|
||||||
// Redirect the user agent (browser) to the authorization URL
|
|
||||||
res.redirect(authorizationUrl)
|
res.redirect(authorizationUrl)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`[Auth] Error in /auth/openid route: ${error}`)
|
Logger.error(`[Auth] Error in /auth/openid route: ${error}`)
|
||||||
res.status(500).send('Internal Server Error')
|
res.status(500).send('Internal Server Error')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generatePkce(req, isMobileFlow) {
|
||||||
|
if (isMobileFlow) {
|
||||||
|
if (!req.query.code_challenge) {
|
||||||
|
throw new Error('code_challenge required for mobile flow (PKCE)')
|
||||||
|
}
|
||||||
|
if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') {
|
||||||
|
throw new Error('Only S256 code_challenge_method method supported')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
code_challenge: req.query.code_challenge,
|
||||||
|
code_challenge_method: req.query.code_challenge_method || 'S256'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const code_verifier = OpenIDClient.generators.codeVerifier()
|
||||||
|
const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier)
|
||||||
|
return { code_challenge, code_challenge_method: 'S256', code_verifier }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidRedirectUri(uri) {
|
||||||
|
// Check if the redirect_uri is in the whitelist
|
||||||
|
return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) ||
|
||||||
|
(Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// This will be the oauth2 callback route for mobile clients
|
// This will be the oauth2 callback route for mobile clients
|
||||||
@ -453,6 +452,12 @@ class Auth {
|
|||||||
if (loginError) {
|
if (loginError) {
|
||||||
return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`)
|
return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The id_token does not provide access to the user, but is used to identify the user to the SSO provider
|
||||||
|
// instead it containts a JWT with userinfo like user email, username, etc.
|
||||||
|
// the client will get to know it anyway in the logout url according to the oauth2 spec
|
||||||
|
// so it is safe to send it to the client, but we use strict settings
|
||||||
|
res.cookie('openid_id_token', user.openid_id_token, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true, secure: true, sameSite: 'Strict' })
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -521,7 +526,46 @@ class Auth {
|
|||||||
if (err) {
|
if (err) {
|
||||||
res.sendStatus(500)
|
res.sendStatus(500)
|
||||||
} else {
|
} else {
|
||||||
res.sendStatus(200)
|
const authMethod = req.cookies.auth_method
|
||||||
|
|
||||||
|
res.clearCookie('auth_method')
|
||||||
|
|
||||||
|
if (authMethod === 'openid' || authMethod === 'openid-mobile') {
|
||||||
|
// If we are using openid, we need to redirect to the logout endpoint
|
||||||
|
// node-openid-client does not support doing it over passport
|
||||||
|
const oidcStrategy = passport._strategy('openid-client')
|
||||||
|
const client = oidcStrategy._client
|
||||||
|
|
||||||
|
let postLogoutRedirectUri = null
|
||||||
|
|
||||||
|
if (authMethod === 'openid') {
|
||||||
|
const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http'
|
||||||
|
const host = req.get('host')
|
||||||
|
// TODO: ABS does currently not support subfolders for installation
|
||||||
|
// If we want to support it we need to include a config for the serverurl
|
||||||
|
postLogoutRedirectUri = `${protocol}://${host}/login`
|
||||||
|
}
|
||||||
|
// else for openid-mobile we keep postLogoutRedirectUri on null
|
||||||
|
// nice would be to redirect to the app here, but for example Authentik does not implement
|
||||||
|
// the post_logout_redirect_uri parameter at all and for other providers
|
||||||
|
// we would also need again to implement (and even before get to know somehow for 3rd party apps)
|
||||||
|
// the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect).
|
||||||
|
// Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like
|
||||||
|
// &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution
|
||||||
|
// (The URL needs to be whitelisted in the config of the SSO/ID provider)
|
||||||
|
|
||||||
|
const logoutUrl = client.endSessionUrl({
|
||||||
|
id_token_hint: req.cookies.openid_id_token,
|
||||||
|
post_logout_redirect_uri: postLogoutRedirectUri
|
||||||
|
})
|
||||||
|
|
||||||
|
res.clearCookie('openid_id_token')
|
||||||
|
|
||||||
|
// Tell the user agent (browser) to redirect to the authentification provider's logout URL
|
||||||
|
res.send({ redirect_url: logoutUrl })
|
||||||
|
} else {
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -612,7 +656,7 @@ class Auth {
|
|||||||
* Checks if a username and password tuple is valid and the user active.
|
* Checks if a username and password tuple is valid and the user active.
|
||||||
* @param {string} username
|
* @param {string} username
|
||||||
* @param {string} password
|
* @param {string} password
|
||||||
* @param {function} done
|
* @param {Promise<function>} done
|
||||||
*/
|
*/
|
||||||
async localAuthCheckUserPw(username, password, done) {
|
async localAuthCheckUserPw(username, password, done) {
|
||||||
// Load the user given it's username
|
// Load the user given it's username
|
||||||
@ -654,7 +698,7 @@ class Auth {
|
|||||||
/**
|
/**
|
||||||
* Hashes a password with bcrypt.
|
* Hashes a password with bcrypt.
|
||||||
* @param {string} password
|
* @param {string} password
|
||||||
* @returns {string} hash
|
* @returns {Promise<string>} hash
|
||||||
*/
|
*/
|
||||||
hashPass(password) {
|
hashPass(password) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@ -688,8 +732,8 @@ class Auth {
|
|||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {string} password
|
* @param {string} password
|
||||||
* @param {*} user
|
* @param {import('./models/User')} user
|
||||||
* @returns {boolean}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
comparePassword(password, user) {
|
comparePassword(password, user) {
|
||||||
if (user.type === 'root' && !password && !user.pash) return true
|
if (user.type === 'root' && !password && !user.pash) return true
|
||||||
|
@ -132,6 +132,11 @@ class Database {
|
|||||||
return this.models.playbackSession
|
return this.models.playbackSession
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {typeof import('./models/CustomMetadataProvider')} */
|
||||||
|
get customMetadataProviderModel() {
|
||||||
|
return this.models.customMetadataProvider
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if db file exists
|
* Check if db file exists
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
@ -245,6 +250,7 @@ class Database {
|
|||||||
require('./models/Feed').init(this.sequelize)
|
require('./models/Feed').init(this.sequelize)
|
||||||
require('./models/FeedEpisode').init(this.sequelize)
|
require('./models/FeedEpisode').init(this.sequelize)
|
||||||
require('./models/Setting').init(this.sequelize)
|
require('./models/Setting').init(this.sequelize)
|
||||||
|
require('./models/CustomMetadataProvider').init(this.sequelize)
|
||||||
|
|
||||||
return this.sequelize.sync({ force, alter: false })
|
return this.sequelize.sync({ force, alter: false })
|
||||||
}
|
}
|
||||||
@ -413,10 +419,21 @@ class Database {
|
|||||||
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
|
await this.models.libraryItem.fullCreateFromOld(oldLibraryItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save metadata file and update library item
|
||||||
|
*
|
||||||
|
* @param {import('./objects/LibraryItem')} oldLibraryItem
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
async updateLibraryItem(oldLibraryItem) {
|
async updateLibraryItem(oldLibraryItem) {
|
||||||
if (!this.sequelize) return false
|
if (!this.sequelize) return false
|
||||||
await oldLibraryItem.saveMetadata()
|
await oldLibraryItem.saveMetadata()
|
||||||
return this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
|
const updated = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem)
|
||||||
|
// Clear library filter data cache
|
||||||
|
if (updated) {
|
||||||
|
delete this.libraryFilterData[oldLibraryItem.libraryId]
|
||||||
|
}
|
||||||
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeLibraryItem(libraryItemId) {
|
async removeLibraryItem(libraryItemId) {
|
||||||
|
@ -3,13 +3,17 @@ const { LogLevel } = require('./utils/constants')
|
|||||||
|
|
||||||
class Logger {
|
class Logger {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
/** @type {import('./managers/LogManager')} */
|
||||||
|
this.logManager = null
|
||||||
|
|
||||||
this.isDev = process.env.NODE_ENV !== 'production'
|
this.isDev = process.env.NODE_ENV !== 'production'
|
||||||
this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE
|
this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE
|
||||||
this.socketListeners = []
|
this.socketListeners = []
|
||||||
|
|
||||||
this.logManager = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
get timestamp() {
|
get timestamp() {
|
||||||
return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss.SSS')
|
return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss.SSS')
|
||||||
}
|
}
|
||||||
@ -23,6 +27,9 @@ class Logger {
|
|||||||
return 'UNKNOWN'
|
return 'UNKNOWN'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
get source() {
|
get source() {
|
||||||
try {
|
try {
|
||||||
throw new Error()
|
throw new Error()
|
||||||
@ -62,7 +69,12 @@ class Logger {
|
|||||||
this.socketListeners = this.socketListeners.filter(s => s.id !== socketId)
|
this.socketListeners = this.socketListeners.filter(s => s.id !== socketId)
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLog(level, args) {
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} level
|
||||||
|
* @param {string[]} args
|
||||||
|
*/
|
||||||
|
async handleLog(level, args) {
|
||||||
const logObj = {
|
const logObj = {
|
||||||
timestamp: this.timestamp,
|
timestamp: this.timestamp,
|
||||||
source: this.source,
|
source: this.source,
|
||||||
@ -71,15 +83,17 @@ class Logger {
|
|||||||
level
|
level
|
||||||
}
|
}
|
||||||
|
|
||||||
if (level >= this.logLevel && this.logManager) {
|
// Emit log to sockets that are listening to log events
|
||||||
this.logManager.logToFile(logObj)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.socketListeners.forEach((socketListener) => {
|
this.socketListeners.forEach((socketListener) => {
|
||||||
if (socketListener.level <= level) {
|
if (socketListener.level <= level) {
|
||||||
socketListener.socket.emit('log', logObj)
|
socketListener.socket.emit('log', logObj)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Save log to file
|
||||||
|
if (level >= this.logLevel) {
|
||||||
|
await this.logManager.logToFile(logObj)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setLogLevel(level) {
|
setLogLevel(level) {
|
||||||
@ -117,9 +131,15 @@ class Logger {
|
|||||||
this.handleLog(LogLevel.ERROR, args)
|
this.handleLog(LogLevel.ERROR, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fatal errors are ones that exit the process
|
||||||
|
* Fatal logs are saved to crash_logs.txt
|
||||||
|
*
|
||||||
|
* @param {...any} args
|
||||||
|
*/
|
||||||
fatal(...args) {
|
fatal(...args) {
|
||||||
console.error(`[${this.timestamp}] FATAL:`, ...args, `(${this.source})`)
|
console.error(`[${this.timestamp}] FATAL:`, ...args, `(${this.source})`)
|
||||||
this.handleLog(LogLevel.FATAL, args)
|
return this.handleLog(LogLevel.FATAL, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
note(...args) {
|
note(...args) {
|
||||||
|
@ -2,9 +2,9 @@ const Path = require('path')
|
|||||||
const Sequelize = require('sequelize')
|
const Sequelize = require('sequelize')
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
const http = require('http')
|
const http = require('http')
|
||||||
|
const util = require('util')
|
||||||
const fs = require('./libs/fsExtra')
|
const fs = require('./libs/fsExtra')
|
||||||
const fileUpload = require('./libs/expressFileupload')
|
const fileUpload = require('./libs/expressFileupload')
|
||||||
const rateLimit = require('./libs/expressRateLimit')
|
|
||||||
const cookieParser = require("cookie-parser")
|
const cookieParser = require("cookie-parser")
|
||||||
|
|
||||||
const { version } = require('../package.json')
|
const { version } = require('../package.json')
|
||||||
@ -21,11 +21,11 @@ const SocketAuthority = require('./SocketAuthority')
|
|||||||
const ApiRouter = require('./routers/ApiRouter')
|
const ApiRouter = require('./routers/ApiRouter')
|
||||||
const HlsRouter = require('./routers/HlsRouter')
|
const HlsRouter = require('./routers/HlsRouter')
|
||||||
|
|
||||||
|
const LogManager = require('./managers/LogManager')
|
||||||
const NotificationManager = require('./managers/NotificationManager')
|
const NotificationManager = require('./managers/NotificationManager')
|
||||||
const EmailManager = require('./managers/EmailManager')
|
const EmailManager = require('./managers/EmailManager')
|
||||||
const AbMergeManager = require('./managers/AbMergeManager')
|
const AbMergeManager = require('./managers/AbMergeManager')
|
||||||
const CacheManager = require('./managers/CacheManager')
|
const CacheManager = require('./managers/CacheManager')
|
||||||
const LogManager = require('./managers/LogManager')
|
|
||||||
const BackupManager = require('./managers/BackupManager')
|
const BackupManager = require('./managers/BackupManager')
|
||||||
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
|
const PlaybackSessionManager = require('./managers/PlaybackSessionManager')
|
||||||
const PodcastManager = require('./managers/PodcastManager')
|
const PodcastManager = require('./managers/PodcastManager')
|
||||||
@ -67,7 +67,6 @@ class Server {
|
|||||||
this.notificationManager = new NotificationManager()
|
this.notificationManager = new NotificationManager()
|
||||||
this.emailManager = new EmailManager()
|
this.emailManager = new EmailManager()
|
||||||
this.backupManager = new BackupManager()
|
this.backupManager = new BackupManager()
|
||||||
this.logManager = new LogManager()
|
|
||||||
this.abMergeManager = new AbMergeManager()
|
this.abMergeManager = new AbMergeManager()
|
||||||
this.playbackSessionManager = new PlaybackSessionManager()
|
this.playbackSessionManager = new PlaybackSessionManager()
|
||||||
this.podcastManager = new PodcastManager(this.watcher, this.notificationManager)
|
this.podcastManager = new PodcastManager(this.watcher, this.notificationManager)
|
||||||
@ -81,7 +80,7 @@ class Server {
|
|||||||
this.apiRouter = new ApiRouter(this)
|
this.apiRouter = new ApiRouter(this)
|
||||||
this.hlsRouter = new HlsRouter(this.auth, this.playbackSessionManager)
|
this.hlsRouter = new HlsRouter(this.auth, this.playbackSessionManager)
|
||||||
|
|
||||||
Logger.logManager = this.logManager
|
Logger.logManager = new LogManager()
|
||||||
|
|
||||||
this.server = null
|
this.server = null
|
||||||
this.io = null
|
this.io = null
|
||||||
@ -102,10 +101,13 @@ class Server {
|
|||||||
*/
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
Logger.info('[Server] Init v' + version)
|
Logger.info('[Server] Init v' + version)
|
||||||
|
|
||||||
await this.playbackSessionManager.removeOrphanStreams()
|
await this.playbackSessionManager.removeOrphanStreams()
|
||||||
|
|
||||||
await Database.init(false)
|
await Database.init(false)
|
||||||
|
|
||||||
|
await Logger.logManager.init()
|
||||||
|
|
||||||
// Create token secret if does not exist (Added v2.1.0)
|
// Create token secret if does not exist (Added v2.1.0)
|
||||||
if (!Database.serverSettings.tokenSecret) {
|
if (!Database.serverSettings.tokenSecret) {
|
||||||
await this.auth.initTokenSecret()
|
await this.auth.initTokenSecret()
|
||||||
@ -115,7 +117,6 @@ class Server {
|
|||||||
await CacheManager.ensureCachePaths()
|
await CacheManager.ensureCachePaths()
|
||||||
|
|
||||||
await this.backupManager.init()
|
await this.backupManager.init()
|
||||||
await this.logManager.init()
|
|
||||||
await this.rssFeedManager.init()
|
await this.rssFeedManager.init()
|
||||||
|
|
||||||
const libraries = await Database.libraryModel.getAllOldLibraries()
|
const libraries = await Database.libraryModel.getAllOldLibraries()
|
||||||
@ -135,8 +136,41 @@ class Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen for SIGINT and uncaught exceptions
|
||||||
|
*/
|
||||||
|
initProcessEventListeners() {
|
||||||
|
let sigintAlreadyReceived = false
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
if (!sigintAlreadyReceived) {
|
||||||
|
sigintAlreadyReceived = true
|
||||||
|
Logger.info('SIGINT (Ctrl+C) received. Shutting down...')
|
||||||
|
await this.stop()
|
||||||
|
Logger.info('Server stopped. Exiting.')
|
||||||
|
} else {
|
||||||
|
Logger.info('SIGINT (Ctrl+C) received again. Exiting immediately.')
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://nodejs.org/api/process.html#event-uncaughtexceptionmonitor
|
||||||
|
*/
|
||||||
|
process.on('uncaughtExceptionMonitor', async (error, origin) => {
|
||||||
|
await Logger.fatal(`[Server] Uncaught exception origin: ${origin}, error:`, util.format('%O', error))
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* @see https://nodejs.org/api/process.html#event-unhandledrejection
|
||||||
|
*/
|
||||||
|
process.on('unhandledRejection', async (reason, promise) => {
|
||||||
|
await Logger.fatal(`[Server] Unhandled rejection: ${reason}, promise:`, util.format('%O', promise))
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
Logger.info('=== Starting Server ===')
|
Logger.info('=== Starting Server ===')
|
||||||
|
this.initProcessEventListeners()
|
||||||
await this.init()
|
await this.init()
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
@ -252,8 +286,6 @@ class Server {
|
|||||||
]
|
]
|
||||||
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||||
|
|
||||||
// router.post('/login', passport.authenticate('local', this.auth.login), this.auth.loginResult.bind(this))
|
|
||||||
// router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
|
||||||
router.post('/init', (req, res) => {
|
router.post('/init', (req, res) => {
|
||||||
if (Database.hasRootUser) {
|
if (Database.hasRootUser) {
|
||||||
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
||||||
@ -284,19 +316,6 @@ class Server {
|
|||||||
})
|
})
|
||||||
app.get('/healthcheck', (req, res) => res.sendStatus(200))
|
app.get('/healthcheck', (req, res) => res.sendStatus(200))
|
||||||
|
|
||||||
let sigintAlreadyReceived = false
|
|
||||||
process.on('SIGINT', async () => {
|
|
||||||
if (!sigintAlreadyReceived) {
|
|
||||||
sigintAlreadyReceived = true
|
|
||||||
Logger.info('SIGINT (Ctrl+C) received. Shutting down...')
|
|
||||||
await this.stop()
|
|
||||||
Logger.info('Server stopped. Exiting.')
|
|
||||||
} else {
|
|
||||||
Logger.info('SIGINT (Ctrl+C) received again. Exiting immediately.')
|
|
||||||
}
|
|
||||||
process.exit(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.server.listen(this.Port, this.Host, () => {
|
this.server.listen(this.Port, this.Host, () => {
|
||||||
if (this.Host) Logger.info(`Listening on http://${this.Host}:${this.Port}`)
|
if (this.Host) Logger.info(`Listening on http://${this.Host}:${this.Port}`)
|
||||||
else Logger.info(`Listening on port :${this.Port}`)
|
else Logger.info(`Listening on port :${this.Port}`)
|
||||||
@ -379,30 +398,6 @@ class Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// First time login rate limit is hit
|
|
||||||
loginLimitReached(req, res, options) {
|
|
||||||
Logger.error(`[Server] Login rate limit (${options.max}) was hit for ip ${req.ip}`)
|
|
||||||
options.message = 'Too many attempts. Login temporarily locked.'
|
|
||||||
}
|
|
||||||
|
|
||||||
getLoginRateLimiter() {
|
|
||||||
return rateLimit({
|
|
||||||
windowMs: Database.serverSettings.rateLimitLoginWindow, // 5 minutes
|
|
||||||
max: Database.serverSettings.rateLimitLoginRequests,
|
|
||||||
skipSuccessfulRequests: true,
|
|
||||||
onLimitReached: this.loginLimitReached
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
logout(req, res) {
|
|
||||||
if (req.body.socketId) {
|
|
||||||
Logger.info(`[Server] User ${req.user ? req.user.username : 'Unknown'} is logging out with socket ${req.body.socketId}`)
|
|
||||||
SocketAuthority.logout(req.body.socketId)
|
|
||||||
}
|
|
||||||
|
|
||||||
res.sendStatus(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gracefully stop server
|
* Gracefully stop server
|
||||||
* Stops watcher and socket server
|
* Stops watcher and socket server
|
||||||
|
@ -116,7 +116,6 @@ class SocketAuthority {
|
|||||||
// Logs
|
// Logs
|
||||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||||
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
||||||
socket.on('fetch_daily_logs', () => this.Server.logManager.socketRequestDailyLogs(socket))
|
|
||||||
|
|
||||||
// Sent automatically from socket.io clients
|
// Sent automatically from socket.io clients
|
||||||
socket.on('disconnect', (reason) => {
|
socket.on('disconnect', (reason) => {
|
||||||
@ -220,25 +219,6 @@ class SocketAuthority {
|
|||||||
client.socket.emit('init', initialPayload)
|
client.socket.emit('init', initialPayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
logout(socketId) {
|
|
||||||
// Strip user and client from client and client socket
|
|
||||||
if (socketId && this.clients[socketId]) {
|
|
||||||
const client = this.clients[socketId]
|
|
||||||
const clientSocket = client.socket
|
|
||||||
Logger.debug(`[SocketAuthority] Found user client ${clientSocket.id}, Has user: ${!!client.user}, Socket has client: ${!!clientSocket.sheepClient}`)
|
|
||||||
|
|
||||||
if (client.user) {
|
|
||||||
Logger.debug('[SocketAuthority] User Offline ' + client.user.username)
|
|
||||||
this.adminEmitter('user_offline', client.user.toJSONForPublic())
|
|
||||||
}
|
|
||||||
|
|
||||||
delete this.clients[socketId].user
|
|
||||||
if (clientSocket && clientSocket.sheepClient) delete this.clients[socketId].socket.sheepClient
|
|
||||||
} else if (socketId) {
|
|
||||||
Logger.warn(`[SocketAuthority] No client for socket ${socketId}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelScan(id) {
|
cancelScan(id) {
|
||||||
Logger.debug('[SocketAuthority] Cancel scan', id)
|
Logger.debug('[SocketAuthority] Cancel scan', id)
|
||||||
this.Server.cancelLibraryScan(id)
|
this.Server.cancelLibraryScan(id)
|
||||||
|
117
server/controllers/CustomMetadataProviderController.js
Normal file
117
server/controllers/CustomMetadataProviderController.js
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
const Logger = require('../Logger')
|
||||||
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
const Database = require('../Database')
|
||||||
|
|
||||||
|
const { validateUrl } = require('../utils/index')
|
||||||
|
|
||||||
|
//
|
||||||
|
// This is a controller for routes that don't have a home yet :(
|
||||||
|
//
|
||||||
|
class CustomMetadataProviderController {
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/custom-metadata-providers
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
|
async getAll(req, res) {
|
||||||
|
const providers = await Database.customMetadataProviderModel.findAll()
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
providers
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: /api/custom-metadata-providers
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
|
async create(req, res) {
|
||||||
|
const { name, url, mediaType, authHeaderValue } = req.body
|
||||||
|
|
||||||
|
if (!name || !url || !mediaType) {
|
||||||
|
return res.status(400).send('Invalid request body')
|
||||||
|
}
|
||||||
|
|
||||||
|
const validUrl = validateUrl(url)
|
||||||
|
if (!validUrl) {
|
||||||
|
Logger.error(`[CustomMetadataProviderController] Invalid url "${url}"`)
|
||||||
|
return res.status(400).send('Invalid url')
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await Database.customMetadataProviderModel.create({
|
||||||
|
name,
|
||||||
|
mediaType,
|
||||||
|
url,
|
||||||
|
authHeaderValue: !authHeaderValue ? null : authHeaderValue,
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: Necessary to emit to all clients?
|
||||||
|
SocketAuthority.emitter('custom_metadata_provider_added', provider.toClientJson())
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
provider
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE: /api/custom-metadata-providers/:id
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
|
async delete(req, res) {
|
||||||
|
const slug = `custom-${req.params.id}`
|
||||||
|
|
||||||
|
/** @type {import('../models/CustomMetadataProvider')} */
|
||||||
|
const provider = req.customMetadataProvider
|
||||||
|
const providerClientJson = provider.toClientJson()
|
||||||
|
|
||||||
|
const fallbackProvider = provider.mediaType === 'book' ? 'google' : 'itunes'
|
||||||
|
|
||||||
|
await provider.destroy()
|
||||||
|
|
||||||
|
// Libraries using this provider fallback to default provider
|
||||||
|
await Database.libraryModel.update({
|
||||||
|
provider: fallbackProvider
|
||||||
|
}, {
|
||||||
|
where: {
|
||||||
|
provider: slug
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: Necessary to emit to all clients?
|
||||||
|
SocketAuthority.emitter('custom_metadata_provider_removed', providerClientJson)
|
||||||
|
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware that requires admin or up
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
* @param {import('express').NextFunction} next
|
||||||
|
*/
|
||||||
|
async middleware(req, res, next) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.warn(`[CustomMetadataProviderController] Non-admin user "${req.user.username}" attempted access route "${req.path}"`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If id param then add req.customMetadataProvider
|
||||||
|
if (req.params.id) {
|
||||||
|
req.customMetadataProvider = await Database.customMetadataProviderModel.findByPk(req.params.id)
|
||||||
|
if (!req.customMetadataProvider) {
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = new CustomMetadataProviderController()
|
@ -33,6 +33,14 @@ class LibraryController {
|
|||||||
return res.status(500).send('Invalid request')
|
return res.status(500).send('Invalid request')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate that the custom provider exists if given any
|
||||||
|
if (newLibraryPayload.provider?.startsWith('custom-')) {
|
||||||
|
if (!await Database.customMetadataProviderModel.checkExistsBySlug(newLibraryPayload.provider)) {
|
||||||
|
Logger.error(`[LibraryController] Custom metadata provider "${newLibraryPayload.provider}" does not exist`)
|
||||||
|
return res.status(400).send('Custom metadata provider does not exist')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate folder paths exist or can be created & resolve rel paths
|
// Validate folder paths exist or can be created & resolve rel paths
|
||||||
// returns 400 if a folder fails to access
|
// returns 400 if a folder fails to access
|
||||||
newLibraryPayload.folders = newLibraryPayload.folders.map(f => {
|
newLibraryPayload.folders = newLibraryPayload.folders.map(f => {
|
||||||
@ -86,19 +94,27 @@ class LibraryController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/libraries/:id
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
async findOne(req, res) {
|
async findOne(req, res) {
|
||||||
const includeArray = (req.query.include || '').split(',')
|
const includeArray = (req.query.include || '').split(',')
|
||||||
if (includeArray.includes('filterdata')) {
|
if (includeArray.includes('filterdata')) {
|
||||||
const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)
|
const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)
|
||||||
|
const customMetadataProviders = await Database.customMetadataProviderModel.getForClientByMediaType(req.library.mediaType)
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
filterdata,
|
filterdata,
|
||||||
issues: filterdata.numIssues,
|
issues: filterdata.numIssues,
|
||||||
numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
|
numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
|
||||||
|
customMetadataProviders,
|
||||||
library: req.library
|
library: req.library
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return res.json(req.library)
|
res.json(req.library)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -115,6 +131,14 @@ class LibraryController {
|
|||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
const library = req.library
|
const library = req.library
|
||||||
|
|
||||||
|
// Validate that the custom provider exists if given any
|
||||||
|
if (req.body.provider?.startsWith('custom-')) {
|
||||||
|
if (!await Database.customMetadataProviderModel.checkExistsBySlug(req.body.provider)) {
|
||||||
|
Logger.error(`[LibraryController] Custom metadata provider "${req.body.provider}" does not exist`)
|
||||||
|
return res.status(400).send('Custom metadata provider does not exist')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate new folder paths exist or can be created & resolve rel paths
|
// Validate new folder paths exist or can be created & resolve rel paths
|
||||||
// returns 400 if a new folder fails to access
|
// returns 400 if a new folder fails to access
|
||||||
if (req.body.folders) {
|
if (req.body.folders) {
|
||||||
|
@ -336,7 +336,7 @@ class MeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET: /api/stats/year/:year
|
* GET: /api/me/stats/year/:year
|
||||||
*
|
*
|
||||||
* @param {import('express').Request} req
|
* @param {import('express').Request} req
|
||||||
* @param {import('express').Response} res
|
* @param {import('express').Response} res
|
||||||
|
@ -633,7 +633,7 @@ class MiscController {
|
|||||||
} else if (key === 'authOpenIDMobileRedirectURIs') {
|
} else if (key === 'authOpenIDMobileRedirectURIs') {
|
||||||
function isValidRedirectURI(uri) {
|
function isValidRedirectURI(uri) {
|
||||||
if (typeof uri !== 'string') return false
|
if (typeof uri !== 'string') return false
|
||||||
const pattern = new RegExp('^\\w+://[\\w.-]+$', 'i')
|
const pattern = new RegExp('^\\w+://[\\w\\.-]+(/[\\w\\./-]*)*$', 'i')
|
||||||
return pattern.test(uri)
|
return pattern.test(uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -699,7 +699,7 @@ class MiscController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET: /api/me/stats/year/:year
|
* GET: /api/stats/year/:year
|
||||||
*
|
*
|
||||||
* @param {import('express').Request} req
|
* @param {import('express').Request} req
|
||||||
* @param {import('express').Response} res
|
* @param {import('express').Response} res
|
||||||
@ -717,5 +717,23 @@ class MiscController {
|
|||||||
const stats = await adminStats.getStatsForYear(year)
|
const stats = await adminStats.getStatsForYear(year)
|
||||||
res.json(stats)
|
res.json(stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/logger-data
|
||||||
|
* admin or up
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
|
async getLoggerData(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get logger data`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
currentDailyLogs: Logger.logManager.getMostRecentCurrentDailyLogs()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = new MiscController()
|
module.exports = new MiscController()
|
||||||
|
@ -43,12 +43,15 @@ class SearchController {
|
|||||||
*/
|
*/
|
||||||
async findPodcasts(req, res) {
|
async findPodcasts(req, res) {
|
||||||
const term = req.query.term
|
const term = req.query.term
|
||||||
|
const country = req.query.country || 'us'
|
||||||
if (!term) {
|
if (!term) {
|
||||||
Logger.error('[SearchController] Invalid request query param "term" is required')
|
Logger.error('[SearchController] Invalid request query param "term" is required')
|
||||||
return res.status(400).send('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, {
|
||||||
|
country
|
||||||
|
})
|
||||||
res.json(results)
|
res.json(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -161,7 +161,7 @@ class SessionController {
|
|||||||
* @typedef batchDeleteReqBody
|
* @typedef batchDeleteReqBody
|
||||||
* @property {string[]} sessions
|
* @property {string[]} sessions
|
||||||
*
|
*
|
||||||
* @param {import('express').Request<{}, {}, batchDeleteReqBody, {}} req
|
* @param {import('express').Request<{}, {}, batchDeleteReqBody, {}>} req
|
||||||
* @param {import('express').Response} res
|
* @param {import('express').Response} res
|
||||||
*/
|
*/
|
||||||
async batchDelete(req, res) {
|
async batchDelete(req, res) {
|
||||||
|
@ -194,6 +194,23 @@ class UserController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH: /api/users/:id/openid-unlink
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {import('express').Response} res
|
||||||
|
*/
|
||||||
|
async unlinkFromOpenID(req, res) {
|
||||||
|
Logger.debug(`[UserController] Unlinking user "${req.reqUser.username}" from OpenID with sub "${req.reqUser.authOpenIDSub}"`)
|
||||||
|
req.reqUser.authOpenIDSub = null
|
||||||
|
if (await Database.userModel.updateFromOld(req.reqUser)) {
|
||||||
|
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.reqUser.toJSONForBrowser())
|
||||||
|
res.sendStatus(200)
|
||||||
|
} else {
|
||||||
|
res.sendStatus(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GET: api/users/:id/listening-sessions
|
// GET: api/users/:id/listening-sessions
|
||||||
async getListeningSessions(req, res) {
|
async getListeningSessions(req, res) {
|
||||||
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
|
var listeningSessions = await this.getUserListeningSessionsHelper(req.params.id)
|
||||||
|
@ -15,12 +15,19 @@ class AuthorFinder {
|
|||||||
return this.audnexus.findAuthorByASIN(asin, region)
|
return this.audnexus.findAuthorByASIN(asin, region)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string} region
|
||||||
|
* @param {Object} [options={}]
|
||||||
|
* @returns {Promise<import('../providers/Audnexus').AuthorSearchObj>}
|
||||||
|
*/
|
||||||
async findAuthorByName(name, region, options = {}) {
|
async findAuthorByName(name, region, options = {}) {
|
||||||
if (!name) return null
|
if (!name) return null
|
||||||
const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 3
|
const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 3
|
||||||
|
|
||||||
const author = await this.audnexus.findAuthorByName(name, region, maxLevenshtein)
|
const author = await this.audnexus.findAuthorByName(name, region, maxLevenshtein)
|
||||||
if (!author || !author.name) {
|
if (!author?.name) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return author
|
return author
|
||||||
|
@ -5,6 +5,7 @@ const iTunes = require('../providers/iTunes')
|
|||||||
const Audnexus = require('../providers/Audnexus')
|
const Audnexus = require('../providers/Audnexus')
|
||||||
const FantLab = require('../providers/FantLab')
|
const FantLab = require('../providers/FantLab')
|
||||||
const AudiobookCovers = require('../providers/AudiobookCovers')
|
const AudiobookCovers = require('../providers/AudiobookCovers')
|
||||||
|
const CustomProviderAdapter = require('../providers/CustomProviderAdapter')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { levenshteinDistance, escapeRegExp } = require('../utils/index')
|
const { levenshteinDistance, escapeRegExp } = require('../utils/index')
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ class BookFinder {
|
|||||||
this.audnexus = new Audnexus()
|
this.audnexus = new Audnexus()
|
||||||
this.fantLab = new FantLab()
|
this.fantLab = new FantLab()
|
||||||
this.audiobookCovers = new AudiobookCovers()
|
this.audiobookCovers = new AudiobookCovers()
|
||||||
|
this.customProviderAdapter = new CustomProviderAdapter()
|
||||||
|
|
||||||
this.providers = ['google', 'itunes', 'openlibrary', 'fantlab', 'audiobookcovers', 'audible', 'audible.ca', 'audible.uk', 'audible.au', 'audible.fr', 'audible.de', 'audible.jp', 'audible.it', 'audible.in', 'audible.es']
|
this.providers = ['google', 'itunes', 'openlibrary', 'fantlab', 'audiobookcovers', 'audible', 'audible.ca', 'audible.uk', 'audible.au', 'audible.fr', 'audible.de', 'audible.jp', 'audible.it', 'audible.in', 'audible.es']
|
||||||
|
|
||||||
@ -147,6 +149,20 @@ class BookFinder {
|
|||||||
return books
|
return books
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} title
|
||||||
|
* @param {string} author
|
||||||
|
* @param {string} providerSlug
|
||||||
|
* @returns {Promise<Object[]>}
|
||||||
|
*/
|
||||||
|
async getCustomProviderResults(title, author, providerSlug) {
|
||||||
|
const books = await this.customProviderAdapter.search(title, author, providerSlug, 'book')
|
||||||
|
if (this.verbose) Logger.debug(`Custom provider '${providerSlug}' Search Results: ${books.length || 0}`)
|
||||||
|
|
||||||
|
return books
|
||||||
|
}
|
||||||
|
|
||||||
static TitleCandidates = class {
|
static TitleCandidates = class {
|
||||||
|
|
||||||
constructor(cleanAuthor) {
|
constructor(cleanAuthor) {
|
||||||
@ -315,6 +331,11 @@ class BookFinder {
|
|||||||
const maxFuzzySearches = !isNaN(options.maxFuzzySearches) ? Number(options.maxFuzzySearches) : 5
|
const maxFuzzySearches = !isNaN(options.maxFuzzySearches) ? Number(options.maxFuzzySearches) : 5
|
||||||
let numFuzzySearches = 0
|
let numFuzzySearches = 0
|
||||||
|
|
||||||
|
// Custom providers are assumed to be correct
|
||||||
|
if (provider.startsWith('custom-')) {
|
||||||
|
return this.getCustomProviderResults(title, author, provider)
|
||||||
|
}
|
||||||
|
|
||||||
if (!title)
|
if (!title)
|
||||||
return books
|
return books
|
||||||
|
|
||||||
@ -397,8 +418,7 @@ class BookFinder {
|
|||||||
books = await this.getFantLabResults(title, author)
|
books = await this.getFantLabResults(title, author)
|
||||||
} else if (provider === 'audiobookcovers') {
|
} else if (provider === 'audiobookcovers') {
|
||||||
books = await this.getAudiobookCoversResults(title)
|
books = await this.getAudiobookCoversResults(title)
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
books = await this.getGoogleBooksResults(title, author)
|
books = await this.getGoogleBooksResults(title, author)
|
||||||
}
|
}
|
||||||
return books
|
return books
|
||||||
|
@ -6,10 +6,16 @@ class PodcastFinder {
|
|||||||
this.iTunesApi = new iTunes()
|
this.iTunesApi = new iTunes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} term
|
||||||
|
* @param {{country:string}} options
|
||||||
|
* @returns {Promise<import('../providers/iTunes').iTunesPodcastSearchResult[]>}
|
||||||
|
*/
|
||||||
async search(term, options = {}) {
|
async search(term, options = {}) {
|
||||||
if (!term) return null
|
if (!term) return null
|
||||||
Logger.debug(`[iTunes] Searching for podcast with term "${term}"`)
|
Logger.debug(`[iTunes] Searching for podcast with term "${term}"`)
|
||||||
var results = await this.iTunesApi.searchPodcasts(term, options)
|
const results = await this.iTunesApi.searchPodcasts(term, options)
|
||||||
Logger.debug(`[iTunes] Podcast search for "${term}" returned ${results.length} results`)
|
Logger.debug(`[iTunes] Podcast search for "${term}" returned ${results.length} results`)
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
# MIT License
|
|
||||||
|
|
||||||
Copyright 2021 Nathan Friedly
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
||||||
this software and associated documentation files (the "Software"), to deal in
|
|
||||||
the Software without restriction, including without limitation the rights to
|
|
||||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
||||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
||||||
subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
||||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
||||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
||||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@ -1,196 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
//
|
|
||||||
// modified for use in audiobookshelf
|
|
||||||
// Source: https://github.com/nfriedly/express-rate-limit
|
|
||||||
//
|
|
||||||
|
|
||||||
const MemoryStore = require("./memory-store");
|
|
||||||
|
|
||||||
function RateLimit(options) {
|
|
||||||
options = Object.assign(
|
|
||||||
{
|
|
||||||
windowMs: 60 * 1000, // milliseconds - how long to keep records of requests in memory
|
|
||||||
max: 5, // max number of recent connections during `window` milliseconds before sending a 429 response
|
|
||||||
message: "Too many requests, please try again later.",
|
|
||||||
statusCode: 429, // 429 status = Too Many Requests (RFC 6585)
|
|
||||||
headers: true, //Send custom rate limit header with limit and remaining
|
|
||||||
draft_polli_ratelimit_headers: false, //Support for the new RateLimit standardization headers
|
|
||||||
// ability to manually decide if request was successful. Used when `skipSuccessfulRequests` and/or `skipFailedRequests` are set to `true`
|
|
||||||
requestWasSuccessful: function (req, res) {
|
|
||||||
return res.statusCode < 400;
|
|
||||||
},
|
|
||||||
skipFailedRequests: false, // Do not count failed requests
|
|
||||||
skipSuccessfulRequests: false, // Do not count successful requests
|
|
||||||
// allows to create custom keys (by default user IP is used)
|
|
||||||
keyGenerator: function (req /*, res*/) {
|
|
||||||
if (!req.ip) {
|
|
||||||
console.error(
|
|
||||||
"express-rate-limit: req.ip is undefined - you can avoid this by providing a custom keyGenerator function, but it may be indicative of a larger issue."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return req.ip;
|
|
||||||
},
|
|
||||||
skip: function (/*req, res*/) {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
handler: function (req, res /*, next, optionsUsed*/) {
|
|
||||||
res.status(options.statusCode).send(options.message);
|
|
||||||
},
|
|
||||||
onLimitReached: function (/*req, res, optionsUsed*/) { },
|
|
||||||
requestPropertyName: "rateLimit", // Parameter name appended to req object
|
|
||||||
},
|
|
||||||
options
|
|
||||||
);
|
|
||||||
|
|
||||||
// store to use for persisting rate limit data
|
|
||||||
options.store = options.store || new MemoryStore(options.windowMs);
|
|
||||||
|
|
||||||
// ensure that the store has the incr method
|
|
||||||
if (
|
|
||||||
typeof options.store.incr !== "function" ||
|
|
||||||
typeof options.store.resetKey !== "function" ||
|
|
||||||
(options.skipFailedRequests &&
|
|
||||||
typeof options.store.decrement !== "function")
|
|
||||||
) {
|
|
||||||
throw new Error("The store is not valid.");
|
|
||||||
}
|
|
||||||
|
|
||||||
["global", "delayMs", "delayAfter"].forEach((key) => {
|
|
||||||
// note: this doesn't trigger if delayMs or delayAfter are set to 0, because that essentially disables them
|
|
||||||
if (options[key]) {
|
|
||||||
throw new Error(
|
|
||||||
`The ${key} option was removed from express-rate-limit v3.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function rateLimit(req, res, next) {
|
|
||||||
Promise.resolve(options.skip(req, res))
|
|
||||||
.then((skip) => {
|
|
||||||
if (skip) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = options.keyGenerator(req, res);
|
|
||||||
|
|
||||||
options.store.incr(key, function (err, current, resetTime) {
|
|
||||||
if (err) {
|
|
||||||
return next(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxResult =
|
|
||||||
typeof options.max === "function"
|
|
||||||
? options.max(req, res)
|
|
||||||
: options.max;
|
|
||||||
|
|
||||||
Promise.resolve(maxResult)
|
|
||||||
.then((max) => {
|
|
||||||
req[options.requestPropertyName] = {
|
|
||||||
limit: max,
|
|
||||||
current: current,
|
|
||||||
remaining: Math.max(max - current, 0),
|
|
||||||
resetTime: resetTime,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options.headers && !res.headersSent) {
|
|
||||||
res.setHeader("X-RateLimit-Limit", max);
|
|
||||||
res.setHeader(
|
|
||||||
"X-RateLimit-Remaining",
|
|
||||||
req[options.requestPropertyName].remaining
|
|
||||||
);
|
|
||||||
if (resetTime instanceof Date) {
|
|
||||||
// if we have a resetTime, also provide the current date to help avoid issues with incorrect clocks
|
|
||||||
res.setHeader("Date", new Date().toUTCString());
|
|
||||||
res.setHeader(
|
|
||||||
"X-RateLimit-Reset",
|
|
||||||
Math.ceil(resetTime.getTime() / 1000)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (options.draft_polli_ratelimit_headers && !res.headersSent) {
|
|
||||||
res.setHeader("RateLimit-Limit", max);
|
|
||||||
res.setHeader(
|
|
||||||
"RateLimit-Remaining",
|
|
||||||
req[options.requestPropertyName].remaining
|
|
||||||
);
|
|
||||||
if (resetTime) {
|
|
||||||
const deltaSeconds = Math.ceil(
|
|
||||||
(resetTime.getTime() - Date.now()) / 1000
|
|
||||||
);
|
|
||||||
res.setHeader("RateLimit-Reset", Math.max(0, deltaSeconds));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
options.skipFailedRequests ||
|
|
||||||
options.skipSuccessfulRequests
|
|
||||||
) {
|
|
||||||
let decremented = false;
|
|
||||||
const decrementKey = () => {
|
|
||||||
if (!decremented) {
|
|
||||||
options.store.decrement(key);
|
|
||||||
decremented = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options.skipFailedRequests) {
|
|
||||||
res.on("finish", function () {
|
|
||||||
if (!options.requestWasSuccessful(req, res)) {
|
|
||||||
decrementKey();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.on("close", () => {
|
|
||||||
if (!res.finished) {
|
|
||||||
decrementKey();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.on("error", () => decrementKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.skipSuccessfulRequests) {
|
|
||||||
res.on("finish", function () {
|
|
||||||
if (options.requestWasSuccessful(req, res)) {
|
|
||||||
options.store.decrement(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (max && current === max + 1) {
|
|
||||||
options.onLimitReached(req, res, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (max && current > max) {
|
|
||||||
if (options.headers && !res.headersSent) {
|
|
||||||
res.setHeader(
|
|
||||||
"Retry-After",
|
|
||||||
Math.ceil(options.windowMs / 1000)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return options.handler(req, res, next, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.catch(next);
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.catch(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
rateLimit.resetKey = options.store.resetKey.bind(options.store);
|
|
||||||
|
|
||||||
// Backward compatibility function
|
|
||||||
rateLimit.resetIp = rateLimit.resetKey;
|
|
||||||
|
|
||||||
return rateLimit;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = RateLimit;
|
|
@ -1,47 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
function calculateNextResetTime(windowMs) {
|
|
||||||
const d = new Date();
|
|
||||||
d.setMilliseconds(d.getMilliseconds() + windowMs);
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MemoryStore(windowMs) {
|
|
||||||
let hits = {};
|
|
||||||
let resetTime = calculateNextResetTime(windowMs);
|
|
||||||
|
|
||||||
this.incr = function (key, cb) {
|
|
||||||
if (hits[key]) {
|
|
||||||
hits[key]++;
|
|
||||||
} else {
|
|
||||||
hits[key] = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
cb(null, hits[key], resetTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.decrement = function (key) {
|
|
||||||
if (hits[key]) {
|
|
||||||
hits[key]--;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// export an API to allow hits all IPs to be reset
|
|
||||||
this.resetAll = function () {
|
|
||||||
hits = {};
|
|
||||||
resetTime = calculateNextResetTime(windowMs);
|
|
||||||
};
|
|
||||||
|
|
||||||
// export an API to allow hits from one IP to be reset
|
|
||||||
this.resetKey = function (key) {
|
|
||||||
delete hits[key];
|
|
||||||
};
|
|
||||||
|
|
||||||
// simply reset ALL hits every windowMs
|
|
||||||
const interval = setInterval(this.resetAll, windowMs);
|
|
||||||
if (interval.unref) {
|
|
||||||
interval.unref();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = MemoryStore;
|
|
@ -1,3 +1,6 @@
|
|||||||
|
const child_process = require('child_process')
|
||||||
|
const { promisify } = require('util')
|
||||||
|
const exec = promisify(child_process.exec)
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const which = require('../libs/which')
|
const which = require('../libs/which')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
@ -8,67 +11,143 @@ const fileUtils = require('../utils/fileUtils')
|
|||||||
class BinaryManager {
|
class BinaryManager {
|
||||||
|
|
||||||
defaultRequiredBinaries = [
|
defaultRequiredBinaries = [
|
||||||
{ name: 'ffmpeg', envVariable: 'FFMPEG_PATH' },
|
{ name: 'ffmpeg', envVariable: 'FFMPEG_PATH', validVersions: ['5.1', '6'] },
|
||||||
{ name: 'ffprobe', envVariable: 'FFPROBE_PATH' }
|
{ name: 'ffprobe', envVariable: 'FFPROBE_PATH', validVersions: ['5.1', '6'] }
|
||||||
]
|
]
|
||||||
|
|
||||||
constructor(requiredBinaries = this.defaultRequiredBinaries) {
|
constructor(requiredBinaries = this.defaultRequiredBinaries) {
|
||||||
this.requiredBinaries = requiredBinaries
|
this.requiredBinaries = requiredBinaries
|
||||||
this.mainInstallPath = process.pkg ? path.dirname(process.execPath) : global.appRoot
|
this.mainInstallPath = process.pkg ? path.dirname(process.execPath) : global.appRoot
|
||||||
this.altInstallPath = global.ConfigPath
|
this.altInstallPath = global.ConfigPath
|
||||||
|
this.initialized = false
|
||||||
|
this.exec = exec
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
if (this.initialized) return
|
if (this.initialized) return
|
||||||
const missingBinaries = await this.findRequiredBinaries()
|
const missingBinaries = await this.findRequiredBinaries()
|
||||||
if (missingBinaries.length == 0) return
|
if (missingBinaries.length == 0) return
|
||||||
|
await this.removeOldBinaries(missingBinaries)
|
||||||
await this.install(missingBinaries)
|
await this.install(missingBinaries)
|
||||||
const missingBinariesAfterInstall = await this.findRequiredBinaries()
|
const missingBinariesAfterInstall = await this.findRequiredBinaries()
|
||||||
if (missingBinariesAfterInstall.length != 0) {
|
if (missingBinariesAfterInstall.length) {
|
||||||
Logger.error(`[BinaryManager] Failed to find or install required binaries: ${missingBinariesAfterInstall.join(', ')}`)
|
Logger.error(`[BinaryManager] Failed to find or install required binaries: ${missingBinariesAfterInstall.join(', ')}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove old/invalid binaries in main or alt install path
|
||||||
|
*
|
||||||
|
* @param {string[]} binaryNames
|
||||||
|
*/
|
||||||
|
async removeOldBinaries(binaryNames) {
|
||||||
|
for (const binaryName of binaryNames) {
|
||||||
|
const executable = this.getExecutableFileName(binaryName)
|
||||||
|
const mainInstallPath = path.join(this.mainInstallPath, executable)
|
||||||
|
if (await fs.pathExists(mainInstallPath)) {
|
||||||
|
Logger.debug(`[BinaryManager] Removing old binary: ${mainInstallPath}`)
|
||||||
|
await fs.remove(mainInstallPath)
|
||||||
|
}
|
||||||
|
const altInstallPath = path.join(this.altInstallPath, executable)
|
||||||
|
if (await fs.pathExists(altInstallPath)) {
|
||||||
|
Logger.debug(`[BinaryManager] Removing old binary: ${altInstallPath}`)
|
||||||
|
await fs.remove(altInstallPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find required binaries and return array of binary names that are missing
|
||||||
|
*
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
async findRequiredBinaries() {
|
async findRequiredBinaries() {
|
||||||
const missingBinaries = []
|
const missingBinaries = []
|
||||||
for (const binary of this.requiredBinaries) {
|
for (const binary of this.requiredBinaries) {
|
||||||
const binaryPath = await this.findBinary(binary.name, binary.envVariable)
|
const binaryPath = await this.findBinary(binary.name, binary.envVariable, binary.validVersions)
|
||||||
if (binaryPath) {
|
if (binaryPath) {
|
||||||
Logger.info(`[BinaryManager] Found ${binary.name} at ${binaryPath}`)
|
Logger.info(`[BinaryManager] Found valid binary ${binary.name} at ${binaryPath}`)
|
||||||
if (process.env[binary.envVariable] !== binaryPath) {
|
if (process.env[binary.envVariable] !== binaryPath) {
|
||||||
Logger.info(`[BinaryManager] Updating process.env.${binary.envVariable}`)
|
Logger.info(`[BinaryManager] Updating process.env.${binary.envVariable}`)
|
||||||
process.env[binary.envVariable] = binaryPath
|
process.env[binary.envVariable] = binaryPath
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Logger.info(`[BinaryManager] ${binary.name} not found`)
|
Logger.info(`[BinaryManager] ${binary.name} not found or version too old`)
|
||||||
missingBinaries.push(binary.name)
|
missingBinaries.push(binary.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return missingBinaries
|
return missingBinaries
|
||||||
}
|
}
|
||||||
|
|
||||||
async findBinary(name, envVariable) {
|
/**
|
||||||
const executable = name + (process.platform == 'win32' ? '.exe' : '')
|
* Find absolute path for binary
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string} envVariable
|
||||||
|
* @param {string[]} [validVersions]
|
||||||
|
* @returns {Promise<string>} Path to binary
|
||||||
|
*/
|
||||||
|
async findBinary(name, envVariable, validVersions = []) {
|
||||||
|
const executable = this.getExecutableFileName(name)
|
||||||
|
// 1. check path specified in environment variable
|
||||||
const defaultPath = process.env[envVariable]
|
const defaultPath = process.env[envVariable]
|
||||||
if (defaultPath && await fs.pathExists(defaultPath)) return defaultPath
|
if (await this.isBinaryGood(defaultPath, validVersions)) return defaultPath
|
||||||
|
// 2. find the first instance of the binary in the PATH environment variable
|
||||||
const whichPath = which.sync(executable, { nothrow: true })
|
const whichPath = which.sync(executable, { nothrow: true })
|
||||||
if (whichPath) return whichPath
|
if (await this.isBinaryGood(whichPath, validVersions)) return whichPath
|
||||||
|
// 3. check main install path (binary root dir)
|
||||||
const mainInstallPath = path.join(this.mainInstallPath, executable)
|
const mainInstallPath = path.join(this.mainInstallPath, executable)
|
||||||
if (await fs.pathExists(mainInstallPath)) return mainInstallPath
|
if (await this.isBinaryGood(mainInstallPath, validVersions)) return mainInstallPath
|
||||||
|
// 4. check alt install path (/config)
|
||||||
const altInstallPath = path.join(this.altInstallPath, executable)
|
const altInstallPath = path.join(this.altInstallPath, executable)
|
||||||
if (await fs.pathExists(altInstallPath)) return altInstallPath
|
if (await this.isBinaryGood(altInstallPath, validVersions)) return altInstallPath
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check binary path exists and optionally check version is valid
|
||||||
|
*
|
||||||
|
* @param {string} binaryPath
|
||||||
|
* @param {string[]} [validVersions]
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isBinaryGood(binaryPath, validVersions = []) {
|
||||||
|
if (!binaryPath || !await fs.pathExists(binaryPath)) return false
|
||||||
|
if (!validVersions.length) return true
|
||||||
|
try {
|
||||||
|
const { stdout } = await this.exec('"' + binaryPath + '"' + ' -version')
|
||||||
|
const version = stdout.match(/version\s([\d\.]+)/)?.[1]
|
||||||
|
if (!version) return false
|
||||||
|
return validVersions.some(validVersion => version.startsWith(validVersion))
|
||||||
|
} catch (err) {
|
||||||
|
Logger.error(`[BinaryManager] Failed to check version of ${binaryPath}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string[]} binaries
|
||||||
|
*/
|
||||||
async install(binaries) {
|
async install(binaries) {
|
||||||
if (binaries.length == 0) return
|
if (!binaries.length) return
|
||||||
Logger.info(`[BinaryManager] Installing binaries: ${binaries.join(', ')}`)
|
Logger.info(`[BinaryManager] Installing binaries: ${binaries.join(', ')}`)
|
||||||
let destination = await fileUtils.isWritable(this.mainInstallPath) ? this.mainInstallPath : this.altInstallPath
|
let destination = await fileUtils.isWritable(this.mainInstallPath) ? this.mainInstallPath : this.altInstallPath
|
||||||
await ffbinaries.downloadBinaries(binaries, { destination })
|
await ffbinaries.downloadBinaries(binaries, { destination, version: '6.1', force: true })
|
||||||
Logger.info(`[BinaryManager] Binaries installed to ${destination}`)
|
Logger.info(`[BinaryManager] Binaries installed to ${destination}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append .exe to binary name for Windows
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getExecutableFileName(name) {
|
||||||
|
return name + (process.platform == 'win32' ? '.exe' : '')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = BinaryManager
|
module.exports = BinaryManager
|
@ -1,19 +1,34 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
|
|
||||||
|
const Logger = require('../Logger')
|
||||||
const DailyLog = require('../objects/DailyLog')
|
const DailyLog = require('../objects/DailyLog')
|
||||||
|
|
||||||
const Logger = require('../Logger')
|
const { LogLevel } = require('../utils/constants')
|
||||||
|
|
||||||
const TAG = '[LogManager]'
|
const TAG = '[LogManager]'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef LogObject
|
||||||
|
* @property {string} timestamp
|
||||||
|
* @property {string} source
|
||||||
|
* @property {string} message
|
||||||
|
* @property {string} levelName
|
||||||
|
* @property {number} level
|
||||||
|
*/
|
||||||
|
|
||||||
class LogManager {
|
class LogManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.DailyLogPath = Path.posix.join(global.MetadataPath, 'logs', 'daily')
|
this.DailyLogPath = Path.posix.join(global.MetadataPath, 'logs', 'daily')
|
||||||
this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans')
|
this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans')
|
||||||
|
|
||||||
|
/** @type {DailyLog} */
|
||||||
this.currentDailyLog = null
|
this.currentDailyLog = null
|
||||||
|
|
||||||
|
/** @type {LogObject[]} */
|
||||||
this.dailyLogBuffer = []
|
this.dailyLogBuffer = []
|
||||||
|
|
||||||
|
/** @type {string[]} */
|
||||||
this.dailyLogFiles = []
|
this.dailyLogFiles = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,12 +41,12 @@ class LogManager {
|
|||||||
await fs.ensureDir(this.ScanLogPath)
|
await fs.ensureDir(this.ScanLogPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureScanLogDir() {
|
/**
|
||||||
if (!(await fs.pathExists(this.ScanLogPath))) {
|
* 1. Ensure log directories exist
|
||||||
await fs.mkdir(this.ScanLogPath)
|
* 2. Load daily log files
|
||||||
}
|
* 3. Remove old daily log files
|
||||||
}
|
* 4. Create/set current daily log file
|
||||||
|
*/
|
||||||
async init() {
|
async init() {
|
||||||
await this.ensureLogDirs()
|
await this.ensureLogDirs()
|
||||||
|
|
||||||
@ -46,11 +61,11 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// set current daily log file or create if does not exist
|
||||||
const currentDailyLogFilename = DailyLog.getCurrentDailyLogFilename()
|
const currentDailyLogFilename = DailyLog.getCurrentDailyLogFilename()
|
||||||
Logger.info(TAG, `Init current daily log filename: ${currentDailyLogFilename}`)
|
Logger.info(TAG, `Init current daily log filename: ${currentDailyLogFilename}`)
|
||||||
|
|
||||||
this.currentDailyLog = new DailyLog()
|
this.currentDailyLog = new DailyLog(this.DailyLogPath)
|
||||||
this.currentDailyLog.setData({ dailyLogDirPath: this.DailyLogPath })
|
|
||||||
|
|
||||||
if (this.dailyLogFiles.includes(currentDailyLogFilename)) {
|
if (this.dailyLogFiles.includes(currentDailyLogFilename)) {
|
||||||
Logger.debug(TAG, `Daily log file already exists - set in Logger`)
|
Logger.debug(TAG, `Daily log file already exists - set in Logger`)
|
||||||
@ -59,7 +74,7 @@ class LogManager {
|
|||||||
this.dailyLogFiles.push(this.currentDailyLog.filename)
|
this.dailyLogFiles.push(this.currentDailyLog.filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log buffered Logs
|
// Log buffered daily logs
|
||||||
if (this.dailyLogBuffer.length) {
|
if (this.dailyLogBuffer.length) {
|
||||||
this.dailyLogBuffer.forEach((logObj) => {
|
this.dailyLogBuffer.forEach((logObj) => {
|
||||||
this.currentDailyLog.appendLog(logObj)
|
this.currentDailyLog.appendLog(logObj)
|
||||||
@ -68,9 +83,12 @@ class LogManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all daily log filenames in /metadata/logs/daily
|
||||||
|
*/
|
||||||
async scanLogFiles() {
|
async scanLogFiles() {
|
||||||
const dailyFiles = await fs.readdir(this.DailyLogPath)
|
const dailyFiles = await fs.readdir(this.DailyLogPath)
|
||||||
if (dailyFiles && dailyFiles.length) {
|
if (dailyFiles?.length) {
|
||||||
dailyFiles.forEach((logFile) => {
|
dailyFiles.forEach((logFile) => {
|
||||||
if (Path.extname(logFile) === '.txt') {
|
if (Path.extname(logFile) === '.txt') {
|
||||||
Logger.debug('Daily Log file found', logFile)
|
Logger.debug('Daily Log file found', logFile)
|
||||||
@ -83,30 +101,38 @@ class LogManager {
|
|||||||
this.dailyLogFiles.sort()
|
this.dailyLogFiles.sort()
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeOldestLog() {
|
/**
|
||||||
if (!this.dailyLogFiles.length) return
|
*
|
||||||
const oldestLog = this.dailyLogFiles[0]
|
* @param {string} filename
|
||||||
return this.removeLogFile(oldestLog)
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
async removeLogFile(filename) {
|
async removeLogFile(filename) {
|
||||||
const fullPath = Path.join(this.DailyLogPath, filename)
|
const fullPath = Path.join(this.DailyLogPath, filename)
|
||||||
const exists = await fs.pathExists(fullPath)
|
const exists = await fs.pathExists(fullPath)
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
Logger.error(TAG, 'Invalid log dne ' + fullPath)
|
Logger.error(TAG, 'Invalid log dne ' + fullPath)
|
||||||
this.dailyLogFiles = this.dailyLogFiles.filter(dlf => dlf.filename !== filename)
|
this.dailyLogFiles = this.dailyLogFiles.filter(dlf => dlf !== filename)
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await fs.unlink(fullPath)
|
await fs.unlink(fullPath)
|
||||||
Logger.info(TAG, 'Removed daily log: ' + filename)
|
Logger.info(TAG, 'Removed daily log: ' + filename)
|
||||||
this.dailyLogFiles = this.dailyLogFiles.filter(dlf => dlf.filename !== filename)
|
this.dailyLogFiles = this.dailyLogFiles.filter(dlf => dlf !== filename)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(TAG, 'Failed to unlink log file ' + fullPath)
|
Logger.error(TAG, 'Failed to unlink log file ' + fullPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logToFile(logObj) {
|
/**
|
||||||
|
*
|
||||||
|
* @param {LogObject} logObj
|
||||||
|
*/
|
||||||
|
async logToFile(logObj) {
|
||||||
|
// Fatal crashes get logged to a separate file
|
||||||
|
if (logObj.level === LogLevel.FATAL) {
|
||||||
|
await this.logCrashToFile(logObj)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer when logging before daily logs have been initialized
|
||||||
if (!this.currentDailyLog) {
|
if (!this.currentDailyLog) {
|
||||||
this.dailyLogBuffer.push(logObj)
|
this.dailyLogBuffer.push(logObj)
|
||||||
return
|
return
|
||||||
@ -114,25 +140,39 @@ class LogManager {
|
|||||||
|
|
||||||
// Check log rolls to next day
|
// Check log rolls to next day
|
||||||
if (this.currentDailyLog.id !== DailyLog.getCurrentDateString()) {
|
if (this.currentDailyLog.id !== DailyLog.getCurrentDateString()) {
|
||||||
const newDailyLog = new DailyLog()
|
this.currentDailyLog = new DailyLog(this.DailyLogPath)
|
||||||
newDailyLog.setData({ dailyLogDirPath: this.DailyLogPath })
|
|
||||||
this.currentDailyLog = newDailyLog
|
|
||||||
if (this.dailyLogFiles.length > this.loggerDailyLogsToKeep) {
|
if (this.dailyLogFiles.length > this.loggerDailyLogsToKeep) {
|
||||||
this.removeOldestLog()
|
// Remove oldest log
|
||||||
|
this.removeLogFile(this.dailyLogFiles[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append log line to log file
|
// Append log line to log file
|
||||||
this.currentDailyLog.appendLog(logObj)
|
return this.currentDailyLog.appendLog(logObj)
|
||||||
}
|
}
|
||||||
|
|
||||||
socketRequestDailyLogs(socket) {
|
/**
|
||||||
if (!this.currentDailyLog) {
|
*
|
||||||
return
|
* @param {LogObject} logObj
|
||||||
}
|
*/
|
||||||
|
async logCrashToFile(logObj) {
|
||||||
|
const line = JSON.stringify(logObj) + '\n'
|
||||||
|
|
||||||
const lastLogs = this.currentDailyLog.logs.slice(-5000)
|
const logsDir = Path.join(global.MetadataPath, 'logs')
|
||||||
socket.emit('daily_logs', lastLogs)
|
await fs.ensureDir(logsDir)
|
||||||
|
const crashLogPath = Path.join(logsDir, 'crash_logs.txt')
|
||||||
|
return fs.writeFile(crashLogPath, line, { flag: "a+" }).catch((error) => {
|
||||||
|
console.log('[LogManager] Appended crash log', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most recent 5000 daily logs
|
||||||
|
*
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
getMostRecentCurrentDailyLogs() {
|
||||||
|
return this.currentDailyLog?.logs.slice(-5000) || ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = LogManager
|
module.exports = LogManager
|
103
server/models/CustomMetadataProvider.js
Normal file
103
server/models/CustomMetadataProvider.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
const { DataTypes, Model } = require('sequelize')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef ClientCustomMetadataProvider
|
||||||
|
* @property {UUIDV4} id
|
||||||
|
* @property {string} name
|
||||||
|
* @property {string} url
|
||||||
|
* @property {string} slug
|
||||||
|
*/
|
||||||
|
|
||||||
|
class CustomMetadataProvider extends Model {
|
||||||
|
constructor(values, options) {
|
||||||
|
super(values, options)
|
||||||
|
|
||||||
|
/** @type {UUIDV4} */
|
||||||
|
this.id
|
||||||
|
/** @type {string} */
|
||||||
|
this.mediaType
|
||||||
|
/** @type {string} */
|
||||||
|
this.name
|
||||||
|
/** @type {string} */
|
||||||
|
this.url
|
||||||
|
/** @type {string} */
|
||||||
|
this.authHeaderValue
|
||||||
|
/** @type {Object} */
|
||||||
|
this.extraData
|
||||||
|
/** @type {Date} */
|
||||||
|
this.createdAt
|
||||||
|
/** @type {Date} */
|
||||||
|
this.updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
getSlug() {
|
||||||
|
return `custom-${this.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe for clients
|
||||||
|
* @returns {ClientCustomMetadataProvider}
|
||||||
|
*/
|
||||||
|
toClientJson() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
mediaType: this.mediaType,
|
||||||
|
slug: this.getSlug()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get providers for client by media type
|
||||||
|
* Currently only available for "book" media type
|
||||||
|
*
|
||||||
|
* @param {string} mediaType
|
||||||
|
* @returns {Promise<ClientCustomMetadataProvider[]>}
|
||||||
|
*/
|
||||||
|
static async getForClientByMediaType(mediaType) {
|
||||||
|
if (mediaType !== 'book') return []
|
||||||
|
const customMetadataProviders = await this.findAll({
|
||||||
|
where: {
|
||||||
|
mediaType
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return customMetadataProviders.map(cmp => cmp.toClientJson())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if provider exists by slug
|
||||||
|
*
|
||||||
|
* @param {string} providerSlug
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
static async checkExistsBySlug(providerSlug) {
|
||||||
|
const providerId = providerSlug?.split?.('custom-')[1]
|
||||||
|
if (!providerId) return false
|
||||||
|
|
||||||
|
return (await this.count({ where: { id: providerId } })) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize model
|
||||||
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
*/
|
||||||
|
static init(sequelize) {
|
||||||
|
super.init({
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
name: DataTypes.STRING,
|
||||||
|
mediaType: DataTypes.STRING,
|
||||||
|
url: DataTypes.STRING,
|
||||||
|
authHeaderValue: DataTypes.STRING,
|
||||||
|
extraData: DataTypes.JSON
|
||||||
|
}, {
|
||||||
|
sequelize,
|
||||||
|
modelName: 'customMetadataProvider'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CustomMetadataProvider
|
@ -225,6 +225,12 @@ class LibraryItem extends Model {
|
|||||||
return newLibraryItem
|
return newLibraryItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates libraryItem, book, authors and series from old library item
|
||||||
|
*
|
||||||
|
* @param {oldLibraryItem} oldLibraryItem
|
||||||
|
* @returns {Promise<boolean>} true if updates were made
|
||||||
|
*/
|
||||||
static async fullUpdateFromOld(oldLibraryItem) {
|
static async fullUpdateFromOld(oldLibraryItem) {
|
||||||
const libraryItemExpanded = await this.findByPk(oldLibraryItem.id, {
|
const libraryItemExpanded = await this.findByPk(oldLibraryItem.id, {
|
||||||
include: [
|
include: [
|
||||||
|
@ -118,7 +118,9 @@ class PlaybackSession extends Model {
|
|||||||
|
|
||||||
static createFromOld(oldPlaybackSession) {
|
static createFromOld(oldPlaybackSession) {
|
||||||
const playbackSession = this.getFromOld(oldPlaybackSession)
|
const playbackSession = this.getFromOld(oldPlaybackSession)
|
||||||
return this.create(playbackSession)
|
return this.create(playbackSession, {
|
||||||
|
silent: true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
static updateFromOld(oldPlaybackSession) {
|
static updateFromOld(oldPlaybackSession) {
|
||||||
@ -126,7 +128,8 @@ class PlaybackSession extends Model {
|
|||||||
return this.update(playbackSession, {
|
return this.update(playbackSession, {
|
||||||
where: {
|
where: {
|
||||||
id: playbackSession.id
|
id: playbackSession.id
|
||||||
}
|
},
|
||||||
|
silent: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,23 +1,28 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const date = require('../libs/dateAndTime')
|
const date = require('../libs/dateAndTime')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const { readTextFile } = require('../utils/fileUtils')
|
const fileUtils = require('../utils/fileUtils')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
class DailyLog {
|
class DailyLog {
|
||||||
constructor() {
|
/**
|
||||||
this.id = null
|
*
|
||||||
this.datePretty = null
|
* @param {string} dailyLogDirPath Path to daily logs /metadata/logs/daily
|
||||||
|
*/
|
||||||
|
constructor(dailyLogDirPath) {
|
||||||
|
this.id = date.format(new Date(), 'YYYY-MM-DD')
|
||||||
|
|
||||||
this.dailyLogDirPath = null
|
this.dailyLogDirPath = dailyLogDirPath
|
||||||
this.filename = null
|
this.filename = this.id + '.txt'
|
||||||
this.path = null
|
this.fullPath = Path.join(this.dailyLogDirPath, this.filename)
|
||||||
this.fullPath = null
|
|
||||||
|
|
||||||
this.createdAt = null
|
this.createdAt = Date.now()
|
||||||
|
|
||||||
|
/** @type {import('../managers/LogManager').LogObject[]} */
|
||||||
this.logs = []
|
this.logs = []
|
||||||
|
/** @type {string[]} */
|
||||||
this.bufferedLogLines = []
|
this.bufferedLogLines = []
|
||||||
|
|
||||||
this.locked = false
|
this.locked = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,8 +37,6 @@ class DailyLog {
|
|||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
datePretty: this.datePretty,
|
|
||||||
path: this.path,
|
|
||||||
dailyLogDirPath: this.dailyLogDirPath,
|
dailyLogDirPath: this.dailyLogDirPath,
|
||||||
fullPath: this.fullPath,
|
fullPath: this.fullPath,
|
||||||
filename: this.filename,
|
filename: this.filename,
|
||||||
@ -41,36 +44,34 @@ class DailyLog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setData(data) {
|
/**
|
||||||
this.id = date.format(new Date(), 'YYYY-MM-DD')
|
* Append all buffered lines to daily log file
|
||||||
this.datePretty = date.format(new Date(), 'ddd, MMM D YYYY')
|
*/
|
||||||
|
appendBufferedLogs() {
|
||||||
this.dailyLogDirPath = data.dailyLogDirPath
|
let buffered = [...this.bufferedLogLines]
|
||||||
|
|
||||||
this.filename = this.id + '.txt'
|
|
||||||
this.path = Path.join('backups', this.filename)
|
|
||||||
this.fullPath = Path.join(this.dailyLogDirPath, this.filename)
|
|
||||||
|
|
||||||
this.createdAt = Date.now()
|
|
||||||
}
|
|
||||||
|
|
||||||
async appendBufferedLogs() {
|
|
||||||
var buffered = [...this.bufferedLogLines]
|
|
||||||
this.bufferedLogLines = []
|
this.bufferedLogLines = []
|
||||||
|
|
||||||
var oneBigLog = ''
|
let oneBigLog = ''
|
||||||
buffered.forEach((logLine) => {
|
buffered.forEach((logLine) => {
|
||||||
oneBigLog += logLine
|
oneBigLog += logLine
|
||||||
})
|
})
|
||||||
this.appendLogLine(oneBigLog)
|
return this.appendLogLine(oneBigLog)
|
||||||
}
|
}
|
||||||
|
|
||||||
async appendLog(logObj) {
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../managers/LogManager').LogObject} logObj
|
||||||
|
*/
|
||||||
|
appendLog(logObj) {
|
||||||
this.logs.push(logObj)
|
this.logs.push(logObj)
|
||||||
var line = JSON.stringify(logObj) + '\n'
|
return this.appendLogLine(JSON.stringify(logObj) + '\n')
|
||||||
this.appendLogLine(line)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append log to daily log file
|
||||||
|
*
|
||||||
|
* @param {string} line
|
||||||
|
*/
|
||||||
async appendLogLine(line) {
|
async appendLogLine(line) {
|
||||||
if (this.locked) {
|
if (this.locked) {
|
||||||
this.bufferedLogLines.push(line)
|
this.bufferedLogLines.push(line)
|
||||||
@ -84,24 +85,29 @@ class DailyLog {
|
|||||||
|
|
||||||
this.locked = false
|
this.locked = false
|
||||||
if (this.bufferedLogLines.length) {
|
if (this.bufferedLogLines.length) {
|
||||||
this.appendBufferedLogs()
|
await this.appendBufferedLogs()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all logs from file
|
||||||
|
* Parses lines and re-saves the file if bad lines are removed
|
||||||
|
*/
|
||||||
async loadLogs() {
|
async loadLogs() {
|
||||||
var exists = await fs.pathExists(this.fullPath)
|
if (!await fs.pathExists(this.fullPath)) {
|
||||||
if (!exists) {
|
|
||||||
console.error('Daily log does not exist')
|
console.error('Daily log does not exist')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var text = await readTextFile(this.fullPath)
|
const text = await fileUtils.readTextFile(this.fullPath)
|
||||||
|
|
||||||
var hasFailures = false
|
let hasFailures = false
|
||||||
|
|
||||||
var logLines = text.split(/\r?\n/)
|
let logLines = text.split(/\r?\n/)
|
||||||
// remove last log if empty
|
// remove last log if empty
|
||||||
if (logLines.length && !logLines[logLines.length - 1]) logLines = logLines.slice(0, -1)
|
if (logLines.length && !logLines[logLines.length - 1]) logLines = logLines.slice(0, -1)
|
||||||
|
|
||||||
|
// JSON parse log lines
|
||||||
this.logs = logLines.map(t => {
|
this.logs = logLines.map(t => {
|
||||||
if (!t) {
|
if (!t) {
|
||||||
hasFailures = true
|
hasFailures = true
|
||||||
@ -118,7 +124,7 @@ class DailyLog {
|
|||||||
|
|
||||||
// Rewrite log file to remove errors
|
// Rewrite log file to remove errors
|
||||||
if (hasFailures) {
|
if (hasFailures) {
|
||||||
var newLogLines = this.logs.map(l => JSON.stringify(l)).join('\n') + '\n'
|
const newLogLines = this.logs.map(l => JSON.stringify(l)).join('\n') + '\n'
|
||||||
await fs.writeFile(this.fullPath, newLogLines)
|
await fs.writeFile(this.fullPath, newLogLines)
|
||||||
console.log('Re-Saved log file to remove bad lines')
|
console.log('Re-Saved log file to remove bad lines')
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ class LibrarySettings {
|
|||||||
this.audiobooksOnly = false
|
this.audiobooksOnly = false
|
||||||
this.hideSingleBookSeries = false // Do not show series that only have 1 book
|
this.hideSingleBookSeries = false // Do not show series that only have 1 book
|
||||||
this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
||||||
|
this.podcastSearchRegion = 'us'
|
||||||
|
|
||||||
if (settings) {
|
if (settings) {
|
||||||
this.construct(settings)
|
this.construct(settings)
|
||||||
@ -30,6 +31,7 @@ class LibrarySettings {
|
|||||||
// Added in v2.4.5
|
// Added in v2.4.5
|
||||||
this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
||||||
}
|
}
|
||||||
|
this.podcastSearchRegion = settings.podcastSearchRegion || 'us'
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON() {
|
toJSON() {
|
||||||
@ -41,7 +43,8 @@ class LibrarySettings {
|
|||||||
autoScanCronExpression: this.autoScanCronExpression,
|
autoScanCronExpression: this.autoScanCronExpression,
|
||||||
audiobooksOnly: this.audiobooksOnly,
|
audiobooksOnly: this.audiobooksOnly,
|
||||||
hideSingleBookSeries: this.hideSingleBookSeries,
|
hideSingleBookSeries: this.hideSingleBookSeries,
|
||||||
metadataPrecedence: [...this.metadataPrecedence]
|
metadataPrecedence: [...this.metadataPrecedence],
|
||||||
|
podcastSearchRegion: this.podcastSearchRegion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -113,7 +113,7 @@ class ServerSettings {
|
|||||||
this.version = settings.version || null
|
this.version = settings.version || null
|
||||||
this.buildNumber = settings.buildNumber || 0 // Added v2.4.5
|
this.buildNumber = settings.buildNumber || 0 // Added v2.4.5
|
||||||
|
|
||||||
this.authLoginCustomMessage = settings.authLoginCustomMessage || null // Added v2.7.3
|
this.authLoginCustomMessage = settings.authLoginCustomMessage || null // Added v2.8.0
|
||||||
this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local']
|
this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local']
|
||||||
|
|
||||||
this.authOpenIDIssuerURL = settings.authOpenIDIssuerURL || null
|
this.authOpenIDIssuerURL = settings.authOpenIDIssuerURL || null
|
||||||
|
@ -117,7 +117,8 @@ class User {
|
|||||||
createdAt: this.createdAt,
|
createdAt: this.createdAt,
|
||||||
permissions: this.permissions,
|
permissions: this.permissions,
|
||||||
librariesAccessible: [...this.librariesAccessible],
|
librariesAccessible: [...this.librariesAccessible],
|
||||||
itemTagsSelected: [...this.itemTagsSelected]
|
itemTagsSelected: [...this.itemTagsSelected],
|
||||||
|
hasOpenIDLink: !!this.authOpenIDSub
|
||||||
}
|
}
|
||||||
if (minimal) {
|
if (minimal) {
|
||||||
delete json.mediaProgress
|
delete json.mediaProgress
|
||||||
|
@ -2,15 +2,30 @@ const axios = require('axios')
|
|||||||
const { levenshteinDistance } = require('../utils/index')
|
const { levenshteinDistance } = require('../utils/index')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef AuthorSearchObj
|
||||||
|
* @property {string} asin
|
||||||
|
* @property {string} description
|
||||||
|
* @property {string} image
|
||||||
|
* @property {string} name
|
||||||
|
*/
|
||||||
|
|
||||||
class Audnexus {
|
class Audnexus {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.baseUrl = 'https://api.audnex.us'
|
this.baseUrl = 'https://api.audnex.us'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string} region
|
||||||
|
* @returns {Promise<{asin:string, name:string}[]>}
|
||||||
|
*/
|
||||||
authorASINsRequest(name, region) {
|
authorASINsRequest(name, region) {
|
||||||
name = encodeURIComponent(name)
|
const searchParams = new URLSearchParams()
|
||||||
const regionQuery = region ? `®ion=${region}` : ''
|
searchParams.set('name', name)
|
||||||
const authorRequestUrl = `${this.baseUrl}/authors?name=${name}${regionQuery}`
|
if (region) searchParams.set('region', region)
|
||||||
|
const authorRequestUrl = `${this.baseUrl}/authors?${searchParams.toString()}`
|
||||||
Logger.info(`[Audnexus] Searching for author "${authorRequestUrl}"`)
|
Logger.info(`[Audnexus] Searching for author "${authorRequestUrl}"`)
|
||||||
return axios.get(authorRequestUrl).then((res) => {
|
return axios.get(authorRequestUrl).then((res) => {
|
||||||
return res.data || []
|
return res.data || []
|
||||||
@ -20,6 +35,12 @@ class Audnexus {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} asin
|
||||||
|
* @param {string} region
|
||||||
|
* @returns {Promise<AuthorSearchObj>}
|
||||||
|
*/
|
||||||
authorRequest(asin, region) {
|
authorRequest(asin, region) {
|
||||||
asin = encodeURIComponent(asin)
|
asin = encodeURIComponent(asin)
|
||||||
const regionQuery = region ? `?region=${region}` : ''
|
const regionQuery = region ? `?region=${region}` : ''
|
||||||
@ -33,6 +54,12 @@ class Audnexus {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} asin
|
||||||
|
* @param {string} region
|
||||||
|
* @returns {Promise<AuthorSearchObj>}
|
||||||
|
*/
|
||||||
async findAuthorByASIN(asin, region) {
|
async findAuthorByASIN(asin, region) {
|
||||||
const author = await this.authorRequest(asin, region)
|
const author = await this.authorRequest(asin, region)
|
||||||
if (!author) {
|
if (!author) {
|
||||||
@ -46,14 +73,28 @@ class Audnexus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string} region
|
||||||
|
* @param {number} maxLevenshtein
|
||||||
|
* @returns {Promise<AuthorSearchObj>}
|
||||||
|
*/
|
||||||
async findAuthorByName(name, region, maxLevenshtein = 3) {
|
async findAuthorByName(name, region, maxLevenshtein = 3) {
|
||||||
Logger.debug(`[Audnexus] Looking up author by name ${name}`)
|
Logger.debug(`[Audnexus] Looking up author by name ${name}`)
|
||||||
const asins = await this.authorASINsRequest(name, region)
|
const authorAsinObjs = await this.authorASINsRequest(name, region)
|
||||||
const matchingAsin = asins.find(obj => levenshteinDistance(obj.name, name) <= maxLevenshtein)
|
|
||||||
if (!matchingAsin) {
|
let closestMatch = null
|
||||||
|
authorAsinObjs.forEach((authorAsinObj) => {
|
||||||
|
authorAsinObj.levenshteinDistance = levenshteinDistance(authorAsinObj.name, name)
|
||||||
|
if (!closestMatch || closestMatch.levenshteinDistance > authorAsinObj.levenshteinDistance) {
|
||||||
|
closestMatch = authorAsinObj
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!closestMatch || closestMatch.levenshteinDistance > maxLevenshtein) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const author = await this.authorRequest(matchingAsin.asin)
|
const author = await this.authorRequest(closestMatch.asin)
|
||||||
if (!author) {
|
if (!author) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
93
server/providers/CustomProviderAdapter.js
Normal file
93
server/providers/CustomProviderAdapter.js
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
const Database = require('../Database')
|
||||||
|
const axios = require('axios')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
|
class CustomProviderAdapter {
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} title
|
||||||
|
* @param {string} author
|
||||||
|
* @param {string} providerSlug
|
||||||
|
* @param {string} mediaType
|
||||||
|
* @returns {Promise<Object[]>}
|
||||||
|
*/
|
||||||
|
async search(title, author, providerSlug, mediaType) {
|
||||||
|
const providerId = providerSlug.split('custom-')[1]
|
||||||
|
const provider = await Database.customMetadataProviderModel.findByPk(providerId)
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error("Custom provider not found for the given id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup query params
|
||||||
|
const queryObj = {
|
||||||
|
mediaType,
|
||||||
|
query: title
|
||||||
|
}
|
||||||
|
if (author) {
|
||||||
|
queryObj.author = author
|
||||||
|
}
|
||||||
|
const queryString = (new URLSearchParams(queryObj)).toString()
|
||||||
|
|
||||||
|
// Setup headers
|
||||||
|
const axiosOptions = {}
|
||||||
|
if (provider.authHeaderValue) {
|
||||||
|
axiosOptions.headers = {
|
||||||
|
'Authorization': provider.authHeaderValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await axios.get(`${provider.url}/search?${queryString}}`, axiosOptions).then((res) => {
|
||||||
|
if (!res?.data || !Array.isArray(res.data.matches)) return null
|
||||||
|
return res.data.matches
|
||||||
|
}).catch(error => {
|
||||||
|
Logger.error('[CustomMetadataProvider] Search error', error)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
throw new Error("Custom provider returned malformed response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// re-map keys to throw out
|
||||||
|
return matches.map(({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
author,
|
||||||
|
narrator,
|
||||||
|
publisher,
|
||||||
|
publishedYear,
|
||||||
|
description,
|
||||||
|
cover,
|
||||||
|
isbn,
|
||||||
|
asin,
|
||||||
|
genres,
|
||||||
|
tags,
|
||||||
|
series,
|
||||||
|
language,
|
||||||
|
duration
|
||||||
|
}) => {
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
author,
|
||||||
|
narrator,
|
||||||
|
publisher,
|
||||||
|
publishedYear,
|
||||||
|
description,
|
||||||
|
cover,
|
||||||
|
isbn,
|
||||||
|
asin,
|
||||||
|
genres,
|
||||||
|
tags: tags?.join(',') || null,
|
||||||
|
series: series?.length ? series : null,
|
||||||
|
language,
|
||||||
|
duration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = CustomProviderAdapter
|
@ -2,16 +2,46 @@ const axios = require('axios')
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef iTunesSearchParams
|
||||||
|
* @property {string} term
|
||||||
|
* @property {string} country
|
||||||
|
* @property {string} media
|
||||||
|
* @property {string} entity
|
||||||
|
* @property {number} limit
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef iTunesPodcastSearchResult
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} artistId
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} artistName
|
||||||
|
* @property {string} description
|
||||||
|
* @property {string} descriptionPlain
|
||||||
|
* @property {string} releaseDate
|
||||||
|
* @property {string[]} genres
|
||||||
|
* @property {string} cover
|
||||||
|
* @property {string} feedUrl
|
||||||
|
* @property {string} pageUrl
|
||||||
|
* @property {boolean} explicit
|
||||||
|
*/
|
||||||
|
|
||||||
class iTunes {
|
class iTunes {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
// https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/Searching.html
|
/**
|
||||||
|
* @see https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/Searching.html
|
||||||
|
*
|
||||||
|
* @param {iTunesSearchParams} options
|
||||||
|
* @returns {Promise<Object[]>}
|
||||||
|
*/
|
||||||
search(options) {
|
search(options) {
|
||||||
if (!options.term) {
|
if (!options.term) {
|
||||||
Logger.error('[iTunes] Invalid search options - no term')
|
Logger.error('[iTunes] Invalid search options - no term')
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
var query = {
|
const query = {
|
||||||
term: options.term,
|
term: options.term,
|
||||||
media: options.media,
|
media: options.media,
|
||||||
entity: options.entity,
|
entity: options.entity,
|
||||||
@ -82,6 +112,11 @@ class iTunes {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Object} data
|
||||||
|
* @returns {iTunesPodcastSearchResult}
|
||||||
|
*/
|
||||||
cleanPodcast(data) {
|
cleanPodcast(data) {
|
||||||
return {
|
return {
|
||||||
id: data.collectionId,
|
id: data.collectionId,
|
||||||
@ -100,6 +135,12 @@ class iTunes {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} term
|
||||||
|
* @param {{country:string}} options
|
||||||
|
* @returns {Promise<iTunesPodcastSearchResult[]>}
|
||||||
|
*/
|
||||||
searchPodcasts(term, options = {}) {
|
searchPodcasts(term, options = {}) {
|
||||||
return this.search({ term, entity: 'podcast', media: 'podcast', ...options }).then((results) => {
|
return this.search({ term, entity: 'podcast', media: 'podcast', ...options }).then((results) => {
|
||||||
return results.map(this.cleanPodcast.bind(this))
|
return results.map(this.cleanPodcast.bind(this))
|
||||||
|
@ -28,6 +28,7 @@ const SearchController = require('../controllers/SearchController')
|
|||||||
const CacheController = require('../controllers/CacheController')
|
const CacheController = require('../controllers/CacheController')
|
||||||
const ToolsController = require('../controllers/ToolsController')
|
const ToolsController = require('../controllers/ToolsController')
|
||||||
const RSSFeedController = require('../controllers/RSSFeedController')
|
const RSSFeedController = require('../controllers/RSSFeedController')
|
||||||
|
const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController')
|
||||||
const MiscController = require('../controllers/MiscController')
|
const MiscController = require('../controllers/MiscController')
|
||||||
|
|
||||||
const Author = require('../objects/entities/Author')
|
const Author = require('../objects/entities/Author')
|
||||||
@ -129,7 +130,7 @@ class ApiRouter {
|
|||||||
this.router.get('/users/:id', UserController.middleware.bind(this), UserController.findOne.bind(this))
|
this.router.get('/users/:id', UserController.middleware.bind(this), UserController.findOne.bind(this))
|
||||||
this.router.patch('/users/:id', UserController.middleware.bind(this), UserController.update.bind(this))
|
this.router.patch('/users/:id', UserController.middleware.bind(this), UserController.update.bind(this))
|
||||||
this.router.delete('/users/:id', UserController.middleware.bind(this), UserController.delete.bind(this))
|
this.router.delete('/users/:id', UserController.middleware.bind(this), UserController.delete.bind(this))
|
||||||
|
this.router.patch('/users/:id/openid-unlink', UserController.middleware.bind(this), UserController.unlinkFromOpenID.bind(this))
|
||||||
this.router.get('/users/:id/listening-sessions', UserController.middleware.bind(this), UserController.getListeningSessions.bind(this))
|
this.router.get('/users/:id/listening-sessions', UserController.middleware.bind(this), UserController.getListeningSessions.bind(this))
|
||||||
this.router.get('/users/:id/listening-stats', UserController.middleware.bind(this), UserController.getListeningStats.bind(this))
|
this.router.get('/users/:id/listening-stats', UserController.middleware.bind(this), UserController.getListeningStats.bind(this))
|
||||||
|
|
||||||
@ -299,6 +300,14 @@ class ApiRouter {
|
|||||||
this.router.post('/feeds/series/:seriesId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForSeries.bind(this))
|
this.router.post('/feeds/series/:seriesId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForSeries.bind(this))
|
||||||
this.router.post('/feeds/:id/close', RSSFeedController.middleware.bind(this), RSSFeedController.closeRSSFeed.bind(this))
|
this.router.post('/feeds/:id/close', RSSFeedController.middleware.bind(this), RSSFeedController.closeRSSFeed.bind(this))
|
||||||
|
|
||||||
|
//
|
||||||
|
// Custom Metadata Provider routes
|
||||||
|
//
|
||||||
|
this.router.get('/custom-metadata-providers', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.getAll.bind(this))
|
||||||
|
this.router.post('/custom-metadata-providers', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.create.bind(this))
|
||||||
|
this.router.delete('/custom-metadata-providers/:id', CustomMetadataProviderController.middleware.bind(this), CustomMetadataProviderController.delete.bind(this))
|
||||||
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// Misc Routes
|
// Misc Routes
|
||||||
//
|
//
|
||||||
@ -318,6 +327,7 @@ class ApiRouter {
|
|||||||
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))
|
this.router.get('/stats/year/:year', MiscController.getAdminStatsForYear.bind(this))
|
||||||
|
this.router.get('/logger-data', MiscController.getLoggerData.bind(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
@ -134,10 +134,13 @@ class LibraryScan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async saveLog() {
|
async saveLog() {
|
||||||
await Logger.logManager.ensureScanLogDir()
|
const scanLogDir = Path.join(global.MetadataPath, 'logs', 'scans')
|
||||||
|
|
||||||
const logDir = Path.join(global.MetadataPath, 'logs', 'scans')
|
if (!(await fs.pathExists(scanLogDir))) {
|
||||||
const outputPath = Path.join(logDir, this.logFilename)
|
await fs.mkdir(scanLogDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = Path.join(scanLogDir, this.logFilename)
|
||||||
const logLines = [JSON.stringify(this.toJSON())]
|
const logLines = [JSON.stringify(this.toJSON())]
|
||||||
this.logs.forEach(l => {
|
this.logs.forEach(l => {
|
||||||
logLines.push(JSON.stringify(l))
|
logLines.push(JSON.stringify(l))
|
||||||
|
@ -101,8 +101,8 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
|||||||
})
|
})
|
||||||
if (!response) return resolve(false)
|
if (!response) return resolve(false)
|
||||||
|
|
||||||
|
|
||||||
const ffmpeg = Ffmpeg(response.data)
|
const ffmpeg = Ffmpeg(response.data)
|
||||||
|
ffmpeg.addOption('-loglevel debug') // Debug logs printed on error
|
||||||
ffmpeg.outputOptions(
|
ffmpeg.outputOptions(
|
||||||
'-c', 'copy',
|
'-c', 'copy',
|
||||||
'-metadata', 'podcast=1'
|
'-metadata', 'podcast=1'
|
||||||
@ -110,6 +110,7 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
|||||||
|
|
||||||
const podcastMetadata = podcastEpisodeDownload.libraryItem.media.metadata
|
const podcastMetadata = podcastEpisodeDownload.libraryItem.media.metadata
|
||||||
const podcastEpisode = podcastEpisodeDownload.podcastEpisode
|
const podcastEpisode = podcastEpisodeDownload.podcastEpisode
|
||||||
|
const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0)
|
||||||
|
|
||||||
const taggings = {
|
const taggings = {
|
||||||
'album': podcastMetadata.title,
|
'album': podcastMetadata.title,
|
||||||
@ -147,13 +148,30 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
|||||||
|
|
||||||
ffmpeg.addOutput(podcastEpisodeDownload.targetPath)
|
ffmpeg.addOutput(podcastEpisodeDownload.targetPath)
|
||||||
|
|
||||||
|
const stderrLines = []
|
||||||
|
ffmpeg.on('stderr', (stderrLine) => {
|
||||||
|
if (typeof stderrLine === 'string') {
|
||||||
|
stderrLines.push(stderrLine)
|
||||||
|
}
|
||||||
|
})
|
||||||
ffmpeg.on('start', (cmd) => {
|
ffmpeg.on('start', (cmd) => {
|
||||||
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Cmd: ${cmd}`)
|
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Cmd: ${cmd}`)
|
||||||
})
|
})
|
||||||
ffmpeg.on('error', (err, stdout, stderr) => {
|
ffmpeg.on('error', (err) => {
|
||||||
Logger.error(`[FfmpegHelpers] downloadPodcastEpisode: Error ${err} ${stdout} ${stderr}`)
|
Logger.error(`[FfmpegHelpers] downloadPodcastEpisode: Error ${err}`)
|
||||||
|
if (stderrLines.length) {
|
||||||
|
Logger.error(`Full stderr dump for episode url "${podcastEpisodeDownload.url}": ${stderrLines.join('\n')}`)
|
||||||
|
}
|
||||||
resolve(false)
|
resolve(false)
|
||||||
})
|
})
|
||||||
|
ffmpeg.on('progress', (progress) => {
|
||||||
|
let progressPercent = 0
|
||||||
|
if (finalSizeInBytes && progress.targetSize && !isNaN(progress.targetSize)) {
|
||||||
|
const finalSizeInKb = Math.floor(finalSizeInBytes / 1000)
|
||||||
|
progressPercent = Math.min(1, progress.targetSize / finalSizeInKb) * 100
|
||||||
|
}
|
||||||
|
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Progress estimate ${progressPercent.toFixed(0)}% (${progress?.targetSize || 'N/A'} KB) for "${podcastEpisodeDownload.url}"`)
|
||||||
|
})
|
||||||
ffmpeg.on('end', () => {
|
ffmpeg.on('end', () => {
|
||||||
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Complete`)
|
Logger.debug(`[FfmpegHelpers] downloadPodcastEpisode: Complete`)
|
||||||
resolve(podcastEpisodeDownload.targetPath)
|
resolve(podcastEpisodeDownload.targetPath)
|
||||||
|
@ -366,15 +366,16 @@ module.exports.encodeUriPath = (path) => {
|
|||||||
* This method is necessary because fs.access(directory, fs.constants.W_OK) does not work on Windows
|
* This method is necessary because fs.access(directory, fs.constants.W_OK) does not work on Windows
|
||||||
*
|
*
|
||||||
* @param {string} directory
|
* @param {string} directory
|
||||||
* @returns {boolean}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
module.exports.isWritable = async (directory) => {
|
module.exports.isWritable = async (directory) => {
|
||||||
try {
|
try {
|
||||||
const accessTestFile = path.join(directory, 'accessTest')
|
const accessTestFile = Path.join(directory, 'accessTest')
|
||||||
await fs.writeFile(accessTestFile, '')
|
await fs.writeFile(accessTestFile, '')
|
||||||
await fs.remove(accessTestFile)
|
await fs.remove(accessTestFile)
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
Logger.info(`[fileUtils] Directory is not writable "${directory}"`, err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +0,0 @@
|
|||||||
const { parentPort } = require("worker_threads")
|
|
||||||
const prober = require('./prober')
|
|
||||||
|
|
||||||
parentPort.on("message", async ({ mediaPath }) => {
|
|
||||||
const results = await prober.probe(mediaPath)
|
|
||||||
parentPort.postMessage({
|
|
||||||
data: results,
|
|
||||||
})
|
|
||||||
})
|
|
@ -110,7 +110,7 @@ module.exports = {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Filter out bad genres like "audiobook" and "audio book"
|
// 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'))
|
const genres = (ls.mediaMetadata.genres || []).filter(g => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
|
||||||
genres.forEach((genre) => {
|
genres.forEach((genre) => {
|
||||||
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
|
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
|
||||||
genreListeningMap[genre] += (ls.timeListening || 0)
|
genreListeningMap[genre] += (ls.timeListening || 0)
|
||||||
|
@ -141,7 +141,7 @@ module.exports = {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Filter out bad genres like "audiobook" and "audio book"
|
// 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'))
|
const genres = (ls.mediaMetadata.genres || []).filter(g => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
|
||||||
genres.forEach((genre) => {
|
genres.forEach((genre) => {
|
||||||
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
|
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
|
||||||
genreListeningMap[genre] += listeningSessionListeningTime
|
genreListeningMap[genre] += listeningSessionListeningTime
|
||||||
|
@ -15,6 +15,7 @@ describe('BinaryManager', () => {
|
|||||||
describe('init', () => {
|
describe('init', () => {
|
||||||
let findStub
|
let findStub
|
||||||
let installStub
|
let installStub
|
||||||
|
let removeOldBinariesStub
|
||||||
let errorStub
|
let errorStub
|
||||||
let exitStub
|
let exitStub
|
||||||
|
|
||||||
@ -22,6 +23,7 @@ describe('BinaryManager', () => {
|
|||||||
binaryManager = new BinaryManager()
|
binaryManager = new BinaryManager()
|
||||||
findStub = sinon.stub(binaryManager, 'findRequiredBinaries')
|
findStub = sinon.stub(binaryManager, 'findRequiredBinaries')
|
||||||
installStub = sinon.stub(binaryManager, 'install')
|
installStub = sinon.stub(binaryManager, 'install')
|
||||||
|
removeOldBinariesStub = sinon.stub(binaryManager, 'removeOldBinaries')
|
||||||
errorStub = sinon.stub(console, 'error')
|
errorStub = sinon.stub(console, 'error')
|
||||||
exitStub = sinon.stub(process, 'exit')
|
exitStub = sinon.stub(process, 'exit')
|
||||||
})
|
})
|
||||||
@ -29,6 +31,7 @@ describe('BinaryManager', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
findStub.restore()
|
findStub.restore()
|
||||||
installStub.restore()
|
installStub.restore()
|
||||||
|
removeOldBinariesStub.restore()
|
||||||
errorStub.restore()
|
errorStub.restore()
|
||||||
exitStub.restore()
|
exitStub.restore()
|
||||||
})
|
})
|
||||||
@ -39,6 +42,7 @@ describe('BinaryManager', () => {
|
|||||||
await binaryManager.init()
|
await binaryManager.init()
|
||||||
|
|
||||||
expect(installStub.called).to.be.false
|
expect(installStub.called).to.be.false
|
||||||
|
expect(removeOldBinariesStub.called).to.be.false
|
||||||
expect(findStub.calledOnce).to.be.true
|
expect(findStub.calledOnce).to.be.true
|
||||||
expect(errorStub.called).to.be.false
|
expect(errorStub.called).to.be.false
|
||||||
expect(exitStub.called).to.be.false
|
expect(exitStub.called).to.be.false
|
||||||
@ -54,6 +58,7 @@ describe('BinaryManager', () => {
|
|||||||
|
|
||||||
expect(findStub.calledTwice).to.be.true
|
expect(findStub.calledTwice).to.be.true
|
||||||
expect(installStub.calledOnce).to.be.true
|
expect(installStub.calledOnce).to.be.true
|
||||||
|
expect(removeOldBinariesStub.calledOnce).to.be.true
|
||||||
expect(errorStub.called).to.be.false
|
expect(errorStub.called).to.be.false
|
||||||
expect(exitStub.called).to.be.false
|
expect(exitStub.called).to.be.false
|
||||||
})
|
})
|
||||||
@ -68,6 +73,7 @@ describe('BinaryManager', () => {
|
|||||||
|
|
||||||
expect(findStub.calledTwice).to.be.true
|
expect(findStub.calledTwice).to.be.true
|
||||||
expect(installStub.calledOnce).to.be.true
|
expect(installStub.calledOnce).to.be.true
|
||||||
|
expect(removeOldBinariesStub.calledOnce).to.be.true
|
||||||
expect(errorStub.calledOnce).to.be.true
|
expect(errorStub.calledOnce).to.be.true
|
||||||
expect(exitStub.calledOnce).to.be.true
|
expect(exitStub.calledOnce).to.be.true
|
||||||
expect(exitStub.calledWith(1)).to.be.true
|
expect(exitStub.calledWith(1)).to.be.true
|
||||||
@ -171,7 +177,7 @@ describe('BinaryManager', () => {
|
|||||||
|
|
||||||
describe('findBinary', () => {
|
describe('findBinary', () => {
|
||||||
let binaryManager
|
let binaryManager
|
||||||
let fsPathExistsStub
|
let isBinaryGoodStub
|
||||||
let whichSyncStub
|
let whichSyncStub
|
||||||
let mainInstallPath
|
let mainInstallPath
|
||||||
let altInstallPath
|
let altInstallPath
|
||||||
@ -185,7 +191,7 @@ describe('findBinary', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
binaryManager = new BinaryManager()
|
binaryManager = new BinaryManager()
|
||||||
fsPathExistsStub = sinon.stub(fs, 'pathExists')
|
isBinaryGoodStub = sinon.stub(binaryManager, 'isBinaryGood')
|
||||||
whichSyncStub = sinon.stub(which, 'sync')
|
whichSyncStub = sinon.stub(which, 'sync')
|
||||||
binaryManager.mainInstallPath = '/path/to/main/install'
|
binaryManager.mainInstallPath = '/path/to/main/install'
|
||||||
mainInstallPath = path.join(binaryManager.mainInstallPath, executable)
|
mainInstallPath = path.join(binaryManager.mainInstallPath, executable)
|
||||||
@ -194,71 +200,182 @@ describe('findBinary', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fsPathExistsStub.restore()
|
isBinaryGoodStub.restore()
|
||||||
whichSyncStub.restore()
|
whichSyncStub.restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return defaultPath if it exists', async () => {
|
it('should return the defaultPath if it exists and is a good binary', async () => {
|
||||||
process.env[envVariable] = defaultPath
|
process.env[envVariable] = defaultPath
|
||||||
fsPathExistsStub.withArgs(defaultPath).resolves(true)
|
isBinaryGoodStub.withArgs(defaultPath).resolves(true)
|
||||||
|
|
||||||
const result = await binaryManager.findBinary(name, envVariable)
|
const result = await binaryManager.findBinary(name, envVariable)
|
||||||
|
|
||||||
expect(result).to.equal(defaultPath)
|
expect(result).to.equal(defaultPath)
|
||||||
expect(fsPathExistsStub.calledOnceWith(defaultPath)).to.be.true
|
expect(isBinaryGoodStub.calledOnce).to.be.true
|
||||||
expect(whichSyncStub.notCalled).to.be.true
|
expect(isBinaryGoodStub.calledWith(defaultPath)).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return whichPath if it exists', async () => {
|
it('should return the whichPath if it exists and is a good binary', async () => {
|
||||||
delete process.env[envVariable]
|
delete process.env[envVariable]
|
||||||
|
isBinaryGoodStub.withArgs(undefined).resolves(false)
|
||||||
|
isBinaryGoodStub.withArgs(whichPath).resolves(true)
|
||||||
whichSyncStub.returns(whichPath)
|
whichSyncStub.returns(whichPath)
|
||||||
|
|
||||||
const result = await binaryManager.findBinary(name, envVariable)
|
const result = await binaryManager.findBinary(name, envVariable)
|
||||||
|
|
||||||
expect(result).to.equal(whichPath)
|
expect(result).to.equal(whichPath)
|
||||||
expect(fsPathExistsStub.notCalled).to.be.true
|
expect(isBinaryGoodStub.calledTwice).to.be.true
|
||||||
expect(whichSyncStub.calledOnce).to.be.true
|
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
|
||||||
|
expect(isBinaryGoodStub.calledWith(whichPath)).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return mainInstallPath if it exists', async () => {
|
it('should return the mainInstallPath if it exists and is a good binary', async () => {
|
||||||
delete process.env[envVariable]
|
delete process.env[envVariable]
|
||||||
|
isBinaryGoodStub.withArgs(undefined).resolves(false)
|
||||||
|
isBinaryGoodStub.withArgs(null).resolves(false)
|
||||||
|
isBinaryGoodStub.withArgs(mainInstallPath).resolves(true)
|
||||||
whichSyncStub.returns(null)
|
whichSyncStub.returns(null)
|
||||||
fsPathExistsStub.withArgs(mainInstallPath).resolves(true)
|
|
||||||
|
|
||||||
const result = await binaryManager.findBinary(name, envVariable)
|
const result = await binaryManager.findBinary(name, envVariable)
|
||||||
|
|
||||||
expect(result).to.equal(mainInstallPath)
|
expect(result).to.equal(mainInstallPath)
|
||||||
expect(whichSyncStub.calledOnce).to.be.true
|
expect(isBinaryGoodStub.callCount).to.be.equal(3)
|
||||||
expect(fsPathExistsStub.calledOnceWith(mainInstallPath)).to.be.true
|
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
|
||||||
|
expect(isBinaryGoodStub.calledWith(null)).to.be.true
|
||||||
|
expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return altInstallPath if it exists', async () => {
|
it('should return the altInstallPath if it exists and is a good binary', async () => {
|
||||||
delete process.env[envVariable]
|
delete process.env[envVariable]
|
||||||
|
isBinaryGoodStub.withArgs(undefined).resolves(false)
|
||||||
|
isBinaryGoodStub.withArgs(null).resolves(false)
|
||||||
|
isBinaryGoodStub.withArgs(mainInstallPath).resolves(false)
|
||||||
|
isBinaryGoodStub.withArgs(altInstallPath).resolves(true)
|
||||||
whichSyncStub.returns(null)
|
whichSyncStub.returns(null)
|
||||||
fsPathExistsStub.withArgs(mainInstallPath).resolves(false)
|
|
||||||
fsPathExistsStub.withArgs(altInstallPath).resolves(true)
|
|
||||||
|
|
||||||
const result = await binaryManager.findBinary(name, envVariable)
|
const result = await binaryManager.findBinary(name, envVariable)
|
||||||
|
|
||||||
expect(result).to.equal(altInstallPath)
|
expect(result).to.equal(altInstallPath)
|
||||||
expect(whichSyncStub.calledOnce).to.be.true
|
expect(isBinaryGoodStub.callCount).to.be.equal(4)
|
||||||
expect(fsPathExistsStub.calledTwice).to.be.true
|
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
|
||||||
expect(fsPathExistsStub.calledWith(mainInstallPath)).to.be.true
|
expect(isBinaryGoodStub.calledWith(null)).to.be.true
|
||||||
expect(fsPathExistsStub.calledWith(altInstallPath)).to.be.true
|
expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
|
||||||
|
expect(isBinaryGoodStub.calledWith(altInstallPath)).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return null if binary is not found', async () => {
|
it('should return null if no good binary is found', async () => {
|
||||||
delete process.env[envVariable]
|
delete process.env[envVariable]
|
||||||
|
isBinaryGoodStub.withArgs(undefined).resolves(false)
|
||||||
|
isBinaryGoodStub.withArgs(null).resolves(false)
|
||||||
|
isBinaryGoodStub.withArgs(mainInstallPath).resolves(false)
|
||||||
|
isBinaryGoodStub.withArgs(altInstallPath).resolves(false)
|
||||||
whichSyncStub.returns(null)
|
whichSyncStub.returns(null)
|
||||||
fsPathExistsStub.withArgs(mainInstallPath).resolves(false)
|
|
||||||
fsPathExistsStub.withArgs(altInstallPath).resolves(false)
|
|
||||||
|
|
||||||
const result = await binaryManager.findBinary(name, envVariable)
|
const result = await binaryManager.findBinary(name, envVariable)
|
||||||
|
|
||||||
expect(result).to.be.null
|
expect(result).to.be.null
|
||||||
expect(whichSyncStub.calledOnce).to.be.true
|
expect(isBinaryGoodStub.callCount).to.be.equal(4)
|
||||||
expect(fsPathExistsStub.calledTwice).to.be.true
|
expect(isBinaryGoodStub.calledWith(undefined)).to.be.true
|
||||||
expect(fsPathExistsStub.calledWith(mainInstallPath)).to.be.true
|
expect(isBinaryGoodStub.calledWith(null)).to.be.true
|
||||||
expect(fsPathExistsStub.calledWith(altInstallPath)).to.be.true
|
expect(isBinaryGoodStub.calledWith(mainInstallPath)).to.be.true
|
||||||
|
expect(isBinaryGoodStub.calledWith(altInstallPath)).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isBinaryGood', () => {
|
||||||
|
let binaryManager
|
||||||
|
let fsPathExistsStub
|
||||||
|
let execStub
|
||||||
|
let loggerInfoStub
|
||||||
|
let loggerErrorStub
|
||||||
|
|
||||||
|
const binaryPath = '/path/to/binary'
|
||||||
|
const execCommand = '"' + binaryPath + '"' + ' -version'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
binaryManager = new BinaryManager()
|
||||||
|
fsPathExistsStub = sinon.stub(fs, 'pathExists')
|
||||||
|
execStub = sinon.stub(binaryManager, 'exec')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fsPathExistsStub.restore()
|
||||||
|
execStub.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if binaryPath is falsy', async () => {
|
||||||
|
fsPathExistsStub.resolves(true)
|
||||||
|
|
||||||
|
const result = await binaryManager.isBinaryGood(null)
|
||||||
|
|
||||||
|
expect(result).to.be.false
|
||||||
|
expect(fsPathExistsStub.called).to.be.false
|
||||||
|
expect(execStub.called).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if binaryPath does not exist', async () => {
|
||||||
|
fsPathExistsStub.resolves(false)
|
||||||
|
|
||||||
|
const result = await binaryManager.isBinaryGood(binaryPath)
|
||||||
|
|
||||||
|
expect(result).to.be.false
|
||||||
|
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||||
|
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
|
||||||
|
expect(execStub.called).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if failed to check version of binary', async () => {
|
||||||
|
fsPathExistsStub.resolves(true)
|
||||||
|
execStub.rejects(new Error('Failed to execute command'))
|
||||||
|
|
||||||
|
const result = await binaryManager.isBinaryGood(binaryPath)
|
||||||
|
|
||||||
|
expect(result).to.be.false
|
||||||
|
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||||
|
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
|
||||||
|
expect(execStub.calledOnce).to.be.true
|
||||||
|
expect(execStub.calledWith(execCommand)).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if version is not found', async () => {
|
||||||
|
const stdout = 'Some output without version'
|
||||||
|
fsPathExistsStub.resolves(true)
|
||||||
|
execStub.resolves({ stdout })
|
||||||
|
|
||||||
|
const result = await binaryManager.isBinaryGood(binaryPath)
|
||||||
|
|
||||||
|
expect(result).to.be.false
|
||||||
|
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||||
|
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
|
||||||
|
expect(execStub.calledOnce).to.be.true
|
||||||
|
expect(execStub.calledWith(execCommand)).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false if version is found but does not match a good version', async () => {
|
||||||
|
const stdout = 'version 1.2.3'
|
||||||
|
fsPathExistsStub.resolves(true)
|
||||||
|
execStub.resolves({ stdout })
|
||||||
|
|
||||||
|
const result = await binaryManager.isBinaryGood(binaryPath)
|
||||||
|
|
||||||
|
expect(result).to.be.false
|
||||||
|
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||||
|
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
|
||||||
|
expect(execStub.calledOnce).to.be.true
|
||||||
|
expect(execStub.calledWith(execCommand)).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return true if version is found and matches a good version', async () => {
|
||||||
|
const stdout = 'version 6.1.2'
|
||||||
|
fsPathExistsStub.resolves(true)
|
||||||
|
execStub.resolves({ stdout })
|
||||||
|
|
||||||
|
const result = await binaryManager.isBinaryGood(binaryPath)
|
||||||
|
|
||||||
|
expect(result).to.be.true
|
||||||
|
expect(fsPathExistsStub.calledOnce).to.be.true
|
||||||
|
expect(fsPathExistsStub.calledWith(binaryPath)).to.be.true
|
||||||
|
expect(execStub.calledOnce).to.be.true
|
||||||
|
expect(execStub.calledWith(execCommand)).to.be.true
|
||||||
})
|
})
|
||||||
})
|
})
|
Loading…
Reference in New Issue
Block a user