mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-07-15 20:04:56 +02:00
Compare commits
No commits in common. "master" and "v2.24.0" have entirely different histories.
119 changed files with 1224 additions and 4097 deletions
|
@ -57,7 +57,7 @@ WORKDIR /app
|
|||
# Copy compiled frontend and server from build stages
|
||||
COPY --from=build-client /client/dist /app/client/dist
|
||||
COPY --from=build-server /server /app
|
||||
COPY --from=build-server ${NUSQLITE3_PATH} ${NUSQLITE3_PATH}
|
||||
COPY --from=build-server /usr/local/lib/nusqlite3 /usr/local/lib/nusqlite3
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
|
|
|
@ -70,11 +70,6 @@ export default {
|
|||
title: this.$strings.HeaderUsers,
|
||||
path: '/config/users'
|
||||
},
|
||||
{
|
||||
id: 'config-api-keys',
|
||||
title: this.$strings.HeaderApiKeys,
|
||||
path: '/config/api-keys'
|
||||
},
|
||||
{
|
||||
id: 'config-sessions',
|
||||
title: this.$strings.HeaderListeningSessions,
|
||||
|
|
|
@ -778,6 +778,10 @@ export default {
|
|||
windowResize() {
|
||||
this.executeRebuild()
|
||||
},
|
||||
socketInit() {
|
||||
// Server settings are set on socket init
|
||||
this.executeRebuild()
|
||||
},
|
||||
initListeners() {
|
||||
window.addEventListener('resize', this.windowResize)
|
||||
|
||||
|
@ -790,6 +794,7 @@ export default {
|
|||
})
|
||||
|
||||
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('socket_init', this.socketInit)
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
|
||||
if (this.$root.socket) {
|
||||
|
@ -821,6 +826,7 @@ export default {
|
|||
}
|
||||
|
||||
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('socket_init', this.socketInit)
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
|
||||
if (this.$root.socket) {
|
||||
|
|
|
@ -71,6 +71,9 @@ export default {
|
|||
coverHeight() {
|
||||
return this.cardHeight
|
||||
},
|
||||
userToken() {
|
||||
return this.store.getters['user/getToken']
|
||||
},
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
|
|
|
@ -198,7 +198,7 @@ export default {
|
|||
return this.store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
dateFormat() {
|
||||
return this.store.getters['getServerSetting']('dateFormat')
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
},
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
|
|
|
@ -71,7 +71,7 @@ export default {
|
|||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
dateFormat() {
|
||||
return this.store.getters['getServerSetting']('dateFormat')
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
|
|
|
@ -94,9 +94,6 @@ export default {
|
|||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
userCanAccessExplicitContent() {
|
||||
return this.$store.getters['user/getUserCanAccessExplicitContent']
|
||||
},
|
||||
libraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
|
@ -242,15 +239,6 @@ export default {
|
|||
sublist: false
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userCanAccessExplicitContent) {
|
||||
items.push({
|
||||
text: this.$strings.LabelExplicit,
|
||||
value: 'explicit',
|
||||
sublist: false
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userIsAdminOrUp) {
|
||||
items.push({
|
||||
text: this.$strings.LabelShareOpen,
|
||||
|
@ -261,7 +249,7 @@ export default {
|
|||
return items
|
||||
},
|
||||
podcastItems() {
|
||||
const items = [
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
|
@ -295,16 +283,6 @@ export default {
|
|||
sublist: false
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userCanAccessExplicitContent) {
|
||||
items.push({
|
||||
text: this.$strings.LabelExplicit,
|
||||
value: 'explicit',
|
||||
sublist: false
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
selectItems() {
|
||||
if (this.isSeries) return this.seriesItems
|
||||
|
|
|
@ -39,6 +39,9 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
|
|
|
@ -309,9 +309,9 @@ export default {
|
|||
} else {
|
||||
console.log('Account updated', data.user)
|
||||
|
||||
if (data.user.id === this.user.id && data.user.accessToken !== this.user.accessToken) {
|
||||
console.log('Current user access token was updated')
|
||||
this.$store.commit('user/setAccessToken', data.user.accessToken)
|
||||
if (data.user.id === this.user.id && data.user.token !== this.user.token) {
|
||||
console.log('Current user token was updated')
|
||||
this.$store.commit('user/setUserToken', data.user.token)
|
||||
}
|
||||
|
||||
this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
|
||||
|
@ -351,6 +351,9 @@ export default {
|
|||
this.$toast.error(errMsg || 'Failed to create account')
|
||||
})
|
||||
},
|
||||
toggleActive() {
|
||||
this.newUser.isActive = !this.newUser.isActive
|
||||
},
|
||||
userTypeUpdated(type) {
|
||||
this.newUser.permissions = {
|
||||
download: type !== 'guest',
|
||||
|
|
|
@ -1,60 +0,0 @@
|
|||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="api-key-created" :width="800" :height="'unset'" persistent>
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 200px; max-height: 80vh">
|
||||
<div class="w-full p-8">
|
||||
<p class="text-lg text-white mb-4">{{ $getString('LabelApiKeyCreated', [apiKeyName]) }}</p>
|
||||
|
||||
<p class="text-lg text-white mb-4">{{ $strings.LabelApiKeyCreatedDescription }}</p>
|
||||
|
||||
<ui-text-input label="API Key" :value="apiKeyKey" readonly show-copy />
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<ui-btn color="bg-primary" @click="show = false">{{ $strings.ButtonClose }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
apiKey: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.$strings.HeaderNewApiKey
|
||||
},
|
||||
apiKeyName() {
|
||||
return this.apiKey?.name || ''
|
||||
},
|
||||
apiKeyKey() {
|
||||
return this.apiKey?.apiKey || ''
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
|
@ -1,198 +0,0 @@
|
|||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="api-key" :width="800" :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">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full 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 py-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model.trim="newApiKey.name" :readonly="!isNew" :label="$strings.LabelName" />
|
||||
</div>
|
||||
<div v-if="isNew" class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model.trim="newApiKey.expiresIn" :label="$strings.LabelExpiresInSeconds" type="number" :min="0" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center pt-4 pb-2 gap-2">
|
||||
<div class="flex items-center px-2">
|
||||
<p class="px-3 font-semibold" id="user-enabled-toggle">{{ $strings.LabelEnable }}</p>
|
||||
<ui-toggle-switch :disabled="isExpired && !apiKey.isActive" labeledBy="user-enabled-toggle" v-model="newApiKey.isActive" />
|
||||
</div>
|
||||
<div v-if="isExpired" class="px-2">
|
||||
<p class="text-sm text-error">{{ $strings.LabelExpired }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full border-t border-b border-black-200 py-4 px-3 mt-4">
|
||||
<p class="text-lg mb-2 font-semibold">{{ $strings.LabelApiKeyUser }}</p>
|
||||
<p class="text-sm mb-2 text-gray-400">{{ $strings.LabelApiKeyUserDescription }}</p>
|
||||
<ui-select-input v-model="newApiKey.userId" :disabled="isExpired && !apiKey.isActive" :items="userItems" :placeholder="$strings.LabelSelectUser" :label="$strings.LabelApiKeyUser" label-hidden />
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4 px-2">
|
||||
<div class="grow" />
|
||||
<ui-btn color="bg-success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
apiKey: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newApiKey: {},
|
||||
isNew: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.isNew ? this.$strings.HeaderNewApiKey : this.$strings.HeaderUpdateApiKey
|
||||
},
|
||||
userItems() {
|
||||
return this.users
|
||||
.filter((u) => {
|
||||
// Only show root user if the current user is root
|
||||
return u.type !== 'root' || this.$store.getters['user/getIsRoot']
|
||||
})
|
||||
.map((u) => ({ text: u.username, value: u.id, subtext: u.type }))
|
||||
},
|
||||
isExpired() {
|
||||
if (!this.apiKey || !this.apiKey.expiresAt) return false
|
||||
|
||||
return new Date(this.apiKey.expiresAt).getTime() < Date.now()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitForm() {
|
||||
if (!this.newApiKey.name) {
|
||||
this.$toast.error(this.$strings.ToastNameRequired)
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.newApiKey.userId) {
|
||||
this.$toast.error(this.$strings.ToastNewApiKeyUserError)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.isNew) {
|
||||
this.submitCreateApiKey()
|
||||
} else {
|
||||
this.submitUpdateApiKey()
|
||||
}
|
||||
},
|
||||
submitUpdateApiKey() {
|
||||
if (this.newApiKey.isActive === this.apiKey.isActive && this.newApiKey.userId === this.apiKey.userId) {
|
||||
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||
this.show = false
|
||||
return
|
||||
}
|
||||
|
||||
const apiKey = {
|
||||
isActive: this.newApiKey.isActive,
|
||||
userId: this.newApiKey.userId
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$patch(`/api/api-keys/${this.apiKey.id}`, apiKey)
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(`${this.$strings.ToastFailedToUpdate}: ${data.error}`)
|
||||
} else {
|
||||
this.show = false
|
||||
this.$emit('updated', data.apiKey)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processing = false
|
||||
console.error('Failed to update apiKey', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || this.$strings.ToastFailedToUpdate)
|
||||
})
|
||||
},
|
||||
submitCreateApiKey() {
|
||||
const apiKey = { ...this.newApiKey }
|
||||
|
||||
if (this.newApiKey.expiresIn) {
|
||||
apiKey.expiresIn = parseInt(this.newApiKey.expiresIn)
|
||||
} else {
|
||||
delete apiKey.expiresIn
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post('/api/api-keys', apiKey)
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(this.$strings.ToastFailedToCreate + ': ' + data.error)
|
||||
} else {
|
||||
this.show = false
|
||||
this.$emit('created', data.apiKey)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.processing = false
|
||||
console.error('Failed to create apiKey', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || this.$strings.ToastFailedToCreate)
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.isNew = !this.apiKey
|
||||
|
||||
if (this.apiKey) {
|
||||
this.newApiKey = {
|
||||
name: this.apiKey.name,
|
||||
isActive: this.apiKey.isActive,
|
||||
userId: this.apiKey.userId
|
||||
}
|
||||
} else {
|
||||
this.newApiKey = {
|
||||
name: null,
|
||||
expiresIn: null,
|
||||
isActive: true,
|
||||
userId: null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
|
@ -79,10 +79,10 @@ export default {
|
|||
return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -159,10 +159,10 @@ export default {
|
|||
return 'Unknown'
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
},
|
||||
isOpenSession() {
|
||||
return !!this._session.open
|
||||
|
|
|
@ -23,7 +23,7 @@ export default {
|
|||
processing: Boolean,
|
||||
persistent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: true
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
|
@ -99,7 +99,7 @@ export default {
|
|||
this.preventClickoutside = false
|
||||
return
|
||||
}
|
||||
if (this.processing || this.persistent) return
|
||||
if (this.processing && this.persistent) return
|
||||
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
||||
this.show = false
|
||||
}
|
||||
|
|
|
@ -144,7 +144,7 @@ export default {
|
|||
expirationDateString() {
|
||||
if (!this.expireDurationSeconds) return this.$strings.LabelPermanent
|
||||
const dateMs = Date.now() + this.expireDurationSeconds * 1000
|
||||
return this.$formatDatetime(dateMs, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat'))
|
||||
return this.$formatDatetime(dateMs, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -40,7 +40,7 @@ export default {
|
|||
}
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
releasesToShow() {
|
||||
return this.versionData?.releasesToShow || []
|
||||
|
|
|
@ -29,6 +29,9 @@ export default {
|
|||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
|
|
|
@ -35,14 +35,7 @@
|
|||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- published -->
|
||||
<p class="text-xs text-gray-300 w-40">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
<!-- duration -->
|
||||
<p v-if="episode.durationSeconds && !isNaN(episode.durationSeconds)" class="text-xs text-gray-300 min-w-28">{{ $strings.LabelDuration }}: {{ $elapsedPretty(episode.durationSeconds) }}</p>
|
||||
<!-- size -->
|
||||
<p v-if="episode.enclosure?.length && !isNaN(episode.enclosure.length) && Number(episode.enclosure.length) > 0" class="text-xs text-gray-300">{{ $strings.LabelSize }}: {{ $bytesPretty(Number(episode.enclosure.length)) }}</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
{{ $getString('MessageConfirmRemoveEpisode', [episodeTitle]) }}
|
||||
</p>
|
||||
<p v-else class="text-lg text-gray-200 mb-4">{{ $getString('MessageConfirmRemoveEpisodes', [episodes.length]) }}</p>
|
||||
<p class="text-xs font-semibold text-warning/90">{{ $strings.MessageConfirmRemoveEpisodeNote }}</p>
|
||||
<p class="text-xs font-semibold text-warning/90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pt-4">
|
||||
<ui-checkbox v-model="hardDeleteFile" :label="$strings.LabelHardDeleteFile" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<p dir="auto" class="text-lg font-semibold mb-6">{{ title }}</p>
|
||||
<div v-if="description" dir="auto" class="default-style less-spacing" @click="handleDescriptionClick" v-html="description" />
|
||||
<div v-if="description" dir="auto" class="default-style less-spacing" v-html="description" />
|
||||
<p v-else class="mb-2">{{ $strings.MessageNoDescription }}</p>
|
||||
|
||||
<div class="w-full h-px bg-white/5 my-4" />
|
||||
|
@ -34,12 +34,6 @@
|
|||
{{ audioFileSize }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grow">
|
||||
<p class="font-semibold text-xs mb-1">{{ $strings.LabelDuration }}</p>
|
||||
<p class="mb-2 text-xs">
|
||||
{{ audioFileDuration }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
|
@ -74,7 +68,7 @@ export default {
|
|||
return this.episode.title || 'No Episode Title'
|
||||
},
|
||||
description() {
|
||||
return this.parseDescription(this.episode.description || '')
|
||||
return this.episode.description || ''
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem?.media || {}
|
||||
|
@ -96,49 +90,11 @@ export default {
|
|||
|
||||
return this.$bytesPretty(size)
|
||||
},
|
||||
audioFileDuration() {
|
||||
const duration = this.episode.duration || 0
|
||||
return this.$elapsedPretty(duration)
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleDescriptionClick(e) {
|
||||
if (e.target.matches('span.time-marker')) {
|
||||
const time = parseInt(e.target.dataset.time)
|
||||
if (!isNaN(time)) {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
episodeId: this.episodeId,
|
||||
libraryItemId: this.libraryItem.id,
|
||||
startTime: time
|
||||
})
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
},
|
||||
parseDescription(description) {
|
||||
const timeMarkerLinkRegex = /<a href="#([^"]*?\b\d{1,2}:\d{1,2}(?::\d{1,2})?)">(.*?)<\/a>/g
|
||||
const timeMarkerRegex = /\b\d{1,2}:\d{1,2}(?::\d{1,2})?\b/g
|
||||
|
||||
function convertToSeconds(time) {
|
||||
const timeParts = time.split(':').map(Number)
|
||||
return timeParts.reduce((acc, part, index) => acc * 60 + part, 0)
|
||||
}
|
||||
|
||||
return description
|
||||
.replace(timeMarkerLinkRegex, (match, href, displayTime) => {
|
||||
const time = displayTime.match(timeMarkerRegex)[0]
|
||||
const seekTimeInSeconds = convertToSeconds(time)
|
||||
return `<span class="time-marker cursor-pointer text-blue-400 hover:text-blue-300" data-time="${seekTimeInSeconds}">${displayTime}</span>`
|
||||
})
|
||||
.replace(timeMarkerRegex, (match) => {
|
||||
const seekTimeInSeconds = convertToSeconds(match)
|
||||
return `<span class="time-marker cursor-pointer text-blue-400 hover:text-blue-300" data-time="${seekTimeInSeconds}">${match}</span>`
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -129,6 +129,9 @@ export default {
|
|||
return `${hoursRounded}h`
|
||||
}
|
||||
},
|
||||
token() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
timeRemaining() {
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = this.currentTime - this.currentChapter.start
|
||||
|
|
|
@ -104,6 +104,9 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
},
|
||||
|
@ -231,7 +234,10 @@ export default {
|
|||
async extract() {
|
||||
this.loading = true
|
||||
var buff = await this.$axios.$get(this.ebookUrl, {
|
||||
responseType: 'blob'
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
})
|
||||
const archive = await Archive.open(buff)
|
||||
const originalFilesObject = await archive.getFilesObject()
|
||||
|
|
|
@ -57,6 +57,9 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
/** @returns {string} */
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
|
@ -94,9 +97,9 @@ export default {
|
|||
},
|
||||
ebookUrl() {
|
||||
if (this.fileId) {
|
||||
return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
||||
return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}`
|
||||
}
|
||||
return `/api/items/${this.libraryItemId}/ebook`
|
||||
return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook`
|
||||
},
|
||||
themeRules() {
|
||||
const isDark = this.ereaderSettings.theme === 'dark'
|
||||
|
@ -306,24 +309,14 @@ export default {
|
|||
/** @type {EpubReader} */
|
||||
const reader = this
|
||||
|
||||
// Use axios to make request because we have token refresh logic in interceptor
|
||||
const customRequest = async (url) => {
|
||||
try {
|
||||
return this.$axios.$get(url, {
|
||||
responseType: 'arraybuffer'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('EpubReader.initEpub customRequest failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {ePub.Book} */
|
||||
reader.book = new ePub(reader.ebookUrl, {
|
||||
width: this.readerWidth,
|
||||
height: this.readerHeight - 50,
|
||||
openAs: 'epub',
|
||||
requestMethod: customRequest
|
||||
requestHeaders: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
})
|
||||
|
||||
/** @type {ePub.Rendition} */
|
||||
|
@ -344,33 +337,29 @@ export default {
|
|||
this.applyTheme()
|
||||
})
|
||||
|
||||
reader.book.ready
|
||||
.then(() => {
|
||||
// set up event listeners
|
||||
reader.rendition.on('relocated', reader.relocated)
|
||||
reader.rendition.on('keydown', reader.keyUp)
|
||||
reader.book.ready.then(() => {
|
||||
// set up event listeners
|
||||
reader.rendition.on('relocated', reader.relocated)
|
||||
reader.rendition.on('keydown', reader.keyUp)
|
||||
|
||||
reader.rendition.on('touchstart', (event) => {
|
||||
this.$emit('touchstart', event)
|
||||
})
|
||||
reader.rendition.on('touchend', (event) => {
|
||||
this.$emit('touchend', event)
|
||||
})
|
||||
reader.rendition.on('touchstart', (event) => {
|
||||
this.$emit('touchstart', event)
|
||||
})
|
||||
reader.rendition.on('touchend', (event) => {
|
||||
this.$emit('touchend', event)
|
||||
})
|
||||
|
||||
// load ebook cfi locations
|
||||
const savedLocations = this.loadLocations()
|
||||
if (savedLocations) {
|
||||
reader.book.locations.load(savedLocations)
|
||||
} else {
|
||||
reader.book.locations.generate().then(() => {
|
||||
this.checkSaveLocations(reader.book.locations.save())
|
||||
})
|
||||
}
|
||||
this.getChapters()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('EpubReader.initEpub failed:', error)
|
||||
})
|
||||
// load ebook cfi locations
|
||||
const savedLocations = this.loadLocations()
|
||||
if (savedLocations) {
|
||||
reader.book.locations.load(savedLocations)
|
||||
} else {
|
||||
reader.book.locations.generate().then(() => {
|
||||
this.checkSaveLocations(reader.book.locations.save())
|
||||
})
|
||||
}
|
||||
this.getChapters()
|
||||
})
|
||||
},
|
||||
getChapters() {
|
||||
// Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759
|
||||
|
|
|
@ -26,6 +26,9 @@ export default {
|
|||
return {}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
},
|
||||
|
@ -93,8 +96,11 @@ export default {
|
|||
},
|
||||
async initMobi() {
|
||||
// Fetch mobi file as blob
|
||||
const buff = await this.$axios.$get(this.ebookUrl, {
|
||||
responseType: 'blob'
|
||||
var buff = await this.$axios.$get(this.ebookUrl, {
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
})
|
||||
var reader = new FileReader()
|
||||
reader.onload = async (event) => {
|
||||
|
|
|
@ -55,8 +55,7 @@ export default {
|
|||
loadedRatio: 0,
|
||||
page: 1,
|
||||
numPages: 0,
|
||||
pdfDocInitParams: null,
|
||||
isRefreshing: false
|
||||
pdfDocInitParams: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
@ -153,34 +152,7 @@ export default {
|
|||
this.page++
|
||||
this.updateProgress()
|
||||
},
|
||||
async refreshToken() {
|
||||
if (this.isRefreshing) return
|
||||
this.isRefreshing = true
|
||||
const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => {
|
||||
console.error('Failed to refresh token', error)
|
||||
return null
|
||||
})
|
||||
if (!newAccessToken) {
|
||||
// Redirect to login on failed refresh
|
||||
this.$router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// Force Vue to re-render the PDF component by creating a new object
|
||||
this.pdfDocInitParams = {
|
||||
url: this.ebookUrl,
|
||||
httpHeaders: {
|
||||
Authorization: `Bearer ${newAccessToken}`
|
||||
}
|
||||
}
|
||||
this.isRefreshing = false
|
||||
},
|
||||
async error(err) {
|
||||
if (err && err.status === 401) {
|
||||
console.log('Received 401 error, refreshing token')
|
||||
await this.refreshToken()
|
||||
return
|
||||
}
|
||||
error(err) {
|
||||
console.error(err)
|
||||
},
|
||||
resize() {
|
||||
|
|
|
@ -266,6 +266,9 @@ export default {
|
|||
isComic() {
|
||||
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
keepProgress() {
|
||||
return this.$store.state.ereaderKeepProgress
|
||||
},
|
||||
|
|
|
@ -1,177 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="text-center">
|
||||
<table v-if="apiKeys.length > 0" id="api-keys">
|
||||
<tr>
|
||||
<th>{{ $strings.LabelName }}</th>
|
||||
<th class="w-44">{{ $strings.LabelApiKeyUser }}</th>
|
||||
<th class="w-32">{{ $strings.LabelExpiresAt }}</th>
|
||||
<th class="w-32">{{ $strings.LabelCreatedAt }}</th>
|
||||
<th class="w-32"></th>
|
||||
</tr>
|
||||
<tr v-for="apiKey in apiKeys" :key="apiKey.id" :class="apiKey.isActive ? '' : 'bg-error/10!'">
|
||||
<td>
|
||||
<div class="flex items-center">
|
||||
<p class="pl-2 truncate">{{ apiKey.name }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<nuxt-link v-if="apiKey.user" :to="`/config/users/${apiKey.user.id}`" class="text-xs hover:underline">
|
||||
{{ apiKey.user.username }}
|
||||
</nuxt-link>
|
||||
<p v-else class="text-xs">Error</p>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<p v-if="apiKey.expiresAt" class="text-xs" :title="apiKey.expiresAt">{{ getExpiresAtText(apiKey) }}</p>
|
||||
<p v-else class="text-xs">{{ $strings.LabelExpiresNever }}</p>
|
||||
</td>
|
||||
<td class="text-xs font-mono">
|
||||
<ui-tooltip direction="top" :text="$formatJsDatetime(new Date(apiKey.createdAt), dateFormat, timeFormat)">
|
||||
{{ $formatJsDate(new Date(apiKey.createdAt), dateFormat) }}
|
||||
</ui-tooltip>
|
||||
</td>
|
||||
<td class="py-0">
|
||||
<div class="w-full flex justify-left">
|
||||
<div class="h-8 w-8 flex items-center justify-center text-white/50 hover:text-white/100 cursor-pointer" @click.stop="editApiKey(apiKey)">
|
||||
<button type="button" :aria-label="$strings.ButtonEdit" class="material-symbols text-base">edit</button>
|
||||
</div>
|
||||
<div class="h-8 w-8 flex items-center justify-center text-white/50 hover:text-error cursor-pointer" @click.stop="deleteApiKeyClick(apiKey)">
|
||||
<button type="button" :aria-label="$strings.ButtonDelete" class="material-symbols text-base">delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p v-else class="text-base text-gray-300 py-4">{{ $strings.LabelNoApiKeys }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
apiKeys: [],
|
||||
isDeletingApiKey: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getExpiresAtText(apiKey) {
|
||||
if (new Date(apiKey.expiresAt).getTime() < Date.now()) {
|
||||
return this.$strings.LabelExpired
|
||||
}
|
||||
return this.$formatJsDatetime(new Date(apiKey.expiresAt), this.dateFormat, this.timeFormat)
|
||||
},
|
||||
deleteApiKeyClick(apiKey) {
|
||||
if (this.isDeletingApiKey) return
|
||||
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmDeleteApiKey', [apiKey.name]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteApiKey(apiKey)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteApiKey(apiKey) {
|
||||
this.isDeletingApiKey = true
|
||||
this.$axios
|
||||
.$delete(`/api/api-keys/${apiKey.id}`)
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.removeApiKey(apiKey.id)
|
||||
this.$emit('numApiKeys', this.apiKeys.length)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete apiKey', error)
|
||||
this.$toast.error(this.$strings.ToastFailedToDelete)
|
||||
})
|
||||
.finally(() => {
|
||||
this.isDeletingApiKey = false
|
||||
})
|
||||
},
|
||||
editApiKey(apiKey) {
|
||||
this.$emit('edit', apiKey)
|
||||
},
|
||||
addApiKey(apiKey) {
|
||||
this.apiKeys.push(apiKey)
|
||||
},
|
||||
removeApiKey(apiKeyId) {
|
||||
this.apiKeys = this.apiKeys.filter((a) => a.id !== apiKeyId)
|
||||
},
|
||||
updateApiKey(apiKey) {
|
||||
this.apiKeys = this.apiKeys.map((a) => (a.id === apiKey.id ? apiKey : a))
|
||||
},
|
||||
loadApiKeys() {
|
||||
this.$axios
|
||||
.$get('/api/api-keys')
|
||||
.then((res) => {
|
||||
this.apiKeys = res.apiKeys.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
this.$emit('numApiKeys', this.apiKeys.length)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load apiKeys', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadApiKeys()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#api-keys {
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid #474747;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#api-keys td,
|
||||
#api-keys th {
|
||||
/* border: 1px solid #2e2e2e; */
|
||||
padding: 8px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#api-keys td.py-0 {
|
||||
padding: 0px 8px;
|
||||
}
|
||||
|
||||
#api-keys tr:nth-child(even) {
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
#api-keys tr:nth-child(odd) {
|
||||
background-color: #2f2f2f;
|
||||
}
|
||||
|
||||
#api-keys tr:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#api-keys th {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #272727;
|
||||
}
|
||||
</style>
|
|
@ -78,10 +78,10 @@ export default {
|
|||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -49,6 +49,9 @@ export default {
|
|||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
|
|
|
@ -53,6 +53,9 @@ export default {
|
|||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
|
|
|
@ -76,10 +76,10 @@ export default {
|
|||
return usermap
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -112,7 +112,7 @@ export default {
|
|||
return this.episode?.publishedAt
|
||||
},
|
||||
dateFormat() {
|
||||
return this.store.getters['getServerSetting']('dateFormat')
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
},
|
||||
itemProgress() {
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId)
|
||||
|
|
|
@ -239,10 +239,10 @@ export default {
|
|||
})
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -85,6 +85,9 @@ export default {
|
|||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = []
|
||||
if (this.disabled) classes.push('bg-black-300')
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div class="relative w-full">
|
||||
<p v-if="label && !labelHidden" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<button ref="buttonWrapper" type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded-sm shadow-xs pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small, 'text-gray-400': !selectedText }">{{ selectedText || placeholder }}</span>
|
||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
||||
<span v-if="selectedSubtext">: </span>
|
||||
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
|
||||
</span>
|
||||
|
@ -36,15 +36,10 @@ export default {
|
|||
type: String,
|
||||
default: ''
|
||||
},
|
||||
labelHidden: Boolean,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
disabled: Boolean,
|
||||
small: Boolean,
|
||||
menuMaxHeight: {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</label>
|
||||
</slot>
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :min="min" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -21,7 +21,6 @@ export default {
|
|||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
min: [String, Number],
|
||||
readonly: Boolean,
|
||||
disabled: Boolean,
|
||||
inputClass: String,
|
||||
|
|
|
@ -318,8 +318,10 @@ export default {
|
|||
}
|
||||
},
|
||||
handleAttachmentAdd(event) {
|
||||
// Prevent pasting in images/any files from the browser
|
||||
event.attachment.remove()
|
||||
// Prevent pasting in images from the browser
|
||||
if (!event.attachment.file) {
|
||||
event.attachment.remove()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
|
|
@ -85,7 +85,7 @@ export default {
|
|||
nextRun() {
|
||||
if (!this.cronExpression) return ''
|
||||
const parsed = this.$getNextScheduledDate(this.cronExpression)
|
||||
return this.$formatJsDatetime(parsed, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat')) || ''
|
||||
return this.$formatJsDatetime(parsed, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat) || ''
|
||||
},
|
||||
description() {
|
||||
if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return ''
|
||||
|
|
|
@ -143,18 +143,10 @@ export default {
|
|||
localStorage.setItem('embedMetadataCodec', val)
|
||||
},
|
||||
getEncodingOptions() {
|
||||
if (this.showAdvancedView) {
|
||||
return {
|
||||
codec: this.customCodec || this.selectedCodec || 'aac',
|
||||
bitrate: this.customBitrate || this.selectedBitrate || '128k',
|
||||
channels: this.customChannels || this.selectedChannels || 2
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
codec: this.selectedCodec || 'aac',
|
||||
bitrate: this.selectedBitrate || '128k',
|
||||
channels: this.selectedChannels || 2
|
||||
}
|
||||
return {
|
||||
codec: this.selectedCodec || 'aac',
|
||||
bitrate: this.selectedBitrate || '128k',
|
||||
channels: this.selectedChannels || 2
|
||||
}
|
||||
},
|
||||
setPreset() {
|
||||
|
|
|
@ -40,7 +40,6 @@ describe('LazySeriesCard', () => {
|
|||
},
|
||||
$store: {
|
||||
getters: {
|
||||
getServerSetting: () => 'MM/dd/yyyy',
|
||||
'user/getUserCanUpdate': true,
|
||||
'user/getUserMediaProgress': (id) => null,
|
||||
'user/getSizeMultiplier': 1,
|
||||
|
|
|
@ -33,7 +33,6 @@ export default {
|
|||
return {
|
||||
socket: null,
|
||||
isSocketConnected: false,
|
||||
isSocketAuthenticated: false,
|
||||
isFirstSocketConnection: true,
|
||||
socketConnectionToastId: null,
|
||||
currentLang: null,
|
||||
|
@ -82,28 +81,9 @@ export default {
|
|||
document.body.classList.add('app-bar')
|
||||
}
|
||||
},
|
||||
tokenRefreshed(newAccessToken) {
|
||||
if (this.isSocketConnected && !this.isSocketAuthenticated) {
|
||||
console.log('[SOCKET] Re-authenticating socket after token refresh')
|
||||
this.socket.emit('auth', newAccessToken)
|
||||
}
|
||||
},
|
||||
updateSocketConnectionToast(content, type, timeout) {
|
||||
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
|
||||
const toastUpdateOptions = {
|
||||
content: content,
|
||||
options: {
|
||||
timeout: timeout,
|
||||
type: type,
|
||||
closeButton: false,
|
||||
position: 'bottom-center',
|
||||
onClose: () => {
|
||||
this.socketConnectionToastId = null
|
||||
},
|
||||
closeOnClick: timeout !== null
|
||||
}
|
||||
}
|
||||
this.$toast.update(this.socketConnectionToastId, toastUpdateOptions, false)
|
||||
this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false)
|
||||
} else {
|
||||
this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null })
|
||||
}
|
||||
|
@ -129,7 +109,7 @@ export default {
|
|||
this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null)
|
||||
},
|
||||
reconnect() {
|
||||
console.log('[SOCKET] reconnected')
|
||||
console.error('[SOCKET] reconnected')
|
||||
},
|
||||
reconnectAttempt(val) {
|
||||
console.log(`[SOCKET] reconnect attempt ${val}`)
|
||||
|
@ -140,10 +120,6 @@ export default {
|
|||
reconnectFailed() {
|
||||
console.error('[SOCKET] reconnect failed')
|
||||
},
|
||||
authFailed(payload) {
|
||||
console.error('[SOCKET] auth failed', payload.message)
|
||||
this.isSocketAuthenticated = false
|
||||
},
|
||||
init(payload) {
|
||||
console.log('Init Payload', payload)
|
||||
|
||||
|
@ -151,7 +127,7 @@ export default {
|
|||
this.$store.commit('users/setUsersOnline', payload.usersOnline)
|
||||
}
|
||||
|
||||
this.isSocketAuthenticated = true
|
||||
this.$eventBus.$emit('socket_init')
|
||||
},
|
||||
streamOpen(stream) {
|
||||
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream)
|
||||
|
@ -378,15 +354,6 @@ export default {
|
|||
this.$store.commit('scanners/removeCustomMetadataProvider', provider)
|
||||
},
|
||||
initializeSocket() {
|
||||
if (this.$root.socket) {
|
||||
// Can happen in dev due to hot reload
|
||||
console.warn('Socket already initialized')
|
||||
this.socket = this.$root.socket
|
||||
this.isSocketConnected = this.$root.socket?.connected
|
||||
this.isFirstSocketConnection = false
|
||||
this.socketConnectionToastId = null
|
||||
return
|
||||
}
|
||||
this.socket = this.$nuxtSocket({
|
||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||
persist: 'main',
|
||||
|
@ -397,7 +364,6 @@ export default {
|
|||
path: `${this.$config.routerBasePath}/socket.io`
|
||||
})
|
||||
this.$root.socket = this.socket
|
||||
this.isSocketAuthenticated = false
|
||||
console.log('Socket initialized')
|
||||
|
||||
// Pre-defined socket events
|
||||
|
@ -411,7 +377,6 @@ export default {
|
|||
|
||||
// Event received after authorizing socket
|
||||
this.socket.on('init', this.init)
|
||||
this.socket.on('auth_failed', this.authFailed)
|
||||
|
||||
// Stream Listeners
|
||||
this.socket.on('stream_open', this.streamOpen)
|
||||
|
@ -606,7 +571,6 @@ export default {
|
|||
this.updateBodyClass()
|
||||
this.resize()
|
||||
this.$eventBus.$on('change-lang', this.changeLanguage)
|
||||
this.$eventBus.$on('token_refreshed', this.tokenRefreshed)
|
||||
window.addEventListener('resize', this.resize)
|
||||
window.addEventListener('keydown', this.keyDown)
|
||||
|
||||
|
@ -630,7 +594,6 @@ export default {
|
|||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('change-lang', this.changeLanguage)
|
||||
this.$eventBus.$off('token_refreshed', this.tokenRefreshed)
|
||||
window.removeEventListener('resize', this.resize)
|
||||
window.removeEventListener('keydown', this.keyDown)
|
||||
}
|
||||
|
|
|
@ -73,8 +73,7 @@ module.exports = {
|
|||
|
||||
// Axios module configuration: https://go.nuxtjs.dev/config-axios
|
||||
axios: {
|
||||
baseURL: routerBasePath,
|
||||
progress: false
|
||||
baseURL: routerBasePath
|
||||
},
|
||||
|
||||
// nuxt/pwa https://pwa.nuxtjs.org
|
||||
|
|
4
client/package-lock.json
generated
4
client/package-lock.json
generated
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.26.0",
|
||||
"version": "2.24.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.26.0",
|
||||
"version": "2.24.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.26.0",
|
||||
"version": "2.24.0",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
|
|
@ -182,19 +182,18 @@ export default {
|
|||
password: this.password,
|
||||
newPassword: this.newPassword
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
|
||||
this.resetForm()
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
|
||||
this.resetForm()
|
||||
} else {
|
||||
this.$toast.error(res.error || this.$strings.ToastUnknownError)
|
||||
}
|
||||
this.changingPassword = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to change password', error)
|
||||
let errorMessage = this.$strings.ToastUnknownError
|
||||
if (error.response?.data && typeof error.response.data === 'string') {
|
||||
errorMessage = error.response.data
|
||||
}
|
||||
this.$toast.error(errorMessage)
|
||||
})
|
||||
.finally(() => {
|
||||
console.error(error)
|
||||
this.$toast.error(this.$strings.ToastUnknownError)
|
||||
this.changingPassword = false
|
||||
})
|
||||
},
|
||||
|
|
|
@ -28,14 +28,14 @@
|
|||
<div class="flex justify-center flex-wrap lg:flex-nowrap gap-4">
|
||||
<div class="w-full max-w-2xl border border-white/10 bg-bg">
|
||||
<div class="flex py-2 px-4">
|
||||
<div class="w-28 min-w-28 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelMetaTag }}</div>
|
||||
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
|
||||
<div class="w-1/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelMetaTag }}</div>
|
||||
<div class="w-2/3 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelValue }}</div>
|
||||
</div>
|
||||
<div class="w-full max-h-72 overflow-auto">
|
||||
<template v-for="(value, key, index) in metadataObject">
|
||||
<div :key="key" class="flex py-1 px-4 text-sm" :class="index % 2 === 0 ? 'bg-primary/25' : ''">
|
||||
<div class="w-28 min-w-28 font-semibold">{{ key }}</div>
|
||||
<div class="grow">
|
||||
<div class="w-1/3 font-semibold">{{ key }}</div>
|
||||
<div class="w-2/3">
|
||||
{{ value }}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -45,18 +45,18 @@
|
|||
<div class="w-full max-w-2xl border border-white/10 bg-bg">
|
||||
<div class="flex py-2 px-4 bg-primary/25">
|
||||
<div class="grow text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelChapterTitle }}</div>
|
||||
<div class="w-16 min-w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelStart }}</div>
|
||||
<div class="w-16 min-w-16 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelEnd }}</div>
|
||||
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelStart }}</div>
|
||||
<div class="w-24 text-xs font-semibold uppercase text-gray-200">{{ $strings.LabelEnd }}</div>
|
||||
</div>
|
||||
<div class="w-full max-h-72 overflow-auto">
|
||||
<p v-if="!metadataChapters.length" class="py-5 text-center text-gray-200">{{ $strings.MessageNoChapters }}</p>
|
||||
<template v-for="(chapter, index) in metadataChapters">
|
||||
<div :key="index" class="flex py-1 px-4 text-sm" :class="index % 2 === 1 ? 'bg-primary/25' : ''">
|
||||
<div class="grow font-semibold">{{ chapter.title }}</div>
|
||||
<div class="w-16 min-w-16">
|
||||
<div class="w-24">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</div>
|
||||
<div class="w-16 min-w-16">
|
||||
<div class="w-24">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -356,8 +356,6 @@ export default {
|
|||
|
||||
const encodeOptions = this.$refs.encoderOptionsCard.getEncodingOptions()
|
||||
|
||||
this.encodingOptions = encodeOptions
|
||||
|
||||
const queryParams = new URLSearchParams(encodeOptions)
|
||||
|
||||
this.processing = true
|
||||
|
|
|
@ -53,7 +53,6 @@ export default {
|
|||
else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions
|
||||
else if (pageName === 'stats') return this.$strings.HeaderYourStats
|
||||
else if (pageName === 'users') return this.$strings.HeaderUsers
|
||||
else if (pageName === 'api-keys') return this.$strings.HeaderApiKeys
|
||||
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
||||
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
|
||||
else if (pageName === 'email') return this.$strings.HeaderEmail
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderApiKeys">
|
||||
<template #header-items>
|
||||
<div v-if="numApiKeys" class="mx-2 px-1.5 rounded-lg bg-primary/50 text-gray-300/90 text-sm inline-flex items-center justify-center">
|
||||
<span>{{ numApiKeys }}</span>
|
||||
</div>
|
||||
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/api-keys" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="grow" />
|
||||
|
||||
<ui-btn color="bg-primary" :disabled="loadingUsers || users.length === 0" small @click="setShowApiKeyModal()">{{ $strings.ButtonAddApiKey }}</ui-btn>
|
||||
</template>
|
||||
|
||||
<tables-api-keys-table ref="apiKeysTable" class="pt-2" @edit="setShowApiKeyModal" @numApiKeys="(count) => (numApiKeys = count)" />
|
||||
</app-settings-content>
|
||||
<modals-api-key-modal ref="apiKeyModal" v-model="showApiKeyModal" :api-key="selectedApiKey" :users="users" @created="apiKeyCreated" @updated="apiKeyUpdated" />
|
||||
<modals-api-key-created-modal ref="apiKeyCreatedModal" v-model="showApiKeyCreatedModal" :api-key="selectedApiKey" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loadingUsers: false,
|
||||
selectedApiKey: null,
|
||||
showApiKeyModal: false,
|
||||
showApiKeyCreatedModal: false,
|
||||
numApiKeys: 0,
|
||||
users: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
apiKeyCreated(apiKey) {
|
||||
this.numApiKeys++
|
||||
this.selectedApiKey = apiKey
|
||||
this.showApiKeyCreatedModal = true
|
||||
if (this.$refs.apiKeysTable) {
|
||||
this.$refs.apiKeysTable.addApiKey(apiKey)
|
||||
}
|
||||
},
|
||||
apiKeyUpdated(apiKey) {
|
||||
if (this.$refs.apiKeysTable) {
|
||||
this.$refs.apiKeysTable.updateApiKey(apiKey)
|
||||
}
|
||||
},
|
||||
setShowApiKeyModal(selectedApiKey) {
|
||||
this.selectedApiKey = selectedApiKey
|
||||
this.showApiKeyModal = true
|
||||
},
|
||||
loadUsers() {
|
||||
this.loadingUsers = true
|
||||
this.$axios
|
||||
.$get('/api/users')
|
||||
.then((res) => {
|
||||
this.users = res.users.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingUsers = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadUsers()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
|
@ -78,10 +78,10 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -68,7 +68,7 @@
|
|||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
|
@ -250,10 +250,10 @@ export default {
|
|||
return user?.username || null
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
},
|
||||
numSelected() {
|
||||
return this.listeningSessions.filter((s) => s.selected).length
|
||||
|
|
|
@ -13,8 +13,8 @@
|
|||
<widgets-online-indicator :value="!!userOnline" />
|
||||
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||
</div>
|
||||
<div v-if="legacyToken" class="flex text-xs mt-4">
|
||||
<ui-text-input-with-label label="Legacy API Token" :value="legacyToken" readonly show-copy />
|
||||
<div v-if="userToken" class="flex text-xs mt-4">
|
||||
<ui-text-input-with-label :label="$strings.LabelApiToken" :value="userToken" readonly show-copy />
|
||||
</div>
|
||||
<div class="w-full h-px bg-white/10 my-2" />
|
||||
<div class="py-2">
|
||||
|
@ -100,11 +100,8 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
legacyToken() {
|
||||
return this.user.token
|
||||
},
|
||||
userToken() {
|
||||
return this.user.accessToken
|
||||
return this.user.token
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
|
@ -132,10 +129,10 @@ export default {
|
|||
return this.listeningSessions.sessions[0]
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
|
@ -98,10 +98,10 @@ export default {
|
|||
return this.$store.getters['users/getIsUserOnline'](this.user.id)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.getters['getServerSetting']('timeFormat')
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -193,7 +193,7 @@ export default {
|
|||
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
</tr>
|
||||
<tr v-for="narrator in narrators" :key="narrator.id">
|
||||
<td>
|
||||
<nuxt-link v-if="selectedNarrator?.id !== narrator.id" :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${narrator.id}`" class="text-sm md:text-base text-gray-100 hover:underline">{{ narrator.name }}</nuxt-link>
|
||||
<p v-if="selectedNarrator?.id !== narrator.id" class="text-sm md:text-base text-gray-100">{{ narrator.name }}</p>
|
||||
<form v-else @submit.prevent="saveClick">
|
||||
<ui-text-input v-model="newNarratorName" />
|
||||
</form>
|
||||
|
|
|
@ -141,7 +141,7 @@ export default {
|
|||
return episodeIds
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.getters['getServerSetting']('dateFormat')
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -40,15 +40,6 @@
|
|||
|
||||
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
|
||||
|
||||
<div v-if="showNewAuthSystemMessage" class="mb-4">
|
||||
<widgets-alert type="warning">
|
||||
<div>
|
||||
<p>{{ $strings.MessageAuthenticationSecurityMessage }}</p>
|
||||
<a v-if="showNewAuthSystemAdminMessage" href="https://github.com/advplyr/audiobookshelf/discussions/4460" target="_blank" class="underline">{{ $strings.LabelMoreInfo }}</a>
|
||||
</div>
|
||||
</widgets-alert>
|
||||
</div>
|
||||
|
||||
<form v-show="login_local" @submit.prevent="submitForm">
|
||||
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
|
||||
<ui-text-input v-model.trim="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
|
||||
|
@ -94,10 +85,7 @@ export default {
|
|||
MetadataPath: '',
|
||||
login_local: true,
|
||||
login_openid: false,
|
||||
authFormData: null,
|
||||
// New JWT auth system re-login flags
|
||||
showNewAuthSystemMessage: false,
|
||||
showNewAuthSystemAdminMessage: false
|
||||
authFormData: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
@ -191,14 +179,11 @@ export default {
|
|||
|
||||
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
||||
this.$store.commit('user/setUser', user)
|
||||
this.$store.commit('user/setAccessToken', user.accessToken)
|
||||
|
||||
this.$store.dispatch('user/loadUserSettings')
|
||||
},
|
||||
async submitForm() {
|
||||
this.error = null
|
||||
this.showNewAuthSystemMessage = false
|
||||
this.showNewAuthSystemAdminMessage = false
|
||||
this.processing = true
|
||||
|
||||
const payload = {
|
||||
|
@ -232,23 +217,14 @@ export default {
|
|||
}
|
||||
})
|
||||
.then((res) => {
|
||||
// Force re-login if user is using an old token with no expiration
|
||||
if (res.user.isOldToken) {
|
||||
this.username = res.user.username
|
||||
this.showNewAuthSystemMessage = true
|
||||
// Admin user sees link to github discussion
|
||||
this.showNewAuthSystemAdminMessage = res.user.type === 'admin' || res.user.type === 'root'
|
||||
return false
|
||||
}
|
||||
this.setUser(res)
|
||||
this.processing = false
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Authorize error', error)
|
||||
return false
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
return false
|
||||
})
|
||||
},
|
||||
checkStatus() {
|
||||
|
@ -304,9 +280,8 @@ export default {
|
|||
}
|
||||
},
|
||||
async mounted() {
|
||||
// Token passed as query parameter after successful oidc login
|
||||
if (this.$route.query?.accessToken) {
|
||||
localStorage.setItem('token', this.$route.query.accessToken)
|
||||
if (this.$route.query?.setToken) {
|
||||
localStorage.setItem('token', this.$route.query.setToken)
|
||||
}
|
||||
if (localStorage.getItem('token')) {
|
||||
if (await this.checkAuth()) return // if valid user no need to check status
|
||||
|
|
|
@ -1,19 +1,4 @@
|
|||
export default function ({ $axios, store, $root, app }) {
|
||||
// Track if we're currently refreshing to prevent multiple refresh attempts
|
||||
let isRefreshing = false
|
||||
let failedQueue = []
|
||||
|
||||
const processQueue = (error, token = null) => {
|
||||
failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(token)
|
||||
}
|
||||
})
|
||||
failedQueue = []
|
||||
}
|
||||
|
||||
export default function ({ $axios, store, $config }) {
|
||||
$axios.onRequest((config) => {
|
||||
if (!config.url) {
|
||||
console.error('Axios request invalid config', config)
|
||||
|
@ -22,7 +7,7 @@ export default function ({ $axios, store, $root, app }) {
|
|||
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
||||
return
|
||||
}
|
||||
const bearerToken = store.getters['user/getToken']
|
||||
const bearerToken = store.state.user.user?.token || null
|
||||
if (bearerToken) {
|
||||
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
||||
}
|
||||
|
@ -32,79 +17,9 @@ export default function ({ $axios, store, $root, app }) {
|
|||
}
|
||||
})
|
||||
|
||||
$axios.onError(async (error) => {
|
||||
const originalRequest = error.config
|
||||
$axios.onError((error) => {
|
||||
const code = parseInt(error.response && error.response.status)
|
||||
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
|
||||
console.error('Axios error', code, message)
|
||||
|
||||
// Handle 401 Unauthorized (token expired)
|
||||
if (code === 401 && !originalRequest._retry) {
|
||||
// Skip refresh for auth endpoints to prevent infinite loops
|
||||
if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/login') {
|
||||
// Refresh failed or login failed, redirect to login
|
||||
store.commit('user/setUser', null)
|
||||
store.commit('user/setAccessToken', null)
|
||||
app.router.push('/login')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
// If already refreshing, queue this request
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject })
|
||||
})
|
||||
.then((token) => {
|
||||
if (!originalRequest.headers) {
|
||||
originalRequest.headers = {}
|
||||
}
|
||||
originalRequest.headers['Authorization'] = `Bearer ${token}`
|
||||
return $axios(originalRequest)
|
||||
})
|
||||
.catch((err) => {
|
||||
return Promise.reject(err)
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
// Attempt to refresh the token
|
||||
// Updates store if successful, otherwise clears store and throw error
|
||||
const newAccessToken = await store.dispatch('user/refreshToken')
|
||||
if (!newAccessToken) {
|
||||
console.error('No new access token received')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// Update the original request with new token
|
||||
if (!originalRequest.headers) {
|
||||
originalRequest.headers = {}
|
||||
}
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`
|
||||
|
||||
// Process any queued requests
|
||||
processQueue(null, newAccessToken)
|
||||
|
||||
// Retry the original request
|
||||
return $axios(originalRequest)
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh failed:', refreshError)
|
||||
|
||||
// Process queued requests with error
|
||||
processQueue(refreshError, null)
|
||||
|
||||
// Redirect to login
|
||||
app.router.push('/login')
|
||||
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -37,48 +37,6 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false, useMilliseconds =
|
|||
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPrettyLocalized = (seconds, useFullNames = false, useMilliseconds = false) => {
|
||||
if (isNaN(seconds) || seconds === null) return ''
|
||||
|
||||
try {
|
||||
const df = new Intl.DurationFormat(Vue.prototype.$languageCodes.current, {
|
||||
style: useFullNames ? 'long' : 'short'
|
||||
})
|
||||
|
||||
const duration = {}
|
||||
|
||||
if (seconds < 60) {
|
||||
if (useMilliseconds && seconds < 1) {
|
||||
duration.milliseconds = Math.floor(seconds * 1000)
|
||||
} else {
|
||||
duration.seconds = Math.floor(seconds)
|
||||
}
|
||||
} else if (seconds < 3600) {
|
||||
// 1 hour
|
||||
duration.minutes = Math.floor(seconds / 60)
|
||||
} else if (seconds < 86400) {
|
||||
// 1 day
|
||||
duration.hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (minutes > 0) {
|
||||
duration.minutes = minutes
|
||||
}
|
||||
} else {
|
||||
duration.days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
if (hours > 0) {
|
||||
duration.hours = hours
|
||||
}
|
||||
}
|
||||
|
||||
return df.format(duration)
|
||||
} catch (error) {
|
||||
// Handle not supported
|
||||
console.warn('Intl.DurationFormat not supported, not localizing duration')
|
||||
return Vue.prototype.$elapsedPretty(seconds, useFullNames, useMilliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
Vue.prototype.$secondsToTimestamp = (seconds, includeMs = false, alwaysIncludeHours = false) => {
|
||||
if (!seconds) {
|
||||
return alwaysIncludeHours ? '00:00:00' : '0:00'
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
export const state = () => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
settings: {
|
||||
orderBy: 'media.metadata.title',
|
||||
orderDesc: false,
|
||||
|
@ -26,19 +25,19 @@ export const getters = {
|
|||
getIsRoot: (state) => state.user && state.user.type === 'root',
|
||||
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
|
||||
getToken: (state) => {
|
||||
return state.accessToken || null
|
||||
return state.user?.token || null
|
||||
},
|
||||
getUserMediaProgress:
|
||||
(state) =>
|
||||
(libraryItemId, episodeId = null) => {
|
||||
if (!state.user?.mediaProgress) return null
|
||||
if (!state.user.mediaProgress) return null
|
||||
return state.user.mediaProgress.find((li) => {
|
||||
if (episodeId && li.episodeId !== episodeId) return false
|
||||
return li.libraryItemId == libraryItemId
|
||||
})
|
||||
},
|
||||
getUserBookmarksForItem: (state) => (libraryItemId) => {
|
||||
if (!state.user?.bookmarks) return []
|
||||
if (!state.user.bookmarks) return []
|
||||
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
|
||||
},
|
||||
getUserSetting: (state) => (key) => {
|
||||
|
@ -59,9 +58,6 @@ export const getters = {
|
|||
getUserCanAccessAllLibraries: (state) => {
|
||||
return !!state.user?.permissions?.accessAllLibraries
|
||||
},
|
||||
getUserCanAccessExplicitContent: (state) => {
|
||||
return !!state.user?.permissions?.accessExplicitContent
|
||||
},
|
||||
getLibrariesAccessible: (state, getters) => {
|
||||
if (!state.user) return []
|
||||
if (getters.getUserCanAccessAllLibraries) return []
|
||||
|
@ -146,43 +142,22 @@ export const actions = {
|
|||
} catch (error) {
|
||||
console.error('Failed to load userSettings from local storage', error)
|
||||
}
|
||||
},
|
||||
refreshToken({ state, commit }) {
|
||||
return this.$axios
|
||||
.$post('/auth/refresh')
|
||||
.then(async (response) => {
|
||||
const newAccessToken = response.user.accessToken
|
||||
commit('setUser', response.user)
|
||||
commit('setAccessToken', newAccessToken)
|
||||
// Emit event used to re-authenticate socket in default.vue since $root is not available here
|
||||
if (this.$eventBus) {
|
||||
this.$eventBus.$emit('token_refreshed', newAccessToken)
|
||||
}
|
||||
return newAccessToken
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to refresh token', error)
|
||||
commit('setUser', null)
|
||||
commit('setAccessToken', null)
|
||||
// Calling function handles redirect to login
|
||||
throw error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setUser(state, user) {
|
||||
state.user = user
|
||||
},
|
||||
setAccessToken(state, token) {
|
||||
if (!token) {
|
||||
localStorage.removeItem('token')
|
||||
state.accessToken = null
|
||||
if (user) {
|
||||
if (user.token) localStorage.setItem('token', user.token)
|
||||
} else {
|
||||
state.accessToken = token
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
},
|
||||
setUserToken(state, token) {
|
||||
state.user.token = token
|
||||
localStorage.setItem('token', token)
|
||||
},
|
||||
updateMediaProgress(state, { id, data }) {
|
||||
if (!state.user) return
|
||||
if (!data) {
|
||||
|
|
|
@ -514,7 +514,7 @@
|
|||
"LabelPublishers": "الناشرون",
|
||||
"LabelRSSFeedCustomOwnerEmail": "البريد الالكتروني المخصص للمالك",
|
||||
"LabelRSSFeedCustomOwnerName": "الاسم المخصص للمالك",
|
||||
"LabelRSSFeedOpen": "موجز RSS مفتوح",
|
||||
"LabelRSSFeedOpen": "فتح تغذية RSS",
|
||||
"LabelRSSFeedPreventIndexing": "منع الفهرسة",
|
||||
"LabelRSSFeedSlug": "اسم تعريف تغذية RSS",
|
||||
"LabelRSSFeedURL": "رابط تغذية RSS",
|
||||
|
@ -918,8 +918,6 @@
|
|||
"NotificationOnBackupCompletedDescription": "يتم تشغيله عند اكتمال النسخ الاحتياطي",
|
||||
"NotificationOnBackupFailedDescription": "يتم تشغيله عند فشل النسخ الاحتياطي",
|
||||
"NotificationOnEpisodeDownloadedDescription": "يتم تشغيله عند تنزيل حلقة بودكاست تلقائيًا",
|
||||
"NotificationOnRSSFeedDisabledDescription": "يتم تشغيله عندما يتم تعطيل تنزيلات الحلقة التلقائية بسبب الكثير من المحاولات الفاشلة",
|
||||
"NotificationOnRSSFeedFailedDescription": "يتم تشغيله عند فشل طلب تغذية RSS في تنزيل حلقة تلقائية",
|
||||
"NotificationOnTestDescription": "حدث لاختبار نظام الإشعارات",
|
||||
"PlaceholderNewCollection": "اسم المجموعة الجديدة",
|
||||
"PlaceholderNewFolderPath": "مسار المجلد الجديد",
|
||||
|
|
|
@ -154,7 +154,7 @@
|
|||
"HeaderListeningSessions": "Poslechové relace",
|
||||
"HeaderListeningStats": "Statistiky poslechu",
|
||||
"HeaderLogin": "Přihlásit",
|
||||
"HeaderLogs": "Logy",
|
||||
"HeaderLogs": "Záznamy",
|
||||
"HeaderManageGenres": "Spravovat žánry",
|
||||
"HeaderManageTags": "Spravovat štítky",
|
||||
"HeaderMapDetails": "Podrobnosti mapování",
|
||||
|
@ -177,7 +177,6 @@
|
|||
"HeaderPlaylist": "Seznam skladeb",
|
||||
"HeaderPlaylistItems": "Položky seznamu přehrávání",
|
||||
"HeaderPodcastsToAdd": "Podcasty k přidání",
|
||||
"HeaderPresets": "Předvolba",
|
||||
"HeaderPreviewCover": "Náhled obálky",
|
||||
"HeaderRSSFeedGeneral": "Podrobnosti o RSS",
|
||||
"HeaderRSSFeedIsOpen": "Informační kanál RSS je otevřený",
|
||||
|
@ -346,11 +345,11 @@
|
|||
"LabelExample": "Příklad",
|
||||
"LabelExpandSeries": "Rozbalit série",
|
||||
"LabelExpandSubSeries": "Rozbalit podsérie",
|
||||
"LabelExplicit": "Explicitně",
|
||||
"LabelExplicit": "Explicitní",
|
||||
"LabelExplicitChecked": "Explicitní (zaškrtnuto)",
|
||||
"LabelExplicitUnchecked": "Není explicitní (nezaškrtnuto)",
|
||||
"LabelExportOPML": "Export OPML",
|
||||
"LabelFeedURL": "URL kanálu",
|
||||
"LabelFeedURL": "URL zdroje",
|
||||
"LabelFetchingMetadata": "Získávání metadat",
|
||||
"LabelFile": "Soubor",
|
||||
"LabelFileBirthtime": "Čas vzniku souboru",
|
||||
|
@ -514,9 +513,9 @@
|
|||
"LabelPublishers": "Vydavatelé",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
||||
"LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka",
|
||||
"LabelRSSFeedOpen": "RSS kanál otevřen",
|
||||
"LabelRSSFeedOpen": "Otevření RSS kanálu",
|
||||
"LabelRSSFeedPreventIndexing": "Zabránit indexování",
|
||||
"LabelRSSFeedSlug": "Klíčové slovo kanálu RSS",
|
||||
"LabelRSSFeedSlug": "RSS kanál Slug",
|
||||
"LabelRSSFeedURL": "URL RSS kanálu",
|
||||
"LabelRandomly": "Náhodně",
|
||||
"LabelReAddSeriesToContinueListening": "Znovu přidat sérii k pokračování poslechu",
|
||||
|
@ -531,7 +530,6 @@
|
|||
"LabelReleaseDate": "Datum vydání",
|
||||
"LabelRemoveAllMetadataAbs": "Odebrat všechny soubory metadata.abs",
|
||||
"LabelRemoveAllMetadataJson": "Smazat všechny soubory metadata.json",
|
||||
"LabelRemoveAudibleBranding": "Odebrat úvod a závěr Audible z kapitol",
|
||||
"LabelRemoveCover": "Odstranit obálku",
|
||||
"LabelRemoveMetadataFile": "Odstranit soubory metadat ve složkách položek knihovny",
|
||||
"LabelRemoveMetadataFileHelp": "Odstraníte všechny soubory metadata.json a metadata.abs ve svých složkách {0}.",
|
||||
|
@ -551,7 +549,7 @@
|
|||
"LabelSeries": "Série",
|
||||
"LabelSeriesName": "Název série",
|
||||
"LabelSeriesProgress": "Průběh série",
|
||||
"LabelServerLogLevel": "Úroveň Logování serveru",
|
||||
"LabelServerLogLevel": "Úroveň protokolu serveru",
|
||||
"LabelServerYearReview": "Přehled roku na serveru ({0})",
|
||||
"LabelSetEbookAsPrimary": "Nastavit jako primární",
|
||||
"LabelSetEbookAsSupplementary": "Nastavit jako doplňkové",
|
||||
|
@ -708,7 +706,6 @@
|
|||
"MessageAddToPlayerQueue": "Přidat do fronty přehrávače",
|
||||
"MessageAppriseDescription": "Abyste mohli používat tuto funkci, musíte mít spuštěnou instanci <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> nebo API, které bude zpracovávat stejné požadavky. <br />Adresa URL API Apprise by měla být úplná URL cesta pro odeslání oznámení, např. pokud je vaše instance API obsluhována na adrese <code>http://192.168.1.1:8337</code> pak byste měli zadat <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAsinCheck": "Ujistěte se, že používáte ASIN ze správného regionu Audible a ne z Amazonu.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Po uložení restartujte server, aby se změny OIDC použily.",
|
||||
"MessageBackupsDescription": "Zálohy zahrnují uživatele, průběh uživatele, podrobnosti o položkách knihovny, nastavení serveru a obrázky uložené v <code>/metadata/items</code> a <code>/metadata/authors</code>. Zálohy <strong>ne</strong> zahrnují všechny soubory uložené ve složkách knihovny.",
|
||||
"MessageBackupsLocationEditNote": "Poznámka: Změna umístění záloh nepřesune ani nezmění existující zálohy",
|
||||
"MessageBackupsLocationNoEditNote": "Poznámka: Umístění záloh je nastavené z proměnných prostředí a nelze zde změnit.",
|
||||
|
@ -757,7 +754,6 @@
|
|||
"MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodeNote": "Poznámka: Tím se zvukový soubor neodstraní, pokud nepřepnete volbu “Tvrdé odstranění souboru“",
|
||||
"MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?",
|
||||
"MessageConfirmRemoveListeningSessions": "Opravdu chcete odebrat {0} poslechových relací?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Jste si jisti, že chcete odstranit všechny metadata.{0} soubory ve složkách s položkami ve vaší knihovně?",
|
||||
|
@ -791,7 +787,7 @@
|
|||
"MessageJoinUsOn": "Přidejte se k nám",
|
||||
"MessageLoading": "Načítá se...",
|
||||
"MessageLoadingFolders": "Načítám složky...",
|
||||
"MessageLogsDescription": "Logy se ukládají do souborů JSON v <code>/metadata/logs</code>. Logy o pádech jsou uloženy v <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
"MessageLogsDescription": "Protokoly se ukládají do souborů JSON v <code>/metadata/logs</code>. Protokoly o pádech jsou uloženy v <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
"MessageM4BFailed": "M4B se nezdařil!",
|
||||
"MessageM4BFinished": "M4B dokončen!",
|
||||
"MessageMapChapterTitles": "Mapování názvů kapitol ke stávajícím kapitolám audioknihy bez úpravy časových razítek",
|
||||
|
@ -815,11 +811,11 @@
|
|||
"MessageNoEpisodes": "Žádné epizody",
|
||||
"MessageNoFoldersAvailable": "Nejsou k dispozici žádné složky",
|
||||
"MessageNoGenres": "Žádné žánry",
|
||||
"MessageNoIssues": "Žádné problémy",
|
||||
"MessageNoIssues": "Žádné výtisk",
|
||||
"MessageNoItems": "Žádné položky",
|
||||
"MessageNoItemsFound": "Nebyly nalezeny žádné položky",
|
||||
"MessageNoListeningSessions": "Žádné poslechové relace",
|
||||
"MessageNoLogs": "Žádné logy",
|
||||
"MessageNoLogs": "Žádné protokoly",
|
||||
"MessageNoMediaProgress": "Žádný průběh médií",
|
||||
"MessageNoNotifications": "Žádná oznámení",
|
||||
"MessageNoPodcastFeed": "Neplatný podcast: Žádný kanál",
|
||||
|
@ -857,7 +853,6 @@
|
|||
"MessageScheduleRunEveryWeekdayAtTime": "Spusť každý {0} v {1}",
|
||||
"MessageSearchResultsFor": "Výsledky hledání pro",
|
||||
"MessageSelected": "{0} vybráno",
|
||||
"MessageSeriesSequenceCannotContainSpaces": "Sekvence série nesmí obsahovat mezery",
|
||||
"MessageServerCouldNotBeReached": "Server je nedostupný",
|
||||
"MessageSetChaptersFromTracksDescription": "Nastavit kapitoly jako kapitolu a název kapitoly jako název zvukového souboru",
|
||||
"MessageShareExpirationWillBe": "Expiruje <strong>{0}</strong>",
|
||||
|
@ -919,8 +914,6 @@
|
|||
"NotificationOnBackupCompletedDescription": "Spuštěno po dokončení zálohování",
|
||||
"NotificationOnBackupFailedDescription": "Spuštěno pokud zálohování selže",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Spuštěno při automatickém stažení epizody podcastu",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Aktivováno když je automatické stahování pozastaveno z důvodu příliš mnoho neůspěšných pokusů",
|
||||
"NotificationOnRSSFeedFailedDescription": "Aktivováno když selže RSS kanál pro stahování epizod",
|
||||
"NotificationOnTestDescription": "Akce pro otestování upozorňovacího systému",
|
||||
"PlaceholderNewCollection": "Nový název kolekce",
|
||||
"PlaceholderNewFolderPath": "Nová cesta ke složce",
|
||||
|
@ -965,7 +958,7 @@
|
|||
"ToastBackupRestoreFailed": "Nepodařilo se obnovit zálohu",
|
||||
"ToastBackupUploadFailed": "Nepodařilo se nahrát zálohu",
|
||||
"ToastBackupUploadSuccess": "Záloha nahrána",
|
||||
"ToastBatchApplyDetailsToItemsSuccess": "Detaily byly aplikované na položky",
|
||||
"ToastBatchApplyDetailsToItemsSuccess": "Detaily aplikované na položky",
|
||||
"ToastBatchDeleteFailed": "Hromadné smazání selhalo",
|
||||
"ToastBatchDeleteSuccess": "Hromadné smazání proběhlo úspěšně",
|
||||
"ToastBatchQuickMatchFailed": "Rychlá schoda dávky se nezdařila!",
|
||||
|
@ -978,8 +971,6 @@
|
|||
"ToastCachePurgeFailed": "Nepodařilo se vyčistit mezipaměť",
|
||||
"ToastCachePurgeSuccess": "Vyrovnávací paměť úspěšně vyčištěna",
|
||||
"ToastChaptersHaveErrors": "Kapitoly obsahují chyby",
|
||||
"ToastChaptersInvalidShiftAmountLast": "Nesprávná délka posunu. Čas začátku poslední kapitoly by přesáhl dobu trvání této audioknihy.",
|
||||
"ToastChaptersInvalidShiftAmountStart": "Nesprávná délka posunu. První kapitola by měla nulovou nebo zápornou délku a byla by přepsána druhou kapitolou. Zvětšete čas začátku druhé kapitoly.",
|
||||
"ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy",
|
||||
"ToastChaptersRemoved": "Kapitoly odstraněny",
|
||||
"ToastChaptersUpdated": "Kapitola aktualizována",
|
||||
|
@ -1100,7 +1091,7 @@
|
|||
"ToastUnlinkOpenIdFailed": "Chyba při odpárování uživatele z OpenID",
|
||||
"ToastUnlinkOpenIdSuccess": "Uživatel odpárován z uživatele z OpenID",
|
||||
"ToastUploaderFilepathExistsError": "Soubor \"{0}\" na serveru již existuje",
|
||||
"ToastUploaderItemExistsInSubdirectoryError": "Položka \"{0}\" používá podadresář cesty pro nahrání.",
|
||||
"ToastUploaderItemExistsInSubdirectoryError": "Položka \"{0}\" používá podsložku nahrávané cesty.",
|
||||
"ToastUserDeleteFailed": "Nepodařilo se smazat uživatele",
|
||||
"ToastUserDeleteSuccess": "Uživatel smazán",
|
||||
"ToastUserPasswordChangeSuccess": "Heslo bylo změněno úspěšně",
|
||||
|
|
|
@ -177,7 +177,6 @@
|
|||
"HeaderPlaylist": "Afspilningsliste",
|
||||
"HeaderPlaylistItems": "Afspilningsliste Elementer",
|
||||
"HeaderPodcastsToAdd": "Podcasts til Tilføjelse",
|
||||
"HeaderPresets": "Forudindstillinger",
|
||||
"HeaderPreviewCover": "Forhåndsvis Omslag",
|
||||
"HeaderRSSFeedGeneral": "RSS Detaljer",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed er Åben",
|
||||
|
@ -514,7 +513,7 @@
|
|||
"LabelPublishers": "Forlag",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
|
||||
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
|
||||
"LabelRSSFeedOpen": "RSS-feed åbent",
|
||||
"LabelRSSFeedOpen": "Åben RSS-feed",
|
||||
"LabelRSSFeedPreventIndexing": "Forhindrer indeksering",
|
||||
"LabelRSSFeedSlug": "RSS-feed-slug",
|
||||
"LabelRSSFeedURL": "RSS-feed-URL",
|
||||
|
@ -531,7 +530,6 @@
|
|||
"LabelReleaseDate": "Udgivelsesdato",
|
||||
"LabelRemoveAllMetadataAbs": "Fjern alle metadata.abs filer",
|
||||
"LabelRemoveAllMetadataJson": "Fjern alle metadata.json filer",
|
||||
"LabelRemoveAudibleBranding": "Fjern Audible intro og outro fra kapitler",
|
||||
"LabelRemoveCover": "Fjern omslag",
|
||||
"LabelRemoveMetadataFile": "Fjern alle metadata filer i biblioteksmapper",
|
||||
"LabelRemoveMetadataFileHelp": "Fjern alle metadata.json og metadata.abs filer i dine {0} mapper.",
|
||||
|
@ -606,7 +604,6 @@
|
|||
"LabelSlug": "Snegl",
|
||||
"LabelSortAscending": "Stigende",
|
||||
"LabelSortDescending": "Faldende",
|
||||
"LabelSortPubDate": "Sortér Pub Dato",
|
||||
"LabelStart": "Start",
|
||||
"LabelStartTime": "Starttid",
|
||||
"LabelStarted": "Startet",
|
||||
|
@ -707,8 +704,6 @@
|
|||
"LabelYourProgress": "Din fremgang",
|
||||
"MessageAddToPlayerQueue": "Tilføj til afspilningskø",
|
||||
"MessageAppriseDescription": "For at bruge denne funktion skal du have en instans af <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kørende eller en API, der håndterer de samme anmodninger. <br /> Apprise API-webadressen skal være den fulde URL-sti for at sende underretningen, f.eks. hvis din API-instans er tilgængelig på <code>http://192.168.1.1:8337</code>, så skal du bruge <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAsinCheck": "Sikr dig at du bruger ASIN fra den korrekte Audible region, ikke Amazon.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Genstart sin server efter du har gemt for at bekræfte OIDC ændringer.",
|
||||
"MessageBackupsDescription": "Backups inkluderer brugere, brugerfremskridt, biblioteksvareoplysninger, serverindstillinger og billeder gemt i <code>/metadata/items</code> og <code>/metadata/authors</code>. Backups inkluderer <strong>ikke</strong> nogen filer gemt i dine biblioteksmapper.",
|
||||
"MessageBackupsLocationEditNote": "Note: Opdatering af backup sti vil ikke fjerne eller modificere eksisterende backups",
|
||||
"MessageBackupsLocationNoEditNote": "Note: Backup sti er sat igennem miljøvariabel og kan ikke ændres her.",
|
||||
|
@ -727,7 +722,6 @@
|
|||
"MessageChapterErrorStartGteDuration": "Ugyldig starttid skal være mindre end lydbogens varighed",
|
||||
"MessageChapterErrorStartLtPrev": "Ugyldig starttid skal være større end eller lig med den foregående kapitels starttid",
|
||||
"MessageChapterStartIsAfter": "Kapitelstarten er efter slutningen af din lydbog",
|
||||
"MessageChaptersNotFound": "Kapitler ikke fundet",
|
||||
"MessageCheckingCron": "Tjekker cron...",
|
||||
"MessageConfirmCloseFeed": "Er du sikker på, at du vil lukke dette feed?",
|
||||
"MessageConfirmDeleteBackup": "Er du sikker på, at du vil slette backup for {0}?",
|
||||
|
@ -784,7 +778,6 @@
|
|||
"MessageForceReScanDescription": "vil scanne alle filer igen som en frisk scanning. Lydfilens ID3-tags, OPF-filer og tekstfiler scannes som nye.",
|
||||
"MessageImportantNotice": "Vigtig besked!",
|
||||
"MessageInsertChapterBelow": "Indsæt kapitel nedenfor",
|
||||
"MessageInvalidAsin": "Ugyldig ASIN",
|
||||
"MessageItemsSelected": "{0} elementer valgt",
|
||||
"MessageItemsUpdated": "{0} elementer opdateret",
|
||||
"MessageJoinUsOn": "Deltag i os på",
|
||||
|
@ -856,7 +849,6 @@
|
|||
"MessageScheduleRunEveryWeekdayAtTime": "Kør hvert {0} af {1}",
|
||||
"MessageSearchResultsFor": "Søgeresultater for",
|
||||
"MessageSelected": "{0} valgt",
|
||||
"MessageSeriesSequenceCannotContainSpaces": "Serie sekvens kan ikke indeholde mellemrum",
|
||||
"MessageServerCouldNotBeReached": "Serveren kunne ikke nås",
|
||||
"MessageSetChaptersFromTracksDescription": "Indstil kapitler ved at bruge hver lydfil som et kapitel og kapiteloverskrift som lydfilnavn",
|
||||
"MessageShareExpirationWillBe": "Udløb vil være <strong>{0}</strong>",
|
||||
|
@ -918,8 +910,6 @@
|
|||
"NotificationOnBackupCompletedDescription": "Udløst når backup er færdig",
|
||||
"NotificationOnBackupFailedDescription": "Udløst når backup fejler",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Udløst når et podcast afsnit er automatisk downloadet",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Aktiveret når automatiske episode-downloads er slået fra, på grund af for mange forsøg",
|
||||
"NotificationOnRSSFeedFailedDescription": "Aktiveret når anmodning om RSS-feedet fejler for en automatisk episode-download",
|
||||
"NotificationOnTestDescription": "Event for test af notifikationssystemet",
|
||||
"PlaceholderNewCollection": "Nyt samlingnavn",
|
||||
"PlaceholderNewFolderPath": "Ny mappes sti",
|
||||
|
@ -964,7 +954,6 @@
|
|||
"ToastBackupRestoreFailed": "Mislykkedes gendannelse af sikkerhedskopi",
|
||||
"ToastBackupUploadFailed": "Mislykkedes upload af sikkerhedskopi",
|
||||
"ToastBackupUploadSuccess": "Sikkerhedskopi uploadet",
|
||||
"ToastBatchApplyDetailsToItemsSuccess": "Detaljer bekræftet på element",
|
||||
"ToastBatchDeleteFailed": "Batch slet fejlede",
|
||||
"ToastBatchDeleteSuccess": "Batch slet succes",
|
||||
"ToastBatchQuickMatchFailed": "Batch Hurtig Match fejlede!",
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
"ButtonEditChapters": "Kapitel bearbeiten",
|
||||
"ButtonEditPodcast": "Podcast bearbeiten",
|
||||
"ButtonEnable": "Aktivieren",
|
||||
"ButtonFireAndFail": "Abschicken und fehlschlagen",
|
||||
"ButtonFireAndFail": "Abfeuern und versagen",
|
||||
"ButtonFireOnTest": "Test-Event abfeuern",
|
||||
"ButtonForceReScan": "Komplett-Scan (alle Medien)",
|
||||
"ButtonFullPath": "Vollständiger Pfad",
|
||||
|
@ -53,7 +53,7 @@
|
|||
"ButtonNext": "Vor",
|
||||
"ButtonNextChapter": "Nächstes Kapitel",
|
||||
"ButtonNextItemInQueue": "Das nächste Element in der Warteschlange",
|
||||
"ButtonOk": "Ok",
|
||||
"ButtonOk": "Einverstanden",
|
||||
"ButtonOpenFeed": "Feed öffnen",
|
||||
"ButtonOpenManager": "Manager öffnen",
|
||||
"ButtonPause": "Pausieren",
|
||||
|
@ -708,7 +708,7 @@
|
|||
"MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen",
|
||||
"MessageAppriseDescription": "Um diese Funktion nutzen zu können, musst du eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würdest du <code>http://192.168.1.1:8337/notify</code> eingeben.",
|
||||
"MessageAsinCheck": "Stellen Sie sicher, dass Sie die ASIN aus der richtigen Audible Region verwenden, nicht Amazon.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muss der Server neugestartet werden um die OIDC Änderungen zu übernehmen.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muß der Server neugestartet werden um die OIDC Änderungen zu übernehmen.",
|
||||
"MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in <code>/metadata/items</code> & <code>/metadata/authors</code> gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.",
|
||||
"MessageBackupsLocationEditNote": "Hinweis: Durch das Aktualisieren des Backup-Speicherorts werden vorhandene Sicherungen nicht verschoben oder geändert",
|
||||
"MessageBackupsLocationNoEditNote": "Hinweis: Der Sicherungsspeicherort wird über eine Umgebungsvariable festgelegt und kann hier nicht geändert werden.",
|
||||
|
@ -757,7 +757,6 @@
|
|||
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird entfernt! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveEpisode": "Episode \"{0}\" wird entfernt! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveEpisodeNote": "Hinweis: Die Audiodatei wird nicht gelöscht, es sei denn \"Datei dauerhaft löschen\" ist aktiviert",
|
||||
"MessageConfirmRemoveEpisodes": "{0} Episoden werden entfernt! Bist du dir sicher?",
|
||||
"MessageConfirmRemoveListeningSessions": "Bist du dir sicher, dass du {0} Hörsitzungen enfernen möchtest?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Bist du sicher, dass du alle metadata.{0} Dateien in deinen Bibliotheksordnern löschen willst?",
|
||||
|
@ -853,13 +852,13 @@
|
|||
"MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Bist du dir sicher?",
|
||||
"MessageRestoreBackupConfirm": "Bist du dir sicher, dass du die Sicherung wiederherstellen willst, welche am",
|
||||
"MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in deinen Bibliotheksordnern verändert. Wenn du die Servereinstellungen aktiviert hast, um Cover und Metadaten in deinen Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.",
|
||||
"MessageScheduleLibraryScanNote": "Für die meisten Anwender wird empfohlen, diese Funktion deaktiviert und die Ordnerüberwachung aktiviert zu lassen. Die Ordnerüberwachung wird Änderungen in den Bibliotheksordnern automatisch erkennen. Die Ordnerüberwachung funktioniert nicht mit allen Dateisystemen (wie NFS), hier kann stattdessen die automatischen Bibliothekssuchen verwendet werden.",
|
||||
"MessageScheduleLibraryScanNote": "Für die meisten Nutzer wird empfohlen, diese Funktion deaktiviert zu lassen und stattdessen die Ordnerüberwachung aktiviert zu lassen. Die Ordnerüberwachung erkennt automatisch Änderungen in deinen Bibliotheksordnern. Da die Ordnerüberwachung jedoch nicht mit jedem Dateisystem (z.B. NFS) funktioniert, können alternativ hier geplante Bibliotheks-Scans aktiviert werden.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Immer {0} um {1} ausführen",
|
||||
"MessageSearchResultsFor": "Suchergebnisse für",
|
||||
"MessageSelected": "{0} ausgewählt",
|
||||
"MessageSeriesSequenceCannotContainSpaces": "Serie Abfolge kann keine Leerzeichen enthalten",
|
||||
"MessageServerCouldNotBeReached": "Server kann nicht erreicht werden",
|
||||
"MessageSetChaptersFromTracksDescription": "Kapitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
|
||||
"MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird",
|
||||
"MessageShareExpirationWillBe": "Läuft am <strong>{0}</strong> ab",
|
||||
"MessageShareExpiresIn": "Läuft in {0} ab",
|
||||
"MessageShareURLWillBe": "Der Freigabe Link wird <strong>{0}</strong> sein",
|
||||
|
@ -919,8 +918,6 @@
|
|||
"NotificationOnBackupCompletedDescription": "Wird ausgeführt wenn ein Backup erstellt wurde",
|
||||
"NotificationOnBackupFailedDescription": "Wird ausgeführt wenn ein Backup fehlgeschlagen ist",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Wird ausgeführt wenn eine Podcast Folge automatisch heruntergeladen wird",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Wird ausgeführt wenn automatische Downloads von Episoden wegen zu vielen fehlgeschlagenen Versuchen deaktiviert sind",
|
||||
"NotificationOnRSSFeedFailedDescription": "Wird ausgelöst, wenn die RSS-Feed-Anforderung für einen automatischen Episoden-Download fehlschlägt",
|
||||
"NotificationOnTestDescription": "Wird ausgeführt wenn das Benachrichtigungssystem getestet wird",
|
||||
"PlaceholderNewCollection": "Neuer Sammlungsname",
|
||||
"PlaceholderNewFolderPath": "Neuer Ordnerpfad",
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"ButtonAdd": "Add",
|
||||
"ButtonAddApiKey": "Add API Key",
|
||||
"ButtonAddChapters": "Add Chapters",
|
||||
"ButtonAddDevice": "Add Device",
|
||||
"ButtonAddLibrary": "Add Library",
|
||||
|
@ -21,7 +20,6 @@
|
|||
"ButtonChooseAFolder": "Choose a folder",
|
||||
"ButtonChooseFiles": "Choose files",
|
||||
"ButtonClearFilter": "Clear Filter",
|
||||
"ButtonClose": "Close",
|
||||
"ButtonCloseFeed": "Close Feed",
|
||||
"ButtonCloseSession": "Close Open Session",
|
||||
"ButtonCollections": "Collections",
|
||||
|
@ -121,7 +119,6 @@
|
|||
"HeaderAccount": "Account",
|
||||
"HeaderAddCustomMetadataProvider": "Add Custom Metadata Provider",
|
||||
"HeaderAdvanced": "Advanced",
|
||||
"HeaderApiKeys": "API Keys",
|
||||
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
|
||||
"HeaderAudioTracks": "Audio Tracks",
|
||||
"HeaderAudiobookTools": "Audiobook File Management Tools",
|
||||
|
@ -165,7 +162,6 @@
|
|||
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
|
||||
"HeaderMetadataToEmbed": "Metadata to embed",
|
||||
"HeaderNewAccount": "New Account",
|
||||
"HeaderNewApiKey": "New API Key",
|
||||
"HeaderNewLibrary": "New Library",
|
||||
"HeaderNotificationCreate": "Create Notification",
|
||||
"HeaderNotificationUpdate": "Update Notification",
|
||||
|
@ -210,7 +206,6 @@
|
|||
"HeaderTableOfContents": "Table of Contents",
|
||||
"HeaderTools": "Tools",
|
||||
"HeaderUpdateAccount": "Update Account",
|
||||
"HeaderUpdateApiKey": "Update API Key",
|
||||
"HeaderUpdateAuthor": "Update Author",
|
||||
"HeaderUpdateDetails": "Update Details",
|
||||
"HeaderUpdateLibrary": "Update Library",
|
||||
|
@ -240,10 +235,6 @@
|
|||
"LabelAllUsersExcludingGuests": "All users excluding guests",
|
||||
"LabelAllUsersIncludingGuests": "All users including guests",
|
||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||
"LabelApiKeyCreated": "API Key \"{0}\" created successfully.",
|
||||
"LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.",
|
||||
"LabelApiKeyUser": "Act on behalf of user",
|
||||
"LabelApiKeyUserDescription": "This API key will have the same permissions as the user it is acting on behalf of. This will appear the same in logs as if the user was making the request.",
|
||||
"LabelApiToken": "API Token",
|
||||
"LabelAppend": "Append",
|
||||
"LabelAudioBitrate": "Audio Bitrate (e.g. 128k)",
|
||||
|
@ -355,10 +346,6 @@
|
|||
"LabelExample": "Example",
|
||||
"LabelExpandSeries": "Expand Series",
|
||||
"LabelExpandSubSeries": "Expand Sub Series",
|
||||
"LabelExpired": "Expired",
|
||||
"LabelExpiresAt": "Expires At",
|
||||
"LabelExpiresInSeconds": "Expires in (seconds)",
|
||||
"LabelExpiresNever": "Never",
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelExplicitChecked": "Explicit (checked)",
|
||||
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
|
||||
|
@ -468,7 +455,6 @@
|
|||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNextBackupDate": "Next backup date",
|
||||
"LabelNextScheduledRun": "Next scheduled run",
|
||||
"LabelNoApiKeys": "No API keys",
|
||||
"LabelNoCustomMetadataProviders": "No custom metadata providers",
|
||||
"LabelNoEpisodesSelected": "No episodes selected",
|
||||
"LabelNotFinished": "Not Finished",
|
||||
|
@ -558,7 +544,6 @@
|
|||
"LabelSelectAll": "Select all",
|
||||
"LabelSelectAllEpisodes": "Select all episodes",
|
||||
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||
"LabelSelectUser": "Select user",
|
||||
"LabelSelectUsers": "Select users",
|
||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||
"LabelSequence": "Sequence",
|
||||
|
@ -724,7 +709,6 @@
|
|||
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.",
|
||||
"MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users are required to re-login.",
|
||||
"MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.",
|
||||
"MessageBackupsLocationEditNote": "Note: Updating the backup location will not move or modify existing backups",
|
||||
"MessageBackupsLocationNoEditNote": "Note: The backup location is set through an environment variable and cannot be changed here.",
|
||||
|
@ -746,7 +730,6 @@
|
|||
"MessageChaptersNotFound": "Chapters not found",
|
||||
"MessageCheckingCron": "Checking cron...",
|
||||
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?",
|
||||
"MessageConfirmDeleteApiKey": "Are you sure you want to delete API key \"{0}\"?",
|
||||
"MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
|
||||
"MessageConfirmDeleteDevice": "Are you sure you want to delete e-reader device \"{0}\"?",
|
||||
"MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
|
||||
|
@ -774,7 +757,6 @@
|
|||
"MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodeNote": "Note: This does not delete the audio file unless toggling \"Hard delete file\"",
|
||||
"MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?",
|
||||
"MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Are you sure you want to remove all metadata.{0} files in your library item folders?",
|
||||
|
@ -936,8 +918,6 @@
|
|||
"NotificationOnBackupCompletedDescription": "Triggered when a backup is completed",
|
||||
"NotificationOnBackupFailedDescription": "Triggered when a backup fails",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Triggered when a podcast episode is auto-downloaded",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Triggered when automatic episode downloads are disabled due to too many failed attempts",
|
||||
"NotificationOnRSSFeedFailedDescription": "Triggered when the RSS feed request fails for an automatic episode download",
|
||||
"NotificationOnTestDescription": "Event for testing the notification system",
|
||||
"PlaceholderNewCollection": "New collection name",
|
||||
"PlaceholderNewFolderPath": "New folder path",
|
||||
|
@ -1018,8 +998,6 @@
|
|||
"ToastEpisodeDownloadQueueClearSuccess": "Episode download queue cleared",
|
||||
"ToastEpisodeUpdateSuccess": "{0} episodes updated",
|
||||
"ToastErrorCannotShare": "Cannot share natively on this device",
|
||||
"ToastFailedToCreate": "Failed to create",
|
||||
"ToastFailedToDelete": "Failed to delete",
|
||||
"ToastFailedToLoadData": "Failed to load data",
|
||||
"ToastFailedToMatch": "Failed to match",
|
||||
"ToastFailedToShare": "Failed to share",
|
||||
|
@ -1051,7 +1029,6 @@
|
|||
"ToastMustHaveAtLeastOnePath": "Must have at least one path",
|
||||
"ToastNameEmailRequired": "Name and email are required",
|
||||
"ToastNameRequired": "Name is required",
|
||||
"ToastNewApiKeyUserError": "Must select a user",
|
||||
"ToastNewEpisodesFound": "{0} new episodes found",
|
||||
"ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"",
|
||||
"ToastNewUserCreatedSuccess": "New account created",
|
||||
|
|
|
@ -9,9 +9,6 @@
|
|||
"ButtonApply": "લાગુ કરો",
|
||||
"ButtonApplyChapters": "પ્રકરણો લાગુ કરો",
|
||||
"ButtonAuthors": "લેખકો",
|
||||
"ButtonBack": "પાછા",
|
||||
"ButtonBatchEditPopulateFromExisting": "હાલની માહિતીમાંથી ભરો",
|
||||
"ButtonBatchEditPopulateMapDetails": "નકશાની વિગત ભરો",
|
||||
"ButtonBrowseForFolder": "ફોલ્ડર માટે જુઓ",
|
||||
"ButtonCancel": "રદ કરો",
|
||||
"ButtonCancelEncode": "એન્કોડ રદ કરો",
|
||||
|
@ -30,14 +27,11 @@
|
|||
"ButtonEdit": "સંપાદિત કરો",
|
||||
"ButtonEditChapters": "પ્રકરણો સંપાદિત કરો",
|
||||
"ButtonEditPodcast": "પોડકાસ્ટ સંપાદિત કરો",
|
||||
"ButtonEnable": "સક્રિય કરો",
|
||||
"ButtonForceReScan": "બળપૂર્વક ફરીથી સ્કેન કરો",
|
||||
"ButtonFullPath": "સંપૂર્ણ પથ",
|
||||
"ButtonHide": "છુપાવો",
|
||||
"ButtonHome": "ઘર",
|
||||
"ButtonIssues": "સમસ્યાઓ",
|
||||
"ButtonJumpBackward": "પાછળ જાવો",
|
||||
"ButtonJumpForward": "આગળ જાવો",
|
||||
"ButtonLatest": "નવીનતમ",
|
||||
"ButtonLibrary": "પુસ્તકાલય",
|
||||
"ButtonLogout": "લૉગ આઉટ",
|
||||
|
@ -47,32 +41,19 @@
|
|||
"ButtonMatchAllAuthors": "બધા મેળ ખાતા લેખકો શોધો",
|
||||
"ButtonMatchBooks": "મેળ ખાતી પુસ્તકો શોધો",
|
||||
"ButtonNevermind": "કંઈ વાંધો નહીં",
|
||||
"ButtonNext": "આગળ જાઓ",
|
||||
"ButtonNextChapter": "આગળનું અધ્યાય",
|
||||
"ButtonNextItemInQueue": "કતારમાં આવતું આગળનું અધ્યાય",
|
||||
"ButtonOk": "ઓકે",
|
||||
"ButtonOpenFeed": "ફીડ ખોલો",
|
||||
"ButtonOpenManager": "મેનેજર ખોલો",
|
||||
"ButtonPause": "વિરામ",
|
||||
"ButtonPlay": "ચલાવો",
|
||||
"ButtonPlayAll": "બધું ચલાવો",
|
||||
"ButtonPlaying": "ચલાવી રહ્યું છે",
|
||||
"ButtonPlaylists": "પ્લેલિસ્ટ",
|
||||
"ButtonPrevious": "પાછળનું",
|
||||
"ButtonPreviousChapter": "પાછળનું અધ્યાય",
|
||||
"ButtonProbeAudioFile": "ઑડિયો ફાઇલ તપાસો",
|
||||
"ButtonPurgeAllCache": "બધો Cache કાઢી નાખો",
|
||||
"ButtonPurgeItemsCache": "વસ્તુઓનો Cache કાઢી નાખો",
|
||||
"ButtonQueueAddItem": "કતારમાં ઉમેરો",
|
||||
"ButtonQueueRemoveItem": "કતારથી કાઢી નાખો",
|
||||
"ButtonQuickEmbed": "ઝડપથી સમાવેશ કરો",
|
||||
"ButtonQuickEmbedMetadata": "ઝડપથી મેટાડેટા સમાવવો",
|
||||
"ButtonQuickMatch": "ઝડપી મેળ ખવડાવો",
|
||||
"ButtonReScan": "ફરીથી સ્કેન કરો",
|
||||
"ButtonRead": "વાંચો",
|
||||
"ButtonReadLess": "ઓછું વાંચો",
|
||||
"ButtonReadMore": "વધારે વાંચો",
|
||||
"ButtonRefresh": "તાજું કરો",
|
||||
"ButtonRemove": "કાઢી નાખો",
|
||||
"ButtonRemoveAll": "બધું કાઢી નાખો",
|
||||
"ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો",
|
||||
|
@ -87,21 +68,16 @@
|
|||
"ButtonSaveTracklist": "ટ્રેક યાદી સાચવો",
|
||||
"ButtonScan": "સ્કેન કરો",
|
||||
"ButtonScanLibrary": "પુસ્તકાલય સ્કેન કરો",
|
||||
"ButtonScrollLeft": "ડાબે",
|
||||
"ButtonScrollRight": "જમણે",
|
||||
"ButtonSearch": "શોધો",
|
||||
"ButtonSelectFolderPath": "ફોલ્ડર પથ પસંદ કરો",
|
||||
"ButtonSeries": "સિરીઝ",
|
||||
"ButtonSetChaptersFromTracks": "ટ્રેક્સથી પ્રકરણો સેટ કરો",
|
||||
"ButtonShare": "શેર કરો",
|
||||
"ButtonShiftTimes": "સમય શિફ્ટ કરો",
|
||||
"ButtonShow": "બતાવો",
|
||||
"ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો",
|
||||
"ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો",
|
||||
"ButtonStats": "આંકડા",
|
||||
"ButtonSubmit": "સબમિટ કરો",
|
||||
"ButtonTest": "પરખ કરો",
|
||||
"ButtonUnlinkOpenId": "OpenID દૂર કરો",
|
||||
"ButtonUpload": "અપલોડ કરો",
|
||||
"ButtonUploadBackup": "બેકઅપ અપલોડ કરો",
|
||||
"ButtonUploadCover": "કવર અપલોડ કરો",
|
||||
|
@ -110,16 +86,11 @@
|
|||
"ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો",
|
||||
"ButtonViewAll": "બધું જુઓ",
|
||||
"ButtonYes": "હા",
|
||||
"ErrorUploadFetchMetadataAPI": "મેટાડેટા મેળવવામાં તકલીફ આવી",
|
||||
"ErrorUploadFetchMetadataNoResults": "મેટાડેટા મેળવી શક્યા નહીં – કૃપા કરીને શીર્ષક અને/અથવા લેખકનું નામ અપડેટ કરવાનો પ્રયત્ન કરો",
|
||||
"ErrorUploadLacksTitle": "શીર્ષક હોવું આવશ્યક છે",
|
||||
"HeaderAccount": "એકાઉન્ટ",
|
||||
"HeaderAddCustomMetadataProvider": "કસ્ટમ મેટાડેટા પ્રોવાઇડર ઉમેરો",
|
||||
"HeaderAdvanced": "અડ્વાન્સડ",
|
||||
"HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ",
|
||||
"HeaderAudioTracks": "ઓડિયો ટ્રેક્સ",
|
||||
"HeaderAudiobookTools": "ઓડિયોબુક ફાઇલ વ્યવસ્થાપન ટૂલ્સ",
|
||||
"HeaderAuthentication": "પ્રમાણીકરણ",
|
||||
"HeaderBackups": "બેકઅપ્સ",
|
||||
"HeaderChangePassword": "પાસવર્ડ બદલો",
|
||||
"HeaderChapters": "પ્રકરણો",
|
||||
|
@ -128,7 +99,6 @@
|
|||
"HeaderCollectionItems": "સંગ્રહ વસ્તુઓ",
|
||||
"HeaderCover": "આવરણ",
|
||||
"HeaderCurrentDownloads": "વર્તમાન ડાઉનલોડ્સ",
|
||||
"HeaderCustomMetadataProviders": "કસ્ટમ મેટાડેટા પ્રોવાઇડર્સ",
|
||||
"HeaderDetails": "વિગતો",
|
||||
"HeaderDownloadQueue": "ડાઉનલોડ કતાર",
|
||||
"HeaderEbookFiles": "ઇબુક ફાઇલો",
|
||||
|
@ -159,7 +129,6 @@
|
|||
"HeaderMetadataToEmbed": "એમ્બેડ કરવા માટે મેટાડેટા",
|
||||
"HeaderNewAccount": "નવું એકાઉન્ટ",
|
||||
"HeaderNewLibrary": "નવી પુસ્તકાલય",
|
||||
"HeaderNotificationCreate": "સૂચના બનાવો",
|
||||
"HeaderNotifications": "સૂચનાઓ",
|
||||
"HeaderOpenRSSFeed": "RSS ફીડ ખોલો",
|
||||
"HeaderOtherFiles": "અન્ય ફાઇલો",
|
||||
|
|
|
@ -918,8 +918,6 @@
|
|||
"NotificationOnBackupCompletedDescription": "Pokreće se po završetku sigurnosnog kopiranja",
|
||||
"NotificationOnBackupFailedDescription": "Pokreće se kada sigurnosno kopiranje ne uspije",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Pokreće se kada se nastavak podcasta automatski preuzme",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Pokreće se kada su automatska preuzimanja nastavaka onemogućena zbog previše neuspjelih pokušaja",
|
||||
"NotificationOnRSSFeedFailedDescription": "Pokreće se u slučaju pogreške pri pokušaju automatskog preuzimanja nastavka s RSS izvora",
|
||||
"NotificationOnTestDescription": "Događaj za testiranje sustava obavijesti",
|
||||
"PlaceholderNewCollection": "Ime nove zbirke",
|
||||
"PlaceholderNewFolderPath": "Nova putanja mape",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"ButtonAuthors": "Szerzők",
|
||||
"ButtonBack": "Vissza",
|
||||
"ButtonBatchEditPopulateFromExisting": "Létezőből feltöltés",
|
||||
"ButtonBatchEditPopulateMapDetails": "A térkép részleteinek feltöltése",
|
||||
"ButtonBatchEditPopulateMapDetails": "",
|
||||
"ButtonBrowseForFolder": "Mappa keresése",
|
||||
"ButtonCancel": "Mégse",
|
||||
"ButtonCancelEncode": "Kódolás megszakítása",
|
||||
|
@ -177,7 +177,6 @@
|
|||
"HeaderPlaylist": "Lejátszási lista",
|
||||
"HeaderPlaylistItems": "Lejátszási lista elemek",
|
||||
"HeaderPodcastsToAdd": "Hozzáadandó podcastok",
|
||||
"HeaderPresets": "Alapbeállítások",
|
||||
"HeaderPreviewCover": "Borító előnézete",
|
||||
"HeaderRSSFeedGeneral": "RSS részletek",
|
||||
"HeaderRSSFeedIsOpen": "RSS hírcsatorna nyitva van",
|
||||
|
@ -220,7 +219,6 @@
|
|||
"LabelAccountTypeAdmin": "Adminisztrátor",
|
||||
"LabelAccountTypeGuest": "Vendég",
|
||||
"LabelAccountTypeUser": "Felhasználó",
|
||||
"LabelActivities": "Tevékenységek",
|
||||
"LabelActivity": "Tevékenység",
|
||||
"LabelAddToCollection": "Hozzáadás a gyűjteményhez",
|
||||
"LabelAddToCollectionBatch": "{0} könyv hozzáadása a gyűjteményhez",
|
||||
|
@ -230,7 +228,6 @@
|
|||
"LabelAddedDate": "{0} Hozzáadva",
|
||||
"LabelAdminUsersOnly": "Csak admin felhasználók",
|
||||
"LabelAll": "Összes",
|
||||
"LabelAllEpisodesDownloaded": "Minden epizód letöltve",
|
||||
"LabelAllUsers": "Minden felhasználó",
|
||||
"LabelAllUsersExcludingGuests": "Minden felhasználó, vendégek kivételével",
|
||||
"LabelAllUsersIncludingGuests": "Minden felhasználó, beleértve a vendégeket is",
|
||||
|
@ -254,7 +251,7 @@
|
|||
"LabelBackToUser": "Vissza a felhasználóhoz",
|
||||
"LabelBackupAudioFiles": "Audiófájlok biztonsági mentése",
|
||||
"LabelBackupLocation": "Biztonsági másolat helye",
|
||||
"LabelBackupsEnableAutomaticBackups": "Automatikus biztonsági másolatok",
|
||||
"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) (0-tól végtelenig)",
|
||||
"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.",
|
||||
|
@ -278,7 +275,7 @@
|
|||
"LabelCollapseSeries": "Sorozat összecsukása",
|
||||
"LabelCollapseSubSeries": "Alszéria összecsukása",
|
||||
"LabelCollection": "Gyűjtemény",
|
||||
"LabelCollections": "Gyűjtemények",
|
||||
"LabelCollections": "Gyűjtemény",
|
||||
"LabelComplete": "Kész",
|
||||
"LabelConfirmPassword": "Jelszó megerősítése",
|
||||
"LabelContinueListening": "Hallgatás folytatása",
|
||||
|
@ -286,7 +283,6 @@
|
|||
"LabelContinueSeries": "Sorozat folytatása",
|
||||
"LabelCover": "Borító",
|
||||
"LabelCoverImageURL": "Borítókép URL",
|
||||
"LabelCoverProvider": "Borító Szolgáltató",
|
||||
"LabelCreatedAt": "Létrehozás ideje",
|
||||
"LabelCronExpression": "Cron kifejezés",
|
||||
"LabelCurrent": "Jelenlegi",
|
||||
|
@ -395,8 +391,7 @@
|
|||
"LabelIntervalEvery6Hours": "Minden 6 órában",
|
||||
"LabelIntervalEveryDay": "Minden nap",
|
||||
"LabelIntervalEveryHour": "Minden órában",
|
||||
"LabelIntervalEveryMinute": "Minden percben",
|
||||
"LabelInvert": "Inverz",
|
||||
"LabelInvert": "Megfordítás",
|
||||
"LabelItem": "Elem",
|
||||
"LabelJumpBackwardAmount": "Visszafelé ugrás mennyisége",
|
||||
"LabelJumpForwardAmount": "Előre ugrás mennyisége",
|
||||
|
@ -491,7 +486,6 @@
|
|||
"LabelPersonalYearReview": "Az éved összefoglalása ({0})",
|
||||
"LabelPhotoPathURL": "Fénykép útvonal/URL",
|
||||
"LabelPlayMethod": "Lejátszási módszer",
|
||||
"LabelPlaybackRateIncrementDecrement": "Lejátszási sebesség növelés/csökkentés értéke",
|
||||
"LabelPlayerChapterNumberMarker": "{0} a {1} -ből",
|
||||
"LabelPlaylists": "Lejátszási listák",
|
||||
"LabelPodcast": "Podcast",
|
||||
|
@ -514,7 +508,7 @@
|
|||
"LabelPublishers": "Kiadók",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Egyéni tulajdonos e-mail",
|
||||
"LabelRSSFeedCustomOwnerName": "Egyéni tulajdonos neve",
|
||||
"LabelRSSFeedOpen": "RSS-hírcsatorna nyitva",
|
||||
"LabelRSSFeedOpen": "RSS hírcsatorna nyitva",
|
||||
"LabelRSSFeedPreventIndexing": "Indexelés megakadályozása",
|
||||
"LabelRSSFeedSlug": "RSS hírcsatorna slug",
|
||||
"LabelRSSFeedURL": "RSS hírcsatorna URL",
|
||||
|
@ -531,7 +525,6 @@
|
|||
"LabelReleaseDate": "Megjelenés dátuma",
|
||||
"LabelRemoveAllMetadataAbs": "Az összes metadata.abs fájl eltávolítása",
|
||||
"LabelRemoveAllMetadataJson": "Az összes metadata.json fájl eltávolítása",
|
||||
"LabelRemoveAudibleBranding": "Audible intro és outro eltávolítása a fejezetekből",
|
||||
"LabelRemoveCover": "Borító eltávolítása",
|
||||
"LabelRemoveMetadataFile": "Metaadatfájlok eltávolítása a könyvtár elemek mappáiból",
|
||||
"LabelRemoveMetadataFileHelp": "A metadata.json és metadata.abs fájlokat eltávolítása a {0} mappáidból.",
|
||||
|
@ -561,8 +554,6 @@
|
|||
"LabelSettingsBookshelfViewHelp": "Skeuomorfikus dizájn fa polcokkal",
|
||||
"LabelSettingsChromecastSupport": "Chromecast támogatás",
|
||||
"LabelSettingsDateFormat": "Dátumformátum",
|
||||
"LabelSettingsEnableWatcher": "Változások automatikus vizsgálata a könyvtárakban",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Változások automatikus vizsgálata 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",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Szkriptelt tartalmak engedélyezése epub-okban",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Megengedi, hogy az epub fájlok szkripteket hajtsanak végre. Ezt a beállítást kikapcsolva ajánlott tartani, kivéve, ha megbízik az epub fájlok forrásában.",
|
||||
|
@ -606,7 +597,6 @@
|
|||
"LabelSlug": "Rövid cím",
|
||||
"LabelSortAscending": "Emelkedő",
|
||||
"LabelSortDescending": "Csökkenő",
|
||||
"LabelSortPubDate": "Rendezés megjelenés dátuma szerint",
|
||||
"LabelStart": "Kezdés",
|
||||
"LabelStartTime": "Kezdési idő",
|
||||
"LabelStarted": "Elkezdődött",
|
||||
|
@ -707,17 +697,12 @@
|
|||
"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.",
|
||||
"MessageAsinCheck": "Győződjön meg róla, hogy az ASIN-t a megfelelő Audible régióból használja, nem az Amazonból.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "A mentés után indítsa újra a szervert az OIDC módosítások alkalmazásához.",
|
||||
"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.",
|
||||
"MessageBackupsLocationEditNote": "Megjegyzés: A biztonsági mentés helyének frissítése nem mozgatja vagy módosítja a meglévő biztonsági mentéseket",
|
||||
"MessageBackupsLocationNoEditNote": "Megjegyzés: A biztonsági mentés helye egy környezeti változóval van beállítva, és itt nem módosítható.",
|
||||
"MessageBackupsLocationPathEmpty": "A biztonsági mentés helyének elérési útvonala nem lehet üres",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Az engedélyezett mezők feltöltése az összes elem adatával. A több értéket tartalmazó mezők összevonásra kerülnek",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "A térkép engedélyezett adatmezőinek feltöltése ezen elem adataival",
|
||||
"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",
|
||||
"MessageBookshelfNoCollectionsHelp": "A gyűjtemények nyilvánosak. Minden, a könyvtárhoz hozzáféréssel rendelkező felhasználó láthatja őket.",
|
||||
"MessageBookshelfNoRSSFeeds": "Nincsenek nyitott RSS hírcsatornák",
|
||||
"MessageBookshelfNoResultsForFilter": "Nincs eredmény a \"{0}: {1}\" szűrőre",
|
||||
"MessageBookshelfNoResultsForQuery": "Nincs eredmény a lekérdezéshez",
|
||||
|
@ -727,7 +712,6 @@
|
|||
"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",
|
||||
"MessageChaptersNotFound": "Fejezetek nem találhatók",
|
||||
"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?",
|
||||
|
@ -757,7 +741,6 @@
|
|||
"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?",
|
||||
"MessageConfirmRemoveEpisodeNote": "Megjegyzés: Ez nem törli a hangfájlt, kivéve, ha a \"Hangfájl végleges törlése\" be van kapcsolva",
|
||||
"MessageConfirmRemoveEpisodes": "Biztosan eltávolítja a(z) {0} epizódot?",
|
||||
"MessageConfirmRemoveListeningSessions": "Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Biztos, hogy az összes metaadatot el akarja távolítani {0} fájl van könyvtár mappáiban?",
|
||||
|
@ -785,7 +768,6 @@
|
|||
"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",
|
||||
"MessageInvalidAsin": "Érvénytelen ASIN",
|
||||
"MessageItemsSelected": "{0} kiválasztott elem",
|
||||
"MessageItemsUpdated": "{0} frissített elem",
|
||||
"MessageJoinUsOn": "Csatlakozzon hozzánk a",
|
||||
|
@ -831,7 +813,6 @@
|
|||
"MessageNoTasksRunning": "Nincsenek futó feladatok",
|
||||
"MessageNoUpdatesWereNecessary": "Nem volt szükség frissítésekre",
|
||||
"MessageNoUserPlaylists": "Nincsenek felhasználói lejátszási listák",
|
||||
"MessageNoUserPlaylistsHelp": "A lejátszási listák személyesek. Csak az a felhasználó láthatja őket, aki létrehozta őket.",
|
||||
"MessageNotYetImplemented": "Még nem implementált",
|
||||
"MessageOpmlPreviewNote": "Megjegyzés: Ez egy előnézeti kép az elemzett OPML fájlról. A podcast tényleges címe az RSS hírcsatornából származik.",
|
||||
"MessageOr": "vagy",
|
||||
|
@ -854,10 +835,8 @@
|
|||
"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.",
|
||||
"MessageScheduleLibraryScanNote": "A legtöbb felhasználó számára ajánlott ezt a funkciót kikapcsolva hagyni, és engedélyezni a mappafigyelő beállítást. A mappafigyelő automatikusan észleli a könyvtári mappák változásait. A mappafigyelő nem működik minden fájlrendszernél (mint például az NFS), ezért helyette ütemezett könyvtárellenőrzéseket lehet használni.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Futás minden {1} óra {0}-kor",
|
||||
"MessageSearchResultsFor": "Keresési eredmények",
|
||||
"MessageSelected": "{0} kiválasztva",
|
||||
"MessageSeriesSequenceCannotContainSpaces": "Sorozat sorrend nem tartalmazhat szóközt",
|
||||
"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",
|
||||
"MessageShareExpirationWillBe": "A lejárat: <strong>{0}</strong>",
|
||||
|
@ -882,7 +861,6 @@
|
|||
"MessageTaskNoFilesToScan": "Nincs beolvasandó fájl",
|
||||
"MessageTaskOpmlImport": "OPML import",
|
||||
"MessageTaskOpmlImportDescription": "Podcastok létrehozása {0} RSS hírcsatornából",
|
||||
"MessageTaskOpmlImportFeed": "OPML import hírcsatorna",
|
||||
"MessageTaskOpmlImportFeedDescription": "RSS feed „{0}” importálása",
|
||||
"MessageTaskOpmlImportFeedFailed": "Nem sikerült letölteni a podcast feedet",
|
||||
"MessageTaskOpmlImportFeedPodcastDescription": "„{0}” podcast létrehozása",
|
||||
|
@ -891,7 +869,6 @@
|
|||
"MessageTaskOpmlImportFinished": "{0} podcast hozzáadva",
|
||||
"MessageTaskOpmlParseFailed": "Az OPML fájl elemzése nem sikerült",
|
||||
"MessageTaskOpmlParseFastFail": "Érvénytelen OPML fájl: <opml> tag nem található VAGY nem találtak <outline> taget",
|
||||
"MessageTaskOpmlParseNoneFound": "Nem található feed az OPML fájlban",
|
||||
"MessageTaskScanItemsAdded": "{0} hozzáadva",
|
||||
"MessageTaskScanItemsMissing": "{0} hiányzik",
|
||||
"MessageTaskScanItemsUpdated": "{0} frissítve",
|
||||
|
@ -919,8 +896,6 @@
|
|||
"NotificationOnBackupCompletedDescription": "A biztonsági mentés befejezésekor aktiválódik",
|
||||
"NotificationOnBackupFailedDescription": "A biztonsági mentés sikertelensége esetén aktiválódik",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Egy podcast epizód automatikus letöltésekor aktiválódik",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Akkor lép működésbe, ha az automatikus epizódletöltés a túl sok sikertelen próbálkozás miatt letiltásra kerül",
|
||||
"NotificationOnRSSFeedFailedDescription": "Akkor aktiválódik, ha az RSS feed kérés sikertelen az automatikus epizódletöltésnél",
|
||||
"NotificationOnTestDescription": "Esemény az értesítési rendszer teszteléséhez",
|
||||
"PlaceholderNewCollection": "Új gyűjtemény neve",
|
||||
"PlaceholderNewFolderPath": "Új mappa útvonala",
|
||||
|
@ -965,11 +940,8 @@
|
|||
"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",
|
||||
"ToastBatchApplyDetailsToItemsSuccess": "Tételekre alkalmazott részletek",
|
||||
"ToastBatchDeleteFailed": "A tömeges törlés nem sikerült",
|
||||
"ToastBatchDeleteSuccess": "Sikeres tömeges törlés",
|
||||
"ToastBatchQuickMatchFailed": "Tömeges Gyors Egyeztetés sikertelen!",
|
||||
"ToastBatchQuickMatchStarted": "{0} könyv Tömeges Gyors Egyeztetése elkezdődött!",
|
||||
"ToastBatchUpdateFailed": "Kötegelt frissítés sikertelen",
|
||||
"ToastBatchUpdateSuccess": "Kötegelt frissítés sikeres",
|
||||
"ToastBookmarkCreateFailed": "Könyvjelző létrehozása sikertelen",
|
||||
|
@ -978,12 +950,9 @@
|
|||
"ToastCachePurgeFailed": "A gyorsítótár törlése sikertelen",
|
||||
"ToastCachePurgeSuccess": "A gyorsítótár sikeresen törölve",
|
||||
"ToastChaptersHaveErrors": "A fejezetek hibákat tartalmaznak",
|
||||
"ToastChaptersInvalidShiftAmountLast": "Érvénytelen eltolási érték. Az utolsó fejezet kezdési időpontja túlnyúlna a hangoskönyv időtartamán.",
|
||||
"ToastChaptersInvalidShiftAmountStart": "Érvénytelen eltolási érték. Az első fejezet hossza nulla vagy negatív lenne, és a második fejezet felülírná. Növelje a második fejezet kezdő időtartamát.",
|
||||
"ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük",
|
||||
"ToastChaptersRemoved": "Fejezetek eltávolítva",
|
||||
"ToastChaptersUpdated": "Fejezetek frissítve",
|
||||
"ToastCollectionItemsAddFailed": "A tétel(ek) hozzáadása gyűjteményhez sikertelen",
|
||||
"ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva",
|
||||
"ToastCollectionUpdateSuccess": "Gyűjtemény frissítve",
|
||||
"ToastCoverUpdateFailed": "A borító frissítése nem sikerült",
|
||||
|
@ -998,7 +967,6 @@
|
|||
"ToastEncodeCancelFailed": "A kódolás törlése sikertelen volt",
|
||||
"ToastEncodeCancelSucces": "Kódolás törölve",
|
||||
"ToastEpisodeDownloadQueueClearFailed": "Nem sikerült törölni a várólistát",
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "Epizód letöltési várólista törölve",
|
||||
"ToastEpisodeUpdateSuccess": "{0} epizód frissítve",
|
||||
"ToastErrorCannotShare": "Ezen az eszközön nem lehet natívan megosztani",
|
||||
"ToastFailedToLoadData": "Sikertelen adatbetöltés",
|
||||
|
@ -1006,7 +974,6 @@
|
|||
"ToastFailedToShare": "Nem sikerült megosztani",
|
||||
"ToastFailedToUpdate": "Nem sikerült frissíteni",
|
||||
"ToastInvalidImageUrl": "Érvénytelen a kép URL címe",
|
||||
"ToastInvalidMaxEpisodesToDownload": "A letölthető epizódok száma érvénytelen",
|
||||
"ToastInvalidUrl": "Érvénytelen URL",
|
||||
"ToastItemCoverUpdateSuccess": "Elem borítója frissítve",
|
||||
"ToastItemDeletedFailed": "Nem sikerült törölni az elemet",
|
||||
|
@ -1044,11 +1011,8 @@
|
|||
"ToastNoUpdatesNecessary": "Nincs szükség frissítésre",
|
||||
"ToastNotificationCreateFailed": "Értesítés létrehozása sikertelen",
|
||||
"ToastNotificationDeleteFailed": "Értesítés törlése sikertelen",
|
||||
"ToastNotificationFailedMaximum": "A sikertelen kísérletek maximális száma >= 0 kell, hogy legyen",
|
||||
"ToastNotificationQueueMaximum": "Az értesítési sor maximális száma >= 0 kell, hogy legyen",
|
||||
"ToastNotificationSettingsUpdateSuccess": "Értesítési beállítások frissítve",
|
||||
"ToastNotificationTestTriggerFailed": "Nem sikerült a tesztértesítést elindítani",
|
||||
"ToastNotificationTestTriggerSuccess": "Kiváltott tesztértesítés",
|
||||
"ToastNotificationUpdateSuccess": "Értesítés frissítve",
|
||||
"ToastPlaylistCreateFailed": "Lejátszási lista létrehozása sikertelen",
|
||||
"ToastPlaylistCreateSuccess": "Lejátszási lista létrehozva",
|
||||
|
@ -1056,7 +1020,6 @@
|
|||
"ToastPlaylistUpdateSuccess": "Lejátszási lista frissítve",
|
||||
"ToastPodcastCreateFailed": "Podcast létrehozása sikertelen",
|
||||
"ToastPodcastCreateSuccess": "A podcast sikeresen létrehozva",
|
||||
"ToastPodcastGetFeedFailed": "Nem sikerült podcast feedet kapni",
|
||||
"ToastPodcastNoEpisodesInFeed": "Nincsenek epizódok az RSS hírcsatornában",
|
||||
"ToastPodcastNoRssFeed": "A podcastnak nincs RSS-hírcsatornája",
|
||||
"ToastProgressIsNotBeingSynced": "Az előrehaladás nem szinkronizálódik, a lejátszás újraindul",
|
||||
|
@ -1069,18 +1032,10 @@
|
|||
"ToastRemoveFailed": "Sikertelen eltávolítás",
|
||||
"ToastRemoveItemFromCollectionFailed": "Tétel eltávolítása a gyűjteményből sikertelen",
|
||||
"ToastRemoveItemFromCollectionSuccess": "Tétel eltávolítva a gyűjteményből",
|
||||
"ToastRemoveItemsWithIssuesFailed": "Nem sikerült eltávolítani a hibás könyvtárelemeket",
|
||||
"ToastRemoveItemsWithIssuesSuccess": "Hibás könyvtárelemek eltávolítva",
|
||||
"ToastRenameFailed": "Sikertelen átnevezés",
|
||||
"ToastRescanFailed": "Sikertelen újrakeresés a következőnél: {0}",
|
||||
"ToastRescanRemoved": "A teljes újrabeolvasás befejezve, elem eltávolítva",
|
||||
"ToastRescanUpToDate": "A teljes újrabeolvasás befejezve, elem naprakész volt",
|
||||
"ToastRescanUpdated": "A teljes újrabeolvasás befejezve, elem frissítve",
|
||||
"ToastScanFailed": "Nem sikerült beolvasni a könyvtárelemet",
|
||||
"ToastSelectAtLeastOneUser": "Válasszon legalább egy felhasználót",
|
||||
"ToastSendEbookToDeviceFailed": "E-könyv küldése az eszközre sikertelen",
|
||||
"ToastSendEbookToDeviceSuccess": "E-könyv elküldve az eszközre \"{0}\"",
|
||||
"ToastSeriesSubmitFailedSameName": "Nem lehet két azonos nevű sorozatot hozzáadni",
|
||||
"ToastSeriesUpdateFailed": "Sorozat frissítése sikertelen",
|
||||
"ToastSeriesUpdateSuccess": "Sorozat frissítése sikeres",
|
||||
"ToastServerSettingsUpdateSuccess": "Szerver beállítások frissítve",
|
||||
|
@ -1088,8 +1043,6 @@
|
|||
"ToastSessionDeleteFailed": "Munkamenet törlése sikertelen",
|
||||
"ToastSessionDeleteSuccess": "Munkamenet törölve",
|
||||
"ToastSleepTimerDone": "Alvásidőzítő kész... zZzzZZz",
|
||||
"ToastSlugMustChange": "A Slug érvénytelen karaktereket tartalmaz",
|
||||
"ToastSlugRequired": "Slug szükséges",
|
||||
"ToastSocketConnected": "Socket csatlakoztatva",
|
||||
"ToastSocketDisconnected": "Socket lecsatlakoztatva",
|
||||
"ToastSocketFailedToConnect": "A Socket csatlakoztatása sikertelen",
|
||||
|
@ -1097,14 +1050,9 @@
|
|||
"ToastSortingPrefixesUpdateSuccess": "Rendezési előtagok frissítése ({0} elem)",
|
||||
"ToastTitleRequired": "A cím kötelező",
|
||||
"ToastUnknownError": "Ismeretlen hiba",
|
||||
"ToastUnlinkOpenIdFailed": "Nem sikerült leválasztani a felhasználót az OpenID-ről",
|
||||
"ToastUnlinkOpenIdSuccess": "Felhasználó leválasztva az OpenID-ről",
|
||||
"ToastUploaderFilepathExistsError": "A \"{0}\" fájl elérési útja már létezik a szerveren",
|
||||
"ToastUploaderItemExistsInSubdirectoryError": "A „{0}” elem a feltöltési útvonal egy alkönyvtárát használja.",
|
||||
"ToastUserDeleteFailed": "Felhasználó törlése sikertelen",
|
||||
"ToastUserDeleteSuccess": "Felhasználó törölve",
|
||||
"ToastUserPasswordChangeSuccess": "Jelszó sikeresen megváltoztatva",
|
||||
"ToastUserPasswordMismatch": "A jelszavak nem egyeznek",
|
||||
"ToastUserPasswordMustChange": "Az új jelszó nem egyezik a régi jelszóval",
|
||||
"ToastUserRootRequireName": "Egy root felhasználónevet kell megadnia"
|
||||
}
|
||||
|
|
|
@ -514,7 +514,7 @@
|
|||
"LabelPublishers": "Editori",
|
||||
"LabelRSSFeedCustomOwnerEmail": "E-mail del proprietario personalizzato",
|
||||
"LabelRSSFeedCustomOwnerName": "Nome del proprietario personalizzato",
|
||||
"LabelRSSFeedOpen": "Feed RSS aperto",
|
||||
"LabelRSSFeedOpen": "Flusso RSS aperto",
|
||||
"LabelRSSFeedPreventIndexing": "Impedisci l'indicizzazione",
|
||||
"LabelRSSFeedSlug": "Parole chiave del flusso RSS",
|
||||
"LabelRSSFeedURL": "URL del flusso RSS",
|
||||
|
@ -918,8 +918,6 @@
|
|||
"NotificationOnBackupCompletedDescription": "Attivato al completamento di un backup",
|
||||
"NotificationOnBackupFailedDescription": "Attivato quando un backup fallisce",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Attivato quando un episodio di podcast viene scaricato automaticamente",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Attivato quando i download automatici degli episodi vengono disabilitati a causa di troppi tentativi falliti",
|
||||
"NotificationOnRSSFeedFailedDescription": "Attivato quando la richiesta del feed RSS per il download automatico di un episodio fallisce",
|
||||
"NotificationOnTestDescription": "test il sistema di notifica",
|
||||
"PlaceholderNewCollection": "Nome Nuova Raccolta",
|
||||
"PlaceholderNewFolderPath": "Nuovo Percorso Cartella",
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
"ButtonCloseSession": "Sluit Sessie",
|
||||
"ButtonCollections": "Collecties",
|
||||
"ButtonConfigureScanner": "Configureer scanner",
|
||||
"ButtonCreate": "Aanmaken",
|
||||
"ButtonCreate": "Creëer",
|
||||
"ButtonCreateBackup": "Maak back-up",
|
||||
"ButtonDelete": "Verwijder",
|
||||
"ButtonDownloadQueue": "Wachtrij",
|
||||
|
@ -43,9 +43,9 @@
|
|||
"ButtonJumpForward": "Spring vooruit",
|
||||
"ButtonLatest": "Meest recent",
|
||||
"ButtonLibrary": "Bibliotheek",
|
||||
"ButtonLogout": "Uitloggen",
|
||||
"ButtonLogout": "Log uit",
|
||||
"ButtonLookup": "Zoeken",
|
||||
"ButtonManageTracks": "Tracks beheren",
|
||||
"ButtonManageTracks": "Beheer tracks",
|
||||
"ButtonMapChapterTitles": "Hoofdstuktitels mappen",
|
||||
"ButtonMatchAllAuthors": "Alle auteurs matchen",
|
||||
"ButtonMatchBooks": "Alle boeken matchen",
|
||||
|
@ -72,7 +72,7 @@
|
|||
"ButtonQuickEmbedMetadata": "Snel Metadata Insluiten",
|
||||
"ButtonQuickMatch": "Snelle match",
|
||||
"ButtonReScan": "Nieuwe scan",
|
||||
"ButtonRead": "Lezen",
|
||||
"ButtonRead": "Lees",
|
||||
"ButtonReadLess": "Lees minder",
|
||||
"ButtonReadMore": "Lees meer",
|
||||
"ButtonRefresh": "Verversen",
|
||||
|
@ -107,7 +107,7 @@
|
|||
"ButtonUnlinkOpenId": "OpenID Ontkoppelen",
|
||||
"ButtonUpload": "Upload",
|
||||
"ButtonUploadBackup": "Upload back-up",
|
||||
"ButtonUploadCover": "Omslag uploaden",
|
||||
"ButtonUploadCover": "Upload cover",
|
||||
"ButtonUploadOPMLFile": "Upload OPML-bestand",
|
||||
"ButtonUserDelete": "Verwijder gebruiker {0}",
|
||||
"ButtonUserEdit": "Wijzig gebruiker {0}",
|
||||
|
@ -177,8 +177,7 @@
|
|||
"HeaderPlaylist": "Afspeellijst",
|
||||
"HeaderPlaylistItems": "Onderdelen in afspeellijst",
|
||||
"HeaderPodcastsToAdd": "Toe te voegen podcasts",
|
||||
"HeaderPresets": "Voorinstellingen",
|
||||
"HeaderPreviewCover": "Voorbeeld omslag",
|
||||
"HeaderPreviewCover": "Preview cover",
|
||||
"HeaderRSSFeedGeneral": "RSS-details",
|
||||
"HeaderRSSFeedIsOpen": "RSS-feed is open",
|
||||
"HeaderRSSFeeds": "RSS-feeds",
|
||||
|
@ -285,7 +284,7 @@
|
|||
"LabelContinueReading": "Verder lezen",
|
||||
"LabelContinueSeries": "Doorgaan met Serie",
|
||||
"LabelCover": "Omslag",
|
||||
"LabelCoverImageURL": "Omslagafbeelding-URL",
|
||||
"LabelCoverImageURL": "Coverafbeelding URL",
|
||||
"LabelCoverProvider": "Omslag bron",
|
||||
"LabelCreatedAt": "Gecreëerd op",
|
||||
"LabelCronExpression": "Cron-uitdrukking",
|
||||
|
@ -322,7 +321,7 @@
|
|||
"LabelEmailSettingsSecure": "Veilig",
|
||||
"LabelEmailSettingsSecureHelp": "Als 'waar', dan gebruikt de verbinding TLS om met de server te verbinden. Als 'onwaar', dan wordt TLS gebruikt als de server de STARTTLS-extensie ondersteunt. In de meeste gevallen kies je voor 'waar' verbindt met poort 465. Voo poort 587 of 25, laat op 'onwaar'. (van nodemailer.com/smtp/#authentication)",
|
||||
"LabelEmailSettingsTestAddress": "Test-adres",
|
||||
"LabelEmbeddedCover": "Omslag in bestand",
|
||||
"LabelEmbeddedCover": "Ingesloten cover",
|
||||
"LabelEnable": "Inschakelen",
|
||||
"LabelEncodingBackupLocation": "Er wordt een back-up van uw originele audiobestanden opgeslagen in:",
|
||||
"LabelEncodingChaptersNotEmbedded": "Hoofdstukken zijn niet ingesloten in audioboeken met meerdere sporen.",
|
||||
|
@ -331,7 +330,7 @@
|
|||
"LabelEncodingInfoEmbedded": "Metagegevens worden ingesloten in de audiotracks in uw audioboekmap.",
|
||||
"LabelEncodingStartedNavigation": "Eenmaal de taak is gestart kan u weg navigeren van deze pagina.",
|
||||
"LabelEncodingTimeWarning": "Encoding kan tot 30 minuten duren.",
|
||||
"LabelEncodingWarningAdvancedSettings": "Waarschuwing: pas deze instellingen niet aan tenzij u bekend bent met de coderingsopties van ffmpeg.",
|
||||
"LabelEncodingWarningAdvancedSettings": "Waarschuwing: update deze instellingen niet tenzij u bekend bent met de coderingsopties van ffmpeg.",
|
||||
"LabelEncodingWatcherDisabled": "Als u de watcher hebt uitgeschakeld, moet u het audioboek daarna opnieuw scannen.",
|
||||
"LabelEnd": "Einde",
|
||||
"LabelEndOfChapter": "Einde van het Hoofdstuk",
|
||||
|
@ -373,7 +372,7 @@
|
|||
"LabelFull": "Vol",
|
||||
"LabelGenre": "Genre",
|
||||
"LabelGenres": "Genres",
|
||||
"LabelHardDeleteFile": "Bestand permanent verwijderen",
|
||||
"LabelHardDeleteFile": "Hard-delete bestand",
|
||||
"LabelHasEbook": "Heeft Ebook",
|
||||
"LabelHasSupplementaryEbook": "Heeft aanvullend Ebook",
|
||||
"LabelHideSubtitles": "Ondertitels Verstoppen",
|
||||
|
@ -395,7 +394,6 @@
|
|||
"LabelIntervalEvery6Hours": "Iedere 6 uur",
|
||||
"LabelIntervalEveryDay": "Iedere dag",
|
||||
"LabelIntervalEveryHour": "Ieder uur",
|
||||
"LabelIntervalEveryMinute": "Elke minuut",
|
||||
"LabelInvert": "Omdraaien",
|
||||
"LabelItem": "Onderdeel",
|
||||
"LabelJumpBackwardAmount": "Terugspoelen hoeveelheid",
|
||||
|
@ -407,7 +405,7 @@
|
|||
"LabelLastBookUpdated": "Laatst bijgewerkte boek",
|
||||
"LabelLastSeen": "Laatst gezien",
|
||||
"LabelLastTime": "Laatste keer",
|
||||
"LabelLastUpdate": "Laatste wijziging",
|
||||
"LabelLastUpdate": "Laatste update",
|
||||
"LabelLayout": "Layout",
|
||||
"LabelLayoutSinglePage": "Enkele pagina",
|
||||
"LabelLayoutSplitPage": "Gesplitste pagina",
|
||||
|
@ -426,7 +424,7 @@
|
|||
"LabelLookForNewEpisodesAfterDate": "Zoek naar nieuwe afleveringen na deze datum",
|
||||
"LabelLowestPriority": "Laagste Prioriteit",
|
||||
"LabelMatchExistingUsersBy": "Bestaande gebruikers matchen op",
|
||||
"LabelMatchExistingUsersByDescription": "Wordt gebruikt om bestaande gebruikers te verbinden. Zodra ze verbonden zijn, worden gebruikers gekoppeld aan een unieke id van uw SSO-provider",
|
||||
"LabelMatchExistingUsersByDescription": "Wordt gebruikt om bestaande gebruikers te verbinden. Zodra ze verbonden zijn, worden gebruikers gekoppeld aan een unieke id van uw SSO-provider.",
|
||||
"LabelMaxEpisodesToDownload": "Maximale # afleveringen om te downloaden. Gebruik 0 voor ongelimiteerd.",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Maximale # nieuwe afleveringen om te downloaden per check",
|
||||
"LabelMaxEpisodesToKeep": "Maximale # afleveringen om te houden",
|
||||
|
@ -514,7 +512,7 @@
|
|||
"LabelPublishers": "Uitgevers",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Aangepast e-mailadres eigenaar",
|
||||
"LabelRSSFeedCustomOwnerName": "Aangepaste naam eigenaar",
|
||||
"LabelRSSFeedOpen": "RSS Feed Open",
|
||||
"LabelRSSFeedOpen": "RSS-feed open",
|
||||
"LabelRSSFeedPreventIndexing": "Voorkom indexering",
|
||||
"LabelRSSFeedSlug": "RSS-feed slug",
|
||||
"LabelRSSFeedURL": "RSS-feed URL",
|
||||
|
@ -531,8 +529,7 @@
|
|||
"LabelReleaseDate": "Verschijningsdatum",
|
||||
"LabelRemoveAllMetadataAbs": "Verwijder alle metadata.abs bestanden",
|
||||
"LabelRemoveAllMetadataJson": "Verwijder alle metadata.json bestanden",
|
||||
"LabelRemoveAudibleBranding": "Verwijder Audible intro en outro uit hoofdstukken",
|
||||
"LabelRemoveCover": "Omslag verwijderen",
|
||||
"LabelRemoveCover": "Verwijder cover",
|
||||
"LabelRemoveMetadataFile": "Verwijder metadata bestanden in bibliotheek item folders",
|
||||
"LabelRemoveMetadataFileHelp": "Verwijder alle metadata.json en metadata.abs bestanden in uw {0} folders.",
|
||||
"LabelRowsPerPage": "Rijen per pagina",
|
||||
|
@ -560,16 +557,14 @@
|
|||
"LabelSettingsAudiobooksOnlyHelp": "Deze instelling inschakelen zorgt ervoor dat ebook-bestanden genegeerd worden tenzij ze in een audiobook-map staan, in welk geval ze worden ingesteld als supplementaire ebooks",
|
||||
"LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken",
|
||||
"LabelSettingsChromecastSupport": "Chromecast ondersteuning",
|
||||
"LabelSettingsDateFormat": "Datumnotatie",
|
||||
"LabelSettingsEnableWatcher": "Bibliotheken automatisch scannen op wijzigingen",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch scannen op wijzigingen",
|
||||
"LabelSettingsDateFormat": "Datum format",
|
||||
"LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Sta scripted content toe in epubs",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Sta toe dat epub-bestanden scripts uitvoeren. Het wordt aanbevolen om deze instelling uitgeschakeld te houden, tenzij u de bron van de epub-bestanden vertrouwt.",
|
||||
"LabelSettingsExperimentalFeatures": "Experimentele functies",
|
||||
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
|
||||
"LabelSettingsFindCovers": "Omslagen zoeken",
|
||||
"LabelSettingsFindCoversHelp": "Als je audioboek geen omslag in het bestand of in de map heeft, zal de scanner automatisch proberen een omslag te vinden.<br>Opmerking: Dit kan de scantijd verlengen",
|
||||
"LabelSettingsFindCovers": "Zoek covers",
|
||||
"LabelSettingsFindCoversHelp": "Als je audioboek geen ingesloten cover of cover in de map heeft, zal de scanner proberen een cover te vinden.<br>Opmerking: Dit zal de scan-duur verlengen",
|
||||
"LabelSettingsHideSingleBookSeries": "Verberg series met een enkel boek",
|
||||
"LabelSettingsHideSingleBookSeriesHelp": "Series die slechts een enkel boek bevatten worden verborgen op de seriespagina en de homepagina-planken.",
|
||||
"LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina",
|
||||
|
@ -579,18 +574,18 @@
|
|||
"LabelSettingsLibraryMarkAsFinishedWhen": "Markeer media item wanneer voltooid",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sla eedere boeken in Serie Verderzetten over",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "De Continue Series home page shelf toont het eerste boek dat nog niet is begonnen in series waarvan er minstens één is voltooid en er geen boeken in uitvoering zijn. Als u deze instelling inschakelt, wordt de serie voortgezet vanaf het boek dat het verst is voltooid in plaats van het eerste boek dat nog niet is begonnen.",
|
||||
"LabelSettingsParseSubtitles": "Subtitel afleiden uit foldernaam",
|
||||
"LabelSettingsParseSubtitles": "Parseer subtitel",
|
||||
"LabelSettingsParseSubtitlesHelp": "Haal subtitels uit mapnaam van audioboek.<br>Subtitel moet gescheiden zijn met \" - \"<br>b.v. \"Boektitel - Een Subtitel Hier\" heeft als subtitel \"Een Subtitel Hier\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "Geef voorkeur aan gematchte metadata",
|
||||
"LabelSettingsPreferMatchedMetadata": "Prefereer gematchte metadata",
|
||||
"LabelSettingsPreferMatchedMetadataHelp": "Gematchte data zal onderdeeldetails overschrijven bij gebruik van Quick Match. Standaard vult Quick Match uitsluitend ontbrekende details aan.",
|
||||
"LabelSettingsSkipMatchingBooksWithASIN": "Sla matchen van boeken over die al over een ASIN beschikken",
|
||||
"LabelSettingsSkipMatchingBooksWithISBN": "Sla matchen van boeken over die al over een ISBN beschikken",
|
||||
"LabelSettingsSortingIgnorePrefixes": "Negeer voorvoegsels bij sorteren",
|
||||
"LabelSettingsSortingIgnorePrefixesHelp": "b.v. voor voorvoegsel \"The\" wordt titel \"The Title\" dan gesorteerd als \"Title, The\"",
|
||||
"LabelSettingsSquareBookCovers": "Gebruik vierkante boekomslagen",
|
||||
"LabelSettingsSquareBookCoversHelp": "Gebruik vierkante boekomslagen in plaats van standaard 1,6:1",
|
||||
"LabelSettingsStoreCoversWithItem": "Bewaar omslagen bij onderdeel",
|
||||
"LabelSettingsStoreCoversWithItemHelp": "Omslagen worden standaard in /metadata/items opgeslagen. Bij inschakelen worden ze in de map van het bibliotheekitem zelf opgeslagen. Slechts een bestand genaamd \"cover\" zal worden bewaard",
|
||||
"LabelSettingsSquareBookCovers": "Gebruik vierkante boekcovers",
|
||||
"LabelSettingsSquareBookCoversHelp": "Prefereer gebruik van vierkante covers boven standaard 1.6:1 boekcovers",
|
||||
"LabelSettingsStoreCoversWithItem": "Bewaar covers bij onderdeel",
|
||||
"LabelSettingsStoreCoversWithItemHelp": "Standaard worden covers bewaard in /metadata/items, door deze instelling in te schakelen zullen covers in de map van je bibliotheekonderdeel bewaard worden. Slechts een bestand genaamd \"cover\" zal worden bewaard",
|
||||
"LabelSettingsStoreMetadataWithItem": "Bewaar metadata bij onderdeel",
|
||||
"LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden",
|
||||
"LabelSettingsTimeFormat": "Tijdformat",
|
||||
|
@ -606,7 +601,6 @@
|
|||
"LabelSlug": "Slak",
|
||||
"LabelSortAscending": "Oplopend",
|
||||
"LabelSortDescending": "Aflopend",
|
||||
"LabelSortPubDate": "Sorteer Pub Datum",
|
||||
"LabelStart": "Start",
|
||||
"LabelStartTime": "Starttijd",
|
||||
"LabelStarted": "Gestart",
|
||||
|
@ -652,12 +646,12 @@
|
|||
"LabelTimeToShift": "Tijd op te schuiven in seconden",
|
||||
"LabelTitle": "Titel",
|
||||
"LabelToolsEmbedMetadata": "Metadata insluiten",
|
||||
"LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief omslagafbeelding en hoofdstukken.",
|
||||
"LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief coverafbeelding en hoofdstukken.",
|
||||
"LabelToolsM4bEncoder": "M4B Encoder",
|
||||
"LabelToolsMakeM4b": "Maak M4B-audioboekbestand",
|
||||
"LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, omslagafbeelding en hoofdstukken.",
|
||||
"LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, coverafbeelding en hoofdstukken.",
|
||||
"LabelToolsSplitM4b": "Splitst M4B in MP3's",
|
||||
"LabelToolsSplitM4bDescription": "Maak MP3's van een M4B, gesplitst per hoofdstuk met ingesloten metadata, omslagafbeelding en hoofdstukken.",
|
||||
"LabelToolsSplitM4bDescription": "Maak MP3's van een M4B, gesplitst per hoofdstuk met ingesloten metadata, coverafbeelding en hoofdstukken.",
|
||||
"LabelTotalDuration": "Totale duur",
|
||||
"LabelTotalTimeListened": "Totale tijd geluisterd",
|
||||
"LabelTrackFromFilename": "Track vanuit bestandsnaam",
|
||||
|
@ -672,8 +666,8 @@
|
|||
"LabelUndo": "Ongedaan maken",
|
||||
"LabelUnknown": "Onbekend",
|
||||
"LabelUnknownPublishDate": "Onbekende uitgeefdatum",
|
||||
"LabelUpdateCover": "Omslag bijwerken",
|
||||
"LabelUpdateCoverHelp": "Sta overschrijven van bestaande omslagen toe voor de geselecteerde boeken wanneer een match is gevonden",
|
||||
"LabelUpdateCover": "Cover bijwerken",
|
||||
"LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden",
|
||||
"LabelUpdateDetails": "Details bijwerken",
|
||||
"LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden",
|
||||
"LabelUpdatedAt": "Bijgewerkt op",
|
||||
|
@ -707,15 +701,13 @@
|
|||
"LabelYourProgress": "Je voortgang",
|
||||
"MessageAddToPlayerQueue": "Toevoegen aan wachtrij",
|
||||
"MessageAppriseDescription": "Om deze functie te gebruiken heb je een draaiende instantie van <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> nodig of een api die dezelfde requests afhandelt. <br />De Apprise API Url moet het volledige URL-pad zijn om de notificatie te verzenden, b.v., als je API-instantie draait op <code>http://192.168.1.1:8337</code> dan zou je <code>http://192.168.1.1:8337/notify</code> gebruiken.",
|
||||
"MessageAsinCheck": "Zorg ervoor dat u de ASIN van de juiste Audible-regio gebruikt, niet die van Amazon.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Start uw server opnieuw op nadat u het opslaan hebt uitgevoerd, om de OIDC-wijzigingen toe te passen.",
|
||||
"MessageBackupsDescription": "Back-ups omvatten gebruikers, gebruikers' voortgang, bibliotheekonderdeeldetails, serverinstellingen en afbeeldingen bewaard in <code>/metadata/items</code> & <code>/metadata/authors</code>. Back-ups <strong>bevatten niet</strong> de bestanden bewaard in je bibliotheekmappen.",
|
||||
"MessageBackupsLocationEditNote": "Let op: het bijwerken van de back-uplocatie zal bestaande back-ups niet verplaatsen of wijzigen",
|
||||
"MessageBackupsLocationNoEditNote": "Let op: De back-uplocatie wordt ingesteld via een omgevingsvariabele en kan hier niet worden gewijzigd.",
|
||||
"MessageBackupsLocationPathEmpty": "Backup locatie pad kan niet leeg zijn",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Vul actieve velden in met data van alle items. Velden met meerdere waarden zullen worden samengevoegd",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "Vul actieve folder detail velden met de data van dit item",
|
||||
"MessageBatchQuickMatchDescription": "Quick Match probeert ontbrekende omslagen en metadata toe te voegen aan de geselecteerde items. Schakel de opties hieronder in om Quick Match bestaande omslagen en/of metadata te laten overschrijven.",
|
||||
"MessageBatchQuickMatchDescription": "Quick Match zal proberen ontbrekende covers en metadata voor de geselecteerde onderdelen te matchten. Schakel de opties hieronder in om Quick Match toe te staan bestaande covers en/of metadata te overschrijven.",
|
||||
"MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt",
|
||||
"MessageBookshelfNoCollectionsHelp": "Collecties zijn publiekelijk. Alle gebruikers met toegang tot de bibliotheek kunnen ze zien.",
|
||||
"MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend",
|
||||
|
@ -727,7 +719,6 @@
|
|||
"MessageChapterErrorStartGteDuration": "Ongeldig: starttijd moet kleiner zijn dan duur van audioboek",
|
||||
"MessageChapterErrorStartLtPrev": "Ongeldig: starttijd moet be groter zijn dan of equal aan starttijd van vorig hoofdstuk",
|
||||
"MessageChapterStartIsAfter": "Start van hoofdstuk is na het einde van je audioboek",
|
||||
"MessageChaptersNotFound": "Hoofdstukken niet gevonden",
|
||||
"MessageCheckingCron": "Cron aan het checken...",
|
||||
"MessageConfirmCloseFeed": "Ben je zeker dat je deze feed wil sluiten?",
|
||||
"MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?",
|
||||
|
@ -757,7 +748,6 @@
|
|||
"MessageConfirmRemoveAuthor": "Weet je zeker dat je auteur \"{0}\" wil verwijderen?",
|
||||
"MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?",
|
||||
"MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?",
|
||||
"MessageConfirmRemoveEpisodeNote": "Let op: Het audiobestand wordt niet verwijderd, tenzij je ‘Bestand permanent verwijderen’ inschakelt",
|
||||
"MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?",
|
||||
"MessageConfirmRemoveListeningSessions": "Weet je zeker dat je {0} luistersessies wilt verwijderen?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Bent u zeker dat u alle metadata wil verwijderen. {0} bestanden in uw bibliotheel item folders?",
|
||||
|
@ -785,9 +775,8 @@
|
|||
"MessageForceReScanDescription": "zal alle bestanden opnieuw scannen als een verse scan. Audiobestanden ID3-tags, OPF-bestanden en textbestanden zullen als nieuw worden gescand.",
|
||||
"MessageImportantNotice": "Belangrijke opmerking!",
|
||||
"MessageInsertChapterBelow": "Hoofdstuk hieronder invoegen",
|
||||
"MessageInvalidAsin": "Ongeldige ASIN",
|
||||
"MessageItemsSelected": "{0} items geselecteerd",
|
||||
"MessageItemsUpdated": "{0} items bijgewerkt",
|
||||
"MessageItemsSelected": "{0} onderdelen geselecteerd",
|
||||
"MessageItemsUpdated": "{0} onderdelen bijgewerkt",
|
||||
"MessageJoinUsOn": "Doe mee op",
|
||||
"MessageLoading": "Aan het laden...",
|
||||
"MessageLoadingFolders": "Mappen aan het laden...",
|
||||
|
@ -799,14 +788,14 @@
|
|||
"MessageMarkAllEpisodesNotFinished": "Markeer alle afleveringen als niet voltooid",
|
||||
"MessageMarkAsFinished": "Markeer als Voltooid",
|
||||
"MessageMarkAsNotFinished": "Markeer als Niet Voltooid",
|
||||
"MessageMatchBooksDescription": "zal proberen boeken in de bibliotheek te koppelen aan een boek uit de geselecteerde bron en ontbrekende gegevens en een omslag toe te voegen. Overschrijft geen bestaande gegevens.",
|
||||
"MessageMatchBooksDescription": "zal proberen boeken in de bibliotheek te matchen met een boek uit de geselecteerde bron en lege details en coverafbeelding te vullen. Overschrijft details niet.",
|
||||
"MessageNoAudioTracks": "Geen audiotracks",
|
||||
"MessageNoAuthors": "Geen auteurs",
|
||||
"MessageNoBackups": "Geen back-ups",
|
||||
"MessageNoBookmarks": "Geen boekwijzers",
|
||||
"MessageNoChapters": "Geen hoofdstukken",
|
||||
"MessageNoCollections": "Geen collecties",
|
||||
"MessageNoCoversFound": "Geen omslagen gevonden",
|
||||
"MessageNoCoversFound": "Geen covers gevonden",
|
||||
"MessageNoDescription": "Geen beschrijving",
|
||||
"MessageNoDevices": "Geen Apparaten",
|
||||
"MessageNoDownloadsInProgress": "Geen downloads bezig op dit moment",
|
||||
|
@ -819,7 +808,7 @@
|
|||
"MessageNoItems": "Geen onderdelen",
|
||||
"MessageNoItemsFound": "Geen onderdelen gevonden",
|
||||
"MessageNoListeningSessions": "Geen luistersessies",
|
||||
"MessageNoLogs": "Geen logbestanden",
|
||||
"MessageNoLogs": "Geen logs",
|
||||
"MessageNoMediaProgress": "Geen mediavoortgang",
|
||||
"MessageNoNotifications": "Geen notificaties",
|
||||
"MessageNoPodcastFeed": "Ongeldige podcast: Geen Feed",
|
||||
|
@ -844,7 +833,7 @@
|
|||
"MessageQuickEmbedInProgress": "Snelle inbedding in uitvoering",
|
||||
"MessageQuickEmbedQueue": "In de wachtrij voor snelle insluiting ({0} in wachtrij)",
|
||||
"MessageQuickMatchAllEpisodes": "Alle Afleveringen Snel Matchen",
|
||||
"MessageQuickMatchDescription": "Vult ontbrekende gegevens & omslag met eerste matchresultaat van '{0}'. Overschrijft gegevens alleen als de serverinstelling ‘Geef voorkeur aan gematchte metadata’ is ingeschakeld.",
|
||||
"MessageQuickMatchDescription": "Vul lege onderdeeldetails & cover met eerste matchresultaat van '{0}'. Overschrijft geen details tenzij 'Prefereer gematchte metadata' serverinstelling is ingeschakeld.",
|
||||
"MessageRemoveChapter": "Verwijder hoofdstuk",
|
||||
"MessageRemoveEpisodes": "Verwijder {0} aflevering(en)",
|
||||
"MessageRemoveFromPlayerQueue": "Verwijder uit afspeelwachtrij",
|
||||
|
@ -852,12 +841,10 @@
|
|||
"MessageReportBugsAndContribute": "Rapporteer bugs, vraag functionaliteiten aan en draag bij op",
|
||||
"MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?",
|
||||
"MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op",
|
||||
"MessageRestoreBackupWarning": "Een back-up herstellen zal de volledige database in /config en de omslagen in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om omslagen en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle apparaten die je server gebruiken, worden automatisch ververst.",
|
||||
"MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle clients die van je server gebruik maken zullen automatisch worden ververst.",
|
||||
"MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het raadzaam om deze functie uitgeschakeld te laten en de folder watcher-instelling ingeschakeld te houden. De folder watcher detecteert automatisch wijzigingen in uw bibliotheekmappen. De folder watcher werkt niet voor elk bestandssysteem (zoals NFS), dus geplande bibliotheekscans kunnen in plaats daarvan worden gebruikt.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Elke {0} uitvoeren op {1}",
|
||||
"MessageSearchResultsFor": "Zoekresultaten voor",
|
||||
"MessageSelected": "{0} geselecteerd",
|
||||
"MessageSeriesSequenceCannotContainSpaces": "Serievolgorde mag geen spaties bevatten",
|
||||
"MessageServerCouldNotBeReached": "Server niet bereikbaar",
|
||||
"MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel",
|
||||
"MessageShareExpirationWillBe": "Vervaldatum is <strong>{0}</strong>",
|
||||
|
@ -919,8 +906,6 @@
|
|||
"NotificationOnBackupCompletedDescription": "Wordt geactiveerd wanneer een back-up is voltooid",
|
||||
"NotificationOnBackupFailedDescription": "Wordt geactiveerd wanneer een back-up mislukt",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Wordt geactiveerd wanneer een podcastaflevering automatisch wordt gedownload",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Wordt geactiveerd wanneer automatische afleveringsdownloads zijn uitgeschakeld vanwege te veel mislukte pogingen",
|
||||
"NotificationOnRSSFeedFailedDescription": "Getriggerd wanneer de RSS feed aanvraag faalt voor een automatische aflevering download",
|
||||
"NotificationOnTestDescription": "Event voor het testen van het notificatiesysteem",
|
||||
"PlaceholderNewCollection": "Nieuwe naam collectie",
|
||||
"PlaceholderNewFolderPath": "Nieuwe locatie map",
|
||||
|
@ -965,7 +950,6 @@
|
|||
"ToastBackupRestoreFailed": "Herstellen back-up mislukt",
|
||||
"ToastBackupUploadFailed": "Uploaden back-up mislukt",
|
||||
"ToastBackupUploadSuccess": "Back-up geüpload",
|
||||
"ToastBatchApplyDetailsToItemsSuccess": "Details toegepast op items",
|
||||
"ToastBatchDeleteFailed": "Batch verwijderen mislukt",
|
||||
"ToastBatchDeleteSuccess": "Batch verwijderen gelukt",
|
||||
"ToastBatchQuickMatchFailed": "Batch Snel Vergelijken mislukt!",
|
||||
|
@ -978,15 +962,13 @@
|
|||
"ToastCachePurgeFailed": "Cache wissen is mislukt",
|
||||
"ToastCachePurgeSuccess": "Cache succesvol verwijderd",
|
||||
"ToastChaptersHaveErrors": "Hoofdstukken bevatten fouten",
|
||||
"ToastChaptersInvalidShiftAmountLast": "Ongeldige shift-tijd. De starttijd van het laatste hoofdstuk zou langer zijn dan de duur van dit audioboek.",
|
||||
"ToastChaptersInvalidShiftAmountStart": "Ongeldige shift-lengte. Het eerste hoofdstuk zou nul of een negatieve lengte hebben en zou worden overschreven door het tweede hoofdstuk. Verleng de startduur van het tweede hoofdstuk.",
|
||||
"ToastChaptersMustHaveTitles": "Hoofdstukken moeten titels hebben",
|
||||
"ToastChaptersRemoved": "Hoofdstukken verwijderd",
|
||||
"ToastChaptersUpdated": "Hoofdstukken bijgewerkt",
|
||||
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
|
||||
"ToastCollectionRemoveSuccess": "Collectie verwijderd",
|
||||
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
|
||||
"ToastCoverUpdateFailed": "Omslag bijwerken mislukt",
|
||||
"ToastCoverUpdateFailed": "Cover update mislukt",
|
||||
"ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig",
|
||||
"ToastDeleteFileFailed": "Bestand verwijderen mislukt",
|
||||
"ToastDeleteFileSuccess": "Bestand verwijderd",
|
||||
|
@ -1008,7 +990,7 @@
|
|||
"ToastInvalidImageUrl": "Ongeldige afbeeldings-URL",
|
||||
"ToastInvalidMaxEpisodesToDownload": "Ongeldig maximum aantal afleveringen om te downloaden",
|
||||
"ToastInvalidUrl": "Ongeldige URL",
|
||||
"ToastItemCoverUpdateSuccess": "Omslag bijgewerkt",
|
||||
"ToastItemCoverUpdateSuccess": "Cover onderdeel bijgewerkt",
|
||||
"ToastItemDeletedFailed": "Item verwijderen mislukt",
|
||||
"ToastItemDeletedSuccess": "Verwijderd item",
|
||||
"ToastItemDetailsUpdateSuccess": "Details onderdeel bijgewerkt",
|
||||
|
@ -1080,7 +1062,6 @@
|
|||
"ToastSelectAtLeastOneUser": "Selecteer ten minste een gebruiker",
|
||||
"ToastSendEbookToDeviceFailed": "Ebook naar apparaat sturen mislukt",
|
||||
"ToastSendEbookToDeviceSuccess": "Ebook verstuurd naar apparaat \"{0}\"",
|
||||
"ToastSeriesSubmitFailedSameName": "Kan niet twee series met dezelfde naam toevoegen",
|
||||
"ToastSeriesUpdateFailed": "Bijwerken serie mislukt",
|
||||
"ToastSeriesUpdateSuccess": "Bijwerken serie gelukt",
|
||||
"ToastServerSettingsUpdateSuccess": "Server instellingen bijgewerkt",
|
||||
|
@ -1099,8 +1080,6 @@
|
|||
"ToastUnknownError": "Onbekende fout",
|
||||
"ToastUnlinkOpenIdFailed": "Gebruiker ontkoppelen van OpenID mislukt",
|
||||
"ToastUnlinkOpenIdSuccess": "Gebruiker ontkoppeld van OpenID",
|
||||
"ToastUploaderFilepathExistsError": "Bestandspad \"{0}\" bestaat al op de server",
|
||||
"ToastUploaderItemExistsInSubdirectoryError": "Item \"{0}\" gebruikt een submap van het uploadpad.",
|
||||
"ToastUserDeleteFailed": "Verwijderen gebruiker mislukt",
|
||||
"ToastUserDeleteSuccess": "Gebruiker verwijderd",
|
||||
"ToastUserPasswordChangeSuccess": "Wachtwoord succesvol gewijzigd",
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
"ButtonAddYourFirstLibrary": "Legg til ditt første bibliotek",
|
||||
"ButtonApply": "Bruk",
|
||||
"ButtonApplyChapters": "Bruk kapittel",
|
||||
"ButtonAuthors": "Forfattere",
|
||||
"ButtonAuthors": "Forfatter",
|
||||
"ButtonBack": "Tilbake",
|
||||
"ButtonBrowseForFolder": "Bla gjennom mappe",
|
||||
"ButtonCancel": "Avbryt",
|
||||
|
@ -175,7 +175,6 @@
|
|||
"HeaderPlaylist": "Spilleliste",
|
||||
"HeaderPlaylistItems": "Spillelisteelement",
|
||||
"HeaderPodcastsToAdd": "Podcaster å legge til",
|
||||
"HeaderPresets": "Forhåndsinnstillinger",
|
||||
"HeaderPreviewCover": "Forhåndsvis omslag",
|
||||
"HeaderRSSFeedGeneral": "RSS Detailer",
|
||||
"HeaderRSSFeedIsOpen": "RSS Feed er åpen",
|
||||
|
@ -218,7 +217,6 @@
|
|||
"LabelAccountTypeAdmin": "Administrator",
|
||||
"LabelAccountTypeGuest": "Gjest",
|
||||
"LabelAccountTypeUser": "Bruker",
|
||||
"LabelActivities": "Aktiviteter",
|
||||
"LabelActivity": "Aktivitet",
|
||||
"LabelAddToCollection": "Legg til i samling",
|
||||
"LabelAddToCollectionBatch": "Legg {0} bøker til samling",
|
||||
|
@ -228,7 +226,6 @@
|
|||
"LabelAddedDate": "La til {0}",
|
||||
"LabelAdminUsersOnly": "Kun administratorer",
|
||||
"LabelAll": "Alle",
|
||||
"LabelAllEpisodesDownloaded": "Alle nedlastede episoder",
|
||||
"LabelAllUsers": "Alle brukere",
|
||||
"LabelAllUsersExcludingGuests": "Alle brukere bortsett fra gjester",
|
||||
"LabelAllUsersIncludingGuests": "Alle brukere inkludert gjester",
|
||||
|
@ -284,7 +281,6 @@
|
|||
"LabelContinueSeries": "Fortsett serier",
|
||||
"LabelCover": "Omslag",
|
||||
"LabelCoverImageURL": "Omslagsbilde URL",
|
||||
"LabelCoverProvider": "Tilbyder av omslagsbilde",
|
||||
"LabelCreatedAt": "Dato opprettet",
|
||||
"LabelCronExpression": "Cron uttrykk",
|
||||
"LabelCurrent": "Nåværende",
|
||||
|
@ -393,7 +389,6 @@
|
|||
"LabelIntervalEvery6Hours": "Hver 6. timer",
|
||||
"LabelIntervalEveryDay": "Hver dag",
|
||||
"LabelIntervalEveryHour": "Hver time",
|
||||
"LabelIntervalEveryMinute": "Hvert minutt",
|
||||
"LabelInvert": "Inverter",
|
||||
"LabelItem": "Enhet",
|
||||
"LabelJumpBackwardAmount": "Hopp bakover med",
|
||||
|
@ -469,7 +464,6 @@
|
|||
"LabelNotificationsMaxQueueSizeHelp": "Hendelser er begrenset til avfyre én gang per sekund. Hendelser blir ignorert om køen er full. Dette forhindrer overflod av varslinger.",
|
||||
"LabelNumberOfBooks": "Antall bøker",
|
||||
"LabelNumberOfEpisodes": "Antall episoder",
|
||||
"LabelOpenIDAdvancedPermsClaimDescription": "Navnet på OpenID claim'et som inneholder avanserte tilganger for brukerhandlinger i applikasjonen som vil brukes for ikke-administratorroller (<b>hvis konfigurert</b>). Hvis claim'et mangler fra responsen, nektes tilgang til ABS. Hvis en enkelt opsjon mangler, blir behandlet som <code>false</code>. Påse at identitetstilbyderens claim stemmer overens med den forventede strukturen:",
|
||||
"LabelOpenIDClaims": "La følge valg være tomme for å slå av avanserte gruppe og tillatelser. Gruppen \"Bruker\" vil da også automatisk legges til.",
|
||||
"LabelOpenRSSFeed": "Åpne RSS Feed",
|
||||
"LabelOverwrite": "Overskriv",
|
||||
|
@ -527,7 +521,6 @@
|
|||
"LabelReleaseDate": "Utgivelsesdato",
|
||||
"LabelRemoveAllMetadataAbs": "Fjern alle metadata.abs filer",
|
||||
"LabelRemoveAllMetadataJson": "Fjern alle metadata.json filer",
|
||||
"LabelRemoveAudibleBranding": "Fjern Audible inn- og utledning fra kapitler",
|
||||
"LabelRemoveCover": "Fjern omslag",
|
||||
"LabelRemoveMetadataFile": "Fjern metadata-filer fra biblioteks-mapper",
|
||||
"LabelRemoveMetadataFileHelp": "Fjern alle metadata.json og metadata.abs i alle {0} mappene.",
|
||||
|
@ -557,8 +550,6 @@
|
|||
"LabelSettingsBookshelfViewHelp": "Skeuomorf design med hyller av ved",
|
||||
"LabelSettingsChromecastSupport": "Chromecast støtte",
|
||||
"LabelSettingsDateFormat": "Dato Format",
|
||||
"LabelSettingsEnableWatcher": "Skann biblioteker automatisk for endringer",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Skann bibliotek automatisk for endringer",
|
||||
"LabelSettingsEnableWatcherHelp": "Aktiverer automatisk opprettelse/oppdatering av enheter når filendringer er oppdaget. *Krever restart av server*",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Tillat scripting i innholdet i ebub-bøker",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Tillat epub-filer å kjøre script. Det er anbefalt å slå av denne innstillingen med mindre du stoler på kilden til epub-filene.",
|
||||
|
@ -602,7 +593,6 @@
|
|||
"LabelSlug": "Slug",
|
||||
"LabelSortAscending": "Stigende",
|
||||
"LabelSortDescending": "Synkende",
|
||||
"LabelSortPubDate": "Sorter etter publiseringsdato",
|
||||
"LabelStart": "Start",
|
||||
"LabelStartTime": "Start Tid",
|
||||
"LabelStarted": "Startet",
|
||||
|
@ -703,8 +693,6 @@
|
|||
"LabelYourProgress": "Din fremgang",
|
||||
"MessageAddToPlayerQueue": "Legg til i kø",
|
||||
"MessageAppriseDescription": "For å bruke denne funksjonen trenger du en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kjørende eller et API som håndterer disse forespørslene. <br />Apprise API URL skal være hele URL-en til varslingen, f.eks., hvis din API-instans er på <code>http://192.168.1.1:8337</code> så skal du bruke <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAsinCheck": "Påse at du bruker ASIN fra den riktige Audible-regionen, ikke Amazon.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Etter å ha lagret, start serveren din på nytt for at OIDC-endringene skal tre i kraft.",
|
||||
"MessageBackupsDescription": "Sikkerhetskopier inkluderer, brukerfremgang, detaljer om bibliotekgjenstander, tjener instillinger og bilder lagret under <code>/metadata/items</code> og <code>/metadata/authors</code>. Sikkerhetskopier <strong>vil ikke</strong> inkludere filer som er lagret i bibliotek mappene.",
|
||||
"MessageBackupsLocationEditNote": "Viktig: Endring av mappen for sikkerhetskopi hverken endrer eller flytter eksisterende sikkerhetskopier!",
|
||||
"MessageBackupsLocationNoEditNote": "NB: Mappen for sikkerhetskopi settes i en miljøvariabel og kan ikke endres her.",
|
||||
|
|
|
@ -329,9 +329,7 @@
|
|||
"LabelEpisode": "Odcinek",
|
||||
"LabelEpisodeTitle": "Tytuł odcinka",
|
||||
"LabelEpisodeType": "Typ odcinka",
|
||||
"LabelEpisodeUrlFromRssFeed": "Adres URL odcinka z kanału RSS",
|
||||
"LabelEpisodes": "Epizody",
|
||||
"LabelEpisodic": "Epizodyczny",
|
||||
"LabelExample": "Przykład",
|
||||
"LabelExpandSeries": "Rozwiń serie",
|
||||
"LabelExpandSubSeries": "Rozwiń podserie",
|
||||
|
@ -359,7 +357,6 @@
|
|||
"LabelFontScale": "Rozmiar czcionki",
|
||||
"LabelFontStrikethrough": "Przekreślony",
|
||||
"LabelFormat": "Format",
|
||||
"LabelFull": "Pełny",
|
||||
"LabelGenre": "Gatunek",
|
||||
"LabelGenres": "Gatunki",
|
||||
"LabelHardDeleteFile": "Usuń trwale plik",
|
||||
|
@ -384,7 +381,6 @@
|
|||
"LabelIntervalEvery6Hours": "Co 6 godzin",
|
||||
"LabelIntervalEveryDay": "Każdego dnia",
|
||||
"LabelIntervalEveryHour": "Każdej godziny",
|
||||
"LabelIntervalEveryMinute": "Co minutę",
|
||||
"LabelInvert": "Inversja",
|
||||
"LabelItem": "Pozycja",
|
||||
"LabelJumpBackwardAmount": "Przeskocz do tyłu o:",
|
||||
|
@ -416,9 +412,6 @@
|
|||
"LabelLowestPriority": "Najniższy priorytet",
|
||||
"LabelMatchExistingUsersBy": "Dopasuje istniejących użytkowników poprzez",
|
||||
"LabelMatchExistingUsersByDescription": "Służy do łączenia istniejących użytkowników. Po połączeniu użytkownicy zostaną dopasowani za pomocą unikalnego identyfikatora od dostawcy SSO",
|
||||
"LabelMaxEpisodesToDownload": "Maksymalna liczba odcinków do pobrania. Użyj 0, aby wyłączyć ograniczenie.",
|
||||
"LabelMaxEpisodesToKeep": "Maksymalna liczba odcinków do zachowania",
|
||||
"LabelMaxEpisodesToKeepHelp": "Wartość 0 wyłącza maksymalny limit. Po automatycznym pobraniu nowego odcinka, najstarszy odcinek zostanie usunięty, jeśli masz ich więcej niż X. Spowoduje to usunięcie tylko 1 odcinka na nowe pobieranie.",
|
||||
"LabelMediaPlayer": "Odtwarzacz",
|
||||
"LabelMediaType": "Typ mediów",
|
||||
"LabelMetaTag": "Tag",
|
||||
|
@ -431,7 +424,6 @@
|
|||
"LabelMissingEbook": "Nie posiada ebooka",
|
||||
"LabelMissingSupplementaryEbook": "Nie posiada dodatkowego ebooka",
|
||||
"LabelMobileRedirectURIs": "Dozwolone URI przekierowań mobilnych",
|
||||
"LabelMobileRedirectURIsDescription": "To jest biała lista prawidłowych adresów URI przekierowań dla aplikacji mobilnych. Domyślny adres to <code>audiobookshelf://oauth</code>, który można usunąć lub dodać inne adresy URI w celu integracji z aplikacjami innych firm. Użycie gwiazdki (<code>*</code>) jako jedynego wpisu zezwala na dowolny URI.",
|
||||
"LabelMore": "Więcej",
|
||||
"LabelMoreInfo": "Więcej informacji",
|
||||
"LabelName": "Nazwa",
|
||||
|
@ -461,14 +453,12 @@
|
|||
"LabelNumberOfEpisodes": "# Odcinków",
|
||||
"LabelOpenRSSFeed": "Otwórz kanał RSS",
|
||||
"LabelOverwrite": "Nadpisz",
|
||||
"LabelPaginationPageXOfY": "Strona {0} z {1}",
|
||||
"LabelPassword": "Hasło",
|
||||
"LabelPath": "Ścieżka",
|
||||
"LabelPermanent": "Stałe",
|
||||
"LabelPermissionsAccessAllLibraries": "Ma dostęp do wszystkich bibliotek",
|
||||
"LabelPermissionsAccessAllTags": "Ma dostęp do wszystkich tagów",
|
||||
"LabelPermissionsAccessExplicitContent": "Ma dostęp do treści oznacznych jako nieprzyzwoite",
|
||||
"LabelPermissionsCreateEreader": "Możliwość stworzenia czytnika e-booków",
|
||||
"LabelPermissionsDelete": "Ma możliwość usuwania",
|
||||
"LabelPermissionsDownload": "Ma możliwość pobierania",
|
||||
"LabelPermissionsUpdate": "Ma możliwość aktualizowania",
|
||||
|
@ -476,25 +466,19 @@
|
|||
"LabelPersonalYearReview": "Podsumowanie twojego roku ({0})",
|
||||
"LabelPhotoPathURL": "Scieżka/URL do zdjęcia",
|
||||
"LabelPlayMethod": "Metoda odtwarzania",
|
||||
"LabelPlayerChapterNumberMarker": "{0} z {1}",
|
||||
"LabelPlaylists": "Listy odtwarzania",
|
||||
"LabelPodcast": "Podcast",
|
||||
"LabelPodcastSearchRegion": "Obszar wyszukiwania podcastów",
|
||||
"LabelPodcastType": "Typ podcastu",
|
||||
"LabelPodcasts": "Podcasty",
|
||||
"LabelPort": "Port",
|
||||
"LabelPrefixesToIgnore": "Ignorowane prefiksy (wielkość liter nie ma znaczenia)",
|
||||
"LabelPreventIndexing": "Zapobiega indeksowaniu przez iTunes i Google",
|
||||
"LabelPrimaryEbook": "Główny ebook",
|
||||
"LabelProgress": "Postęp",
|
||||
"LabelProvider": "Dostawca",
|
||||
"LabelProviderAuthorizationValue": "Wartość nagłówka autoryzacji",
|
||||
"LabelPubDate": "Data publikacji",
|
||||
"LabelPublishYear": "Rok publikacji",
|
||||
"LabelPublishedDate": "Opublikowano {0}",
|
||||
"LabelPublisher": "Wydawca",
|
||||
"LabelPublishers": "Wydawcy",
|
||||
"LabelRSSFeedOpen": "Otwarty Kanał RSS",
|
||||
"LabelRSSFeedOpen": "RSS Feed otwarty",
|
||||
"LabelRSSFeedPreventIndexing": "Zapobiegaj indeksowaniu",
|
||||
"LabelRSSFeedURL": "URL kanały RSS",
|
||||
"LabelRandomly": "Losowo",
|
||||
|
@ -506,22 +490,15 @@
|
|||
"LabelRecentlyAdded": "Niedawno dodane",
|
||||
"LabelRecommended": "Polecane",
|
||||
"LabelRedo": "Wycofaj",
|
||||
"LabelRegion": "Region",
|
||||
"LabelReleaseDate": "Data wydania",
|
||||
"LabelRemoveAllMetadataAbs": "Usuń wszystkie pliki metadata.abs",
|
||||
"LabelRemoveAllMetadataJson": "Usuń wszystkie pliki metadata.json",
|
||||
"LabelRemoveCover": "Usuń okładkę",
|
||||
"LabelRemoveMetadataFile": "Usuń pliki metadanych z folderów biblioteki",
|
||||
"LabelRemoveMetadataFileHelp": "Usuń wszystkie pliki metadata.json i metadata.abs z {0} folderów.",
|
||||
"LabelRowsPerPage": "Wierszy na stronę",
|
||||
"LabelSearchTerm": "Wyszukiwanie frazy",
|
||||
"LabelSearchTitle": "Wyszukaj tytuł",
|
||||
"LabelSearchTitleOrASIN": "Szukaj tytuł lub ASIN",
|
||||
"LabelSeason": "Sezon",
|
||||
"LabelSeasonNumber": "Sezon #{0}",
|
||||
"LabelSelectAll": "Wybierz wszystko",
|
||||
"LabelSelectAllEpisodes": "Wybierz wszystkie odcinki",
|
||||
"LabelSelectEpisodesShowing": "Wybierz {0} wyświetlanych odcinków",
|
||||
"LabelSelectUsers": "Wybór użytkowników",
|
||||
"LabelSendEbookToDevice": "Wyślij ebook do...",
|
||||
"LabelSequence": "Kolejność",
|
||||
|
@ -536,8 +513,6 @@
|
|||
"LabelSettingsBookshelfViewHelp": "Widok półki z książkami",
|
||||
"LabelSettingsChromecastSupport": "Wsparcie Chromecast",
|
||||
"LabelSettingsDateFormat": "Format daty",
|
||||
"LabelSettingsEnableWatcher": "Automatyczne skanowanie bibliotek w poszukiwaniu zmian",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Automatyczne skanowanie biblioteki w poszukiwaniu zmian",
|
||||
"LabelSettingsEnableWatcherHelp": "Włącza automatyczne dodawanie/aktualizację pozycji gdy wykryte zostaną zmiany w plikach. Wymaga restartu serwera",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Zezwalanie na skrypty w plikach epub",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Zezwala plikom epub na wykonywanie skryptów. Zaleca się mieć to ustawienie wyłączone, chyba że ma się zaufanie do źródła plików epub.",
|
||||
|
@ -549,8 +524,6 @@
|
|||
"LabelSettingsHideSingleBookSeriesHelp": "Serie, które posiadają tylko jedną książkę, nie będą pokazywane na stronie z seriami i na stronie domowej z półkami.",
|
||||
"LabelSettingsHomePageBookshelfView": "Widok półki z książkami na stronie głównej",
|
||||
"LabelSettingsLibraryBookshelfView": "Widok półki z książkami na stronie biblioteki",
|
||||
"LabelSettingsLibraryMarkAsFinishedWhen": "Oznacz element multimedialny jako ukończony, gdy",
|
||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Pomiń poprzednie książki przy kontynuacji serii",
|
||||
"LabelSettingsParseSubtitles": "Przetwarzaj podtytuły",
|
||||
"LabelSettingsParseSubtitlesHelp": "Opcja pozwala na pobranie podtytułu z nazwy folderu z audiobookiem. <br>Podtytuł musi być rozdzielony za pomocą separatora \" - \"<br>Przykład: \"Book Title - A Subtitle Here\" podtytuł \"A Subtitle Here\"",
|
||||
"LabelSettingsPreferMatchedMetadata": "Preferowanie dopasowanych metadanych",
|
||||
|
@ -574,9 +547,6 @@
|
|||
"LabelShowSubtitles": "Pokaż Napisy",
|
||||
"LabelSize": "Rozmiar",
|
||||
"LabelSleepTimer": "Wyłącznik czasowy",
|
||||
"LabelSortAscending": "Rosnąco",
|
||||
"LabelSortDescending": "Malejąco",
|
||||
"LabelSortPubDate": "Sortuj według daty publikacji",
|
||||
"LabelStart": "Rozpocznij",
|
||||
"LabelStartTime": "Czas rozpoczęcia",
|
||||
"LabelStarted": "Rozpoczęty",
|
||||
|
@ -598,21 +568,14 @@
|
|||
"LabelStatsWeekListening": "Tydzień słuchania",
|
||||
"LabelSubtitle": "Podtytuł",
|
||||
"LabelSupportedFileTypes": "Obsługiwane typy plików",
|
||||
"LabelTag": "Znacznik",
|
||||
"LabelTags": "Tagi",
|
||||
"LabelTagsAccessibleToUser": "Tagi dostępne dla użytkownika",
|
||||
"LabelTagsNotAccessibleToUser": "Znaczniki niedostępne dla użytkownika",
|
||||
"LabelTasks": "Uruchomione zadania",
|
||||
"LabelTextEditorLink": "Link",
|
||||
"LabelTextEditorNumberedList": "Lista numerowana",
|
||||
"LabelTextEditorUnlink": "Usuń link",
|
||||
"LabelThemeDark": "Ciemny",
|
||||
"LabelThemeLight": "Jasny",
|
||||
"LabelTimeDurationXHours": "{0} godzin",
|
||||
"LabelTimeDurationXMinutes": "{0} minuty",
|
||||
"LabelTimeDurationXSeconds": "{0} sekundy",
|
||||
"LabelTimeInMinutes": "Czas w minutach",
|
||||
"LabelTimeLeft": "pozostało {0}",
|
||||
"LabelTimeListened": "Czas odtwarzania",
|
||||
"LabelTimeListenedToday": "Czas odtwarzania dzisiaj",
|
||||
"LabelTimeRemaining": "Pozostało {0}",
|
||||
|
@ -620,7 +583,6 @@
|
|||
"LabelTitle": "Tytuł",
|
||||
"LabelToolsEmbedMetadata": "Załącz metadane",
|
||||
"LabelToolsEmbedMetadataDescription": "Załącz metadane do plików audio (okładkę oraz znaczniki rozdziałów).",
|
||||
"LabelToolsM4bEncoder": "Enkoder M4B",
|
||||
"LabelToolsMakeM4b": "Generuj plik M4B",
|
||||
"LabelToolsMakeM4bDescription": "Tworzy plik w formacie .M4B, który zawiera metadane, okładkę oraz rozdziały.",
|
||||
"LabelToolsSplitM4b": "Podziel plik .M4B na pliki .MP3",
|
||||
|
@ -633,14 +595,12 @@
|
|||
"LabelType": "Typ",
|
||||
"LabelUndo": "Wycofaj",
|
||||
"LabelUnknown": "Nieznany",
|
||||
"LabelUnknownPublishDate": "Nieznana data publikacji",
|
||||
"LabelUpdateCover": "Zaktalizuj odkładkę",
|
||||
"LabelUpdateCoverHelp": "Umożliwienie nadpisania istniejących okładek dla wybranych książek w przypadku znalezienia dopasowania",
|
||||
"LabelUpdateDetails": "Zaktualizuj szczegóły",
|
||||
"LabelUpdateDetailsHelp": "Umożliwienie nadpisania istniejących szczegółów dla wybranych książek w przypadku znalezienia dopasowania",
|
||||
"LabelUpdatedAt": "Zaktualizowano",
|
||||
"LabelUploaderDragAndDrop": "Przeciągnij i puść foldery lub pliki",
|
||||
"LabelUploaderDragAndDropFilesOnly": "Przeciągnij i upuść pliki",
|
||||
"LabelUploaderDropFiles": "Puść pliki",
|
||||
"LabelUploaderItemFetchMetadataHelp": "Automatycznie pobierz tytuł, autora i serie",
|
||||
"LabelUseChapterTrack": "Użyj ścieżki rozdziału",
|
||||
|
|
|
@ -348,7 +348,7 @@
|
|||
"LabelExpandSubSeries": "Развернуть подсерию",
|
||||
"LabelExplicit": "18+",
|
||||
"LabelExplicitChecked": "18+ (отмечено)",
|
||||
"LabelExplicitUnchecked": "+18 (не отмечено)",
|
||||
"LabelExplicitUnchecked": "Не явно (не отмечено)",
|
||||
"LabelExportOPML": "Экспорт OPML",
|
||||
"LabelFeedURL": "URL канала",
|
||||
"LabelFetchingMetadata": "Извлечение метаданных",
|
||||
|
@ -514,7 +514,7 @@
|
|||
"LabelPublishers": "Издатели",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца",
|
||||
"LabelRSSFeedCustomOwnerName": "Пользовательское Имя владельца",
|
||||
"LabelRSSFeedOpen": "Открыть RSS-ленту",
|
||||
"LabelRSSFeedOpen": "Открыть RSS-канал",
|
||||
"LabelRSSFeedPreventIndexing": "Запретить индексирование",
|
||||
"LabelRSSFeedSlug": "Встроить RSS-канал",
|
||||
"LabelRSSFeedURL": "URL RSS-канала",
|
||||
|
@ -918,8 +918,6 @@
|
|||
"NotificationOnBackupCompletedDescription": "Запускается при завершении резервного копирования",
|
||||
"NotificationOnBackupFailedDescription": "Срабатывает при сбое резервного копирования",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Запускается при автоматической загрузке эпизода подкаста",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Срабатывает, когда автоматическая загрузка эпизодов отключена из-за слишком большого количества неудачных попыток",
|
||||
"NotificationOnRSSFeedFailedDescription": "Срабатывает при сбое запроса RSS-канала на автоматическую загрузку эпизода",
|
||||
"NotificationOnTestDescription": "Событие для тестирования системы оповещения",
|
||||
"PlaceholderNewCollection": "Новое имя коллекции",
|
||||
"PlaceholderNewFolderPath": "Путь к новой папке",
|
||||
|
|
|
@ -346,7 +346,7 @@
|
|||
"LabelExample": "Príklad",
|
||||
"LabelExpandSeries": "Rozbaliť série",
|
||||
"LabelExpandSubSeries": "Rozbaliť podsérie",
|
||||
"LabelExplicit": "Explicitný obsah",
|
||||
"LabelExplicit": "Explicitné",
|
||||
"LabelExplicitChecked": "Explicitné (zaškrtnuté)",
|
||||
"LabelExplicitUnchecked": "Ne-explicitné (nezaškrtnuté)",
|
||||
"LabelExportOPML": "Exportovať OPML",
|
||||
|
@ -757,7 +757,6 @@
|
|||
"MessageConfirmRemoveAuthor": "Ste si istý, že chcete odstrániť autora \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "Ste si istý, že chcete odstrániť zbierku \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Ste si istý, že chcete odstrániť epizódu \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodeNote": "Poznámka: Tento krok neodstráni zvukový súbor, pokiaľ nezaškrtnete voľbu \"Nezvratné zmazanie súborov\"",
|
||||
"MessageConfirmRemoveEpisodes": "Ste si istý, že chcete odstrániť {0} epizód?",
|
||||
"MessageConfirmRemoveListeningSessions": "Ste si istý, že chcete odstrániť týchto {0} relácií?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Ste si istý, že chcete odstrániť všetky súbory metadata.{0} z priečinkov položiek vašej knižnice?",
|
||||
|
@ -919,8 +918,6 @@
|
|||
"NotificationOnBackupCompletedDescription": "Spustené po dokončení zálohovania",
|
||||
"NotificationOnBackupFailedDescription": "Spustené pri zlyhaní zálohovania",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Spustené po automatickom stiahnutí epizódy podcastu",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Spustí sa, keď je automatické sťahovanie epizód pozastavené z dôvodu veľkého počtu zlyhaní",
|
||||
"NotificationOnRSSFeedFailedDescription": "Spustí sa v prípade, keď zlyhá požiadavka RSS zdroja na automatické stiahnutie epizódy",
|
||||
"NotificationOnTestDescription": "Udalosť určená na testovanie systému notifikácií",
|
||||
"PlaceholderNewCollection": "Názov novej zbierky",
|
||||
"PlaceholderNewFolderPath": "Umiestnenie nového priečinka",
|
||||
|
|
|
@ -514,7 +514,7 @@
|
|||
"LabelPublishers": "Izdajatelji",
|
||||
"LabelRSSFeedCustomOwnerEmail": "E-pošta lastnika po meri",
|
||||
"LabelRSSFeedCustomOwnerName": "Ime lastnika po meri",
|
||||
"LabelRSSFeedOpen": "RSS vir je odprt",
|
||||
"LabelRSSFeedOpen": "Odprt vir RSS",
|
||||
"LabelRSSFeedPreventIndexing": "Prepreči indeksiranje",
|
||||
"LabelRSSFeedSlug": "Slug RSS vira",
|
||||
"LabelRSSFeedURL": "URL vira RSS",
|
||||
|
@ -757,7 +757,6 @@
|
|||
"MessageConfirmRemoveAuthor": "Ali ste prepričani, da želite odstraniti avtorja \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "Ali ste prepričani, da želite odstraniti zbirko \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Ali ste prepričani, da želite odstraniti epizodo \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodeNote": "Opomba: S tem se zvočna datoteka ne izbriše, razen če vklopite možnost \"Trdo brisanje datoteke\"",
|
||||
"MessageConfirmRemoveEpisodes": "Ali ste prepričani, da želite odstraniti {0} epizod?",
|
||||
"MessageConfirmRemoveListeningSessions": "Ali ste prepričani, da želite odstraniti {0} sej poslušanja?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Ali ste prepričani, da želite odstraniti vse metapodatke.{0} v mapah elementov knjižnice?",
|
||||
|
@ -919,8 +918,6 @@
|
|||
"NotificationOnBackupCompletedDescription": "Sproži se, ko je varnostno kopiranje končano",
|
||||
"NotificationOnBackupFailedDescription": "Sproži se, ko varnostno kopiranje ne uspe",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Sproži se, ko se epizoda podcasta samodejno prenese",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Sproži se, ko so samodejni prenosi epizod onemogočeni zaradi preveč neuspelih poskusov",
|
||||
"NotificationOnRSSFeedFailedDescription": "Sproži se, ko zahteva za vir RSS za samodejni prenos epizode ne uspe",
|
||||
"NotificationOnTestDescription": "Dogodek za testiranje sistema obveščanja",
|
||||
"PlaceholderNewCollection": "Novo ime zbirke",
|
||||
"PlaceholderNewFolderPath": "Pot nove mape",
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"ButtonAddChapters": "Додати глави",
|
||||
"ButtonAddDevice": "Додати пристрій",
|
||||
"ButtonAddLibrary": "Додати бібліотеку",
|
||||
"ButtonAddPodcasts": "Додати подкасти",
|
||||
"ButtonAddPodcasts": "Додати подкаст",
|
||||
"ButtonAddUser": "Додати користувача",
|
||||
"ButtonAddYourFirstLibrary": "Додайте вашу першу бібліотеку",
|
||||
"ButtonApply": "Застосувати",
|
||||
|
@ -16,7 +16,7 @@
|
|||
"ButtonCancel": "Скасувати",
|
||||
"ButtonCancelEncode": "Скасувати кодування",
|
||||
"ButtonChangeRootPassword": "Змінити кореневий пароль",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Перевірити та скачати нові епізоди",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Перевірити та завантажити нові епізоди",
|
||||
"ButtonChooseAFolder": "Обрати теку",
|
||||
"ButtonChooseFiles": "Обрати файли",
|
||||
"ButtonClearFilter": "Очистити фільтр",
|
||||
|
@ -32,8 +32,8 @@
|
|||
"ButtonEditChapters": "Редагувати глави",
|
||||
"ButtonEditPodcast": "Редагувати подкаст",
|
||||
"ButtonEnable": "Увімкнути",
|
||||
"ButtonFireAndFail": "Виконати і завершити з помилкою",
|
||||
"ButtonFireOnTest": "Виконати подію onTest",
|
||||
"ButtonFireAndFail": "Вогонь і невдача",
|
||||
"ButtonFireOnTest": "Випробування на вогнестійкість",
|
||||
"ButtonForceReScan": "Примусово сканувати",
|
||||
"ButtonFullPath": "Повний шлях",
|
||||
"ButtonHide": "Приховати",
|
||||
|
@ -44,7 +44,7 @@
|
|||
"ButtonLatest": "Останні",
|
||||
"ButtonLibrary": "Бібліотека",
|
||||
"ButtonLogout": "Вийти",
|
||||
"ButtonLookup": "Пошуки",
|
||||
"ButtonLookup": "Пошук",
|
||||
"ButtonManageTracks": "Керувати доріжками",
|
||||
"ButtonMapChapterTitles": "Призначити назви глав",
|
||||
"ButtonMatchAllAuthors": "Віднайти усіх авторів",
|
||||
|
@ -57,7 +57,7 @@
|
|||
"ButtonOpenFeed": "Відкрити стрічку",
|
||||
"ButtonOpenManager": "Відкрити менеджер",
|
||||
"ButtonPause": "Пауза",
|
||||
"ButtonPlay": "Відтворити",
|
||||
"ButtonPlay": "Слухати",
|
||||
"ButtonPlayAll": "Відтворити все",
|
||||
"ButtonPlaying": "Відтворюється",
|
||||
"ButtonPlaylists": "Списки відтворення",
|
||||
|
@ -86,7 +86,7 @@
|
|||
"ButtonResetToDefault": "Скинути до стандартних",
|
||||
"ButtonRestore": "Відновити",
|
||||
"ButtonSave": "Зберегти",
|
||||
"ButtonSaveAndClose": "Зберегти і закрити",
|
||||
"ButtonSaveAndClose": "Зберегти та закрити",
|
||||
"ButtonSaveTracklist": "Зберегти порядок",
|
||||
"ButtonScan": "Сканувати",
|
||||
"ButtonScanLibrary": "Сканувати бібліотеку",
|
||||
|
@ -103,7 +103,7 @@
|
|||
"ButtonStartMetadataEmbed": "Почати вбудування метаданих",
|
||||
"ButtonStats": "Статистика",
|
||||
"ButtonSubmit": "Надіслати",
|
||||
"ButtonTest": "Тест",
|
||||
"ButtonTest": "Перевірити",
|
||||
"ButtonUnlinkOpenId": "Вимкнути OpenID",
|
||||
"ButtonUpload": "Завантажити",
|
||||
"ButtonUploadBackup": "Завантажити резервну копію",
|
||||
|
@ -115,7 +115,7 @@
|
|||
"ButtonYes": "Так",
|
||||
"ErrorUploadFetchMetadataAPI": "Помилка при отриманні метаданих",
|
||||
"ErrorUploadFetchMetadataNoResults": "Не вдалося отримати метадані — спробуйте оновити заголовок та/або автора",
|
||||
"ErrorUploadLacksTitle": "Потрібна назва",
|
||||
"ErrorUploadLacksTitle": "Назва обов'язкова",
|
||||
"HeaderAccount": "Профіль",
|
||||
"HeaderAddCustomMetadataProvider": "Додати користувацький постачальник метаданих",
|
||||
"HeaderAdvanced": "Розширені",
|
||||
|
@ -130,11 +130,11 @@
|
|||
"HeaderCollection": "Добірка",
|
||||
"HeaderCollectionItems": "Елементи добірки",
|
||||
"HeaderCover": "Обкладинка",
|
||||
"HeaderCurrentDownloads": "Поточні скачування",
|
||||
"HeaderCurrentDownloads": "Поточні завантаження",
|
||||
"HeaderCustomMessageOnLogin": "Повідомлення при вході",
|
||||
"HeaderCustomMetadataProviders": "Постачальники метаданих",
|
||||
"HeaderDetails": "Подробиці",
|
||||
"HeaderDownloadQueue": "Черга скачувань",
|
||||
"HeaderDownloadQueue": "Черга завантажень",
|
||||
"HeaderEbookFiles": "Файли електронних книг",
|
||||
"HeaderEmail": "Електронна пошта",
|
||||
"HeaderEmailSettings": "Налаштування електронної пошти",
|
||||
|
@ -152,13 +152,13 @@
|
|||
"HeaderLibraryFiles": "Файли бібліотеки",
|
||||
"HeaderLibraryStats": "Статистика бібліотеки",
|
||||
"HeaderListeningSessions": "Сеанси прослуховування",
|
||||
"HeaderListeningStats": "Статистика прослуховування",
|
||||
"HeaderListeningStats": "Статистика відтворення",
|
||||
"HeaderLogin": "Вхід",
|
||||
"HeaderLogs": "Журнал",
|
||||
"HeaderManageGenres": "Керувати жанрами",
|
||||
"HeaderManageTags": "Керувати мітками",
|
||||
"HeaderMapDetails": "Призначити подробиці",
|
||||
"HeaderMatch": "Допасуй",
|
||||
"HeaderMatch": "Пошук",
|
||||
"HeaderMetadataOrderOfPrecedence": "Порядок метаданих",
|
||||
"HeaderMetadataToEmbed": "Вбудувати метадані",
|
||||
"HeaderNewAccount": "Новий профіль",
|
||||
|
@ -176,7 +176,7 @@
|
|||
"HeaderPlayerSettings": "Налаштування програвача",
|
||||
"HeaderPlaylist": "Список відтворення",
|
||||
"HeaderPlaylistItems": "Елементи списку відтворення",
|
||||
"HeaderPodcastsToAdd": "Подкасти для додання",
|
||||
"HeaderPodcastsToAdd": "Додати подкасти",
|
||||
"HeaderPresets": "Пресети",
|
||||
"HeaderPreviewCover": "Попередній перегляд",
|
||||
"HeaderRSSFeedGeneral": "Подробиці RSS",
|
||||
|
@ -186,7 +186,7 @@
|
|||
"HeaderRemoveEpisodes": "Видалити епізодів: {0}",
|
||||
"HeaderSavedMediaProgress": "Збережений прогрес медіа",
|
||||
"HeaderSchedule": "Розклад",
|
||||
"HeaderScheduleEpisodeDownloads": "Запланувати автоматичне скачування епізодів",
|
||||
"HeaderScheduleEpisodeDownloads": "Запланувати автоматичне завантаження епізодів",
|
||||
"HeaderScheduleLibraryScans": "Розклад автосканування бібліотеки",
|
||||
"HeaderSession": "Сеанс",
|
||||
"HeaderSetBackupSchedule": "Встановити розклад резервного копіювання",
|
||||
|
@ -223,21 +223,21 @@
|
|||
"LabelActivities": "Діяльність",
|
||||
"LabelActivity": "Активність",
|
||||
"LabelAddToCollection": "Додати у добірку",
|
||||
"LabelAddToCollectionBatch": "Додати {0} книг до добірки",
|
||||
"LabelAddToCollectionBatch": "Додати книги до добірки: {0}",
|
||||
"LabelAddToPlaylist": "Додати до списку відтворення",
|
||||
"LabelAddToPlaylistBatch": "Додати {0} елементів до списку відтворення",
|
||||
"LabelAddToPlaylistBatch": "Додано елементів у список відтворення: {0}",
|
||||
"LabelAddedAt": "Дата додавання",
|
||||
"LabelAddedDate": "Додано {0}",
|
||||
"LabelAdminUsersOnly": "Тільки для адміністраторів",
|
||||
"LabelAll": "Усе",
|
||||
"LabelAllEpisodesDownloaded": "Усі епізоди скачано",
|
||||
"LabelAllEpisodesDownloaded": "Усі серії завантажено",
|
||||
"LabelAllUsers": "Усі користувачі",
|
||||
"LabelAllUsersExcludingGuests": "Усі, крім гостей",
|
||||
"LabelAllUsersIncludingGuests": "Усі, включно з гостями",
|
||||
"LabelAlreadyInYourLibrary": "Вже у вашій бібліотеці",
|
||||
"LabelApiToken": "Токен API",
|
||||
"LabelAppend": "Додати",
|
||||
"LabelAudioBitrate": "Бітрейт аудіо (наприклад, 128k)",
|
||||
"LabelAudioBitrate": "Бітрейт аудіо (напр. 128k)",
|
||||
"LabelAudioChannels": "Канали аудіо (1 або 2)",
|
||||
"LabelAudioCodec": "Аудіокодек",
|
||||
"LabelAuthor": "Автор",
|
||||
|
@ -256,18 +256,18 @@
|
|||
"LabelBackupLocation": "Розташування резервних копій",
|
||||
"LabelBackupsEnableAutomaticBackups": "Автоматичне резервне копіювання",
|
||||
"LabelBackupsEnableAutomaticBackupsHelp": "Резервні копії збережено у /metadata/backups",
|
||||
"LabelBackupsMaxBackupSize": "Максимальний розмір резервної копії (у ГБ) (0 — без обмежень)",
|
||||
"LabelBackupsMaxBackupSize": "Максимальний розмір резервної копії (у ГБ) (0 — необмежене)",
|
||||
"LabelBackupsMaxBackupSizeHelp": "У якості захисту від неправильного налаштування, резервну копію не буде збережено, якщо її розмір перевищуватиме вказаний.",
|
||||
"LabelBackupsNumberToKeep": "Кількість резервних копій",
|
||||
"LabelBackupsNumberToKeepHelp": "Видаляється лише 1 резервна копія за раз, тому якщо у вас більше копій, видаліть їх вручну.",
|
||||
"LabelBackupsNumberToKeepHelp": "Лиш 1 резервну копію буде видалено за раз, тож якщо їх багато, то вам варто видалити їх вручну.",
|
||||
"LabelBitrate": "Бітрейт",
|
||||
"LabelBonus": "Бонус",
|
||||
"LabelBooks": "Книг",
|
||||
"LabelBooks": "Книги",
|
||||
"LabelButtonText": "Текст кнопки",
|
||||
"LabelByAuthor": "від {0}",
|
||||
"LabelChangePassword": "Змінити пароль",
|
||||
"LabelChannels": "Канали",
|
||||
"LabelChapterCount": "{0} глав",
|
||||
"LabelChapterCount": "{0} Глав",
|
||||
"LabelChapterTitle": "Назва глави",
|
||||
"LabelChapters": "Глави",
|
||||
"LabelChaptersFound": "глав знайдено",
|
||||
|
@ -304,9 +304,9 @@
|
|||
"LabelDiscFromFilename": "Диск за назвою файлу",
|
||||
"LabelDiscFromMetadata": "Диск за метаданими",
|
||||
"LabelDiscover": "Огляд",
|
||||
"LabelDownload": "Скачати",
|
||||
"LabelDownloadNEpisodes": "Скачати {0} епізодів",
|
||||
"LabelDownloadable": "Можна скачати",
|
||||
"LabelDownload": "Завантажити",
|
||||
"LabelDownloadNEpisodes": "Завантажити епізодів: {0}",
|
||||
"LabelDownloadable": "Можна завантажити",
|
||||
"LabelDuration": "Тривалість",
|
||||
"LabelDurationComparisonExactMatch": "(повний збіг)",
|
||||
"LabelDurationComparisonLonger": "(на {0} довше)",
|
||||
|
@ -346,16 +346,16 @@
|
|||
"LabelExample": "Приклад",
|
||||
"LabelExpandSeries": "Розгорнути серії",
|
||||
"LabelExpandSubSeries": "Розгорнути підсерії",
|
||||
"LabelExplicit": "Відвертий",
|
||||
"LabelExplicit": "Відверта",
|
||||
"LabelExplicitChecked": "Відверта (з прапорцем)",
|
||||
"LabelExplicitUnchecked": "Не відверта (без прапорця)",
|
||||
"LabelExportOPML": "Експорт OPML",
|
||||
"LabelFeedURL": "Адреса стрічки",
|
||||
"LabelFetchingMetadata": "Отримання метаданих",
|
||||
"LabelFile": "Файл",
|
||||
"LabelFileBirthtime": "Дата створення файлу",
|
||||
"LabelFileBirthtime": "Дата створення",
|
||||
"LabelFileBornDate": "Народився {0}",
|
||||
"LabelFileModified": "Дата зміни файлу",
|
||||
"LabelFileModified": "Дата змінення",
|
||||
"LabelFileModifiedDate": "Змінено {0}",
|
||||
"LabelFilename": "Ім'я файлу",
|
||||
"LabelFilterByUser": "Фільтрувати за користувачем",
|
||||
|
@ -395,7 +395,7 @@
|
|||
"LabelIntervalEvery6Hours": "Кожні 6 годин",
|
||||
"LabelIntervalEveryDay": "Щодня",
|
||||
"LabelIntervalEveryHour": "Щогодини",
|
||||
"LabelIntervalEveryMinute": "Щохвилини",
|
||||
"LabelIntervalEveryMinute": "Кожну хвилину",
|
||||
"LabelInvert": "Інвертувати",
|
||||
"LabelItem": "Елемент",
|
||||
"LabelJumpBackwardAmount": "Час переходу назад",
|
||||
|
@ -427,10 +427,10 @@
|
|||
"LabelLowestPriority": "Найнижчий пріоритет",
|
||||
"LabelMatchExistingUsersBy": "Шукати наявних користувачів за",
|
||||
"LabelMatchExistingUsersByDescription": "Використовується для підключення наявних користувачів. Після підключення користувач отримає унікальний id від вашого сервісу SSO",
|
||||
"LabelMaxEpisodesToDownload": "Максимальна кількість епізодів для скачування. Використовуйте 0 для необмеженої кількості.",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Максимальна кількість нових епізодів для скачування за перевірку",
|
||||
"LabelMaxEpisodesToDownload": "Максимальна кількість епізодів для завантаження. Використовуйте 0 для необмеженої кількості.",
|
||||
"LabelMaxEpisodesToDownloadPerCheck": "Максимальна кількість нових епізодів для завантаження за перевірку",
|
||||
"LabelMaxEpisodesToKeep": "Максимальна кількість епізодів для зберігання",
|
||||
"LabelMaxEpisodesToKeepHelp": "Значення 0 — без обмежень. Після автоматичного завантаження нового епізоду буде видалено найстаріший, якщо їх більше X. Видаляється лише 1 епізод за одне нове завантаження.",
|
||||
"LabelMaxEpisodesToKeepHelp": "Значення 0 не встановлює обмеження. Після автоматичного завантаження нового епізоду, буде видалено найстаріший епізод, якщо у вас більше ніж X епізодів. Видаляється лише 1 епізод за одне нове завантаження.",
|
||||
"LabelMediaPlayer": "Програвач медіа",
|
||||
"LabelMediaType": "Тип медіа",
|
||||
"LabelMetaTag": "Метатег",
|
||||
|
@ -485,7 +485,7 @@
|
|||
"LabelPermissionsAccessExplicitContent": "Доступ до відвертого вмісту",
|
||||
"LabelPermissionsCreateEreader": "Можна створити читалку",
|
||||
"LabelPermissionsDelete": "Може видаляти",
|
||||
"LabelPermissionsDownload": "Може скачувати",
|
||||
"LabelPermissionsDownload": "Може завантажувати",
|
||||
"LabelPermissionsUpdate": "Може оновлювати",
|
||||
"LabelPermissionsUpload": "Може завантажувати",
|
||||
"LabelPersonalYearReview": "Ваші підсумки року ({0})",
|
||||
|
@ -514,7 +514,7 @@
|
|||
"LabelPublishers": "Видавці",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Користувацька електронна адреса власника",
|
||||
"LabelRSSFeedCustomOwnerName": "Користувацьке ім'я власника",
|
||||
"LabelRSSFeedOpen": "RSS-канал відкритий",
|
||||
"LabelRSSFeedOpen": "RSS-канал відкрито",
|
||||
"LabelRSSFeedPreventIndexing": "Запобігати індексації",
|
||||
"LabelRSSFeedSlug": "Назва RSS-каналу",
|
||||
"LabelRSSFeedURL": "Адреса RSS-каналу",
|
||||
|
@ -542,8 +542,8 @@
|
|||
"LabelSeason": "Сезон",
|
||||
"LabelSeasonNumber": "Сезон #{0}",
|
||||
"LabelSelectAll": "Вибрати все",
|
||||
"LabelSelectAllEpisodes": "Вибрати всі епізоди",
|
||||
"LabelSelectEpisodesShowing": "Вибрати {0} показаних епізодів",
|
||||
"LabelSelectAllEpisodes": "Вибрати всі серії",
|
||||
"LabelSelectEpisodesShowing": "Обрати показані епізоди: {0}",
|
||||
"LabelSelectUsers": "Вибрати користувачів",
|
||||
"LabelSendEbookToDevice": "Надіслати електронну книгу на...",
|
||||
"LabelSequence": "Послідовність",
|
||||
|
@ -595,7 +595,7 @@
|
|||
"LabelSettingsStoreMetadataWithItemHelp": "За замовчуванням файли метаданих зберігаються у /metadata/items. Цей параметр увімкне збереження метаданих у теці елемента бібліотеки",
|
||||
"LabelSettingsTimeFormat": "Формат часу",
|
||||
"LabelShare": "Поділитися",
|
||||
"LabelShareDownloadableHelp": "Дозволяє користувачам із посиланням для спільного доступу скачування zip-файлу елемента бібліотеки.",
|
||||
"LabelShareDownloadableHelp": "Дозволяє користувачам із посиланням для спільного доступу завантажувати zip-файл елемента бібліотеки.",
|
||||
"LabelShareOpen": "Поділитися відкрито",
|
||||
"LabelShareURL": "Поділитися URL",
|
||||
"LabelShowAll": "Показати все",
|
||||
|
@ -714,19 +714,19 @@
|
|||
"MessageBackupsLocationNoEditNote": "Примітка: розташування резервної копії встановлюється за допомогою змінної середовища та не може бути змінене тут.",
|
||||
"MessageBackupsLocationPathEmpty": "Шлях розташування резервної копії не може бути порожнім",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Заповнити увімкнені поля даними з усіх елементів. Поля з кількома значеннями буде об’єднано",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "Заповнити увімкнені поля деталізації даними з цього елемента",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "Заповніть увімкнені поля деталей карти даними з цього елемента",
|
||||
"MessageBatchQuickMatchDescription": "Швидкий пошук спробує знайти відсутні обкладинки та метадані обраних елементів. Увімкніть налаштування нижче, аби дозволити заміну наявних обкладинок та/або метаданих під час швидкого пошуку.",
|
||||
"MessageBookshelfNoCollections": "Ви ще не створили жодної добірки",
|
||||
"MessageBookshelfNoCollections": "Ви не створили жодної добірки",
|
||||
"MessageBookshelfNoCollectionsHelp": "Колекції публічні. Їх можуть бачити всі користувачі, які мають доступ до бібліотеки.",
|
||||
"MessageBookshelfNoRSSFeeds": "Немає відкритих RSS-каналів",
|
||||
"MessageBookshelfNoResultsForFilter": "Немає результатів з фільтром \"{0}: {1}\"",
|
||||
"MessageBookshelfNoResultsForQuery": "Немає результатів за запитом",
|
||||
"MessageBookshelfNoSeries": "У вас немає серій",
|
||||
"MessageChapterEndIsAfter": "Кінець глави після завершення аудіокниги",
|
||||
"MessageChapterErrorFirstNotZero": "Перша глава повинна починатися з 0",
|
||||
"MessageChapterErrorStartGteDuration": "Час початку має бути меншим за тривалість аудіокниги",
|
||||
"MessageChapterErrorStartLtPrev": "Час початку має бути більшим або рівним часу початку попередньої глави",
|
||||
"MessageChapterStartIsAfter": "Початок глави після завершення аудіокниги",
|
||||
"MessageBookshelfNoSeries": "Серії відсутні",
|
||||
"MessageChapterEndIsAfter": "Кінець глави знаходиться після закінчення книги",
|
||||
"MessageChapterErrorFirstNotZero": "Перша глава мусить починатися з 0",
|
||||
"MessageChapterErrorStartGteDuration": "Час початку мусить бути меншим за тривалість аудіокниги",
|
||||
"MessageChapterErrorStartLtPrev": "Неприпустимий час початку, має бути більшим за час початку попередньої глави",
|
||||
"MessageChapterStartIsAfter": "Початок глави знаходиться після закінчення книги",
|
||||
"MessageChaptersNotFound": "Розділи не знайдені",
|
||||
"MessageCheckingCron": "Перевірка планувальника...",
|
||||
"MessageConfirmCloseFeed": "Ви дійсно бажаєте закрити цей канал?",
|
||||
|
@ -734,72 +734,71 @@
|
|||
"MessageConfirmDeleteDevice": "Ви впевнені, що хочете видалити пристрій для читання \"{0}\"?",
|
||||
"MessageConfirmDeleteFile": "Файл буде видалено з вашої файлової системи. Ви впевнені?",
|
||||
"MessageConfirmDeleteLibrary": "Ви дійсно бажаєте назавжди видалити бібліотеку \"{0}\"?",
|
||||
"MessageConfirmDeleteLibraryItem": "Елемент бібліотеки буде видалено з бази даних і файлової системи. Ви впевнені?",
|
||||
"MessageConfirmDeleteLibraryItems": "Буде видалено {0} елементів бібліотеки з бази даних і файлової системи. Ви впевнені?",
|
||||
"MessageConfirmDeleteLibraryItem": "Елемент бібліотеки буде видалено з бази даних та вашої файлової системи. Ви впевнені?",
|
||||
"MessageConfirmDeleteLibraryItems": "З бази даних та вашої файлової системи будуть видалені елементи бібліотеки: {0}. Ви впевнені?",
|
||||
"MessageConfirmDeleteMetadataProvider": "Ви впевнені, що хочете видалити користувацького постачальника метаданих \"{0}\"?",
|
||||
"MessageConfirmDeleteNotification": "Ви впевнені, що хочете видалити це сповіщення?",
|
||||
"MessageConfirmDeleteSession": "Ви дійсно бажаєте видалити цей сеанс?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Ви впевнені, що хочете вбудувати метадані у {0} аудіофайлів?",
|
||||
"MessageConfirmEmbedMetadataInAudioFiles": "Ви впевнені, що хочете вставити метадані в {0} аудіофайлів?",
|
||||
"MessageConfirmForceReScan": "Ви дійсно бажаєте примусово пересканувати?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Ви впевнені, що хочете позначити всі епізоди завершеними?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Ви впевнені, що хочете позначити всі епізоди незавершеними?",
|
||||
"MessageConfirmMarkAllEpisodesFinished": "Ви дійсно бажаєте позначити усі епізоди завершеними?",
|
||||
"MessageConfirmMarkAllEpisodesNotFinished": "Ви дійсно бажаєте позначити усі епізоди незавершеними?",
|
||||
"MessageConfirmMarkItemFinished": "Ви впевнені, що хочете позначити \"{0}\" як завершене?",
|
||||
"MessageConfirmMarkItemNotFinished": "Ви впевнені, що хочете позначити \"{0}\" як незавершене?",
|
||||
"MessageConfirmMarkSeriesFinished": "Ви дійсно бажаєте позначити усі книги серії завершеними?",
|
||||
"MessageConfirmMarkSeriesNotFinished": "Ви дійсно бажаєте позначити всі книги серії незавершеними?",
|
||||
"MessageConfirmNotificationTestTrigger": "Активувати це сповіщення з тестовими даними?",
|
||||
"MessageConfirmPurgeCache": "Очищення кешу видалить всю теку <code>/metadata/cache</code>. <br /><br />Ви впевнені, що хочете видалити теку кешу?",
|
||||
"MessageConfirmPurgeItemsCache": "Очищення кешу елементів видалить всю теку <code>/metadata/cache/items</code>.<br />Ви впевнені?",
|
||||
"MessageConfirmQuickEmbed": "Увага! Швидке вбудовування не створює резервних копій ваших аудіофайлів. Переконайтеся, що маєте резервну копію. <br><br>Продовжити?",
|
||||
"MessageConfirmQuickMatchEpisodes": "Швидке співставлення епізодів перезапише подробиці, якщо знайдено відповідність. Оновлюються лише невідповідні епізоди. Ви впевнені?",
|
||||
"MessageConfirmReScanLibraryItems": "Ви впевнені, що хочете пересканувати {0} елементів?",
|
||||
"MessageConfirmPurgeCache": "Очищення кешу видалить усю теку <code>/metadata/cache</code>. <br /><br />Ви дійсно бажаєте видалити теку кешу?",
|
||||
"MessageConfirmPurgeItemsCache": "Очищення кешу елементів видалить усю теку <code>/metadata/cache/items</code>. <br />Ви певні?",
|
||||
"MessageConfirmQuickEmbed": "Увага! Швидке вбудування не створює резервних копій ваших аудіо. Переконайтеся, що маєте копію ваших файлів.<br><br>Продовжити?",
|
||||
"MessageConfirmQuickMatchEpisodes": "При виявленні співпадінь інформація про епізоди швидкого пошуку буде перезаписана. Будуть оновлені тільки несуперечливі епізоди. Ви впевнені?",
|
||||
"MessageConfirmReScanLibraryItems": "Ви дійсно бажаєте пересканувати елементи: {0}?",
|
||||
"MessageConfirmRemoveAllChapters": "Ви дійсно бажаєте видалити усі глави?",
|
||||
"MessageConfirmRemoveAuthor": "Ви дійсно бажаєте видалити автора \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "Ви дійсно бажаєте видалити добірку \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "Ви дійсно бажаєте видалити епізод \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodeNote": "Примітка: Це не видаляє аудіофайл, якщо не перемикає \"файл жорсткого видалення\"",
|
||||
"MessageConfirmRemoveEpisodes": "Ви дійсно бажаєте видалити епізодів: {0}?",
|
||||
"MessageConfirmRemoveListeningSessions": "Ви дійсно бажаєте видалити сеанси прослуховування: {0}?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Ви впевнені, що хочете видалити всі файли metadata.{0} у папках елементів вашої бібліотеки?",
|
||||
"MessageConfirmRemoveNarrator": "Ви дійсно бажаєте видалити читця \"{0}\"?",
|
||||
"MessageConfirmRemovePlaylist": "Ви дійсно бажаєте видалити ваш список відтворення \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Ви впевнені, що хочете перейменувати жанр \"{0}\" на \"{1}\" для всіх елементів?",
|
||||
"MessageConfirmRenameGenreMergeNote": "Примітка: Такий жанр вже існує, тому вони будуть об'єднані.",
|
||||
"MessageConfirmRenameGenreWarning": "Увага! Схожий жанр з іншом регістром вже існує \"{0}\".",
|
||||
"MessageConfirmRenameTag": "Ви впевнені, що хочете перейменувати мітку \"{0}\" на \"{1}\" для всіх елементів?",
|
||||
"MessageConfirmRenameTagMergeNote": "Примітка: Така мітка вже існує, тому вони будуть об'єднані.",
|
||||
"MessageConfirmRenameTagWarning": "Увага! Схожа мітка з іншою регістром вже існує \"{0}\".",
|
||||
"MessageConfirmRemovePlaylist": "Ви дійсно бажаєте видалити список відтворення \"{0}\"?",
|
||||
"MessageConfirmRenameGenre": "Ви дійсно бажаєте замінити жанр \"{0}\" на \"{1}\" для усіх елементів?",
|
||||
"MessageConfirmRenameGenreMergeNote": "Примітка: такий жанр вже існує, тож їх буде об'єднано.",
|
||||
"MessageConfirmRenameGenreWarning": "Увага! Вже існує схожий жанр у іншому регістрі \"{0}\".",
|
||||
"MessageConfirmRenameTag": "Ви дійсно бажаєте замінити мітку \"{0}\" на \"{1}\" для усіх елементів?",
|
||||
"MessageConfirmRenameTagMergeNote": "Примітка: така мітка вже існує, тож їх буде об'єднано.",
|
||||
"MessageConfirmRenameTagWarning": "Увага! Вже існує схожа мітка у іншому регістрі \"{0}\".",
|
||||
"MessageConfirmResetProgress": "Ви впевнені, що хочете скинути свій прогрес?",
|
||||
"MessageConfirmSendEbookToDevice": "Ви дійсно хочете відправити на пристрій \"{2}\" електроні книги: {0}, \"{1}\"?",
|
||||
"MessageConfirmUnlinkOpenId": "Ви впевнені, що хочете відв'язати цього користувача від OpenID?",
|
||||
"MessageDaysListenedInTheLastYear": "{0} днів, прослуханих за останній рік",
|
||||
"MessageDownloadingEpisode": "Скачування епізоду",
|
||||
"MessageDownloadingEpisode": "Завантаження епізоду",
|
||||
"MessageDragFilesIntoTrackOrder": "Перетягніть файли до правильного порядку",
|
||||
"MessageEmbedFailed": "Не вдалося вбудувати!",
|
||||
"MessageEmbedFinished": "Вбудовування завершено!",
|
||||
"MessageEmbedQueue": "У черзі на вбудовування метаданих ({0} у черзі)",
|
||||
"MessageEpisodesQueuedForDownload": "{0} епізод(ів) у черзі на завантаження",
|
||||
"MessageEmbedFinished": "Вбудовано!",
|
||||
"MessageEmbedQueue": "В черзі на вбудовування метаданих ({0} в черзі)",
|
||||
"MessageEpisodesQueuedForDownload": "Епізодів у черзі завантаження: {0}",
|
||||
"MessageEreaderDevices": "Аби гарантувати отримання електронних книг, вам може знадобитися додати вказану вище адресу електронної пошти як правильного відправника на кожному з пристроїв зі списку нижче.",
|
||||
"MessageFeedURLWillBe": "URL-адреса каналу буде {0}",
|
||||
"MessageFetching": "Отримання...",
|
||||
"MessageForceReScanDescription": "Просканує всі файли заново, як при першому скануванні. ID3-мітки, OPF-файли та текстові файли будуть проскановані як нові.",
|
||||
"MessageForceReScanDescription": "Просканує усі файли заново, неначе вперше. ID3-мітки, файли OPF та текстові файли будуть проскановані як нові.",
|
||||
"MessageImportantNotice": "Важливе повідомлення!",
|
||||
"MessageInsertChapterBelow": "Введіть главу нижче",
|
||||
"MessageInvalidAsin": "Невірний ASIN",
|
||||
"MessageItemsSelected": "Вибрано {0} елементів",
|
||||
"MessageItemsUpdated": "Оновлено {0} елементів",
|
||||
"MessageItemsSelected": "Вибрано елементів: {0}",
|
||||
"MessageItemsUpdated": "Оновлено елементів: {0}",
|
||||
"MessageJoinUsOn": "Приєднуйтесь до",
|
||||
"MessageLoading": "Завантаження...",
|
||||
"MessageLoadingFolders": "Завантаження папок...",
|
||||
"MessageLoadingFolders": "Завантаження тек...",
|
||||
"MessageLogsDescription": "Журнали зберігаються у <code>/metadata/logs</code> як JSON-файли. Журнали збоїв зберігаються у <code>/metadata/logs/crash_logs.txt</code>.",
|
||||
"MessageM4BFailed": "Помилка M4B!",
|
||||
"MessageM4BFinished": "M4B створено!",
|
||||
"MessageMapChapterTitles": "Встановіть назви глав вашої аудіокниги без зміни часових міток",
|
||||
"MessageMapChapterTitles": "Встановіть назви глав вашої аудіокниги без визначення налаштувань тривалості",
|
||||
"MessageMarkAllEpisodesFinished": "Позначити всі епізоди завершеними",
|
||||
"MessageMarkAllEpisodesNotFinished": "Позначити всі епізоди незавершеними",
|
||||
"MessageMarkAsFinished": "Позначити як завершене",
|
||||
"MessageMarkAsNotFinished": "Позначити як незавершене",
|
||||
"MessageMatchBooksDescription": "Спробує знайти книги у бібліотеці у вибраному джерелі пошуку та заповнити порожні подробиці й обкладинку. Не перезаписує подробиці.",
|
||||
"MessageMarkAsFinished": "Позначити завершеним",
|
||||
"MessageMarkAsNotFinished": "Позначити незавершеним",
|
||||
"MessageMatchBooksDescription": "Спробує віднайти книгу у вказаному джерелі пошуку та встановити подробиці та обкладинку, яких бракує. Не перезаписує подробиці.",
|
||||
"MessageNoAudioTracks": "Аудіодоріжки відсутні",
|
||||
"MessageNoAuthors": "Автори відсутні",
|
||||
"MessageNoBackups": "Резервні копії відсутні",
|
||||
|
@ -809,8 +808,8 @@
|
|||
"MessageNoCoversFound": "Обкладинок не знайдено",
|
||||
"MessageNoDescription": "Без опису",
|
||||
"MessageNoDevices": "Немає пристроїв",
|
||||
"MessageNoDownloadsInProgress": "Немає активних скачувань",
|
||||
"MessageNoDownloadsQueued": "Немає скачувань у черзі",
|
||||
"MessageNoDownloadsInProgress": "Немає активних завантажень",
|
||||
"MessageNoDownloadsQueued": "Немає завантажень у черзі",
|
||||
"MessageNoEpisodeMatchesFound": "Відповідних епізодів не знайдено",
|
||||
"MessageNoEpisodes": "Епізоди відсутні",
|
||||
"MessageNoFoldersAvailable": "Немає доступних тек",
|
||||
|
@ -822,18 +821,18 @@
|
|||
"MessageNoLogs": "Немає журнали",
|
||||
"MessageNoMediaProgress": "Прогрес відсутній",
|
||||
"MessageNoNotifications": "Сповіщення відсутні",
|
||||
"MessageNoPodcastFeed": "Некоректний подкаст: немає каналу",
|
||||
"MessageNoPodcastFeed": "Невірний подкаст: Немає каналу",
|
||||
"MessageNoPodcastsFound": "Подкастів не знайдено",
|
||||
"MessageNoResults": "Немає результатів",
|
||||
"MessageNoSearchResultsFor": "Немає результатів пошуку для \"{0}\"",
|
||||
"MessageNoSeries": "Немає серій",
|
||||
"MessageNoTags": "Немає міток",
|
||||
"MessageNoSeries": "Без серії",
|
||||
"MessageNoTags": "Без міток",
|
||||
"MessageNoTasksRunning": "Немає активних завдань",
|
||||
"MessageNoUpdatesWereNecessary": "Оновлення не потрібні",
|
||||
"MessageNoUpdatesWereNecessary": "Оновлень не потрібно",
|
||||
"MessageNoUserPlaylists": "У вас немає списків відтворення",
|
||||
"MessageNoUserPlaylistsHelp": "Списки відтворення приватні. Лише користувач, який їх створив, може їх бачити.",
|
||||
"MessageNoUserPlaylistsHelp": "Списки відтворення приватні. Лише користувач, який їх створює, може бачити їх.",
|
||||
"MessageNotYetImplemented": "Ще не реалізовано",
|
||||
"MessageOpmlPreviewNote": "Примітка: це попередній перегляд OPML-файлу. Актуальна назва подкасту буде взята з RSS-каналу.",
|
||||
"MessageOpmlPreviewNote": "Примітка: це попередній перегляд OPML-файлу. Актуальна назва подкасту буде завантажена з RSS-каналу.",
|
||||
"MessageOr": "або",
|
||||
"MessagePauseChapter": "Призупинити відтворення глави",
|
||||
"MessagePlayChapter": "Слухати початок глави",
|
||||
|
@ -842,7 +841,7 @@
|
|||
"MessagePodcastHasNoRSSFeedForMatching": "Подкаст не має RSS-каналу для пошуку",
|
||||
"MessagePodcastSearchField": "Введіть пошуковий запит або URL RSS-стрічки",
|
||||
"MessageQuickEmbedInProgress": "Швидке вбудовування в процесі",
|
||||
"MessageQuickEmbedQueue": "У черзі на швидке вбудовування ({0} в черзі)",
|
||||
"MessageQuickEmbedQueue": "В черзі на швидке вбудовування ({0} в черзі)",
|
||||
"MessageQuickMatchAllEpisodes": "Швидке співставлення всіх епізодів",
|
||||
"MessageQuickMatchDescription": "Заповнити відсутні подробиці та обкладинку першим результатом пошуку '{0}'. Не перезаписує подробиці, якщо не увімкнено параметр \"Надавати перевагу віднайденим метаданим\".",
|
||||
"MessageRemoveChapter": "Видалити главу",
|
||||
|
@ -850,9 +849,9 @@
|
|||
"MessageRemoveFromPlayerQueue": "Вилучити з черги відтворення",
|
||||
"MessageRemoveUserWarning": "Ви дійсно бажаєте назавжди видалити користувача \"{0}\"?",
|
||||
"MessageReportBugsAndContribute": "Повідомляйте про помилки, пропонуйте функції та долучайтеся на",
|
||||
"MessageResetChaptersConfirm": "Ви впевнені, що хочете скинути глави та скасувати внесені зміни?",
|
||||
"MessageRestoreBackupConfirm": "Ви впевнені, що хочете відновити резервну копію, створену",
|
||||
"MessageRestoreBackupWarning": "Відновлення резервної копії перезапише всю базу даних у /config і зображення обкладинок у /metadata/items та /metadata/authors.<br /><br />Резервні копії не змінюють файли у теках бібліотеки. Якщо у налаштуваннях сервера увімкнено збереження обкладинок і метаданих у теках бібліотеки, вони не створюються під час резервного копіювання і не перезаписуються.<br /><br />Всі клієнти, що користуються вашим сервером, будуть автоматично оновлені.",
|
||||
"MessageResetChaptersConfirm": "Ви дійсно бажаєте скинути глави та скасувати внесені зміни?",
|
||||
"MessageRestoreBackupConfirm": "Ви дійсно бажаєте відновити резервну копію від",
|
||||
"MessageRestoreBackupWarning": "Відновлення резервної копії перезапише всю базу даних, розташовану в /config, і зображення обкладинок в /metadata/items та /metadata/authors.<br /><br />Резервні копії не змінюють жодних файлів у теках бібліотеки. Якщо у налаштуваннях сервера увімкнено збереження обкладинок і метаданих у теках бібліотеки, вони не створюються під час резервного копіювання і не перезаписуються..<br /><br />Всі клієнти, що користуються вашим сервером, будуть автоматично оновлені.",
|
||||
"MessageScheduleLibraryScanNote": "Для більшості користувачів рекомендується залишити цю функцію вимкненою та залишити параметр перегляду папок увімкненим. Засіб спостереження за папками автоматично виявить зміни в папках вашої бібліотеки. Засіб спостереження за папками не працює для кожної файлової системи (наприклад, NFS), тому замість нього можна використовувати сканування бібліотек за розкладом.",
|
||||
"MessageScheduleRunEveryWeekdayAtTime": "Запуск кожні {0} о {1}",
|
||||
"MessageSearchResultsFor": "Результати пошуку для",
|
||||
|
@ -861,12 +860,12 @@
|
|||
"MessageServerCouldNotBeReached": "Не вдалося підключитися до сервера",
|
||||
"MessageSetChaptersFromTracksDescription": "Створити глави з аудіодоріжок, встановивши назви файлів за заголовки",
|
||||
"MessageShareExpirationWillBe": "Термін сплине за <strong>{0}</strong>",
|
||||
"MessageShareExpiresIn": "Спливає через {0}",
|
||||
"MessageShareURLWillBe": "URL для спільного доступу — <strong>{0}</strong>",
|
||||
"MessageShareExpiresIn": "Сплине за {0}",
|
||||
"MessageShareURLWillBe": "Поширюваний URL - <strong>{0}</strong>",
|
||||
"MessageStartPlaybackAtTime": "Почати відтворення \"{0}\" з {1}?",
|
||||
"MessageTaskAudioFileNotWritable": "Аудіофайл \"{0}\" недоступний для запису",
|
||||
"MessageTaskCanceledByUser": "Завдання скасовано користувачем",
|
||||
"MessageTaskDownloadingEpisodeDescription": "Скачування епізоду \"{0}\"",
|
||||
"MessageTaskCanceledByUser": "Задача скасована користувачем",
|
||||
"MessageTaskDownloadingEpisodeDescription": "Завантаження епізоду \"{0}\"",
|
||||
"MessageTaskEmbeddingMetadata": "Вбудовування метаданих",
|
||||
"MessageTaskEmbeddingMetadataDescription": "Вбудовування метаданих у аудіокнигу \"{0}\"",
|
||||
"MessageTaskEncodingM4b": "Кодування M4B",
|
||||
|
@ -881,19 +880,19 @@
|
|||
"MessageTaskMatchingBooksInLibrary": "Відповідність книг у бібліотеці \"{0}\"",
|
||||
"MessageTaskNoFilesToScan": "Немає файлів для сканування",
|
||||
"MessageTaskOpmlImport": "Імпорт OPML",
|
||||
"MessageTaskOpmlImportDescription": "Створення подкастів з {0} RSS-каналів",
|
||||
"MessageTaskOpmlImportFeed": "Імпорт RSS-каналу OPML",
|
||||
"MessageTaskOpmlImportDescription": "Створення подкастів з {0} RSS-стрічок",
|
||||
"MessageTaskOpmlImportFeed": "Канал імпорту OPML",
|
||||
"MessageTaskOpmlImportFeedDescription": "Імпорт RSS-каналу \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedFailed": "Не вдалося отримати подкаст-канал",
|
||||
"MessageTaskOpmlImportFeedFailed": "Не вдалося отримати подкаст-стрічку",
|
||||
"MessageTaskOpmlImportFeedPodcastDescription": "Створення подкасту \"{0}\"",
|
||||
"MessageTaskOpmlImportFeedPodcastExists": "Подкаст вже існує за цим шляхом",
|
||||
"MessageTaskOpmlImportFeedPodcastFailed": "Не вдалося створити подкаст",
|
||||
"MessageTaskOpmlImportFinished": "Додано {0} подкастів",
|
||||
"MessageTaskOpmlParseFailed": "Не вдалося розібрати OPML-файл",
|
||||
"MessageTaskOpmlParseFastFail": "Некоректний OPML-файл: не знайдено тег <opml> або <outline>",
|
||||
"MessageTaskOpmlParseNoneFound": "У OPML-файлі не знайдено жодного каналу",
|
||||
"MessageTaskOpmlParseFailed": "Не вдалося розібрати файл OPML",
|
||||
"MessageTaskOpmlParseFastFail": "Невірний файл OPML: не знайдено тег <opml> або тег <outline>",
|
||||
"MessageTaskOpmlParseNoneFound": "У файлі OPML не знайдено жодного канала",
|
||||
"MessageTaskScanItemsAdded": "{0} додано",
|
||||
"MessageTaskScanItemsMissing": "{0} відсутні",
|
||||
"MessageTaskScanItemsMissing": "{0} відсутній",
|
||||
"MessageTaskScanItemsUpdated": "{0} оновлено",
|
||||
"MessageTaskScanNoChangesNeeded": "Змін не потрібно",
|
||||
"MessageTaskScanningFileChanges": "Сканування змін файлів у \"{0}\"",
|
||||
|
@ -903,24 +902,22 @@
|
|||
"MessageUploaderItemFailed": "Не вдалося завантажити",
|
||||
"MessageUploaderItemSuccess": "Успішно завантажено!",
|
||||
"MessageUploading": "Завантаження...",
|
||||
"MessageValidCronExpression": "Коректний cron-вираз",
|
||||
"MessageWatcherIsDisabledGlobally": "Спостерігача вимкнено у глобальних налаштуваннях сервера",
|
||||
"MessageValidCronExpression": "Допустима команда cron",
|
||||
"MessageWatcherIsDisabledGlobally": "Спостерігача вимкнено в налаштуваннях сервера",
|
||||
"MessageXLibraryIsEmpty": "Бібліотека {0} порожня!",
|
||||
"MessageYourAudiobookDurationIsLonger": "Тривалість вашої аудіокниги більша за знайдену",
|
||||
"MessageYourAudiobookDurationIsShorter": "Тривалість вашої аудіокниги менша за знайдену",
|
||||
"NoteChangeRootPassword": "Тільки користувач root може мати порожній пароль",
|
||||
"NoteChapterEditorTimes": "Примітка: Перша глава повинна починатися з 0:00, а час початку останньої глави не може перевищувати тривалість цієї аудіокниги.",
|
||||
"NoteFolderPicker": "Примітка: вже додані папки не відображаються",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Попередження: більшість додатків подкастів вимагають використання HTTPS для RSS-каналу",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Попередження: один або більше ваших епізодів не мають дати публікації. Деякі додатки подкастів цього вимагають.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Теки з медіафайлами обробляються як окремі елементи бібліотеки.",
|
||||
"NoteUploaderOnlyAudioFiles": "Якщо завантажувати лише аудіофайли, кожен файл буде окремою аудіокнигою.",
|
||||
"NoteUploaderUnsupportedFiles": "Непідтримувані файли ігноруються. При виборі або перетягуванні теки, файли поза теками елементів ігноруються.",
|
||||
"NotificationOnBackupCompletedDescription": "Виконується після завершення резервного копіювання",
|
||||
"NotificationOnBackupFailedDescription": "Виконується при помилці резервного копіювання",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Виконується при автоматичному завантаженні епізоду подкасту",
|
||||
"NotificationOnRSSFeedDisabledDescription": "Виконується, коли автоматичне завантаження епізодів вимкнено через забагато невдалих спроб",
|
||||
"NotificationOnRSSFeedFailedDescription": "Виконується, коли запит RSS-каналу не вдається для автоматичного завантаження епізоду",
|
||||
"MessageYourAudiobookDurationIsLonger": "Тривалість вашої аудіокниги довша за віднайдену",
|
||||
"MessageYourAudiobookDurationIsShorter": "Тривалість вашої аудіокниги коротша за віднайдену",
|
||||
"NoteChangeRootPassword": "Тільки користувач root — єдиний, хто може мати порожній пароль",
|
||||
"NoteChapterEditorTimes": "Примітка: Перша глава мусить починатися з 0:00, а час початку останньої глави не може бути більшим за зазначену тривалість аудіокниги.",
|
||||
"NoteFolderPicker": "Примітка: вже обрані теки не буде показано",
|
||||
"NoteRSSFeedPodcastAppsHttps": "Попередження: Більшість додатків подкастів вимагатимуть використання протоколу HTTPS від RSS-каналу",
|
||||
"NoteRSSFeedPodcastAppsPubDate": "Попередження: 1 або більше ваших епізодів не мають дати публікації. Деякі додатки подкастів вимагають це.",
|
||||
"NoteUploaderFoldersWithMediaFiles": "Теки з медіафайлами буде оброблено як окремі елементи бібліотеки.",
|
||||
"NoteUploaderOnlyAudioFiles": "Якщо завантажувати лише аудіофайли, то кожен файл буде оброблено як окрему книгу.",
|
||||
"NoteUploaderUnsupportedFiles": "Непідтримувані файли пропущено. Під час вибору або перетягування теки, файли, що знаходяться поза текою, пропускаються.",
|
||||
"NotificationOnBackupCompletedDescription": "Запускається після завершення резервного копіювання",
|
||||
"NotificationOnBackupFailedDescription": "Срабатывает при збої резервного копіювання",
|
||||
"NotificationOnEpisodeDownloadedDescription": "Запускається при автоматичному завантаженні епізоду подкасту",
|
||||
"NotificationOnTestDescription": "Подія для тестування системи сповіщень",
|
||||
"PlaceholderNewCollection": "Нова назва добірки",
|
||||
"PlaceholderNewFolderPath": "Новий шлях до теки",
|
||||
|
@ -998,7 +995,7 @@
|
|||
"ToastEncodeCancelFailed": "Не вдалося скасувати кодування",
|
||||
"ToastEncodeCancelSucces": "Кодування скасовано",
|
||||
"ToastEpisodeDownloadQueueClearFailed": "Не вдалося очистити чергу",
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "Чергу на скачування епізодів очищено",
|
||||
"ToastEpisodeDownloadQueueClearSuccess": "Чергу на завантаження епізодів очищено",
|
||||
"ToastEpisodeUpdateSuccess": "{0} епізодів оновлено",
|
||||
"ToastErrorCannotShare": "Не можна типово поширити на цей пристрій",
|
||||
"ToastFailedToLoadData": "Не вдалося завантажити дані",
|
||||
|
@ -1006,7 +1003,7 @@
|
|||
"ToastFailedToShare": "Не вдалося поділитися",
|
||||
"ToastFailedToUpdate": "Не вдалося оновити",
|
||||
"ToastInvalidImageUrl": "Невірний URL зображення",
|
||||
"ToastInvalidMaxEpisodesToDownload": "Невірна кількість епізодів для скачування",
|
||||
"ToastInvalidMaxEpisodesToDownload": "Невірна кількість епізодів для завантаження",
|
||||
"ToastInvalidUrl": "Невірний URL",
|
||||
"ToastItemCoverUpdateSuccess": "Обкладинку елемента оновлено",
|
||||
"ToastItemDeletedFailed": "Не вдалося видалити елемент",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"ButtonAuthors": "作者",
|
||||
"ButtonBack": "返回",
|
||||
"ButtonBatchEditPopulateFromExisting": "用现有内容填充",
|
||||
"ButtonBatchEditPopulateMapDetails": "填入此项详情",
|
||||
"ButtonBatchEditPopulateMapDetails": "填充地图详细信息",
|
||||
"ButtonBrowseForFolder": "浏览文件夹",
|
||||
"ButtonCancel": "取消",
|
||||
"ButtonCancelEncode": "取消编码",
|
||||
|
@ -73,7 +73,7 @@
|
|||
"ButtonQuickMatch": "快速匹配",
|
||||
"ButtonReScan": "重新扫描",
|
||||
"ButtonRead": "读取",
|
||||
"ButtonReadLess": "收起",
|
||||
"ButtonReadLess": "阅读较少",
|
||||
"ButtonReadMore": "阅读更多",
|
||||
"ButtonRefresh": "刷新",
|
||||
"ButtonRemove": "移除",
|
||||
|
@ -212,7 +212,7 @@
|
|||
"HeaderUsers": "用户",
|
||||
"HeaderYearReview": "{0} 年回顾",
|
||||
"HeaderYourStats": "你的统计数据",
|
||||
"LabelAbridged": "删节版",
|
||||
"LabelAbridged": "概要",
|
||||
"LabelAbridgedChecked": "删节版 (已勾选)",
|
||||
"LabelAbridgedUnchecked": "未删节版 (未勾选)",
|
||||
"LabelAccessibleBy": "可访问",
|
||||
|
@ -320,7 +320,7 @@
|
|||
"LabelEmailSettingsRejectUnauthorized": "拒绝未经授权的证书",
|
||||
"LabelEmailSettingsRejectUnauthorizedHelp": "禁用SSL证书验证可能会使你的连接面临安全风险, 例如中间人攻击. 只有当你了解其中的含义并信任所连接的邮件服务器时, 才能禁用此选项.",
|
||||
"LabelEmailSettingsSecure": "安全",
|
||||
"LabelEmailSettingsSecureHelp": "开启此选项时,将始终通过TLS连接服务器。关闭此选项时,仅在服务器支持STARTTLS扩展时使用TLS。在大多数情况下,如果连接到端口465,请将此项设为开启。如果连接到端口587或25,请将此设置保持为关闭。(来自nodemailer.com/smtp/#authentication)",
|
||||
"LabelEmailSettingsSecureHelp": "如果选是, 则连接将在连接到服务器时使用TLS. 如果选否, 则若服务器支持STARTTLS扩展, 则使用TLS. 在大多数情况下, 如果连接到端口465, 请将该值设置为是. 对于端口587或25, 请保持为否. (来自nodemailer.com/smtp/#authentication)",
|
||||
"LabelEmailSettingsTestAddress": "测试地址",
|
||||
"LabelEmbeddedCover": "嵌入封面",
|
||||
"LabelEnable": "启用",
|
||||
|
@ -346,15 +346,15 @@
|
|||
"LabelExample": "示例",
|
||||
"LabelExpandSeries": "展开系列",
|
||||
"LabelExpandSubSeries": "展开子系列",
|
||||
"LabelExplicit": "含成人内容",
|
||||
"LabelExplicitChecked": "成人内容(已核实)",
|
||||
"LabelExplicitUnchecked": "无成人内容 (未核实)",
|
||||
"LabelExplicit": "信息准确",
|
||||
"LabelExplicitChecked": "明确(已选中)",
|
||||
"LabelExplicitUnchecked": "不明确 (未选中)",
|
||||
"LabelExportOPML": "导出 OPML",
|
||||
"LabelFeedURL": "源 URL",
|
||||
"LabelFetchingMetadata": "正在获取元数据",
|
||||
"LabelFile": "文件",
|
||||
"LabelFileBirthtime": "文件创建时间",
|
||||
"LabelFileBornDate": "添加于 {0}",
|
||||
"LabelFileBornDate": "生于 {0}",
|
||||
"LabelFileModified": "文件修改时间",
|
||||
"LabelFileModifiedDate": "已修改 {0}",
|
||||
"LabelFilename": "文件名",
|
||||
|
@ -482,7 +482,7 @@
|
|||
"LabelPermanent": "永久的",
|
||||
"LabelPermissionsAccessAllLibraries": "可以访问所有媒体库",
|
||||
"LabelPermissionsAccessAllTags": "可以访问所有标签",
|
||||
"LabelPermissionsAccessExplicitContent": "可以访问成人内容",
|
||||
"LabelPermissionsAccessExplicitContent": "可以访问显式内容",
|
||||
"LabelPermissionsCreateEreader": "可以创建电子阅读器",
|
||||
"LabelPermissionsDelete": "可以删除",
|
||||
"LabelPermissionsDownload": "可以下载",
|
||||
|
@ -613,12 +613,12 @@
|
|||
"LabelStartedAt": "从这开始",
|
||||
"LabelStatsAudioTracks": "音轨",
|
||||
"LabelStatsAuthors": "作者",
|
||||
"LabelStatsBestDay": "单日最高",
|
||||
"LabelStatsBestDay": "最好的一天",
|
||||
"LabelStatsDailyAverage": "每日平均值",
|
||||
"LabelStatsDays": "连续收听",
|
||||
"LabelStatsDays": "天",
|
||||
"LabelStatsDaysListened": "收听天数",
|
||||
"LabelStatsHours": "小时",
|
||||
"LabelStatsInARow": "天",
|
||||
"LabelStatsInARow": "在一行",
|
||||
"LabelStatsItemsFinished": "已完成的项目",
|
||||
"LabelStatsItemsInLibrary": "媒体库中的项目",
|
||||
"LabelStatsMinutes": "分钟",
|
||||
|
@ -714,7 +714,7 @@
|
|||
"MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.",
|
||||
"MessageBackupsLocationPathEmpty": "备份位置路径不能为空",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "提取此项目的信息,填入上方所有勾选的编辑框中",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "使用此项目的数据填充已启用的地图详细信息字段",
|
||||
"MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.",
|
||||
"MessageBookshelfNoCollections": "你尚未进行任何收藏",
|
||||
"MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.",
|
||||
|
@ -757,7 +757,6 @@
|
|||
"MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodeNote": "注意:此操作不会删除音频文件,除非勾选“完全删除文件”选项",
|
||||
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
|
||||
"MessageConfirmRemoveListeningSessions": "你确定要移除 {0} 收听会话吗?",
|
||||
"MessageConfirmRemoveMetadataFiles": "你确实要删除库项目文件夹中的所有 metadata.{0} 文件吗?",
|
||||
|
@ -918,9 +917,7 @@
|
|||
"NoteUploaderUnsupportedFiles": "不支持的文件将被忽略. 选择或删除文件夹时, 将忽略不在项目文件夹中的其他文件.",
|
||||
"NotificationOnBackupCompletedDescription": "备份完成时触发",
|
||||
"NotificationOnBackupFailedDescription": "备份失败时触发",
|
||||
"NotificationOnEpisodeDownloadedDescription": "当播客节目自动下载完成时触发",
|
||||
"NotificationOnRSSFeedDisabledDescription": "由于尝试失败次数过多而导致剧集自动下载被禁用时触发",
|
||||
"NotificationOnRSSFeedFailedDescription": "当用于自动下载剧集的 RSS 源请求失败时触发",
|
||||
"NotificationOnEpisodeDownloadedDescription": "当播客节目自动下载时触发",
|
||||
"NotificationOnTestDescription": "测试通知系统的事件",
|
||||
"PlaceholderNewCollection": "输入收藏夹名称",
|
||||
"PlaceholderNewFolderPath": "输入文件夹路径",
|
||||
|
|
7
index.js
7
index.js
|
@ -4,9 +4,7 @@ const optionDefinitions = [
|
|||
{ name: 'port', alias: 'p', type: String },
|
||||
{ name: 'host', alias: 'h', type: String },
|
||||
{ name: 'source', alias: 's', type: String },
|
||||
{ name: 'dev', alias: 'd', type: Boolean },
|
||||
// Run in production mode and use dev.js config
|
||||
{ name: 'prod-with-dev-env', alias: 'r', type: Boolean }
|
||||
{ name: 'dev', alias: 'd', type: Boolean }
|
||||
]
|
||||
|
||||
const commandLineArgs = require('./server/libs/commandLineArgs')
|
||||
|
@ -19,7 +17,7 @@ const server = require('./server/Server')
|
|||
global.appRoot = __dirname
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production'
|
||||
if (isDev || options['prod-with-dev-env']) {
|
||||
if (isDev) {
|
||||
const devEnv = require('./dev').config
|
||||
if (devEnv.Port) process.env.PORT = devEnv.Port
|
||||
if (devEnv.ConfigPath) process.env.CONFIG_PATH = devEnv.ConfigPath
|
||||
|
@ -30,7 +28,6 @@ if (isDev || options['prod-with-dev-env']) {
|
|||
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
|
||||
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
||||
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
||||
if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath
|
||||
process.env.SOURCE = 'local'
|
||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
|
||||
}
|
||||
|
|
20
package-lock.json
generated
20
package-lock.json
generated
|
@ -1,18 +1,17 @@
|
|||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.26.0",
|
||||
"version": "2.24.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.26.0",
|
||||
"version": "2.24.0",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.17.1",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"express-session": "^1.17.3",
|
||||
"graceful-fs": "^4.2.10",
|
||||
"htmlparser2": "^8.0.1",
|
||||
|
@ -1894,21 +1893,6 @@
|
|||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "7.5.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express-session": {
|
||||
"version": "1.17.3",
|
||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz",
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.26.0",
|
||||
"version": "2.24.0",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon --watch server index.js -- --dev",
|
||||
"start": "node index.js",
|
||||
"start-dev": "node index.js --prod-with-dev-env",
|
||||
"client": "cd client && npm ci && npm run generate",
|
||||
"prod": "npm run client && npm ci && node index.js",
|
||||
"build-win": "npm run client && pkg -t node20-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
||||
|
@ -40,7 +39,6 @@
|
|||
"axios": "^0.27.2",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.17.1",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"express-session": "^1.17.3",
|
||||
"graceful-fs": "^4.2.10",
|
||||
"htmlparser2": "^8.0.1",
|
||||
|
|
906
server/Auth.js
906
server/Auth.js
File diff suppressed because it is too large
Load diff
|
@ -42,16 +42,6 @@ class Database {
|
|||
return this.models.user
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Session')} */
|
||||
get sessionModel() {
|
||||
return this.models.session
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/ApiKey')} */
|
||||
get apiKeyModel() {
|
||||
return this.models.apiKey
|
||||
}
|
||||
|
||||
/** @type {typeof import('./models/Library')} */
|
||||
get libraryModel() {
|
||||
return this.models.library
|
||||
|
@ -321,8 +311,6 @@ class Database {
|
|||
|
||||
buildModels(force = false) {
|
||||
require('./models/User').init(this.sequelize)
|
||||
require('./models/Session').init(this.sequelize)
|
||||
require('./models/ApiKey').init(this.sequelize)
|
||||
require('./models/Library').init(this.sequelize)
|
||||
require('./models/LibraryFolder').init(this.sequelize)
|
||||
require('./models/Book').init(this.sequelize)
|
||||
|
@ -668,9 +656,6 @@ class Database {
|
|||
* Series should have atleast one Book
|
||||
* Book and Podcast must have an associated LibraryItem (and vice versa)
|
||||
* Remove playback sessions that are 3 seconds or less
|
||||
* Remove duplicate mediaProgresses
|
||||
* Remove expired auth sessions
|
||||
* Deactivate expired api keys
|
||||
*/
|
||||
async cleanDatabase() {
|
||||
// Remove invalid Podcast records
|
||||
|
@ -780,60 +765,6 @@ class Database {
|
|||
if (badSessionsRemoved > 0) {
|
||||
Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`)
|
||||
}
|
||||
|
||||
// Remove mediaProgresses with duplicate mediaItemId (remove the oldest updatedAt or if updatedAt is the same, remove arbitrary one)
|
||||
const [duplicateMediaProgresses] = await this.sequelize.query(`SELECT mp1.id, mp1.mediaItemId
|
||||
FROM mediaProgresses mp1
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM mediaProgresses mp2
|
||||
WHERE mp2.mediaItemId = mp1.mediaItemId
|
||||
AND mp2.userId = mp1.userId
|
||||
AND (
|
||||
mp2.updatedAt > mp1.updatedAt
|
||||
OR (mp2.updatedAt = mp1.updatedAt AND mp2.id < mp1.id)
|
||||
)
|
||||
)`)
|
||||
for (const duplicateMediaProgress of duplicateMediaProgresses) {
|
||||
Logger.warn(`Found duplicate mediaProgress for mediaItem "${duplicateMediaProgress.mediaItemId}" - removing it`)
|
||||
await this.mediaProgressModel.destroy({
|
||||
where: { id: duplicateMediaProgress.id }
|
||||
})
|
||||
}
|
||||
|
||||
// Remove expired Session records
|
||||
await this.cleanupExpiredSessions()
|
||||
|
||||
// Deactivate expired api keys
|
||||
await this.deactivateExpiredApiKeys()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate expired api keys
|
||||
*/
|
||||
async deactivateExpiredApiKeys() {
|
||||
try {
|
||||
const affectedCount = await this.apiKeyModel.deactivateExpiredApiKeys()
|
||||
if (affectedCount > 0) {
|
||||
Logger.info(`[Database] Deactivated ${affectedCount} expired api keys`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[Database] Error deactivating expired api keys: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions from the database
|
||||
*/
|
||||
async cleanupExpiredSessions() {
|
||||
try {
|
||||
const deletedCount = await this.sessionModel.cleanupExpiredSessions()
|
||||
if (deletedCount > 0) {
|
||||
Logger.info(`[Database] Cleaned up ${deletedCount} expired sessions`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[Database] Error cleaning up expired sessions: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async createTextSearchQuery(query) {
|
||||
|
|
|
@ -12,7 +12,6 @@ const { version } = require('../package.json')
|
|||
|
||||
// Utils
|
||||
const fileUtils = require('./utils/fileUtils')
|
||||
const { toNumber } = require('./utils/index')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
const Auth = require('./Auth')
|
||||
|
@ -85,8 +84,12 @@ class Server {
|
|||
global.DisableSsrfRequestFilter = (url) => whitelistedUrls.includes(new URL(url).hostname)
|
||||
}
|
||||
}
|
||||
global.PodcastDownloadTimeout = toNumber(process.env.PODCAST_DOWNLOAD_TIMEOUT, 30000)
|
||||
global.MaxFailedEpisodeChecks = toNumber(process.env.MAX_FAILED_EPISODE_CHECKS, 24)
|
||||
|
||||
if (process.env.PODCAST_DOWNLOAD_TIMEOUT) {
|
||||
global.PodcastDownloadTimeout = process.env.PODCAST_DOWNLOAD_TIMEOUT
|
||||
} else {
|
||||
global.PodcastDownloadTimeout = 30000
|
||||
}
|
||||
|
||||
if (!fs.pathExistsSync(global.ConfigPath)) {
|
||||
fs.mkdirSync(global.ConfigPath)
|
||||
|
@ -156,11 +159,14 @@ class Server {
|
|||
}
|
||||
|
||||
await Database.init(false)
|
||||
// Create or set JWT secret in token manager
|
||||
await this.auth.tokenManager.initTokenSecret()
|
||||
|
||||
await Logger.logManager.init()
|
||||
|
||||
// Create token secret if does not exist (Added v2.1.0)
|
||||
if (!Database.serverSettings.tokenSecret) {
|
||||
await this.auth.initTokenSecret()
|
||||
}
|
||||
|
||||
await this.cleanUserData() // Remove invalid user item progress
|
||||
await CacheManager.ensureCachePaths()
|
||||
|
||||
|
@ -217,7 +223,6 @@ class Server {
|
|||
|
||||
async start() {
|
||||
Logger.info('=== Starting Server ===')
|
||||
|
||||
this.initProcessEventListeners()
|
||||
await this.init()
|
||||
|
||||
|
@ -261,7 +266,7 @@ class Server {
|
|||
// enable express-session
|
||||
app.use(
|
||||
expressSession({
|
||||
secret: this.auth.tokenManager.TokenSecret,
|
||||
secret: global.ServerSettings.tokenSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
|
@ -279,7 +284,6 @@ class Server {
|
|||
await this.auth.initPassportJs()
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// if RouterBasePath is set, modify all requests to include the base path
|
||||
app.use((req, res, next) => {
|
||||
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
||||
|
@ -306,14 +310,16 @@ class Server {
|
|||
})
|
||||
)
|
||||
router.use(express.urlencoded({ extended: true, limit: '5mb' }))
|
||||
|
||||
// Skip JSON parsing for internal-api routes
|
||||
router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' }))
|
||||
router.use(express.json({ limit: '10mb' }))
|
||||
|
||||
router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router)
|
||||
router.use('/hls', this.hlsRouter.router)
|
||||
router.use('/public', this.publicRouter.router)
|
||||
|
||||
// Static path to generated nuxt
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
router.use(express.static(distPath))
|
||||
|
||||
// Static folder
|
||||
router.use(express.static(Path.join(global.appRoot, 'static')))
|
||||
|
||||
|
@ -333,6 +339,32 @@ class Server {
|
|||
// Auth routes
|
||||
await this.auth.initAuthRoutes(router)
|
||||
|
||||
// Client dynamic routes
|
||||
const dynamicRoutes = [
|
||||
'/item/:id',
|
||||
'/author/:id',
|
||||
'/audiobook/:id/chapters',
|
||||
'/audiobook/:id/edit',
|
||||
'/audiobook/:id/manage',
|
||||
'/library/:library',
|
||||
'/library/:library/search',
|
||||
'/library/:library/bookshelf/:id?',
|
||||
'/library/:library/authors',
|
||||
'/library/:library/narrators',
|
||||
'/library/:library/stats',
|
||||
'/library/:library/series/:id?',
|
||||
'/library/:library/podcast/search',
|
||||
'/library/:library/podcast/latest',
|
||||
'/library/:library/podcast/download-queue',
|
||||
'/config/users/:id',
|
||||
'/config/users/:id/sessions',
|
||||
'/config/item-metadata-utils/:id',
|
||||
'/collection/:id',
|
||||
'/playlist/:id',
|
||||
'/share/:slug'
|
||||
]
|
||||
dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||
|
||||
router.post('/init', (req, res) => {
|
||||
if (Database.hasRootUser) {
|
||||
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
||||
|
@ -363,49 +395,6 @@ class Server {
|
|||
})
|
||||
router.get('/healthcheck', (req, res) => res.sendStatus(200))
|
||||
|
||||
const ReactClientPath = process.env.REACT_CLIENT_PATH
|
||||
if (!ReactClientPath) {
|
||||
// Static path to generated nuxt
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
router.use(express.static(distPath))
|
||||
|
||||
// Client dynamic routes
|
||||
const dynamicRoutes = [
|
||||
'/item/:id',
|
||||
'/author/:id',
|
||||
'/audiobook/:id/chapters',
|
||||
'/audiobook/:id/edit',
|
||||
'/audiobook/:id/manage',
|
||||
'/library/:library',
|
||||
'/library/:library/search',
|
||||
'/library/:library/bookshelf/:id?',
|
||||
'/library/:library/authors',
|
||||
'/library/:library/narrators',
|
||||
'/library/:library/stats',
|
||||
'/library/:library/series/:id?',
|
||||
'/library/:library/podcast/search',
|
||||
'/library/:library/podcast/latest',
|
||||
'/library/:library/podcast/download-queue',
|
||||
'/config/users/:id',
|
||||
'/config/users/:id/sessions',
|
||||
'/config/item-metadata-utils/:id',
|
||||
'/collection/:id',
|
||||
'/playlist/:id',
|
||||
'/share/:slug'
|
||||
]
|
||||
dynamicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||
} else {
|
||||
// This is for using the experimental Next.js client
|
||||
Logger.info(`Using React client at ${ReactClientPath}`)
|
||||
const nextPath = Path.join(ReactClientPath, 'node_modules/next')
|
||||
const next = require(nextPath)
|
||||
const nextApp = next({ dev: Logger.isDev, dir: ReactClientPath })
|
||||
const handle = nextApp.getRequestHandler()
|
||||
await nextApp.prepare()
|
||||
router.get('*', (req, res) => handle(req, res))
|
||||
router.post('/internal-api/*', (req, res) => handle(req, res))
|
||||
}
|
||||
|
||||
const unixSocketPrefix = 'unix/'
|
||||
if (this.Host?.startsWith(unixSocketPrefix)) {
|
||||
const sockPath = this.Host.slice(unixSocketPrefix.length)
|
||||
|
@ -428,7 +417,7 @@ class Server {
|
|||
Logger.info(`[Server] Initializing new server`)
|
||||
const newRoot = req.body.newRoot
|
||||
const rootUsername = newRoot.username || 'root'
|
||||
const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : ''
|
||||
const rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
|
||||
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
||||
await Database.createRootUser(rootUsername, rootPash, this.auth)
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
const SocketIO = require('socket.io')
|
||||
const Logger = require('./Logger')
|
||||
const Database = require('./Database')
|
||||
const TokenManager = require('./auth/TokenManager')
|
||||
const Auth = require('./Auth')
|
||||
|
||||
/**
|
||||
* @typedef SocketClient
|
||||
|
@ -231,22 +231,18 @@ class SocketAuthority {
|
|||
* When setting up a socket connection the user needs to be associated with a socket id
|
||||
* for this the client will send a 'auth' event that includes the users API token
|
||||
*
|
||||
* Sends event 'init' to the socket. For admins this contains an array of users online.
|
||||
* For failed authentication it sends event 'auth_failed' with a message
|
||||
*
|
||||
* @param {SocketIO.Socket} socket
|
||||
* @param {string} token JWT
|
||||
*/
|
||||
async authenticateSocket(socket, token) {
|
||||
// we don't use passport to authenticate the jwt we get over the socket connection.
|
||||
// it's easier to directly verify/decode it.
|
||||
// TODO: Support API keys for web socket connections
|
||||
const token_data = TokenManager.validateAccessToken(token)
|
||||
const token_data = Auth.validateAccessToken(token)
|
||||
|
||||
if (!token_data?.userId) {
|
||||
// Token invalid
|
||||
Logger.error('Cannot validate socket - invalid token')
|
||||
return socket.emit('auth_failed', { message: 'Invalid token' })
|
||||
return socket.emit('invalid_token')
|
||||
}
|
||||
|
||||
// get the user via the id from the decoded jwt.
|
||||
|
@ -254,11 +250,7 @@ class SocketAuthority {
|
|||
if (!user) {
|
||||
// user not found
|
||||
Logger.error('Cannot validate socket - invalid token')
|
||||
return socket.emit('auth_failed', { message: 'Invalid token' })
|
||||
}
|
||||
if (!user.isActive) {
|
||||
Logger.error('Cannot validate socket - user is not active')
|
||||
return socket.emit('auth_failed', { message: 'Invalid user' })
|
||||
return socket.emit('invalid_token')
|
||||
}
|
||||
|
||||
const client = this.clients[socket.id]
|
||||
|
@ -268,18 +260,13 @@ class SocketAuthority {
|
|||
}
|
||||
|
||||
if (client.user !== undefined) {
|
||||
if (client.user.id === user.id) {
|
||||
// Allow re-authentication of a socket to the same user
|
||||
Logger.info(`[SocketAuthority] Authenticating socket already associated to user "${client.user.username}"`)
|
||||
} else {
|
||||
// Allow re-authentication of a socket to a different user but shouldn't happen
|
||||
Logger.warn(`[SocketAuthority] Authenticating socket to user "${user.username}", but is already associated with a different user "${client.user.username}"`)
|
||||
}
|
||||
} else {
|
||||
Logger.debug(`[SocketAuthority] Authenticating socket to user "${user.username}"`)
|
||||
Logger.debug(`[SocketAuthority] Authenticating socket client already has user`, client.user.username)
|
||||
}
|
||||
|
||||
client.user = user
|
||||
|
||||
Logger.debug(`[SocketAuthority] User Online ${client.user.username}`)
|
||||
|
||||
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
|
||||
|
||||
// Update user lastSeen without firing sequelize bulk update hooks
|
||||
|
|
|
@ -1,186 +0,0 @@
|
|||
const passport = require('passport')
|
||||
const LocalStrategy = require('../libs/passportLocal')
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const bcrypt = require('../libs/bcryptjs')
|
||||
const requestIp = require('../libs/requestIp')
|
||||
|
||||
/**
|
||||
* Local authentication strategy using username/password
|
||||
*/
|
||||
class LocalAuthStrategy {
|
||||
constructor() {
|
||||
this.name = 'local'
|
||||
this.strategy = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the passport strategy instance
|
||||
* @returns {LocalStrategy}
|
||||
*/
|
||||
getStrategy() {
|
||||
if (!this.strategy) {
|
||||
this.strategy = new LocalStrategy({ passReqToCallback: true }, this.verifyCredentials.bind(this))
|
||||
}
|
||||
return this.strategy
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the strategy with passport
|
||||
*/
|
||||
init() {
|
||||
passport.use(this.name, this.getStrategy())
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the strategy from passport
|
||||
*/
|
||||
unuse() {
|
||||
passport.unuse(this.name)
|
||||
this.strategy = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify user credentials
|
||||
* @param {import('express').Request} req
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @param {Function} done - Passport callback
|
||||
*/
|
||||
async verifyCredentials(req, username, password, done) {
|
||||
// Load the user given it's username
|
||||
const user = await Database.userModel.getUserByUsername(username.toLowerCase())
|
||||
|
||||
if (!user?.isActive) {
|
||||
if (user) {
|
||||
this.logFailedLoginAttempt(req, user.username, 'User is not active')
|
||||
} else {
|
||||
this.logFailedLoginAttempt(req, username, 'User not found')
|
||||
}
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check passwordless root user
|
||||
if (user.type === 'root' && !user.pash) {
|
||||
if (password) {
|
||||
// deny login
|
||||
this.logFailedLoginAttempt(req, user.username, 'Root user has no password set')
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
// approve login
|
||||
Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
||||
|
||||
done(null, user)
|
||||
return
|
||||
} else if (!user.pash) {
|
||||
this.logFailedLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID')
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check password match
|
||||
const compare = await bcrypt.compare(password, user.pash)
|
||||
if (compare) {
|
||||
// approve login
|
||||
Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`)
|
||||
|
||||
done(null, user)
|
||||
return
|
||||
}
|
||||
|
||||
// deny login
|
||||
this.logFailedLoginAttempt(req, user.username, 'Invalid password')
|
||||
done(null, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log failed login attempts
|
||||
* @param {import('express').Request} req
|
||||
* @param {string} username
|
||||
* @param {string} message
|
||||
*/
|
||||
logFailedLoginAttempt(req, username, message) {
|
||||
if (!req || !username || !message) return
|
||||
Logger.error(`[LocalAuth] Failed login attempt for username "${username}" from ip ${requestIp.getClientIp(req)} (${message})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password with bcrypt
|
||||
* @param {string} password
|
||||
* @returns {Promise<string>} hash
|
||||
*/
|
||||
hashPassword(password) {
|
||||
return new Promise((resolve) => {
|
||||
bcrypt.hash(password, 8, (err, hash) => {
|
||||
if (err) {
|
||||
resolve(null)
|
||||
} else {
|
||||
resolve(hash)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare password with user's hashed password
|
||||
* @param {string} password
|
||||
* @param {import('../models/User')} user
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
comparePassword(password, user) {
|
||||
if (user.type === 'root' && !password && !user.pash) return true
|
||||
if (!password || !user.pash) return false
|
||||
return bcrypt.compare(password, user.pash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password
|
||||
* @param {import('../models/User')} user
|
||||
* @param {string} password
|
||||
* @param {string} newPassword
|
||||
*/
|
||||
async changePassword(user, password, newPassword) {
|
||||
// Only root can have an empty password
|
||||
if (user.type !== 'root' && !newPassword) {
|
||||
return {
|
||||
error: 'Invalid new password - Only root can have an empty password'
|
||||
}
|
||||
}
|
||||
|
||||
// Check password match
|
||||
const compare = await this.comparePassword(password, user)
|
||||
if (!compare) {
|
||||
return {
|
||||
error: 'Invalid password'
|
||||
}
|
||||
}
|
||||
|
||||
let pw = ''
|
||||
if (newPassword) {
|
||||
pw = await this.hashPassword(newPassword)
|
||||
if (!pw) {
|
||||
return {
|
||||
error: 'Hash failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await user.update({ pash: pw })
|
||||
Logger.info(`[LocalAuth] User "${user.username}" changed password`)
|
||||
return {
|
||||
success: true
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[LocalAuth] User "${user.username}" failed to change password`, error)
|
||||
return {
|
||||
error: 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LocalAuthStrategy
|
|
@ -1,488 +0,0 @@
|
|||
const { Request, Response } = require('express')
|
||||
const passport = require('passport')
|
||||
const OpenIDClient = require('openid-client')
|
||||
const axios = require('axios')
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
/**
|
||||
* OpenID Connect authentication strategy
|
||||
*/
|
||||
class OidcAuthStrategy {
|
||||
constructor() {
|
||||
this.name = 'openid-client'
|
||||
this.strategy = null
|
||||
this.client = null
|
||||
// Map of openId sessions indexed by oauth2 state-variable
|
||||
this.openIdAuthSession = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the passport strategy instance
|
||||
* @returns {OpenIDClient.Strategy}
|
||||
*/
|
||||
getStrategy() {
|
||||
if (!this.strategy) {
|
||||
this.strategy = new OpenIDClient.Strategy(
|
||||
{
|
||||
client: this.getClient(),
|
||||
params: {
|
||||
redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
|
||||
scope: this.getScope()
|
||||
}
|
||||
},
|
||||
this.verifyCallback.bind(this)
|
||||
)
|
||||
}
|
||||
return this.strategy
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OpenID Connect client
|
||||
* @returns {OpenIDClient.Client}
|
||||
*/
|
||||
getClient() {
|
||||
if (!this.client) {
|
||||
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
||||
throw new Error('OpenID Connect settings are not valid')
|
||||
}
|
||||
|
||||
// Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing
|
||||
OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 })
|
||||
|
||||
const openIdIssuerClient = new OpenIDClient.Issuer({
|
||||
issuer: global.ServerSettings.authOpenIDIssuerURL,
|
||||
authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL,
|
||||
token_endpoint: global.ServerSettings.authOpenIDTokenURL,
|
||||
userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL,
|
||||
jwks_uri: global.ServerSettings.authOpenIDJwksURL,
|
||||
end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL
|
||||
}).Client
|
||||
|
||||
this.client = new openIdIssuerClient({
|
||||
client_id: global.ServerSettings.authOpenIDClientID,
|
||||
client_secret: global.ServerSettings.authOpenIDClientSecret,
|
||||
id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm
|
||||
})
|
||||
}
|
||||
return this.client
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scope string for the OpenID Connect request
|
||||
* @returns {string}
|
||||
*/
|
||||
getScope() {
|
||||
let scope = 'openid profile email'
|
||||
if (global.ServerSettings.authOpenIDGroupClaim) {
|
||||
scope += ' ' + global.ServerSettings.authOpenIDGroupClaim
|
||||
}
|
||||
if (global.ServerSettings.authOpenIDAdvancedPermsClaim) {
|
||||
scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the strategy with passport
|
||||
*/
|
||||
init() {
|
||||
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
||||
Logger.error(`[OidcAuth] Cannot init openid auth strategy - invalid settings`)
|
||||
return
|
||||
}
|
||||
passport.use(this.name, this.getStrategy())
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the strategy from passport
|
||||
*/
|
||||
unuse() {
|
||||
passport.unuse(this.name)
|
||||
this.strategy = null
|
||||
this.client = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify callback for OpenID Connect authentication
|
||||
* @param {Object} tokenset
|
||||
* @param {Object} userinfo
|
||||
* @param {Function} done - Passport callback
|
||||
*/
|
||||
async verifyCallback(tokenset, userinfo, done) {
|
||||
try {
|
||||
Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
|
||||
|
||||
if (!userinfo.sub) {
|
||||
throw new Error('Invalid userinfo, no sub')
|
||||
}
|
||||
|
||||
if (!this.validateGroupClaim(userinfo)) {
|
||||
throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
|
||||
}
|
||||
|
||||
let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo)
|
||||
|
||||
if (!user?.isActive) {
|
||||
throw new Error('User not active or not found')
|
||||
}
|
||||
|
||||
await this.setUserGroup(user, userinfo)
|
||||
await this.updateUserPermissions(user, userinfo)
|
||||
|
||||
// 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
|
||||
|
||||
return done(null, user)
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`)
|
||||
return done(null, null, 'Unauthorized')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the presence and content of the group claim in userinfo.
|
||||
* @param {Object} userinfo
|
||||
* @returns {boolean}
|
||||
*/
|
||||
validateGroupClaim(userinfo) {
|
||||
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
|
||||
if (!groupClaimName)
|
||||
// Allow no group claim when configured like this
|
||||
return true
|
||||
|
||||
// If configured it must exist in userinfo
|
||||
if (!userinfo[groupClaimName]) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user group based on group claim in userinfo.
|
||||
* @param {import('../models/User')} user
|
||||
* @param {Object} userinfo
|
||||
*/
|
||||
async setUserGroup(user, userinfo) {
|
||||
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
|
||||
if (!groupClaimName)
|
||||
// No group claim configured, don't set anything
|
||||
return
|
||||
|
||||
if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
|
||||
|
||||
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
|
||||
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
||||
|
||||
let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
|
||||
if (userType) {
|
||||
if (user.type === 'root') {
|
||||
// Check OpenID Group
|
||||
if (userType !== 'admin') {
|
||||
throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`)
|
||||
} else {
|
||||
// If root user is logging in via OpenID, we will not change the type
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (user.type !== userType) {
|
||||
Logger.info(`[OidcAuth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`)
|
||||
user.type = userType
|
||||
await user.save()
|
||||
}
|
||||
} else {
|
||||
throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates user permissions based on the advanced permissions claim.
|
||||
* @param {import('../models/User')} user
|
||||
* @param {Object} userinfo
|
||||
*/
|
||||
async updateUserPermissions(user, userinfo) {
|
||||
const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim
|
||||
if (!absPermissionsClaim)
|
||||
// No advanced permissions claim configured, don't set anything
|
||||
return
|
||||
|
||||
if (user.type === 'admin' || user.type === 'root') return
|
||||
|
||||
const absPermissions = userinfo[absPermissionsClaim]
|
||||
if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
|
||||
|
||||
if (await user.updatePermissionsFromExternalJSON(absPermissions)) {
|
||||
Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE parameters for the authorization request
|
||||
* @param {Request} req
|
||||
* @param {boolean} isMobileFlow
|
||||
* @returns {Object|{error: string}}
|
||||
*/
|
||||
generatePkce(req, isMobileFlow) {
|
||||
if (isMobileFlow) {
|
||||
if (!req.query.code_challenge) {
|
||||
return {
|
||||
error: 'code_challenge required for mobile flow (PKCE)'
|
||||
}
|
||||
}
|
||||
if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') {
|
||||
return {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a redirect URI is valid
|
||||
* @param {string} uri
|
||||
* @returns {boolean}
|
||||
*/
|
||||
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] === '*')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authorization URL for OpenID Connect
|
||||
* Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow
|
||||
* @param {Request} req
|
||||
* @returns {{ authorizationUrl: string }|{status: number, error: string}}
|
||||
*/
|
||||
getAuthorizationUrl(req) {
|
||||
const client = this.getClient()
|
||||
const strategy = this.getStrategy()
|
||||
const sessionKey = strategy._key
|
||||
|
||||
try {
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const hostUrl = new URL(`${protocol}://${req.get('host')}`)
|
||||
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
|
||||
|
||||
// Only allow code flow (for mobile clients)
|
||||
if (req.query.response_type && req.query.response_type !== 'code') {
|
||||
Logger.debug(`[OidcAuth] OIDC Invalid response_type=${req.query.response_type}`)
|
||||
return {
|
||||
status: 400,
|
||||
error: 'Invalid response_type, only code supported'
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a state on web flow or if no state supplied
|
||||
const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state
|
||||
|
||||
// Redirect URL for the SSO provider
|
||||
let redirectUri
|
||||
if (isMobileFlow) {
|
||||
// Mobile required redirect uri
|
||||
// If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect
|
||||
// where we will handle the redirect to it
|
||||
if (!req.query.redirect_uri || !this.isValidRedirectUri(req.query.redirect_uri)) {
|
||||
Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`)
|
||||
return {
|
||||
status: 400,
|
||||
error: '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(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
|
||||
} else {
|
||||
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString()
|
||||
|
||||
if (req.query.state) {
|
||||
Logger.debug(`[OidcAuth] Invalid state - not allowed on web openid flow`)
|
||||
return {
|
||||
status: 400,
|
||||
error: 'Invalid state, not allowed on web flow'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the strategy's redirect_uri for this request
|
||||
strategy._params.redirect_uri = redirectUri
|
||||
Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`)
|
||||
|
||||
const pkceData = this.generatePkce(req, isMobileFlow)
|
||||
if (pkceData.error) {
|
||||
return {
|
||||
status: 400,
|
||||
error: pkceData.error
|
||||
}
|
||||
}
|
||||
|
||||
req.session[sessionKey] = {
|
||||
...req.session[sessionKey],
|
||||
state: state,
|
||||
max_age: strategy._params.max_age,
|
||||
response_type: 'code',
|
||||
code_verifier: pkceData.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
|
||||
sso_redirect_uri: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback
|
||||
}
|
||||
|
||||
const authorizationUrl = client.authorizationUrl({
|
||||
...strategy._params,
|
||||
redirect_uri: redirectUri,
|
||||
state: state,
|
||||
response_type: 'code',
|
||||
scope: this.getScope(),
|
||||
code_challenge: pkceData.code_challenge,
|
||||
code_challenge_method: pkceData.code_challenge_method
|
||||
})
|
||||
|
||||
return {
|
||||
authorizationUrl,
|
||||
isMobileFlow
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Error generating authorization URL: ${error}\n${error?.stack}`)
|
||||
return {
|
||||
status: 500,
|
||||
error: error.message || 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the end session URL for logout
|
||||
* @param {Request} req
|
||||
* @param {string} idToken
|
||||
* @param {string} authMethod
|
||||
* @returns {string|null}
|
||||
*/
|
||||
getEndSessionUrl(req, idToken, authMethod) {
|
||||
const client = this.getClient()
|
||||
|
||||
if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) {
|
||||
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}${global.RouterBasePath}/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)
|
||||
|
||||
return client.endSessionUrl({
|
||||
id_token_hint: idToken,
|
||||
post_logout_redirect_uri: postLogoutRedirectUri
|
||||
})
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} OpenIdIssuerConfig
|
||||
* @property {string} issuer
|
||||
* @property {string} authorization_endpoint
|
||||
* @property {string} token_endpoint
|
||||
* @property {string} userinfo_endpoint
|
||||
* @property {string} end_session_endpoint
|
||||
* @property {string} jwks_uri
|
||||
* @property {string} id_token_signing_alg_values_supported
|
||||
*
|
||||
* Get OpenID Connect configuration from an issuer URL
|
||||
* @param {string} issuerUrl
|
||||
* @returns {Promise<OpenIdIssuerConfig|{status: number, error: string}>}
|
||||
*/
|
||||
async getIssuerConfig(issuerUrl) {
|
||||
// Strip trailing slash
|
||||
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
||||
|
||||
// Append config pathname and validate URL
|
||||
let configUrl = null
|
||||
try {
|
||||
configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`)
|
||||
if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) {
|
||||
throw new Error('Invalid pathname')
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
|
||||
return {
|
||||
status: 400,
|
||||
error: "Invalid request. Query param 'issuer' is invalid"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(configUrl.toString())
|
||||
return {
|
||||
issuer: data.issuer,
|
||||
authorization_endpoint: data.authorization_endpoint,
|
||||
token_endpoint: data.token_endpoint,
|
||||
userinfo_endpoint: data.userinfo_endpoint,
|
||||
end_session_endpoint: data.end_session_endpoint,
|
||||
jwks_uri: data.jwks_uri,
|
||||
id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Failed to get openid configuration at "${configUrl}"`, error)
|
||||
return {
|
||||
status: 400,
|
||||
error: 'Failed to get openid configuration'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mobile redirect for OAuth2 callback
|
||||
* @param {Request} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
handleMobileRedirect(req, res) {
|
||||
try {
|
||||
// Extract the state parameter from the request
|
||||
const { state, code } = req.query
|
||||
|
||||
// Check if the state provided is in our list
|
||||
if (!state || !this.openIdAuthSession.has(state)) {
|
||||
Logger.error('[OidcAuth] /auth/openid/mobile-redirect route: State parameter mismatch')
|
||||
return res.status(400).send('State parameter mismatch')
|
||||
}
|
||||
|
||||
let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri
|
||||
|
||||
if (!mobile_redirect_uri) {
|
||||
Logger.error('[OidcAuth] No redirect URI')
|
||||
return res.status(400).send('No redirect URI')
|
||||
}
|
||||
|
||||
this.openIdAuthSession.delete(state)
|
||||
|
||||
const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
||||
// Redirect to the overwrite URI saved in the map
|
||||
res.redirect(redirectUri)
|
||||
} catch (error) {
|
||||
Logger.error(`[OidcAuth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`)
|
||||
res.status(500).send('Internal Server Error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = OidcAuthStrategy
|
|
@ -1,418 +0,0 @@
|
|||
const { Op } = require('sequelize')
|
||||
|
||||
const Database = require('../Database')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const requestIp = require('../libs/requestIp')
|
||||
const jwt = require('../libs/jsonwebtoken')
|
||||
|
||||
class TokenManager {
|
||||
/** @type {string} JWT secret key */
|
||||
static TokenSecret = null
|
||||
|
||||
constructor() {
|
||||
/** @type {number} Refresh token expiry in seconds */
|
||||
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days
|
||||
/** @type {number} Access token expiry in seconds */
|
||||
this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours
|
||||
|
||||
if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
|
||||
Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
|
||||
}
|
||||
if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {
|
||||
Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)
|
||||
}
|
||||
}
|
||||
|
||||
get TokenSecret() {
|
||||
return TokenManager.TokenSecret
|
||||
}
|
||||
|
||||
/**
|
||||
* Token secret is used to sign and verify JWTs
|
||||
* Set by ENV variable "JWT_SECRET_KEY" or generated and stored on server settings if not set
|
||||
*/
|
||||
async initTokenSecret() {
|
||||
if (process.env.JWT_SECRET_KEY) {
|
||||
// Use user supplied token secret
|
||||
Logger.info('[TokenManager] JWT secret key set from ENV variable')
|
||||
TokenManager.TokenSecret = process.env.JWT_SECRET_KEY
|
||||
} else if (!Database.serverSettings.tokenSecret) {
|
||||
// Generate new token secret and store it on server settings
|
||||
Logger.info('[TokenManager] JWT secret key not found, generating one')
|
||||
TokenManager.TokenSecret = require('crypto').randomBytes(256).toString('base64')
|
||||
Database.serverSettings.tokenSecret = TokenManager.TokenSecret
|
||||
await Database.updateServerSettings()
|
||||
} else {
|
||||
// Use existing token secret from server settings
|
||||
TokenManager.TokenSecret = Database.serverSettings.tokenSecret
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the refresh token cookie
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {string} refreshToken
|
||||
*/
|
||||
setRefreshTokenCookie(req, res, refreshToken) {
|
||||
res.cookie('refresh_token', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: req.secure || req.get('x-forwarded-proto') === 'https',
|
||||
sameSite: 'lax',
|
||||
maxAge: this.RefreshTokenExpiry * 1000,
|
||||
path: '/'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to validate a jwt token for a given user
|
||||
* Used to authenticate socket connections
|
||||
* TODO: Support API keys for web socket connections
|
||||
*
|
||||
* @param {string} token
|
||||
* @returns {Object} tokens data
|
||||
*/
|
||||
static validateAccessToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, TokenManager.TokenSecret)
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token for a given user
|
||||
* TODO: Old method with no expiration
|
||||
* @deprecated
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @returns {string}
|
||||
*/
|
||||
static generateAccessToken(user) {
|
||||
return jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret)
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to generate a jwt token for a given user
|
||||
* TODO: Old method with no expiration
|
||||
* @deprecated
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @returns {string}
|
||||
*/
|
||||
generateAccessToken(user) {
|
||||
return TokenManager.generateAccessToken(user)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token for a given user
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @returns {string}
|
||||
*/
|
||||
generateTempAccessToken(user) {
|
||||
const payload = {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
type: 'access'
|
||||
}
|
||||
const options = {
|
||||
expiresIn: this.AccessTokenExpiry
|
||||
}
|
||||
try {
|
||||
return jwt.sign(payload, TokenManager.TokenSecret, options)
|
||||
} catch (error) {
|
||||
Logger.error(`[TokenManager] Error generating access token for user ${user.id}: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token for a given user
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @returns {string}
|
||||
*/
|
||||
generateRefreshToken(user) {
|
||||
const payload = {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
type: 'refresh'
|
||||
}
|
||||
const options = {
|
||||
expiresIn: this.RefreshTokenExpiry
|
||||
}
|
||||
try {
|
||||
return jwt.sign(payload, TokenManager.TokenSecret, options)
|
||||
} catch (error) {
|
||||
Logger.error(`[TokenManager] Error generating refresh token for user ${user.id}: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tokens and session for a given user
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @param {import('express').Request} req
|
||||
* @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>}
|
||||
*/
|
||||
async createTokensAndSession(user, req) {
|
||||
const ipAddress = requestIp.getClientIp(req)
|
||||
const userAgent = req.headers['user-agent']
|
||||
const accessToken = this.generateTempAccessToken(user)
|
||||
const refreshToken = this.generateRefreshToken(user)
|
||||
|
||||
// Calculate expiration time for the refresh token
|
||||
const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
|
||||
|
||||
const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt)
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
session
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate tokens for a given session
|
||||
*
|
||||
* @param {import('../models/Session')} session
|
||||
* @param {import('../models/User')} user
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @returns {Promise<{ accessToken:string, refreshToken:string }>}
|
||||
*/
|
||||
async rotateTokensForSession(session, user, req, res) {
|
||||
// Generate new tokens
|
||||
const newAccessToken = this.generateTempAccessToken(user)
|
||||
const newRefreshToken = this.generateRefreshToken(user)
|
||||
|
||||
// Calculate new expiration time
|
||||
const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
|
||||
|
||||
// Update the session with the new refresh token and expiration
|
||||
session.refreshToken = newRefreshToken
|
||||
session.expiresAt = newExpiresAt
|
||||
await session.save()
|
||||
|
||||
// Set new refresh token cookie
|
||||
this.setRefreshTokenCookie(req, res, newRefreshToken)
|
||||
|
||||
return {
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the jwt is valid
|
||||
*
|
||||
* @param {Object} jwt_payload
|
||||
* @param {Function} done - passportjs callback
|
||||
*/
|
||||
async jwtAuthCheck(jwt_payload, done) {
|
||||
if (jwt_payload.type === 'api') {
|
||||
// Api key based authentication
|
||||
const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId)
|
||||
|
||||
if (!apiKey?.isActive) {
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the api key is expired and deactivate it
|
||||
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
||||
done(null, null)
|
||||
|
||||
apiKey.isActive = false
|
||||
await apiKey.save()
|
||||
Logger.info(`[TokenManager] API key ${apiKey.id} is expired - deactivated`)
|
||||
return
|
||||
}
|
||||
|
||||
const user = await Database.userModel.getUserById(apiKey.userId)
|
||||
done(null, user)
|
||||
} else {
|
||||
// JWT based authentication
|
||||
|
||||
// Check if the jwt is expired
|
||||
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// load user by id from the jwt token
|
||||
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)
|
||||
|
||||
if (!user?.isActive) {
|
||||
// deny login
|
||||
done(null, null)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Temporary flag to report old tokens to users
|
||||
// May be a better place for this but here means we dont have to decode the token again
|
||||
if (!jwt_payload.exp && !user.isOldToken) {
|
||||
Logger.debug(`[TokenManager] User ${user.username} is using an access token without an expiration`)
|
||||
user.isOldToken = true
|
||||
} else if (jwt_payload.exp && user.isOldToken !== undefined) {
|
||||
delete user.isOldToken
|
||||
}
|
||||
|
||||
// approve login
|
||||
done(null, user)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle refresh token
|
||||
*
|
||||
* @param {string} refreshToken
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @returns {Promise<{ accessToken?:string, refreshToken?:string, user?:import('../models/User'), error?:string }>}
|
||||
*/
|
||||
async handleRefreshToken(refreshToken, req, res) {
|
||||
try {
|
||||
// Verify the refresh token
|
||||
const decoded = jwt.verify(refreshToken, TokenManager.TokenSecret)
|
||||
|
||||
if (decoded.type !== 'refresh') {
|
||||
Logger.error(`[TokenManager] Failed to refresh token. Invalid token type: ${decoded.type}`)
|
||||
return {
|
||||
error: 'Invalid token type'
|
||||
}
|
||||
}
|
||||
|
||||
const session = await Database.sessionModel.findOne({
|
||||
where: { refreshToken: refreshToken }
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)
|
||||
return {
|
||||
error: 'Invalid refresh token'
|
||||
}
|
||||
}
|
||||
|
||||
// Check if session is expired in database
|
||||
if (session.expiresAt < new Date()) {
|
||||
Logger.info(`[TokenManager] Session expired in database, cleaning up`)
|
||||
await session.destroy()
|
||||
return {
|
||||
error: 'Refresh token expired'
|
||||
}
|
||||
}
|
||||
|
||||
const user = await Database.userModel.getUserById(decoded.userId)
|
||||
if (!user?.isActive) {
|
||||
Logger.error(`[TokenManager] Failed to refresh token. User not found or inactive for user id: ${decoded.userId}`)
|
||||
return {
|
||||
error: 'User not found or inactive'
|
||||
}
|
||||
}
|
||||
|
||||
const newTokens = await this.rotateTokensForSession(session, user, req, res)
|
||||
return {
|
||||
accessToken: newTokens.accessToken,
|
||||
refreshToken: newTokens.refreshToken,
|
||||
user
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
Logger.info(`[TokenManager] Refresh token expired, cleaning up session`)
|
||||
|
||||
// Clean up the expired session from database
|
||||
try {
|
||||
await Database.sessionModel.destroy({
|
||||
where: { refreshToken: refreshToken }
|
||||
})
|
||||
Logger.info(`[TokenManager] Expired session cleaned up`)
|
||||
} catch (cleanupError) {
|
||||
Logger.error(`[TokenManager] Error cleaning up expired session: ${cleanupError.message}`)
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'Refresh token expired'
|
||||
}
|
||||
} else if (error.name === 'JsonWebTokenError') {
|
||||
Logger.error(`[TokenManager] Invalid refresh token format: ${error.message}`)
|
||||
return {
|
||||
error: 'Invalid refresh token'
|
||||
}
|
||||
} else {
|
||||
Logger.error(`[TokenManager] Refresh token error: ${error.message}`)
|
||||
return {
|
||||
error: 'Invalid refresh token'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all JWT sessions for a given user
|
||||
* If user is current user and refresh token is valid, rotate tokens for the current session
|
||||
*
|
||||
* @param {import('../models/User')} user
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
|
||||
*/
|
||||
async invalidateJwtSessionsForUser(user, req, res) {
|
||||
const currentRefreshToken = req.cookies.refresh_token
|
||||
if (req.user.id === user.id && currentRefreshToken) {
|
||||
// Current user is the same as the user to invalidate sessions for
|
||||
// So rotate token for current session
|
||||
const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
|
||||
if (currentSession) {
|
||||
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res)
|
||||
|
||||
// Invalidate all sessions for the user except the current one
|
||||
await Database.sessionModel.destroy({
|
||||
where: {
|
||||
id: {
|
||||
[Op.ne]: currentSession.id
|
||||
},
|
||||
userId: user.id
|
||||
}
|
||||
})
|
||||
|
||||
return newTokens.accessToken
|
||||
} else {
|
||||
Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Current user is not the same as the user to invalidate sessions for (or no refresh token)
|
||||
// So invalidate all sessions for the user
|
||||
await Database.sessionModel.destroy({ where: { userId: user.id } })
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate a refresh token - used for logout
|
||||
*
|
||||
* @param {string} refreshToken
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async invalidateRefreshToken(refreshToken) {
|
||||
if (!refreshToken) {
|
||||
Logger.error(`[TokenManager] No refresh token provided to invalidate`)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } })
|
||||
Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`)
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TokenManager
|
|
@ -1,207 +0,0 @@
|
|||
const { Request, Response, NextFunction } = require('express')
|
||||
const uuidv4 = require('uuid').v4
|
||||
const Logger = require('../Logger')
|
||||
const Database = require('../Database')
|
||||
|
||||
/**
|
||||
* @typedef RequestUserObject
|
||||
* @property {import('../models/User')} user
|
||||
*
|
||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||
*/
|
||||
|
||||
class ApiKeyController {
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* GET: /api/api-keys
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async getAll(req, res) {
|
||||
const apiKeys = await Database.apiKeyModel.findAll({
|
||||
include: [
|
||||
{
|
||||
model: Database.userModel,
|
||||
attributes: ['id', 'username', 'type']
|
||||
},
|
||||
{
|
||||
model: Database.userModel,
|
||||
as: 'createdByUser',
|
||||
attributes: ['id', 'username', 'type']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return res.json({
|
||||
apiKeys: apiKeys.map((a) => a.toJSON())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: /api/api-keys
|
||||
*
|
||||
* @this {import('../routers/ApiRouter')}
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async create(req, res) {
|
||||
if (!req.body.name || typeof req.body.name !== 'string') {
|
||||
Logger.warn(`[ApiKeyController] create: Invalid name: ${req.body.name}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (req.body.expiresIn && (typeof req.body.expiresIn !== 'number' || req.body.expiresIn <= 0)) {
|
||||
Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (!req.body.userId || typeof req.body.userId !== 'string') {
|
||||
Logger.warn(`[ApiKeyController] create: Invalid userId: ${req.body.userId}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
const user = await Database.userModel.getUserById(req.body.userId)
|
||||
if (!user) {
|
||||
Logger.warn(`[ApiKeyController] create: User not found: ${req.body.userId}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.warn(`[ApiKeyController] create: Root user API key cannot be created by non-root user`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const keyId = uuidv4() // Generate key id ahead of time to use in JWT
|
||||
const apiKey = await Database.apiKeyModel.generateApiKey(this.auth.tokenManager.TokenSecret, keyId, req.body.name, req.body.expiresIn)
|
||||
|
||||
if (!apiKey) {
|
||||
Logger.error(`[ApiKeyController] create: Error generating API key`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
// Calculate expiration time for the api key
|
||||
const expiresAt = req.body.expiresIn ? new Date(Date.now() + req.body.expiresIn * 1000) : null
|
||||
|
||||
const apiKeyInstance = await Database.apiKeyModel.create({
|
||||
id: keyId,
|
||||
name: req.body.name,
|
||||
expiresAt,
|
||||
userId: req.body.userId,
|
||||
isActive: !!req.body.isActive,
|
||||
createdByUserId: req.user.id
|
||||
})
|
||||
apiKeyInstance.dataValues.user = await apiKeyInstance.getUser({
|
||||
attributes: ['id', 'username', 'type']
|
||||
})
|
||||
|
||||
Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`)
|
||||
return res.json({
|
||||
apiKey: {
|
||||
apiKey, // Actual key only shown to user on creation
|
||||
...apiKeyInstance.toJSON()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH: /api/api-keys/:id
|
||||
* Only isActive and userId can be updated because name and expiresIn are in the JWT
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async update(req, res) {
|
||||
const apiKey = await Database.apiKeyModel.findByPk(req.params.id, {
|
||||
include: {
|
||||
model: Database.userModel
|
||||
}
|
||||
})
|
||||
if (!apiKey) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
// Only root user can update root user API keys
|
||||
if (apiKey.user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.warn(`[ApiKeyController] update: Root user API key cannot be updated by non-root user`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
let hasUpdates = false
|
||||
if (req.body.userId !== undefined) {
|
||||
if (typeof req.body.userId !== 'string') {
|
||||
Logger.warn(`[ApiKeyController] update: Invalid userId: ${req.body.userId}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
const user = await Database.userModel.getUserById(req.body.userId)
|
||||
if (!user) {
|
||||
Logger.warn(`[ApiKeyController] update: User not found: ${req.body.userId}`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (user.type === 'root' && !req.user.isRoot) {
|
||||
Logger.warn(`[ApiKeyController] update: Root user API key cannot be created by non-root user`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (apiKey.userId !== req.body.userId) {
|
||||
apiKey.userId = req.body.userId
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (req.body.isActive !== undefined) {
|
||||
if (typeof req.body.isActive !== 'boolean') {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
if (apiKey.isActive !== req.body.isActive) {
|
||||
apiKey.isActive = req.body.isActive
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
await apiKey.save()
|
||||
apiKey.dataValues.user = await apiKey.getUser({
|
||||
attributes: ['id', 'username', 'type']
|
||||
})
|
||||
Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`)
|
||||
} else {
|
||||
Logger.info(`[ApiKeyController] No updates needed to API key "${apiKey.name}"`)
|
||||
}
|
||||
|
||||
return res.json({
|
||||
apiKey: apiKey.toJSON()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE: /api/api-keys/:id
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async delete(req, res) {
|
||||
const apiKey = await Database.apiKeyModel.findByPk(req.params.id)
|
||||
if (!apiKey) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
await apiKey.destroy()
|
||||
Logger.info(`[ApiKeyController] Deleted API key "${apiKey.name}"`)
|
||||
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
* @param {NextFunction} next
|
||||
*/
|
||||
middleware(req, res, next) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[ApiKeyController] Non-admin user "${req.user.username}" attempting to access api keys`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ApiKeyController()
|
|
@ -89,6 +89,7 @@ class FileSystemController {
|
|||
}
|
||||
|
||||
const { directory, folderPath } = req.body
|
||||
|
||||
if (!directory?.length || typeof directory !== 'string' || !folderPath?.length || typeof folderPath !== 'string') {
|
||||
Logger.error(`[FileSystemController] Invalid request body: ${JSON.stringify(req.body)}`)
|
||||
return res.status(400).json({
|
||||
|
@ -108,14 +109,7 @@ class FileSystemController {
|
|||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
if (!req.user.checkCanAccessLibrary(libraryFolder.libraryId)) {
|
||||
Logger.error(`[FileSystemController] User "${req.user.username}" attempting to check path exists for library "${libraryFolder.libraryId}" without access`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
let filepath = Path.join(libraryFolder.path, directory)
|
||||
filepath = fileUtils.filePathToPOSIX(filepath)
|
||||
|
||||
const filepath = Path.posix.join(libraryFolder.path, directory)
|
||||
// Ensure filepath is inside library folder (prevents directory traversal)
|
||||
if (!filepath.startsWith(libraryFolder.path)) {
|
||||
Logger.error(`[FileSystemController] Filepath is not inside library folder: ${filepath}`)
|
||||
|
|
|
@ -273,24 +273,12 @@ class MeController {
|
|||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
*/
|
||||
async updatePassword(req, res) {
|
||||
updatePassword(req, res) {
|
||||
if (req.user.isGuest) {
|
||||
Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`)
|
||||
return res.sendStatus(403)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
const { password, newPassword } = req.body
|
||||
if (!password || !newPassword || typeof password !== 'string' || typeof newPassword !== 'string') {
|
||||
return res.status(400).send('Missing or invalid password or new password')
|
||||
}
|
||||
|
||||
const result = await this.auth.localAuthStrategy.changePassword(req.user, password, newPassword)
|
||||
|
||||
if (result.error) {
|
||||
return res.status(400).send(result.error)
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
this.auth.userChangePassword(req, res)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -59,12 +59,6 @@ class MiscController {
|
|||
if (!library) {
|
||||
return res.status(404).send('Library not found')
|
||||
}
|
||||
|
||||
if (!req.user.checkCanAccessLibrary(library.id)) {
|
||||
Logger.error(`[MiscController] User "${req.user.username}" attempting to upload to library "${library.id}" without access`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const folder = library.libraryFolders.find((fold) => fold.id === folderId)
|
||||
if (!folder) {
|
||||
return res.status(404).send('Folder not found')
|
||||
|
|
|
@ -127,8 +127,8 @@ class UserController {
|
|||
}
|
||||
|
||||
const userId = uuidv4()
|
||||
const pash = await this.auth.localAuthStrategy.hashPassword(req.body.password)
|
||||
const token = this.auth.generateAccessToken({ id: userId, username: req.body.username })
|
||||
const pash = await this.auth.hashPass(req.body.password)
|
||||
const token = await this.auth.generateAccessToken({ id: userId, username: req.body.username })
|
||||
const userType = req.body.type || 'user'
|
||||
|
||||
// librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions
|
||||
|
@ -237,7 +237,6 @@ class UserController {
|
|||
|
||||
let hasUpdates = false
|
||||
let shouldUpdateToken = false
|
||||
let shouldInvalidateJwtSessions = false
|
||||
// When changing username create a new API token
|
||||
if (updatePayload.username && updatePayload.username !== user.username) {
|
||||
const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username)
|
||||
|
@ -246,13 +245,12 @@ class UserController {
|
|||
}
|
||||
user.username = updatePayload.username
|
||||
shouldUpdateToken = true
|
||||
shouldInvalidateJwtSessions = true
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Updating password
|
||||
if (updatePayload.password) {
|
||||
user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password)
|
||||
user.pash = await this.auth.hashPass(updatePayload.password)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
|
@ -327,24 +325,9 @@ class UserController {
|
|||
|
||||
if (hasUpdates) {
|
||||
if (shouldUpdateToken) {
|
||||
user.token = this.auth.generateAccessToken(user)
|
||||
user.token = await this.auth.generateAccessToken(user)
|
||||
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
|
||||
}
|
||||
|
||||
// Handle JWT session invalidation for username changes
|
||||
if (shouldInvalidateJwtSessions) {
|
||||
const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res)
|
||||
if (newAccessToken) {
|
||||
user.accessToken = newAccessToken
|
||||
// Refresh tokens are only returned for mobile clients
|
||||
// Mobile apps currently do not use this API endpoint so always set to null
|
||||
user.refreshToken = null
|
||||
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`)
|
||||
} else {
|
||||
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`)
|
||||
}
|
||||
}
|
||||
|
||||
await user.save()
|
||||
SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toOldJSONForBrowser())
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -203,15 +203,7 @@ class AbMergeManager {
|
|||
// Move library item tracks to cache
|
||||
for (const [index, trackPath] of task.data.originalTrackPaths.entries()) {
|
||||
const trackFilename = Path.basename(trackPath)
|
||||
let moveToPath = Path.join(task.data.itemCachePath, trackFilename)
|
||||
|
||||
// If the track is the same as the temp file, we need to rename it to avoid overwriting it
|
||||
if (task.data.tempFilepath === moveToPath) {
|
||||
const trackExtname = Path.extname(task.data.tempFilepath)
|
||||
const newTrackFilename = Path.basename(task.data.tempFilepath, trackExtname) + '.backup' + trackExtname
|
||||
moveToPath = Path.join(task.data.itemCachePath, newTrackFilename)
|
||||
}
|
||||
|
||||
const moveToPath = Path.join(task.data.itemCachePath, trackFilename)
|
||||
Logger.debug(`[AbMergeManager] Backing up original track "${trackPath}" to ${moveToPath}`)
|
||||
if (index === 0) {
|
||||
// copy the first track to the cache directory
|
||||
|
|
|
@ -31,12 +31,10 @@ class CronManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Initialize open session & auth session cleanup cron
|
||||
* Initialize open session cleanup cron
|
||||
* Runs every day at 00:30
|
||||
* Closes open share sessions that have not been updated in 24 hours
|
||||
* Closes open playback sessions that have not been updated in 36 hours
|
||||
* Cleans up expired auth sessions
|
||||
* Deactivates expired api keys
|
||||
* TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner
|
||||
*/
|
||||
initOpenSessionCleanupCron() {
|
||||
|
@ -44,8 +42,6 @@ class CronManager {
|
|||
Logger.debug('[CronManager] Open session cleanup cron executing')
|
||||
ShareManager.closeStaleOpenShareSessions()
|
||||
await this.playbackSessionManager.closeStaleOpenSessions()
|
||||
await Database.cleanupExpiredSessions()
|
||||
await Database.deactivateExpiredApiKeys()
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -71,54 +71,6 @@ class NotificationManager {
|
|||
this.triggerNotification('onBackupCompleted', eventData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles scheduled episode download RSS feed request failed
|
||||
*
|
||||
* @param {string} feedUrl
|
||||
* @param {number} numFailed
|
||||
* @param {string} title
|
||||
*/
|
||||
async onRSSFeedFailed(feedUrl, numFailed, title) {
|
||||
if (!Database.notificationSettings.isUseable) return
|
||||
|
||||
if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onRSSFeedFailed')) {
|
||||
Logger.debug(`[NotificationManager] onRSSFeedFailed: No active notifications`)
|
||||
return
|
||||
}
|
||||
|
||||
Logger.debug(`[NotificationManager] onRSSFeedFailed: RSS feed request failed for ${feedUrl}`)
|
||||
const eventData = {
|
||||
feedUrl: feedUrl,
|
||||
numFailed: numFailed || 0,
|
||||
title: title || 'Unknown Title'
|
||||
}
|
||||
this.triggerNotification('onRSSFeedFailed', eventData)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles scheduled episode downloads disabled due to too many failed attempts
|
||||
*
|
||||
* @param {string} feedUrl
|
||||
* @param {number} numFailed
|
||||
* @param {string} title
|
||||
*/
|
||||
async onRSSFeedDisabled(feedUrl, numFailed, title) {
|
||||
if (!Database.notificationSettings.isUseable) return
|
||||
|
||||
if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onRSSFeedDisabled')) {
|
||||
Logger.debug(`[NotificationManager] onRSSFeedDisabled: No active notifications`)
|
||||
return
|
||||
}
|
||||
|
||||
Logger.debug(`[NotificationManager] onRSSFeedDisabled: Podcast scheduled episode download disabled due to ${numFailed} failed requests for ${feedUrl}`)
|
||||
const eventData = {
|
||||
feedUrl: feedUrl,
|
||||
numFailed: numFailed || 0,
|
||||
title: title || 'Unknown Title'
|
||||
}
|
||||
this.triggerNotification('onRSSFeedDisabled', eventData)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} errorMsg
|
||||
|
|
|
@ -107,7 +107,7 @@ class PlaybackSessionManager {
|
|||
|
||||
const syncResults = []
|
||||
for (const sessionJson of sessions) {
|
||||
Logger.info(`[PlaybackSessionManager] Syncing local session "${sessionJson.displayTitle}" (${sessionJson.id}) (updatedAt: ${sessionJson.updatedAt})`)
|
||||
Logger.info(`[PlaybackSessionManager] Syncing local session "${sessionJson.displayTitle}" (${sessionJson.id})`)
|
||||
const result = await this.syncLocalSession(user, sessionJson, deviceInfo)
|
||||
syncResults.push(result)
|
||||
}
|
||||
|
@ -230,9 +230,9 @@ class PlaybackSessionManager {
|
|||
let userProgressForItem = user.getMediaProgress(mediaItemId)
|
||||
if (userProgressForItem) {
|
||||
if (userProgressForItem.updatedAt.valueOf() > session.updatedAt) {
|
||||
Logger.info(`[PlaybackSessionManager] Not updating progress for "${session.displayTitle}" because it has been updated more recently (${userProgressForItem.updatedAt.valueOf()} > ${session.updatedAt}) (incoming currentTime: ${session.currentTime}) (current currentTime: ${userProgressForItem.currentTime})`)
|
||||
Logger.debug(`[PlaybackSessionManager] Not updating progress for "${session.displayTitle}" because it has been updated more recently`)
|
||||
} else {
|
||||
Logger.info(`[PlaybackSessionManager] Updating progress for "${session.displayTitle}" with current time ${session.currentTime} (previously ${userProgressForItem.currentTime})`)
|
||||
Logger.debug(`[PlaybackSessionManager] Updating progress for "${session.displayTitle}" with current time ${session.currentTime} (previously ${userProgressForItem.currentTime})`)
|
||||
const updateResponse = await user.createUpdateMediaProgressFromPayload({
|
||||
libraryItemId: libraryItem.id,
|
||||
episodeId: session.episodeId,
|
||||
|
@ -246,7 +246,7 @@ class PlaybackSessionManager {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
Logger.info(`[PlaybackSessionManager] Creating new media progress for media item "${session.displayTitle}"`)
|
||||
Logger.debug(`[PlaybackSessionManager] Creating new media progress for media item "${session.displayTitle}"`)
|
||||
const updateResponse = await user.createUpdateMediaProgressFromPayload({
|
||||
libraryItemId: libraryItem.id,
|
||||
episodeId: session.episodeId,
|
||||
|
|
|
@ -30,7 +30,7 @@ class PodcastManager {
|
|||
this.currentDownload = null
|
||||
|
||||
this.failedCheckMap = {}
|
||||
this.MaxFailedEpisodeChecks = global.MaxFailedEpisodeChecks
|
||||
this.MaxFailedEpisodeChecks = 24
|
||||
}
|
||||
|
||||
getEpisodeDownloadsInQueue(libraryItemId) {
|
||||
|
@ -345,14 +345,12 @@ class PodcastManager {
|
|||
// Allow up to MaxFailedEpisodeChecks failed attempts before disabling auto download
|
||||
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
|
||||
this.failedCheckMap[libraryItem.id]++
|
||||
if (this.MaxFailedEpisodeChecks !== 0 && this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
|
||||
if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
|
||||
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}" - disabling auto download`)
|
||||
void NotificationManager.onRSSFeedDisabled(libraryItem.media.feedURL, this.failedCheckMap[libraryItem.id], libraryItem.media.title)
|
||||
libraryItem.media.autoDownloadEpisodes = false
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
} else {
|
||||
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}"`)
|
||||
void NotificationManager.onRSSFeedFailed(libraryItem.media.feedURL, this.failedCheckMap[libraryItem.id], libraryItem.media.title)
|
||||
}
|
||||
} else if (newEpisodes.length) {
|
||||
delete this.failedCheckMap[libraryItem.id]
|
||||
|
@ -386,17 +384,7 @@ class PodcastManager {
|
|||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`)
|
||||
return null
|
||||
}
|
||||
const feed = await Promise.race([
|
||||
getPodcastFeed(podcastLibraryItem.media.feedURL),
|
||||
new Promise((_, reject) =>
|
||||
// The added second is to make sure that axios can fail first and only falls back later
|
||||
setTimeout(() => reject(new Error('Timeout. getPodcastFeed seemed to timeout but not triggering the timeout.')), global.PodcastDownloadTimeout + 1000)
|
||||
)
|
||||
]).catch((error) => {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes failed to fetch feed for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id}):`, error)
|
||||
return null
|
||||
})
|
||||
|
||||
const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL)
|
||||
if (!feed?.episodes) {
|
||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed)
|
||||
return null
|
||||
|
|
|
@ -1,163 +0,0 @@
|
|||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.26.0'
|
||||
const migrationName = `${migrationVersion}-create-auth-tables`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This upward migration creates a sessions table and apiKeys table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
// Upwards migration script
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
// Check if table exists
|
||||
if (await queryInterface.tableExists('sessions')) {
|
||||
logger.info(`${loggerPrefix} table "sessions" already exists`)
|
||||
} else {
|
||||
// Create table
|
||||
logger.info(`${loggerPrefix} creating table "sessions"`)
|
||||
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
||||
await queryInterface.createTable('sessions', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
ipAddress: DataTypes.STRING,
|
||||
userAgent: DataTypes.STRING,
|
||||
refreshToken: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
expiresAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'users'
|
||||
},
|
||||
key: 'id'
|
||||
},
|
||||
allowNull: false,
|
||||
onDelete: 'CASCADE'
|
||||
}
|
||||
})
|
||||
logger.info(`${loggerPrefix} created table "sessions"`)
|
||||
}
|
||||
|
||||
// Check if table exists
|
||||
if (await queryInterface.tableExists('apiKeys')) {
|
||||
logger.info(`${loggerPrefix} table "apiKeys" already exists`)
|
||||
} else {
|
||||
// Create table
|
||||
logger.info(`${loggerPrefix} creating table "apiKeys"`)
|
||||
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
|
||||
await queryInterface.createTable('apiKeys', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: DataTypes.TEXT,
|
||||
expiresAt: DataTypes.DATE,
|
||||
lastUsedAt: DataTypes.DATE,
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
permissions: DataTypes.JSON,
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'users'
|
||||
},
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.UUID,
|
||||
references: {
|
||||
model: {
|
||||
tableName: 'users',
|
||||
as: 'createdByUser'
|
||||
},
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'SET NULL'
|
||||
}
|
||||
})
|
||||
logger.info(`${loggerPrefix} created table "apiKeys"`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This downward migration script removes the sessions table and apiKeys table.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
// Downward migration script
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
// Check if table exists
|
||||
if (await queryInterface.tableExists('sessions')) {
|
||||
logger.info(`${loggerPrefix} dropping table "sessions"`)
|
||||
// Drop table
|
||||
await queryInterface.dropTable('sessions')
|
||||
logger.info(`${loggerPrefix} dropped table "sessions"`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} table "sessions" does not exist`)
|
||||
}
|
||||
|
||||
if (await queryInterface.tableExists('apiKeys')) {
|
||||
logger.info(`${loggerPrefix} dropping table "apiKeys"`)
|
||||
await queryInterface.dropTable('apiKeys')
|
||||
logger.info(`${loggerPrefix} dropped table "apiKeys"`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} table "apiKeys" does not exist`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
|
@ -1,272 +0,0 @@
|
|||
const { DataTypes, Model, Op } = require('sequelize')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const { LRUCache } = require('lru-cache')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
/**
|
||||
* @typedef {Object} ApiKeyPermissions
|
||||
* @property {boolean} download
|
||||
* @property {boolean} update
|
||||
* @property {boolean} delete
|
||||
* @property {boolean} upload
|
||||
* @property {boolean} createEreader
|
||||
* @property {boolean} accessAllLibraries
|
||||
* @property {boolean} accessAllTags
|
||||
* @property {boolean} accessExplicitContent
|
||||
* @property {boolean} selectedTagsNotAccessible
|
||||
* @property {string[]} librariesAccessible
|
||||
* @property {string[]} itemTagsSelected
|
||||
*/
|
||||
|
||||
class ApiKeyCache {
|
||||
constructor() {
|
||||
this.cache = new LRUCache({ max: 100 })
|
||||
}
|
||||
|
||||
getById(id) {
|
||||
const apiKey = this.cache.get(id)
|
||||
return apiKey
|
||||
}
|
||||
|
||||
set(apiKey) {
|
||||
apiKey.fromCache = true
|
||||
this.cache.set(apiKey.id, apiKey)
|
||||
}
|
||||
|
||||
delete(apiKeyId) {
|
||||
this.cache.delete(apiKeyId)
|
||||
}
|
||||
|
||||
maybeInvalidate(apiKey) {
|
||||
if (!apiKey.fromCache) this.delete(apiKey.id)
|
||||
}
|
||||
}
|
||||
|
||||
const apiKeyCache = new ApiKeyCache()
|
||||
|
||||
class ApiKey extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
||||
/** @type {UUIDV4} */
|
||||
this.id
|
||||
/** @type {string} */
|
||||
this.name
|
||||
/** @type {string} */
|
||||
this.description
|
||||
/** @type {Date} */
|
||||
this.expiresAt
|
||||
/** @type {Date} */
|
||||
this.lastUsedAt
|
||||
/** @type {boolean} */
|
||||
this.isActive
|
||||
/** @type {ApiKeyPermissions} */
|
||||
this.permissions
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
/** @type {UUIDV4} */
|
||||
this.userId
|
||||
/** @type {UUIDV4} */
|
||||
this.createdByUserId
|
||||
|
||||
// Expanded properties
|
||||
|
||||
/** @type {import('./User').User} */
|
||||
this.user
|
||||
}
|
||||
|
||||
/**
|
||||
* Same properties as User.getDefaultPermissions
|
||||
* @returns {ApiKeyPermissions}
|
||||
*/
|
||||
static getDefaultPermissions() {
|
||||
return {
|
||||
download: true,
|
||||
update: true,
|
||||
delete: true,
|
||||
upload: true,
|
||||
createEreader: true,
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true,
|
||||
accessExplicitContent: true,
|
||||
selectedTagsNotAccessible: false, // Inverts itemTagsSelected
|
||||
librariesAccessible: [],
|
||||
itemTagsSelected: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge permissions from request with default permissions
|
||||
* @param {ApiKeyPermissions} reqPermissions
|
||||
* @returns {ApiKeyPermissions}
|
||||
*/
|
||||
static mergePermissionsWithDefault(reqPermissions) {
|
||||
const permissions = this.getDefaultPermissions()
|
||||
|
||||
if (!reqPermissions || typeof reqPermissions !== 'object') {
|
||||
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permissions: ${reqPermissions}`)
|
||||
return permissions
|
||||
}
|
||||
|
||||
for (const key in reqPermissions) {
|
||||
if (reqPermissions[key] === undefined) {
|
||||
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission key: ${key}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === 'librariesAccessible' || key === 'itemTagsSelected') {
|
||||
if (!Array.isArray(reqPermissions[key]) || reqPermissions[key].some((value) => typeof value !== 'string')) {
|
||||
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid ${key} value: ${reqPermissions[key]}`)
|
||||
continue
|
||||
}
|
||||
|
||||
permissions[key] = reqPermissions[key]
|
||||
} else if (typeof reqPermissions[key] !== 'boolean') {
|
||||
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission value for key ${key}. Should be boolean`)
|
||||
continue
|
||||
}
|
||||
|
||||
permissions[key] = reqPermissions[key]
|
||||
}
|
||||
|
||||
return permissions
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate expired api keys
|
||||
* @returns {Promise<number>} Number of api keys affected
|
||||
*/
|
||||
static async deactivateExpiredApiKeys() {
|
||||
const [affectedCount] = await ApiKey.update(
|
||||
{
|
||||
isActive: false
|
||||
},
|
||||
{
|
||||
where: {
|
||||
isActive: true,
|
||||
expiresAt: {
|
||||
[Op.lt]: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
return affectedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new api key
|
||||
* @param {string} tokenSecret
|
||||
* @param {string} keyId
|
||||
* @param {string} name
|
||||
* @param {number} [expiresIn] - Seconds until the api key expires or undefined for no expiration
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
static async generateApiKey(tokenSecret, keyId, name, expiresIn) {
|
||||
const options = {}
|
||||
if (expiresIn && !isNaN(expiresIn) && expiresIn > 0) {
|
||||
options.expiresIn = expiresIn
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
jwt.sign(
|
||||
{
|
||||
keyId,
|
||||
name,
|
||||
type: 'api'
|
||||
},
|
||||
tokenSecret,
|
||||
options,
|
||||
(err, token) => {
|
||||
if (err) {
|
||||
Logger.error(`[ApiKey] Error generating API key: ${err}`)
|
||||
resolve(null)
|
||||
} else {
|
||||
resolve(token)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an api key by id, from cache or database
|
||||
* @param {string} apiKeyId
|
||||
* @returns {Promise<ApiKey | null>}
|
||||
*/
|
||||
static async getById(apiKeyId) {
|
||||
if (!apiKeyId) return null
|
||||
|
||||
const cachedApiKey = apiKeyCache.getById(apiKeyId)
|
||||
if (cachedApiKey) return cachedApiKey
|
||||
|
||||
const apiKey = await ApiKey.findByPk(apiKeyId)
|
||||
if (!apiKey) return null
|
||||
|
||||
apiKeyCache.set(apiKey)
|
||||
return apiKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
*/
|
||||
static init(sequelize) {
|
||||
super.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
description: DataTypes.TEXT,
|
||||
expiresAt: DataTypes.DATE,
|
||||
lastUsedAt: DataTypes.DATE,
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
permissions: DataTypes.JSON
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'apiKey'
|
||||
}
|
||||
)
|
||||
|
||||
const { user } = sequelize.models
|
||||
user.hasMany(ApiKey, {
|
||||
onDelete: 'CASCADE'
|
||||
})
|
||||
ApiKey.belongsTo(user)
|
||||
|
||||
user.hasMany(ApiKey, {
|
||||
foreignKey: 'createdByUserId',
|
||||
onDelete: 'SET NULL'
|
||||
})
|
||||
ApiKey.belongsTo(user, { as: 'createdByUser', foreignKey: 'createdByUserId' })
|
||||
}
|
||||
|
||||
async update(values, options) {
|
||||
apiKeyCache.maybeInvalidate(this)
|
||||
return await super.update(values, options)
|
||||
}
|
||||
|
||||
async save(options) {
|
||||
apiKeyCache.maybeInvalidate(this)
|
||||
return await super.save(options)
|
||||
}
|
||||
|
||||
async destroy(options) {
|
||||
apiKeyCache.delete(this.id)
|
||||
await super.destroy(options)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ApiKey
|
|
@ -183,7 +183,7 @@ class MediaProgress extends Model {
|
|||
* @param {import('./User').ProgressUpdatePayload} progressPayload
|
||||
* @returns {Promise<MediaProgress>}
|
||||
*/
|
||||
async applyProgressUpdate(progressPayload) {
|
||||
applyProgressUpdate(progressPayload) {
|
||||
if (!this.extraData) this.extraData = {}
|
||||
if (progressPayload.isFinished !== undefined) {
|
||||
if (progressPayload.isFinished && !this.isFinished) {
|
||||
|
@ -222,13 +222,13 @@ class MediaProgress extends Model {
|
|||
const markAsFinishedPercentComplete = Number(progressPayload.markAsFinishedPercentComplete) / 100
|
||||
shouldMarkAsFinished = markAsFinishedPercentComplete < this.progress
|
||||
if (shouldMarkAsFinished) {
|
||||
Logger.info(`[MediaProgress] Marking media progress as finished because progress (${this.progress}) is greater than ${markAsFinishedPercentComplete} (media item ${this.mediaItemId})`)
|
||||
Logger.debug(`[MediaProgress] Marking media progress as finished because progress (${this.progress}) is greater than ${markAsFinishedPercentComplete}`)
|
||||
}
|
||||
} else {
|
||||
const markAsFinishedTimeRemaining = isNullOrNaN(progressPayload.markAsFinishedTimeRemaining) ? 10 : Number(progressPayload.markAsFinishedTimeRemaining)
|
||||
shouldMarkAsFinished = timeRemaining < markAsFinishedTimeRemaining
|
||||
if (shouldMarkAsFinished) {
|
||||
Logger.info(`[MediaProgress] Marking media progress as finished because time remaining (${timeRemaining}) is less than ${markAsFinishedTimeRemaining} seconds (media item ${this.mediaItemId})`)
|
||||
Logger.debug(`[MediaProgress] Marking media progress as finished because time remaining (${timeRemaining}) is less than ${markAsFinishedTimeRemaining} seconds`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -243,23 +243,13 @@ class MediaProgress extends Model {
|
|||
this.finishedAt = null
|
||||
}
|
||||
|
||||
await this.save()
|
||||
|
||||
// For local sync
|
||||
if (progressPayload.lastUpdate) {
|
||||
if (isNaN(new Date(progressPayload.lastUpdate))) {
|
||||
Logger.warn(`[MediaProgress] Invalid date provided for lastUpdate: ${progressPayload.lastUpdate} (media item ${this.mediaItemId})`)
|
||||
} else {
|
||||
const escapedDate = this.sequelize.escape(new Date(progressPayload.lastUpdate))
|
||||
Logger.info(`[MediaProgress] Manually setting updatedAt to ${escapedDate} (media item ${this.mediaItemId})`)
|
||||
|
||||
await this.sequelize.query(`UPDATE "mediaProgresses" SET "updatedAt" = ${escapedDate} WHERE "id" = '${this.id}'`)
|
||||
|
||||
await this.reload()
|
||||
}
|
||||
this.updatedAt = progressPayload.lastUpdate
|
||||
this.changed('updatedAt', true)
|
||||
}
|
||||
|
||||
return this
|
||||
return this.save({ silent: !!progressPayload.lastUpdate })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue