mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-03 21:20:53 -05:00
Compare commits
168 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75aede914f | ||
|
|
19dcb6173e | ||
|
|
d9d34e87e0 | ||
|
|
a65f7e6fad | ||
|
|
577f3bead9 | ||
|
|
49072769a5 | ||
|
|
a590e795e3 | ||
|
|
b20cbb020a | ||
|
|
1637b6c7f0 | ||
|
|
78449d918b | ||
|
|
bd336345ee | ||
|
|
326663d9d9 | ||
|
|
ef2b9a0415 | ||
|
|
509d9e5e03 | ||
|
|
3dd8dc6dd4 | ||
|
|
a4bf3a759f | ||
|
|
8d9d5a8d1b | ||
|
|
28cbe0a95c | ||
|
|
0db34dcab5 | ||
|
|
4e45ff83c6 | ||
|
|
47c6c1aaad | ||
|
|
d753683090 | ||
|
|
d6cab8e591 | ||
|
|
de5baba930 | ||
|
|
dc18eb408e | ||
|
|
b26c1ba886 | ||
|
|
6f891208d0 | ||
|
|
b74b12301c | ||
|
|
643040e635 | ||
|
|
bb7e580ee3 | ||
|
|
4c07f9ec25 | ||
|
|
c82d888b5a | ||
|
|
0ba38d45bc | ||
|
|
07207b091d | ||
|
|
0da327222e | ||
|
|
8dc0acb6b7 | ||
|
|
868e1af28a | ||
|
|
8389a31775 | ||
|
|
a343a1038c | ||
|
|
893032bcc0 | ||
|
|
3e5338ec8e | ||
|
|
26b37def46 | ||
|
|
01fdca4bf9 | ||
|
|
29ba62357d | ||
|
|
ed96dd7c81 | ||
|
|
8ddfc6e7ac | ||
|
|
1ead5de9f5 | ||
|
|
ef8ec3d7ab | ||
|
|
68f534a97e | ||
|
|
0d556c3f76 | ||
|
|
8c91ef85c9 | ||
|
|
ac950f2173 | ||
|
|
bf1e6051be | ||
|
|
0eb00c8820 | ||
|
|
6067aecc4f | ||
|
|
f0b070ca1b | ||
|
|
34f0815319 | ||
|
|
2ed2790550 | ||
|
|
e2a6bf6c1a | ||
|
|
cce860ff74 | ||
|
|
73ef4cd4b5 | ||
|
|
d95689a078 | ||
|
|
784ffbc568 | ||
|
|
77eee0c85a | ||
|
|
0d33ed98d9 | ||
|
|
4de3ffac63 | ||
|
|
be6da8dad9 | ||
|
|
ec23250273 | ||
|
|
8fdbe58da8 | ||
|
|
e1fd74caaf | ||
|
|
37c38e69df | ||
|
|
31e109d0f0 | ||
|
|
b23f9362ef | ||
|
|
ae1b94e991 | ||
|
|
03feaf6e5a | ||
|
|
c821ef424f | ||
|
|
9e668bbf35 | ||
|
|
9e8ea43d5b | ||
|
|
c7b01165fd | ||
|
|
a63d76985c | ||
|
|
ea61400329 | ||
|
|
3ba3069894 | ||
|
|
6738c51f6e | ||
|
|
ef587a5f58 | ||
|
|
540f3e4196 | ||
|
|
043b353333 | ||
|
|
737f8342f4 | ||
|
|
c884fd6880 | ||
|
|
07994d38d5 | ||
|
|
80e2e35dcd | ||
|
|
bb7e79004d | ||
|
|
62d0a82dde | ||
|
|
5f69339a27 | ||
|
|
bd0e8518be | ||
|
|
a56b3a8096 | ||
|
|
a7c538193c | ||
|
|
5dd1542af0 | ||
|
|
9107620b3c | ||
|
|
c30955f909 | ||
|
|
beaa1e14bb | ||
|
|
394d312282 | ||
|
|
299fc95c78 | ||
|
|
07a2a0aefd | ||
|
|
2e82370408 | ||
|
|
c8d857edb9 | ||
|
|
11d7330c5d | ||
|
|
4f094df0b2 | ||
|
|
838d188504 | ||
|
|
6f6a3f71b3 | ||
|
|
a66a84bd2d | ||
|
|
af0365c81f | ||
|
|
45ee42bddd | ||
|
|
1aff255170 | ||
|
|
27956bd67a | ||
|
|
6a2d7af720 | ||
|
|
3c854ca32b | ||
|
|
c5bc865e6c | ||
|
|
7a35342577 | ||
|
|
1b63d90cd7 | ||
|
|
7ddb4f0539 | ||
|
|
28966e191b | ||
|
|
35808abdf6 | ||
|
|
db0ecfb51b | ||
|
|
64cc7efa6a | ||
|
|
b4a62dbf4b | ||
|
|
091aa6ef82 | ||
|
|
c208e876d2 | ||
|
|
3141e74513 | ||
|
|
1dc01615dd | ||
|
|
cb40e063da | ||
|
|
0c1a29adbf | ||
|
|
6e8270c5ea | ||
|
|
51357195e2 | ||
|
|
759be593b6 | ||
|
|
82241fd2f2 | ||
|
|
d63e582e56 | ||
|
|
c59cc52667 | ||
|
|
db2f2d6660 | ||
|
|
7ef977b783 | ||
|
|
40502aab1e | ||
|
|
33bbf1a49b | ||
|
|
a93f409dcd | ||
|
|
5ecfaa88c2 | ||
|
|
e7898377ed | ||
|
|
c3fd9045a8 | ||
|
|
f70e1beca1 | ||
|
|
fb0a6f4ec2 | ||
|
|
696766945c | ||
|
|
5e9d40b663 | ||
|
|
1c453a3560 | ||
|
|
343657fe97 | ||
|
|
e04b5d3152 | ||
|
|
30ce8fee76 | ||
|
|
f30fa2fb0c | ||
|
|
7d4e2e3d97 | ||
|
|
744aacbb4b | ||
|
|
506f16c431 | ||
|
|
08c3f84c06 | ||
|
|
7df0708f38 | ||
|
|
d59aefd8c7 | ||
|
|
2c5b5dbeae | ||
|
|
1c88c0a796 | ||
|
|
7e482352b1 | ||
|
|
6e8fe32bf5 | ||
|
|
9a74825bad | ||
|
|
dee7bcb2c2 | ||
|
|
8fbdc1523d | ||
|
|
6930e69b55 |
@@ -11,7 +11,6 @@ RUN npm run generate
|
||||
### STAGE 2: Build server ###
|
||||
FROM node:12-alpine
|
||||
ENV NODE_ENV=production
|
||||
ENV LOG_LEVEL=INFO
|
||||
COPY --from=build /client/dist /client/dist
|
||||
COPY --from=ffmpeg / /
|
||||
COPY index.js index.js
|
||||
|
||||
@@ -9,20 +9,38 @@
|
||||
height: calc(100% - 64px - 165px);
|
||||
max-height: calc(100% - 64px - 165px);
|
||||
}
|
||||
#bookshelf {
|
||||
height: calc(100% - 40px);
|
||||
}
|
||||
|
||||
/* width */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
/* ::-webkit-scrollbar:horizontal { */
|
||||
/* height: 16px; */
|
||||
/* height: 24px;
|
||||
} */
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: rgba(0,0,0,0);
|
||||
}
|
||||
/* ::-webkit-scrollbar-track:horizontal { */
|
||||
/* background: rgb(149, 119, 90); */
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
/* background: linear-gradient(180deg, rgb(117, 88, 60) 0%, rgb(65, 41, 17) 17%, rgb(71, 43, 15) 88%, rgb(3, 2, 1) 100%);
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
} */
|
||||
/* Handle */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #855620;
|
||||
border-radius: 4px;
|
||||
}
|
||||
/* ::-webkit-scrollbar-thumb:horizontal { */
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
/* box-shadow: 2px 14px 8px #111111aa;
|
||||
border-radius: 4px;
|
||||
} */
|
||||
/* Handle on hover */
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #704922;
|
||||
|
||||
@@ -440,7 +440,7 @@ export default {
|
||||
})
|
||||
|
||||
this.hlsInstance.on(Hls.Events.ERROR, (e, data) => {
|
||||
console.error('[HLS] Error', data.type, data.details)
|
||||
console.error('[HLS] Error', data.type, data.details, data)
|
||||
if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
|
||||
console.error('[HLS] BUFFER STALLED ERROR')
|
||||
}
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
<template>
|
||||
<div class="w-full h-16 bg-primary relative">
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-30">
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-40">
|
||||
<div class="flex h-full items-center">
|
||||
<img v-if="!showBack" src="/Logo48.png" class="w-12 h-12 mr-4" />
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer">
|
||||
<span class="material-icons text-4xl text-white">arrow_back</span>
|
||||
</a>
|
||||
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
|
||||
<!-- <div class="-mb-2">
|
||||
<h1 class="text-lg font-book leading-3 mr-6 px-1">AudioBookshelf</h1>
|
||||
<div class="bg-black bg-opacity-20 rounded-sm py-1.5 px-2 mt-1.5 flex items-center justify-between border border-bg">
|
||||
<p class="text-sm text-gray-400 leading-3">My Library</p>
|
||||
<span class="material-icons text-sm leading-3 text-gray-400">expand_more</span>
|
||||
</div>
|
||||
</div> -->
|
||||
<!-- <div class="-mb-2 mr-6"> -->
|
||||
<!-- <h1 class="text-base font-book leading-3 px-1">AudioBookshelf</h1> -->
|
||||
|
||||
<!-- <div class="bg-black bg-opacity-20 rounded-sm py-1 px-2 flex items-center border border-bg mt-1.5 cursor-pointer" @click="clickLibrary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-white text-opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
|
||||
<p class="text-sm text-white text-opacity-70 leading-3 font-book pl-2">{{ libraryName }}</p>
|
||||
</div> -->
|
||||
<!-- </div> -->
|
||||
<div class="bg-black bg-opacity-20 rounded-sm py-1.5 px-3 flex items-center border border-bg text-white text-opacity-70 cursor-pointer hover:bg-opacity-10 hover:text-opacity-90" @click="clickLibrary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
|
||||
<p class="text-base leading-3 font-book pl-2">{{ libraryName }}</p>
|
||||
</div>
|
||||
|
||||
<controls-global-search />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-4">logo_dev</span>
|
||||
|
||||
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
|
||||
<span class="material-icons">upload</span>
|
||||
</nuxt-link>
|
||||
@@ -37,7 +51,9 @@
|
||||
|
||||
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
|
||||
<ui-btn small class="text-sm mx-2" @click="toggleSelectAll">{{ isAllSelected ? 'Select None' : 'Select All' }}</ui-btn>
|
||||
<ui-btn v-show="!isHome" small class="text-sm mx-2" @click="toggleSelectAll"
|
||||
>{{ isAllSelected ? 'Select None' : 'Select All' }}<span class="pl-2">({{ audiobooksShowing.length }})</span></ui-btn
|
||||
>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
@@ -62,8 +78,17 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
libraryName() {
|
||||
return this.currentLibrary ? this.currentLibrary.name : 'unknown'
|
||||
},
|
||||
isHome() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
showBack() {
|
||||
return this.$route.name !== 'library-id'
|
||||
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
@@ -87,7 +112,11 @@ export default {
|
||||
return this.$store.state.user.user.audiobooks || {}
|
||||
},
|
||||
audiobooksShowing() {
|
||||
return this.$store.getters['audiobooks/getFiltered']()
|
||||
// return this.$store.getters['audiobooks/getFiltered']()
|
||||
return this.$store.getters['audiobooks/getEntitiesShowing']()
|
||||
},
|
||||
selectedSeries() {
|
||||
return this.$store.state.audiobooks.selectedSeries
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
@@ -107,15 +136,19 @@ export default {
|
||||
},
|
||||
processingBatch() {
|
||||
return this.$store.state.processingBatch
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
back() {
|
||||
if (this.$route.name === 'audiobook-id-edit') {
|
||||
this.$router.push(`/audiobook/${this.$route.params.id}`)
|
||||
} else {
|
||||
this.$router.push('/library')
|
||||
}
|
||||
clickLibrary() {
|
||||
this.$store.commit('libraries/setShowModal', true)
|
||||
},
|
||||
async back() {
|
||||
var popped = await this.$store.dispatch('popRoute')
|
||||
var backTo = popped || '/'
|
||||
this.$router.push(backTo)
|
||||
},
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatchDelete) return
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<template v-for="entity in shelf">
|
||||
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" />
|
||||
<!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
|
||||
<cards-book-card v-else :key="entity.id" :show-volume-number="selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" />
|
||||
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
|
||||
@@ -68,11 +68,13 @@ export default {
|
||||
},
|
||||
selectedSeries() {
|
||||
this.$nextTick(() => {
|
||||
this.$store.commit('audiobooks/setSelectedSeries', this.selectedSeries)
|
||||
this.setBookshelfEntities()
|
||||
})
|
||||
},
|
||||
searchResults() {
|
||||
this.$nextTick(() => {
|
||||
this.$store.commit('audiobooks/setSearchResults', this.searchResults)
|
||||
this.setBookshelfEntities()
|
||||
})
|
||||
}
|
||||
@@ -100,7 +102,8 @@ export default {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
bookWidth() {
|
||||
return this.bookCoverWidth + this.paddingX * 2
|
||||
var _width = this.bookCoverWidth + this.paddingX * 2
|
||||
return this.showGroups ? _width * 1.6 : _width
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumAudiobooksSelected']
|
||||
@@ -130,9 +133,6 @@ export default {
|
||||
clickGroup(group) {
|
||||
this.$emit('update:selectedSeries', group.name)
|
||||
},
|
||||
changeRotation() {
|
||||
this.rotation = 'show-right'
|
||||
},
|
||||
clearFilter() {
|
||||
this.$store.commit('audiobooks/setKeywordFilter', null)
|
||||
if (this.filterBy !== 'all') {
|
||||
@@ -162,6 +162,7 @@ export default {
|
||||
setBookshelfEntities() {
|
||||
this.wrapperClientWidth = this.$refs.wrapper.clientWidth
|
||||
var width = Math.max(0, this.wrapperClientWidth - this.rowPaddingX * 2)
|
||||
|
||||
var booksPerRow = Math.floor(width / this.bookWidth)
|
||||
|
||||
var entities = this.entities
|
||||
@@ -215,7 +216,7 @@ export default {
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan')
|
||||
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
|
||||
162
client/components/app/BookShelfCategorized.vue
Normal file
162
client/components/app/BookShelfCategorized.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
||||
<!-- Cover size widget -->
|
||||
<div class="fixed bottom-2 right-4 z-40">
|
||||
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
||||
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
|
||||
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
|
||||
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
|
||||
<div class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else id="bookshelf" class="w-full flex flex-col items-center">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
|
||||
selectedSizeIndex: 3,
|
||||
rowPaddingX: 40,
|
||||
keywordFilterTimeout: null,
|
||||
scannerParseSubtitle: false,
|
||||
wrapperClientWidth: 0,
|
||||
overflowingShelvesRight: {},
|
||||
overflowingShelvesLeft: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
},
|
||||
audiobooks() {
|
||||
return this.$store.state.audiobooks.audiobooks
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return this.availableSizes[this.selectedSizeIndex]
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.bookCoverWidth / 120
|
||||
},
|
||||
signSizeMultiplier() {
|
||||
return (1 - this.sizeMultiplier) / 2 + this.sizeMultiplier
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
bookWidth() {
|
||||
return this.bookCoverWidth + this.paddingX * 2
|
||||
},
|
||||
mostRecentPlayed() {
|
||||
var audiobooks = this.audiobooks.filter((ab) => this.userAudiobooks[ab.id] && this.userAudiobooks[ab.id].lastUpdate > 0 && this.userAudiobooks[ab.id].progress > 0 && !this.userAudiobooks[ab.id].isRead).map((ab) => ({ ...ab }))
|
||||
audiobooks.sort((a, b) => {
|
||||
return this.userAudiobooks[b.id].lastUpdate - this.userAudiobooks[a.id].lastUpdate
|
||||
})
|
||||
return audiobooks.slice(0, 10)
|
||||
},
|
||||
mostRecentAdded() {
|
||||
var audiobooks = this.audiobooks.map((ab) => ({ ...ab })).sort((a, b) => b.addedAt - a.addedAt)
|
||||
return audiobooks.slice(0, 10)
|
||||
},
|
||||
seriesGroups() {
|
||||
return this.$store.getters['audiobooks/getSeriesGroups']()
|
||||
},
|
||||
recentlyUpdatedSeries() {
|
||||
var mostRecentTime = 0
|
||||
var mostRecentSeries = null
|
||||
this.seriesGroups.forEach((series) => {
|
||||
if ((series.books.length && mostRecentSeries === null) || series.lastUpdate > mostRecentTime) {
|
||||
mostRecentTime = series.lastUpdate
|
||||
mostRecentSeries = series
|
||||
}
|
||||
})
|
||||
if (!mostRecentSeries) return null
|
||||
return mostRecentSeries.books
|
||||
},
|
||||
booksRecentlyRead() {
|
||||
var audiobooks = this.audiobooks.filter((ab) => this.userAudiobooks[ab.id] && this.userAudiobooks[ab.id].isRead).map((ab) => ({ ...ab }))
|
||||
audiobooks.sort((a, b) => {
|
||||
return this.userAudiobooks[b.id].finishedAt - this.userAudiobooks[a.id].finishedAt
|
||||
})
|
||||
return audiobooks.slice(0, 10)
|
||||
},
|
||||
shelves() {
|
||||
var shelves = []
|
||||
if (this.mostRecentPlayed.length) {
|
||||
shelves.push({ books: this.mostRecentPlayed, label: 'Continue Reading' })
|
||||
}
|
||||
|
||||
shelves.push({ books: this.mostRecentAdded, label: 'Recently Added' })
|
||||
|
||||
if (this.recentlyUpdatedSeries) {
|
||||
shelves.push({ books: this.recentlyUpdatedSeries, label: 'Newest Series' })
|
||||
}
|
||||
|
||||
if (this.booksRecentlyRead.length) {
|
||||
shelves.push({ books: this.booksRecentlyRead, label: 'Read Again' })
|
||||
}
|
||||
return shelves
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
increaseSize() {
|
||||
this.selectedSizeIndex = Math.min(this.availableSizes.length - 1, this.selectedSizeIndex + 1)
|
||||
this.resize()
|
||||
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
|
||||
},
|
||||
decreaseSize() {
|
||||
this.selectedSizeIndex = Math.max(0, this.selectedSizeIndex - 1)
|
||||
this.resize()
|
||||
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
|
||||
},
|
||||
async init() {
|
||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||
|
||||
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize)
|
||||
if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex
|
||||
|
||||
await this.$store.dispatch('audiobooks/load')
|
||||
},
|
||||
resize() {},
|
||||
audiobooksUpdated() {},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
|
||||
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
|
||||
if (index >= 0) {
|
||||
this.selectedSizeIndex = index
|
||||
this.resize()
|
||||
}
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.resize)
|
||||
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
this.$store.commit('audiobooks/removeListener', 'bookshelf')
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
139
client/components/app/BookShelfRow.vue
Normal file
139
client/components/app/BookShelfRow.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div ref="shelf" class="w-full max-w-full bookshelfRowCategorized relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: 2.5 * sizeMultiplier + 'rem' }" @scroll="scrolled">
|
||||
<div class="w-full h-full" :style="{ marginTop: sizeMultiplier + 'rem' }">
|
||||
<div class="flex items-center -mb-2">
|
||||
<template v-for="entity in shelf.books">
|
||||
<cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-8 w-36 rounded-md" style="height: 22px">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
||||
<p class="transform text-sm">{{ shelf.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bookshelfDividerCategorized h-6 w-full absolute bottom-0 left-0 right-0 z-20"></div>
|
||||
|
||||
<div v-show="canScrollLeft && !isScrolling" class="absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left flex items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollLeft">
|
||||
<span class="material-icons text-8xl text-white">chevron_left</span>
|
||||
</div>
|
||||
<div v-show="canScrollRight && !isScrolling" class="absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right flex items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
|
||||
<span class="material-icons text-8xl text-white">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
shelf: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
sizeMultiplier: Number,
|
||||
bookCoverWidth: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canScrollRight: false,
|
||||
canScrollLeft: false,
|
||||
isScrolling: false,
|
||||
scrollTimer: null,
|
||||
updateTimer: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
scrolled() {
|
||||
clearTimeout(this.scrollTimer)
|
||||
this.scrollTimer = setTimeout(() => {
|
||||
this.isScrolling = false
|
||||
this.$nextTick(this.checkCanScroll)
|
||||
}, 50)
|
||||
},
|
||||
scrollLeft() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
this.isScrolling = true
|
||||
this.$refs.shelf.scrollLeft = 0
|
||||
},
|
||||
scrollRight() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
this.isScrolling = true
|
||||
this.$refs.shelf.scrollLeft = 999
|
||||
},
|
||||
updatedBookCard() {
|
||||
clearTimeout(this.updateTimer)
|
||||
this.updateTimer = setTimeout(() => {
|
||||
this.$nextTick(this.checkCanScroll)
|
||||
}, 100)
|
||||
},
|
||||
checkCanScroll() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
var clientWidth = this.$refs.shelf.clientWidth
|
||||
var scrollWidth = this.$refs.shelf.scrollWidth
|
||||
var scrollLeft = this.$refs.shelf.scrollLeft
|
||||
if (scrollWidth > clientWidth) {
|
||||
this.canScrollRight = scrollLeft === 0
|
||||
this.canScrollLeft = scrollLeft > 0
|
||||
} else {
|
||||
this.canScrollRight = false
|
||||
this.canScrollLeft = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bookshelfRowCategorized {
|
||||
scroll-behavior: smooth;
|
||||
width: calc(100vw - 80px);
|
||||
background-image: url(/wood_panels.jpg);
|
||||
}
|
||||
.bookshelfDividerCategorized {
|
||||
background: rgb(149, 119, 90);
|
||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
||||
background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%);
|
||||
/* background: linear-gradient(180deg, rgb(114, 85, 59) 0%, rgb(73, 48, 22) 17%, rgb(71, 43, 15) 88%, rgb(61, 41, 20) 100%); */
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
}
|
||||
.categoryPlacard {
|
||||
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.shinyBlack {
|
||||
background-color: #2d3436;
|
||||
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
||||
border-color: rgba(255, 244, 182, 0.6);
|
||||
border-style: solid;
|
||||
color: #fce3a6;
|
||||
}
|
||||
.book-shelf-arrow-right {
|
||||
height: calc(100% - 24px);
|
||||
background: rgb(48, 48, 48);
|
||||
background: linear-gradient(90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
|
||||
}
|
||||
.book-shelf-arrow-left {
|
||||
height: calc(100% - 24px);
|
||||
background: rgb(48, 48, 48);
|
||||
background: linear-gradient(-90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-10 relative">
|
||||
<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'">
|
||||
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-40 flex items-center px-8">
|
||||
<template v-if="page !== 'search' && !isHome">
|
||||
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
|
||||
<div v-else class="flex items-center">
|
||||
<div @click="seriesBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
@@ -18,7 +18,7 @@
|
||||
<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" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-else-if="!isHome">
|
||||
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-white">west</span>
|
||||
</div>
|
||||
@@ -35,6 +35,7 @@
|
||||
export default {
|
||||
props: {
|
||||
page: String,
|
||||
isHome: Boolean,
|
||||
selectedSeries: String,
|
||||
searchResults: {
|
||||
type: Array,
|
||||
@@ -80,14 +81,17 @@ export default {
|
||||
set(val) {
|
||||
this.$store.commit('audiobooks/setKeywordFilter', val)
|
||||
}
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
searchBackArrow() {
|
||||
this.$router.replace('/library')
|
||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf`)
|
||||
},
|
||||
seriesBackArrow() {
|
||||
this.$router.replace('/library/series')
|
||||
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/series`)
|
||||
this.$emit('update:selectedSeries', null)
|
||||
},
|
||||
updateOrder() {
|
||||
|
||||
182
client/components/app/Reader.vue
Normal file
182
client/components/app/Reader.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div v-if="value" class="w-screen h-screen fixed top-0 left-0 z-50 bg-white text-black">
|
||||
<div class="absolute top-4 right-4 z-10">
|
||||
<span class="material-icons cursor-pointer text-4xl" @click="show = false">close</span>
|
||||
</div>
|
||||
<!-- <div v-if="chapters.length" class="absolute top-0 left-0 w-52">
|
||||
<select v-model="selectedChapter" class="w-52" @change="changedChapter">
|
||||
<option v-for="chapter in chapters" :key="chapter.href" :value="chapter.href">{{ chapter.label }}</option>
|
||||
</select>
|
||||
</div> -->
|
||||
<div class="absolute top-4 left-4 font-book">
|
||||
<h1 class="text-2xl mb-1">{{ title }}</h1>
|
||||
|
||||
<p v-if="author">by {{ author }}</p>
|
||||
</div>
|
||||
<div class="h-full flex items-center">
|
||||
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden">
|
||||
<span v-show="hasPrev" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @click="pageLeft">chevron_left</span>
|
||||
</div>
|
||||
<div id="frame" class="w-full" style="height: 650px">
|
||||
<div id="viewer" class="spreads"></div>
|
||||
|
||||
<div class="px-16 flex justify-center" style="height: 50px">
|
||||
<p class="px-4">{{ progress }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden">
|
||||
<span v-show="hasNext" class="material-icons text-black text-opacity-30 hover:text-opacity-80 cursor-pointer text-8xl" @click="pageRight">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ePub from 'epubjs'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
url: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
book: null,
|
||||
rendition: null,
|
||||
chapters: [],
|
||||
title: '',
|
||||
author: '',
|
||||
progress: 0,
|
||||
hasNext: true,
|
||||
hasPrev: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
} else {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
fullUrl() {
|
||||
var serverUrl = process.env.serverUrl || '/local'
|
||||
return `${serverUrl}/${this.url}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
changedChapter() {
|
||||
if (this.rendition) {
|
||||
this.rendition.display(this.selectedChapter)
|
||||
}
|
||||
},
|
||||
pageLeft() {
|
||||
if (this.rendition) {
|
||||
this.rendition.prev()
|
||||
}
|
||||
},
|
||||
pageRight() {
|
||||
if (this.rendition) {
|
||||
this.rendition.next()
|
||||
}
|
||||
},
|
||||
keyUp(e) {
|
||||
if (!this.rendition) {
|
||||
console.error('No rendition')
|
||||
return
|
||||
}
|
||||
|
||||
if ((e.keyCode || e.which) == 37) {
|
||||
this.rendition.prev()
|
||||
} else if ((e.keyCode || e.which) == 39) {
|
||||
this.rendition.next()
|
||||
} else if ((e.keyCode || e.which) == 27) {
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
registerListeners() {
|
||||
document.addEventListener('keyup', this.keyUp)
|
||||
},
|
||||
unregisterListeners() {
|
||||
document.removeEventListener('keyup', this.keyUp)
|
||||
},
|
||||
init() {
|
||||
this.registerListeners()
|
||||
|
||||
var book = ePub(this.fullUrl)
|
||||
this.book = book
|
||||
|
||||
this.rendition = book.renderTo('viewer', {
|
||||
width: window.innerWidth - 200,
|
||||
height: 600,
|
||||
ignoreClass: 'annotator-hl',
|
||||
manager: 'continuous',
|
||||
spread: 'always'
|
||||
})
|
||||
var displayed = this.rendition.display()
|
||||
|
||||
book.ready
|
||||
.then(() => {
|
||||
console.log('Book ready')
|
||||
return book.locations.generate(1600)
|
||||
})
|
||||
.then((locations) => {
|
||||
// console.log('Loaded locations', locations)
|
||||
// Wait for book to be rendered to get current page
|
||||
displayed.then(() => {
|
||||
// Get the current CFI
|
||||
var currentLocation = this.rendition.currentLocation()
|
||||
if (!currentLocation.start) {
|
||||
console.error('No Start', currentLocation)
|
||||
} else {
|
||||
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
|
||||
// console.log('current page', currentPage)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
book.loaded.navigation.then((toc) => {
|
||||
var _chapters = []
|
||||
toc.forEach((chapter) => {
|
||||
_chapters.push(chapter)
|
||||
})
|
||||
this.chapters = _chapters
|
||||
})
|
||||
book.loaded.metadata.then((metadata) => {
|
||||
this.author = metadata.creator
|
||||
this.title = metadata.title
|
||||
})
|
||||
|
||||
this.rendition.on('keyup', this.keyUp)
|
||||
|
||||
this.rendition.on('relocated', (location) => {
|
||||
var percent = book.locations.percentageFromCfi(location.start.cfi)
|
||||
var percentage = Math.floor(percent * 100)
|
||||
this.progress = percentage
|
||||
|
||||
this.hasNext = !location.atEnd
|
||||
this.hasPrev = !location.atStart
|
||||
})
|
||||
},
|
||||
close() {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.show) this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,17 +1,27 @@
|
||||
<template>
|
||||
<div class="w-20 bg-bg h-full relative box-shadow-side z-20" style="min-width: 80px">
|
||||
<div class="w-20 bg-bg h-full relative box-shadow-side z-30" style="min-width: 80px">
|
||||
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||
<nuxt-link to="/library" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === '' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Home</p>
|
||||
|
||||
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === '' && !homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Library</p>
|
||||
|
||||
<div v-show="paramId === ''" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
<div v-show="paramId === '' && !homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/library/series" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
@@ -62,8 +72,14 @@ export default {
|
||||
paramId() {
|
||||
return this.$route.params ? this.$route.params.id || '' : ''
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
selectedClassName() {
|
||||
return ''
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'library-library'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
@@ -63,12 +63,15 @@ export default {
|
||||
},
|
||||
playlistUrl() {
|
||||
return this.stream ? this.stream.clientPlaylistUri : null
|
||||
},
|
||||
libraryId() {
|
||||
return this.streamAudiobook ? this.streamAudiobook.libraryId : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
filterByAuthor() {
|
||||
if (this.$route.name !== 'index') {
|
||||
this.$router.push('/library')
|
||||
this.$router.push(`/library/${this.libraryId || this.$store.state.libraries.currentLibraryId}/bookshelf`)
|
||||
}
|
||||
var settingsUpdate = {
|
||||
filterBy: `authors.${this.$encode(this.author)}`
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
<div class="absolute -bottom-4 left-0 triangle-right" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @click.stop>
|
||||
<nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
|
||||
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }">
|
||||
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }" @click="clickCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
|
||||
|
||||
<div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist">
|
||||
@@ -29,10 +29,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="volumeNumber && showVolumeNumber && !isHovering" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ top: 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' }">#{{ volumeNumber }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showExperimentalFeatures && hasEbook" class="absolute rounded-full bg-blue-500 w-6 h-6 flex items-center justify-center bg-opacity-90" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<!-- <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">EBook</p> -->
|
||||
<span class="material-icons text-white text-base">auto_stories</span>
|
||||
</div>
|
||||
|
||||
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
|
||||
@@ -69,6 +74,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
isNew() {
|
||||
return this.tags.includes('New')
|
||||
},
|
||||
@@ -78,8 +86,12 @@ export default {
|
||||
audiobookId() {
|
||||
return this.audiobook.id
|
||||
},
|
||||
hasEbook() {
|
||||
return this.audiobook.numEbooks
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumAudiobooksSelected']
|
||||
// return this.$store.getters['getNumAudiobooksSelected']
|
||||
return !!this.selectedAudiobooks.length
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks
|
||||
@@ -199,7 +211,6 @@ export default {
|
||||
this.selectBtnClick()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
fullCoverUrl() {
|
||||
return this.$store.getters['audiobooks/getBookCoverSrc'](this.book, this.placeholderUrl)
|
||||
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
||||
},
|
||||
cover() {
|
||||
return this.book.cover || this.placeholderUrl
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
|
||||
<nuxt-link :to="`/library/series?${groupType}=${groupEncode}`" class="cursor-pointer">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series?${groupType}=${groupEncode}`" class="cursor-pointer">
|
||||
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: height + 'px', width: height + 'px' }">
|
||||
<cards-group-cover ref="groupcover" :name="groupName" :book-items="bookItems" :width="height" :height="height" />
|
||||
|
||||
@@ -48,6 +48,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
_group() {
|
||||
return this.group || {}
|
||||
},
|
||||
|
||||
@@ -105,7 +105,7 @@ export default {
|
||||
this.coverDiv.remove()
|
||||
this.coverDiv = null
|
||||
}
|
||||
var validCovers = this.bookItems.map((bookItem) => this.getCoverUrl(bookItem.book)).filter((b) => b !== '')
|
||||
var validCovers = this.bookItems.map((bookItem) => this.getCoverUrl(bookItem)).filter((b) => b !== '')
|
||||
if (!validCovers.length) {
|
||||
this.noValidCovers = true
|
||||
return
|
||||
|
||||
@@ -50,12 +50,15 @@ export default {
|
||||
computed: {
|
||||
audiobooks() {
|
||||
return this.$store.state.audiobooks.audiobooks
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitSearch() {
|
||||
if (!this.search) return
|
||||
this.$router.push(`/library/search?query=${this.search}`)
|
||||
this.$router.push(`/library/${this.currentLibraryId}/bookshelf/search?query=${this.search}`)
|
||||
|
||||
this.search = null
|
||||
this.items = []
|
||||
|
||||
45
client/components/modals/EditLibraryModal.vue
Normal file
45
client/components/modals/EditLibraryModal.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="700" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<modals-libraries-edit-library v-if="show" :library="library" :processing.sync="processing" @close="show = false" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
library: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.library ? 'Update Library' : 'New Library'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
99
client/components/modals/LibrariesModal.vue
Normal file
99
client/components/modals/LibrariesModal.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="700" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 200px; max-height: 80vh">
|
||||
<div v-if="!showAddLibrary" class="w-full h-full flex flex-col justify-center px-4">
|
||||
<div class="flex items-center mb-4">
|
||||
<p>{{ libraries.length }} Libraries</p>
|
||||
<!-- <div class="flex-grow" />
|
||||
<ui-btn @click="addLibraryClick">Add Library</ui-btn> -->
|
||||
</div>
|
||||
|
||||
<template v-for="library in libraries">
|
||||
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="false" @edit="editLibrary" @delete="deleteLibrary" @click="clickLibrary" />
|
||||
</template>
|
||||
</div>
|
||||
<modals-libraries-edit-library v-else :library="selectedLibrary" :show="showAddLibrary" :processing.sync="processing" @back="showAddLibrary = false" @close="showAddLibrary = false" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
selectedLibrary: null,
|
||||
processing: false,
|
||||
showAddLibrary: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.libraries.showModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('libraries/setShowModal', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return 'Libraries'
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.currentLibrary ? this.currentLibrary.id : null
|
||||
},
|
||||
libraries() {
|
||||
return this.$store.state.libraries.libraries
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) this.showAddLibrary = false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clickLibrary(library) {
|
||||
await this.$store.dispatch('libraries/fetch', library.id)
|
||||
this.$router.push(`/library/${library.id}`)
|
||||
this.show = false
|
||||
},
|
||||
editLibrary(library) {
|
||||
this.selectedLibrary = library
|
||||
this.showAddLibrary = true
|
||||
},
|
||||
addLibraryClick() {
|
||||
this.selectedLibrary = null
|
||||
this.showAddLibrary = true
|
||||
},
|
||||
deleteLibrary(library) {
|
||||
if (confirm(`Are you sure you want to delete library "${library.name}"?\n(no files will be deleted but book data will be lost)`)) {
|
||||
console.log('Delete library', library)
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/library/${library.id}`)
|
||||
.then(() => {
|
||||
console.log('Library delete success')
|
||||
this.$toast.success(`Library "${library.name}" deleted`)
|
||||
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete library', error)
|
||||
var errMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.$toast.error(errMsg)
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -33,7 +33,7 @@
|
||||
<template v-for="cover in localCovers">
|
||||
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover.localPath)">
|
||||
<div class="h-24 bg-primary" style="width: 60px">
|
||||
<img :src="cover.localPath" class="h-full w-full object-contain" />
|
||||
<img :src="`${cover.localPath}?token=${userToken}`" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -124,21 +124,31 @@ export default {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
},
|
||||
audiobookPath() {
|
||||
return this.audiobook ? this.audiobook.path : null
|
||||
},
|
||||
otherFiles() {
|
||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
||||
},
|
||||
userCanUpload() {
|
||||
return this.$store.getters['user/getUserCanUpload']
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
localCovers() {
|
||||
return this.otherFiles
|
||||
.filter((f) => f.filetype === 'image')
|
||||
.map((file) => {
|
||||
var _file = { ...file }
|
||||
_file.localPath = Path.join('local', _file.path)
|
||||
var imgRelPath = _file.path.replace(this.audiobookPath, '')
|
||||
_file.localPath = `/s/book/${this.audiobookId}${imgRelPath}`
|
||||
return _file
|
||||
})
|
||||
}
|
||||
@@ -162,7 +172,11 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Oops, something went wrong...')
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
} else {
|
||||
this.$toast.error('Oops, something went wrong...')
|
||||
}
|
||||
this.processingUpload = false
|
||||
})
|
||||
},
|
||||
@@ -204,20 +218,39 @@ export default {
|
||||
}
|
||||
|
||||
this.isProcessing = true
|
||||
const updatePayload = {
|
||||
book: {
|
||||
cover: cover
|
||||
var success = false
|
||||
|
||||
// Download cover from url and use
|
||||
if (cover.startsWith('http:') || cover.startsWith('https:')) {
|
||||
success = await this.$axios.$post(`/api/audiobook/${this.audiobook.id}/cover`, { url: cover }).catch((error) => {
|
||||
console.error('Failed to download cover from url', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
}
|
||||
return false
|
||||
})
|
||||
} else {
|
||||
// Update local cover url
|
||||
const updatePayload = {
|
||||
book: {
|
||||
cover: cover
|
||||
}
|
||||
}
|
||||
success = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updatedAudiobook) {
|
||||
if (success) {
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
} else {
|
||||
this.imageUrl = this.book.cover || ''
|
||||
}
|
||||
this.isProcessing = false
|
||||
},
|
||||
getSearchQuery() {
|
||||
var searchQuery = `provider=openlibrary&title=${this.searchTitle}`
|
||||
|
||||
@@ -55,6 +55,15 @@
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
||||
<div class="flex px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||
|
||||
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" direction="bottom" class="ml-4">
|
||||
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :disabled="libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="ml-4">
|
||||
<ui-btn v-if="isRootUser" :loading="rescanning" :disabled="libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<ui-btn type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
@@ -87,7 +96,9 @@ export default {
|
||||
},
|
||||
newTags: [],
|
||||
resettingProgress: false,
|
||||
isScrollable: false
|
||||
isScrollable: false,
|
||||
savingMetadata: false,
|
||||
rescanning: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -107,6 +118,9 @@ export default {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
@@ -124,9 +138,51 @@ export default {
|
||||
},
|
||||
series() {
|
||||
return this.$store.state.audiobooks.series
|
||||
},
|
||||
libraryId() {
|
||||
return this.audiobook ? this.audiobook.libraryId : null
|
||||
},
|
||||
libraryScan() {
|
||||
if (!this.libraryId) return null
|
||||
return this.$store.getters['scanners/getLibraryScan'](this.libraryId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
audiobookScanComplete(result) {
|
||||
this.rescanning = false
|
||||
if (!result) {
|
||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||
} else if (result === 'UPDATED') {
|
||||
this.$toast.success(`Re-Scan complete audiobook was updated`)
|
||||
} else if (result === 'UPTODATE') {
|
||||
this.$toast.success(`Re-Scan complete audiobook was up to date`)
|
||||
} else if (result === 'REMOVED') {
|
||||
this.$toast.error(`Re-Scan complete audiobook was removed`)
|
||||
}
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$root.socket.once('audiobook_scan_complete', this.audiobookScanComplete)
|
||||
this.$root.socket.emit('scan_audiobook', this.audiobookId)
|
||||
},
|
||||
saveMetadataComplete(result) {
|
||||
this.savingMetadata = false
|
||||
if (result.error) {
|
||||
this.$toast.error(result.error)
|
||||
} else if (result.audiobookId) {
|
||||
var { savedPath } = result
|
||||
if (!savedPath) {
|
||||
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
|
||||
} else {
|
||||
this.$toast.success(`Metadata file saved "${result.audiobookTitle}"`)
|
||||
}
|
||||
}
|
||||
},
|
||||
saveMetadata() {
|
||||
this.savingMetadata = true
|
||||
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
||||
this.$root.socket.emit('save_metadata', this.audiobookId)
|
||||
},
|
||||
async submitForm() {
|
||||
if (this.isProcessing) {
|
||||
return
|
||||
@@ -158,6 +214,8 @@ export default {
|
||||
this.details.volumeNumber = this.book.volumeNumber
|
||||
this.details.publishYear = this.book.publishYear
|
||||
|
||||
console.log('INIT', this.details)
|
||||
|
||||
this.newTags = this.audiobook.tags || []
|
||||
},
|
||||
resetProgress() {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<div class="w-full border border-black-200 p-4 my-4">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<!-- <p class="text-lg">{{ isSingleTrack ? 'Single Track' : 'M4B Audiobook File' }}</p> -->
|
||||
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
|
||||
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
|
||||
</div>
|
||||
@@ -14,7 +13,6 @@
|
||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
|
||||
|
||||
<!-- <a v-if="isSingleTrack" :href="`/local/${singleTrackPath}`" class="btn outline-none rounded-md shadow-md relative border border-gray-600 px-4 py-2 bg-primary">Download Track</a> -->
|
||||
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
|
||||
<div v-else>
|
||||
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<th class="text-left">Duration</th>
|
||||
<th v-if="showDownload" class="text-center">Download</th>
|
||||
</tr>
|
||||
<template v-for="track in tracks">
|
||||
<template v-for="track in tracksCleaned">
|
||||
<tr :key="track.index">
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
@@ -28,7 +28,7 @@
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="showDownload" class="font-mono text-center">
|
||||
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
|
||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -59,6 +59,23 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobookPath() {
|
||||
return this.audiobook.path
|
||||
},
|
||||
tracksCleaned() {
|
||||
return this.tracks.map((track) => {
|
||||
var trackPath = track.path.replace(/\\/g, '/')
|
||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
||||
|
||||
return {
|
||||
...track,
|
||||
relativePath: trackPath.replace(audiobookPath, '').replace(/%/g, '%25').replace(/#/g, '%23')
|
||||
}
|
||||
})
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
|
||||
154
client/components/modals/libraries/EditLibrary.vue
Normal file
154
client/components/modals/libraries/EditLibrary.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-4 py-2 mb-12">
|
||||
<div class="flex items-center py-1 mb-2">
|
||||
<span v-show="showDirectoryPicker" class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="backArrowPress">arrow_back</span>
|
||||
<p class="px-4 text-xl">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
|
||||
<ui-text-input-with-label v-model="name" label="Library Name" />
|
||||
|
||||
<div class="w-full py-4">
|
||||
<p class="px-1 text-sm font-semibold">Folders</p>
|
||||
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
|
||||
<!-- <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>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 px-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="submit">{{ library ? 'Update Library' : 'Create Library' }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<modals-libraries-folder-chooser v-else :paths="folderPaths" @select="selectFolder" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
library: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: '',
|
||||
folders: [],
|
||||
showDirectoryPicker: false,
|
||||
newLibraryName: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
if (this.showDirectoryPicker) return 'Choose a Folder'
|
||||
return ''
|
||||
},
|
||||
folderPaths() {
|
||||
return this.folders.map((f) => f.fullPath)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
removeFolder(folder) {
|
||||
this.folders = this.folders.filter((f) => f.fullPath !== folder.fullPath)
|
||||
},
|
||||
backArrowPress() {
|
||||
if (this.showDirectoryPicker) {
|
||||
this.showDirectoryPicker = false
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.name = this.library ? this.library.name : ''
|
||||
this.folders = this.library ? this.library.folders.map((p) => ({ ...p })) : []
|
||||
this.showDirectoryPicker = false
|
||||
},
|
||||
selectFolder(fullPath) {
|
||||
this.folders.push({ fullPath })
|
||||
this.showDirectoryPicker = false
|
||||
},
|
||||
submit() {
|
||||
if (this.library) {
|
||||
this.updateLibrary()
|
||||
} else {
|
||||
this.createLibrary()
|
||||
}
|
||||
},
|
||||
updateLibrary() {
|
||||
if (!this.name) {
|
||||
this.$toast.error('Library must have a name')
|
||||
return
|
||||
}
|
||||
if (!this.folders.length) {
|
||||
this.$toast.error('Library must have at least 1 path')
|
||||
return
|
||||
}
|
||||
var newLibraryPayload = {
|
||||
name: this.name,
|
||||
folders: this.folders
|
||||
}
|
||||
|
||||
this.$emit('update:processing', true)
|
||||
this.$axios
|
||||
.$patch(`/api/library/${this.library.id}`, newLibraryPayload)
|
||||
.then((res) => {
|
||||
this.$emit('update:processing', false)
|
||||
this.$emit('close')
|
||||
this.$toast.success(`Library "${res.name}" updated successfully`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
} else {
|
||||
this.$toast.error('Failed to update library')
|
||||
}
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
},
|
||||
createLibrary() {
|
||||
if (!this.name) {
|
||||
this.$toast.error('Library must have a name')
|
||||
return
|
||||
}
|
||||
if (!this.folders.length) {
|
||||
this.$toast.error('Library must have at least 1 path')
|
||||
return
|
||||
}
|
||||
var newLibraryPayload = {
|
||||
name: this.name,
|
||||
folders: this.folders
|
||||
}
|
||||
|
||||
this.$emit('update:processing', true)
|
||||
this.$axios
|
||||
.$post('/api/library', newLibraryPayload)
|
||||
.then((res) => {
|
||||
this.$emit('update:processing', false)
|
||||
this.$emit('close')
|
||||
this.$toast.success(`Library "${res.name}" created successfully`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
if (error.response && error.response.data) {
|
||||
this.$toast.error(error.response.data)
|
||||
} else {
|
||||
this.$toast.error('Failed to create library')
|
||||
}
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('Mounted edit library')
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
165
client/components/modals/libraries/FolderChooser.vue
Normal file
165
client/components/modals/libraries/FolderChooser.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
|
||||
<p class="font-mono truncate">{{ selectedPath || '\\' }}</p>
|
||||
</div>
|
||||
<div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4">
|
||||
<div class="w-1/2 border-r border-bg">
|
||||
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack">
|
||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<p class="text-base font-mono px-2">..</p>
|
||||
</div>
|
||||
<div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" :class="dir.className" @click="selectDir(dir)">
|
||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
|
||||
<span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" @click="selectSubDir(dir)">
|
||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loadingFolders" class="py-12 text-center">
|
||||
<p>Loading folders...</p>
|
||||
</div>
|
||||
<div v-else class="py-12 text-center">
|
||||
<p class="text-lg mb-2">No Folders Available</p>
|
||||
<p class="text-gray-300">Note: folders already mapped will not be shown</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-4 px-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="selectFolder">Select</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
paths: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loadingFolders: false,
|
||||
allFolders: [],
|
||||
directories: [],
|
||||
selectedPath: '',
|
||||
selectedFullPath: '',
|
||||
subdirs: [],
|
||||
level: 0,
|
||||
currentDir: null,
|
||||
previousDir: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
_directories() {
|
||||
return this.directories.map((d) => {
|
||||
console.log('Directories', d)
|
||||
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
|
||||
var isSelected = d.path === this.selectedPath
|
||||
var classes = []
|
||||
if (isSelected) classes.push('dir-selected')
|
||||
if (isUsed) classes.push('dir-used')
|
||||
return {
|
||||
isUsed,
|
||||
isSelected,
|
||||
className: classes.join(' '),
|
||||
...d
|
||||
}
|
||||
})
|
||||
},
|
||||
_subdirs() {
|
||||
return this.subdirs.map((d) => {
|
||||
var isUsed = !!this.paths.find((path) => path.endsWith(d.path))
|
||||
var classes = []
|
||||
if (isUsed) classes.push('dir-used')
|
||||
return {
|
||||
isUsed,
|
||||
className: classes.join(' '),
|
||||
...d
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
goBack() {
|
||||
var splitPaths = this.selectedPath.split('\\').slice(1)
|
||||
var prev = splitPaths.slice(0, -1).join('\\')
|
||||
|
||||
var currDirs = this.allFolders
|
||||
for (let i = 0; i < splitPaths.length; i++) {
|
||||
var _dir = currDirs.find((dir) => dir.dirname === splitPaths[i])
|
||||
if (_dir && _dir.path.slice(1) === prev) {
|
||||
this.directories = currDirs
|
||||
this.selectDir(_dir)
|
||||
return
|
||||
} else if (_dir) {
|
||||
currDirs = _dir.dirs
|
||||
}
|
||||
}
|
||||
},
|
||||
selectDir(dir) {
|
||||
if (dir.isUsed) return
|
||||
this.selectedPath = dir.path
|
||||
this.selectedFullPath = dir.fullPath
|
||||
this.level = dir.level
|
||||
this.subdirs = dir.dirs
|
||||
},
|
||||
selectSubDir(dir) {
|
||||
if (dir.isUsed) return
|
||||
this.selectedPath = dir.path
|
||||
this.selectedFullPath = dir.fullPath
|
||||
this.level = dir.level
|
||||
this.directories = this.subdirs
|
||||
this.subdirs = dir.dirs
|
||||
},
|
||||
selectFolder() {
|
||||
if (!this.selectedPath) {
|
||||
console.error('No Selected path')
|
||||
return
|
||||
}
|
||||
if (this.paths.find((p) => p.startsWith(this.selectedFullPath))) {
|
||||
this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`)
|
||||
return
|
||||
}
|
||||
this.$emit('select', this.selectedFullPath)
|
||||
this.selectedPath = ''
|
||||
this.selectedFullPath = ''
|
||||
},
|
||||
async init() {
|
||||
this.loadingFolders = true
|
||||
this.allFolders = await this.$store.dispatch('libraries/loadFolders')
|
||||
this.loadingFolders = false
|
||||
|
||||
this.directories = this.allFolders
|
||||
this.subdirs = []
|
||||
this.selectedPath = ''
|
||||
this.selectedFullPath = ''
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('folder chooser mounted')
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
.dir-item.dir-selected {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.dir-item.dir-used {
|
||||
background-color: rgba(255, 25, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
67
client/components/modals/libraries/LibraryItem.vue
Normal file
67
client/components/modals/libraries/LibraryItem.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="w-full px-4 h-12 border border-white border-opacity-10 cursor-pointer flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false" @click="itemClicked">
|
||||
<div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" />
|
||||
<svg v-if="!libraryScan" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white" :class="mouseover ? 'text-opacity-90' : 'text-opacity-50'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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 v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
<p class="text-xl font-book pl-4" :class="mouseover ? 'underline' : ''">{{ library.name }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-show="mouseover && !libraryScan && canScan" small color="bg" @click.stop="scan">Scan</ui-btn>
|
||||
<span v-show="mouseover && !libraryScan && showEdit && canEdit" class="material-icons text-xl text-gray-300 hover:text-gray-50 ml-4" @click.stop="editClick">edit</span>
|
||||
<span v-show="!libraryScan && mouseover && showEdit && canDelete" class="material-icons text-xl text-gray-300 ml-3" :class="isMain ? 'text-opacity-5 cursor-not-allowed' : 'hover:text-gray-50'" @click.stop="deleteClick">delete</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
library: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
selected: Boolean,
|
||||
showEdit: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
mouseover: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isMain() {
|
||||
return this.library.id === 'main'
|
||||
},
|
||||
libraryScan() {
|
||||
return this.$store.getters['scanners/getLibraryScan'](this.library.id)
|
||||
},
|
||||
canEdit() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
canDelete() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
canScan() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
itemClicked() {
|
||||
this.$emit('click', this.library)
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.library)
|
||||
},
|
||||
deleteClick() {
|
||||
if (this.isMain) return
|
||||
this.$emit('delete', this.library)
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan', this.library.id)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
77
client/components/tables/LibrariesTable.vue
Normal file
77
client/components/tables/LibrariesTable.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-xl">Libraries</h1>
|
||||
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddLibrary">
|
||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="library in libraries">
|
||||
<modals-libraries-library-item :key="library.id" :library="library" :selected="currentLibraryId === library.id" :show-edit="true" @edit="editLibrary" @delete="deleteLibrary" @click="clickLibrary" />
|
||||
</template>
|
||||
<modals-edit-library-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showLibraryModal: false,
|
||||
selectedLibrary: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.currentLibrary ? this.currentLibrary.id : null
|
||||
},
|
||||
libraries() {
|
||||
return this.$store.state.libraries.libraries
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clickLibrary(library) {
|
||||
await this.$store.dispatch('libraries/fetch', library.id)
|
||||
this.$router.push(`/library/${library.id}`)
|
||||
},
|
||||
deleteLibrary(library) {
|
||||
if (library.id === 'main') return
|
||||
// if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
||||
// this.isDeletingUser = true
|
||||
// this.$axios
|
||||
// .$delete(`/api/user/${user.id}`)
|
||||
// .then((data) => {
|
||||
// this.isDeletingUser = false
|
||||
// if (data.error) {
|
||||
// this.$toast.error(data.error)
|
||||
// } else {
|
||||
// this.$toast.success('User deleted')
|
||||
// }
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// console.error('Failed to delete user', error)
|
||||
// this.$toast.error('Failed to delete user')
|
||||
// this.isDeletingUser = false
|
||||
// })
|
||||
// }
|
||||
},
|
||||
clickAddLibrary() {
|
||||
this.selectedLibrary = null
|
||||
this.showLibraryModal = true
|
||||
},
|
||||
editLibrary(library) {
|
||||
this.selectedLibrary = library
|
||||
this.showLibraryModal = true
|
||||
},
|
||||
init() {}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -21,14 +21,12 @@
|
||||
<th class="text-left">Duration</th>
|
||||
<th v-if="userCanDownload" class="text-center">Download</th>
|
||||
</tr>
|
||||
<template v-for="track in tracks">
|
||||
<template v-for="track in tracksCleaned">
|
||||
<tr :key="track.index">
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
</td>
|
||||
<td class="font-book">
|
||||
{{ track.filename }}
|
||||
</td>
|
||||
<td class="font-book">{{ track.filename }}</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(track.size) }}
|
||||
</td>
|
||||
@@ -36,7 +34,7 @@
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="userCanDownload" class="text-center">
|
||||
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
|
||||
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -53,7 +51,10 @@ export default {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
audiobookId: String
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -61,6 +62,26 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobookId() {
|
||||
return this.audiobook.id
|
||||
},
|
||||
audiobookPath() {
|
||||
return this.audiobook.path
|
||||
},
|
||||
tracksCleaned() {
|
||||
return this.tracks.map((track) => {
|
||||
var trackPath = track.path.replace(/\\/g, '/')
|
||||
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
|
||||
|
||||
return {
|
||||
...track,
|
||||
relativePath: trackPath.replace(audiobookPath, '').replace(/%/g, '%25').replace(/#/g, '%23')
|
||||
}
|
||||
})
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
|
||||
130
client/components/tables/UsersTable.vue
Normal file
130
client/components/tables/UsersTable.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-xl">Users</h1>
|
||||
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddUser">
|
||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
|
||||
<div class="text-center">
|
||||
<table id="accounts">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Account Type</th>
|
||||
<th style="width: 200px">Created At</th>
|
||||
<th style="width: 100px"></th>
|
||||
</tr>
|
||||
<tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
|
||||
<td>
|
||||
{{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
|
||||
</td>
|
||||
<td>{{ user.type }}</td>
|
||||
<td class="text-sm font-mono">
|
||||
{{ new Date(user.createdAt).toISOString() }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="w-full flex justify-center">
|
||||
<span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click="editUser(user)">edit</span>
|
||||
<span v-show="user.type !== 'root'" class="material-icons text-base hover:text-error cursor-pointer" @click="deleteUserClick(user)">delete</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
users: [],
|
||||
selectedAccount: null,
|
||||
showAccountModal: false,
|
||||
isDeletingUser: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
deleteUserClick(user) {
|
||||
if (this.isDeletingUser) return
|
||||
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
||||
this.isDeletingUser = true
|
||||
this.$axios
|
||||
.$delete(`/api/user/${user.id}`)
|
||||
.then((data) => {
|
||||
this.isDeletingUser = false
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.$toast.success('User deleted')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete user', error)
|
||||
this.$toast.error('Failed to delete user')
|
||||
this.isDeletingUser = false
|
||||
})
|
||||
}
|
||||
},
|
||||
clickAddUser() {
|
||||
this.selectedAccount = null
|
||||
this.showAccountModal = true
|
||||
},
|
||||
editUser(user) {
|
||||
this.selectedAccount = user
|
||||
this.showAccountModal = true
|
||||
},
|
||||
loadUsers() {
|
||||
this.$axios
|
||||
.$get('/api/users')
|
||||
.then((users) => {
|
||||
this.users = users
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
})
|
||||
},
|
||||
addUpdateUser(user) {
|
||||
if (!this.users) return
|
||||
var index = this.users.findIndex((u) => u.id === user.id)
|
||||
if (index >= 0) {
|
||||
this.users.splice(index, 1, user)
|
||||
} else {
|
||||
this.users.push(user)
|
||||
}
|
||||
},
|
||||
userRemoved(user) {
|
||||
this.users = this.users.filter((u) => u.id !== user.id)
|
||||
},
|
||||
init(attempts = 0) {
|
||||
if (!this.$root.socket) {
|
||||
if (attempts > 10) {
|
||||
return console.error('Failed to setup socket listeners')
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.init(++attempts)
|
||||
}, 250)
|
||||
return
|
||||
}
|
||||
this.$root.socket.on('user_added', this.addUpdateUser)
|
||||
this.$root.socket.on('user_updated', this.addUpdateUser)
|
||||
this.$root.socket.on('user_removed', this.userRemoved)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadUsers()
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('user_added', this.newUserAdded)
|
||||
this.$root.socket.off('user_updated', this.userUpdated)
|
||||
this.$root.socket.off('user_removed', this.userRemoved)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
77
client/components/ui/Dropdown.vue
Normal file
77
client/components/ui/Dropdown.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="relative w-full" v-click-outside="clickOutside">
|
||||
<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">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate">{{ selectedText }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span class="material-icons text-gray-100">chevron_down</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMenu: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
selectedItem() {
|
||||
return this.items.find((i) => i.value === this.selected)
|
||||
},
|
||||
selectedText() {
|
||||
return this.selectedItem ? this.selectedItem.text : ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickShowMenu() {
|
||||
if (this.disabled) return
|
||||
this.showMenu = !this.showMenu
|
||||
},
|
||||
clickOutside() {
|
||||
this.showMenu = false
|
||||
},
|
||||
clickedOption(itemValue) {
|
||||
this.selected = itemValue
|
||||
this.showMenu = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
58
client/components/ui/EditableText.vue
Normal file
58
client/components/ui/EditableText.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-1 bg-transparent border-b border-opacity-0 border-gray-400 focus:border-opacity-100 focus:outline-none" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
placeholder: String,
|
||||
readonly: Boolean,
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
inputValue: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
focused() {
|
||||
this.$emit('focus')
|
||||
},
|
||||
blurred() {
|
||||
this.$emit('blur')
|
||||
},
|
||||
change(e) {
|
||||
this.$emit('change', e.target.value)
|
||||
},
|
||||
keyup(e) {
|
||||
this.$emit('keyup', e)
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
border-style: inherit !important;
|
||||
}
|
||||
input:read-only {
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
inputAccept: 'image/*'
|
||||
inputAccept: '.png, .jpg, .jpeg, .webp'
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-12 justify-start" :class="className" @click="clickToggle">
|
||||
<span class="rounded-full border w-6 h-6 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
|
||||
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-10 justify-start" :class="className" @click="clickToggle">
|
||||
<span class="rounded-full border w-5 h-5 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,12 +30,12 @@ export default {
|
||||
}
|
||||
},
|
||||
className() {
|
||||
if (this.disabled) return 'bg-bg cursor-not-allowed'
|
||||
if (this.disabled) return this.toggleValue ? `bg-${this.onColor} cursor-not-allowed` : `bg-${this.offColor} cursor-not-allowed`
|
||||
return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
|
||||
},
|
||||
switchClassName() {
|
||||
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
|
||||
return this.toggleValue ? 'translate-x-6 ' + bgColor : bgColor
|
||||
return this.toggleValue ? 'translate-x-5 ' + bgColor : bgColor
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -20,6 +20,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
tooltip: null,
|
||||
tooltipId: null,
|
||||
isShowing: false
|
||||
}
|
||||
},
|
||||
@@ -45,13 +46,14 @@ export default {
|
||||
'font-size': '0.75rem'
|
||||
}
|
||||
var size = this.$calculateTextSize(this.text, styles)
|
||||
console.log('Text Size', size.width, size.height)
|
||||
return size.width
|
||||
},
|
||||
createTooltip() {
|
||||
if (!this.$refs.box) return
|
||||
var tooltip = document.createElement('div')
|
||||
tooltip.className = 'absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
|
||||
this.tooltipId = String(Math.floor(Math.random() * 10000))
|
||||
tooltip.id = this.tooltipId
|
||||
tooltip.className = 'tooltip-wrapper absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
|
||||
tooltip.style.zIndex = 100
|
||||
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
||||
tooltip.innerHTML = this.text
|
||||
@@ -91,8 +93,14 @@ export default {
|
||||
if (this.disabled) return
|
||||
if (!this.tooltip) {
|
||||
this.createTooltip()
|
||||
if (!this.tooltip) return
|
||||
}
|
||||
if (!this.$refs.box) return // Ensure element is not destroyed
|
||||
try {
|
||||
document.body.appendChild(this.tooltip)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
document.body.appendChild(this.tooltip)
|
||||
this.isShowing = true
|
||||
},
|
||||
hideTooltip() {
|
||||
|
||||
33
client/components/widgets/CloseButton.vue
Normal file
33
client/components/widgets/CloseButton.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<button class="bg-error text-white px-2 py-1 shadow-md" @click="$emit('click', $event)">Cancel</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.Vue-Toastification__close-button.cancel-scan-btn {
|
||||
background-color: rgb(255, 82, 82);
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
opacity: 1;
|
||||
padding: 0px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: normal;
|
||||
font-family: 'Open Sans';
|
||||
margin-left: 10px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.Vue-Toastification__close-button.cancel-scan-btn:hover {
|
||||
background-color: rgb(235, 65, 65);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -5,12 +5,15 @@
|
||||
<Nuxt />
|
||||
|
||||
<app-stream-container ref="streamContainer" />
|
||||
<modals-libraries-modal />
|
||||
<modals-edit-modal />
|
||||
<widgets-scan-alert />
|
||||
<!-- <widgets-scan-alert /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CloseButton from '@/components/widgets/CloseButton'
|
||||
|
||||
export default {
|
||||
middleware: 'authenticated',
|
||||
data() {
|
||||
@@ -64,6 +67,11 @@ export default {
|
||||
if (payload.serverSettings) {
|
||||
this.$store.commit('setServerSettings', payload.serverSettings)
|
||||
}
|
||||
if (payload.librariesScanning) {
|
||||
payload.librariesScanning.forEach((libraryScan) => {
|
||||
this.scanStart(libraryScan)
|
||||
})
|
||||
}
|
||||
},
|
||||
streamOpen(stream) {
|
||||
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
|
||||
@@ -89,43 +97,61 @@ export default {
|
||||
audiobookRemoved(audiobook) {
|
||||
if (this.$route.name.startsWith('audiobook')) {
|
||||
if (this.$route.params.id === audiobook.id) {
|
||||
this.$router.replace('/library')
|
||||
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
|
||||
}
|
||||
}
|
||||
this.$store.commit('audiobooks/remove', audiobook)
|
||||
},
|
||||
scanComplete({ scanType, results }) {
|
||||
if (scanType === 'covers') {
|
||||
this.$store.commit('setIsScanningCovers', false)
|
||||
if (results) {
|
||||
this.$toast.success(`Scan Finished\nUpdated ${results.found} covers`)
|
||||
}
|
||||
} else {
|
||||
this.$store.commit('setIsScanning', false)
|
||||
if (results) {
|
||||
var scanResultMsgs = []
|
||||
if (results.added) scanResultMsgs.push(`${results.added} added`)
|
||||
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
|
||||
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
|
||||
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
|
||||
if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
|
||||
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
|
||||
}
|
||||
}
|
||||
libraryAdded(library) {
|
||||
this.$store.commit('libraries/addUpdate', library)
|
||||
},
|
||||
scanStart(scanType) {
|
||||
if (scanType === 'covers') {
|
||||
this.$store.commit('setIsScanningCovers', true)
|
||||
} else {
|
||||
this.$store.commit('setIsScanning', true)
|
||||
}
|
||||
libraryUpdated(library) {
|
||||
this.$store.commit('libraries/addUpdate', library)
|
||||
},
|
||||
scanProgress({ scanType, progress }) {
|
||||
if (scanType === 'covers') {
|
||||
this.$store.commit('setCoverScanProgress', progress)
|
||||
libraryRemoved(library) {
|
||||
this.$store.commit('libraries/remove', library)
|
||||
},
|
||||
scanComplete(data) {
|
||||
var message = `Scan "${data.name}" complete!`
|
||||
if (data.results) {
|
||||
var scanResultMsgs = []
|
||||
var results = data.results
|
||||
if (results.added) scanResultMsgs.push(`${results.added} added`)
|
||||
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
|
||||
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
|
||||
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
|
||||
if (!scanResultMsgs.length) message += '\nEverything was up to date'
|
||||
else message += '\n' + scanResultMsgs.join('\n')
|
||||
} else {
|
||||
this.$store.commit('setScanProgress', progress)
|
||||
message = `Scan "${data.name}" was canceled`
|
||||
}
|
||||
|
||||
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
|
||||
if (existingScan && !isNaN(existingScan.toastId)) {
|
||||
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: 'success', closeButton: false, position: 'bottom-center', onClose: () => null } }, true)
|
||||
} else {
|
||||
this.$toast.success(message, { timeout: 5000, position: 'bottom-center' })
|
||||
}
|
||||
|
||||
this.$store.commit('scanners/remove', data)
|
||||
},
|
||||
onScanToastCancel(id) {
|
||||
this.$root.socket.emit('cancel_scan', id)
|
||||
},
|
||||
scanStart(data) {
|
||||
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
|
||||
this.$store.commit('scanners/addUpdate', data)
|
||||
},
|
||||
scanProgress(data) {
|
||||
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
|
||||
if (existingScan && !isNaN(existingScan.toastId)) {
|
||||
data.toastId = existingScan.toastId
|
||||
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
|
||||
} else {
|
||||
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, position: 'bottom-center', onClose: () => this.onScanToastCancel(data.id) })
|
||||
}
|
||||
|
||||
this.$store.commit('scanners/addUpdate', data)
|
||||
},
|
||||
userUpdated(user) {
|
||||
if (this.$store.state.user.user.id === user.id) {
|
||||
@@ -137,6 +163,7 @@ export default {
|
||||
if (!download || !download.audiobookId) {
|
||||
return console.error('Invalid download object', download)
|
||||
}
|
||||
|
||||
var audiobook = this.$store.getters['audiobooks/getAudiobook'](download.audiobookId)
|
||||
if (!audiobook) {
|
||||
return console.error('Audiobook not found for download', download)
|
||||
@@ -190,6 +217,9 @@ export default {
|
||||
download.status = this.$constants.DownloadStatus.EXPIRED
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
logEvtReceived(payload) {
|
||||
this.$store.commit('logs/logEvt', payload)
|
||||
},
|
||||
initializeSocket() {
|
||||
this.socket = this.$nuxtSocket({
|
||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||
@@ -223,6 +253,11 @@ export default {
|
||||
this.socket.on('audiobook_added', this.audiobookAdded)
|
||||
this.socket.on('audiobook_removed', this.audiobookRemoved)
|
||||
|
||||
// Library Listeners
|
||||
this.socket.on('library_updated', this.libraryUpdated)
|
||||
this.socket.on('library_added', this.libraryAdded)
|
||||
this.socket.on('library_removed', this.libraryRemoved)
|
||||
|
||||
// User Listeners
|
||||
this.socket.on('user_updated', this.userUpdated)
|
||||
|
||||
@@ -237,6 +272,8 @@ export default {
|
||||
this.socket.on('download_failed', this.downloadFailed)
|
||||
this.socket.on('download_killed', this.downloadKilled)
|
||||
this.socket.on('download_expired', this.downloadExpired)
|
||||
|
||||
this.socket.on('log', this.logEvtReceived)
|
||||
},
|
||||
showUpdateToast(versionData) {
|
||||
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
||||
@@ -265,6 +302,8 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.initializeSocket()
|
||||
this.$store.dispatch('libraries/load')
|
||||
|
||||
this.$store
|
||||
.dispatch('checkForUpdate')
|
||||
.then((res) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export default function ({ store, redirect, route, app }) {
|
||||
// If the user is not authenticated
|
||||
if (!store.state.user.user) {
|
||||
if (route.name === 'batch') return redirect('/login')
|
||||
if (route.name === 'batch' || route.name === 'index') return redirect('/login')
|
||||
return redirect(`/login?redirect=${route.fullPath}`)
|
||||
}
|
||||
}
|
||||
24
client/middleware/routed.js
Normal file
24
client/middleware/routed.js
Normal file
@@ -0,0 +1,24 @@
|
||||
export default function (context) {
|
||||
if (process.client) {
|
||||
var route = context.route
|
||||
var from = context.from
|
||||
var store = context.store
|
||||
|
||||
if (route.name === 'login' || from.name === 'login') return
|
||||
|
||||
if (!route.name) {
|
||||
console.warn('No Route name', route)
|
||||
return
|
||||
}
|
||||
|
||||
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id')) {
|
||||
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && from.name !== 'config' && from.name !== 'config-log' && from.name !== 'upload' && from.name !== 'account') {
|
||||
var _history = [...store.state.routeHistory]
|
||||
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
|
||||
_history.push(from.fullPath)
|
||||
store.commit('setRouteHistory', _history)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,10 @@ module.exports = {
|
||||
]
|
||||
},
|
||||
|
||||
router: {
|
||||
middleware: ['routed']
|
||||
},
|
||||
|
||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||
css: [
|
||||
'@/assets/app.css'
|
||||
@@ -71,7 +75,9 @@ module.exports = {
|
||||
|
||||
proxy: {
|
||||
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
|
||||
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/', pathRewrite: { '^/local/': '' } },
|
||||
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||
'/lib/': { 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' : '/' }
|
||||
},
|
||||
|
||||
|
||||
160
client/package-lock.json
generated
160
client/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.1.13",
|
||||
"version": "1.3.4",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -2532,6 +2532,14 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/less/-/less-3.0.2.tgz",
|
||||
"integrity": "sha512-62vfe65cMSzYaWmpmhqCMMNl0khen89w57mByPi1OseGfcV/LV03fO8YVrNj7rFQsRWNJo650WWyh6m7p8vZmA=="
|
||||
},
|
||||
"@types/localforage": {
|
||||
"version": "0.0.34",
|
||||
"resolved": "https://registry.npmjs.org/@types/localforage/-/localforage-0.0.34.tgz",
|
||||
"integrity": "sha1-XjHDLdh5HsS5/z70fJy1Wy0NlDg=",
|
||||
"requires": {
|
||||
"localforage": "*"
|
||||
}
|
||||
},
|
||||
"@types/mime": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
|
||||
@@ -4978,6 +4986,15 @@
|
||||
"resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz",
|
||||
"integrity": "sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk="
|
||||
},
|
||||
"d": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz",
|
||||
"integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==",
|
||||
"requires": {
|
||||
"es5-ext": "^0.10.50",
|
||||
"type": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"de-indent": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||
@@ -5373,6 +5390,22 @@
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
||||
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="
|
||||
},
|
||||
"epubjs": {
|
||||
"version": "0.3.88",
|
||||
"resolved": "https://registry.npmjs.org/epubjs/-/epubjs-0.3.88.tgz",
|
||||
"integrity": "sha512-VRumULpUELYmYwzypyfbDwoSIqDp2LXOXCtY3o55o3YDW5Zm32UjtZuX/xaWFGqyZORNNMWWQ8VlMaY1djnDYg==",
|
||||
"requires": {
|
||||
"@types/localforage": "0.0.34",
|
||||
"core-js": "^3.6.5",
|
||||
"event-emitter": "^0.3.5",
|
||||
"jszip": "^3.4.0",
|
||||
"localforage": "^1.7.3",
|
||||
"lodash": "^4.17.15",
|
||||
"marks-pane": "^1.0.9",
|
||||
"path-webpack": "0.0.3",
|
||||
"xmldom": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"errno": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
|
||||
@@ -5431,6 +5464,35 @@
|
||||
"is-symbol": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"es5-ext": {
|
||||
"version": "0.10.53",
|
||||
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.53.tgz",
|
||||
"integrity": "sha512-Xs2Stw6NiNHWypzRTY1MtaG/uJlwCk8kH81920ma8mvN8Xq1gsfhZvpkImLQArw8AHnv8MT2I45J3c0R8slE+Q==",
|
||||
"requires": {
|
||||
"es6-iterator": "~2.0.3",
|
||||
"es6-symbol": "~3.1.3",
|
||||
"next-tick": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"es6-iterator": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
|
||||
"integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=",
|
||||
"requires": {
|
||||
"d": "1",
|
||||
"es5-ext": "^0.10.35",
|
||||
"es6-symbol": "^3.1.1"
|
||||
}
|
||||
},
|
||||
"es6-symbol": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz",
|
||||
"integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==",
|
||||
"requires": {
|
||||
"d": "^1.0.1",
|
||||
"ext": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"escalade": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
|
||||
@@ -5490,6 +5552,15 @@
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
|
||||
},
|
||||
"event-emitter": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
|
||||
"integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=",
|
||||
"requires": {
|
||||
"d": "1",
|
||||
"es5-ext": "~0.10.14"
|
||||
}
|
||||
},
|
||||
"eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
@@ -5580,6 +5651,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ext": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz",
|
||||
"integrity": "sha512-sdBImtzkq2HpkdRLtlLWDa6w4DX22ijZLKx8BMPUuKe1c5lbN6xwQDQCxSfxBQnHZ13ls/FH0MQZx/q/gr6FQg==",
|
||||
"requires": {
|
||||
"type": "^2.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"type": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/type/-/type-2.5.0.tgz",
|
||||
"integrity": "sha512-180WMDQaIMm3+7hGXWf12GtdniDEy7nYcyFMKJn/eZz/6tSLXrUN9V0wKSbMjej0I1WHWbpREDEKHtqPQa9NNw=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"extend-shallow": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz",
|
||||
@@ -6583,6 +6669,11 @@
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
|
||||
"integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw=="
|
||||
},
|
||||
"immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps="
|
||||
},
|
||||
"import-cwd": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
|
||||
@@ -7105,6 +7196,27 @@
|
||||
"universalify": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"jszip": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.7.1.tgz",
|
||||
"integrity": "sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg==",
|
||||
"requires": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"set-immediate-shim": "~1.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"requires": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"keygrip": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
|
||||
@@ -7263,6 +7375,14 @@
|
||||
"launch-editor": "^2.2.1"
|
||||
}
|
||||
},
|
||||
"lie": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
|
||||
"integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=",
|
||||
"requires": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"lilconfig": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.3.tgz",
|
||||
@@ -7300,6 +7420,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"localforage": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
|
||||
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
|
||||
"requires": {
|
||||
"lie": "3.1.1"
|
||||
}
|
||||
},
|
||||
"locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
@@ -7412,6 +7540,11 @@
|
||||
"object-visit": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"marks-pane": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/marks-pane/-/marks-pane-1.0.9.tgz",
|
||||
"integrity": "sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg=="
|
||||
},
|
||||
"md5.js": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
|
||||
@@ -7713,6 +7846,11 @@
|
||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
|
||||
},
|
||||
"next-tick": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
|
||||
"integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw="
|
||||
},
|
||||
"no-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
|
||||
@@ -8334,6 +8472,11 @@
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="
|
||||
},
|
||||
"path-webpack": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/path-webpack/-/path-webpack-0.0.3.tgz",
|
||||
"integrity": "sha1-/23sdJ7sWpRgXATV9j/FVgegOhY="
|
||||
},
|
||||
"pbkdf2": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.2.tgz",
|
||||
@@ -11708,6 +11851,11 @@
|
||||
"resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz",
|
||||
"integrity": "sha1-8Tv5KOQrnD55OD5hzDmYtdFObN0="
|
||||
},
|
||||
"set-immediate-shim": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz",
|
||||
"integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E="
|
||||
},
|
||||
"set-value": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz",
|
||||
@@ -12814,6 +12962,11 @@
|
||||
"resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz",
|
||||
"integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY="
|
||||
},
|
||||
"type": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz",
|
||||
"integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg=="
|
||||
},
|
||||
"type-fest": {
|
||||
"version": "0.21.3",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
|
||||
@@ -14145,6 +14298,11 @@
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz",
|
||||
"integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg=="
|
||||
},
|
||||
"xmldom": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.3.0.tgz",
|
||||
"integrity": "sha512-z9s6k3wxE+aZHgXYxSTpGDo7BYOUfJsIRyoZiX6HTjwpwfS2wpQBQKa2fD+ShLyPkqDYo5ud7KitmLZ2Cd6r0g=="
|
||||
},
|
||||
"xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.2.4",
|
||||
"version": "1.4.0",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -15,6 +15,7 @@
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"@nuxtjs/proxy": "^2.1.0",
|
||||
"core-js": "^3.16.0",
|
||||
"epubjs": "^0.3.88",
|
||||
"hls.js": "^1.0.7",
|
||||
"nuxt": "^2.15.7",
|
||||
"nuxt-socket-io": "^1.1.18",
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
<div class="flex-grow px-10">
|
||||
<div class="flex">
|
||||
<div class="mb-2">
|
||||
<h1 class="text-2xl font-book leading-7">{{ title }}</h1>
|
||||
<h1 class="text-2xl font-book leading-7">
|
||||
{{ title }}<span v-if="isDeveloperMode"> ({{ audiobook.ino }})</span>
|
||||
</h1>
|
||||
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
|
||||
<div class="w-min">
|
||||
<ui-tooltip :text="authorTooltipText" direction="bottom">
|
||||
@@ -42,6 +44,11 @@
|
||||
Missing
|
||||
</ui-btn>
|
||||
|
||||
<ui-btn v-if="showExperimentalFeatures && epubEbook" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
||||
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
|
||||
Read
|
||||
</ui-btn>
|
||||
|
||||
<ui-tooltip v-if="userCanUpdate" text="Edit" direction="top">
|
||||
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
|
||||
</ui-tooltip>
|
||||
@@ -79,7 +86,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-tracks-table :tracks="tracks" :audiobook-id="audiobook.id" class="mt-6" />
|
||||
<tables-tracks-table :tracks="tracks" :audiobook="audiobook" class="mt-6" />
|
||||
|
||||
<tables-audio-files-table v-if="otherAudioFiles.length" :audiobook-id="audiobook.id" :files="otherAudioFiles" class="mt-6" />
|
||||
|
||||
@@ -87,6 +94,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-reader v-if="showExperimentalFeatures" v-model="showReader" :url="epubUrl" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -104,12 +113,14 @@ export default {
|
||||
console.error('No audiobook...', params.id)
|
||||
return redirect('/')
|
||||
}
|
||||
store.commit('audiobooks/addUpdate', audiobook)
|
||||
return {
|
||||
audiobook
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showReader: false,
|
||||
isRead: false,
|
||||
resettingProgress: false,
|
||||
isProcessingReadUpdate: false
|
||||
@@ -127,6 +138,9 @@ export default {
|
||||
isDeveloperMode() {
|
||||
return this.$store.state.developerMode
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
missingPartChunks() {
|
||||
if (this.missingParts === 1) return this.missingParts[0]
|
||||
var chunks = []
|
||||
@@ -223,6 +237,15 @@ export default {
|
||||
audioFiles() {
|
||||
return this.audiobook.audioFiles || []
|
||||
},
|
||||
ebooks() {
|
||||
return this.audiobook.ebooks
|
||||
},
|
||||
epubEbook() {
|
||||
return this.audiobook.ebooks.find((eb) => eb.ext === '.epub')
|
||||
},
|
||||
epubUrl() {
|
||||
return this.epubEbook ? this.epubEbook.path : null
|
||||
},
|
||||
description() {
|
||||
return this.book.description || ''
|
||||
},
|
||||
@@ -261,6 +284,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openEbook() {
|
||||
this.showReader = true
|
||||
},
|
||||
toggleRead() {
|
||||
var updatePayload = {
|
||||
isRead: !this.isRead
|
||||
|
||||
@@ -1,48 +1,46 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-2xl">Users</h1>
|
||||
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddUser">
|
||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||
<tables-users-table />
|
||||
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
|
||||
|
||||
<tables-libraries-table />
|
||||
<!-- <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> -->
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-xl">Settings</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" small :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
|
||||
<ui-tooltip :text="parseSubtitleTooltip">
|
||||
<p class="pl-4 text-lg">Scanner parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerFindCovers" :disabled="updatingServerSettings" @input="updateScannerFindCovers" />
|
||||
<ui-tooltip :text="scannerFindCoversTooltip">
|
||||
<p class="pl-4 text-lg">Scanner find covers <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<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>
|
||||
<!-- <ui-btn small :padding-x="4" class="h-8">Create User</ui-btn> -->
|
||||
</div>
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
<div class="p-4 text-center">
|
||||
<table id="accounts" class="mb-8">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Account Type</th>
|
||||
<th style="width: 200px">Created At</th>
|
||||
<th style="width: 100px"></th>
|
||||
</tr>
|
||||
<tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
|
||||
<td>
|
||||
{{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
|
||||
</td>
|
||||
<td>{{ user.type }}</td>
|
||||
<td class="text-sm font-mono">
|
||||
{{ new Date(user.createdAt).toISOString() }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="w-full flex justify-center">
|
||||
<span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click="editUser(user)">edit</span>
|
||||
<span v-show="user.type !== 'root'" class="material-icons text-base hover:text-error cursor-pointer" @click="deleteUserClick(user)">delete</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
<div class="py-4 mb-8">
|
||||
|
||||
<!-- <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" @input="updateScannerParseSubtitle" />
|
||||
<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>
|
||||
<p class="pl-4 text-lg">Parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,8 +48,8 @@
|
||||
<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">
|
||||
<ui-tooltip direction="bottom" text="Only scans audiobooks without a cover. Covers will be applied if a close match is found." class="w-full">
|
||||
<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>
|
||||
@@ -61,8 +59,32 @@
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
|
||||
<div class="py-4 mb-4">
|
||||
<p class="text-2xl">Metadata</p>
|
||||
<div class="flex items-start py-2">
|
||||
<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 class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
|
||||
<div class="flex items-center py-4">
|
||||
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn to="/config/log">View Logger</ui-btn>
|
||||
</div>
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
@@ -79,10 +101,28 @@
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
|
||||
|
||||
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
|
||||
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
||||
|
||||
<div class="py-12 mb-4 opacity-60 hover:opacity-100">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="showExperimentalFeatures" @input="toggleShowExperimentalFeatures" />
|
||||
<ui-tooltip :text="experimentalFeaturesTooltip">
|
||||
<p class="pl-4 text-lg">Experimental Features <span class="material-icons icon-text">info_outlined</span></p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="flex-grow" /> -->
|
||||
<div>
|
||||
<a href="https://github.com/advplyr/audiobookshelf/discussions/75#discussion-3604812" target="_blank" class="text-blue-500 hover:text-blue-300 underline">Join the discussion</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -95,18 +135,17 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
storeCoversInAudiobookDir: false,
|
||||
isResettingAudiobooks: false,
|
||||
users: [],
|
||||
selectedAccount: null,
|
||||
showAccountModal: false,
|
||||
isDeletingUser: false,
|
||||
newServerSettings: {}
|
||||
newServerSettings: {},
|
||||
updatingServerSettings: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
serverSettings(newVal, oldVal) {
|
||||
if (newVal && !oldVal) {
|
||||
this.newServerSettings = { ...this.serverSettings }
|
||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -114,6 +153,18 @@ export default {
|
||||
parseSubtitleTooltip() {
|
||||
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
|
||||
},
|
||||
coverDestinationTooltip() {
|
||||
return 'By default covers are stored in /metadata/books, enabling this setting will store covers inside your audiobooks directory. Only one file named "cover" will be kept.'
|
||||
},
|
||||
scannerFindCoversTooltip() {
|
||||
return 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time'
|
||||
},
|
||||
saveMetadataTooltip() {
|
||||
return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
|
||||
},
|
||||
experimentalFeaturesTooltip() {
|
||||
return 'Features in development that could use your feedback and help testing.'
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
@@ -125,20 +176,42 @@ export default {
|
||||
},
|
||||
isScanningCovers() {
|
||||
return this.$store.state.isScanningCovers
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleShowExperimentalFeatures() {
|
||||
this.$store.commit('setExperimentalFeatures', !this.showExperimentalFeatures)
|
||||
},
|
||||
updateScannerFindCovers(val) {
|
||||
this.updateServerSettings({
|
||||
scannerFindCovers: !!val
|
||||
})
|
||||
},
|
||||
updateCoverStorageDestination(val) {
|
||||
this.newServerSettings.coverDestination = val ? this.$constants.CoverDestination.AUDIOBOOK : this.$constants.CoverDestination.METADATA
|
||||
this.updateServerSettings({
|
||||
coverDestination: this.newServerSettings.coverDestination
|
||||
})
|
||||
},
|
||||
updateScannerParseSubtitle(val) {
|
||||
var payload = {
|
||||
scannerParseSubtitle: val
|
||||
}
|
||||
this.updateServerSettings({
|
||||
scannerParseSubtitle: !!val
|
||||
})
|
||||
},
|
||||
updateServerSettings(payload) {
|
||||
this.updatingServerSettings = true
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
},
|
||||
setDeveloperMode() {
|
||||
@@ -147,20 +220,20 @@ export default {
|
||||
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan')
|
||||
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
|
||||
},
|
||||
scanCovers() {
|
||||
this.$root.socket.emit('scan_covers')
|
||||
},
|
||||
loadUsers() {
|
||||
this.$axios
|
||||
.$get('/api/users')
|
||||
.then((users) => {
|
||||
this.users = users
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
})
|
||||
saveMetadataComplete(result) {
|
||||
this.savingMetadata = false
|
||||
if (!result) return
|
||||
this.$toast.success(`Metadata saved for ${result.success} audiobooks`)
|
||||
},
|
||||
saveMetadataFiles() {
|
||||
this.savingMetadata = true
|
||||
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
||||
this.$root.socket.emit('save_metadata')
|
||||
},
|
||||
resetAudiobooks() {
|
||||
if (confirm('WARNING! This action will remove all audiobooks from the database including any updates or matches you have made. This does not do anything to your actual files. Shall we continue?')) {
|
||||
@@ -170,81 +243,22 @@ export default {
|
||||
.then(() => {
|
||||
this.isResettingAudiobooks = false
|
||||
this.$toast.success('Successfully reset audiobooks')
|
||||
location.reload()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('failed to reset audiobooks', error)
|
||||
this.isResettingAudiobooks = false
|
||||
this.$toast.error('Failed to reset audiobooks - stop docker and manually remove appdata')
|
||||
this.$toast.error('Failed to reset audiobooks - manually remove the /config/audiobooks folder')
|
||||
})
|
||||
}
|
||||
},
|
||||
clickAddUser() {
|
||||
this.selectedAccount = null
|
||||
this.showAccountModal = true
|
||||
},
|
||||
editUser(user) {
|
||||
this.selectedAccount = user
|
||||
this.showAccountModal = true
|
||||
},
|
||||
deleteUserClick(user) {
|
||||
if (this.isDeletingUser) return
|
||||
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
|
||||
this.isDeletingUser = true
|
||||
this.$axios
|
||||
.$delete(`/api/user/${user.id}`)
|
||||
.then((data) => {
|
||||
this.isDeletingUser = false
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.$toast.success('User deleted')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete user', error)
|
||||
this.$toast.error('Failed to delete user')
|
||||
this.isDeletingUser = false
|
||||
})
|
||||
}
|
||||
},
|
||||
addUpdateUser(user) {
|
||||
if (!this.users) return
|
||||
var index = this.users.findIndex((u) => u.id === user.id)
|
||||
if (index >= 0) {
|
||||
this.users.splice(index, 1, user)
|
||||
} else {
|
||||
this.users.push(user)
|
||||
}
|
||||
},
|
||||
userRemoved(user) {
|
||||
this.users = this.users.filter((u) => u.id !== user.id)
|
||||
},
|
||||
init(attempts = 0) {
|
||||
if (!this.$root.socket) {
|
||||
if (attempts > 10) {
|
||||
return console.error('Failed to setup socket listeners')
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.init(++attempts)
|
||||
}, 250)
|
||||
return
|
||||
}
|
||||
this.$root.socket.on('user_added', this.addUpdateUser)
|
||||
this.$root.socket.on('user_updated', this.addUpdateUser)
|
||||
this.$root.socket.on('user_removed', this.userRemoved)
|
||||
|
||||
init() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadUsers()
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('user_added', this.newUserAdded)
|
||||
this.$root.socket.off('user_updated', this.userUpdated)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
138
client/pages/config/log.vue
Normal file
138
client/pages/config/log.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<p class="text-2xl">Logger</p>
|
||||
|
||||
<div class="w-44">
|
||||
<ui-dropdown v-model="newServerSettings.logLevel" label="Server Log Level" :items="logLevelItems" @input="logLevelUpdated" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div ref="container" class="relative w-full h-full bg-primary border-bg overflow-x-hidden overflow-y-auto text-red shadow-inner rounded-md" style="max-height: 550px; min-height: 550px">
|
||||
<template v-for="(log, index) in logs">
|
||||
<div :key="index" class="flex flex-nowrap px-2 py-1 items-start text-sm bg-opacity-10" :class="`bg-${logColors[log.level]}`">
|
||||
<p class="text-gray-400 w-40 font-mono">{{ log.timestamp.split('.')[0].split('T').join(' ') }}</p>
|
||||
<p class="font-semibold w-12 text-right" :class="`text-${logColors[log.level]}`">{{ log.levelName }}</p>
|
||||
<p class="px-4 logmessage">{{ log.message }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="!logs.length" class="absolute top-0 left-0 w-full h-full flex flex-col items-center justify-center text-center">
|
||||
<p class="text-xl text-gray-200 mb-2">No Logs</p>
|
||||
<p class="text-base text-gray-400">Log listening starts when you login</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsRoot']) {
|
||||
redirect('/?error=unauthorized')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newServerSettings: {},
|
||||
logColors: ['yellow-200', 'gray-400', 'info', 'warning', 'error', 'red-800', 'blue-400'],
|
||||
logLevels: [
|
||||
{
|
||||
value: 1,
|
||||
text: 'Debug'
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
text: 'Info'
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
text: 'Warn'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
serverSettings(newVal, oldVal) {
|
||||
if (newVal && !oldVal) {
|
||||
this.newServerSettings = { ...this.serverSettings }
|
||||
}
|
||||
},
|
||||
logs() {
|
||||
this.updateScroll()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
logLevelItems() {
|
||||
if (process.env.NODE_ENV === 'production') return this.logLevels
|
||||
this.logLevels.unshift({ text: 'Trace', value: 0 })
|
||||
return this.logLevels
|
||||
},
|
||||
logs() {
|
||||
return this.$store.state.logs.logs.filter((log) => {
|
||||
return log.level >= this.newServerSettings.logLevel
|
||||
})
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateScroll() {
|
||||
if (this.$refs.container) {
|
||||
this.$refs.container.scrollTop = this.$refs.container.scrollHeight - this.$refs.container.clientHeight
|
||||
}
|
||||
},
|
||||
logLevelUpdated(val) {
|
||||
var payload = {
|
||||
logLevel: Number(val)
|
||||
}
|
||||
this.updateServerSettings(payload)
|
||||
|
||||
this.$store.dispatch('logs/setLogListener', this.newServerSettings.logLevel)
|
||||
this.$nextTick(this.updateScroll)
|
||||
},
|
||||
updateServerSettings(payload) {
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
})
|
||||
},
|
||||
init(attempts = 0) {
|
||||
if (!this.$root.socket) {
|
||||
if (attempts > 10) {
|
||||
return console.error('Failed to setup socket listeners')
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.init(++attempts)
|
||||
}, 250)
|
||||
return
|
||||
}
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.$nextTick(this.updateScroll)
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logmessage {
|
||||
width: calc(100% - 208px);
|
||||
}
|
||||
</style>
|
||||
@@ -7,13 +7,23 @@
|
||||
<!-- <app-book-shelf /> -->
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
|
||||
<div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar is-home />
|
||||
<app-book-shelf-categorized />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ redirect }) {
|
||||
redirect('/library')
|
||||
asyncData({ redirect, store }) {
|
||||
var currentLibraryId = store.state.libraries.currentLibraryId
|
||||
console.log('Redir', currentLibraryId)
|
||||
redirect(`/library/${currentLibraryId}`)
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
||||
@@ -12,7 +12,13 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ params, query, store, app }) {
|
||||
async asyncData({ params, query, store, app, redirect }) {
|
||||
var libraryId = params.library
|
||||
var library = await store.dispatch('libraries/fetch', libraryId)
|
||||
if (!library) {
|
||||
return redirect('/oops?message=Library not found')
|
||||
}
|
||||
|
||||
if (query.filter) {
|
||||
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
|
||||
}
|
||||
@@ -20,16 +26,22 @@ export default {
|
||||
var searchQuery = null
|
||||
if (params.id === 'search' && query.query) {
|
||||
searchQuery = query.query
|
||||
searchResults = await app.$axios.$get(`/api/audiobooks?q=${query.query}`).catch((error) => {
|
||||
searchResults = await app.$axios.$get(`/api/library/${libraryId}/audiobooks?q=${query.query}`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
store.commit('audiobooks/setSearchResults', searchResults)
|
||||
}
|
||||
var selectedSeries = query.series ? app.$decode(query.series) : null
|
||||
store.commit('audiobooks/setSelectedSeries', selectedSeries)
|
||||
var libraryPage = params.id || ''
|
||||
store.commit('audiobooks/setLibraryPage', libraryPage)
|
||||
|
||||
return {
|
||||
id: params.id,
|
||||
id: libraryPage,
|
||||
searchQuery,
|
||||
searchResults,
|
||||
selectedSeries: query.series ? app.$decode(query.series) : null
|
||||
selectedSeries
|
||||
}
|
||||
},
|
||||
data() {
|
||||
37
client/pages/library/_library/index.vue
Normal file
37
client/pages/library/_library/index.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="page" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar is-home />
|
||||
<app-book-shelf-categorized />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, params, redirect }) {
|
||||
var libraryId = params.library
|
||||
var library = await store.dispatch('libraries/fetch', libraryId)
|
||||
if (!library) {
|
||||
return redirect(`/oops?message=Library "${libraryId}" not found`)
|
||||
}
|
||||
return {
|
||||
library
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -37,7 +37,7 @@ export default {
|
||||
if (this.$route.query.redirect) {
|
||||
this.$router.replace(this.$route.query.redirect)
|
||||
} else {
|
||||
this.$router.replace('/library')
|
||||
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,15 +57,14 @@ export default {
|
||||
password: this.password || ''
|
||||
}
|
||||
var authRes = await this.$axios.$post('/login', payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
console.error('Failed', error.response)
|
||||
if (error.response) this.error = error.response.data
|
||||
else this.error = 'Unknown Error'
|
||||
return false
|
||||
})
|
||||
console.log('Auth res', authRes)
|
||||
if (!authRes) {
|
||||
this.error = 'Unknown Failure'
|
||||
} else if (authRes.error) {
|
||||
if (authRes && authRes.error) {
|
||||
this.error = authRes.error
|
||||
} else {
|
||||
} else if (authRes) {
|
||||
this.$store.commit('user/setUser', authRes.user)
|
||||
}
|
||||
this.processing = false
|
||||
@@ -77,7 +76,6 @@ export default {
|
||||
if (token) {
|
||||
this.processing = true
|
||||
|
||||
console.log('Authorize', token)
|
||||
this.$axios
|
||||
.$post('/api/authorize', null, {
|
||||
headers: {
|
||||
|
||||
23
client/pages/oops.vue
Normal file
23
client/pages/oops.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="w-screen h-screen overflow-hidden page">
|
||||
<div class="flex h-1/3 items-center justify-center">
|
||||
<h1 class="text-2xl">Oops... {{ message }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ query }) {
|
||||
return {
|
||||
message: query.message || ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -4,6 +4,15 @@
|
||||
<article class="max-h-full overflow-y-auto relative flex flex-col rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
|
||||
<h1 class="text-xl font-book px-8 pt-4 pb-2">Audiobook Uploader</h1>
|
||||
|
||||
<div class="flex my-2 px-6">
|
||||
<div class="w-1/3 px-2">
|
||||
<!-- <ui-text-input-with-label v-model="title" label="Title" /> -->
|
||||
<ui-dropdown v-model="selectedLibraryId" :items="libraryItems" label="Library" @input="libraryChanged" />
|
||||
</div>
|
||||
<div class="w-2/3 px-2">
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="!selectedLibraryId" label="Folder" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex my-2 px-6">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="title" label="Title" />
|
||||
@@ -31,7 +40,7 @@
|
||||
|
||||
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
|
||||
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept.join(', ') }}</p>
|
||||
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept }}</p>
|
||||
</header>
|
||||
</section>
|
||||
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
|
||||
@@ -120,14 +129,16 @@ export default {
|
||||
title: null,
|
||||
author: null,
|
||||
series: null,
|
||||
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a'],
|
||||
acceptedImageFormats: ['image/*'],
|
||||
inputAccept: ['image/*, .mp3, .m4b, .m4a'],
|
||||
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a', '.flac', '.opus'],
|
||||
acceptedImageFormats: ['.png', '.jpg', '.jpeg', '.webp'],
|
||||
inputAccept: '.png, .jpg, .jpeg, .webp, .mp3, .m4b, .m4a, .flac, .opus',
|
||||
isDragOver: false,
|
||||
showUploader: true,
|
||||
validAudioFiles: [],
|
||||
validImageFiles: [],
|
||||
invalidFiles: []
|
||||
invalidFiles: [],
|
||||
selectedLibraryId: null,
|
||||
selectedFolderId: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -140,13 +151,55 @@ export default {
|
||||
directory() {
|
||||
if (!this.author || !this.title) return ''
|
||||
if (this.series) {
|
||||
return Path.join('/audiobooks', this.author, this.series, this.title)
|
||||
return Path.join(this.author, this.series, this.title)
|
||||
} else {
|
||||
return Path.join('/audiobooks', this.author, this.title)
|
||||
return Path.join(this.author, this.title)
|
||||
}
|
||||
},
|
||||
libraries() {
|
||||
return this.$store.state.libraries.libraries
|
||||
},
|
||||
libraryItems() {
|
||||
return this.libraries.map((lib) => {
|
||||
return {
|
||||
value: lib.id,
|
||||
text: lib.name
|
||||
}
|
||||
})
|
||||
},
|
||||
selectedLibrary() {
|
||||
return this.libraries.find((lib) => lib.id === this.selectedLibraryId)
|
||||
},
|
||||
selectedFolder() {
|
||||
if (!this.selectedLibrary) return null
|
||||
return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)
|
||||
},
|
||||
folderItems() {
|
||||
if (!this.selectedLibrary) return []
|
||||
return this.selectedLibrary.folders.map((fold) => {
|
||||
return {
|
||||
value: fold.id,
|
||||
text: fold.fullPath
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
libraryChanged() {
|
||||
if (!this.selectedLibrary && this.selectedFolderId) {
|
||||
this.selectedFolderId = null
|
||||
} else if (this.selectedFolderId) {
|
||||
if (!this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId)) {
|
||||
this.selectedFolderId = null
|
||||
}
|
||||
}
|
||||
this.setDefaultFolder()
|
||||
},
|
||||
setDefaultFolder() {
|
||||
if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) {
|
||||
this.selectedFolderId = this.selectedLibrary.folders[0].id
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
this.title = ''
|
||||
this.author = ''
|
||||
@@ -218,12 +271,18 @@ export default {
|
||||
this.$toast.error('Must enter a title and author')
|
||||
return
|
||||
}
|
||||
if (!this.selectedLibraryId || !this.selectedFolderId) {
|
||||
this.$toast.error('Must select a library and folder')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
|
||||
var form = new FormData()
|
||||
form.set('title', this.title)
|
||||
form.set('author', this.author)
|
||||
form.set('series', this.series)
|
||||
form.set('library', this.selectedLibraryId)
|
||||
form.set('folder', this.selectedFolderId)
|
||||
|
||||
var index = 0
|
||||
var files = this.validAudioFiles.concat(this.validImageFiles)
|
||||
@@ -234,21 +293,21 @@ export default {
|
||||
this.$axios
|
||||
.$post('/upload', form)
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.$toast.success('Audiobook Uploaded Successfully')
|
||||
this.reset()
|
||||
}
|
||||
this.$toast.success('Audiobook Uploaded Successfully')
|
||||
this.reset()
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Oops, something went wrong...')
|
||||
var errorMessage = error.response && error.response.data ? error.response.data : 'Oops, something went wrong...'
|
||||
this.$toast.error(errorMessage)
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.selectedLibraryId = this.$store.state.libraries.currentLibraryId
|
||||
this.setDefaultFolder()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -16,6 +16,7 @@ export default function ({ $axios, store }) {
|
||||
|
||||
$axios.onError(error => {
|
||||
const code = parseInt(error.response && error.response.status)
|
||||
console.error('Axios error code', code)
|
||||
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
console.error('Axios error', code, message)
|
||||
})
|
||||
}
|
||||
@@ -5,8 +5,14 @@ const DownloadStatus = {
|
||||
FAILED: 3
|
||||
}
|
||||
|
||||
const CoverDestination = {
|
||||
METADATA: 0,
|
||||
AUDIOBOOK: 1
|
||||
}
|
||||
|
||||
const Constants = {
|
||||
DownloadStatus
|
||||
DownloadStatus,
|
||||
CoverDestination
|
||||
}
|
||||
|
||||
export default ({ app }, inject) => {
|
||||
|
||||
@@ -5,18 +5,38 @@ const STANDARD_GENRES = ['Adventure', 'Autobiography', 'Biography', 'Childrens',
|
||||
|
||||
export const state = () => ({
|
||||
audiobooks: [],
|
||||
loadedLibraryId: '',
|
||||
lastLoad: 0,
|
||||
listeners: [],
|
||||
genres: [...STANDARD_GENRES],
|
||||
tags: [],
|
||||
series: [],
|
||||
keywordFilter: null
|
||||
keywordFilter: null,
|
||||
selectedSeries: null,
|
||||
libraryPage: null,
|
||||
searchResults: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getAudiobook: (state) => id => {
|
||||
return state.audiobooks.find(ab => ab.id === id)
|
||||
},
|
||||
getEntitiesShowing: (state, getters, rootState, rootGetters) => () => {
|
||||
if (!state.libraryPage) {
|
||||
return getters.getFiltered()
|
||||
} else if (state.libraryPage === 'search') {
|
||||
return state.searchResults
|
||||
} else if (state.libraryPage === 'series') {
|
||||
var series = getters.getSeriesGroups()
|
||||
if (state.selectedSeries) {
|
||||
var _series = series.find(__series => __series.name === state.selectedSeries)
|
||||
if (!_series) return []
|
||||
return _series.books || []
|
||||
}
|
||||
return series
|
||||
}
|
||||
return []
|
||||
},
|
||||
getFiltered: (state, getters, rootState, rootGetters) => () => {
|
||||
var filtered = state.audiobooks
|
||||
var settings = rootState.user.settings || {}
|
||||
@@ -69,12 +89,15 @@ export const getters = {
|
||||
state.audiobooks.forEach((audiobook) => {
|
||||
if (audiobook.book && audiobook.book.series) {
|
||||
if (series[audiobook.book.series]) {
|
||||
var bookLastUpdate = audiobook.book.lastUpdate
|
||||
if (bookLastUpdate > series[audiobook.book.series].lastUpdate) series[audiobook.book.series].lastUpdate = bookLastUpdate
|
||||
series[audiobook.book.series].books.push(audiobook)
|
||||
} else {
|
||||
series[audiobook.book.series] = {
|
||||
type: 'series',
|
||||
name: audiobook.book.series || '',
|
||||
books: [audiobook]
|
||||
books: [audiobook],
|
||||
lastUpdate: audiobook.book.lastUpdate
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,11 +123,12 @@ export const getters = {
|
||||
state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres))
|
||||
return [...new Set(_genres)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
},
|
||||
getBookCoverSrc: (state, getters, rootState, rootGetters) => (book, placeholder = '/book_placeholder.jpg') => {
|
||||
getBookCoverSrc: (state, getters, rootState, rootGetters) => (bookItem, placeholder = '/book_placeholder.jpg') => {
|
||||
var book = bookItem.book
|
||||
if (!book || !book.cover || book.cover === placeholder) return placeholder
|
||||
var cover = book.cover
|
||||
|
||||
// Absolute URL covers
|
||||
// Absolute URL covers (should no longer be used)
|
||||
if (cover.startsWith('http:') || cover.startsWith('https:')) return cover
|
||||
|
||||
// Server hosted covers
|
||||
@@ -113,7 +137,20 @@ export const getters = {
|
||||
var bookLastUpdate = book.lastUpdate || Date.now()
|
||||
var userToken = rootGetters['user/getToken']
|
||||
|
||||
var url = new URL(cover, document.baseURI)
|
||||
cover = cover.replace(/\\/g, '/')
|
||||
|
||||
// Map old covers to new format /s/book/{bookid}/*
|
||||
if (cover.startsWith('/local')) {
|
||||
cover = cover.replace('local', `s/book/${bookItem.id}`)
|
||||
if (cover.includes(bookItem.path + '/')) { // Remove book path
|
||||
cover = cover.replace(bookItem.path + '/', '')
|
||||
}
|
||||
}
|
||||
|
||||
// Easier to replace these special characters then to encodeUriComponent of the filename
|
||||
var encodedCover = cover.replace(/%/g, '%25').replace(/#/g, '%23')
|
||||
|
||||
var url = new URL(encodedCover, document.baseURI)
|
||||
return url.href + `?token=${userToken}&ts=${bookLastUpdate}`
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
@@ -130,35 +167,52 @@ export const actions = {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't load again if already loaded in the last 5 minutes
|
||||
var lastLoadDiff = Date.now() - state.lastLoad
|
||||
if (lastLoadDiff < 5 * 60 * 1000) {
|
||||
// Already up to date
|
||||
return false
|
||||
var currentLibraryId = rootState.libraries.currentLibraryId
|
||||
|
||||
if (currentLibraryId === state.loadedLibraryId) {
|
||||
// Don't load again if already loaded in the last 5 minutes
|
||||
var lastLoadDiff = Date.now() - state.lastLoad
|
||||
if (lastLoadDiff < 5 * 60 * 1000) {
|
||||
// Already up to date
|
||||
return false
|
||||
}
|
||||
}
|
||||
commit('setLoadedLibrary', currentLibraryId)
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/audiobooks`)
|
||||
.$get(`/api/library/${currentLibraryId}/audiobooks`)
|
||||
.then((data) => {
|
||||
commit('set', data)
|
||||
commit('setLastLoad')
|
||||
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
commit('set', [])
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setLoadedLibrary(state, val) {
|
||||
state.loadedLibraryId = val
|
||||
},
|
||||
setLastLoad(state) {
|
||||
state.lastLoad = Date.now()
|
||||
},
|
||||
setKeywordFilter(state, val) {
|
||||
state.keywordFilter = val
|
||||
},
|
||||
setSelectedSeries(state, val) {
|
||||
state.selectedSeries = val
|
||||
},
|
||||
setLibraryPage(state, val) {
|
||||
state.libraryPage = val
|
||||
},
|
||||
setSearchResults(state, val) {
|
||||
state.searchResults = val
|
||||
},
|
||||
set(state, audiobooks) {
|
||||
// GENRES
|
||||
var genres = [...state.genres]
|
||||
@@ -192,6 +246,11 @@ export const mutations = {
|
||||
})
|
||||
},
|
||||
addUpdate(state, audiobook) {
|
||||
if (audiobook.libraryId !== state.loadedLibraryId) {
|
||||
console.warn('Invalid library', audiobook)
|
||||
return
|
||||
}
|
||||
|
||||
var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
|
||||
var origAudiobook = null
|
||||
if (index >= 0) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { checkForUpdate } from '@/plugins/version'
|
||||
import Vue from 'vue'
|
||||
|
||||
export const state = () => ({
|
||||
versionData: null,
|
||||
@@ -8,13 +9,16 @@ export const state = () => ({
|
||||
showEditModal: false,
|
||||
selectedAudiobook: null,
|
||||
playOnLoad: false,
|
||||
isScanning: false,
|
||||
isScanningCovers: false,
|
||||
scanProgress: null,
|
||||
coverScanProgress: null,
|
||||
// isScanning: false,
|
||||
// isScanningCovers: false,
|
||||
// scanProgress: null,
|
||||
// coverScanProgress: null,
|
||||
developerMode: false,
|
||||
selectedAudiobooks: [],
|
||||
processingBatch: false
|
||||
processingBatch: false,
|
||||
previousPath: '/',
|
||||
routeHistory: [],
|
||||
showExperimentalFeatures: false
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
@@ -31,7 +35,7 @@ export const actions = {
|
||||
}
|
||||
return this.$axios.$patch('/api/serverSettings', updatePayload).then((result) => {
|
||||
if (result.success) {
|
||||
commit('setServerSettings', result.settings)
|
||||
commit('setServerSettings', result.serverSettings)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
@@ -51,14 +55,30 @@ export const actions = {
|
||||
console.error('Update check failed', error)
|
||||
return false
|
||||
})
|
||||
},
|
||||
popRoute({ commit, state }) {
|
||||
if (!state.routeHistory.length) {
|
||||
return null
|
||||
}
|
||||
var _history = [...state.routeHistory]
|
||||
var last = _history.pop()
|
||||
commit('setRouteHistory', _history)
|
||||
return last
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setRouteHistory(state, val) {
|
||||
state.routeHistory = val
|
||||
},
|
||||
setPreviousPath(state, val) {
|
||||
state.previousPath = val
|
||||
},
|
||||
setVersionData(state, versionData) {
|
||||
state.versionData = versionData
|
||||
},
|
||||
setServerSettings(state, settings) {
|
||||
if (!settings) return
|
||||
state.serverSettings = settings
|
||||
},
|
||||
setStreamAudiobook(state, audiobook) {
|
||||
@@ -94,34 +114,42 @@ export const mutations = {
|
||||
setShowEditModal(state, 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
|
||||
},
|
||||
// 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) {
|
||||
state.developerMode = val
|
||||
},
|
||||
setSelectedAudiobooks(state, audiobooks) {
|
||||
state.selectedAudiobooks = audiobooks
|
||||
Vue.set(state, 'selectedAudiobooks', audiobooks)
|
||||
// state.selectedAudiobooks = audiobooks
|
||||
},
|
||||
toggleAudiobookSelected(state, audiobookId) {
|
||||
if (state.selectedAudiobooks.includes(audiobookId)) {
|
||||
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
|
||||
} else {
|
||||
state.selectedAudiobooks.push(audiobookId)
|
||||
var newSel = state.selectedAudiobooks.concat([audiobookId])
|
||||
// state.selectedAudiobooks = newSel
|
||||
console.log('Setting toggle on sel', newSel)
|
||||
Vue.set(state, 'selectedAudiobooks', newSel)
|
||||
// state.selectedAudiobooks.push(audiobookId)
|
||||
}
|
||||
},
|
||||
setProcessingBatch(state, val) {
|
||||
state.processingBatch = val
|
||||
},
|
||||
setExperimentalFeatures(state, val) {
|
||||
state.showExperimentalFeatures = val
|
||||
}
|
||||
}
|
||||
144
client/store/libraries.js
Normal file
144
client/store/libraries.js
Normal file
@@ -0,0 +1,144 @@
|
||||
export const state = () => ({
|
||||
libraries: [],
|
||||
lastLoad: 0,
|
||||
listeners: [],
|
||||
currentLibraryId: 'main',
|
||||
showModal: false,
|
||||
folders: [],
|
||||
folderLastUpdate: 0
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getCurrentLibrary: state => {
|
||||
return state.libraries.find(lib => lib.id === state.currentLibraryId)
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
loadFolders({ state, commit }) {
|
||||
if (state.folders.length) {
|
||||
var lastCheck = Date.now() - state.folderLastUpdate
|
||||
if (lastCheck < 1000 * 60 * 10) { // 10 minutes
|
||||
// Folders up to date
|
||||
return state.folders
|
||||
}
|
||||
}
|
||||
console.log('Loading folders')
|
||||
commit('setFoldersLastUpdate')
|
||||
|
||||
return this.$axios
|
||||
.$get('/api/filesystem')
|
||||
.then((res) => {
|
||||
console.log('Settings folders', res)
|
||||
commit('setFolders', res)
|
||||
return res
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load dirs', error)
|
||||
commit('setFolders', [])
|
||||
return []
|
||||
})
|
||||
},
|
||||
fetch({ state, commit, rootState }, libraryId) {
|
||||
if (!rootState.user || !rootState.user.user) {
|
||||
console.error('libraries/fetch - User not set')
|
||||
return false
|
||||
}
|
||||
|
||||
var library = state.libraries.find(lib => lib.id === libraryId)
|
||||
if (library) {
|
||||
commit('setCurrentLibrary', libraryId)
|
||||
return library
|
||||
}
|
||||
|
||||
return this.$axios
|
||||
.$get(`/api/library/${libraryId}`)
|
||||
.then((data) => {
|
||||
commit('addUpdate', data)
|
||||
commit('setCurrentLibrary', libraryId)
|
||||
return data
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
},
|
||||
// Return true if calling load
|
||||
load({ state, commit, rootState }) {
|
||||
if (!rootState.user || !rootState.user.user) {
|
||||
console.error('libraries/load - User not set')
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't load again if already loaded in the last 5 minutes
|
||||
var lastLoadDiff = Date.now() - state.lastLoad
|
||||
if (lastLoadDiff < 5 * 60 * 1000) {
|
||||
// Already up to date
|
||||
return false
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/libraries`)
|
||||
.then((data) => {
|
||||
commit('set', data)
|
||||
commit('setLastLoad')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
commit('set', [])
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setFolders(state, folders) {
|
||||
state.folders = folders
|
||||
},
|
||||
setFoldersLastUpdate(state) {
|
||||
state.folderLastUpdate = Date.now()
|
||||
},
|
||||
setShowModal(state, val) {
|
||||
state.showModal = val
|
||||
},
|
||||
setLastLoad(state) {
|
||||
state.lastLoad = Date.now()
|
||||
},
|
||||
setCurrentLibrary(state, val) {
|
||||
state.currentLibraryId = val
|
||||
},
|
||||
set(state, libraries) {
|
||||
state.libraries = libraries
|
||||
state.listeners.forEach((listener) => {
|
||||
listener.meth()
|
||||
})
|
||||
},
|
||||
addUpdate(state, library) {
|
||||
var index = state.libraries.findIndex(a => a.id === library.id)
|
||||
if (index >= 0) {
|
||||
state.libraries.splice(index, 1, library)
|
||||
} else {
|
||||
state.libraries.push(library)
|
||||
}
|
||||
|
||||
state.listeners.forEach((listener) => {
|
||||
listener.meth()
|
||||
})
|
||||
},
|
||||
remove(state, library) {
|
||||
state.libraries = state.libraries.filter(a => a.id !== library.id)
|
||||
|
||||
state.listeners.forEach((listener) => {
|
||||
listener.meth()
|
||||
})
|
||||
},
|
||||
addListener(state, listener) {
|
||||
var index = state.listeners.findIndex(l => l.id === listener.id)
|
||||
if (index >= 0) state.listeners.splice(index, 1, listener)
|
||||
else state.listeners.push(listener)
|
||||
},
|
||||
removeListener(state, listenerId) {
|
||||
state.listeners = state.listeners.filter(l => l.id !== listenerId)
|
||||
}
|
||||
}
|
||||
31
client/store/logs.js
Normal file
31
client/store/logs.js
Normal file
@@ -0,0 +1,31 @@
|
||||
export const state = () => ({
|
||||
isListening: false,
|
||||
logs: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
setLogListener({ state, commit, dispatch }) {
|
||||
dispatch('$nuxtSocket/emit', {
|
||||
label: 'main',
|
||||
evt: 'set_log_listener',
|
||||
msg: 0
|
||||
}, { root: true })
|
||||
commit('setIsListening', true)
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setIsListening(state, val) {
|
||||
state.isListening = val
|
||||
},
|
||||
logEvt(state, payload) {
|
||||
state.logs.push(payload)
|
||||
if (state.logs.length > 500) {
|
||||
state.logs = state.logs.slice(50)
|
||||
}
|
||||
}
|
||||
}
|
||||
27
client/store/scanners.js
Normal file
27
client/store/scanners.js
Normal file
@@ -0,0 +1,27 @@
|
||||
export const state = () => ({
|
||||
libraryScans: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getLibraryScan: state => id => {
|
||||
return state.libraryScans.find(ls => ls.id === id)
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
addUpdate(state, data) {
|
||||
var index = state.libraryScans.findIndex(lib => lib.id === data.id)
|
||||
if (index >= 0) {
|
||||
state.libraryScans.splice(index, 1, data)
|
||||
} else {
|
||||
state.libraryScans.push(data)
|
||||
}
|
||||
},
|
||||
remove(state, data) {
|
||||
state.libraryScans = state.libraryScans.filter(scan => scan.id !== data.id)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ module.exports = {
|
||||
safelist: [
|
||||
'bg-success',
|
||||
'bg-red-600',
|
||||
'py-1.5'
|
||||
'py-1.5',
|
||||
'bg-info'
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -16,6 +17,12 @@ module.exports = {
|
||||
height: {
|
||||
'7.5': '1.75rem'
|
||||
},
|
||||
spacing: {
|
||||
'-54': '-13.5rem'
|
||||
},
|
||||
rotate: {
|
||||
'-60': '-60deg'
|
||||
},
|
||||
colors: {
|
||||
bg: '#373838',
|
||||
primary: '#232323',
|
||||
|
||||
487
package-lock.json
generated
487
package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.1.13",
|
||||
"version": "1.3.4",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -69,6 +69,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"abbrev": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
|
||||
"optional": true
|
||||
},
|
||||
"aborter": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/aborter/-/aborter-1.1.0.tgz",
|
||||
@@ -83,6 +89,23 @@
|
||||
"negotiator": "0.6.2"
|
||||
}
|
||||
},
|
||||
"adm-zip": {
|
||||
"version": "0.4.16",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.16.tgz",
|
||||
"integrity": "sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg=="
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
|
||||
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
|
||||
"optional": true
|
||||
},
|
||||
"aproba": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
|
||||
"integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==",
|
||||
"optional": true
|
||||
},
|
||||
"archiver": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.0.tgz",
|
||||
@@ -138,6 +161,33 @@
|
||||
"is-primitive": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"are-we-there-yet": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz",
|
||||
"integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"delegates": "^1.0.0",
|
||||
"readable-stream": "^2.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"readable-stream": {
|
||||
"version": "2.3.7",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
|
||||
"integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"array-back": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
|
||||
@@ -285,6 +335,12 @@
|
||||
"responselike": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"optional": true
|
||||
},
|
||||
"clone-response": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz",
|
||||
@@ -293,6 +349,12 @@
|
||||
"mimic-response": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"code-point-at": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
|
||||
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
|
||||
"optional": true
|
||||
},
|
||||
"command-line-args": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.0.tgz",
|
||||
@@ -325,6 +387,12 @@
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
|
||||
},
|
||||
"console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
|
||||
"optional": true
|
||||
},
|
||||
"content-disposition": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
|
||||
@@ -417,11 +485,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"optional": true
|
||||
},
|
||||
"defer-to-connect": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
|
||||
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="
|
||||
},
|
||||
"delegates": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
||||
"integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=",
|
||||
"optional": true
|
||||
},
|
||||
"depd": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
|
||||
@@ -432,6 +512,12 @@
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
|
||||
"integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
|
||||
},
|
||||
"detect-libc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||
"integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=",
|
||||
"optional": true
|
||||
},
|
||||
"dicer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz",
|
||||
@@ -508,6 +594,16 @@
|
||||
"base64-arraybuffer": "0.1.4"
|
||||
}
|
||||
},
|
||||
"epub": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/epub/-/epub-1.2.1.tgz",
|
||||
"integrity": "sha512-2GDDr2qcH3dvwX1lgwCQ3gki0CwwrxELLI005SauhT2TacJUiDqZrQuGuOSWEYIHX6ox5kXHpn1ZjsHqkNCb+g==",
|
||||
"requires": {
|
||||
"adm-zip": "^0.4.11",
|
||||
"xml2js": "^0.4.23",
|
||||
"zipfile": "^0.5.11"
|
||||
}
|
||||
},
|
||||
"escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -568,6 +664,16 @@
|
||||
"busboy": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"express-rate-limit": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.3.0.tgz",
|
||||
"integrity": "sha512-qJhfEgCnmteSeZAeuOKQ2WEIFTX5ajrzE0xS6gCOBCoRQcU+xEzQmgYQQTpzCcqUAAzTEtu4YEih4pnLfvNtew=="
|
||||
},
|
||||
"file-type": {
|
||||
"version": "10.11.0",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz",
|
||||
"integrity": "sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw=="
|
||||
},
|
||||
"finalhandler": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
|
||||
@@ -629,11 +735,36 @@
|
||||
"universalify": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"fs-minipass": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz",
|
||||
"integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minipass": "^2.6.0"
|
||||
}
|
||||
},
|
||||
"fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
||||
},
|
||||
"gauge": {
|
||||
"version": "2.7.4",
|
||||
"resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
|
||||
"integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"aproba": "^1.0.3",
|
||||
"console-control-strings": "^1.0.0",
|
||||
"has-unicode": "^2.0.0",
|
||||
"object-assign": "^4.1.0",
|
||||
"signal-exit": "^3.0.0",
|
||||
"string-width": "^1.0.1",
|
||||
"strip-ansi": "^3.0.1",
|
||||
"wide-align": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"get-stream": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||
@@ -679,6 +810,12 @@
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz",
|
||||
"integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ=="
|
||||
},
|
||||
"has-unicode": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
|
||||
"integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=",
|
||||
"optional": true
|
||||
},
|
||||
"http-cache-semantics": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
|
||||
@@ -718,6 +855,23 @@
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
|
||||
},
|
||||
"ignore-walk": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.4.tgz",
|
||||
"integrity": "sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minimatch": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"image-type": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/image-type/-/image-type-4.1.0.tgz",
|
||||
"integrity": "sha512-CFJMJ8QK8lJvRlTCEgarL4ro6hfDQKif2HjSvYCdQZESaIPV4v9imrf7BQHK+sQeTeNeMpWciR9hyC/g8ybXEg==",
|
||||
"requires": {
|
||||
"file-type": "^10.10.0"
|
||||
}
|
||||
},
|
||||
"inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
@@ -732,6 +886,12 @@
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
||||
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"optional": true
|
||||
},
|
||||
"ip": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
|
||||
@@ -742,6 +902,15 @@
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
|
||||
},
|
||||
"is-fullwidth-code-point": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
|
||||
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"number-is-nan": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"is-primitive": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-3.0.1.tgz",
|
||||
@@ -965,11 +1134,79 @@
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"minimist": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
|
||||
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
|
||||
"optional": true
|
||||
},
|
||||
"minipass": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz",
|
||||
"integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"safe-buffer": "^5.1.2",
|
||||
"yallist": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"minizlib": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz",
|
||||
"integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minipass": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
|
||||
"integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minimist": "^1.2.5"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
|
||||
},
|
||||
"nan": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz",
|
||||
"integrity": "sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA==",
|
||||
"optional": true
|
||||
},
|
||||
"needle": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz",
|
||||
"integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"debug": "^3.2.6",
|
||||
"iconv-lite": "^0.4.4",
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"negotiator": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
|
||||
@@ -991,6 +1228,34 @@
|
||||
"minimatch": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node-pre-gyp": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz",
|
||||
"integrity": "sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"detect-libc": "^1.0.2",
|
||||
"mkdirp": "^0.5.1",
|
||||
"needle": "^2.2.1",
|
||||
"nopt": "^4.0.1",
|
||||
"npm-packlist": "^1.1.6",
|
||||
"npmlog": "^4.0.2",
|
||||
"rc": "^1.2.7",
|
||||
"rimraf": "^2.6.1",
|
||||
"semver": "^5.3.0",
|
||||
"tar": "^4"
|
||||
}
|
||||
},
|
||||
"nopt": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.3.tgz",
|
||||
"integrity": "sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"abbrev": "1",
|
||||
"osenv": "^0.1.4"
|
||||
}
|
||||
},
|
||||
"normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -1001,6 +1266,50 @@
|
||||
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
|
||||
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="
|
||||
},
|
||||
"npm-bundled": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz",
|
||||
"integrity": "sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"npm-normalize-package-bin": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"npm-normalize-package-bin": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz",
|
||||
"integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==",
|
||||
"optional": true
|
||||
},
|
||||
"npm-packlist": {
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz",
|
||||
"integrity": "sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ignore-walk": "^3.0.1",
|
||||
"npm-bundled": "^1.0.1",
|
||||
"npm-normalize-package-bin": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"npmlog": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
|
||||
"integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"are-we-there-yet": "~1.1.2",
|
||||
"console-control-strings": "~1.1.0",
|
||||
"gauge": "~2.7.3",
|
||||
"set-blocking": "~2.0.0"
|
||||
}
|
||||
},
|
||||
"number-is-nan": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
|
||||
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
|
||||
"optional": true
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -1022,11 +1331,43 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"os-homedir": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
|
||||
"integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=",
|
||||
"optional": true
|
||||
},
|
||||
"os-tmpdir": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
|
||||
"integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=",
|
||||
"optional": true
|
||||
},
|
||||
"osenv": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz",
|
||||
"integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"os-homedir": "^1.0.0",
|
||||
"os-tmpdir": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"p-cancelable": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="
|
||||
},
|
||||
"p-finally": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
|
||||
"integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
|
||||
},
|
||||
"p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
|
||||
},
|
||||
"parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -1042,6 +1383,11 @@
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
|
||||
"integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
|
||||
},
|
||||
"pify": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
|
||||
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="
|
||||
},
|
||||
"podcast": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/podcast/-/podcast-1.3.0.tgz",
|
||||
@@ -1119,6 +1465,27 @@
|
||||
"unpipe": "1.0.0"
|
||||
}
|
||||
},
|
||||
"rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
}
|
||||
},
|
||||
"read-chunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/read-chunk/-/read-chunk-3.1.0.tgz",
|
||||
"integrity": "sha512-ZdiZJXXoZYE08SzZvTipHhI+ZW0FpzxmFtLI3vIeMuRN9ySbIZ+SZawKogqJ7dxW9fJ/W73BNtxu4Zu/bZp+Ng==",
|
||||
"requires": {
|
||||
"pify": "^4.0.1",
|
||||
"with-open-file": "^0.1.5"
|
||||
}
|
||||
},
|
||||
"readable-stream": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
|
||||
@@ -1155,6 +1522,15 @@
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs="
|
||||
},
|
||||
"rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
|
||||
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"glob": "^7.1.3"
|
||||
}
|
||||
},
|
||||
"ripstat": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ripstat/-/ripstat-1.1.1.tgz",
|
||||
@@ -1197,6 +1573,11 @@
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"sax": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
|
||||
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
|
||||
},
|
||||
"semver": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
|
||||
@@ -1240,6 +1621,12 @@
|
||||
"send": "0.17.1"
|
||||
}
|
||||
},
|
||||
"set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
|
||||
"optional": true
|
||||
},
|
||||
"setprototypeof": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
|
||||
@@ -1326,6 +1713,17 @@
|
||||
"resolved": "https://registry.npmjs.org/string-indexes/-/string-indexes-1.0.0.tgz",
|
||||
"integrity": "sha512-RUlx+2YydZJNlRAvoh1siPYWj/Xfk6t1sQLkA5n1tMGRCKkRLzkRtJhHk4qRmKergEBh8R3pWhsUsDqia/bolw=="
|
||||
},
|
||||
"string-width": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
|
||||
"integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"code-point-at": "^1.0.0",
|
||||
"is-fullwidth-code-point": "^1.0.0",
|
||||
"strip-ansi": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
@@ -1334,6 +1732,44 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
|
||||
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=",
|
||||
"optional": true
|
||||
},
|
||||
"tar": {
|
||||
"version": "4.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-4.4.19.tgz",
|
||||
"integrity": "sha512-a20gEsvHnWe0ygBY8JbxoM4w3SJdhc7ZAuxkLqh+nvNQN2IOt0B5lLgM490X5Hl8FF0dl0tOf2ewFYAlIFgzVA==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"chownr": "^1.1.4",
|
||||
"fs-minipass": "^1.2.7",
|
||||
"minipass": "^2.9.0",
|
||||
"minizlib": "^1.3.3",
|
||||
"mkdirp": "^0.5.5",
|
||||
"safe-buffer": "^5.2.1",
|
||||
"yallist": "^3.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
@@ -1419,6 +1855,25 @@
|
||||
"isexe": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"wide-align": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz",
|
||||
"integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"string-width": "^1.0.2 || 2"
|
||||
}
|
||||
},
|
||||
"with-open-file": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/with-open-file/-/with-open-file-0.1.7.tgz",
|
||||
"integrity": "sha512-ecJS2/oHtESJ1t3ZfMI3B7KIDKyfN0O16miWxdn30zdh66Yd3LsRFebXZXq6GU4xfxLf6nVxp9kIqElb5fqczA==",
|
||||
"requires": {
|
||||
"p-finally": "^1.0.0",
|
||||
"p-try": "^2.1.0",
|
||||
"pify": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@@ -1434,6 +1889,26 @@
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU="
|
||||
},
|
||||
"xml2js": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||
"requires": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
}
|
||||
},
|
||||
"xmlbuilder": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"optional": true
|
||||
},
|
||||
"zip-stream": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.0.tgz",
|
||||
@@ -1443,6 +1918,16 @@
|
||||
"compress-commons": "^4.1.0",
|
||||
"readable-stream": "^3.6.0"
|
||||
}
|
||||
},
|
||||
"zipfile": {
|
||||
"version": "0.5.12",
|
||||
"resolved": "https://registry.npmjs.org/zipfile/-/zipfile-0.5.12.tgz",
|
||||
"integrity": "sha512-zA60gW+XgQBu/Q4qV3BCXNIDRald6Xi5UOPj3jWGlnkjmBHaKDwIz7kyXWV3kq7VEsQN/2t/IWjdXdKeVNm6Eg==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"nan": "~2.10.0",
|
||||
"node-pre-gyp": "~0.10.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.2.4",
|
||||
"version": "1.4.0",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -27,16 +27,20 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"command-line-args": "^5.2.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"epub": "^1.2.1",
|
||||
"express": "^4.17.1",
|
||||
"express-fileupload": "^1.2.1",
|
||||
"express-rate-limit": "^5.3.0",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"fs-extra": "^10.0.0",
|
||||
"image-type": "^4.1.0",
|
||||
"ip": "^1.1.5",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"libgen": "^2.1.0",
|
||||
"njodb": "^0.4.20",
|
||||
"node-dir": "^0.1.17",
|
||||
"podcast": "^1.3.0",
|
||||
"read-chunk": "^3.1.0",
|
||||
"socket.io": "^4.1.3",
|
||||
"watcher": "^1.2.0"
|
||||
},
|
||||
|
||||
64
readme.md
64
readme.md
@@ -2,7 +2,7 @@
|
||||
|
||||
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
|
||||
|
||||
See [Install guides](https://audiobookshelf.org/install) and docs coming soon to [audiobookshelf.org](https://audiobookshelf.org)
|
||||
See [Install guides](https://audiobookshelf.org/install) and [documentation](https://audiobookshelf.org/docs)
|
||||
|
||||
Android app is in beta, try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
||||
|
||||
@@ -11,63 +11,19 @@ Android app is in beta, try it out on the [Google Play Store](https://play.googl
|
||||
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
|
||||
|
||||
|
||||
## Directory Structure
|
||||
## Organizing your audiobooks
|
||||
|
||||
Author, Series, Volume Number, Title and Publish Year can all be parsed from your folder structure.
|
||||
#### Directory structure and folder names are critical to AudioBookshelf!
|
||||
|
||||
**Note**: Files in the root directory `/audiobooks` will be ignored, all audiobooks should be in a directory
|
||||
|
||||
**1 Folder:** `/Title/...`\
|
||||
**2 Folders:** `/Author/Title/...`\
|
||||
**3 Folders:** `/Author/Series/Title/...`
|
||||
|
||||
### Parsing publish year
|
||||
|
||||
`/1984 - Hackers/...`\
|
||||
Will save the publish year as `1984` and the title as `Hackers`
|
||||
|
||||
### Parsing volume number (only for series)
|
||||
|
||||
`/Book 3 - Hackers/...`\
|
||||
Will save the volume number as `3` and the title as `Hackers`
|
||||
|
||||
`Book` `Volume` `Vol` `Vol.` are all supported case insensitive
|
||||
|
||||
These combinations will also work:\
|
||||
`/Hackers - Vol. 3/...`\
|
||||
`/1984 - Volume 3 - Hackers/...`\
|
||||
`/1984 - Hackers Book 3/...`
|
||||
See [documentation](https://audiobookshelf.org/docs) for supported directory structure, folder naming conventions, and audio file metadata usage.
|
||||
|
||||
|
||||
### Parsing subtitles (optional in settings)
|
||||
|
||||
Title Folder: `/Hackers - Heroes of the Computer Revolution/...`
|
||||
|
||||
Will save the title as `Hackers` and the subtitle as `Heroes of the Computer Revolution`
|
||||
|
||||
|
||||
### Full example
|
||||
|
||||
`/Steven Levy/The Hacker Series/1984 - Hackers - Heroes of the Computer Revolution - Vol. 1/...`
|
||||
|
||||
**Becomes:**
|
||||
| Key | Value |
|
||||
|---------------|-----------------------------------|
|
||||
| Author | Steven Levy |
|
||||
| Series | The Hacker Series |
|
||||
| Publish Year | 1984 |
|
||||
| Title | Hackers |
|
||||
| Subtitle | Heroes of the Computer Revolution |
|
||||
| Volume Number | 1 |
|
||||
|
||||
|
||||
## Features coming soon
|
||||
|
||||
* Support different views to see more details of each audiobook
|
||||
* iOS App (Android is in beta [here](https://play.google.com/store/apps/details?id=com.audiobookshelf.app))
|
||||
|
||||
## Installation
|
||||
|
||||
** Default username is "root" with no password
|
||||
|
||||
### Docker Install
|
||||
Available in Unraid Community Apps
|
||||
|
||||
@@ -113,14 +69,10 @@ curl -s --compressed "https://advplyr.github.io/audiobookshelf-ppa/KEY.gpg" | su
|
||||
|
||||
Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa).
|
||||
|
||||
```bash
|
||||
wget https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf_1.2.3_amd64.deb
|
||||
|
||||
sudo apt install ./audiobookshelf_1.2.3_amd64.deb
|
||||
```
|
||||
See [instructions](https://www.audiobookshelf.org/install#debian)
|
||||
|
||||
|
||||
#### File locations
|
||||
#### Linux file locations
|
||||
|
||||
Project directory: `/usr/share/audiobookshelf/`
|
||||
|
||||
|
||||
@@ -3,17 +3,19 @@ const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const Logger = require('./Logger')
|
||||
const User = require('./objects/User')
|
||||
const { isObject, isAcceptableCoverMimeType } = require('./utils/index')
|
||||
const { CoverDestination } = require('./utils/constants')
|
||||
const { isObject } = require('./utils/index')
|
||||
const Library = require('./objects/Library')
|
||||
|
||||
class ApiController {
|
||||
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter, clientEmitter) {
|
||||
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, coverController, watcher, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
this.scanner = scanner
|
||||
this.auth = auth
|
||||
this.streamManager = streamManager
|
||||
this.rssFeeds = rssFeeds
|
||||
this.downloadManager = downloadManager
|
||||
this.coverController = coverController
|
||||
this.watcher = watcher
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
this.MetadataPath = MetadataPath
|
||||
@@ -26,7 +28,14 @@ class ApiController {
|
||||
this.router.get('/find/covers', this.findCovers.bind(this))
|
||||
this.router.get('/find/:method', this.find.bind(this))
|
||||
|
||||
this.router.get('/audiobooks', this.getAudiobooks.bind(this))
|
||||
this.router.get('/libraries', this.getLibraries.bind(this))
|
||||
this.router.get('/library/:id', this.getLibrary.bind(this))
|
||||
this.router.delete('/library/:id', this.deleteLibrary.bind(this))
|
||||
this.router.patch('/library/:id', this.updateLibrary.bind(this))
|
||||
this.router.get('/library/:id/audiobooks', this.getLibraryAudiobooks.bind(this))
|
||||
this.router.post('/library', this.createNewLibrary.bind(this))
|
||||
|
||||
this.router.get('/audiobooks', this.getAudiobooks.bind(this)) // Old route should pass library id
|
||||
this.router.delete('/audiobooks', this.deleteAllAudiobooks.bind(this))
|
||||
this.router.post('/audiobooks/delete', this.batchDeleteAudiobooks.bind(this))
|
||||
this.router.post('/audiobooks/update', this.batchUpdateAudiobooks.bind(this))
|
||||
@@ -37,7 +46,6 @@ class ApiController {
|
||||
this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this))
|
||||
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
|
||||
|
||||
this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
|
||||
this.router.patch('/match/:id', this.match.bind(this))
|
||||
|
||||
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
|
||||
@@ -60,6 +68,8 @@ class ApiController {
|
||||
this.router.post('/feed', this.openRssFeed.bind(this))
|
||||
|
||||
this.router.get('/download/:id', this.download.bind(this))
|
||||
|
||||
this.router.get('/filesystem', this.getFileSystemPaths.bind(this))
|
||||
}
|
||||
|
||||
find(req, res) {
|
||||
@@ -70,11 +80,6 @@ class ApiController {
|
||||
this.scanner.findCovers(req, res)
|
||||
}
|
||||
|
||||
async getMetadata(req, res) {
|
||||
var metadata = await this.scanner.fetchMetadata(req.params.id, req.params.trackIndex)
|
||||
res.json(metadata)
|
||||
}
|
||||
|
||||
authorize(req, res) {
|
||||
if (!req.user) {
|
||||
Logger.error('Invalid user in authorize')
|
||||
@@ -83,6 +88,102 @@ class ApiController {
|
||||
res.json({ user: req.user })
|
||||
}
|
||||
|
||||
getLibraries(req, res) {
|
||||
var libraries = this.db.libraries.map(lib => lib.toJSON())
|
||||
res.json(libraries)
|
||||
}
|
||||
|
||||
getLibrary(req, res) {
|
||||
var library = this.db.libraries.find(lib => lib.id === req.params.id)
|
||||
if (!library) {
|
||||
return res.status(404).send('Library not found')
|
||||
}
|
||||
return res.json(library.toJSON())
|
||||
}
|
||||
|
||||
async deleteLibrary(req, res) {
|
||||
var library = this.db.libraries.find(lib => lib.id === req.params.id)
|
||||
if (!library) {
|
||||
return res.status(404).send('Library not found')
|
||||
}
|
||||
|
||||
// Remove library watcher
|
||||
this.watcher.removeLibrary(library)
|
||||
|
||||
// Remove audiobooks in this library
|
||||
var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
|
||||
Logger.info(`[Server] deleting library "${library.name}" with ${audiobooks.length} audiobooks"`)
|
||||
for (let i = 0; i < audiobooks.length; i++) {
|
||||
await this.handleDeleteAudiobook(audiobooks[i])
|
||||
}
|
||||
|
||||
var libraryJson = library.toJSON()
|
||||
await this.db.removeEntity('library', library.id)
|
||||
this.emitter('library_removed', libraryJson)
|
||||
return res.json(libraryJson)
|
||||
}
|
||||
|
||||
async updateLibrary(req, res) {
|
||||
var library = this.db.libraries.find(lib => lib.id === req.params.id)
|
||||
if (!library) {
|
||||
return res.status(404).send('Library not found')
|
||||
}
|
||||
var hasUpdates = library.update(req.body)
|
||||
if (hasUpdates) {
|
||||
// Update watcher
|
||||
this.watcher.updateLibrary(library)
|
||||
|
||||
// Remove audiobooks no longer in library
|
||||
var audiobooksToRemove = this.db.audiobooks.filter(ab => !library.checkFullPathInLibrary(ab.fullPath))
|
||||
if (audiobooksToRemove.length) {
|
||||
Logger.info(`[Scanner] Updating library, removing ${audiobooksToRemove.length} audiobooks`)
|
||||
for (let i = 0; i < audiobooksToRemove.length; i++) {
|
||||
await this.handleDeleteAudiobook(audiobooksToRemove[i])
|
||||
}
|
||||
}
|
||||
await this.db.updateEntity('library', library)
|
||||
this.emitter('library_updated', library.toJSON())
|
||||
}
|
||||
return res.json(library.toJSON())
|
||||
}
|
||||
|
||||
getLibraryAudiobooks(req, res) {
|
||||
var libraryId = req.params.id
|
||||
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||
if (!library) {
|
||||
return res.status(400).send('Library does not exist')
|
||||
}
|
||||
|
||||
var audiobooks = []
|
||||
if (req.query.q) {
|
||||
audiobooks = this.db.audiobooks.filter(ab => {
|
||||
return ab.libraryId === libraryId && ab.isSearchMatch(req.query.q)
|
||||
}).map(ab => ab.toJSONMinified())
|
||||
} else {
|
||||
audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId).map(ab => ab.toJSONMinified())
|
||||
}
|
||||
res.json(audiobooks)
|
||||
}
|
||||
|
||||
async createNewLibrary(req, res) {
|
||||
var newLibraryPayload = {
|
||||
...req.body
|
||||
}
|
||||
if (!newLibraryPayload.name || !newLibraryPayload.folders || !newLibraryPayload.folders.length) {
|
||||
return res.status(500).send('Invalid request')
|
||||
}
|
||||
|
||||
var library = new Library()
|
||||
library.setData(newLibraryPayload)
|
||||
await this.db.insertEntity('library', library)
|
||||
this.emitter('library_added', library.toJSON())
|
||||
|
||||
// Add library watcher
|
||||
this.watcher.addLibrary(library)
|
||||
|
||||
res.json(library)
|
||||
}
|
||||
|
||||
getAudiobooks(req, res) {
|
||||
var audiobooks = []
|
||||
if (req.query.q) {
|
||||
@@ -227,77 +328,36 @@ class ApiController {
|
||||
Logger.warn('User attempted to upload a cover without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (!req.files || !req.files.cover) {
|
||||
return res.status(400).send('No files were uploaded')
|
||||
}
|
||||
|
||||
var audiobookId = req.params.id
|
||||
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||
if (!audiobook) {
|
||||
return res.status(404).send('Audiobook not found')
|
||||
}
|
||||
|
||||
var coverFile = req.files.cover
|
||||
var mimeType = coverFile.mimetype
|
||||
var extname = Path.extname(coverFile.name.toLowerCase()) || '.jpg'
|
||||
if (!isAcceptableCoverMimeType(mimeType)) {
|
||||
return res.status(400).send('Invalid image file type: ' + mimeType)
|
||||
}
|
||||
|
||||
var coverDestination = this.db.serverSettings ? this.db.serverSettings.coverDestination : CoverDestination.METADATA
|
||||
Logger.info(`[ApiController] Cover Upload destination ${coverDestination}`)
|
||||
|
||||
var coverDirpath = audiobook.fullPath
|
||||
var coverRelDirpath = Path.join('/local', audiobook.path)
|
||||
if (coverDestination === CoverDestination.METADATA) {
|
||||
coverDirpath = Path.join(this.MetadataPath, 'books', audiobookId)
|
||||
coverRelDirpath = Path.join('/metadata', 'books', audiobookId)
|
||||
Logger.debug(`[ApiController] storing in metadata | ${coverDirpath}`)
|
||||
await fs.ensureDir(coverDirpath)
|
||||
var result = null
|
||||
if (req.body && req.body.url) {
|
||||
Logger.debug(`[ApiController] Requesting download cover from url "${req.body.url}"`)
|
||||
result = await this.coverController.downloadCoverFromUrl(audiobook, req.body.url)
|
||||
} else if (req.files && req.files.cover) {
|
||||
Logger.debug(`[ApiController] Handling uploaded cover`)
|
||||
var coverFile = req.files.cover
|
||||
result = await this.coverController.uploadCover(audiobook, coverFile)
|
||||
} else {
|
||||
Logger.debug(`[ApiController] storing in audiobook | ${coverRelDirpath}`)
|
||||
return res.status(400).send('Invalid request no file or url')
|
||||
}
|
||||
|
||||
var coverFilename = `cover${extname}`
|
||||
var coverFullPath = Path.join(coverDirpath, coverFilename)
|
||||
var coverPath = Path.join(coverRelDirpath, coverFilename)
|
||||
|
||||
// If current cover is a metadata cover and does not match replacement, then remove it
|
||||
var currentBookCover = audiobook.book.cover
|
||||
if (currentBookCover && currentBookCover.startsWith(Path.sep + 'metadata')) {
|
||||
Logger.debug(`Current Book Cover is metadata ${currentBookCover}`)
|
||||
if (currentBookCover !== coverPath) {
|
||||
Logger.info(`[ApiController] removing old metadata cover "${currentBookCover}"`)
|
||||
var oldFullBookCoverPath = Path.join(this.MetadataPath, currentBookCover.replace(Path.sep + 'metadata', ''))
|
||||
|
||||
// Metadata path may have changed, check if exists first
|
||||
var exists = await fs.pathExists(oldFullBookCoverPath)
|
||||
if (exists) {
|
||||
try {
|
||||
await fs.remove(oldFullBookCoverPath)
|
||||
} catch (error) {
|
||||
Logger.error(`[ApiController] Failed to remove old metadata book cover ${oldFullBookCoverPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result && result.error) {
|
||||
return res.status(400).send(result.error)
|
||||
} else if (!result || !result.cover) {
|
||||
return res.status(500).send('Unknown error occurred')
|
||||
}
|
||||
|
||||
var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
|
||||
Logger.error('Failed to move cover file', path, error)
|
||||
return false
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
return res.status(500).send('Failed to move cover into destination')
|
||||
}
|
||||
|
||||
Logger.info(`[ApiController] Uploaded audiobook cover "${coverPath}" for "${audiobook.title}"`)
|
||||
|
||||
audiobook.updateBookCover(coverPath)
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||
res.json({
|
||||
success: true,
|
||||
cover: coverPath
|
||||
cover: result.cover
|
||||
})
|
||||
}
|
||||
|
||||
@@ -417,7 +477,7 @@ class ApiController {
|
||||
account.token = await this.auth.generateAccessToken({ userId: account.id })
|
||||
account.createdAt = Date.now()
|
||||
var newUser = new User(account)
|
||||
var success = await this.db.insertUser(newUser)
|
||||
var success = await this.db.insertEntity('user', newUser)
|
||||
if (success) {
|
||||
this.clientEmitter(req.user.id, 'user_added', newUser)
|
||||
res.json({
|
||||
@@ -497,7 +557,7 @@ class ApiController {
|
||||
}
|
||||
var settingsUpdate = req.body
|
||||
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
||||
return res.sendStatus(500)
|
||||
return res.status(500).send('Invalid settings update object')
|
||||
}
|
||||
var madeUpdates = this.db.serverSettings.update(settingsUpdate)
|
||||
if (madeUpdates) {
|
||||
@@ -539,5 +599,49 @@ class ApiController {
|
||||
genres: this.db.getGenres()
|
||||
})
|
||||
}
|
||||
|
||||
async getDirectories(dir, relpath, excludedDirs, level = 0) {
|
||||
try {
|
||||
var paths = await fs.readdir(dir)
|
||||
|
||||
var dirs = await Promise.all(paths.map(async dirname => {
|
||||
var fullPath = Path.join(dir, dirname)
|
||||
var path = Path.join(relpath, dirname)
|
||||
|
||||
var isDir = (await fs.lstat(fullPath)).isDirectory()
|
||||
if (isDir && !excludedDirs.includes(dirname)) {
|
||||
return {
|
||||
path,
|
||||
dirname,
|
||||
fullPath,
|
||||
level,
|
||||
dirs: level < 4 ? (await this.getDirectories(fullPath, path, excludedDirs, level + 1)) : []
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}))
|
||||
dirs = dirs.filter(d => d)
|
||||
return dirs
|
||||
} catch (error) {
|
||||
Logger.error('Failed to readdir', dir, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async getFileSystemPaths(req, res) {
|
||||
var excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc']
|
||||
|
||||
// Do not include existing mapped library paths in response
|
||||
this.db.libraries.forEach(lib => {
|
||||
lib.folders.forEach((folder) => {
|
||||
excludedDirs.push(Path.basename(folder.fullPath))
|
||||
})
|
||||
})
|
||||
|
||||
Logger.debug(`[Server] get file system paths, excluded: ${excludedDirs.join(', ')}`)
|
||||
var dirs = await this.getDirectories(global.appRoot, '/', excludedDirs)
|
||||
res.json(dirs)
|
||||
}
|
||||
}
|
||||
module.exports = ApiController
|
||||
@@ -103,18 +103,18 @@ class Auth {
|
||||
|
||||
var user = this.users.find(u => u.username === username)
|
||||
|
||||
if (!user) {
|
||||
return res.json({ error: 'User not found' })
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return res.json({ error: 'User unavailable' })
|
||||
if (!user || !user.isActive) {
|
||||
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
|
||||
if (req.rateLimit.remaining <= 2) {
|
||||
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
|
||||
}
|
||||
return res.status(401).send('Invalid user or password')
|
||||
}
|
||||
|
||||
// Check passwordless root user
|
||||
if (user.id === 'root' && (!user.pash || user.pash === '')) {
|
||||
if (password) {
|
||||
return res.json({ error: 'Invalid root password (hint: there is none)' })
|
||||
return res.status(401).send('Invalid root password (hint: there is none)')
|
||||
} else {
|
||||
return res.json({ user: user.toJSONForBrowser() })
|
||||
}
|
||||
@@ -127,12 +127,24 @@ class Auth {
|
||||
user: user.toJSONForBrowser()
|
||||
})
|
||||
} else {
|
||||
res.json({
|
||||
error: 'Invalid Password'
|
||||
})
|
||||
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
|
||||
if (req.rateLimit.remaining <= 2) {
|
||||
Logger.error(`[Auth] Failed login attempt for user ${user.username}. Attempts: ${req.rateLimit.current}`)
|
||||
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
|
||||
}
|
||||
return res.status(401).send('Invalid user or password')
|
||||
}
|
||||
}
|
||||
|
||||
// Not in use now
|
||||
lockUser(user) {
|
||||
user.isLocked = true
|
||||
return this.db.updateEntity('user', user).catch((error) => {
|
||||
Logger.error('[Auth] Failed to lock user', user.username, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
comparePassword(password, user) {
|
||||
if (user.type === 'root' && !password && !user.pash) return true
|
||||
if (!password || !user.pash) return false
|
||||
|
||||
@@ -7,6 +7,8 @@ class BookFinder {
|
||||
constructor() {
|
||||
this.openLibrary = new OpenLibrary()
|
||||
this.libGen = new LibGen()
|
||||
|
||||
this.verbose = false
|
||||
}
|
||||
|
||||
async findByISBN(isbn) {
|
||||
@@ -92,17 +94,17 @@ class BookFinder {
|
||||
return b
|
||||
}).filter(b => {
|
||||
if (b.includesTitle) { // If search title was found in result title then skip over leven distance check
|
||||
Logger.debug(`Exact title was included in "${b.title}", Search: "${b.includesTitle}"`)
|
||||
if (this.verbose) Logger.debug(`Exact title was included in "${b.title}", Search: "${b.includesTitle}"`)
|
||||
} else if (b.titleDistance > maxTitleDistance) {
|
||||
Logger.debug(`Filtering out search result title distance = ${b.titleDistance}: "${b.cleanedTitle}"/"${searchTitle}"`)
|
||||
if (this.verbose) Logger.debug(`Filtering out search result title distance = ${b.titleDistance}: "${b.cleanedTitle}"/"${searchTitle}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (author) {
|
||||
if (b.includesAuthor) { // If search author was found in result author then skip over leven distance check
|
||||
Logger.debug(`Exact author was included in "${b.author}", Search: "${b.includesAuthor}"`)
|
||||
if (this.verbose) Logger.debug(`Exact author was included in "${b.author}", Search: "${b.includesAuthor}"`)
|
||||
} else if (b.authorDistance > maxAuthorDistance) {
|
||||
Logger.debug(`Filtering out search result "${b.author}", author distance = ${b.authorDistance}: "${b.author}"/"${author}"`)
|
||||
if (this.verbose) Logger.debug(`Filtering out search result "${b.author}", author distance = ${b.authorDistance}: "${b.author}"/"${author}"`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -115,28 +117,28 @@ class BookFinder {
|
||||
|
||||
async getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) {
|
||||
var books = await this.libGen.search(title)
|
||||
Logger.debug(`LibGen Book Search Results: ${books.length || 0}`)
|
||||
if (this.verbose) Logger.debug(`LibGen Book Search Results: ${books.length || 0}`)
|
||||
if (books.errorCode) {
|
||||
Logger.error(`LibGen Search Error ${books.errorCode}`)
|
||||
return []
|
||||
}
|
||||
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
|
||||
if (!booksFiltered.length && books.length) {
|
||||
Logger.debug(`Search has ${books.length} matches, but no close title matches`)
|
||||
if (this.verbose) Logger.debug(`Search has ${books.length} matches, but no close title matches`)
|
||||
}
|
||||
return booksFiltered
|
||||
}
|
||||
|
||||
async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) {
|
||||
var books = await this.openLibrary.searchTitle(title)
|
||||
Logger.debug(`OpenLib Book Search Results: ${books.length || 0}`)
|
||||
if (this.verbose) Logger.debug(`OpenLib Book Search Results: ${books.length || 0}`)
|
||||
if (books.errorCode) {
|
||||
Logger.error(`OpenLib Search Error ${books.errorCode}`)
|
||||
return []
|
||||
}
|
||||
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
|
||||
if (!booksFiltered.length && books.length) {
|
||||
Logger.debug(`Search has ${books.length} matches, but no close title matches`)
|
||||
if (this.verbose) Logger.debug(`Search has ${books.length} matches, but no close title matches`)
|
||||
}
|
||||
return booksFiltered
|
||||
}
|
||||
@@ -145,7 +147,7 @@ class BookFinder {
|
||||
var books = []
|
||||
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
|
||||
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
|
||||
Logger.debug(`Book Search, title: "${title}", author: "${author}", provider: ${provider}`)
|
||||
Logger.debug(`Cover Search: title: "${title}", author: "${author}", provider: ${provider}`)
|
||||
|
||||
if (provider === 'libgen') {
|
||||
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
|
||||
184
server/CoverController.js
Normal file
184
server/CoverController.js
Normal file
@@ -0,0 +1,184 @@
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const axios = require('axios')
|
||||
const Logger = require('./Logger')
|
||||
const readChunk = require('read-chunk')
|
||||
const imageType = require('image-type')
|
||||
|
||||
const globals = require('./utils/globals')
|
||||
const { CoverDestination } = require('./utils/constants')
|
||||
|
||||
class CoverController {
|
||||
constructor(db, MetadataPath, AudiobookPath) {
|
||||
this.db = db
|
||||
this.MetadataPath = MetadataPath
|
||||
this.BookMetadataPath = Path.join(this.MetadataPath, 'books')
|
||||
this.AudiobookPath = AudiobookPath
|
||||
}
|
||||
|
||||
getCoverDirectory(audiobook) {
|
||||
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
||||
return {
|
||||
fullPath: audiobook.fullPath,
|
||||
relPath: '/s/book/' + audiobook.id
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
fullPath: Path.join(this.BookMetadataPath, audiobook.id),
|
||||
relPath: Path.join('/metadata', 'books', audiobook.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getFilesInDirectory(dir) {
|
||||
try {
|
||||
return fs.readdir(dir)
|
||||
} catch (error) {
|
||||
Logger.error(`[CoverController] Failed to get files in dir ${dir}`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
removeFile(filepath) {
|
||||
try {
|
||||
return fs.pathExists(filepath).then((exists) => {
|
||||
if (!exists) Logger.warn(`[CoverController] Attempting to remove file that does not exist ${filepath}`)
|
||||
return exists ? fs.unlink(filepath) : false
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error(`[CoverController] Failed to remove file "${filepath}"`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Remove covers that dont have the same filename as the new cover
|
||||
async removeOldCovers(dirpath, newCoverExt) {
|
||||
var filesInDir = await this.getFilesInDirectory(dirpath)
|
||||
|
||||
for (let i = 0; i < filesInDir.length; i++) {
|
||||
var file = filesInDir[i]
|
||||
var _extname = Path.extname(file)
|
||||
var _filename = Path.basename(file, _extname)
|
||||
if (_filename === 'cover' && _extname !== newCoverExt) {
|
||||
var filepath = Path.join(dirpath, file)
|
||||
Logger.debug(`[CoverController] Removing old cover from metadata "${filepath}"`)
|
||||
await this.removeFile(filepath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async checkFileIsValidImage(imagepath) {
|
||||
const buffer = await readChunk(imagepath, 0, 12)
|
||||
const imgType = imageType(buffer)
|
||||
if (!imgType) {
|
||||
await this.removeFile(imagepath)
|
||||
return {
|
||||
error: 'Invalid image'
|
||||
}
|
||||
}
|
||||
|
||||
if (!globals.SupportedImageTypes.includes(imgType.ext)) {
|
||||
await this.removeFile(imagepath)
|
||||
return {
|
||||
error: `Invalid image type ${imgType.ext} (Supported: ${globals.SupportedImageTypes.join(',')})`
|
||||
}
|
||||
}
|
||||
return imgType
|
||||
}
|
||||
|
||||
async uploadCover(audiobook, coverFile) {
|
||||
var extname = Path.extname(coverFile.name.toLowerCase())
|
||||
if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) {
|
||||
return {
|
||||
error: `Invalid image type ${extname} (Supported: ${globals.SupportedImageTypes.join(',')})`
|
||||
}
|
||||
}
|
||||
|
||||
var { fullPath, relPath } = this.getCoverDirectory(audiobook)
|
||||
await fs.ensureDir(fullPath)
|
||||
|
||||
var coverFilename = `cover${extname}`
|
||||
var coverFullPath = Path.join(fullPath, coverFilename)
|
||||
var coverPath = Path.join(relPath, coverFilename)
|
||||
|
||||
// Move cover from temp upload dir to destination
|
||||
var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
|
||||
Logger.error('[CoverController] Failed to move cover file', path, error)
|
||||
return false
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
return {
|
||||
error: 'Failed to move cover into destination'
|
||||
}
|
||||
}
|
||||
|
||||
await this.removeOldCovers(fullPath, extname)
|
||||
|
||||
Logger.info(`[CoverController] Uploaded audiobook cover "${coverPath}" for "${audiobook.title}"`)
|
||||
|
||||
audiobook.updateBookCover(coverPath, coverFullPath)
|
||||
return {
|
||||
cover: coverPath
|
||||
}
|
||||
}
|
||||
|
||||
async downloadFile(url, filepath) {
|
||||
Logger.debug(`[CoverController] Starting file download to ${filepath}`)
|
||||
const writer = fs.createWriteStream(filepath)
|
||||
const response = await axios({
|
||||
url,
|
||||
method: 'GET',
|
||||
responseType: 'stream'
|
||||
})
|
||||
response.data.pipe(writer)
|
||||
return new Promise((resolve, reject) => {
|
||||
writer.on('finish', resolve)
|
||||
writer.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
async downloadCoverFromUrl(audiobook, url) {
|
||||
try {
|
||||
var { fullPath, relPath } = this.getCoverDirectory(audiobook)
|
||||
await fs.ensureDir(fullPath)
|
||||
|
||||
var temppath = Path.join(fullPath, 'cover')
|
||||
var success = await this.downloadFile(url, temppath).then(() => true).catch((err) => {
|
||||
Logger.error(`[CoverController] Download image file failed for "${url}"`, err)
|
||||
return false
|
||||
})
|
||||
if (!success) {
|
||||
return {
|
||||
error: 'Failed to download image from url'
|
||||
}
|
||||
}
|
||||
|
||||
var imgtype = await this.checkFileIsValidImage(temppath)
|
||||
|
||||
if (imgtype.error) {
|
||||
return imgtype
|
||||
}
|
||||
|
||||
var coverFilename = `cover.${imgtype.ext}`
|
||||
var coverPath = Path.join(relPath, coverFilename)
|
||||
var coverFullPath = Path.join(fullPath, coverFilename)
|
||||
await fs.rename(temppath, coverFullPath)
|
||||
|
||||
await this.removeOldCovers(fullPath, '.' + imgtype.ext)
|
||||
|
||||
Logger.info(`[CoverController] Downloaded audiobook cover "${coverPath}" from url "${url}" for "${audiobook.title}"`)
|
||||
|
||||
audiobook.updateBookCover(coverPath, coverFullPath)
|
||||
return {
|
||||
cover: coverPath
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[CoverController] Fetch cover image from url "${url}" failed`, error)
|
||||
return {
|
||||
error: 'Failed to fetch image from url'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
module.exports = CoverController
|
||||
132
server/Db.js
132
server/Db.js
@@ -4,20 +4,25 @@ const jwt = require('jsonwebtoken')
|
||||
const Logger = require('./Logger')
|
||||
const Audiobook = require('./objects/Audiobook')
|
||||
const User = require('./objects/User')
|
||||
const Library = require('./objects/Library')
|
||||
const ServerSettings = require('./objects/ServerSettings')
|
||||
|
||||
class Db {
|
||||
constructor(CONFIG_PATH) {
|
||||
this.ConfigPath = CONFIG_PATH
|
||||
this.AudiobooksPath = Path.join(CONFIG_PATH, 'audiobooks')
|
||||
this.UsersPath = Path.join(CONFIG_PATH, 'users')
|
||||
this.SettingsPath = Path.join(CONFIG_PATH, 'settings')
|
||||
constructor(ConfigPath, AudiobookPath) {
|
||||
this.ConfigPath = ConfigPath
|
||||
this.AudiobookPath = AudiobookPath
|
||||
this.AudiobooksPath = Path.join(ConfigPath, 'audiobooks')
|
||||
this.UsersPath = Path.join(ConfigPath, 'users')
|
||||
this.LibrariesPath = Path.join(ConfigPath, 'libraries')
|
||||
this.SettingsPath = Path.join(ConfigPath, 'settings')
|
||||
|
||||
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 })
|
||||
|
||||
this.users = []
|
||||
this.libraries = []
|
||||
this.audiobooks = []
|
||||
this.settings = []
|
||||
|
||||
@@ -27,18 +32,14 @@ class Db {
|
||||
getEntityDb(entityName) {
|
||||
if (entityName === 'user') return this.usersDb
|
||||
else if (entityName === 'audiobook') return this.audiobooksDb
|
||||
else if (entityName === 'library') return this.librariesDb
|
||||
return this.settingsDb
|
||||
}
|
||||
|
||||
getEntityDbKey(entityName) {
|
||||
if (entityName === 'user') return 'usersDb'
|
||||
else if (entityName === 'audiobook') return 'audiobooksDb'
|
||||
return 'settingsDb'
|
||||
}
|
||||
|
||||
getEntityArrayKey(entityName) {
|
||||
if (entityName === 'user') return 'users'
|
||||
else if (entityName === 'audiobook') return 'audiobooks'
|
||||
else if (entityName === 'library') return 'libraries'
|
||||
return 'settings'
|
||||
}
|
||||
|
||||
@@ -46,7 +47,6 @@ class Db {
|
||||
return new User({
|
||||
id: 'root',
|
||||
type: 'root',
|
||||
|
||||
username: 'root',
|
||||
pash: '',
|
||||
stream: null,
|
||||
@@ -56,6 +56,20 @@ class Db {
|
||||
})
|
||||
}
|
||||
|
||||
getDefaultLibrary() {
|
||||
var defaultLibrary = new Library()
|
||||
defaultLibrary.setData({
|
||||
id: 'main',
|
||||
name: 'Main',
|
||||
folder: { // Generates default folder
|
||||
id: 'audiobooks',
|
||||
fullPath: this.AudiobookPath,
|
||||
libraryId: 'main'
|
||||
}
|
||||
})
|
||||
return defaultLibrary
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.load()
|
||||
|
||||
@@ -63,25 +77,33 @@ class Db {
|
||||
if (!this.users.find(u => u.type === 'root')) {
|
||||
var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
|
||||
Logger.debug('Generated default token', token)
|
||||
await this.insertUser(this.getDefaultUser(token))
|
||||
await this.insertEntity('user', this.getDefaultUser(token))
|
||||
}
|
||||
|
||||
if (!this.libraries.length) {
|
||||
await this.insertEntity('library', this.getDefaultLibrary())
|
||||
}
|
||||
|
||||
if (!this.serverSettings) {
|
||||
this.serverSettings = new ServerSettings()
|
||||
await this.insertSettings(this.serverSettings)
|
||||
await this.insertEntity('settings', this.serverSettings)
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
var p1 = this.audiobooksDb.select(() => true).then((results) => {
|
||||
this.audiobooks = results.data.map(a => new Audiobook(a))
|
||||
Logger.info(`[DB] Audiobooks Loaded ${this.audiobooks.length}`)
|
||||
Logger.info(`[DB] ${this.audiobooks.length} Audiobooks Loaded`)
|
||||
})
|
||||
var p2 = this.usersDb.select(() => true).then((results) => {
|
||||
this.users = results.data.map(u => new User(u))
|
||||
Logger.info(`[DB] Users Loaded ${this.users.length}`)
|
||||
Logger.info(`[DB] ${this.users.length} Users Loaded`)
|
||||
})
|
||||
var p3 = this.settingsDb.select(() => true).then((results) => {
|
||||
var p3 = this.librariesDb.select(() => true).then((results) => {
|
||||
this.libraries = results.data.map(l => new Library(l))
|
||||
Logger.info(`[DB] ${this.libraries.length} Libraries Loaded`)
|
||||
})
|
||||
var p4 = this.settingsDb.select(() => true).then((results) => {
|
||||
if (results.data && results.data.length) {
|
||||
this.settings = results.data
|
||||
var serverSettings = this.settings.find(s => s.id === 'server-settings')
|
||||
@@ -90,30 +112,21 @@ class Db {
|
||||
}
|
||||
}
|
||||
})
|
||||
await Promise.all([p1, p2, p3])
|
||||
await Promise.all([p1, p2, p3, p4])
|
||||
}
|
||||
|
||||
insertSettings(settings) {
|
||||
return this.settingsDb.insert([settings]).then((results) => {
|
||||
Logger.debug(`[DB] Inserted ${results.inserted} settings`)
|
||||
this.settings = this.settings.concat(settings)
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Insert settings Failed ${error}`)
|
||||
})
|
||||
}
|
||||
// insertAudiobook(audiobook) {
|
||||
// return this.insertAudiobooks([audiobook])
|
||||
// }
|
||||
|
||||
insertAudiobook(audiobook) {
|
||||
return this.insertAudiobooks([audiobook])
|
||||
}
|
||||
|
||||
insertAudiobooks(audiobooks) {
|
||||
return this.audiobooksDb.insert(audiobooks).then((results) => {
|
||||
Logger.debug(`[DB] Inserted ${results.inserted} audiobooks`)
|
||||
this.audiobooks = this.audiobooks.concat(audiobooks)
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Insert audiobooks Failed ${error}`)
|
||||
})
|
||||
}
|
||||
// insertAudiobooks(audiobooks) {
|
||||
// return this.audiobooksDb.insert(audiobooks).then((results) => {
|
||||
// Logger.debug(`[DB] Inserted ${results.inserted} audiobooks`)
|
||||
// this.audiobooks = this.audiobooks.concat(audiobooks)
|
||||
// }).catch((error) => {
|
||||
// Logger.error(`[DB] Insert audiobooks Failed ${error}`)
|
||||
// })
|
||||
// }
|
||||
|
||||
updateAudiobook(audiobook) {
|
||||
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
|
||||
@@ -125,16 +138,25 @@ class Db {
|
||||
})
|
||||
}
|
||||
|
||||
insertUser(user) {
|
||||
return this.usersDb.insert([user]).then((results) => {
|
||||
Logger.debug(`[DB] Inserted user ${results.inserted}`)
|
||||
this.users.push(user)
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Insert user Failed ${error}`)
|
||||
return false
|
||||
})
|
||||
}
|
||||
// insertUser(user) {
|
||||
// return this.usersDb.insert([user]).then((results) => {
|
||||
// Logger.debug(`[DB] Inserted user ${results.inserted}`)
|
||||
// this.users.push(user)
|
||||
// return true
|
||||
// }).catch((error) => {
|
||||
// Logger.error(`[DB] Insert user Failed ${error}`)
|
||||
// return false
|
||||
// })
|
||||
// }
|
||||
|
||||
// insertSettings(settings) {
|
||||
// return this.settingsDb.insert([settings]).then((results) => {
|
||||
// Logger.debug(`[DB] Inserted ${results.inserted} settings`)
|
||||
// this.settings = this.settings.concat(settings)
|
||||
// }).catch((error) => {
|
||||
// Logger.error(`[DB] Insert settings Failed ${error}`)
|
||||
// })
|
||||
// }
|
||||
|
||||
updateUserStream(userId, streamId) {
|
||||
return this.usersDb.update((record) => record.id === userId, (user) => {
|
||||
@@ -153,6 +175,20 @@ class Db {
|
||||
})
|
||||
}
|
||||
|
||||
insertEntity(entityName, entity) {
|
||||
var entityDb = this.getEntityDb(entityName)
|
||||
return entityDb.insert([entity]).then((results) => {
|
||||
Logger.debug(`[DB] Inserted ${results.inserted} ${entityName}`)
|
||||
|
||||
var arrayKey = this.getEntityArrayKey(entityName)
|
||||
this[arrayKey].push(entity)
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Failed to insert ${entityName}`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
updateEntity(entityName, entity) {
|
||||
var entityDb = this.getEntityDb(entityName)
|
||||
return entityDb.update((record) => record.id === entity.id, () => entity).then((results) => {
|
||||
|
||||
@@ -240,8 +240,8 @@ class DownloadManager {
|
||||
}
|
||||
|
||||
if (shouldIncludeCover) {
|
||||
var _cover = audiobook.book.cover
|
||||
if (_cover.startsWith(Path.sep + 'local')) {
|
||||
var _cover = audiobook.book.coverFullPath
|
||||
if (!_cover && audiobook.book.cover && audiobook.book.cover.startsWith(Path.sep + 'local')) {
|
||||
_cover = Path.join(this.AudiobookPath, _cover.replace(Path.sep + 'local', ''))
|
||||
Logger.debug('Local cover url', _cover)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class HlsController {
|
||||
}
|
||||
|
||||
parseSegmentFilename(filename) {
|
||||
var basename = Path.basename(filename, '.ts')
|
||||
var basename = Path.basename(filename, Path.extname(filename))
|
||||
var num_part = basename.split('-')[1]
|
||||
return Number(num_part)
|
||||
}
|
||||
@@ -41,7 +41,7 @@ class HlsController {
|
||||
Logger.warn('File path does not exist', fullFilePath)
|
||||
|
||||
var fileExt = Path.extname(req.params.file)
|
||||
if (fileExt === '.ts') {
|
||||
if (fileExt === '.ts' || fileExt === '.m4s') {
|
||||
var segNum = this.parseSegmentFilename(req.params.file)
|
||||
var stream = this.streamManager.getStream(streamId)
|
||||
if (!stream) {
|
||||
@@ -66,6 +66,7 @@ class HlsController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Logger.info('Sending file', fullFilePath)
|
||||
res.sendFile(fullFilePath)
|
||||
}
|
||||
|
||||
@@ -1,55 +1,110 @@
|
||||
const LOG_LEVEL = {
|
||||
TRACE: 0,
|
||||
DEBUG: 1,
|
||||
INFO: 2,
|
||||
WARN: 3,
|
||||
ERROR: 4,
|
||||
FATAL: 5
|
||||
}
|
||||
const { LogLevel } = require('./utils/constants')
|
||||
|
||||
class Logger {
|
||||
constructor() {
|
||||
let env_log_level = process.env.LOG_LEVEL || 'TRACE'
|
||||
this.LogLevel = LOG_LEVEL[env_log_level] || LOG_LEVEL.TRACE
|
||||
this.info(`Log Level: ${this.LogLevel}`)
|
||||
this.logLevel = process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.TRACE
|
||||
this.socketListeners = []
|
||||
}
|
||||
|
||||
get timestamp() {
|
||||
return (new Date()).toISOString()
|
||||
}
|
||||
|
||||
get levelString() {
|
||||
for (const key in LogLevel) {
|
||||
if (LogLevel[key] === this.logLevel) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return 'UNKNOWN'
|
||||
}
|
||||
|
||||
getLogLevelString(level) {
|
||||
for (const key in LogLevel) {
|
||||
if (LogLevel[key] === level) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return 'UNKNOWN'
|
||||
}
|
||||
|
||||
addSocketListener(socket, level) {
|
||||
var index = this.socketListeners.findIndex(s => s.id === socket.id)
|
||||
if (index >= 0) {
|
||||
this.socketListeners.splice(index, 1, {
|
||||
id: socket.id,
|
||||
socket,
|
||||
level
|
||||
})
|
||||
} else {
|
||||
this.socketListeners.push({
|
||||
id: socket.id,
|
||||
socket,
|
||||
level
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
removeSocketListener(socketId) {
|
||||
this.socketListeners = this.socketListeners.filter(s => s.id !== socketId)
|
||||
}
|
||||
|
||||
logToSockets(level, args) {
|
||||
this.socketListeners.forEach((socketListener) => {
|
||||
if (socketListener.level <= level) {
|
||||
socketListener.socket.emit('log', {
|
||||
timestamp: this.timestamp,
|
||||
message: args.join(' '),
|
||||
levelName: this.getLogLevelString(level),
|
||||
level
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setLogLevel(level) {
|
||||
this.logLevel = level
|
||||
this.debug(`Set Log Level to ${this.levelString}`)
|
||||
}
|
||||
|
||||
trace(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.TRACE) return
|
||||
if (this.logLevel > LogLevel.TRACE) return
|
||||
console.trace(`[${this.timestamp}] TRACE:`, ...args)
|
||||
this.logToSockets(LogLevel.TRACE, args)
|
||||
}
|
||||
|
||||
debug(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.DEBUG) return
|
||||
if (this.logLevel > LogLevel.DEBUG) return
|
||||
console.debug(`[${this.timestamp}] DEBUG:`, ...args)
|
||||
this.logToSockets(LogLevel.DEBUG, args)
|
||||
}
|
||||
|
||||
info(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.INFO) return
|
||||
if (this.logLevel > LogLevel.INFO) return
|
||||
console.info(`[${this.timestamp}] INFO:`, ...args)
|
||||
}
|
||||
|
||||
note(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.INFO) return
|
||||
console.log(`[${this.timestamp}] NOTE:`, ...args)
|
||||
this.logToSockets(LogLevel.INFO, args)
|
||||
}
|
||||
|
||||
warn(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.WARN) return
|
||||
if (this.logLevel > LogLevel.WARN) return
|
||||
console.warn(`[${this.timestamp}] WARN:`, ...args)
|
||||
this.logToSockets(LogLevel.WARN, args)
|
||||
}
|
||||
|
||||
error(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.ERROR) return
|
||||
if (this.logLevel > LogLevel.ERROR) return
|
||||
console.error(`[${this.timestamp}] ERROR:`, ...args)
|
||||
this.logToSockets(LogLevel.ERROR, args)
|
||||
}
|
||||
|
||||
fatal(...args) {
|
||||
console.error(`[${this.timestamp}] FATAL:`, ...args)
|
||||
this.logToSockets(LogLevel.FATAL, args)
|
||||
}
|
||||
|
||||
note(...args) {
|
||||
console.log(`[${this.timestamp}] NOTE:`, ...args)
|
||||
this.logToSockets(LogLevel.NOTE, args)
|
||||
}
|
||||
}
|
||||
module.exports = new Logger()
|
||||
@@ -1,22 +1,32 @@
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
|
||||
// Utils
|
||||
const Logger = require('./Logger')
|
||||
const BookFinder = require('./BookFinder')
|
||||
const Audiobook = require('./objects/Audiobook')
|
||||
const { version } = require('../package.json')
|
||||
const audioFileScanner = require('./utils/audioFileScanner')
|
||||
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('./utils/scandir')
|
||||
const { comparePaths, getIno } = require('./utils/index')
|
||||
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||
const { ScanResult } = require('./utils/constants')
|
||||
const { ScanResult, CoverDestination } = require('./utils/constants')
|
||||
|
||||
// Classes
|
||||
const BookFinder = require('./BookFinder')
|
||||
const Audiobook = require('./objects/Audiobook')
|
||||
|
||||
class Scanner {
|
||||
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
|
||||
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, coverController, emitter) {
|
||||
this.AudiobookPath = AUDIOBOOK_PATH
|
||||
this.MetadataPath = METADATA_PATH
|
||||
this.BookMetadataPath = Path.join(this.MetadataPath, 'books')
|
||||
|
||||
this.db = db
|
||||
this.coverController = coverController
|
||||
this.emitter = emitter
|
||||
|
||||
this.cancelScan = false
|
||||
this.cancelLibraryScan = {}
|
||||
this.librariesScanning = []
|
||||
|
||||
this.bookFinder = new BookFinder()
|
||||
}
|
||||
@@ -25,23 +35,18 @@ class Scanner {
|
||||
return this.db.audiobooks
|
||||
}
|
||||
|
||||
async setAudiobookDataInos(audiobookData) {
|
||||
for (let i = 0; i < audiobookData.length; i++) {
|
||||
var abd = audiobookData[i]
|
||||
var matchingAB = this.db.audiobooks.find(_ab => comparePaths(_ab.path, abd.path))
|
||||
if (matchingAB) {
|
||||
if (!matchingAB.ino) {
|
||||
matchingAB.ino = await getIno(matchingAB.fullPath)
|
||||
}
|
||||
abd.ino = matchingAB.ino
|
||||
} else {
|
||||
abd.ino = await getIno(abd.fullPath)
|
||||
if (!abd.ino) {
|
||||
Logger.error('[Scanner] Invalid ino - ignoring audiobook data', abd.path)
|
||||
}
|
||||
getCoverDirectory(audiobook) {
|
||||
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
|
||||
return {
|
||||
fullPath: audiobook.fullPath,
|
||||
relPath: '/s/book/' + audiobook.id
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
fullPath: Path.join(this.BookMetadataPath, audiobook.id),
|
||||
relPath: Path.join('/metadata', 'books', audiobook.id)
|
||||
}
|
||||
}
|
||||
return audiobookData.filter(abd => !!abd.ino)
|
||||
}
|
||||
|
||||
async setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) {
|
||||
@@ -63,154 +68,417 @@ class Scanner {
|
||||
return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino)
|
||||
}
|
||||
|
||||
async scanAudiobookData(audiobookData) {
|
||||
var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino)
|
||||
Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
|
||||
// Only updates audio files with matching paths
|
||||
syncAudiobookInodeValues(audiobook, { audioFiles, otherFiles }) {
|
||||
var filesUpdated = 0
|
||||
|
||||
if (existingAudiobook) {
|
||||
|
||||
// REMOVE: No valid audio files
|
||||
// TODO: Label as incomplete, do not actually delete
|
||||
if (!audiobookData.audioFiles.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
|
||||
|
||||
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
||||
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
||||
|
||||
return ScanResult.REMOVED
|
||||
}
|
||||
|
||||
audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles)
|
||||
|
||||
// Check for audio files that were removed
|
||||
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
|
||||
var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino))
|
||||
if (removedAudioFiles.length) {
|
||||
Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`)
|
||||
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
|
||||
}
|
||||
|
||||
// Check for new audio files and sync existing audio files
|
||||
var newAudioFiles = []
|
||||
var hasUpdatedAudioFiles = false
|
||||
audiobookData.audioFiles.forEach((file) => {
|
||||
var existingAudioFile = existingAudiobook.getAudioFileByIno(file.ino)
|
||||
if (existingAudioFile) { // Audio file exists, sync paths
|
||||
if (existingAudiobook.syncAudioFile(existingAudioFile, file)) {
|
||||
hasUpdatedAudioFiles = true
|
||||
}
|
||||
// Sync audio files & audio tracks with updated inodes
|
||||
audiobook._audioFiles.forEach((audioFile) => {
|
||||
var matchingAudioFile = audioFiles.find(af => af.ino !== audioFile.ino && af.path === audioFile.path)
|
||||
if (matchingAudioFile) {
|
||||
// Audio Track should always have the same ino as the equivalent audio file (not all audio files have a track)
|
||||
var audioTrack = audiobook.tracks.find(t => t.ino === audioFile.ino)
|
||||
if (audioTrack) {
|
||||
Logger.debug(`[Scanner] Found audio file & track with mismatched inode "${audioFile.filename}" - updating it`)
|
||||
audioTrack.ino = matchingAudioFile.ino
|
||||
filesUpdated++
|
||||
} else {
|
||||
var audioFileWithMatchingPath = existingAudiobook.getAudioFileByPath(file.fullPath)
|
||||
if (audioFileWithMatchingPath) {
|
||||
Logger.warn(`[Scanner] Audio file with path already exists with different inode, New: "${file.filename}" (${file.ino}) | Existing: ${audioFileWithMatchingPath.filename} (${audioFileWithMatchingPath.ino})`)
|
||||
} else {
|
||||
newAudioFiles.push(file)
|
||||
}
|
||||
Logger.debug(`[Scanner] Found audio file with mismatched inode "${audioFile.filename}" - updating it`)
|
||||
}
|
||||
})
|
||||
if (newAudioFiles.length) {
|
||||
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
|
||||
// Scan new audio files found - sets tracks
|
||||
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
|
||||
|
||||
audioFile.ino = matchingAudioFile.ino
|
||||
filesUpdated++
|
||||
}
|
||||
})
|
||||
|
||||
// REMOVE: No valid audio tracks
|
||||
// TODO: Label as incomplete, do not actually delete
|
||||
if (!existingAudiobook.tracks.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
|
||||
|
||||
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
||||
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
||||
return ScanResult.REMOVED
|
||||
// Sync other files with updated inodes
|
||||
audiobook._otherFiles.forEach((otherFile) => {
|
||||
var matchingOtherFile = otherFiles.find(of => of.ino !== otherFile.ino && of.path === otherFile.path)
|
||||
if (matchingOtherFile) {
|
||||
Logger.debug(`[Scanner] Found other file with mismatched inode "${otherFile.filename}" - updating it`)
|
||||
otherFile.ino = matchingOtherFile.ino
|
||||
filesUpdated++
|
||||
}
|
||||
})
|
||||
|
||||
var hasUpdates = removedAudioFiles.length || newAudioFiles.length || hasUpdatedAudioFiles
|
||||
return filesUpdated
|
||||
}
|
||||
|
||||
if (existingAudiobook.checkUpdateMissingParts()) {
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
|
||||
hasUpdates = true
|
||||
async searchForCover(audiobook) {
|
||||
var options = {
|
||||
titleDistance: 2,
|
||||
authorDistance: 2
|
||||
}
|
||||
var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
|
||||
if (results.length) {
|
||||
Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`)
|
||||
|
||||
// If the first cover result fails, attempt to download the second
|
||||
for (let i = 0; i < results.length && i < 2; i++) {
|
||||
|
||||
// Downloads and updates the book cover
|
||||
var result = await this.coverController.downloadCoverFromUrl(audiobook, results[i])
|
||||
|
||||
if (result.error) {
|
||||
Logger.error(`[Scanner] Failed to download cover from url "${results[i]}" | Attempt ${i + 1}`, result.error)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (existingAudiobook.syncOtherFiles(audiobookData.otherFiles)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Syncs path and fullPath
|
||||
if (existingAudiobook.syncPaths(audiobookData)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (existingAudiobook.isMissing) {
|
||||
existingAudiobook.isMissing = false
|
||||
hasUpdates = true
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was missing but now it is found`)
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
existingAudiobook.setChapters()
|
||||
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
|
||||
existingAudiobook.lastUpdate = Date.now()
|
||||
await this.db.updateAudiobook(existingAudiobook)
|
||||
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
||||
|
||||
return ScanResult.UPDATED
|
||||
}
|
||||
|
||||
return ScanResult.UPTODATE
|
||||
async scanExistingAudiobook(existingAudiobook, audiobookData, hasUpdatedIno, hasUpdatedLibraryOrFolder, forceAudioFileScan) {
|
||||
// Always sync files and inode values
|
||||
var filesInodeUpdated = this.syncAudiobookInodeValues(existingAudiobook, audiobookData)
|
||||
if (hasUpdatedIno || filesInodeUpdated > 0) {
|
||||
Logger.info(`[Scanner] Updating inode value for "${existingAudiobook.title}" - ${filesInodeUpdated} files updated`)
|
||||
hasUpdatedIno = true
|
||||
}
|
||||
|
||||
// NEW: Check new audiobook
|
||||
// TEMP: Check if is older audiobook and needs force rescan
|
||||
if (!forceAudioFileScan && (!existingAudiobook.scanVersion || existingAudiobook.checkHasOldCoverPath())) {
|
||||
Logger.info(`[Scanner] Force rescan for "${existingAudiobook.title}" | Last scan v${existingAudiobook.scanVersion} | Old Cover Path ${!!existingAudiobook.checkHasOldCoverPath()}`)
|
||||
forceAudioFileScan = true
|
||||
}
|
||||
|
||||
// ino is now set for every file in scandir
|
||||
audiobookData.audioFiles = audiobookData.audioFiles.filter(af => af.ino)
|
||||
|
||||
// REMOVE: No valid audio files
|
||||
// TODO: Label as incomplete, do not actually delete
|
||||
if (!audiobookData.audioFiles.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
|
||||
|
||||
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
||||
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
||||
|
||||
return ScanResult.REMOVED
|
||||
}
|
||||
|
||||
// Check for audio files that were removed
|
||||
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
|
||||
var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino))
|
||||
if (removedAudioFiles.length) {
|
||||
Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`)
|
||||
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
|
||||
}
|
||||
|
||||
// Check for mismatched audio tracks - tracks with no matching audio file
|
||||
var removedAudioTracks = existingAudiobook.tracks.filter(track => !abdAudioFileInos.includes(track.ino))
|
||||
if (removedAudioTracks.length) {
|
||||
Logger.error(`[Scanner] ${removedAudioTracks.length} tracks removed no matching audio file for audiobook "${existingAudiobook.title}"`)
|
||||
removedAudioTracks.forEach((at) => existingAudiobook.removeAudioTrack(at))
|
||||
}
|
||||
|
||||
// Check for new audio files and sync existing audio files
|
||||
var newAudioFiles = []
|
||||
var hasUpdatedAudioFiles = false
|
||||
audiobookData.audioFiles.forEach((file) => {
|
||||
var existingAudioFile = existingAudiobook.getAudioFileByIno(file.ino)
|
||||
if (existingAudioFile) { // Audio file exists, sync path (path may have been renamed)
|
||||
if (existingAudiobook.syncAudioFile(existingAudioFile, file)) {
|
||||
hasUpdatedAudioFiles = true
|
||||
}
|
||||
} else {
|
||||
// New audio file, triple check for matching file path
|
||||
var audioFileWithMatchingPath = existingAudiobook.getAudioFileByPath(file.fullPath)
|
||||
if (audioFileWithMatchingPath) {
|
||||
Logger.warn(`[Scanner] Audio file with path already exists with different inode, New: "${file.filename}" (${file.ino}) | Existing: ${audioFileWithMatchingPath.filename} (${audioFileWithMatchingPath.ino})`)
|
||||
} else {
|
||||
newAudioFiles.push(file)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Rescan audio file metadata
|
||||
if (forceAudioFileScan) {
|
||||
Logger.info(`[Scanner] Rescanning ${existingAudiobook.audioFiles.length} audio files for "${existingAudiobook.title}"`)
|
||||
var numAudioFilesUpdated = await audioFileScanner.rescanAudioFiles(existingAudiobook)
|
||||
if (numAudioFilesUpdated > 0) {
|
||||
Logger.info(`[Scanner] Rescan complete, ${numAudioFilesUpdated} audio files were updated for "${existingAudiobook.title}"`)
|
||||
hasUpdatedAudioFiles = true
|
||||
|
||||
// Use embedded cover art if audiobook has no cover
|
||||
if (existingAudiobook.hasEmbeddedCoverArt && !existingAudiobook.cover) {
|
||||
var outputCoverDirs = this.getCoverDirectory(existingAudiobook)
|
||||
var relativeDir = await existingAudiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath)
|
||||
if (relativeDir) {
|
||||
Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.info(`[Scanner] Rescan complete, audio files were up to date for "${existingAudiobook.title}"`)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan and add new audio files found and set tracks
|
||||
if (newAudioFiles.length) {
|
||||
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
|
||||
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
|
||||
}
|
||||
|
||||
// If after a scan no valid audio tracks remain
|
||||
// TODO: Label as incomplete, do not actually delete
|
||||
if (!existingAudiobook.tracks.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
|
||||
|
||||
await this.db.removeEntity('audiobook', existingAudiobook.id)
|
||||
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
|
||||
return ScanResult.REMOVED
|
||||
}
|
||||
|
||||
var hasUpdates = hasUpdatedIno || hasUpdatedLibraryOrFolder || removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
|
||||
|
||||
// Check that audio tracks are in sequential order with no gaps
|
||||
if (existingAudiobook.checkUpdateMissingParts()) {
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Sync other files (all files that are not audio files) - Updates cover path
|
||||
var otherFilesUpdated = await existingAudiobook.syncOtherFiles(audiobookData.otherFiles, this.MetadataPath, forceAudioFileScan)
|
||||
if (otherFilesUpdated) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Syncs path and fullPath
|
||||
if (existingAudiobook.syncPaths(audiobookData)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// If audiobook was missing before, it is now found
|
||||
if (existingAudiobook.isMissing) {
|
||||
existingAudiobook.isMissing = false
|
||||
hasUpdates = true
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was missing but now it is found`)
|
||||
}
|
||||
|
||||
if (hasUpdates || version !== existingAudiobook.scanVersion) {
|
||||
existingAudiobook.setChapters()
|
||||
existingAudiobook.setLastScan(version)
|
||||
await this.db.updateAudiobook(existingAudiobook)
|
||||
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated`)
|
||||
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
||||
return ScanResult.UPDATED
|
||||
}
|
||||
|
||||
return ScanResult.UPTODATE
|
||||
}
|
||||
|
||||
async scanNewAudiobook(audiobookData) {
|
||||
if (!audiobookData.audioFiles.length) {
|
||||
Logger.error('[Scanner] No valid audio tracks for Audiobook', audiobookData.path)
|
||||
return ScanResult.NOTHING
|
||||
return null
|
||||
}
|
||||
|
||||
var audiobook = new Audiobook()
|
||||
audiobook.setData(audiobookData)
|
||||
|
||||
// Scan audio files and set tracks, pulls metadata
|
||||
await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
|
||||
if (!audiobook.tracks.length) {
|
||||
Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title)
|
||||
return ScanResult.NOTHING
|
||||
return null
|
||||
}
|
||||
|
||||
// Look for desc.txt and reader.txt and update
|
||||
await audiobook.saveDataFromTextFiles()
|
||||
|
||||
// Extract embedded cover art if cover is not already in directory
|
||||
if (audiobook.hasEmbeddedCoverArt && !audiobook.cover) {
|
||||
var outputCoverDirs = this.getCoverDirectory(audiobook)
|
||||
var relativeDir = await audiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath)
|
||||
if (relativeDir) {
|
||||
Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`)
|
||||
}
|
||||
}
|
||||
|
||||
// Set book details from metadata pulled from audio files
|
||||
audiobook.setDetailsFromFileMetadata()
|
||||
|
||||
// Check for gaps in track numbers
|
||||
audiobook.checkUpdateMissingParts()
|
||||
|
||||
// Set chapters from audio files
|
||||
audiobook.setChapters()
|
||||
|
||||
audiobook.setLastScan(version)
|
||||
|
||||
Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
|
||||
await this.db.insertAudiobook(audiobook)
|
||||
await this.db.insertEntity('audiobook', audiobook)
|
||||
this.emitter('audiobook_added', audiobook.toJSONMinified())
|
||||
return ScanResult.ADDED
|
||||
return audiobook
|
||||
}
|
||||
|
||||
async scan() {
|
||||
// TODO: This temporary fix from pre-release should be removed soon, including the "fixRelativePath" and "checkUpdateInos"
|
||||
// TEMP - fix relative file paths
|
||||
// TEMP - update ino for each audiobook
|
||||
// if (this.audiobooks.length) {
|
||||
// for (let i = 0; i < this.audiobooks.length; i++) {
|
||||
// var ab = this.audiobooks[i]
|
||||
// var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
|
||||
async scanAudiobookData(audiobookData, forceAudioFileScan = false) {
|
||||
var scannerFindCovers = this.db.serverSettings.scannerFindCovers
|
||||
var libraryId = audiobookData.libraryId
|
||||
var folderId = audiobookData.folderId
|
||||
|
||||
// // Update ino if an audio file has the same ino as the audiobook
|
||||
// var shouldUpdateIno = !ab.ino || (ab.audioFiles || []).find(abf => abf.ino === ab.ino)
|
||||
// if (shouldUpdateIno) {
|
||||
// await ab.checkUpdateInos()
|
||||
// }
|
||||
// if (shouldUpdate) {
|
||||
// await this.db.updateAudiobook(ab)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
var hasUpdatedLibraryOrFolder = false
|
||||
|
||||
var existingAudiobook = this.audiobooks.find(ab => ab.ino === audiobookData.ino)
|
||||
|
||||
// Make sure existing audiobook has the same library & folder id
|
||||
if (existingAudiobook && (existingAudiobook.libraryId !== libraryId || existingAudiobook.folderId !== folderId)) {
|
||||
var existingAudiobookLibrary = this.db.libraries.find(lib => lib.id === existingAudiobook.libraryId)
|
||||
|
||||
if (!existingAudiobookLibrary) {
|
||||
Logger.error(`[Scanner] Audiobook "${existingAudiobook.title}" found in different library that no longer exists ${existingAudiobook.libraryId}`)
|
||||
} else if (existingAudiobook.libraryId !== libraryId) {
|
||||
Logger.warn(`[Scanner] Audiobook "${existingAudiobook.title}" found in different library "${existingAudiobookLibrary.name}"`)
|
||||
} else {
|
||||
Logger.warn(`[Scanner] Audiobook "${existingAudiobook.title}" found in different folder "${existingAudiobook.folderId}" of library "${existingAudiobookLibrary.name}"`)
|
||||
}
|
||||
|
||||
existingAudiobook.libraryId = libraryId
|
||||
existingAudiobook.folderId = folderId
|
||||
hasUpdatedLibraryOrFolder = true
|
||||
Logger.info(`[Scanner] Updated Audiobook "${existingAudiobook.title}" library and folder to "${libraryId}" "${folderId}"`)
|
||||
}
|
||||
|
||||
// var audiobooksInLibrary = this.audiobooks.filter(ab => ab.libraryId === libraryId)
|
||||
// var existingAudiobook = audiobooksInLibrary.find(a => a.ino === audiobookData.ino)
|
||||
|
||||
// inode value may change when using shared drives, update inode if matching path is found
|
||||
// Note: inode will not change on rename
|
||||
var hasUpdatedIno = false
|
||||
if (!existingAudiobook) {
|
||||
// check an audiobook exists with matching path, then update inodes
|
||||
existingAudiobook = this.audiobooks.find(a => a.path === audiobookData.path)
|
||||
if (existingAudiobook) {
|
||||
var oldIno = existingAudiobook.ino
|
||||
existingAudiobook.ino = audiobookData.ino
|
||||
Logger.debug(`[Scanner] Scan Audiobook Data: Updated inode from "${oldIno}" to "${existingAudiobook.ino}"`)
|
||||
hasUpdatedIno = true
|
||||
|
||||
if (existingAudiobook.libraryId !== libraryId || existingAudiobook.folderId !== folderId) {
|
||||
Logger.warn(`[Scanner] Audiobook found by path is in a different library or folder, ${existingAudiobook.libraryId}/${existingAudiobook.folderId} should be ${libraryId}/${folderId}`)
|
||||
|
||||
existingAudiobook.libraryId = libraryId
|
||||
existingAudiobook.folderId = folderId
|
||||
hasUpdatedLibraryOrFolder = true
|
||||
Logger.info(`[Scanner] Updated Audiobook "${existingAudiobook.title}" library and folder to "${libraryId}" "${folderId}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var scanResult = null
|
||||
var finalAudiobook = null
|
||||
|
||||
if (existingAudiobook) {
|
||||
finalAudiobook = existingAudiobook
|
||||
|
||||
scanResult = await this.scanExistingAudiobook(existingAudiobook, audiobookData, hasUpdatedIno, hasUpdatedLibraryOrFolder, forceAudioFileScan)
|
||||
|
||||
if (scanResult === ScanResult.REMOVED || scanResult === ScanResult.NOTHING) {
|
||||
finalAudiobook = null
|
||||
}
|
||||
} else {
|
||||
finalAudiobook = await this.scanNewAudiobook(audiobookData)
|
||||
|
||||
scanResult = finalAudiobook ? ScanResult.ADDED : ScanResult.NOTHING
|
||||
|
||||
if (finalAudiobook === ScanResult.NOTHING) {
|
||||
finalAudiobook = null
|
||||
scanResult = ScanResult.NOTHING
|
||||
} else {
|
||||
scanResult = ScanResult.ADDED
|
||||
}
|
||||
}
|
||||
|
||||
// Scan for cover if enabled and has no cover
|
||||
if (finalAudiobook && scannerFindCovers && !finalAudiobook.cover) {
|
||||
if (finalAudiobook.book.shouldSearchForCover) {
|
||||
var updatedCover = await this.searchForCover(finalAudiobook)
|
||||
|
||||
finalAudiobook.book.updateLastCoverSearch(updatedCover)
|
||||
|
||||
if (updatedCover && scanResult === ScanResult.UPTODATE) {
|
||||
scanResult = ScanResult.UPDATED
|
||||
}
|
||||
await this.db.updateAudiobook(finalAudiobook)
|
||||
this.emitter('audiobook_updated', finalAudiobook.toJSONMinified())
|
||||
} else {
|
||||
Logger.debug(`[Scanner] Audiobook "${finalAudiobook.title}" cover already scanned - not re-scanning`)
|
||||
}
|
||||
}
|
||||
|
||||
return scanResult
|
||||
}
|
||||
|
||||
async scan(libraryId, forceAudioFileScan = false) {
|
||||
if (this.librariesScanning.includes(libraryId)) {
|
||||
Logger.error(`[Scanner] Already scanning ${libraryId}`)
|
||||
return
|
||||
}
|
||||
|
||||
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[Scanner] Library not found for scan ${libraryId}`)
|
||||
return
|
||||
} else if (!library.folders.length) {
|
||||
Logger.warn(`[Scanner] Library has no folders to scan "${library.name}"`)
|
||||
return
|
||||
}
|
||||
|
||||
var scanPayload = {
|
||||
id: libraryId,
|
||||
name: library.name,
|
||||
scanType: 'library',
|
||||
folders: library.folders.length
|
||||
}
|
||||
this.emitter('scan_start', scanPayload)
|
||||
Logger.info(`[Scanner] Starting scan of library "${library.name}" with ${library.folders.length} folders`)
|
||||
|
||||
library.lastScan = Date.now()
|
||||
await this.db.updateEntity('library', library)
|
||||
|
||||
this.librariesScanning.push(scanPayload)
|
||||
|
||||
var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === libraryId)
|
||||
|
||||
// TODO: This temporary fix from pre-release should be removed soon, "checkUpdateInos"
|
||||
// TEMP - update ino for each audiobook
|
||||
if (audiobooksInLibrary.length) {
|
||||
for (let i = 0; i < audiobooksInLibrary.length; i++) {
|
||||
var ab = audiobooksInLibrary[i]
|
||||
// Update ino if inos are not set
|
||||
var shouldUpdateIno = ab.hasMissingIno
|
||||
if (shouldUpdateIno) {
|
||||
var filesWithMissingIno = ab.getFilesWithMissingIno()
|
||||
|
||||
Logger.debug(`\n\Updating inos for "${ab.title}"`)
|
||||
Logger.debug(`In Scan, Files with missing inode`, filesWithMissingIno)
|
||||
|
||||
var hasUpdates = await ab.checkUpdateInos()
|
||||
if (hasUpdates) {
|
||||
await this.db.updateAudiobook(ab)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scanStart = Date.now()
|
||||
var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings)
|
||||
var audiobookDataFound = []
|
||||
for (let i = 0; i < library.folders.length; i++) {
|
||||
var folder = library.folders[i]
|
||||
var abDataFoundInFolder = await scanRootDir(folder, this.db.serverSettings)
|
||||
Logger.debug(`[Scanner] ${abDataFoundInFolder.length} ab data found in folder "${folder.fullPath}"`)
|
||||
audiobookDataFound = audiobookDataFound.concat(abDataFoundInFolder)
|
||||
}
|
||||
|
||||
// Set ino for each ab data as a string
|
||||
audiobookDataFound = await this.setAudiobookDataInos(audiobookDataFound)
|
||||
// Remove audiobooks with no inode
|
||||
audiobookDataFound = audiobookDataFound.filter(abd => abd.ino)
|
||||
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
if (this.cancelLibraryScan[libraryId]) {
|
||||
Logger.info(`[Scanner] Canceling scan ${libraryId}`)
|
||||
delete this.cancelLibraryScan[libraryId]
|
||||
this.librariesScanning = this.librariesScanning.filter(l => l.id !== libraryId)
|
||||
this.emitter('scan_complete', { id: libraryId, name: library.name, scanType: 'library', results: null })
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -222,8 +490,8 @@ class Scanner {
|
||||
}
|
||||
|
||||
// Check for removed audiobooks
|
||||
for (let i = 0; i < this.audiobooks.length; i++) {
|
||||
var audiobook = this.audiobooks[i]
|
||||
for (let i = 0; i < audiobooksInLibrary.length; i++) {
|
||||
var audiobook = audiobooksInLibrary[i]
|
||||
var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino)
|
||||
if (!dataFound) {
|
||||
Logger.info(`[Scanner] Audiobook "${audiobook.title}" is missing`)
|
||||
@@ -233,126 +501,235 @@ class Scanner {
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||
}
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
return null
|
||||
if (this.cancelLibraryScan[libraryId]) {
|
||||
Logger.info(`[Scanner] Canceling scan ${libraryId}`)
|
||||
delete this.cancelLibraryScan[libraryId]
|
||||
this.librariesScanning = this.librariesScanning.filter(l => l.id !== libraryId)
|
||||
this.emitter('scan_complete', { id: libraryId, name: library.name, scanType: 'library', results: scanResults })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new and updated audiobooks
|
||||
for (let i = 0; i < audiobookDataFound.length; i++) {
|
||||
var audiobookData = audiobookDataFound[i]
|
||||
var result = await this.scanAudiobookData(audiobookData)
|
||||
var result = await this.scanAudiobookData(audiobookDataFound[i], forceAudioFileScan)
|
||||
if (result === ScanResult.ADDED) scanResults.added++
|
||||
if (result === ScanResult.REMOVED) scanResults.removed++
|
||||
if (result === ScanResult.UPDATED) scanResults.updated++
|
||||
|
||||
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
|
||||
this.emitter('scan_progress', {
|
||||
scanType: 'files',
|
||||
id: libraryId,
|
||||
name: library.name,
|
||||
scanType: 'library',
|
||||
progress: {
|
||||
total: audiobookDataFound.length,
|
||||
done: i + 1,
|
||||
progress
|
||||
}
|
||||
})
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
if (this.cancelLibraryScan[libraryId]) {
|
||||
Logger.info(`[Scanner] Canceling scan ${libraryId}`)
|
||||
delete this.cancelLibraryScan[libraryId]
|
||||
break
|
||||
}
|
||||
}
|
||||
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
|
||||
Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | ${scanResults.missing} missing | elapsed: ${secondsToTimestamp(scanElapsed)}`)
|
||||
return scanResults
|
||||
this.librariesScanning = this.librariesScanning.filter(l => l.id !== libraryId)
|
||||
this.emitter('scan_complete', { id: libraryId, name: library.name, scanType: 'library', results: scanResults })
|
||||
}
|
||||
|
||||
async scanAudiobook(audiobookPath) {
|
||||
Logger.debug('[Scanner] scanAudiobook', audiobookPath)
|
||||
var audiobookData = await getAudiobookFileData(this.AudiobookPath, audiobookPath, this.db.serverSettings)
|
||||
async scanAudiobookById(audiobookId) {
|
||||
const audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||
if (!audiobook) {
|
||||
Logger.error(`[Scanner] Scan audiobook by id not found ${audiobookId}`)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
const library = this.db.libraries.find(lib => lib.id === audiobook.libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[Scanner] Scan audiobook by id library not found "${audiobook.libraryId}"`)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
const folder = library.folders.find(f => f.id === audiobook.folderId)
|
||||
if (!folder) {
|
||||
Logger.error(`[Scanner] Scan audiobook by id folder not found "${audiobook.folderId}" in library "${library.name}"`)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
if (!folder.libraryId) {
|
||||
Logger.fatal(`[Scanner] Folder does not have a library id set...`, folder)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
|
||||
Logger.info(`[Scanner] Scanning Audiobook "${audiobook.title}"`)
|
||||
return this.scanAudiobook(folder, audiobook.fullPath, true)
|
||||
}
|
||||
|
||||
async scanAudiobook(folder, audiobookFullPath, forceAudioFileScan = false) {
|
||||
Logger.debug('[Scanner] scanAudiobook', audiobookFullPath)
|
||||
var audiobookData = await getAudiobookFileData(folder, audiobookFullPath, this.db.serverSettings)
|
||||
if (!audiobookData) {
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
audiobookData.ino = await getIno(audiobookData.fullPath)
|
||||
return this.scanAudiobookData(audiobookData)
|
||||
return this.scanAudiobookData(audiobookData, forceAudioFileScan)
|
||||
}
|
||||
|
||||
// Files were modified in this directory, check it out
|
||||
async checkDir(dir) {
|
||||
var exists = await fs.pathExists(dir)
|
||||
if (!exists) {
|
||||
// Audiobook was deleted, TODO: Should confirm this better
|
||||
var audiobook = this.db.audiobooks.find(ab => ab.fullPath === dir)
|
||||
if (audiobook) {
|
||||
var audiobookJSON = audiobook.toJSONMinified()
|
||||
await this.db.removeEntity('audiobook', audiobook.id)
|
||||
this.emitter('audiobook_removed', audiobookJSON)
|
||||
return ScanResult.REMOVED
|
||||
// async checkDir(dir) {
|
||||
// var exists = await fs.pathExists(dir)
|
||||
// if (!exists) {
|
||||
// // Audiobook was deleted, TODO: Should confirm this better
|
||||
// var audiobook = this.db.audiobooks.find(ab => ab.fullPath === dir)
|
||||
// if (audiobook) {
|
||||
// var audiobookJSON = audiobook.toJSONMinified()
|
||||
// await this.db.removeEntity('audiobook', audiobook.id)
|
||||
// this.emitter('audiobook_removed', audiobookJSON)
|
||||
// return ScanResult.REMOVED
|
||||
// }
|
||||
|
||||
// // Path inside audiobook was deleted, scan audiobook
|
||||
// audiobook = this.db.audiobooks.find(ab => dir.startsWith(ab.fullPath))
|
||||
// if (audiobook) {
|
||||
// Logger.info(`[Scanner] Path inside audiobook "${audiobook.title}" was deleted: ${dir}`)
|
||||
// return this.scanAudiobook(audiobook.fullPath)
|
||||
// }
|
||||
|
||||
// Logger.warn('[Scanner] Path was deleted but no audiobook found', dir)
|
||||
// return ScanResult.NOTHING
|
||||
// }
|
||||
|
||||
// // Check if this is a subdirectory of an audiobook
|
||||
// var audiobook = this.db.audiobooks.find((ab) => dir.startsWith(ab.fullPath))
|
||||
// if (audiobook) {
|
||||
// Logger.debug(`[Scanner] Check Dir audiobook "${audiobook.title}" found: ${dir}`)
|
||||
// return this.scanAudiobook(audiobook.fullPath)
|
||||
// }
|
||||
|
||||
// // Check if an audiobook is a subdirectory of this dir
|
||||
// audiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(dir))
|
||||
// if (audiobook) {
|
||||
// Logger.warn(`[Scanner] Files were added/updated in a root directory of an existing audiobook, ignore files: ${dir}`)
|
||||
// return ScanResult.NOTHING
|
||||
// }
|
||||
|
||||
// // Must be a new audiobook
|
||||
// Logger.debug(`[Scanner] Check Dir must be a new audiobook: ${dir}`)
|
||||
// return this.scanAudiobook(dir)
|
||||
// }
|
||||
|
||||
async scanFolderUpdates(libraryId, folderId, fileUpdateBookGroup) {
|
||||
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[Scanner] Library "${libraryId}" not found for scan library updates`)
|
||||
return null
|
||||
}
|
||||
var folder = library.folders.find(f => f.id === folderId)
|
||||
if (!folder) {
|
||||
Logger.error(`[Scanner] Folder "${folderId}" not found in library "${library.name}" for scan library updates`)
|
||||
return null
|
||||
}
|
||||
|
||||
Logger.debug(`[Scanner] Scanning file update groups in folder "${folder.id}" of library "${library.name}"`)
|
||||
|
||||
var bookGroupingResults = {}
|
||||
for (const bookDir in fileUpdateBookGroup) {
|
||||
var fullPath = Path.join(folder.fullPath, bookDir)
|
||||
|
||||
// Check if book dir group is already an audiobook or in a subdir of an audiobook
|
||||
var existingAudiobook = this.db.audiobooks.find(ab => fullPath.startsWith(ab.fullPath))
|
||||
if (existingAudiobook) {
|
||||
|
||||
// Is the audiobook exactly - check if was deleted
|
||||
if (existingAudiobook.fullPath === fullPath) {
|
||||
var exists = await fs.pathExists(fullPath)
|
||||
if (!exists) {
|
||||
Logger.info(`[Scanner] Scanning file update group and audiobook was deleted "${existingAudiobook.title}" - marking as missing`)
|
||||
existingAudiobook.isMissing = true
|
||||
existingAudiobook.lastUpdate = Date.now()
|
||||
await this.db.updateAudiobook(existingAudiobook)
|
||||
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
||||
|
||||
bookGroupingResults[bookDir] = ScanResult.REMOVED
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan audiobook for updates
|
||||
Logger.debug(`[Scanner] Folder update for relative path "${bookDir}" is in audiobook "${existingAudiobook.title}" - scan for updates`)
|
||||
bookGroupingResults[bookDir] = await this.scanAudiobook(folder, existingAudiobook.fullPath)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Path inside audiobook was deleted, scan audiobook
|
||||
audiobook = this.db.audiobooks.find(ab => dir.startsWith(ab.fullPath))
|
||||
if (audiobook) {
|
||||
Logger.info(`[Scanner] Path inside audiobook "${audiobook.title}" was deleted: ${dir}`)
|
||||
return this.scanAudiobook(audiobook.fullPath)
|
||||
// Check if an audiobook is a subdirectory of this dir
|
||||
var childAudiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(fullPath))
|
||||
if (childAudiobook) {
|
||||
Logger.warn(`[Scanner] Files were modified in a parent directory of an audiobook "${childAudiobook.title}" - ignoring`)
|
||||
bookGroupingResults[bookDir] = ScanResult.NOTHING
|
||||
continue;
|
||||
}
|
||||
|
||||
Logger.warn('[Scanner] Path was deleted but no audiobook found', dir)
|
||||
return ScanResult.NOTHING
|
||||
Logger.debug(`[Scanner] Folder update group must be a new book "${bookDir}" in library "${library.name}"`)
|
||||
bookGroupingResults[bookDir] = await this.scanAudiobook(folder, fullPath)
|
||||
}
|
||||
|
||||
// Check if this is a subdirectory of an audiobook
|
||||
var audiobook = this.db.audiobooks.find((ab) => dir.startsWith(ab.fullPath))
|
||||
if (audiobook) {
|
||||
Logger.debug(`[Scanner] Check Dir audiobook "${audiobook.title}" found: ${dir}`)
|
||||
return this.scanAudiobook(audiobook.fullPath)
|
||||
}
|
||||
|
||||
// Check if an audiobook is a subdirectory of this dir
|
||||
audiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(dir))
|
||||
if (audiobook) {
|
||||
Logger.warn(`[Scanner] Files were added/updated in a root directory of an existing audiobook, ignore files: ${dir}`)
|
||||
return ScanResult.NOTHING
|
||||
}
|
||||
|
||||
// Must be a new audiobook
|
||||
Logger.debug(`[Scanner] Check Dir must be a new audiobook: ${dir}`)
|
||||
return this.scanAudiobook(dir)
|
||||
return bookGroupingResults
|
||||
}
|
||||
|
||||
// Array of files that may have been renamed, removed or added
|
||||
async filesChanged(filepaths) {
|
||||
if (!filepaths.length) return ScanResult.NOTHING
|
||||
var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
|
||||
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths)
|
||||
// Array of file update objects that may have been renamed, removed or added
|
||||
async filesChanged(fileUpdates) {
|
||||
if (!fileUpdates.length) return null
|
||||
|
||||
var results = []
|
||||
for (const dir in fileGroupings) {
|
||||
Logger.debug(`[Scanner] Check dir ${dir}`)
|
||||
var fullPath = Path.join(this.AudiobookPath, dir)
|
||||
var result = await this.checkDir(fullPath)
|
||||
Logger.debug(`[Scanner] Check dir result ${result}`)
|
||||
results.push(result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
// Group files by folder
|
||||
var folderGroups = {}
|
||||
fileUpdates.forEach((file) => {
|
||||
if (folderGroups[file.folderId]) {
|
||||
folderGroups[file.folderId].fileUpdates.push(file)
|
||||
} else {
|
||||
folderGroups[file.folderId] = {
|
||||
libraryId: file.libraryId,
|
||||
folderId: file.folderId,
|
||||
fileUpdates: [file]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async fetchMetadata(id, trackIndex = 0) {
|
||||
var audiobook = this.audiobooks.find(a => a.id === id)
|
||||
if (!audiobook) {
|
||||
return false
|
||||
const libraryScanResults = {}
|
||||
|
||||
// Group files by book
|
||||
for (const folderId in folderGroups) {
|
||||
var libraryId = folderGroups[folderId].libraryId
|
||||
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[Scanner] Library not found in files changed ${libraryId}`)
|
||||
continue;
|
||||
}
|
||||
var folder = library.getFolderById(folderId)
|
||||
if (!folder) {
|
||||
Logger.error(`[Scanner] Folder is not in library in files changed "${folderId}", Library "${library.name}"`)
|
||||
|
||||
Logger.debug(`Looking at folders in library "${library.name}" for folderid ${folderId}`)
|
||||
library.folders.forEach((fold) => {
|
||||
Logger.debug(`Folder "${fold.id}" "${fold.fullPath}"`)
|
||||
})
|
||||
continue;
|
||||
}
|
||||
|
||||
var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath)
|
||||
var fileUpdateBookGroup = groupFilesIntoAudiobookPaths(relFilePaths, true)
|
||||
var folderScanResults = await this.scanFolderUpdates(libraryId, folderId, fileUpdateBookGroup)
|
||||
libraryScanResults[libraryId] = folderScanResults
|
||||
}
|
||||
var tracks = audiobook.tracks
|
||||
var index = isNaN(trackIndex) ? 0 : Number(trackIndex)
|
||||
var firstTrack = tracks[index]
|
||||
var firstTrackFullPath = firstTrack.fullPath
|
||||
var scanResult = await audioFileScanner.scan(firstTrackFullPath)
|
||||
return scanResult
|
||||
|
||||
Logger.debug(`[Scanner] Finished scanning file changes, results:`, libraryScanResults)
|
||||
return libraryScanResults
|
||||
}
|
||||
|
||||
async scanCovers() {
|
||||
var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
|
||||
var found = 0
|
||||
var notFound = 0
|
||||
var failed = 0
|
||||
|
||||
for (let i = 0; i < audiobooksNeedingCover.length; i++) {
|
||||
var audiobook = audiobooksNeedingCover[i]
|
||||
var options = {
|
||||
@@ -362,10 +739,15 @@ class Scanner {
|
||||
var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
|
||||
if (results.length) {
|
||||
Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`)
|
||||
audiobook.book.cover = results[0]
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
found++
|
||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||
var coverUrl = results[0]
|
||||
var result = await this.coverController.downloadCoverFromUrl(audiobook, coverUrl)
|
||||
if (result.error) {
|
||||
failed++
|
||||
} else {
|
||||
found++
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||
}
|
||||
} else {
|
||||
notFound++
|
||||
}
|
||||
@@ -387,7 +769,41 @@ class Scanner {
|
||||
}
|
||||
return {
|
||||
found,
|
||||
notFound
|
||||
notFound,
|
||||
failed
|
||||
}
|
||||
}
|
||||
|
||||
async saveMetadata(audiobookId) {
|
||||
if (audiobookId) {
|
||||
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||
if (!audiobook) {
|
||||
return {
|
||||
error: 'Audiobook not found'
|
||||
}
|
||||
}
|
||||
var savedPath = await audiobook.writeNfoFile()
|
||||
return {
|
||||
audiobookId,
|
||||
audiobookTitle: audiobook.title,
|
||||
savedPath
|
||||
}
|
||||
} else {
|
||||
var response = {
|
||||
success: 0,
|
||||
failed: 0
|
||||
}
|
||||
for (let i = 0; i < this.db.audiobooks.length; i++) {
|
||||
var audiobook = this.db.audiobooks[i]
|
||||
var savedPath = await audiobook.writeNfoFile()
|
||||
if (savedPath) {
|
||||
Logger.info(`[Scanner] Saved metadata nfo ${savedPath}`)
|
||||
response.success++
|
||||
} else {
|
||||
response.failed++
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
342
server/Server.js
342
server/Server.js
@@ -4,7 +4,15 @@ const http = require('http')
|
||||
const SocketIO = require('socket.io')
|
||||
const fs = require('fs-extra')
|
||||
const fileUpload = require('express-fileupload')
|
||||
const rateLimit = require('express-rate-limit')
|
||||
|
||||
const { version } = require('../package.json')
|
||||
|
||||
// Utils
|
||||
const { ScanResult } = require('./utils/constants')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
// Classes
|
||||
const Auth = require('./Auth')
|
||||
const Watcher = require('./Watcher')
|
||||
const Scanner = require('./Scanner')
|
||||
@@ -14,7 +22,8 @@ const HlsController = require('./HlsController')
|
||||
const StreamManager = require('./StreamManager')
|
||||
const RssFeeds = require('./RssFeeds')
|
||||
const DownloadManager = require('./DownloadManager')
|
||||
const Logger = require('./Logger')
|
||||
const CoverController = require('./CoverController')
|
||||
|
||||
|
||||
class Server {
|
||||
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||
@@ -28,29 +37,32 @@ class Server {
|
||||
fs.ensureDirSync(METADATA_PATH)
|
||||
fs.ensureDirSync(AUDIOBOOK_PATH)
|
||||
|
||||
this.db = new Db(this.ConfigPath)
|
||||
this.db = new Db(this.ConfigPath, this.AudiobookPath)
|
||||
this.auth = new Auth(this.db)
|
||||
this.watcher = new Watcher(this.AudiobookPath)
|
||||
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
|
||||
this.coverController = new CoverController(this.db, this.MetadataPath, this.AudiobookPath)
|
||||
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
|
||||
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
||||
this.rssFeeds = new RssFeeds(this.Port, this.db)
|
||||
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
|
||||
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.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.watcher, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
|
||||
|
||||
this.expressApp = null
|
||||
this.server = null
|
||||
this.io = null
|
||||
|
||||
this.clients = {}
|
||||
|
||||
this.isScanning = false
|
||||
this.isScanningCovers = false
|
||||
this.isInitialized = false
|
||||
}
|
||||
|
||||
get audiobooks() {
|
||||
return this.db.audiobooks
|
||||
}
|
||||
get libraries() {
|
||||
return this.db.libraries
|
||||
}
|
||||
get serverSettings() {
|
||||
return this.db.serverSettings
|
||||
}
|
||||
@@ -76,40 +88,8 @@ class Server {
|
||||
})
|
||||
}
|
||||
|
||||
async filesChanged(files) {
|
||||
Logger.info('[Server]', files.length, 'Files Changed')
|
||||
var result = await this.scanner.filesChanged(files)
|
||||
Logger.info('[Server] Files changed result', result)
|
||||
}
|
||||
|
||||
async scan() {
|
||||
Logger.info('[Server] Starting Scan')
|
||||
this.isScanning = true
|
||||
this.isInitialized = true
|
||||
this.emitter('scan_start', 'files')
|
||||
var results = await this.scanner.scan()
|
||||
this.isScanning = false
|
||||
this.emitter('scan_complete', { scanType: 'files', results })
|
||||
Logger.info('[Server] Scan complete')
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!this.isScanningCovers && !this.isScanning) return
|
||||
this.scanner.cancelScan = true
|
||||
}
|
||||
|
||||
async init() {
|
||||
Logger.info('[Server] Init')
|
||||
Logger.info('[Server] Init v' + version)
|
||||
await this.streamManager.ensureStreamsDir()
|
||||
await this.streamManager.removeOrphanStreams()
|
||||
await this.downloadManager.removeOrphanDownloads()
|
||||
@@ -117,100 +97,83 @@ class Server {
|
||||
await this.db.init()
|
||||
this.auth.init()
|
||||
|
||||
this.watcher.initWatcher()
|
||||
await this.purgeMetadata()
|
||||
|
||||
this.watcher.initWatcher(this.libraries)
|
||||
this.watcher.on('files', this.filesChanged.bind(this))
|
||||
}
|
||||
|
||||
authMiddleware(req, res, next) {
|
||||
this.auth.authMiddleware(req, res, next)
|
||||
}
|
||||
|
||||
async handleUpload(req, res) {
|
||||
if (!req.user.canUpload) {
|
||||
Logger.warn('User attempted to upload without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var files = Object.values(req.files)
|
||||
var title = req.body.title
|
||||
var author = req.body.author
|
||||
var series = req.body.series
|
||||
|
||||
if (!files.length || !title || !author) {
|
||||
return res.json({
|
||||
error: 'Invalid post data received'
|
||||
})
|
||||
}
|
||||
|
||||
var outputDirectory = ''
|
||||
if (series && series.length && series !== 'null') {
|
||||
outputDirectory = Path.join(this.AudiobookPath, author, series, title)
|
||||
} else {
|
||||
outputDirectory = Path.join(this.AudiobookPath, author, title)
|
||||
}
|
||||
|
||||
var exists = await fs.pathExists(outputDirectory)
|
||||
if (exists) {
|
||||
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
|
||||
return res.json({
|
||||
error: `Directory "${outputDirectory}" already exists`
|
||||
})
|
||||
}
|
||||
|
||||
await fs.ensureDir(outputDirectory)
|
||||
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
var file = files[i]
|
||||
|
||||
var path = Path.join(outputDirectory, file.name)
|
||||
await file.mv(path).catch((error) => {
|
||||
Logger.error('Failed to move file', path, error)
|
||||
})
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async start() {
|
||||
Logger.info('=== Starting Server ===')
|
||||
await this.init()
|
||||
|
||||
const app = express()
|
||||
this.expressApp = app
|
||||
|
||||
this.server = http.createServer(app)
|
||||
|
||||
app.use(this.auth.cors)
|
||||
app.use(fileUpload())
|
||||
|
||||
// Static path to generated nuxt
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use(express.static(distPath))
|
||||
app.use('/local', express.static(this.AudiobookPath))
|
||||
} else {
|
||||
app.use(express.static(this.AudiobookPath))
|
||||
}
|
||||
|
||||
app.use('/metadata', this.authMiddleware.bind(this), express.static(this.MetadataPath))
|
||||
|
||||
app.use(express.static(this.MetadataPath))
|
||||
app.use(express.static(Path.join(global.appRoot, 'static')))
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json())
|
||||
|
||||
// Dynamic routes are not generated on client
|
||||
// Static path to generated nuxt
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
app.use(express.static(distPath))
|
||||
|
||||
// Old static path for covers
|
||||
app.use('/local', this.authMiddleware.bind(this), express.static(this.AudiobookPath))
|
||||
|
||||
// Metadata folder static path
|
||||
app.use('/metadata', this.authMiddleware.bind(this), express.static(this.MetadataPath))
|
||||
|
||||
// Downloads folder static path
|
||||
app.use('/downloads', this.authMiddleware.bind(this), express.static(this.downloadManager.downloadDirPath))
|
||||
|
||||
// Static folder
|
||||
app.use(express.static(Path.join(global.appRoot, 'static')))
|
||||
|
||||
// Static file routes
|
||||
app.get('/lib/:library/:folder/*', this.authMiddleware.bind(this), (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)
|
||||
})
|
||||
|
||||
// Book static file routes
|
||||
app.get('/s/book/:id/*', this.authMiddleware.bind(this), (req, res) => {
|
||||
var audiobook = this.audiobooks.find(ab => ab.id === req.params.id)
|
||||
if (!audiobook) return res.status(404).send('Book not found with id ' + req.params.id)
|
||||
|
||||
var remainingPath = req.params['0']
|
||||
var fullPath = Path.join(audiobook.fullPath, remainingPath)
|
||||
res.sendFile(fullPath)
|
||||
})
|
||||
|
||||
// Client routes
|
||||
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/library/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/library', (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('/library/:library', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/library/:library/bookshelf/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
|
||||
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
|
||||
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
|
||||
// app.use('/hls', this.hlsController.router)
|
||||
app.use('/feeds', this.rssFeeds.router)
|
||||
|
||||
// Incomplete work in progress
|
||||
// app.use('/feeds', this.rssFeeds.router)
|
||||
|
||||
app.post('/upload', this.authMiddleware.bind(this), this.handleUpload.bind(this))
|
||||
|
||||
app.post('/login', (req, res) => this.auth.login(req, res))
|
||||
var loginRateLimiter = this.getLoginRateLimiter()
|
||||
app.post('/login', loginRateLimiter, (req, res) => this.auth.login(req, res))
|
||||
|
||||
app.post('/logout', this.logout.bind(this))
|
||||
|
||||
app.get('/ping', (req, res) => {
|
||||
Logger.info('Recieved ping')
|
||||
res.json({ success: true })
|
||||
@@ -229,7 +192,6 @@ class Server {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
this.server.listen(this.Port, this.Host, () => {
|
||||
Logger.info(`Running on http://${this.Host}:${this.Port}`)
|
||||
})
|
||||
@@ -257,6 +219,8 @@ class Server {
|
||||
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('scan_audiobook', (audiobookId) => this.scanAudiobook(socket, audiobookId))
|
||||
socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId))
|
||||
|
||||
// Streaming
|
||||
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
|
||||
@@ -269,11 +233,15 @@ class Server {
|
||||
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
|
||||
socket.on('remove_download', (downloadId) => this.downloadManager.removeSocketRequest(socket, downloadId))
|
||||
|
||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||
|
||||
socket.on('test', () => {
|
||||
socket.emit('test_received', socket.id)
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
Logger.removeSocketListener(socket.id)
|
||||
|
||||
var _client = this.clients[socket.id]
|
||||
if (!_client) {
|
||||
Logger.warn('[SOCKET] Socket disconnect, no client ' + socket.id)
|
||||
@@ -289,6 +257,150 @@ class Server {
|
||||
})
|
||||
}
|
||||
|
||||
async filesChanged(fileUpdates) {
|
||||
Logger.info('[Server]', fileUpdates.length, 'Files Changed')
|
||||
await this.scanner.filesChanged(fileUpdates)
|
||||
// Logger.debug('[Server] Files changed result', result)
|
||||
}
|
||||
|
||||
async scan(libraryId, forceAudioFileScan = false) {
|
||||
Logger.info('[Server] Starting Scan')
|
||||
await this.scanner.scan(libraryId, forceAudioFileScan)
|
||||
Logger.info('[Server] Scan complete')
|
||||
}
|
||||
|
||||
async scanAudiobook(socket, audiobookId) {
|
||||
var result = await this.scanner.scanAudiobookById(audiobookId)
|
||||
var scanResultName = ''
|
||||
for (const key in ScanResult) {
|
||||
if (ScanResult[key] === result) {
|
||||
scanResultName = key
|
||||
}
|
||||
}
|
||||
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) {
|
||||
Logger.debug('[Server] Cancel scan', id)
|
||||
this.scanner.cancelLibraryScan[id] = true
|
||||
}
|
||||
|
||||
// Generates an NFO metadata file, if no audiobookId is passed then all audiobooks are done
|
||||
async saveMetadata(socket, audiobookId = null) {
|
||||
Logger.info('[Server] Starting save metadata files')
|
||||
var response = await this.scanner.saveMetadata(audiobookId)
|
||||
Logger.info(`[Server] Finished saving metadata files Successful: ${response.success}, Failed: ${response.failed}`)
|
||||
socket.emit('save_metadata_complete', response)
|
||||
}
|
||||
|
||||
// Remove unused /metadata/books/{id} folders
|
||||
async purgeMetadata() {
|
||||
var booksMetadata = Path.join(this.MetadataPath, 'books')
|
||||
var booksMetadataExists = await fs.pathExists(booksMetadata)
|
||||
if (!booksMetadataExists) return
|
||||
var foldersInBooksMetadata = await fs.readdir(booksMetadata)
|
||||
|
||||
var purged = 0
|
||||
await Promise.all(foldersInBooksMetadata.map(async foldername => {
|
||||
var hasMatchingAudiobook = this.audiobooks.find(ab => ab.id === foldername)
|
||||
if (!hasMatchingAudiobook) {
|
||||
var folderPath = Path.join(booksMetadata, foldername)
|
||||
Logger.debug(`[Server] Purging unused metadata ${folderPath}`)
|
||||
|
||||
await fs.remove(folderPath).then(() => {
|
||||
purged++
|
||||
}).catch((err) => {
|
||||
Logger.error(`[Server] Failed to delete folder path ${folderPath}`, err)
|
||||
})
|
||||
}
|
||||
}))
|
||||
if (purged > 0) {
|
||||
Logger.info(`[Server] Purged ${purged} unused audiobook metadata`)
|
||||
}
|
||||
return purged
|
||||
}
|
||||
|
||||
authMiddleware(req, res, next) {
|
||||
this.auth.authMiddleware(req, res, next)
|
||||
}
|
||||
|
||||
async handleUpload(req, res) {
|
||||
if (!req.user.canUpload) {
|
||||
Logger.warn('User attempted to upload without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var files = Object.values(req.files)
|
||||
var title = req.body.title
|
||||
var author = req.body.author
|
||||
var series = req.body.series
|
||||
var libraryId = req.body.library
|
||||
var folderId = req.body.folder
|
||||
|
||||
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||
if (!library) {
|
||||
return res.status(500).error(`Library not found with id ${libraryId}`)
|
||||
}
|
||||
var folder = library.folders.find(fold => fold.id === folderId)
|
||||
if (!folder) {
|
||||
return res.status(500).error(`Folder not found with id ${folderId} in library ${library.name}`)
|
||||
}
|
||||
|
||||
if (!files.length || !title || !author) {
|
||||
return res.status(500).error(`Invalid post data`)
|
||||
}
|
||||
|
||||
var outputDirectory = ''
|
||||
if (series && series.length && series !== 'null') {
|
||||
outputDirectory = Path.join(folder.fullPath, author, series, title)
|
||||
} else {
|
||||
outputDirectory = Path.join(folder.fullPath, author, title)
|
||||
}
|
||||
|
||||
var exists = await fs.pathExists(outputDirectory)
|
||||
if (exists) {
|
||||
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
|
||||
return res.status(500).error(`Directory "${outputDirectory}" already exists`)
|
||||
}
|
||||
|
||||
await fs.ensureDir(outputDirectory)
|
||||
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
var file = files[i]
|
||||
|
||||
var path = Path.join(outputDirectory, file.name)
|
||||
await file.mv(path).catch((error) => {
|
||||
Logger.error('Failed to move file', path, error)
|
||||
})
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// First time login rate limit is hit
|
||||
loginLimitReached(req, res, options) {
|
||||
Logger.error(`[Server] Login rate limit (${options.max}) was hit for ip ${req.ip}`)
|
||||
options.message = 'Too many attempts. Login temporarily locked.'
|
||||
}
|
||||
|
||||
getLoginRateLimiter() {
|
||||
return rateLimit({
|
||||
windowMs: this.db.serverSettings.rateLimitLoginWindow, // 5 minutes
|
||||
max: this.db.serverSettings.rateLimitLoginRequests,
|
||||
skipSuccessfulRequests: true,
|
||||
onLimitReached: this.loginLimitReached
|
||||
})
|
||||
}
|
||||
|
||||
logout(req, res) {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
@@ -328,15 +440,19 @@ class Server {
|
||||
|
||||
const initialPayload = {
|
||||
serverSettings: this.serverSettings.toJSON(),
|
||||
isScanning: this.isScanning,
|
||||
isInitialized: this.isInitialized,
|
||||
audiobookPath: this.AudiobookPath,
|
||||
metadataPath: this.MetadataPath,
|
||||
configPath: this.ConfigPath,
|
||||
user: client.user.toJSONForBrowser(),
|
||||
stream: client.stream || null
|
||||
stream: client.stream || null,
|
||||
librariesScanning: this.scanner.librariesScanning
|
||||
}
|
||||
client.socket.emit('init', initialPayload)
|
||||
|
||||
// Setup log listener for root user
|
||||
if (user.type === 'root') {
|
||||
Logger.addSocketListener(socket, this.db.serverSettings.logLevel || 0)
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
|
||||
@@ -4,107 +4,160 @@ const Watcher = require('watcher')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
class FolderWatcher extends EventEmitter {
|
||||
constructor(audiobookPath) {
|
||||
constructor() {
|
||||
super()
|
||||
this.AudiobookPath = audiobookPath
|
||||
this.folderMap = {}
|
||||
this.watcher = null
|
||||
this.paths = [] // Not used
|
||||
this.pendingFiles = [] // Not used
|
||||
|
||||
this.pendingFiles = []
|
||||
this.libraryWatchers = []
|
||||
this.pendingFileUpdates = []
|
||||
this.pendingDelay = 4000
|
||||
this.pendingTimeout = null
|
||||
}
|
||||
|
||||
initWatcher() {
|
||||
try {
|
||||
Logger.info('[FolderWatcher] Initializing..')
|
||||
this.watcher = new Watcher(this.AudiobookPath, {
|
||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||
renameDetection: true,
|
||||
renameTimeout: 2000,
|
||||
recursive: true,
|
||||
ignoreInitial: true,
|
||||
persistent: true
|
||||
get pendingFilePaths() {
|
||||
return this.pendingFileUpdates.map(f => f.path)
|
||||
}
|
||||
|
||||
buildLibraryWatcher(library) {
|
||||
if (this.libraryWatchers.find(w => w.id === library.id)) {
|
||||
Logger.warn('[Watcher] Already watching library', library.name)
|
||||
return
|
||||
}
|
||||
Logger.info(`[Watcher] Initializing watcher for "${library.name}"..`)
|
||||
var folderPaths = library.folderPaths
|
||||
var watcher = new Watcher(folderPaths, {
|
||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||
renameDetection: true,
|
||||
renameTimeout: 2000,
|
||||
recursive: true,
|
||||
ignoreInitial: true,
|
||||
persistent: true
|
||||
})
|
||||
watcher
|
||||
.on('add', (path) => {
|
||||
this.onNewFile(library.id, path)
|
||||
}).on('change', (path) => {
|
||||
// This is triggered from metadata changes, not what we want
|
||||
// this.onFileUpdated(path)
|
||||
}).on('unlink', path => {
|
||||
this.onFileRemoved(library.id, path)
|
||||
}).on('rename', (path, pathNext) => {
|
||||
this.onRename(library.id, path, pathNext)
|
||||
}).on('error', (error) => {
|
||||
Logger.error(`[Watcher] ${error}`)
|
||||
}).on('ready', () => {
|
||||
Logger.info(`[Watcher] "${library.name}" Ready`)
|
||||
})
|
||||
this.watcher
|
||||
.on('add', (path) => {
|
||||
this.onNewFile(path)
|
||||
}).on('change', (path) => {
|
||||
// This is triggered from metadata changes, not what we want
|
||||
// this.onFileUpdated(path)
|
||||
}).on('unlink', path => {
|
||||
this.onFileRemoved(path)
|
||||
}).on('rename', (path, pathNext) => {
|
||||
this.onRename(path, pathNext)
|
||||
}).on('error', (error) => {
|
||||
Logger.error(`[FolderWatcher] ${error}`)
|
||||
}).on('ready', () => {
|
||||
Logger.info('[FolderWatcher] Ready')
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error('Chokidar watcher failed', error)
|
||||
|
||||
this.libraryWatchers.push({
|
||||
id: library.id,
|
||||
name: library.name,
|
||||
folders: library.folders,
|
||||
paths: library.folderPaths,
|
||||
watcher
|
||||
})
|
||||
}
|
||||
|
||||
initWatcher(libraries) {
|
||||
libraries.forEach((lib) => {
|
||||
this.buildLibraryWatcher(lib)
|
||||
})
|
||||
}
|
||||
|
||||
addLibrary(library) {
|
||||
this.buildLibraryWatcher(library)
|
||||
}
|
||||
|
||||
updateLibrary(library) {
|
||||
var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
|
||||
if (libwatcher) {
|
||||
libwatcher.name = library.name
|
||||
|
||||
var pathsToAdd = library.folderPaths.filter(path => !libwatcher.paths.includes(path))
|
||||
if (pathsToAdd.length) {
|
||||
Logger.info(`[Watcher] Adding paths to library watcher "${library.name}"`)
|
||||
libwatcher.paths = library.folderPaths
|
||||
libwatcher.folders = library.folders
|
||||
libwatcher.watcher.watchPaths(pathsToAdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeLibrary(library) {
|
||||
var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
|
||||
if (libwatcher) {
|
||||
Logger.info(`[Watcher] Removed watcher for "${library.name}"`)
|
||||
libwatcher.watcher.close()
|
||||
this.libraryWatchers = this.libraryWatchers.filter(lib => lib.id !== library.id)
|
||||
} else {
|
||||
Logger.error(`[Watcher] Library watcher not found for "${library.name}"`)
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
return this.watcher.close()
|
||||
return this.libraryWatchers.map(lib => lib.watcher.close())
|
||||
}
|
||||
|
||||
// After [pendingBatchDelay] seconds emit batch
|
||||
async onNewFile(path) {
|
||||
if (this.pendingFiles.includes(path)) return
|
||||
|
||||
Logger.debug('FolderWatcher: New File', path)
|
||||
|
||||
var dir = Path.dirname(path)
|
||||
if (dir === this.AudiobookPath) {
|
||||
Logger.debug('New File added to root dir, ignoring it')
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingFiles.push(path)
|
||||
clearTimeout(this.pendingTimeout)
|
||||
this.pendingTimeout = setTimeout(() => {
|
||||
this.emit('files', this.pendingFiles.map(f => f))
|
||||
this.pendingFiles = []
|
||||
}, this.pendingDelay)
|
||||
onNewFile(libraryId, path) {
|
||||
Logger.debug('[Watcher] File Added', path)
|
||||
this.addFileUpdate(libraryId, path, 'added')
|
||||
}
|
||||
|
||||
onFileRemoved(path) {
|
||||
Logger.debug('[FolderWatcher] File Removed', path)
|
||||
|
||||
var dir = Path.dirname(path)
|
||||
if (dir === this.AudiobookPath) {
|
||||
Logger.debug('New File added to root dir, ignoring it')
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingFiles.push(path)
|
||||
clearTimeout(this.pendingTimeout)
|
||||
this.pendingTimeout = setTimeout(() => {
|
||||
this.emit('files', this.pendingFiles.map(f => f))
|
||||
this.pendingFiles = []
|
||||
}, this.pendingDelay)
|
||||
onFileRemoved(libraryId, path) {
|
||||
Logger.debug('[Watcher] File Removed', path)
|
||||
this.addFileUpdate(libraryId, path, 'deleted')
|
||||
}
|
||||
|
||||
onFileUpdated(path) {
|
||||
Logger.debug('[FolderWatcher] Updated File', path)
|
||||
Logger.debug('[Watcher] Updated File', path)
|
||||
}
|
||||
|
||||
onRename(pathFrom, pathTo) {
|
||||
Logger.debug(`[FolderWatcher] Rename ${pathFrom} => ${pathTo}`)
|
||||
onRename(libraryId, pathFrom, pathTo) {
|
||||
Logger.debug(`[Watcher] Rename ${pathFrom} => ${pathTo}`)
|
||||
this.addFileUpdate(libraryId, pathTo, 'renamed')
|
||||
}
|
||||
|
||||
var dir = Path.dirname(pathTo)
|
||||
if (dir === this.AudiobookPath) {
|
||||
Logger.debug('New File added to root dir, ignoring it')
|
||||
addFileUpdate(libraryId, path, type) {
|
||||
if (this.pendingFilePaths.includes(path)) return
|
||||
|
||||
// Get file library
|
||||
var libwatcher = this.libraryWatchers.find(lw => lw.id === libraryId)
|
||||
if (!libwatcher) {
|
||||
Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingFiles.push(pathTo)
|
||||
// Get file folder
|
||||
var folder = libwatcher.folders.find(fold => path.startsWith(fold.fullPath))
|
||||
if (!folder) {
|
||||
Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file was added to root directory
|
||||
var dir = Path.dirname(path)
|
||||
if (dir === folder.fullPath) {
|
||||
Logger.warn(`[Watcher] New file "${Path.basename(path)}" added to folder root - ignoring it`)
|
||||
return
|
||||
}
|
||||
|
||||
var relPath = path.replace(folder.fullPath, '')
|
||||
Logger.debug(`[Watcher] Modified file in library "${libwatcher.name}" and folder "${folder.id}" with relPath "${relPath}"`)
|
||||
|
||||
this.pendingFileUpdates.push({
|
||||
path,
|
||||
relPath,
|
||||
folderId: folder.id,
|
||||
libraryId,
|
||||
type
|
||||
})
|
||||
|
||||
// Notify server of update after "pendingDelay"
|
||||
clearTimeout(this.pendingTimeout)
|
||||
this.pendingTimeout = setTimeout(() => {
|
||||
this.emit('files', this.pendingFiles.map(f => f))
|
||||
this.pendingFiles = []
|
||||
this.emit('files', this.pendingFileUpdates)
|
||||
this.pendingFileUpdates = []
|
||||
}, this.pendingDelay)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
const Logger = require('../Logger')
|
||||
const AudioFileMetadata = require('./AudioFileMetadata')
|
||||
|
||||
class AudioFile {
|
||||
constructor(data) {
|
||||
this.index = null
|
||||
@@ -21,12 +24,10 @@ class AudioFile {
|
||||
this.channels = null
|
||||
this.channelLayout = null
|
||||
this.chapters = []
|
||||
this.embeddedCoverArt = null
|
||||
|
||||
this.tagAlbum = null
|
||||
this.tagArtist = null
|
||||
this.tagGenre = null
|
||||
this.tagTitle = null
|
||||
this.tagTrack = null
|
||||
// Tags scraped from the audio file
|
||||
this.metadata = null
|
||||
|
||||
this.manuallyVerified = false
|
||||
this.invalid = false
|
||||
@@ -58,15 +59,13 @@ class AudioFile {
|
||||
size: this.size,
|
||||
bitRate: this.bitRate,
|
||||
language: this.language,
|
||||
codec: this.codec,
|
||||
timeBase: this.timeBase,
|
||||
channels: this.channels,
|
||||
channelLayout: this.channelLayout,
|
||||
chapters: this.chapters,
|
||||
tagAlbum: this.tagAlbum,
|
||||
tagArtist: this.tagArtist,
|
||||
tagGenre: this.tagGenre,
|
||||
tagTitle: this.tagTitle,
|
||||
tagTrack: this.tagTrack
|
||||
embeddedCoverArt: this.embeddedCoverArt,
|
||||
metadata: this.metadata ? this.metadata.toJSON() : {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,17 +90,20 @@ class AudioFile {
|
||||
this.size = data.size
|
||||
this.bitRate = data.bitRate
|
||||
this.language = data.language
|
||||
this.codec = data.codec
|
||||
this.codec = data.codec || null
|
||||
this.timeBase = data.timeBase
|
||||
this.channels = data.channels
|
||||
this.channelLayout = data.channelLayout
|
||||
this.chapters = data.chapters
|
||||
this.embeddedCoverArt = data.embeddedCoverArt || null
|
||||
|
||||
this.tagAlbum = data.tagAlbum
|
||||
this.tagArtist = data.tagArtist
|
||||
this.tagGenre = data.tagGenre
|
||||
this.tagTitle = data.tagTitle
|
||||
this.tagTrack = data.tagTrack
|
||||
// Old version of AudioFile used `tagAlbum` etc.
|
||||
var isOldVersion = Object.keys(data).find(key => key.startsWith('tag'))
|
||||
if (isOldVersion) {
|
||||
this.metadata = new AudioFileMetadata(data)
|
||||
} else {
|
||||
this.metadata = new AudioFileMetadata(data.metadata || {})
|
||||
}
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
@@ -126,23 +128,85 @@ class AudioFile {
|
||||
this.size = data.size
|
||||
this.bitRate = data.bit_rate || null
|
||||
this.language = data.language
|
||||
this.codec = data.codec
|
||||
this.codec = data.codec || null
|
||||
this.timeBase = data.time_base
|
||||
this.channels = data.channels
|
||||
this.channelLayout = data.channel_layout
|
||||
this.chapters = data.chapters || []
|
||||
this.embeddedCoverArt = data.embedded_cover_art || null
|
||||
|
||||
this.tagAlbum = data.file_tag_album || null
|
||||
this.tagArtist = data.file_tag_artist || null
|
||||
this.tagGenre = data.file_tag_genre || null
|
||||
this.tagTitle = data.file_tag_title || null
|
||||
this.tagTrack = data.file_tag_track || null
|
||||
this.metadata = new AudioFileMetadata()
|
||||
this.metadata.setData(data)
|
||||
}
|
||||
|
||||
syncChapters(updatedChapters) {
|
||||
if (this.chapters.length !== updatedChapters.length) {
|
||||
this.chapters = updatedChapters.map(ch => ({ ...ch }))
|
||||
return true
|
||||
} else if (updatedChapters.length === 0) {
|
||||
if (this.chapters.length > 0) {
|
||||
this.chapters = []
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var hasUpdates = false
|
||||
for (let i = 0; i < updatedChapters.length; i++) {
|
||||
if (JSON.stringify(updatedChapters[i]) !== JSON.stringify(this.chapters[i])) {
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
if (hasUpdates) {
|
||||
this.chapters = updatedChapters.map(ch => ({ ...ch }))
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
// Called from audioFileScanner.js with scanData
|
||||
updateMetadata(data) {
|
||||
if (!this.metadata) this.metadata = new AudioFileMetadata()
|
||||
|
||||
var dataMap = {
|
||||
format: data.format,
|
||||
duration: data.duration,
|
||||
size: data.size,
|
||||
bitRate: data.bit_rate || null,
|
||||
language: data.language,
|
||||
codec: data.codec || null,
|
||||
timeBase: data.time_base,
|
||||
channels: data.channels,
|
||||
channelLayout: data.channel_layout,
|
||||
chapters: data.chapters || [],
|
||||
embeddedCoverArt: data.embedded_cover_art || null
|
||||
}
|
||||
|
||||
var hasUpdates = false
|
||||
for (const key in dataMap) {
|
||||
if (key === 'chapters') {
|
||||
var chaptersUpdated = this.syncChapters(dataMap.chapters)
|
||||
if (chaptersUpdated) {
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (dataMap[key] !== this[key]) {
|
||||
// Logger.debug(`[AudioFile] "${key}" from ${this[key]} => ${dataMap[key]}`)
|
||||
this[key] = dataMap[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (this.metadata.updateData(data)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new AudioFile(this.toJSON())
|
||||
}
|
||||
|
||||
// If the file or parent directory was renamed it is synced here
|
||||
syncFile(newFile) {
|
||||
var hasUpdates = false
|
||||
var keysToSync = ['path', 'fullPath', 'ext', 'filename']
|
||||
|
||||
97
server/objects/AudioFileMetadata.js
Normal file
97
server/objects/AudioFileMetadata.js
Normal file
@@ -0,0 +1,97 @@
|
||||
class AudioFileMetadata {
|
||||
constructor(metadata) {
|
||||
this.tagAlbum = null
|
||||
this.tagArtist = null
|
||||
this.tagGenre = null
|
||||
this.tagTitle = null
|
||||
this.tagTrack = null
|
||||
this.tagSubtitle = null
|
||||
this.tagAlbumArtist = null
|
||||
this.tagDate = null
|
||||
this.tagComposer = null
|
||||
this.tagPublisher = null
|
||||
this.tagComment = null
|
||||
this.tagDescription = null
|
||||
this.tagEncoder = null
|
||||
this.tagEncodedBy = null
|
||||
|
||||
if (metadata) {
|
||||
this.construct(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
// Only return the tags that are actually set
|
||||
var json = {}
|
||||
for (const key in this) {
|
||||
if (key.startsWith('tag') && this[key]) {
|
||||
json[key] = this[key]
|
||||
}
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
construct(metadata) {
|
||||
this.tagAlbum = metadata.tagAlbum || null
|
||||
this.tagArtist = metadata.tagArtist || null
|
||||
this.tagGenre = metadata.tagGenre || null
|
||||
this.tagTitle = metadata.tagTitle || null
|
||||
this.tagTrack = metadata.tagTrack || null
|
||||
this.tagSubtitle = metadata.tagSubtitle || null
|
||||
this.tagAlbumArtist = metadata.tagAlbumArtist || null
|
||||
this.tagDate = metadata.tagDate || null
|
||||
this.tagComposer = metadata.tagComposer || null
|
||||
this.tagPublisher = metadata.tagPublisher || null
|
||||
this.tagComment = metadata.tagComment || null
|
||||
this.tagDescription = metadata.tagDescription || null
|
||||
this.tagEncoder = metadata.tagEncoder || null
|
||||
this.tagEncodedBy = metadata.tagEncodedBy || null
|
||||
}
|
||||
|
||||
// Data parsed in prober.js
|
||||
setData(payload) {
|
||||
this.tagAlbum = payload.file_tag_album || null
|
||||
this.tagArtist = payload.file_tag_artist || null
|
||||
this.tagGenre = payload.file_tag_genre || null
|
||||
this.tagTitle = payload.file_tag_title || null
|
||||
this.tagTrack = payload.file_tag_track || null
|
||||
this.tagSubtitle = payload.file_tag_subtitle || null
|
||||
this.tagAlbumArtist = payload.file_tag_albumartist || null
|
||||
this.tagDate = payload.file_tag_date || null
|
||||
this.tagComposer = payload.file_tag_composer || null
|
||||
this.tagPublisher = payload.file_tag_publisher || null
|
||||
this.tagComment = payload.file_tag_comment || null
|
||||
this.tagDescription = payload.file_tag_description || null
|
||||
this.tagEncoder = payload.file_tag_encoder || null
|
||||
this.tagEncodedBy = payload.file_tag_encodedby || null
|
||||
}
|
||||
|
||||
updateData(payload) {
|
||||
const dataMap = {
|
||||
tagAlbum: payload.file_tag_album || null,
|
||||
tagArtist: payload.file_tag_artist || null,
|
||||
tagGenre: payload.file_tag_genre || null,
|
||||
tagTitle: payload.file_tag_title || null,
|
||||
tagTrack: payload.file_tag_track || null,
|
||||
tagSubtitle: payload.file_tag_subtitle || null,
|
||||
tagAlbumArtist: payload.file_tag_albumartist || null,
|
||||
tagDate: payload.file_tag_date || null,
|
||||
tagComposer: payload.file_tag_composer || null,
|
||||
tagPublisher: payload.file_tag_publisher || null,
|
||||
tagComment: payload.file_tag_comment || null,
|
||||
tagDescription: payload.file_tag_description || null,
|
||||
tagEncoder: payload.file_tag_encoder || null,
|
||||
tagEncodedBy: payload.file_tag_encodedby || null
|
||||
}
|
||||
|
||||
var hasUpdates = false
|
||||
for (const key in dataMap) {
|
||||
if (dataMap[key] !== this[key]) {
|
||||
this[key] = dataMap[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = AudioFileMetadata
|
||||
@@ -20,12 +20,6 @@ class AudioTrack {
|
||||
this.channels = null
|
||||
this.channelLayout = null
|
||||
|
||||
this.tagAlbum = null
|
||||
this.tagArtist = null
|
||||
this.tagGenre = null
|
||||
this.tagTitle = null
|
||||
this.tagTrack = null
|
||||
|
||||
if (audioTrack) {
|
||||
this.construct(audioTrack)
|
||||
}
|
||||
@@ -49,12 +43,6 @@ class AudioTrack {
|
||||
this.timeBase = audioTrack.timeBase
|
||||
this.channels = audioTrack.channels
|
||||
this.channelLayout = audioTrack.channelLayout
|
||||
|
||||
this.tagAlbum = audioTrack.tagAlbum
|
||||
this.tagArtist = audioTrack.tagArtist
|
||||
this.tagGenre = audioTrack.tagGenre
|
||||
this.tagTitle = audioTrack.tagTitle
|
||||
this.tagTrack = audioTrack.tagTrack
|
||||
}
|
||||
|
||||
get name() {
|
||||
@@ -74,14 +62,10 @@ class AudioTrack {
|
||||
size: this.size,
|
||||
bitRate: this.bitRate,
|
||||
language: this.language,
|
||||
codec: this.codec,
|
||||
timeBase: this.timeBase,
|
||||
channels: this.channels,
|
||||
channelLayout: this.channelLayout,
|
||||
tagAlbum: this.tagAlbum,
|
||||
tagArtist: this.tagArtist,
|
||||
tagGenre: this.tagGenre,
|
||||
tagTitle: this.tagTitle,
|
||||
tagTrack: this.tagTrack
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,16 +83,22 @@ class AudioTrack {
|
||||
this.size = probeData.size
|
||||
this.bitRate = probeData.bitRate
|
||||
this.language = probeData.language
|
||||
this.codec = probeData.codec
|
||||
this.codec = probeData.codec || null
|
||||
this.timeBase = probeData.timeBase
|
||||
this.channels = probeData.channels
|
||||
this.channelLayout = probeData.channelLayout
|
||||
}
|
||||
|
||||
this.tagAlbum = probeData.file_tag_album || null
|
||||
this.tagArtist = probeData.file_tag_artist || null
|
||||
this.tagGenre = probeData.file_tag_genre || null
|
||||
this.tagTitle = probeData.file_tag_title || null
|
||||
this.tagTrack = probeData.file_tag_track || null
|
||||
syncMetadata(audioFile) {
|
||||
var hasUpdates = false
|
||||
var keysToSync = ['format', 'duration', 'size', 'bitRate', 'language', 'codec', 'timeBase', 'channels', 'channelLayout']
|
||||
keysToSync.forEach((key) => {
|
||||
if (audioFile[key] !== undefined && audioFile[key] !== this[key]) {
|
||||
hasUpdates = true
|
||||
this[key] = audioFile[key]
|
||||
}
|
||||
})
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
syncFile(newFile) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
const Path = require('path')
|
||||
const { bytesPretty, elapsedPretty } = require('../utils/fileUtils')
|
||||
const fs = require('fs-extra')
|
||||
const { bytesPretty, elapsedPretty, readTextFile } = require('../utils/fileUtils')
|
||||
const { comparePaths, getIno } = require('../utils/index')
|
||||
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
||||
const nfoGenerator = require('../utils/nfoGenerator')
|
||||
const Logger = require('../Logger')
|
||||
const Book = require('./Book')
|
||||
const AudioTrack = require('./AudioTrack')
|
||||
@@ -12,11 +15,15 @@ class Audiobook {
|
||||
this.id = null
|
||||
this.ino = null // Inode
|
||||
|
||||
this.libraryId = null
|
||||
this.folderId = null
|
||||
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
|
||||
this.addedAt = null
|
||||
this.lastUpdate = null
|
||||
this.lastScan = null
|
||||
this.scanVersion = null
|
||||
|
||||
this.tracks = []
|
||||
this.missingParts = []
|
||||
@@ -39,11 +46,14 @@ class Audiobook {
|
||||
construct(audiobook) {
|
||||
this.id = audiobook.id
|
||||
this.ino = audiobook.ino || null
|
||||
|
||||
this.libraryId = audiobook.libraryId || 'main'
|
||||
this.folderId = audiobook.folderId || 'audiobooks'
|
||||
this.path = audiobook.path
|
||||
this.fullPath = audiobook.fullPath
|
||||
this.addedAt = audiobook.addedAt
|
||||
this.lastUpdate = audiobook.lastUpdate || this.addedAt
|
||||
this.lastScan = audiobook.lastScan || null
|
||||
this.scanVersion = audiobook.scanVersion || null
|
||||
|
||||
this.tracks = audiobook.tracks.map(track => new AudioTrack(track))
|
||||
this.missingParts = audiobook.missingParts
|
||||
@@ -103,7 +113,35 @@ class Audiobook {
|
||||
}
|
||||
|
||||
get invalidParts() {
|
||||
return (this.audioFiles || []).filter(af => af.invalid).map(af => ({ filename: af.filename, error: af.error || 'Unknown Error' }))
|
||||
return this._audioFiles.filter(af => af.invalid).map(af => ({ filename: af.filename, error: af.error || 'Unknown Error' }))
|
||||
}
|
||||
|
||||
get _audioFiles() { return this.audioFiles || [] }
|
||||
get _otherFiles() { return this.otherFiles || [] }
|
||||
get _tracks() { return this.tracks || [] }
|
||||
|
||||
get ebooks() {
|
||||
return this.otherFiles.filter(file => file.filetype === 'ebook')
|
||||
}
|
||||
|
||||
get hasEpub() {
|
||||
return this.otherFiles.find(file => file.ext === '.epub')
|
||||
}
|
||||
|
||||
get hasMissingIno() {
|
||||
return !this.ino || this._audioFiles.find(abf => !abf.ino) || this._otherFiles.find(f => !f.ino) || this._tracks.find(t => !t.ino)
|
||||
}
|
||||
|
||||
get hasEmbeddedCoverArt() {
|
||||
return !!this._audioFiles.find(af => af.embeddedCoverArt)
|
||||
}
|
||||
|
||||
// TEMP: Issue with inodes not always being set for files
|
||||
getFilesWithMissingIno() {
|
||||
var afs = this._audioFiles.filter(af => !af.ino)
|
||||
var ofs = this._otherFiles.filter(f => !f.ino)
|
||||
var ts = this._tracks.filter(t => !t.ino)
|
||||
return afs.concat(ofs).concat(ts)
|
||||
}
|
||||
|
||||
bookToJSON() {
|
||||
@@ -119,6 +157,8 @@ class Audiobook {
|
||||
return {
|
||||
id: this.id,
|
||||
ino: this.ino,
|
||||
libraryId: this.libraryId,
|
||||
folderId: this.folderId,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
cover: this.cover,
|
||||
@@ -126,12 +166,14 @@ class Audiobook {
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
lastUpdate: this.lastUpdate,
|
||||
lastScan: this.lastScan,
|
||||
scanVersion: this.scanVersion,
|
||||
missingParts: this.missingParts,
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON(),
|
||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||
audioFiles: this._audioFiles.map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: this._otherFiles.map(otherFile => otherFile.toJSON()),
|
||||
chapters: this.chapters || [],
|
||||
isMissing: !!this.isMissing
|
||||
}
|
||||
@@ -141,6 +183,8 @@ class Audiobook {
|
||||
return {
|
||||
id: this.id,
|
||||
ino: this.ino,
|
||||
libraryId: this.libraryId,
|
||||
folderId: this.folderId,
|
||||
book: this.bookToJSON(),
|
||||
tags: this.tags,
|
||||
path: this.path,
|
||||
@@ -152,6 +196,8 @@ class Audiobook {
|
||||
hasBookMatch: !!this.book,
|
||||
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
||||
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
|
||||
// numEbooks: this.ebooks.length,
|
||||
numEbooks: this.hasEpub ? 1 : 0,
|
||||
numTracks: this.tracks.length,
|
||||
chapters: this.chapters || [],
|
||||
isMissing: !!this.isMissing
|
||||
@@ -161,6 +207,9 @@ class Audiobook {
|
||||
toJSONExpanded() {
|
||||
return {
|
||||
id: this.id,
|
||||
ino: this.ino,
|
||||
libraryId: this.libraryId,
|
||||
folderId: this.folderId,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
@@ -173,6 +222,7 @@ class Audiobook {
|
||||
invalidParts: this.invalidParts,
|
||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||
ebooks: (this.ebooks || []).map(ebook => ebook.toJSON()),
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON(),
|
||||
@@ -181,38 +231,74 @@ class Audiobook {
|
||||
}
|
||||
}
|
||||
|
||||
// Scanner had a bug that was saving a file path as the audiobook path.
|
||||
// audiobook path should be a directory.
|
||||
// fixing this before a scan prevents audiobooks being removed and re-added
|
||||
fixRelativePath(abRootPath) {
|
||||
var pathExt = Path.extname(this.path)
|
||||
if (pathExt) {
|
||||
this.path = Path.dirname(this.path)
|
||||
this.fullPath = Path.join(abRootPath, this.path)
|
||||
Logger.warn('Audiobook path has extname', pathExt, 'fixed path:', this.path)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Update was made to add ino values, ensure they are set
|
||||
// Originally files did not store the inode value
|
||||
// this function checks all files and sets the inode
|
||||
async checkUpdateInos() {
|
||||
var hasUpdates = false
|
||||
|
||||
// Audiobook folder needs inode
|
||||
if (!this.ino) {
|
||||
this.ino = await getIno(this.fullPath)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Check audio files have an inode
|
||||
for (let i = 0; i < this.audioFiles.length; i++) {
|
||||
var af = this.audioFiles[i]
|
||||
var at = this.tracks.find(t => t.ino === af.ino)
|
||||
if (!at) {
|
||||
at = this.tracks.find(t => comparePaths(t.path, af.path))
|
||||
if (!at && !af.exclude) {
|
||||
Logger.warn(`[Audiobook] No matching track for audio file "${af.filename}"`)
|
||||
}
|
||||
}
|
||||
if (!af.ino || af.ino === this.ino) {
|
||||
af.ino = await getIno(af.fullPath)
|
||||
if (!af.ino) {
|
||||
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for audio file', af.fullPath)
|
||||
} else {
|
||||
var track = this.tracks.find(t => comparePaths(t.path, af.path))
|
||||
if (track) {
|
||||
track.ino = af.ino
|
||||
Logger.debug(`[Audiobook] Set INO For audio file ${af.path}`)
|
||||
if (at) at.ino = af.ino
|
||||
}
|
||||
hasUpdates = true
|
||||
} else if (at && at.ino !== af.ino) {
|
||||
at.ino = af.ino
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.tracks.length; i++) {
|
||||
var at = this.tracks[i]
|
||||
if (!at.ino) {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} still does not have ino`)
|
||||
var atino = await getIno(at.fullPath)
|
||||
var af = this.audioFiles.find(_af => _af.ino === atino)
|
||||
if (!af) {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} no matching audio file with ino ${atino}`)
|
||||
af = this.audioFiles.find(_af => _af.filename === at.filename)
|
||||
if (!af) {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} no matching audio file with filename`)
|
||||
} else {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} found matching filename but mismatch ino ${atino}/${af.ino}`)
|
||||
// at.ino = af.ino
|
||||
// at.path = af.path
|
||||
// at.fullPath = af.fullPath
|
||||
// hasUpdates = true
|
||||
}
|
||||
} else {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} found audio file with matching ino ${at.path}/${af.path}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.otherFiles.length; i++) {
|
||||
var file = this.otherFiles[i]
|
||||
if (!file.ino || file.ino === this.ino) {
|
||||
file.ino = await getIno(file.fullPath)
|
||||
if (!file.ino) {
|
||||
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for other file', file.fullPath)
|
||||
} else {
|
||||
Logger.debug(`[Audiobook] Set INO For other file ${file.path}`)
|
||||
}
|
||||
hasUpdates = true
|
||||
}
|
||||
@@ -222,6 +308,8 @@ class Audiobook {
|
||||
|
||||
setData(data) {
|
||||
this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
this.libraryId = data.libraryId || 'main'
|
||||
this.folderId = data.folderId || 'audiobooks'
|
||||
this.ino = data.ino || null
|
||||
|
||||
this.path = data.path
|
||||
@@ -238,7 +326,27 @@ class Audiobook {
|
||||
this.setBook(data)
|
||||
}
|
||||
|
||||
checkHasOldCoverPath() {
|
||||
return this.book.cover && !this.book.coverFullPath
|
||||
}
|
||||
|
||||
setLastScan(version) {
|
||||
this.lastScan = Date.now()
|
||||
this.lastUpdate = Date.now()
|
||||
this.scanVersion = version
|
||||
}
|
||||
|
||||
setBook(data) {
|
||||
// Use first image file as cover
|
||||
if (this.otherFiles && this.otherFiles.length) {
|
||||
var imageFile = this.otherFiles.find(f => f.filetype === 'image')
|
||||
if (imageFile) {
|
||||
data.coverFullPath = Path.normalize(imageFile.fullPath)
|
||||
var relImagePath = imageFile.path.replace(this.path, '')
|
||||
data.cover = Path.normalize(Path.join(`/s/book/${this.id}`, relImagePath))
|
||||
}
|
||||
}
|
||||
|
||||
this.book = new Book()
|
||||
this.book.setData(data)
|
||||
}
|
||||
@@ -289,9 +397,9 @@ class Audiobook {
|
||||
}
|
||||
|
||||
// Cover Url may be the same, this ensures the lastUpdate is updated
|
||||
updateBookCover(cover) {
|
||||
updateBookCover(cover, coverFullPath) {
|
||||
if (!this.book) return false
|
||||
return this.book.updateCover(cover)
|
||||
return this.book.updateCover(cover, coverFullPath)
|
||||
}
|
||||
|
||||
updateAudioTracks(orderedFileData) {
|
||||
@@ -321,6 +429,7 @@ class Audiobook {
|
||||
this.addTrack(file)
|
||||
}
|
||||
})
|
||||
this.setChapters()
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
||||
@@ -329,6 +438,11 @@ class Audiobook {
|
||||
this.audioFiles = this.audioFiles.filter(f => f.ino !== audioFile.ino)
|
||||
}
|
||||
|
||||
removeAudioTrack(track) {
|
||||
this.tracks = this.tracks.filter(t => t.ino !== track.ino)
|
||||
this.audioFiles = this.audioFiles.filter(f => f.ino !== track.ino)
|
||||
}
|
||||
|
||||
checkUpdateMissingParts() {
|
||||
var currMissingParts = (this.missingParts || []).join(',') || ''
|
||||
|
||||
@@ -357,39 +471,126 @@ class Audiobook {
|
||||
}
|
||||
|
||||
// On scan check other files found with other files saved
|
||||
syncOtherFiles(newOtherFiles) {
|
||||
async syncOtherFiles(newOtherFiles, metadataPath, forceRescan = false) {
|
||||
var hasUpdates = false
|
||||
|
||||
var currOtherFileNum = this.otherFiles.length
|
||||
|
||||
var alreadyHasDescTxt = this.otherFiles.find(of => of.filename === 'desc.txt')
|
||||
var alreadyHasReaderTxt = this.otherFiles.find(of => of.filename === 'reader.txt')
|
||||
|
||||
var newOtherFilePaths = newOtherFiles.map(f => f.path)
|
||||
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
|
||||
|
||||
// Some files are not there anymore and filtered out
|
||||
if (currOtherFileNum !== this.otherFiles.length) {
|
||||
Logger.debug(`[Audiobook] ${currOtherFileNum - this.otherFiles.length} other files were removed for "${this.title}"`)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// If desc.txt is new or forcing rescan then read it and update description (will overwrite)
|
||||
var descriptionTxt = newOtherFiles.find(file => file.filename === 'desc.txt')
|
||||
if (descriptionTxt && (!alreadyHasDescTxt || forceRescan)) {
|
||||
var newDescription = await readTextFile(descriptionTxt.fullPath)
|
||||
if (newDescription) {
|
||||
Logger.debug(`[Audiobook] Sync Other File desc.txt: ${newDescription}`)
|
||||
this.update({ book: { description: newDescription } })
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
// If reader.txt is new or forcing rescan then read it and update narrarator (will overwrite)
|
||||
var readerTxt = newOtherFiles.find(file => file.filename === 'reader.txt')
|
||||
if (readerTxt && (!alreadyHasReaderTxt || forceRescan)) {
|
||||
var newReader = await readTextFile(readerTxt.fullPath)
|
||||
if (newReader) {
|
||||
Logger.debug(`[Audiobook] Sync Other File reader.txt: ${newReader}`)
|
||||
this.update({ book: { narrarator: newReader } })
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
newOtherFiles.forEach((file) => {
|
||||
var existingOtherFile = this.otherFiles.find(f => f.path === file.path)
|
||||
var existingOtherFile = this.otherFiles.find(f => f.ino === file.ino)
|
||||
if (!existingOtherFile) {
|
||||
Logger.debug(`[Audiobook] New other file found on sync ${file.filename}/${file.filetype} | "${this.title}"`)
|
||||
Logger.debug(`[Audiobook] New other file found on sync ${file.filename} | "${this.title}"`)
|
||||
this.addOtherFile(file)
|
||||
hasUpdates = true
|
||||
}
|
||||
})
|
||||
|
||||
var hasUpdates = currOtherFileNum !== this.otherFiles.length
|
||||
|
||||
// Check if cover was a local image and that it still exists
|
||||
var imageFiles = this.otherFiles.filter(f => f.filetype === 'image')
|
||||
|
||||
// OLD Path Check if cover was a local image and that it still exists
|
||||
if (this.book.cover && this.book.cover.substr(1).startsWith('local')) {
|
||||
var coverStillExists = imageFiles.find(f => comparePaths(f.path, this.book.cover.substr('/local/'.length)))
|
||||
var coverStripped = this.book.cover.substr('/local/'.length)
|
||||
// Check if was removed first
|
||||
var coverStillExists = imageFiles.find(f => comparePaths(f.path, coverStripped))
|
||||
if (!coverStillExists) {
|
||||
Logger.info(`[Audiobook] Local cover was removed | "${this.title}"`)
|
||||
this.book.cover = null
|
||||
this.book.removeCover()
|
||||
} else {
|
||||
var oldFormat = this.book.cover
|
||||
|
||||
// Update book cover path to new format
|
||||
this.book.coverFullPath = Path.normalize(Path.join(this.fullPath, this.book.cover.substr(7)))
|
||||
this.book.cover = Path.normalize(coverStripped.replace(this.path, `/s/book/${this.id}`))
|
||||
Logger.debug(`[Audiobook] updated book cover to new format "${oldFormat}" => "${this.book.cover}"`)
|
||||
}
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Check if book was removed from book dir
|
||||
if (this.book.cover && this.book.cover.substr(1).startsWith('s\\book\\')) {
|
||||
// Fixing old cover paths
|
||||
if (!this.book.coverFullPath) {
|
||||
this.book.coverFullPath = Path.normalize(Path.join(this.fullPath, this.book.cover.substr(`/s/book/${this.id}`.length)))
|
||||
Logger.debug(`[Audiobook] Metadata cover full path set "${this.book.coverFullPath}" for "${this.title}"`)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
var coverStillExists = imageFiles.find(f => f.fullPath === this.book.coverFullPath)
|
||||
if (!coverStillExists) {
|
||||
Logger.info(`[Audiobook] Local cover "${this.book.cover}" was removed | "${this.title}"`)
|
||||
this.book.removeCover()
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (this.book.cover && this.book.cover.substr(1).startsWith('metadata')) {
|
||||
// Fixing old cover paths
|
||||
if (!this.book.coverFullPath) {
|
||||
this.book.coverFullPath = Path.normalize(Path.join(metadataPath, this.book.cover.substr('/metadata/'.length)))
|
||||
Logger.debug(`[Audiobook] Metadata cover full path set "${this.book.coverFullPath}" for "${this.title}"`)
|
||||
hasUpdates = true
|
||||
}
|
||||
var coverStillExists = imageFiles.find(f => f.fullPath === this.book.coverFullPath)
|
||||
if (!coverStillExists) {
|
||||
Logger.info(`[Audiobook] Metadata cover "${this.book.cover}" was removed | "${this.title}"`)
|
||||
this.book.removeCover()
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (this.book.cover && !this.book.coverFullPath) {
|
||||
if (this.book.cover.startsWith('http')) {
|
||||
Logger.debug(`[Audiobook] Still using http path for cover "${this.book.cover}" - should update to local`)
|
||||
this.book.coverFullPath = this.book.cover
|
||||
hasUpdates = true
|
||||
} else {
|
||||
Logger.warn(`[Audiobook] Full cover path still not set "${this.book.cover}"`)
|
||||
}
|
||||
}
|
||||
|
||||
// If no cover set and image file exists then use it
|
||||
if (!this.book.cover && imageFiles.length) {
|
||||
this.book.cover = Path.join('/local', imageFiles[0].path)
|
||||
Logger.info(`[Audiobook] Local cover was set | "${this.title}"`)
|
||||
var imagePathRelativeToBook = imageFiles[0].path.replace(this.path, '')
|
||||
this.book.cover = Path.normalize(Path.join(`/s/book/${this.id}`, imagePathRelativeToBook))
|
||||
this.book.coverFullPath = imageFiles[0].fullPath
|
||||
Logger.info(`[Audiobook] Local cover was set to "${this.book.cover}" | "${this.title}"`)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
@@ -431,7 +632,6 @@ class Audiobook {
|
||||
|
||||
setChapters() {
|
||||
// If 1 audio file without chapters, then no chapters will be set
|
||||
|
||||
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
|
||||
if (includedAudioFiles.length === 1) {
|
||||
// 1 audio file with chapters
|
||||
@@ -467,12 +667,71 @@ class Audiobook {
|
||||
id: currChapterId++,
|
||||
start: currStartTime,
|
||||
end: currStartTime + file.duration,
|
||||
title: `Chapter ${currChapterId}`
|
||||
title: file.filename ? Path.basename(file.filename, Path.extname(file.filename)) : `Chapter ${currChapterId}`
|
||||
})
|
||||
currStartTime += file.duration
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
writeNfoFile(nfoFilename = 'metadata.nfo') {
|
||||
return nfoGenerator(this, nfoFilename)
|
||||
}
|
||||
|
||||
// Return cover filename
|
||||
async saveEmbeddedCoverArt(coverDirFullPath, coverDirRelPath) {
|
||||
var audioFileWithCover = this.audioFiles.find(af => af.embeddedCoverArt)
|
||||
if (!audioFileWithCover) return false
|
||||
|
||||
var coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'
|
||||
var coverFilePath = Path.join(coverDirFullPath, coverFilename)
|
||||
|
||||
var coverAlreadyExists = await fs.pathExists(coverFilePath)
|
||||
if (coverAlreadyExists) {
|
||||
Logger.warn(`[Audiobook] Extract embedded cover art but cover already exists for "${this.title}" - bail`)
|
||||
return false
|
||||
}
|
||||
|
||||
var success = await extractCoverArt(audioFileWithCover.fullPath, coverFilePath)
|
||||
if (success) {
|
||||
var coverRelPath = Path.join(coverDirRelPath, coverFilename)
|
||||
this.update({ book: { cover: coverRelPath } })
|
||||
return coverRelPath
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Look for desc.txt and reader.txt and update details if found
|
||||
async saveDataFromTextFiles() {
|
||||
var bookUpdatePayload = {}
|
||||
var descriptionText = await this.fetchTextFromTextFile('desc.txt')
|
||||
if (descriptionText) {
|
||||
Logger.debug(`[Audiobook] "${this.title}" found desc.txt updating description with "${descriptionText.slice(0, 20)}..."`)
|
||||
bookUpdatePayload.description = descriptionText
|
||||
}
|
||||
var readerText = await this.fetchTextFromTextFile('reader.txt')
|
||||
if (readerText) {
|
||||
Logger.debug(`[Audiobook] "${this.title}" found reader.txt updating narrarator with "${readerText}"`)
|
||||
bookUpdatePayload.narrarator = readerText
|
||||
}
|
||||
if (Object.keys(bookUpdatePayload).length) {
|
||||
return this.update({ book: bookUpdatePayload })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fetchTextFromTextFile(textfileName) {
|
||||
var textFile = this.otherFiles.find(file => file.filename === textfileName)
|
||||
if (!textFile) return false
|
||||
return readTextFile(textFile.fullPath)
|
||||
}
|
||||
|
||||
// Audio file metadata tags map to book details (will not overwrite)
|
||||
setDetailsFromFileMetadata() {
|
||||
if (!this.audioFiles.length) return false
|
||||
var audioFile = this.audioFiles[0]
|
||||
return this.book.setDetailsFromFileMetadata(audioFile.metadata)
|
||||
}
|
||||
}
|
||||
module.exports = Audiobook
|
||||
@@ -1,3 +1,4 @@
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const parseAuthors = require('../utils/parseAuthors')
|
||||
@@ -17,9 +18,16 @@ class Book {
|
||||
this.publisher = null
|
||||
this.description = null
|
||||
this.cover = null
|
||||
this.coverFullPath = null
|
||||
this.genres = []
|
||||
|
||||
this.lastUpdate = null
|
||||
|
||||
// Should not continue looking up a cover when it is not findable
|
||||
this.lastCoverSearch = null
|
||||
this.lastCoverSearchTitle = null
|
||||
this.lastCoverSearchAuthor = null
|
||||
|
||||
if (book) {
|
||||
this.construct(book)
|
||||
}
|
||||
@@ -31,6 +39,12 @@ class Book {
|
||||
get _author() { return this.author || '' }
|
||||
get _series() { return this.series || '' }
|
||||
|
||||
get shouldSearchForCover() {
|
||||
if (this.author !== this.lastCoverSearchAuthor || this.title !== this.lastCoverSearchTitle || !this.lastCoverSearch) return true
|
||||
var timeSinceLastSearch = Date.now() - this.lastCoverSearch
|
||||
return timeSinceLastSearch > 1000 * 60 * 60 * 24 * 7 // every 7 days do another lookup
|
||||
}
|
||||
|
||||
construct(book) {
|
||||
this.olid = book.olid
|
||||
this.title = book.title
|
||||
@@ -45,8 +59,12 @@ class Book {
|
||||
this.publisher = book.publisher
|
||||
this.description = book.description
|
||||
this.cover = book.cover
|
||||
this.coverFullPath = book.coverFullPath || null
|
||||
this.genres = book.genres
|
||||
this.lastUpdate = book.lastUpdate || Date.now()
|
||||
this.lastCoverSearch = book.lastCoverSearch || null
|
||||
this.lastCoverSearchTitle = book.lastCoverSearchTitle || null
|
||||
this.lastCoverSearchAuthor = book.lastCoverSearchAuthor || null
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
@@ -64,8 +82,12 @@ class Book {
|
||||
publisher: this.publisher,
|
||||
description: this.description,
|
||||
cover: this.cover,
|
||||
coverFullPath: this.coverFullPath,
|
||||
genres: this.genres,
|
||||
lastUpdate: this.lastUpdate
|
||||
lastUpdate: this.lastUpdate,
|
||||
lastCoverSearch: this.lastCoverSearch,
|
||||
lastCoverSearchTitle: this.lastCoverSearchTitle,
|
||||
lastCoverSearchAuthor: this.lastCoverSearchAuthor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,20 +121,16 @@ class Book {
|
||||
this.publishYear = data.publishYear || null
|
||||
this.description = data.description || null
|
||||
this.cover = data.cover || null
|
||||
this.coverFullPath = data.coverFullPath || null
|
||||
this.genres = data.genres || []
|
||||
this.lastUpdate = Date.now()
|
||||
this.lastCoverSearch = data.lastCoverSearch || null
|
||||
this.lastCoverSearchTitle = data.lastCoverSearchTitle || null
|
||||
this.lastCoverSearchAuthor = data.lastCoverSearchAuthor || null
|
||||
|
||||
if (data.author) {
|
||||
this.setParseAuthor(this.author)
|
||||
}
|
||||
|
||||
// Use first image file as cover
|
||||
if (data.otherFiles && data.otherFiles.length) {
|
||||
var imageFile = data.otherFiles.find(f => f.filetype === 'image')
|
||||
if (imageFile) {
|
||||
this.cover = Path.normalize(Path.join('/local', imageFile.path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
@@ -122,6 +140,7 @@ class Book {
|
||||
// If updating to local cover then normalize path
|
||||
if (!payload.cover.startsWith('http:') && !payload.cover.startsWith('https:')) {
|
||||
payload.cover = Path.normalize(payload.cover)
|
||||
if (payload.coverFullPath) payload.coverFullPath = Path.normalize(payload.coverFullPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,16 +176,31 @@ class Book {
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
updateCover(cover) {
|
||||
updateLastCoverSearch(coverWasFound) {
|
||||
this.lastCoverSearch = coverWasFound ? null : Date.now()
|
||||
this.lastCoverSearchAuthor = coverWasFound ? null : this.author
|
||||
this.lastCoverSearchTitle = coverWasFound ? null : this.title
|
||||
}
|
||||
|
||||
updateCover(cover, coverFullPath) {
|
||||
if (!cover) return false
|
||||
if (!cover.startsWith('http:') && !cover.startsWith('https:')) {
|
||||
cover = Path.normalize(cover)
|
||||
this.coverFullPath = Path.normalize(coverFullPath)
|
||||
} else {
|
||||
this.coverFullPath = cover
|
||||
}
|
||||
this.cover = cover
|
||||
this.lastUpdate = Date.now()
|
||||
return true
|
||||
}
|
||||
|
||||
removeCover() {
|
||||
this.cover = null
|
||||
this.coverFullPath = null
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
||||
// If audiobook directory path was changed, check and update properties set from dirnames
|
||||
// May be worthwhile checking if these were manually updated and not override manual updates
|
||||
syncPathsUpdated(audiobookData) {
|
||||
@@ -182,5 +216,47 @@ class Book {
|
||||
isSearchMatch(search) {
|
||||
return this._title.toLowerCase().includes(search) || this._subtitle.toLowerCase().includes(search) || this._author.toLowerCase().includes(search) || this._series.toLowerCase().includes(search)
|
||||
}
|
||||
|
||||
setDetailsFromFileMetadata(audioFileMetadata) {
|
||||
const MetadataMapArray = [
|
||||
{
|
||||
tag: 'tagComposer',
|
||||
key: 'narrarator'
|
||||
},
|
||||
{
|
||||
tag: 'tagDescription',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
tag: 'tagPublisher',
|
||||
key: 'publisher'
|
||||
},
|
||||
{
|
||||
tag: 'tagDate',
|
||||
key: 'publishYear'
|
||||
},
|
||||
{
|
||||
tag: 'tagSubtitle',
|
||||
key: 'subtitle'
|
||||
},
|
||||
{
|
||||
tag: 'tagArtist',
|
||||
key: 'author'
|
||||
}
|
||||
]
|
||||
|
||||
var updatePayload = {}
|
||||
MetadataMapArray.forEach((mapping) => {
|
||||
if (!this[mapping.key] && audioFileMetadata[mapping.tag]) {
|
||||
updatePayload[mapping.key] = audioFileMetadata[mapping.tag]
|
||||
Logger.debug(`[Book] Mapping metadata to key ${mapping.tag} => ${mapping.key}: ${updatePayload[mapping.key]}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
return this.update(updatePayload)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
module.exports = Book
|
||||
36
server/objects/Folder.js
Normal file
36
server/objects/Folder.js
Normal file
@@ -0,0 +1,36 @@
|
||||
class Folder {
|
||||
constructor(folder = null) {
|
||||
this.id = null
|
||||
this.fullPath = null
|
||||
this.libraryId = null
|
||||
this.addedAt = null
|
||||
|
||||
if (folder) {
|
||||
this.construct(folder)
|
||||
}
|
||||
}
|
||||
|
||||
construct(folder) {
|
||||
this.id = folder.id
|
||||
this.fullPath = folder.fullPath
|
||||
this.libraryId = folder.libraryId
|
||||
this.addedAt = folder.addedAt
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
fullPath: this.fullPath,
|
||||
libraryId: this.libraryId,
|
||||
addedAt: this.addedAt
|
||||
}
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.id = data.id ? data.id : 'fol' + (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
this.fullPath = data.fullPath
|
||||
this.libraryId = data.libraryId
|
||||
this.addedAt = Date.now()
|
||||
}
|
||||
}
|
||||
module.exports = Folder
|
||||
102
server/objects/Library.js
Normal file
102
server/objects/Library.js
Normal file
@@ -0,0 +1,102 @@
|
||||
const Folder = require('./Folder')
|
||||
|
||||
class Library {
|
||||
constructor(library = null) {
|
||||
this.id = null
|
||||
this.name = null
|
||||
this.folders = []
|
||||
|
||||
this.lastScan = 0
|
||||
|
||||
this.createdAt = null
|
||||
this.lastUpdate = null
|
||||
|
||||
if (library) {
|
||||
this.construct(library)
|
||||
}
|
||||
}
|
||||
|
||||
get folderPaths() {
|
||||
return this.folders.map(f => f.fullPath)
|
||||
}
|
||||
|
||||
construct(library) {
|
||||
this.id = library.id
|
||||
this.name = library.name
|
||||
this.folders = (library.folders || []).map(f => new Folder(f))
|
||||
this.createdAt = library.createdAt
|
||||
this.lastUpdate = library.lastUpdate
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
folders: (this.folders || []).map(f => f.toJSON()),
|
||||
createdAt: this.createdAt,
|
||||
lastUpdate: this.lastUpdate
|
||||
}
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.id = data.id ? data.id : 'lib' + (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
this.name = data.name
|
||||
if (data.folder) {
|
||||
this.folders = [
|
||||
new Folder(data.folder)
|
||||
]
|
||||
} else if (data.folders) {
|
||||
this.folders = data.folders.map(folder => {
|
||||
var newFolder = new Folder()
|
||||
newFolder.setData({
|
||||
fullPath: folder.fullPath,
|
||||
libraryId: this.id
|
||||
})
|
||||
return newFolder
|
||||
})
|
||||
}
|
||||
this.createdAt = Date.now()
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
if (payload.name && payload.name !== this.name) {
|
||||
this.name = payload.name
|
||||
hasUpdates = true
|
||||
}
|
||||
if (payload.folders) {
|
||||
var newFolders = payload.folders.filter(f => !f.id)
|
||||
var removedFolders = this.folders.filter(f => !payload.folders.find(_f => _f.id === f.id))
|
||||
|
||||
if (removedFolders.length) {
|
||||
var removedFolderIds = removedFolders.map(f => f.id)
|
||||
this.folders = this.folders.filter(f => !removedFolderIds.includes(f.id))
|
||||
}
|
||||
|
||||
if (newFolders.length) {
|
||||
newFolders.forEach((folderData) => {
|
||||
folderData.libraryId = this.id
|
||||
var newFolder = new Folder()
|
||||
newFolder.setData(folderData)
|
||||
this.folders.push(newFolder)
|
||||
})
|
||||
}
|
||||
|
||||
hasUpdates = newFolders.length || removedFolders.length
|
||||
}
|
||||
if (hasUpdates) {
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
checkFullPathInLibrary(fullPath) {
|
||||
return this.folders.find(folder => fullPath.startsWith(folder.fullPath))
|
||||
}
|
||||
|
||||
getFolderById(id) {
|
||||
return this.folders.find(folder => folder.id === id)
|
||||
}
|
||||
}
|
||||
module.exports = Library
|
||||
@@ -1,12 +1,19 @@
|
||||
const { CoverDestination } = require('../utils/constants')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
class ServerSettings {
|
||||
constructor(settings) {
|
||||
this.id = 'server-settings'
|
||||
|
||||
this.autoTagNew = false
|
||||
this.newTagExpireDays = 15
|
||||
this.scannerParseSubtitle = false
|
||||
this.scannerFindCovers = false
|
||||
this.coverDestination = CoverDestination.METADATA
|
||||
this.saveMetadataFile = false
|
||||
this.rateLimitLoginRequests = 10
|
||||
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
|
||||
this.logLevel = Logger.logLevel
|
||||
|
||||
if (settings) {
|
||||
this.construct(settings)
|
||||
@@ -16,8 +23,17 @@ class ServerSettings {
|
||||
construct(settings) {
|
||||
this.autoTagNew = settings.autoTagNew
|
||||
this.newTagExpireDays = settings.newTagExpireDays
|
||||
this.scannerFindCovers = !!settings.scannerFindCovers
|
||||
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
||||
this.coverDestination = settings.coverDestination || CoverDestination.METADATA
|
||||
this.saveMetadataFile = !!settings.saveMetadataFile
|
||||
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
||||
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
|
||||
this.logLevel = settings.logLevel || Logger.logLevel
|
||||
|
||||
if (this.logLevel !== Logger.logLevel) {
|
||||
Logger.setLogLevel(this.logLevel)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
@@ -25,8 +41,13 @@ class ServerSettings {
|
||||
id: this.id,
|
||||
autoTagNew: this.autoTagNew,
|
||||
newTagExpireDays: this.newTagExpireDays,
|
||||
scannerFindCovers: this.scannerFindCovers,
|
||||
scannerParseSubtitle: this.scannerParseSubtitle,
|
||||
coverDestination: this.coverDestination
|
||||
coverDestination: this.coverDestination,
|
||||
saveMetadataFile: !!this.saveMetadataFile,
|
||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
||||
logLevel: this.logLevel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +55,9 @@ class ServerSettings {
|
||||
var hasUpdates = false
|
||||
for (const key in payload) {
|
||||
if (this[key] !== payload[key]) {
|
||||
if (key === 'logLevel') {
|
||||
Logger.setLogLevel(payload[key])
|
||||
}
|
||||
this[key] = payload[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ class Stream extends EventEmitter {
|
||||
this.audiobook = audiobook
|
||||
|
||||
this.segmentLength = 6
|
||||
this.segmentBasename = 'output-%d.ts'
|
||||
this.streamPath = Path.join(streamPath, this.id)
|
||||
this.concatFilesPath = Path.join(this.streamPath, 'files.txt')
|
||||
this.playlistPath = Path.join(this.streamPath, 'output.m3u8')
|
||||
@@ -51,6 +50,21 @@ class Stream extends EventEmitter {
|
||||
return this.audiobook.totalDuration
|
||||
}
|
||||
|
||||
get tracksAudioFileType() {
|
||||
if (!this.tracks.length) return null
|
||||
return this.tracks[0].ext.toLowerCase().slice(1)
|
||||
}
|
||||
|
||||
get hlsSegmentType() {
|
||||
var hasFlac = this.tracks.find(t => t.ext.toLowerCase() === '.flac')
|
||||
return hasFlac ? 'fmp4' : 'mpegts'
|
||||
}
|
||||
|
||||
get segmentBasename() {
|
||||
if (this.hlsSegmentType === 'fmp4') return 'output-%d.m4s'
|
||||
return 'output-%d.ts'
|
||||
}
|
||||
|
||||
get segmentStartNumber() {
|
||||
if (!this.startTime) return 0
|
||||
return Math.floor(this.startTime / this.segmentLength)
|
||||
@@ -98,7 +112,7 @@ class Stream extends EventEmitter {
|
||||
var userAudiobook = clientUserAudiobooks[this.audiobookId] || null
|
||||
if (userAudiobook) {
|
||||
var timeRemaining = this.totalDuration - userAudiobook.currentTime
|
||||
Logger.info('[STREAM] User has progress for audiobook', userAudiobook, `Time Remaining: ${timeRemaining}s`)
|
||||
Logger.info('[STREAM] User has progress for audiobook', userAudiobook.progress, `Time Remaining: ${timeRemaining}s`)
|
||||
if (timeRemaining > 15) {
|
||||
this.startTime = userAudiobook.currentTime
|
||||
this.clientCurrentTime = this.startTime
|
||||
@@ -133,7 +147,7 @@ class Stream extends EventEmitter {
|
||||
|
||||
async generatePlaylist() {
|
||||
fs.ensureDirSync(this.streamPath)
|
||||
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength)
|
||||
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength, this.hlsSegmentType)
|
||||
return this.clientPlaylistUri
|
||||
}
|
||||
|
||||
@@ -142,7 +156,7 @@ class Stream extends EventEmitter {
|
||||
var files = await fs.readdir(this.streamPath)
|
||||
files.forEach((file) => {
|
||||
var extname = Path.extname(file)
|
||||
if (extname === '.ts') {
|
||||
if (extname === '.ts' || extname === '.m4s') {
|
||||
var basename = Path.basename(file, extname)
|
||||
var num_part = basename.split('-')[1]
|
||||
var part_num = Number(num_part)
|
||||
@@ -238,24 +252,31 @@ class Stream extends EventEmitter {
|
||||
}
|
||||
|
||||
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
|
||||
const audioCodec = (this.hlsSegmentType === 'fmp4' || this.tracksAudioFileType === 'opus') ? 'aac' : 'copy'
|
||||
this.ffmpeg.addOption([
|
||||
`-loglevel ${logLevel}`,
|
||||
'-map 0:a',
|
||||
'-c:a copy'
|
||||
`-c:a ${audioCodec}`
|
||||
])
|
||||
this.ffmpeg.addOption([
|
||||
const hlsOptions = [
|
||||
'-f hls',
|
||||
"-copyts",
|
||||
"-avoid_negative_ts disabled",
|
||||
"-max_delay 5000000",
|
||||
"-max_muxing_queue_size 2048",
|
||||
`-hls_time 6`,
|
||||
"-hls_segment_type mpegts",
|
||||
`-hls_segment_type ${this.hlsSegmentType}`,
|
||||
`-start_number ${this.segmentStartNumber}`,
|
||||
"-hls_playlist_type vod",
|
||||
"-hls_list_size 0",
|
||||
"-hls_allow_cache 0"
|
||||
])
|
||||
]
|
||||
if (this.hlsSegmentType === 'fmp4') {
|
||||
hlsOptions.push('-strict -2')
|
||||
var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4')
|
||||
hlsOptions.push(`-hls_fmp4_init_filename ${fmp4InitFilename}`)
|
||||
}
|
||||
this.ffmpeg.addOption(hlsOptions)
|
||||
var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
|
||||
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
|
||||
this.ffmpeg.output(this.finalPlaylistPath)
|
||||
|
||||
@@ -9,6 +9,7 @@ class User {
|
||||
this.stream = null
|
||||
this.token = null
|
||||
this.isActive = true
|
||||
this.isLocked = false
|
||||
this.createdAt = null
|
||||
this.audiobooks = null
|
||||
|
||||
@@ -76,6 +77,7 @@ class User {
|
||||
token: this.token,
|
||||
audiobooks: this.audiobooksToJSON(),
|
||||
isActive: this.isActive,
|
||||
isLocked: this.isLocked,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings,
|
||||
permissions: this.permissions
|
||||
@@ -91,6 +93,7 @@ class User {
|
||||
token: this.token,
|
||||
audiobooks: this.audiobooksToJSON(),
|
||||
isActive: this.isActive,
|
||||
isLocked: this.isLocked,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings,
|
||||
permissions: this.permissions
|
||||
@@ -112,7 +115,8 @@ class User {
|
||||
}
|
||||
}
|
||||
}
|
||||
this.isActive = (user.isActive === undefined || user.id === 'root') ? true : !!user.isActive
|
||||
this.isActive = (user.isActive === undefined || user.type === 'root') ? true : !!user.isActive
|
||||
this.isLocked = user.type === 'root' ? false : !!user.isLocked
|
||||
this.createdAt = user.createdAt || Date.now()
|
||||
this.settings = user.settings || this.getDefaultUserSettings()
|
||||
this.permissions = user.permissions || this.getDefaultUserPermissions()
|
||||
|
||||
@@ -2,6 +2,8 @@ const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const prober = require('./prober')
|
||||
|
||||
const ImageCodecs = ['mjpeg', 'jpeg', 'png']
|
||||
|
||||
function getDefaultAudioStream(audioStreams) {
|
||||
if (audioStreams.length === 1) return audioStreams[0]
|
||||
var defaultStream = audioStreams.find(a => a.is_default)
|
||||
@@ -37,6 +39,11 @@ async function scan(path) {
|
||||
chapters: probeData.chapters || []
|
||||
}
|
||||
|
||||
var hasCoverArt = probeData.video_stream ? ImageCodecs.includes(probeData.video_stream.codec) : false
|
||||
if (hasCoverArt) {
|
||||
finalData.embedded_cover_art = probeData.video_stream.codec
|
||||
}
|
||||
|
||||
for (const key in probeData) {
|
||||
if (probeData[key] && key.startsWith('file_tag')) {
|
||||
finalData[key] = probeData[key]
|
||||
@@ -76,6 +83,9 @@ function getTrackNumberFromFilename(title, author, series, publishYear, filename
|
||||
if (series) partbasename = partbasename.replace(series, '')
|
||||
if (publishYear) partbasename = partbasename.replace(publishYear)
|
||||
|
||||
// Remove eg. "disc 1" from path
|
||||
partbasename = partbasename.replace(/ disc \d\d? /i, '')
|
||||
|
||||
var numbersinpath = partbasename.match(/\d+/g)
|
||||
if (!numbersinpath) return null
|
||||
|
||||
@@ -85,12 +95,14 @@ function getTrackNumberFromFilename(title, author, series, publishYear, filename
|
||||
|
||||
async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
if (!newAudioFiles || !newAudioFiles.length) {
|
||||
Logger.error('[AudioFileScanner] Scan Audio Files no files', audiobook.title)
|
||||
Logger.error('[AudioFileScanner] Scan Audio Files no new files', audiobook.title)
|
||||
return
|
||||
}
|
||||
|
||||
var tracks = []
|
||||
var numDuplicateTracks = 0
|
||||
var numInvalidTracks = 0
|
||||
|
||||
for (let i = 0; i < newAudioFiles.length; i++) {
|
||||
var audioFile = newAudioFiles[i]
|
||||
var scanData = await scan(audioFile.fullPath)
|
||||
@@ -102,6 +114,7 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
|
||||
var trackNumFromMeta = getTrackNumberFromMeta(scanData)
|
||||
var book = audiobook.book || {}
|
||||
|
||||
var trackNumFromFilename = getTrackNumberFromFilename(book.title, book.author, book.series, book.publishYear, audioFile.filename)
|
||||
|
||||
var audioFileObj = {
|
||||
@@ -129,7 +142,7 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
}
|
||||
|
||||
if (tracks.find(t => t.index === trackNumber)) {
|
||||
Logger.debug('[AudioFileScanner] Duplicate track number for', audioFile.filename)
|
||||
// Logger.debug('[AudioFileScanner] Duplicate track number for', audioFile.filename)
|
||||
audioFile.invalid = true
|
||||
audioFile.error = 'Duplicate track number'
|
||||
numDuplicateTracks++
|
||||
@@ -175,4 +188,46 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
audiobook.tracks.sort((a, b) => a.index - b.index)
|
||||
}
|
||||
}
|
||||
module.exports.scanAudioFiles = scanAudioFiles
|
||||
module.exports.scanAudioFiles = scanAudioFiles
|
||||
|
||||
|
||||
async function rescanAudioFiles(audiobook) {
|
||||
var audioFiles = audiobook.audioFiles
|
||||
var updates = 0
|
||||
|
||||
for (let i = 0; i < audioFiles.length; i++) {
|
||||
var audioFile = audioFiles[i]
|
||||
var scanData = await scan(audioFile.fullPath)
|
||||
if (!scanData || scanData.error) {
|
||||
Logger.error('[AudioFileScanner] Scan failed for', audioFile.path)
|
||||
// audiobook.invalidAudioFiles.push(parts[i])
|
||||
continue;
|
||||
}
|
||||
var hasUpdates = audioFile.updateMetadata(scanData)
|
||||
if (hasUpdates) {
|
||||
// Sync audio track with audio file
|
||||
var matchingAudioTrack = audiobook.tracks.find(t => t.ino === audioFile.ino)
|
||||
if (matchingAudioTrack) {
|
||||
matchingAudioTrack.syncMetadata(audioFile)
|
||||
} else if (!audioFile.exclude) { // If audio file is not excluded then it should have an audio track
|
||||
|
||||
// Fallback to checking path
|
||||
matchingAudioTrack = audiobook.tracks.find(t => t.path === audioFile.path)
|
||||
if (matchingAudioTrack) {
|
||||
Logger.error(`[AudioFileScanner] Audio File mismatch ino with audio track "${audioFile.filename}"`)
|
||||
matchingAudioTrack.ino = audioFile.ino
|
||||
matchingAudioTrack.syncMetadata(audioFile)
|
||||
} else {
|
||||
Logger.error(`[AudioFileScanner] Audio File has no matching Track ${audioFile.filename} for "${audiobook.title}"`)
|
||||
|
||||
// Exclude audio file to prevent further errors
|
||||
// audioFile.exclude = true
|
||||
}
|
||||
}
|
||||
updates++
|
||||
}
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
module.exports.rescanAudioFiles = rescanAudioFiles
|
||||
@@ -9,4 +9,14 @@ module.exports.ScanResult = {
|
||||
module.exports.CoverDestination = {
|
||||
METADATA: 0,
|
||||
AUDIOBOOK: 1
|
||||
}
|
||||
|
||||
module.exports.LogLevel = {
|
||||
TRACE: 0,
|
||||
DEBUG: 1,
|
||||
INFO: 2,
|
||||
WARN: 3,
|
||||
ERROR: 4,
|
||||
FATAL: 5,
|
||||
NOTE: 6
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
const Ffmpeg = require('fluent-ffmpeg')
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const package = require('../../package.json')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
function escapeSingleQuotes(path) {
|
||||
// return path.replace(/'/g, '\'\\\'\'')
|
||||
@@ -64,4 +67,29 @@ async function writeMetadataFile(audiobook, outputPath) {
|
||||
await fs.writeFile(outputPath, inputstrs.join('\n'))
|
||||
return inputstrs
|
||||
}
|
||||
module.exports.writeMetadataFile = writeMetadataFile
|
||||
module.exports.writeMetadataFile = writeMetadataFile
|
||||
|
||||
async function extractCoverArt(filepath, outputpath) {
|
||||
var dirname = Path.dirname(outputpath)
|
||||
await fs.ensureDir(dirname)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
var ffmpeg = Ffmpeg(filepath)
|
||||
ffmpeg.addOption(['-map 0:v', '-frames:v 1'])
|
||||
ffmpeg.output(outputpath)
|
||||
|
||||
ffmpeg.on('start', (cmd) => {
|
||||
Logger.debug(`[FfmpegHelpers] Extract Cover Cmd: ${cmd}`)
|
||||
})
|
||||
ffmpeg.on('error', (err, stdout, stderr) => {
|
||||
Logger.error(`[FfmpegHelpers] Extract Cover Error ${err}`)
|
||||
resolve(false)
|
||||
})
|
||||
ffmpeg.on('end', () => {
|
||||
Logger.debug(`[FfmpegHelpers] Cover Art Extracted Successfully`)
|
||||
resolve(outputpath)
|
||||
})
|
||||
ffmpeg.run()
|
||||
})
|
||||
}
|
||||
module.exports.extractCoverArt = extractCoverArt
|
||||
@@ -1,4 +1,5 @@
|
||||
const fs = require('fs-extra')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
async function getFileStat(path) {
|
||||
try {
|
||||
@@ -24,14 +25,26 @@ async function getFileSize(path) {
|
||||
}
|
||||
module.exports.getFileSize = getFileSize
|
||||
|
||||
async function readTextFile(path) {
|
||||
try {
|
||||
var data = await fs.readFile(path)
|
||||
return String(data)
|
||||
} catch (error) {
|
||||
Logger.error(`[FileUtils] ReadTextFile error ${error}`)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
module.exports.readTextFile = readTextFile
|
||||
|
||||
function bytesPretty(bytes, decimals = 0) {
|
||||
if (bytes === 0) {
|
||||
return '0 Bytes'
|
||||
}
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
var dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
if (i > 2 && dm === 0) dm = 1
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
module.exports.bytesPretty = bytesPretty
|
||||
|
||||
7
server/utils/globals.js
Normal file
7
server/utils/globals.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const globals = {
|
||||
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus'],
|
||||
SupportedEbookTypes: ['epub', 'pdf', 'mobi']
|
||||
}
|
||||
|
||||
module.exports = globals
|
||||
@@ -1,6 +1,8 @@
|
||||
const fs = require('fs-extra')
|
||||
|
||||
function getPlaylistStr(segmentName, duration, segmentLength) {
|
||||
function getPlaylistStr(segmentName, duration, segmentLength, hlsSegmentType) {
|
||||
var ext = hlsSegmentType === 'fmp4' ? 'm4s' : 'ts'
|
||||
|
||||
var lines = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
@@ -9,22 +11,25 @@ function getPlaylistStr(segmentName, duration, segmentLength) {
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
'#EXT-X-PLAYLIST-TYPE:VOD'
|
||||
]
|
||||
if (hlsSegmentType === 'fmp4') {
|
||||
lines.push('#EXT-X-MAP:URI="init.mp4"')
|
||||
}
|
||||
var numSegments = Math.floor(duration / segmentLength)
|
||||
var lastSegment = duration - (numSegments * segmentLength)
|
||||
for (let i = 0; i < numSegments; i++) {
|
||||
lines.push(`#EXTINF:6,`)
|
||||
lines.push(`${segmentName}-${i}.ts`)
|
||||
lines.push(`${segmentName}-${i}.${ext}`)
|
||||
}
|
||||
if (lastSegment > 0) {
|
||||
lines.push(`#EXTINF:${lastSegment},`)
|
||||
lines.push(`${segmentName}-${numSegments}.ts`)
|
||||
lines.push(`${segmentName}-${numSegments}.${ext}`)
|
||||
}
|
||||
lines.push('#EXT-X-ENDLIST')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function generatePlaylist(outputPath, segmentName, duration, segmentLength) {
|
||||
var playlistStr = getPlaylistStr(segmentName, duration, segmentLength)
|
||||
function generatePlaylist(outputPath, segmentName, duration, segmentLength, hlsSegmentType) {
|
||||
var playlistStr = getPlaylistStr(segmentName, duration, segmentLength, hlsSegmentType)
|
||||
return fs.writeFile(outputPath, playlistStr)
|
||||
}
|
||||
module.exports = generatePlaylist
|
||||
@@ -63,7 +63,3 @@ module.exports.getIno = (path) => {
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
module.exports.isAcceptableCoverMimeType = (mimeType) => {
|
||||
return mimeType && mimeType.startsWith('image/')
|
||||
}
|
||||
91
server/utils/nfoGenerator.js
Normal file
91
server/utils/nfoGenerator.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const { bytesPretty } = require('./fileUtils')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const LEFT_COL_LEN = 25
|
||||
|
||||
function sectionHeaderLines(title) {
|
||||
return [title, ''.padEnd(10, '=')]
|
||||
}
|
||||
|
||||
function generateSection(sectionTitle, sectionData) {
|
||||
var lines = sectionHeaderLines(sectionTitle)
|
||||
for (const key in sectionData) {
|
||||
var line = key.padEnd(LEFT_COL_LEN) + (sectionData[key] || '')
|
||||
lines.push(line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
async function generate(audiobook, nfoFilename = 'metadata.nfo') {
|
||||
var jsonObj = audiobook.toJSON()
|
||||
var book = jsonObj.book
|
||||
|
||||
var generalSectionData = {
|
||||
'Title': book.title,
|
||||
'Subtitle': book.subtitle,
|
||||
'Author': book.author,
|
||||
'Narrator': book.narrarator,
|
||||
'Series': book.series,
|
||||
'Volume Number': book.volumeNumber,
|
||||
'Publish Year': book.publishYear,
|
||||
'Genre': book.genres ? book.genres.join(', ') : '',
|
||||
'Duration': audiobook.durationPretty,
|
||||
'Chapters': jsonObj.chapters.length
|
||||
}
|
||||
|
||||
if (!book.subtitle) {
|
||||
delete generalSectionData['Subtitle']
|
||||
}
|
||||
|
||||
if (!book.series) {
|
||||
delete generalSectionData['Series']
|
||||
delete generalSectionData['Volume Number']
|
||||
}
|
||||
|
||||
var tracks = audiobook.tracks
|
||||
var audioTrack = tracks.length ? audiobook.tracks[0] : {}
|
||||
|
||||
var totalBitrate = 0
|
||||
var numBitrates = 0
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
if (tracks[i].bitRate) {
|
||||
totalBitrate += tracks[i].bitRate
|
||||
numBitrates++
|
||||
}
|
||||
}
|
||||
var averageBitrate = numBitrates ? totalBitrate / numBitrates : 0
|
||||
|
||||
var mediaSectionData = {
|
||||
'Tracks': jsonObj.tracks.length,
|
||||
'Size': audiobook.sizePretty,
|
||||
'Codec': audioTrack.codec,
|
||||
'Ext': audioTrack.ext,
|
||||
'Channels': audioTrack.channels,
|
||||
'Channel Layout': audioTrack.channelLayout,
|
||||
'Average Bitrate': bytesPretty(averageBitrate)
|
||||
}
|
||||
|
||||
var bookSection = generateSection('Book Info', generalSectionData)
|
||||
|
||||
var descriptionSection = null
|
||||
if (book.description) {
|
||||
descriptionSection = sectionHeaderLines('Book Description')
|
||||
descriptionSection.push(book.description)
|
||||
}
|
||||
|
||||
var mediaSection = generateSection('Media Info', mediaSectionData)
|
||||
|
||||
var fullFile = bookSection.join('\n') + '\n\n'
|
||||
if (descriptionSection) fullFile += descriptionSection.join('\n') + '\n\n'
|
||||
fullFile += mediaSection.join('\n')
|
||||
|
||||
var nfoPath = Path.join(audiobook.fullPath, nfoFilename)
|
||||
var relativePath = Path.join(audiobook.path, nfoFilename)
|
||||
return fs.writeFile(nfoPath, fullFile).then(() => relativePath).catch((error) => {
|
||||
Logger.error(`Failed to write nfo file ${error}`)
|
||||
return false
|
||||
})
|
||||
}
|
||||
module.exports = generate
|
||||
@@ -1,4 +1,6 @@
|
||||
var Ffmpeg = require('fluent-ffmpeg')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
function tryGrabBitRate(stream, all_streams, total_bit_rate) {
|
||||
if (!isNaN(stream.bit_rate) && stream.bit_rate) {
|
||||
@@ -72,6 +74,15 @@ function tryGrabTag(stream, tag) {
|
||||
return stream.tags[tag] || stream.tags[tag.toUpperCase()] || null
|
||||
}
|
||||
|
||||
function tryGrabTags(stream, ...tags) {
|
||||
if (!stream.tags) return null
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
var value = stream.tags[tags[i]] || stream.tags[tags[i].toUpperCase()]
|
||||
if (value) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
|
||||
var info = {
|
||||
index: stream.index,
|
||||
@@ -124,6 +135,53 @@ function parseChapters(chapters) {
|
||||
})
|
||||
}
|
||||
|
||||
function parseTags(format) {
|
||||
if (!format.tags) {
|
||||
return {}
|
||||
}
|
||||
// Logger.debug('Tags', format.tags)
|
||||
const tags = {
|
||||
file_tag_encoder: tryGrabTags(format, 'encoder', 'tsse', 'tss'),
|
||||
file_tag_encodedby: tryGrabTags(format, 'encoded_by', 'tenc', 'ten'),
|
||||
file_tag_title: tryGrabTags(format, 'title', 'tit2', 'tt2'),
|
||||
file_tag_subtitle: tryGrabTags(format, 'subtitle', 'tit3', 'tt3'),
|
||||
file_tag_track: tryGrabTags(format, 'track', 'trck', 'trk'),
|
||||
file_tag_album: tryGrabTags(format, 'album', 'talb', 'tal'),
|
||||
file_tag_artist: tryGrabTags(format, 'artist', 'tpe1', 'tp1'),
|
||||
file_tag_albumartist: tryGrabTags(format, 'albumartist', 'tpe2'),
|
||||
file_tag_date: tryGrabTags(format, 'date', 'tyer', 'tye'),
|
||||
file_tag_composer: tryGrabTags(format, 'composer', 'tcom', 'tcm'),
|
||||
file_tag_publisher: tryGrabTags(format, 'publisher', 'tpub', 'tpb'),
|
||||
file_tag_comment: tryGrabTags(format, 'comment', 'comm', 'com'),
|
||||
file_tag_description: tryGrabTags(format, 'description', 'desc'),
|
||||
file_tag_genre: tryGrabTags(format, 'genre', 'tcon', 'tco'),
|
||||
|
||||
// Not sure if these are actually used yet or not
|
||||
file_tag_creation_time: tryGrabTag(format, 'creation_time'),
|
||||
file_tag_wwwaudiofile: tryGrabTags(format, 'wwwaudiofile', 'woaf', 'waf'),
|
||||
file_tag_contentgroup: tryGrabTags(format, 'contentgroup', 'tit1', 'tt1'),
|
||||
file_tag_releasetime: tryGrabTags(format, 'releasetime', 'tdrl'),
|
||||
file_tag_movementname: tryGrabTags(format, 'movementname', 'mvnm'),
|
||||
file_tag_movement: tryGrabTags(format, 'movement', 'mvin'),
|
||||
file_tag_series: tryGrabTag(format, 'series'),
|
||||
file_tag_seriespart: tryGrabTag(format, 'series-part'),
|
||||
file_tag_genre1: tryGrabTags(format, 'tmp_genre1', 'genre1'),
|
||||
file_tag_genre2: tryGrabTags(format, 'tmp_genre2', 'genre2')
|
||||
}
|
||||
for (const key in tags) {
|
||||
if (!tags[key]) {
|
||||
delete tags[key]
|
||||
}
|
||||
}
|
||||
|
||||
var keysToLookOutFor = ['file_tag_genre1', 'file_tag_genre2', 'file_tag_series', 'file_tag_seriespart', 'file_tag_movement', 'file_tag_movementname', 'file_tag_wwwaudiofile', 'file_tag_contentgroup', 'file_tag_releasetime']
|
||||
var success = keysToLookOutFor.find(key => !!tags[key])
|
||||
if (success) {
|
||||
Logger.debug('Notable!', success)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
function parseProbeData(data) {
|
||||
try {
|
||||
var { format, streams, chapters } = data
|
||||
@@ -131,20 +189,16 @@ function parseProbeData(data) {
|
||||
|
||||
var sizeBytes = !isNaN(size) ? Number(size) : null
|
||||
var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null
|
||||
|
||||
// Logger.debug('Parsing Data for', Path.basename(format.filename))
|
||||
var tags = parseTags(format)
|
||||
var cleanedData = {
|
||||
format: format_long_name,
|
||||
duration: !isNaN(duration) ? Number(duration) : null,
|
||||
size: sizeBytes,
|
||||
sizeMb,
|
||||
bit_rate: !isNaN(bit_rate) ? Number(bit_rate) : null,
|
||||
file_tag_encoder: tryGrabTag(format, 'encoder') || tryGrabTag(format, 'encoded_by'),
|
||||
file_tag_title: tryGrabTag(format, 'title'),
|
||||
file_tag_track: tryGrabTag(format, 'track') || tryGrabTag(format, 'trk'),
|
||||
file_tag_album: tryGrabTag(format, 'album') || tryGrabTag(format, 'tal'),
|
||||
file_tag_artist: tryGrabTag(format, 'artist') || tryGrabTag(format, 'tp1'),
|
||||
file_tag_date: tryGrabTag(format, 'date') || tryGrabTag(format, 'tye'),
|
||||
file_tag_genre: tryGrabTag(format, 'genre'),
|
||||
file_tag_creation_time: tryGrabTag(format, 'creation_time')
|
||||
...tags
|
||||
}
|
||||
|
||||
const cleaned_streams = streams.map(s => parseMediaStreamInfo(s, streams, cleanedData.bit_rate))
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
const Path = require('path')
|
||||
const dir = require('node-dir')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a']
|
||||
const INFO_FORMATS = ['nfo']
|
||||
const IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'webp']
|
||||
const EBOOK_FORMATS = ['epub', 'pdf']
|
||||
const { getIno } = require('./index')
|
||||
const globals = require('./globals')
|
||||
|
||||
function getPaths(path) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -23,10 +20,12 @@ function isAudioFile(path) {
|
||||
if (!path) return false
|
||||
var ext = Path.extname(path)
|
||||
if (!ext) return false
|
||||
return AUDIO_FORMATS.includes(ext.slice(1).toLowerCase())
|
||||
return globals.SupportedAudioTypes.includes(ext.slice(1).toLowerCase())
|
||||
}
|
||||
|
||||
function groupFilesIntoAudiobookPaths(paths) {
|
||||
// Input: array of relative file paths
|
||||
// Output: map of files grouped into potential audiobook dirs
|
||||
function groupFilesIntoAudiobookPaths(paths, useAllFileTypes = false) {
|
||||
// Step 1: Normalize path, Remove leading "/", Filter out files in root dir
|
||||
var pathsFiltered = paths.map(path => Path.normalize(path.slice(1))).filter(path => Path.parse(path).dir)
|
||||
|
||||
@@ -37,11 +36,11 @@ function groupFilesIntoAudiobookPaths(paths) {
|
||||
return pathsA - pathsB
|
||||
})
|
||||
|
||||
// Step 2.5: Seperate audio files and other files
|
||||
// Step 2.5: Seperate audio files and other files (optional)
|
||||
var audioFilePaths = []
|
||||
var otherFilePaths = []
|
||||
pathsFiltered.forEach(path => {
|
||||
if (isAudioFile(path)) audioFilePaths.push(path)
|
||||
if (isAudioFile(path) || useAllFileTypes) audioFilePaths.push(path)
|
||||
else otherFilePaths.push(path)
|
||||
})
|
||||
|
||||
@@ -106,35 +105,44 @@ function cleanFileObjects(basepath, abrelpath, files) {
|
||||
function getFileType(ext) {
|
||||
var ext_cleaned = ext.toLowerCase()
|
||||
if (ext_cleaned.startsWith('.')) ext_cleaned = ext_cleaned.slice(1)
|
||||
if (AUDIO_FORMATS.includes(ext_cleaned)) return 'audio'
|
||||
if (INFO_FORMATS.includes(ext_cleaned)) return 'info'
|
||||
if (IMAGE_FORMATS.includes(ext_cleaned)) return 'image'
|
||||
if (EBOOK_FORMATS.includes(ext_cleaned)) return 'ebook'
|
||||
if (globals.SupportedAudioTypes.includes(ext_cleaned)) return 'audio'
|
||||
if (ext_cleaned === 'nfo') return 'info'
|
||||
if (ext_cleaned === 'txt') return 'text'
|
||||
if (globals.SupportedImageTypes.includes(ext_cleaned)) return 'image'
|
||||
if (globals.SupportedEbookTypes.includes(ext_cleaned)) return 'ebook'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
// Primary scan: abRootPath is /audiobooks
|
||||
async function scanRootDir(abRootPath, serverSettings = {}) {
|
||||
// Scan folder
|
||||
async function scanRootDir(folder, serverSettings = {}) {
|
||||
var folderPath = folder.fullPath
|
||||
var parseSubtitle = !!serverSettings.scannerParseSubtitle
|
||||
|
||||
var pathdata = await getPaths(abRootPath)
|
||||
var pathdata = await getPaths(folderPath)
|
||||
var filepaths = pathdata.files.map(filepath => {
|
||||
return Path.normalize(filepath).replace(abRootPath, '')
|
||||
return Path.normalize(filepath).replace(folderPath, '')
|
||||
})
|
||||
|
||||
var audiobookGrouping = groupFilesIntoAudiobookPaths(filepaths)
|
||||
|
||||
if (!Object.keys(audiobookGrouping).length) {
|
||||
Logger.error('Root path has no audiobooks')
|
||||
Logger.error('Root path has no audiobooks', filepaths)
|
||||
return []
|
||||
}
|
||||
|
||||
var audiobooks = []
|
||||
for (const audiobookPath in audiobookGrouping) {
|
||||
var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookPath, parseSubtitle)
|
||||
var audiobookData = getAudiobookDataFromDir(folderPath, audiobookPath, parseSubtitle)
|
||||
|
||||
var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
|
||||
for (let i = 0; i < fileObjs.length; i++) {
|
||||
fileObjs[i].ino = await getIno(fileObjs[i].fullPath)
|
||||
}
|
||||
var audiobookIno = await getIno(audiobookData.fullPath)
|
||||
audiobooks.push({
|
||||
folderId: folder.id,
|
||||
libraryId: folder.libraryId,
|
||||
ino: audiobookIno,
|
||||
...audiobookData,
|
||||
audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
|
||||
otherFiles: fileObjs.filter(f => f.filetype !== 'audio')
|
||||
@@ -145,7 +153,7 @@ async function scanRootDir(abRootPath, serverSettings = {}) {
|
||||
module.exports.scanRootDir = scanRootDir
|
||||
|
||||
// Input relative filepath, output all details that can be parsed
|
||||
function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
|
||||
function getAudiobookDataFromDir(folderPath, dir, parseSubtitle = false) {
|
||||
var splitDir = dir.split(Path.sep)
|
||||
|
||||
// Audio files will always be in the directory named for the title
|
||||
@@ -216,11 +224,11 @@ function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
|
||||
volumeNumber,
|
||||
publishYear,
|
||||
path: dir, // relative audiobook path i.e. /Author Name/Book Name/..
|
||||
fullPath: Path.join(abRootPath, dir) // i.e. /audiobook/Author Name/Book Name/..
|
||||
fullPath: Path.join(folderPath, dir) // i.e. /audiobook/Author Name/Book Name/..
|
||||
}
|
||||
}
|
||||
|
||||
async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings = {}) {
|
||||
async function getAudiobookFileData(folder, audiobookPath, serverSettings = {}) {
|
||||
var parseSubtitle = !!serverSettings.scannerParseSubtitle
|
||||
|
||||
var paths = await getPaths(audiobookPath)
|
||||
@@ -233,19 +241,26 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
|
||||
return pathsA - pathsB
|
||||
})
|
||||
|
||||
var audiobookDir = Path.normalize(audiobookPath).replace(abRootPath, '').slice(1)
|
||||
var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookDir, parseSubtitle)
|
||||
var audiobookDir = Path.normalize(audiobookPath).replace(folder.fullPath, '').slice(1)
|
||||
var audiobookData = getAudiobookDataFromDir(folder.fullPath, audiobookDir, parseSubtitle)
|
||||
var audiobook = {
|
||||
ino: await getIno(audiobookData.fullPath),
|
||||
folderId: folder.id,
|
||||
libraryId: folder.libraryId,
|
||||
...audiobookData,
|
||||
audioFiles: [],
|
||||
otherFiles: []
|
||||
}
|
||||
|
||||
filepaths.forEach((filepath) => {
|
||||
var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1)
|
||||
for (let i = 0; i < filepaths.length; i++) {
|
||||
var filepath = filepaths[i]
|
||||
|
||||
var relpath = Path.normalize(filepath).replace(folder.fullPath, '').slice(1)
|
||||
var extname = Path.extname(filepath)
|
||||
var basename = Path.basename(filepath)
|
||||
var ino = await getIno(filepath)
|
||||
var fileObj = {
|
||||
ino,
|
||||
filetype: getFileType(extname),
|
||||
filename: basename,
|
||||
path: relpath,
|
||||
@@ -257,7 +272,7 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
|
||||
} else {
|
||||
audiobook.otherFiles.push(fileObj)
|
||||
}
|
||||
})
|
||||
}
|
||||
return audiobook
|
||||
}
|
||||
module.exports.getAudiobookFileData = getAudiobookFileData
|
||||
Reference in New Issue
Block a user