Merge branch 'advplyr:master' into master

This commit is contained in:
John 2025-07-29 22:02:19 -05:00 committed by GitHub
commit 093be36192
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 4189 additions and 1108 deletions

View file

@ -70,6 +70,11 @@ export default {
title: this.$strings.HeaderUsers, title: this.$strings.HeaderUsers,
path: '/config/users' path: '/config/users'
}, },
{
id: 'config-api-keys',
title: this.$strings.HeaderApiKeys,
path: '/config/api-keys'
},
{ {
id: 'config-sessions', id: 'config-sessions',
title: this.$strings.HeaderListeningSessions, title: this.$strings.HeaderListeningSessions,

View file

@ -778,10 +778,6 @@ export default {
windowResize() { windowResize() {
this.executeRebuild() this.executeRebuild()
}, },
socketInit() {
// Server settings are set on socket init
this.executeRebuild()
},
initListeners() { initListeners() {
window.addEventListener('resize', this.windowResize) window.addEventListener('resize', this.windowResize)
@ -794,7 +790,6 @@ export default {
}) })
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities) this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$on('socket_init', this.socketInit)
this.$eventBus.$on('user-settings', this.settingsUpdated) this.$eventBus.$on('user-settings', this.settingsUpdated)
if (this.$root.socket) { if (this.$root.socket) {
@ -826,7 +821,6 @@ export default {
} }
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities) this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$off('socket_init', this.socketInit)
this.$eventBus.$off('user-settings', this.settingsUpdated) this.$eventBus.$off('user-settings', this.settingsUpdated)
if (this.$root.socket) { if (this.$root.socket) {

View file

@ -116,7 +116,7 @@
</div> </div>
<div class="w-full h-12 px-1 py-2 border-t border-black/20 bg-bg absolute left-0" :style="{ bottom: streamLibraryItem ? '224px' : '65px' }"> <div class="w-full h-12 px-1 py-2 border-t border-black/20 bg-bg absolute left-0" :style="{ bottom: streamLibraryItem ? '224px' : '65px' }">
<p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p> <p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1 cursor-pointer" @click="clickChangelog">v{{ $config.version }}</p>
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a> <a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a>
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p> <p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
</div> </div>

View file

@ -71,9 +71,6 @@ export default {
coverHeight() { coverHeight() {
return this.cardHeight return this.cardHeight
}, },
userToken() {
return this.store.getters['user/getToken']
},
_author() { _author() {
return this.author || {} return this.author || {}
}, },

View file

@ -13,9 +13,17 @@
<div class="grow" /> <div class="grow" />
<p class="text-sm md:text-base">{{ book.publishedYear }}</p> <p class="text-sm md:text-base">{{ book.publishedYear }}</p>
</div> </div>
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">{{ $getString('LabelByAuthor', [book.author]) }}</p>
<p v-if="book.narrator" class="text-gray-400 text-xs">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p> <div class="flex items-center">
<p v-if="book.duration" class="text-gray-400 text-xs">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p> <div>
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">{{ $getString('LabelByAuthor', [book.author]) }}</p>
<p v-if="book.narrator" class="text-gray-400 text-xs">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p>
<p v-if="book.duration" class="text-gray-400 text-xs">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
</div>
<div class="grow" />
<div v-if="book.matchConfidence" class="rounded-full px-2 py-1 text-xs whitespace-nowrap text-white" :class="book.matchConfidence > 0.95 ? 'bg-success/80' : 'bg-info/80'">{{ $strings.LabelMatchConfidence }}: {{ (book.matchConfidence * 100).toFixed(0) }}%</div>
</div>
<div v-if="book.series?.length" class="flex py-1 -mx-1"> <div v-if="book.series?.length" class="flex py-1 -mx-1">
<div v-for="(series, index) in book.series" :key="index" class="bg-white/10 rounded-full px-1 py-0.5 mx-1"> <div v-for="(series, index) in book.series" :key="index" class="bg-white/10 rounded-full px-1 py-0.5 mx-1">
<p class="leading-3 text-xs text-gray-400"> <p class="leading-3 text-xs text-gray-400">

View file

@ -101,7 +101,8 @@
<!-- Podcast Episode # --> <!-- Podcast Episode # -->
<div cy-id="podcastEpisodeNumber" v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black/90 box-shadow-md z-10" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }"> <div cy-id="podcastEpisodeNumber" v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black/90 box-shadow-md z-10" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }">
<p :style="{ fontSize: 0.8 + 'em' }"> <p :style="{ fontSize: 0.8 + 'em' }">
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span> Episode
<span v-if="recentEpisodeNumber">#{{ recentEpisodeNumber }}</span>
</p> </p>
</div> </div>
@ -200,6 +201,9 @@ export default {
dateFormat() { dateFormat() {
return this.store.getters['getServerSetting']('dateFormat') return this.store.getters['getServerSetting']('dateFormat')
}, },
timeFormat() {
return this.store.getters['getServerSetting']('timeFormat')
},
_libraryItem() { _libraryItem() {
return this.libraryItem || {} return this.libraryItem || {}
}, },
@ -345,6 +349,10 @@ export default {
if (this.mediaMetadata.publishedYear) return this.$getString('LabelPublishedDate', [this.mediaMetadata.publishedYear]) if (this.mediaMetadata.publishedYear) return this.$getString('LabelPublishedDate', [this.mediaMetadata.publishedYear])
return '\u00A0' return '\u00A0'
} }
if (this.orderBy === 'progress') {
if (!this.userProgressLastUpdated) return '\u00A0'
return this.$getString('LabelLastProgressDate', [this.$formatDatetime(this.userProgressLastUpdated, this.dateFormat, this.timeFormat)])
}
return null return null
}, },
episodeProgress() { episodeProgress() {
@ -377,6 +385,10 @@ export default {
let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0 let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0
return Math.max(Math.min(1, progressPercent), 0) return Math.max(Math.min(1, progressPercent), 0)
}, },
userProgressLastUpdated() {
if (!this.userProgress) return null
return this.userProgress.lastUpdate
},
itemIsFinished() { itemIsFinished() {
if (this.booksInSeries) return this.seriesIsFinished if (this.booksInSeries) return this.seriesIsFinished
return this.userProgress ? !!this.userProgress.isFinished : false return this.userProgress ? !!this.userProgress.isFinished : false

View file

@ -7,7 +7,7 @@
</span> </span>
</button> </button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black/5 overflow-auto focus:outline-hidden text-sm" role="menu"> <ul v-show="showMenu" class="librarySortMenu absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black/5 overflow-auto focus:outline-hidden text-sm" role="menu">
<template v-for="item in selectItems"> <template v-for="item in selectItems">
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)"> <li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="menuitem" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
@ -130,6 +130,10 @@ export default {
text: this.$strings.LabelFileModified, text: this.$strings.LabelFileModified,
value: 'mtimeMs' value: 'mtimeMs'
}, },
{
text: this.$strings.LabelLibrarySortByProgress,
value: 'progress'
},
{ {
text: this.$strings.LabelRandomly, text: this.$strings.LabelRandomly,
value: 'random' value: 'random'
@ -191,3 +195,9 @@ export default {
} }
} }
</script> </script>
<style scoped>
.librarySortMenu {
max-height: calc(100vh - 125px);
}
</style>

View file

@ -39,9 +39,6 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
_author() { _author() {
return this.author || {} return this.author || {}
}, },

View file

@ -309,9 +309,9 @@ export default {
} else { } else {
console.log('Account updated', data.user) console.log('Account updated', data.user)
if (data.user.id === this.user.id && data.user.token !== this.user.token) { if (data.user.id === this.user.id && data.user.accessToken !== this.user.accessToken) {
console.log('Current user token was updated') console.log('Current user access token was updated')
this.$store.commit('user/setUserToken', data.user.token) this.$store.commit('user/setAccessToken', data.user.accessToken)
} }
this.$toast.success(this.$strings.ToastAccountUpdateSuccess) this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
@ -351,9 +351,6 @@ export default {
this.$toast.error(errMsg || 'Failed to create account') this.$toast.error(errMsg || 'Failed to create account')
}) })
}, },
toggleActive() {
this.newUser.isActive = !this.newUser.isActive
},
userTypeUpdated(type) { userTypeUpdated(type) {
this.newUser.permissions = { this.newUser.permissions = {
download: type !== 'guest', download: type !== 'guest',

View file

@ -0,0 +1,60 @@
<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>

View file

@ -0,0 +1,198 @@
<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>

View file

@ -81,7 +81,7 @@
</div> </div>
<div class="w-full md:w-1/3"> <div class="w-full md:w-1/3">
<p v-if="!isMediaItemShareSession" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p> <p v-if="!isMediaItemShareSession" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
<p v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ _session.userId }}</p> <p v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ username }}</p>
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p> <p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
<p class="mb-1">{{ playMethodName }}</p> <p class="mb-1">{{ playMethodName }}</p>
@ -132,6 +132,9 @@ export default {
_session() { _session() {
return this.session || {} return this.session || {}
}, },
username() {
return this._session.user?.username || this._session.userId || ''
},
deviceInfo() { deviceInfo() {
return this._session.deviceInfo || {} return this._session.deviceInfo || {}
}, },

View file

@ -23,7 +23,7 @@ export default {
processing: Boolean, processing: Boolean,
persistent: { persistent: {
type: Boolean, type: Boolean,
default: true default: false
}, },
width: { width: {
type: [String, Number], type: [String, Number],
@ -99,7 +99,7 @@ export default {
this.preventClickoutside = false this.preventClickoutside = false
return return
} }
if (this.processing && this.persistent) return if (this.processing || this.persistent) return
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) { if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
this.show = false this.show = false
} }

View file

@ -29,9 +29,6 @@ export default {
media() { media() {
return this.libraryItem.media || {} return this.libraryItem.media || {}
}, },
userToken() {
return this.$store.getters['user/getToken']
},
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
}, },

View file

@ -129,9 +129,6 @@ export default {
return `${hoursRounded}h` return `${hoursRounded}h`
} }
}, },
token() {
return this.$store.getters['user/getToken']
},
timeRemaining() { timeRemaining() {
if (this.useChapterTrack && this.currentChapter) { if (this.useChapterTrack && this.currentChapter) {
var currChapTime = this.currentTime - this.currentChapter.start var currChapTime = this.currentTime - this.currentChapter.start

View file

@ -104,9 +104,6 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() { libraryItemId() {
return this.libraryItem?.id return this.libraryItem?.id
}, },
@ -234,10 +231,7 @@ export default {
async extract() { async extract() {
this.loading = true this.loading = true
var buff = await this.$axios.$get(this.ebookUrl, { var buff = await this.$axios.$get(this.ebookUrl, {
responseType: 'blob', responseType: 'blob'
headers: {
Authorization: `Bearer ${this.userToken}`
}
}) })
const archive = await Archive.open(buff) const archive = await Archive.open(buff)
const originalFilesObject = await archive.getFilesObject() const originalFilesObject = await archive.getFilesObject()

View file

@ -57,9 +57,6 @@ export default {
} }
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
/** @returns {string} */ /** @returns {string} */
libraryItemId() { libraryItemId() {
return this.libraryItem?.id return this.libraryItem?.id
@ -97,27 +94,37 @@ export default {
}, },
ebookUrl() { ebookUrl() {
if (this.fileId) { if (this.fileId) {
return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}` return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
} }
return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook` return `/api/items/${this.libraryItemId}/ebook`
}, },
themeRules() { themeRules() {
const isDark = this.ereaderSettings.theme === 'dark' const theme = this.ereaderSettings.theme
const fontColor = isDark ? '#fff' : '#000' const isDark = theme === 'dark'
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)' const isSepia = theme === 'sepia'
const fontColor = isDark
? '#fff'
: isSepia
? '#5b4636'
: '#000'
const backgroundColor = isDark
? 'rgb(35 35 35)'
: isSepia
? 'rgb(244, 236, 216)'
: 'rgb(255, 255, 255)'
const lineSpacing = this.ereaderSettings.lineSpacing / 100 const lineSpacing = this.ereaderSettings.lineSpacing / 100
const fontScale = this.ereaderSettings.fontScale / 100
const fontScale = this.ereaderSettings.fontScale / 100 const textStroke = this.ereaderSettings.textStroke / 100
const textStroke = this.ereaderSettings.textStroke / 100
return { return {
'*': { '*': {
color: `${fontColor}!important`, color: `${fontColor}!important`,
'background-color': `${backgroundColor}!important`, 'background-color': `${backgroundColor}!important`,
'line-height': lineSpacing * fontScale + 'rem!important', 'line-height': `${lineSpacing * fontScale}rem!important`,
'-webkit-text-stroke': textStroke + 'px ' + fontColor + '!important' '-webkit-text-stroke': `${textStroke}px ${fontColor}!important`
}, },
a: { a: {
color: `${fontColor}!important` color: `${fontColor}!important`
@ -309,14 +316,24 @@ export default {
/** @type {EpubReader} */ /** @type {EpubReader} */
const reader = this 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} */ /** @type {ePub.Book} */
reader.book = new ePub(reader.ebookUrl, { reader.book = new ePub(reader.ebookUrl, {
width: this.readerWidth, width: this.readerWidth,
height: this.readerHeight - 50, height: this.readerHeight - 50,
openAs: 'epub', openAs: 'epub',
requestHeaders: { requestMethod: customRequest
Authorization: `Bearer ${this.userToken}`
}
}) })
/** @type {ePub.Rendition} */ /** @type {ePub.Rendition} */
@ -337,29 +354,33 @@ export default {
this.applyTheme() this.applyTheme()
}) })
reader.book.ready.then(() => { reader.book.ready
// set up event listeners .then(() => {
reader.rendition.on('relocated', reader.relocated) // set up event listeners
reader.rendition.on('keydown', reader.keyUp) reader.rendition.on('relocated', reader.relocated)
reader.rendition.on('keydown', reader.keyUp)
reader.rendition.on('touchstart', (event) => { reader.rendition.on('touchstart', (event) => {
this.$emit('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())
}) })
} reader.rendition.on('touchend', (event) => {
this.getChapters() 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)
})
}, },
getChapters() { getChapters() {
// Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759 // Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759

View file

@ -26,9 +26,6 @@ export default {
return {} return {}
}, },
computed: { computed: {
userToken() {
return this.$store.getters['user/getToken']
},
libraryItemId() { libraryItemId() {
return this.libraryItem?.id return this.libraryItem?.id
}, },
@ -96,11 +93,8 @@ export default {
}, },
async initMobi() { async initMobi() {
// Fetch mobi file as blob // Fetch mobi file as blob
var buff = await this.$axios.$get(this.ebookUrl, { const buff = await this.$axios.$get(this.ebookUrl, {
responseType: 'blob', responseType: 'blob'
headers: {
Authorization: `Bearer ${this.userToken}`
}
}) })
var reader = new FileReader() var reader = new FileReader()
reader.onload = async (event) => { reader.onload = async (event) => {

View file

@ -55,7 +55,8 @@ export default {
loadedRatio: 0, loadedRatio: 0,
page: 1, page: 1,
numPages: 0, numPages: 0,
pdfDocInitParams: null pdfDocInitParams: null,
isRefreshing: false
} }
}, },
computed: { computed: {
@ -152,7 +153,34 @@ export default {
this.page++ this.page++
this.updateProgress() this.updateProgress()
}, },
error(err) { 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
}
console.error(err) console.error(err)
}, },
resize() { resize() {

View file

@ -1,5 +1,5 @@
<template> <template>
<div v-if="show" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': !!streamLibraryItem }"> <div v-if="show" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black data-[theme=sepia]:bg-[rgb(244,236,216)] data-[theme=sepia]:text-[#5b4636]" :class="{ 'reader-player-open': !!streamLibraryItem }">
<div class="absolute top-4 left-4 z-20 flex items-center"> <div class="absolute top-4 left-4 z-20 flex items-center">
<button v-if="isEpub" @click="toggleToC" type="button" aria-label="Table of contents menu" class="inline-flex opacity-80 hover:opacity-100"> <button v-if="isEpub" @click="toggleToC" type="button" aria-label="Table of contents menu" class="inline-flex opacity-80 hover:opacity-100">
<span class="material-symbols text-2xl">menu</span> <span class="material-symbols text-2xl">menu</span>
@ -27,7 +27,12 @@
<!-- TOC side nav --> <!-- TOC side nav -->
<div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div> <div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
<div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent> <div
v-if="isEpub"
class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black group-data-[theme=sepia]:bg-[rgb(244,236,216)] group-data-[theme=sepia]:text-[#5b4636]"
:class="tocOpen ? 'translate-x-0' : '-translate-x-96'"
@click.stop.prevent
>
<div class="flex flex-col p-4 h-full"> <div class="flex flex-col p-4 h-full">
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100"> <button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100">
@ -37,7 +42,7 @@
<p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p> <p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p>
</div> </div>
<form @submit.prevent="searchBook" @click.stop.prevent> <form @submit.prevent="searchBook" @click.stop.prevent>
<ui-text-input clearable ref="input" @clear="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" /> <ui-text-input clearable ref="input" @clear="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" custom-input-class="text-inherit !bg-inherit" class="h-8 w-full text-sm flex mb-2" />
</form> </form>
<div class="overflow-y-auto"> <div class="overflow-y-auto">
@ -181,6 +186,10 @@ export default {
text: this.$strings.LabelThemeDark, text: this.$strings.LabelThemeDark,
value: 'dark' value: 'dark'
}, },
{
text: this.$strings.LabelThemeSepia,
value: 'sepia'
},
{ {
text: this.$strings.LabelThemeLight, text: this.$strings.LabelThemeLight,
value: 'light' value: 'light'
@ -266,9 +275,6 @@ export default {
isComic() { isComic() {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr' return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
}, },
userToken() {
return this.$store.getters['user/getToken']
},
keepProgress() { keepProgress() {
return this.$store.state.ereaderKeepProgress return this.$store.state.ereaderKeepProgress
}, },

View file

@ -0,0 +1,177 @@
<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>

View file

@ -49,9 +49,6 @@ export default {
libraryItemId() { libraryItemId() {
return this.libraryItem.id return this.libraryItem.id
}, },
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() { userCanDownload() {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
}, },

View file

@ -53,9 +53,6 @@ export default {
libraryItemId() { libraryItemId() {
return this.libraryItem.id return this.libraryItem.id
}, },
userToken() {
return this.$store.getters['user/getToken']
},
userCanDownload() { userCanDownload() {
return this.$store.getters['user/getUserCanDownload'] return this.$store.getters['user/getUserCanDownload']
}, },

View file

@ -85,9 +85,6 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
userToken() {
return this.$store.getters['user/getToken']
},
wrapperClass() { wrapperClass() {
var classes = [] var classes = []
if (this.disabled) classes.push('bg-black-300') if (this.disabled) classes.push('bg-black-300')

View file

@ -1,9 +1,9 @@
<template> <template>
<div class="relative w-full"> <div class="relative w-full">
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p> <p v-if="label && !labelHidden" 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"> <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="flex items-center">
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span> <span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small, 'text-gray-400': !selectedText }">{{ selectedText || placeholder }}</span>
<span v-if="selectedSubtext">:&nbsp;</span> <span v-if="selectedSubtext">:&nbsp;</span>
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span> <span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
</span> </span>
@ -36,10 +36,15 @@ export default {
type: String, type: String,
default: '' default: ''
}, },
labelHidden: Boolean,
items: { items: {
type: Array, type: Array,
default: () => [] default: () => []
}, },
placeholder: {
type: String,
default: ''
},
disabled: Boolean, disabled: Boolean,
small: Boolean, small: Boolean,
menuMaxHeight: { menuMaxHeight: {

View file

@ -6,7 +6,7 @@
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em> <em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</label> </label>
</slot> </slot>
<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" /> <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" />
</div> </div>
</template> </template>
@ -21,6 +21,7 @@ export default {
type: String, type: String,
default: 'text' default: 'text'
}, },
min: [String, Number],
readonly: Boolean, readonly: Boolean,
disabled: Boolean, disabled: Boolean,
inputClass: String, inputClass: String,

View file

@ -33,6 +33,7 @@ export default {
return { return {
socket: null, socket: null,
isSocketConnected: false, isSocketConnected: false,
isSocketAuthenticated: false,
isFirstSocketConnection: true, isFirstSocketConnection: true,
socketConnectionToastId: null, socketConnectionToastId: null,
currentLang: null, currentLang: null,
@ -81,9 +82,28 @@ export default {
document.body.classList.add('app-bar') 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) { updateSocketConnectionToast(content, type, timeout) {
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) { if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false) 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)
} else { } else {
this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null }) this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null })
} }
@ -109,7 +129,7 @@ export default {
this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null) this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null)
}, },
reconnect() { reconnect() {
console.error('[SOCKET] reconnected') console.log('[SOCKET] reconnected')
}, },
reconnectAttempt(val) { reconnectAttempt(val) {
console.log(`[SOCKET] reconnect attempt ${val}`) console.log(`[SOCKET] reconnect attempt ${val}`)
@ -120,6 +140,10 @@ export default {
reconnectFailed() { reconnectFailed() {
console.error('[SOCKET] reconnect failed') console.error('[SOCKET] reconnect failed')
}, },
authFailed(payload) {
console.error('[SOCKET] auth failed', payload.message)
this.isSocketAuthenticated = false
},
init(payload) { init(payload) {
console.log('Init Payload', payload) console.log('Init Payload', payload)
@ -127,7 +151,7 @@ export default {
this.$store.commit('users/setUsersOnline', payload.usersOnline) this.$store.commit('users/setUsersOnline', payload.usersOnline)
} }
this.$eventBus.$emit('socket_init') this.isSocketAuthenticated = true
}, },
streamOpen(stream) { streamOpen(stream) {
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream) if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream)
@ -354,6 +378,15 @@ export default {
this.$store.commit('scanners/removeCustomMetadataProvider', provider) this.$store.commit('scanners/removeCustomMetadataProvider', provider)
}, },
initializeSocket() { 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({ this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod', name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
persist: 'main', persist: 'main',
@ -364,6 +397,7 @@ export default {
path: `${this.$config.routerBasePath}/socket.io` path: `${this.$config.routerBasePath}/socket.io`
}) })
this.$root.socket = this.socket this.$root.socket = this.socket
this.isSocketAuthenticated = false
console.log('Socket initialized') console.log('Socket initialized')
// Pre-defined socket events // Pre-defined socket events
@ -377,6 +411,7 @@ export default {
// Event received after authorizing socket // Event received after authorizing socket
this.socket.on('init', this.init) this.socket.on('init', this.init)
this.socket.on('auth_failed', this.authFailed)
// Stream Listeners // Stream Listeners
this.socket.on('stream_open', this.streamOpen) this.socket.on('stream_open', this.streamOpen)
@ -571,6 +606,7 @@ export default {
this.updateBodyClass() this.updateBodyClass()
this.resize() this.resize()
this.$eventBus.$on('change-lang', this.changeLanguage) this.$eventBus.$on('change-lang', this.changeLanguage)
this.$eventBus.$on('token_refreshed', this.tokenRefreshed)
window.addEventListener('resize', this.resize) window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown) window.addEventListener('keydown', this.keyDown)
@ -594,6 +630,7 @@ export default {
}, },
beforeDestroy() { beforeDestroy() {
this.$eventBus.$off('change-lang', this.changeLanguage) this.$eventBus.$off('change-lang', this.changeLanguage)
this.$eventBus.$off('token_refreshed', this.tokenRefreshed)
window.removeEventListener('resize', this.resize) window.removeEventListener('resize', this.resize)
window.removeEventListener('keydown', this.keyDown) window.removeEventListener('keydown', this.keyDown)
} }

View file

@ -73,7 +73,8 @@ module.exports = {
// Axios module configuration: https://go.nuxtjs.dev/config-axios // Axios module configuration: https://go.nuxtjs.dev/config-axios
axios: { axios: {
baseURL: routerBasePath baseURL: routerBasePath,
progress: false
}, },
// nuxt/pwa https://pwa.nuxtjs.org // nuxt/pwa https://pwa.nuxtjs.org

View file

@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.25.1", "version": "2.26.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.25.1", "version": "2.26.3",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",

View file

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.25.1", "version": "2.26.3",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",

View file

@ -182,18 +182,19 @@ export default {
password: this.password, password: this.password,
newPassword: this.newPassword newPassword: this.newPassword
}) })
.then((res) => { .then(() => {
if (res.success) { this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess) this.resetForm()
this.resetForm()
} else {
this.$toast.error(res.error || this.$strings.ToastUnknownError)
}
this.changingPassword = false
}) })
.catch((error) => { .catch((error) => {
console.error(error) console.error('Failed to change password', error)
this.$toast.error(this.$strings.ToastUnknownError) let errorMessage = this.$strings.ToastUnknownError
if (error.response?.data && typeof error.response.data === 'string') {
errorMessage = error.response.data
}
this.$toast.error(errorMessage)
})
.finally(() => {
this.changingPassword = false this.changingPassword = false
}) })
}, },

View file

@ -53,6 +53,7 @@ export default {
else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions
else if (pageName === 'stats') return this.$strings.HeaderYourStats else if (pageName === 'stats') return this.$strings.HeaderYourStats
else if (pageName === 'users') return this.$strings.HeaderUsers 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 === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
else if (pageName === 'email') return this.$strings.HeaderEmail else if (pageName === 'email') return this.$strings.HeaderEmail

View file

@ -0,0 +1,84 @@
<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>

View file

@ -6,80 +6,82 @@
</div> </div>
<div v-if="listeningSessions.length" class="block max-w-full relative"> <div v-if="listeningSessions.length" class="block max-w-full relative">
<table class="userSessionsTable"> <div class="overflow-x-auto">
<tr class="bg-primary/40"> <table class="userSessionsTable">
<th class="w-6 min-w-6 text-left hidden md:table-cell h-11"> <tr class="bg-primary/40">
<ui-checkbox v-model="isAllSelected" :partial="numSelected > 0 && !isAllSelected" small checkbox-bg="bg" /> <th class="w-6 min-w-6 text-left hidden md:table-cell h-11">
</th> <ui-checkbox v-model="isAllSelected" :partial="numSelected > 0 && !isAllSelected" small checkbox-bg="bg" />
<th v-if="numSelected" class="grow text-left" :colspan="7"> </th>
<div class="flex items-center"> <th v-if="numSelected" class="grow text-left" :colspan="7">
<p>{{ $getString('MessageSelected', [numSelected]) }}</p> <div class="flex items-center">
<div class="grow" /> <p>{{ $getString('MessageSelected', [numSelected]) }}</p>
<ui-btn small color="bg-error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn> <div class="grow" />
</div> <ui-btn small color="bg-error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn>
</th> </div>
<th v-if="!numSelected" class="grow sm:grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')"> </th>
<div class="inline-flex items-center"> <th v-if="!numSelected" class="grow sm:grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')">
{{ $strings.LabelItem }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> <div class="inline-flex items-center">
</div> {{ $strings.LabelItem }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</th> </div>
<th v-if="!numSelected" class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th> </th>
<th v-if="!numSelected" class="w-26 min-w-26 text-left hidden md:table-cell group cursor-pointer" @click.stop="sortColumn('playMethod')"> <th v-if="!numSelected" class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
<div class="inline-flex items-center"> <th v-if="!numSelected" class="w-26 min-w-26 text-left hidden md:table-cell group cursor-pointer" @click.stop="sortColumn('playMethod')">
{{ $strings.LabelPlayMethod }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('playMethod') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> <div class="inline-flex items-center">
</div> {{ $strings.LabelPlayMethod }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('playMethod') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</th> </div>
<th v-if="!numSelected" class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th> </th>
<th v-if="!numSelected" class="w-24 min-w-24 sm:w-32 sm:min-w-32 group cursor-pointer" @click.stop="sortColumn('timeListening')"> <th v-if="!numSelected" class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
<div class="inline-flex items-center"> <th v-if="!numSelected" class="w-24 min-w-24 sm:w-32 sm:min-w-32 group cursor-pointer" @click.stop="sortColumn('timeListening')">
{{ $strings.LabelTimeListened }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> <div class="inline-flex items-center">
</div> {{ $strings.LabelTimeListened }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</th> </div>
<th v-if="!numSelected" class="w-24 min-w-24 group cursor-pointer" @click.stop="sortColumn('currentTime')"> </th>
<div class="inline-flex items-center"> <th v-if="!numSelected" class="w-24 min-w-24 group cursor-pointer" @click.stop="sortColumn('currentTime')">
{{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> <div class="inline-flex items-center">
</div> {{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-symbols text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</th> </div>
<th v-if="!numSelected" class="grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')"> </th>
<div class="inline-flex items-center"> <th v-if="!numSelected" class="grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')">
{{ $strings.LabelLastUpdate }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('updatedAt') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> <div class="inline-flex items-center">
</div> {{ $strings.LabelLastUpdate }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('updatedAt') }" class="material-symbols text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
</th> </div>
</tr> </th>
</tr>
<tr v-for="session in listeningSessions" :key="session.id" :class="{ selected: session.selected }" class="cursor-pointer" @click="clickSessionRow(session)"> <tr v-for="session in listeningSessions" :key="session.id" :class="{ selected: session.selected }" class="cursor-pointer" @click="clickSessionRow(session)">
<td class="hidden md:table-cell py-1 max-w-6 relative"> <td class="hidden md:table-cell py-1 max-w-6 relative">
<ui-checkbox v-model="session.selected" small checkbox-bg="bg" /> <ui-checkbox v-model="session.selected" small checkbox-bg="bg" />
<!-- overlay of the checkbox so that the entire box is clickable --> <!-- overlay of the checkbox so that the entire box is clickable -->
<div class="absolute inset-0 w-full h-full" @click.stop="session.selected = !session.selected" /> <div class="absolute inset-0 w-full h-full" @click.stop="session.selected = !session.selected" />
</td> </td>
<td class="py-1 grow sm:grow-0 sm:w-48 sm:max-w-48"> <td class="py-1 grow sm:grow-0 sm:w-48 sm:max-w-48">
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p> <p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p> <p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
</td> </td>
<td class="hidden md:table-cell w-20 min-w-20"> <td class="hidden md:table-cell w-20 min-w-20">
<p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p> <p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p>
<p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p> <p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p>
</td> </td>
<td class="hidden md:table-cell w-26 min-w-26"> <td class="hidden md:table-cell w-26 min-w-26">
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p> <p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
</td> </td>
<td class="hidden sm:table-cell max-w-32 min-w-32"> <td class="hidden sm:table-cell max-w-32 min-w-32">
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" /> <p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
</td> </td>
<td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32"> <td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32">
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p> <p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>
</td> </td>
<td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)"> <td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)">
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p> <p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
</td> </td>
<td class="text-center hidden sm:table-cell"> <td class="text-center hidden sm:table-cell">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)"> <ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p> <p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip> </ui-tooltip>
</td> </td>
</tr> </tr>
</table> </table>
</div>
<!-- table bottom options --> <!-- table bottom options -->
<div class="flex items-center my-2"> <div class="flex items-center my-2">
<div class="grow" /> <div class="grow" />

View file

@ -13,8 +13,10 @@
<widgets-online-indicator :value="!!userOnline" /> <widgets-online-indicator :value="!!userOnline" />
<h1 class="text-xl pl-2">{{ username }}</h1> <h1 class="text-xl pl-2">{{ username }}</h1>
</div> </div>
<div v-if="userToken" class="flex text-xs mt-4"> <div v-if="legacyToken" class="text-xs space-y-2 mt-4">
<ui-text-input-with-label :label="$strings.LabelApiToken" :value="userToken" readonly show-copy /> <ui-text-input-with-label label="Legacy API Token" :value="legacyToken" readonly show-copy />
<p class="text-warning" v-html="$strings.MessageAuthenticationLegacyTokenWarning" />
</div> </div>
<div class="w-full h-px bg-white/10 my-2" /> <div class="w-full h-px bg-white/10 my-2" />
<div class="py-2"> <div class="py-2">
@ -100,9 +102,12 @@ export default {
} }
}, },
computed: { computed: {
userToken() { legacyToken() {
return this.user.token return this.user.token
}, },
userToken() {
return this.user.accessToken
},
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },

View file

@ -19,39 +19,41 @@
<div class="py-2"> <div class="py-2">
<h1 class="text-lg mb-2 text-white/90 px-2 sm:px-0">{{ $strings.HeaderListeningSessions }}</h1> <h1 class="text-lg mb-2 text-white/90 px-2 sm:px-0">{{ $strings.HeaderListeningSessions }}</h1>
<div v-if="listeningSessions.length"> <div v-if="listeningSessions.length">
<table class="userSessionsTable"> <div class="overflow-x-auto">
<tr class="bg-primary/40"> <table class="userSessionsTable">
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th> <tr class="bg-primary/40">
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th> <th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th> <th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
<th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th> <th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th> <th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th>
<th class="grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th> <th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
</tr> <th class="grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
<tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)"> </tr>
<td class="py-1 max-w-48"> <tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)">
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p> <td class="py-1 max-w-48">
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p> <p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
</td> <p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
<td class="hidden md:table-cell"> </td>
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p> <td class="hidden md:table-cell">
</td> <p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
<td class="hidden sm:table-cell min-w-32 max-w-32"> </td>
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" /> <td class="hidden sm:table-cell min-w-32 max-w-32">
</td> <p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
<td class="text-center"> </td>
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p> <td class="text-center">
</td> <p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)"> </td>
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p> <td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
</td> <p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
<td class="text-center hidden sm:table-cell"> </td>
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)"> <td class="text-center hidden sm:table-cell">
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p> <ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
</ui-tooltip> <p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</td> </ui-tooltip>
</tr> </td>
</table> </tr>
</table>
</div>
<div class="flex items-center justify-end py-1"> <div class="flex items-center justify-end py-1">
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" /> <ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" />
<p class="text-sm mx-1">{{ $getString('LabelPaginationPageXOfY', [currentPage + 1, numPages]) }}</p> <p class="text-sm mx-1">{{ $getString('LabelPaginationPageXOfY', [currentPage + 1, numPages]) }}</p>

View file

@ -40,6 +40,15 @@
<p v-if="error" class="text-error text-center py-2">{{ error }}</p> <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"> <form v-show="login_local" @submit.prevent="submitForm">
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label> <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" /> <ui-text-input v-model.trim="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
@ -85,7 +94,10 @@ export default {
MetadataPath: '', MetadataPath: '',
login_local: true, login_local: true,
login_openid: false, login_openid: false,
authFormData: null authFormData: null,
// New JWT auth system re-login flags
showNewAuthSystemMessage: false,
showNewAuthSystemAdminMessage: false
} }
}, },
watch: { watch: {
@ -179,11 +191,17 @@ export default {
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
// Access token only returned from login, not authorize
if (user.accessToken) {
this.$store.commit('user/setAccessToken', user.accessToken)
}
this.$store.dispatch('user/loadUserSettings') this.$store.dispatch('user/loadUserSettings')
}, },
async submitForm() { async submitForm() {
this.error = null this.error = null
this.showNewAuthSystemMessage = false
this.showNewAuthSystemAdminMessage = false
this.processing = true this.processing = true
const payload = { const payload = {
@ -210,6 +228,8 @@ export default {
this.processing = true this.processing = true
this.$store.commit('user/setAccessToken', token)
return this.$axios return this.$axios
.$post('/api/authorize', null, { .$post('/api/authorize', null, {
headers: { headers: {
@ -217,15 +237,25 @@ export default {
} }
}) })
.then((res) => { .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.setUser(res)
this.processing = false
return true return true
}) })
.catch((error) => { .catch((error) => {
console.error('Authorize error', error) console.error('Authorize error', error)
this.processing = false
return false return false
}) })
.finally(() => {
this.processing = false
})
}, },
checkStatus() { checkStatus() {
this.processing = true this.processing = true
@ -280,8 +310,9 @@ export default {
} }
}, },
async mounted() { async mounted() {
if (this.$route.query?.setToken) { // Token passed as query parameter after successful oidc login
localStorage.setItem('token', this.$route.query.setToken) if (this.$route.query?.accessToken) {
localStorage.setItem('token', this.$route.query.accessToken)
} }
if (localStorage.getItem('token')) { if (localStorage.getItem('token')) {
if (await this.checkAuth()) return // if valid user no need to check status if (await this.checkAuth()) return // if valid user no need to check status

View file

@ -1,4 +1,19 @@
export default function ({ $axios, store, $config }) { 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 = []
}
$axios.onRequest((config) => { $axios.onRequest((config) => {
if (!config.url) { if (!config.url) {
console.error('Axios request invalid config', config) console.error('Axios request invalid config', config)
@ -7,7 +22,7 @@ export default function ({ $axios, store, $config }) {
if (config.url.startsWith('http:') || config.url.startsWith('https:')) { if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
return return
} }
const bearerToken = store.state.user.user?.token || null const bearerToken = store.getters['user/getToken']
if (bearerToken) { if (bearerToken) {
config.headers.common['Authorization'] = `Bearer ${bearerToken}` config.headers.common['Authorization'] = `Bearer ${bearerToken}`
} }
@ -17,9 +32,79 @@ export default function ({ $axios, store, $config }) {
} }
}) })
$axios.onError((error) => { $axios.onError(async (error) => {
const originalRequest = error.config
const code = parseInt(error.response && error.response.status) const code = parseInt(error.response && error.response.status)
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error' const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
console.error('Axios error', code, message) 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)
}) })
} }

View file

@ -37,6 +37,48 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false, useMilliseconds =
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}` 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) => { Vue.prototype.$secondsToTimestamp = (seconds, includeMs = false, alwaysIncludeHours = false) => {
if (!seconds) { if (!seconds) {
return alwaysIncludeHours ? '00:00:00' : '0:00' return alwaysIncludeHours ? '00:00:00' : '0:00'

View file

@ -1,5 +1,6 @@
export const state = () => ({ export const state = () => ({
user: null, user: null,
accessToken: null,
settings: { settings: {
orderBy: 'media.metadata.title', orderBy: 'media.metadata.title',
orderDesc: false, orderDesc: false,
@ -25,19 +26,19 @@ export const getters = {
getIsRoot: (state) => state.user && state.user.type === 'root', getIsRoot: (state) => state.user && state.user.type === 'root',
getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'), getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'),
getToken: (state) => { getToken: (state) => {
return state.user?.token || null return state.accessToken || null
}, },
getUserMediaProgress: getUserMediaProgress:
(state) => (state) =>
(libraryItemId, episodeId = null) => { (libraryItemId, episodeId = null) => {
if (!state.user.mediaProgress) return null if (!state.user?.mediaProgress) return null
return state.user.mediaProgress.find((li) => { return state.user.mediaProgress.find((li) => {
if (episodeId && li.episodeId !== episodeId) return false if (episodeId && li.episodeId !== episodeId) return false
return li.libraryItemId == libraryItemId return li.libraryItemId == libraryItemId
}) })
}, },
getUserBookmarksForItem: (state) => (libraryItemId) => { getUserBookmarksForItem: (state) => (libraryItemId) => {
if (!state.user.bookmarks) return [] if (!state.user?.bookmarks) return []
return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId) return state.user.bookmarks.filter((bm) => bm.libraryItemId === libraryItemId)
}, },
getUserSetting: (state) => (key) => { getUserSetting: (state) => (key) => {
@ -91,7 +92,7 @@ export const actions = {
if (state.settings.orderBy == 'media.duration') { if (state.settings.orderBy == 'media.duration') {
settingsUpdate.orderBy = 'media.numTracks' settingsUpdate.orderBy = 'media.numTracks'
} }
if (state.settings.orderBy == 'media.metadata.publishedYear') { if (state.settings.orderBy == 'media.metadata.publishedYear' || state.settings.orderBy == 'progress') {
settingsUpdate.orderBy = 'media.metadata.title' settingsUpdate.orderBy = 'media.metadata.title'
} }
const invalidFilters = ['series', 'authors', 'narrators', 'publishers', 'publishedDecades', 'languages', 'progress', 'issues', 'ebooks', 'abridged'] const invalidFilters = ['series', 'authors', 'narrators', 'publishers', 'publishedDecades', 'languages', 'progress', 'issues', 'ebooks', 'abridged']
@ -145,21 +146,42 @@ export const actions = {
} catch (error) { } catch (error) {
console.error('Failed to load userSettings from local storage', 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 = { export const mutations = {
setUser(state, user) { setUser(state, user) {
state.user = user state.user = user
if (user) {
if (user.token) localStorage.setItem('token', user.token)
} else {
localStorage.removeItem('token')
}
}, },
setUserToken(state, token) { setAccessToken(state, token) {
state.user.token = token if (!token) {
localStorage.setItem('token', token) localStorage.removeItem('token')
state.accessToken = null
} else {
state.accessToken = token
localStorage.setItem('token', token)
}
}, },
updateMediaProgress(state, { id, data }) { updateMediaProgress(state, { id, data }) {
if (!state.user) return if (!state.user) return

View file

@ -1,5 +1,6 @@
{ {
"ButtonAdd": "Přidat", "ButtonAdd": "Přidat",
"ButtonAddApiKey": "Přidat API klíč",
"ButtonAddChapters": "Přidat kapitoly", "ButtonAddChapters": "Přidat kapitoly",
"ButtonAddDevice": "Přidat zařízení", "ButtonAddDevice": "Přidat zařízení",
"ButtonAddLibrary": "Přidat knihovnu", "ButtonAddLibrary": "Přidat knihovnu",
@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Vybrat složku", "ButtonChooseAFolder": "Vybrat složku",
"ButtonChooseFiles": "Vybrat soubory", "ButtonChooseFiles": "Vybrat soubory",
"ButtonClearFilter": "Vymazat filtr", "ButtonClearFilter": "Vymazat filtr",
"ButtonClose": "Zavřít",
"ButtonCloseFeed": "Zavřít kanál", "ButtonCloseFeed": "Zavřít kanál",
"ButtonCloseSession": "Zavřít otevřenou relaci", "ButtonCloseSession": "Zavřít otevřenou relaci",
"ButtonCollections": "Kolekce", "ButtonCollections": "Kolekce",
@ -119,6 +121,7 @@
"HeaderAccount": "Účet", "HeaderAccount": "Účet",
"HeaderAddCustomMetadataProvider": "Přidat vlastního poskytovatele metadat", "HeaderAddCustomMetadataProvider": "Přidat vlastního poskytovatele metadat",
"HeaderAdvanced": "Pokročilé", "HeaderAdvanced": "Pokročilé",
"HeaderApiKeys": "API klíče",
"HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise", "HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise",
"HeaderAudioTracks": "Zvukové stopy", "HeaderAudioTracks": "Zvukové stopy",
"HeaderAudiobookTools": "Nástroje pro správu souborů audioknih", "HeaderAudiobookTools": "Nástroje pro správu souborů audioknih",
@ -162,6 +165,7 @@
"HeaderMetadataOrderOfPrecedence": "Pořadí priorit metadat", "HeaderMetadataOrderOfPrecedence": "Pořadí priorit metadat",
"HeaderMetadataToEmbed": "Metadata k vložení", "HeaderMetadataToEmbed": "Metadata k vložení",
"HeaderNewAccount": "Nový účet", "HeaderNewAccount": "Nový účet",
"HeaderNewApiKey": "Nový API klíč",
"HeaderNewLibrary": "Nová knihovna", "HeaderNewLibrary": "Nová knihovna",
"HeaderNotificationCreate": "Vytvořit notifikaci", "HeaderNotificationCreate": "Vytvořit notifikaci",
"HeaderNotificationUpdate": "Aktualizovat notifikaci", "HeaderNotificationUpdate": "Aktualizovat notifikaci",
@ -206,6 +210,7 @@
"HeaderTableOfContents": "Obsah", "HeaderTableOfContents": "Obsah",
"HeaderTools": "Nástroje", "HeaderTools": "Nástroje",
"HeaderUpdateAccount": "Aktualizovat účet", "HeaderUpdateAccount": "Aktualizovat účet",
"HeaderUpdateApiKey": "Aktualizovat API klíč",
"HeaderUpdateAuthor": "Aktualizovat autora", "HeaderUpdateAuthor": "Aktualizovat autora",
"HeaderUpdateDetails": "Aktualizovat podrobnosti", "HeaderUpdateDetails": "Aktualizovat podrobnosti",
"HeaderUpdateLibrary": "Aktualizovat knihovnu", "HeaderUpdateLibrary": "Aktualizovat knihovnu",
@ -235,6 +240,10 @@
"LabelAllUsersExcludingGuests": "Všichni uživatelé kromě hostů", "LabelAllUsersExcludingGuests": "Všichni uživatelé kromě hostů",
"LabelAllUsersIncludingGuests": "Všichni uživatelé včetně hostů", "LabelAllUsersIncludingGuests": "Všichni uživatelé včetně hostů",
"LabelAlreadyInYourLibrary": "Již ve vaší knihovně", "LabelAlreadyInYourLibrary": "Již ve vaší knihovně",
"LabelApiKeyCreated": "API klíč \"{0}\" byl úspěšně vytvořen.",
"LabelApiKeyCreatedDescription": "Zkopírujte si API klíč nyní, později již nebude možné jej zobrazit.",
"LabelApiKeyUser": "Vydávat se za uživatele",
"LabelApiKeyUserDescription": "Tento API klíč bude mít stejná oprávnění jako uživatel za něhož vystupuje. V protokolech to bude vypadat jako kdyby požadavky vytvářel přímo daný uživatel.",
"LabelApiToken": "API Token", "LabelApiToken": "API Token",
"LabelAppend": "Připojit", "LabelAppend": "Připojit",
"LabelAudioBitrate": "Bitový tok zvuku (např. 128k)", "LabelAudioBitrate": "Bitový tok zvuku (např. 128k)",
@ -346,11 +355,15 @@
"LabelExample": "Příklad", "LabelExample": "Příklad",
"LabelExpandSeries": "Rozbalit série", "LabelExpandSeries": "Rozbalit série",
"LabelExpandSubSeries": "Rozbalit podsérie", "LabelExpandSubSeries": "Rozbalit podsérie",
"LabelExplicit": "Explicitní", "LabelExpired": "Expirovaný",
"LabelExpiresAt": "Expiruje v",
"LabelExpiresInSeconds": "Expiruje za (sekundy)",
"LabelExpiresNever": "Nikdy",
"LabelExplicit": "Explicitně",
"LabelExplicitChecked": "Explicitní (zaškrtnuto)", "LabelExplicitChecked": "Explicitní (zaškrtnuto)",
"LabelExplicitUnchecked": "Není explicitní (nezaškrtnuto)", "LabelExplicitUnchecked": "Není explicitní (nezaškrtnuto)",
"LabelExportOPML": "Export OPML", "LabelExportOPML": "Export OPML",
"LabelFeedURL": "URL zdroje", "LabelFeedURL": "URL kanálu",
"LabelFetchingMetadata": "Získávání metadat", "LabelFetchingMetadata": "Získávání metadat",
"LabelFile": "Soubor", "LabelFile": "Soubor",
"LabelFileBirthtime": "Čas vzniku souboru", "LabelFileBirthtime": "Čas vzniku souboru",
@ -455,6 +468,7 @@
"LabelNewestEpisodes": "Nejnovější epizody", "LabelNewestEpisodes": "Nejnovější epizody",
"LabelNextBackupDate": "Datum příští zálohy", "LabelNextBackupDate": "Datum příští zálohy",
"LabelNextScheduledRun": "Další naplánované spuštění", "LabelNextScheduledRun": "Další naplánované spuštění",
"LabelNoApiKeys": "Žádné API klíče",
"LabelNoCustomMetadataProviders": "Žádní vlastní poskytovatelé metadat", "LabelNoCustomMetadataProviders": "Žádní vlastní poskytovatelé metadat",
"LabelNoEpisodesSelected": "Nebyly vybrány žádné epizody", "LabelNoEpisodesSelected": "Nebyly vybrány žádné epizody",
"LabelNotFinished": "Nedokončeno", "LabelNotFinished": "Nedokončeno",
@ -544,6 +558,7 @@
"LabelSelectAll": "Vybrat vše", "LabelSelectAll": "Vybrat vše",
"LabelSelectAllEpisodes": "Vybrat všechny epizody", "LabelSelectAllEpisodes": "Vybrat všechny epizody",
"LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují", "LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují",
"LabelSelectUser": "Vybrat uživatele",
"LabelSelectUsers": "Vybrat uživatele", "LabelSelectUsers": "Vybrat uživatele",
"LabelSendEbookToDevice": "Odeslat e-knihu do...", "LabelSendEbookToDevice": "Odeslat e-knihu do...",
"LabelSequence": "Sekvence", "LabelSequence": "Sekvence",
@ -708,7 +723,9 @@
"MessageAddToPlayerQueue": "Přidat do fronty přehrávače", "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>.", "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.", "MessageAsinCheck": "Ujistěte se, že používáte ASIN ze správného regionu Audible a ne z Amazonu.",
"MessageAuthenticationLegacyTokenWarning": "Zastaralé API tokeny budou v budoucnu odstraněny. Použijte místo nich <a href=\"/config/api-keys\">API klíče</a>.",
"MessageAuthenticationOIDCChangesRestart": "Po uložení restartujte server, aby se změny OIDC použily.", "MessageAuthenticationOIDCChangesRestart": "Po uložení restartujte server, aby se změny OIDC použily.",
"MessageAuthenticationSecurityMessage": "Bezpečnost autentizace byla vylepšena. Všichni uživatelé se musí znovu přihlásit.",
"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.", "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", "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.", "MessageBackupsLocationNoEditNote": "Poznámka: Umístění záloh je nastavené z proměnných prostředí a nelze zde změnit.",
@ -730,6 +747,7 @@
"MessageChaptersNotFound": "Kapitoly nenalezeny", "MessageChaptersNotFound": "Kapitoly nenalezeny",
"MessageCheckingCron": "Kontrola cronu...", "MessageCheckingCron": "Kontrola cronu...",
"MessageConfirmCloseFeed": "Opravdu chcete zavřít tento kanál?", "MessageConfirmCloseFeed": "Opravdu chcete zavřít tento kanál?",
"MessageConfirmDeleteApiKey": "Opravdu chcete vymazat API klíč \"{0}\"?",
"MessageConfirmDeleteBackup": "Opravdu chcete smazat zálohu pro {0}?", "MessageConfirmDeleteBackup": "Opravdu chcete smazat zálohu pro {0}?",
"MessageConfirmDeleteDevice": "Opravdu chcete vymazat zařízení e-reader \"{0}\"?", "MessageConfirmDeleteDevice": "Opravdu chcete vymazat zařízení e-reader \"{0}\"?",
"MessageConfirmDeleteFile": "Tento krok smaže soubor ze souborového systému. Jsi si jisti?", "MessageConfirmDeleteFile": "Tento krok smaže soubor ze souborového systému. Jsi si jisti?",
@ -757,6 +775,7 @@
"MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?", "MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?",
"MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?", "MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?",
"MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{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?", "MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?",
"MessageConfirmRemoveListeningSessions": "Opravdu chcete odebrat {0} poslechových relací?", "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ě?", "MessageConfirmRemoveMetadataFiles": "Jste si jisti, že chcete odstranit všechny metadata.{0} soubory ve složkách s položkami ve vaší knihovně?",
@ -1000,6 +1019,8 @@
"ToastEpisodeDownloadQueueClearSuccess": "Fronta stahování epizod je prázdná", "ToastEpisodeDownloadQueueClearSuccess": "Fronta stahování epizod je prázdná",
"ToastEpisodeUpdateSuccess": "{0} epizod aktualizováno", "ToastEpisodeUpdateSuccess": "{0} epizod aktualizováno",
"ToastErrorCannotShare": "Na tomto zařízení nelze nativně sdílet", "ToastErrorCannotShare": "Na tomto zařízení nelze nativně sdílet",
"ToastFailedToCreate": "Nepodařilo se vytvořit",
"ToastFailedToDelete": "Nepodařilo se odstranit",
"ToastFailedToLoadData": "Nepodařilo se načíst data", "ToastFailedToLoadData": "Nepodařilo se načíst data",
"ToastFailedToMatch": "Nepodařilo se spárovat", "ToastFailedToMatch": "Nepodařilo se spárovat",
"ToastFailedToShare": "Sdílení selhalo", "ToastFailedToShare": "Sdílení selhalo",
@ -1031,6 +1052,7 @@
"ToastMustHaveAtLeastOnePath": "Musí mít minimálně jednu cestu", "ToastMustHaveAtLeastOnePath": "Musí mít minimálně jednu cestu",
"ToastNameEmailRequired": "Jméno a email jsou vyžadovány", "ToastNameEmailRequired": "Jméno a email jsou vyžadovány",
"ToastNameRequired": "Jméno je vyžadováno", "ToastNameRequired": "Jméno je vyžadováno",
"ToastNewApiKeyUserError": "Je nutné vybrat uživatele",
"ToastNewEpisodesFound": "{0} nových epizod bylo nalezeno", "ToastNewEpisodesFound": "{0} nových epizod bylo nalezeno",
"ToastNewUserCreatedFailed": "Chyba při vytváření účtu: \"{0}\"", "ToastNewUserCreatedFailed": "Chyba při vytváření účtu: \"{0}\"",
"ToastNewUserCreatedSuccess": "Vytvořen nový účet", "ToastNewUserCreatedSuccess": "Vytvořen nový účet",

View file

@ -1,5 +1,6 @@
{ {
"ButtonAdd": "Hinzufügen", "ButtonAdd": "Hinzufügen",
"ButtonAddApiKey": "API-Schlüssel hinzufügen",
"ButtonAddChapters": "Kapitel hinzufügen", "ButtonAddChapters": "Kapitel hinzufügen",
"ButtonAddDevice": "Gerät hinzufügen", "ButtonAddDevice": "Gerät hinzufügen",
"ButtonAddLibrary": "Bibliothek hinzufügen", "ButtonAddLibrary": "Bibliothek hinzufügen",
@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Wähle einen Ordner", "ButtonChooseAFolder": "Wähle einen Ordner",
"ButtonChooseFiles": "Wähle eine Datei", "ButtonChooseFiles": "Wähle eine Datei",
"ButtonClearFilter": "Filter löschen", "ButtonClearFilter": "Filter löschen",
"ButtonClose": "Schließen",
"ButtonCloseFeed": "Feed schließen", "ButtonCloseFeed": "Feed schließen",
"ButtonCloseSession": "Offene Sitzung schließen", "ButtonCloseSession": "Offene Sitzung schließen",
"ButtonCollections": "Sammlungen", "ButtonCollections": "Sammlungen",
@ -32,7 +34,7 @@
"ButtonEditChapters": "Kapitel bearbeiten", "ButtonEditChapters": "Kapitel bearbeiten",
"ButtonEditPodcast": "Podcast bearbeiten", "ButtonEditPodcast": "Podcast bearbeiten",
"ButtonEnable": "Aktivieren", "ButtonEnable": "Aktivieren",
"ButtonFireAndFail": "Abfeuern und versagen", "ButtonFireAndFail": "Abschicken und fehlschlagen",
"ButtonFireOnTest": "Test-Event abfeuern", "ButtonFireOnTest": "Test-Event abfeuern",
"ButtonForceReScan": "Komplett-Scan (alle Medien)", "ButtonForceReScan": "Komplett-Scan (alle Medien)",
"ButtonFullPath": "Vollständiger Pfad", "ButtonFullPath": "Vollständiger Pfad",
@ -119,6 +121,7 @@
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAddCustomMetadataProvider": "Benutzerdefinierten Metadatenanbieter hinzufügen", "HeaderAddCustomMetadataProvider": "Benutzerdefinierten Metadatenanbieter hinzufügen",
"HeaderAdvanced": "Erweitert", "HeaderAdvanced": "Erweitert",
"HeaderApiKeys": "API-Schlüssel",
"HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen", "HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen",
"HeaderAudioTracks": "Audiodateien", "HeaderAudioTracks": "Audiodateien",
"HeaderAudiobookTools": "Hörbuch-Dateiverwaltungswerkzeuge", "HeaderAudiobookTools": "Hörbuch-Dateiverwaltungswerkzeuge",
@ -162,6 +165,7 @@
"HeaderMetadataOrderOfPrecedence": "Metadaten Rangfolge", "HeaderMetadataOrderOfPrecedence": "Metadaten Rangfolge",
"HeaderMetadataToEmbed": "Einzubettende Metadaten", "HeaderMetadataToEmbed": "Einzubettende Metadaten",
"HeaderNewAccount": "Neues Konto", "HeaderNewAccount": "Neues Konto",
"HeaderNewApiKey": "Neuen API-Schlüssel erstellen",
"HeaderNewLibrary": "Neue Bibliothek", "HeaderNewLibrary": "Neue Bibliothek",
"HeaderNotificationCreate": "Benachrichtigung erstellen", "HeaderNotificationCreate": "Benachrichtigung erstellen",
"HeaderNotificationUpdate": "Benachrichtigung bearbeiten", "HeaderNotificationUpdate": "Benachrichtigung bearbeiten",
@ -206,6 +210,7 @@
"HeaderTableOfContents": "Inhaltsverzeichnis", "HeaderTableOfContents": "Inhaltsverzeichnis",
"HeaderTools": "Werkzeuge", "HeaderTools": "Werkzeuge",
"HeaderUpdateAccount": "Konto aktualisieren", "HeaderUpdateAccount": "Konto aktualisieren",
"HeaderUpdateApiKey": "API-Schlüssel aktualisieren",
"HeaderUpdateAuthor": "Autor aktualisieren", "HeaderUpdateAuthor": "Autor aktualisieren",
"HeaderUpdateDetails": "Details aktualisieren", "HeaderUpdateDetails": "Details aktualisieren",
"HeaderUpdateLibrary": "Bibliothek aktualisieren", "HeaderUpdateLibrary": "Bibliothek aktualisieren",
@ -235,6 +240,10 @@
"LabelAllUsersExcludingGuests": "Alle Benutzer außer Gästen", "LabelAllUsersExcludingGuests": "Alle Benutzer außer Gästen",
"LabelAllUsersIncludingGuests": "Alle Benutzer und Gäste", "LabelAllUsersIncludingGuests": "Alle Benutzer und Gäste",
"LabelAlreadyInYourLibrary": "Bereits in der Bibliothek", "LabelAlreadyInYourLibrary": "Bereits in der Bibliothek",
"LabelApiKeyCreated": "API-Schlüssel \"{0}\" erfolgreich erstellt.",
"LabelApiKeyCreatedDescription": "Speichere den API-Schlüssel an einem sicheren Ort, du wirst ihn später nicht mehr abrufen können.",
"LabelApiKeyUser": "Im Kontext eines Nutzers agieren",
"LabelApiKeyUserDescription": "Dieser API-Schlüssel hat die gleichen Berechtigungen wie der Benutzer, in dessen Namen er erstellt wurde .In den Protokollen wird es aussehen, als ob der Benutzer die Anfrage durchführte.",
"LabelApiToken": "API Schlüssel", "LabelApiToken": "API Schlüssel",
"LabelAppend": "Anhängen", "LabelAppend": "Anhängen",
"LabelAudioBitrate": "Audiobitrate (z. B. 128 kbit/s)", "LabelAudioBitrate": "Audiobitrate (z. B. 128 kbit/s)",
@ -346,7 +355,11 @@
"LabelExample": "Beispiel", "LabelExample": "Beispiel",
"LabelExpandSeries": "Serie ausklappen", "LabelExpandSeries": "Serie ausklappen",
"LabelExpandSubSeries": "Unterserie ausklappen", "LabelExpandSubSeries": "Unterserie ausklappen",
"LabelExplicit": "Explizit (Altersbeschränkung)", "LabelExpired": "Abgelaufen",
"LabelExpiresAt": "Läuft ab am",
"LabelExpiresInSeconds": "Ablauf in (seconds) Sekunden",
"LabelExpiresNever": "Niemals",
"LabelExplicit": "Explizit",
"LabelExplicitChecked": "Explicit (Altersbeschränkung) (angehakt)", "LabelExplicitChecked": "Explicit (Altersbeschränkung) (angehakt)",
"LabelExplicitUnchecked": "Not Explicit (Altersbeschränkung) (nicht angehakt)", "LabelExplicitUnchecked": "Not Explicit (Altersbeschränkung) (nicht angehakt)",
"LabelExportOPML": "OPML exportieren", "LabelExportOPML": "OPML exportieren",
@ -425,6 +438,7 @@
"LabelLogLevelWarn": "Warnungen", "LabelLogLevelWarn": "Warnungen",
"LabelLookForNewEpisodesAfterDate": "Suche nach neuen Episoden nach diesem Datum", "LabelLookForNewEpisodesAfterDate": "Suche nach neuen Episoden nach diesem Datum",
"LabelLowestPriority": "Niedrigste Priorität", "LabelLowestPriority": "Niedrigste Priorität",
"LabelMatchConfidence": "Zuversicht",
"LabelMatchExistingUsersBy": "Zuordnen existierender Benutzer mit", "LabelMatchExistingUsersBy": "Zuordnen existierender Benutzer mit",
"LabelMatchExistingUsersByDescription": "Wird zum Verbinden vorhandener Benutzer verwendet. Sobald die Verbindung hergestellt ist, wird den Benutzern eine eindeutige ID vom SSO-Anbieter zugeordnet", "LabelMatchExistingUsersByDescription": "Wird zum Verbinden vorhandener Benutzer verwendet. Sobald die Verbindung hergestellt ist, wird den Benutzern eine eindeutige ID vom SSO-Anbieter zugeordnet",
"LabelMaxEpisodesToDownload": "Max. Anzahl an Episoden zum Herunterladen, 0 für unbegrenzte Episoden.", "LabelMaxEpisodesToDownload": "Max. Anzahl an Episoden zum Herunterladen, 0 für unbegrenzte Episoden.",
@ -455,6 +469,7 @@
"LabelNewestEpisodes": "Neueste Episoden", "LabelNewestEpisodes": "Neueste Episoden",
"LabelNextBackupDate": "Nächstes Sicherungsdatum", "LabelNextBackupDate": "Nächstes Sicherungsdatum",
"LabelNextScheduledRun": "Nächster planmäßiger Durchlauf", "LabelNextScheduledRun": "Nächster planmäßiger Durchlauf",
"LabelNoApiKeys": "Keine API-Schlüssel vorhanden",
"LabelNoCustomMetadataProviders": "Keine benutzerdefinierten Metadata Anbieter", "LabelNoCustomMetadataProviders": "Keine benutzerdefinierten Metadata Anbieter",
"LabelNoEpisodesSelected": "Keine Episoden ausgewählt", "LabelNoEpisodesSelected": "Keine Episoden ausgewählt",
"LabelNotFinished": "Nicht beendet", "LabelNotFinished": "Nicht beendet",
@ -514,7 +529,7 @@
"LabelPublishers": "Herausgeber", "LabelPublishers": "Herausgeber",
"LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail", "LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail",
"LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers", "LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers",
"LabelRSSFeedOpen": "RSS Feed öffnen", "LabelRSSFeedOpen": "RSS Feed offen",
"LabelRSSFeedPreventIndexing": "Indizierung verhindern", "LabelRSSFeedPreventIndexing": "Indizierung verhindern",
"LabelRSSFeedSlug": "RSS-Feed-Schlagwort", "LabelRSSFeedSlug": "RSS-Feed-Schlagwort",
"LabelRSSFeedURL": "RSS-Feed-URL", "LabelRSSFeedURL": "RSS-Feed-URL",
@ -544,6 +559,7 @@
"LabelSelectAll": "Alles auswählen", "LabelSelectAll": "Alles auswählen",
"LabelSelectAllEpisodes": "Alle Episoden auswählen", "LabelSelectAllEpisodes": "Alle Episoden auswählen",
"LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt", "LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt",
"LabelSelectUser": "Ausgewählter Benutzer",
"LabelSelectUsers": "Benutzer auswählen", "LabelSelectUsers": "Benutzer auswählen",
"LabelSendEbookToDevice": "E-Buch senden an …", "LabelSendEbookToDevice": "E-Buch senden an …",
"LabelSequence": "Reihenfolge", "LabelSequence": "Reihenfolge",
@ -708,7 +724,9 @@
"MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen", "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.", "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.", "MessageAsinCheck": "Stellen Sie sicher, dass Sie die ASIN aus der richtigen Audible Region verwenden, nicht Amazon.",
"MessageAuthenticationLegacyTokenWarning": "Alte API tokens werden in Zukunft entfernt. Benutze stattdessen <a href=\"/config/api-keys\">API Keys</a>.",
"MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muss der Server neugestartet werden um die OIDC Änderungen zu übernehmen.", "MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muss der Server neugestartet werden um die OIDC Änderungen zu übernehmen.",
"MessageAuthenticationSecurityMessage": "Die Anmeldung wurde abgesichert. Benutzersitzungen werden getrennt, alle Benutzer müssen sich erneut anmelden.",
"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.", "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", "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.", "MessageBackupsLocationNoEditNote": "Hinweis: Der Sicherungsspeicherort wird über eine Umgebungsvariable festgelegt und kann hier nicht geändert werden.",
@ -730,6 +748,7 @@
"MessageChaptersNotFound": "Kapitel gefunden nicht", "MessageChaptersNotFound": "Kapitel gefunden nicht",
"MessageCheckingCron": "Überprüfe Cron...", "MessageCheckingCron": "Überprüfe Cron...",
"MessageConfirmCloseFeed": "Feed wird geschlossen! Bist du dir sicher?", "MessageConfirmCloseFeed": "Feed wird geschlossen! Bist du dir sicher?",
"MessageConfirmDeleteApiKey": "Möchtest du den API-Schlüssel \"{0}\" wirklich entfernen ?",
"MessageConfirmDeleteBackup": "Sicherung für {0} wird gelöscht! Bist du dir sicher?", "MessageConfirmDeleteBackup": "Sicherung für {0} wird gelöscht! Bist du dir sicher?",
"MessageConfirmDeleteDevice": "Möchtest du das Lesegerät „{0}“ wirklich löschen?", "MessageConfirmDeleteDevice": "Möchtest du das Lesegerät „{0}“ wirklich löschen?",
"MessageConfirmDeleteFile": "Datei wird vom System gelöscht! Bist du dir sicher?", "MessageConfirmDeleteFile": "Datei wird vom System gelöscht! Bist du dir sicher?",
@ -757,6 +776,7 @@
"MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?",
"MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird entfernt! Bist du dir sicher?", "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird entfernt! Bist du dir sicher?",
"MessageConfirmRemoveEpisode": "Episode \"{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?", "MessageConfirmRemoveEpisodes": "{0} Episoden werden entfernt! Bist du dir sicher?",
"MessageConfirmRemoveListeningSessions": "Bist du dir sicher, dass du {0} Hörsitzungen enfernen möchtest?", "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?", "MessageConfirmRemoveMetadataFiles": "Bist du sicher, dass du alle metadata.{0} Dateien in deinen Bibliotheksordnern löschen willst?",
@ -1000,7 +1020,9 @@
"ToastEpisodeDownloadQueueClearSuccess": "Warteschlange für Episoden-Downloads gelöscht", "ToastEpisodeDownloadQueueClearSuccess": "Warteschlange für Episoden-Downloads gelöscht",
"ToastEpisodeUpdateSuccess": "{0} Episoden aktualisiert", "ToastEpisodeUpdateSuccess": "{0} Episoden aktualisiert",
"ToastErrorCannotShare": "Das kann nicht nativ auf diesem Gerät freigegeben werden", "ToastErrorCannotShare": "Das kann nicht nativ auf diesem Gerät freigegeben werden",
"ToastFailedToLoadData": "Daten laden fehlgeschlagen", "ToastFailedToCreate": "Fehler beim Erzeugen",
"ToastFailedToDelete": "Fehler beim Löschen",
"ToastFailedToLoadData": "Fehler beim laden der Daten",
"ToastFailedToMatch": "Fehler beim Abgleich", "ToastFailedToMatch": "Fehler beim Abgleich",
"ToastFailedToShare": "Fehler beim Teilen", "ToastFailedToShare": "Fehler beim Teilen",
"ToastFailedToUpdate": "Aktualisierung ist fehlgeschlagen", "ToastFailedToUpdate": "Aktualisierung ist fehlgeschlagen",
@ -1031,6 +1053,7 @@
"ToastMustHaveAtLeastOnePath": "Es muss mindestens ein Pfad angegeben werden", "ToastMustHaveAtLeastOnePath": "Es muss mindestens ein Pfad angegeben werden",
"ToastNameEmailRequired": "Name und E-Mail sind erforderlich", "ToastNameEmailRequired": "Name und E-Mail sind erforderlich",
"ToastNameRequired": "Name ist erforderlich", "ToastNameRequired": "Name ist erforderlich",
"ToastNewApiKeyUserError": "Bitte wähle einen Benutzer aus (Pflichtfeld)",
"ToastNewEpisodesFound": "{0} neue Episoden gefunden", "ToastNewEpisodesFound": "{0} neue Episoden gefunden",
"ToastNewUserCreatedFailed": "Fehler beim erstellen des Accounts: \"{ 0}\"", "ToastNewUserCreatedFailed": "Fehler beim erstellen des Accounts: \"{ 0}\"",
"ToastNewUserCreatedSuccess": "Neuer Account erstellt", "ToastNewUserCreatedSuccess": "Neuer Account erstellt",

View file

@ -1,5 +1,6 @@
{ {
"ButtonAdd": "Add", "ButtonAdd": "Add",
"ButtonAddApiKey": "Add API Key",
"ButtonAddChapters": "Add Chapters", "ButtonAddChapters": "Add Chapters",
"ButtonAddDevice": "Add Device", "ButtonAddDevice": "Add Device",
"ButtonAddLibrary": "Add Library", "ButtonAddLibrary": "Add Library",
@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Choose a folder", "ButtonChooseAFolder": "Choose a folder",
"ButtonChooseFiles": "Choose files", "ButtonChooseFiles": "Choose files",
"ButtonClearFilter": "Clear Filter", "ButtonClearFilter": "Clear Filter",
"ButtonClose": "Close",
"ButtonCloseFeed": "Close Feed", "ButtonCloseFeed": "Close Feed",
"ButtonCloseSession": "Close Open Session", "ButtonCloseSession": "Close Open Session",
"ButtonCollections": "Collections", "ButtonCollections": "Collections",
@ -119,6 +121,7 @@
"HeaderAccount": "Account", "HeaderAccount": "Account",
"HeaderAddCustomMetadataProvider": "Add Custom Metadata Provider", "HeaderAddCustomMetadataProvider": "Add Custom Metadata Provider",
"HeaderAdvanced": "Advanced", "HeaderAdvanced": "Advanced",
"HeaderApiKeys": "API Keys",
"HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAppriseNotificationSettings": "Apprise Notification Settings",
"HeaderAudioTracks": "Audio Tracks", "HeaderAudioTracks": "Audio Tracks",
"HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudiobookTools": "Audiobook File Management Tools",
@ -162,6 +165,7 @@
"HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence",
"HeaderMetadataToEmbed": "Metadata to embed", "HeaderMetadataToEmbed": "Metadata to embed",
"HeaderNewAccount": "New Account", "HeaderNewAccount": "New Account",
"HeaderNewApiKey": "New API Key",
"HeaderNewLibrary": "New Library", "HeaderNewLibrary": "New Library",
"HeaderNotificationCreate": "Create Notification", "HeaderNotificationCreate": "Create Notification",
"HeaderNotificationUpdate": "Update Notification", "HeaderNotificationUpdate": "Update Notification",
@ -206,6 +210,7 @@
"HeaderTableOfContents": "Table of Contents", "HeaderTableOfContents": "Table of Contents",
"HeaderTools": "Tools", "HeaderTools": "Tools",
"HeaderUpdateAccount": "Update Account", "HeaderUpdateAccount": "Update Account",
"HeaderUpdateApiKey": "Update API Key",
"HeaderUpdateAuthor": "Update Author", "HeaderUpdateAuthor": "Update Author",
"HeaderUpdateDetails": "Update Details", "HeaderUpdateDetails": "Update Details",
"HeaderUpdateLibrary": "Update Library", "HeaderUpdateLibrary": "Update Library",
@ -235,6 +240,10 @@
"LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersExcludingGuests": "All users excluding guests",
"LabelAllUsersIncludingGuests": "All users including guests", "LabelAllUsersIncludingGuests": "All users including guests",
"LabelAlreadyInYourLibrary": "Already in your library", "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", "LabelApiToken": "API Token",
"LabelAppend": "Append", "LabelAppend": "Append",
"LabelAudioBitrate": "Audio Bitrate (e.g. 128k)", "LabelAudioBitrate": "Audio Bitrate (e.g. 128k)",
@ -346,6 +355,10 @@
"LabelExample": "Example", "LabelExample": "Example",
"LabelExpandSeries": "Expand Series", "LabelExpandSeries": "Expand Series",
"LabelExpandSubSeries": "Expand Sub Series", "LabelExpandSubSeries": "Expand Sub Series",
"LabelExpired": "Expired",
"LabelExpiresAt": "Expires At",
"LabelExpiresInSeconds": "Expires in (seconds)",
"LabelExpiresNever": "Never",
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelExplicitChecked": "Explicit (checked)", "LabelExplicitChecked": "Explicit (checked)",
"LabelExplicitUnchecked": "Not Explicit (unchecked)", "LabelExplicitUnchecked": "Not Explicit (unchecked)",
@ -405,6 +418,7 @@
"LabelLanguages": "Languages", "LabelLanguages": "Languages",
"LabelLastBookAdded": "Last Book Added", "LabelLastBookAdded": "Last Book Added",
"LabelLastBookUpdated": "Last Book Updated", "LabelLastBookUpdated": "Last Book Updated",
"LabelLastProgressDate": "Last progress: {0}",
"LabelLastSeen": "Last Seen", "LabelLastSeen": "Last Seen",
"LabelLastTime": "Last Time", "LabelLastTime": "Last Time",
"LabelLastUpdate": "Last Update", "LabelLastUpdate": "Last Update",
@ -417,6 +431,7 @@
"LabelLibraryFilterSublistEmpty": "No {0}", "LabelLibraryFilterSublistEmpty": "No {0}",
"LabelLibraryItem": "Library Item", "LabelLibraryItem": "Library Item",
"LabelLibraryName": "Library Name", "LabelLibraryName": "Library Name",
"LabelLibrarySortByProgress": "Progress Updated",
"LabelLimit": "Limit", "LabelLimit": "Limit",
"LabelLineSpacing": "Line spacing", "LabelLineSpacing": "Line spacing",
"LabelListenAgain": "Listen Again", "LabelListenAgain": "Listen Again",
@ -425,6 +440,7 @@
"LabelLogLevelWarn": "Warn", "LabelLogLevelWarn": "Warn",
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelLowestPriority": "Lowest Priority", "LabelLowestPriority": "Lowest Priority",
"LabelMatchConfidence": "Confidence",
"LabelMatchExistingUsersBy": "Match existing users by", "LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMaxEpisodesToDownload": "Max # of episodes to download. Use 0 for unlimited.", "LabelMaxEpisodesToDownload": "Max # of episodes to download. Use 0 for unlimited.",
@ -455,6 +471,7 @@
"LabelNewestEpisodes": "Newest Episodes", "LabelNewestEpisodes": "Newest Episodes",
"LabelNextBackupDate": "Next backup date", "LabelNextBackupDate": "Next backup date",
"LabelNextScheduledRun": "Next scheduled run", "LabelNextScheduledRun": "Next scheduled run",
"LabelNoApiKeys": "No API keys",
"LabelNoCustomMetadataProviders": "No custom metadata providers", "LabelNoCustomMetadataProviders": "No custom metadata providers",
"LabelNoEpisodesSelected": "No episodes selected", "LabelNoEpisodesSelected": "No episodes selected",
"LabelNotFinished": "Not Finished", "LabelNotFinished": "Not Finished",
@ -544,6 +561,7 @@
"LabelSelectAll": "Select all", "LabelSelectAll": "Select all",
"LabelSelectAllEpisodes": "Select all episodes", "LabelSelectAllEpisodes": "Select all episodes",
"LabelSelectEpisodesShowing": "Select {0} episodes showing", "LabelSelectEpisodesShowing": "Select {0} episodes showing",
"LabelSelectUser": "Select user",
"LabelSelectUsers": "Select users", "LabelSelectUsers": "Select users",
"LabelSendEbookToDevice": "Send Ebook to...", "LabelSendEbookToDevice": "Send Ebook to...",
"LabelSequence": "Sequence", "LabelSequence": "Sequence",
@ -640,6 +658,7 @@
"LabelTheme": "Theme", "LabelTheme": "Theme",
"LabelThemeDark": "Dark", "LabelThemeDark": "Dark",
"LabelThemeLight": "Light", "LabelThemeLight": "Light",
"LabelThemeSepia": "Sepia",
"LabelTimeBase": "Time Base", "LabelTimeBase": "Time Base",
"LabelTimeDurationXHours": "{0} hours", "LabelTimeDurationXHours": "{0} hours",
"LabelTimeDurationXMinutes": "{0} minutes", "LabelTimeDurationXMinutes": "{0} minutes",
@ -708,7 +727,9 @@
"MessageAddToPlayerQueue": "Add to player queue", "MessageAddToPlayerQueue": "Add to player queue",
"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>.", "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.", "MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.",
"MessageAuthenticationLegacyTokenWarning": "Legacy API tokens will be removed in the future. Use <a href=\"/config/api-keys\">API Keys</a> instead.",
"MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.", "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.", "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", "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.", "MessageBackupsLocationNoEditNote": "Note: The backup location is set through an environment variable and cannot be changed here.",
@ -730,6 +751,7 @@
"MessageChaptersNotFound": "Chapters not found", "MessageChaptersNotFound": "Chapters not found",
"MessageCheckingCron": "Checking cron...", "MessageCheckingCron": "Checking cron...",
"MessageConfirmCloseFeed": "Are you sure you want to close this feed?", "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}?", "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?",
"MessageConfirmDeleteDevice": "Are you sure you want to delete e-reader device \"{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?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?",
@ -1001,6 +1023,8 @@
"ToastEpisodeDownloadQueueClearSuccess": "Episode download queue cleared", "ToastEpisodeDownloadQueueClearSuccess": "Episode download queue cleared",
"ToastEpisodeUpdateSuccess": "{0} episodes updated", "ToastEpisodeUpdateSuccess": "{0} episodes updated",
"ToastErrorCannotShare": "Cannot share natively on this device", "ToastErrorCannotShare": "Cannot share natively on this device",
"ToastFailedToCreate": "Failed to create",
"ToastFailedToDelete": "Failed to delete",
"ToastFailedToLoadData": "Failed to load data", "ToastFailedToLoadData": "Failed to load data",
"ToastFailedToMatch": "Failed to match", "ToastFailedToMatch": "Failed to match",
"ToastFailedToShare": "Failed to share", "ToastFailedToShare": "Failed to share",
@ -1032,6 +1056,7 @@
"ToastMustHaveAtLeastOnePath": "Must have at least one path", "ToastMustHaveAtLeastOnePath": "Must have at least one path",
"ToastNameEmailRequired": "Name and email are required", "ToastNameEmailRequired": "Name and email are required",
"ToastNameRequired": "Name is required", "ToastNameRequired": "Name is required",
"ToastNewApiKeyUserError": "Must select a user",
"ToastNewEpisodesFound": "{0} new episodes found", "ToastNewEpisodesFound": "{0} new episodes found",
"ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"", "ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"",
"ToastNewUserCreatedSuccess": "New account created", "ToastNewUserCreatedSuccess": "New account created",

View file

@ -1,5 +1,6 @@
{ {
"ButtonAdd": "Ajouter", "ButtonAdd": "Ajouter",
"ButtonAddApiKey": "Ajouter une clé API",
"ButtonAddChapters": "Ajouter des chapitres", "ButtonAddChapters": "Ajouter des chapitres",
"ButtonAddDevice": "Ajouter un appareil", "ButtonAddDevice": "Ajouter un appareil",
"ButtonAddLibrary": "Ajouter une bibliothèque", "ButtonAddLibrary": "Ajouter une bibliothèque",
@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Sélectionner un dossier", "ButtonChooseAFolder": "Sélectionner un dossier",
"ButtonChooseFiles": "Sélectionner des fichiers", "ButtonChooseFiles": "Sélectionner des fichiers",
"ButtonClearFilter": "Effacer le filtre", "ButtonClearFilter": "Effacer le filtre",
"ButtonClose": "Fermer",
"ButtonCloseFeed": "Fermer le flux", "ButtonCloseFeed": "Fermer le flux",
"ButtonCloseSession": "Fermer la session", "ButtonCloseSession": "Fermer la session",
"ButtonCollections": "Collections", "ButtonCollections": "Collections",
@ -119,6 +121,7 @@
"HeaderAccount": "Compte", "HeaderAccount": "Compte",
"HeaderAddCustomMetadataProvider": "Ajouter un fournisseur de métadonnées personnalisé", "HeaderAddCustomMetadataProvider": "Ajouter un fournisseur de métadonnées personnalisé",
"HeaderAdvanced": "Avancé", "HeaderAdvanced": "Avancé",
"HeaderApiKeys": "Clés API",
"HeaderAppriseNotificationSettings": "Configuration des notifications Apprise", "HeaderAppriseNotificationSettings": "Configuration des notifications Apprise",
"HeaderAudioTracks": "Pistes audio", "HeaderAudioTracks": "Pistes audio",
"HeaderAudiobookTools": "Outils de gestion de fichiers de livres audio", "HeaderAudiobookTools": "Outils de gestion de fichiers de livres audio",
@ -162,6 +165,7 @@
"HeaderMetadataOrderOfPrecedence": "Ordre de priorité des métadonnées", "HeaderMetadataOrderOfPrecedence": "Ordre de priorité des métadonnées",
"HeaderMetadataToEmbed": "Métadonnées à intégrer", "HeaderMetadataToEmbed": "Métadonnées à intégrer",
"HeaderNewAccount": "Nouveau compte", "HeaderNewAccount": "Nouveau compte",
"HeaderNewApiKey": "Nouvelle clé API",
"HeaderNewLibrary": "Nouvelle bibliothèque", "HeaderNewLibrary": "Nouvelle bibliothèque",
"HeaderNotificationCreate": "Créer une notification", "HeaderNotificationCreate": "Créer une notification",
"HeaderNotificationUpdate": "Mise à jour de la notification", "HeaderNotificationUpdate": "Mise à jour de la notification",
@ -177,6 +181,7 @@
"HeaderPlaylist": "Liste de lecture", "HeaderPlaylist": "Liste de lecture",
"HeaderPlaylistItems": "Éléments de la liste de lecture", "HeaderPlaylistItems": "Éléments de la liste de lecture",
"HeaderPodcastsToAdd": "Podcasts à ajouter", "HeaderPodcastsToAdd": "Podcasts à ajouter",
"HeaderPresets": "Préréglages",
"HeaderPreviewCover": "Prévisualiser la couverture", "HeaderPreviewCover": "Prévisualiser la couverture",
"HeaderRSSFeedGeneral": "Détails du flux RSS", "HeaderRSSFeedGeneral": "Détails du flux RSS",
"HeaderRSSFeedIsOpen": "Le flux RSS est actif", "HeaderRSSFeedIsOpen": "Le flux RSS est actif",
@ -205,6 +210,7 @@
"HeaderTableOfContents": "Table des matières", "HeaderTableOfContents": "Table des matières",
"HeaderTools": "Outils", "HeaderTools": "Outils",
"HeaderUpdateAccount": "Mettre à jour le compte", "HeaderUpdateAccount": "Mettre à jour le compte",
"HeaderUpdateApiKey": "Mettre à jour la clé API",
"HeaderUpdateAuthor": "Mettre à jour lauteur", "HeaderUpdateAuthor": "Mettre à jour lauteur",
"HeaderUpdateDetails": "Mettre à jour les détails", "HeaderUpdateDetails": "Mettre à jour les détails",
"HeaderUpdateLibrary": "Mettre à jour la bibliothèque", "HeaderUpdateLibrary": "Mettre à jour la bibliothèque",
@ -234,6 +240,10 @@
"LabelAllUsersExcludingGuests": "Tous les utilisateurs à lexception des invités", "LabelAllUsersExcludingGuests": "Tous les utilisateurs à lexception des invités",
"LabelAllUsersIncludingGuests": "Tous les utilisateurs, y compris les invités", "LabelAllUsersIncludingGuests": "Tous les utilisateurs, y compris les invités",
"LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque", "LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque",
"LabelApiKeyCreated": "La clé API « {0} » a été créée avec succès.",
"LabelApiKeyCreatedDescription": "Assurez-vous de copier la clé API maintenant car vous ne pourrez plus la voir.",
"LabelApiKeyUser": "Agir au nom de lutilisateur",
"LabelApiKeyUserDescription": "Cette clé API disposera des mêmes autorisations que lutilisateur pour lequel elle agit. Elle apparaîtra dans les journaux comme si cétait lutilisateur qui effectuait la requête.",
"LabelApiToken": "Token API", "LabelApiToken": "Token API",
"LabelAppend": "Ajouter", "LabelAppend": "Ajouter",
"LabelAudioBitrate": "Débit audio (par exemple 128k)", "LabelAudioBitrate": "Débit audio (par exemple 128k)",
@ -345,6 +355,10 @@
"LabelExample": "Exemple", "LabelExample": "Exemple",
"LabelExpandSeries": "Développer la série", "LabelExpandSeries": "Développer la série",
"LabelExpandSubSeries": "Développer les sous-séries", "LabelExpandSubSeries": "Développer les sous-séries",
"LabelExpired": "Expiré",
"LabelExpiresAt": "Expire à",
"LabelExpiresInSeconds": "Expire dans (secondes)",
"LabelExpiresNever": "Jamais",
"LabelExplicit": "Restriction", "LabelExplicit": "Restriction",
"LabelExplicitChecked": "Explicite (vérifié)", "LabelExplicitChecked": "Explicite (vérifié)",
"LabelExplicitUnchecked": "Non explicite (non vérifié)", "LabelExplicitUnchecked": "Non explicite (non vérifié)",
@ -454,6 +468,7 @@
"LabelNewestEpisodes": "Épisodes récents", "LabelNewestEpisodes": "Épisodes récents",
"LabelNextBackupDate": "Date de la prochaine sauvegarde", "LabelNextBackupDate": "Date de la prochaine sauvegarde",
"LabelNextScheduledRun": "Prochain lancement prévu", "LabelNextScheduledRun": "Prochain lancement prévu",
"LabelNoApiKeys": "Aucune clé API",
"LabelNoCustomMetadataProviders": "Aucun fournisseurs de métadonnées personnalisés", "LabelNoCustomMetadataProviders": "Aucun fournisseurs de métadonnées personnalisés",
"LabelNoEpisodesSelected": "Aucun épisode sélectionné", "LabelNoEpisodesSelected": "Aucun épisode sélectionné",
"LabelNotFinished": "Non terminé", "LabelNotFinished": "Non terminé",
@ -543,6 +558,7 @@
"LabelSelectAll": "Tout sélectionner", "LabelSelectAll": "Tout sélectionner",
"LabelSelectAllEpisodes": "Sélectionner tous les épisodes", "LabelSelectAllEpisodes": "Sélectionner tous les épisodes",
"LabelSelectEpisodesShowing": "Sélectionner {0} épisode(s) en cours", "LabelSelectEpisodesShowing": "Sélectionner {0} épisode(s) en cours",
"LabelSelectUser": "Sélectionner lutilisateur",
"LabelSelectUsers": "Sélectionner les utilisateurs", "LabelSelectUsers": "Sélectionner les utilisateurs",
"LabelSendEbookToDevice": "Envoyer le livre numérique à…", "LabelSendEbookToDevice": "Envoyer le livre numérique à…",
"LabelSequence": "Séquence", "LabelSequence": "Séquence",
@ -707,7 +723,9 @@
"MessageAddToPlayerQueue": "Ajouter en file dattente", "MessageAddToPlayerQueue": "Ajouter en file dattente",
"MessageAppriseDescription": "Nécessite une instance d<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes.<br />LURL de lAPI Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "Nécessite une instance d<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes.<br />LURL de lAPI Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.",
"MessageAsinCheck": "Assurez-vous dutiliser lASIN de la bonne région Audible, et non dAmazon.", "MessageAsinCheck": "Assurez-vous dutiliser lASIN de la bonne région Audible, et non dAmazon.",
"MessageAuthenticationLegacyTokenWarning": "Les jetons dAPI hérités seront supprimés à lavenir. Utilisez plutôt les <a href=\"/config/api-keys\">clés API</a>.",
"MessageAuthenticationOIDCChangesRestart": "Redémarrez votre serveur après avoir enregistré pour appliquer les modifications OIDC.", "MessageAuthenticationOIDCChangesRestart": "Redémarrez votre serveur après avoir enregistré pour appliquer les modifications OIDC.",
"MessageAuthenticationSecurityMessage": "Lauthentification a été améliorée pour plus de sécurité. Tous les utilisateurs doivent se reconnecter.",
"MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression des utilisateurs, les détails des éléments de la bibliothèque, les paramètres du serveur et les images stockées dans <code>/metadata/items</code> & <code>/metadata/authors</code>. Les sauvegardes <strong>nincluent pas</strong> les fichiers stockés dans les dossiers de votre bibliothèque.", "MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression des utilisateurs, les détails des éléments de la bibliothèque, les paramètres du serveur et les images stockées dans <code>/metadata/items</code> & <code>/metadata/authors</code>. Les sauvegardes <strong>nincluent pas</strong> les fichiers stockés dans les dossiers de votre bibliothèque.",
"MessageBackupsLocationEditNote": "Remarque: Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes", "MessageBackupsLocationEditNote": "Remarque: Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes",
"MessageBackupsLocationNoEditNote": "Remarque: lemplacement de sauvegarde est défini via une variable denvironnement et ne peut pas être modifié ici.", "MessageBackupsLocationNoEditNote": "Remarque: lemplacement de sauvegarde est défini via une variable denvironnement et ne peut pas être modifié ici.",
@ -729,6 +747,7 @@
"MessageChaptersNotFound": "Chapitres non trouvés", "MessageChaptersNotFound": "Chapitres non trouvés",
"MessageCheckingCron": "Vérification du cron…", "MessageCheckingCron": "Vérification du cron…",
"MessageConfirmCloseFeed": "Êtes-vous sûr·e de vouloir fermer ce flux?", "MessageConfirmCloseFeed": "Êtes-vous sûr·e de vouloir fermer ce flux?",
"MessageConfirmDeleteApiKey": "Êtes-vous sûr de vouloir supprimer la clé API « {0} » ?",
"MessageConfirmDeleteBackup": "Êtes-vous sûr·e de vouloir supprimer la sauvegarde de « {0} » ?", "MessageConfirmDeleteBackup": "Êtes-vous sûr·e de vouloir supprimer la sauvegarde de « {0} » ?",
"MessageConfirmDeleteDevice": "Êtes-vous sûr·e de vouloir supprimer la liseuse « {0} » ?", "MessageConfirmDeleteDevice": "Êtes-vous sûr·e de vouloir supprimer la liseuse « {0} » ?",
"MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?", "MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?",
@ -756,6 +775,7 @@
"MessageConfirmRemoveAuthor": "Êtes-vous sûr·e de vouloir supprimer lauteur « {0} » ?", "MessageConfirmRemoveAuthor": "Êtes-vous sûr·e de vouloir supprimer lauteur « {0} » ?",
"MessageConfirmRemoveCollection": "Êtes-vous sûr·e de vouloir supprimer la collection « {0} » ?", "MessageConfirmRemoveCollection": "Êtes-vous sûr·e de vouloir supprimer la collection « {0} » ?",
"MessageConfirmRemoveEpisode": "Êtes-vous sûr·e de vouloir supprimer lépisode « {0} » ?", "MessageConfirmRemoveEpisode": "Êtes-vous sûr·e de vouloir supprimer lépisode « {0} » ?",
"MessageConfirmRemoveEpisodeNote": "Remarque : cela ne supprime pas le fichier audio, sauf si vous activez « Supprimer définitivement le fichier »",
"MessageConfirmRemoveEpisodes": "Êtes-vous sûr·e de vouloir supprimer {0} épisodes?", "MessageConfirmRemoveEpisodes": "Êtes-vous sûr·e de vouloir supprimer {0} épisodes?",
"MessageConfirmRemoveListeningSessions": "Êtes-vous sûr·e de vouloir supprimer {0} sessions découte?", "MessageConfirmRemoveListeningSessions": "Êtes-vous sûr·e de vouloir supprimer {0} sessions découte?",
"MessageConfirmRemoveMetadataFiles": "Êtes-vous sûr·e de vouloir supprimer tous les fichiers « metatadata.{0} » des dossiers déléments de votre bibliothèque?", "MessageConfirmRemoveMetadataFiles": "Êtes-vous sûr·e de vouloir supprimer tous les fichiers « metatadata.{0} » des dossiers déléments de votre bibliothèque?",
@ -917,6 +937,8 @@
"NotificationOnBackupCompletedDescription": "Déclenché lorsquune sauvegarde est terminée", "NotificationOnBackupCompletedDescription": "Déclenché lorsquune sauvegarde est terminée",
"NotificationOnBackupFailedDescription": "Déclenché lorsqu'une sauvegarde échoue", "NotificationOnBackupFailedDescription": "Déclenché lorsqu'une sauvegarde échoue",
"NotificationOnEpisodeDownloadedDescription": "Déclenché lorsquun épisode de podcast est téléchargé automatiquement", "NotificationOnEpisodeDownloadedDescription": "Déclenché lorsquun épisode de podcast est téléchargé automatiquement",
"NotificationOnRSSFeedDisabledDescription": "Déclenché lorsque les téléchargements automatiques dépisodes sont désactivés en raison dun trop grand nombre de tentatives infructueuses",
"NotificationOnRSSFeedFailedDescription": "Déclenché lorsque la demande de flux RSS échoue pour un téléchargement automatique dépisode",
"NotificationOnTestDescription": "Événement pour tester le système de notification", "NotificationOnTestDescription": "Événement pour tester le système de notification",
"PlaceholderNewCollection": "Nom de la nouvelle collection", "PlaceholderNewCollection": "Nom de la nouvelle collection",
"PlaceholderNewFolderPath": "Nouveau chemin de dossier", "PlaceholderNewFolderPath": "Nouveau chemin de dossier",
@ -997,6 +1019,8 @@
"ToastEpisodeDownloadQueueClearSuccess": "File dattente de téléchargement des épisodes effacée", "ToastEpisodeDownloadQueueClearSuccess": "File dattente de téléchargement des épisodes effacée",
"ToastEpisodeUpdateSuccess": "{0} épisodes mis à jour", "ToastEpisodeUpdateSuccess": "{0} épisodes mis à jour",
"ToastErrorCannotShare": "Impossible de partager nativement sur cet appareil", "ToastErrorCannotShare": "Impossible de partager nativement sur cet appareil",
"ToastFailedToCreate": "Échec de la création",
"ToastFailedToDelete": "Échec de la suppression",
"ToastFailedToLoadData": "Échec du chargement des données", "ToastFailedToLoadData": "Échec du chargement des données",
"ToastFailedToMatch": "Échec de la correspondance", "ToastFailedToMatch": "Échec de la correspondance",
"ToastFailedToShare": "Échec du partage", "ToastFailedToShare": "Échec du partage",
@ -1028,6 +1052,7 @@
"ToastMustHaveAtLeastOnePath": "Doit avoir au moins un chemin", "ToastMustHaveAtLeastOnePath": "Doit avoir au moins un chemin",
"ToastNameEmailRequired": "Le nom et le courriel sont requis", "ToastNameEmailRequired": "Le nom et le courriel sont requis",
"ToastNameRequired": "Le nom est requis", "ToastNameRequired": "Le nom est requis",
"ToastNewApiKeyUserError": "Vous devez sélectionner un utilisateur",
"ToastNewEpisodesFound": "{0} nouveaux épisodes trouvés", "ToastNewEpisodesFound": "{0} nouveaux épisodes trouvés",
"ToastNewUserCreatedFailed": "La création du compte à échouée: « {0} »", "ToastNewUserCreatedFailed": "La création du compte à échouée: « {0} »",
"ToastNewUserCreatedSuccess": "Nouveau compte créé", "ToastNewUserCreatedSuccess": "Nouveau compte créé",

View file

@ -9,6 +9,9 @@
"ButtonApply": "લાગુ કરો", "ButtonApply": "લાગુ કરો",
"ButtonApplyChapters": "પ્રકરણો લાગુ કરો", "ButtonApplyChapters": "પ્રકરણો લાગુ કરો",
"ButtonAuthors": "લેખકો", "ButtonAuthors": "લેખકો",
"ButtonBack": "પાછા",
"ButtonBatchEditPopulateFromExisting": "હાલની માહિતીમાંથી ભરો",
"ButtonBatchEditPopulateMapDetails": "નકશાની વિગત ભરો",
"ButtonBrowseForFolder": "ફોલ્ડર માટે જુઓ", "ButtonBrowseForFolder": "ફોલ્ડર માટે જુઓ",
"ButtonCancel": "રદ કરો", "ButtonCancel": "રદ કરો",
"ButtonCancelEncode": "એન્કોડ રદ કરો", "ButtonCancelEncode": "એન્કોડ રદ કરો",
@ -27,11 +30,14 @@
"ButtonEdit": "સંપાદિત કરો", "ButtonEdit": "સંપાદિત કરો",
"ButtonEditChapters": "પ્રકરણો સંપાદિત કરો", "ButtonEditChapters": "પ્રકરણો સંપાદિત કરો",
"ButtonEditPodcast": "પોડકાસ્ટ સંપાદિત કરો", "ButtonEditPodcast": "પોડકાસ્ટ સંપાદિત કરો",
"ButtonEnable": "સક્રિય કરો",
"ButtonForceReScan": "બળપૂર્વક ફરીથી સ્કેન કરો", "ButtonForceReScan": "બળપૂર્વક ફરીથી સ્કેન કરો",
"ButtonFullPath": "સંપૂર્ણ પથ", "ButtonFullPath": "સંપૂર્ણ પથ",
"ButtonHide": "છુપાવો", "ButtonHide": "છુપાવો",
"ButtonHome": "ઘર", "ButtonHome": "ઘર",
"ButtonIssues": "સમસ્યાઓ", "ButtonIssues": "સમસ્યાઓ",
"ButtonJumpBackward": "પાછળ જાવો",
"ButtonJumpForward": "આગળ જાવો",
"ButtonLatest": "નવીનતમ", "ButtonLatest": "નવીનતમ",
"ButtonLibrary": "પુસ્તકાલય", "ButtonLibrary": "પુસ્તકાલય",
"ButtonLogout": "લૉગ આઉટ", "ButtonLogout": "લૉગ આઉટ",
@ -41,19 +47,32 @@
"ButtonMatchAllAuthors": "બધા મેળ ખાતા લેખકો શોધો", "ButtonMatchAllAuthors": "બધા મેળ ખાતા લેખકો શોધો",
"ButtonMatchBooks": "મેળ ખાતી પુસ્તકો શોધો", "ButtonMatchBooks": "મેળ ખાતી પુસ્તકો શોધો",
"ButtonNevermind": "કંઈ વાંધો નહીં", "ButtonNevermind": "કંઈ વાંધો નહીં",
"ButtonNext": "આગળ જાઓ",
"ButtonNextChapter": "આગળનું અધ્યાય",
"ButtonNextItemInQueue": "કતારમાં આવતું આગળનું અધ્યાય",
"ButtonOk": "ઓકે", "ButtonOk": "ઓકે",
"ButtonOpenFeed": "ફીડ ખોલો", "ButtonOpenFeed": "ફીડ ખોલો",
"ButtonOpenManager": "મેનેજર ખોલો", "ButtonOpenManager": "મેનેજર ખોલો",
"ButtonPause": "વિરામ",
"ButtonPlay": "ચલાવો", "ButtonPlay": "ચલાવો",
"ButtonPlayAll": "બધું ચલાવો",
"ButtonPlaying": "ચલાવી રહ્યું છે", "ButtonPlaying": "ચલાવી રહ્યું છે",
"ButtonPlaylists": "પ્લેલિસ્ટ", "ButtonPlaylists": "પ્લેલિસ્ટ",
"ButtonPrevious": "પાછળનું",
"ButtonPreviousChapter": "પાછળનું અધ્યાય",
"ButtonProbeAudioFile": "ઑડિયો ફાઇલ તપાસો",
"ButtonPurgeAllCache": "બધો Cache કાઢી નાખો", "ButtonPurgeAllCache": "બધો Cache કાઢી નાખો",
"ButtonPurgeItemsCache": "વસ્તુઓનો Cache કાઢી નાખો", "ButtonPurgeItemsCache": "વસ્તુઓનો Cache કાઢી નાખો",
"ButtonQueueAddItem": "કતારમાં ઉમેરો", "ButtonQueueAddItem": "કતારમાં ઉમેરો",
"ButtonQueueRemoveItem": "કતારથી કાઢી નાખો", "ButtonQueueRemoveItem": "કતારથી કાઢી નાખો",
"ButtonQuickEmbed": "ઝડપથી સમાવેશ કરો",
"ButtonQuickEmbedMetadata": "ઝડપથી મેટાડેટા સમાવવો",
"ButtonQuickMatch": "ઝડપી મેળ ખવડાવો", "ButtonQuickMatch": "ઝડપી મેળ ખવડાવો",
"ButtonReScan": "ફરીથી સ્કેન કરો", "ButtonReScan": "ફરીથી સ્કેન કરો",
"ButtonRead": "વાંચો", "ButtonRead": "વાંચો",
"ButtonReadLess": "ઓછું વાંચો",
"ButtonReadMore": "વધારે વાંચો",
"ButtonRefresh": "તાજું કરો",
"ButtonRemove": "કાઢી નાખો", "ButtonRemove": "કાઢી નાખો",
"ButtonRemoveAll": "બધું કાઢી નાખો", "ButtonRemoveAll": "બધું કાઢી નાખો",
"ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો", "ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો",
@ -68,16 +87,21 @@
"ButtonSaveTracklist": "ટ્રેક યાદી સાચવો", "ButtonSaveTracklist": "ટ્રેક યાદી સાચવો",
"ButtonScan": "સ્કેન કરો", "ButtonScan": "સ્કેન કરો",
"ButtonScanLibrary": "પુસ્તકાલય સ્કેન કરો", "ButtonScanLibrary": "પુસ્તકાલય સ્કેન કરો",
"ButtonScrollLeft": "ડાબે",
"ButtonScrollRight": "જમણે",
"ButtonSearch": "શોધો", "ButtonSearch": "શોધો",
"ButtonSelectFolderPath": "ફોલ્ડર પથ પસંદ કરો", "ButtonSelectFolderPath": "ફોલ્ડર પથ પસંદ કરો",
"ButtonSeries": "સિરીઝ", "ButtonSeries": "સિરીઝ",
"ButtonSetChaptersFromTracks": "ટ્રેક્સથી પ્રકરણો સેટ કરો", "ButtonSetChaptersFromTracks": "ટ્રેક્સથી પ્રકરણો સેટ કરો",
"ButtonShare": "શેર કરો",
"ButtonShiftTimes": "સમય શિફ્ટ કરો", "ButtonShiftTimes": "સમય શિફ્ટ કરો",
"ButtonShow": "બતાવો", "ButtonShow": "બતાવો",
"ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો", "ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો",
"ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો", "ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો",
"ButtonStats": "આંકડા",
"ButtonSubmit": "સબમિટ કરો", "ButtonSubmit": "સબમિટ કરો",
"ButtonTest": "પરખ કરો", "ButtonTest": "પરખ કરો",
"ButtonUnlinkOpenId": "OpenID દૂર કરો",
"ButtonUpload": "અપલોડ કરો", "ButtonUpload": "અપલોડ કરો",
"ButtonUploadBackup": "બેકઅપ અપલોડ કરો", "ButtonUploadBackup": "બેકઅપ અપલોડ કરો",
"ButtonUploadCover": "કવર અપલોડ કરો", "ButtonUploadCover": "કવર અપલોડ કરો",
@ -86,11 +110,16 @@
"ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો", "ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો",
"ButtonViewAll": "બધું જુઓ", "ButtonViewAll": "બધું જુઓ",
"ButtonYes": "હા", "ButtonYes": "હા",
"ErrorUploadFetchMetadataAPI": "મેટાડેટા મેળવવામાં તકલીફ આવી",
"ErrorUploadFetchMetadataNoResults": "મેટાડેટા મેળવી શક્યા નહીં કૃપા કરીને શીર્ષક અને/અથવા લેખકનું નામ અપડેટ કરવાનો પ્રયત્ન કરો",
"ErrorUploadLacksTitle": "શીર્ષક હોવું આવશ્યક છે",
"HeaderAccount": "એકાઉન્ટ", "HeaderAccount": "એકાઉન્ટ",
"HeaderAddCustomMetadataProvider": "કસ્ટમ મેટાડેટા પ્રોવાઇડર ઉમેરો",
"HeaderAdvanced": "અડ્વાન્સડ", "HeaderAdvanced": "અડ્વાન્સડ",
"HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ", "HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ",
"HeaderAudioTracks": "ઓડિયો ટ્રેક્સ", "HeaderAudioTracks": "ઓડિયો ટ્રેક્સ",
"HeaderAudiobookTools": "ઓડિયોબુક ફાઇલ વ્યવસ્થાપન ટૂલ્સ", "HeaderAudiobookTools": "ઓડિયોબુક ફાઇલ વ્યવસ્થાપન ટૂલ્સ",
"HeaderAuthentication": "પ્રમાણીકરણ",
"HeaderBackups": "બેકઅપ્સ", "HeaderBackups": "બેકઅપ્સ",
"HeaderChangePassword": "પાસવર્ડ બદલો", "HeaderChangePassword": "પાસવર્ડ બદલો",
"HeaderChapters": "પ્રકરણો", "HeaderChapters": "પ્રકરણો",
@ -99,6 +128,7 @@
"HeaderCollectionItems": "સંગ્રહ વસ્તુઓ", "HeaderCollectionItems": "સંગ્રહ વસ્તુઓ",
"HeaderCover": "આવરણ", "HeaderCover": "આવરણ",
"HeaderCurrentDownloads": "વર્તમાન ડાઉનલોડ્સ", "HeaderCurrentDownloads": "વર્તમાન ડાઉનલોડ્સ",
"HeaderCustomMetadataProviders": "કસ્ટમ મેટાડેટા પ્રોવાઇડર્સ",
"HeaderDetails": "વિગતો", "HeaderDetails": "વિગતો",
"HeaderDownloadQueue": "ડાઉનલોડ કતાર", "HeaderDownloadQueue": "ડાઉનલોડ કતાર",
"HeaderEbookFiles": "ઇબુક ફાઇલો", "HeaderEbookFiles": "ઇબુક ફાઇલો",
@ -129,6 +159,7 @@
"HeaderMetadataToEmbed": "એમ્બેડ કરવા માટે મેટાડેટા", "HeaderMetadataToEmbed": "એમ્બેડ કરવા માટે મેટાડેટા",
"HeaderNewAccount": "નવું એકાઉન્ટ", "HeaderNewAccount": "નવું એકાઉન્ટ",
"HeaderNewLibrary": "નવી પુસ્તકાલય", "HeaderNewLibrary": "નવી પુસ્તકાલય",
"HeaderNotificationCreate": "સૂચના બનાવો",
"HeaderNotifications": "સૂચનાઓ", "HeaderNotifications": "સૂચનાઓ",
"HeaderOpenRSSFeed": "RSS ફીડ ખોલો", "HeaderOpenRSSFeed": "RSS ફીડ ખોલો",
"HeaderOtherFiles": "અન્ય ફાઇલો", "HeaderOtherFiles": "અન્ય ફાઇલો",

View file

@ -1,5 +1,6 @@
{ {
"ButtonAdd": "Dodaj", "ButtonAdd": "Dodaj",
"ButtonAddApiKey": "Dodaj API ključ",
"ButtonAddChapters": "Dodaj poglavlja", "ButtonAddChapters": "Dodaj poglavlja",
"ButtonAddDevice": "Dodaj uređaj", "ButtonAddDevice": "Dodaj uređaj",
"ButtonAddLibrary": "Dodaj knjižnicu", "ButtonAddLibrary": "Dodaj knjižnicu",
@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Odaberi mapu", "ButtonChooseAFolder": "Odaberi mapu",
"ButtonChooseFiles": "Odaberi datoteke", "ButtonChooseFiles": "Odaberi datoteke",
"ButtonClearFilter": "Poništi filter", "ButtonClearFilter": "Poništi filter",
"ButtonClose": "Zatvori",
"ButtonCloseFeed": "Zatvori izvor", "ButtonCloseFeed": "Zatvori izvor",
"ButtonCloseSession": "Zatvori otvorenu sesiju", "ButtonCloseSession": "Zatvori otvorenu sesiju",
"ButtonCollections": "Zbirke", "ButtonCollections": "Zbirke",
@ -119,6 +121,7 @@
"HeaderAccount": "Korisnički račun", "HeaderAccount": "Korisnički račun",
"HeaderAddCustomMetadataProvider": "Dodaj prilagođenog pružatelja meta-podataka", "HeaderAddCustomMetadataProvider": "Dodaj prilagođenog pružatelja meta-podataka",
"HeaderAdvanced": "Napredno", "HeaderAdvanced": "Napredno",
"HeaderApiKeys": "API ključevi",
"HeaderAppriseNotificationSettings": "Postavke obavijesti Apprise", "HeaderAppriseNotificationSettings": "Postavke obavijesti Apprise",
"HeaderAudioTracks": "Zvučni zapisi", "HeaderAudioTracks": "Zvučni zapisi",
"HeaderAudiobookTools": "Alati za upravljanje datotekama zvučnih knjiga", "HeaderAudiobookTools": "Alati za upravljanje datotekama zvučnih knjiga",
@ -162,6 +165,7 @@
"HeaderMetadataOrderOfPrecedence": "Redoslijed prihvaćanja meta-podataka", "HeaderMetadataOrderOfPrecedence": "Redoslijed prihvaćanja meta-podataka",
"HeaderMetadataToEmbed": "Meta-podatci za ugradnju", "HeaderMetadataToEmbed": "Meta-podatci za ugradnju",
"HeaderNewAccount": "Novi korisnički račun", "HeaderNewAccount": "Novi korisnički račun",
"HeaderNewApiKey": "Novi API ključ",
"HeaderNewLibrary": "Nova knjižnica", "HeaderNewLibrary": "Nova knjižnica",
"HeaderNotificationCreate": "Izradi obavijest", "HeaderNotificationCreate": "Izradi obavijest",
"HeaderNotificationUpdate": "Ažuriraj obavijest", "HeaderNotificationUpdate": "Ažuriraj obavijest",
@ -206,6 +210,7 @@
"HeaderTableOfContents": "Sadržaj", "HeaderTableOfContents": "Sadržaj",
"HeaderTools": "Alati", "HeaderTools": "Alati",
"HeaderUpdateAccount": "Ažuriraj korisnički račun", "HeaderUpdateAccount": "Ažuriraj korisnički račun",
"HeaderUpdateApiKey": "Ažuriraj API ključ",
"HeaderUpdateAuthor": "Ažuriraj autora", "HeaderUpdateAuthor": "Ažuriraj autora",
"HeaderUpdateDetails": "Ažuriraj pojedinosti", "HeaderUpdateDetails": "Ažuriraj pojedinosti",
"HeaderUpdateLibrary": "Ažuriraj knjižnicu", "HeaderUpdateLibrary": "Ažuriraj knjižnicu",
@ -235,6 +240,10 @@
"LabelAllUsersExcludingGuests": "Svi korisnici osim gostiju", "LabelAllUsersExcludingGuests": "Svi korisnici osim gostiju",
"LabelAllUsersIncludingGuests": "Svi korisnici uključujući i goste", "LabelAllUsersIncludingGuests": "Svi korisnici uključujući i goste",
"LabelAlreadyInYourLibrary": "Već u vašoj knjižnici", "LabelAlreadyInYourLibrary": "Već u vašoj knjižnici",
"LabelApiKeyCreated": "API ključ \"{0}\" uspješno izrađen.",
"LabelApiKeyCreatedDescription": "Ne zaboravite odmah kopirati API ključ jer ga više nećete moći vidjeti.",
"LabelApiKeyUser": "Izvršavaj u ime korisnika",
"LabelApiKeyUserDescription": "Ovaj API ključ imat će iste dozvole kao i korisnik u čije ime djeluje. U zapisnicima će biti zabilježeno da je korisnik slao zahtjeve.",
"LabelApiToken": "API Token", "LabelApiToken": "API Token",
"LabelAppend": "Pridodaj", "LabelAppend": "Pridodaj",
"LabelAudioBitrate": "Kvaliteta zvučnog zapisa (npr. 128k)", "LabelAudioBitrate": "Kvaliteta zvučnog zapisa (npr. 128k)",
@ -346,7 +355,11 @@
"LabelExample": "Primjer", "LabelExample": "Primjer",
"LabelExpandSeries": "Serijal prikaži prošireno", "LabelExpandSeries": "Serijal prikaži prošireno",
"LabelExpandSubSeries": "Podserijal prikaži prošireno", "LabelExpandSubSeries": "Podserijal prikaži prošireno",
"LabelExplicit": "Eksplicitni sadržaj", "LabelExpired": "Istekao",
"LabelExpiresAt": "Istječe",
"LabelExpiresInSeconds": "Istječe za (sekundi)",
"LabelExpiresNever": "Nikada",
"LabelExplicit": "Eksplicitno",
"LabelExplicitChecked": "Eksplicitni sadržaj (označeno)", "LabelExplicitChecked": "Eksplicitni sadržaj (označeno)",
"LabelExplicitUnchecked": "Nije eksplicitni sadržaj (odznačeno)", "LabelExplicitUnchecked": "Nije eksplicitni sadržaj (odznačeno)",
"LabelExportOPML": "Izvoz OPML-a", "LabelExportOPML": "Izvoz OPML-a",
@ -455,6 +468,7 @@
"LabelNewestEpisodes": "Najnoviji nastavci", "LabelNewestEpisodes": "Najnoviji nastavci",
"LabelNextBackupDate": "Sljedeća izrada sigurnosne kopije", "LabelNextBackupDate": "Sljedeća izrada sigurnosne kopije",
"LabelNextScheduledRun": "Sljedeće zakazano izvođenje", "LabelNextScheduledRun": "Sljedeće zakazano izvođenje",
"LabelNoApiKeys": "Nema API ključeva",
"LabelNoCustomMetadataProviders": "Nema prilagođenih pružatelja meta-podataka", "LabelNoCustomMetadataProviders": "Nema prilagođenih pružatelja meta-podataka",
"LabelNoEpisodesSelected": "Nema odabranih nastavaka", "LabelNoEpisodesSelected": "Nema odabranih nastavaka",
"LabelNotFinished": "Nije dovršeno", "LabelNotFinished": "Nije dovršeno",
@ -544,6 +558,7 @@
"LabelSelectAll": "Označi sve", "LabelSelectAll": "Označi sve",
"LabelSelectAllEpisodes": "Označi sve nastavke", "LabelSelectAllEpisodes": "Označi sve nastavke",
"LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka", "LabelSelectEpisodesShowing": "Prikazujem {0} odabranih nastavaka",
"LabelSelectUser": "Odaberite korisnika",
"LabelSelectUsers": "Označi korisnike", "LabelSelectUsers": "Označi korisnike",
"LabelSendEbookToDevice": "Pošalji e-knjigu …", "LabelSendEbookToDevice": "Pošalji e-knjigu …",
"LabelSequence": "Slijed", "LabelSequence": "Slijed",
@ -708,7 +723,9 @@
"MessageAddToPlayerQueue": "Dodaj u redoslijed izvođenja", "MessageAddToPlayerQueue": "Dodaj u redoslijed izvođenja",
"MessageAppriseDescription": "Da biste se koristili ovom značajkom, treba vam instanca <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API-ja</a> ili API koji može rukovati istom vrstom zahtjeva.<br />The Adresa Apprise API-ja treba biti puna URL putanja za slanje obavijesti, npr. ako vam se API instanca poslužuje na adresi <code>http://192.168.1.1:8337</code> trebate upisati <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "Da biste se koristili ovom značajkom, treba vam instanca <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API-ja</a> ili API koji može rukovati istom vrstom zahtjeva.<br />The Adresa Apprise API-ja treba biti puna URL putanja za slanje obavijesti, npr. ako vam se API instanca poslužuje na adresi <code>http://192.168.1.1:8337</code> trebate upisati <code>http://192.168.1.1:8337/notify</code>.",
"MessageAsinCheck": "Upišite ASIN iz odgovarajuće Audibleove regije, ne s Amazonov.", "MessageAsinCheck": "Upišite ASIN iz odgovarajuće Audibleove regije, ne s Amazonov.",
"MessageAuthenticationLegacyTokenWarning": "Starije API tokene ćemo ukloniti. Umjesto njih, koristite se <a href=\"/config/api-keys\">API ključevima</a> .",
"MessageAuthenticationOIDCChangesRestart": "Ponovno pokrenite poslužitelj da biste primijenili OIDC promjene.", "MessageAuthenticationOIDCChangesRestart": "Ponovno pokrenite poslužitelj da biste primijenili OIDC promjene.",
"MessageAuthenticationSecurityMessage": "Provjera autentičnosti poboljšana je radi sigurnosti. Svi se korisnici moraju ponovno prijaviti.",
"MessageBackupsDescription": "Sigurnosne kopije sadrže korisnike, korisnikov napredak medija, pojedinosti knjižničke građe, postavke poslužitelja i slike koje se spremaju u <code>/metadata/items</code> & <code>/metadata/authors</code>. Sigurnosne kopije ne sadrže niti jednu datoteku iz mapa knjižnice.", "MessageBackupsDescription": "Sigurnosne kopije sadrže korisnike, korisnikov napredak medija, pojedinosti knjižničke građe, postavke poslužitelja i slike koje se spremaju u <code>/metadata/items</code> & <code>/metadata/authors</code>. Sigurnosne kopije ne sadrže niti jednu datoteku iz mapa knjižnice.",
"MessageBackupsLocationEditNote": "Napomena: Uređivanje lokacije za sigurnosne kopije ne premješta ili mijenja postojeće sigurnosne kopije", "MessageBackupsLocationEditNote": "Napomena: Uređivanje lokacije za sigurnosne kopije ne premješta ili mijenja postojeće sigurnosne kopije",
"MessageBackupsLocationNoEditNote": "Napomena: Lokacija za sigurnosne kopije zadana je kroz varijablu okoline i ovdje se ne može izmijeniti.", "MessageBackupsLocationNoEditNote": "Napomena: Lokacija za sigurnosne kopije zadana je kroz varijablu okoline i ovdje se ne može izmijeniti.",
@ -730,6 +747,7 @@
"MessageChaptersNotFound": "Poglavlja nisu pronađena", "MessageChaptersNotFound": "Poglavlja nisu pronađena",
"MessageCheckingCron": "Provjeravam cron...", "MessageCheckingCron": "Provjeravam cron...",
"MessageConfirmCloseFeed": "Sigurno želite zatvoriti ovaj izvor?", "MessageConfirmCloseFeed": "Sigurno želite zatvoriti ovaj izvor?",
"MessageConfirmDeleteApiKey": "Sigurno želite izbrisati API ključ \"{0}\"?",
"MessageConfirmDeleteBackup": "Sigurno želite izbrisati sigurnosnu kopiju za {0}?", "MessageConfirmDeleteBackup": "Sigurno želite izbrisati sigurnosnu kopiju za {0}?",
"MessageConfirmDeleteDevice": "Sigurno želite izbrisati e-čitač \"{0}\"?", "MessageConfirmDeleteDevice": "Sigurno želite izbrisati e-čitač \"{0}\"?",
"MessageConfirmDeleteFile": "Ovo će izbrisati datoteke s datotečnog sustava. Jeste li sigurni?", "MessageConfirmDeleteFile": "Ovo će izbrisati datoteke s datotečnog sustava. Jeste li sigurni?",
@ -757,6 +775,7 @@
"MessageConfirmRemoveAuthor": "Sigurno želite ukloniti autora \"{0}\"?", "MessageConfirmRemoveAuthor": "Sigurno želite ukloniti autora \"{0}\"?",
"MessageConfirmRemoveCollection": "Sigurno želite obrisati kolekciju \"{0}\"?", "MessageConfirmRemoveCollection": "Sigurno želite obrisati kolekciju \"{0}\"?",
"MessageConfirmRemoveEpisode": "Sigurno želite ukloniti nastavak \"{0}\"?", "MessageConfirmRemoveEpisode": "Sigurno želite ukloniti nastavak \"{0}\"?",
"MessageConfirmRemoveEpisodeNote": "Napomena: Ova funkcija neće izbrisati zvučnu datoteku ukoliko ne uključite opciju \"Izbriši datoteku zauvijek\"",
"MessageConfirmRemoveEpisodes": "Sigurno želite ukloniti {0} nastavaka?", "MessageConfirmRemoveEpisodes": "Sigurno želite ukloniti {0} nastavaka?",
"MessageConfirmRemoveListeningSessions": "Sigurno želite ukloniti {0} sesija slušanja?", "MessageConfirmRemoveListeningSessions": "Sigurno želite ukloniti {0} sesija slušanja?",
"MessageConfirmRemoveMetadataFiles": "Sigurno želite ukloniti sve datoteke metadata.{0} u mapama vaših knjižničkih stavki?", "MessageConfirmRemoveMetadataFiles": "Sigurno želite ukloniti sve datoteke metadata.{0} u mapama vaših knjižničkih stavki?",
@ -1000,6 +1019,8 @@
"ToastEpisodeDownloadQueueClearSuccess": "Redoslijed preuzimanja nastavaka očišćen", "ToastEpisodeDownloadQueueClearSuccess": "Redoslijed preuzimanja nastavaka očišćen",
"ToastEpisodeUpdateSuccess": "{0} nastavak/a ažurirano", "ToastEpisodeUpdateSuccess": "{0} nastavak/a ažurirano",
"ToastErrorCannotShare": "Dijeljenje na ovaj uređaj nije moguće", "ToastErrorCannotShare": "Dijeljenje na ovaj uređaj nije moguće",
"ToastFailedToCreate": "Izrada nije uspjela",
"ToastFailedToDelete": "Brisanje nije uspjelo",
"ToastFailedToLoadData": "Učitavanje podataka nije uspjelo", "ToastFailedToLoadData": "Učitavanje podataka nije uspjelo",
"ToastFailedToMatch": "Nije prepoznato", "ToastFailedToMatch": "Nije prepoznato",
"ToastFailedToShare": "Dijeljenje nije uspjelo", "ToastFailedToShare": "Dijeljenje nije uspjelo",
@ -1031,6 +1052,7 @@
"ToastMustHaveAtLeastOnePath": "Mora postojati barem jedna putanja", "ToastMustHaveAtLeastOnePath": "Mora postojati barem jedna putanja",
"ToastNameEmailRequired": "Ime i adresa e-pošte su obavezni", "ToastNameEmailRequired": "Ime i adresa e-pošte su obavezni",
"ToastNameRequired": "Ime je obavezno", "ToastNameRequired": "Ime je obavezno",
"ToastNewApiKeyUserError": "Morate odabrati korisnika",
"ToastNewEpisodesFound": "pronađeno {0} novih nastavaka", "ToastNewEpisodesFound": "pronađeno {0} novih nastavaka",
"ToastNewUserCreatedFailed": "Račun \"{0}\" nije uspješno izrađen", "ToastNewUserCreatedFailed": "Račun \"{0}\" nije uspješno izrađen",
"ToastNewUserCreatedSuccess": "Novi račun izrađen", "ToastNewUserCreatedSuccess": "Novi račun izrađen",

View file

@ -278,7 +278,7 @@
"LabelCollapseSeries": "Sorozat összecsukása", "LabelCollapseSeries": "Sorozat összecsukása",
"LabelCollapseSubSeries": "Alszéria összecsukása", "LabelCollapseSubSeries": "Alszéria összecsukása",
"LabelCollection": "Gyűjtemény", "LabelCollection": "Gyűjtemény",
"LabelCollections": "Gyűjtemény", "LabelCollections": "Gyűjtemények",
"LabelComplete": "Kész", "LabelComplete": "Kész",
"LabelConfirmPassword": "Jelszó megerősítése", "LabelConfirmPassword": "Jelszó megerősítése",
"LabelContinueListening": "Hallgatás folytatása", "LabelContinueListening": "Hallgatás folytatása",
@ -757,6 +757,7 @@
"MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?", "MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?",
"MessageConfirmRemoveCollection": "Biztosan eltávolítja a(z) \"{0}\" gyűjteményt?", "MessageConfirmRemoveCollection": "Biztosan eltávolítja a(z) \"{0}\" gyűjteményt?",
"MessageConfirmRemoveEpisode": "Biztosan eltávolítja a(z) \"{0}\" epizódot?", "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?", "MessageConfirmRemoveEpisodes": "Biztosan eltávolítja a(z) {0} epizódot?",
"MessageConfirmRemoveListeningSessions": "Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?", "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?", "MessageConfirmRemoveMetadataFiles": "Biztos, hogy az összes metaadatot el akarja távolítani {0} fájl van könyvtár mappáiban?",

View file

@ -1,18 +1,19 @@
{ {
"ButtonAdd": "Aggiungi", "ButtonAdd": "Aggiungi",
"ButtonAddApiKey": "Aggiungi chiave API",
"ButtonAddChapters": "Aggiungi Capitoli", "ButtonAddChapters": "Aggiungi Capitoli",
"ButtonAddDevice": "Aggiungi Dispositivo", "ButtonAddDevice": "Aggiungi Dispositivo",
"ButtonAddLibrary": "Aggiungi Libreria", "ButtonAddLibrary": "Aggiungi Libreria",
"ButtonAddPodcasts": "Aggiungi Podcast", "ButtonAddPodcasts": "Aggiungi Podcast",
"ButtonAddUser": "Aggiungi User", "ButtonAddUser": "Aggiungi Utente",
"ButtonAddYourFirstLibrary": "Aggiungi la tua prima libreria", "ButtonAddYourFirstLibrary": "Aggiungi la tua prima libreria",
"ButtonApply": "Applica", "ButtonApply": "Applica",
"ButtonApplyChapters": "Applica", "ButtonApplyChapters": "Applica Capitoli",
"ButtonAuthors": "Autori", "ButtonAuthors": "Autori",
"ButtonBack": "Indietro", "ButtonBack": "Indietro",
"ButtonBatchEditPopulateFromExisting": "Popola da esistente", "ButtonBatchEditPopulateFromExisting": "Popola da esistente",
"ButtonBatchEditPopulateMapDetails": "Inserisci i dettagli della mappa", "ButtonBatchEditPopulateMapDetails": "Inserisci i dettagli della mappa",
"ButtonBrowseForFolder": "Per Cartella", "ButtonBrowseForFolder": "Sfoglia per Cartella",
"ButtonCancel": "Annulla", "ButtonCancel": "Annulla",
"ButtonCancelEncode": "Ferma la codifica", "ButtonCancelEncode": "Ferma la codifica",
"ButtonChangeRootPassword": "Cambia la Password di root", "ButtonChangeRootPassword": "Cambia la Password di root",
@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Seleziona la Cartella", "ButtonChooseAFolder": "Seleziona la Cartella",
"ButtonChooseFiles": "Seleziona i File", "ButtonChooseFiles": "Seleziona i File",
"ButtonClearFilter": "Elimina filtri", "ButtonClearFilter": "Elimina filtri",
"ButtonClose": "Chiudi",
"ButtonCloseFeed": "Chiudi flusso", "ButtonCloseFeed": "Chiudi flusso",
"ButtonCloseSession": "Chiudi la sessione aperta", "ButtonCloseSession": "Chiudi la sessione aperta",
"ButtonCollections": "Raccolte", "ButtonCollections": "Raccolte",

View file

@ -24,7 +24,7 @@
"ButtonCloseSession": "Sluit Sessie", "ButtonCloseSession": "Sluit Sessie",
"ButtonCollections": "Collecties", "ButtonCollections": "Collecties",
"ButtonConfigureScanner": "Configureer scanner", "ButtonConfigureScanner": "Configureer scanner",
"ButtonCreate": "Creëer", "ButtonCreate": "Aanmaken",
"ButtonCreateBackup": "Maak back-up", "ButtonCreateBackup": "Maak back-up",
"ButtonDelete": "Verwijder", "ButtonDelete": "Verwijder",
"ButtonDownloadQueue": "Wachtrij", "ButtonDownloadQueue": "Wachtrij",
@ -43,9 +43,9 @@
"ButtonJumpForward": "Spring vooruit", "ButtonJumpForward": "Spring vooruit",
"ButtonLatest": "Meest recent", "ButtonLatest": "Meest recent",
"ButtonLibrary": "Bibliotheek", "ButtonLibrary": "Bibliotheek",
"ButtonLogout": "Log uit", "ButtonLogout": "Uitloggen",
"ButtonLookup": "Zoeken", "ButtonLookup": "Zoeken",
"ButtonManageTracks": "Beheer tracks", "ButtonManageTracks": "Tracks beheren",
"ButtonMapChapterTitles": "Hoofdstuktitels mappen", "ButtonMapChapterTitles": "Hoofdstuktitels mappen",
"ButtonMatchAllAuthors": "Alle auteurs matchen", "ButtonMatchAllAuthors": "Alle auteurs matchen",
"ButtonMatchBooks": "Alle boeken matchen", "ButtonMatchBooks": "Alle boeken matchen",
@ -72,7 +72,7 @@
"ButtonQuickEmbedMetadata": "Snel Metadata Insluiten", "ButtonQuickEmbedMetadata": "Snel Metadata Insluiten",
"ButtonQuickMatch": "Snelle match", "ButtonQuickMatch": "Snelle match",
"ButtonReScan": "Nieuwe scan", "ButtonReScan": "Nieuwe scan",
"ButtonRead": "Lees", "ButtonRead": "Lezen",
"ButtonReadLess": "Lees minder", "ButtonReadLess": "Lees minder",
"ButtonReadMore": "Lees meer", "ButtonReadMore": "Lees meer",
"ButtonRefresh": "Verversen", "ButtonRefresh": "Verversen",
@ -107,7 +107,7 @@
"ButtonUnlinkOpenId": "OpenID Ontkoppelen", "ButtonUnlinkOpenId": "OpenID Ontkoppelen",
"ButtonUpload": "Upload", "ButtonUpload": "Upload",
"ButtonUploadBackup": "Upload back-up", "ButtonUploadBackup": "Upload back-up",
"ButtonUploadCover": "Upload cover", "ButtonUploadCover": "Omslag uploaden",
"ButtonUploadOPMLFile": "Upload OPML-bestand", "ButtonUploadOPMLFile": "Upload OPML-bestand",
"ButtonUserDelete": "Verwijder gebruiker {0}", "ButtonUserDelete": "Verwijder gebruiker {0}",
"ButtonUserEdit": "Wijzig gebruiker {0}", "ButtonUserEdit": "Wijzig gebruiker {0}",
@ -178,7 +178,7 @@
"HeaderPlaylistItems": "Onderdelen in afspeellijst", "HeaderPlaylistItems": "Onderdelen in afspeellijst",
"HeaderPodcastsToAdd": "Toe te voegen podcasts", "HeaderPodcastsToAdd": "Toe te voegen podcasts",
"HeaderPresets": "Voorinstellingen", "HeaderPresets": "Voorinstellingen",
"HeaderPreviewCover": "Preview cover", "HeaderPreviewCover": "Voorbeeld omslag",
"HeaderRSSFeedGeneral": "RSS-details", "HeaderRSSFeedGeneral": "RSS-details",
"HeaderRSSFeedIsOpen": "RSS-feed is open", "HeaderRSSFeedIsOpen": "RSS-feed is open",
"HeaderRSSFeeds": "RSS-feeds", "HeaderRSSFeeds": "RSS-feeds",
@ -285,7 +285,7 @@
"LabelContinueReading": "Verder lezen", "LabelContinueReading": "Verder lezen",
"LabelContinueSeries": "Doorgaan met Serie", "LabelContinueSeries": "Doorgaan met Serie",
"LabelCover": "Omslag", "LabelCover": "Omslag",
"LabelCoverImageURL": "Coverafbeelding URL", "LabelCoverImageURL": "Omslagafbeelding-URL",
"LabelCoverProvider": "Omslag bron", "LabelCoverProvider": "Omslag bron",
"LabelCreatedAt": "Gecreëerd op", "LabelCreatedAt": "Gecreëerd op",
"LabelCronExpression": "Cron-uitdrukking", "LabelCronExpression": "Cron-uitdrukking",
@ -322,7 +322,7 @@
"LabelEmailSettingsSecure": "Veilig", "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)", "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", "LabelEmailSettingsTestAddress": "Test-adres",
"LabelEmbeddedCover": "Ingesloten cover", "LabelEmbeddedCover": "Omslag in bestand",
"LabelEnable": "Inschakelen", "LabelEnable": "Inschakelen",
"LabelEncodingBackupLocation": "Er wordt een back-up van uw originele audiobestanden opgeslagen in:", "LabelEncodingBackupLocation": "Er wordt een back-up van uw originele audiobestanden opgeslagen in:",
"LabelEncodingChaptersNotEmbedded": "Hoofdstukken zijn niet ingesloten in audioboeken met meerdere sporen.", "LabelEncodingChaptersNotEmbedded": "Hoofdstukken zijn niet ingesloten in audioboeken met meerdere sporen.",
@ -331,7 +331,7 @@
"LabelEncodingInfoEmbedded": "Metagegevens worden ingesloten in de audiotracks in uw audioboekmap.", "LabelEncodingInfoEmbedded": "Metagegevens worden ingesloten in de audiotracks in uw audioboekmap.",
"LabelEncodingStartedNavigation": "Eenmaal de taak is gestart kan u weg navigeren van deze pagina.", "LabelEncodingStartedNavigation": "Eenmaal de taak is gestart kan u weg navigeren van deze pagina.",
"LabelEncodingTimeWarning": "Encoding kan tot 30 minuten duren.", "LabelEncodingTimeWarning": "Encoding kan tot 30 minuten duren.",
"LabelEncodingWarningAdvancedSettings": "Waarschuwing: update deze instellingen niet tenzij u bekend bent met de coderingsopties van ffmpeg.", "LabelEncodingWarningAdvancedSettings": "Waarschuwing: pas deze instellingen niet aan tenzij u bekend bent met de coderingsopties van ffmpeg.",
"LabelEncodingWatcherDisabled": "Als u de watcher hebt uitgeschakeld, moet u het audioboek daarna opnieuw scannen.", "LabelEncodingWatcherDisabled": "Als u de watcher hebt uitgeschakeld, moet u het audioboek daarna opnieuw scannen.",
"LabelEnd": "Einde", "LabelEnd": "Einde",
"LabelEndOfChapter": "Einde van het Hoofdstuk", "LabelEndOfChapter": "Einde van het Hoofdstuk",
@ -373,7 +373,7 @@
"LabelFull": "Vol", "LabelFull": "Vol",
"LabelGenre": "Genre", "LabelGenre": "Genre",
"LabelGenres": "Genres", "LabelGenres": "Genres",
"LabelHardDeleteFile": "Hard-delete bestand", "LabelHardDeleteFile": "Bestand permanent verwijderen",
"LabelHasEbook": "Heeft Ebook", "LabelHasEbook": "Heeft Ebook",
"LabelHasSupplementaryEbook": "Heeft aanvullend Ebook", "LabelHasSupplementaryEbook": "Heeft aanvullend Ebook",
"LabelHideSubtitles": "Ondertitels Verstoppen", "LabelHideSubtitles": "Ondertitels Verstoppen",
@ -407,7 +407,7 @@
"LabelLastBookUpdated": "Laatst bijgewerkte boek", "LabelLastBookUpdated": "Laatst bijgewerkte boek",
"LabelLastSeen": "Laatst gezien", "LabelLastSeen": "Laatst gezien",
"LabelLastTime": "Laatste keer", "LabelLastTime": "Laatste keer",
"LabelLastUpdate": "Laatste update", "LabelLastUpdate": "Laatste wijziging",
"LabelLayout": "Layout", "LabelLayout": "Layout",
"LabelLayoutSinglePage": "Enkele pagina", "LabelLayoutSinglePage": "Enkele pagina",
"LabelLayoutSplitPage": "Gesplitste pagina", "LabelLayoutSplitPage": "Gesplitste pagina",
@ -532,7 +532,7 @@
"LabelRemoveAllMetadataAbs": "Verwijder alle metadata.abs bestanden", "LabelRemoveAllMetadataAbs": "Verwijder alle metadata.abs bestanden",
"LabelRemoveAllMetadataJson": "Verwijder alle metadata.json bestanden", "LabelRemoveAllMetadataJson": "Verwijder alle metadata.json bestanden",
"LabelRemoveAudibleBranding": "Verwijder Audible intro en outro uit hoofdstukken", "LabelRemoveAudibleBranding": "Verwijder Audible intro en outro uit hoofdstukken",
"LabelRemoveCover": "Verwijder cover", "LabelRemoveCover": "Omslag verwijderen",
"LabelRemoveMetadataFile": "Verwijder metadata bestanden in bibliotheek item folders", "LabelRemoveMetadataFile": "Verwijder metadata bestanden in bibliotheek item folders",
"LabelRemoveMetadataFileHelp": "Verwijder alle metadata.json en metadata.abs bestanden in uw {0} folders.", "LabelRemoveMetadataFileHelp": "Verwijder alle metadata.json en metadata.abs bestanden in uw {0} folders.",
"LabelRowsPerPage": "Rijen per pagina", "LabelRowsPerPage": "Rijen per pagina",
@ -560,7 +560,7 @@
"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", "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", "LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken",
"LabelSettingsChromecastSupport": "Chromecast ondersteuning", "LabelSettingsChromecastSupport": "Chromecast ondersteuning",
"LabelSettingsDateFormat": "Datum format", "LabelSettingsDateFormat": "Datumnotatie",
"LabelSettingsEnableWatcher": "Bibliotheken automatisch scannen op wijzigingen", "LabelSettingsEnableWatcher": "Bibliotheken automatisch scannen op wijzigingen",
"LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch scannen op wijzigingen", "LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch scannen op wijzigingen",
"LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server", "LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server",
@ -568,8 +568,8 @@
"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.", "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", "LabelSettingsExperimentalFeatures": "Experimentele functies",
"LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.", "LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.",
"LabelSettingsFindCovers": "Zoek covers", "LabelSettingsFindCovers": "Omslagen zoeken",
"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", "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",
"LabelSettingsHideSingleBookSeries": "Verberg series met een enkel boek", "LabelSettingsHideSingleBookSeries": "Verberg series met een enkel boek",
"LabelSettingsHideSingleBookSeriesHelp": "Series die slechts een enkel boek bevatten worden verborgen op de seriespagina en de homepagina-planken.", "LabelSettingsHideSingleBookSeriesHelp": "Series die slechts een enkel boek bevatten worden verborgen op de seriespagina en de homepagina-planken.",
"LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina", "LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina",
@ -579,18 +579,18 @@
"LabelSettingsLibraryMarkAsFinishedWhen": "Markeer media item wanneer voltooid", "LabelSettingsLibraryMarkAsFinishedWhen": "Markeer media item wanneer voltooid",
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sla eedere boeken in Serie Verderzetten over", "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.", "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": "Parseer subtitel", "LabelSettingsParseSubtitles": "Subtitel afleiden uit foldernaam",
"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\"", "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": "Prefereer gematchte metadata", "LabelSettingsPreferMatchedMetadata": "Geef voorkeur aan gematchte metadata",
"LabelSettingsPreferMatchedMetadataHelp": "Gematchte data zal onderdeeldetails overschrijven bij gebruik van Quick Match. Standaard vult Quick Match uitsluitend ontbrekende details aan.", "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", "LabelSettingsSkipMatchingBooksWithASIN": "Sla matchen van boeken over die al over een ASIN beschikken",
"LabelSettingsSkipMatchingBooksWithISBN": "Sla matchen van boeken over die al over een ISBN beschikken", "LabelSettingsSkipMatchingBooksWithISBN": "Sla matchen van boeken over die al over een ISBN beschikken",
"LabelSettingsSortingIgnorePrefixes": "Negeer voorvoegsels bij sorteren", "LabelSettingsSortingIgnorePrefixes": "Negeer voorvoegsels bij sorteren",
"LabelSettingsSortingIgnorePrefixesHelp": "b.v. voor voorvoegsel \"The\" wordt titel \"The Title\" dan gesorteerd als \"Title, The\"", "LabelSettingsSortingIgnorePrefixesHelp": "b.v. voor voorvoegsel \"The\" wordt titel \"The Title\" dan gesorteerd als \"Title, The\"",
"LabelSettingsSquareBookCovers": "Gebruik vierkante boekcovers", "LabelSettingsSquareBookCovers": "Gebruik vierkante boekomslagen",
"LabelSettingsSquareBookCoversHelp": "Prefereer gebruik van vierkante covers boven standaard 1.6:1 boekcovers", "LabelSettingsSquareBookCoversHelp": "Gebruik vierkante boekomslagen in plaats van standaard 1,6:1",
"LabelSettingsStoreCoversWithItem": "Bewaar covers bij onderdeel", "LabelSettingsStoreCoversWithItem": "Bewaar omslagen 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", "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",
"LabelSettingsStoreMetadataWithItem": "Bewaar metadata bij onderdeel", "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", "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", "LabelSettingsTimeFormat": "Tijdformat",
@ -652,12 +652,12 @@
"LabelTimeToShift": "Tijd op te schuiven in seconden", "LabelTimeToShift": "Tijd op te schuiven in seconden",
"LabelTitle": "Titel", "LabelTitle": "Titel",
"LabelToolsEmbedMetadata": "Metadata insluiten", "LabelToolsEmbedMetadata": "Metadata insluiten",
"LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief coverafbeelding en hoofdstukken.", "LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief omslagafbeelding en hoofdstukken.",
"LabelToolsM4bEncoder": "M4B Encoder", "LabelToolsM4bEncoder": "M4B Encoder",
"LabelToolsMakeM4b": "Maak M4B-audioboekbestand", "LabelToolsMakeM4b": "Maak M4B-audioboekbestand",
"LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, coverafbeelding en hoofdstukken.", "LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, omslagafbeelding en hoofdstukken.",
"LabelToolsSplitM4b": "Splitst M4B in MP3's", "LabelToolsSplitM4b": "Splitst M4B in MP3's",
"LabelToolsSplitM4bDescription": "Maak MP3's van een M4B, gesplitst per hoofdstuk met ingesloten metadata, coverafbeelding en hoofdstukken.", "LabelToolsSplitM4bDescription": "Maak MP3's van een M4B, gesplitst per hoofdstuk met ingesloten metadata, omslagafbeelding en hoofdstukken.",
"LabelTotalDuration": "Totale duur", "LabelTotalDuration": "Totale duur",
"LabelTotalTimeListened": "Totale tijd geluisterd", "LabelTotalTimeListened": "Totale tijd geluisterd",
"LabelTrackFromFilename": "Track vanuit bestandsnaam", "LabelTrackFromFilename": "Track vanuit bestandsnaam",
@ -672,8 +672,8 @@
"LabelUndo": "Ongedaan maken", "LabelUndo": "Ongedaan maken",
"LabelUnknown": "Onbekend", "LabelUnknown": "Onbekend",
"LabelUnknownPublishDate": "Onbekende uitgeefdatum", "LabelUnknownPublishDate": "Onbekende uitgeefdatum",
"LabelUpdateCover": "Cover bijwerken", "LabelUpdateCover": "Omslag bijwerken",
"LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden", "LabelUpdateCoverHelp": "Sta overschrijven van bestaande omslagen toe voor de geselecteerde boeken wanneer een match is gevonden",
"LabelUpdateDetails": "Details bijwerken", "LabelUpdateDetails": "Details bijwerken",
"LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden", "LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden",
"LabelUpdatedAt": "Bijgewerkt op", "LabelUpdatedAt": "Bijgewerkt op",
@ -715,7 +715,7 @@
"MessageBackupsLocationPathEmpty": "Backup locatie pad kan niet leeg zijn", "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", "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", "MessageBatchEditPopulateMapDetailsItemHelp": "Vul actieve folder detail velden met de data van dit item",
"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.", "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.",
"MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt", "MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt",
"MessageBookshelfNoCollectionsHelp": "Collecties zijn publiekelijk. Alle gebruikers met toegang tot de bibliotheek kunnen ze zien.", "MessageBookshelfNoCollectionsHelp": "Collecties zijn publiekelijk. Alle gebruikers met toegang tot de bibliotheek kunnen ze zien.",
"MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend", "MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend",
@ -757,6 +757,7 @@
"MessageConfirmRemoveAuthor": "Weet je zeker dat je auteur \"{0}\" wil verwijderen?", "MessageConfirmRemoveAuthor": "Weet je zeker dat je auteur \"{0}\" wil verwijderen?",
"MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?", "MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?",
"MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?", "MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?",
"MessageConfirmRemoveEpisodeNote": "Let op: Het audiobestand wordt niet verwijderd, tenzij je Bestand permanent verwijderen inschakelt",
"MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?", "MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?",
"MessageConfirmRemoveListeningSessions": "Weet je zeker dat je {0} luistersessies wilt 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?", "MessageConfirmRemoveMetadataFiles": "Bent u zeker dat u alle metadata wil verwijderen. {0} bestanden in uw bibliotheel item folders?",
@ -798,14 +799,14 @@
"MessageMarkAllEpisodesNotFinished": "Markeer alle afleveringen als niet voltooid", "MessageMarkAllEpisodesNotFinished": "Markeer alle afleveringen als niet voltooid",
"MessageMarkAsFinished": "Markeer als Voltooid", "MessageMarkAsFinished": "Markeer als Voltooid",
"MessageMarkAsNotFinished": "Markeer als Niet Voltooid", "MessageMarkAsNotFinished": "Markeer als Niet Voltooid",
"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.", "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.",
"MessageNoAudioTracks": "Geen audiotracks", "MessageNoAudioTracks": "Geen audiotracks",
"MessageNoAuthors": "Geen auteurs", "MessageNoAuthors": "Geen auteurs",
"MessageNoBackups": "Geen back-ups", "MessageNoBackups": "Geen back-ups",
"MessageNoBookmarks": "Geen boekwijzers", "MessageNoBookmarks": "Geen boekwijzers",
"MessageNoChapters": "Geen hoofdstukken", "MessageNoChapters": "Geen hoofdstukken",
"MessageNoCollections": "Geen collecties", "MessageNoCollections": "Geen collecties",
"MessageNoCoversFound": "Geen covers gevonden", "MessageNoCoversFound": "Geen omslagen gevonden",
"MessageNoDescription": "Geen beschrijving", "MessageNoDescription": "Geen beschrijving",
"MessageNoDevices": "Geen Apparaten", "MessageNoDevices": "Geen Apparaten",
"MessageNoDownloadsInProgress": "Geen downloads bezig op dit moment", "MessageNoDownloadsInProgress": "Geen downloads bezig op dit moment",
@ -843,7 +844,7 @@
"MessageQuickEmbedInProgress": "Snelle inbedding in uitvoering", "MessageQuickEmbedInProgress": "Snelle inbedding in uitvoering",
"MessageQuickEmbedQueue": "In de wachtrij voor snelle insluiting ({0} in wachtrij)", "MessageQuickEmbedQueue": "In de wachtrij voor snelle insluiting ({0} in wachtrij)",
"MessageQuickMatchAllEpisodes": "Alle Afleveringen Snel Matchen", "MessageQuickMatchAllEpisodes": "Alle Afleveringen Snel Matchen",
"MessageQuickMatchDescription": "Vul lege onderdeeldetails & cover met eerste matchresultaat van '{0}'. Overschrijft geen details tenzij 'Prefereer gematchte metadata' serverinstelling is ingeschakeld.", "MessageQuickMatchDescription": "Vult ontbrekende gegevens & omslag met eerste matchresultaat van '{0}'. Overschrijft gegevens alleen als de serverinstelling Geef voorkeur aan gematchte metadata is ingeschakeld.",
"MessageRemoveChapter": "Verwijder hoofdstuk", "MessageRemoveChapter": "Verwijder hoofdstuk",
"MessageRemoveEpisodes": "Verwijder {0} aflevering(en)", "MessageRemoveEpisodes": "Verwijder {0} aflevering(en)",
"MessageRemoveFromPlayerQueue": "Verwijder uit afspeelwachtrij", "MessageRemoveFromPlayerQueue": "Verwijder uit afspeelwachtrij",
@ -851,7 +852,7 @@
"MessageReportBugsAndContribute": "Rapporteer bugs, vraag functionaliteiten aan en draag bij op", "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?", "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", "MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op",
"MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle clients die van je server gebruik maken zullen automatisch worden ververst.", "MessageRestoreBackupWarning": "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.",
"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.", "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}", "MessageScheduleRunEveryWeekdayAtTime": "Elke {0} uitvoeren op {1}",
"MessageSearchResultsFor": "Zoekresultaten voor", "MessageSearchResultsFor": "Zoekresultaten voor",
@ -985,7 +986,7 @@
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt", "ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
"ToastCollectionRemoveSuccess": "Collectie verwijderd", "ToastCollectionRemoveSuccess": "Collectie verwijderd",
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt", "ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
"ToastCoverUpdateFailed": "Cover update mislukt", "ToastCoverUpdateFailed": "Omslag bijwerken mislukt",
"ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig", "ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig",
"ToastDeleteFileFailed": "Bestand verwijderen mislukt", "ToastDeleteFileFailed": "Bestand verwijderen mislukt",
"ToastDeleteFileSuccess": "Bestand verwijderd", "ToastDeleteFileSuccess": "Bestand verwijderd",
@ -1007,7 +1008,7 @@
"ToastInvalidImageUrl": "Ongeldige afbeeldings-URL", "ToastInvalidImageUrl": "Ongeldige afbeeldings-URL",
"ToastInvalidMaxEpisodesToDownload": "Ongeldig maximum aantal afleveringen om te downloaden", "ToastInvalidMaxEpisodesToDownload": "Ongeldig maximum aantal afleveringen om te downloaden",
"ToastInvalidUrl": "Ongeldige URL", "ToastInvalidUrl": "Ongeldige URL",
"ToastItemCoverUpdateSuccess": "Cover onderdeel bijgewerkt", "ToastItemCoverUpdateSuccess": "Omslag bijgewerkt",
"ToastItemDeletedFailed": "Item verwijderen mislukt", "ToastItemDeletedFailed": "Item verwijderen mislukt",
"ToastItemDeletedSuccess": "Verwijderd item", "ToastItemDeletedSuccess": "Verwijderd item",
"ToastItemDetailsUpdateSuccess": "Details onderdeel bijgewerkt", "ToastItemDetailsUpdateSuccess": "Details onderdeel bijgewerkt",

View file

@ -1,5 +1,6 @@
{ {
"ButtonAdd": "Dodaj", "ButtonAdd": "Dodaj",
"ButtonAddApiKey": "Dodaj klucz API",
"ButtonAddChapters": "Dodaj rozdziały", "ButtonAddChapters": "Dodaj rozdziały",
"ButtonAddDevice": "Dodaj urządzenie", "ButtonAddDevice": "Dodaj urządzenie",
"ButtonAddLibrary": "Dodaj bibliotekę", "ButtonAddLibrary": "Dodaj bibliotekę",
@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Wybierz folder", "ButtonChooseAFolder": "Wybierz folder",
"ButtonChooseFiles": "Wybierz pliki", "ButtonChooseFiles": "Wybierz pliki",
"ButtonClearFilter": "Wyczyść filtr", "ButtonClearFilter": "Wyczyść filtr",
"ButtonClose": "Zamknij",
"ButtonCloseFeed": "Zamknij kanał", "ButtonCloseFeed": "Zamknij kanał",
"ButtonCloseSession": "Zamknij otwartą sesję", "ButtonCloseSession": "Zamknij otwartą sesję",
"ButtonCollections": "Kolekcje", "ButtonCollections": "Kolekcje",
@ -119,6 +121,7 @@
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAddCustomMetadataProvider": "Dodaj niestandardowego dostawcę metadanych", "HeaderAddCustomMetadataProvider": "Dodaj niestandardowego dostawcę metadanych",
"HeaderAdvanced": "Zaawansowane", "HeaderAdvanced": "Zaawansowane",
"HeaderApiKeys": "Klucze API",
"HeaderAppriseNotificationSettings": "Ustawienia powiadomień Apprise", "HeaderAppriseNotificationSettings": "Ustawienia powiadomień Apprise",
"HeaderAudioTracks": "Ścieżki audio", "HeaderAudioTracks": "Ścieżki audio",
"HeaderAudiobookTools": "Narzędzia do zarządzania audiobookami", "HeaderAudiobookTools": "Narzędzia do zarządzania audiobookami",
@ -162,6 +165,7 @@
"HeaderMetadataOrderOfPrecedence": "Kolejność metadanych", "HeaderMetadataOrderOfPrecedence": "Kolejność metadanych",
"HeaderMetadataToEmbed": "Metadane do osadzenia", "HeaderMetadataToEmbed": "Metadane do osadzenia",
"HeaderNewAccount": "Nowe konto", "HeaderNewAccount": "Nowe konto",
"HeaderNewApiKey": "Nowy klucz API",
"HeaderNewLibrary": "Nowa biblioteka", "HeaderNewLibrary": "Nowa biblioteka",
"HeaderNotificationCreate": "Utwórz powiadomienie", "HeaderNotificationCreate": "Utwórz powiadomienie",
"HeaderNotificationUpdate": "Zaktualizuj powiadomienie", "HeaderNotificationUpdate": "Zaktualizuj powiadomienie",
@ -206,6 +210,7 @@
"HeaderTableOfContents": "Spis treści", "HeaderTableOfContents": "Spis treści",
"HeaderTools": "Narzędzia", "HeaderTools": "Narzędzia",
"HeaderUpdateAccount": "Zaktualizuj konto", "HeaderUpdateAccount": "Zaktualizuj konto",
"HeaderUpdateApiKey": "Aktualizuj klucz API",
"HeaderUpdateAuthor": "Zaktualizuj autorów", "HeaderUpdateAuthor": "Zaktualizuj autorów",
"HeaderUpdateDetails": "Zaktualizuj szczegóły", "HeaderUpdateDetails": "Zaktualizuj szczegóły",
"HeaderUpdateLibrary": "Zaktualizuj bibliotekę", "HeaderUpdateLibrary": "Zaktualizuj bibliotekę",
@ -235,6 +240,7 @@
"LabelAllUsersExcludingGuests": "Wszyscy użytkownicy z wyłączeniem gości", "LabelAllUsersExcludingGuests": "Wszyscy użytkownicy z wyłączeniem gości",
"LabelAllUsersIncludingGuests": "Wszyscy użytkownicy, łącznie z gośćmi", "LabelAllUsersIncludingGuests": "Wszyscy użytkownicy, łącznie z gośćmi",
"LabelAlreadyInYourLibrary": "Już istnieje w twojej bibliotece", "LabelAlreadyInYourLibrary": "Już istnieje w twojej bibliotece",
"LabelApiKeyCreated": "Klucz API \"{0}\" został pomyślnie utworzony.",
"LabelApiToken": "API Token", "LabelApiToken": "API Token",
"LabelAppend": "Dołącz", "LabelAppend": "Dołącz",
"LabelAudioBitrate": "Audio Bitrate (np. 128k)", "LabelAudioBitrate": "Audio Bitrate (np. 128k)",
@ -324,6 +330,10 @@
"LabelEmbeddedCover": "Wbudowana okładka", "LabelEmbeddedCover": "Wbudowana okładka",
"LabelEnable": "Włącz", "LabelEnable": "Włącz",
"LabelEncodingBackupLocation": "Kopia zapasowa twoich oryginalnych plików audio będzie się znajdować w:", "LabelEncodingBackupLocation": "Kopia zapasowa twoich oryginalnych plików audio będzie się znajdować w:",
"LabelEncodingChaptersNotEmbedded": "W audiobookach wielościeżkowych rozdziały nie są osadzone.",
"LabelEncodingClearItemCache": "Pamiętaj o okresowym czyszczeniu pamięci podręcznej elementów.",
"LabelEncodingFinishedM4B": "Ukończony plik M4B zostanie umieszczony w folderze audiobooka pod adresem:",
"LabelEncodingInfoEmbedded": "Metadane zostaną osadzone w ścieżkach audio w folderze z audiobookiem.",
"LabelEnd": "Zakończ", "LabelEnd": "Zakończ",
"LabelEndOfChapter": "Koniec rozdziału", "LabelEndOfChapter": "Koniec rozdziału",
"LabelEpisode": "Odcinek", "LabelEpisode": "Odcinek",

View file

@ -1,5 +1,6 @@
{ {
"ButtonAdd": "Добавить", "ButtonAdd": "Добавить",
"ButtonAddApiKey": "Добавить API ключ",
"ButtonAddChapters": "Добавить главы", "ButtonAddChapters": "Добавить главы",
"ButtonAddDevice": "Добавить устройство", "ButtonAddDevice": "Добавить устройство",
"ButtonAddLibrary": "Добавить библиотеку", "ButtonAddLibrary": "Добавить библиотеку",
@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Выбор папки", "ButtonChooseAFolder": "Выбор папки",
"ButtonChooseFiles": "Выбор файлов", "ButtonChooseFiles": "Выбор файлов",
"ButtonClearFilter": "Очистить фильтр", "ButtonClearFilter": "Очистить фильтр",
"ButtonClose": "Закрыть",
"ButtonCloseFeed": "Закрыть канал", "ButtonCloseFeed": "Закрыть канал",
"ButtonCloseSession": "Закрыть открытый сеанс", "ButtonCloseSession": "Закрыть открытый сеанс",
"ButtonCollections": "Коллекции", "ButtonCollections": "Коллекции",
@ -119,6 +121,7 @@
"HeaderAccount": "Учетная запись", "HeaderAccount": "Учетная запись",
"HeaderAddCustomMetadataProvider": "Добавление пользовательского поставщика метаданных", "HeaderAddCustomMetadataProvider": "Добавление пользовательского поставщика метаданных",
"HeaderAdvanced": "Дополнительно", "HeaderAdvanced": "Дополнительно",
"HeaderApiKeys": "API ключи",
"HeaderAppriseNotificationSettings": "Настройки оповещений", "HeaderAppriseNotificationSettings": "Настройки оповещений",
"HeaderAudioTracks": "Аудио треки", "HeaderAudioTracks": "Аудио треки",
"HeaderAudiobookTools": "Инструменты файлов аудиокниг", "HeaderAudiobookTools": "Инструменты файлов аудиокниг",
@ -162,6 +165,7 @@
"HeaderMetadataOrderOfPrecedence": "Порядок приоритета метаданных", "HeaderMetadataOrderOfPrecedence": "Порядок приоритета метаданных",
"HeaderMetadataToEmbed": "Метаинформация для встраивания", "HeaderMetadataToEmbed": "Метаинформация для встраивания",
"HeaderNewAccount": "Новая учетная запись", "HeaderNewAccount": "Новая учетная запись",
"HeaderNewApiKey": "Новый API ключ",
"HeaderNewLibrary": "Новая библиотека", "HeaderNewLibrary": "Новая библиотека",
"HeaderNotificationCreate": "Создать уведомление", "HeaderNotificationCreate": "Создать уведомление",
"HeaderNotificationUpdate": "Уведомление об обновлении", "HeaderNotificationUpdate": "Уведомление об обновлении",
@ -206,6 +210,7 @@
"HeaderTableOfContents": "Содержание", "HeaderTableOfContents": "Содержание",
"HeaderTools": "Инструменты", "HeaderTools": "Инструменты",
"HeaderUpdateAccount": "Обновить учетную запись", "HeaderUpdateAccount": "Обновить учетную запись",
"HeaderUpdateApiKey": "Обновить API ключ",
"HeaderUpdateAuthor": "Обновить автора", "HeaderUpdateAuthor": "Обновить автора",
"HeaderUpdateDetails": "Обновить детали", "HeaderUpdateDetails": "Обновить детали",
"HeaderUpdateLibrary": "Обновить библиотеку", "HeaderUpdateLibrary": "Обновить библиотеку",
@ -235,6 +240,10 @@
"LabelAllUsersExcludingGuests": "Все пользователи, кроме гостей", "LabelAllUsersExcludingGuests": "Все пользователи, кроме гостей",
"LabelAllUsersIncludingGuests": "Все пользователи, включая гостей", "LabelAllUsersIncludingGuests": "Все пользователи, включая гостей",
"LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке", "LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке",
"LabelApiKeyCreated": "API ключ \"{0}\" успешно создан.",
"LabelApiKeyCreatedDescription": "Обязательно скопируйте API-ключ сейчас, так как вы больше не сможете его увидеть.",
"LabelApiKeyUser": "Управление от пользователя",
"LabelApiKeyUserDescription": "Этот API-ключ будет иметь те же права доступа, что и пользователь, от имени которого он действует. В логах это будет отображаться так же, как если бы пользователь отправлял запрос.",
"LabelApiToken": "Токен API", "LabelApiToken": "Токен API",
"LabelAppend": "Добавить", "LabelAppend": "Добавить",
"LabelAudioBitrate": "Битрейт (напр. 128k)", "LabelAudioBitrate": "Битрейт (напр. 128k)",
@ -346,6 +355,10 @@
"LabelExample": "Пример", "LabelExample": "Пример",
"LabelExpandSeries": "Развернуть серию", "LabelExpandSeries": "Развернуть серию",
"LabelExpandSubSeries": "Развернуть подсерию", "LabelExpandSubSeries": "Развернуть подсерию",
"LabelExpired": "Истекший",
"LabelExpiresAt": "Истекает в",
"LabelExpiresInSeconds": "Истекает через (секунд)",
"LabelExpiresNever": "Никогда",
"LabelExplicit": "18+", "LabelExplicit": "18+",
"LabelExplicitChecked": "18+ (отмечено)", "LabelExplicitChecked": "18+ (отмечено)",
"LabelExplicitUnchecked": "+18 (не отмечено)", "LabelExplicitUnchecked": "+18 (не отмечено)",
@ -425,6 +438,7 @@
"LabelLogLevelWarn": "Предупреждение", "LabelLogLevelWarn": "Предупреждение",
"LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты", "LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты",
"LabelLowestPriority": "Самый низкий приоритет", "LabelLowestPriority": "Самый низкий приоритет",
"LabelMatchConfidence": "Уверенность",
"LabelMatchExistingUsersBy": "Сопоставление существующих пользователей по", "LabelMatchExistingUsersBy": "Сопоставление существующих пользователей по",
"LabelMatchExistingUsersByDescription": "Используется для подключения существующих пользователей. После подключения пользователям будет присвоен уникальный идентификатор от поставщика единого входа", "LabelMatchExistingUsersByDescription": "Используется для подключения существующих пользователей. После подключения пользователям будет присвоен уникальный идентификатор от поставщика единого входа",
"LabelMaxEpisodesToDownload": "Максимальное количество эпизодов для загрузки. Используйте 0 для неограниченного количества.", "LabelMaxEpisodesToDownload": "Максимальное количество эпизодов для загрузки. Используйте 0 для неограниченного количества.",
@ -455,6 +469,7 @@
"LabelNewestEpisodes": "Новые эпизоды", "LabelNewestEpisodes": "Новые эпизоды",
"LabelNextBackupDate": "Следующая дата бэкапирования", "LabelNextBackupDate": "Следующая дата бэкапирования",
"LabelNextScheduledRun": "Следущий запланированный запуск", "LabelNextScheduledRun": "Следущий запланированный запуск",
"LabelNoApiKeys": "API ключи отсутствуют",
"LabelNoCustomMetadataProviders": "Нет пользовательских поставщиков метаданных", "LabelNoCustomMetadataProviders": "Нет пользовательских поставщиков метаданных",
"LabelNoEpisodesSelected": "Эпизоды не выбраны", "LabelNoEpisodesSelected": "Эпизоды не выбраны",
"LabelNotFinished": "Не завершено", "LabelNotFinished": "Не завершено",
@ -544,6 +559,7 @@
"LabelSelectAll": "Выбрать все", "LabelSelectAll": "Выбрать все",
"LabelSelectAllEpisodes": "Выбрать все эпизоды", "LabelSelectAllEpisodes": "Выбрать все эпизоды",
"LabelSelectEpisodesShowing": "Выберите {0} эпизодов для показа", "LabelSelectEpisodesShowing": "Выберите {0} эпизодов для показа",
"LabelSelectUser": "Выбрать пользователя",
"LabelSelectUsers": "Выбор пользователей", "LabelSelectUsers": "Выбор пользователей",
"LabelSendEbookToDevice": "Отправить e-книгу в...", "LabelSendEbookToDevice": "Отправить e-книгу в...",
"LabelSequence": "Последовательность", "LabelSequence": "Последовательность",
@ -708,7 +724,9 @@
"MessageAddToPlayerQueue": "Добавить в очередь проигрывателя", "MessageAddToPlayerQueue": "Добавить в очередь проигрывателя",
"MessageAppriseDescription": "Для использования этой функции необходимо иметь запущенный экземпляр <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> или api которое обрабатывает те же самые запросы. <br />URL-адрес API Apprise должен быть полным URL-адресом для отправки уведомления, т.е., если API запущено по адресу <code>http://192.168.1.1:8337</code> тогда нужно указать <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "Для использования этой функции необходимо иметь запущенный экземпляр <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> или api которое обрабатывает те же самые запросы. <br />URL-адрес API Apprise должен быть полным URL-адресом для отправки уведомления, т.е., если API запущено по адресу <code>http://192.168.1.1:8337</code> тогда нужно указать <code>http://192.168.1.1:8337/notify</code>.",
"MessageAsinCheck": "Убедитесь, что вы используете ASIN из правильной региональной зоны Audible, а не из Amazon.", "MessageAsinCheck": "Убедитесь, что вы используете ASIN из правильной региональной зоны Audible, а не из Amazon.",
"MessageAuthenticationLegacyTokenWarning": "Устаревшие токены API в будущем будут удалены. Вместо них используйте <a href=\"/config/api-keys\">API-ключи</a>.",
"MessageAuthenticationOIDCChangesRestart": "Перезапустите ваш сервер после сохранения для применения изменений в OIDC.", "MessageAuthenticationOIDCChangesRestart": "Перезапустите ваш сервер после сохранения для применения изменений в OIDC.",
"MessageAuthenticationSecurityMessage": "В целях безопасности была улучшена аутентификация. Всем пользователям необходимо повторно войти в систему.",
"MessageBackupsDescription": "Бэкап включает пользователей, прогресс пользователей, данные элементов библиотеки, настройки сервера и изображения хранящиеся в <code>/metadata/items</code> и <code>/metadata/authors</code>. Бэкапы <strong>НЕ</strong> сохраняют файлы из папок библиотек.", "MessageBackupsDescription": "Бэкап включает пользователей, прогресс пользователей, данные элементов библиотеки, настройки сервера и изображения хранящиеся в <code>/metadata/items</code> и <code>/metadata/authors</code>. Бэкапы <strong>НЕ</strong> сохраняют файлы из папок библиотек.",
"MessageBackupsLocationEditNote": "Примечание: Обновление местоположения резервной копии не приведет к перемещению или изменению существующих резервных копий", "MessageBackupsLocationEditNote": "Примечание: Обновление местоположения резервной копии не приведет к перемещению или изменению существующих резервных копий",
"MessageBackupsLocationNoEditNote": "Примечание: Местоположение резервного копирования задается с помощью переменной среды и не может быть изменено здесь.", "MessageBackupsLocationNoEditNote": "Примечание: Местоположение резервного копирования задается с помощью переменной среды и не может быть изменено здесь.",
@ -730,6 +748,7 @@
"MessageChaptersNotFound": "Главы не найденны", "MessageChaptersNotFound": "Главы не найденны",
"MessageCheckingCron": "Проверка cron...", "MessageCheckingCron": "Проверка cron...",
"MessageConfirmCloseFeed": "Вы уверены, что хотите закрыть этот канал?", "MessageConfirmCloseFeed": "Вы уверены, что хотите закрыть этот канал?",
"MessageConfirmDeleteApiKey": "Вы уверены, что хотите удалить API ключ \"{0}\"?",
"MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?", "MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?",
"MessageConfirmDeleteDevice": "Вы уверены, что хотите удалить устройство для чтения электронных книг \"{0}\"?", "MessageConfirmDeleteDevice": "Вы уверены, что хотите удалить устройство для чтения электронных книг \"{0}\"?",
"MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?", "MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?",
@ -757,6 +776,7 @@
"MessageConfirmRemoveAuthor": "Вы уверены, что хотите удалить автора \"{0}\"?", "MessageConfirmRemoveAuthor": "Вы уверены, что хотите удалить автора \"{0}\"?",
"MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?", "MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?",
"MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?", "MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?",
"MessageConfirmRemoveEpisodeNote": "Примечание: Это не приведет к удалению аудиофайла, если не включить опцию \"Жесткое удаление файла\"",
"MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?", "MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?",
"MessageConfirmRemoveListeningSessions": "Вы уверены, что хотите удалить {0} сеансов прослушивания?", "MessageConfirmRemoveListeningSessions": "Вы уверены, что хотите удалить {0} сеансов прослушивания?",
"MessageConfirmRemoveMetadataFiles": "Вы уверены, что хотите удалить все файлы metadata. {0} файлов из папок элементов вашей библиотеки?", "MessageConfirmRemoveMetadataFiles": "Вы уверены, что хотите удалить все файлы metadata. {0} файлов из папок элементов вашей библиотеки?",
@ -1000,6 +1020,8 @@
"ToastEpisodeDownloadQueueClearSuccess": "Очередь загрузки эпизода очищена", "ToastEpisodeDownloadQueueClearSuccess": "Очередь загрузки эпизода очищена",
"ToastEpisodeUpdateSuccess": "{0 эпизодов обновлено", "ToastEpisodeUpdateSuccess": "{0 эпизодов обновлено",
"ToastErrorCannotShare": "Невозможно предоставить общий доступ на этом устройстве", "ToastErrorCannotShare": "Невозможно предоставить общий доступ на этом устройстве",
"ToastFailedToCreate": "Не удалось создать",
"ToastFailedToDelete": "Не удалось удалить",
"ToastFailedToLoadData": "Не удалось загрузить данные", "ToastFailedToLoadData": "Не удалось загрузить данные",
"ToastFailedToMatch": "Не удалось найти совпадения", "ToastFailedToMatch": "Не удалось найти совпадения",
"ToastFailedToShare": "Не удалось поделиться", "ToastFailedToShare": "Не удалось поделиться",
@ -1031,6 +1053,7 @@
"ToastMustHaveAtLeastOnePath": "Должен быть хотя бы один путь", "ToastMustHaveAtLeastOnePath": "Должен быть хотя бы один путь",
"ToastNameEmailRequired": "Имя и адрес электронной почты обязательны", "ToastNameEmailRequired": "Имя и адрес электронной почты обязательны",
"ToastNameRequired": "Имя обязательно для заполнения", "ToastNameRequired": "Имя обязательно для заполнения",
"ToastNewApiKeyUserError": "Необходимо выбрать пользователя",
"ToastNewEpisodesFound": "{0} новых эпизодов найдено", "ToastNewEpisodesFound": "{0} новых эпизодов найдено",
"ToastNewUserCreatedFailed": "Не удалось создать учетную запись: \"{0}\"", "ToastNewUserCreatedFailed": "Не удалось создать учетную запись: \"{0}\"",
"ToastNewUserCreatedSuccess": "Новая учетная запись создана", "ToastNewUserCreatedSuccess": "Новая учетная запись создана",

View file

@ -346,7 +346,7 @@
"LabelExample": "Príklad", "LabelExample": "Príklad",
"LabelExpandSeries": "Rozbaliť série", "LabelExpandSeries": "Rozbaliť série",
"LabelExpandSubSeries": "Rozbaliť podsérie", "LabelExpandSubSeries": "Rozbaliť podsérie",
"LabelExplicit": "Explicitné", "LabelExplicit": "Explicitný obsah",
"LabelExplicitChecked": "Explicitné (zaškrtnuté)", "LabelExplicitChecked": "Explicitné (zaškrtnuté)",
"LabelExplicitUnchecked": "Ne-explicitné (nezaškrtnuté)", "LabelExplicitUnchecked": "Ne-explicitné (nezaškrtnuté)",
"LabelExportOPML": "Exportovať OPML", "LabelExportOPML": "Exportovať OPML",
@ -757,6 +757,7 @@
"MessageConfirmRemoveAuthor": "Ste si istý, že chcete odstrániť autora \"{0}\"?", "MessageConfirmRemoveAuthor": "Ste si istý, že chcete odstrániť autora \"{0}\"?",
"MessageConfirmRemoveCollection": "Ste si istý, že chcete odstrániť zbierku \"{0}\"?", "MessageConfirmRemoveCollection": "Ste si istý, že chcete odstrániť zbierku \"{0}\"?",
"MessageConfirmRemoveEpisode": "Ste si istý, že chcete odstrániť epizódu \"{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?", "MessageConfirmRemoveEpisodes": "Ste si istý, že chcete odstrániť {0} epizód?",
"MessageConfirmRemoveListeningSessions": "Ste si istý, že chcete odstrániť týchto {0} relácií?", "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?", "MessageConfirmRemoveMetadataFiles": "Ste si istý, že chcete odstrániť všetky súbory metadata.{0} z priečinkov položiek vašej knižnice?",
@ -918,6 +919,8 @@
"NotificationOnBackupCompletedDescription": "Spustené po dokončení zálohovania", "NotificationOnBackupCompletedDescription": "Spustené po dokončení zálohovania",
"NotificationOnBackupFailedDescription": "Spustené pri zlyhaní zálohovania", "NotificationOnBackupFailedDescription": "Spustené pri zlyhaní zálohovania",
"NotificationOnEpisodeDownloadedDescription": "Spustené po automatickom stiahnutí epizódy podcastu", "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í", "NotificationOnTestDescription": "Udalosť určená na testovanie systému notifikácií",
"PlaceholderNewCollection": "Názov novej zbierky", "PlaceholderNewCollection": "Názov novej zbierky",
"PlaceholderNewFolderPath": "Umiestnenie nového priečinka", "PlaceholderNewFolderPath": "Umiestnenie nového priečinka",

View file

@ -757,6 +757,7 @@
"MessageConfirmRemoveAuthor": "Ali ste prepričani, da želite odstraniti avtorja \"{0}\"?", "MessageConfirmRemoveAuthor": "Ali ste prepričani, da želite odstraniti avtorja \"{0}\"?",
"MessageConfirmRemoveCollection": "Ali ste prepričani, da želite odstraniti zbirko \"{0}\"?", "MessageConfirmRemoveCollection": "Ali ste prepričani, da želite odstraniti zbirko \"{0}\"?",
"MessageConfirmRemoveEpisode": "Ali ste prepričani, da želite odstraniti epizodo \"{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?", "MessageConfirmRemoveEpisodes": "Ali ste prepričani, da želite odstraniti {0} epizod?",
"MessageConfirmRemoveListeningSessions": "Ali ste prepričani, da želite odstraniti {0} sej poslušanja?", "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?", "MessageConfirmRemoveMetadataFiles": "Ali ste prepričani, da želite odstraniti vse metapodatke.{0} v mapah elementov knjižnice?",

View file

@ -1,5 +1,6 @@
{ {
"ButtonAdd": "Lägg till", "ButtonAdd": "Lägg till",
"ButtonAddApiKey": "Addera API-nyckel",
"ButtonAddChapters": "Lägg till kapitel", "ButtonAddChapters": "Lägg till kapitel",
"ButtonAddDevice": "Lägg till enhet", "ButtonAddDevice": "Lägg till enhet",
"ButtonAddLibrary": "Lägg till bibliotek", "ButtonAddLibrary": "Lägg till bibliotek",
@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Välj en mapp", "ButtonChooseAFolder": "Välj en mapp",
"ButtonChooseFiles": "Välj filer", "ButtonChooseFiles": "Välj filer",
"ButtonClearFilter": "Rensa filter", "ButtonClearFilter": "Rensa filter",
"ButtonClose": "Stäng",
"ButtonCloseFeed": "Stäng flöde", "ButtonCloseFeed": "Stäng flöde",
"ButtonCloseSession": "Stäng öppen session", "ButtonCloseSession": "Stäng öppen session",
"ButtonCollections": "Samlingar", "ButtonCollections": "Samlingar",
@ -119,6 +121,7 @@
"HeaderAccount": "Konto", "HeaderAccount": "Konto",
"HeaderAddCustomMetadataProvider": "Addera egen källa för metadata", "HeaderAddCustomMetadataProvider": "Addera egen källa för metadata",
"HeaderAdvanced": "Avancerad", "HeaderAdvanced": "Avancerad",
"HeaderApiKeys": "API-nyckel",
"HeaderAppriseNotificationSettings": "Inställningar av meddelanden med Apprise", "HeaderAppriseNotificationSettings": "Inställningar av meddelanden med Apprise",
"HeaderAudioTracks": "Ljudfiler", "HeaderAudioTracks": "Ljudfiler",
"HeaderAudiobookTools": "Hantering av ljudboksfiler", "HeaderAudiobookTools": "Hantering av ljudboksfiler",
@ -162,6 +165,7 @@
"HeaderMetadataOrderOfPrecedence": "Prioriteringsordning vid inläsning av metadata", "HeaderMetadataOrderOfPrecedence": "Prioriteringsordning vid inläsning av metadata",
"HeaderMetadataToEmbed": "Metadata som kommer att adderas", "HeaderMetadataToEmbed": "Metadata som kommer att adderas",
"HeaderNewAccount": "Nytt konto", "HeaderNewAccount": "Nytt konto",
"HeaderNewApiKey": "Ny API-nyckel",
"HeaderNewLibrary": "Nytt bibliotek", "HeaderNewLibrary": "Nytt bibliotek",
"HeaderNotificationCreate": "Addera ett meddelande", "HeaderNotificationCreate": "Addera ett meddelande",
"HeaderNotificationUpdate": "Uppdateringsnotis", "HeaderNotificationUpdate": "Uppdateringsnotis",
@ -205,6 +209,7 @@
"HeaderTableOfContents": "Innehållsförteckning", "HeaderTableOfContents": "Innehållsförteckning",
"HeaderTools": "Verktyg", "HeaderTools": "Verktyg",
"HeaderUpdateAccount": "Uppdatera konto", "HeaderUpdateAccount": "Uppdatera konto",
"HeaderUpdateApiKey": "Uppdatera API-nyckel",
"HeaderUpdateAuthor": "Uppdatera författare", "HeaderUpdateAuthor": "Uppdatera författare",
"HeaderUpdateDetails": "Uppdatera detaljer om boken", "HeaderUpdateDetails": "Uppdatera detaljer om boken",
"HeaderUpdateLibrary": "Uppdatera bibliotek", "HeaderUpdateLibrary": "Uppdatera bibliotek",
@ -234,6 +239,9 @@
"LabelAllUsersExcludingGuests": "Alla användare utom gäster", "LabelAllUsersExcludingGuests": "Alla användare utom gäster",
"LabelAllUsersIncludingGuests": "Alla användare inklusive gäster", "LabelAllUsersIncludingGuests": "Alla användare inklusive gäster",
"LabelAlreadyInYourLibrary": "Finns redan i samlingen", "LabelAlreadyInYourLibrary": "Finns redan i samlingen",
"LabelApiKeyCreated": "API-nyckel \"{0}\" har adderats.",
"LabelApiKeyCreatedDescription": "Se till att kopiera API-nyckeln omedelbart eftersom du inte kommer att kunna se den igen.",
"LabelApiKeyUserDescription": "Denna API-nyckel kommer att ha samma behörigheter som användaren den agerar på uppdrag av. Detta kommer att visas på samma sätt i loggarna som om användaren gjorde begäran.",
"LabelApiToken": "API-token", "LabelApiToken": "API-token",
"LabelAppend": "Lägg till", "LabelAppend": "Lägg till",
"LabelAudioBitrate": "Bitrate (t.ex. 128k)", "LabelAudioBitrate": "Bitrate (t.ex. 128k)",
@ -345,7 +353,11 @@
"LabelExample": "Exempel", "LabelExample": "Exempel",
"LabelExpandSeries": "Expandera serier", "LabelExpandSeries": "Expandera serier",
"LabelExpandSubSeries": "Expandera Underserier", "LabelExpandSubSeries": "Expandera Underserier",
"LabelExplicit": "Explicit version", "LabelExpired": "Upphört",
"LabelExpiresAt": "Gäller till och med",
"LabelExpiresInSeconds": "Upphör om (sekunder)",
"LabelExpiresNever": "Aldrig",
"LabelExplicit": "Bestämd",
"LabelExplicitChecked": "Explicit version (markerad)", "LabelExplicitChecked": "Explicit version (markerad)",
"LabelExplicitUnchecked": "Ej Explicit version (ej markerad)", "LabelExplicitUnchecked": "Ej Explicit version (ej markerad)",
"LabelExportOPML": "Exportera OPML-information", "LabelExportOPML": "Exportera OPML-information",
@ -454,6 +466,7 @@
"LabelNewestEpisodes": "Senaste avsnitten", "LabelNewestEpisodes": "Senaste avsnitten",
"LabelNextBackupDate": "Nästa tillfälle för säkerhetskopiering", "LabelNextBackupDate": "Nästa tillfälle för säkerhetskopiering",
"LabelNextScheduledRun": "Nästa schemalagda körning", "LabelNextScheduledRun": "Nästa schemalagda körning",
"LabelNoApiKeys": "Ingen API-nyckel",
"LabelNoCustomMetadataProviders": "Ingen egen källa för metadata", "LabelNoCustomMetadataProviders": "Ingen egen källa för metadata",
"LabelNoEpisodesSelected": "Inga avsnitt har valts", "LabelNoEpisodesSelected": "Inga avsnitt har valts",
"LabelNotFinished": "Ej avslutad", "LabelNotFinished": "Ej avslutad",
@ -470,11 +483,15 @@
"LabelNotificationsMaxQueueSizeHelp": "Evenemang är begränsade till att utlösa ett per sekund. Evenemang kommer att ignoreras om kön är full. Detta förhindrar aviseringsspam.", "LabelNotificationsMaxQueueSizeHelp": "Evenemang är begränsade till att utlösa ett per sekund. Evenemang kommer att ignoreras om kön är full. Detta förhindrar aviseringsspam.",
"LabelNumberOfBooks": "Antal böcker", "LabelNumberOfBooks": "Antal böcker",
"LabelNumberOfEpisodes": "# av Avsnitt", "LabelNumberOfEpisodes": "# av Avsnitt",
"LabelOpenIDAdvancedPermsClaimDescription": "Namn på OpenID-anspråket som innehåller avancerade behörigheter för användaråtgärder i applikationen, vilka gäller för icke-administratörsroller (<b>om konfigurerat</b>). Om anspråket saknas i svaret kommer åtkomst till ABS att nekas. Om ett enskilt alternativ saknas kommer det att behandlas som <code>falskt</code>. Se till att identitetsleverantörens anspråk matchar den förväntade strukturen:",
"LabelOpenIDClaims": "Lämna följande alternativ tomma för att inaktivera avancerad grupp- och behörighetstilldelning, och tilldela då automatiskt gruppen 'Användare'.",
"LabelOpenIDGroupClaimDescription": "Namn på OpenID-anspråket som innehåller en lista över användarens grupper. Vanligtvis kallat <code>groups</code>. <b>Om det är konfigurerat</b> kommer programmet automatiskt att tilldela roller baserat på användarens gruppmedlemskap, förutsatt att dessa grupper namnges utan att skiftlägeskänsligt tolkas som 'admin', 'user' eller 'guest' i anspråket. Anspråket ska innehålla en lista, och om en användare tillhör flera grupper kommer programmet att tilldela den roll som motsvarar den högsta åtkomstnivån. Om ingen grupp matchar kommer åtkomst att nekas.",
"LabelOpenRSSFeed": "Öppna RSS-flöde", "LabelOpenRSSFeed": "Öppna RSS-flöde",
"LabelOverwrite": "Skriv över", "LabelOverwrite": "Skriv över",
"LabelPaginationPageXOfY": "Sida {0} av {1}", "LabelPaginationPageXOfY": "Sida {0} av {1}",
"LabelPassword": "Lösenord", "LabelPassword": "Lösenord",
"LabelPath": "Sökväg", "LabelPath": "Sökväg",
"LabelPermanent": "Permanent",
"LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek", "LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek",
"LabelPermissionsAccessAllTags": "Kan komma åt alla taggar", "LabelPermissionsAccessAllTags": "Kan komma åt alla taggar",
"LabelPermissionsAccessExplicitContent": "Kan komma åt explicit version", "LabelPermissionsAccessExplicitContent": "Kan komma åt explicit version",
@ -486,6 +503,7 @@
"LabelPersonalYearReview": "En sammanställning av ditt år, sidan {0}", "LabelPersonalYearReview": "En sammanställning av ditt år, sidan {0}",
"LabelPhotoPathURL": "Bildsökväg/URL", "LabelPhotoPathURL": "Bildsökväg/URL",
"LabelPlayMethod": "Spelläge", "LabelPlayMethod": "Spelläge",
"LabelPlaybackRateIncrementDecrement": "Uppspelningshastighetsökning/minskning",
"LabelPlayerChapterNumberMarker": "{0} av {1}", "LabelPlayerChapterNumberMarker": "{0} av {1}",
"LabelPlaylists": "Spellistor", "LabelPlaylists": "Spellistor",
"LabelPodcast": "Podcast", "LabelPodcast": "Podcast",
@ -524,6 +542,7 @@
"LabelReleaseDate": "Utgivningsdatum", "LabelReleaseDate": "Utgivningsdatum",
"LabelRemoveAllMetadataAbs": "Radera alla 'metadata.abs' filer", "LabelRemoveAllMetadataAbs": "Radera alla 'metadata.abs' filer",
"LabelRemoveAllMetadataJson": "Radera alla 'metadata.json' filer", "LabelRemoveAllMetadataJson": "Radera alla 'metadata.json' filer",
"LabelRemoveAudibleBranding": "Ta bort Audible intro och outro från kapitel",
"LabelRemoveCover": "Ta bort omslag", "LabelRemoveCover": "Ta bort omslag",
"LabelRemoveMetadataFile": "Radera metadata-filer i alla mappar i biblioteket", "LabelRemoveMetadataFile": "Radera metadata-filer i alla mappar i biblioteket",
"LabelRemoveMetadataFileHelp": "Radera alla 'metadata.json' och 'metadata.abs' filer i dina {0} mappar.", "LabelRemoveMetadataFileHelp": "Radera alla 'metadata.json' och 'metadata.abs' filer i dina {0} mappar.",
@ -536,6 +555,7 @@
"LabelSelectAll": "Välj alla", "LabelSelectAll": "Välj alla",
"LabelSelectAllEpisodes": "Välj alla avsnitt", "LabelSelectAllEpisodes": "Välj alla avsnitt",
"LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas", "LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas",
"LabelSelectUser": "Välj användare",
"LabelSelectUsers": "Välj användare", "LabelSelectUsers": "Välj användare",
"LabelSendEbookToDevice": "Skicka e-bok till...", "LabelSendEbookToDevice": "Skicka e-bok till...",
"LabelSequence": "Ordningsnummer", "LabelSequence": "Ordningsnummer",
@ -694,6 +714,7 @@
"LabelYourProgress": "Framsteg", "LabelYourProgress": "Framsteg",
"MessageAddToPlayerQueue": "Lägg till i spellistan", "MessageAddToPlayerQueue": "Lägg till i spellistan",
"MessageAppriseDescription": "För att använda den här funktionen behöver du ha en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> igång eller en API som hanterar dessa begäranden. <br />Apprise API-urlen bör vara hela URL-sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på <code>http://192.168.1.1:8337</code>, bör du ange <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "För att använda den här funktionen behöver du ha en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> igång eller en API som hanterar dessa begäranden. <br />Apprise API-urlen bör vara hela URL-sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på <code>http://192.168.1.1:8337</code>, bör du ange <code>http://192.168.1.1:8337/notify</code>.",
"MessageAuthenticationSecurityMessage": "Identifieringen av användare har förbättrats av säkerhetsskäl. Alla användare måste därför logga in på nytt.",
"MessageBackupsDescription": "Säkerhetskopior inkluderar användare, användarnas framsteg, biblioteksobjekt,<br>serverinställningar och bilder lagrade i <code>/metadata/items</code> & <code>/metadata/authors</code>.<br>De inkluderar <strong>INTE</strong> några filer lagrade i dina biblioteksmappar.", "MessageBackupsDescription": "Säkerhetskopior inkluderar användare, användarnas framsteg, biblioteksobjekt,<br>serverinställningar och bilder lagrade i <code>/metadata/items</code> & <code>/metadata/authors</code>.<br>De inkluderar <strong>INTE</strong> några filer lagrade i dina biblioteksmappar.",
"MessageBackupsLocationEditNote": "OBS: När du ändrar plats för säkerhetskopiorna så flyttas INTE gamla säkerhetskopior dit", "MessageBackupsLocationEditNote": "OBS: När du ändrar plats för säkerhetskopiorna så flyttas INTE gamla säkerhetskopior dit",
"MessageBackupsLocationNoEditNote": "OBS: Platsen där säkerhetskopiorna lagras bestäms av en central inställning och kan inte ändras här.", "MessageBackupsLocationNoEditNote": "OBS: Platsen där säkerhetskopiorna lagras bestäms av en central inställning och kan inte ändras här.",
@ -714,6 +735,7 @@
"MessageChapterStartIsAfter": "Kapitlets start är efter din ljudboks slut", "MessageChapterStartIsAfter": "Kapitlets start är efter din ljudboks slut",
"MessageCheckingCron": "Kontrollerar cron...", "MessageCheckingCron": "Kontrollerar cron...",
"MessageConfirmCloseFeed": "Är du säker på att du vill stänga detta flöde?", "MessageConfirmCloseFeed": "Är du säker på att du vill stänga detta flöde?",
"MessageConfirmDeleteApiKey": "Är du säker på att du vill radera API-nyckel \"{0}\"?",
"MessageConfirmDeleteBackup": "Är du säker på att du vill radera säkerhetskopian för {0}?", "MessageConfirmDeleteBackup": "Är du säker på att du vill radera säkerhetskopian för {0}?",
"MessageConfirmDeleteDevice": "Är du säkert på att du vill radera enheten för e-böcker \"{0}\"?", "MessageConfirmDeleteDevice": "Är du säkert på att du vill radera enheten för e-böcker \"{0}\"?",
"MessageConfirmDeleteFile": "Detta kommer att radera filen från ditt filsystem. Är du säker?", "MessageConfirmDeleteFile": "Detta kommer att radera filen från ditt filsystem. Är du säker?",
@ -764,6 +786,7 @@
"MessageForceReScanDescription": "kommer att göra en omgångssökning av alla filer som en färsk sökning. ID3-taggar för ljudfiler, OPF-filer och textfiler kommer att sökas som nya.", "MessageForceReScanDescription": "kommer att göra en omgångssökning av alla filer som en färsk sökning. ID3-taggar för ljudfiler, OPF-filer och textfiler kommer att sökas som nya.",
"MessageImportantNotice": "Viktig meddelande!", "MessageImportantNotice": "Viktig meddelande!",
"MessageInsertChapterBelow": "Infoga kapitel nedanför", "MessageInsertChapterBelow": "Infoga kapitel nedanför",
"MessageInvalidAsin": "Felaktig ASIN-kod",
"MessageItemsSelected": "{0} objekt markerade", "MessageItemsSelected": "{0} objekt markerade",
"MessageItemsUpdated": "{0} Objekt uppdaterade", "MessageItemsUpdated": "{0} Objekt uppdaterade",
"MessageJoinUsOn": "Anslut dig till oss på", "MessageJoinUsOn": "Anslut dig till oss på",

View file

@ -1,5 +1,6 @@
{ {
"ButtonAdd": "Ekle", "ButtonAdd": "Ekle",
"ButtonAddApiKey": "API Anahtarı Ekle",
"ButtonAddChapters": "Bölüm Ekle", "ButtonAddChapters": "Bölüm Ekle",
"ButtonAddDevice": "Cihaz Ekle", "ButtonAddDevice": "Cihaz Ekle",
"ButtonAddLibrary": "Kütüphane Ekle", "ButtonAddLibrary": "Kütüphane Ekle",
@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Klasör seç", "ButtonChooseAFolder": "Klasör seç",
"ButtonChooseFiles": "Dosya seç", "ButtonChooseFiles": "Dosya seç",
"ButtonClearFilter": "Filtreyi Temizle", "ButtonClearFilter": "Filtreyi Temizle",
"ButtonClose": "Kapat",
"ButtonCloseFeed": "Akışı Kapat", "ButtonCloseFeed": "Akışı Kapat",
"ButtonCloseSession": "Acık Oturumu Kapat", "ButtonCloseSession": "Acık Oturumu Kapat",
"ButtonCollections": "Koleksiyonlar", "ButtonCollections": "Koleksiyonlar",
@ -95,7 +97,17 @@
"ButtonSearch": "Ara", "ButtonSearch": "Ara",
"ButtonSelectFolderPath": "Klasör Yolunu Seç", "ButtonSelectFolderPath": "Klasör Yolunu Seç",
"ButtonSeries": "Seriler", "ButtonSeries": "Seriler",
"ButtonShare": "Paylaş",
"ButtonStats": "İstatistikler",
"ButtonSubmit": "Gönder", "ButtonSubmit": "Gönder",
"ButtonTest": "Dene",
"ButtonUnlinkOpenId": "OpenID ilişiğini kaldır",
"ButtonUpload": "Yükle",
"ButtonUploadBackup": "Yedeği Yükle",
"ButtonUploadCover": "Kapağı Yükle",
"ButtonUploadOPMLFile": "OPML Dosyası Yükle",
"ButtonUserDelete": "{0} kullanıcısını sil.",
"ButtonUserEdit": "{0} kullanıcısını düzenle",
"ButtonViewAll": "Tümünü Görüntüle", "ButtonViewAll": "Tümünü Görüntüle",
"ButtonYes": "Evet", "ButtonYes": "Evet",
"ErrorUploadFetchMetadataAPI": "Üst veriyi almakta hata", "ErrorUploadFetchMetadataAPI": "Üst veriyi almakta hata",
@ -104,6 +116,7 @@
"HeaderAccount": "Hesap", "HeaderAccount": "Hesap",
"HeaderAddCustomMetadataProvider": "Özel Üstveri Sağlayıcısı Ekle", "HeaderAddCustomMetadataProvider": "Özel Üstveri Sağlayıcısı Ekle",
"HeaderAdvanced": "Gelişmiş", "HeaderAdvanced": "Gelişmiş",
"HeaderApiKeys": "API Anahtarları",
"HeaderAppriseNotificationSettings": "Bildirim Ayarlarının Haberini Ver", "HeaderAppriseNotificationSettings": "Bildirim Ayarlarının Haberini Ver",
"HeaderAudioTracks": "Ses Kanalları", "HeaderAudioTracks": "Ses Kanalları",
"HeaderAudiobookTools": "Sesli Kitap Dosya Yönetim Araçları", "HeaderAudiobookTools": "Sesli Kitap Dosya Yönetim Araçları",
@ -111,13 +124,23 @@
"HeaderBackups": "Yedeklemeler", "HeaderBackups": "Yedeklemeler",
"HeaderChangePassword": "Parolayı Değiştir", "HeaderChangePassword": "Parolayı Değiştir",
"HeaderChapters": "Bölümler", "HeaderChapters": "Bölümler",
"HeaderChooseAFolder": "Klasör Seç",
"HeaderCollection": "Koleksiyon", "HeaderCollection": "Koleksiyon",
"HeaderCollectionItems": "Koleksiyon Öğeleri", "HeaderCollectionItems": "Koleksiyon Öğeleri",
"HeaderCover": "Kapak",
"HeaderCurrentDownloads": "Geçerli İndirmeler",
"HeaderCustomMessageOnLogin": "Girişteki Kişiselleştirilmiş Mesaj",
"HeaderCustomMetadataProviders": "Kişiselleştirilmiş Metadata Sağlayıcıları",
"HeaderDetails": "Detaylar", "HeaderDetails": "Detaylar",
"HeaderDownloadQueue": "Kuyruktakileri İndir",
"HeaderEbookFiles": "Ebook Dosyaları", "HeaderEbookFiles": "Ebook Dosyaları",
"HeaderEmail": "Email",
"HeaderEmailSettings": "Email Ayarları",
"HeaderEpisodes": "Bölümler", "HeaderEpisodes": "Bölümler",
"HeaderEreaderDevices": "Ekitap Cihazları",
"HeaderEreaderSettings": "Ereader Ayarları", "HeaderEreaderSettings": "Ereader Ayarları",
"HeaderFiles": "Dosyalar", "HeaderFiles": "Dosyalar",
"HeaderFindChapters": "Bölümleri Bul",
"HeaderIgnoredFiles": "Görmezden Gelinen Dosyalar", "HeaderIgnoredFiles": "Görmezden Gelinen Dosyalar",
"HeaderItemFiles": "Öğe Dosyaları", "HeaderItemFiles": "Öğe Dosyaları",
"HeaderItemMetadataUtils": "Öğe Üstveri Araçları", "HeaderItemMetadataUtils": "Öğe Üstveri Araçları",

View file

@ -1,5 +1,6 @@
{ {
"ButtonAdd": "Додати", "ButtonAdd": "Додати",
"ButtonAddApiKey": "Додати ключ API",
"ButtonAddChapters": "Додати глави", "ButtonAddChapters": "Додати глави",
"ButtonAddDevice": "Додати пристрій", "ButtonAddDevice": "Додати пристрій",
"ButtonAddLibrary": "Додати бібліотеку", "ButtonAddLibrary": "Додати бібліотеку",
@ -20,6 +21,7 @@
"ButtonChooseAFolder": "Обрати теку", "ButtonChooseAFolder": "Обрати теку",
"ButtonChooseFiles": "Обрати файли", "ButtonChooseFiles": "Обрати файли",
"ButtonClearFilter": "Очистити фільтр", "ButtonClearFilter": "Очистити фільтр",
"ButtonClose": "Закрити",
"ButtonCloseFeed": "Закрити стрічку", "ButtonCloseFeed": "Закрити стрічку",
"ButtonCloseSession": "Закрити відкритий сеанс", "ButtonCloseSession": "Закрити відкритий сеанс",
"ButtonCollections": "Добірки", "ButtonCollections": "Добірки",
@ -44,7 +46,7 @@
"ButtonLatest": "Останні", "ButtonLatest": "Останні",
"ButtonLibrary": "Бібліотека", "ButtonLibrary": "Бібліотека",
"ButtonLogout": "Вийти", "ButtonLogout": "Вийти",
"ButtonLookup": "Пошук", "ButtonLookup": "Пошуки",
"ButtonManageTracks": "Керувати доріжками", "ButtonManageTracks": "Керувати доріжками",
"ButtonMapChapterTitles": "Призначити назви глав", "ButtonMapChapterTitles": "Призначити назви глав",
"ButtonMatchAllAuthors": "Віднайти усіх авторів", "ButtonMatchAllAuthors": "Віднайти усіх авторів",
@ -119,6 +121,7 @@
"HeaderAccount": "Профіль", "HeaderAccount": "Профіль",
"HeaderAddCustomMetadataProvider": "Додати користувацький постачальник метаданих", "HeaderAddCustomMetadataProvider": "Додати користувацький постачальник метаданих",
"HeaderAdvanced": "Розширені", "HeaderAdvanced": "Розширені",
"HeaderApiKeys": "Ключі API",
"HeaderAppriseNotificationSettings": "Налаштування сповіщень Apprise", "HeaderAppriseNotificationSettings": "Налаштування сповіщень Apprise",
"HeaderAudioTracks": "Аудіодоріжки", "HeaderAudioTracks": "Аудіодоріжки",
"HeaderAudiobookTools": "Інструменти керування файлами книг", "HeaderAudiobookTools": "Інструменти керування файлами книг",
@ -162,6 +165,7 @@
"HeaderMetadataOrderOfPrecedence": "Порядок метаданих", "HeaderMetadataOrderOfPrecedence": "Порядок метаданих",
"HeaderMetadataToEmbed": "Вбудувати метадані", "HeaderMetadataToEmbed": "Вбудувати метадані",
"HeaderNewAccount": "Новий профіль", "HeaderNewAccount": "Новий профіль",
"HeaderNewApiKey": "Новий ключ API",
"HeaderNewLibrary": "Нова бібліотека", "HeaderNewLibrary": "Нова бібліотека",
"HeaderNotificationCreate": "Створити сповіщення", "HeaderNotificationCreate": "Створити сповіщення",
"HeaderNotificationUpdate": "Оновити сповіщення", "HeaderNotificationUpdate": "Оновити сповіщення",
@ -206,6 +210,7 @@
"HeaderTableOfContents": "Зміст", "HeaderTableOfContents": "Зміст",
"HeaderTools": "Інструменти", "HeaderTools": "Інструменти",
"HeaderUpdateAccount": "Оновити профіль", "HeaderUpdateAccount": "Оновити профіль",
"HeaderUpdateApiKey": "Оновити ключ API",
"HeaderUpdateAuthor": "Оновити автора", "HeaderUpdateAuthor": "Оновити автора",
"HeaderUpdateDetails": "Оновити подробиці", "HeaderUpdateDetails": "Оновити подробиці",
"HeaderUpdateLibrary": "Оновити бібліотеку", "HeaderUpdateLibrary": "Оновити бібліотеку",
@ -235,6 +240,10 @@
"LabelAllUsersExcludingGuests": "Усі, крім гостей", "LabelAllUsersExcludingGuests": "Усі, крім гостей",
"LabelAllUsersIncludingGuests": "Усі, включно з гостями", "LabelAllUsersIncludingGuests": "Усі, включно з гостями",
"LabelAlreadyInYourLibrary": "Вже у вашій бібліотеці", "LabelAlreadyInYourLibrary": "Вже у вашій бібліотеці",
"LabelApiKeyCreated": "Ключ API \"{0}\" успішно створено.",
"LabelApiKeyCreatedDescription": "Обов’язково скопіюйте ключ API зараз, оскільки ви більше не зможете його побачити.",
"LabelApiKeyUser": "Діяти від імені користувача",
"LabelApiKeyUserDescription": "Цей ключ API матиме ті самі дозволи, що й користувач, від імені якого він діє. Це відображатиметься в журналах так само, як і в разі надсилання запиту користувачем.",
"LabelApiToken": "Токен API", "LabelApiToken": "Токен API",
"LabelAppend": "Додати", "LabelAppend": "Додати",
"LabelAudioBitrate": "Бітрейт аудіо (наприклад, 128k)", "LabelAudioBitrate": "Бітрейт аудіо (наприклад, 128k)",
@ -346,7 +355,11 @@
"LabelExample": "Приклад", "LabelExample": "Приклад",
"LabelExpandSeries": "Розгорнути серії", "LabelExpandSeries": "Розгорнути серії",
"LabelExpandSubSeries": "Розгорнути підсерії", "LabelExpandSubSeries": "Розгорнути підсерії",
"LabelExplicit": "Відверта", "LabelExpired": "Термін дії минув",
"LabelExpiresAt": "Термін дії закінчується о",
"LabelExpiresInSeconds": "Термін дії закінчується через (секунди)",
"LabelExpiresNever": "Ніколи",
"LabelExplicit": "Відвертий",
"LabelExplicitChecked": "Відверта (з прапорцем)", "LabelExplicitChecked": "Відверта (з прапорцем)",
"LabelExplicitUnchecked": "Не відверта (без прапорця)", "LabelExplicitUnchecked": "Не відверта (без прапорця)",
"LabelExportOPML": "Експорт OPML", "LabelExportOPML": "Експорт OPML",
@ -425,6 +438,7 @@
"LabelLogLevelWarn": "Увага", "LabelLogLevelWarn": "Увага",
"LabelLookForNewEpisodesAfterDate": "Шукати нові епізоди після вказаної дати", "LabelLookForNewEpisodesAfterDate": "Шукати нові епізоди після вказаної дати",
"LabelLowestPriority": "Найнижчий пріоритет", "LabelLowestPriority": "Найнижчий пріоритет",
"LabelMatchConfidence": "Впевненість",
"LabelMatchExistingUsersBy": "Шукати наявних користувачів за", "LabelMatchExistingUsersBy": "Шукати наявних користувачів за",
"LabelMatchExistingUsersByDescription": "Використовується для підключення наявних користувачів. Після підключення користувач отримає унікальний id від вашого сервісу SSO", "LabelMatchExistingUsersByDescription": "Використовується для підключення наявних користувачів. Після підключення користувач отримає унікальний id від вашого сервісу SSO",
"LabelMaxEpisodesToDownload": "Максимальна кількість епізодів для скачування. Використовуйте 0 для необмеженої кількості.", "LabelMaxEpisodesToDownload": "Максимальна кількість епізодів для скачування. Використовуйте 0 для необмеженої кількості.",
@ -455,6 +469,7 @@
"LabelNewestEpisodes": "Нові епізоди", "LabelNewestEpisodes": "Нові епізоди",
"LabelNextBackupDate": "Дата наступного резервного копіювання", "LabelNextBackupDate": "Дата наступного резервного копіювання",
"LabelNextScheduledRun": "Наступний запланований запуск", "LabelNextScheduledRun": "Наступний запланований запуск",
"LabelNoApiKeys": "Без ключів API",
"LabelNoCustomMetadataProviders": "Без постачальників метаданих", "LabelNoCustomMetadataProviders": "Без постачальників метаданих",
"LabelNoEpisodesSelected": "Не вибрано жодного епізоду", "LabelNoEpisodesSelected": "Не вибрано жодного епізоду",
"LabelNotFinished": "Незавершені", "LabelNotFinished": "Незавершені",
@ -544,6 +559,7 @@
"LabelSelectAll": "Вибрати все", "LabelSelectAll": "Вибрати все",
"LabelSelectAllEpisodes": "Вибрати всі епізоди", "LabelSelectAllEpisodes": "Вибрати всі епізоди",
"LabelSelectEpisodesShowing": "Вибрати {0} показаних епізодів", "LabelSelectEpisodesShowing": "Вибрати {0} показаних епізодів",
"LabelSelectUser": "Виберіть користувача",
"LabelSelectUsers": "Вибрати користувачів", "LabelSelectUsers": "Вибрати користувачів",
"LabelSendEbookToDevice": "Надіслати електронну книгу на...", "LabelSendEbookToDevice": "Надіслати електронну книгу на...",
"LabelSequence": "Послідовність", "LabelSequence": "Послідовність",
@ -708,7 +724,9 @@
"MessageAddToPlayerQueue": "Додати до черги відтворення", "MessageAddToPlayerQueue": "Додати до черги відтворення",
"MessageAppriseDescription": "Щоб скористатися цією функцією, вам потрібно мати запущену <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> або API, що оброблятиме ті ж запити. <br />Аби надсилати сповіщення, URL-адреса API Apprise мусить бути повною, наприклад, якщо ваш API розміщено за адресою <code>http://192.168.1.1:8337</code>, то необхідно вказати адресу <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "Щоб скористатися цією функцією, вам потрібно мати запущену <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> або API, що оброблятиме ті ж запити. <br />Аби надсилати сповіщення, URL-адреса API Apprise мусить бути повною, наприклад, якщо ваш API розміщено за адресою <code>http://192.168.1.1:8337</code>, то необхідно вказати адресу <code>http://192.168.1.1:8337/notify</code>.",
"MessageAsinCheck": "Переконайтесь, що ви використовуєте ASIN з правильної регіональної Audible зони, а не з Amazon.", "MessageAsinCheck": "Переконайтесь, що ви використовуєте ASIN з правильної регіональної Audible зони, а не з Amazon.",
"MessageAuthenticationLegacyTokenWarning": "Застарілі токени API будуть видалені в майбутньому. Натомість використовуйте <a href=\"/config/api-keys\">Ключі API</a>.",
"MessageAuthenticationOIDCChangesRestart": "Перезавантажте сервер після збереження, щоб застосувати зміни OIDC.", "MessageAuthenticationOIDCChangesRestart": "Перезавантажте сервер після збереження, щоб застосувати зміни OIDC.",
"MessageAuthenticationSecurityMessage": "Автентифікацію покращено для безпеки. Усім користувачам потрібно повторно увійти в систему.",
"MessageBackupsDescription": "Резервні копії містять користувачів, прогрес, подробиці елементів бібліотеки, налаштування сервера та зображення з <code>/metadata/items</code> та <code>/metadata/authors</code>. Резервні копії <strong>не</strong> містять жодних файлів з тек бібліотеки.", "MessageBackupsDescription": "Резервні копії містять користувачів, прогрес, подробиці елементів бібліотеки, налаштування сервера та зображення з <code>/metadata/items</code> та <code>/metadata/authors</code>. Резервні копії <strong>не</strong> містять жодних файлів з тек бібліотеки.",
"MessageBackupsLocationEditNote": "Примітка: оновлення розташування резервної копії не переносить та не змінює існуючих копій", "MessageBackupsLocationEditNote": "Примітка: оновлення розташування резервної копії не переносить та не змінює існуючих копій",
"MessageBackupsLocationNoEditNote": "Примітка: розташування резервної копії встановлюється за допомогою змінної середовища та не може бути змінене тут.", "MessageBackupsLocationNoEditNote": "Примітка: розташування резервної копії встановлюється за допомогою змінної середовища та не може бути змінене тут.",
@ -730,6 +748,7 @@
"MessageChaptersNotFound": "Розділи не знайдені", "MessageChaptersNotFound": "Розділи не знайдені",
"MessageCheckingCron": "Перевірка планувальника...", "MessageCheckingCron": "Перевірка планувальника...",
"MessageConfirmCloseFeed": "Ви дійсно бажаєте закрити цей канал?", "MessageConfirmCloseFeed": "Ви дійсно бажаєте закрити цей канал?",
"MessageConfirmDeleteApiKey": "Ви впевнені, що хочете видалити ключ API? \"{0}\"?",
"MessageConfirmDeleteBackup": "Ви дійсно бажаєте видалити резервну копію за {0}?", "MessageConfirmDeleteBackup": "Ви дійсно бажаєте видалити резервну копію за {0}?",
"MessageConfirmDeleteDevice": "Ви впевнені, що хочете видалити пристрій для читання \"{0}\"?", "MessageConfirmDeleteDevice": "Ви впевнені, що хочете видалити пристрій для читання \"{0}\"?",
"MessageConfirmDeleteFile": "Файл буде видалено з вашої файлової системи. Ви впевнені?", "MessageConfirmDeleteFile": "Файл буде видалено з вашої файлової системи. Ви впевнені?",
@ -757,6 +776,7 @@
"MessageConfirmRemoveAuthor": "Ви дійсно бажаєте видалити автора \"{0}\"?", "MessageConfirmRemoveAuthor": "Ви дійсно бажаєте видалити автора \"{0}\"?",
"MessageConfirmRemoveCollection": "Ви дійсно бажаєте видалити добірку \"{0}\"?", "MessageConfirmRemoveCollection": "Ви дійсно бажаєте видалити добірку \"{0}\"?",
"MessageConfirmRemoveEpisode": "Ви дійсно бажаєте видалити епізод \"{0}\"?", "MessageConfirmRemoveEpisode": "Ви дійсно бажаєте видалити епізод \"{0}\"?",
"MessageConfirmRemoveEpisodeNote": "Примітка: Це не видаляє аудіофайл, якщо не перемикає \"файл жорсткого видалення\"",
"MessageConfirmRemoveEpisodes": "Ви дійсно бажаєте видалити епізодів: {0}?", "MessageConfirmRemoveEpisodes": "Ви дійсно бажаєте видалити епізодів: {0}?",
"MessageConfirmRemoveListeningSessions": "Ви дійсно бажаєте видалити сеанси прослуховування: {0}?", "MessageConfirmRemoveListeningSessions": "Ви дійсно бажаєте видалити сеанси прослуховування: {0}?",
"MessageConfirmRemoveMetadataFiles": "Ви впевнені, що хочете видалити всі файли metadata.{0} у папках елементів вашої бібліотеки?", "MessageConfirmRemoveMetadataFiles": "Ви впевнені, що хочете видалити всі файли metadata.{0} у папках елементів вашої бібліотеки?",
@ -818,7 +838,7 @@
"MessageNoItems": "Елементи відсутні", "MessageNoItems": "Елементи відсутні",
"MessageNoItemsFound": "Елементів не знайдено", "MessageNoItemsFound": "Елементів не знайдено",
"MessageNoListeningSessions": "Сеанси прослуховування відсутні", "MessageNoListeningSessions": "Сеанси прослуховування відсутні",
"MessageNoLogs": "Немає журналів", "MessageNoLogs": "Немає журналів'",
"MessageNoMediaProgress": "Прогрес відсутній", "MessageNoMediaProgress": "Прогрес відсутній",
"MessageNoNotifications": "Сповіщення відсутні", "MessageNoNotifications": "Сповіщення відсутні",
"MessageNoPodcastFeed": "Некоректний подкаст: немає каналу", "MessageNoPodcastFeed": "Некоректний подкаст: немає каналу",
@ -1000,6 +1020,8 @@
"ToastEpisodeDownloadQueueClearSuccess": "Чергу на скачування епізодів очищено", "ToastEpisodeDownloadQueueClearSuccess": "Чергу на скачування епізодів очищено",
"ToastEpisodeUpdateSuccess": "{0} епізодів оновлено", "ToastEpisodeUpdateSuccess": "{0} епізодів оновлено",
"ToastErrorCannotShare": "Не можна типово поширити на цей пристрій", "ToastErrorCannotShare": "Не можна типово поширити на цей пристрій",
"ToastFailedToCreate": "Не вдалося створити",
"ToastFailedToDelete": "Не вдалося видалити",
"ToastFailedToLoadData": "Не вдалося завантажити дані", "ToastFailedToLoadData": "Не вдалося завантажити дані",
"ToastFailedToMatch": "Не вдалося знайти відповідність", "ToastFailedToMatch": "Не вдалося знайти відповідність",
"ToastFailedToShare": "Не вдалося поділитися", "ToastFailedToShare": "Не вдалося поділитися",
@ -1031,6 +1053,7 @@
"ToastMustHaveAtLeastOnePath": "Повинен бути хоча б один шлях", "ToastMustHaveAtLeastOnePath": "Повинен бути хоча б один шлях",
"ToastNameEmailRequired": "Ім'я та електронна пошта обов'язкові", "ToastNameEmailRequired": "Ім'я та електронна пошта обов'язкові",
"ToastNameRequired": "Ім'я обов'язкове", "ToastNameRequired": "Ім'я обов'язкове",
"ToastNewApiKeyUserError": "Потрібно вибрати користувача",
"ToastNewEpisodesFound": "{0} нових епізодів знайдено", "ToastNewEpisodesFound": "{0} нових епізодів знайдено",
"ToastNewUserCreatedFailed": "Не вдалося створити акаунт: \"{0}\"", "ToastNewUserCreatedFailed": "Не вдалося створити акаунт: \"{0}\"",
"ToastNewUserCreatedSuccess": "Новий акаунт створено", "ToastNewUserCreatedSuccess": "Новий акаунт створено",
@ -1065,7 +1088,7 @@
"ToastProviderRemoveSuccess": "Постачальник видалений", "ToastProviderRemoveSuccess": "Постачальник видалений",
"ToastRSSFeedCloseFailed": "Не вдалося закрити RSS-канал", "ToastRSSFeedCloseFailed": "Не вдалося закрити RSS-канал",
"ToastRSSFeedCloseSuccess": "RSS-канал закрито", "ToastRSSFeedCloseSuccess": "RSS-канал закрито",
"ToastRemoveFailed": "Не вдалося видалити", "ToastRemoveFailed": "Не вдалося вилучити",
"ToastRemoveItemFromCollectionFailed": "Не вдалося видалити елемент із добірки", "ToastRemoveItemFromCollectionFailed": "Не вдалося видалити елемент із добірки",
"ToastRemoveItemFromCollectionSuccess": "Елемент видалено з добірки", "ToastRemoveItemFromCollectionSuccess": "Елемент видалено з добірки",
"ToastRemoveItemsWithIssuesFailed": "Не вдалося видалити елементи бібліотеки з проблемами", "ToastRemoveItemsWithIssuesFailed": "Не вдалося видалити елементи бібліотеки з проблемами",

View file

@ -1,5 +1,6 @@
{ {
"ButtonAdd": "添加", "ButtonAdd": "添加",
"ButtonAddApiKey": "添加 API 密钥",
"ButtonAddChapters": "添加章节", "ButtonAddChapters": "添加章节",
"ButtonAddDevice": "添加设备", "ButtonAddDevice": "添加设备",
"ButtonAddLibrary": "添加库", "ButtonAddLibrary": "添加库",
@ -11,7 +12,7 @@
"ButtonAuthors": "作者", "ButtonAuthors": "作者",
"ButtonBack": "返回", "ButtonBack": "返回",
"ButtonBatchEditPopulateFromExisting": "用现有内容填充", "ButtonBatchEditPopulateFromExisting": "用现有内容填充",
"ButtonBatchEditPopulateMapDetails": "填充地图详细信息", "ButtonBatchEditPopulateMapDetails": "填入此项详情",
"ButtonBrowseForFolder": "浏览文件夹", "ButtonBrowseForFolder": "浏览文件夹",
"ButtonCancel": "取消", "ButtonCancel": "取消",
"ButtonCancelEncode": "取消编码", "ButtonCancelEncode": "取消编码",
@ -20,6 +21,7 @@
"ButtonChooseAFolder": "选择文件夹", "ButtonChooseAFolder": "选择文件夹",
"ButtonChooseFiles": "选择文件", "ButtonChooseFiles": "选择文件",
"ButtonClearFilter": "清除过滤器", "ButtonClearFilter": "清除过滤器",
"ButtonClose": "关闭",
"ButtonCloseFeed": "关闭源", "ButtonCloseFeed": "关闭源",
"ButtonCloseSession": "关闭活动会话", "ButtonCloseSession": "关闭活动会话",
"ButtonCollections": "收藏", "ButtonCollections": "收藏",
@ -73,7 +75,7 @@
"ButtonQuickMatch": "快速匹配", "ButtonQuickMatch": "快速匹配",
"ButtonReScan": "重新扫描", "ButtonReScan": "重新扫描",
"ButtonRead": "读取", "ButtonRead": "读取",
"ButtonReadLess": "阅读较少", "ButtonReadLess": "收起",
"ButtonReadMore": "阅读更多", "ButtonReadMore": "阅读更多",
"ButtonRefresh": "刷新", "ButtonRefresh": "刷新",
"ButtonRemove": "移除", "ButtonRemove": "移除",
@ -119,6 +121,7 @@
"HeaderAccount": "帐户", "HeaderAccount": "帐户",
"HeaderAddCustomMetadataProvider": "添加自定义元数据提供商", "HeaderAddCustomMetadataProvider": "添加自定义元数据提供商",
"HeaderAdvanced": "高级", "HeaderAdvanced": "高级",
"HeaderApiKeys": "API 密钥",
"HeaderAppriseNotificationSettings": "测试通知设置", "HeaderAppriseNotificationSettings": "测试通知设置",
"HeaderAudioTracks": "音轨", "HeaderAudioTracks": "音轨",
"HeaderAudiobookTools": "有声读物文件管理工具", "HeaderAudiobookTools": "有声读物文件管理工具",
@ -162,12 +165,13 @@
"HeaderMetadataOrderOfPrecedence": "元数据优先级", "HeaderMetadataOrderOfPrecedence": "元数据优先级",
"HeaderMetadataToEmbed": "嵌入元数据", "HeaderMetadataToEmbed": "嵌入元数据",
"HeaderNewAccount": "新建帐户", "HeaderNewAccount": "新建帐户",
"HeaderNewApiKey": "新建 API 密钥",
"HeaderNewLibrary": "新建媒体库", "HeaderNewLibrary": "新建媒体库",
"HeaderNotificationCreate": "创建通知", "HeaderNotificationCreate": "创建通知",
"HeaderNotificationUpdate": "更新通知", "HeaderNotificationUpdate": "更新通知",
"HeaderNotifications": "通知", "HeaderNotifications": "通知",
"HeaderOpenIDConnectAuthentication": "OpenID 连接身份验证", "HeaderOpenIDConnectAuthentication": "OpenID 连接身份验证",
"HeaderOpenListeningSessions": "打开收听会话", "HeaderOpenListeningSessions": "活动中会话",
"HeaderOpenRSSFeed": "打开 RSS 源", "HeaderOpenRSSFeed": "打开 RSS 源",
"HeaderOtherFiles": "其他文件", "HeaderOtherFiles": "其他文件",
"HeaderPasswordAuthentication": "密码认证", "HeaderPasswordAuthentication": "密码认证",
@ -206,13 +210,14 @@
"HeaderTableOfContents": "目录", "HeaderTableOfContents": "目录",
"HeaderTools": "工具", "HeaderTools": "工具",
"HeaderUpdateAccount": "更新帐户", "HeaderUpdateAccount": "更新帐户",
"HeaderUpdateApiKey": "更新 API 密钥",
"HeaderUpdateAuthor": "更新作者", "HeaderUpdateAuthor": "更新作者",
"HeaderUpdateDetails": "更新详情", "HeaderUpdateDetails": "更新详情",
"HeaderUpdateLibrary": "更新媒体库", "HeaderUpdateLibrary": "更新媒体库",
"HeaderUsers": "用户", "HeaderUsers": "用户",
"HeaderYearReview": "{0} 年回顾", "HeaderYearReview": "{0} 年回顾",
"HeaderYourStats": "你的统计数据", "HeaderYourStats": "你的统计数据",
"LabelAbridged": "概要", "LabelAbridged": "删节版",
"LabelAbridgedChecked": "删节版 (已勾选)", "LabelAbridgedChecked": "删节版 (已勾选)",
"LabelAbridgedUnchecked": "未删节版 (未勾选)", "LabelAbridgedUnchecked": "未删节版 (未勾选)",
"LabelAccessibleBy": "可访问", "LabelAccessibleBy": "可访问",
@ -235,6 +240,10 @@
"LabelAllUsersExcludingGuests": "除访客外的所有用户", "LabelAllUsersExcludingGuests": "除访客外的所有用户",
"LabelAllUsersIncludingGuests": "包括访客的所有用户", "LabelAllUsersIncludingGuests": "包括访客的所有用户",
"LabelAlreadyInYourLibrary": "已存在你的库中", "LabelAlreadyInYourLibrary": "已存在你的库中",
"LabelApiKeyCreated": "API 密钥 \"{0}\" 创建成功.",
"LabelApiKeyCreatedDescription": "请确保现在就复制 API 密钥, 之后将无法再次查看.",
"LabelApiKeyUser": "代用户操作",
"LabelApiKeyUserDescription": "此 API 密钥将具有与其代理的用户相同的权限. 在日志中, 其请求将被视为由该用户直接发出.",
"LabelApiToken": "API 令牌", "LabelApiToken": "API 令牌",
"LabelAppend": "附加", "LabelAppend": "附加",
"LabelAudioBitrate": "音频比特率 (例如: 128k)", "LabelAudioBitrate": "音频比特率 (例如: 128k)",
@ -320,7 +329,7 @@
"LabelEmailSettingsRejectUnauthorized": "拒绝未经授权的证书", "LabelEmailSettingsRejectUnauthorized": "拒绝未经授权的证书",
"LabelEmailSettingsRejectUnauthorizedHelp": "禁用SSL证书验证可能会使你的连接面临安全风险, 例如中间人攻击. 只有当你了解其中的含义并信任所连接的邮件服务器时, 才能禁用此选项.", "LabelEmailSettingsRejectUnauthorizedHelp": "禁用SSL证书验证可能会使你的连接面临安全风险, 例如中间人攻击. 只有当你了解其中的含义并信任所连接的邮件服务器时, 才能禁用此选项.",
"LabelEmailSettingsSecure": "安全", "LabelEmailSettingsSecure": "安全",
"LabelEmailSettingsSecureHelp": "如果选是, 则连接将在连接到服务器时使用TLS. 如果选否, 则若服务器支持STARTTLS扩展, 则使用TLS. 在大多数情况下, 如果连接到端口465, 请将该值设置为是. 对于端口587或25, 请保持为否. (来自nodemailer.com/smtp/#authentication)", "LabelEmailSettingsSecureHelp": "开启此选项时, 将始终通过TLS连接服务器. 关闭此选项时, 仅在服务器支持STARTTLS扩展时使用TLS. 在大多数情况下, 如果连接到端口465, 请将此项设为开启. 如果连接到端口587或25, 请将此设置保持为关闭. (来自nodemailer.com/smtp/#authentication)",
"LabelEmailSettingsTestAddress": "测试地址", "LabelEmailSettingsTestAddress": "测试地址",
"LabelEmbeddedCover": "嵌入封面", "LabelEmbeddedCover": "嵌入封面",
"LabelEnable": "启用", "LabelEnable": "启用",
@ -346,15 +355,19 @@
"LabelExample": "示例", "LabelExample": "示例",
"LabelExpandSeries": "展开系列", "LabelExpandSeries": "展开系列",
"LabelExpandSubSeries": "展开子系列", "LabelExpandSubSeries": "展开子系列",
"LabelExplicit": "信息准确", "LabelExpired": "已过期",
"LabelExplicitChecked": "明确(已选中)", "LabelExpiresAt": "过期时间",
"LabelExplicitUnchecked": "不明确 (未选中)", "LabelExpiresInSeconds": "有效期 (秒)",
"LabelExpiresNever": "从不",
"LabelExplicit": "含成人内容",
"LabelExplicitChecked": "成人内容 (已核实)",
"LabelExplicitUnchecked": "无成人内容 (未核实)",
"LabelExportOPML": "导出 OPML", "LabelExportOPML": "导出 OPML",
"LabelFeedURL": "源 URL", "LabelFeedURL": "源 URL",
"LabelFetchingMetadata": "正在获取元数据", "LabelFetchingMetadata": "正在获取元数据",
"LabelFile": "文件", "LabelFile": "文件",
"LabelFileBirthtime": "文件创建时间", "LabelFileBirthtime": "文件创建时间",
"LabelFileBornDate": "于 {0}", "LabelFileBornDate": "添加于 {0}",
"LabelFileModified": "文件修改时间", "LabelFileModified": "文件修改时间",
"LabelFileModifiedDate": "已修改 {0}", "LabelFileModifiedDate": "已修改 {0}",
"LabelFilename": "文件名", "LabelFilename": "文件名",
@ -425,6 +438,7 @@
"LabelLogLevelWarn": "警告", "LabelLogLevelWarn": "警告",
"LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集", "LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集",
"LabelLowestPriority": "最低优先级", "LabelLowestPriority": "最低优先级",
"LabelMatchConfidence": "置信度",
"LabelMatchExistingUsersBy": "匹配现有用户", "LabelMatchExistingUsersBy": "匹配现有用户",
"LabelMatchExistingUsersByDescription": "用于连接现有用户. 连接后, 用户将通过 SSO 提供商提供的唯一 id 进行匹配", "LabelMatchExistingUsersByDescription": "用于连接现有用户. 连接后, 用户将通过 SSO 提供商提供的唯一 id 进行匹配",
"LabelMaxEpisodesToDownload": "可下载的最大集数. 输入 0 表示无限制.", "LabelMaxEpisodesToDownload": "可下载的最大集数. 输入 0 表示无限制.",
@ -455,6 +469,7 @@
"LabelNewestEpisodes": "最新剧集", "LabelNewestEpisodes": "最新剧集",
"LabelNextBackupDate": "下次备份日期", "LabelNextBackupDate": "下次备份日期",
"LabelNextScheduledRun": "下次任务运行", "LabelNextScheduledRun": "下次任务运行",
"LabelNoApiKeys": "无 API 密钥",
"LabelNoCustomMetadataProviders": "没有自定义元数据提供商", "LabelNoCustomMetadataProviders": "没有自定义元数据提供商",
"LabelNoEpisodesSelected": "未选择任何剧集", "LabelNoEpisodesSelected": "未选择任何剧集",
"LabelNotFinished": "未听完", "LabelNotFinished": "未听完",
@ -482,7 +497,7 @@
"LabelPermanent": "永久的", "LabelPermanent": "永久的",
"LabelPermissionsAccessAllLibraries": "可以访问所有媒体库", "LabelPermissionsAccessAllLibraries": "可以访问所有媒体库",
"LabelPermissionsAccessAllTags": "可以访问所有标签", "LabelPermissionsAccessAllTags": "可以访问所有标签",
"LabelPermissionsAccessExplicitContent": "可以访问显式内容", "LabelPermissionsAccessExplicitContent": "可以访问成人内容",
"LabelPermissionsCreateEreader": "可以创建电子阅读器", "LabelPermissionsCreateEreader": "可以创建电子阅读器",
"LabelPermissionsDelete": "可以删除", "LabelPermissionsDelete": "可以删除",
"LabelPermissionsDownload": "可以下载", "LabelPermissionsDownload": "可以下载",
@ -544,6 +559,7 @@
"LabelSelectAll": "全选", "LabelSelectAll": "全选",
"LabelSelectAllEpisodes": "选择所有剧集", "LabelSelectAllEpisodes": "选择所有剧集",
"LabelSelectEpisodesShowing": "选择正在播放的 {0} 剧集", "LabelSelectEpisodesShowing": "选择正在播放的 {0} 剧集",
"LabelSelectUser": "选择用户",
"LabelSelectUsers": "选择用户", "LabelSelectUsers": "选择用户",
"LabelSendEbookToDevice": "发送电子书到...", "LabelSendEbookToDevice": "发送电子书到...",
"LabelSequence": "序列", "LabelSequence": "序列",
@ -610,15 +626,15 @@
"LabelStart": "开始", "LabelStart": "开始",
"LabelStartTime": "开始时间", "LabelStartTime": "开始时间",
"LabelStarted": "开始于", "LabelStarted": "开始于",
"LabelStartedAt": "从这开始", "LabelStartedAt": "收听始于",
"LabelStatsAudioTracks": "音轨", "LabelStatsAudioTracks": "音轨",
"LabelStatsAuthors": "作者", "LabelStatsAuthors": "作者",
"LabelStatsBestDay": "最好的一天", "LabelStatsBestDay": "单日最高",
"LabelStatsDailyAverage": "每日平均值", "LabelStatsDailyAverage": "每日平均值",
"LabelStatsDays": "", "LabelStatsDays": "连续",
"LabelStatsDaysListened": "收听天数", "LabelStatsDaysListened": "收听天数",
"LabelStatsHours": "小时", "LabelStatsHours": "小时",
"LabelStatsInARow": "在一行", "LabelStatsInARow": "",
"LabelStatsItemsFinished": "已完成的项目", "LabelStatsItemsFinished": "已完成的项目",
"LabelStatsItemsInLibrary": "媒体库中的项目", "LabelStatsItemsInLibrary": "媒体库中的项目",
"LabelStatsMinutes": "分钟", "LabelStatsMinutes": "分钟",
@ -708,13 +724,15 @@
"MessageAddToPlayerQueue": "添加到播放队列", "MessageAddToPlayerQueue": "添加到播放队列",
"MessageAppriseDescription": "要使用此功能,你需要运行一个 <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> 实例或一个可以处理这些相同请求的 API. <br />Apprise API Url 应该是发送通知的完整 URL 路径, 例如: 如果你的 API 实例运行在 <code>http://192.168.1.1:8337</code>, 那么你可以输入 <code>http://192.168.1.1:8337/notify</code>.", "MessageAppriseDescription": "要使用此功能,你需要运行一个 <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> 实例或一个可以处理这些相同请求的 API. <br />Apprise API Url 应该是发送通知的完整 URL 路径, 例如: 如果你的 API 实例运行在 <code>http://192.168.1.1:8337</code>, 那么你可以输入 <code>http://192.168.1.1:8337/notify</code>.",
"MessageAsinCheck": "确保你使用的 ASIN 来自正确的 Audible 地区, 而不是亚马逊.", "MessageAsinCheck": "确保你使用的 ASIN 来自正确的 Audible 地区, 而不是亚马逊.",
"MessageAuthenticationLegacyTokenWarning": "旧版 API 令牌将来会被移除. 请改用 <a href=\"/config/api-keys\">API 密钥</a>.",
"MessageAuthenticationOIDCChangesRestart": "保存后重新启动服务器以应用 OIDC 更改.", "MessageAuthenticationOIDCChangesRestart": "保存后重新启动服务器以应用 OIDC 更改.",
"MessageAuthenticationSecurityMessage": "身份验证安全性已增强, 所有用户都需要重新登录.",
"MessageBackupsDescription": "备份包括用户, 用户进度, 媒体库项目详细信息, 服务器设置和图像, 存储在 <code>/metadata/items</code> & <code>/metadata/authors</code>. 备份不包括存储在你的媒体库文件夹中的任何文件.", "MessageBackupsDescription": "备份包括用户, 用户进度, 媒体库项目详细信息, 服务器设置和图像, 存储在 <code>/metadata/items</code> & <code>/metadata/authors</code>. 备份不包括存储在你的媒体库文件夹中的任何文件.",
"MessageBackupsLocationEditNote": "注意: 更新备份位置不会移动或修改现有备份", "MessageBackupsLocationEditNote": "注意: 更新备份位置不会移动或修改现有备份",
"MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.", "MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.",
"MessageBackupsLocationPathEmpty": "备份位置路径不能为空", "MessageBackupsLocationPathEmpty": "备份位置路径不能为空",
"MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并", "MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并",
"MessageBatchEditPopulateMapDetailsItemHelp": "使用此项目的数据填充已启用的地图详细信息字段", "MessageBatchEditPopulateMapDetailsItemHelp": "提取此项目的信息, 填入上方所有勾选的编辑框中",
"MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.", "MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.",
"MessageBookshelfNoCollections": "你尚未进行任何收藏", "MessageBookshelfNoCollections": "你尚未进行任何收藏",
"MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.", "MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.",
@ -730,6 +748,7 @@
"MessageChaptersNotFound": "未找到章节", "MessageChaptersNotFound": "未找到章节",
"MessageCheckingCron": "检查计划任务...", "MessageCheckingCron": "检查计划任务...",
"MessageConfirmCloseFeed": "你确定要关闭此订阅源吗?", "MessageConfirmCloseFeed": "你确定要关闭此订阅源吗?",
"MessageConfirmDeleteApiKey": "你确定要删除 API 密钥 \"{0}\" 吗?",
"MessageConfirmDeleteBackup": "你确定要删除备份 {0}?", "MessageConfirmDeleteBackup": "你确定要删除备份 {0}?",
"MessageConfirmDeleteDevice": "你确定要删除电子阅读器设备 \"{0}\" 吗?", "MessageConfirmDeleteDevice": "你确定要删除电子阅读器设备 \"{0}\" 吗?",
"MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?", "MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?",
@ -757,6 +776,7 @@
"MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?", "MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?",
"MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?", "MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?",
"MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?", "MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?",
"MessageConfirmRemoveEpisodeNote": "注意: 此操作不会删除音频文件, 除非勾选 \"完全删除文件\" 选项",
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?", "MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
"MessageConfirmRemoveListeningSessions": "你确定要移除 {0} 收听会话吗?", "MessageConfirmRemoveListeningSessions": "你确定要移除 {0} 收听会话吗?",
"MessageConfirmRemoveMetadataFiles": "你确实要删除库项目文件夹中的所有 metadata.{0} 文件吗?", "MessageConfirmRemoveMetadataFiles": "你确实要删除库项目文件夹中的所有 metadata.{0} 文件吗?",
@ -787,7 +807,7 @@
"MessageInvalidAsin": "无效的 ASIN", "MessageInvalidAsin": "无效的 ASIN",
"MessageItemsSelected": "已选定 {0} 个项目", "MessageItemsSelected": "已选定 {0} 个项目",
"MessageItemsUpdated": "已更新 {0} 个项目", "MessageItemsUpdated": "已更新 {0} 个项目",
"MessageJoinUsOn": "加入我们", "MessageJoinUsOn": "加入我们",
"MessageLoading": "正在加载...", "MessageLoading": "正在加载...",
"MessageLoadingFolders": "加载文件夹...", "MessageLoadingFolders": "加载文件夹...",
"MessageLogsDescription": "日志以 JSON 文件形式存储在 <code>/metadata/logs</code> 目录中. 崩溃日志存储在 <code>/metadata/logs/crash_logs.txt</code> 目录中.", "MessageLogsDescription": "日志以 JSON 文件形式存储在 <code>/metadata/logs</code> 目录中. 崩溃日志存储在 <code>/metadata/logs/crash_logs.txt</code> 目录中.",
@ -848,12 +868,12 @@
"MessageRemoveEpisodes": "移除 {0} 剧集", "MessageRemoveEpisodes": "移除 {0} 剧集",
"MessageRemoveFromPlayerQueue": "从播放队列中移除", "MessageRemoveFromPlayerQueue": "从播放队列中移除",
"MessageRemoveUserWarning": "是否确实要永久删除用户 \"{0}\"?", "MessageRemoveUserWarning": "是否确实要永久删除用户 \"{0}\"?",
"MessageReportBugsAndContribute": "报告错误、请求功能和贡献在", "MessageReportBugsAndContribute": "反馈问题, 建议功能或参与贡献, 请访问",
"MessageResetChaptersConfirm": "你确定要重置章节并撤消你所做的更改吗?", "MessageResetChaptersConfirm": "你确定要重置章节并撤消你所做的更改吗?",
"MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份", "MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份",
"MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果你已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.", "MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果你已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.",
"MessageScheduleLibraryScanNote": "对于大多数用户, 建议禁用此功能并保持文件夹监视程序设置启用. 文件夹监视程序将自动检测库文件夹中的更改. 文件夹监视程序不适用于每个文件系统 (如 NFS), 因此可以使用计划库扫描.", "MessageScheduleLibraryScanNote": "对于大多数用户, 建议禁用此功能并保持文件夹监视程序设置启用. 文件夹监视程序将自动检测库文件夹中的更改. 文件夹监视程序不适用于每个文件系统 (如 NFS), 因此可以使用计划库扫描.",
"MessageScheduleRunEveryWeekdayAtTime": "每隔 {0} 在 {1} 运行一次", "MessageScheduleRunEveryWeekdayAtTime": "每 {0} 的 {1} 执行",
"MessageSearchResultsFor": "搜索结果", "MessageSearchResultsFor": "搜索结果",
"MessageSelected": "{0} 已选择", "MessageSelected": "{0} 已选择",
"MessageSeriesSequenceCannotContainSpaces": "系列序列不能包含空格", "MessageSeriesSequenceCannotContainSpaces": "系列序列不能包含空格",
@ -917,9 +937,9 @@
"NoteUploaderUnsupportedFiles": "不支持的文件将被忽略. 选择或删除文件夹时, 将忽略不在项目文件夹中的其他文件.", "NoteUploaderUnsupportedFiles": "不支持的文件将被忽略. 选择或删除文件夹时, 将忽略不在项目文件夹中的其他文件.",
"NotificationOnBackupCompletedDescription": "备份完成时触发", "NotificationOnBackupCompletedDescription": "备份完成时触发",
"NotificationOnBackupFailedDescription": "备份失败时触发", "NotificationOnBackupFailedDescription": "备份失败时触发",
"NotificationOnEpisodeDownloadedDescription": "当播客节目自动下载时触发", "NotificationOnEpisodeDownloadedDescription": "当播客节目自动下载完成时触发",
"NotificationOnRSSFeedDisabledDescription": "由于尝试失败次数过多而导致剧集自动下载被禁用时触发", "NotificationOnRSSFeedDisabledDescription": "由于尝试失败次数过多而导致剧集自动下载被禁用时触发",
"NotificationOnRSSFeedFailedDescription": "当 RSS 源请求自动下载剧集失败时触发", "NotificationOnRSSFeedFailedDescription": "当用于自动下载剧集的 RSS 源请求失败时触发",
"NotificationOnTestDescription": "测试通知系统的事件", "NotificationOnTestDescription": "测试通知系统的事件",
"PlaceholderNewCollection": "输入收藏夹名称", "PlaceholderNewCollection": "输入收藏夹名称",
"PlaceholderNewFolderPath": "输入文件夹路径", "PlaceholderNewFolderPath": "输入文件夹路径",
@ -1000,6 +1020,8 @@
"ToastEpisodeDownloadQueueClearSuccess": "剧集下载队列已清空", "ToastEpisodeDownloadQueueClearSuccess": "剧集下载队列已清空",
"ToastEpisodeUpdateSuccess": "已更新 {0} 剧集", "ToastEpisodeUpdateSuccess": "已更新 {0} 剧集",
"ToastErrorCannotShare": "无法在此设备上本地共享", "ToastErrorCannotShare": "无法在此设备上本地共享",
"ToastFailedToCreate": "创建失败",
"ToastFailedToDelete": "删除失败",
"ToastFailedToLoadData": "加载数据失败", "ToastFailedToLoadData": "加载数据失败",
"ToastFailedToMatch": "匹配失败", "ToastFailedToMatch": "匹配失败",
"ToastFailedToShare": "分享失败", "ToastFailedToShare": "分享失败",
@ -1031,6 +1053,7 @@
"ToastMustHaveAtLeastOnePath": "必须至少有一个路径", "ToastMustHaveAtLeastOnePath": "必须至少有一个路径",
"ToastNameEmailRequired": "姓名和电子邮件为必填项", "ToastNameEmailRequired": "姓名和电子邮件为必填项",
"ToastNameRequired": "姓名为必填项", "ToastNameRequired": "姓名为必填项",
"ToastNewApiKeyUserError": "必须选择一个用户",
"ToastNewEpisodesFound": "找到 {0} 个新剧集", "ToastNewEpisodesFound": "找到 {0} 个新剧集",
"ToastNewUserCreatedFailed": "无法创建帐户: \"{0}\"", "ToastNewUserCreatedFailed": "无法创建帐户: \"{0}\"",
"ToastNewUserCreatedSuccess": "已创建新帐户", "ToastNewUserCreatedSuccess": "已创建新帐户",

20
package-lock.json generated
View file

@ -1,17 +1,18 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.25.1", "version": "2.26.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.25.1", "version": "2.26.3",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"express": "^4.17.1", "express": "^4.17.1",
"express-rate-limit": "^7.5.1",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"graceful-fs": "^4.2.10", "graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1", "htmlparser2": "^8.0.1",
@ -1893,6 +1894,21 @@
"node": ">= 0.10.0" "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": { "node_modules/express-session": {
"version": "1.17.3", "version": "1.17.3",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz",

View file

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.25.1", "version": "2.26.3",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
@ -40,6 +40,7 @@
"axios": "^0.27.2", "axios": "^0.27.2",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"express": "^4.17.1", "express": "^4.17.1",
"express-rate-limit": "^7.5.1",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"graceful-fs": "^4.2.10", "graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1", "htmlparser2": "^8.0.1",

File diff suppressed because it is too large Load diff

View file

@ -42,6 +42,16 @@ class Database {
return this.models.user 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')} */ /** @type {typeof import('./models/Library')} */
get libraryModel() { get libraryModel() {
return this.models.library return this.models.library
@ -311,6 +321,8 @@ class Database {
buildModels(force = false) { buildModels(force = false) {
require('./models/User').init(this.sequelize) 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/Library').init(this.sequelize)
require('./models/LibraryFolder').init(this.sequelize) require('./models/LibraryFolder').init(this.sequelize)
require('./models/Book').init(this.sequelize) require('./models/Book').init(this.sequelize)
@ -656,6 +668,9 @@ class Database {
* Series should have atleast one Book * Series should have atleast one Book
* Book and Podcast must have an associated LibraryItem (and vice versa) * Book and Podcast must have an associated LibraryItem (and vice versa)
* Remove playback sessions that are 3 seconds or less * Remove playback sessions that are 3 seconds or less
* Remove duplicate mediaProgresses
* Remove expired auth sessions
* Deactivate expired api keys
*/ */
async cleanDatabase() { async cleanDatabase() {
// Remove invalid Podcast records // Remove invalid Podcast records
@ -785,6 +800,40 @@ WHERE EXISTS (
where: { id: duplicateMediaProgress.id } 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) { async createTextSearchQuery(query) {

View file

@ -156,14 +156,11 @@ class Server {
} }
await Database.init(false) await Database.init(false)
// Create or set JWT secret in token manager
await this.auth.tokenManager.initTokenSecret()
await Logger.logManager.init() 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 this.cleanUserData() // Remove invalid user item progress
await CacheManager.ensureCachePaths() await CacheManager.ensureCachePaths()
@ -243,7 +240,7 @@ class Server {
* Running in development allows cors to allow testing the mobile apps in the browser * Running in development allows cors to allow testing the mobile apps in the browser
* or env variable ALLOW_CORS = '1' * or env variable ALLOW_CORS = '1'
*/ */
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) { if (global.AllowCors || Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
const allowedOrigins = ['capacitor://localhost', 'http://localhost'] const allowedOrigins = ['capacitor://localhost', 'http://localhost']
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) { if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
res.header('Access-Control-Allow-Origin', req.get('origin')) res.header('Access-Control-Allow-Origin', req.get('origin'))
@ -264,7 +261,7 @@ class Server {
// enable express-session // enable express-session
app.use( app.use(
expressSession({ expressSession({
secret: global.ServerSettings.tokenSecret, secret: this.auth.tokenManager.TokenSecret,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
@ -309,7 +306,9 @@ class Server {
}) })
) )
router.use(express.urlencoded({ extended: true, limit: '5mb' })) router.use(express.urlencoded({ extended: true, limit: '5mb' }))
router.use(express.json({ limit: '10mb' }))
// Skip JSON parsing for internal-api routes
router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' }))
router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router) router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router)
router.use('/hls', this.hlsRouter.router) router.use('/hls', this.hlsRouter.router)
@ -404,6 +403,7 @@ class Server {
const handle = nextApp.getRequestHandler() const handle = nextApp.getRequestHandler()
await nextApp.prepare() await nextApp.prepare()
router.get('*', (req, res) => handle(req, res)) router.get('*', (req, res) => handle(req, res))
router.post('/internal-api/*', (req, res) => handle(req, res))
} }
const unixSocketPrefix = 'unix/' const unixSocketPrefix = 'unix/'
@ -428,7 +428,7 @@ class Server {
Logger.info(`[Server] Initializing new server`) Logger.info(`[Server] Initializing new server`)
const newRoot = req.body.newRoot const newRoot = req.body.newRoot
const rootUsername = newRoot.username || 'root' const rootUsername = newRoot.username || 'root'
const rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : '' const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : ''
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`) if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
await Database.createRootUser(rootUsername, rootPash, this.auth) await Database.createRootUser(rootUsername, rootPash, this.auth)

View file

@ -1,7 +1,7 @@
const SocketIO = require('socket.io') const SocketIO = require('socket.io')
const Logger = require('./Logger') const Logger = require('./Logger')
const Database = require('./Database') const Database = require('./Database')
const Auth = require('./Auth') const TokenManager = require('./auth/TokenManager')
/** /**
* @typedef SocketClient * @typedef SocketClient
@ -231,18 +231,22 @@ class SocketAuthority {
* When setting up a socket connection the user needs to be associated with a socket id * 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 * 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 {SocketIO.Socket} socket
* @param {string} token JWT * @param {string} token JWT
*/ */
async authenticateSocket(socket, token) { async authenticateSocket(socket, token) {
// we don't use passport to authenticate the jwt we get over the socket connection. // we don't use passport to authenticate the jwt we get over the socket connection.
// it's easier to directly verify/decode it. // it's easier to directly verify/decode it.
const token_data = Auth.validateAccessToken(token) // TODO: Support API keys for web socket connections
const token_data = TokenManager.validateAccessToken(token)
if (!token_data?.userId) { if (!token_data?.userId) {
// Token invalid // Token invalid
Logger.error('Cannot validate socket - invalid token') Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token') return socket.emit('auth_failed', { message: 'Invalid token' })
} }
// get the user via the id from the decoded jwt. // get the user via the id from the decoded jwt.
@ -250,7 +254,11 @@ class SocketAuthority {
if (!user) { if (!user) {
// user not found // user not found
Logger.error('Cannot validate socket - invalid token') Logger.error('Cannot validate socket - invalid token')
return socket.emit('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' })
} }
const client = this.clients[socket.id] const client = this.clients[socket.id]
@ -260,13 +268,18 @@ class SocketAuthority {
} }
if (client.user !== undefined) { if (client.user !== undefined) {
Logger.debug(`[SocketAuthority] Authenticating socket client already has user`, client.user.username) 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}"`)
} }
client.user = user client.user = user
Logger.debug(`[SocketAuthority] User Online ${client.user.username}`)
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
// Update user lastSeen without firing sequelize bulk update hooks // Update user lastSeen without firing sequelize bulk update hooks

View file

@ -0,0 +1,186 @@
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

View file

@ -0,0 +1,488 @@
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

418
server/auth/TokenManager.js Normal file
View file

@ -0,0 +1,418 @@
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

View file

@ -0,0 +1,207 @@
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()

View file

@ -273,12 +273,24 @@ class MeController {
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
updatePassword(req, res) { async updatePassword(req, res) {
if (req.user.isGuest) { if (req.user.isGuest) {
Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`) Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`)
return res.sendStatus(500) return res.sendStatus(403)
} }
this.auth.userChangePassword(req, res)
const { password, newPassword } = req.body
if ((typeof password !== 'string' && password !== null) || (typeof newPassword !== 'string' && newPassword !== null)) {
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)
} }
/** /**
@ -438,7 +450,7 @@ class MeController {
if (updated) { if (updated) {
await Database.updateSetting(Database.emailSettings) await Database.updateSetting(Database.emailSettings)
SocketAuthority.clientEmitter(req.user.id, 'ereader-devices-updated', { SocketAuthority.clientEmitter(req.user.id, 'ereader-devices-updated', {
ereaderDevices: Database.emailSettings.ereaderDevices ereaderDevices: Database.emailSettings.getEReaderDevices(req.user)
}) })
} }
res.json({ res.json({

View file

@ -57,26 +57,24 @@ class SessionController {
} }
let where = null let where = null
const include = [
{
model: Database.models.device
}
]
if (userId) { if (userId) {
where = { where = {
userId userId
} }
} else {
include.push({
model: Database.userModel,
attributes: ['id', 'username']
})
} }
const { rows, count } = await Database.playbackSessionModel.findAndCountAll({ const { rows, count } = await Database.playbackSessionModel.findAndCountAll({
where, where,
include, include: [
{
model: Database.deviceModel
},
{
model: Database.userModel,
attributes: ['id', 'username']
}
],
order: [[orderKey, orderDesc]], order: [[orderKey, orderDesc]],
limit: itemsPerPage, limit: itemsPerPage,
offset: itemsPerPage * page offset: itemsPerPage * page
@ -290,7 +288,12 @@ class SessionController {
return res.sendStatus(404) return res.sendStatus(404)
} }
const audioTrack = playbackSession.audioTracks.find((t) => t.index === audioTrackIndex) let audioTrack = playbackSession.audioTracks.find((t) => toNumber(t.index, 1) === audioTrackIndex)
// Support clients passing 0 or 1 for podcast episode audio track index (handles old episodes pre-v2.21.0 having null index)
if (!audioTrack && playbackSession.mediaType === 'podcast' && audioTrackIndex === 0) {
audioTrack = playbackSession.audioTracks[0]
}
if (!audioTrack) { if (!audioTrack) {
Logger.error(`[SessionController] Unable to find audio track with index=${audioTrackIndex}`) Logger.error(`[SessionController] Unable to find audio track with index=${audioTrackIndex}`)
return res.sendStatus(404) return res.sendStatus(404)

View file

@ -127,8 +127,8 @@ class UserController {
} }
const userId = uuidv4() const userId = uuidv4()
const pash = await this.auth.hashPass(req.body.password) const pash = await this.auth.localAuthStrategy.hashPassword(req.body.password)
const token = await this.auth.generateAccessToken({ id: userId, username: req.body.username }) const token = this.auth.generateAccessToken({ id: userId, username: req.body.username })
const userType = req.body.type || 'user' const userType = req.body.type || 'user'
// librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions // librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions
@ -237,6 +237,7 @@ class UserController {
let hasUpdates = false let hasUpdates = false
let shouldUpdateToken = false let shouldUpdateToken = false
let shouldInvalidateJwtSessions = false
// When changing username create a new API token // When changing username create a new API token
if (updatePayload.username && updatePayload.username !== user.username) { if (updatePayload.username && updatePayload.username !== user.username) {
const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username) const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username)
@ -245,12 +246,13 @@ class UserController {
} }
user.username = updatePayload.username user.username = updatePayload.username
shouldUpdateToken = true shouldUpdateToken = true
shouldInvalidateJwtSessions = true
hasUpdates = true hasUpdates = true
} }
// Updating password // Updating password
if (updatePayload.password) { if (updatePayload.password) {
user.pash = await this.auth.hashPass(updatePayload.password) user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password)
hasUpdates = true hasUpdates = true
} }
@ -325,9 +327,24 @@ class UserController {
if (hasUpdates) { if (hasUpdates) {
if (shouldUpdateToken) { if (shouldUpdateToken) {
user.token = await this.auth.generateAccessToken(user) user.token = this.auth.generateAccessToken(user)
Logger.info(`[UserController] User ${user.username} has generated a new api token`) 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() await user.save()
SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toOldJSONForBrowser()) SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toOldJSONForBrowser())
} }
@ -422,7 +439,16 @@ class UserController {
const page = toNumber(req.query.page, 0) const page = toNumber(req.query.page, 0)
const start = page * itemsPerPage const start = page * itemsPerPage
const sessions = listeningSessions.slice(start, start + itemsPerPage) // Map user to sessions to match the format of the sessions endpoint
const sessions = listeningSessions.slice(start, start + itemsPerPage).map((session) => {
return {
...session,
user: {
id: req.reqUser.id,
username: req.reqUser.username
}
}
})
const payload = { const payload = {
total: listeningSessions.length, total: listeningSessions.length,

View file

@ -7,7 +7,7 @@ const FantLab = require('../providers/FantLab')
const AudiobookCovers = require('../providers/AudiobookCovers') const AudiobookCovers = require('../providers/AudiobookCovers')
const CustomProviderAdapter = require('../providers/CustomProviderAdapter') const CustomProviderAdapter = require('../providers/CustomProviderAdapter')
const Logger = require('../Logger') const Logger = require('../Logger')
const { levenshteinDistance, escapeRegExp } = require('../utils/index') const { levenshteinDistance, levenshteinSimilarity, escapeRegExp, isValidASIN } = require('../utils/index')
const htmlSanitizer = require('../utils/htmlSanitizer') const htmlSanitizer = require('../utils/htmlSanitizer')
class BookFinder { class BookFinder {
@ -385,7 +385,11 @@ class BookFinder {
if (!title) return books if (!title) return books
books = await this.runSearch(title, author, provider, asin, maxTitleDistance, maxAuthorDistance) const isTitleAsin = isValidASIN(title.toUpperCase())
let actualTitleQuery = title
let actualAuthorQuery = author
books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance)
if (!books.length && maxFuzzySearches > 0) { if (!books.length && maxFuzzySearches > 0) {
// Normalize title and author // Normalize title and author
@ -408,19 +412,26 @@ class BookFinder {
for (const titlePart of titleParts) titleCandidates.add(titlePart) for (const titlePart of titleParts) titleCandidates.add(titlePart)
titleCandidates = titleCandidates.getCandidates() titleCandidates = titleCandidates.getCandidates()
for (const titleCandidate of titleCandidates) { for (const titleCandidate of titleCandidates) {
if (titleCandidate == title && authorCandidate == author) continue // We already tried this if (titleCandidate == actualTitleQuery && authorCandidate == actualAuthorQuery) continue // We already tried this
if (++numFuzzySearches > maxFuzzySearches) break loop_author if (++numFuzzySearches > maxFuzzySearches) break loop_author
books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance) actualTitleQuery = titleCandidate
actualAuthorQuery = authorCandidate
books = await this.runSearch(actualTitleQuery, actualAuthorQuery, provider, asin, maxTitleDistance, maxAuthorDistance)
if (books.length) break loop_author if (books.length) break loop_author
} }
} }
} }
if (books.length) { if (books.length) {
const resultsHaveDuration = provider.startsWith('audible') const isAudibleProvider = provider.startsWith('audible')
if (resultsHaveDuration && libraryItem?.media?.duration) { const libraryItemDurationMinutes = libraryItem?.media?.duration ? libraryItem.media.duration / 60 : null
const libraryItemDurationMinutes = libraryItem.media.duration / 60
// If provider results have duration, sort by ascendinge duration difference from libraryItem books.forEach((book) => {
if (typeof book !== 'object' || !isAudibleProvider) return
book.matchConfidence = this.calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin)
})
if (isAudibleProvider && libraryItemDurationMinutes) {
books.sort((a, b) => { books.sort((a, b) => {
const aDuration = a.duration || Number.POSITIVE_INFINITY const aDuration = a.duration || Number.POSITIVE_INFINITY
const bDuration = b.duration || Number.POSITIVE_INFINITY const bDuration = b.duration || Number.POSITIVE_INFINITY
@ -433,6 +444,120 @@ class BookFinder {
return books return books
} }
/**
* Calculate match confidence score for a book
* @param {Object} book - The book object to calculate confidence for
* @param {number|null} libraryItemDurationMinutes - Duration of library item in minutes
* @param {string} actualTitleQuery - Actual title query
* @param {string} actualAuthorQuery - Actual author query
* @param {boolean} isTitleAsin - Whether the title is an ASIN
* @returns {number|null} - Match confidence score or null if not applicable
*/
calculateMatchConfidence(book, libraryItemDurationMinutes, actualTitleQuery, actualAuthorQuery, isTitleAsin) {
// ASIN results are always a match
if (isTitleAsin) return 1.0
let durationScore
if (libraryItemDurationMinutes && typeof book.duration === 'number') {
const durationDiff = Math.abs(book.duration - libraryItemDurationMinutes)
// Duration scores:
// diff | score
// 0 | 1.0
// 1 | 1.0
// 2 | 0.9
// 3 | 0.8
// 4 | 0.7
// 5 | 0.6
// 6 | 0.48
// 7 | 0.36
// 8 | 0.24
// 9 | 0.12
// 10 | 0.0
if (durationDiff <= 1) {
// Covers durationDiff = 0 for score 1.0
durationScore = 1.0
} else if (durationDiff <= 5) {
// (1, 5] - Score from 1.0 down to 0.6
// Linearly interpolates between (1, 1.0) and (5, 0.6)
// Equation: y = 1.0 - 0.08 * x
durationScore = 1.1 - 0.1 * durationDiff
} else if (durationDiff <= 10) {
// (5, 10] - Score from 0.6 down to 0.0
// Linearly interpolates between (5, 0.6) and (10, 0.0)
// Equation: y = 1.2 - 0.12 * x
durationScore = 1.2 - 0.12 * durationDiff
} else {
// durationDiff > 10 - Score is 0.0
durationScore = 0.0
}
Logger.debug(`[BookFinder] Duration diff: ${durationDiff}, durationScore: ${durationScore}`)
} else {
// Default score if library item duration or book duration is not available
durationScore = 0.1
}
const calculateTitleScore = (titleQuery, book, keepSubtitle = false) => {
const cleanTitle = cleanTitleForCompares(book.title || '', keepSubtitle)
const cleanSubtitle = keepSubtitle && book.subtitle ? `: ${book.subtitle}` : ''
const normBookTitle = `${cleanTitle}${cleanSubtitle}`
const normTitleQuery = cleanTitleForCompares(titleQuery, keepSubtitle)
const titleSimilarity = levenshteinSimilarity(normTitleQuery, normBookTitle)
Logger.debug(`[BookFinder] keepSubtitle: ${keepSubtitle}, normBookTitle: ${normBookTitle}, normTitleQuery: ${normTitleQuery}, titleSimilarity: ${titleSimilarity}`)
return titleSimilarity
}
const titleQueryHasSubtitle = hasSubtitle(actualTitleQuery)
const titleScore = calculateTitleScore(actualTitleQuery, book, titleQueryHasSubtitle)
let authorScore
const normAuthorQuery = cleanAuthorForCompares(actualAuthorQuery)
const normBookAuthor = cleanAuthorForCompares(book.author || '')
if (!normAuthorQuery) {
// Original query had no author
authorScore = 1.0 // Neutral score
} else {
// Original query HAS an author (cleanedQueryAuthorForScore is not empty)
if (normBookAuthor) {
const bookAuthorParts = normBookAuthor.split(',').map((name) => name.trim().toLowerCase())
// Filter out empty parts that might result from ", ," or trailing/leading commas
const validBookAuthorParts = bookAuthorParts.filter((p) => p.length > 0)
if (validBookAuthorParts.length === 0) {
// Book author string was present but effectively empty (e.g. ",,")
// Since cleanedQueryAuthorForScore is non-empty here, this is a mismatch.
authorScore = 0.0
} else {
let maxPartScore = levenshteinSimilarity(normAuthorQuery, normBookAuthor)
Logger.debug(`[BookFinder] normAuthorQuery: ${normAuthorQuery}, normBookAuthor: ${normBookAuthor}, similarity: ${maxPartScore}`)
if (validBookAuthorParts.length > 1 || normBookAuthor.includes(',')) {
validBookAuthorParts.forEach((part) => {
// part is guaranteed to be non-empty here
// cleanedQueryAuthorForScore is also guaranteed non-empty here.
// levenshteinDistance lowercases by default, but part is already lowercased.
const similarity = levenshteinSimilarity(normAuthorQuery, part)
Logger.debug(`[BookFinder] normAuthorQuery: ${normAuthorQuery}, bookAuthorPart: ${part}, similarity: ${similarity}`)
const currentPartScore = similarity
maxPartScore = Math.max(maxPartScore, currentPartScore)
})
}
authorScore = maxPartScore
}
} else {
// Book has NO author (or not a string, or empty string)
// Query has an author (cleanedQueryAuthorForScore is non-empty), book does not.
authorScore = 0.0
}
}
const W_DURATION = 0.7
const W_TITLE = 0.2
const W_AUTHOR = 0.1
Logger.debug(`[BookFinder] Duration score: ${durationScore}, Title score: ${titleScore}, Author score: ${authorScore}`)
const confidence = W_DURATION * durationScore + W_TITLE * titleScore + W_AUTHOR * authorScore
Logger.debug(`[BookFinder] Confidence: ${confidence}`)
return Math.max(0, Math.min(1, confidence))
}
/** /**
* Search for books * Search for books
* *
@ -464,6 +589,7 @@ class BookFinder {
} else { } else {
books = await this.getGoogleBooksResults(title, author) books = await this.getGoogleBooksResults(title, author)
} }
books.forEach((book) => { books.forEach((book) => {
if (book.description) { if (book.description) {
book.description = htmlSanitizer.sanitize(book.description) book.description = htmlSanitizer.sanitize(book.description)
@ -505,6 +631,9 @@ class BookFinder {
} }
module.exports = new BookFinder() module.exports = new BookFinder()
function hasSubtitle(title) {
return title.includes(':') || title.includes(' - ')
}
function stripSubtitle(title) { function stripSubtitle(title) {
if (title.includes(':')) { if (title.includes(':')) {
return title.split(':')[0].trim() return title.split(':')[0].trim()
@ -523,12 +652,12 @@ function replaceAccentedChars(str) {
} }
} }
function cleanTitleForCompares(title) { function cleanTitleForCompares(title, keepSubtitle = false) {
if (!title) return '' if (!title) return ''
title = stripRedundantSpaces(title) title = stripRedundantSpaces(title)
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book") // Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
let stripped = stripSubtitle(title) let stripped = keepSubtitle ? title : stripSubtitle(title)
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game") // Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
let cleaned = stripped.replace(/ *\([^)]*\) */g, '') let cleaned = stripped.replace(/ *\([^)]*\) */g, '')

View file

@ -31,10 +31,12 @@ class CronManager {
} }
/** /**
* Initialize open session cleanup cron * Initialize open session & auth session cleanup cron
* Runs every day at 00:30 * Runs every day at 00:30
* Closes open share sessions that have not been updated in 24 hours * Closes open share sessions that have not been updated in 24 hours
* Closes open playback sessions that have not been updated in 36 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 * TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner
*/ */
initOpenSessionCleanupCron() { initOpenSessionCleanupCron() {
@ -42,6 +44,8 @@ class CronManager {
Logger.debug('[CronManager] Open session cleanup cron executing') Logger.debug('[CronManager] Open session cleanup cron executing')
ShareManager.closeStaleOpenShareSessions() ShareManager.closeStaleOpenShareSessions()
await this.playbackSessionManager.closeStaleOpenSessions() await this.playbackSessionManager.closeStaleOpenSessions()
await Database.cleanupExpiredSessions()
await Database.deactivateExpiredApiKeys()
}) })
} }

View file

@ -0,0 +1,163 @@
/**
* @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 }

272
server/models/ApiKey.js Normal file
View file

@ -0,0 +1,272 @@
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

View file

@ -185,6 +185,7 @@ class PodcastEpisode extends Model {
const track = structuredClone(this.audioFile) const track = structuredClone(this.audioFile)
track.startOffset = 0 track.startOffset = 0
track.title = this.audioFile.metadata.filename track.title = this.audioFile.metadata.filename
track.index = 1 // Podcast episodes only have one track
track.contentUrl = `/api/items/${libraryItemId}/file/${track.ino}` track.contentUrl = `/api/items/${libraryItemId}/file/${track.ino}`
return track return track
} }

88
server/models/Session.js Normal file
View file

@ -0,0 +1,88 @@
const { DataTypes, Model, Op } = require('sequelize')
class Session extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.ipAddress
/** @type {string} */
this.userAgent
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
/** @type {UUIDV4} */
this.userId
/** @type {Date} */
this.expiresAt
// Expanded properties
/** @type {import('./User').User} */
this.user
}
static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt) {
const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt })
return session
}
/**
* Clean up expired sessions from the database
* @returns {Promise<number>} Number of sessions deleted
*/
static async cleanupExpiredSessions() {
const deletedCount = await Session.destroy({
where: {
expiresAt: {
[Op.lt]: new Date()
}
}
})
return deletedCount
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init(
{
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
}
},
{
sequelize,
modelName: 'session'
}
)
const { user } = sequelize.models
user.hasMany(Session, {
onDelete: 'CASCADE',
foreignKey: {
allowNull: false
}
})
Session.belongsTo(user)
}
}
module.exports = Session

View file

@ -1,9 +1,11 @@
const uuidv4 = require('uuid').v4 const uuidv4 = require('uuid').v4
const sequelize = require('sequelize') const sequelize = require('sequelize')
const { LRUCache } = require('lru-cache')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const { isNullOrNaN } = require('../utils') const { isNullOrNaN } = require('../utils')
const { LRUCache } = require('lru-cache') const TokenManager = require('../auth/TokenManager')
class UserCache { class UserCache {
constructor() { constructor() {
@ -190,7 +192,7 @@ class User extends Model {
static async createRootUser(username, pash, auth) { static async createRootUser(username, pash, auth) {
const userId = uuidv4() const userId = uuidv4()
const token = await auth.generateAccessToken({ id: userId, username }) const token = auth.generateAccessToken({ id: userId, username })
const newUser = { const newUser = {
id: userId, id: userId,
@ -209,18 +211,106 @@ class User extends Model {
} }
/** /**
* Create user from openid userinfo * Finds an existing user by OpenID subject identifier, or by email/username based on server settings,
* or creates a new user if configured to do so.
*
* @param {Object} userinfo * @param {Object} userinfo
* @param {import('../Auth')} auth
* @returns {Promise<User>} * @returns {Promise<User>}
*/ */
static async createUserFromOpenIdUserInfo(userinfo, auth) { static async findOrCreateUserFromOpenIdUserInfo(userinfo) {
let user = await this.getUserByOpenIDSub(userinfo.sub)
// Matched by sub
if (user) {
Logger.debug(`[User] openid: User found by sub`)
return user
}
// Match existing user by email
if (global.ServerSettings.authOpenIDMatchExistingBy === 'email') {
if (userinfo.email) {
// Only disallow when email_verified explicitly set to false (allow both if not set or true)
if (userinfo.email_verified === false) {
Logger.warn(`[User] openid: User not found and email "${userinfo.email}" is not verified`)
return null
} else {
Logger.info(`[User] openid: User not found, checking existing with email "${userinfo.email}"`)
user = await this.getUserByEmail(userinfo.email)
if (user?.authOpenIDSub) {
Logger.warn(`[User] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`)
return null // User is linked to a different OpenID subject; do not proceed.
}
}
} else {
Logger.warn(`[User] openid: User not found and no email in userinfo`)
// We deny login, because if the admin whishes to match email, it makes sense to require it
return null
}
}
// Match existing user by username
else if (global.ServerSettings.authOpenIDMatchExistingBy === 'username') {
let username
if (userinfo.preferred_username) {
Logger.info(`[User] openid: User not found, checking existing with userinfo.preferred_username "${userinfo.preferred_username}"`)
username = userinfo.preferred_username
} else if (userinfo.username) {
Logger.info(`[User] openid: User not found, checking existing with userinfo.username "${userinfo.username}"`)
username = userinfo.username
} else {
Logger.warn(`[User] openid: User not found and neither preferred_username nor username in userinfo`)
return null
}
user = await this.getUserByUsername(username)
if (user?.authOpenIDSub) {
Logger.warn(`[User] openid: User found with username "${username}" but is already matched with sub "${user.authOpenIDSub}"`)
return null // User is linked to a different OpenID subject; do not proceed.
}
}
// Found existing user via email or username
if (user) {
if (!user.isActive) {
Logger.warn(`[User] openid: User found but is not active`)
return null
}
// Update user with OpenID sub
if (!user.extraData) user.extraData = {}
user.extraData.authOpenIDSub = userinfo.sub
user.changed('extraData', true)
await user.save()
Logger.debug(`[User] openid: User found by email/username`)
return user
}
// If no existing user was matched, auto-register if configured
if (global.ServerSettings.authOpenIDAutoRegister) {
Logger.info(`[User] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo)
user = await this.createUserFromOpenIdUserInfo(userinfo)
return user
}
Logger.warn(`[User] openid: User not found and auto-register is disabled`)
return null
}
/**
* Create user from openid userinfo
* @param {Object} userinfo
* @returns {Promise<User>}
*/
static async createUserFromOpenIdUserInfo(userinfo) {
const userId = uuidv4() const userId = uuidv4()
// TODO: Ensure username is unique? // TODO: Ensure username is unique?
const username = userinfo.preferred_username || userinfo.name || userinfo.sub const username = userinfo.preferred_username || userinfo.name || userinfo.sub
const email = userinfo.email && userinfo.email_verified ? userinfo.email : null const email = userinfo.email && userinfo.email_verified ? userinfo.email : null
const token = await auth.generateAccessToken({ id: userId, username }) const token = TokenManager.generateAccessToken({ id: userId, username })
const newUser = { const newUser = {
id: userId, id: userId,
@ -520,7 +610,11 @@ class User extends Model {
username: this.username, username: this.username,
email: this.email, email: this.email,
type: this.type, type: this.type,
// TODO: Old non-expiring token
token: this.type === 'root' && hideRootToken ? '' : this.token, token: this.type === 'root' && hideRootToken ? '' : this.token,
// TODO: Temporary flag not saved in db that is set in Auth.js jwtAuthCheck
// Necessary to detect apps using old tokens that no longer match the old token stored on the user
isOldToken: this.isOldToken,
mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [], mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
seriesHideFromContinueListening: [...seriesHideFromContinueListening], seriesHideFromContinueListening: [...seriesHideFromContinueListening],
bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [], bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],

View file

@ -7,6 +7,7 @@ const User = require('../../models/User')
class ServerSettings { class ServerSettings {
constructor(settings) { constructor(settings) {
this.id = 'server-settings' this.id = 'server-settings'
/** @type {string} JWT secret key ONLY used when JWT_SECRET_KEY is not set in ENV */
this.tokenSecret = null this.tokenSecret = null
// Scanner // Scanner

View file

@ -34,6 +34,7 @@ const CustomMetadataProviderController = require('../controllers/CustomMetadataP
const MiscController = require('../controllers/MiscController') const MiscController = require('../controllers/MiscController')
const ShareController = require('../controllers/ShareController') const ShareController = require('../controllers/ShareController')
const StatsController = require('../controllers/StatsController') const StatsController = require('../controllers/StatsController')
const ApiKeyController = require('../controllers/ApiKeyController')
class ApiRouter { class ApiRouter {
constructor(Server) { constructor(Server) {
@ -181,7 +182,7 @@ class ApiRouter {
this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this)) this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this))
this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this)) this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this)) this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))
this.router.patch('/me/password', MeController.updatePassword.bind(this)) this.router.patch('/me/password', this.auth.authRateLimiter, MeController.updatePassword.bind(this))
this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this)) this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
@ -325,6 +326,14 @@ class ApiRouter {
this.router.get('/stats/year/:year', StatsController.middleware.bind(this), StatsController.getAdminStatsForYear.bind(this)) this.router.get('/stats/year/:year', StatsController.middleware.bind(this), StatsController.getAdminStatsForYear.bind(this))
this.router.get('/stats/server', StatsController.middleware.bind(this), StatsController.getServerStats.bind(this)) this.router.get('/stats/server', StatsController.middleware.bind(this), StatsController.getServerStats.bind(this))
//
// API Key Routes
//
this.router.get('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.getAll.bind(this))
this.router.post('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.create.bind(this))
this.router.patch('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.update.bind(this))
this.router.delete('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.delete.bind(this))
// //
// Misc Routes // Misc Routes
// //

View file

@ -34,6 +34,14 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
} }
module.exports.levenshteinDistance = levenshteinDistance module.exports.levenshteinDistance = levenshteinDistance
const levenshteinSimilarity = (str1, str2, caseSensitive = false) => {
const distance = levenshteinDistance(str1, str2, caseSensitive)
const maxLength = Math.max(str1.length, str2.length)
if (maxLength === 0) return 1
return 1 - distance / maxLength
}
module.exports.levenshteinSimilarity = levenshteinSimilarity
module.exports.isObject = (val) => { module.exports.isObject = (val) => {
return val !== null && typeof val === 'object' return val !== null && typeof val === 'object'
} }

View file

@ -399,9 +399,6 @@ module.exports = {
if (filterGroup !== 'series' && sortBy === 'sequence') { if (filterGroup !== 'series' && sortBy === 'sequence') {
sortBy = 'media.metadata.title' sortBy = 'media.metadata.title'
} }
if (filterGroup !== 'progress' && sortBy === 'progress') {
sortBy = 'media.metadata.title'
}
const includeRSSFeed = include.includes('rssfeed') const includeRSSFeed = include.includes('rssfeed')
const includeMediaItemShare = !!user?.isAdminOrUp && include.includes('share') const includeMediaItemShare = !!user?.isAdminOrUp && include.includes('share')
@ -532,6 +529,18 @@ module.exports = {
} }
} }
// When sorting by progress but not filtering by progress, include media progresses
if (filterGroup !== 'progress' && sortBy === 'progress') {
bookIncludes.push({
model: Database.mediaProgressModel,
attributes: ['id', 'isFinished', 'currentTime', 'ebookProgress', 'updatedAt'],
where: {
userId: user.id
},
required: false
})
}
let { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue) let { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue)
let bookWhere = Array.isArray(mediaWhere) ? mediaWhere : [mediaWhere] let bookWhere = Array.isArray(mediaWhere) ? mediaWhere : [mediaWhere]

View file

@ -0,0 +1,82 @@
const { rateLimit, RateLimitRequestHandler } = require('express-rate-limit')
const Logger = require('../Logger')
const requestIp = require('../libs/requestIp')
/**
* Factory for creating authentication rate limiters
*/
class RateLimiterFactory {
static DEFAULT_WINDOW_MS = 10 * 60 * 1000 // 10 minutes
static DEFAULT_MAX = 40 // 40 attempts
constructor() {
this.authRateLimiter = null
}
/**
* Get the authentication rate limiter
* @returns {RateLimitRequestHandler}
*/
getAuthRateLimiter() {
if (this.authRateLimiter) {
return this.authRateLimiter
}
// Disable by setting max to 0
if (process.env.RATE_LIMIT_AUTH_MAX === '0') {
this.authRateLimiter = (req, res, next) => next()
Logger.info(`[RateLimiterFactory] Authentication rate limiting disabled by ENV variable`)
return this.authRateLimiter
}
let windowMs = RateLimiterFactory.DEFAULT_WINDOW_MS
if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) {
windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW)
if (windowMs !== RateLimiterFactory.DEFAULT_WINDOW_MS) {
Logger.info(`[RateLimiterFactory] Authentication rate limiting window set to ${windowMs}ms by ENV variable`)
}
}
let max = RateLimiterFactory.DEFAULT_MAX
if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) {
max = parseInt(process.env.RATE_LIMIT_AUTH_MAX)
if (max !== RateLimiterFactory.DEFAULT_MAX) {
Logger.info(`[RateLimiterFactory] Authentication rate limiting max set to ${max} by ENV variable`)
}
}
let message = 'Too many authentication requests'
if (process.env.RATE_LIMIT_AUTH_MESSAGE) {
message = process.env.RATE_LIMIT_AUTH_MESSAGE
}
this.authRateLimiter = rateLimit({
windowMs,
max,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
// Override keyGenerator to handle proxy IPs
return requestIp.getClientIp(req) || req.ip
},
handler: (req, res) => {
const userAgent = req.get('User-Agent') || 'Unknown'
const endpoint = req.path
const method = req.method
const ip = requestIp.getClientIp(req) || req.ip
Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`)
res.status(429).json({
error: message
})
}
})
Logger.debug(`[RateLimiterFactory] Created auth rate limiter: ${max} attempts per ${windowMs / 1000 / 60} minutes`)
return this.authRateLimiter
}
}
module.exports = new RateLimiterFactory()

View file

@ -6,6 +6,7 @@ const Database = require('../../../server/Database')
const ApiRouter = require('../../../server/routers/ApiRouter') const ApiRouter = require('../../../server/routers/ApiRouter')
const LibraryItemController = require('../../../server/controllers/LibraryItemController') const LibraryItemController = require('../../../server/controllers/LibraryItemController')
const ApiCacheManager = require('../../../server/managers/ApiCacheManager') const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
const Auth = require('../../../server/Auth')
const Logger = require('../../../server/Logger') const Logger = require('../../../server/Logger')
describe('LibraryItemController', () => { describe('LibraryItemController', () => {
@ -19,6 +20,7 @@ describe('LibraryItemController', () => {
await Database.buildModels() await Database.buildModels()
apiRouter = new ApiRouter({ apiRouter = new ApiRouter({
auth: new Auth(),
apiCacheManager: new ApiCacheManager() apiCacheManager: new ApiCacheManager()
}) })

View file

@ -5,6 +5,12 @@ const bookFinder = require('../../../server/finders/BookFinder')
const { LogLevel } = require('../../../server/utils/constants') const { LogLevel } = require('../../../server/utils/constants')
const Logger = require('../../../server/Logger') const Logger = require('../../../server/Logger')
Logger.setLogLevel(LogLevel.INFO) Logger.setLogLevel(LogLevel.INFO)
const { levenshteinDistance } = require('../../../server/utils/index')
// levenshteinDistance is needed for manual calculation of expected scores in tests.
// Assuming it's accessible for testing purposes or we mock/replicate its basic behavior if needed.
// For now, we'll assume bookFinder.search uses it internally correctly.
// const { levenshteinDistance } = require('../../../server/utils/index') // Not used directly in test logic, but for reasoning.
describe('TitleCandidates', () => { describe('TitleCandidates', () => {
describe('cleanAuthor non-empty', () => { describe('cleanAuthor non-empty', () => {
@ -326,31 +332,262 @@ describe('search', () => {
const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }] const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }]
beforeEach(() => { beforeEach(() => {
runSearchStub.withArgs(t, a, provider).resolves(unsorted) runSearchStub.withArgs(t, a, provider).resolves(structuredClone(unsorted))
})
afterEach(() => {
sinon.restore()
}) })
it('returns results sorted by library item duration diff', async () => { it('returns results sorted by library item duration diff', async () => {
expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted) const result = (await bookFinder.search(libraryItem, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
expect(result).to.deep.equal(sorted)
}) })
it('returns unsorted results if library item is null', async () => { it('returns unsorted results if library item is null', async () => {
expect(await bookFinder.search(null, provider, t, a)).to.deep.equal(unsorted) const result = (await bookFinder.search(null, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
expect(result).to.deep.equal(unsorted)
}) })
it('returns unsorted results if library item duration is undefined', async () => { it('returns unsorted results if library item duration is undefined', async () => {
expect(await bookFinder.search({ media: {} }, provider, t, a)).to.deep.equal(unsorted) const result = (await bookFinder.search({ media: {} }, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
expect(result).to.deep.equal(unsorted)
}) })
it('returns unsorted results if library item media is undefined', async () => { it('returns unsorted results if library item media is undefined', async () => {
expect(await bookFinder.search({}, provider, t, a)).to.deep.equal(unsorted) const result = (await bookFinder.search({}, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
expect(result).to.deep.equal(unsorted)
}) })
it('should return a result last if it has no duration', async () => { it('should return a result last if it has no duration', async () => {
const unsorted = [{}, { duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }] const unsorted = [{}, { duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }]
const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }, {}] const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }, {}]
runSearchStub.withArgs(t, a, provider).resolves(unsorted) runSearchStub.withArgs(t, a, provider).resolves(structuredClone(unsorted))
const result = (await bookFinder.search(libraryItem, provider, t, a)).map((r) => (r.duration ? { duration: r.duration } : {}))
expect(result).to.deep.equal(sorted)
})
})
expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted) describe('matchConfidence score', () => {
const W_DURATION = 0.7
const W_TITLE = 0.2
const W_AUTHOR = 0.1
const DEFAULT_DURATION_SCORE_MISSING_INFO = 0.1
const libraryItemPerfectDuration = { media: { duration: 600 } } // 10 minutes
// Helper to calculate expected title/author score based on Levenshtein
// Assumes queryPart and bookPart are already "cleaned" for length calculation consistency with BookFinder.js
const calculateStringMatchScore = (cleanedQueryPart, cleanedBookPart) => {
if (!cleanedQueryPart) return cleanedBookPart ? 0 : 1 // query empty: 1 if book empty, else 0
if (!cleanedBookPart) return 0 // query non-empty, book empty: 0
// Use the imported levenshteinDistance. It defaults to case-insensitive, which is what we want.
const distance = levenshteinDistance(cleanedQueryPart, cleanedBookPart)
return Math.max(0, 1 - distance / Math.max(cleanedQueryPart.length, cleanedBookPart.length))
}
beforeEach(() => {
runSearchStub.resolves([])
})
afterEach(() => {
sinon.restore()
})
describe('for audible provider', () => {
const provider = 'audible'
it('should be 1.0 for perfect duration, title, and author match', async () => {
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
// durationScore = 1.0 (diff 0 <= 1 min)
// titleScore = 1.0 (exact match)
// authorScore = 1.0 (exact match)
const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
})
it('should correctly score a large duration mismatch', async () => {
const bookResults = [{ duration: 21, title: 'The Great Novel', author: 'John Doe' }] // 21 min, diff = 11 min
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
// durationScore = 0.0
// titleScore = 1.0
// authorScore = 1.0
const expectedConfidence = W_DURATION * 0.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
})
it('should correctly score a medium duration mismatch', async () => {
const bookResults = [{ duration: 16, title: 'The Great Novel', author: 'John Doe' }] // 16 min, diff = 6 min
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
// durationScore = 1.2 - 6 * 0.12 = 0.48
// titleScore = 1.0
// authorScore = 1.0
const expectedConfidence = W_DURATION * 0.48 + W_TITLE * 1.0 + W_AUTHOR * 1.0
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
})
it('should correctly score a minor duration mismatch', async () => {
const bookResults = [{ duration: 14, title: 'The Great Novel', author: 'John Doe' }] // 14 min, diff = 4 min
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
// durationScore = 1.1 - 4 * 0.1 = 0.7
// titleScore = 1.0
// authorScore = 1.0
const expectedConfidence = W_DURATION * 0.7 + W_TITLE * 1.0 + W_AUTHOR * 1.0
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
})
it('should correctly score a tiny duration mismatch', async () => {
const bookResults = [{ duration: 11, title: 'The Great Novel', author: 'John Doe' }] // 11 min, diff = 1 min
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
// durationScore = 1.0
// titleScore = 1.0
// authorScore = 1.0
const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 1.0
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
})
it('should use default duration score if libraryItem duration is missing', async () => {
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
runSearchStub.resolves(bookResults)
const results = await bookFinder.search({ media: {} }, provider, 'The Great Novel', 'John Doe')
// durationScore = DEFAULT_DURATION_SCORE_MISSING_INFO (0.2)
const expectedConfidence = W_DURATION * DEFAULT_DURATION_SCORE_MISSING_INFO + W_TITLE * 1.0 + W_AUTHOR * 1.0
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
})
it('should use default duration score if book duration is missing', async () => {
const bookResults = [{ title: 'The Great Novel', author: 'John Doe' }] // No duration in book
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
// durationScore = DEFAULT_DURATION_SCORE_MISSING_INFO (0.2)
const expectedConfidence = W_DURATION * DEFAULT_DURATION_SCORE_MISSING_INFO + W_TITLE * 1.0 + W_AUTHOR * 1.0
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
})
it('should correctly score a partial title match', async () => {
const bookResults = [{ duration: 10, title: 'Novel', author: 'John Doe' }]
runSearchStub.resolves(bookResults)
// Query: 'Novel Ex', Book: 'Novel'
// cleanTitleForCompares('Novel Ex') -> 'novel ex' (length 8)
// cleanTitleForCompares('Novel') -> 'novel' (length 5)
// levenshteinDistance('novel ex', 'novel') = 3
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'Novel Ex', 'John Doe')
const expectedTitleScore = calculateStringMatchScore('novel ex', 'novel') // 1 - (3/8) = 0.625
const expectedConfidence = W_DURATION * 1.0 + W_TITLE * expectedTitleScore + W_AUTHOR * 1.0
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
})
it('should correctly score a partial author match (comma-separated)', async () => {
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'Jane Smith, Jon Doee' }]
runSearchStub.resolves(bookResults)
// Query: 'Jon Doe', Book part: 'Jon Doee'
// cleanAuthorForCompares('Jon Doe') -> 'jon doe' (length 7)
// book author part (already lowercased) -> 'jon doee' (length 8)
// levenshteinDistance('jon doe', 'jon doee') = 1
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'Jon Doe')
// For the author part 'jon doee':
const expectedAuthorPartScore = calculateStringMatchScore('jon doe', 'jon doee') // 1 - (1/7)
// Assuming 'jane smith' gives a lower or 0 score, max score will be from 'jon doee'
const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * expectedAuthorPartScore
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
})
it('should give authorScore 0 if query has author but book does not', async () => {
const bookResults = [{ duration: 10, title: 'The Great Novel', author: null }]
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
// authorScore = 0.0
const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 0.0
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
})
it('should give authorScore 1.0 if query has no author', async () => {
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', '') // Empty author
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
})
it('handles book author string that is only commas correctly (score 0)', async () => {
const bookResults = [{ duration: 10, title: 'The Great Novel', author: ',, ,, ,' }]
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
// cleanedQueryAuthorForScore = "john doe"
// book.author leads to validBookAuthorParts being empty.
// authorScore = 0.0
const expectedConfidence = W_DURATION * 1.0 + W_TITLE * 1.0 + W_AUTHOR * 0.0
expect(results[0].matchConfidence).to.be.closeTo(expectedConfidence, 0.001)
})
it('should return 1.0 for ASIN results', async () => {
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'B000F28ZJ4', null)
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
})
it('should return 1.0 when author matches one of the book authors', async () => {
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe, Jane Smith' }]
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
})
it('should return 1.0 when author query and multiple book authors are the same', async () => {
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe, Jane Smith' }]
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe, Jane Smith')
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
})
it('should correctly score against a book with a subtitle when the query has a subtitle', async () => {
const bookResults = [{ duration: 10, title: 'The Great Novel', subtitle: 'A Novel', author: 'John Doe' }]
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel: A Novel', 'John Doe')
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
})
it('should correctly score against a book with a subtitle when the query does not have a subtitle', async () => {
const bookResults = [{ duration: 10, title: 'The Great Novel', subtitle: 'A Novel', author: 'John Doe' }]
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
})
describe('after fuzzy searches', () => {
it('should return 1.0 for a title candidate match', async () => {
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
runSearchStub.resolves([])
runSearchStub.withArgs('the great novel', 'john doe').resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel - A Novel', 'John Doe')
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
})
it('should return 1.0 for an author candidate match', async () => {
const bookResults = [{ duration: 10, title: 'The Great Novel', author: 'John Doe' }]
runSearchStub.resolves([])
runSearchStub.withArgs('the great novel', 'john doe').resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe, Jane Smith')
expect(results[0].matchConfidence).to.be.closeTo(1.0, 0.001)
})
})
})
describe('for non-audible provider (e.g., google)', () => {
const provider = 'google'
it('should have not have matchConfidence', async () => {
const bookResults = [{ title: 'The Great Novel', author: 'John Doe' }]
runSearchStub.resolves(bookResults)
const results = await bookFinder.search(libraryItemPerfectDuration, provider, 'The Great Novel', 'John Doe')
expect(results[0]).to.not.have.property('matchConfidence')
})
}) })
}) })
}) })