mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-28 23:48:45 +02:00
Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c86f336536 | ||
|
|
1876bfbfe7 | ||
|
|
a7b380cd7a | ||
|
|
71c5d27ea5 | ||
|
|
83707e08bf | ||
|
|
e8d1b58bd2 | ||
|
|
aff79b5579 | ||
|
|
321d8bfda1 | ||
|
|
a3ff41f90e | ||
|
|
60f9ac93f7 | ||
|
|
bd61a43458 | ||
|
|
df6ec07f18 | ||
|
|
6d02aa3aa0 | ||
|
|
144fe743c6 | ||
|
|
0851b346d0 | ||
|
|
53e904314e | ||
|
|
84f80f1903 | ||
|
|
98b1f4abad | ||
|
|
ec0d147d6d | ||
|
|
a7825bb461 | ||
|
|
09be7799f6 | ||
|
|
dc6a8a7292 | ||
|
|
0c075674a0 | ||
|
|
5b13820cb4 | ||
|
|
d5b4d57fe3 | ||
|
|
f12b64495a | ||
|
|
43f3150a52 | ||
|
|
65d3a668e3 | ||
|
|
2b5297bee8 | ||
|
|
08caeb5975 | ||
|
|
6c170a5064 | ||
|
|
4b62f90964 | ||
|
|
e9ea511c90 | ||
|
|
c6afeb7dc9 | ||
|
|
35a2c5e87c | ||
|
|
4c71a076e7 | ||
|
|
e56c1099e6 | ||
|
|
dc0b24a03f | ||
|
|
c900676be7 | ||
|
|
a6cde926ee | ||
|
|
e8db14fe33 | ||
|
|
e9b533637f | ||
|
|
8b97da500d | ||
|
|
37b42601ef | ||
|
|
5da4ec51ce | ||
|
|
4c65a87d5c | ||
|
|
0e5618dcd8 | ||
|
|
d063d851e1 | ||
|
|
6458e6d3d2 | ||
|
|
70953d8c82 | ||
|
|
2d9f63963e | ||
|
|
f12abca3fc | ||
|
|
bced308462 | ||
|
|
528d03fef3 | ||
|
|
b93c3133a9 | ||
|
|
8d9db8d103 | ||
|
|
08bbf9cd9d | ||
|
|
95af4812cd | ||
|
|
8ec547e302 | ||
|
|
32e4da8c92 | ||
|
|
0ce37004af | ||
|
|
527745b54e | ||
|
|
3c016314e6 | ||
|
|
59c7d0275c | ||
|
|
4883d0f60c | ||
|
|
9d9ab49664 | ||
|
|
82aec5f60c | ||
|
|
7ee5e1bdf6 | ||
|
|
3bb53d939d | ||
|
|
6f03467f35 | ||
|
|
b2f593f1c4 | ||
|
|
58dcda36a5 | ||
|
|
354e0bf793 | ||
|
|
0784373b61 | ||
|
|
2f219ea3cc | ||
|
|
56e60b8420 | ||
|
|
9b92b5de34 | ||
|
|
3417c0c721 | ||
|
|
cbda0360aa | ||
|
|
036bc081f0 | ||
|
|
e70e4b9d40 | ||
|
|
aacdcc47ec | ||
|
|
499b52b4dd | ||
|
|
1bad2d9072 | ||
|
|
c009db9f28 | ||
|
|
325469c5a5 | ||
|
|
c97b36e11c | ||
|
|
e944b2a2f5 |
@@ -22,7 +22,7 @@
|
|||||||
<div v-if="entityName === 'collections' || entityName === 'playlists'" class="flex justify-center mt-4">
|
<div v-if="entityName === 'collections' || entityName === 'playlists'" class="flex justify-center mt-4">
|
||||||
{{ emptyMessageHelp }}
|
{{ emptyMessageHelp }}
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
</a>
|
</a>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||||
<div class="absolute top-0 right-0 p-4">
|
<div class="absolute top-0 right-0 p-4">
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
<a href="https://www.audiobookshelf.org/guides/media-item-shares" target="_blank" class="inline-flex">
|
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/public-shares#media-item-shares" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
</a>
|
</a>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<div class="text-sm flex items-center justify-center text-gray-200">
|
<div class="text-sm flex items-center justify-center text-gray-200">
|
||||||
<p>{{ $strings.MessageBookshelfNoCollectionsHelp }}</p>
|
<p>{{ $strings.MessageBookshelfNoCollectionsHelp }}</p>
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
</a>
|
</a>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<div class="flex items-center justify-between md:justify-start mb-4">
|
<div class="flex items-center justify-between md:justify-start mb-4">
|
||||||
<p class="text-sm text-gray-300 pr-2">{{ $strings.LabelMetadataOrderOfPrecedenceDescription }}</p>
|
<p class="text-sm text-gray-300 pr-2">{{ $strings.LabelMetadataOrderOfPrecedenceDescription }}</p>
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex">
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex">
|
||||||
<a href="https://www.audiobookshelf.org/guides/book-scanner" target="_blank" class="inline-flex">
|
<a href="https://audiobookshelf.org/docs/documentation/libraries/book-library/book-metadata" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5">help_outline</span>
|
<span class="material-symbols text-xl w-5">help_outline</span>
|
||||||
</a>
|
</a>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<div class="text-sm flex items-center justify-center text-gray-200">
|
<div class="text-sm flex items-center justify-center text-gray-200">
|
||||||
<p>{{ $strings.MessageNoUserPlaylistsHelp }}</p>
|
<p>{{ $strings.MessageNoUserPlaylistsHelp }}</p>
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
<a href="https://www.audiobookshelf.org/guides/collections" target="_blank" class="inline-flex">
|
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/playlists" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
</a>
|
</a>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.35.0",
|
"version": "2.35.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.35.0",
|
"version": "2.35.1",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.35.0",
|
"version": "2.35.1",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast client",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|||||||
+142
-5
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="w-full max-w-xl mx-auto">
|
<div class="w-full max-w-2xl mx-auto">
|
||||||
<h1 class="text-2xl">{{ $strings.HeaderAccount }}</h1>
|
<h1 class="text-2xl">{{ $strings.HeaderAccount }}</h1>
|
||||||
|
|
||||||
<div class="my-4">
|
<div class="my-4">
|
||||||
@@ -69,8 +69,52 @@
|
|||||||
</app-settings-content>
|
</app-settings-content>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="py-4 mt-8 flex">
|
<div v-if="!isGuest">
|
||||||
<ui-btn color="bg-primary flex items-center text-lg" @click="logout"><span class="material-symbols mr-4 icon-text">logout</span>{{ $strings.ButtonLogout }}</ui-btn>
|
<div class="w-full h-px bg-white/10 my-4" />
|
||||||
|
|
||||||
|
<app-settings-content :header-text="$strings.HeaderSessions">
|
||||||
|
<table v-if="authSessions.length" class="tracksTable mt-4 table-fixed w-full">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left">{{ $strings.LabelDeviceInfo }}</th>
|
||||||
|
<th class="text-left hidden sm:table-cell w-36">{{ $strings.LabelIpAddress }}</th>
|
||||||
|
<th class="text-left w-30 sm:w-40">{{ $strings.LabelLastUpdate }}</th>
|
||||||
|
<th class="w-12"></th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="session in authSessions" :key="session.id">
|
||||||
|
<td class="max-w-0">
|
||||||
|
<div class="flex items-center gap-0.5 min-w-0">
|
||||||
|
<span class="text-sm text-gray-100 truncate min-w-0" :title="session.userAgent">{{ getSessionDeviceLabel(session) }}</span>
|
||||||
|
<ui-tooltip v-if="session.current" direction="top" :text="$strings.LabelCurrent" class="inline-flex shrink-0">
|
||||||
|
<span class="material-symbols text-success text-sm leading-none">check</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="hidden sm:table-cell w-36">
|
||||||
|
<p class="text-sm text-gray-100 truncate" :title="session.ipAddress">{{ session.ipAddress || '-' }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="w-30 sm:w-40">
|
||||||
|
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||||
|
<p class="text-xs sm:text-sm text-gray-100">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
<td class="w-12">
|
||||||
|
<div class="flex justify-end items-center h-10">
|
||||||
|
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingSessionId === session.id || loadingAuthSessions" @click="deleteAuthSessionClick(session)" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div v-if="authSessionsNumPages > 1" class="flex items-center justify-end py-1">
|
||||||
|
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="loadingAuthSessions || authSessionsPage === 0" @click="prevAuthSessionsPage" />
|
||||||
|
<p class="text-sm mx-1">{{ $getString('LabelPaginationPageXOfY', [authSessionsPage + 1, authSessionsNumPages]) }}</p>
|
||||||
|
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="loadingAuthSessions || authSessionsPage >= authSessionsNumPages - 1" @click="nextAuthSessionsPage" />
|
||||||
|
</div>
|
||||||
|
</app-settings-content>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-4 mt-8 flex flex-wrap gap-2">
|
||||||
|
<ui-btn v-if="!isGuest" color="bg-primary flex items-center text-lg" :disabled="loggingOut" @click="logout(true)"> <span class="material-symbols mr-4 icon-text">devices</span>{{ $strings.ButtonLogoutAllDevices }} </ui-btn>
|
||||||
|
<ui-btn color="bg-primary flex items-center text-lg" :disabled="loggingOut" @click="logout(false)"><span class="material-symbols mr-4 icon-text">logout</span>{{ $strings.ButtonLogout }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-emails-user-e-reader-device-modal v-model="showEReaderDeviceModal" :existing-devices="revisedEreaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" />
|
<modals-emails-user-e-reader-device-modal v-model="showEReaderDeviceModal" :existing-devices="revisedEreaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" />
|
||||||
@@ -87,6 +131,13 @@ export default {
|
|||||||
newPassword: null,
|
newPassword: null,
|
||||||
confirmPassword: null,
|
confirmPassword: null,
|
||||||
changingPassword: false,
|
changingPassword: false,
|
||||||
|
loggingOut: false,
|
||||||
|
authSessions: [],
|
||||||
|
authSessionsPage: 0,
|
||||||
|
authSessionsNumPages: 0,
|
||||||
|
authSessionsItemsPerPage: 10,
|
||||||
|
loadingAuthSessions: false,
|
||||||
|
deletingSessionId: null,
|
||||||
selectedLanguage: '',
|
selectedLanguage: '',
|
||||||
newEReaderDevice: {
|
newEReaderDevice: {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -129,13 +180,19 @@ export default {
|
|||||||
},
|
},
|
||||||
revisedEreaderDevices() {
|
revisedEreaderDevices() {
|
||||||
return this.ereaderDevices.filter((device) => device.users?.length === 1)
|
return this.ereaderDevices.filter((device) => device.users?.length === 1)
|
||||||
|
},
|
||||||
|
dateFormat() {
|
||||||
|
return this.$store.getters['getServerSetting']('dateFormat')
|
||||||
|
},
|
||||||
|
timeFormat() {
|
||||||
|
return this.$store.getters['getServerSetting']('timeFormat')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
updateLocalLanguage(lang) {
|
updateLocalLanguage(lang) {
|
||||||
this.$setLanguageCode(lang)
|
this.$setLanguageCode(lang)
|
||||||
},
|
},
|
||||||
logout() {
|
logout(allDevices = false) {
|
||||||
// Disconnect from socket
|
// Disconnect from socket
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
console.log('Disconnecting from socket', this.$root.socket.id)
|
console.log('Disconnecting from socket', this.$root.socket.id)
|
||||||
@@ -149,8 +206,10 @@ export default {
|
|||||||
this.$store.commit('libraries/setUserPlaylists', [])
|
this.$store.commit('libraries/setUserPlaylists', [])
|
||||||
this.$store.commit('libraries/setCollections', [])
|
this.$store.commit('libraries/setCollections', [])
|
||||||
|
|
||||||
|
this.loggingOut = true
|
||||||
|
const url = allDevices ? '/logout?allDevices=1' : '/logout'
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post('/logout')
|
.$post(url)
|
||||||
.then((logoutPayload) => {
|
.then((logoutPayload) => {
|
||||||
const redirect_url = logoutPayload.redirect_url
|
const redirect_url = logoutPayload.redirect_url
|
||||||
|
|
||||||
@@ -163,6 +222,9 @@ export default {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.loggingOut = false
|
||||||
|
})
|
||||||
},
|
},
|
||||||
resetForm() {
|
resetForm() {
|
||||||
this.password = null
|
this.password = null
|
||||||
@@ -238,11 +300,86 @@ export default {
|
|||||||
},
|
},
|
||||||
ereaderDevicesUpdated(ereaderDevices) {
|
ereaderDevicesUpdated(ereaderDevices) {
|
||||||
this.ereaderDevices = ereaderDevices
|
this.ereaderDevices = ereaderDevices
|
||||||
|
},
|
||||||
|
loadAuthSessions(page = 0) {
|
||||||
|
if (this.loadingAuthSessions) return
|
||||||
|
if (page < 0) return
|
||||||
|
if (this.authSessionsNumPages > 0 && page > this.authSessionsNumPages - 1) return
|
||||||
|
|
||||||
|
this.loadingAuthSessions = true
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/me/sessions?page=${page}&itemsPerPage=${this.authSessionsItemsPerPage}`)
|
||||||
|
.then((data) => {
|
||||||
|
this.authSessions = data.sessions || []
|
||||||
|
this.authSessionsPage = data.page ?? page
|
||||||
|
this.authSessionsNumPages = data.numPages ?? 0
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load sessions', error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.loadingAuthSessions = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
prevAuthSessionsPage() {
|
||||||
|
if (this.authSessionsPage <= 0) return
|
||||||
|
this.loadAuthSessions(this.authSessionsPage - 1)
|
||||||
|
},
|
||||||
|
nextAuthSessionsPage() {
|
||||||
|
if (this.authSessionsPage >= this.authSessionsNumPages - 1) return
|
||||||
|
this.loadAuthSessions(this.authSessionsPage + 1)
|
||||||
|
},
|
||||||
|
deleteAuthSessionClick(session) {
|
||||||
|
this.$store.commit('globals/setConfirmPrompt', {
|
||||||
|
message: this.$getString('MessageConfirmLogoutDevice', [this.getSessionDeviceLabel(session)]),
|
||||||
|
callback: (confirmed) => {
|
||||||
|
if (confirmed) this.deleteAuthSession(session)
|
||||||
|
},
|
||||||
|
type: 'yesNo'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
deleteAuthSession(session) {
|
||||||
|
// Call logout instead for current session
|
||||||
|
if (session.current) {
|
||||||
|
this.logout(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deletingSessionId = session.id
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/me/sessions/${session.id}`)
|
||||||
|
.then(() => {
|
||||||
|
if (this.authSessions.length === 1 && this.authSessionsPage > 0) {
|
||||||
|
this.loadAuthSessions(this.authSessionsPage - 1)
|
||||||
|
} else {
|
||||||
|
this.loadAuthSessions(this.authSessionsPage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to delete session', error)
|
||||||
|
this.$toast.error(this.$strings.ToastFailedToDelete)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.deletingSessionId = null
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getSessionDeviceLabel(session) {
|
||||||
|
const deviceInfo = session.deviceInfo
|
||||||
|
if (!deviceInfo) return session.userAgent || '-'
|
||||||
|
|
||||||
|
const parts = []
|
||||||
|
if (deviceInfo.model) parts.push(deviceInfo.model)
|
||||||
|
if (deviceInfo.osName) parts.push(`${deviceInfo.osName}${deviceInfo.osVersion ? ` ${deviceInfo.osVersion}` : ''}`)
|
||||||
|
if (deviceInfo.browserName) parts.push(deviceInfo.browserName)
|
||||||
|
return parts.join(' / ') || session.userAgent || '-'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.selectedLanguage = this.$languageCodes.current
|
this.selectedLanguage = this.$languageCodes.current
|
||||||
this.ereaderDevices = this.$store.state.libraries.ereaderDevices || []
|
this.ereaderDevices = this.$store.state.libraries.ereaderDevices || []
|
||||||
|
if (!this.isGuest) {
|
||||||
|
this.loadAuthSessions()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
<a href="https://www.audiobookshelf.org/guides/api-keys" target="_blank" class="inline-flex">
|
<a href="https://audiobookshelf.org/docs/documentation/server-management/api-keys" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
</a>
|
</a>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" />
|
<ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" />
|
||||||
<p class="text-lg pl-4">{{ $strings.HeaderOpenIDConnectAuthentication }}</p>
|
<p class="text-lg pl-4">{{ $strings.HeaderOpenIDConnectAuthentication }}</p>
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
<a href="https://www.audiobookshelf.org/guides/oidc_authentication" target="_blank" class="inline-flex">
|
<a href="https://audiobookshelf.org/docs/documentation/server-management/oidc-authentication" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
</a>
|
</a>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<app-settings-content :header-text="$strings.HeaderEmailSettings" :description="''">
|
<app-settings-content :header-text="$strings.HeaderEmailSettings" :description="''">
|
||||||
<template #header-items>
|
<template #header-items>
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
<a href="https://www.audiobookshelf.org/guides/send_to_ereader" target="_blank" class="inline-flex">
|
<a href="https://audiobookshelf.org/docs/documentation/libraries/book-library/ebooks" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
</a>
|
</a>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #header-items>
|
<template #header-items>
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
<a href="https://www.audiobookshelf.org/guides/custom-metadata-providers" target="_blank" class="inline-flex">
|
<a href="https://audiobookshelf.org/docs/documentation/community/community-providers" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
</a>
|
</a>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<app-settings-content :header-text="$strings.HeaderLibraries">
|
<app-settings-content :header-text="$strings.HeaderLibraries">
|
||||||
<template #header-items>
|
<template #header-items>
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
<a href="https://www.audiobookshelf.org/guides/library_creation" target="_blank" class="inline-flex">
|
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/overview" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
</a>
|
</a>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<app-settings-content :header-text="$strings.HeaderLogs" :description="$strings.MessageLogsDescription">
|
<app-settings-content :header-text="$strings.HeaderLogs" :description="$strings.MessageLogsDescription">
|
||||||
<template #header-items>
|
<template #header-items>
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
<a href="https://www.audiobookshelf.org/guides/server_logs" target="_blank" class="inline-flex">
|
<a href="https://audiobookshelf.org/docs/documentation/server-management/server-logs" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
</a>
|
</a>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<app-settings-content :header-text="$strings.HeaderRSSFeeds">
|
<app-settings-content :header-text="$strings.HeaderRSSFeeds">
|
||||||
<template #header-items>
|
<template #header-items>
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
<a href="https://www.audiobookshelf.org/guides/rss_feeds" target="_blank" class="inline-flex">
|
<a href="https://audiobookshelf.org/docs/documentation/libraries/common-content/public-shares#rss-feed" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
</a>
|
</a>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||||
<a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex">
|
<a href="https://audiobookshelf.org/docs/documentation/server-management/user-management" target="_blank" class="inline-flex">
|
||||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||||
</a>
|
</a>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|||||||
+249
-249
@@ -3,7 +3,7 @@
|
|||||||
"ButtonAddApiKey": "Tilføj API-nøgle",
|
"ButtonAddApiKey": "Tilføj API-nøgle",
|
||||||
"ButtonAddChapters": "Tilføj kapitler",
|
"ButtonAddChapters": "Tilføj kapitler",
|
||||||
"ButtonAddDevice": "Tilføj enhed",
|
"ButtonAddDevice": "Tilføj enhed",
|
||||||
"ButtonAddLibrary": "Tilføj Bibliotek",
|
"ButtonAddLibrary": "Tilføj bibliotek",
|
||||||
"ButtonAddPodcasts": "Tilføj podcasts",
|
"ButtonAddPodcasts": "Tilføj podcasts",
|
||||||
"ButtonAddUser": "Tilføj bruger",
|
"ButtonAddUser": "Tilføj bruger",
|
||||||
"ButtonAddYourFirstLibrary": "Tilføj dit første bibliotek",
|
"ButtonAddYourFirstLibrary": "Tilføj dit første bibliotek",
|
||||||
@@ -11,21 +11,21 @@
|
|||||||
"ButtonApplyChapters": "Anvend kapitler",
|
"ButtonApplyChapters": "Anvend kapitler",
|
||||||
"ButtonAuthors": "Forfattere",
|
"ButtonAuthors": "Forfattere",
|
||||||
"ButtonBack": "Tilbage",
|
"ButtonBack": "Tilbage",
|
||||||
"ButtonBatchEditPopulateFromExisting": "Opret fra eksisterende",
|
"ButtonBatchEditPopulateFromExisting": "Kopiér fra eksisterende",
|
||||||
"ButtonBatchEditPopulateMapDetails": "Opret fra kortlægnings detaljer",
|
"ButtonBatchEditPopulateMapDetails": "Kopiér til masseredigering",
|
||||||
"ButtonBrowseForFolder": "Gennemse mappe",
|
"ButtonBrowseForFolder": "Gennemse mappe",
|
||||||
"ButtonCancel": "Annuller",
|
"ButtonCancel": "Annuller",
|
||||||
"ButtonCancelEncode": "Annuller kodning",
|
"ButtonCancelEncode": "Annuller kodning",
|
||||||
"ButtonChangeRootPassword": "Ændr rodadgangskode",
|
"ButtonChangeRootPassword": "Ændr rodadgangskode",
|
||||||
"ButtonCheckAndDownloadNewEpisodes": "Tjek og download nye episoder",
|
"ButtonCheckAndDownloadNewEpisodes": "Tjek og hent nye afsnit",
|
||||||
"ButtonChooseAFolder": "Vælg en mappe",
|
"ButtonChooseAFolder": "Vælg en mappe",
|
||||||
"ButtonChooseFiles": "Vælg filer",
|
"ButtonChooseFiles": "Vælg filer",
|
||||||
"ButtonClearFilter": "Ryd filter",
|
"ButtonClearFilter": "Ryd filter",
|
||||||
"ButtonClose": "Luk",
|
"ButtonClose": "Luk",
|
||||||
"ButtonCloseFeed": "Luk feed",
|
"ButtonCloseFeed": "Luk feed",
|
||||||
"ButtonCloseSession": "Luk Åben Session",
|
"ButtonCloseSession": "Luk åben session",
|
||||||
"ButtonCollections": "Samlinger",
|
"ButtonCollections": "Samlinger",
|
||||||
"ButtonConfigureScanner": "Konfigurer scanner",
|
"ButtonConfigureScanner": "Konfigurer skanner",
|
||||||
"ButtonCreate": "Opret",
|
"ButtonCreate": "Opret",
|
||||||
"ButtonCreateBackup": "Opret sikkerhedskopi",
|
"ButtonCreateBackup": "Opret sikkerhedskopi",
|
||||||
"ButtonDelete": "Slet",
|
"ButtonDelete": "Slet",
|
||||||
@@ -33,10 +33,10 @@
|
|||||||
"ButtonEdit": "Rediger",
|
"ButtonEdit": "Rediger",
|
||||||
"ButtonEditChapters": "Rediger kapitler",
|
"ButtonEditChapters": "Rediger kapitler",
|
||||||
"ButtonEditPodcast": "Rediger podcast",
|
"ButtonEditPodcast": "Rediger podcast",
|
||||||
"ButtonEnable": "Aktiver",
|
"ButtonEnable": "Aktivér",
|
||||||
"ButtonFireAndFail": "Affyring Og Fejl",
|
"ButtonFireAndFail": "Affyring Og Fejl",
|
||||||
"ButtonFireOnTest": "Affyring vedTest begivenhed",
|
"ButtonFireOnTest": "Udløs onTest-hændelse",
|
||||||
"ButtonForceReScan": "Tving genindlæsning",
|
"ButtonForceReScan": "Tving genskanning",
|
||||||
"ButtonFullPath": "Fuld sti",
|
"ButtonFullPath": "Fuld sti",
|
||||||
"ButtonHide": "Skjul",
|
"ButtonHide": "Skjul",
|
||||||
"ButtonHome": "Hjem",
|
"ButtonHome": "Hjem",
|
||||||
@@ -53,321 +53,321 @@
|
|||||||
"ButtonMatchBooks": "Match bøger",
|
"ButtonMatchBooks": "Match bøger",
|
||||||
"ButtonNevermind": "Glem det",
|
"ButtonNevermind": "Glem det",
|
||||||
"ButtonNext": "Næste",
|
"ButtonNext": "Næste",
|
||||||
"ButtonNextChapter": "Næste Kapitel",
|
"ButtonNextChapter": "Næste kapitel",
|
||||||
"ButtonNextItemInQueue": "Næste Element i Køen",
|
"ButtonNextItemInQueue": "Næste element i køen",
|
||||||
"ButtonOk": "Ok",
|
"ButtonOk": "Ok",
|
||||||
"ButtonOpenFeed": "Åbn feed",
|
"ButtonOpenFeed": "Åbn feed",
|
||||||
"ButtonOpenManager": "Åbn manager",
|
"ButtonOpenManager": "Åbn manager",
|
||||||
"ButtonPause": "Pause",
|
"ButtonPause": "Pause",
|
||||||
"ButtonPlay": "Afspil",
|
"ButtonPlay": "Afspil",
|
||||||
"ButtonPlayAll": "Afspil Alle",
|
"ButtonPlayAll": "Afspil alle",
|
||||||
"ButtonPlaying": "Afspiller",
|
"ButtonPlaying": "Afspiller",
|
||||||
"ButtonPlaylists": "Afspilningslister",
|
"ButtonPlaylists": "Afspilningslister",
|
||||||
"ButtonPrevious": "Sidste",
|
"ButtonPrevious": "Forrige",
|
||||||
"ButtonPreviousChapter": "Sidste Kapitel",
|
"ButtonPreviousChapter": "Forrige kapitel",
|
||||||
"ButtonProbeAudioFile": "Undersøg Lydfil",
|
"ButtonProbeAudioFile": "Undersøg lydfil",
|
||||||
"ButtonPurgeAllCache": "Ryd al cache",
|
"ButtonPurgeAllCache": "Ryd alle mellemlagre",
|
||||||
"ButtonPurgeItemsCache": "Ryd elementcache",
|
"ButtonPurgeItemsCache": "Ryd mediemellemlager",
|
||||||
"ButtonQueueAddItem": "Tilføj til kø",
|
"ButtonQueueAddItem": "Sæt i kø",
|
||||||
"ButtonQueueRemoveItem": "Fjern fra kø",
|
"ButtonQueueRemoveItem": "Fjern fra kø",
|
||||||
"ButtonQuickEmbed": "Hurtig Indlejring",
|
"ButtonQuickEmbed": "Hurtig indlejring",
|
||||||
"ButtonQuickEmbedMetadata": "Hurtig Indlejring af Metadata",
|
"ButtonQuickEmbedMetadata": "Hurtig indlejring af metadata",
|
||||||
"ButtonQuickMatch": "Hurtig Match",
|
"ButtonQuickMatch": "Hurtig Match",
|
||||||
"ButtonReScan": "Gen-scan",
|
"ButtonReScan": "Skan igen",
|
||||||
"ButtonRead": "Læs",
|
"ButtonRead": "Læs",
|
||||||
"ButtonReadLess": "Se mindre",
|
"ButtonReadLess": "Se mindre",
|
||||||
"ButtonReadMore": "Se mere",
|
"ButtonReadMore": "Se mere",
|
||||||
"ButtonRefresh": "Genindlæs",
|
"ButtonRefresh": "Genindlæs",
|
||||||
"ButtonRemove": "Fjern",
|
"ButtonRemove": "Fjern",
|
||||||
"ButtonRemoveAll": "Fjern Alle",
|
"ButtonRemoveAll": "Fjern alle",
|
||||||
"ButtonRemoveAllLibraryItems": "Fjern Alle Bibliotekselementer",
|
"ButtonRemoveAllLibraryItems": "Fjern alle bibliotekselementer",
|
||||||
"ButtonRemoveFromContinueListening": "Fjern fra Fortsæt Lytning",
|
"ButtonRemoveFromContinueListening": "Fjern fra »Fortsæt med at lytte«",
|
||||||
"ButtonRemoveFromContinueReading": "Fjern fra Fortsæt Læsning",
|
"ButtonRemoveFromContinueReading": "Fjern fra »Fortsæt med at læse«",
|
||||||
"ButtonRemoveSeriesFromContinueSeries": "Fjern Serie fra Fortsæt Serie",
|
"ButtonRemoveSeriesFromContinueSeries": "Fjern Serie fra »Fortsæt serie«",
|
||||||
"ButtonReset": "Nulstil",
|
"ButtonReset": "Nulstil",
|
||||||
"ButtonResetToDefault": "Nulstil til standard",
|
"ButtonResetToDefault": "Nulstil til standard",
|
||||||
"ButtonRestore": "Gendan",
|
"ButtonRestore": "Gendan",
|
||||||
"ButtonSave": "Gem",
|
"ButtonSave": "Gem",
|
||||||
"ButtonSaveAndClose": "Gem & Luk",
|
"ButtonSaveAndClose": "Gem og luk",
|
||||||
"ButtonSaveTracklist": "Gem Sporliste",
|
"ButtonSaveTracklist": "Gem sporliste",
|
||||||
"ButtonScan": "Scan",
|
"ButtonScan": "Skan",
|
||||||
"ButtonScanLibrary": "Scan Bibliotek",
|
"ButtonScanLibrary": "Skan bibliotek",
|
||||||
"ButtonScrollLeft": "Rul til Venstre",
|
"ButtonScrollLeft": "Rul til venstre",
|
||||||
"ButtonScrollRight": "Rul til Højre",
|
"ButtonScrollRight": "Rul til højre",
|
||||||
"ButtonSearch": "Søg",
|
"ButtonSearch": "Søg",
|
||||||
"ButtonSelectFolderPath": "Vælg Mappe Sti",
|
"ButtonSelectFolderPath": "Vælg mappesti",
|
||||||
"ButtonSeries": "Serier",
|
"ButtonSeries": "Serier",
|
||||||
"ButtonSetChaptersFromTracks": "Sæt kapitler fra spor",
|
"ButtonSetChaptersFromTracks": "Sæt kapitler fra spor",
|
||||||
"ButtonShare": "Del",
|
"ButtonShare": "Del",
|
||||||
"ButtonShiftTimes": "Skift Tider",
|
"ButtonShiftTimes": "Forskyd tider",
|
||||||
"ButtonShow": "Vis",
|
"ButtonShow": "Vis",
|
||||||
"ButtonStartM4BEncode": "Start M4B Kode",
|
"ButtonStartM4BEncode": "Start konvertering til M4B",
|
||||||
"ButtonStartMetadataEmbed": "Start Metadata Indlejring",
|
"ButtonStartMetadataEmbed": "Start metadataindlejring",
|
||||||
"ButtonStats": "Statistik",
|
"ButtonStats": "Statistik",
|
||||||
"ButtonSubmit": "Send",
|
"ButtonSubmit": "Send",
|
||||||
"ButtonTest": "Test",
|
"ButtonTest": "Test",
|
||||||
"ButtonUnlinkOpenId": "Afkobl OpenID",
|
"ButtonUnlinkOpenId": "Afkobl OpenID",
|
||||||
"ButtonUpload": "Upload",
|
"ButtonUpload": "Overfør",
|
||||||
"ButtonUploadBackup": "Upload Backup",
|
"ButtonUploadBackup": "Overfør sikkerhedskopi",
|
||||||
"ButtonUploadCover": "Upload Omslag",
|
"ButtonUploadCover": "Overfør omslag",
|
||||||
"ButtonUploadOPMLFile": "Upload OPML Fil",
|
"ButtonUploadOPMLFile": "Overfør OPML-fil",
|
||||||
"ButtonUserDelete": "Slet bruger {0}",
|
"ButtonUserDelete": "Slet bruger {0}",
|
||||||
"ButtonUserEdit": "Rediger bruger {0}",
|
"ButtonUserEdit": "Rediger bruger {0}",
|
||||||
"ButtonViewAll": "Vis Alle",
|
"ButtonViewAll": "Vis alle",
|
||||||
"ButtonYes": "Ja",
|
"ButtonYes": "Ja",
|
||||||
"ErrorUploadFetchMetadataAPI": "Fejl henter metadata",
|
"ErrorUploadFetchMetadataAPI": "Fejl ved hentning af metadata",
|
||||||
"ErrorUploadFetchMetadataNoResults": "Kunne ikke hente metadata - prøv at uploade title og/eller forfatter",
|
"ErrorUploadFetchMetadataNoResults": "Kunne ikke hente metadata - prøv at ændre titel og/eller forfatter",
|
||||||
"ErrorUploadLacksTitle": "Skal have en title",
|
"ErrorUploadLacksTitle": "Titel kræves",
|
||||||
"HeaderAccount": "Konto",
|
"HeaderAccount": "Konto",
|
||||||
"HeaderAddCustomMetadataProvider": "Tilføj Brugerdefineret Metadataudbyder",
|
"HeaderAddCustomMetadataProvider": "Tilføj brugerdefineret metadataudbyder",
|
||||||
"HeaderAdvanced": "Avanceret",
|
"HeaderAdvanced": "Avanceret",
|
||||||
"HeaderApiKeys": "API-nøgler",
|
"HeaderApiKeys": "API-nøgler",
|
||||||
"HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger",
|
"HeaderAppriseNotificationSettings": "Apprise-notifikationsindstillinger",
|
||||||
"HeaderAudioTracks": "Lydspor",
|
"HeaderAudioTracks": "Lydspor",
|
||||||
"HeaderAudiobookTools": "Audiobog Filhåndteringsværktøjer",
|
"HeaderAudiobookTools": "Håndtering af lydbogsfiler",
|
||||||
"HeaderAuthentication": "Autentificering",
|
"HeaderAuthentication": "Loginindstillinger",
|
||||||
"HeaderBackups": "Sikkerhedskopier",
|
"HeaderBackups": "Sikkerhedskopier",
|
||||||
"HeaderBulkChapterModal": "Tilføj flere kapitler",
|
"HeaderBulkChapterModal": "Tilføj flere kapitler",
|
||||||
"HeaderChangePassword": "Skift Adgangskode",
|
"HeaderChangePassword": "Skift adgangskode",
|
||||||
"HeaderChapters": "Kapitler",
|
"HeaderChapters": "Kapitler",
|
||||||
"HeaderChooseAFolder": "Vælg en Mappe",
|
"HeaderChooseAFolder": "Vælg en mappe",
|
||||||
"HeaderCollection": "Samling",
|
"HeaderCollection": "Samling",
|
||||||
"HeaderCollectionItems": "Samlingselementer",
|
"HeaderCollectionItems": "Bøger i samlingen",
|
||||||
"HeaderCover": "Omslag",
|
"HeaderCover": "Omslag",
|
||||||
"HeaderCurrentDownloads": "Nuværende Downloads",
|
"HeaderCurrentDownloads": "Aktuelle downloads",
|
||||||
"HeaderCustomMessageOnLogin": "Brugerdefineret Besked ved Login",
|
"HeaderCustomMessageOnLogin": "Brugerdefineret besked ved logind",
|
||||||
"HeaderCustomMetadataProviders": "Brugerdefineret Metadataudbyder",
|
"HeaderCustomMetadataProviders": "Brugerdefineret metadataudbyder",
|
||||||
"HeaderDetails": "Detaljer",
|
"HeaderDetails": "Detaljer",
|
||||||
"HeaderDownloadQueue": "Download Kø",
|
"HeaderDownloadQueue": "Downloadkø",
|
||||||
"HeaderEbookFiles": "E-bogsfiler",
|
"HeaderEbookFiles": "E-bogsfiler",
|
||||||
"HeaderEmail": "Email",
|
"HeaderEmail": "Email",
|
||||||
"HeaderEmailSettings": "Email Indstillinger",
|
"HeaderEmailSettings": "Emailindstillinger",
|
||||||
"HeaderEpisodes": "Episoder",
|
"HeaderEpisodes": "Afsnit",
|
||||||
"HeaderEreaderDevices": "E-læser Enheder",
|
"HeaderEreaderDevices": "E-bogslæsere",
|
||||||
"HeaderEreaderSettings": "E-læser Indstillinger",
|
"HeaderEreaderSettings": "Indstillinger for e-bogslæsere",
|
||||||
"HeaderFiles": "Filer",
|
"HeaderFiles": "Filer",
|
||||||
"HeaderFindChapters": "Find Kapitler",
|
"HeaderFindChapters": "Find kapitler",
|
||||||
"HeaderIgnoredFiles": "Ignorerede Filer",
|
"HeaderIgnoredFiles": "Ignorerede filer",
|
||||||
"HeaderItemFiles": "Emnefiler",
|
"HeaderItemFiles": "Filer",
|
||||||
"HeaderItemMetadataUtils": "Emne Metadata Værktøjer",
|
"HeaderItemMetadataUtils": "Metadata-værktøjer",
|
||||||
"HeaderLastListeningSession": "Seneste Lyttesession",
|
"HeaderLastListeningSession": "Seneste lyttesession",
|
||||||
"HeaderLatestEpisodes": "Seneste episoder",
|
"HeaderLatestEpisodes": "Nyeste afsnit",
|
||||||
"HeaderLibraries": "Biblioteker",
|
"HeaderLibraries": "Biblioteker",
|
||||||
"HeaderLibraryFiles": "Biblioteksfiler",
|
"HeaderLibraryFiles": "Biblioteksfiler",
|
||||||
"HeaderLibraryStats": "Biblioteksstatistik",
|
"HeaderLibraryStats": "Biblioteksstatistik",
|
||||||
"HeaderListeningSessions": "Lyttesessioner",
|
"HeaderListeningSessions": "Lyttesessioner",
|
||||||
"HeaderListeningStats": "Lyttestatistik",
|
"HeaderListeningStats": "Lyttestatistik",
|
||||||
"HeaderLogin": "Log ind",
|
"HeaderLogin": "Log ind",
|
||||||
"HeaderLogs": "Logs",
|
"HeaderLogs": "Logfiler",
|
||||||
"HeaderManageGenres": "Administrer Genrer",
|
"HeaderManageGenres": "Administrer genrer",
|
||||||
"HeaderManageTags": "Administrer Tags",
|
"HeaderManageTags": "Administrer emneord",
|
||||||
"HeaderMapDetails": "Kort Detaljer",
|
"HeaderMapDetails": "Masseredigering",
|
||||||
"HeaderMatch": "Match",
|
"HeaderMatch": "Find match",
|
||||||
"HeaderMetadataOrderOfPrecedence": "Metadata-prioritet",
|
"HeaderMetadataOrderOfPrecedence": "Metadata-prioritet",
|
||||||
"HeaderMetadataToEmbed": "Metadata til indlejring",
|
"HeaderMetadataToEmbed": "Metadata til indlejring",
|
||||||
"HeaderNewAccount": "Ny Konto",
|
"HeaderNewAccount": "Ny konto",
|
||||||
"HeaderNewApiKey": "Ny API-nøgle",
|
"HeaderNewApiKey": "Ny API-nøgle",
|
||||||
"HeaderNewLibrary": "Nyt Bibliotek",
|
"HeaderNewLibrary": "Nyt bibliotek",
|
||||||
"HeaderNotificationCreate": "Opret Notifikation",
|
"HeaderNotificationCreate": "Opret notifikation",
|
||||||
"HeaderNotificationUpdate": "Updater Notifikation",
|
"HeaderNotificationUpdate": "Updater Notifikation",
|
||||||
"HeaderNotifications": "Meddelelser",
|
"HeaderNotifications": "Meddelelser",
|
||||||
"HeaderOpenIDConnectAuthentication": "OpenID Connect-autentificering",
|
"HeaderOpenIDConnectAuthentication": "Log ind med OpenID Connect",
|
||||||
"HeaderOpenListeningSessions": "Åbne lyttesessioner",
|
"HeaderOpenListeningSessions": "Åbne lyttesessioner",
|
||||||
"HeaderOpenRSSFeed": "Åbn RSS Feed",
|
"HeaderOpenRSSFeed": "Åbn RSS-feed",
|
||||||
"HeaderOtherFiles": "Andre Filer",
|
"HeaderOtherFiles": "Andre filer",
|
||||||
"HeaderPasswordAuthentication": "Adgangskodeautentificering",
|
"HeaderPasswordAuthentication": "Log ind med adgangskode",
|
||||||
"HeaderPermissions": "Tilladelser",
|
"HeaderPermissions": "Tilladelser",
|
||||||
"HeaderPlayerQueue": "Afspilningskø",
|
"HeaderPlayerQueue": "Afspilningskø",
|
||||||
"HeaderPlayerSettings": "Afspiller Indstillinger",
|
"HeaderPlayerSettings": "Afspilningsindstillinger",
|
||||||
"HeaderPlaylist": "Afspilningsliste",
|
"HeaderPlaylist": "Afspilningsliste",
|
||||||
"HeaderPlaylistItems": "Afspilningsliste Elementer",
|
"HeaderPlaylistItems": "Elementer i afspilningslisten",
|
||||||
"HeaderPodcastsToAdd": "Podcasts til Tilføjelse",
|
"HeaderPodcastsToAdd": "Podcasts som skal tilføjes",
|
||||||
"HeaderPresets": "Forudindstillinger",
|
"HeaderPresets": "Forudindstillinger",
|
||||||
"HeaderPreviewCover": "Forhåndsvis Omslag",
|
"HeaderPreviewCover": "Forhåndsvis omslag",
|
||||||
"HeaderRSSFeedGeneral": "RSS Detaljer",
|
"HeaderRSSFeedGeneral": "RSS-detaljer",
|
||||||
"HeaderRSSFeedIsOpen": "RSS Feed er Åben",
|
"HeaderRSSFeedIsOpen": "RSS-feedet er åbent",
|
||||||
"HeaderRSSFeeds": "RSS-Feeds",
|
"HeaderRSSFeeds": "RSS-feeds",
|
||||||
"HeaderRemoveEpisode": "Fjern Episode",
|
"HeaderRemoveEpisode": "Fjern afsnit",
|
||||||
"HeaderRemoveEpisodes": "Fjern {0} Episoder",
|
"HeaderRemoveEpisodes": "Fjern {0} afsnit",
|
||||||
"HeaderSavedMediaProgress": "Gemt Medieforløb",
|
"HeaderSavedMediaProgress": "Gemt lyttefremgang",
|
||||||
"HeaderSchedule": "Planlæg",
|
"HeaderSchedule": "Planlæg",
|
||||||
"HeaderScheduleEpisodeDownloads": "Planlæg Automatisk Episode-Download",
|
"HeaderScheduleEpisodeDownloads": "Planlæg automatisk hentning af afsnit",
|
||||||
"HeaderScheduleLibraryScans": "Planlæg Automatiske Biblioteksscanninger",
|
"HeaderScheduleLibraryScans": "Planlæg automatisk biblioteksskanning",
|
||||||
"HeaderSession": "Session",
|
"HeaderSession": "Session",
|
||||||
"HeaderSetBackupSchedule": "Indstil Sikkerhedskopieringsplan",
|
"HeaderSetBackupSchedule": "Indstil tidsplan for sikkerhedskopiering",
|
||||||
"HeaderSettings": "Indstillinger",
|
"HeaderSettings": "Indstillinger",
|
||||||
"HeaderSettingsDisplay": "Skærm",
|
"HeaderSettingsDisplay": "Visning",
|
||||||
"HeaderSettingsExperimental": "Eksperimentelle Funktioner",
|
"HeaderSettingsExperimental": "Eksperimentelle funktioner",
|
||||||
"HeaderSettingsGeneral": "Generelt",
|
"HeaderSettingsGeneral": "Generelt",
|
||||||
"HeaderSettingsScanner": "Scanner",
|
"HeaderSettingsScanner": "Skanner",
|
||||||
"HeaderSettingsSecurity": "Sikkerhed",
|
"HeaderSettingsSecurity": "Sikkerhed",
|
||||||
"HeaderSettingsWebClient": "Webklient",
|
"HeaderSettingsWebClient": "Webklient",
|
||||||
"HeaderSleepTimer": "Søvntimer",
|
"HeaderSleepTimer": "Sovefunktion",
|
||||||
"HeaderStatsLargestItems": "Største Elementer",
|
"HeaderStatsLargestItems": "Største bøger",
|
||||||
"HeaderStatsLongestItems": "Længste Elementer (timer)",
|
"HeaderStatsLongestItems": "Længste bøger (timer)",
|
||||||
"HeaderStatsMinutesListeningChart": "Minutter Lyttet (sidste 7 dage)",
|
"HeaderStatsMinutesListeningChart": "Minutter lyttet (sidste 7 dage)",
|
||||||
"HeaderStatsRecentSessions": "Seneste Sessions",
|
"HeaderStatsRecentSessions": "Seneste sessioner",
|
||||||
"HeaderStatsTop10Authors": "Top 10 Forfattere",
|
"HeaderStatsTop10Authors": "Top 10 forfattere",
|
||||||
"HeaderStatsTop5Genres": "Top 5 Genrer",
|
"HeaderStatsTop5Genres": "Top 5 genrer",
|
||||||
"HeaderTableOfContents": "Indholdsfortegnelse",
|
"HeaderTableOfContents": "Indholdsfortegnelse",
|
||||||
"HeaderTools": "Værktøjer",
|
"HeaderTools": "Værktøjer",
|
||||||
"HeaderUpdateAccount": "Opdater Konto",
|
"HeaderUpdateAccount": "Opdater konto",
|
||||||
"HeaderUpdateApiKey": "Opdater API-nøgle",
|
"HeaderUpdateApiKey": "Opdater API-nøgle",
|
||||||
"HeaderUpdateAuthor": "Opdater Forfatter",
|
"HeaderUpdateAuthor": "Opdater forfatter",
|
||||||
"HeaderUpdateDetails": "Opdater Detaljer",
|
"HeaderUpdateDetails": "Opdater detaljer",
|
||||||
"HeaderUpdateLibrary": "Opdater Bibliotek",
|
"HeaderUpdateLibrary": "Opdater bibliotek",
|
||||||
"HeaderUsers": "Brugere",
|
"HeaderUsers": "Brugere",
|
||||||
"HeaderYearReview": "Gennemgang af År {0}",
|
"HeaderYearReview": "Året {0} opsummeret",
|
||||||
"HeaderYourStats": "Dine Statistikker",
|
"HeaderYourStats": "Din statistik",
|
||||||
"LabelAbridged": "Forkortet",
|
"LabelAbridged": "Forkortet udgave",
|
||||||
"LabelAbridgedChecked": "Forkortet (kontrolleret)",
|
"LabelAbridgedChecked": "Forkortet udgave (bekræftet)",
|
||||||
"LabelAbridgedUnchecked": "Uforkortet (ikke kontrolleret)",
|
"LabelAbridgedUnchecked": "Uforkortet udgave (ikke bekræftet)",
|
||||||
"LabelAccessibleBy": "Tilgængelig af",
|
"LabelAccessibleBy": "Tilgængelig for",
|
||||||
"LabelAccountType": "Brugertype",
|
"LabelAccountType": "Kontotype",
|
||||||
"LabelAccountTypeAdmin": "Administrator",
|
"LabelAccountTypeAdmin": "Administrator",
|
||||||
"LabelAccountTypeGuest": "Gæst",
|
"LabelAccountTypeGuest": "Gæst",
|
||||||
"LabelAccountTypeUser": "Bruger",
|
"LabelAccountTypeUser": "Bruger",
|
||||||
"LabelActivities": "Aktiviteter",
|
"LabelActivities": "Aktiviteter",
|
||||||
"LabelActivity": "Aktivitet",
|
"LabelActivity": "Aktivitet",
|
||||||
"LabelAddToCollection": "Tilføj til Samling",
|
"LabelAddToCollection": "Tilføj til samling",
|
||||||
"LabelAddToCollectionBatch": "Tilføj {0} Bøger til Samling",
|
"LabelAddToCollectionBatch": "Tilføj {0} bøger til samling",
|
||||||
"LabelAddToPlaylist": "Tilføj til Afspilningsliste",
|
"LabelAddToPlaylist": "Tilføj til afspilningsliste",
|
||||||
"LabelAddToPlaylistBatch": "Tilføj {0} Elementer til Afspilningsliste",
|
"LabelAddToPlaylistBatch": "Tilføj {0} elementer til afspilningsliste",
|
||||||
"LabelAddedAt": "Tilføjet",
|
"LabelAddedAt": "Tilføjet",
|
||||||
"LabelAddedDate": "Tilføjet {0}",
|
"LabelAddedDate": "Tilføjede {0}",
|
||||||
"LabelAdminUsersOnly": "Kun Administratorer",
|
"LabelAdminUsersOnly": "Kun administratorer",
|
||||||
"LabelAll": "Alle",
|
"LabelAll": "Alle",
|
||||||
"LabelAllEpisodesDownloaded": "Alle episoder hentet",
|
"LabelAllEpisodesDownloaded": "Alle afsnit hentet",
|
||||||
"LabelAllUsers": "Alle Brugere",
|
"LabelAllUsers": "Alle brugere",
|
||||||
"LabelAllUsersExcludingGuests": "Alle bruger eksklusiv gæster",
|
"LabelAllUsersExcludingGuests": "Alle brugere, bortset fra gæster",
|
||||||
"LabelAllUsersIncludingGuests": "Alle bruger inklusiv gæster",
|
"LabelAllUsersIncludingGuests": "Alle brugere, også gæster",
|
||||||
"LabelAlreadyInYourLibrary": "Allerede i dit bibliotek",
|
"LabelAlreadyInYourLibrary": "Allerede i dit bibliotek",
|
||||||
"LabelApiKeyCreated": "API-nøgle\"{0}\" oprettet succesfuldt.",
|
"LabelApiKeyCreated": "API-nøglen \"{0}\" blev oprettet.",
|
||||||
"LabelApiKeyCreatedDescription": "Sørg for at kopiere API-nøglen nu, da du ikke vil kunne se den igen.",
|
"LabelApiKeyCreatedDescription": "Sørg for at kopiere API-nøglen nu, da du ikke vil kunne se den igen.",
|
||||||
"LabelApiKeyUser": "Ret på vegne af brugeren",
|
"LabelApiKeyUser": "Handl på vegne af bruger",
|
||||||
"LabelApiKeyUserDescription": "Denne API-nøgle vil have de samme tilladelser som den bruger, den handler på vegne af. Dette vil fremgå på samme måde i logfiler, som hvis brugeren foretog anmodningen.",
|
"LabelApiKeyUserDescription": "Denne API-nøgle vil have de samme tilladelser som den bruger, den handler på vegne af. Dette vil fremgå på samme måde i logfiler, som hvis brugeren selv foretog anmodningen.",
|
||||||
"LabelApiToken": "API Token",
|
"LabelApiToken": "API-nøgle",
|
||||||
"LabelAppend": "Tilføj",
|
"LabelAppend": "Vedhæft",
|
||||||
"LabelAudioBitrate": "Lydbitrate (f.eks. 128k)",
|
"LabelAudioBitrate": "Lydbitrate (f.eks. 128k)",
|
||||||
"LabelAudioChannels": "Lydkanaler (1 eller 2)",
|
"LabelAudioChannels": "Lydkanaler (1 eller 2)",
|
||||||
"LabelAudioCodec": "Lydkodek",
|
"LabelAudioCodec": "Lyd-codec",
|
||||||
"LabelAuthor": "Forfatter",
|
"LabelAuthor": "Forfatter",
|
||||||
"LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)",
|
"LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)",
|
||||||
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
|
"LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)",
|
||||||
"LabelAuthors": "Forfattere",
|
"LabelAuthors": "Forfattere",
|
||||||
"LabelAutoDownloadEpisodes": "Auto Download Episoder",
|
"LabelAutoDownloadEpisodes": "Hent automatisk afsnit",
|
||||||
"LabelAutoFetchMetadata": "Automatisk Hent Metadata",
|
"LabelAutoFetchMetadata": "Hent automatisk metadata",
|
||||||
"LabelAutoFetchMetadataHelp": "Henter metadata for titler, forfatter og serier for at strømligne uploading. Ekstra metadata har måske brug for at blive matchet efter upload.",
|
"LabelAutoFetchMetadataHelp": "Henter metadata for titler, forfatter og serie for at strømline uploading. Ekstra metadata har måske brug for at blive matchet efter upload.",
|
||||||
"LabelAutoLaunch": "Åben Automatisk",
|
"LabelAutoLaunch": "Automatisk start",
|
||||||
"LabelAutoLaunchDescription": "Viderestil automatisk til login-udbyderen ved navigation til login-siden (manuel overstyring via <code>/login?autoLaunch=0</code>)",
|
"LabelAutoLaunchDescription": "Viderestil automatisk til login-udbyderen ved navigation til login-siden (manuel overstyring via <code>/login?autoLaunch=0</code>)",
|
||||||
"LabelAutoRegister": "Registrer Automatisk",
|
"LabelAutoRegister": "Registrer automatisk",
|
||||||
"LabelAutoRegisterDescription": "Automatisk oprettelse af nye brugere efter login",
|
"LabelAutoRegisterDescription": "Automatisk oprettelse af nye brugere efter login",
|
||||||
"LabelBackToUser": "Tilbage til Bruger",
|
"LabelBackToUser": "Tilbage til bruger",
|
||||||
"LabelBackupAudioFiles": "Sikkerhedskopier lydfiler",
|
"LabelBackupAudioFiles": "Sikkerhedskopiér lydfiler",
|
||||||
"LabelBackupLocation": "Backup Placering",
|
"LabelBackupLocation": "Placering af sikkerhedskopi",
|
||||||
"LabelBackupsEnableAutomaticBackups": "Automatisk sikkerhedskopiering",
|
"LabelBackupsEnableAutomaticBackups": "Automatisk sikkerhedskopiering",
|
||||||
"LabelBackupsEnableAutomaticBackupsHelp": "Sikkerhedskopier gemt i /metadata/backups",
|
"LabelBackupsEnableAutomaticBackupsHelp": "Sikkerhedskopier gemt i /metadata/backups",
|
||||||
"LabelBackupsMaxBackupSize": "Maksimal sikkerhedskopistørrelse (i GB) (0 for ubegrænset)",
|
"LabelBackupsMaxBackupSize": "Maksimal sikkerhedskopistørrelse (i GB) (0 for ubegrænset)",
|
||||||
"LabelBackupsMaxBackupSizeHelp": "Som en beskyttelse mod fejlkonfiguration fejler sikkerhedskopier, hvis de overstiger den konfigurerede størrelse.",
|
"LabelBackupsMaxBackupSizeHelp": "Som en beskyttelse mod fejlkonfiguration fejler sikkerhedskopier, hvis de overstiger den konfigurerede størrelse.",
|
||||||
"LabelBackupsNumberToKeep": "Antal sikkerhedskopier at beholde",
|
"LabelBackupsNumberToKeep": "Antal sikkerhedskopier som skal beholdes",
|
||||||
"LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.",
|
"LabelBackupsNumberToKeepHelp": "Kun én sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.",
|
||||||
"LabelBitrate": "Bitrate",
|
"LabelBitrate": "Bitrate",
|
||||||
"LabelBonus": "Bonus",
|
"LabelBonus": "Bonus",
|
||||||
"LabelBooks": "Bøger",
|
"LabelBooks": "Bøger",
|
||||||
"LabelButtonText": "Knap tekst",
|
"LabelButtonText": "Tekst på knap",
|
||||||
"LabelByAuthor": "Efter Forfatter",
|
"LabelByAuthor": "af {0}",
|
||||||
"LabelChangePassword": "Ændre Adgangskode",
|
"LabelChangePassword": "Ændr adgangskode",
|
||||||
"LabelChannels": "Kanaler",
|
"LabelChannels": "Kanaler",
|
||||||
"LabelChapterCount": "{0} Kapitler",
|
"LabelChapterCount": "{0} kapitler",
|
||||||
"LabelChapterTitle": "Kapitel Titel",
|
"LabelChapterTitle": "Kapiteltitel",
|
||||||
"LabelChapters": "Kapitler",
|
"LabelChapters": "Kapitler",
|
||||||
"LabelChaptersFound": "fundne kapitler",
|
"LabelChaptersFound": "kapitler fundet",
|
||||||
"LabelClickForMoreInfo": "Klik for mere info",
|
"LabelClickForMoreInfo": "Klik for mere info",
|
||||||
"LabelClickToUseCurrentValue": "Klik for at bruge nuværende værdi",
|
"LabelClickToUseCurrentValue": "Klik for at bruge nuværende værdi",
|
||||||
"LabelClosePlayer": "Luk afspiller",
|
"LabelClosePlayer": "Luk afspiller",
|
||||||
"LabelCodec": "Kodeks",
|
"LabelCodec": "Codec",
|
||||||
"LabelCollapseSeries": "Fold Serier Sammen",
|
"LabelCollapseSeries": "Fold serier sammen",
|
||||||
"LabelCollapseSubSeries": "Fold underserie sammen",
|
"LabelCollapseSubSeries": "Fold underserie sammen",
|
||||||
"LabelCollection": "Samling",
|
"LabelCollection": "Samling",
|
||||||
"LabelCollections": "Samlinger",
|
"LabelCollections": "Samlinger",
|
||||||
"LabelComplete": "Fuldfør",
|
"LabelComplete": "Afsluttet",
|
||||||
"LabelConfirmPassword": "Bekræft Adgangskode",
|
"LabelConfirmPassword": "Bekræft adgangskode",
|
||||||
"LabelContinueListening": "Fortsæt med at lytte",
|
"LabelContinueListening": "Fortsæt med at lytte",
|
||||||
"LabelContinueReading": "Fortsæt med at læse",
|
"LabelContinueReading": "Fortsæt med at læse",
|
||||||
"LabelContinueSeries": "Fortsæt Serien",
|
"LabelContinueSeries": "Fortsæt serien",
|
||||||
"LabelCorsAllowed": "Tilladte CORS-oprindelser",
|
"LabelCorsAllowed": "Tilladte CORS-oprindelser",
|
||||||
"LabelCover": "Omslag",
|
"LabelCover": "Omslag",
|
||||||
"LabelCoverImageURL": "Omslagsbillede URL",
|
"LabelCoverImageURL": "Omslagsbilledes-URL",
|
||||||
"LabelCoverProvider": "Cover billede udbyder",
|
"LabelCoverProvider": "Kilde til omslagsbillede",
|
||||||
"LabelCreatedAt": "Oprettet d.",
|
"LabelCreatedAt": "Oprettet",
|
||||||
"LabelCronExpression": "Cron Udtryk",
|
"LabelCronExpression": "Cron-kommando",
|
||||||
"LabelCurrent": "Aktuel",
|
"LabelCurrent": "Aktuel",
|
||||||
"LabelCurrently": "Aktuelt:",
|
"LabelCurrently": "Nuværende:",
|
||||||
"LabelCustomCronExpression": "Brugerdefineret Cron Udtryk:",
|
"LabelCustomCronExpression": "Brugerdefineret Cron-kommando:",
|
||||||
"LabelDatetime": "Dato og Tid",
|
"LabelDatetime": "Dato og tid",
|
||||||
"LabelDays": "Dage",
|
"LabelDays": "Dage",
|
||||||
"LabelDeleteFromFileSystemCheckbox": "Slet fra filsystem (afmarker kun for at fjerne fra databasen)",
|
"LabelDeleteFromFileSystemCheckbox": "Slet fra filsystem (afmarkér for udelukkende at fjerne fra databasen)",
|
||||||
"LabelDescription": "Beskrivelse",
|
"LabelDescription": "Beskrivelse",
|
||||||
"LabelDeselectAll": "Fravælg Alle",
|
"LabelDeselectAll": "Fravælg alle",
|
||||||
"LabelDetectedPattern": "Identificeret mønster:",
|
"LabelDetectedPattern": "Identificeret mønster:",
|
||||||
"LabelDevice": "Enheds",
|
"LabelDevice": "Enhed",
|
||||||
"LabelDeviceInfo": "Enhedsinformation",
|
"LabelDeviceInfo": "Enhedsinformation",
|
||||||
"LabelDeviceIsAvailableTo": "Enhed er tilgængelig for...",
|
"LabelDeviceIsAvailableTo": "Enheden er tilgængelig for...",
|
||||||
"LabelDirectory": "Mappe",
|
"LabelDirectory": "Mappe",
|
||||||
"LabelDiscFromFilename": "Disk fra Filnavn",
|
"LabelDiscFromFilename": "Disk fra filnavn",
|
||||||
"LabelDiscFromMetadata": "Disk fra Metadata",
|
"LabelDiscFromMetadata": "Disk fra metadata",
|
||||||
"LabelDiscover": "Opdag",
|
"LabelDiscover": "Opdag",
|
||||||
"LabelDownload": "Download",
|
"LabelDownload": "Hent",
|
||||||
"LabelDownloadNEpisodes": "Download {0} episoder",
|
"LabelDownloadNEpisodes": "Hent {0} afsnit",
|
||||||
"LabelDownloadable": "Downloadbar",
|
"LabelDownloadable": "Kan hentes",
|
||||||
"LabelDuration": "Varighed",
|
"LabelDuration": "Varighed",
|
||||||
"LabelDurationComparisonExactMatch": "(præcis match)",
|
"LabelDurationComparisonExactMatch": "(præcist match)",
|
||||||
"LabelDurationComparisonLonger": "({0} længere)",
|
"LabelDurationComparisonLonger": "({0} længere)",
|
||||||
"LabelDurationComparisonShorter": "({0} kortere)",
|
"LabelDurationComparisonShorter": "({0} kortere)",
|
||||||
"LabelDurationFound": "Fundet varighed:",
|
"LabelDurationFound": "Fundet varighed:",
|
||||||
"LabelEbook": "E-bog",
|
"LabelEbook": "E-bog",
|
||||||
"LabelEbooks": "E-bøger",
|
"LabelEbooks": "E-bøger",
|
||||||
"LabelEdit": "Rediger",
|
"LabelEdit": "Rediger",
|
||||||
"LabelEmail": "E-mail",
|
"LabelEmail": "Email",
|
||||||
"LabelEmailSettingsFromAddress": "Fra Adresse",
|
"LabelEmailSettingsFromAddress": "Afsenderadresse",
|
||||||
"LabelEmailSettingsRejectUnauthorized": "Afvis uautoriserede certifikater",
|
"LabelEmailSettingsRejectUnauthorized": "Afvis ukendte sikkerhedscertifikater",
|
||||||
"LabelEmailSettingsRejectUnauthorizedHelp": "Deaktivering af SSL certifikat validering kan udsætte din forbindelse for sikkerhedsrisici, eksempelvis man-in-the-middle angreb. Deaktiver kun denne indstilling hvis du forstår de potentielle implikationer og stoler på den mailserver du forbinder til.",
|
"LabelEmailSettingsRejectUnauthorizedHelp": "Hvis du slår SSL-certifikatkontrollen fra, risikerer du at uvedkommende kan opsnappe dine data. Slå kun dette fra, hvis du er helt tryg ved situationen og har fuld tillid til den mailserver, du forbinder til.",
|
||||||
"LabelEmailSettingsSecure": "Sikker",
|
"LabelEmailSettingsSecure": "Sikker forbindelse",
|
||||||
"LabelEmailSettingsSecureHelp": "Hvis sandt, vil forbindelsen bruge TLS ved tilslutning til serveren. Hvis falsk, bruges TLS, hvis serveren understøtter STARTTLS-udvidelsen. I de fleste tilfælde skal denne værdi sættes til sandt, hvis du tilslutter til port 465. Til port 587 eller 25 skal du holde det falsk. (fra nodemailer.com/smtp/#authentication)",
|
"LabelEmailSettingsSecureHelp": "Når denne er slået til, bruger forbindelsen altid fuld kryptering (TLS). Hvis den er slået fra, bruges kryptering kun, hvis serveren tillader det (via STARTTLS). Lad dette være slået til, hvis du bruger port 465. Slå det fra, hvis du bruger port 587 eller 25",
|
||||||
"LabelEmailSettingsTestAddress": "Test Adresse",
|
"LabelEmailSettingsTestAddress": "Modtageradresse til test",
|
||||||
"LabelEmbeddedCover": "Indlejret Omslag",
|
"LabelEmbeddedCover": "Indlejret omslag",
|
||||||
"LabelEnable": "Aktivér",
|
"LabelEnable": "Aktivér",
|
||||||
"LabelEncodingBackupLocation": "En sikkerhedskopi af dine originale lydfiler vil blive gemt under:",
|
"LabelEncodingBackupLocation": "En sikkerhedskopi af dine originale lydfiler vil blive gemt i mappen:",
|
||||||
"LabelEncodingChaptersNotEmbedded": "Kapitler er ikke indlejret i multi spors lydbøger.",
|
"LabelEncodingChaptersNotEmbedded": "Kapitler bliver ikke indlejret i lydbøger som består af flere lydfiler.",
|
||||||
"LabelEncodingClearItemCache": "Sørg for periodisk at rense indholdscachen.",
|
"LabelEncodingClearItemCache": "Sørg for jævnligt at rense mellemlageret.",
|
||||||
"LabelEncodingFinishedM4B": "Færdiggjort M4B som vil blive placeret i din lydbogsmappe ved:",
|
"LabelEncodingFinishedM4B": "Den færdige M4B-fil vil blive placeret i lydbogsmappen:",
|
||||||
"LabelEncodingInfoEmbedded": "Metadata vil blive indlejret i lydfiler i lydbogsmappen.",
|
"LabelEncodingInfoEmbedded": "Metadata vil blive indlejret i lydfilerne i lydbogsmappen.",
|
||||||
"LabelEncodingStartedNavigation": "Når opgaven er startet kan du navigere væk fra denne side.",
|
"LabelEncodingStartedNavigation": "Når opgaven er sat i gang kan du navigere væk fra denne side.",
|
||||||
"LabelEncodingTimeWarning": "Indkodning kan tage op til 30 minutter.",
|
"LabelEncodingTimeWarning": "Omkodningen kan tage op til 30 minutter.",
|
||||||
"LabelEncodingWarningAdvancedSettings": "Advarsel: Opdater ikke disse indstillinger med mindre du kender til ffmpeg indkodningsindstillinger.",
|
"LabelEncodingWarningAdvancedSettings": "Advarsel: Ændr ikke disse indstillinger med mindre du kender til ffmpeg-indkodningsindstillinger.",
|
||||||
"LabelEncodingWatcherDisabled": "Hvis du har watcheren deaktiveret skal du gen-scanne denne lydbog bagefter.",
|
"LabelEncodingWatcherDisabled": "Hvis du har slået automatisk biblioteksovervågning fra skal du genskanne denne lydbog bagefter.",
|
||||||
"LabelEnd": "Slut",
|
"LabelEnd": "Slut",
|
||||||
"LabelEndOfChapter": "Slutningen af kapitel",
|
"LabelEndOfChapter": "Slutningen af kapitel",
|
||||||
"LabelEpisode": "Afsnit",
|
"LabelEpisode": "Afsnit",
|
||||||
"LabelEpisodeNotLinkedToRssFeed": "Afsnit er ikke koblet til RSS feed",
|
"LabelEpisodeNotLinkedToRssFeed": "Afsnit er ikke koblet til RSS-feed",
|
||||||
"LabelEpisodeNumber": "Afsnit #{0}",
|
"LabelEpisodeNumber": "Afsnit #{0}",
|
||||||
"LabelEpisodeTitle": "Episodetitel",
|
"LabelEpisodeTitle": "Afsnitstitel",
|
||||||
"LabelEpisodeType": "Episodetype",
|
"LabelEpisodeType": "Afsnitstype",
|
||||||
"LabelEpisodeUrlFromRssFeed": "Afsnit URL fra RSS feed",
|
"LabelEpisodeUrlFromRssFeed": "Afsnits-URL fra RSS-feed",
|
||||||
"LabelEpisodes": "Afsnit",
|
"LabelEpisodes": "Afsnit",
|
||||||
"LabelEpisodic": "Afsnit",
|
"LabelEpisodic": "Episodisk",
|
||||||
"LabelExample": "Eksempel",
|
"LabelExample": "Eksempel",
|
||||||
"LabelExpandSeries": "Udfold serie",
|
"LabelExpandSeries": "Fold serie ud",
|
||||||
"LabelExpandSubSeries": "Udfold underserie",
|
"LabelExpandSubSeries": "Fold underserie ud",
|
||||||
"LabelExpired": "Udløbet",
|
"LabelExpired": "Udløbet",
|
||||||
"LabelExpiresAt": "Udløbsdato",
|
"LabelExpiresAt": "Udløbsdato",
|
||||||
"LabelExpiresInSeconds": "Udløber om (seconds)",
|
"LabelExpiresInSeconds": "Udløber om (seconds)",
|
||||||
"LabelExpiresNever": "Aldrig",
|
"LabelExpiresNever": "Aldrig",
|
||||||
"LabelExplicit": "Eksplisit",
|
"LabelExplicit": "Voksenindhold",
|
||||||
"LabelExplicitChecked": "Eksplicit (markeret)",
|
"LabelExplicitChecked": "Voksenindhold (markeret)",
|
||||||
"LabelExplicitUnchecked": "Ikke eksplicit (ikke markeret)",
|
"LabelExplicitUnchecked": "Ikke voksenindhold (ikke markeret)",
|
||||||
"LabelExportOPML": "Eksport OPML",
|
"LabelExportOPML": "Eksport OPML",
|
||||||
"LabelFeedURL": "Feed URL",
|
"LabelFeedURL": "Feed-URL",
|
||||||
"LabelFetchingMetadata": "Henter metadata",
|
"LabelFetchingMetadata": "Henter metadata",
|
||||||
"LabelFile": "Fil",
|
"LabelFile": "Fil",
|
||||||
"LabelFileBirthtime": "Oprettelsestidspunkt for fil",
|
"LabelFileBirthtime": "Oprettelsestidspunkt for fil",
|
||||||
@@ -391,7 +391,7 @@
|
|||||||
"LabelFull": "Fuld",
|
"LabelFull": "Fuld",
|
||||||
"LabelGenre": "Genre",
|
"LabelGenre": "Genre",
|
||||||
"LabelGenres": "Genrer",
|
"LabelGenres": "Genrer",
|
||||||
"LabelHardDeleteFile": "Permanent slet fil",
|
"LabelHardDeleteFile": "Slet fil permanent",
|
||||||
"LabelHasEbook": "Har e-bog",
|
"LabelHasEbook": "Har e-bog",
|
||||||
"LabelHasSupplementaryEbook": "Har supplerende e-bog",
|
"LabelHasSupplementaryEbook": "Har supplerende e-bog",
|
||||||
"LabelHideSubtitles": "Skjul undertitler",
|
"LabelHideSubtitles": "Skjul undertitler",
|
||||||
@@ -400,7 +400,7 @@
|
|||||||
"LabelHour": "Time",
|
"LabelHour": "Time",
|
||||||
"LabelHours": "Timer",
|
"LabelHours": "Timer",
|
||||||
"LabelIcon": "Ikon",
|
"LabelIcon": "Ikon",
|
||||||
"LabelImageURLFromTheWeb": "Billede URL fra nettet",
|
"LabelImageURLFromTheWeb": "Billede-URL fra nettet",
|
||||||
"LabelInProgress": "I gang",
|
"LabelInProgress": "I gang",
|
||||||
"LabelIncludeInTracklist": "Inkluder i afspilningsliste",
|
"LabelIncludeInTracklist": "Inkluder i afspilningsliste",
|
||||||
"LabelIncomplete": "Ufuldstændig",
|
"LabelIncomplete": "Ufuldstændig",
|
||||||
@@ -414,22 +414,22 @@
|
|||||||
"LabelIntervalEveryDay": "Hver dag",
|
"LabelIntervalEveryDay": "Hver dag",
|
||||||
"LabelIntervalEveryHour": "Hver time",
|
"LabelIntervalEveryHour": "Hver time",
|
||||||
"LabelIntervalEveryMinute": "Hvert minut",
|
"LabelIntervalEveryMinute": "Hvert minut",
|
||||||
"LabelInvert": "Inverter",
|
"LabelInvert": "Omvendt begrænsning",
|
||||||
"LabelItem": "Element",
|
"LabelItem": "Element",
|
||||||
"LabelJumpBackwardAmount": "Spring bagud mængde",
|
"LabelJumpBackwardAmount": "Tilbagespolingstid",
|
||||||
"LabelJumpForwardAmount": "Spring fremad mængde",
|
"LabelJumpForwardAmount": "Fremadspolingstid",
|
||||||
"LabelLanguage": "Sprog",
|
"LabelLanguage": "Sprog",
|
||||||
"LabelLanguageDefaultServer": "Standard server sprog",
|
"LabelLanguageDefaultServer": "Serverens standardsprog",
|
||||||
"LabelLanguages": "Sprog",
|
"LabelLanguages": "Sprog",
|
||||||
"LabelLastBookAdded": "Senest tilføjede bog",
|
"LabelLastBookAdded": "Senest tilføjede bog",
|
||||||
"LabelLastBookUpdated": "Senest opdaterede bog",
|
"LabelLastBookUpdated": "Senest opdaterede bog",
|
||||||
"LabelLastProgressDate": "Sidste fremgang: {0}",
|
"LabelLastProgressDate": "Seneste fremgang: {0}",
|
||||||
"LabelLastSeen": "Sidst set",
|
"LabelLastSeen": "Sidst set",
|
||||||
"LabelLastTime": "Sidste gang",
|
"LabelLastTime": "Sidst lyttet",
|
||||||
"LabelLastUpdate": "Seneste opdatering",
|
"LabelLastUpdate": "Sidst ændret",
|
||||||
"LabelLayout": "Layout",
|
"LabelLayout": "Udseende",
|
||||||
"LabelLayoutSinglePage": "Enkeltside",
|
"LabelLayoutSinglePage": "Enkeltside",
|
||||||
"LabelLayoutSplitPage": "Opdelt side",
|
"LabelLayoutSplitPage": "Opdelte sider",
|
||||||
"LabelLess": "Mindre",
|
"LabelLess": "Mindre",
|
||||||
"LabelLibrariesAccessibleToUser": "Biblioteker tilgængelige for bruger",
|
"LabelLibrariesAccessibleToUser": "Biblioteker tilgængelige for bruger",
|
||||||
"LabelLibrary": "Bibliotek",
|
"LabelLibrary": "Bibliotek",
|
||||||
@@ -445,14 +445,14 @@
|
|||||||
"LabelLogLevelDebug": "Fejlsøgning",
|
"LabelLogLevelDebug": "Fejlsøgning",
|
||||||
"LabelLogLevelInfo": "Information",
|
"LabelLogLevelInfo": "Information",
|
||||||
"LabelLogLevelWarn": "Advarsel",
|
"LabelLogLevelWarn": "Advarsel",
|
||||||
"LabelLookForNewEpisodesAfterDate": "Søg efter nye episoder efter denne dato",
|
"LabelLookForNewEpisodesAfterDate": "Søg efter nye afsnit efter denne dato",
|
||||||
"LabelLowestPriority": "Laveste prioritet",
|
"LabelLowestPriority": "Laveste prioritet",
|
||||||
"LabelMatchConfidence": "Confidens",
|
"LabelMatchConfidence": "Nøjagtighed",
|
||||||
"LabelMatchExistingUsersBy": "Match eksisterende brugere ved",
|
"LabelMatchExistingUsersBy": "Forbind eksisterende brugere med",
|
||||||
"LabelMatchExistingUsersByDescription": "Anvendt for at forbinde brugere. Når forbundet, brugere vil blive matchet ved unikt id fra din SSO udbyder",
|
"LabelMatchExistingUsersByDescription": "Anvendes til at tilslutte eksisterende brugere. Ved tilslutningen kobles brugeren til et unikt ID fra din SSO-udbyder",
|
||||||
"LabelMaxEpisodesToDownload": "Max # afsnit for at downloade. Anvend 0 for ubegrænset.",
|
"LabelMaxEpisodesToDownload": "Maksimalt antal afsnit som skal hentes. Anvend 0 for ubegrænset.",
|
||||||
"LabelMaxEpisodesToDownloadPerCheck": "Max # afsnit til at downloade per check",
|
"LabelMaxEpisodesToDownloadPerCheck": "Maksimalt antal afsnit som skal hentes per tjek",
|
||||||
"LabelMaxEpisodesToKeep": "Max # afsnit at beholde",
|
"LabelMaxEpisodesToKeep": "Maksimalt antal afsnit som skal beholdes",
|
||||||
"LabelMaxEpisodesToKeepHelp": "Værdi af 0 sætter intet maks begrænsning. After et nyt afsnit er automatisk downloaded vil det ældste afsnit blive slettet hvis du har mere end X afsnit. Dette vil kun slette 1 afsnit for hvert nye download.",
|
"LabelMaxEpisodesToKeepHelp": "Værdi af 0 sætter intet maks begrænsning. After et nyt afsnit er automatisk downloaded vil det ældste afsnit blive slettet hvis du har mere end X afsnit. Dette vil kun slette 1 afsnit for hvert nye download.",
|
||||||
"LabelMediaPlayer": "Medieafspiller",
|
"LabelMediaPlayer": "Medieafspiller",
|
||||||
"LabelMediaType": "Medietype",
|
"LabelMediaType": "Medietype",
|
||||||
@@ -463,20 +463,20 @@
|
|||||||
"LabelMinute": "Minut",
|
"LabelMinute": "Minut",
|
||||||
"LabelMinutes": "Minutter",
|
"LabelMinutes": "Minutter",
|
||||||
"LabelMissing": "Mangler",
|
"LabelMissing": "Mangler",
|
||||||
"LabelMissingEbook": "Har ingen ebog",
|
"LabelMissingEbook": "Har ingen e-bog",
|
||||||
"LabelMissingSupplementaryEbook": "Har ingen tillægsbog",
|
"LabelMissingSupplementaryEbook": "Har ingen supplerende e-bog",
|
||||||
"LabelMobileRedirectURIs": "Godkendte mobil redirect URI'er",
|
"LabelMobileRedirectURIs": "Godkendte mobil redirect URI'er",
|
||||||
"LabelMobileRedirectURIsDescription": "Dete vil whiteliste en gyldig omdirigerings URL for mobile apps. Den standarde er <code>audiobookshelf://oauth</code> som du kan fjerne eller supplere med flere URI'er for tredjeparts app integration. Anvend en stjerne (<code>*</code>) som den eneste indstilling for at tilade en hvilkensomhelst URI.",
|
"LabelMobileRedirectURIsDescription": "Dete vil whiteliste en gyldig omdirigerings URL for mobile apps. Den standarde er <code>audiobookshelf://oauth</code> som du kan fjerne eller supplere med flere URI'er for tredjeparts app integration. Anvend en stjerne (<code>*</code>) som den eneste indstilling for at tilade en hvilkensomhelst URI.",
|
||||||
"LabelMore": "Mere",
|
"LabelMore": "Mere",
|
||||||
"LabelMoreInfo": "Mere info",
|
"LabelMoreInfo": "Mere info",
|
||||||
"LabelName": "Navn",
|
"LabelName": "Navn",
|
||||||
"LabelNarrator": "Fortæller",
|
"LabelNarrator": "Oplæser",
|
||||||
"LabelNarrators": "Fortællere",
|
"LabelNarrators": "Oplæsere",
|
||||||
"LabelNew": "Ny",
|
"LabelNew": "Ny",
|
||||||
"LabelNewPassword": "Ny adgangskode",
|
"LabelNewPassword": "Ny adgangskode",
|
||||||
"LabelNewestAuthors": "Nyeste forfattere",
|
"LabelNewestAuthors": "Nyeste forfattere",
|
||||||
"LabelNewestEpisodes": "Nyeste episoder",
|
"LabelNewestEpisodes": "Nyeste afsnit",
|
||||||
"LabelNextBackupDate": "Næste sikkerhedskopi dato",
|
"LabelNextBackupDate": "Dato for næste sikkerhedskopi",
|
||||||
"LabelNextChapters": "Næste kapitler vil være:",
|
"LabelNextChapters": "Næste kapitler vil være:",
|
||||||
"LabelNextScheduledRun": "Næste planlagte kørsel",
|
"LabelNextScheduledRun": "Næste planlagte kørsel",
|
||||||
"LabelNoApiKeys": "Ingen API-nøgler",
|
"LabelNoApiKeys": "Ingen API-nøgler",
|
||||||
@@ -526,28 +526,28 @@
|
|||||||
"LabelPodcasts": "Podcast",
|
"LabelPodcasts": "Podcast",
|
||||||
"LabelPort": "Port",
|
"LabelPort": "Port",
|
||||||
"LabelPrefixesToIgnore": "Præfikser der skal ignoreres (skal ikke skelne mellem store og små bogstaver)",
|
"LabelPrefixesToIgnore": "Præfikser der skal ignoreres (skal ikke skelne mellem store og små bogstaver)",
|
||||||
"LabelPreventIndexing": "Forhindrer, at dit feed bliver indekseret af iTunes og Google podcastkataloger",
|
"LabelPreventIndexing": "Forhindrer at dit feed bliver indekseret af iTunes- og Google-podcastkataloger",
|
||||||
"LabelPrimaryEbook": "Primær e-bog",
|
"LabelPrimaryEbook": "Primær e-bog",
|
||||||
"LabelProgress": "Fremskridt",
|
"LabelProgress": "Fremskridt",
|
||||||
"LabelProvider": "Udbyder",
|
"LabelProvider": "Udbyder",
|
||||||
"LabelProviderAuthorizationValue": "Authorization Header værdi",
|
"LabelProviderAuthorizationValue": "Authorization Header værdi",
|
||||||
"LabelPubDate": "Udgivelsesdato",
|
"LabelPubDate": "Udgivelsesdato",
|
||||||
"LabelPublishYear": "Udgivelsesår",
|
"LabelPublishYear": "Udgivelsesår",
|
||||||
"LabelPublishedDate": "Publiceret {0}",
|
"LabelPublishedDate": "Udgivet {0}",
|
||||||
"LabelPublishedDecade": "Publiceret årti",
|
"LabelPublishedDecade": "Udgivelsesårti",
|
||||||
"LabelPublishedDecades": "Publiceret årtier",
|
"LabelPublishedDecades": "Udgivelsesårtier",
|
||||||
"LabelPublisher": "Forlag",
|
"LabelPublisher": "Forlag",
|
||||||
"LabelPublishers": "Forlag",
|
"LabelPublishers": "Forlag",
|
||||||
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
|
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
|
||||||
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
|
"LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn",
|
||||||
"LabelRSSFeedOpen": "RSS-feed åbent",
|
"LabelRSSFeedOpen": "RSS-feed åbent",
|
||||||
"LabelRSSFeedPreventIndexing": "Forhindrer indeksering",
|
"LabelRSSFeedPreventIndexing": "Forhindr indeksering",
|
||||||
"LabelRSSFeedSlug": "RSS-feed-slug",
|
"LabelRSSFeedSlug": "RSS-feed-slug",
|
||||||
"LabelRSSFeedURL": "RSS-feed-URL",
|
"LabelRSSFeedURL": "RSS-feed-URL",
|
||||||
"LabelRandomly": "Tilfældigt",
|
"LabelRandomly": "Tilfældigt",
|
||||||
"LabelReAddSeriesToContinueListening": "Gentilføj serier til Fortsæt Lytning",
|
"LabelReAddSeriesToContinueListening": "Gentilføj serier til »Fortsæt med at lytte«",
|
||||||
"LabelRead": "Læst",
|
"LabelRead": "Læst",
|
||||||
"LabelReadAgain": "Læs Igen",
|
"LabelReadAgain": "Læs igen",
|
||||||
"LabelReadEbookWithoutProgress": "Læs e-bog uden at følge fremskridt",
|
"LabelReadEbookWithoutProgress": "Læs e-bog uden at følge fremskridt",
|
||||||
"LabelRecentSeries": "Seneste serier",
|
"LabelRecentSeries": "Seneste serier",
|
||||||
"LabelRecentlyAdded": "Senest tilføjet",
|
"LabelRecentlyAdded": "Senest tilføjet",
|
||||||
@@ -555,9 +555,9 @@
|
|||||||
"LabelRedo": "Gøre igen",
|
"LabelRedo": "Gøre igen",
|
||||||
"LabelRegion": "Region",
|
"LabelRegion": "Region",
|
||||||
"LabelReleaseDate": "Udgivelsesdato",
|
"LabelReleaseDate": "Udgivelsesdato",
|
||||||
"LabelRemoveAllMetadataAbs": "Fjern alle metadata.abs filer",
|
"LabelRemoveAllMetadataAbs": "Fjern alle \"metadata.abs\"-filer",
|
||||||
"LabelRemoveAllMetadataJson": "Fjern alle metadata.json filer",
|
"LabelRemoveAllMetadataJson": "Fjern alle \"metadata.json\"-filer",
|
||||||
"LabelRemoveAudibleBranding": "Fjern Audible intro og outro fra kapitler",
|
"LabelRemoveAudibleBranding": "Fjern Audible-intro og -outro fra kapitler",
|
||||||
"LabelRemoveCover": "Fjern omslag",
|
"LabelRemoveCover": "Fjern omslag",
|
||||||
"LabelRemoveMetadataFile": "Fjern alle metadata filer i biblioteksmapper",
|
"LabelRemoveMetadataFile": "Fjern alle metadata filer i biblioteksmapper",
|
||||||
"LabelRemoveMetadataFileHelp": "Fjern alle metadata.json og metadata.abs filer i dine {0} mapper.",
|
"LabelRemoveMetadataFileHelp": "Fjern alle metadata.json og metadata.abs filer i dine {0} mapper.",
|
||||||
@@ -571,7 +571,7 @@
|
|||||||
"LabelSelectAllEpisodes": "Vælg alle episoder",
|
"LabelSelectAllEpisodes": "Vælg alle episoder",
|
||||||
"LabelSelectEpisodesShowing": "Vælg {0} episoder vist",
|
"LabelSelectEpisodesShowing": "Vælg {0} episoder vist",
|
||||||
"LabelSelectUser": "Vælg bruger",
|
"LabelSelectUser": "Vælg bruger",
|
||||||
"LabelSelectUsers": "Valgte brugere",
|
"LabelSelectUsers": "Vælg brugere",
|
||||||
"LabelSendEbookToDevice": "Send e-bog til...",
|
"LabelSendEbookToDevice": "Send e-bog til...",
|
||||||
"LabelSequence": "Sekvens",
|
"LabelSequence": "Sekvens",
|
||||||
"LabelSerial": "Seriel",
|
"LabelSerial": "Seriel",
|
||||||
@@ -582,21 +582,21 @@
|
|||||||
"LabelServerYearReview": "Server år i review ({0})",
|
"LabelServerYearReview": "Server år i review ({0})",
|
||||||
"LabelSetEbookAsPrimary": "Indstil som primær",
|
"LabelSetEbookAsPrimary": "Indstil som primær",
|
||||||
"LabelSetEbookAsSupplementary": "Indstil som supplerende",
|
"LabelSetEbookAsSupplementary": "Indstil som supplerende",
|
||||||
"LabelSettingsAllowIframe": "Tillad embedding i en iframe",
|
"LabelSettingsAllowIframe": "Tillad indlejring i en iframe",
|
||||||
"LabelSettingsAudiobooksOnly": "Kun lydbøger",
|
"LabelSettingsAudiobooksOnly": "Kun lydbøger",
|
||||||
"LabelSettingsAudiobooksOnlyHelp": "Aktivering af denne indstilling vil ignorere e-bogsfiler, medmindre de er inde i en lydbogmappe, hvor de vil blive indstillet som supplerende e-bøger",
|
"LabelSettingsAudiobooksOnlyHelp": "Aktivering af denne indstilling vil ignorere e-bogsfiler, medmindre de er inde i en lydbogmappe, hvor de vil blive indstillet som supplerende e-bøger",
|
||||||
"LabelSettingsBookshelfViewHelp": "Skeumorfisk design med træhylder",
|
"LabelSettingsBookshelfViewHelp": "Skeumorfisk design med træhylder",
|
||||||
"LabelSettingsChromecastSupport": "Chromecast-understøttelse",
|
"LabelSettingsChromecastSupport": "Chromecast-understøttelse",
|
||||||
"LabelSettingsDateFormat": "Datoformat",
|
"LabelSettingsDateFormat": "Datoformat",
|
||||||
"LabelSettingsEnableWatcher": "Automatisk biblioteksovervåger",
|
"LabelSettingsEnableWatcher": "Automatisk biblioteksovervågning",
|
||||||
"LabelSettingsEnableWatcherForLibrary": "Automatisk biblioteksovervåger",
|
"LabelSettingsEnableWatcherForLibrary": "Automatisk biblioteksovervåger",
|
||||||
"LabelSettingsEnableWatcherHelp": "Aktiverer automatisk tilføjelse/opdatering af elementer, når filændringer registreres. *Kræver servergenstart",
|
"LabelSettingsEnableWatcherHelp": "Aktiverer automatisk opdatering af kataloget, når filændringer registreres. *Kræver genstart af serveren",
|
||||||
"LabelSettingsEpubsAllowScriptedContent": "Tillad scriptet indhold i epub",
|
"LabelSettingsEpubsAllowScriptedContent": "Tillad scriptet indhold i epub",
|
||||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Tillad epub filer at køre scripts. Det anbefales at holde denne indstilling deaktiveret med mindre du stoler på kilderne af epub filerne.",
|
"LabelSettingsEpubsAllowScriptedContentHelp": "Tillad epub filer at køre scripts. Det anbefales at holde denne indstilling deaktiveret med mindre du stoler på kilderne af epub filerne.",
|
||||||
"LabelSettingsExperimentalFeatures": "Eksperimentelle funktioner",
|
"LabelSettingsExperimentalFeatures": "Eksperimentelle funktioner",
|
||||||
"LabelSettingsExperimentalFeaturesHelp": "Funktioner under udvikling, der kunne bruge din feedback og hjælp til test. Klik for at åbne Github-diskussionen.",
|
"LabelSettingsExperimentalFeaturesHelp": "Funktioner under udvikling, der kunne bruge din feedback og hjælp til test. Klik for at åbne Github-diskussionen.",
|
||||||
"LabelSettingsFindCovers": "Find omslag",
|
"LabelSettingsFindCovers": "Find omslag",
|
||||||
"LabelSettingsFindCoversHelp": "Hvis din lydbog ikke har et indlejret omslag eller et omslagsbillede i mappen, vil skanneren forsøge at finde et omslag.<br>Bemærk: Dette vil forlænge scanntiden",
|
"LabelSettingsFindCoversHelp": "Hvis din lydbog ikke har et indlejret omslag eller et omslagsbillede i mappen, vil skanneren forsøge at finde et omslag.<br>Bemærk: Dette vil forlænge skanningens varighed",
|
||||||
"LabelSettingsHideSingleBookSeries": "Skjul enkeltbogsserier",
|
"LabelSettingsHideSingleBookSeries": "Skjul enkeltbogsserier",
|
||||||
"LabelSettingsHideSingleBookSeriesHelp": "Serier med en enkelt bog vil blive skjult fra serie-siden og hjemmesidehylder.",
|
"LabelSettingsHideSingleBookSeriesHelp": "Serier med en enkelt bog vil blive skjult fra serie-siden og hjemmesidehylder.",
|
||||||
"LabelSettingsHomePageBookshelfView": "Brug bogreolvisning på startside",
|
"LabelSettingsHomePageBookshelfView": "Brug bogreolvisning på startside",
|
||||||
@@ -607,7 +607,7 @@
|
|||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Spring tidligere bøger i Fortsæt serie over",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeries": "Spring tidligere bøger i Fortsæt serie over",
|
||||||
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Fortsæt Serien siden viser den første bog som ikke er startet i serier med mindst en bog som ikke er startet og hvor ingen bøger i gang. Aktivering af denne indstilling vil fortsætte serien fra den sidst gennemførte bog i stedet for fra den første ikke startede bog.",
|
"LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Fortsæt Serien siden viser den første bog som ikke er startet i serier med mindst en bog som ikke er startet og hvor ingen bøger i gang. Aktivering af denne indstilling vil fortsætte serien fra den sidst gennemførte bog i stedet for fra den første ikke startede bog.",
|
||||||
"LabelSettingsParseSubtitles": "Fortolk undertitler",
|
"LabelSettingsParseSubtitles": "Fortolk undertitler",
|
||||||
"LabelSettingsParseSubtitlesHelp": "Udtræk undertekster fra lydbogsmappenavne.<br>Undertitler skal adskilles af \" - \"<br>f.eks. \"Bogtitel - En undertitel her\" har undertitlen \"En undertitel her\"",
|
"LabelSettingsParseSubtitlesHelp": "Udtræk undertitler fra lydbogsmappenavne.<br>Undertitler skal adskilles af \" - \"<br>f.eks. \"Bogtitel - En undertitel her\" har undertitlen \"En undertitel her\"",
|
||||||
"LabelSettingsPreferMatchedMetadata": "Foretræk matchede metadata",
|
"LabelSettingsPreferMatchedMetadata": "Foretræk matchede metadata",
|
||||||
"LabelSettingsPreferMatchedMetadataHelp": "Matchede data vil tilsidesætte elementdetaljer ved brug af Hurtig Match. Som standard udfylder Hurtig Match kun manglende detaljer.",
|
"LabelSettingsPreferMatchedMetadataHelp": "Matchede data vil tilsidesætte elementdetaljer ved brug af Hurtig Match. Som standard udfylder Hurtig Match kun manglende detaljer.",
|
||||||
"LabelSettingsSkipMatchingBooksWithASIN": "Ignorer matchende bøger, der allerede har en ASIN",
|
"LabelSettingsSkipMatchingBooksWithASIN": "Ignorer matchende bøger, der allerede har en ASIN",
|
||||||
@@ -647,14 +647,14 @@
|
|||||||
"LabelStatsDaysListened": "Dage hørt",
|
"LabelStatsDaysListened": "Dage hørt",
|
||||||
"LabelStatsHours": "Timer",
|
"LabelStatsHours": "Timer",
|
||||||
"LabelStatsInARow": "i træk",
|
"LabelStatsInARow": "i træk",
|
||||||
"LabelStatsItemsFinished": "Elementer færdige",
|
"LabelStatsItemsFinished": "færdiglæste bøger",
|
||||||
"LabelStatsItemsInLibrary": "Elementer i biblioteket",
|
"LabelStatsItemsInLibrary": "bøger i biblioteket",
|
||||||
"LabelStatsMinutes": "minutter",
|
"LabelStatsMinutes": "minutter",
|
||||||
"LabelStatsMinutesListening": "Minutter hørt",
|
"LabelStatsMinutesListening": "Minutter hørt",
|
||||||
"LabelStatsOverallDays": "Samlede dage",
|
"LabelStatsOverallDays": "Samlede dage",
|
||||||
"LabelStatsOverallHours": "Samlede timer",
|
"LabelStatsOverallHours": "Samlede timer",
|
||||||
"LabelStatsWeekListening": "Ugens lytning",
|
"LabelStatsWeekListening": "Ugens lytning",
|
||||||
"LabelSubtitle": "Undertekst",
|
"LabelSubtitle": "Undertitel",
|
||||||
"LabelSupportedFileTypes": "Understøttede filtyper",
|
"LabelSupportedFileTypes": "Understøttede filtyper",
|
||||||
"LabelTag": "Mærke",
|
"LabelTag": "Mærke",
|
||||||
"LabelTags": "Mærker",
|
"LabelTags": "Mærker",
|
||||||
@@ -663,7 +663,7 @@
|
|||||||
"LabelTasks": "Kører opgaver",
|
"LabelTasks": "Kører opgaver",
|
||||||
"LabelTextEditorBulletedList": "Punktopstilling",
|
"LabelTextEditorBulletedList": "Punktopstilling",
|
||||||
"LabelTextEditorLink": "Link",
|
"LabelTextEditorLink": "Link",
|
||||||
"LabelTextEditorNumberedList": "Nummeropstilling",
|
"LabelTextEditorNumberedList": "Nummereret liste",
|
||||||
"LabelTextEditorUnlink": "Aflink",
|
"LabelTextEditorUnlink": "Aflink",
|
||||||
"LabelTheme": "Tema",
|
"LabelTheme": "Tema",
|
||||||
"LabelThemeDark": "Mørk",
|
"LabelThemeDark": "Mørk",
|
||||||
@@ -735,17 +735,17 @@
|
|||||||
"LabelYourPlaylists": "Dine spillelister",
|
"LabelYourPlaylists": "Dine spillelister",
|
||||||
"LabelYourProgress": "Din fremgang",
|
"LabelYourProgress": "Din fremgang",
|
||||||
"MessageAddToPlayerQueue": "Tilføj til afspilningskø",
|
"MessageAddToPlayerQueue": "Tilføj til afspilningskø",
|
||||||
"MessageAppriseDescription": "For at bruge denne funktion skal du have en instans af <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kørende eller en API, der håndterer de samme anmodninger. <br /> Apprise API-webadressen skal være den fulde URL-sti for at sende underretningen, f.eks. hvis din API-instans er tilgængelig på <code>http://192.168.1.1:8337</code>, så skal du bruge <code>http://192.168.1.1:8337/notify</code>.",
|
"MessageAppriseDescription": "For at bruge denne funktion skal du have en instans af <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kørende eller en API, der kan håndtere de samme anmodninger. <br />Apprise API-webadressen skal være den fulde URL-sti for at sende underretningen, f.eks. hvis din API-instans er tilgængelig på <code>http://192.168.1.1:8337</code>, så skal du bruge <code>http://192.168.1.1:8337/notify</code>.",
|
||||||
"MessageAsinCheck": "Sikr dig at du bruger ASIN fra den korrekte Audible region, ikke Amazon.",
|
"MessageAsinCheck": "Sikr dig at du bruger ASIN fra den korrekte Audible region, ikke Amazon.",
|
||||||
"MessageAuthenticationLegacyTokenWarning": "Ældre API tokens vil blive fjernet i fremtiden. Brug <a href=\"/config/api-keys\">API-nøgler</a> i stedet.",
|
"MessageAuthenticationLegacyTokenWarning": "Ældre API tokens vil blive fjernet i fremtiden. Brug <a href=\"/config/api-keys\">API-nøgler</a> i stedet.",
|
||||||
"MessageAuthenticationOIDCChangesRestart": "Genstart sin server efter du har gemt for at bekræfte OIDC ændringer.",
|
"MessageAuthenticationOIDCChangesRestart": "Genstart sin server efter du har gemt for at bekræfte OIDC ændringer.",
|
||||||
"MessageAuthenticationSecurityMessage": "Autentificeringen er blevet forbedret af sikkerhedsmæssige årsager. Alle brugere skal logge ind igen.",
|
"MessageAuthenticationSecurityMessage": "Brugerlogin er blevet forbedret af sikkerhedsmæssige årsager. Alle brugere skal logge ind igen.",
|
||||||
"MessageBackupsDescription": "Backups inkluderer brugere, brugerfremskridt, biblioteksvareoplysninger, serverindstillinger og billeder gemt i <code>/metadata/items</code> og <code>/metadata/authors</code>. Backups inkluderer <strong>ikke</strong> nogen filer gemt i dine biblioteksmapper.",
|
"MessageBackupsDescription": "Backups inkluderer brugere, brugerfremskridt, biblioteksvareoplysninger, serverindstillinger og billeder gemt i <code>/metadata/items</code> og <code>/metadata/authors</code>. Backups inkluderer <strong>ikke</strong> nogen filer gemt i dine biblioteksmapper.",
|
||||||
"MessageBackupsLocationEditNote": "Note: Opdatering af backup sti vil ikke fjerne eller modificere eksisterende backups",
|
"MessageBackupsLocationEditNote": "Note: Opdatering af backup sti vil ikke fjerne eller modificere eksisterende backups",
|
||||||
"MessageBackupsLocationNoEditNote": "Note: Backup sti er sat igennem miljøvariabel og kan ikke ændres her.",
|
"MessageBackupsLocationNoEditNote": "Note: Backup sti er sat igennem miljøvariabel og kan ikke ændres her.",
|
||||||
"MessageBackupsLocationPathEmpty": "Backup sti kan ikke være tom",
|
"MessageBackupsLocationPathEmpty": "Backup sti kan ikke være tom",
|
||||||
"MessageBatchEditPopulateMapDetailsAllHelp": "Opret felter slået til med data fra alle genstande. Felter med flere værdier vil blive sammenflettet",
|
"MessageBatchEditPopulateMapDetailsAllHelp": "Udfyld valgte felter med data fra alle bøger. Felter med flere værdier vil blive sammenflettet",
|
||||||
"MessageBatchEditPopulateMapDetailsItemHelp": "Opret kort med værdier der er slået til fra felter med data fra denne genstand",
|
"MessageBatchEditPopulateMapDetailsItemHelp": "Udfyld de felter, som er tilvalgt i masseredigering med data fra denne bog",
|
||||||
"MessageBatchQuickMatchDescription": "Quick Match vil forsøge at tilføje manglende omslag og metadata til de valgte elementer. Aktivér indstillingerne nedenfor for at tillade Quick Match at overskrive eksisterende omslag og/eller metadata.",
|
"MessageBatchQuickMatchDescription": "Quick Match vil forsøge at tilføje manglende omslag og metadata til de valgte elementer. Aktivér indstillingerne nedenfor for at tillade Quick Match at overskrive eksisterende omslag og/eller metadata.",
|
||||||
"MessageBookshelfNoCollections": "Du har ikke oprettet nogen samlinger endnu",
|
"MessageBookshelfNoCollections": "Du har ikke oprettet nogen samlinger endnu",
|
||||||
"MessageBookshelfNoCollectionsHelp": "Samlinger er offentlige. Alle brugere med adgang til biblioteket kan se dem.",
|
"MessageBookshelfNoCollectionsHelp": "Samlinger er offentlige. Alle brugere med adgang til biblioteket kan se dem.",
|
||||||
@@ -888,7 +888,7 @@
|
|||||||
"MessageResetChaptersConfirm": "Er du sikker på, at du vil nulstille kapitler og annullere ændringerne, du har foretaget?",
|
"MessageResetChaptersConfirm": "Er du sikker på, at du vil nulstille kapitler og annullere ændringerne, du har foretaget?",
|
||||||
"MessageRestoreBackupConfirm": "Er du sikker på, at du vil gendanne sikkerhedskopien oprettet den",
|
"MessageRestoreBackupConfirm": "Er du sikker på, at du vil gendanne sikkerhedskopien oprettet den",
|
||||||
"MessageRestoreBackupWarning": "Gendannelse af en sikkerhedskopi vil overskrive hele databasen, som er placeret på /config, og omslagsbilleder i /metadata/items & /metadata/authors.<br /><br />Sikkerhedskopier ændrer ikke nogen filer i dine biblioteksmapper. Hvis du har aktiveret serverindstillinger for at gemme omslagskunst og metadata i dine biblioteksmapper, sikkerhedskopieres eller overskrives disse ikke.<br /><br />Alle klienter, der bruger din server, opdateres automatisk.",
|
"MessageRestoreBackupWarning": "Gendannelse af en sikkerhedskopi vil overskrive hele databasen, som er placeret på /config, og omslagsbilleder i /metadata/items & /metadata/authors.<br /><br />Sikkerhedskopier ændrer ikke nogen filer i dine biblioteksmapper. Hvis du har aktiveret serverindstillinger for at gemme omslagskunst og metadata i dine biblioteksmapper, sikkerhedskopieres eller overskrives disse ikke.<br /><br />Alle klienter, der bruger din server, opdateres automatisk.",
|
||||||
"MessageScheduleLibraryScanNote": "For de fleste brugere er det anbefalet, at efterlade denne funktion deaktiveret, og lade biblioteksovervågeren være aktiveret - den vil automatisk opdage ændringer i dine biblioteksmapper. Aktiver denne funktion, hvis biblioteksovervågeren ikke virker med dit filsystem (f. eks. NFS).",
|
"MessageScheduleLibraryScanNote": "For de fleste brugere er det anbefalet, at lade denne funktion være deaktiveret, og lade biblioteksovervågning være aktiveret - den vil automatisk opdage ændringer i dine biblioteksmapper. Aktiver denne funktion, hvis biblioteksovervågning ikke virker med dit filsystem (f. eks. NFS).",
|
||||||
"MessageScheduleRunEveryWeekdayAtTime": "Kør hvert {0} af {1}",
|
"MessageScheduleRunEveryWeekdayAtTime": "Kør hvert {0} af {1}",
|
||||||
"MessageSearchResultsFor": "Søgeresultater for",
|
"MessageSearchResultsFor": "Søgeresultater for",
|
||||||
"MessageSelected": "{0} valgt",
|
"MessageSelected": "{0} valgt",
|
||||||
@@ -915,7 +915,7 @@
|
|||||||
"MessageTaskFailedToWriteMetadataFile": "Fejlede i at skrive metadata fil",
|
"MessageTaskFailedToWriteMetadataFile": "Fejlede i at skrive metadata fil",
|
||||||
"MessageTaskMatchingBooksInLibrary": "Matchede bøger i bibliotek \"{0}\"",
|
"MessageTaskMatchingBooksInLibrary": "Matchede bøger i bibliotek \"{0}\"",
|
||||||
"MessageTaskNoFilesToScan": "Ingen filer at scanne",
|
"MessageTaskNoFilesToScan": "Ingen filer at scanne",
|
||||||
"MessageTaskOpmlImport": "OPML import",
|
"MessageTaskOpmlImport": "OPML-import",
|
||||||
"MessageTaskOpmlImportDescription": "Oprettelse af podcasts fra {0} RSS feeds",
|
"MessageTaskOpmlImportDescription": "Oprettelse af podcasts fra {0} RSS feeds",
|
||||||
"MessageTaskOpmlImportFeed": "OPML importering fejlede",
|
"MessageTaskOpmlImportFeed": "OPML importering fejlede",
|
||||||
"MessageTaskOpmlImportFeedDescription": "Importering af RSS feed \"{0}\"",
|
"MessageTaskOpmlImportFeedDescription": "Importering af RSS feed \"{0}\"",
|
||||||
@@ -939,13 +939,13 @@
|
|||||||
"MessageUploaderItemSuccess": "Uploadet med succes!",
|
"MessageUploaderItemSuccess": "Uploadet med succes!",
|
||||||
"MessageUploading": "Uploader...",
|
"MessageUploading": "Uploader...",
|
||||||
"MessageValidCronExpression": "Gyldigt cron-udtryk",
|
"MessageValidCronExpression": "Gyldigt cron-udtryk",
|
||||||
"MessageWatcherIsDisabledGlobally": "Watcher er deaktiveret globalt i serverindstillinger",
|
"MessageWatcherIsDisabledGlobally": "Automatisk biblioteksovervågning er deaktiveret globalt i serverens indstillinger",
|
||||||
"MessageXLibraryIsEmpty": "{0} bibliotek er tomt!",
|
"MessageXLibraryIsEmpty": "{0} bibliotek er tomt!",
|
||||||
"MessageYourAudiobookDurationIsLonger": "Din lydbogsvarighed er længere end den fundne varighed",
|
"MessageYourAudiobookDurationIsLonger": "Din lydbogsvarighed er længere end den fundne varighed",
|
||||||
"MessageYourAudiobookDurationIsShorter": "Din lydbogsvarighed er kortere end den fundne varighed",
|
"MessageYourAudiobookDurationIsShorter": "Din lydbogsvarighed er kortere end den fundne varighed",
|
||||||
"NoteChangeRootPassword": "Root-brugeren er den eneste bruger, der kan have en tom adgangskode",
|
"NoteChangeRootPassword": "Root-brugeren er den eneste bruger, der kan have en tom adgangskode",
|
||||||
"NoteChapterEditorTimes": "Bemærk: Første kapitel starttidspunkt skal forblive kl. 0:00, og det sidste kapitel starttidspunkt må ikke overstige denne lydbogs varighed.",
|
"NoteChapterEditorTimes": "Bemærk: Første kapitel starttidspunkt skal forblive kl. 0:00, og det sidste kapitel starttidspunkt må ikke overstige denne lydbogs varighed.",
|
||||||
"NoteFolderPicker": "Bemærk: Mapper, der allerede er mappet, vises ikke",
|
"NoteFolderPicker": "Bemærk: Allerede kortlagte mapper vises ikke",
|
||||||
"NoteRSSFeedPodcastAppsHttps": "Advarsel: De fleste podcast-apps kræver, at RSS-feedets URL bruger HTTPS",
|
"NoteRSSFeedPodcastAppsHttps": "Advarsel: De fleste podcast-apps kræver, at RSS-feedets URL bruger HTTPS",
|
||||||
"NoteRSSFeedPodcastAppsPubDate": "Advarsel: En eller flere af dine episoder har ikke en Pub Date. Nogle podcast-apps kræver dette.",
|
"NoteRSSFeedPodcastAppsPubDate": "Advarsel: En eller flere af dine episoder har ikke en Pub Date. Nogle podcast-apps kræver dette.",
|
||||||
"NoteUploaderFoldersWithMediaFiles": "Mapper med mediefiler håndteres som separate bibliotekselementer.",
|
"NoteUploaderFoldersWithMediaFiles": "Mapper med mediefiler håndteres som separate bibliotekselementer.",
|
||||||
@@ -1018,8 +1018,8 @@
|
|||||||
"ToastChapterStartTimeAdjusted": "Kapitelstarttid justeret med {0} sekunder",
|
"ToastChapterStartTimeAdjusted": "Kapitelstarttid justeret med {0} sekunder",
|
||||||
"ToastChaptersAllLocked": "Alle kapitler er låst. Lås op for nogle kapitler for at ændre deres tider.",
|
"ToastChaptersAllLocked": "Alle kapitler er låst. Lås op for nogle kapitler for at ændre deres tider.",
|
||||||
"ToastChaptersHaveErrors": "Kapitler har fejl",
|
"ToastChaptersHaveErrors": "Kapitler har fejl",
|
||||||
"ToastChaptersInvalidShiftAmountLast": "Ugyldig ændring. Det sidste kapitels starttid ville fortsætte længere end varigheden på denne lydbog.",
|
"ToastChaptersInvalidShiftAmountLast": "Forskydningen er ikke mulig. Det sidste kapitels starttidspunkt ville strække sig ud over denne lydbogs varighed.",
|
||||||
"ToastChaptersInvalidShiftAmountStart": "Ugyldig ændring. Første kapitel ville have en længde på nul eller negativt og ville blive overskrevet af andet kapitel. Udvid startvarigheden på andet kapitel.",
|
"ToastChaptersInvalidShiftAmountStart": "Forskydningen er ikke mulig. Det første kapitel ville have en længde på nul eller negativ længde og ville blive overskrevet af det andet kapitel. Øg startvarigheden af det andet kapitel.",
|
||||||
"ToastChaptersMustHaveTitles": "Kapitler skal have titler",
|
"ToastChaptersMustHaveTitles": "Kapitler skal have titler",
|
||||||
"ToastChaptersRemoved": "Kapitler fjernet",
|
"ToastChaptersRemoved": "Kapitler fjernet",
|
||||||
"ToastChaptersUpdated": "Kapitler opdateret",
|
"ToastChaptersUpdated": "Kapitler opdateret",
|
||||||
@@ -1044,7 +1044,7 @@
|
|||||||
"ToastEpisodeUpdateSuccess": "{0} afsnit opdateret",
|
"ToastEpisodeUpdateSuccess": "{0} afsnit opdateret",
|
||||||
"ToastErrorCannotShare": "Kan ikke dele på denne enhed",
|
"ToastErrorCannotShare": "Kan ikke dele på denne enhed",
|
||||||
"ToastFailedToCreate": "Oprettelsen mislykkedes",
|
"ToastFailedToCreate": "Oprettelsen mislykkedes",
|
||||||
"ToastFailedToDelete": "Sletning fejlede",
|
"ToastFailedToDelete": "Kunne ikke slette",
|
||||||
"ToastFailedToLoadData": "Indlæsning af data fejlede",
|
"ToastFailedToLoadData": "Indlæsning af data fejlede",
|
||||||
"ToastFailedToMatch": "Fejlet match",
|
"ToastFailedToMatch": "Fejlet match",
|
||||||
"ToastFailedToShare": "Deling fejlede",
|
"ToastFailedToShare": "Deling fejlede",
|
||||||
@@ -1109,7 +1109,7 @@
|
|||||||
"ToastProgressIsNotBeingSynced": "Fremskridt ikke synkroniseret, genstart afspilning",
|
"ToastProgressIsNotBeingSynced": "Fremskridt ikke synkroniseret, genstart afspilning",
|
||||||
"ToastProviderCreatedFailed": "Fejlede at tilføje udbyder",
|
"ToastProviderCreatedFailed": "Fejlede at tilføje udbyder",
|
||||||
"ToastProviderCreatedSuccess": "Ny udbyder tilføjet",
|
"ToastProviderCreatedSuccess": "Ny udbyder tilføjet",
|
||||||
"ToastProviderNameAndUrlRequired": "Navn og URL påkrævet",
|
"ToastProviderNameAndUrlRequired": "Navn og URL kræves",
|
||||||
"ToastProviderRemoveSuccess": "Udbyder fjernet",
|
"ToastProviderRemoveSuccess": "Udbyder fjernet",
|
||||||
"ToastRSSFeedCloseFailed": "Mislykkedes lukning af RSS-feed",
|
"ToastRSSFeedCloseFailed": "Mislykkedes lukning af RSS-feed",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS-feed lukket",
|
"ToastRSSFeedCloseSuccess": "RSS-feed lukket",
|
||||||
|
|||||||
+22
-4
@@ -242,12 +242,13 @@
|
|||||||
"LabelDescription": "Περιγραφή",
|
"LabelDescription": "Περιγραφή",
|
||||||
"LabelDevice": "Συσκευή",
|
"LabelDevice": "Συσκευή",
|
||||||
"LabelDeviceInfo": "Πληροφορίες Συσκευής",
|
"LabelDeviceInfo": "Πληροφορίες Συσκευής",
|
||||||
|
"LabelDiscover": "Ανακάλυψη",
|
||||||
"LabelDownload": "Λήψη",
|
"LabelDownload": "Λήψη",
|
||||||
"LabelDownloadNEpisodes": "Λήψη {0} επεισοδίων",
|
"LabelDownloadNEpisodes": "Λήψη {0} επεισοδίων",
|
||||||
"LabelDuration": "Διάρκεια",
|
"LabelDuration": "Διάρκεια",
|
||||||
"LabelDurationComparisonExactMatch": "(ακριβής ταύτιση)",
|
"LabelDurationComparisonExactMatch": "(ακριβής ταύτιση)",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "Ηλεκτρονικό Βιβλίο/ Ebook",
|
||||||
"LabelEbooks": "Ebooks",
|
"LabelEbooks": "Ηλεκτρονικά Βιβλία / Ebooks",
|
||||||
"LabelEdit": "Επεξεργασία",
|
"LabelEdit": "Επεξεργασία",
|
||||||
"LabelEmail": "Ηλεκτρονικό Ταχυδρομίο",
|
"LabelEmail": "Ηλεκτρονικό Ταχυδρομίο",
|
||||||
"LabelEmailSettingsFromAddress": "Από Διεύθυνση",
|
"LabelEmailSettingsFromAddress": "Από Διεύθυνση",
|
||||||
@@ -258,19 +259,34 @@
|
|||||||
"LabelEnd": "Τέλος",
|
"LabelEnd": "Τέλος",
|
||||||
"LabelEndOfChapter": "Τέλος Κεφαλαίου",
|
"LabelEndOfChapter": "Τέλος Κεφαλαίου",
|
||||||
"LabelEpisode": "Επεισόδιο",
|
"LabelEpisode": "Επεισόδιο",
|
||||||
|
"LabelExplicit": "Άσεμνο περιεχόμενο",
|
||||||
|
"LabelFeedURL": "URL ροής",
|
||||||
"LabelFile": "Αρχείο",
|
"LabelFile": "Αρχείο",
|
||||||
|
"LabelFileBirthtime": "Ώρα δημιουργίας αρχείου",
|
||||||
|
"LabelFileModified": "Τροποποιημένο αρχείο",
|
||||||
"LabelFilename": "Όνομα Αρχείου",
|
"LabelFilename": "Όνομα Αρχείου",
|
||||||
"LabelFinished": "Ολοκληρώθηκε",
|
"LabelFinished": "Ολοκληρώθηκε",
|
||||||
"LabelFolder": "Φάκελος",
|
"LabelFolder": "Φάκελος",
|
||||||
|
"LabelFontBoldness": "Ένταση γραμματοσειράς",
|
||||||
"LabelFontFamily": "Οικογένεια Γραμματοσειράς",
|
"LabelFontFamily": "Οικογένεια Γραμματοσειράς",
|
||||||
"LabelGenre": "Είδος",
|
"LabelFontScale": "Μέγεθος γραμματοσειράς",
|
||||||
"LabelGenres": "Είδη",
|
"LabelGenre": "Λογοτεχνικό Είδος",
|
||||||
|
"LabelGenres": "Λογοτεχνικά Είδη",
|
||||||
|
"LabelHasEbook": "Έχει ebook",
|
||||||
|
"LabelHasSupplementaryEbook": "Διαθέτει συμπληρωματικό ηλεκτρονικό βιβλίο",
|
||||||
"LabelHost": "Διακομιστής",
|
"LabelHost": "Διακομιστής",
|
||||||
"LabelInProgress": "Σε Εξέλιξη",
|
"LabelInProgress": "Σε Εξέλιξη",
|
||||||
|
"LabelIncomplete": "Ελειπής",
|
||||||
"LabelLanguage": "Γλώσσα",
|
"LabelLanguage": "Γλώσσα",
|
||||||
|
"LabelLayout": "Διάταξη",
|
||||||
"LabelLayoutSinglePage": "Μονή Σελίδα",
|
"LabelLayoutSinglePage": "Μονή Σελίδα",
|
||||||
|
"LabelLibrarySortByProgress": "Πρόοδος: Τελευταία ενημέρωση",
|
||||||
|
"LabelLibrarySortByProgressFinished": "Πρόοδος: Ολοκληρώθηκε",
|
||||||
|
"LabelLibrarySortByProgressStarted": "Πρόοδος: Έναρξη",
|
||||||
|
"LabelLineSpacing": "Διάστημα γραμμών",
|
||||||
"LabelListenAgain": "Επανάληψη Ακρόασης",
|
"LabelListenAgain": "Επανάληψη Ακρόασης",
|
||||||
"LabelMediaType": "Τύπος Πολυμέσων",
|
"LabelMediaType": "Τύπος Πολυμέσων",
|
||||||
|
"LabelMissing": "Λείπει η ετικέτα",
|
||||||
"LabelMore": "Περισσότερα",
|
"LabelMore": "Περισσότερα",
|
||||||
"LabelMoreInfo": "Περισσότερες Πληροφορίες",
|
"LabelMoreInfo": "Περισσότερες Πληροφορίες",
|
||||||
"LabelName": "Όνομα",
|
"LabelName": "Όνομα",
|
||||||
@@ -278,11 +294,13 @@
|
|||||||
"LabelNarrators": "Αφηγητές",
|
"LabelNarrators": "Αφηγητές",
|
||||||
"LabelNewestAuthors": "Πρόσφατοι Συγγραφείς",
|
"LabelNewestAuthors": "Πρόσφατοι Συγγραφείς",
|
||||||
"LabelNewestEpisodes": "Πρόσφατα Επεισόδια",
|
"LabelNewestEpisodes": "Πρόσφατα Επεισόδια",
|
||||||
|
"LabelNotFinished": "Δεν έχει ολοκληρωθεί",
|
||||||
"LabelNotStarted": "Δεν Έχει Ξεκινήσει",
|
"LabelNotStarted": "Δεν Έχει Ξεκινήσει",
|
||||||
"LabelNumberOfEpisodes": "# Επεισοδίων",
|
"LabelNumberOfEpisodes": "# Επεισοδίων",
|
||||||
"LabelPassword": "Κωδικός Πρόσβασης",
|
"LabelPassword": "Κωδικός Πρόσβασης",
|
||||||
"LabelPath": "Διαδρομή",
|
"LabelPath": "Διαδρομή",
|
||||||
"LabelProgress": "Πρόοδος",
|
"LabelProgress": "Πρόοδος",
|
||||||
|
"LabelPubDate": "Ημερομηνία έκδοσης",
|
||||||
"LabelPublishYear": "Χρονολογία Έκδοσης",
|
"LabelPublishYear": "Χρονολογία Έκδοσης",
|
||||||
"LabelPublishedDate": "Εκδόθηκε {0}",
|
"LabelPublishedDate": "Εκδόθηκε {0}",
|
||||||
"LabelRandomly": "Τυχαία",
|
"LabelRandomly": "Τυχαία",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"ButtonLatest": "Latest",
|
"ButtonLatest": "Latest",
|
||||||
"ButtonLibrary": "Library",
|
"ButtonLibrary": "Library",
|
||||||
"ButtonLogout": "Logout",
|
"ButtonLogout": "Logout",
|
||||||
|
"ButtonLogoutAllDevices": "Logout All Devices",
|
||||||
"ButtonLookup": "Lookup",
|
"ButtonLookup": "Lookup",
|
||||||
"ButtonManageTracks": "Manage Tracks",
|
"ButtonManageTracks": "Manage Tracks",
|
||||||
"ButtonMapChapterTitles": "Map Chapter Titles",
|
"ButtonMapChapterTitles": "Map Chapter Titles",
|
||||||
@@ -194,6 +195,7 @@
|
|||||||
"HeaderScheduleEpisodeDownloads": "Schedule Automatic Episode Downloads",
|
"HeaderScheduleEpisodeDownloads": "Schedule Automatic Episode Downloads",
|
||||||
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
|
||||||
"HeaderSession": "Session",
|
"HeaderSession": "Session",
|
||||||
|
"HeaderSessions": "Sessions",
|
||||||
"HeaderSetBackupSchedule": "Set Backup Schedule",
|
"HeaderSetBackupSchedule": "Set Backup Schedule",
|
||||||
"HeaderSettings": "Settings",
|
"HeaderSettings": "Settings",
|
||||||
"HeaderSettingsDisplay": "Display",
|
"HeaderSettingsDisplay": "Display",
|
||||||
@@ -415,6 +417,7 @@
|
|||||||
"LabelIntervalEveryHour": "Every hour",
|
"LabelIntervalEveryHour": "Every hour",
|
||||||
"LabelIntervalEveryMinute": "Every minute",
|
"LabelIntervalEveryMinute": "Every minute",
|
||||||
"LabelInvert": "Invert",
|
"LabelInvert": "Invert",
|
||||||
|
"LabelIpAddress": "IP Address",
|
||||||
"LabelItem": "Item",
|
"LabelItem": "Item",
|
||||||
"LabelJumpBackwardAmount": "Jump backward amount",
|
"LabelJumpBackwardAmount": "Jump backward amount",
|
||||||
"LabelJumpForwardAmount": "Jump forward amount",
|
"LabelJumpForwardAmount": "Jump forward amount",
|
||||||
@@ -774,6 +777,7 @@
|
|||||||
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
|
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
|
||||||
"MessageConfirmEmbedMetadataInAudioFiles": "Are you sure you want to embed metadata in {0} audio files?",
|
"MessageConfirmEmbedMetadataInAudioFiles": "Are you sure you want to embed metadata in {0} audio files?",
|
||||||
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
|
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?",
|
||||||
|
"MessageConfirmLogoutDevice": "Are you sure you want to log out of \"{0}\"?",
|
||||||
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
|
"MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?",
|
||||||
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
|
"MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
|
||||||
"MessageConfirmMarkItemFinished": "Are you sure you want to mark \"{0}\" as finished?",
|
"MessageConfirmMarkItemFinished": "Are you sure you want to mark \"{0}\" as finished?",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"ButtonLatest": "Najnovije",
|
"ButtonLatest": "Najnovije",
|
||||||
"ButtonLibrary": "Knjižnica",
|
"ButtonLibrary": "Knjižnica",
|
||||||
"ButtonLogout": "Odjavi se",
|
"ButtonLogout": "Odjavi se",
|
||||||
|
"ButtonLogoutAllDevices": "Odjava sa svih uređaja",
|
||||||
"ButtonLookup": "Potraži",
|
"ButtonLookup": "Potraži",
|
||||||
"ButtonManageTracks": "Upravljanje zvučnim zapisima",
|
"ButtonManageTracks": "Upravljanje zvučnim zapisima",
|
||||||
"ButtonMapChapterTitles": "Mapiraj naslove poglavlja",
|
"ButtonMapChapterTitles": "Mapiraj naslove poglavlja",
|
||||||
@@ -154,7 +155,7 @@
|
|||||||
"HeaderLatestEpisodes": "Najnoviji nastavci",
|
"HeaderLatestEpisodes": "Najnoviji nastavci",
|
||||||
"HeaderLibraries": "Knjižnice",
|
"HeaderLibraries": "Knjižnice",
|
||||||
"HeaderLibraryFiles": "Datoteke knjižnice",
|
"HeaderLibraryFiles": "Datoteke knjižnice",
|
||||||
"HeaderLibraryStats": "Statistika knjižnice",
|
"HeaderLibraryStats": "Knjižnična statistika",
|
||||||
"HeaderListeningSessions": "Sesije slušanja",
|
"HeaderListeningSessions": "Sesije slušanja",
|
||||||
"HeaderListeningStats": "Statistika slušanja",
|
"HeaderListeningStats": "Statistika slušanja",
|
||||||
"HeaderLogin": "Prijava",
|
"HeaderLogin": "Prijava",
|
||||||
|
|||||||
+32
-1
@@ -1 +1,32 @@
|
|||||||
{}
|
{
|
||||||
|
"ButtonAdd": "Bæta við",
|
||||||
|
"ButtonAddApiKey": "Bæta við API-lykli",
|
||||||
|
"ButtonAddChapters": "Bæta við köflum",
|
||||||
|
"ButtonAddDevice": "Bæta við tæki",
|
||||||
|
"ButtonAddLibrary": "Bæta við bókasafni",
|
||||||
|
"ButtonAddPodcasts": "Bæta við hlaðvörpum",
|
||||||
|
"ButtonAddUser": "Bæta við notanda",
|
||||||
|
"ButtonAddYourFirstLibrary": "Bættu fyrsta bókasafninu þínu við",
|
||||||
|
"ButtonApply": "Nota",
|
||||||
|
"ButtonApplyChapters": "Skrá kafla",
|
||||||
|
"ButtonAuthors": "Rithöfundar",
|
||||||
|
"ButtonBack": "Bakka",
|
||||||
|
"ButtonBatchEditPopulateFromExisting": "Afrita frá núverandi",
|
||||||
|
"ButtonBatchEditPopulateMapDetails": "Bæta við upplýsingum",
|
||||||
|
"ButtonBrowseForFolder": "Skoða möppu",
|
||||||
|
"ButtonCancel": "Hætta við",
|
||||||
|
"ButtonCancelEncode": "Hætta við Encoding",
|
||||||
|
"ButtonChangeRootPassword": "Breyta Root lykilorði",
|
||||||
|
"ButtonCheckAndDownloadNewEpisodes": "Skoða og sækja nýja þætti",
|
||||||
|
"ButtonChooseAFolder": "Velja möppu",
|
||||||
|
"ButtonChooseFiles": "Velja skrár",
|
||||||
|
"ButtonClearFilter": "Hreinsa síu",
|
||||||
|
"ButtonClose": "Loka",
|
||||||
|
"ButtonCloseSession": "Loka opnu sessioni",
|
||||||
|
"ButtonCollections": "Söfn",
|
||||||
|
"ButtonConfigureScanner": "Stilla skannara",
|
||||||
|
"ButtonCreate": "Stofna",
|
||||||
|
"ButtonCreateBackup": "Búa til backup",
|
||||||
|
"ButtonDelete": "Eyða",
|
||||||
|
"ButtonDownloadQueue": "Röð"
|
||||||
|
}
|
||||||
|
|||||||
@@ -325,8 +325,8 @@
|
|||||||
"LabelDurationComparisonLonger": "({0} maior)",
|
"LabelDurationComparisonLonger": "({0} maior)",
|
||||||
"LabelDurationComparisonShorter": "({0} menor)",
|
"LabelDurationComparisonShorter": "({0} menor)",
|
||||||
"LabelDurationFound": "Duração comprovada:",
|
"LabelDurationFound": "Duração comprovada:",
|
||||||
"LabelEbook": "Ebook",
|
"LabelEbook": "E-book",
|
||||||
"LabelEbooks": "Ebooks",
|
"LabelEbooks": "E-books",
|
||||||
"LabelEdit": "Editar",
|
"LabelEdit": "Editar",
|
||||||
"LabelEmail": "Email",
|
"LabelEmail": "Email",
|
||||||
"LabelEmailSettingsFromAddress": "Remetente",
|
"LabelEmailSettingsFromAddress": "Remetente",
|
||||||
|
|||||||
+533
-533
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
+258
-257
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,11 @@ if (isDev || options['prod-with-dev-env']) {
|
|||||||
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1'
|
||||||
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
||||||
if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath
|
if (devEnv.ReactClientPath) process.env.REACT_CLIENT_PATH = devEnv.ReactClientPath
|
||||||
|
if (devEnv.AllowedDevOrigins) {
|
||||||
|
process.env.ALLOWED_DEV_ORIGINS = Array.isArray(devEnv.AllowedDevOrigins)
|
||||||
|
? devEnv.AllowedDevOrigins.join(',')
|
||||||
|
: String(devEnv.AllowedDevOrigins)
|
||||||
|
}
|
||||||
process.env.SOURCE = 'local'
|
process.env.SOURCE = 'local'
|
||||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
|
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf'
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.35.0",
|
"version": "2.35.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.35.0",
|
"version": "2.35.1",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.35.0",
|
"version": "2.35.1",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
<br />
|
<br />
|
||||||
<a href="https://audiobookshelf.org/docs">Documentation</a>
|
<a href="https://audiobookshelf.org/docs">Documentation</a>
|
||||||
·
|
·
|
||||||
<a href="https://audiobookshelf.org/guides">User Guides</a>
|
|
||||||
·
|
|
||||||
<a href="https://audiobookshelf.org/support">Support</a>
|
<a href="https://audiobookshelf.org/support">Support</a>
|
||||||
|
·
|
||||||
|
<a href="https://audiobooks.dev/">Demo</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,21 +57,17 @@ Try it out on the [Google Play Store](https://play.google.com/store/apps/details
|
|||||||
|
|
||||||
Using Test Flight: https://testflight.apple.com/join/wiic7QIW **_(beta is full)_**
|
Using Test Flight: https://testflight.apple.com/join/wiic7QIW **_(beta is full)_**
|
||||||
|
|
||||||
### Build your own tools & clients
|
|
||||||
|
|
||||||
Check out the [API documentation](https://api.audiobookshelf.org/)
|
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/DemoLibrary.png" />
|
<img alt="Library Screenshot" src="https://github.com/advplyr/audiobookshelf/raw/master/images/DemoLibrary.png" />
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
# Organizing your audiobooks
|
# Organizing your media
|
||||||
|
|
||||||
#### Directory structure and folder names are important to Audiobookshelf!
|
#### Directory structure and folder names are important to Audiobookshelf!
|
||||||
|
|
||||||
See [documentation](https://audiobookshelf.org/docs#book-directory-structure) for supported directory structure, folder naming conventions, and audio file metadata usage.
|
See [library docs](https://audiobookshelf.org/docs/category/libraries) for supported directory structures, folder naming conventions, and audio file metadata usage.
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
@@ -87,275 +83,24 @@ See [install docs](https://www.audiobookshelf.org/docs)
|
|||||||
|
|
||||||
#### Note: Using a subfolder is supported with no additional changes but the path must be `/audiobookshelf` (this is not changeable). See [discussion](https://github.com/advplyr/audiobookshelf/discussions/3535)
|
#### Note: Using a subfolder is supported with no additional changes but the path must be `/audiobookshelf` (this is not changeable). See [discussion](https://github.com/advplyr/audiobookshelf/discussions/3535)
|
||||||
|
|
||||||
### NGINX Proxy Manager
|
See [reverse proxy docs](https://audiobookshelf.org/docs/category/reverse-proxy)
|
||||||
|
|
||||||
Toggle websockets support.
|
<br />
|
||||||
|
|
||||||
<img alt="NGINX Web socket" src="https://user-images.githubusercontent.com/67830747/153679106-b2a7f5b9-0702-48c6-9740-b26b401986e9.png" />
|
|
||||||
|
|
||||||
### NGINX Reverse Proxy
|
|
||||||
|
|
||||||
Add this to the site config file on your nginx server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
server {
|
|
||||||
listen 443 ssl;
|
|
||||||
server_name <sub>.<domain>.<tld>;
|
|
||||||
|
|
||||||
access_log /var/log/nginx/audiobookshelf.access.log;
|
|
||||||
error_log /var/log/nginx/audiobookshelf.error.log;
|
|
||||||
|
|
||||||
ssl_certificate /path/to/certificate;
|
|
||||||
ssl_certificate_key /path/to/key;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header Host $http_host;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_pass http://<URL_to_forward_to>;
|
|
||||||
proxy_redirect http:// https://;
|
|
||||||
|
|
||||||
# Prevent 413 Request Entity Too Large error
|
|
||||||
# by increasing the maximum allowed size of the client request body
|
|
||||||
# For example, set it to 10 GiB
|
|
||||||
client_max_body_size 10240M;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Apache Reverse Proxy
|
|
||||||
|
|
||||||
Add this to the site config file on your Apache server after you have changed the relevant parts in the <> brackets, and inserted your certificate paths.
|
|
||||||
|
|
||||||
For this to work you must enable at least the following mods using `a2enmod`:
|
|
||||||
|
|
||||||
- `ssl`
|
|
||||||
- `proxy`
|
|
||||||
- `proxy_http`
|
|
||||||
- `proxy_balancer`
|
|
||||||
- `proxy_wstunnel`
|
|
||||||
- `rewrite`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
<IfModule mod_ssl.c>
|
|
||||||
<VirtualHost *:443>
|
|
||||||
ServerName <sub>.<domain>.<tld>
|
|
||||||
|
|
||||||
ErrorLog ${APACHE_LOG_DIR}/error.log
|
|
||||||
CustomLog ${APACHE_LOG_DIR}/access.log combined
|
|
||||||
|
|
||||||
ProxyPreserveHost On
|
|
||||||
ProxyPass / http://localhost:<audiobookshelf_port>/
|
|
||||||
RewriteEngine on
|
|
||||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
|
||||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
|
||||||
RewriteRule ^/?(.*) "ws://localhost:<audiobookshelf_port>/$1" [P,L]
|
|
||||||
|
|
||||||
# unless you're doing something special this should be generated by a
|
|
||||||
# tool like certbot by let's encrypt
|
|
||||||
SSLCertificateFile /path/to/cert/file
|
|
||||||
SSLCertificateKeyFile /path/to/key/file
|
|
||||||
</VirtualHost>
|
|
||||||
</IfModule>
|
|
||||||
```
|
|
||||||
|
|
||||||
If using Apache >= 2.4.47 you can use the following, without having to use any of the `RewriteEngine`, `RewriteCond`, or `RewriteRule` directives. For example:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<Location /audiobookshelf>
|
|
||||||
ProxyPreserveHost on
|
|
||||||
ProxyPass http://localhost:<audiobookshelf_port>/audiobookshelf upgrade=websocket
|
|
||||||
ProxyPassReverse http://localhost:<audiobookshelf_port>/audiobookshelf
|
|
||||||
</Location>
|
|
||||||
```
|
|
||||||
|
|
||||||
Some SSL certificates like those signed by Let's Encrypt require ACME validation. To allow Let's Encrypt to write and confirm the ACME challenge, edit your VirtualHost definition to prevent proxying traffic that queries `/.well-known` and instead serve that directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
<VirtualHost *:443>
|
|
||||||
# ...
|
|
||||||
|
|
||||||
# create the directory structure /.well-known/acme-challenges
|
|
||||||
# within DocumentRoot and give the HTTP user recursive write
|
|
||||||
# access to it.
|
|
||||||
DocumentRoot /path/to/local/directory
|
|
||||||
|
|
||||||
ProxyPreserveHost On
|
|
||||||
ProxyPass /.well-known !
|
|
||||||
ProxyPass / http://localhost:<audiobookshelf_port>/
|
|
||||||
|
|
||||||
# ...
|
|
||||||
</VirtualHost>
|
|
||||||
```
|
|
||||||
|
|
||||||
### SWAG Reverse Proxy
|
|
||||||
|
|
||||||
[See LinuxServer.io config sample](https://github.com/linuxserver/reverse-proxy-confs/blob/master/audiobookshelf.subdomain.conf.sample)
|
|
||||||
|
|
||||||
### Synology NAS Reverse Proxy Setup (DSM 7+/Quickconnect)
|
|
||||||
|
|
||||||
1. **Open Control Panel**
|
|
||||||
|
|
||||||
- Navigate to `Login Portal > Advanced`.
|
|
||||||
|
|
||||||
2. **General Tab**
|
|
||||||
|
|
||||||
- Click `Reverse Proxy` > `Create`.
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
| ------------------ | -------------- |
|
|
||||||
| Reverse Proxy Name | audiobookshelf |
|
|
||||||
|
|
||||||
3. **Source Configuration**
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
| ---------------------- | ---------------------------------------- |
|
|
||||||
| Protocol | HTTPS |
|
|
||||||
| Hostname | `<sub>.<quickconnectdomain>.synology.me` |
|
|
||||||
| Port | 443 |
|
|
||||||
| Access Control Profile | Leave as is |
|
|
||||||
|
|
||||||
- Example Hostname: `audiobookshelf.mydomain.synology.me`
|
|
||||||
|
|
||||||
4. **Destination Configuration**
|
|
||||||
|
|
||||||
| Setting | Value |
|
|
||||||
| -------- | ----------- |
|
|
||||||
| Protocol | HTTP |
|
|
||||||
| Hostname | Your NAS IP |
|
|
||||||
| Port | 13378 |
|
|
||||||
|
|
||||||
5. **Custom Header Tab**
|
|
||||||
|
|
||||||
- Go to `Create > Websocket`.
|
|
||||||
- Configure Headers (leave as is):
|
|
||||||
|
|
||||||
| Header Name | Value |
|
|
||||||
| ----------- | --------------------- |
|
|
||||||
| Upgrade | `$http_upgrade` |
|
|
||||||
| Connection | `$connection_upgrade` |
|
|
||||||
|
|
||||||
6. **Advanced Settings Tab**
|
|
||||||
- Leave as is.
|
|
||||||
|
|
||||||
### [Traefik Reverse Proxy](https://doc.traefik.io/traefik/)
|
|
||||||
|
|
||||||
Middleware relating to CORS will cause the app to report Unknown Error when logging in. To prevent this don't apply any of the following headers to the router for this site:
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>accessControlAllowMethods</li>
|
|
||||||
<li>accessControlAllowOriginList</li>
|
|
||||||
<li>accessControlMaxAge</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
From [@Dondochaka](https://discord.com/channels/942908292873723984/942914154254176257/945074590374318170) and [@BeastleeUK](https://discord.com/channels/942908292873723984/942914154254176257/970366039294611506) <br />
|
|
||||||
|
|
||||||
### Example Caddyfile - [Caddy Reverse Proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)
|
|
||||||
|
|
||||||
```
|
|
||||||
subdomain.domain.com {
|
|
||||||
encode gzip zstd
|
|
||||||
reverse_proxy <LOCAL_IP>:<PORT>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### HAProxy
|
|
||||||
|
|
||||||
Below is a generic HAProxy config, using `audiobookshelf.YOUR_DOMAIN.COM`.
|
|
||||||
|
|
||||||
To use `http2`, `ssl` is needed.
|
|
||||||
|
|
||||||
```make
|
|
||||||
global
|
|
||||||
# ... (your global settings go here)
|
|
||||||
|
|
||||||
defaults
|
|
||||||
mode http
|
|
||||||
# ... (your default settings go here)
|
|
||||||
|
|
||||||
frontend my_frontend
|
|
||||||
# Bind to port 443, enable SSL, and specify the certificate list file
|
|
||||||
bind :443 name :443 ssl crt-list /path/to/cert.crt_list alpn h2,http/1.1
|
|
||||||
mode http
|
|
||||||
|
|
||||||
# Define an ACL for subdomains starting with "audiobookshelf"
|
|
||||||
acl is_audiobookshelf hdr_beg(host) -i audiobookshelf
|
|
||||||
|
|
||||||
# Use the ACL to route traffic to audiobookshelf_backend if the condition is met,
|
|
||||||
# otherwise, use the default_backend
|
|
||||||
use_backend audiobookshelf_backend if is_audiobookshelf
|
|
||||||
default_backend default_backend
|
|
||||||
|
|
||||||
backend audiobookshelf_backend
|
|
||||||
mode http
|
|
||||||
# ... (backend settings for audiobookshelf go here)
|
|
||||||
|
|
||||||
# Define the server for the audiobookshelf backend
|
|
||||||
server audiobookshelf_server 127.0.0.99:13378
|
|
||||||
|
|
||||||
backend default_backend
|
|
||||||
mode http
|
|
||||||
# ... (default backend settings go here)
|
|
||||||
|
|
||||||
# Define the server for the default backend
|
|
||||||
server default_server 127.0.0.123:8081
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
### pfSense and HAProxy
|
|
||||||
|
|
||||||
For pfSense the inputs are graphical, and `Health checking` is enabled.
|
|
||||||
|
|
||||||
#### Frontend, Default backend, access control lists and actions
|
|
||||||
|
|
||||||
##### Access Control lists
|
|
||||||
|
|
||||||
| Name | Expression | CS | Not | Value |
|
|
||||||
| :------------: | :---------------: | :-: | :-: | :-------------: |
|
|
||||||
| audiobookshelf | Host starts with: | | | audiobookshelf. |
|
|
||||||
|
|
||||||
##### Actions
|
|
||||||
|
|
||||||
The `condition acl names` needs to match the name above `audiobookshelf`.
|
|
||||||
|
|
||||||
| Action | Parameters | Condition acl names |
|
|
||||||
| :-----------: | :------------: | :-----------------: |
|
|
||||||
| `Use Backend` | audiobookshelf | audiobookshelf |
|
|
||||||
|
|
||||||
#### Backend
|
|
||||||
|
|
||||||
The `Name` needs to match the `Parameters` above `audiobookshelf`.
|
|
||||||
|
|
||||||
| Name | audiobookshelf |
|
|
||||||
| ---- | -------------- |
|
|
||||||
|
|
||||||
##### Server list:
|
|
||||||
|
|
||||||
| Name | Expression | CS | Not | Value |
|
|
||||||
| :------------: | :---------------: | :-: | :-: | :-------------: |
|
|
||||||
| audiobookshelf | Host starts with: | | | audiobookshelf. |
|
|
||||||
|
|
||||||
##### Health checking:
|
|
||||||
|
|
||||||
Health checking is enabled by default. `Http check method` of `OPTIONS` is not supported on Audiobookshelf. If Health check fails, data will not be forwared. Need to do one of following:
|
|
||||||
|
|
||||||
- To disable: Change `Health check method` to `none`.
|
|
||||||
- To make Health checking function: Change `Http check method` to `HEAD` or `GET`.
|
|
||||||
|
|
||||||
# Run from source
|
|
||||||
|
|
||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
This application is built using [NodeJs](https://nodejs.org/).
|
See [contributing docs](https://audiobookshelf.org/docs/contributing/general/)
|
||||||
|
|
||||||
### Localization
|
### Localization
|
||||||
|
|
||||||
Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations). <a href="https://hosted.weblate.org/engage/audiobookshelf/"> <img src="https://hosted.weblate.org/widget/audiobookshelf/abs-web-client/multi-auto.svg" alt="Translation status" /> </a>
|
Thank you to [Weblate](https://hosted.weblate.org/engage/audiobookshelf/) for hosting our localization infrastructure pro-bono. If you want to see Audiobookshelf in your language, please help us localize. Additional information on helping with the translations [here](https://www.audiobookshelf.org/faq#how-do-i-help-with-translations).
|
||||||
|
<a href="https://hosted.weblate.org/engage/audiobookshelf/"> <img src="https://hosted.weblate.org/widget/audiobookshelf/abs-web-client/multi-auto.svg" alt="Translation status" /> </a>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
# Run from source
|
||||||
|
|
||||||
|
This application is built using [NodeJs](https://nodejs.org/).
|
||||||
|
|
||||||
### Dev Container Setup
|
### Dev Container Setup
|
||||||
|
|
||||||
@@ -447,6 +192,3 @@ If you are using VSCode, this project includes a couple of pre-defined targets t
|
|||||||
- `Debug client (nuxt)`—Run the client with live reload.
|
- `Debug client (nuxt)`—Run the client with live reload.
|
||||||
- `Debug server and client (nuxt)`—Runs both the preceding two debug targets.
|
- `Debug server and client (nuxt)`—Runs both the preceding two debug targets.
|
||||||
|
|
||||||
# How to Support
|
|
||||||
|
|
||||||
[See the incomplete "How to Support" page](https://www.audiobookshelf.org/support)
|
|
||||||
|
|||||||
+9
-4
@@ -81,7 +81,7 @@ class Auth {
|
|||||||
* @param {import('./models/User')} user
|
* @param {import('./models/User')} user
|
||||||
* @param {Request} req
|
* @param {Request} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
|
* @returns {Promise<{ accessToken:string, refreshToken:string }|null>} new tokens for the current session if kept alive
|
||||||
*/
|
*/
|
||||||
async invalidateJwtSessionsForUser(user, req, res) {
|
async invalidateJwtSessionsForUser(user, req, res) {
|
||||||
return this.tokenManager.invalidateJwtSessionsForUser(user, req, res)
|
return this.tokenManager.invalidateJwtSessionsForUser(user, req, res)
|
||||||
@@ -471,18 +471,23 @@ class Auth {
|
|||||||
res.json(openIdIssuerConfig)
|
res.json(openIdIssuerConfig)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Logout route
|
/**
|
||||||
|
* Logout route
|
||||||
|
* Use ?allDevices=1 to destroy every session for this user instead of just the current one
|
||||||
|
*/
|
||||||
router.post('/logout', async (req, res) => {
|
router.post('/logout', async (req, res) => {
|
||||||
// Refresh token be alternatively be sent in the header
|
// Refresh token be alternatively be sent in the header
|
||||||
const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
|
const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
|
||||||
|
const allDevices = req.query.allDevices === '1'
|
||||||
|
|
||||||
// Clear refresh token cookie
|
// Clear refresh token cookie
|
||||||
res.clearCookie('refresh_token', {
|
res.clearCookie('refresh_token', {
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Invalidate the session in database using refresh token
|
if (allDevices) {
|
||||||
if (refreshToken) {
|
await this.tokenManager.invalidateAllSessionsForRefreshToken(refreshToken)
|
||||||
|
} else if (refreshToken) {
|
||||||
await this.tokenManager.invalidateRefreshToken(refreshToken)
|
await this.tokenManager.invalidateRefreshToken(refreshToken)
|
||||||
} else {
|
} else {
|
||||||
Logger.info(`[Auth] logout: No refresh token on request`)
|
Logger.info(`[Auth] logout: No refresh token on request`)
|
||||||
|
|||||||
+5
-3
@@ -14,6 +14,7 @@ const { version } = require('../package.json')
|
|||||||
const is = require('./libs/requestIp/isJs')
|
const is = require('./libs/requestIp/isJs')
|
||||||
const fileUtils = require('./utils/fileUtils')
|
const fileUtils = require('./utils/fileUtils')
|
||||||
const { toNumber } = require('./utils/index')
|
const { toNumber } = require('./utils/index')
|
||||||
|
const { getRequestOrigin } = require('./utils/requestUtils')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
const Auth = require('./Auth')
|
const Auth = require('./Auth')
|
||||||
@@ -288,10 +289,9 @@ class Server {
|
|||||||
// if RouterBasePath is set, modify all requests to include the base path
|
// if RouterBasePath is set, modify all requests to include the base path
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
const urlStartsWithRouterBasePath = req.url.startsWith(global.RouterBasePath)
|
||||||
const host = req.get('host')
|
const { origin } = getRequestOrigin(req)
|
||||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
|
||||||
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
|
const prefix = urlStartsWithRouterBasePath ? global.RouterBasePath : ''
|
||||||
req.originalHostPrefix = `${protocol}://${host}${prefix}`
|
req.originalHostPrefix = `${origin}${prefix}`
|
||||||
if (!urlStartsWithRouterBasePath) {
|
if (!urlStartsWithRouterBasePath) {
|
||||||
req.url = `${global.RouterBasePath}${req.url}`
|
req.url = `${global.RouterBasePath}${req.url}`
|
||||||
}
|
}
|
||||||
@@ -302,7 +302,9 @@ class Server {
|
|||||||
|
|
||||||
this.server = http.createServer(app)
|
this.server = http.createServer(app)
|
||||||
|
|
||||||
|
// Skip file upload parsing for internal-api routes (Next.js proxies read multipart bodies).
|
||||||
router.use(
|
router.use(
|
||||||
|
/^(?!\/internal-api).*/,
|
||||||
fileUpload({
|
fileUpload({
|
||||||
defCharset: 'utf8',
|
defCharset: 'utf8',
|
||||||
defParamCharset: 'utf8',
|
defParamCharset: 'utf8',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const OpenIDClient = require('openid-client')
|
|||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
const { getRequestOrigin } = require('../utils/requestUtils')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenID Connect authentication strategy
|
* OpenID Connect authentication strategy
|
||||||
@@ -289,8 +290,8 @@ class OidcAuthStrategy {
|
|||||||
const sessionKey = strategy._key
|
const sessionKey = strategy._key
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
const { origin } = getRequestOrigin(req)
|
||||||
const hostUrl = new URL(`${protocol}://${req.get('host')}`)
|
const hostUrl = new URL(origin)
|
||||||
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
|
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
|
||||||
|
|
||||||
// Only allow code flow (for mobile clients)
|
// Only allow code flow (for mobile clients)
|
||||||
@@ -394,11 +395,10 @@ class OidcAuthStrategy {
|
|||||||
let postLogoutRedirectUri = null
|
let postLogoutRedirectUri = null
|
||||||
|
|
||||||
if (authMethod === 'openid') {
|
if (authMethod === 'openid') {
|
||||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
const { origin } = getRequestOrigin(req)
|
||||||
const host = req.get('host')
|
|
||||||
// TODO: ABS does currently not support subfolders for installation
|
// TODO: ABS does currently not support subfolders for installation
|
||||||
// If we want to support it we need to include a config for the serverurl
|
// If we want to support it we need to include a config for the serverurl
|
||||||
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
|
postLogoutRedirectUri = `${origin}${global.RouterBasePath}/login`
|
||||||
}
|
}
|
||||||
// else for openid-mobile we keep postLogoutRedirectUri on null
|
// 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
|
// nice would be to redirect to the app here, but for example Authentik does not implement
|
||||||
@@ -515,42 +515,33 @@ class OidcAuthStrategy {
|
|||||||
if (!callbackUrl) return false
|
if (!callbackUrl) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Handle relative URLs - these are always safe if they start with router base path
|
// Reject protocol-relative (//host) and backslash-prefixed (/\host) values,
|
||||||
if (callbackUrl.startsWith('/')) {
|
// which browsers resolve to a cross-origin absolute URL.
|
||||||
// Only allow relative paths that start with the router base path
|
if (callbackUrl.startsWith('//') || callbackUrl.startsWith('/\\')) {
|
||||||
if (callbackUrl.startsWith(global.RouterBasePath + '/')) {
|
Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL: ${callbackUrl}`)
|
||||||
return true
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { origin: serverOrigin } = getRequestOrigin(req)
|
||||||
|
const resolvedUrl = callbackUrl.startsWith('/') ? new URL(callbackUrl, serverOrigin) : new URL(callbackUrl)
|
||||||
|
|
||||||
|
if (resolvedUrl.origin !== serverOrigin) {
|
||||||
|
Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${serverOrigin})`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = decodeURIComponent(resolvedUrl.pathname)
|
||||||
|
if (pathname.startsWith('//') || pathname.startsWith('/\\')) {
|
||||||
|
Logger.warn(`[OidcAuth] Rejected protocol-relative callback URL path: ${callbackUrl}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedUrl.pathname.startsWith(global.RouterBasePath + '/')) {
|
||||||
Logger.warn(`[OidcAuth] Rejected callback URL outside router base path: ${callbackUrl}`)
|
Logger.warn(`[OidcAuth] Rejected callback URL outside router base path: ${callbackUrl}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// For absolute URLs, ensure they point to the same origin
|
return true
|
||||||
const callbackUrlObj = new URL(callbackUrl)
|
|
||||||
// NPM appends both http and https in x-forwarded-proto sometimes, so we need to check for both
|
|
||||||
const xfp = (req.get('x-forwarded-proto') || '').toLowerCase()
|
|
||||||
const currentProtocol =
|
|
||||||
req.secure ||
|
|
||||||
xfp
|
|
||||||
.split(',')
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.includes('https')
|
|
||||||
? 'https'
|
|
||||||
: 'http'
|
|
||||||
const currentHost = req.get('host')
|
|
||||||
|
|
||||||
// Check if protocol and host match exactly
|
|
||||||
if (callbackUrlObj.protocol === currentProtocol + ':' && callbackUrlObj.host === currentHost) {
|
|
||||||
// Additional check: ensure path starts with router base path
|
|
||||||
if (callbackUrlObj.pathname.startsWith(global.RouterBasePath + '/')) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
Logger.warn(`[OidcAuth] Rejected same-origin callback URL outside router base path: ${callbackUrl}`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${currentProtocol}://${currentHost})`)
|
|
||||||
return false
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`[OidcAuth] Invalid callback URL format: ${callbackUrl}`, error)
|
Logger.error(`[OidcAuth] Invalid callback URL format: ${callbackUrl}`, error)
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
const { Op } = require('sequelize')
|
const { Op } = require('sequelize')
|
||||||
|
const uuid = require('uuid')
|
||||||
|
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
const requestIp = require('../libs/requestIp')
|
const requestIp = require('../libs/requestIp')
|
||||||
const jwt = require('../libs/jsonwebtoken')
|
const jwt = require('../libs/jsonwebtoken')
|
||||||
|
const { isRequestSecure } = require('../utils/requestUtils')
|
||||||
|
|
||||||
class TokenManager {
|
class TokenManager {
|
||||||
/** @type {string} JWT secret key */
|
/** @type {string} JWT secret key */
|
||||||
@@ -15,6 +17,8 @@ class TokenManager {
|
|||||||
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 30 * 24 * 60 * 60 // 30 days
|
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 30 * 24 * 60 * 60 // 30 days
|
||||||
/** @type {number} Access token expiry in seconds */
|
/** @type {number} Access token expiry in seconds */
|
||||||
this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 1 * 60 * 60 // 1 hour
|
this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 1 * 60 * 60 // 1 hour
|
||||||
|
/** @type {number} Grace period in seconds during which a rotated (old) refresh token is still accepted */
|
||||||
|
this.RefreshTokenGracePeriod = parseInt(process.env.REFRESH_TOKEN_GRACE_PERIOD) || 10 * 60 // 10 minutes
|
||||||
|
|
||||||
if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
|
if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
|
||||||
Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
|
Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
|
||||||
@@ -22,6 +26,9 @@ class TokenManager {
|
|||||||
if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {
|
if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {
|
||||||
Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)
|
Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)
|
||||||
}
|
}
|
||||||
|
if (parseInt(process.env.REFRESH_TOKEN_GRACE_PERIOD) > 0) {
|
||||||
|
Logger.info(`[TokenManager] Refresh token grace period set from ENV variable to ${this.RefreshTokenGracePeriod} seconds`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get TokenSecret() {
|
get TokenSecret() {
|
||||||
@@ -58,13 +65,25 @@ class TokenManager {
|
|||||||
setRefreshTokenCookie(req, res, refreshToken) {
|
setRefreshTokenCookie(req, res, refreshToken) {
|
||||||
res.cookie('refresh_token', refreshToken, {
|
res.cookie('refresh_token', refreshToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: req.secure || req.get('x-forwarded-proto') === 'https',
|
secure: isRequestSecure(req),
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
maxAge: this.RefreshTokenExpiry * 1000,
|
maxAge: this.RefreshTokenExpiry * 1000,
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a decoded JWT payload may authenticate API/socket requests (not refresh-only credentials).
|
||||||
|
*
|
||||||
|
* @param {Object} decoded
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
static isBearerAccessTokenPayload(decoded) {
|
||||||
|
if (!decoded?.userId) return false
|
||||||
|
if (decoded.type === 'refresh') return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to validate a jwt token for a given user
|
* Function to validate a jwt token for a given user
|
||||||
* Used to authenticate socket connections
|
* Used to authenticate socket connections
|
||||||
@@ -75,7 +94,9 @@ class TokenManager {
|
|||||||
*/
|
*/
|
||||||
static validateAccessToken(token) {
|
static validateAccessToken(token) {
|
||||||
try {
|
try {
|
||||||
return jwt.verify(token, TokenManager.TokenSecret)
|
const decoded = jwt.verify(token, TokenManager.TokenSecret)
|
||||||
|
if (!TokenManager.isBearerAccessTokenPayload(decoded)) return null
|
||||||
|
return decoded
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -115,6 +136,7 @@ class TokenManager {
|
|||||||
const payload = {
|
const payload = {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
jti: uuid.v4(),
|
||||||
type: 'access'
|
type: 'access'
|
||||||
}
|
}
|
||||||
const options = {
|
const options = {
|
||||||
@@ -138,6 +160,7 @@ class TokenManager {
|
|||||||
const payload = {
|
const payload = {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
jti: uuid.v4(),
|
||||||
type: 'refresh'
|
type: 'refresh'
|
||||||
}
|
}
|
||||||
const options = {
|
const options = {
|
||||||
@@ -196,9 +219,13 @@ class TokenManager {
|
|||||||
let lastRefreshTokenExpiresAt = null
|
let lastRefreshTokenExpiresAt = null
|
||||||
if (gracePeriod) {
|
if (gracePeriod) {
|
||||||
// Set grace period of old refresh token in case of race condition in token rotation.
|
// Set grace period of old refresh token in case of race condition in token rotation.
|
||||||
// This grace period may need to be longer if fetching the user data takes longer due to large progress objects
|
// During this window a retry with the old refresh token returns the already-rotated
|
||||||
|
// current token instead of failing, so a client that never received the rotation
|
||||||
|
// response (e.g. dropped/suspended mobile request) can still recover the session.
|
||||||
|
// Configurable via REFRESH_TOKEN_GRACE_PERIOD; may need to be longer if fetching the
|
||||||
|
// user data takes longer due to large progress objects.
|
||||||
lastRefreshToken = previousRefreshToken
|
lastRefreshToken = previousRefreshToken
|
||||||
lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period
|
lastRefreshTokenExpiresAt = new Date(Date.now() + this.RefreshTokenGracePeriod * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update if this session row still has the refresh token we read
|
// Only update if this session row still has the refresh token we read
|
||||||
@@ -279,7 +306,11 @@ class TokenManager {
|
|||||||
|
|
||||||
done(null, user)
|
done(null, user)
|
||||||
} else {
|
} else {
|
||||||
// JWT based authentication
|
// JWT based authentication — refresh tokens are only valid at POST /auth/refresh
|
||||||
|
if (!TokenManager.isBearerAccessTokenPayload(jwt_payload)) {
|
||||||
|
done(null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the jwt is expired
|
// Check if the jwt is expired
|
||||||
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
||||||
@@ -433,14 +464,19 @@ class TokenManager {
|
|||||||
* @param {import('../models/User')} user
|
* @param {import('../models/User')} user
|
||||||
* @param {import('express').Request} req
|
* @param {import('express').Request} req
|
||||||
* @param {import('express').Response} res
|
* @param {import('express').Response} res
|
||||||
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
|
* @returns {Promise<{ accessToken:string, refreshToken:string }|null>} new tokens for the current session if kept alive
|
||||||
*/
|
*/
|
||||||
async invalidateJwtSessionsForUser(user, req, res) {
|
async invalidateJwtSessionsForUser(user, req, res) {
|
||||||
const currentRefreshToken = req.cookies.refresh_token
|
const currentRefreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
|
||||||
if (req.user.id === user.id && currentRefreshToken) {
|
if (req.user.id === user.id && currentRefreshToken) {
|
||||||
// Current user is the same as the user to invalidate sessions for
|
// Current user is the same as the user to invalidate sessions for
|
||||||
// So rotate token for current session
|
// So rotate token for current session
|
||||||
const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
|
const currentSession = await Database.sessionModel.findOne({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
[Op.or]: [{ refreshToken: currentRefreshToken }, { lastRefreshToken: currentRefreshToken }]
|
||||||
|
}
|
||||||
|
})
|
||||||
if (currentSession) {
|
if (currentSession) {
|
||||||
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, false)
|
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, false)
|
||||||
|
|
||||||
@@ -454,7 +490,10 @@ class TokenManager {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return newTokens.accessToken
|
return {
|
||||||
|
accessToken: newTokens.accessToken,
|
||||||
|
refreshToken: newTokens.refreshToken
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Logger.error(`[TokenManager] No session found to rotate tokens`)
|
Logger.error(`[TokenManager] No session found to rotate tokens`)
|
||||||
}
|
}
|
||||||
@@ -466,6 +505,25 @@ class TokenManager {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy all JWT sessions for the user that owns this refresh token
|
||||||
|
*
|
||||||
|
* @param {string} refreshToken
|
||||||
|
*/
|
||||||
|
async invalidateAllSessionsForRefreshToken(refreshToken) {
|
||||||
|
if (!refreshToken) return
|
||||||
|
|
||||||
|
const session = await Database.sessionModel.findOne({
|
||||||
|
where: {
|
||||||
|
[Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const numDeleted = await Database.sessionModel.destroy({ where: { userId: session.userId } })
|
||||||
|
Logger.info(`[TokenManager] Invalidated all JWT sessions for user ${session.userId}, ${numDeleted} deleted`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate a refresh token - used for logout
|
* Invalidate a refresh token - used for logout
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class AuthorController {
|
|||||||
})
|
})
|
||||||
if (libraryItems.length) {
|
if (libraryItems.length) {
|
||||||
await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
|
await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
|
||||||
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor
|
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate, { ignoreDuplicates: true }) // Create all new unique BookAuthor
|
||||||
for (const libraryItem of libraryItems) {
|
for (const libraryItem of libraryItems) {
|
||||||
await libraryItem.saveMetadataFile()
|
await libraryItem.saveMetadataFile()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1432,14 +1432,17 @@ class LibraryController {
|
|||||||
|
|
||||||
const itemIds = req.query.ids.split(',')
|
const itemIds = req.query.ids.split(',')
|
||||||
|
|
||||||
const libraryItems = await Database.libraryItemModel.findAll({
|
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
||||||
attributes: ['id', 'libraryId', 'path', 'isFile'],
|
id: itemIds,
|
||||||
where: {
|
libraryId: req.library.id
|
||||||
id: itemIds,
|
|
||||||
libraryId: req.library.id
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for (const libraryItem of libraryItems) {
|
||||||
|
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (libraryItems.length < itemIds.length) {
|
if (libraryItems.length < itemIds.length) {
|
||||||
Logger.warn(`[LibraryController] User "${req.user.username}" requested ${itemIds.length} items but only ${libraryItems.length} are in library "${req.library.id}"`)
|
Logger.warn(`[LibraryController] User "${req.user.username}" requested ${itemIds.length} items but only ${libraryItems.length} are in library "${req.library.id}"`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
const { Request, Response } = require('express')
|
const { Request, Response } = require('express')
|
||||||
|
const { Op } = require('sequelize')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const { sort } = require('../libs/fastSort')
|
const { sort } = require('../libs/fastSort')
|
||||||
const { toNumber, isNullOrNaN } = require('../utils/index')
|
const { toNumber, isNullOrNaN, isUUID } = require('../utils/index')
|
||||||
const userStats = require('../utils/queries/userStats')
|
const userStats = require('../utils/queries/userStats')
|
||||||
|
const parseUserAgent = require('../utils/parsers/parseUserAgent')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef RequestUserObject
|
* @typedef RequestUserObject
|
||||||
@@ -26,6 +28,124 @@ class MeController {
|
|||||||
res.json(req.user.toOldJSONForBrowser())
|
res.json(req.user.toOldJSONForBrowser())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/me/sessions
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async getSessions(req, res) {
|
||||||
|
const page = Math.max(0, toNumber(req.query.page, 0))
|
||||||
|
const itemsPerPage = Math.max(1, toNumber(req.query.itemsPerPage, 10))
|
||||||
|
|
||||||
|
if (req.user.isGuest) {
|
||||||
|
return res.json({ sessions: [], total: 0, numPages: 0, page, itemsPerPage })
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
|
||||||
|
const { rows, count } = await Database.sessionModel.findAndCountAll({
|
||||||
|
where: {
|
||||||
|
userId: req.user.id,
|
||||||
|
expiresAt: { [Op.gt]: new Date() }
|
||||||
|
},
|
||||||
|
order: [['updatedAt', 'DESC']],
|
||||||
|
limit: itemsPerPage,
|
||||||
|
offset: itemsPerPage * page
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
total: count,
|
||||||
|
numPages: Math.ceil(count / itemsPerPage),
|
||||||
|
page,
|
||||||
|
itemsPerPage,
|
||||||
|
sessions: rows.map((session) => ({
|
||||||
|
id: session.id,
|
||||||
|
ipAddress: session.ipAddress,
|
||||||
|
userAgent: session.userAgent,
|
||||||
|
// For display convenience
|
||||||
|
deviceInfo: parseUserAgent(session.userAgent),
|
||||||
|
createdAt: session.createdAt?.valueOf() ?? null,
|
||||||
|
updatedAt: session.updatedAt?.valueOf() ?? null,
|
||||||
|
current: !!refreshToken && (session.refreshToken === refreshToken || session.lastRefreshToken === refreshToken)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE: /api/me/sessions/:id
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async deleteSession(req, res) {
|
||||||
|
if (req.user.isGuest) {
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUUID(req.params.id)) {
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await Database.sessionModel.findOne({
|
||||||
|
where: {
|
||||||
|
id: req.params.id,
|
||||||
|
userId: req.user.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Database.sessionModel.destroy({ where: { id: session.id } })
|
||||||
|
Logger.info(`[MeController] User ${req.user.username} deleted auth session ${session.id}`)
|
||||||
|
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/me/progress
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
getAllMediaProgress(req, res) {
|
||||||
|
const mediaProgress = req.user.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || []
|
||||||
|
res.json({ mediaProgress })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/me/bookmarks
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
getAllBookmarks(req, res) {
|
||||||
|
const bookmarks = req.user.bookmarks?.map((bookmark) => ({ ...bookmark })) || []
|
||||||
|
res.json({ bookmarks })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/me/bookmarks/:libraryItemId
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async getBookmarksForLibraryItem(req, res) {
|
||||||
|
const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.libraryItemId)
|
||||||
|
if (!libraryItem) {
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||||
|
Logger.error(`[MeController] User "${req.user.username}" attempted to access bookmarks for library item "${req.params.libraryItemId}" without access`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookmarks = req.user.bookmarks?.filter((bookmark) => bookmark.libraryItemId === libraryItem.id).map((bookmark) => ({ ...bookmark })) || []
|
||||||
|
res.json({ bookmarks })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET: /api/me/listening-sessions
|
* GET: /api/me/listening-sessions
|
||||||
*
|
*
|
||||||
@@ -308,6 +428,8 @@ class MeController {
|
|||||||
* User change password. Requires current password.
|
* User change password. Requires current password.
|
||||||
* Guest users cannot change password.
|
* Guest users cannot change password.
|
||||||
*
|
*
|
||||||
|
* Invalidates all other JWT sessions for the user. If using x-refresh-token, returns new tokens for the current session.
|
||||||
|
*
|
||||||
* @this import('../routers/ApiRouter')
|
* @this import('../routers/ApiRouter')
|
||||||
*
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
@@ -330,6 +452,24 @@ class MeController {
|
|||||||
return res.status(400).send(result.error)
|
return res.status(400).send(result.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldReturnTokens = !!req.headers['x-refresh-token']
|
||||||
|
const newTokens = await this.auth.invalidateJwtSessionsForUser(req.user, req, res)
|
||||||
|
|
||||||
|
if (newTokens?.accessToken) {
|
||||||
|
Logger.info(`[MeController] Invalidated other JWT sessions for user ${req.user.username} after password change`)
|
||||||
|
if (shouldReturnTokens) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
accessToken: newTokens.accessToken,
|
||||||
|
refreshToken: newTokens.refreshToken
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Logger.info(`[MeController] Invalidated all JWT sessions for user ${req.user.username} after password change`)
|
||||||
|
}
|
||||||
|
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -437,6 +437,17 @@ class PodcastController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updatePayload[key] = req.body[key]
|
updatePayload[key] = req.body[key]
|
||||||
|
} else if (key === 'enclosure') {
|
||||||
|
const enclosure = req.body.enclosure
|
||||||
|
if (enclosure === null) {
|
||||||
|
updatePayload.enclosureURL = null
|
||||||
|
updatePayload.enclosureSize = null
|
||||||
|
updatePayload.enclosureType = null
|
||||||
|
} else if (typeof enclosure === 'object' && typeof enclosure.url === 'string') {
|
||||||
|
updatePayload.enclosureURL = enclosure.url
|
||||||
|
updatePayload.enclosureType = typeof enclosure.type === 'string' ? enclosure.type : null
|
||||||
|
updatePayload.enclosureSize = enclosure.length !== undefined && enclosure.length !== null ? enclosure.length : null
|
||||||
|
}
|
||||||
} else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) {
|
} else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) {
|
||||||
updatePayload[key] = req.body[key]
|
updatePayload[key] = req.body[key]
|
||||||
} else if (key === 'publishedAt' && typeof req.body[key] === 'number') {
|
} else if (key === 'publishedAt' && typeof req.body[key] === 'number') {
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ class UserController {
|
|||||||
// Updating password
|
// Updating password
|
||||||
if (updatePayload.password) {
|
if (updatePayload.password) {
|
||||||
user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password)
|
user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password)
|
||||||
|
shouldInvalidateJwtSessions = true
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,14 +332,11 @@ class UserController {
|
|||||||
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
|
// Handle JWT session invalidation for username/password changes
|
||||||
if (shouldInvalidateJwtSessions) {
|
if (shouldInvalidateJwtSessions) {
|
||||||
const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res)
|
const newTokens = await this.auth.invalidateJwtSessionsForUser(user, req, res)
|
||||||
if (newAccessToken) {
|
if (newTokens) {
|
||||||
user.accessToken = newAccessToken
|
// Note: for admin users changing their own password they should use MeController.updatePassword instead. This endpoint does not return tokens
|
||||||
// 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`)
|
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`)
|
||||||
} else {
|
} else {
|
||||||
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`)
|
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`)
|
||||||
@@ -363,15 +361,16 @@ class UserController {
|
|||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async delete(req, res) {
|
async delete(req, res) {
|
||||||
if (req.params.id === 'root') {
|
const user = req.reqUser
|
||||||
Logger.error('[UserController] Attempt to delete root user. Root user cannot be deleted')
|
|
||||||
return res.sendStatus(400)
|
|
||||||
}
|
|
||||||
if (req.user.id === req.params.id) {
|
if (req.user.id === req.params.id) {
|
||||||
Logger.error(`[UserController] User ${req.user.username} is attempting to delete self`)
|
Logger.error(`[UserController] User ${req.user.username} is attempting to delete self`)
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
const user = req.reqUser
|
if (user.isRoot) {
|
||||||
|
Logger.error(`[UserController] Admin user "${req.user.username}" attempted to delete root user`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
// Todo: check if user is logged in and cancel streams
|
// Todo: check if user is logged in and cancel streams
|
||||||
|
|
||||||
|
|||||||
+13
-6
@@ -647,6 +647,11 @@ class Book extends Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minified book JSON for list/shelf endpoints.
|
||||||
|
* `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
|
||||||
|
* with the same value semantics. Only additive changes to expanded; never remove or rename keys.
|
||||||
|
*/
|
||||||
toOldJSONMinified() {
|
toOldJSONMinified() {
|
||||||
if (!this.authors) {
|
if (!this.authors) {
|
||||||
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
|
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
|
||||||
@@ -669,6 +674,12 @@ class Book extends Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expanded book JSON for item detail and socket events.
|
||||||
|
* Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields.
|
||||||
|
*
|
||||||
|
* @param {string} libraryItemId
|
||||||
|
*/
|
||||||
toOldJSONExpanded(libraryItemId) {
|
toOldJSONExpanded(libraryItemId) {
|
||||||
if (!libraryItemId) {
|
if (!libraryItemId) {
|
||||||
throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)
|
throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)
|
||||||
@@ -681,16 +692,12 @@ class Book extends Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
...this.toOldJSONMinified(),
|
||||||
libraryItemId: libraryItemId,
|
libraryItemId,
|
||||||
metadata: this.oldMetadataToJSONExpanded(),
|
metadata: this.oldMetadataToJSONExpanded(),
|
||||||
coverPath: this.coverPath,
|
|
||||||
tags: [...(this.tags || [])],
|
|
||||||
audioFiles: structuredClone(this.audioFiles),
|
audioFiles: structuredClone(this.audioFiles),
|
||||||
chapters: structuredClone(this.chapters),
|
chapters: structuredClone(this.chapters),
|
||||||
ebookFile: structuredClone(this.ebookFile),
|
ebookFile: structuredClone(this.ebookFile),
|
||||||
duration: this.duration,
|
|
||||||
size: this.size,
|
|
||||||
tracks: this.getTracklist(libraryItemId)
|
tracks: this.getTracklist(libraryItemId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model, fn, col } = require('sequelize')
|
||||||
|
|
||||||
class BookAuthor extends Model {
|
class BookAuthor extends Model {
|
||||||
constructor(values, options) {
|
constructor(values, options) {
|
||||||
@@ -37,6 +37,32 @@ class BookAuthor extends Model {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get number of books for each author
|
||||||
|
*
|
||||||
|
* @param {string[]} authorIds
|
||||||
|
* @returns {Promise<Record<string, number>>}
|
||||||
|
*/
|
||||||
|
static async getCountsForAuthors(authorIds) {
|
||||||
|
if (!authorIds.length) return {}
|
||||||
|
|
||||||
|
const rows = await this.findAll({
|
||||||
|
attributes: ['authorId', [fn('COUNT', col('id')), 'count']],
|
||||||
|
where: {
|
||||||
|
authorId: authorIds
|
||||||
|
},
|
||||||
|
group: ['authorId'],
|
||||||
|
raw: true
|
||||||
|
})
|
||||||
|
|
||||||
|
/** @type {Record<string, number>} */
|
||||||
|
const counts = {}
|
||||||
|
for (const row of rows) {
|
||||||
|
counts[row.authorId] = Number(row.count)
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize model
|
* Initialize model
|
||||||
* @param {import('../Database').sequelize} sequelize
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
|||||||
@@ -1004,6 +1004,11 @@ class LibraryItem extends Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minified library item JSON for list/shelf endpoints.
|
||||||
|
* `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
|
||||||
|
* with the same value semantics. Only additive changes to expanded; never remove or rename keys.
|
||||||
|
*/
|
||||||
toOldJSONMinified() {
|
toOldJSONMinified() {
|
||||||
if (!this.media) {
|
if (!this.media) {
|
||||||
throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`)
|
throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`)
|
||||||
@@ -1032,30 +1037,18 @@ class LibraryItem extends Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expanded library item JSON for item detail and socket events.
|
||||||
|
* Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields.
|
||||||
|
*/
|
||||||
toOldJSONExpanded() {
|
toOldJSONExpanded() {
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
...this.toOldJSONMinified(),
|
||||||
ino: this.ino,
|
|
||||||
oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
|
|
||||||
libraryId: this.libraryId,
|
|
||||||
folderId: this.libraryFolderId,
|
|
||||||
path: this.path,
|
|
||||||
relPath: this.relPath,
|
|
||||||
isFile: this.isFile,
|
|
||||||
mtimeMs: this.mtime?.valueOf(),
|
|
||||||
ctimeMs: this.ctime?.valueOf(),
|
|
||||||
birthtimeMs: this.birthtime?.valueOf(),
|
|
||||||
addedAt: this.createdAt.valueOf(),
|
|
||||||
updatedAt: this.updatedAt.valueOf(),
|
|
||||||
lastScan: this.lastScan?.valueOf(),
|
lastScan: this.lastScan?.valueOf(),
|
||||||
scanVersion: this.lastScanVersion,
|
scanVersion: this.lastScanVersion,
|
||||||
isMissing: !!this.isMissing,
|
|
||||||
isInvalid: !!this.isInvalid,
|
|
||||||
mediaType: this.mediaType,
|
|
||||||
media: this.media.toOldJSONExpanded(this.id),
|
media: this.media.toOldJSONExpanded(this.id),
|
||||||
// LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database
|
// LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database
|
||||||
libraryFiles: this.getLibraryFilesJson(),
|
libraryFiles: this.getLibraryFilesJson()
|
||||||
size: this.size
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-12
@@ -451,6 +451,11 @@ class Podcast extends Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minified podcast JSON for list/shelf endpoints.
|
||||||
|
* `toOldJSONExpanded()` must be a strict superset: every key here must exist in expanded
|
||||||
|
* with the same value semantics. Only additive changes to expanded; never remove or rename keys.
|
||||||
|
*/
|
||||||
toOldJSONMinified() {
|
toOldJSONMinified() {
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
@@ -468,6 +473,12 @@ class Podcast extends Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expanded podcast JSON for item detail and socket events.
|
||||||
|
* Must be a strict superset of `toOldJSONMinified()` — built by spreading minified, then adding expanded-only fields.
|
||||||
|
*
|
||||||
|
* @param {string} libraryItemId
|
||||||
|
*/
|
||||||
toOldJSONExpanded(libraryItemId) {
|
toOldJSONExpanded(libraryItemId) {
|
||||||
if (!libraryItemId) {
|
if (!libraryItemId) {
|
||||||
throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)
|
throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)
|
||||||
@@ -477,18 +488,9 @@ class Podcast extends Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
...this.toOldJSONMinified(),
|
||||||
libraryItemId: libraryItemId,
|
libraryItemId,
|
||||||
metadata: this.oldMetadataToJSONExpanded(),
|
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId))
|
||||||
coverPath: this.coverPath,
|
|
||||||
tags: [...(this.tags || [])],
|
|
||||||
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)),
|
|
||||||
autoDownloadEpisodes: this.autoDownloadEpisodes,
|
|
||||||
autoDownloadSchedule: this.autoDownloadSchedule,
|
|
||||||
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
|
|
||||||
maxEpisodesToKeep: this.maxEpisodesToKeep,
|
|
||||||
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
|
|
||||||
size: this.size
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-11
@@ -352,11 +352,7 @@ class User extends Model {
|
|||||||
if (cachedUser) return cachedUser
|
if (cachedUser) return cachedUser
|
||||||
|
|
||||||
const user = await this.findOne({
|
const user = await this.findOne({
|
||||||
where: {
|
where: sequelize.where(sequelize.fn('lower', sequelize.col('username')), username.toLowerCase()),
|
||||||
username: {
|
|
||||||
[sequelize.Op.like]: username
|
|
||||||
}
|
|
||||||
},
|
|
||||||
include: this.sequelize.models.mediaProgress
|
include: this.sequelize.models.mediaProgress
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -377,11 +373,7 @@ class User extends Model {
|
|||||||
if (cachedUser) return cachedUser
|
if (cachedUser) return cachedUser
|
||||||
|
|
||||||
const user = await this.findOne({
|
const user = await this.findOne({
|
||||||
where: {
|
where: sequelize.where(sequelize.fn('lower', sequelize.col('email')), email.toLowerCase()),
|
||||||
email: {
|
|
||||||
[sequelize.Op.like]: email
|
|
||||||
}
|
|
||||||
},
|
|
||||||
include: this.sequelize.models.mediaProgress
|
include: this.sequelize.models.mediaProgress
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -538,7 +530,14 @@ class User extends Model {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
sequelize,
|
sequelize,
|
||||||
modelName: 'user'
|
modelName: 'user',
|
||||||
|
hooks: {
|
||||||
|
beforeDestroy(user) {
|
||||||
|
if (user.type === 'root') {
|
||||||
|
throw new Error('Root user cannot be deleted')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,6 +260,18 @@ class ServerSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host timezone used by cron schedulers (not persisted in settings)
|
||||||
|
* @returns {string} IANA timezone name, e.g. "America/New_York"
|
||||||
|
*/
|
||||||
|
static getHostTimeZone() {
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
||||||
|
} catch {
|
||||||
|
return 'UTC'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toJSONForBrowser() {
|
toJSONForBrowser() {
|
||||||
const json = this.toJSON()
|
const json = this.toJSON()
|
||||||
delete json.tokenSecret
|
delete json.tokenSecret
|
||||||
@@ -268,6 +280,7 @@ class ServerSettings {
|
|||||||
delete json.authOpenIDMobileRedirectURIs
|
delete json.authOpenIDMobileRedirectURIs
|
||||||
delete json.authOpenIDGroupClaim
|
delete json.authOpenIDGroupClaim
|
||||||
delete json.authOpenIDAdvancedPermsClaim
|
delete json.authOpenIDAdvancedPermsClaim
|
||||||
|
json.timeZone = ServerSettings.getHostTimeZone()
|
||||||
return json
|
return json
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,11 @@ class ApiRouter {
|
|||||||
// Current User Routes (Me)
|
// Current User Routes (Me)
|
||||||
//
|
//
|
||||||
this.router.get('/me', MeController.getCurrentUser.bind(this))
|
this.router.get('/me', MeController.getCurrentUser.bind(this))
|
||||||
|
this.router.get('/me/sessions', MeController.getSessions.bind(this))
|
||||||
|
this.router.delete('/me/sessions/:id', MeController.deleteSession.bind(this))
|
||||||
|
this.router.get('/me/progress', MeController.getAllMediaProgress.bind(this))
|
||||||
|
this.router.get('/me/bookmarks', MeController.getAllBookmarks.bind(this))
|
||||||
|
this.router.get('/me/bookmarks/:libraryItemId', MeController.getBookmarksForLibraryItem.bind(this))
|
||||||
this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this))
|
this.router.get('/me/listening-sessions', MeController.getListeningSessions.bind(this))
|
||||||
this.router.get('/me/item/listening-sessions/:libraryItemId/:episodeId?', MeController.getItemListeningSessions.bind(this))
|
this.router.get('/me/item/listening-sessions/:libraryItemId/:episodeId?', MeController.getItemListeningSessions.bind(this))
|
||||||
this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))
|
this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ const { LogLevel } = require('../utils/constants')
|
|||||||
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
|
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
|
||||||
|
|
||||||
class AbsMetadataFileScanner {
|
class AbsMetadataFileScanner {
|
||||||
constructor() { }
|
constructor() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check for metadata.json file and set book metadata
|
* Check for metadata.json file and set book metadata
|
||||||
*
|
*
|
||||||
* @param {import('./LibraryScan')} libraryScan
|
* @param {import('./LibraryScan')} libraryScan
|
||||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||||
* @param {Object} bookMetadata
|
* @param {Object} bookMetadata
|
||||||
* @param {string} [existingLibraryItemId]
|
* @param {string} [existingLibraryItemId]
|
||||||
*/
|
*/
|
||||||
async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) {
|
async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) {
|
||||||
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile
|
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile
|
||||||
@@ -32,7 +32,8 @@ class AbsMetadataFileScanner {
|
|||||||
|
|
||||||
if (metadataText) {
|
if (metadataText) {
|
||||||
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
|
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
|
||||||
const abMetadata = abmetadataGenerator.parseJson(metadataText) || {}
|
const abMetadata = abmetadataGenerator.parseJson(metadataText, 'book') || {}
|
||||||
|
|
||||||
for (const key in abMetadata) {
|
for (const key in abMetadata) {
|
||||||
// TODO: When to override with null or empty arrays?
|
// TODO: When to override with null or empty arrays?
|
||||||
if (abMetadata[key] === undefined || abMetadata[key] === null) continue
|
if (abMetadata[key] === undefined || abMetadata[key] === null) continue
|
||||||
@@ -48,11 +49,11 @@ class AbsMetadataFileScanner {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check for metadata.json file and set podcast metadata
|
* Check for metadata.json file and set podcast metadata
|
||||||
*
|
*
|
||||||
* @param {import('./LibraryScan')} libraryScan
|
* @param {import('./LibraryScan')} libraryScan
|
||||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||||
* @param {Object} podcastMetadata
|
* @param {Object} podcastMetadata
|
||||||
* @param {string} [existingLibraryItemId]
|
* @param {string} [existingLibraryItemId]
|
||||||
*/
|
*/
|
||||||
async scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId = null) {
|
async scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId = null) {
|
||||||
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile
|
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile
|
||||||
@@ -71,7 +72,7 @@ class AbsMetadataFileScanner {
|
|||||||
|
|
||||||
if (metadataText) {
|
if (metadataText) {
|
||||||
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
|
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
|
||||||
const abMetadata = abmetadataGenerator.parseJson(metadataText) || {}
|
const abMetadata = abmetadataGenerator.parseJson(metadataText, 'podcast') || {}
|
||||||
for (const key in abMetadata) {
|
for (const key in abMetadata) {
|
||||||
if (abMetadata[key] === undefined || abMetadata[key] === null) continue
|
if (abMetadata[key] === undefined || abMetadata[key] === null) continue
|
||||||
if (key === 'tags' && !abMetadata.tags?.length) continue
|
if (key === 'tags' && !abMetadata.tags?.length) continue
|
||||||
@@ -81,4 +82,4 @@ class AbsMetadataFileScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = new AbsMetadataFileScanner()
|
module.exports = new AbsMetadataFileScanner()
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ class BookScanner {
|
|||||||
bookId: media.id,
|
bookId: media.id,
|
||||||
authorId: existingAuthorId
|
authorId: existingAuthorId
|
||||||
})
|
})
|
||||||
|
libraryScan.authorsNumBooksChangedIds.add(existingAuthorId)
|
||||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added author "${authorName}"`)
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added author "${authorName}"`)
|
||||||
authorsUpdated = true
|
authorsUpdated = true
|
||||||
} else {
|
} else {
|
||||||
@@ -238,6 +239,7 @@ class BookScanner {
|
|||||||
})
|
})
|
||||||
await media.addAuthor(newAuthor)
|
await media.addAuthor(newAuthor)
|
||||||
Database.addAuthorToFilterData(libraryItemData.libraryId, newAuthor.name, newAuthor.id)
|
Database.addAuthorToFilterData(libraryItemData.libraryId, newAuthor.name, newAuthor.id)
|
||||||
|
SocketAuthority.emitter('author_added', newAuthor.toOldJSON())
|
||||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new author "${authorName}"`)
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new author "${authorName}"`)
|
||||||
authorsUpdated = true
|
authorsUpdated = true
|
||||||
}
|
}
|
||||||
@@ -247,6 +249,7 @@ class BookScanner {
|
|||||||
for (const author of media.authors) {
|
for (const author of media.authors) {
|
||||||
if (!bookMetadata.authors.includes(author.name)) {
|
if (!bookMetadata.authors.includes(author.name)) {
|
||||||
await author.bookAuthor.destroy()
|
await author.bookAuthor.destroy()
|
||||||
|
libraryScan.authorsNumBooksChangedIds.add(author.id)
|
||||||
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" removed author "${author.name}"`)
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" removed author "${author.name}"`)
|
||||||
authorsUpdated = true
|
authorsUpdated = true
|
||||||
bookAuthorsRemoved.push(author.id)
|
bookAuthorsRemoved.push(author.id)
|
||||||
@@ -478,6 +481,7 @@ class BookScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createdAtTimestamp = new Date().getTime()
|
const createdAtTimestamp = new Date().getTime()
|
||||||
|
const newAuthorNames = new Set()
|
||||||
if (bookMetadata.authors.length) {
|
if (bookMetadata.authors.length) {
|
||||||
for (const authorName of bookMetadata.authors) {
|
for (const authorName of bookMetadata.authors) {
|
||||||
const matchingAuthorId = await Database.getAuthorIdByName(libraryItemData.libraryId, authorName)
|
const matchingAuthorId = await Database.getAuthorIdByName(libraryItemData.libraryId, authorName)
|
||||||
@@ -485,6 +489,8 @@ class BookScanner {
|
|||||||
bookObject.bookAuthors.push({
|
bookObject.bookAuthors.push({
|
||||||
authorId: matchingAuthorId
|
authorId: matchingAuthorId
|
||||||
})
|
})
|
||||||
|
// Existing author — only numBooks changes; batch emit at scan end
|
||||||
|
libraryScan.authorsNumBooksChangedIds.add(matchingAuthorId)
|
||||||
} else {
|
} else {
|
||||||
// New author
|
// New author
|
||||||
bookObject.bookAuthors.push({
|
bookObject.bookAuthors.push({
|
||||||
@@ -496,6 +502,7 @@ class BookScanner {
|
|||||||
lastFirst: Database.authorModel.getLastFirst(authorName)
|
lastFirst: Database.authorModel.getLastFirst(authorName)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
newAuthorNames.add(authorName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -593,6 +600,9 @@ class BookScanner {
|
|||||||
for (const ba of libraryItem.book.bookAuthors) {
|
for (const ba of libraryItem.book.bookAuthors) {
|
||||||
if (ba.author) {
|
if (ba.author) {
|
||||||
Database.addAuthorToFilterData(libraryItemData.libraryId, ba.author.name, ba.author.id)
|
Database.addAuthorToFilterData(libraryItemData.libraryId, ba.author.name, ba.author.id)
|
||||||
|
if (newAuthorNames.has(ba.author.name)) {
|
||||||
|
SocketAuthority.emitter('author_added', ba.author.toOldJSON())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -825,6 +835,9 @@ class BookScanner {
|
|||||||
|
|
||||||
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
|
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys must match abmetadataGenerator.js
|
||||||
|
*/
|
||||||
const jsonObject = {
|
const jsonObject = {
|
||||||
tags: libraryItem.media.tags || [],
|
tags: libraryItem.media.tags || [],
|
||||||
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })) || [],
|
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })) || [],
|
||||||
@@ -888,6 +901,34 @@ class BookScanner {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit authors_num_books_updated for authors whose book count changed during a scan (linked or unlinked).
|
||||||
|
* Authors with no book links are omitted — orphans are handled by author_removed.
|
||||||
|
* @param {string} libraryId
|
||||||
|
* @param {import('./LibraryScan')|import('./ScanLogger')} scanLogger
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
async emitAuthorsNumBooksUpdated(libraryId, scanLogger) {
|
||||||
|
if (!scanLogger.authorsNumBooksChangedIds?.size) return
|
||||||
|
|
||||||
|
const authorIds = [...scanLogger.authorsNumBooksChangedIds]
|
||||||
|
scanLogger.authorsNumBooksChangedIds.clear()
|
||||||
|
|
||||||
|
const countsByAuthorId = await Database.bookAuthorModel.getCountsForAuthors(authorIds)
|
||||||
|
|
||||||
|
const authors = Object.entries(countsByAuthorId).map(([id, numBooks]) => ({
|
||||||
|
id,
|
||||||
|
numBooks
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (!authors.length) return
|
||||||
|
|
||||||
|
SocketAuthority.emitter('authors_num_books_updated', {
|
||||||
|
libraryId,
|
||||||
|
authors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check authors that were removed from a book and remove them if they no longer have any books
|
* Check authors that were removed from a book and remove them if they no longer have any books
|
||||||
* keep authors without books that have a asin, description or imagePath
|
* keep authors without books that have a asin, description or imagePath
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class LibraryItemScanner {
|
|||||||
if (libraryItemDataUpdated || wasUpdated) {
|
if (libraryItemDataUpdated || wasUpdated) {
|
||||||
SocketAuthority.libraryItemEmitter('item_updated', expandedLibraryItem)
|
SocketAuthority.libraryItemEmitter('item_updated', expandedLibraryItem)
|
||||||
|
|
||||||
await this.checkAuthorsAndSeriesRemovedFromBooks(library.id, scanLogger)
|
await this.finalizeAuthorsAndSeriesFromScan(library.id, scanLogger)
|
||||||
|
|
||||||
return ScanResult.UPDATED
|
return ScanResult.UPDATED
|
||||||
}
|
}
|
||||||
@@ -76,12 +76,13 @@ class LibraryItemScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove empty authors and series
|
* Emit author book count updates, then remove empty authors/series left after the scan
|
||||||
* @param {string} libraryId
|
* @param {string} libraryId
|
||||||
* @param {ScanLogger} scanLogger
|
* @param {ScanLogger} scanLogger
|
||||||
* @returns {Promise}
|
* @returns {Promise}
|
||||||
*/
|
*/
|
||||||
async checkAuthorsAndSeriesRemovedFromBooks(libraryId, scanLogger) {
|
async finalizeAuthorsAndSeriesFromScan(libraryId, scanLogger) {
|
||||||
|
await BookScanner.emitAuthorsNumBooksUpdated(libraryId, scanLogger)
|
||||||
if (scanLogger.authorsRemovedFromBooks.length) {
|
if (scanLogger.authorsRemovedFromBooks.length) {
|
||||||
await BookScanner.checkAuthorsRemovedFromBooks(libraryId, scanLogger)
|
await BookScanner.checkAuthorsRemovedFromBooks(libraryId, scanLogger)
|
||||||
}
|
}
|
||||||
@@ -215,7 +216,10 @@ class LibraryItemScanner {
|
|||||||
scanLogger.verbose = true
|
scanLogger.verbose = true
|
||||||
scanLogger.setData('libraryItem', libraryItemScanData.relPath)
|
scanLogger.setData('libraryItem', libraryItemScanData.relPath)
|
||||||
|
|
||||||
return this.scanNewLibraryItem(libraryItemScanData, library.settings, scanLogger)
|
const newLibraryItem = await this.scanNewLibraryItem(libraryItemScanData, library.settings, scanLogger)
|
||||||
|
// Watcher path does not go through full-library scan cleanup — emit author book count updates here
|
||||||
|
await BookScanner.emitAuthorsNumBooksUpdated(library.id, scanLogger)
|
||||||
|
return newLibraryItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = new LibraryItemScanner()
|
module.exports = new LibraryItemScanner()
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class LibraryScan {
|
|||||||
|
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
this.authorsRemovedFromBooks = []
|
this.authorsRemovedFromBooks = []
|
||||||
|
/** @type {Set<string>} */
|
||||||
|
this.authorsNumBooksChangedIds = new Set()
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
this.seriesRemovedFromBooks = []
|
this.seriesRemovedFromBooks = []
|
||||||
|
|
||||||
|
|||||||
@@ -234,8 +234,7 @@ class LibraryScanner {
|
|||||||
SocketAuthority.libraryItemsEmitter('items_updated', libraryItemsUpdated)
|
SocketAuthority.libraryItemsEmitter('items_updated', libraryItemsUpdated)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authors and series that were removed from books should be removed if they are now empty
|
if (this.shouldCancelScan(libraryScan)) return true
|
||||||
await LibraryItemScanner.checkAuthorsAndSeriesRemovedFromBooks(libraryScan.libraryId, libraryScan)
|
|
||||||
|
|
||||||
// Update missing library items
|
// Update missing library items
|
||||||
if (libraryItemIdsMissing.length) {
|
if (libraryItemIdsMissing.length) {
|
||||||
@@ -281,6 +280,9 @@ class LibraryScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Author book count updates and empty authors/series cleanup after all items are processed
|
||||||
|
await LibraryItemScanner.finalizeAuthorsAndSeriesFromScan(libraryScan.libraryId, libraryScan)
|
||||||
|
|
||||||
libraryScan.addLog(LogLevel.INFO, `Scan completed. ${libraryScan.resultStats}`)
|
libraryScan.addLog(LogLevel.INFO, `Scan completed. ${libraryScan.resultStats}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,6 +425,9 @@ class PodcastScanner {
|
|||||||
|
|
||||||
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
|
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys must match abmetadataGenerator.js
|
||||||
|
*/
|
||||||
const jsonObject = {
|
const jsonObject = {
|
||||||
tags: libraryItem.media.tags || [],
|
tags: libraryItem.media.tags || [],
|
||||||
title: libraryItem.media.title,
|
title: libraryItem.media.title,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ class ScanLogger {
|
|||||||
|
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
this.authorsRemovedFromBooks = []
|
this.authorsRemovedFromBooks = []
|
||||||
|
/** @type {Set<string>} */
|
||||||
|
this.authorsNumBooksChangedIds = new Set()
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
this.seriesRemovedFromBooks = []
|
this.seriesRemovedFromBooks = []
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,51 @@
|
|||||||
const Logger = require('../../Logger')
|
const Logger = require('../../Logger')
|
||||||
const parseSeriesString = require('../parsers/parseSeriesString')
|
const parseSeriesString = require('../parsers/parseSeriesString')
|
||||||
|
|
||||||
function parseJsonMetadataText(text) {
|
const mediaTypeKeys = {
|
||||||
|
book: {
|
||||||
|
tags: 'stringArray',
|
||||||
|
title: 'string',
|
||||||
|
subtitle: 'string',
|
||||||
|
authors: 'stringArray',
|
||||||
|
narrators: 'stringArray',
|
||||||
|
series: 'stringArray',
|
||||||
|
genres: 'stringArray',
|
||||||
|
publishedYear: 'string',
|
||||||
|
publishedDate: 'string',
|
||||||
|
publisher: 'string',
|
||||||
|
description: 'string',
|
||||||
|
isbn: 'string',
|
||||||
|
asin: 'string',
|
||||||
|
language: 'string',
|
||||||
|
explicit: 'boolean',
|
||||||
|
abridged: 'boolean'
|
||||||
|
},
|
||||||
|
podcast: {
|
||||||
|
tags: 'stringArray',
|
||||||
|
title: 'string',
|
||||||
|
author: 'string',
|
||||||
|
description: 'string',
|
||||||
|
releaseDate: 'string',
|
||||||
|
genres: 'stringArray',
|
||||||
|
feedURL: 'string',
|
||||||
|
imageURL: 'string',
|
||||||
|
itunesPageURL: 'string',
|
||||||
|
itunesId: 'string',
|
||||||
|
itunesArtistId: 'string',
|
||||||
|
asin: 'string',
|
||||||
|
language: 'string',
|
||||||
|
explicit: 'boolean',
|
||||||
|
podcastType: 'string'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {string} text
|
||||||
|
* @param {"book" | "podcast"} mediaType
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function parseJsonMetadataText(text, mediaType) {
|
||||||
try {
|
try {
|
||||||
const abmetadataData = JSON.parse(text)
|
const abmetadataData = JSON.parse(text)
|
||||||
|
|
||||||
@@ -19,28 +63,41 @@ function parseJsonMetadataText(text) {
|
|||||||
}
|
}
|
||||||
delete abmetadataData.metadata
|
delete abmetadataData.metadata
|
||||||
|
|
||||||
if (abmetadataData.series?.length) {
|
const expectedKeys = mediaTypeKeys[mediaType]
|
||||||
abmetadataData.series = [...new Set(abmetadataData.series.map((t) => t?.trim()).filter((t) => t))]
|
if (!expectedKeys) {
|
||||||
abmetadataData.series = abmetadataData.series.map((series) => parseSeriesString.parse(series))
|
Logger.error(`[abmetadataGenerator] Invalid media type "${mediaType}"`)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
// clean tags & remove dupes
|
|
||||||
if (abmetadataData.tags?.length) {
|
const validated = {}
|
||||||
abmetadataData.tags = [...new Set(abmetadataData.tags.map((t) => t?.trim()).filter((t) => t))]
|
for (const key in expectedKeys) {
|
||||||
|
const expectedType = expectedKeys[key]
|
||||||
|
if (!(key in abmetadataData)) continue
|
||||||
|
|
||||||
|
const validatedValue = validateMetadataValue(key, abmetadataData[key], expectedType)
|
||||||
|
if (validatedValue !== undefined) {
|
||||||
|
validated[key] = validatedValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (abmetadataData.chapters?.length) {
|
|
||||||
abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title)
|
if (validated.series?.length) {
|
||||||
|
validated.series = validated.series.map((series) => parseSeriesString.parse(series)).filter(Boolean)
|
||||||
}
|
}
|
||||||
// clean remove dupes
|
|
||||||
if (abmetadataData.authors?.length) {
|
if (mediaType === 'book' && 'chapters' in abmetadataData) {
|
||||||
abmetadataData.authors = [...new Set(abmetadataData.authors.map((t) => t?.trim()).filter((t) => t))]
|
if (abmetadataData.chapters === null) {
|
||||||
|
validated.chapters = []
|
||||||
|
} else if (Array.isArray(abmetadataData.chapters)) {
|
||||||
|
const cleanedChapters = cleanChaptersArray(abmetadataData.chapters, validated.title ?? abmetadataData.title)
|
||||||
|
if (cleanedChapters) {
|
||||||
|
validated.chapters = cleanedChapters
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Logger.warn(`[abmetadataGenerator] Invalid metadata key "chapters" expected array, got ${typeof abmetadataData.chapters}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (abmetadataData.narrators?.length) {
|
|
||||||
abmetadataData.narrators = [...new Set(abmetadataData.narrators.map((t) => t?.trim()).filter((t) => t))]
|
return validated
|
||||||
}
|
|
||||||
if (abmetadataData.genres?.length) {
|
|
||||||
abmetadataData.genres = [...new Set(abmetadataData.genres.map((t) => t?.trim()).filter((t) => t))]
|
|
||||||
}
|
|
||||||
return abmetadataData
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)
|
Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)
|
||||||
return null
|
return null
|
||||||
@@ -48,6 +105,54 @@ function parseJsonMetadataText(text) {
|
|||||||
}
|
}
|
||||||
module.exports.parseJson = parseJsonMetadataText
|
module.exports.parseJson = parseJsonMetadataText
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} key
|
||||||
|
* @param {*} value
|
||||||
|
* @param {string} expectedType
|
||||||
|
* @returns {*|undefined} undefined excludes the key
|
||||||
|
*/
|
||||||
|
function validateMetadataValue(key, value, expectedType) {
|
||||||
|
if (expectedType === 'string') {
|
||||||
|
if (value === null) return null
|
||||||
|
if (typeof value === 'number') return String(value)
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string, got ${typeof value}`)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expectedType === 'boolean') {
|
||||||
|
if (value === null) return null
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const lower = value.toLowerCase()
|
||||||
|
if (lower === 'true') return true
|
||||||
|
if (lower === 'false') return false
|
||||||
|
}
|
||||||
|
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected boolean, got ${typeof value}`)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter empty strings and deduplicate
|
||||||
|
if (expectedType === 'stringArray') {
|
||||||
|
if (value === null) return []
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string array, got ${typeof value}`)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanedArray = value.filter((t) => typeof t === 'string')
|
||||||
|
return [...new Set(cleanedArray.map((t) => t.trim()).filter((t) => t))]
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.warn(`[abmetadataGenerator] Unknown expected type "${expectedType}" for key "${key}"`)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object[]} chaptersArray
|
||||||
|
* @param {string} mediaTitle
|
||||||
|
* @returns {Object[]}
|
||||||
|
*/
|
||||||
function cleanChaptersArray(chaptersArray, mediaTitle) {
|
function cleanChaptersArray(chaptersArray, mediaTitle) {
|
||||||
const chapters = []
|
const chapters = []
|
||||||
let index = 0
|
let index = 0
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ module.exports.notificationData = {
|
|||||||
variables: ['version'],
|
variables: ['version'],
|
||||||
defaults: {
|
defaults: {
|
||||||
title: 'Test Notification on Abs {{version}}',
|
title: 'Test Notification on Abs {{version}}',
|
||||||
body: 'Test notificataion body for abs {{version}}.'
|
body: 'Test notification body for abs {{version}}.'
|
||||||
},
|
},
|
||||||
testData: {
|
testData: {
|
||||||
version: 'v' + version
|
version: 'v' + version
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const uaParserJs = require('../../libs/uaParser')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string|null|undefined} userAgent
|
||||||
|
* @returns {{ browserName?:string, browserVersion?:string, osName?:string, osVersion?:string, deviceType?:string, model?:string, vendor?:string }|null}
|
||||||
|
*/
|
||||||
|
function parseUserAgent(userAgent) {
|
||||||
|
if (!userAgent) return null
|
||||||
|
|
||||||
|
const ua = uaParserJs(userAgent)
|
||||||
|
const deviceInfo = {
|
||||||
|
browserName: ua?.browser?.name || undefined,
|
||||||
|
browserVersion: ua?.browser?.version || undefined,
|
||||||
|
osName: ua?.os?.name || undefined,
|
||||||
|
osVersion: ua?.os?.version || undefined,
|
||||||
|
deviceType: ua?.device?.type || undefined,
|
||||||
|
model: ua?.device?.model || undefined,
|
||||||
|
vendor: ua?.device?.vendor || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key in deviceInfo) {
|
||||||
|
if (deviceInfo[key] === undefined) delete deviceInfo[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(deviceInfo).length ? deviceInfo : null
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = parseUserAgent
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Whether the request was made over HTTPS.
|
||||||
|
* Uses Express `req.secure` and `x-forwarded-proto`
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isRequestSecure(req) {
|
||||||
|
if (req.secure) return true
|
||||||
|
const xfp = (req.get('x-forwarded-proto') || '').toLowerCase()
|
||||||
|
// Nginx Proxy Manager sends "http, https"; see https://github.com/advplyr/audiobookshelf/pull/4635
|
||||||
|
return (
|
||||||
|
xfp === 'https' ||
|
||||||
|
xfp
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.includes('https')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @returns {'https' | 'http'}
|
||||||
|
*/
|
||||||
|
function getRequestProtocol(req) {
|
||||||
|
return isRequestSecure(req) ? 'https' : 'http'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @returns {{ protocol: 'https' | 'http', host: string, origin: string }}
|
||||||
|
*/
|
||||||
|
function getRequestOrigin(req) {
|
||||||
|
const protocol = getRequestProtocol(req)
|
||||||
|
const host = req.get('host')
|
||||||
|
return { protocol, host, origin: `${protocol}://${host}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isRequestSecure,
|
||||||
|
getRequestProtocol,
|
||||||
|
getRequestOrigin
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
const { expect } = require('chai')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
|
||||||
|
// Load Database first so Auth resolves OidcAuthStrategy before the circular require completes.
|
||||||
|
require('../../../server/Database')
|
||||||
|
const OidcAuthStrategy = require('../../../server/auth/OidcAuthStrategy')
|
||||||
|
const Logger = require('../../../server/Logger')
|
||||||
|
|
||||||
|
describe('OidcAuthStrategy - isValidWebCallbackUrl', () => {
|
||||||
|
/** @type {OidcAuthStrategy} */
|
||||||
|
let strategy
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
global.RouterBasePath = ''
|
||||||
|
strategy = new OidcAuthStrategy()
|
||||||
|
sinon.stub(Logger, 'warn')
|
||||||
|
sinon.stub(Logger, 'error')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
function mockReq({ secure = false, host = 'books.example.com', xForwardedProto = null } = {}) {
|
||||||
|
return {
|
||||||
|
secure,
|
||||||
|
get(header) {
|
||||||
|
if (header === 'host') return host
|
||||||
|
if (header === 'x-forwarded-proto') return xForwardedProto
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts a same-origin relative path when router base path is empty', () => {
|
||||||
|
expect(strategy.isValidWebCallbackUrl('/library', mockReq())).to.equal(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a same-origin absolute https URL', () => {
|
||||||
|
const req = mockReq({ secure: true })
|
||||||
|
expect(strategy.isValidWebCallbackUrl('https://books.example.com/library', req)).to.equal(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects protocol-relative URLs', () => {
|
||||||
|
expect(strategy.isValidWebCallbackUrl('//evil.example/capture', mockReq())).to.equal(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects backslash-prefixed URLs', () => {
|
||||||
|
expect(strategy.isValidWebCallbackUrl('/\\evil.example/capture', mockReq())).to.equal(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects absolute external URLs', () => {
|
||||||
|
expect(strategy.isValidWebCallbackUrl('http://evil.example/capture', mockReq())).to.equal(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects encoded protocol-relative path segments', () => {
|
||||||
|
expect(strategy.isValidWebCallbackUrl('/%2F%2Fevil.example/capture', mockReq())).to.equal(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects same-origin URLs outside router base path', () => {
|
||||||
|
global.RouterBasePath = '/audiobookshelf'
|
||||||
|
expect(strategy.isValidWebCallbackUrl('/login', mockReq())).to.equal(false)
|
||||||
|
expect(strategy.isValidWebCallbackUrl('/audiobookshelf/login', mockReq())).to.equal(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty and malformed callback URLs', () => {
|
||||||
|
expect(strategy.isValidWebCallbackUrl('', mockReq())).to.equal(false)
|
||||||
|
expect(strategy.isValidWebCallbackUrl(null, mockReq())).to.equal(false)
|
||||||
|
expect(strategy.isValidWebCallbackUrl('not a url', mockReq())).to.equal(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses x-forwarded-proto when determining same-origin https URLs', () => {
|
||||||
|
const req = mockReq({ xForwardedProto: 'https' })
|
||||||
|
expect(strategy.isValidWebCallbackUrl('https://books.example.com/login', req)).to.equal(true)
|
||||||
|
expect(strategy.isValidWebCallbackUrl('http://books.example.com/login', req)).to.equal(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
const { expect } = require('chai')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
const { Op } = require('sequelize')
|
||||||
|
|
||||||
|
const Database = require('../../../server/Database')
|
||||||
|
const jwt = require('../../../server/libs/jsonwebtoken')
|
||||||
|
|
||||||
|
// Database → Auth → TokenManager circular require can leave TokenManager with a partial Database reference; reload before each test
|
||||||
|
function loadTokenManager() {
|
||||||
|
delete require.cache[require.resolve('../../../server/auth/TokenManager')]
|
||||||
|
return require('../../../server/auth/TokenManager')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TokenManager', () => {
|
||||||
|
const secret = 'test-jwt-secret'
|
||||||
|
const userId = 'user-uuid-1'
|
||||||
|
let TokenManager
|
||||||
|
let tokenManager
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TokenManager = loadTokenManager()
|
||||||
|
TokenManager.TokenSecret = secret
|
||||||
|
tokenManager = new TokenManager()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateAccessToken', () => {
|
||||||
|
it('rejects refresh tokens', () => {
|
||||||
|
const refreshToken = jwt.sign({ userId, type: 'refresh' }, secret, { expiresIn: 3600 })
|
||||||
|
expect(TokenManager.validateAccessToken(refreshToken)).to.equal(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts access tokens', () => {
|
||||||
|
const accessToken = jwt.sign({ userId, type: 'access' }, secret, { expiresIn: 3600 })
|
||||||
|
const decoded = TokenManager.validateAccessToken(accessToken)
|
||||||
|
expect(decoded.userId).to.equal(userId)
|
||||||
|
expect(decoded.type).to.equal('access')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('jwtAuthCheck', () => {
|
||||||
|
const user = { id: userId, username: 'testuser', isActive: true }
|
||||||
|
|
||||||
|
it('rejects refresh tokens for API auth', async () => {
|
||||||
|
const refreshToken = tokenManager.generateRefreshToken(user)
|
||||||
|
const decoded = jwt.verify(refreshToken, secret)
|
||||||
|
const done = sinon.spy()
|
||||||
|
|
||||||
|
await tokenManager.jwtAuthCheck(decoded, done)
|
||||||
|
|
||||||
|
expect(done.calledWith(null, null)).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows access tokens for active users', async () => {
|
||||||
|
sinon.stub(Database, 'userModel').get(() => ({
|
||||||
|
getUserByIdOrOldId: sinon.stub().resolves(user)
|
||||||
|
}))
|
||||||
|
const decoded = jwt.verify(tokenManager.generateTempAccessToken(user), secret)
|
||||||
|
const done = sinon.spy()
|
||||||
|
|
||||||
|
await tokenManager.jwtAuthCheck(decoded, done)
|
||||||
|
|
||||||
|
expect(done.calledWith(null, user)).to.be.true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('invalidateJwtSessionsForUser', () => {
|
||||||
|
const targetUser = { id: userId, username: 'testuser' }
|
||||||
|
const currentSession = {
|
||||||
|
id: 'session-current',
|
||||||
|
userId,
|
||||||
|
refreshToken: 'refresh-current'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal req/res for session invalidation (ApiRouter provides auth on real requests). */
|
||||||
|
function makeReq({ requestUserId = userId, refreshToken = null, cookieRefreshToken = null } = {}) {
|
||||||
|
return {
|
||||||
|
user: { id: requestUserId },
|
||||||
|
cookies: cookieRefreshToken ? { refresh_token: cookieRefreshToken } : {},
|
||||||
|
headers: refreshToken ? { 'x-refresh-token': refreshToken } : {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionFindOne
|
||||||
|
let sessionDestroy
|
||||||
|
let rotateStub
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionFindOne = sinon.stub().resolves(currentSession)
|
||||||
|
sessionDestroy = sinon.stub().resolves(1)
|
||||||
|
sinon.stub(Database, 'sessionModel').get(() => ({
|
||||||
|
findOne: sessionFindOne,
|
||||||
|
destroy: sessionDestroy
|
||||||
|
}))
|
||||||
|
rotateStub = sinon.stub(tokenManager, 'rotateTokensForSession').resolves({
|
||||||
|
accessToken: 'access-new',
|
||||||
|
refreshToken: 'refresh-new'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('self password change: keeps current session, deletes others', async () => {
|
||||||
|
const req = makeReq({ refreshToken: 'refresh-current' })
|
||||||
|
const res = { cookie: sinon.spy() }
|
||||||
|
|
||||||
|
const result = await tokenManager.invalidateJwtSessionsForUser(targetUser, req, res)
|
||||||
|
|
||||||
|
// Found this device's session using the x-refresh-token header
|
||||||
|
expect(sessionFindOne.calledOnce).to.be.true
|
||||||
|
const findWhere = sessionFindOne.firstCall.args[0].where
|
||||||
|
expect(findWhere.userId).to.equal(userId)
|
||||||
|
expect(findWhere[Op.or]).to.deep.equal([{ refreshToken: 'refresh-current' }, { lastRefreshToken: 'refresh-current' }])
|
||||||
|
|
||||||
|
// Rotated in place (no grace period) so the caller keeps a valid session
|
||||||
|
expect(rotateStub.calledOnceWith(currentSession, targetUser, req, res, false)).to.be.true
|
||||||
|
|
||||||
|
// Deleted all other sessions, but not this one
|
||||||
|
expect(sessionDestroy.calledOnce).to.be.true
|
||||||
|
const destroyWhere = sessionDestroy.firstCall.args[0].where
|
||||||
|
expect(destroyWhere.userId).to.equal(userId)
|
||||||
|
expect(destroyWhere.id[Op.ne]).to.equal(currentSession.id)
|
||||||
|
|
||||||
|
expect(result).to.deep.equal({ accessToken: 'access-new', refreshToken: 'refresh-new' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin password reset: deletes all target sessions', async () => {
|
||||||
|
const req = makeReq({ requestUserId: 'admin-id', refreshToken: 'refresh-current' })
|
||||||
|
const res = { cookie: sinon.spy() }
|
||||||
|
|
||||||
|
const result = await tokenManager.invalidateJwtSessionsForUser(targetUser, req, res)
|
||||||
|
|
||||||
|
// Token rotation did not happen because target is a different user
|
||||||
|
expect(sessionFindOne.called).to.be.false
|
||||||
|
expect(rotateStub.called).to.be.false
|
||||||
|
|
||||||
|
expect(sessionDestroy.calledOnceWith(sinon.match({ where: { userId } }))).to.be.true
|
||||||
|
expect(result).to.equal(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
const { expect } = require('chai')
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
|
||||||
|
const Database = require('../../../server/Database')
|
||||||
|
const LibraryController = require('../../../server/controllers/LibraryController')
|
||||||
|
const zipHelpers = require('../../../server/utils/zipHelpers')
|
||||||
|
const Logger = require('../../../server/Logger')
|
||||||
|
|
||||||
|
describe('LibraryController.downloadMultiple', () => {
|
||||||
|
let library
|
||||||
|
let libraryFolder
|
||||||
|
let allowedItemId
|
||||||
|
let explicitItemId
|
||||||
|
let taggedItemId
|
||||||
|
let restrictedUser
|
||||||
|
let libraryRecord
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
global.ServerSettings = {}
|
||||||
|
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
|
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||||
|
await Database.buildModels()
|
||||||
|
|
||||||
|
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||||
|
libraryFolder = await Database.libraryFolderModel.create({ path: '/test-lib', libraryId: library.id })
|
||||||
|
libraryRecord = await Database.libraryModel.findByIdWithFolders(library.id)
|
||||||
|
|
||||||
|
const allowedBook = await Database.bookModel.create({
|
||||||
|
title: 'Allowed Book',
|
||||||
|
explicit: false,
|
||||||
|
audioFiles: [],
|
||||||
|
tags: ['allowed-tag'],
|
||||||
|
narrators: [],
|
||||||
|
genres: [],
|
||||||
|
chapters: []
|
||||||
|
})
|
||||||
|
const allowedItem = await Database.libraryItemModel.create({
|
||||||
|
path: '/test-lib/allowed',
|
||||||
|
isFile: false,
|
||||||
|
libraryFiles: [],
|
||||||
|
mediaId: allowedBook.id,
|
||||||
|
mediaType: 'book',
|
||||||
|
libraryId: library.id,
|
||||||
|
libraryFolderId: libraryFolder.id
|
||||||
|
})
|
||||||
|
allowedItemId = allowedItem.id
|
||||||
|
|
||||||
|
const explicitBook = await Database.bookModel.create({
|
||||||
|
title: 'Explicit Book',
|
||||||
|
explicit: true,
|
||||||
|
audioFiles: [],
|
||||||
|
tags: [],
|
||||||
|
narrators: [],
|
||||||
|
genres: [],
|
||||||
|
chapters: []
|
||||||
|
})
|
||||||
|
const explicitItem = await Database.libraryItemModel.create({
|
||||||
|
path: '/test-lib/explicit',
|
||||||
|
isFile: false,
|
||||||
|
libraryFiles: [],
|
||||||
|
mediaId: explicitBook.id,
|
||||||
|
mediaType: 'book',
|
||||||
|
libraryId: library.id,
|
||||||
|
libraryFolderId: libraryFolder.id
|
||||||
|
})
|
||||||
|
explicitItemId = explicitItem.id
|
||||||
|
|
||||||
|
const taggedBook = await Database.bookModel.create({
|
||||||
|
title: 'Tagged Book',
|
||||||
|
explicit: false,
|
||||||
|
audioFiles: [],
|
||||||
|
tags: ['restricted-tag'],
|
||||||
|
narrators: [],
|
||||||
|
genres: [],
|
||||||
|
chapters: []
|
||||||
|
})
|
||||||
|
const taggedItem = await Database.libraryItemModel.create({
|
||||||
|
path: '/test-lib/tagged',
|
||||||
|
isFile: false,
|
||||||
|
libraryFiles: [],
|
||||||
|
mediaId: taggedBook.id,
|
||||||
|
mediaType: 'book',
|
||||||
|
libraryId: library.id,
|
||||||
|
libraryFolderId: libraryFolder.id
|
||||||
|
})
|
||||||
|
taggedItemId = taggedItem.id
|
||||||
|
|
||||||
|
const permissions = Database.userModel.getDefaultPermissionsForUserType('user')
|
||||||
|
permissions.download = true
|
||||||
|
permissions.accessExplicitContent = false
|
||||||
|
permissions.accessAllLibraries = false
|
||||||
|
permissions.accessAllTags = false
|
||||||
|
permissions.librariesAccessible = [library.id]
|
||||||
|
permissions.itemTagsSelected = ['allowed-tag']
|
||||||
|
permissions.selectedTagsNotAccessible = false
|
||||||
|
|
||||||
|
restrictedUser = await Database.userModel.create({
|
||||||
|
username: 'restricted',
|
||||||
|
pash: 'hash',
|
||||||
|
token: 'token',
|
||||||
|
type: 'user',
|
||||||
|
isActive: true,
|
||||||
|
permissions,
|
||||||
|
bookmarks: [],
|
||||||
|
extraData: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
sinon.stub(Logger, 'info')
|
||||||
|
sinon.stub(Logger, 'warn')
|
||||||
|
sinon.stub(Logger, 'error')
|
||||||
|
sinon.stub(zipHelpers, 'zipDirectoriesPipe').resolves()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
sinon.restore()
|
||||||
|
await Database.sequelize.sync({ force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
function makeReq(ids) {
|
||||||
|
return {
|
||||||
|
query: { ids: ids.join(',') },
|
||||||
|
user: restrictedUser,
|
||||||
|
library: libraryRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRes() {
|
||||||
|
return {
|
||||||
|
sendStatus: sinon.spy(),
|
||||||
|
status: sinon.stub().returnsThis(),
|
||||||
|
send: sinon.spy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns 403 for bulk download of an explicit item', async () => {
|
||||||
|
const req = makeReq([explicitItemId])
|
||||||
|
const res = makeRes()
|
||||||
|
|
||||||
|
await LibraryController.downloadMultiple(req, res)
|
||||||
|
|
||||||
|
expect(res.sendStatus.calledWith(403)).to.be.true
|
||||||
|
expect(zipHelpers.zipDirectoriesPipe.called).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 403 for bulk download of a tag-restricted item', async () => {
|
||||||
|
const req = makeReq([taggedItemId])
|
||||||
|
const res = makeRes()
|
||||||
|
|
||||||
|
await LibraryController.downloadMultiple(req, res)
|
||||||
|
|
||||||
|
expect(res.sendStatus.calledWith(403)).to.be.true
|
||||||
|
expect(zipHelpers.zipDirectoriesPipe.called).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns 403 when bulk download includes both allowed and forbidden items', async () => {
|
||||||
|
const req = makeReq([allowedItemId, explicitItemId])
|
||||||
|
const res = makeRes()
|
||||||
|
|
||||||
|
await LibraryController.downloadMultiple(req, res)
|
||||||
|
|
||||||
|
expect(res.sendStatus.calledWith(403)).to.be.true
|
||||||
|
expect(zipHelpers.zipDirectoriesPipe.called).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts zip download for allowed items only', async () => {
|
||||||
|
const req = makeReq([allowedItemId])
|
||||||
|
const res = makeRes()
|
||||||
|
|
||||||
|
await LibraryController.downloadMultiple(req, res)
|
||||||
|
|
||||||
|
expect(res.sendStatus.called).to.be.false
|
||||||
|
expect(zipHelpers.zipDirectoriesPipe.calledOnce).to.be.true
|
||||||
|
const pathObjects = zipHelpers.zipDirectoriesPipe.firstCall.args[0]
|
||||||
|
expect(pathObjects).to.have.length(1)
|
||||||
|
expect(pathObjects[0].path).to.equal('/test-lib/allowed')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,636 +0,0 @@
|
|||||||
const { expect } = require('chai')
|
|
||||||
const { Sequelize } = require('sequelize')
|
|
||||||
const sinon = require('sinon')
|
|
||||||
|
|
||||||
const Database = require('../../../server/Database')
|
|
||||||
const ApiRouter = require('../../../server/routers/ApiRouter')
|
|
||||||
const MeController = require('../../../server/controllers/MeController')
|
|
||||||
const Auth = require('../../../server/Auth')
|
|
||||||
const Logger = require('../../../server/Logger')
|
|
||||||
const SocketAuthority = require('../../../server/SocketAuthority')
|
|
||||||
|
|
||||||
describe('MeController - IDOR Security Tests', () => {
|
|
||||||
/** @type {ApiRouter} */
|
|
||||||
let apiRouter
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
global.ServerSettings = {}
|
|
||||||
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
|
||||||
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
|
||||||
await Database.buildModels()
|
|
||||||
|
|
||||||
// Create mock server object with required dependencies
|
|
||||||
const mockServer = {
|
|
||||||
auth: new Auth(),
|
|
||||||
playbackSessionManager: { sessions: [] },
|
|
||||||
abMergeManager: {},
|
|
||||||
backupManager: {},
|
|
||||||
podcastManager: {},
|
|
||||||
audioMetadataManager: {},
|
|
||||||
cronManager: {},
|
|
||||||
emailManager: {},
|
|
||||||
apiCacheManager: { middleware: (req, res, next) => next() }
|
|
||||||
}
|
|
||||||
|
|
||||||
apiRouter = new ApiRouter(mockServer)
|
|
||||||
|
|
||||||
sinon.stub(Logger, 'info')
|
|
||||||
sinon.stub(Logger, 'error')
|
|
||||||
sinon.stub(SocketAuthority, 'clientEmitter')
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
sinon.restore()
|
|
||||||
|
|
||||||
// Clear all tables
|
|
||||||
await Database.sequelize.sync({ force: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('removeMediaProgress - IDOR Protection', () => {
|
|
||||||
let user1, user2
|
|
||||||
let mediaProgress1, mediaProgress2
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Create two users
|
|
||||||
user1 = await Database.userModel.create({
|
|
||||||
username: 'user1',
|
|
||||||
pash: 'hashed_password_1',
|
|
||||||
type: 'user',
|
|
||||||
isActive: true
|
|
||||||
})
|
|
||||||
|
|
||||||
user2 = await Database.userModel.create({
|
|
||||||
username: 'user2',
|
|
||||||
pash: 'hashed_password_2',
|
|
||||||
type: 'user',
|
|
||||||
isActive: true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create library and book
|
|
||||||
const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
|
||||||
const libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id })
|
|
||||||
const book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
|
||||||
const libraryItem = await Database.libraryItemModel.create({
|
|
||||||
libraryFiles: [],
|
|
||||||
mediaId: book.id,
|
|
||||||
mediaType: 'book',
|
|
||||||
libraryId: library.id,
|
|
||||||
libraryFolderId: libraryFolder.id
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create media progress for each user
|
|
||||||
mediaProgress1 = await Database.mediaProgressModel.create({
|
|
||||||
userId: user1.id,
|
|
||||||
mediaItemId: book.id,
|
|
||||||
mediaItemType: 'book',
|
|
||||||
duration: 1000,
|
|
||||||
currentTime: 500,
|
|
||||||
isFinished: false
|
|
||||||
})
|
|
||||||
|
|
||||||
mediaProgress2 = await Database.mediaProgressModel.create({
|
|
||||||
userId: user2.id,
|
|
||||||
mediaItemId: book.id,
|
|
||||||
mediaItemType: 'book',
|
|
||||||
duration: 1000,
|
|
||||||
currentTime: 300,
|
|
||||||
isFinished: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// Load media progresses into users
|
|
||||||
user1.mediaProgresses = await user1.getMediaProgresses()
|
|
||||||
user2.mediaProgresses = await user2.getMediaProgresses()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should allow user to delete their own media progress', async () => {
|
|
||||||
const fakeReq = {
|
|
||||||
user: user1,
|
|
||||||
params: { id: mediaProgress1.id }
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
await MeController.removeMediaProgress(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
|
|
||||||
|
|
||||||
// Verify media progress was deleted
|
|
||||||
const deletedProgress = await Database.mediaProgressModel.findByPk(mediaProgress1.id)
|
|
||||||
expect(deletedProgress).to.be.null
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should prevent user from deleting another users media progress (IDOR)', async () => {
|
|
||||||
const fakeReq = {
|
|
||||||
user: user1,
|
|
||||||
params: { id: mediaProgress2.id } // Trying to delete user2's progress
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
await MeController.removeMediaProgress(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
|
|
||||||
|
|
||||||
// Verify media progress was NOT deleted
|
|
||||||
const existingProgress = await Database.mediaProgressModel.findByPk(mediaProgress2.id)
|
|
||||||
expect(existingProgress).to.not.be.null
|
|
||||||
expect(existingProgress.userId).to.equal(user2.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return 404 for non-existent media progress', async () => {
|
|
||||||
const fakeReq = {
|
|
||||||
user: user1,
|
|
||||||
params: { id: 'non-existent-id' }
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
await MeController.removeMediaProgress(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Bookmark Operations - Authorization Checks', () => {
|
|
||||||
let user1, user2
|
|
||||||
let library1, library2
|
|
||||||
let libraryItem1, libraryItem2
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Create two users with different library access
|
|
||||||
user1 = await Database.userModel.create({
|
|
||||||
username: 'user1',
|
|
||||||
pash: 'hashed_password_1',
|
|
||||||
type: 'user',
|
|
||||||
isActive: true,
|
|
||||||
librariesAccessible: null // Access to all libraries
|
|
||||||
})
|
|
||||||
|
|
||||||
user2 = await Database.userModel.create({
|
|
||||||
username: 'user2',
|
|
||||||
pash: 'hashed_password_2',
|
|
||||||
type: 'user',
|
|
||||||
isActive: true,
|
|
||||||
librariesAccessible: [] // Will be set to specific library
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create two libraries
|
|
||||||
library1 = await Database.libraryModel.create({ name: 'Library 1', mediaType: 'book' })
|
|
||||||
library2 = await Database.libraryModel.create({ name: 'Library 2', mediaType: 'book' })
|
|
||||||
|
|
||||||
// User2 only has access to library1
|
|
||||||
user2.librariesAccessible = [library1.id]
|
|
||||||
await user2.save()
|
|
||||||
|
|
||||||
const libraryFolder1 = await Database.libraryFolderModel.create({ path: '/test1', libraryId: library1.id })
|
|
||||||
const libraryFolder2 = await Database.libraryFolderModel.create({ path: '/test2', libraryId: library2.id })
|
|
||||||
|
|
||||||
const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
|
||||||
const book2 = await Database.bookModel.create({ title: 'Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
|
||||||
|
|
||||||
libraryItem1 = await Database.libraryItemModel.create({
|
|
||||||
libraryFiles: [],
|
|
||||||
mediaId: book1.id,
|
|
||||||
mediaType: 'book',
|
|
||||||
libraryId: library1.id,
|
|
||||||
libraryFolderId: libraryFolder1.id
|
|
||||||
})
|
|
||||||
|
|
||||||
libraryItem2 = await Database.libraryItemModel.create({
|
|
||||||
libraryFiles: [],
|
|
||||||
mediaId: book2.id,
|
|
||||||
mediaType: 'book',
|
|
||||||
libraryId: library2.id,
|
|
||||||
libraryFolderId: libraryFolder2.id
|
|
||||||
})
|
|
||||||
|
|
||||||
// Initialize bookmarks
|
|
||||||
user1.bookmarks = []
|
|
||||||
user2.bookmarks = []
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('createBookmark', () => {
|
|
||||||
it('should allow user to create bookmark for accessible library item', async () => {
|
|
||||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
|
||||||
|
|
||||||
const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark', createdAt: Date.now() }
|
|
||||||
|
|
||||||
const fakeReq = {
|
|
||||||
user: {
|
|
||||||
...user2.toJSON(),
|
|
||||||
id: user2.id,
|
|
||||||
username: user2.username,
|
|
||||||
checkCanAccessLibraryItem: () => true,
|
|
||||||
createBookmark: sinon.stub().resolves(bookmark),
|
|
||||||
toOldJSONForBrowser: () => ({ id: user2.id, username: user2.username })
|
|
||||||
},
|
|
||||||
params: { id: libraryItem1.id },
|
|
||||||
body: { time: 100, title: 'Test Bookmark' }
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy(),
|
|
||||||
json: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
|
||||||
|
|
||||||
await MeController.createBookmark(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.json.calledOnce).to.be.true
|
|
||||||
expect(fakeRes.json.calledWith(bookmark)).to.be.true
|
|
||||||
|
|
||||||
Database.libraryItemModel.getExpandedById.restore()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should prevent user from creating bookmark for inaccessible library item (IDOR)', async () => {
|
|
||||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
|
|
||||||
|
|
||||||
const fakeReq = {
|
|
||||||
user: user2, // user2 doesn't have access to library2
|
|
||||||
params: { id: libraryItem2.id },
|
|
||||||
body: { time: 100, title: 'Test Bookmark' }
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy(),
|
|
||||||
json: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock getExpandedById
|
|
||||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
|
||||||
|
|
||||||
await MeController.createBookmark(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
|
||||||
expect(fakeRes.json.called).to.be.false
|
|
||||||
|
|
||||||
Database.libraryItemModel.getExpandedById.restore()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return 404 for non-existent library item', async () => {
|
|
||||||
const fakeReq = {
|
|
||||||
user: user1,
|
|
||||||
params: { id: 'non-existent-id' },
|
|
||||||
body: { time: 100, title: 'Test Bookmark' }
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy(),
|
|
||||||
json: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock getExpandedById to return null
|
|
||||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null)
|
|
||||||
|
|
||||||
await MeController.createBookmark(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
|
|
||||||
|
|
||||||
Database.libraryItemModel.getExpandedById.restore()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should validate bookmark time parameter', async () => {
|
|
||||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
|
||||||
|
|
||||||
const fakeReq = {
|
|
||||||
user: {
|
|
||||||
...user1.toJSON(),
|
|
||||||
id: user1.id,
|
|
||||||
username: user1.username,
|
|
||||||
checkCanAccessLibraryItem: () => true
|
|
||||||
},
|
|
||||||
params: { id: libraryItem1.id },
|
|
||||||
body: { time: null, title: 'Test Bookmark' } // null time is invalid
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy(),
|
|
||||||
json: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
|
||||||
|
|
||||||
await MeController.createBookmark(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.status.calledWith(400)).to.be.true
|
|
||||||
expect(fakeRes.send.calledWith('Invalid time')).to.be.true
|
|
||||||
|
|
||||||
Database.libraryItemModel.getExpandedById.restore()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('updateBookmark', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Add existing bookmark to user1
|
|
||||||
user1.bookmarks = [{ libraryItemId: libraryItem1.id, time: 100, title: 'Original Title' }]
|
|
||||||
await user1.save()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should allow user to update bookmark for accessible library item', async () => {
|
|
||||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
|
||||||
|
|
||||||
const bookmark = { libraryItemId: libraryItem1.id, time: 100, title: 'Updated Title' }
|
|
||||||
|
|
||||||
const fakeReq = {
|
|
||||||
user: {
|
|
||||||
...user1.toJSON(),
|
|
||||||
id: user1.id,
|
|
||||||
username: user1.username,
|
|
||||||
checkCanAccessLibraryItem: () => true,
|
|
||||||
updateBookmark: sinon.stub().resolves(bookmark),
|
|
||||||
toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username })
|
|
||||||
},
|
|
||||||
params: { id: libraryItem1.id },
|
|
||||||
body: { time: 100, title: 'Updated Title' }
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy(),
|
|
||||||
json: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
|
||||||
|
|
||||||
await MeController.updateBookmark(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.json.calledOnce).to.be.true
|
|
||||||
expect(fakeRes.json.calledWith(bookmark)).to.be.true
|
|
||||||
|
|
||||||
Database.libraryItemModel.getExpandedById.restore()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should prevent user from updating bookmark for inaccessible library item (IDOR)', async () => {
|
|
||||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
|
|
||||||
|
|
||||||
const fakeReq = {
|
|
||||||
user: user2, // user2 doesn't have access to library2
|
|
||||||
params: { id: libraryItem2.id },
|
|
||||||
body: { time: 100, title: 'Updated Title' }
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy(),
|
|
||||||
json: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
|
||||||
|
|
||||||
await MeController.updateBookmark(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
|
||||||
|
|
||||||
Database.libraryItemModel.getExpandedById.restore()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('removeBookmark', () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Add existing bookmark to user1
|
|
||||||
user1.bookmarks = [{ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }]
|
|
||||||
await user1.save()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should allow user to remove bookmark for accessible library item', async () => {
|
|
||||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
|
||||||
|
|
||||||
const fakeReq = {
|
|
||||||
user: {
|
|
||||||
...user1.toJSON(),
|
|
||||||
id: user1.id,
|
|
||||||
username: user1.username,
|
|
||||||
checkCanAccessLibraryItem: () => true,
|
|
||||||
findBookmark: sinon.stub().returns({ libraryItemId: libraryItem1.id, time: 100, title: 'Test Bookmark' }),
|
|
||||||
removeBookmark: sinon.stub().resolves(true),
|
|
||||||
toOldJSONForBrowser: () => ({ id: user1.id, username: user1.username })
|
|
||||||
},
|
|
||||||
params: { id: libraryItem1.id, time: '100' }
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
|
||||||
|
|
||||||
await MeController.removeBookmark(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.sendStatus.calledWith(200)).to.be.true
|
|
||||||
|
|
||||||
Database.libraryItemModel.getExpandedById.restore()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should prevent user from removing bookmark for inaccessible library item (IDOR)', async () => {
|
|
||||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
|
|
||||||
|
|
||||||
const fakeReq = {
|
|
||||||
user: user2, // user2 doesn't have access to library2
|
|
||||||
params: { id: libraryItem2.id, time: '100' }
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
|
||||||
|
|
||||||
await MeController.removeBookmark(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
|
||||||
|
|
||||||
Database.libraryItemModel.getExpandedById.restore()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should validate time parameter is a number', async () => {
|
|
||||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
|
||||||
|
|
||||||
const fakeReq = {
|
|
||||||
user: {
|
|
||||||
...user1.toJSON(),
|
|
||||||
id: user1.id,
|
|
||||||
username: user1.username,
|
|
||||||
checkCanAccessLibraryItem: () => true
|
|
||||||
},
|
|
||||||
params: { id: libraryItem1.id, time: 'not-a-number' }
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
|
||||||
|
|
||||||
await MeController.removeBookmark(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.status.calledWith(400)).to.be.true
|
|
||||||
expect(fakeRes.send.calledWith('Invalid time')).to.be.true
|
|
||||||
|
|
||||||
Database.libraryItemModel.getExpandedById.restore()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('getItemListeningSessions - Authorization Check', () => {
|
|
||||||
let user1, user2
|
|
||||||
let library1, library2
|
|
||||||
let libraryItem1, libraryItem2
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
// Create two users with different library access
|
|
||||||
user1 = await Database.userModel.create({
|
|
||||||
username: 'user1',
|
|
||||||
pash: 'hashed_password_1',
|
|
||||||
type: 'user',
|
|
||||||
isActive: true,
|
|
||||||
librariesAccessible: null // Access to all libraries
|
|
||||||
})
|
|
||||||
|
|
||||||
user2 = await Database.userModel.create({
|
|
||||||
username: 'user2',
|
|
||||||
pash: 'hashed_password_2',
|
|
||||||
type: 'user',
|
|
||||||
isActive: true,
|
|
||||||
librariesAccessible: [] // Will be set to specific library
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create two libraries
|
|
||||||
library1 = await Database.libraryModel.create({ name: 'Library 1', mediaType: 'book' })
|
|
||||||
library2 = await Database.libraryModel.create({ name: 'Library 2', mediaType: 'book' })
|
|
||||||
|
|
||||||
// User2 only has access to library1
|
|
||||||
user2.librariesAccessible = [library1.id]
|
|
||||||
await user2.save()
|
|
||||||
|
|
||||||
const libraryFolder1 = await Database.libraryFolderModel.create({ path: '/test1', libraryId: library1.id })
|
|
||||||
const libraryFolder2 = await Database.libraryFolderModel.create({ path: '/test2', libraryId: library2.id })
|
|
||||||
|
|
||||||
const book1 = await Database.bookModel.create({ title: 'Book 1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
|
||||||
const book2 = await Database.bookModel.create({ title: 'Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
|
||||||
|
|
||||||
libraryItem1 = await Database.libraryItemModel.create({
|
|
||||||
libraryFiles: [],
|
|
||||||
mediaId: book1.id,
|
|
||||||
mediaType: 'book',
|
|
||||||
libraryId: library1.id,
|
|
||||||
libraryFolderId: libraryFolder1.id
|
|
||||||
})
|
|
||||||
|
|
||||||
libraryItem2 = await Database.libraryItemModel.create({
|
|
||||||
libraryFiles: [],
|
|
||||||
mediaId: book2.id,
|
|
||||||
mediaType: 'book',
|
|
||||||
libraryId: library2.id,
|
|
||||||
libraryFolderId: libraryFolder2.id
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should allow user to view listening sessions for accessible library item', async () => {
|
|
||||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem1.id)
|
|
||||||
|
|
||||||
// Create mock context with getUserItemListeningSessionsHelper
|
|
||||||
const mockContext = {
|
|
||||||
getUserItemListeningSessionsHelper: sinon.stub().resolves([{ id: 'session1', timeListening: 300, startedAt: Date.now() }])
|
|
||||||
}
|
|
||||||
|
|
||||||
const fakeReq = {
|
|
||||||
user: {
|
|
||||||
...user1.toJSON(),
|
|
||||||
id: user1.id,
|
|
||||||
username: user1.username,
|
|
||||||
checkCanAccessLibraryItem: () => true
|
|
||||||
},
|
|
||||||
params: { libraryItemId: libraryItem1.id },
|
|
||||||
query: {}
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy(),
|
|
||||||
json: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
|
||||||
sinon.stub(Database.podcastEpisodeModel, 'findByPk').resolves(null)
|
|
||||||
|
|
||||||
await MeController.getItemListeningSessions.bind(mockContext)(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.json.calledOnce).to.be.true
|
|
||||||
expect(fakeRes.sendStatus.called).to.be.false
|
|
||||||
|
|
||||||
// Verify the payload structure
|
|
||||||
const payload = fakeRes.json.firstCall.args[0]
|
|
||||||
expect(payload).to.have.property('total')
|
|
||||||
expect(payload).to.have.property('sessions')
|
|
||||||
|
|
||||||
Database.libraryItemModel.getExpandedById.restore()
|
|
||||||
Database.podcastEpisodeModel.findByPk.restore()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should prevent user from viewing listening sessions for inaccessible library item (IDOR)', async () => {
|
|
||||||
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem2.id)
|
|
||||||
|
|
||||||
const fakeReq = {
|
|
||||||
user: user2, // user2 doesn't have access to library2
|
|
||||||
params: { libraryItemId: libraryItem2.id },
|
|
||||||
query: {}
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy(),
|
|
||||||
json: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(expandedItem)
|
|
||||||
sinon.stub(Database.podcastEpisodeModel, 'findByPk').resolves(null)
|
|
||||||
|
|
||||||
await MeController.getItemListeningSessions.bind(apiRouter)(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
|
||||||
expect(fakeRes.json.called).to.be.false
|
|
||||||
|
|
||||||
Database.libraryItemModel.getExpandedById.restore()
|
|
||||||
Database.podcastEpisodeModel.findByPk.restore()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return 404 for non-existent library item', async () => {
|
|
||||||
const fakeReq = {
|
|
||||||
user: user1,
|
|
||||||
params: { libraryItemId: 'non-existent-id' },
|
|
||||||
query: {}
|
|
||||||
}
|
|
||||||
const fakeRes = {
|
|
||||||
sendStatus: sinon.spy(),
|
|
||||||
status: sinon.stub().returnsThis(),
|
|
||||||
send: sinon.spy(),
|
|
||||||
json: sinon.spy()
|
|
||||||
}
|
|
||||||
|
|
||||||
sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null)
|
|
||||||
|
|
||||||
await MeController.getItemListeningSessions.bind(apiRouter)(fakeReq, fakeRes)
|
|
||||||
|
|
||||||
expect(fakeRes.sendStatus.calledWith(404)).to.be.true
|
|
||||||
|
|
||||||
Database.libraryItemModel.getExpandedById.restore()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const { expect } = require('chai')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
|
||||||
|
const UserController = require('../../../server/controllers/UserController')
|
||||||
|
const Logger = require('../../../server/Logger')
|
||||||
|
|
||||||
|
describe('UserController - delete', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sinon.stub(Logger, 'error')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects deleting the root user by UUID', async () => {
|
||||||
|
const rootUser = { id: 'root-uuid', isRoot: true, username: 'root' }
|
||||||
|
const adminUser = { id: 'admin-uuid', isRoot: false, username: 'admin' }
|
||||||
|
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
|
||||||
|
|
||||||
|
await UserController.delete({ user: adminUser, reqUser: rootUser, params: { id: rootUser.id } }, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||||
|
expect(fakeRes.json.called).to.be.false
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
const { expect } = require('chai')
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
|
||||||
|
const Database = require('../../../server/Database')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that `expanded` contains every key from `minified` with matching values.
|
||||||
|
* Used to enforce the contract: toOldJSONExpanded() is a strict superset of toOldJSONMinified().
|
||||||
|
*
|
||||||
|
* @param {object} minified
|
||||||
|
* @param {object} expanded
|
||||||
|
* @param {string} [path]
|
||||||
|
*/
|
||||||
|
function assertExpandedSuperset(minified, expanded, path = '') {
|
||||||
|
for (const key of Object.keys(minified)) {
|
||||||
|
const keyPath = path ? `${path}.${key}` : key
|
||||||
|
expect(expanded, `missing key ${keyPath}`).to.have.property(key)
|
||||||
|
|
||||||
|
const minifiedValue = minified[key]
|
||||||
|
const expandedValue = expanded[key]
|
||||||
|
|
||||||
|
if (minifiedValue !== null && typeof minifiedValue === 'object' && !Array.isArray(minifiedValue)) {
|
||||||
|
assertExpandedSuperset(minifiedValue, expandedValue, keyPath)
|
||||||
|
} else if (Array.isArray(minifiedValue)) {
|
||||||
|
expect(expandedValue, `expected array at ${keyPath}`).to.be.an('array')
|
||||||
|
expect(expandedValue).to.deep.equal(minifiedValue)
|
||||||
|
} else {
|
||||||
|
expect(expandedValue, `value mismatch at ${keyPath}`).to.equal(minifiedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('old JSON serialization', () => {
|
||||||
|
let bookLibraryItemId
|
||||||
|
let podcastLibraryItemId
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
global.ServerSettings = {}
|
||||||
|
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
|
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||||
|
await Database.buildModels()
|
||||||
|
|
||||||
|
const bookLibrary = await Database.libraryModel.create({ name: 'Book Library', mediaType: 'book' })
|
||||||
|
const bookLibraryFolder = await Database.libraryFolderModel.create({ path: '/books', libraryId: bookLibrary.id })
|
||||||
|
|
||||||
|
const book = await Database.bookModel.create({
|
||||||
|
title: 'Test Book',
|
||||||
|
audioFiles: [{ index: 1, ino: '1', metadata: { filename: 'track.mp3', ext: '.mp3', path: '/track.mp3', relPath: 'track.mp3', size: 1000 } }],
|
||||||
|
tags: ['fiction'],
|
||||||
|
narrators: ['Narrator One'],
|
||||||
|
genres: ['Fantasy'],
|
||||||
|
chapters: [{ id: 0, start: 0, end: 100, title: 'Chapter 1' }],
|
||||||
|
ebookFile: { ino: '2', metadata: { filename: 'book.epub', ext: '.epub', path: '/book.epub', relPath: 'book.epub', size: 500 }, ebookFormat: 'epub' }
|
||||||
|
})
|
||||||
|
const bookLibraryItem = await Database.libraryItemModel.create({
|
||||||
|
libraryFiles: [{ ino: '1', metadata: { filename: 'track.mp3', ext: '.mp3', path: '/track.mp3', relPath: 'track.mp3' } }],
|
||||||
|
mediaId: book.id,
|
||||||
|
mediaType: 'book',
|
||||||
|
libraryId: bookLibrary.id,
|
||||||
|
libraryFolderId: bookLibraryFolder.id
|
||||||
|
})
|
||||||
|
bookLibraryItemId = bookLibraryItem.id
|
||||||
|
|
||||||
|
const author = await Database.authorModel.create({ name: 'Test Author', libraryId: bookLibrary.id })
|
||||||
|
await Database.bookAuthorModel.create({ bookId: book.id, authorId: author.id })
|
||||||
|
|
||||||
|
const series = await Database.seriesModel.create({ name: 'Test Series', libraryId: bookLibrary.id })
|
||||||
|
await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id, sequence: '2' })
|
||||||
|
|
||||||
|
const podcastLibrary = await Database.libraryModel.create({ name: 'Podcast Library', mediaType: 'podcast' })
|
||||||
|
const podcastLibraryFolder = await Database.libraryFolderModel.create({ path: '/podcasts', libraryId: podcastLibrary.id })
|
||||||
|
|
||||||
|
const podcast = await Database.podcastModel.create({
|
||||||
|
title: 'Test Podcast',
|
||||||
|
tags: ['news'],
|
||||||
|
genres: ['Technology'],
|
||||||
|
autoDownloadEpisodes: false
|
||||||
|
})
|
||||||
|
const podcastLibraryItem = await Database.libraryItemModel.create({
|
||||||
|
libraryFiles: [],
|
||||||
|
mediaId: podcast.id,
|
||||||
|
mediaType: 'podcast',
|
||||||
|
libraryId: podcastLibrary.id,
|
||||||
|
libraryFolderId: podcastLibraryFolder.id
|
||||||
|
})
|
||||||
|
podcastLibraryItemId = podcastLibraryItem.id
|
||||||
|
|
||||||
|
await Database.podcastEpisodeModel.create({
|
||||||
|
podcastId: podcast.id,
|
||||||
|
title: 'Episode 1',
|
||||||
|
index: 1,
|
||||||
|
audioFile: { ino: '3', metadata: { filename: 'ep1.mp3', ext: '.mp3', path: '/ep1.mp3', relPath: 'ep1.mp3' } }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Database.sequelize.sync({ force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Book', () => {
|
||||||
|
it('toOldJSONExpanded is a strict superset of toOldJSONMinified', async () => {
|
||||||
|
const libraryItem = await Database.libraryItemModel.getExpandedById(bookLibraryItemId)
|
||||||
|
const minified = libraryItem.media.toOldJSONMinified()
|
||||||
|
const expanded = libraryItem.media.toOldJSONExpanded(libraryItem.id)
|
||||||
|
|
||||||
|
assertExpandedSuperset(minified, expanded)
|
||||||
|
|
||||||
|
expect(expanded).to.have.property('libraryItemId', libraryItem.id)
|
||||||
|
expect(expanded).to.have.property('audioFiles')
|
||||||
|
expect(expanded).to.have.property('chapters')
|
||||||
|
expect(expanded).to.have.property('tracks')
|
||||||
|
expect(expanded.numTracks).to.equal(expanded.tracks.length)
|
||||||
|
expect(expanded.numAudioFiles).to.equal(expanded.audioFiles.length)
|
||||||
|
expect(expanded.numChapters).to.equal(expanded.chapters.length)
|
||||||
|
expect(expanded.ebookFormat).to.equal('epub')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Podcast', () => {
|
||||||
|
it('toOldJSONExpanded is a strict superset of toOldJSONMinified', async () => {
|
||||||
|
const libraryItem = await Database.libraryItemModel.getExpandedById(podcastLibraryItemId)
|
||||||
|
const minified = libraryItem.media.toOldJSONMinified()
|
||||||
|
const expanded = libraryItem.media.toOldJSONExpanded(libraryItem.id)
|
||||||
|
|
||||||
|
assertExpandedSuperset(minified, expanded)
|
||||||
|
|
||||||
|
expect(expanded).to.have.property('libraryItemId', libraryItem.id)
|
||||||
|
expect(expanded).to.have.property('episodes')
|
||||||
|
expect(expanded.numEpisodes).to.equal(expanded.episodes.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('LibraryItem', () => {
|
||||||
|
it('book library item expanded is a strict superset of minified', async () => {
|
||||||
|
const libraryItem = await Database.libraryItemModel.getExpandedById(bookLibraryItemId)
|
||||||
|
const minified = libraryItem.toOldJSONMinified()
|
||||||
|
const expanded = libraryItem.toOldJSONExpanded()
|
||||||
|
|
||||||
|
assertExpandedSuperset(minified, expanded)
|
||||||
|
|
||||||
|
expect(expanded).to.have.property('libraryFiles')
|
||||||
|
expect(expanded).to.have.property('lastScan')
|
||||||
|
expect(expanded.numFiles).to.equal(expanded.libraryFiles.length)
|
||||||
|
expect(expanded.media.numTracks).to.equal(expanded.media.tracks.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('podcast library item expanded is a strict superset of minified', async () => {
|
||||||
|
const libraryItem = await Database.libraryItemModel.getExpandedById(podcastLibraryItemId)
|
||||||
|
const minified = libraryItem.toOldJSONMinified()
|
||||||
|
const expanded = libraryItem.toOldJSONExpanded()
|
||||||
|
|
||||||
|
assertExpandedSuperset(minified, expanded)
|
||||||
|
|
||||||
|
expect(expanded).to.have.property('libraryFiles')
|
||||||
|
expect(expanded.media.numEpisodes).to.equal(expanded.media.episodes.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const { expect } = require('chai')
|
||||||
|
|
||||||
|
const { isRequestSecure, getRequestProtocol, getRequestOrigin } = require('../../../server/utils/requestUtils')
|
||||||
|
|
||||||
|
function mockReq({ secure = false, host = 'books.example.com', xForwardedProto = null } = {}) {
|
||||||
|
return {
|
||||||
|
secure,
|
||||||
|
get(header) {
|
||||||
|
if (header === 'host') return host
|
||||||
|
if (header === 'x-forwarded-proto') return xForwardedProto
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('requestUtils', () => {
|
||||||
|
it('isRequestSecure uses req.secure', () => {
|
||||||
|
expect(isRequestSecure(mockReq({ secure: true }))).to.equal(true)
|
||||||
|
expect(isRequestSecure(mockReq({ secure: false }))).to.equal(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isRequestSecure uses x-forwarded-proto', () => {
|
||||||
|
expect(isRequestSecure(mockReq({ xForwardedProto: 'https' }))).to.equal(true)
|
||||||
|
expect(isRequestSecure(mockReq({ xForwardedProto: 'http' }))).to.equal(false)
|
||||||
|
expect(isRequestSecure(mockReq({ xForwardedProto: 'http, https' }))).to.equal(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getRequestProtocol returns https or http', () => {
|
||||||
|
expect(getRequestProtocol(mockReq({ secure: true }))).to.equal('https')
|
||||||
|
expect(getRequestProtocol(mockReq())).to.equal('http')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getRequestOrigin builds origin from protocol and host', () => {
|
||||||
|
expect(getRequestOrigin(mockReq({ secure: true }))).to.deep.equal({
|
||||||
|
protocol: 'https',
|
||||||
|
host: 'books.example.com',
|
||||||
|
origin: 'https://books.example.com'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user