mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-05 02:02:44 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e80ec10e8a | |||
| f752c19418 |
+15
-2
@@ -51,6 +51,19 @@
|
|||||||
opacity: 0;
|
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 {
|
.tracksTable {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -66,10 +79,10 @@
|
|||||||
background-color: #474747;
|
background-color: #474747;
|
||||||
}
|
}
|
||||||
.tracksTable td {
|
.tracksTable td {
|
||||||
padding: 4px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
.tracksTable th {
|
.tracksTable th {
|
||||||
padding: 4px;
|
padding: 4px 8px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<p class="text-base leading-3 font-book pl-2">{{ libraryName }}</p>
|
<p class="text-sm leading-3 font-sans pl-2">{{ libraryName }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<controls-global-search />
|
<controls-global-search />
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
shelves: [],
|
shelves: [],
|
||||||
currFilterOrderKey: null,
|
currSearchParams: null,
|
||||||
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
|
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
|
||||||
selectedSizeIndex: 3,
|
selectedSizeIndex: 3,
|
||||||
rowPaddingX: 40,
|
rowPaddingX: 40,
|
||||||
@@ -89,9 +89,6 @@ export default {
|
|||||||
audiobooks() {
|
audiobooks() {
|
||||||
return this.$store.state.audiobooks.audiobooks
|
return this.$store.state.audiobooks.audiobooks
|
||||||
},
|
},
|
||||||
filterOrderKey() {
|
|
||||||
return this.$store.getters['user/getFilterOrderKey']
|
|
||||||
},
|
|
||||||
bookCoverWidth() {
|
bookCoverWidth() {
|
||||||
return this.availableSizes[this.selectedSizeIndex]
|
return this.availableSizes[this.selectedSizeIndex]
|
||||||
},
|
},
|
||||||
@@ -111,6 +108,12 @@ export default {
|
|||||||
filterBy() {
|
filterBy() {
|
||||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||||
},
|
},
|
||||||
|
orderBy() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('orderBy')
|
||||||
|
},
|
||||||
|
orderDesc() {
|
||||||
|
return this.$store.getters['user/getUserSetting']('orderDesc')
|
||||||
|
},
|
||||||
showGroups() {
|
showGroups() {
|
||||||
return this.page !== '' && this.page !== 'search' && !this.selectedSeries
|
return this.page !== '' && this.page !== 'search' && !this.selectedSeries
|
||||||
},
|
},
|
||||||
@@ -165,6 +168,8 @@ export default {
|
|||||||
|
|
||||||
var booksPerRow = Math.floor(width / this.bookWidth)
|
var booksPerRow = Math.floor(width / this.bookWidth)
|
||||||
|
|
||||||
|
this.currSearchParams = this.buildSearchParams()
|
||||||
|
|
||||||
var entities = this.entities
|
var entities = this.entities
|
||||||
var groups = []
|
var groups = []
|
||||||
var currentRow = 0
|
var currentRow = 0
|
||||||
@@ -185,6 +190,8 @@ export default {
|
|||||||
this.shelves = groups
|
this.shelves = groups
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
|
this.checkUpdateSearchParams()
|
||||||
|
|
||||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||||
|
|
||||||
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||||
@@ -203,10 +210,41 @@ export default {
|
|||||||
console.log('[AudioBookshelf] Audiobooks Updated')
|
console.log('[AudioBookshelf] Audiobooks Updated')
|
||||||
this.setBookshelfEntities()
|
this.setBookshelfEntities()
|
||||||
},
|
},
|
||||||
settingsUpdated(settings) {
|
buildSearchParams() {
|
||||||
if (this.currFilterOrderKey !== this.filterOrderKey) {
|
if (this.page === 'search' || this.page === 'series') {
|
||||||
this.setBookshelfEntities()
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let searchParams = new URLSearchParams()
|
||||||
|
if (this.filterBy && this.filterBy !== 'all') {
|
||||||
|
searchParams.set('filter', this.filterBy)
|
||||||
|
}
|
||||||
|
if (this.orderBy) {
|
||||||
|
searchParams.set('order', this.orderBy)
|
||||||
|
searchParams.set('orderdesc', this.orderDesc ? 1 : 0)
|
||||||
|
}
|
||||||
|
return searchParams.toString()
|
||||||
|
},
|
||||||
|
checkUpdateSearchParams() {
|
||||||
|
var newSearchParams = this.buildSearchParams()
|
||||||
|
var currentQueryString = window.location.search
|
||||||
|
|
||||||
|
if (newSearchParams === '') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newSearchParams !== this.currSearchParams || newSearchParams !== currentQueryString) {
|
||||||
|
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
|
||||||
|
window.history.replaceState({ path: newurl }, '', newurl)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
settingsUpdated(settings) {
|
||||||
|
var wasUpdated = this.checkUpdateSearchParams()
|
||||||
|
if (wasUpdated) this.setBookshelfEntities()
|
||||||
|
|
||||||
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
|
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
|
||||||
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
|
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-10 relative">
|
<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">
|
<template v-if="page !== 'search' && !isHome">
|
||||||
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
|
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
|
||||||
<div v-else class="flex items-center">
|
<div v-else class="flex items-center">
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<ui-text-input v-show="!selectedSeries" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" class="text-xs w-40" />
|
<ui-text-input v-show="!selectedSeries" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" clearable class="text-xs w-40" />
|
||||||
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
|
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
|
||||||
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
|
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="h-full flex items-center">
|
<div class="h-full flex items-center">
|
||||||
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden">
|
<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>
|
||||||
<div id="frame" class="w-full" style="height: 650px">
|
<div id="frame" class="w-full" style="height: 650px">
|
||||||
<div id="viewer" class="spreads"></div>
|
<div id="viewer" class="spreads"></div>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,10 +69,13 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fullUrl() {
|
userToken() {
|
||||||
var serverUrl = process.env.serverUrl || '/local'
|
return this.$store.getters['user/getToken']
|
||||||
return `${serverUrl}/${this.url}`
|
|
||||||
}
|
}
|
||||||
|
// fullUrl() {
|
||||||
|
// var serverUrl = process.env.serverUrl || `/s/book/${this.audiobookId}`
|
||||||
|
// return `${serverUrl}/${this.url}`
|
||||||
|
// }
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
changedChapter() {
|
changedChapter() {
|
||||||
@@ -113,7 +116,13 @@ export default {
|
|||||||
init() {
|
init() {
|
||||||
this.registerListeners()
|
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.book = book
|
||||||
|
|
||||||
this.rendition = book.renderTo('viewer', {
|
this.rendition = book.renderTo('viewer', {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
|
||||||
</div>
|
</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` }">
|
<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> -->
|
<!-- <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">EBook</p> -->
|
||||||
<span class="material-icons text-white text-base">auto_stories</span>
|
<span class="material-icons text-white text-base">auto_stories</span>
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ export default {
|
|||||||
book() {
|
book() {
|
||||||
return this.audiobook.book || {}
|
return this.audiobook.book || {}
|
||||||
},
|
},
|
||||||
bookLastUpdate() {
|
|
||||||
return this.book.lastUpdate || Date.now()
|
|
||||||
},
|
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || 'No Title'
|
return this.book.title || 'No Title'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,6 +41,11 @@
|
|||||||
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
|
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('No Series'))">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">No Series</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
<template v-for="item in sublistItems">
|
<template v-for="item in sublistItems">
|
||||||
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
|
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -87,6 +92,11 @@ export default {
|
|||||||
value: 'authors',
|
value: 'authors',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Narrator',
|
||||||
|
value: 'narrators',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Progress',
|
text: 'Progress',
|
||||||
value: 'progress',
|
value: 'progress',
|
||||||
@@ -137,6 +147,9 @@ export default {
|
|||||||
authors() {
|
authors() {
|
||||||
return this.$store.getters['audiobooks/getUniqueAuthors']
|
return this.$store.getters['audiobooks/getUniqueAuthors']
|
||||||
},
|
},
|
||||||
|
narrators() {
|
||||||
|
return this.$store.getters['audiobooks/getUniqueNarrators']
|
||||||
|
},
|
||||||
progress() {
|
progress() {
|
||||||
return ['Read', 'Unread', 'In Progress']
|
return ['Read', 'Unread', 'In Progress']
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
<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>
|
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||||
</div>
|
</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">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<li v-if="isTyping" class="py-2 px-2">
|
<li v-if="isTyping" class="py-2 px-2">
|
||||||
<p>Typing...</p>
|
<p>Typing...</p>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-grow pl-6 pr-2">
|
<div class="flex-grow pl-6 pr-2">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div v-if="userCanUpload" class="w-40 pr-2" style="min-width: 160px">
|
<div v-if="userCanUpload" class="w-40 pr-2 pt-4" style="min-width: 160px">
|
||||||
<ui-file-input ref="fileInput" @change="fileUploadSelected" />
|
<ui-file-input ref="fileInput" @change="fileUploadSelected">Upload Cover</ui-file-input>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||||
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
|
<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 v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
|
||||||
<div class="flex items-center justify-center py-2">
|
<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" />
|
<div class="flex-grow" />
|
||||||
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? 'Hide' : 'Show' }}</ui-btn>
|
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? 'Hide' : 'Show' }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex mt-2 -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="w-1/2 px-1">
|
||||||
<ui-text-input-with-label v-model="details.narrarator" label="Narrarator" />
|
<ui-text-input-with-label v-model="details.narrator" label="Narrator" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +88,7 @@ export default {
|
|||||||
subtitle: null,
|
subtitle: null,
|
||||||
description: null,
|
description: null,
|
||||||
author: null,
|
author: null,
|
||||||
narrarator: null,
|
narrator: null,
|
||||||
series: null,
|
series: null,
|
||||||
volumeNumber: null,
|
volumeNumber: null,
|
||||||
publishYear: null,
|
publishYear: null,
|
||||||
@@ -208,7 +208,7 @@ export default {
|
|||||||
this.details.subtitle = this.book.subtitle
|
this.details.subtitle = this.book.subtitle
|
||||||
this.details.description = this.book.description
|
this.details.description = this.book.description
|
||||||
this.details.author = this.book.author
|
this.details.author = this.book.author
|
||||||
this.details.narrarator = this.book.narrarator
|
this.details.narrator = this.book.narrator
|
||||||
this.details.genres = this.book.genres || []
|
this.details.genres = this.book.genres || []
|
||||||
this.details.series = this.book.series
|
this.details.series = this.book.series
|
||||||
this.details.volumeNumber = this.book.volumeNumber
|
this.details.volumeNumber = this.book.volumeNumber
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
<div class="flex mb-4">
|
<div class="w-full bg-primary px-4 py-2 flex items-center">
|
||||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`">
|
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||||
<ui-btn color="primary">Edit Track Order</ui-btn>
|
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||||
|
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`" class="mr-4">
|
||||||
|
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
<table class="text-sm tracksTable">
|
<table class="text-sm tracksTable">
|
||||||
@@ -18,9 +23,7 @@
|
|||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<p>{{ track.index }}</p>
|
<p>{{ track.index }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="font-book">
|
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
||||||
{{ track.filename }}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono">
|
<td class="font-mono">
|
||||||
{{ $bytesPretty(track.size) }}
|
{{ $bytesPretty(track.size) }}
|
||||||
</td>
|
</td>
|
||||||
@@ -47,7 +50,8 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tracks: null,
|
tracks: null,
|
||||||
audioFiles: null
|
audioFiles: null,
|
||||||
|
showFullPath: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -69,7 +73,10 @@ export default {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...track,
|
...track,
|
||||||
relativePath: trackPath.replace(audiobookPath, '').replace(/%/g, '%25').replace(/#/g, '%23')
|
relativePath: trackPath
|
||||||
|
.replace(audiobookPath + '/', '')
|
||||||
|
.replace(/%/g, '%25')
|
||||||
|
.replace(/#/g, '%23')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<!-- <ui-text-input :value="folder.fullPath" type="text" class="w-full" /> -->
|
<!-- <ui-text-input :value="folder.fullPath" type="text" class="w-full" /> -->
|
||||||
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||||
<ui-editable-text v-model="folder.fullPath" type="text" class="w-full" />
|
<ui-editable-text v-model="folder.fullPath" type="text" class="w-full" />
|
||||||
<span class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
<span v-show="folders.length > 1" class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!folders.length" class="text-sm text-gray-300 px-1 py-2">No folders</p>
|
<p v-if="!folders.length" class="text-sm text-gray-300 px-1 py-2">No folders</p>
|
||||||
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
|
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<div class="absolute bottom-0 left-0 w-full py-4 px-4">
|
<div class="absolute bottom-0 left-0 w-full py-4 px-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="success" @click="submit">{{ library ? 'Update Library' : 'Create Library' }}</ui-btn>
|
<ui-btn v-show="!disableSubmit" color="success" :disabled="disableSubmit" @click="submit">{{ library ? 'Update Library' : 'Create Library' }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,8 +43,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
name: '',
|
name: '',
|
||||||
folders: [],
|
folders: [],
|
||||||
showDirectoryPicker: false,
|
showDirectoryPicker: false
|
||||||
newLibraryName: ''
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -54,6 +53,18 @@ export default {
|
|||||||
},
|
},
|
||||||
folderPaths() {
|
folderPaths() {
|
||||||
return this.folders.map((f) => f.fullPath)
|
return this.folders.map((f) => f.fullPath)
|
||||||
|
},
|
||||||
|
disableSubmit() {
|
||||||
|
if (!this.library) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var newfolderpaths = this.folderPaths.join(',')
|
||||||
|
var origfolderpaths = this.library.folders.map((f) => f.fullPath).join(',')
|
||||||
|
console.log(newfolderpaths)
|
||||||
|
console.log(origfolderpaths)
|
||||||
|
console.log(newfolderpaths === origfolderpaths)
|
||||||
|
|
||||||
|
return newfolderpaths === origfolderpaths && this.name === this.library.name
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -30,11 +30,12 @@
|
|||||||
<p class="text-gray-300">Note: folders already mapped will not be shown</p>
|
<p class="text-gray-300">Note: folders already mapped will not be shown</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute bottom-0 left-0 w-full py-4 px-4">
|
<div class="absolute bottom-0 left-0 w-full py-4 px-8">
|
||||||
<div class="flex items-center">
|
<ui-btn :disabled="!selectedPath" color="primary" class="w-full mt-2" @click="selectFolder">Select Folder Path</ui-btn>
|
||||||
|
<!-- <div class="flex items-center">
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn color="success" @click="selectFolder">Select</ui-btn>
|
<ui-btn color="success" @click="selectFolder">Select</ui-btn>
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -2,11 +2,14 @@
|
|||||||
<div class="w-full my-2">
|
<div class="w-full my-2">
|
||||||
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||||
<p class="pr-4">Other Files</p>
|
<p class="pr-4">Other Files</p>
|
||||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ files.length }}</span>
|
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||||
|
<span class="text-sm font-mono">{{ files.length }}</span>
|
||||||
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<!-- <nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
<!-- <nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
||||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||||
</nuxt-link> -->
|
</nuxt-link> -->
|
||||||
|
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
||||||
<span class="material-icons text-4xl">expand_more</span>
|
<span class="material-icons text-4xl">expand_more</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -15,17 +18,21 @@
|
|||||||
<div class="w-full" v-show="showFiles">
|
<div class="w-full" v-show="showFiles">
|
||||||
<table class="text-sm tracksTable">
|
<table class="text-sm tracksTable">
|
||||||
<tr class="font-book">
|
<tr class="font-book">
|
||||||
<th class="text-left">Path</th>
|
<th class="text-left px-4">Path</th>
|
||||||
<th class="text-left">Filetype</th>
|
<th class="text-left px-4 w-24">Filetype</th>
|
||||||
|
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="file in files">
|
<template v-for="file in otherFilesCleaned">
|
||||||
<tr :key="file.path">
|
<tr :key="file.path">
|
||||||
<td class="font-book pl-2">
|
<td class="font-book pl-2">
|
||||||
{{ file.path }}
|
{{ showFullPath ? file.fullPath : file.path }}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs">
|
<td class="text-xs">
|
||||||
<p>{{ file.filetype }}</p>
|
<p>{{ file.filetype }}</p>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</table>
|
</table>
|
||||||
@@ -41,14 +48,45 @@ export default {
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
audiobookId: String
|
audiobook: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showFiles: false
|
showFiles: false,
|
||||||
|
showFullPath: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {},
|
|
||||||
methods: {
|
methods: {
|
||||||
clickBar() {
|
clickBar() {
|
||||||
this.showFiles = !this.showFiles
|
this.showFiles = !this.showFiles
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
<div class="w-full my-2">
|
<div class="w-full my-2">
|
||||||
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||||
<p class="pr-4">Audio Tracks</p>
|
<p class="pr-4">Audio Tracks</p>
|
||||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span>
|
<div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||||
|
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
|
||||||
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
|
||||||
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
<ui-btn small color="primary">Manage Tracks</ui-btn>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -15,18 +19,18 @@
|
|||||||
<div class="w-full" v-show="showTracks">
|
<div class="w-full" v-show="showTracks">
|
||||||
<table class="text-sm tracksTable">
|
<table class="text-sm tracksTable">
|
||||||
<tr class="font-book">
|
<tr class="font-book">
|
||||||
<th>#</th>
|
<th class="w-10">#</th>
|
||||||
<th class="text-left">Filename</th>
|
<th class="text-left">Filename</th>
|
||||||
<th class="text-left">Size</th>
|
<th class="text-left w-20">Size</th>
|
||||||
<th class="text-left">Duration</th>
|
<th class="text-left w-20">Duration</th>
|
||||||
<th v-if="userCanDownload" class="text-center">Download</th>
|
<th v-if="userCanDownload" class="text-center w-20">Download</th>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="track in tracksCleaned">
|
<template v-for="track in tracksCleaned">
|
||||||
<tr :key="track.index">
|
<tr :key="track.index">
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<p>{{ track.index }}</p>
|
<p>{{ track.index }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="font-book">{{ track.filename }}</td>
|
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
|
||||||
<td class="font-mono">
|
<td class="font-mono">
|
||||||
{{ $bytesPretty(track.size) }}
|
{{ $bytesPretty(track.size) }}
|
||||||
</td>
|
</td>
|
||||||
@@ -58,7 +62,8 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showTracks: false
|
showTracks: false,
|
||||||
|
showFullPath: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -75,7 +80,10 @@ export default {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...track,
|
...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>
|
||||||
@@ -56,6 +56,9 @@ export default {
|
|||||||
if (this.paddingX !== undefined) {
|
if (this.paddingX !== undefined) {
|
||||||
list.push(`px-${this.paddingX}`)
|
list.push(`px-${this.paddingX}`)
|
||||||
}
|
}
|
||||||
|
if (this.disabled) {
|
||||||
|
list.push('cursor-not-allowed')
|
||||||
|
}
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative w-full" v-click-outside="clickOutside">
|
<div class="relative w-full" v-click-outside="clickOutside">
|
||||||
<p class="text-sm text-opacity-75 mb-1">{{ label }}</p>
|
<p class="text-sm text-opacity-75 mb-1">{{ label }}</p>
|
||||||
<button type="button" :disabled="disabled" class="relative h-10 w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
<button type="button" :disabled="disabled" class="relative h-10 w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<span class="block truncate">{{ selectedText }}</span>
|
<span class="block truncate">{{ selectedText }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<input ref="fileInput" id="hidden-input" type="file" :accept="inputAccept" class="hidden" @change="inputChanged" />
|
<input ref="fileInput" id="hidden-input" type="file" :accept="accept" class="hidden" @change="inputChanged" />
|
||||||
<ui-btn @click="clickUpload" color="primary" type="text">Upload Cover</ui-btn>
|
<ui-btn @click="clickUpload" color="primary" type="text"><slot /></ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
data() {
|
props: {
|
||||||
return {
|
accept: {
|
||||||
inputAccept: '.png, .jpg, .jpeg, .webp'
|
type: String,
|
||||||
|
default: '.png, .jpg, .jpeg, .webp'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
computed: {},
|
computed: {},
|
||||||
methods: {
|
methods: {
|
||||||
reset() {
|
reset() {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
<div class="relative">
|
||||||
|
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||||
|
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||||
|
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -20,7 +25,10 @@ export default {
|
|||||||
paddingX: {
|
paddingX: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 3
|
default: 3
|
||||||
}
|
},
|
||||||
|
noSpinner: Boolean,
|
||||||
|
textCenter: Boolean,
|
||||||
|
clearable: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -38,10 +46,15 @@ export default {
|
|||||||
var _list = []
|
var _list = []
|
||||||
_list.push(`px-${this.paddingX}`)
|
_list.push(`px-${this.paddingX}`)
|
||||||
_list.push(`py-${this.paddingY}`)
|
_list.push(`py-${this.paddingY}`)
|
||||||
|
if (this.noSpinner) _list.push('no-spinner')
|
||||||
|
if (this.textCenter) _list.push('text-center')
|
||||||
return _list.join(' ')
|
return _list.join(' ')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clear() {
|
||||||
|
this.inputValue = ''
|
||||||
|
},
|
||||||
focused() {
|
focused() {
|
||||||
this.$emit('focus')
|
this.$emit('focus')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export default {
|
|||||||
if (!this.$refs.box) return // Ensure element is not destroyed
|
if (!this.$refs.box) return // Ensure element is not destroyed
|
||||||
try {
|
try {
|
||||||
document.body.appendChild(this.tooltip)
|
document.body.appendChild(this.tooltip)
|
||||||
|
this.setTooltipPosition(this.tooltip)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export default {
|
|||||||
this.scanStart(libraryScan)
|
this.scanStart(libraryScan)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (payload.backups && payload.backups.length) {
|
||||||
|
this.$store.commit('setBackups', payload.backups)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
streamOpen(stream) {
|
streamOpen(stream) {
|
||||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
|
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
|
||||||
@@ -220,6 +223,10 @@ export default {
|
|||||||
logEvtReceived(payload) {
|
logEvtReceived(payload) {
|
||||||
this.$store.commit('logs/logEvt', payload)
|
this.$store.commit('logs/logEvt', payload)
|
||||||
},
|
},
|
||||||
|
backupApplied() {
|
||||||
|
// Force refresh
|
||||||
|
location.reload()
|
||||||
|
},
|
||||||
initializeSocket() {
|
initializeSocket() {
|
||||||
this.socket = this.$nuxtSocket({
|
this.socket = this.$nuxtSocket({
|
||||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||||
@@ -274,6 +281,8 @@ export default {
|
|||||||
this.socket.on('download_expired', this.downloadExpired)
|
this.socket.on('download_expired', this.downloadExpired)
|
||||||
|
|
||||||
this.socket.on('log', this.logEvtReceived)
|
this.socket.on('log', this.logEvtReceived)
|
||||||
|
|
||||||
|
this.socket.on('backup_applied', this.backupApplied)
|
||||||
},
|
},
|
||||||
showUpdateToast(versionData) {
|
showUpdateToast(versionData) {
|
||||||
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
||||||
@@ -304,6 +313,12 @@ export default {
|
|||||||
this.initializeSocket()
|
this.initializeSocket()
|
||||||
this.$store.dispatch('libraries/load')
|
this.$store.dispatch('libraries/load')
|
||||||
|
|
||||||
|
// If experimental features set in local storage
|
||||||
|
var experimentalFeaturesSaved = localStorage.getItem('experimental')
|
||||||
|
if (experimentalFeaturesSaved === '1') {
|
||||||
|
this.$store.commit('setExperimentalFeatures', true)
|
||||||
|
}
|
||||||
|
|
||||||
this.$store
|
this.$store
|
||||||
.dispatch('checkForUpdate')
|
.dispatch('checkForUpdate')
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
link: [
|
link: [
|
||||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
|
||||||
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Fira+Mono&family=Ubuntu+Mono&family=Open+Sans:wght@400;600&family=Gentium+Book+Basic' },
|
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Fira+Mono&family=Ubuntu+Mono&family=Gentium+Book+Basic&&family=Source+Sans+Pro:wght@300;400;600' },
|
||||||
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
|
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -77,6 +77,7 @@ module.exports = {
|
|||||||
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
|
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
|
||||||
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||||
'/lib/': { 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' : '/' },
|
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||||
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
|
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "1.4.0",
|
"version": "1.4.2",
|
||||||
"description": "Audiobook manager and player",
|
"description": "Audiobook manager and player",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="flex items-center py-2">
|
<div class="flex items-center py-2">
|
||||||
<p v-if="isRoot" class="text-error py-2 text-xs">* Root user is the only user that can have an empty password</p>
|
<p v-if="isRoot" class="text-error py-2 text-xs">* Root user is the only user that can have an empty password</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn type="submit" :loading="changingPassword" color="success">Submit</ui-btn>
|
<ui-btn v-show="password && newPassword && confirmPassword" type="submit" :loading="changingPassword" color="success">Submit</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,22 +10,79 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-10">
|
<div class="flex-grow px-10">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="mb-2">
|
<div class="mb-4">
|
||||||
<h1 class="text-2xl font-book leading-7">
|
<div class="flex items-end">
|
||||||
{{ title }}<span v-if="isDeveloperMode"> ({{ audiobook.ino }})</span>
|
<h1 class="text-3xl font-sans">
|
||||||
</h1>
|
{{ title }}<span v-if="isDeveloperMode"> ({{ audiobook.ino }})</span>
|
||||||
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
|
</h1>
|
||||||
<div class="w-min">
|
<p v-if="subtitle" class="ml-4 text-gray-400 text-2xl">{{ subtitle }}</p>
|
||||||
<ui-tooltip :text="authorTooltipText" direction="bottom">
|
|
||||||
<span class="text-sm text-gray-100 leading-7 whitespace-nowrap">by {{ author }}</span>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="mb-2 mt-0.5 text-gray-100 text-xl">
|
||||||
|
by <nuxt-link v-if="author" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}</nuxt-link
|
||||||
|
><span v-else>Unknown</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 v-if="series" class="font-sans text-gray-300 text-lg leading-7 mb-4">{{ seriesText }}</h3>
|
||||||
|
|
||||||
|
<!-- <div class="w-min">
|
||||||
|
<ui-tooltip :text="authorTooltipText" direction="bottom">
|
||||||
|
<span class="text-base text-gray-100 leading-8 whitespace-nowrap"><span class="text-white text-opacity-60">By:</span> {{ author }}</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div> -->
|
||||||
|
<div v-if="narrator" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Narrated By</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="publishYear" class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Publish Year</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ publishYear }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex py-0.5" v-if="genres.length">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Genres</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<template v-for="(genre, index) in genres">
|
||||||
|
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
|
||||||
|
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Duration</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ durationPretty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex py-0.5">
|
||||||
|
<div class="w-32">
|
||||||
|
<span class="text-white text-opacity-60 uppercase text-sm">Size</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ sizePretty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!--
|
||||||
|
<p v-if="narrator" class="text-base">
|
||||||
|
<span class="text-white text-opacity-60">By:</span> <nuxt-link :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}</nuxt-link>
|
||||||
|
</p> -->
|
||||||
|
<!-- <p v-if="narrator" class="text-base"><span class="text-white text-opacity-60">Narrated by:</span> {{ narrator }}</p>
|
||||||
|
<p v-if="publishYear" class="text-base"><span class="text-white text-opacity-60">Publish year:</span> {{ publishYear }}</p>
|
||||||
|
<p v-if="genres.length" class="text-base"><span class="text-white text-opacity-60">Genres:</span> {{ genres.join(', ') }}</p> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-300 text-sm my-1">
|
|
||||||
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
|
|
||||||
</p>
|
|
||||||
<div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max" :class="resettingProgress ? 'opacity-25' : ''">
|
<div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max" :class="resettingProgress ? 'opacity-25' : ''">
|
||||||
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||||
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||||
@@ -62,12 +119,10 @@
|
|||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn>
|
<ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn>
|
||||||
|
|
||||||
<div class="flex-grow" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="my-4">
|
<div class="my-4 max-w-2xl">
|
||||||
<p class="text-sm text-gray-100">{{ description }}</p>
|
<p class="text-base text-gray-100">{{ description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
|
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
|
||||||
@@ -90,7 +145,7 @@
|
|||||||
|
|
||||||
<tables-audio-files-table v-if="otherAudioFiles.length" :audiobook-id="audiobook.id" :files="otherAudioFiles" class="mt-6" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,12 +236,30 @@ export default {
|
|||||||
invalidParts() {
|
invalidParts() {
|
||||||
return this.audiobook.invalidParts || []
|
return this.audiobook.invalidParts || []
|
||||||
},
|
},
|
||||||
|
libraryId() {
|
||||||
|
return this.audiobook.libraryId
|
||||||
|
},
|
||||||
|
folderId() {
|
||||||
|
return this.audiobook.folderId
|
||||||
|
},
|
||||||
audiobookId() {
|
audiobookId() {
|
||||||
return this.audiobook.id
|
return this.audiobook.id
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
return this.book.title || 'No Title'
|
return this.book.title || 'No Title'
|
||||||
},
|
},
|
||||||
|
publishYear() {
|
||||||
|
return this.book.publishYear
|
||||||
|
},
|
||||||
|
narrator() {
|
||||||
|
return this.book.narrator
|
||||||
|
},
|
||||||
|
subtitle() {
|
||||||
|
return this.book.subtitle
|
||||||
|
},
|
||||||
|
genres() {
|
||||||
|
return this.book.genres || []
|
||||||
|
},
|
||||||
author() {
|
author() {
|
||||||
return this.book.author || 'Unknown'
|
return this.book.author || 'Unknown'
|
||||||
},
|
},
|
||||||
@@ -243,9 +316,16 @@ export default {
|
|||||||
epubEbook() {
|
epubEbook() {
|
||||||
return this.audiobook.ebooks.find((eb) => eb.ext === '.epub')
|
return this.audiobook.ebooks.find((eb) => eb.ext === '.epub')
|
||||||
},
|
},
|
||||||
epubUrl() {
|
epubPath() {
|
||||||
return this.epubEbook ? this.epubEbook.path : null
|
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() {
|
description() {
|
||||||
return this.book.description || ''
|
return this.book.description || ''
|
||||||
},
|
},
|
||||||
@@ -338,6 +418,7 @@ export default {
|
|||||||
this.$axios
|
this.$axios
|
||||||
.$get(`/api/audiobook/${this.audiobookId}`)
|
.$get(`/api/audiobook/${this.audiobookId}`)
|
||||||
.then((audiobook) => {
|
.then((audiobook) => {
|
||||||
|
console.log('Updated audiobook', audiobook)
|
||||||
this.audiobook = audiobook
|
this.audiobook = audiobook
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex mt-2 -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="w-1/2 px-1">
|
||||||
<ui-text-input-with-label v-model="audiobook.book.narrarator" label="Narrarator" />
|
<ui-text-input-with-label v-model="audiobook.book.narrator" label="Narrator" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,6 +94,9 @@ export default {
|
|||||||
},
|
},
|
||||||
seriesItems() {
|
seriesItems() {
|
||||||
return [...this.series, ...this.newSeriesItems]
|
return [...this.series, ...this.newSeriesItems]
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -130,7 +133,7 @@ export default {
|
|||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
if (data.updates) {
|
if (data.updates) {
|
||||||
this.$toast.success(`Successfully updated ${data.updates} audiobooks`)
|
this.$toast.success(`Successfully updated ${data.updates} audiobooks`)
|
||||||
this.$router.replace('/library')
|
this.$router.replace(`/library/${this.currentLibraryId}`)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.warning('No updates were necessary')
|
this.$toast.warning('No updates were necessary')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,51 +33,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div class="py-4">
|
|
||||||
<p class="text-2xl">Scanner</p>
|
|
||||||
<div class="flex items-start py-2">
|
|
||||||
<div class="py-2">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
|
|
||||||
<ui-tooltip :text="parseSubtitleTooltip">
|
|
||||||
<p class="pl-4 text-lg">Parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<div class="w-40 flex flex-col">
|
|
||||||
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
|
|
||||||
|
|
||||||
<div class="w-full mb-4">
|
|
||||||
<ui-tooltip direction="bottom" text="(Warning: Long running task!) Attempts to lookup and match a cover with all audiobooks that don't have one." class="w-full">
|
|
||||||
<ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||||
|
|
||||||
<div class="py-4 mb-4">
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||||
<p class="text-2xl">Metadata</p>
|
<div class="flex items-center mb-2">
|
||||||
<div class="flex items-start py-2">
|
<h1 class="text-xl">Backups</h1>
|
||||||
<div class="py-2">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
|
|
||||||
<ui-tooltip :text="coverDestinationTooltip">
|
|
||||||
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<div class="w-40 flex flex-col">
|
|
||||||
<ui-tooltip :text="saveMetadataTooltip" direction="bottom" class="w-full">
|
|
||||||
<ui-btn color="primary" small class="w-full" @click="saveMetadataFiles">Save Metadata</ui-btn>
|
|
||||||
</ui-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</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="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||||
|
|
||||||
@@ -108,7 +87,7 @@
|
|||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-toggle-switch v-model="showExperimentalFeatures" @input="toggleShowExperimentalFeatures" />
|
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||||
<ui-tooltip :text="experimentalFeaturesTooltip">
|
<ui-tooltip :text="experimentalFeaturesTooltip">
|
||||||
<p class="pl-4 text-lg">Experimental Features <span class="material-icons icon-text">info_outlined</span></p>
|
<p class="pl-4 text-lg">Experimental Features <span class="material-icons icon-text">info_outlined</span></p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -138,14 +117,16 @@ export default {
|
|||||||
storeCoversInAudiobookDir: false,
|
storeCoversInAudiobookDir: false,
|
||||||
isResettingAudiobooks: false,
|
isResettingAudiobooks: false,
|
||||||
newServerSettings: {},
|
newServerSettings: {},
|
||||||
updatingServerSettings: false
|
updatingServerSettings: false,
|
||||||
|
dailyBackups: true,
|
||||||
|
backupsToKeep: 2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
serverSettings(newVal, oldVal) {
|
serverSettings(newVal, oldVal) {
|
||||||
if (newVal && !oldVal) {
|
if (newVal && !oldVal) {
|
||||||
this.newServerSettings = { ...this.serverSettings }
|
this.newServerSettings = { ...this.serverSettings }
|
||||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
this.initServerSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -165,6 +146,12 @@ export default {
|
|||||||
experimentalFeaturesTooltip() {
|
experimentalFeaturesTooltip() {
|
||||||
return 'Features in development that could use your feedback and help testing.'
|
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() {
|
serverSettings() {
|
||||||
return this.$store.state.serverSettings
|
return this.$store.state.serverSettings
|
||||||
},
|
},
|
||||||
@@ -177,13 +164,26 @@ export default {
|
|||||||
isScanningCovers() {
|
isScanningCovers() {
|
||||||
return this.$store.state.isScanningCovers
|
return this.$store.state.isScanningCovers
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
showExperimentalFeatures: {
|
||||||
return this.$store.state.showExperimentalFeatures
|
get() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$store.commit('setExperimentalFeatures', val)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
toggleShowExperimentalFeatures() {
|
updateBackupsSettings() {
|
||||||
this.$store.commit('setExperimentalFeatures', !this.showExperimentalFeatures)
|
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) {
|
updateScannerFindCovers(val) {
|
||||||
this.updateServerSettings({
|
this.updateServerSettings({
|
||||||
@@ -222,9 +222,6 @@ export default {
|
|||||||
scan() {
|
scan() {
|
||||||
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
||||||
},
|
},
|
||||||
scanCovers() {
|
|
||||||
this.$root.socket.emit('scan_covers')
|
|
||||||
},
|
|
||||||
saveMetadataComplete(result) {
|
saveMetadataComplete(result) {
|
||||||
this.savingMetadata = false
|
this.savingMetadata = false
|
||||||
if (!result) return
|
if (!result) return
|
||||||
@@ -254,7 +251,13 @@ export default {
|
|||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
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.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||||
|
this.backupsToKeep = this.newServerSettings.backupsToKeep || 2
|
||||||
|
this.dailyBackups = !!this.newServerSettings.backupSchedule
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -262,34 +265,3 @@ 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>
|
|
||||||
@@ -42,14 +42,19 @@ export const getters = {
|
|||||||
var settings = rootState.user.settings || {}
|
var settings = rootState.user.settings || {}
|
||||||
var filterBy = settings.filterBy || ''
|
var filterBy = settings.filterBy || ''
|
||||||
|
|
||||||
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress']
|
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators']
|
||||||
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
|
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
|
||||||
if (group) {
|
if (group) {
|
||||||
var filter = decode(filterBy.replace(`${group}.`, ''))
|
var filterVal = filterBy.replace(`${group}.`, '')
|
||||||
|
var filter = decode(filterVal)
|
||||||
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
|
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
|
||||||
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
|
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
|
||||||
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
|
else if (group === 'series') {
|
||||||
|
if (filter === 'No Series') filtered = filtered.filter(ab => ab.book && !ab.book.series)
|
||||||
|
else filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
|
||||||
|
}
|
||||||
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
|
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
|
||||||
|
else if (group === 'narrators') filtered = filtered.filter(ab => ab.book && ab.book.narrator === filter)
|
||||||
else if (group === 'progress') {
|
else if (group === 'progress') {
|
||||||
filtered = filtered.filter(ab => {
|
filtered = filtered.filter(ab => {
|
||||||
var userAudiobook = rootGetters['user/getUserAudiobook'](ab.id)
|
var userAudiobook = rootGetters['user/getUserAudiobook'](ab.id)
|
||||||
@@ -62,7 +67,7 @@ export const getters = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (state.keywordFilter) {
|
if (state.keywordFilter) {
|
||||||
const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrarator']
|
const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrator']
|
||||||
const keyworkFilter = state.keywordFilter.toLowerCase()
|
const keyworkFilter = state.keywordFilter.toLowerCase()
|
||||||
return filtered.filter(ab => {
|
return filtered.filter(ab => {
|
||||||
if (!ab.book) return false
|
if (!ab.book) return false
|
||||||
@@ -76,6 +81,7 @@ export const getters = {
|
|||||||
var direction = settings.orderDesc ? 'desc' : 'asc'
|
var direction = settings.orderDesc ? 'desc' : 'asc'
|
||||||
|
|
||||||
var filtered = getters.getFiltered()
|
var filtered = getters.getFiltered()
|
||||||
|
|
||||||
var orderByNumber = settings.orderBy === 'book.volumeNumber'
|
var orderByNumber = settings.orderBy === 'book.volumeNumber'
|
||||||
return sort(filtered)[direction]((ab) => {
|
return sort(filtered)[direction]((ab) => {
|
||||||
// Supports dot notation strings i.e. "book.title"
|
// Supports dot notation strings i.e. "book.title"
|
||||||
@@ -118,6 +124,10 @@ export const getters = {
|
|||||||
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
|
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
|
||||||
return [...new Set(_authors)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
return [...new Set(_authors)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||||
},
|
},
|
||||||
|
getUniqueNarrators: (state) => {
|
||||||
|
var _narrators = state.audiobooks.filter(ab => !!(ab.book && ab.book.narrator)).map(ab => ab.book.narrator)
|
||||||
|
return [...new Set(_narrators)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||||
|
},
|
||||||
getGenresUsed: (state) => {
|
getGenresUsed: (state) => {
|
||||||
var _genres = []
|
var _genres = []
|
||||||
state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres))
|
state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres))
|
||||||
@@ -246,8 +256,8 @@ export const mutations = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
addUpdate(state, audiobook) {
|
addUpdate(state, audiobook) {
|
||||||
if (audiobook.libraryId !== state.loadedLibraryId) {
|
if (state.loadedLibraryId && audiobook.libraryId !== state.loadedLibraryId) {
|
||||||
console.warn('Invalid library', audiobook)
|
console.warn('Invalid library', audiobook, 'loaded library', state.loadedLibraryId, '"')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-23
@@ -9,16 +9,13 @@ export const state = () => ({
|
|||||||
showEditModal: false,
|
showEditModal: false,
|
||||||
selectedAudiobook: null,
|
selectedAudiobook: null,
|
||||||
playOnLoad: false,
|
playOnLoad: false,
|
||||||
// isScanning: false,
|
|
||||||
// isScanningCovers: false,
|
|
||||||
// scanProgress: null,
|
|
||||||
// coverScanProgress: null,
|
|
||||||
developerMode: false,
|
developerMode: false,
|
||||||
selectedAudiobooks: [],
|
selectedAudiobooks: [],
|
||||||
processingBatch: false,
|
processingBatch: false,
|
||||||
previousPath: '/',
|
previousPath: '/',
|
||||||
routeHistory: [],
|
routeHistory: [],
|
||||||
showExperimentalFeatures: false
|
showExperimentalFeatures: false,
|
||||||
|
backups: []
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
@@ -114,36 +111,18 @@ export const mutations = {
|
|||||||
setShowEditModal(state, val) {
|
setShowEditModal(state, val) {
|
||||||
state.showEditModal = val
|
state.showEditModal = val
|
||||||
},
|
},
|
||||||
// setIsScanning(state, isScanning) {
|
|
||||||
// state.isScanning = isScanning
|
|
||||||
// },
|
|
||||||
// setScanProgress(state, scanProgress) {
|
|
||||||
// if (scanProgress && scanProgress.progress > 0) state.isScanning = true
|
|
||||||
// state.scanProgress = scanProgress
|
|
||||||
// },
|
|
||||||
// setIsScanningCovers(state, isScanningCovers) {
|
|
||||||
// state.isScanningCovers = isScanningCovers
|
|
||||||
// },
|
|
||||||
// setCoverScanProgress(state, coverScanProgress) {
|
|
||||||
// if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
|
|
||||||
// state.coverScanProgress = coverScanProgress
|
|
||||||
// },
|
|
||||||
setDeveloperMode(state, val) {
|
setDeveloperMode(state, val) {
|
||||||
state.developerMode = val
|
state.developerMode = val
|
||||||
},
|
},
|
||||||
setSelectedAudiobooks(state, audiobooks) {
|
setSelectedAudiobooks(state, audiobooks) {
|
||||||
Vue.set(state, 'selectedAudiobooks', audiobooks)
|
Vue.set(state, 'selectedAudiobooks', audiobooks)
|
||||||
// state.selectedAudiobooks = audiobooks
|
|
||||||
},
|
},
|
||||||
toggleAudiobookSelected(state, audiobookId) {
|
toggleAudiobookSelected(state, audiobookId) {
|
||||||
if (state.selectedAudiobooks.includes(audiobookId)) {
|
if (state.selectedAudiobooks.includes(audiobookId)) {
|
||||||
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
|
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
|
||||||
} else {
|
} else {
|
||||||
var newSel = state.selectedAudiobooks.concat([audiobookId])
|
var newSel = state.selectedAudiobooks.concat([audiobookId])
|
||||||
// state.selectedAudiobooks = newSel
|
|
||||||
console.log('Setting toggle on sel', newSel)
|
|
||||||
Vue.set(state, 'selectedAudiobooks', newSel)
|
Vue.set(state, 'selectedAudiobooks', newSel)
|
||||||
// state.selectedAudiobooks.push(audiobookId)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setProcessingBatch(state, val) {
|
setProcessingBatch(state, val) {
|
||||||
@@ -151,5 +130,9 @@ export const mutations = {
|
|||||||
},
|
},
|
||||||
setExperimentalFeatures(state, val) {
|
setExperimentalFeatures(state, val) {
|
||||||
state.showExperimentalFeatures = val
|
state.showExperimentalFeatures = val
|
||||||
|
localStorage.setItem('experimental', val ? 1 : 0)
|
||||||
|
},
|
||||||
|
setBackups(state, val) {
|
||||||
|
state.backups = val.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,9 +22,6 @@ export const getters = {
|
|||||||
getUserSetting: (state) => (key) => {
|
getUserSetting: (state) => (key) => {
|
||||||
return state.settings ? state.settings[key] || null : null
|
return state.settings ? state.settings[key] || null : null
|
||||||
},
|
},
|
||||||
getFilterOrderKey: (state) => {
|
|
||||||
return Object.values(state.settings).join('-')
|
|
||||||
},
|
|
||||||
getUserCanUpdate: (state) => {
|
getUserCanUpdate: (state) => {
|
||||||
return state.user && state.user.permissions ? !!state.user.permissions.update : false
|
return state.user && state.user.permissions ? !!state.user.permissions.update : false
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ module.exports = {
|
|||||||
none: 'none'
|
none: 'none'
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Open Sans', ...defaultTheme.fontFamily.sans],
|
sans: ['Source Sans Pro', ...defaultTheme.fontFamily.sans],
|
||||||
mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
|
mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
|
||||||
book: ['Gentium Book Basic', 'serif']
|
book: ['Gentium Book Basic', 'serif']
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -9,7 +9,7 @@
|
|||||||
<Privileged>false</Privileged>
|
<Privileged>false</Privileged>
|
||||||
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
|
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
|
||||||
<Project>https://github.com/advplyr/audiobookshelf</Project>
|
<Project>https://github.com/advplyr/audiobookshelf</Project>
|
||||||
<Overview>**(Android app in beta, try it out)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free & open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>
|
<Overview>**(Android app is live)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free & open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>
|
||||||
<Category>MediaApp:Books MediaServer:Books</Category>
|
<Category>MediaApp:Books MediaServer:Books</Category>
|
||||||
<WebUI>http://[IP]:[PORT:80]</WebUI>
|
<WebUI>http://[IP]:[PORT:80]</WebUI>
|
||||||
<TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL>
|
<TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL>
|
||||||
@@ -48,7 +48,18 @@
|
|||||||
<Mode>rw</Mode>
|
<Mode>rw</Mode>
|
||||||
</Volume>
|
</Volume>
|
||||||
</Data>
|
</Data>
|
||||||
<Environment/>
|
<Environment>
|
||||||
|
<Variable>
|
||||||
|
<Value>99</Value>
|
||||||
|
<Name>AUDIOBOOKSHELF_UID</Name>
|
||||||
|
<Mode/>
|
||||||
|
</Variable>
|
||||||
|
<Variable>
|
||||||
|
<Value>100</Value>
|
||||||
|
<Name>AUDIOBOOKSHELF_GID</Name>
|
||||||
|
<Mode/>
|
||||||
|
</Variable>
|
||||||
|
</Environment>
|
||||||
<Labels/>
|
<Labels/>
|
||||||
<Config Name="Audiobooks" Target="/audiobooks" Default="" Mode="rw" Description="Container Path: /audiobooks" Type="Path" Display="always" Required="true" Mask="false" />
|
<Config Name="Audiobooks" Target="/audiobooks" Default="" Mode="rw" Description="Container Path: /audiobooks" Type="Path" Display="always" Required="true" Mask="false" />
|
||||||
<Config Name="Config" Target="/config" Default="/mnt/user/appdata/audiobookshelf/config/" Mode="rw" Description="Container Path: /config" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/audiobookshelf/config/</Config>
|
<Config Name="Config" Target="/config" Default="/mnt/user/appdata/audiobookshelf/config/" Mode="rw" Description="Container Path: /config" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/audiobookshelf/config/</Config>
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ const PORT = process.env.PORT || 80
|
|||||||
const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
|
const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
|
||||||
const AUDIOBOOK_PATH = process.env.AUDIOBOOK_PATH || '/audiobooks'
|
const AUDIOBOOK_PATH = process.env.AUDIOBOOK_PATH || '/audiobooks'
|
||||||
const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
|
const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
|
||||||
|
const UID = process.env.AUDIOBOOKSHELF_UID || 99
|
||||||
|
const GID = process.env.AUDIOBOOKSHELF_GID || 100
|
||||||
|
|
||||||
console.log('Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
console.log('Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
||||||
|
|
||||||
const Server = new server(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
const Server = new server(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
||||||
Server.start()
|
Server.start()
|
||||||
|
|||||||
Generated
+32
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "1.3.4",
|
"version": "1.4.1",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -457,6 +457,11 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"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": {
|
"debounce": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
|
||||||
@@ -1168,6 +1173,19 @@
|
|||||||
"minimist": "^1.2.5"
|
"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": {
|
"ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
@@ -1220,6 +1238,14 @@
|
|||||||
"proper-lockfile": "^4.1.2"
|
"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": {
|
"node-dir": {
|
||||||
"version": "0.1.17",
|
"version": "0.1.17",
|
||||||
"resolved": "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/node-dir/-/node-dir-0.1.17.tgz",
|
||||||
@@ -1246,6 +1272,11 @@
|
|||||||
"tar": "^4"
|
"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": {
|
"nopt": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz",
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "1.4.0",
|
"version": "1.4.2",
|
||||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"command-line-args": "^5.2.0",
|
"command-line-args": "^5.2.0",
|
||||||
"cookie-parser": "^1.4.5",
|
"cookie-parser": "^1.4.5",
|
||||||
|
"date-and-time": "^2.0.1",
|
||||||
"epub": "^1.2.1",
|
"epub": "^1.2.1",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"express-fileupload": "^1.2.1",
|
"express-fileupload": "^1.2.1",
|
||||||
@@ -38,7 +39,9 @@
|
|||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^8.5.1",
|
||||||
"libgen": "^2.1.0",
|
"libgen": "^2.1.0",
|
||||||
"njodb": "^0.4.20",
|
"njodb": "^0.4.20",
|
||||||
|
"node-cron": "^3.0.0",
|
||||||
"node-dir": "^0.1.17",
|
"node-dir": "^0.1.17",
|
||||||
|
"node-stream-zip": "^1.15.0",
|
||||||
"podcast": "^1.3.0",
|
"podcast": "^1.3.0",
|
||||||
"read-chunk": "^3.1.0",
|
"read-chunk": "^3.1.0",
|
||||||
"socket.io": "^4.1.3",
|
"socket.io": "^4.1.3",
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ const PORT = options.port || process.env.PORT || 3333
|
|||||||
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
|
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
|
||||||
const AUDIOBOOK_PATH = inputAudiobook || process.env.AUDIOBOOK_PATH || Path.resolve('audiobooks')
|
const AUDIOBOOK_PATH = inputAudiobook || process.env.AUDIOBOOK_PATH || Path.resolve('audiobooks')
|
||||||
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
|
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
|
||||||
|
const UID = 99
|
||||||
|
const GID = 100
|
||||||
|
|
||||||
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
||||||
|
|
||||||
const Server = new server(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
const Server = new server(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
||||||
Server.start()
|
Server.start()
|
||||||
|
|||||||
+30
-1
@@ -7,7 +7,7 @@ const { isObject } = require('./utils/index')
|
|||||||
const Library = require('./objects/Library')
|
const Library = require('./objects/Library')
|
||||||
|
|
||||||
class ApiController {
|
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.db = db
|
||||||
this.scanner = scanner
|
this.scanner = scanner
|
||||||
this.auth = auth
|
this.auth = auth
|
||||||
@@ -15,6 +15,7 @@ class ApiController {
|
|||||||
this.rssFeeds = rssFeeds
|
this.rssFeeds = rssFeeds
|
||||||
this.downloadManager = downloadManager
|
this.downloadManager = downloadManager
|
||||||
this.coverController = coverController
|
this.coverController = coverController
|
||||||
|
this.backupManager = backupManager
|
||||||
this.watcher = watcher
|
this.watcher = watcher
|
||||||
this.emitter = emitter
|
this.emitter = emitter
|
||||||
this.clientEmitter = clientEmitter
|
this.clientEmitter = clientEmitter
|
||||||
@@ -61,6 +62,9 @@ class ApiController {
|
|||||||
|
|
||||||
this.router.patch('/serverSettings', this.updateServerSettings.bind(this))
|
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.post('/authorize', this.authorize.bind(this))
|
||||||
|
|
||||||
this.router.get('/genres', this.getGenres.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) {
|
async download(req, res) {
|
||||||
if (!req.user.canDownload) {
|
if (!req.user.canDownload) {
|
||||||
Logger.error('User attempting to download without permission', req.user)
|
Logger.error('User attempting to download without permission', req.user)
|
||||||
|
|||||||
@@ -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
|
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() {
|
async init() {
|
||||||
await this.load()
|
await this.load()
|
||||||
|
|
||||||
|
|||||||
+46
-46
@@ -724,55 +724,55 @@ class Scanner {
|
|||||||
return libraryScanResults
|
return libraryScanResults
|
||||||
}
|
}
|
||||||
|
|
||||||
async scanCovers() {
|
// async scanCovers() {
|
||||||
var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
|
// var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
|
||||||
var found = 0
|
// var found = 0
|
||||||
var notFound = 0
|
// var notFound = 0
|
||||||
var failed = 0
|
// var failed = 0
|
||||||
|
|
||||||
for (let i = 0; i < audiobooksNeedingCover.length; i++) {
|
// for (let i = 0; i < audiobooksNeedingCover.length; i++) {
|
||||||
var audiobook = audiobooksNeedingCover[i]
|
// var audiobook = audiobooksNeedingCover[i]
|
||||||
var options = {
|
// var options = {
|
||||||
titleDistance: 2,
|
// titleDistance: 2,
|
||||||
authorDistance: 2
|
// authorDistance: 2
|
||||||
}
|
// }
|
||||||
var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
|
// var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
|
||||||
if (results.length) {
|
// if (results.length) {
|
||||||
Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`)
|
// Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`)
|
||||||
var coverUrl = results[0]
|
// var coverUrl = results[0]
|
||||||
var result = await this.coverController.downloadCoverFromUrl(audiobook, coverUrl)
|
// var result = await this.coverController.downloadCoverFromUrl(audiobook, coverUrl)
|
||||||
if (result.error) {
|
// if (result.error) {
|
||||||
failed++
|
// failed++
|
||||||
} else {
|
// } else {
|
||||||
found++
|
// found++
|
||||||
await this.db.updateAudiobook(audiobook)
|
// await this.db.updateAudiobook(audiobook)
|
||||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
// this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||||
}
|
// }
|
||||||
} else {
|
// } else {
|
||||||
notFound++
|
// notFound++
|
||||||
}
|
// }
|
||||||
|
|
||||||
var progress = Math.round(100 * (i + 1) / audiobooksNeedingCover.length)
|
// var progress = Math.round(100 * (i + 1) / audiobooksNeedingCover.length)
|
||||||
this.emitter('scan_progress', {
|
// this.emitter('scan_progress', {
|
||||||
scanType: 'covers',
|
// scanType: 'covers',
|
||||||
progress: {
|
// progress: {
|
||||||
total: audiobooksNeedingCover.length,
|
// total: audiobooksNeedingCover.length,
|
||||||
done: i + 1,
|
// done: i + 1,
|
||||||
progress
|
// progress
|
||||||
}
|
// }
|
||||||
})
|
// })
|
||||||
|
|
||||||
if (this.cancelScan) {
|
// if (this.cancelScan) {
|
||||||
this.cancelScan = false
|
// this.cancelScan = false
|
||||||
break
|
// break
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
return {
|
// return {
|
||||||
found,
|
// found,
|
||||||
notFound,
|
// notFound,
|
||||||
failed
|
// failed
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
async saveMetadata(audiobookId) {
|
async saveMetadata(audiobookId) {
|
||||||
if (audiobookId) {
|
if (audiobookId) {
|
||||||
|
|||||||
+42
-19
@@ -10,6 +10,7 @@ const { version } = require('../package.json')
|
|||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
const { ScanResult } = require('./utils/constants')
|
const { ScanResult } = require('./utils/constants')
|
||||||
|
const filePerms = require('./utils/filePerms')
|
||||||
const Logger = require('./Logger')
|
const Logger = require('./Logger')
|
||||||
|
|
||||||
// Classes
|
// Classes
|
||||||
@@ -17,6 +18,7 @@ const Auth = require('./Auth')
|
|||||||
const Watcher = require('./Watcher')
|
const Watcher = require('./Watcher')
|
||||||
const Scanner = require('./Scanner')
|
const Scanner = require('./Scanner')
|
||||||
const Db = require('./Db')
|
const Db = require('./Db')
|
||||||
|
const BackupManager = require('./BackupManager')
|
||||||
const ApiController = require('./ApiController')
|
const ApiController = require('./ApiController')
|
||||||
const HlsController = require('./HlsController')
|
const HlsController = require('./HlsController')
|
||||||
const StreamManager = require('./StreamManager')
|
const StreamManager = require('./StreamManager')
|
||||||
@@ -24,28 +26,30 @@ const RssFeeds = require('./RssFeeds')
|
|||||||
const DownloadManager = require('./DownloadManager')
|
const DownloadManager = require('./DownloadManager')
|
||||||
const CoverController = require('./CoverController')
|
const CoverController = require('./CoverController')
|
||||||
|
|
||||||
|
|
||||||
class Server {
|
class Server {
|
||||||
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
constructor(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||||
this.Port = PORT
|
this.Port = PORT
|
||||||
|
this.Uid = isNaN(UID) ? 0 : Number(UID)
|
||||||
|
this.Gid = isNaN(GID) ? 0 : Number(GID)
|
||||||
this.Host = '0.0.0.0'
|
this.Host = '0.0.0.0'
|
||||||
this.ConfigPath = Path.normalize(CONFIG_PATH)
|
this.ConfigPath = Path.normalize(CONFIG_PATH)
|
||||||
this.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
|
this.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
|
||||||
this.MetadataPath = Path.normalize(METADATA_PATH)
|
this.MetadataPath = Path.normalize(METADATA_PATH)
|
||||||
|
|
||||||
fs.ensureDirSync(CONFIG_PATH)
|
fs.ensureDirSync(CONFIG_PATH, 0o774)
|
||||||
fs.ensureDirSync(METADATA_PATH)
|
fs.ensureDirSync(METADATA_PATH, 0o774)
|
||||||
fs.ensureDirSync(AUDIOBOOK_PATH)
|
fs.ensureDirSync(AUDIOBOOK_PATH, 0o774)
|
||||||
|
|
||||||
this.db = new Db(this.ConfigPath, this.AudiobookPath)
|
this.db = new Db(this.ConfigPath, this.AudiobookPath)
|
||||||
this.auth = new Auth(this.db)
|
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.watcher = new Watcher(this.AudiobookPath)
|
||||||
this.coverController = new CoverController(this.db, this.MetadataPath, 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.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
|
||||||
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
||||||
this.rssFeeds = new RssFeeds(this.Port, this.db)
|
this.rssFeeds = new RssFeeds(this.Port, this.db)
|
||||||
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
|
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.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
|
||||||
|
|
||||||
this.expressApp = null
|
this.expressApp = null
|
||||||
@@ -98,6 +102,7 @@ class Server {
|
|||||||
this.auth.init()
|
this.auth.init()
|
||||||
|
|
||||||
await this.purgeMetadata()
|
await this.purgeMetadata()
|
||||||
|
await this.backupManager.init()
|
||||||
|
|
||||||
this.watcher.initWatcher(this.libraries)
|
this.watcher.initWatcher(this.libraries)
|
||||||
this.watcher.on('files', this.filesChanged.bind(this))
|
this.watcher.on('files', this.filesChanged.bind(this))
|
||||||
@@ -155,6 +160,18 @@ class Server {
|
|||||||
res.sendFile(fullPath)
|
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
|
// Client routes
|
||||||
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
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')))
|
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||||
@@ -217,7 +234,6 @@ class Server {
|
|||||||
|
|
||||||
// Scanning
|
// Scanning
|
||||||
socket.on('scan', this.scan.bind(this))
|
socket.on('scan', this.scan.bind(this))
|
||||||
socket.on('scan_covers', this.scanCovers.bind(this))
|
|
||||||
socket.on('cancel_scan', this.cancelScan.bind(this))
|
socket.on('cancel_scan', this.cancelScan.bind(this))
|
||||||
socket.on('scan_audiobook', (audiobookId) => this.scanAudiobook(socket, audiobookId))
|
socket.on('scan_audiobook', (audiobookId) => this.scanAudiobook(socket, audiobookId))
|
||||||
socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId))
|
socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId))
|
||||||
@@ -233,8 +249,13 @@ class Server {
|
|||||||
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
|
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
|
||||||
socket.on('remove_download', (downloadId) => this.downloadManager.removeSocketRequest(socket, downloadId))
|
socket.on('remove_download', (downloadId) => this.downloadManager.removeSocketRequest(socket, downloadId))
|
||||||
|
|
||||||
|
// Logs
|
||||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
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.on('test', () => {
|
||||||
socket.emit('test_received', socket.id)
|
socket.emit('test_received', socket.id)
|
||||||
})
|
})
|
||||||
@@ -280,16 +301,6 @@ class Server {
|
|||||||
socket.emit('audiobook_scan_complete', scanResultName)
|
socket.emit('audiobook_scan_complete', scanResultName)
|
||||||
}
|
}
|
||||||
|
|
||||||
async scanCovers() {
|
|
||||||
Logger.info('[Server] Start cover scan')
|
|
||||||
this.isScanningCovers = true
|
|
||||||
// this.emitter('scan_start', 'covers')
|
|
||||||
var results = await this.scanner.scanCovers()
|
|
||||||
this.isScanningCovers = false
|
|
||||||
// this.emitter('scan_complete', { scanType: 'covers', results })
|
|
||||||
Logger.info('[Server] Cover scan complete')
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelScan(id) {
|
cancelScan(id) {
|
||||||
Logger.debug('[Server] Cancel scan', id)
|
Logger.debug('[Server] Cancel scan', id)
|
||||||
this.scanner.cancelLibraryScan[id] = true
|
this.scanner.cancelLibraryScan[id] = true
|
||||||
@@ -359,6 +370,9 @@ class Server {
|
|||||||
return res.status(500).error(`Invalid post data`)
|
return res.status(500).error(`Invalid post data`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For setting permissions recursively
|
||||||
|
var firstDirPath = Path.join(folder.fullPath, author)
|
||||||
|
|
||||||
var outputDirectory = ''
|
var outputDirectory = ''
|
||||||
if (series && series.length && series !== 'null') {
|
if (series && series.length && series !== 'null') {
|
||||||
outputDirectory = Path.join(folder.fullPath, author, series, title)
|
outputDirectory = Path.join(folder.fullPath, author, series, title)
|
||||||
@@ -373,16 +387,24 @@ class Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await fs.ensureDir(outputDirectory)
|
await fs.ensureDir(outputDirectory)
|
||||||
|
|
||||||
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
var file = files[i]
|
var file = files[i]
|
||||||
|
|
||||||
var path = Path.join(outputDirectory, file.name)
|
var path = Path.join(outputDirectory, file.name)
|
||||||
await file.mv(path).catch((error) => {
|
await file.mv(path).then(() => {
|
||||||
|
return true
|
||||||
|
}).catch((error) => {
|
||||||
Logger.error('Failed to move file', path, error)
|
Logger.error('Failed to move file', path, error)
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger.info(`[Server] Setting owner/perms for first dir "${firstDirPath}"`)
|
||||||
|
await filePerms(firstDirPath, 0o774, this.Uid, this.Gid)
|
||||||
|
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,7 +467,8 @@ class Server {
|
|||||||
configPath: this.ConfigPath,
|
configPath: this.ConfigPath,
|
||||||
user: client.user.toJSONForBrowser(),
|
user: client.user.toJSONForBrowser(),
|
||||||
stream: client.stream || null,
|
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)
|
client.socket.emit('init', initialPayload)
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class StreamManager {
|
|||||||
if (!dirs || !dirs.length) return true
|
if (!dirs || !dirs.length) return true
|
||||||
|
|
||||||
await Promise.all(dirs.map(async (dirname) => {
|
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)
|
var fullPath = Path.join(this.MetadataPath, dirname)
|
||||||
Logger.warn(`Removing OLD Orphan Stream ${dirname}`)
|
Logger.warn(`Removing OLD Orphan Stream ${dirname}`)
|
||||||
return fs.remove(fullPath)
|
return fs.remove(fullPath)
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ class Audiobook {
|
|||||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
||||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||||
ebooks: (this.ebooks || []).map(ebook => ebook.toJSON()),
|
ebooks: (this.ebooks || []).map(ebook => ebook.toJSON()),
|
||||||
|
numEbooks: this.hasEpub ? 1 : 0,
|
||||||
tags: this.tags,
|
tags: this.tags,
|
||||||
book: this.bookToJSON(),
|
book: this.bookToJSON(),
|
||||||
tracks: this.tracksToJSON(),
|
tracks: this.tracksToJSON(),
|
||||||
@@ -498,13 +499,13 @@ class Audiobook {
|
|||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If reader.txt is new or forcing rescan then read it and update narrarator (will overwrite)
|
// If reader.txt is new or forcing rescan then read it and update narrator (will overwrite)
|
||||||
var readerTxt = newOtherFiles.find(file => file.filename === 'reader.txt')
|
var readerTxt = newOtherFiles.find(file => file.filename === 'reader.txt')
|
||||||
if (readerTxt && (!alreadyHasReaderTxt || forceRescan)) {
|
if (readerTxt && (!alreadyHasReaderTxt || forceRescan)) {
|
||||||
var newReader = await readTextFile(readerTxt.fullPath)
|
var newReader = await readTextFile(readerTxt.fullPath)
|
||||||
if (newReader) {
|
if (newReader) {
|
||||||
Logger.debug(`[Audiobook] Sync Other File reader.txt: ${newReader}`)
|
Logger.debug(`[Audiobook] Sync Other File reader.txt: ${newReader}`)
|
||||||
this.update({ book: { narrarator: newReader } })
|
this.update({ book: { narrator: newReader } })
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -712,8 +713,8 @@ class Audiobook {
|
|||||||
}
|
}
|
||||||
var readerText = await this.fetchTextFromTextFile('reader.txt')
|
var readerText = await this.fetchTextFromTextFile('reader.txt')
|
||||||
if (readerText) {
|
if (readerText) {
|
||||||
Logger.debug(`[Audiobook] "${this.title}" found reader.txt updating narrarator with "${readerText}"`)
|
Logger.debug(`[Audiobook] "${this.title}" found reader.txt updating narrator with "${readerText}"`)
|
||||||
bookUpdatePayload.narrarator = readerText
|
bookUpdatePayload.narrator = readerText
|
||||||
}
|
}
|
||||||
if (Object.keys(bookUpdatePayload).length) {
|
if (Object.keys(bookUpdatePayload).length) {
|
||||||
return this.update({ book: bookUpdatePayload })
|
return this.update({ book: bookUpdatePayload })
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,7 +11,7 @@ class Book {
|
|||||||
this.author = null
|
this.author = null
|
||||||
this.authorFL = null
|
this.authorFL = null
|
||||||
this.authorLF = null
|
this.authorLF = null
|
||||||
this.narrarator = null
|
this.narrator = null
|
||||||
this.series = null
|
this.series = null
|
||||||
this.volumeNumber = null
|
this.volumeNumber = null
|
||||||
this.publishYear = null
|
this.publishYear = null
|
||||||
@@ -35,7 +35,7 @@ class Book {
|
|||||||
|
|
||||||
get _title() { return this.title || '' }
|
get _title() { return this.title || '' }
|
||||||
get _subtitle() { return this.subtitle || '' }
|
get _subtitle() { return this.subtitle || '' }
|
||||||
get _narrarator() { return this.narrarator || '' }
|
get _narrator() { return this.narrator || '' }
|
||||||
get _author() { return this.author || '' }
|
get _author() { return this.author || '' }
|
||||||
get _series() { return this.series || '' }
|
get _series() { return this.series || '' }
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ class Book {
|
|||||||
this.author = book.author
|
this.author = book.author
|
||||||
this.authorFL = book.authorFL || null
|
this.authorFL = book.authorFL || null
|
||||||
this.authorLF = book.authorLF || null
|
this.authorLF = book.authorLF || null
|
||||||
this.narrarator = book.narrarator || null
|
this.narrator = book.narrator || book.narrarator || null // Mispelled initially... need to catch those
|
||||||
this.series = book.series
|
this.series = book.series
|
||||||
this.volumeNumber = book.volumeNumber || null
|
this.volumeNumber = book.volumeNumber || null
|
||||||
this.publishYear = book.publishYear
|
this.publishYear = book.publishYear
|
||||||
@@ -75,7 +75,7 @@ class Book {
|
|||||||
author: this.author,
|
author: this.author,
|
||||||
authorFL: this.authorFL,
|
authorFL: this.authorFL,
|
||||||
authorLF: this.authorLF,
|
authorLF: this.authorLF,
|
||||||
narrarator: this.narrarator,
|
narrator: this.narrator,
|
||||||
series: this.series,
|
series: this.series,
|
||||||
volumeNumber: this.volumeNumber,
|
volumeNumber: this.volumeNumber,
|
||||||
publishYear: this.publishYear,
|
publishYear: this.publishYear,
|
||||||
@@ -115,7 +115,7 @@ class Book {
|
|||||||
this.title = data.title || null
|
this.title = data.title || null
|
||||||
this.subtitle = data.subtitle || null
|
this.subtitle = data.subtitle || null
|
||||||
this.author = data.author || null
|
this.author = data.author || null
|
||||||
this.narrarator = data.narrarator || null
|
this.narrator = data.narrator || data.narrarator || null
|
||||||
this.series = data.series || null
|
this.series = data.series || null
|
||||||
this.volumeNumber = data.volumeNumber || null
|
this.volumeNumber = data.volumeNumber || null
|
||||||
this.publishYear = data.publishYear || null
|
this.publishYear = data.publishYear || null
|
||||||
@@ -221,7 +221,7 @@ class Book {
|
|||||||
const MetadataMapArray = [
|
const MetadataMapArray = [
|
||||||
{
|
{
|
||||||
tag: 'tagComposer',
|
tag: 'tagComposer',
|
||||||
key: 'narrarator'
|
key: 'narrator'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: 'tagDescription',
|
tag: 'tagDescription',
|
||||||
|
|||||||
@@ -5,14 +5,28 @@ class ServerSettings {
|
|||||||
constructor(settings) {
|
constructor(settings) {
|
||||||
this.id = 'server-settings'
|
this.id = 'server-settings'
|
||||||
|
|
||||||
|
// Misc/Unused
|
||||||
this.autoTagNew = false
|
this.autoTagNew = false
|
||||||
this.newTagExpireDays = 15
|
this.newTagExpireDays = 15
|
||||||
|
|
||||||
|
// Scanner
|
||||||
this.scannerParseSubtitle = false
|
this.scannerParseSubtitle = false
|
||||||
this.scannerFindCovers = false
|
this.scannerFindCovers = false
|
||||||
|
|
||||||
|
// Metadata
|
||||||
this.coverDestination = CoverDestination.METADATA
|
this.coverDestination = CoverDestination.METADATA
|
||||||
this.saveMetadataFile = false
|
this.saveMetadataFile = false
|
||||||
|
|
||||||
|
// Security/Rate limits
|
||||||
this.rateLimitLoginRequests = 10
|
this.rateLimitLoginRequests = 10
|
||||||
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
|
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
|
this.logLevel = Logger.logLevel
|
||||||
|
|
||||||
if (settings) {
|
if (settings) {
|
||||||
@@ -29,6 +43,11 @@ class ServerSettings {
|
|||||||
this.saveMetadataFile = !!settings.saveMetadataFile
|
this.saveMetadataFile = !!settings.saveMetadataFile
|
||||||
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
||||||
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
|
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
|
this.logLevel = settings.logLevel || Logger.logLevel
|
||||||
|
|
||||||
if (this.logLevel !== Logger.logLevel) {
|
if (this.logLevel !== Logger.logLevel) {
|
||||||
@@ -47,6 +66,9 @@ class ServerSettings {
|
|||||||
saveMetadataFile: !!this.saveMetadataFile,
|
saveMetadataFile: !!this.saveMetadataFile,
|
||||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||||
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
||||||
|
backupSchedule: this.backupSchedule,
|
||||||
|
backupsToKeep: this.backupsToKeep,
|
||||||
|
backupMetadataCovers: this.backupMetadataCovers,
|
||||||
logLevel: this.logLevel
|
logLevel: this.logLevel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
const fs = require('fs-extra')
|
||||||
|
const Path = require('path')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
|
// Modified from:
|
||||||
|
// https://github.com/isaacs/chmodr/blob/master/chmodr.js
|
||||||
|
|
||||||
|
// If a party has r, add x
|
||||||
|
// so that dirs are listable
|
||||||
|
const dirMode = mode => {
|
||||||
|
if (mode & 0o400)
|
||||||
|
mode |= 0o100
|
||||||
|
if (mode & 0o40)
|
||||||
|
mode |= 0o10
|
||||||
|
if (mode & 0o4)
|
||||||
|
mode |= 0o1
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
const chmodrKid = (p, child, mode, uid, gid, cb) => {
|
||||||
|
if (typeof child === 'string')
|
||||||
|
return fs.lstat(Path.resolve(p, child), (er, stats) => {
|
||||||
|
if (er)
|
||||||
|
return cb(er)
|
||||||
|
stats.name = child
|
||||||
|
chmodrKid(p, stats, mode, uid, gid, cb)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (child.isDirectory()) {
|
||||||
|
chmodr(Path.resolve(p, child.name), mode, uid, gid, er => {
|
||||||
|
if (er)
|
||||||
|
return cb(er)
|
||||||
|
|
||||||
|
var _path = Path.resolve(p, child.name)
|
||||||
|
fs.chmod(_path, dirMode(mode)).then(() => {
|
||||||
|
fs.chown(_path, uid, gid, cb)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
var _path = Path.resolve(p, child.name)
|
||||||
|
fs.chmod(_path, mode).then(() => {
|
||||||
|
fs.chown(_path, uid, gid, cb)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const chmodr = (p, mode, uid, gid, cb) => {
|
||||||
|
fs.readdir(p, { withFileTypes: true }, (er, children) => {
|
||||||
|
// any error other than ENOTDIR means it's not readable, or
|
||||||
|
// doesn't exist. give up.
|
||||||
|
if (er && er.code !== 'ENOTDIR') return cb(er)
|
||||||
|
if (er) {
|
||||||
|
return fs.chmod(p, mode).then(() => {
|
||||||
|
fs.chown(p, uid, gid, cb)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!children.length) {
|
||||||
|
return fs.chmod(p, dirMode(mode)).then(() => {
|
||||||
|
fs.chown(p, uid, gid, cb)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = children.length
|
||||||
|
let errState = null
|
||||||
|
const then = er => {
|
||||||
|
if (errState) return
|
||||||
|
if (er) return cb(errState = er)
|
||||||
|
if (--len === 0) {
|
||||||
|
return fs.chmod(p, dirMode(mode)).then(() => {
|
||||||
|
fs.chown(p, uid, gid, cb)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
children.forEach(child => chmodrKid(p, child, mode, uid, gid, then))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = (p, mode, uid, gid) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${p}"`)
|
||||||
|
chmodr(p, mode, uid, gid, resolve)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -75,4 +75,14 @@ function secondsToTimestamp(seconds) {
|
|||||||
}
|
}
|
||||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
module.exports.secondsToTimestamp = secondsToTimestamp
|
module.exports.secondsToTimestamp = secondsToTimestamp
|
||||||
|
|
||||||
|
function setFileOwner(path, uid, gid) {
|
||||||
|
try {
|
||||||
|
return fs.chown(path, uid, gid).then(() => true)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed set file owner', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports.setFileOwner = setFileOwner
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async function generate(audiobook, nfoFilename = 'metadata.nfo') {
|
|||||||
'Title': book.title,
|
'Title': book.title,
|
||||||
'Subtitle': book.subtitle,
|
'Subtitle': book.subtitle,
|
||||||
'Author': book.author,
|
'Author': book.author,
|
||||||
'Narrator': book.narrarator,
|
'Narrator': book.narrator,
|
||||||
'Series': book.series,
|
'Series': book.series,
|
||||||
'Volume Number': book.volumeNumber,
|
'Volume Number': book.volumeNumber,
|
||||||
'Publish Year': book.publishYear,
|
'Publish Year': book.publishYear,
|
||||||
|
|||||||
Reference in New Issue
Block a user