mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
This commit is contained in:
parent
f752c19418
commit
e80ec10e8a
@ -51,6 +51,19 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
.no-spinner::-webkit-outer-spin-button,
|
||||
.no-spinner::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
|
||||
.tracksTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full h-10 relative">
|
||||
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-40 flex items-center px-8">
|
||||
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8">
|
||||
<template v-if="page !== 'search' && !isHome">
|
||||
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
|
||||
<div v-else class="flex items-center">
|
||||
|
@ -15,7 +15,7 @@
|
||||
</div>
|
||||
<div class="h-full flex items-center">
|
||||
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden">
|
||||
<span v-show="hasPrev" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @click="pageLeft">chevron_left</span>
|
||||
<span v-show="hasPrev" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @mousedown.prevent @click="pageLeft">chevron_left</span>
|
||||
</div>
|
||||
<div id="frame" class="w-full" style="height: 650px">
|
||||
<div id="viewer" class="spreads"></div>
|
||||
@ -25,7 +25,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden">
|
||||
<span v-show="hasNext" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @click="pageRight">chevron_right</span>
|
||||
<span v-show="hasNext" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @mousedown.prevent @click="pageRight">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -69,10 +69,13 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
fullUrl() {
|
||||
var serverUrl = process.env.serverUrl || '/local'
|
||||
return `${serverUrl}/${this.url}`
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
}
|
||||
// fullUrl() {
|
||||
// var serverUrl = process.env.serverUrl || `/s/book/${this.audiobookId}`
|
||||
// return `${serverUrl}/${this.url}`
|
||||
// }
|
||||
},
|
||||
methods: {
|
||||
changedChapter() {
|
||||
@ -113,7 +116,13 @@ export default {
|
||||
init() {
|
||||
this.registerListeners()
|
||||
|
||||
var book = ePub(this.fullUrl)
|
||||
console.log('epub', this.url)
|
||||
// var book = ePub(this.url, {
|
||||
// requestHeaders: {
|
||||
// Authorization: `Bearer ${this.userToken}`
|
||||
// }
|
||||
// })
|
||||
var book = ePub(this.url)
|
||||
this.book = book
|
||||
|
||||
this.rendition = book.renderTo('viewer', {
|
||||
|
@ -33,6 +33,7 @@
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
|
||||
</div>
|
||||
|
||||
<!-- EBook Icon -->
|
||||
<div v-if="showExperimentalFeatures && hasEbook" class="absolute rounded-full bg-blue-500 w-6 h-6 flex items-center justify-center bg-opacity-90" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<!-- <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">EBook</p> -->
|
||||
<span class="material-icons text-white text-base">auto_stories</span>
|
||||
|
@ -53,9 +53,6 @@ export default {
|
||||
book() {
|
||||
return this.audiobook.book || {}
|
||||
},
|
||||
bookLastUpdate() {
|
||||
return this.book.lastUpdate || Date.now()
|
||||
},
|
||||
title() {
|
||||
return this.book.title || 'No Title'
|
||||
},
|
||||
|
@ -7,7 +7,7 @@
|
||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||
</div>
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-10 -mt-px w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li v-if="isTyping" class="py-2 px-2">
|
||||
<p>Typing...</p>
|
||||
|
@ -13,8 +13,8 @@
|
||||
</div>
|
||||
<div class="flex-grow pl-6 pr-2">
|
||||
<div class="flex items-center">
|
||||
<div v-if="userCanUpload" class="w-40 pr-2" style="min-width: 160px">
|
||||
<ui-file-input ref="fileInput" @change="fileUploadSelected" />
|
||||
<div v-if="userCanUpload" class="w-40 pr-2 pt-4" style="min-width: 160px">
|
||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">Upload Cover</ui-file-input>
|
||||
</div>
|
||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
|
||||
@ -24,7 +24,7 @@
|
||||
|
||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
|
||||
<div class="flex items-center justify-center py-2">
|
||||
<p>{{ localCovers.length }} local image(s)</p>
|
||||
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? 'Hide' : 'Show' }}</ui-btn>
|
||||
</div>
|
||||
|
@ -73,7 +73,10 @@ export default {
|
||||
|
||||
return {
|
||||
...track,
|
||||
relativePath: trackPath.replace(audiobookPath, '').replace(/%/g, '%25').replace(/#/g, '%23')
|
||||
relativePath: trackPath
|
||||
.replace(audiobookPath + '/', '')
|
||||
.replace(/%/g, '%25')
|
||||
.replace(/#/g, '%23')
|
||||
}
|
||||
})
|
||||
},
|
||||
|
85
client/components/prompt/Dialog.vue
Normal file
85
client/components/prompt/Dialog.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-40 opacity-0">
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
persistent: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: 500
|
||||
},
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: 'unset'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
el: null,
|
||||
content: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.setShow()
|
||||
} else {
|
||||
this.setHide()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
modalHeight() {
|
||||
if (typeof this.height === 'string') {
|
||||
return this.height
|
||||
} else {
|
||||
return this.height + 'px'
|
||||
}
|
||||
},
|
||||
modalWidth() {
|
||||
return typeof this.width === 'string' ? this.width : this.width + 'px'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setShow() {
|
||||
document.body.appendChild(this.el)
|
||||
setTimeout(() => {
|
||||
this.content.style.transform = 'scale(1)'
|
||||
}, 10)
|
||||
document.documentElement.classList.add('modal-open')
|
||||
},
|
||||
setHide() {
|
||||
this.content.style.transform = 'scale(0)'
|
||||
this.el.remove()
|
||||
document.documentElement.classList.remove('modal-open')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.el = this.$refs.wrapper
|
||||
this.content = this.$refs.content
|
||||
this.content.style.transform = 'scale(0)'
|
||||
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
|
||||
this.el.style.opacity = 1
|
||||
this.el.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
190
client/components/tables/BackupsTable.vue
Normal file
190
client/components/tables/BackupsTable.vue
Normal file
@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div class="text-center mt-4">
|
||||
<div class="flex py-4">
|
||||
<ui-file-input ref="fileInput" class="mr-2" accept=".audiobookshelf" @change="backupUploaded">Upload Backup</ui-file-input>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn :loading="isBackingUp" @click="clickCreateBackup">Create Backup</ui-btn>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<table id="backups">
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th class="w-56">Datetime</th>
|
||||
<th class="w-28">Size</th>
|
||||
<th class="w-36"></th>
|
||||
</tr>
|
||||
<tr v-for="backup in backups" :key="backup.id">
|
||||
<td>
|
||||
<p class="truncate">/{{ backup.path.replace(/\\/g, '/') }}</p>
|
||||
</td>
|
||||
<td class="font-sans">{{ backup.datePretty }}</td>
|
||||
<td class="font-mono">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||
<td>
|
||||
<div class="w-full flex items-center justify-center">
|
||||
<ui-btn small color="primary" @click="applyBackup(backup)">Apply</ui-btn>
|
||||
|
||||
<a :href="`/metadata/${backup.path.replace(/%/g, '%25').replace(/#/g, '%23')}?token=${userToken}`" class="mx-1 pt-0.5 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
|
||||
<!-- <span class="material-icons text-xl hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="downloadBackup">download</span> -->
|
||||
|
||||
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!backups.length" class="staticrow">
|
||||
<td colspan="4" class="text-lg">No Backups</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div v-show="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-25 flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<prompt-dialog v-model="showConfirmApply" :width="675">
|
||||
<div v-if="selectedBackup" class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p class="text-error text-lg font-semibold">Important Notice!</p>
|
||||
<p class="text-base py-1">Applying a backup will overwrite users, user progress, book details, settings, and covers stored in metadata with the backed up data.</p>
|
||||
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories.</p>
|
||||
<p class="text-base py-1">All clients using your server will be automatically refreshed.</p>
|
||||
|
||||
<p class="text-lg text-center my-8">Are you sure you want to apply the backup created on {{ selectedBackup.datePretty }}?</p>
|
||||
<div class="flex px-1 items-center">
|
||||
<ui-btn color="primary" @click="showConfirmApply = false">Nevermind</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="confirm">Apply Backup</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</prompt-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showConfirmApply: false,
|
||||
selectedBackup: null,
|
||||
isBackingUp: false,
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
backups() {
|
||||
return this.$store.state.backups || []
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
confirm() {
|
||||
this.showConfirmApply = false
|
||||
this.$root.socket.once('apply_backup_complete', this.applyBackupComplete)
|
||||
this.$root.socket.emit('apply_backup', this.selectedBackup.id)
|
||||
},
|
||||
deleteBackupClick(backup) {
|
||||
if (confirm(`Are you sure you want to delete backup for ${backup.datePretty}?`)) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/backup/${backup.id}`)
|
||||
.then((backups) => {
|
||||
console.log('Backup deleted', backups)
|
||||
this.$store.commit('setBackups', backups)
|
||||
this.$toast.success(`Backup deleted`)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
this.$toast.error('Failed to delete backup')
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
applyBackupComplete(success) {
|
||||
if (success) {
|
||||
// this.$toast.success('Backup Applied, refresh the page')
|
||||
location.replace('/config?backup=1')
|
||||
} else {
|
||||
this.$toast.error('Failed to apply backup')
|
||||
}
|
||||
},
|
||||
applyBackup(backup) {
|
||||
this.selectedBackup = backup
|
||||
this.showConfirmApply = true
|
||||
},
|
||||
backupComplete(backups) {
|
||||
this.isBackingUp = false
|
||||
if (backups) {
|
||||
this.$toast.success('Backup Successful')
|
||||
this.$store.commit('setBackups', backups)
|
||||
} else this.$toast.error('Backup Failed')
|
||||
},
|
||||
clickCreateBackup() {
|
||||
this.isBackingUp = true
|
||||
this.$root.socket.once('backup_complete', this.backupComplete)
|
||||
this.$root.socket.emit('create_backup')
|
||||
},
|
||||
backupUploaded(file) {
|
||||
var form = new FormData()
|
||||
form.set('file', file)
|
||||
|
||||
this.processing = true
|
||||
|
||||
this.$axios
|
||||
.$post('/api/backup/upload', form)
|
||||
.then((result) => {
|
||||
console.log('Upload backup result', result)
|
||||
this.$store.commit('setBackups', result)
|
||||
this.$toast.success('Backup upload success')
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
var errorMessage = error.response && error.response.data ? error.response.data : 'Failed to upload backup'
|
||||
this.$toast.error(errorMessage)
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.$route.query.backup) {
|
||||
this.$toast.success('Backup applied successfully')
|
||||
this.$router.replace('/config')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#backups {
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#backups td,
|
||||
#backups th {
|
||||
border: 1px solid #2e2e2e;
|
||||
padding: 8px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#backups tr.staticrow td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#backups tr:nth-child(even) {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#backups tr:not(.staticrow):hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#backups th {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #333;
|
||||
}
|
||||
</style>
|
@ -19,9 +19,10 @@
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left px-4">Path</th>
|
||||
<th class="text-left px-4">Filetype</th>
|
||||
<th class="text-left px-4 w-24">Filetype</th>
|
||||
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
||||
</tr>
|
||||
<template v-for="file in files">
|
||||
<template v-for="file in otherFilesCleaned">
|
||||
<tr :key="file.path">
|
||||
<td class="font-book pl-2">
|
||||
{{ showFullPath ? file.fullPath : file.path }}
|
||||
@ -29,6 +30,9 @@
|
||||
<td class="text-xs">
|
||||
<p>{{ file.filetype }}</p>
|
||||
</td>
|
||||
<td v-if="userCanDownload" class="text-center">
|
||||
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</table>
|
||||
@ -44,7 +48,10 @@ export default {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
audiobookId: String
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -52,7 +59,34 @@ export default {
|
||||
showFullPath: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
audiobookId() {
|
||||
return this.audiobook.id
|
||||
},
|
||||
audiobookPath() {
|
||||
return this.audiobook.path
|
||||
},
|
||||
otherFilesCleaned() {
|
||||
return this.files.map((file) => {
|
||||
var filePath = file.path.replace(/\\/g, '/')
|
||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
||||
|
||||
return {
|
||||
...file,
|
||||
relativePath: filePath
|
||||
.replace(audiobookPath + '/', '')
|
||||
.replace(/%/g, '%25')
|
||||
.replace(/#/g, '%23')
|
||||
}
|
||||
})
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.showFiles = !this.showFiles
|
||||
|
@ -80,7 +80,10 @@ export default {
|
||||
|
||||
return {
|
||||
...track,
|
||||
relativePath: trackPath.replace(audiobookPath, '').replace(/%/g, '%25').replace(/#/g, '%23')
|
||||
relativePath: trackPath
|
||||
.replace(audiobookPath + '/', '')
|
||||
.replace(/%/g, '%25')
|
||||
.replace(/#/g, '%23')
|
||||
}
|
||||
})
|
||||
},
|
||||
|
@ -127,4 +127,35 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#accounts {
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#accounts td,
|
||||
#accounts th {
|
||||
border: 1px solid #2e2e2e;
|
||||
padding: 8px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#accounts tr:nth-child(even) {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#accounts tr:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#accounts th {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #333;
|
||||
}
|
||||
</style>
|
@ -1,17 +1,21 @@
|
||||
<template>
|
||||
<div>
|
||||
<input ref="fileInput" id="hidden-input" type="file" :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<ui-btn @click="clickUpload" color="primary" type="text">Upload Cover</ui-btn>
|
||||
<input ref="fileInput" id="hidden-input" type="file" :accept="accept" class="hidden" @change="inputChanged" />
|
||||
<ui-btn @click="clickUpload" color="primary" type="text"><slot /></ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
inputAccept: '.png, .jpg, .jpeg, .webp'
|
||||
props: {
|
||||
accept: {
|
||||
type: String,
|
||||
default: '.png, .jpg, .jpeg, .webp'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
reset() {
|
||||
|
@ -26,6 +26,8 @@ export default {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
noSpinner: Boolean,
|
||||
textCenter: Boolean,
|
||||
clearable: Boolean
|
||||
},
|
||||
data() {
|
||||
@ -44,6 +46,8 @@ export default {
|
||||
var _list = []
|
||||
_list.push(`px-${this.paddingX}`)
|
||||
_list.push(`py-${this.paddingY}`)
|
||||
if (this.noSpinner) _list.push('no-spinner')
|
||||
if (this.textCenter) _list.push('text-center')
|
||||
return _list.join(' ')
|
||||
}
|
||||
},
|
||||
|
@ -98,6 +98,7 @@ export default {
|
||||
if (!this.$refs.box) return // Ensure element is not destroyed
|
||||
try {
|
||||
document.body.appendChild(this.tooltip)
|
||||
this.setTooltipPosition(this.tooltip)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
@ -72,6 +72,9 @@ export default {
|
||||
this.scanStart(libraryScan)
|
||||
})
|
||||
}
|
||||
if (payload.backups && payload.backups.length) {
|
||||
this.$store.commit('setBackups', payload.backups)
|
||||
}
|
||||
},
|
||||
streamOpen(stream) {
|
||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
|
||||
@ -220,6 +223,10 @@ export default {
|
||||
logEvtReceived(payload) {
|
||||
this.$store.commit('logs/logEvt', payload)
|
||||
},
|
||||
backupApplied() {
|
||||
// Force refresh
|
||||
location.reload()
|
||||
},
|
||||
initializeSocket() {
|
||||
this.socket = this.$nuxtSocket({
|
||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||
@ -274,6 +281,8 @@ export default {
|
||||
this.socket.on('download_expired', this.downloadExpired)
|
||||
|
||||
this.socket.on('log', this.logEvtReceived)
|
||||
|
||||
this.socket.on('backup_applied', this.backupApplied)
|
||||
},
|
||||
showUpdateToast(versionData) {
|
||||
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
||||
|
@ -77,6 +77,7 @@ module.exports = {
|
||||
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
|
||||
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||
'/lib/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
|
||||
},
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.2",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@ -145,7 +145,7 @@
|
||||
|
||||
<tables-audio-files-table v-if="otherAudioFiles.length" :audiobook-id="audiobook.id" :files="otherAudioFiles" class="mt-6" />
|
||||
|
||||
<tables-other-files-table v-if="otherFiles.length" :audiobook-id="audiobook.id" :files="otherFiles" class="mt-6" />
|
||||
<tables-other-files-table v-if="otherFiles.length" :audiobook="audiobook" :files="otherFiles" class="mt-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -239,6 +239,9 @@ export default {
|
||||
libraryId() {
|
||||
return this.audiobook.libraryId
|
||||
},
|
||||
folderId() {
|
||||
return this.audiobook.folderId
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook.id
|
||||
},
|
||||
@ -313,9 +316,16 @@ export default {
|
||||
epubEbook() {
|
||||
return this.audiobook.ebooks.find((eb) => eb.ext === '.epub')
|
||||
},
|
||||
epubUrl() {
|
||||
epubPath() {
|
||||
return this.epubEbook ? this.epubEbook.path : null
|
||||
},
|
||||
epubUrl() {
|
||||
if (!this.epubPath) return null
|
||||
return `/ebook/${this.libraryId}/${this.folderId}/${this.epubPath}`
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
description() {
|
||||
return this.book.description || ''
|
||||
},
|
||||
|
@ -35,6 +35,31 @@
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-xl">Backups</h1>
|
||||
</div>
|
||||
|
||||
<p class="text-base mb-4 text-gray-300">Backups include users, user progress, book details, server settings and covers stored in <span class="font-mono text-gray-100">/metadata/books</span>. <br />Backups <strong>do not</strong> include any files stored in your library folders.</p>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="dailyBackups" small :disabled="updatingServerSettings" @input="updateBackupsSettings" />
|
||||
<ui-tooltip :text="dailyBackupsTooltip">
|
||||
<p class="pl-4 text-lg">Run daily backups <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-text-input type="number" v-model="backupsToKeep" no-spinner :disabled="updatingServerSettings" :padding-x="1" text-center class="w-10" @change="updateBackupsSettings" />
|
||||
|
||||
<p class="pl-4 text-lg">Number of backups to keep</p>
|
||||
</div>
|
||||
|
||||
<tables-backups-table />
|
||||
</div>
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
|
||||
<div class="flex items-center py-4">
|
||||
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
@ -92,14 +117,16 @@ export default {
|
||||
storeCoversInAudiobookDir: false,
|
||||
isResettingAudiobooks: false,
|
||||
newServerSettings: {},
|
||||
updatingServerSettings: false
|
||||
updatingServerSettings: false,
|
||||
dailyBackups: true,
|
||||
backupsToKeep: 2
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
serverSettings(newVal, oldVal) {
|
||||
if (newVal && !oldVal) {
|
||||
this.newServerSettings = { ...this.serverSettings }
|
||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||
this.initServerSettings()
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -119,6 +146,12 @@ export default {
|
||||
experimentalFeaturesTooltip() {
|
||||
return 'Features in development that could use your feedback and help testing.'
|
||||
},
|
||||
dailyBackupsTooltip() {
|
||||
return 'Runs at 1am every day (your server time). Saved in /metadata/backups.'
|
||||
},
|
||||
backupsToKeepTooltip() {
|
||||
return ''
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
@ -141,10 +174,17 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// toggleShowExperimentalFeatures() {
|
||||
// var newExperimentalValue = !this.showExperimentalFeatures
|
||||
// this.$store.commit('setExperimentalFeatures', newExperimentalValue)
|
||||
// },
|
||||
updateBackupsSettings() {
|
||||
if (isNaN(this.backupsToKeep) || this.backupsToKeep <= 0 || this.backupsToKeep > 99) {
|
||||
this.$toast.error('Invalid number of backups to keep')
|
||||
return
|
||||
}
|
||||
var updatePayload = {
|
||||
backupSchedule: this.dailyBackups ? '0 1 * * *' : false,
|
||||
backupsToKeep: Number(this.backupsToKeep)
|
||||
}
|
||||
this.updateServerSettings(updatePayload)
|
||||
},
|
||||
updateScannerFindCovers(val) {
|
||||
this.updateServerSettings({
|
||||
scannerFindCovers: !!val
|
||||
@ -211,7 +251,13 @@ export default {
|
||||
},
|
||||
init() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
this.initServerSettings()
|
||||
},
|
||||
initServerSettings() {
|
||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
||||
this.dailyBackups = !!this.newServerSettings.backupSchedule
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@ -219,34 +265,3 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#accounts {
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#accounts td,
|
||||
#accounts th {
|
||||
border: 1px solid #2e2e2e;
|
||||
padding: 8px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#accounts tr:nth-child(even) {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#accounts tr:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#accounts th {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #333;
|
||||
}
|
||||
</style>
|
@ -14,7 +14,8 @@ export const state = () => ({
|
||||
processingBatch: false,
|
||||
previousPath: '/',
|
||||
routeHistory: [],
|
||||
showExperimentalFeatures: false
|
||||
showExperimentalFeatures: false,
|
||||
backups: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
@ -130,5 +131,8 @@ export const mutations = {
|
||||
setExperimentalFeatures(state, val) {
|
||||
state.showExperimentalFeatures = val
|
||||
localStorage.setItem('experimental', val ? 1 : 0)
|
||||
},
|
||||
setBackups(state, val) {
|
||||
state.backups = val.sort((a, b) => b.createdAt - a.createdAt)
|
||||
}
|
||||
}
|
33
package-lock.json
generated
33
package-lock.json
generated
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.3.4",
|
||||
"version": "1.4.1",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@ -457,6 +457,11 @@
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"date-and-time": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/date-and-time/-/date-and-time-2.0.1.tgz",
|
||||
"integrity": "sha512-O7Xe5dLaqvY/aF/MFWArsAM1J4j7w1CSZlPCX9uHgmb+6SbkPd8Q4YOvfvH/cZGvFlJFfHOZKxQtmMUOoZhc/w=="
|
||||
},
|
||||
"debounce": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
|
||||
@ -1168,6 +1173,19 @@
|
||||
"minimist": "^1.2.5"
|
||||
}
|
||||
},
|
||||
"moment": {
|
||||
"version": "2.29.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
|
||||
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
|
||||
},
|
||||
"moment-timezone": {
|
||||
"version": "0.5.33",
|
||||
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.33.tgz",
|
||||
"integrity": "sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w==",
|
||||
"requires": {
|
||||
"moment": ">= 2.9.0"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
@ -1220,6 +1238,14 @@
|
||||
"proper-lockfile": "^4.1.2"
|
||||
}
|
||||
},
|
||||
"node-cron": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.0.tgz",
|
||||
"integrity": "sha512-DDwIvvuCwrNiaU7HEivFDULcaQualDv7KoNlB/UU1wPW0n1tDEmBJKhEIE6DlF2FuoOHcNbLJ8ITL2Iv/3AWmA==",
|
||||
"requires": {
|
||||
"moment-timezone": "^0.5.31"
|
||||
}
|
||||
},
|
||||
"node-dir": {
|
||||
"version": "0.1.17",
|
||||
"resolved": "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz",
|
||||
@ -1246,6 +1272,11 @@
|
||||
"tar": "^4"
|
||||
}
|
||||
},
|
||||
"node-stream-zip": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz",
|
||||
"integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw=="
|
||||
},
|
||||
"nopt": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.4.1",
|
||||
"version": "1.4.2",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@ -27,6 +27,7 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"command-line-args": "^5.2.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"date-and-time": "^2.0.1",
|
||||
"epub": "^1.2.1",
|
||||
"express": "^4.17.1",
|
||||
"express-fileupload": "^1.2.1",
|
||||
@ -38,7 +39,9 @@
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"libgen": "^2.1.0",
|
||||
"njodb": "^0.4.20",
|
||||
"node-cron": "^3.0.0",
|
||||
"node-dir": "^0.1.17",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"podcast": "^1.3.0",
|
||||
"read-chunk": "^3.1.0",
|
||||
"socket.io": "^4.1.3",
|
||||
|
@ -7,7 +7,7 @@ const { isObject } = require('./utils/index')
|
||||
const Library = require('./objects/Library')
|
||||
|
||||
class ApiController {
|
||||
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, coverController, watcher, emitter, clientEmitter) {
|
||||
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, coverController, backupManager, watcher, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
this.scanner = scanner
|
||||
this.auth = auth
|
||||
@ -15,6 +15,7 @@ class ApiController {
|
||||
this.rssFeeds = rssFeeds
|
||||
this.downloadManager = downloadManager
|
||||
this.coverController = coverController
|
||||
this.backupManager = backupManager
|
||||
this.watcher = watcher
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
@ -61,6 +62,9 @@ class ApiController {
|
||||
|
||||
this.router.patch('/serverSettings', this.updateServerSettings.bind(this))
|
||||
|
||||
this.router.delete('/backup/:id', this.deleteBackup.bind(this))
|
||||
this.router.post('/backup/upload', this.uploadBackup.bind(this))
|
||||
|
||||
this.router.post('/authorize', this.authorize.bind(this))
|
||||
|
||||
this.router.get('/genres', this.getGenres.bind(this))
|
||||
@ -569,6 +573,31 @@ class ApiController {
|
||||
})
|
||||
}
|
||||
|
||||
async deleteBackup(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error(`[ApiController] Non-Root user attempting to delete backup`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
|
||||
if (!backup) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
await this.backupManager.removeBackup(backup)
|
||||
res.json(this.backupManager.backups.map(b => b.toJSON()))
|
||||
}
|
||||
|
||||
async uploadBackup(req, res) {
|
||||
if (!req.user.isRoot) {
|
||||
Logger.error(`[ApiController] Non-Root user attempting to upload backup`, req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (!req.files.file) {
|
||||
Logger.error('[ApiController] Upload backup invalid')
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
this.backupManager.uploadBackup(req, res)
|
||||
}
|
||||
|
||||
async download(req, res) {
|
||||
if (!req.user.canDownload) {
|
||||
Logger.error('User attempting to download without permission', req.user)
|
||||
|
277
server/BackupManager.js
Normal file
277
server/BackupManager.js
Normal file
@ -0,0 +1,277 @@
|
||||
const Path = require('path')
|
||||
|
||||
const cron = require('node-cron')
|
||||
const fs = require('fs-extra')
|
||||
const archiver = require('archiver')
|
||||
const StreamZip = require('node-stream-zip')
|
||||
|
||||
// Utils
|
||||
const { getFileSize } = require('./utils/fileUtils')
|
||||
const filePerms = require('./utils/filePerms')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
const Backup = require('./objects/Backup')
|
||||
|
||||
class BackupManager {
|
||||
constructor(MetadataPath, Uid, Gid, db) {
|
||||
this.MetadataPath = MetadataPath
|
||||
this.BackupPath = Path.join(this.MetadataPath, 'backups')
|
||||
|
||||
this.Uid = Uid
|
||||
this.Gid = Gid
|
||||
this.db = db
|
||||
|
||||
this.backups = []
|
||||
}
|
||||
|
||||
get serverSettings() {
|
||||
return this.db.serverSettings || {}
|
||||
}
|
||||
|
||||
async init(overrideCron = null) {
|
||||
var backupsDirExists = await fs.pathExists(this.BackupPath)
|
||||
if (!backupsDirExists) {
|
||||
await fs.ensureDir(this.BackupPath)
|
||||
await filePerms(this.BackupPath, 0o774, this.Uid, this.Gid)
|
||||
}
|
||||
|
||||
await this.loadBackups()
|
||||
|
||||
if (!this.serverSettings.backupSchedule) {
|
||||
Logger.info(`[BackupManager] Auto Backups are disabled`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
var cronSchedule = overrideCron || this.serverSettings.backupSchedule
|
||||
cron.schedule(cronSchedule, this.runBackup.bind(this))
|
||||
} catch (error) {
|
||||
Logger.error(`[BackupManager] Failed to schedule backup cron ${this.serverSettings.backupSchedule}`)
|
||||
}
|
||||
}
|
||||
|
||||
async uploadBackup(req, res) {
|
||||
var backupFile = req.files.file
|
||||
if (Path.extname(backupFile.name) !== '.audiobookshelf') {
|
||||
Logger.error(`[BackupManager] Invalid backup file uploaded "${backupFile.name}"`)
|
||||
return res.status(500).send('Invalid backup file')
|
||||
}
|
||||
|
||||
var tempPath = Path.join(this.BackupPath, backupFile.name)
|
||||
var success = await backupFile.mv(tempPath).then(() => true).catch((error) => {
|
||||
Logger.error('[BackupManager] Failed to move backup file', path, error)
|
||||
return false
|
||||
})
|
||||
if (!success) {
|
||||
return res.status(500).send('Failed to move backup file into backups directory')
|
||||
}
|
||||
|
||||
const zip = new StreamZip.async({ file: tempPath })
|
||||
const data = await zip.entryData('details')
|
||||
var details = data.toString('utf8').split('\n')
|
||||
|
||||
var backup = new Backup({ details, fullPath: tempPath })
|
||||
backup.fileSize = await getFileSize(backup.fullPath)
|
||||
|
||||
var existingBackupIndex = this.backups.findIndex(b => b.id === backup.id)
|
||||
if (existingBackupIndex >= 0) {
|
||||
Logger.warn(`[BackupManager] Backup already exists with id ${backup.id} - overwriting`)
|
||||
this.backups.splice(existingBackupIndex, 1, backup)
|
||||
} else {
|
||||
this.backups.push(backup)
|
||||
}
|
||||
|
||||
return res.json(this.backups.map(b => b.toJSON()))
|
||||
}
|
||||
|
||||
async requestCreateBackup(socket) {
|
||||
// Only Root User allowed
|
||||
var client = socket.sheepClient
|
||||
if (!client || !client.user) {
|
||||
Logger.error(`[BackupManager] Invalid user attempting to create backup`)
|
||||
socket.emit('backup_complete', false)
|
||||
return
|
||||
} else if (!client.user.isRoot) {
|
||||
Logger.error(`[BackupManager] Non-Root user attempting to create backup`)
|
||||
socket.emit('backup_complete', false)
|
||||
return
|
||||
}
|
||||
|
||||
var backupSuccess = await this.runBackup()
|
||||
socket.emit('backup_complete', backupSuccess ? this.backups.map(b => b.toJSON()) : false)
|
||||
}
|
||||
|
||||
async requestApplyBackup(socket, id) {
|
||||
// Only Root User allowed
|
||||
var client = socket.sheepClient
|
||||
if (!client || !client.user) {
|
||||
Logger.error(`[BackupManager] Invalid user attempting to create backup`)
|
||||
socket.emit('apply_backup_complete', false)
|
||||
return
|
||||
} else if (!client.user.isRoot) {
|
||||
Logger.error(`[BackupManager] Non-Root user attempting to create backup`)
|
||||
socket.emit('apply_backup_complete', false)
|
||||
return
|
||||
}
|
||||
|
||||
var backup = this.backups.find(b => b.id === id)
|
||||
if (!backup) {
|
||||
socket.emit('apply_backup_complete', false)
|
||||
return
|
||||
}
|
||||
const zip = new StreamZip.async({ file: backup.fullPath })
|
||||
await zip.extract('config/', this.db.ConfigPath)
|
||||
if (backup.backupMetadataCovers) {
|
||||
var metadataBooksPath = Path.join(this.MetadataPath, 'books')
|
||||
await zip.extract('metadata-books/', metadataBooksPath)
|
||||
}
|
||||
await this.db.reinit()
|
||||
socket.emit('apply_backup_complete', true)
|
||||
socket.broadcast.emit('backup_applied')
|
||||
}
|
||||
|
||||
async setLastBackup() {
|
||||
this.backups.sort((a, b) => b.createdAt - a.createdAt)
|
||||
var lastBackup = this.backups.shift()
|
||||
|
||||
const zip = new StreamZip.async({ file: lastBackup.fullPath })
|
||||
await zip.extract('config/', this.db.ConfigPath)
|
||||
console.log('Set Last Backup')
|
||||
await this.db.reinit()
|
||||
}
|
||||
|
||||
async loadBackups() {
|
||||
try {
|
||||
var filesInDir = await fs.readdir(this.BackupPath)
|
||||
for (let i = 0; i < filesInDir.length; i++) {
|
||||
var filename = filesInDir[i]
|
||||
if (filename.endsWith('.audiobookshelf')) {
|
||||
var fullFilePath = Path.join(this.BackupPath, filename)
|
||||
const zip = new StreamZip.async({ file: fullFilePath })
|
||||
const data = await zip.entryData('details')
|
||||
var details = data.toString('utf8').split('\n')
|
||||
|
||||
var backup = new Backup({ details, fullPath: fullFilePath })
|
||||
backup.fileSize = await getFileSize(backup.fullPath)
|
||||
var existingBackupWithId = this.backups.find(b => b.id === backup.id)
|
||||
if (existingBackupWithId) {
|
||||
Logger.warn(`[BackupManager] Backup already loaded with id ${backup.id} - ignoring`)
|
||||
} else {
|
||||
this.backups.push(backup)
|
||||
}
|
||||
|
||||
|
||||
Logger.debug(`[BackupManager] Backup found "${backup.id}"`)
|
||||
zip.close()
|
||||
}
|
||||
}
|
||||
Logger.info(`[BackupManager] ${this.backups.length} Backups Found`)
|
||||
} catch (error) {
|
||||
Logger.error('[BackupManager] Failed to load backups', error)
|
||||
}
|
||||
}
|
||||
|
||||
async runBackup() {
|
||||
Logger.info(`[BackupManager] Running Backup`)
|
||||
var metadataBooksPath = this.serverSettings.backupMetadataCovers ? Path.join(this.MetadataPath, 'books') : null
|
||||
|
||||
var newBackup = new Backup()
|
||||
|
||||
const newBackData = {
|
||||
backupMetadataCovers: this.serverSettings.backupMetadataCovers,
|
||||
backupDirPath: this.BackupPath
|
||||
}
|
||||
newBackup.setData(newBackData)
|
||||
|
||||
var zipResult = await this.zipBackup(this.db.ConfigPath, metadataBooksPath, newBackup).then(() => true).catch((error) => {
|
||||
Logger.error(`[BackupManager] Backup Failed ${error}`)
|
||||
return false
|
||||
})
|
||||
if (zipResult) {
|
||||
Logger.info(`[BackupManager] Backup successful ${newBackup.id}`)
|
||||
await filePerms(newBackup.fullPath, 0o774, this.Uid, this.Gid)
|
||||
newBackup.fileSize = await getFileSize(newBackup.fullPath)
|
||||
var existingIndex = this.backups.findIndex(b => b.id === newBackup.id)
|
||||
if (existingIndex >= 0) {
|
||||
this.backups.splice(existingIndex, 1, newBackup)
|
||||
} else {
|
||||
this.backups.push(newBackup)
|
||||
}
|
||||
|
||||
// Check remove oldest backup
|
||||
if (this.backups.length > this.serverSettings.backupsToKeep) {
|
||||
this.backups.sort((a, b) => a.createdAt - b.createdAt)
|
||||
|
||||
var oldBackup = this.backups.shift()
|
||||
Logger.debug(`[BackupManager] Removing old backup ${oldBackup.id}`)
|
||||
this.removeBackup(oldBackup)
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async removeBackup(backup) {
|
||||
try {
|
||||
await fs.remove(backup.fullPath)
|
||||
this.backups = this.backups.filter(b => b.id !== backup.id)
|
||||
Logger.info(`[BackupManager] Backup "${backup.id}" Removed`)
|
||||
} catch (error) {
|
||||
Logger.error(`[BackupManager] Failed to remove backup`, error)
|
||||
}
|
||||
}
|
||||
|
||||
zipBackup(configPath, metadataBooksPath, backup) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// create a file to stream archive data to
|
||||
const output = fs.createWriteStream(backup.fullPath)
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 } // Sets the compression level.
|
||||
})
|
||||
|
||||
// listen for all archive data to be written
|
||||
// 'close' event is fired only when a file descriptor is involved
|
||||
output.on('close', () => {
|
||||
Logger.info('[BackupManager]', archive.pointer() + ' total bytes')
|
||||
resolve()
|
||||
})
|
||||
|
||||
// This event is fired when the data source is drained no matter what was the data source.
|
||||
// It is not part of this library but rather from the NodeJS Stream API.
|
||||
// @see: https://nodejs.org/api/stream.html#stream_event_end
|
||||
output.on('end', () => {
|
||||
Logger.debug('Data has been drained')
|
||||
})
|
||||
|
||||
// good practice to catch warnings (ie stat failures and other non-blocking errors)
|
||||
archive.on('warning', function (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
// log warning
|
||||
Logger.warn(`[BackupManager] Archiver warning: ${err.message}`)
|
||||
} else {
|
||||
// throw error
|
||||
Logger.error(`[BackupManager] Archiver error: ${err.message}`)
|
||||
// throw err
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
archive.on('error', function (err) {
|
||||
Logger.error(`[BackupManager] Archiver error: ${err.message}`)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
// pipe archive data to the file
|
||||
archive.pipe(output)
|
||||
|
||||
archive.directory(configPath, 'config')
|
||||
if (metadataBooksPath) {
|
||||
Logger.debug(`[BackupManager] Backing up Metadata Books "${metadataBooksPath}"`)
|
||||
archive.directory(metadataBooksPath, 'metadata-books')
|
||||
}
|
||||
archive.append(backup.detailsString, { name: 'details' })
|
||||
|
||||
archive.finalize()
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = BackupManager
|
@ -70,6 +70,14 @@ class Db {
|
||||
return defaultLibrary
|
||||
}
|
||||
|
||||
reinit() {
|
||||
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
|
||||
this.usersDb = new njodb.Database(this.UsersPath)
|
||||
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 })
|
||||
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
|
||||
return this.init()
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.load()
|
||||
|
||||
|
@ -18,6 +18,7 @@ const Auth = require('./Auth')
|
||||
const Watcher = require('./Watcher')
|
||||
const Scanner = require('./Scanner')
|
||||
const Db = require('./Db')
|
||||
const BackupManager = require('./BackupManager')
|
||||
const ApiController = require('./ApiController')
|
||||
const HlsController = require('./HlsController')
|
||||
const StreamManager = require('./StreamManager')
|
||||
@ -25,7 +26,6 @@ const RssFeeds = require('./RssFeeds')
|
||||
const DownloadManager = require('./DownloadManager')
|
||||
const CoverController = require('./CoverController')
|
||||
|
||||
|
||||
class Server {
|
||||
constructor(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||
this.Port = PORT
|
||||
@ -42,13 +42,14 @@ class Server {
|
||||
|
||||
this.db = new Db(this.ConfigPath, this.AudiobookPath)
|
||||
this.auth = new Auth(this.db)
|
||||
this.backupManager = new BackupManager(this.MetadataPath, this.Uid, this.Gid, this.db)
|
||||
this.watcher = new Watcher(this.AudiobookPath)
|
||||
this.coverController = new CoverController(this.db, this.MetadataPath, this.AudiobookPath)
|
||||
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
|
||||
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
||||
this.rssFeeds = new RssFeeds(this.Port, this.db)
|
||||
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
|
||||
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.watcher, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
|
||||
|
||||
this.expressApp = null
|
||||
@ -101,6 +102,7 @@ class Server {
|
||||
this.auth.init()
|
||||
|
||||
await this.purgeMetadata()
|
||||
await this.backupManager.init()
|
||||
|
||||
this.watcher.initWatcher(this.libraries)
|
||||
this.watcher.on('files', this.filesChanged.bind(this))
|
||||
@ -158,6 +160,18 @@ class Server {
|
||||
res.sendFile(fullPath)
|
||||
})
|
||||
|
||||
// EBook static file routes
|
||||
app.get('/ebook/:library/:folder/*', (req, res) => {
|
||||
var library = this.libraries.find(lib => lib.id === req.params.library)
|
||||
if (!library) return res.sendStatus(404)
|
||||
var folder = library.folders.find(fol => fol.id === req.params.folder)
|
||||
if (!folder) return res.status(404).send('Folder not found')
|
||||
|
||||
var remainingPath = req.params['0']
|
||||
var fullPath = Path.join(folder.fullPath, remainingPath)
|
||||
res.sendFile(fullPath)
|
||||
})
|
||||
|
||||
// Client routes
|
||||
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
@ -235,8 +249,13 @@ class Server {
|
||||
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
|
||||
socket.on('remove_download', (downloadId) => this.downloadManager.removeSocketRequest(socket, downloadId))
|
||||
|
||||
// Logs
|
||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||
|
||||
// Backups
|
||||
socket.on('create_backup', () => this.backupManager.requestCreateBackup(socket))
|
||||
socket.on('apply_backup', (id) => this.backupManager.requestApplyBackup(socket, id))
|
||||
|
||||
socket.on('test', () => {
|
||||
socket.emit('test_received', socket.id)
|
||||
})
|
||||
@ -448,7 +467,8 @@ class Server {
|
||||
configPath: this.ConfigPath,
|
||||
user: client.user.toJSONForBrowser(),
|
||||
stream: client.stream || null,
|
||||
librariesScanning: this.scanner.librariesScanning
|
||||
librariesScanning: this.scanner.librariesScanning,
|
||||
backups: (this.backupManager.backups || []).map(b => b.toJSON())
|
||||
}
|
||||
client.socket.emit('init', initialPayload)
|
||||
|
||||
|
@ -65,7 +65,7 @@ class StreamManager {
|
||||
if (!dirs || !dirs.length) return true
|
||||
|
||||
await Promise.all(dirs.map(async (dirname) => {
|
||||
if (dirname !== 'streams' && dirname !== 'books' && dirname !== 'downloads') {
|
||||
if (dirname !== 'streams' && dirname !== 'books' && dirname !== 'downloads' && dirname !== 'backups') {
|
||||
var fullPath = Path.join(this.MetadataPath, dirname)
|
||||
Logger.warn(`Removing OLD Orphan Stream ${dirname}`)
|
||||
return fs.remove(fullPath)
|
||||
|
@ -223,6 +223,7 @@ class Audiobook {
|
||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||
ebooks: (this.ebooks || []).map(ebook => ebook.toJSON()),
|
||||
numEbooks: this.hasEpub ? 1 : 0,
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON(),
|
||||
|
75
server/objects/Backup.js
Normal file
75
server/objects/Backup.js
Normal file
@ -0,0 +1,75 @@
|
||||
const Path = require('path')
|
||||
const date = require('date-and-time')
|
||||
|
||||
class Backup {
|
||||
constructor(data = null) {
|
||||
this.id = null
|
||||
this.datePretty = null
|
||||
this.backupMetadataCovers = null
|
||||
|
||||
this.backupDirPath = null
|
||||
this.filename = null
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
|
||||
this.fileSize = null
|
||||
this.createdAt = null
|
||||
|
||||
if (data) {
|
||||
this.construct(data)
|
||||
}
|
||||
}
|
||||
|
||||
get detailsString() {
|
||||
var details = []
|
||||
details.push(this.id)
|
||||
details.push(this.backupMetadataCovers ? '1' : '0')
|
||||
details.push(this.createdAt)
|
||||
return details.join('\n')
|
||||
}
|
||||
|
||||
construct(data) {
|
||||
this.id = data.details[0]
|
||||
this.backupMetadataCovers = data.details[1] === '1'
|
||||
this.createdAt = Number(data.details[2])
|
||||
|
||||
this.datePretty = date.format(new Date(this.createdAt), 'ddd, MMM D YYYY HH:mm')
|
||||
|
||||
this.backupDirPath = Path.dirname(data.fullPath)
|
||||
this.filename = Path.basename(data.fullPath)
|
||||
this.path = Path.join('backups', this.filename)
|
||||
this.fullPath = data.fullPath
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
backupMetadataCovers: this.backupMetadataCovers,
|
||||
backupDirPath: this.backupDirPath,
|
||||
datePretty: this.datePretty,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
path: this.path,
|
||||
filename: this.filename,
|
||||
fileSize: this.fileSize,
|
||||
createdAt: this.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.id = date.format(new Date(), 'YYYY-MM-DD[T]HHmm')
|
||||
this.datePretty = date.format(new Date(), 'ddd, MMM D YYYY HH:mm')
|
||||
|
||||
this.backupMetadataCovers = data.backupMetadataCovers
|
||||
|
||||
this.backupDirPath = data.backupDirPath
|
||||
|
||||
this.filename = this.id + '.audiobookshelf'
|
||||
this.path = Path.join('backups', this.filename)
|
||||
this.fullPath = Path.join(this.backupDirPath, this.filename)
|
||||
console.log('Backup fullpath', this.fullPath)
|
||||
|
||||
this.createdAt = Date.now()
|
||||
}
|
||||
}
|
||||
module.exports = Backup
|
@ -5,14 +5,28 @@ class ServerSettings {
|
||||
constructor(settings) {
|
||||
this.id = 'server-settings'
|
||||
|
||||
// Misc/Unused
|
||||
this.autoTagNew = false
|
||||
this.newTagExpireDays = 15
|
||||
|
||||
// Scanner
|
||||
this.scannerParseSubtitle = false
|
||||
this.scannerFindCovers = false
|
||||
|
||||
// Metadata
|
||||
this.coverDestination = CoverDestination.METADATA
|
||||
this.saveMetadataFile = false
|
||||
|
||||
// Security/Rate limits
|
||||
this.rateLimitLoginRequests = 10
|
||||
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
|
||||
|
||||
// Backups
|
||||
// this.backupSchedule = '0 1 * * *' // If false then auto-backups are disabled (default every day at 1am)
|
||||
this.backupSchedule = false
|
||||
this.backupsToKeep = 2
|
||||
this.backupMetadataCovers = true
|
||||
|
||||
this.logLevel = Logger.logLevel
|
||||
|
||||
if (settings) {
|
||||
@ -29,6 +43,11 @@ class ServerSettings {
|
||||
this.saveMetadataFile = !!settings.saveMetadataFile
|
||||
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
||||
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
|
||||
|
||||
this.backupSchedule = settings.backupSchedule || false
|
||||
this.backupsToKeep = settings.backupsToKeep || 2
|
||||
this.backupMetadataCovers = settings.backupMetadataCovers !== false
|
||||
|
||||
this.logLevel = settings.logLevel || Logger.logLevel
|
||||
|
||||
if (this.logLevel !== Logger.logLevel) {
|
||||
@ -47,6 +66,9 @@ class ServerSettings {
|
||||
saveMetadataFile: !!this.saveMetadataFile,
|
||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
||||
backupSchedule: this.backupSchedule,
|
||||
backupsToKeep: this.backupsToKeep,
|
||||
backupMetadataCovers: this.backupMetadataCovers,
|
||||
logLevel: this.logLevel
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user