Merge branch 'advplyr:master' into audible-confidence-score

This commit is contained in:
mikiher 2025-07-13 10:13:00 +03:00 committed by GitHub
commit e9a705587a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 3288 additions and 931 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

@ -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

@ -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

@ -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,9 +94,9 @@ 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 isDark = this.ereaderSettings.theme === 'dark'
@ -309,14 +306,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 +344,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

@ -266,9 +266,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.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.25.1", "version": "2.26.0",
"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.0",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",

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

@ -13,8 +13,8 @@
<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="flex text-xs 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 />
</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 +100,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

@ -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,14 @@ 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)
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 = {
@ -217,15 +232,24 @@ 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 +304,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

@ -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) => {
@ -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

@ -346,11 +346,11 @@
"LabelExample": "Příklad", "LabelExample": "Příklad",
"LabelExpandSeries": "Rozbalit série", "LabelExpandSeries": "Rozbalit série",
"LabelExpandSubSeries": "Rozbalit podsérie", "LabelExpandSubSeries": "Rozbalit podsérie",
"LabelExplicit": "Explicitní", "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",
@ -757,6 +757,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ě?",

View File

@ -32,7 +32,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",
@ -514,7 +514,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",
@ -757,6 +757,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?",

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)",
@ -456,6 +469,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",
@ -545,6 +559,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",
@ -710,6 +725,7 @@
"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.",
"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.",
@ -731,6 +747,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?",
@ -1002,6 +1019,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",
@ -1033,6 +1052,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

@ -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

@ -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

@ -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

@ -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

@ -44,7 +44,7 @@
"ButtonLatest": "Останні", "ButtonLatest": "Останні",
"ButtonLibrary": "Бібліотека", "ButtonLibrary": "Бібліотека",
"ButtonLogout": "Вийти", "ButtonLogout": "Вийти",
"ButtonLookup": "Пошук", "ButtonLookup": "Пошуки",
"ButtonManageTracks": "Керувати доріжками", "ButtonManageTracks": "Керувати доріжками",
"ButtonMapChapterTitles": "Призначити назви глав", "ButtonMapChapterTitles": "Призначити назви глав",
"ButtonMatchAllAuthors": "Віднайти усіх авторів", "ButtonMatchAllAuthors": "Віднайти усіх авторів",
@ -346,7 +346,7 @@
"LabelExample": "Приклад", "LabelExample": "Приклад",
"LabelExpandSeries": "Розгорнути серії", "LabelExpandSeries": "Розгорнути серії",
"LabelExpandSubSeries": "Розгорнути підсерії", "LabelExpandSubSeries": "Розгорнути підсерії",
"LabelExplicit": "Відверта", "LabelExplicit": "Відвертий",
"LabelExplicitChecked": "Відверта (з прапорцем)", "LabelExplicitChecked": "Відверта (з прапорцем)",
"LabelExplicitUnchecked": "Не відверта (без прапорця)", "LabelExplicitUnchecked": "Не відверта (без прапорця)",
"LabelExportOPML": "Експорт OPML", "LabelExportOPML": "Експорт OPML",
@ -757,6 +757,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 +819,7 @@
"MessageNoItems": "Елементи відсутні", "MessageNoItems": "Елементи відсутні",
"MessageNoItemsFound": "Елементів не знайдено", "MessageNoItemsFound": "Елементів не знайдено",
"MessageNoListeningSessions": "Сеанси прослуховування відсутні", "MessageNoListeningSessions": "Сеанси прослуховування відсутні",
"MessageNoLogs": "Немає журналів", "MessageNoLogs": "Немає журнали",
"MessageNoMediaProgress": "Прогрес відсутній", "MessageNoMediaProgress": "Прогрес відсутній",
"MessageNoNotifications": "Сповіщення відсутні", "MessageNoNotifications": "Сповіщення відсутні",
"MessageNoPodcastFeed": "Некоректний подкаст: немає каналу", "MessageNoPodcastFeed": "Некоректний подкаст: немає каналу",

View File

@ -11,7 +11,7 @@
"ButtonAuthors": "作者", "ButtonAuthors": "作者",
"ButtonBack": "返回", "ButtonBack": "返回",
"ButtonBatchEditPopulateFromExisting": "用现有内容填充", "ButtonBatchEditPopulateFromExisting": "用现有内容填充",
"ButtonBatchEditPopulateMapDetails": "填充地图详细信息", "ButtonBatchEditPopulateMapDetails": "填入此项详情",
"ButtonBrowseForFolder": "浏览文件夹", "ButtonBrowseForFolder": "浏览文件夹",
"ButtonCancel": "取消", "ButtonCancel": "取消",
"ButtonCancelEncode": "取消编码", "ButtonCancelEncode": "取消编码",
@ -73,7 +73,7 @@
"ButtonQuickMatch": "快速匹配", "ButtonQuickMatch": "快速匹配",
"ButtonReScan": "重新扫描", "ButtonReScan": "重新扫描",
"ButtonRead": "读取", "ButtonRead": "读取",
"ButtonReadLess": "阅读较少", "ButtonReadLess": "收起",
"ButtonReadMore": "阅读更多", "ButtonReadMore": "阅读更多",
"ButtonRefresh": "刷新", "ButtonRefresh": "刷新",
"ButtonRemove": "移除", "ButtonRemove": "移除",
@ -212,7 +212,7 @@
"HeaderUsers": "用户", "HeaderUsers": "用户",
"HeaderYearReview": "{0} 年回顾", "HeaderYearReview": "{0} 年回顾",
"HeaderYourStats": "你的统计数据", "HeaderYourStats": "你的统计数据",
"LabelAbridged": "概要", "LabelAbridged": "删节版",
"LabelAbridgedChecked": "删节版 (已勾选)", "LabelAbridgedChecked": "删节版 (已勾选)",
"LabelAbridgedUnchecked": "未删节版 (未勾选)", "LabelAbridgedUnchecked": "未删节版 (未勾选)",
"LabelAccessibleBy": "可访问", "LabelAccessibleBy": "可访问",
@ -320,7 +320,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 +346,15 @@
"LabelExample": "示例", "LabelExample": "示例",
"LabelExpandSeries": "展开系列", "LabelExpandSeries": "展开系列",
"LabelExpandSubSeries": "展开子系列", "LabelExpandSubSeries": "展开子系列",
"LabelExplicit": "信息准确", "LabelExplicit": "含成人内容",
"LabelExplicitChecked": "明确(已选中", "LabelExplicitChecked": "成人内容(已核实",
"LabelExplicitUnchecked": "不明确 (未选中)", "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": "文件名",
@ -482,7 +482,7 @@
"LabelPermanent": "永久的", "LabelPermanent": "永久的",
"LabelPermissionsAccessAllLibraries": "可以访问所有媒体库", "LabelPermissionsAccessAllLibraries": "可以访问所有媒体库",
"LabelPermissionsAccessAllTags": "可以访问所有标签", "LabelPermissionsAccessAllTags": "可以访问所有标签",
"LabelPermissionsAccessExplicitContent": "可以访问显式内容", "LabelPermissionsAccessExplicitContent": "可以访问成人内容",
"LabelPermissionsCreateEreader": "可以创建电子阅读器", "LabelPermissionsCreateEreader": "可以创建电子阅读器",
"LabelPermissionsDelete": "可以删除", "LabelPermissionsDelete": "可以删除",
"LabelPermissionsDownload": "可以下载", "LabelPermissionsDownload": "可以下载",
@ -613,12 +613,12 @@
"LabelStartedAt": "从这开始", "LabelStartedAt": "从这开始",
"LabelStatsAudioTracks": "音轨", "LabelStatsAudioTracks": "音轨",
"LabelStatsAuthors": "作者", "LabelStatsAuthors": "作者",
"LabelStatsBestDay": "最好的一天", "LabelStatsBestDay": "单日最高",
"LabelStatsDailyAverage": "每日平均值", "LabelStatsDailyAverage": "每日平均值",
"LabelStatsDays": "", "LabelStatsDays": "连续收听",
"LabelStatsDaysListened": "收听天数", "LabelStatsDaysListened": "收听天数",
"LabelStatsHours": "小时", "LabelStatsHours": "小时",
"LabelStatsInARow": "在一行", "LabelStatsInARow": "",
"LabelStatsItemsFinished": "已完成的项目", "LabelStatsItemsFinished": "已完成的项目",
"LabelStatsItemsInLibrary": "媒体库中的项目", "LabelStatsItemsInLibrary": "媒体库中的项目",
"LabelStatsMinutes": "分钟", "LabelStatsMinutes": "分钟",
@ -714,7 +714,7 @@
"MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.", "MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.",
"MessageBackupsLocationPathEmpty": "备份位置路径不能为空", "MessageBackupsLocationPathEmpty": "备份位置路径不能为空",
"MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并", "MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并",
"MessageBatchEditPopulateMapDetailsItemHelp": "使用此项目的数据填充已启用的地图详细信息字段", "MessageBatchEditPopulateMapDetailsItemHelp": "提取此项目的信息,填入上方所有勾选的编辑框中",
"MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.", "MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.",
"MessageBookshelfNoCollections": "你尚未进行任何收藏", "MessageBookshelfNoCollections": "你尚未进行任何收藏",
"MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.", "MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.",
@ -757,6 +757,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} 文件吗?",
@ -917,9 +918,9 @@
"NoteUploaderUnsupportedFiles": "不支持的文件将被忽略. 选择或删除文件夹时, 将忽略不在项目文件夹中的其他文件.", "NoteUploaderUnsupportedFiles": "不支持的文件将被忽略. 选择或删除文件夹时, 将忽略不在项目文件夹中的其他文件.",
"NotificationOnBackupCompletedDescription": "备份完成时触发", "NotificationOnBackupCompletedDescription": "备份完成时触发",
"NotificationOnBackupFailedDescription": "备份失败时触发", "NotificationOnBackupFailedDescription": "备份失败时触发",
"NotificationOnEpisodeDownloadedDescription": "当播客节目自动下载时触发", "NotificationOnEpisodeDownloadedDescription": "当播客节目自动下载完成时触发",
"NotificationOnRSSFeedDisabledDescription": "由于尝试失败次数过多而导致剧集自动下载被禁用时触发", "NotificationOnRSSFeedDisabledDescription": "由于尝试失败次数过多而导致剧集自动下载被禁用时触发",
"NotificationOnRSSFeedFailedDescription": "当 RSS 源请求自动下载剧集失败时触发", "NotificationOnRSSFeedFailedDescription": "当用于自动下载剧集的 RSS 源请求失败时触发",
"NotificationOnTestDescription": "测试通知系统的事件", "NotificationOnTestDescription": "测试通知系统的事件",
"PlaceholderNewCollection": "输入收藏夹名称", "PlaceholderNewCollection": "输入收藏夹名称",
"PlaceholderNewFolderPath": "输入文件夹路径", "PlaceholderNewFolderPath": "输入文件夹路径",

20
package-lock.json generated
View File

@ -1,17 +1,18 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.25.1", "version": "2.26.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.25.1", "version": "2.26.0",
"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.0",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
@ -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()
@ -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, this)
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

406
server/auth/TokenManager.js Normal file
View File

@ -0,0 +1,406 @@
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
}
}
/**
* 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 jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret)
}
/**
* 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 (!password || !newPassword || typeof password !== 'string' || typeof newPassword !== 'string') {
return res.status(400).send('Missing or invalid password or new password')
}
const result = await this.auth.localAuthStrategy.changePassword(req.user, password, newPassword)
if (result.error) {
return res.status(400).send(result.error)
}
res.sendStatus(200)
} }
/** /**

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())
} }

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

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

@ -190,7 +190,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,
@ -208,6 +208,96 @@ class User extends Model {
return this.create(newUser) return this.create(newUser)
} }
/**
* 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 {import('../Auth')} auth
* @returns {Promise<User>}
*/
static async findOrCreateUserFromOpenIdUserInfo(userinfo, auth) {
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, auth)
return user
}
Logger.warn(`[User] openid: User not found and auto-register is disabled`)
return null
}
/** /**
* Create user from openid userinfo * Create user from openid userinfo
* @param {Object} userinfo * @param {Object} userinfo
@ -220,7 +310,7 @@ class User extends Model {
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 = auth.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

@ -206,6 +206,11 @@ class LibraryItemScanner {
async scanPotentialNewLibraryItem(libraryItemPath, library, folder, isSingleMediaItem) { async scanPotentialNewLibraryItem(libraryItemPath, library, folder, isSingleMediaItem) {
const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem) const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem)
if (!libraryItemScanData.libraryFiles.length) {
Logger.info(`[LibraryItemScanner] Library item at path "${libraryItemPath}" has no files - ignoring`)
return null
}
const scanLogger = new ScanLogger() const scanLogger = new ScanLogger()
scanLogger.verbose = true scanLogger.verbose = true
scanLogger.setData('libraryItem', libraryItemScanData.relPath) scanLogger.setData('libraryItem', libraryItemScanData.relPath)

View File

@ -606,6 +606,11 @@ class LibraryScanner {
} else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) { } else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) {
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" has no audio files`) Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" has no audio files`)
continue continue
} else if (!(await fs.pathExists(fullPath))) {
Logger.info(`[LibraryScanner] File update group "${itemDir}" does not exist - ignoring`)
itemGroupingResults[itemDir] = ScanResult.NOTHING
continue
} }
// Check if a library item is a subdirectory of this dir // Check if a library item is a subdirectory of this dir

View File

@ -109,7 +109,7 @@ function getIno(path) {
.stat(path, { bigint: true }) .stat(path, { bigint: true })
.then((data) => String(data.ino)) .then((data) => String(data.ino))
.catch((err) => { .catch((err) => {
Logger.error('[Utils] Failed to get ino for path', path, err) Logger.warn(`[Utils] Failed to get ino for path "${path}"`, err)
return null return null
}) })
} }

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()
}) })